Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/eight-clouds-count.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@rocket.chat/meteor': patch
---

Fixes intermittent "Channel Not Joined" screen when opening rooms in embedded mode.
17 changes: 11 additions & 6 deletions apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import type { IMessage } from '@rocket.chat/core-typings';
import { Emitter } from '@rocket.chat/emitter';
import { Tracker } from 'meteor/tracker';
import type { RefObject } from 'react';

import { limitQuoteChain } from './limitQuoteChain';
import type { FormattingButton } from './messageBoxFormatting';
import { formattingButtons } from './messageBoxFormatting';
import type { ComposerAPI } from '../../../../client/lib/chats/ChatAPI';
import { createUploadsAPI } from '../../../../client/lib/chats/uploads';
import { settings } from '../../../../client/lib/settings';
import { withDebouncing } from '../../../../lib/utils/highOrderFunctions';

export const createComposerAPI = (
Expand Down Expand Up @@ -195,26 +195,31 @@ export const createComposerAPI = (
setEditing(editing);
};

const [formatters, stopFormatterTracker] = (() => {
const [formatters, stopFormatterSubscription] = (() => {
let actions: FormattingButton[] = [];

const c = Tracker.autorun(() => {
const recompute = (): void => {
actions = formattingButtons.filter(({ condition }) => !condition || condition());
emitter.emit('formatting');
});
};
recompute();
// Coarse-grained: fires on every setting change, but the only condition()
// today is Katex_Enabled and the recompute is a cheap zustand read, so the
// extra work per unrelated setting change is negligible.
const stop = settings.observe('*', recompute);

return [
{
get: () => actions,
subscribe: (callback: () => void) => emitter.on('formatting', callback),
},
c,
stop,
];
})();

const release = (): void => {
input.removeEventListener('input', persist);
stopFormatterTracker.stop();
stopFormatterSubscription();
};

const wrapSelection = (pattern: string): { selectionStart: number; selectionEnd: number; value: string } => {
Expand Down
8 changes: 4 additions & 4 deletions apps/meteor/app/utils/client/lib/SDKClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ const createNewDdpSdkStream = (
if (data?.msg !== 'changed') return;
if (data.collection !== `stream-${streamName}`) return;
if (data.fields?.eventName !== key) return;
streamProxy.emit(`stream-${streamName}/${key}` as keyof EventMap, data.fields.args);
streamProxy.emit(`stream-${streamName}/${key}`, data.fields.args);
});
});

Expand Down Expand Up @@ -266,7 +266,7 @@ const createStreamManager = () => {
// per-stream callbacks fire. With SDK transport on, the frames arrive on
// the SDK socket and createNewDdpSdkStream registers its own onCollection
// listener instead.
Meteor.connection._stream.on('message', (rawMsg: string) => {
Meteor.connection._stream!.on('message', (rawMsg: string) => {
const msg = DDPCommon.parseDDP(rawMsg);
if (!isChangedCollectionPayload(msg)) {
return;
Expand Down Expand Up @@ -299,8 +299,8 @@ const createStreamManager = () => {
const stream =
streams.get(eventLiteral) ||
(sdkTransportEnabled
? createNewDdpSdkStream(streamProxy, name as StreamNames, key as StreamKeys<StreamNames>, args)
: createNewMeteorStream(name as StreamNames, key as StreamKeys<StreamNames>, args));
? createNewDdpSdkStream(streamProxy, name, key as StreamKeys<StreamNames>, args)
: createNewMeteorStream(name, key as StreamKeys<StreamNames>, args));

const stop = (): void => {
streamProxy.off(eventLiteral, proxyCallback);
Expand Down
23 changes: 11 additions & 12 deletions apps/meteor/client/lib/cachedStores/CachedStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@ import type { IRocketChatRecord } from '@rocket.chat/core-typings';
import type { StreamNames } from '@rocket.chat/ddp-client';
import { isTruthy } from '@rocket.chat/tools';
import localforage from 'localforage';
import { Accounts } from 'meteor/accounts-base';
import { Meteor } from 'meteor/meteor';
import { Tracker } from 'meteor/tracker';
import { create, type StoreApi, type UseBoundStore } from 'zustand';

import { baseURI } from '../baseURI';
Expand All @@ -14,6 +12,7 @@ import type { IDocumentMapStore } from './DocumentMapStore';
import { sdk } from '../../../app/utils/client/lib/SDKClient';
import { withDebouncing } from '../../../lib/utils/highOrderFunctions';
import { getDdpSdk } from '../sdk/ddpSdk';
import { STORAGE_KEYS, getStoredItem } from '../sdk/storage';
import { getUserId } from '../user';
import { getConfig } from '../utils/getConfig';

Expand Down Expand Up @@ -315,16 +314,16 @@ export abstract class CachedStore<T extends IRocketChatRecord, U = T> implements
await this.loadFromServerAndPopulate();
}

this.reconnectionComputation?.stop();
let wentOffline = Tracker.nonreactive(() => Meteor.status().status === 'offline');
this.reconnectionComputation = Tracker.autorun(() => {
const { status } = Meteor.status();

if (status === 'offline') {
this.reconnectionUnsubscribe?.();
const sdk = getDdpSdk();
let wentOffline = sdk.connection.status !== 'connected';
this.reconnectionUnsubscribe = sdk.connection.on('connection', () => {
if (sdk.connection.status !== 'connected') {
wentOffline = true;
return;
}

if (status === 'connected' && wentOffline) {
if (wentOffline) {
wentOffline = false;
this.trySync();
}
});
Expand Down Expand Up @@ -362,7 +361,7 @@ export abstract class CachedStore<T extends IRocketChatRecord, U = T> implements
this.setReady(false);
}

private reconnectionComputation: Tracker.Computation | undefined;
private reconnectionUnsubscribe: (() => void) | undefined;

setReady(ready: boolean) {
this.useReady.setState(ready);
Expand All @@ -381,7 +380,7 @@ export class PublicCachedStore<T extends IRocketChatRecord, U = T> extends Cache

export class PrivateCachedStore<T extends IRocketChatRecord, U = T> extends CachedStore<T, U> {
protected override getToken() {
return Accounts._storedLoginToken();
return getStoredItem(STORAGE_KEYS.LOGIN_TOKEN);
}

override clearCacheOnLogout() {
Expand Down
2 changes: 1 addition & 1 deletion apps/meteor/client/lib/sdk/ddpSdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ export const ensureConnectedAndAuthenticated = async (): Promise<void> => {
// parallel re-auth flows in CI's parallel-shard environment and
// kicked otherwise-healthy tests out.
Accounts._unstoreLoginToken();
(Meteor.connection as unknown as { setUserId: (uid: string | null) => void }).setUserId(null);
Meteor.connection.setUserId(null);
return;
}
console.warn('[ddpSdk] loginWithToken failed', error);
Expand Down
41 changes: 29 additions & 12 deletions apps/meteor/client/lib/sdk/meteorBackedSdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { Emitter } from '@rocket.chat/emitter';
import { Accounts } from 'meteor/accounts-base';
import { DDPCommon } from 'meteor/ddp-common';
import { Meteor } from 'meteor/meteor';
import { Tracker } from 'meteor/tracker';

/**
* Meteor-backed pass-through DDPSDK used when the SDK transport is OFF.
Expand All @@ -28,15 +27,33 @@ const safeMeteorStatus = (): { status: string; connected: boolean; retryCount?:
};

const onMeteorStatusChange = (cb: () => void): (() => void) => {
if (typeof Meteor.status !== 'function' || typeof Tracker.autorun !== 'function') {
// Test / SSR environment with a stubbed Meteor — no reactive status to bridge.
// Subscribe to Meteor's underlying WebSocket lifecycle events directly instead
// of riding Meteor.status's Tracker reactivity. The stream is the canonical
// non-reactive source: `'reset'` fires when a new DDP session is established
// (effectively the "connected" signal — see socket-stream-client.js), and
// `'disconnect'` fires when the WebSocket drops or each retry attempt restarts.
// `'connected'` is intentionally NOT subscribed: the stream's allowed event
// list is `['message', 'reset', 'disconnect']` and `on('connected')` throws
// `Error: unknown event type: connected`. Throwing here would propagate up
// through `connection.on(...)` callers (notably `CachedStore.performInitialization`)
// and abort their initialization before `setupListener()` runs, silently
// breaking real-time stream subscriptions for settings, subscriptions, etc.
const stream = Meteor.connection?._stream;
if (!stream || typeof stream.on !== 'function') {
// Test / SSR environment with a stubbed Meteor — no stream to subscribe to.
return noopUnsubscribe;
}
const computation = Tracker.autorun(() => {
Meteor.status();
cb();
});
return () => computation.stop();
let stopped = false;
const handler = (): void => {
if (!stopped) cb();
};
stream.on('reset', handler);
stream.on('disconnect', handler);
// Meteor's stream `on` doesn't expose an `off`; flip a flag instead so the
// stale listener becomes a no-op once stopBridge runs.
return () => {
stopped = true;
};
};

const meteorStatusToSdkStatus = (): string => {
Expand All @@ -58,16 +75,16 @@ const meteorStatusToSdkStatus = (): string => {
};

const createMeteorBackedClient = () => {
const subscribe = (name: string, ...args: unknown[]) => {
const sub = (Meteor.connection.subscribe as (name: string, ...args: unknown[]) => Meteor.SubscriptionHandle)(name, ...args);
const subscribe = (name: string, ...args: Parameters<typeof Meteor.connection.subscribe>) => {
const sub = Meteor.connection.subscribe(name, ...args);
// Approximate DDPSDK's Subscription shape with Meteor's handle. The
// codebase only reads `stop`/`ready`/`isReady`/`id` from it.
return Object.assign(sub, {
id: '',
isReady: false,
ready: () => Promise.resolve(),
onChange: () => undefined,
}) as unknown as ReturnType<DDPSDK['client']['subscribe']>;
});
};

const callAsync = (method: string, ...args: unknown[]): Promise<unknown> & { id: string } => {
Expand All @@ -91,7 +108,7 @@ const createMeteorBackedClient = () => {
if ((msg as { collection?: unknown }).collection !== id) return;
callback(msg);
};
const stream = (Meteor.connection as unknown as { _stream: { on: (k: 'message', cb: (raw: string) => void) => void } })._stream;
const stream = Meteor.connection._stream!;
stream.on('message', handler);
// Meteor's stream `on` doesn't expose an off; the listener is harmless
// and lives for the page lifetime. Caller's stop is a no-op.
Expand Down
20 changes: 11 additions & 9 deletions apps/meteor/client/lib/streamer/streamer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,14 @@ interface StreamerOptions {
}

interface StreamerDDPConnection {
_stream: {
on: {
(key: 'message', callback: (data: string) => void): void;
(key: 'reset', callback: () => void): void;
};
};
_stream:
| {
on: {
(key: 'message', callback: (data: string) => void): void;
(key: 'reset', callback: () => void): void;
};
}
| undefined;
subscribe(name: string, ...args: unknown[]): SubscriptionHandle;
call(methodName: string, ...args: unknown[]): void;
hasMeteorStreamerEventListeners?: boolean;
Expand All @@ -46,7 +48,7 @@ export class StreamerCentral extends EV {
return;
}

ddpConnection._stream.on('message', (rawMessage?: unknown) => {
ddpConnection._stream!.on('message', (rawMessage?: unknown) => {
if (typeof rawMessage !== 'string') {
return;
}
Expand Down Expand Up @@ -75,7 +77,7 @@ export class StreamerCentral extends EV {
getStreamer<N extends EventNames>(name: N, options: StreamerOptions): Streamer<N> {
const existingInstance = this.instances[name];
if (existingInstance) {
return existingInstance as Streamer<N>;
return existingInstance;
}

const streamer = new Streamer(name, options);
Expand Down Expand Up @@ -107,7 +109,7 @@ export class Streamer<N extends EventNames> extends EV {
this.name = name;
this.useCollection = useCollection;

this.ddpConnection._stream.on('reset', () => {
this.ddpConnection._stream!.on('reset', () => {
super.emit('__reconnect__');
});
}
Expand Down
12 changes: 12 additions & 0 deletions apps/meteor/client/meteor/login/LoginCancelledError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Mirrors Meteor's `Accounts.LoginCancelledError` so the OAuth login flow can
// detect server-issued cancellation errors (Meteor.Error.error === numericError)
// without importing from meteor/accounts-base.
export class LoginCancelledError extends Error {
static readonly numericError = 0x8acdc2f;

override name = 'Accounts.LoginCancelledError';

constructor(reason?: string) {
super(reason);
}
}
7 changes: 4 additions & 3 deletions apps/meteor/client/meteor/login/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,17 @@ import { Accounts } from 'meteor/accounts-base';
import { Meteor } from 'meteor/meteor';
import { OAuth } from 'meteor/oauth';

import { LoginCancelledError } from './LoginCancelledError';
import type { IOAuthProvider } from '../../definitions/IOAuthProvider';
import type { LoginCallback } from '../../lib/2fa/overrideLoginMethod';
import { getDdpSdk } from '../../lib/sdk/ddpSdk';

const isLoginCancelledError = (error: unknown): error is Meteor.Error =>
error instanceof Meteor.Error && error.error === Accounts.LoginCancelledError.numericError;
error instanceof Meteor.Error && error.error === LoginCancelledError.numericError;

export const convertError = <T>(error: T): Accounts.LoginCancelledError | T => {
export const convertError = <T>(error: T): LoginCancelledError | T => {
if (isLoginCancelledError(error)) {
return new Accounts.LoginCancelledError(error.reason);
return new LoginCancelledError(error.reason);
}

return error;
Expand Down
8 changes: 1 addition & 7 deletions apps/meteor/client/meteor/overrides/killMeteorStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,7 @@ import { userIdStore } from '../../lib/user';
* ddpOverREST intercepts and routes to REST (or DDPSDK for `login`).
*/
if (isSdkTransportEnabled()) {
const conn = Meteor.connection as unknown as {
_subsBeingRevived: Record<string, unknown>;
_methodsBlockingQuiescence: Record<string, unknown>;
_messagesBufferedUntilQuiescence: unknown[];
_outstandingMethodBlocks: unknown[];
_methodInvokers: Record<string, unknown>;
};
const conn = Meteor.connection;

conn._subsBeingRevived = Object.create(null);
conn._methodsBlockingQuiescence = Object.create(null);
Expand Down
Loading
Loading