diff --git a/.changeset/eight-clouds-count.md b/.changeset/eight-clouds-count.md new file mode 100644 index 0000000000000..25ecce7dc886a --- /dev/null +++ b/.changeset/eight-clouds-count.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes intermittent "Channel Not Joined" screen when opening rooms in embedded mode. diff --git a/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts b/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts index fa05d6c369ffc..04447635ad45e 100644 --- a/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts +++ b/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts @@ -1,6 +1,5 @@ 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'; @@ -8,6 +7,7 @@ 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 = ( @@ -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 } => { diff --git a/apps/meteor/app/utils/client/lib/SDKClient.ts b/apps/meteor/app/utils/client/lib/SDKClient.ts index f54819a43d805..387adde9fbe75 100644 --- a/apps/meteor/app/utils/client/lib/SDKClient.ts +++ b/apps/meteor/app/utils/client/lib/SDKClient.ts @@ -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); }); }); @@ -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; @@ -299,8 +299,8 @@ const createStreamManager = () => { const stream = streams.get(eventLiteral) || (sdkTransportEnabled - ? createNewDdpSdkStream(streamProxy, name as StreamNames, key as StreamKeys, args) - : createNewMeteorStream(name as StreamNames, key as StreamKeys, args)); + ? createNewDdpSdkStream(streamProxy, name, key as StreamKeys, args) + : createNewMeteorStream(name, key as StreamKeys, args)); const stop = (): void => { streamProxy.off(eventLiteral, proxyCallback); diff --git a/apps/meteor/client/lib/cachedStores/CachedStore.ts b/apps/meteor/client/lib/cachedStores/CachedStore.ts index fb13638a59082..84702f8676cf0 100644 --- a/apps/meteor/client/lib/cachedStores/CachedStore.ts +++ b/apps/meteor/client/lib/cachedStores/CachedStore.ts @@ -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'; @@ -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'; @@ -315,16 +314,16 @@ export abstract class CachedStore 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(); } }); @@ -362,7 +361,7 @@ export abstract class CachedStore implements this.setReady(false); } - private reconnectionComputation: Tracker.Computation | undefined; + private reconnectionUnsubscribe: (() => void) | undefined; setReady(ready: boolean) { this.useReady.setState(ready); @@ -381,7 +380,7 @@ export class PublicCachedStore extends Cache export class PrivateCachedStore extends CachedStore { protected override getToken() { - return Accounts._storedLoginToken(); + return getStoredItem(STORAGE_KEYS.LOGIN_TOKEN); } override clearCacheOnLogout() { diff --git a/apps/meteor/client/lib/sdk/ddpSdk.ts b/apps/meteor/client/lib/sdk/ddpSdk.ts index 38354ef22fcfe..f9cb3dbc2b953 100644 --- a/apps/meteor/client/lib/sdk/ddpSdk.ts +++ b/apps/meteor/client/lib/sdk/ddpSdk.ts @@ -143,7 +143,7 @@ export const ensureConnectedAndAuthenticated = async (): Promise => { // 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); diff --git a/apps/meteor/client/lib/sdk/meteorBackedSdk.ts b/apps/meteor/client/lib/sdk/meteorBackedSdk.ts index 281ab6aa87504..d50e3a70da4aa 100644 --- a/apps/meteor/client/lib/sdk/meteorBackedSdk.ts +++ b/apps/meteor/client/lib/sdk/meteorBackedSdk.ts @@ -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. @@ -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 => { @@ -58,8 +75,8 @@ 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) => { + 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, { @@ -67,7 +84,7 @@ const createMeteorBackedClient = () => { isReady: false, ready: () => Promise.resolve(), onChange: () => undefined, - }) as unknown as ReturnType; + }); }; const callAsync = (method: string, ...args: unknown[]): Promise & { id: string } => { @@ -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. diff --git a/apps/meteor/client/lib/streamer/streamer.ts b/apps/meteor/client/lib/streamer/streamer.ts index 1eb31015acaf5..4e256c614737c 100644 --- a/apps/meteor/client/lib/streamer/streamer.ts +++ b/apps/meteor/client/lib/streamer/streamer.ts @@ -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; @@ -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; } @@ -75,7 +77,7 @@ export class StreamerCentral extends EV { getStreamer(name: N, options: StreamerOptions): Streamer { const existingInstance = this.instances[name]; if (existingInstance) { - return existingInstance as Streamer; + return existingInstance; } const streamer = new Streamer(name, options); @@ -107,7 +109,7 @@ export class Streamer extends EV { this.name = name; this.useCollection = useCollection; - this.ddpConnection._stream.on('reset', () => { + this.ddpConnection._stream!.on('reset', () => { super.emit('__reconnect__'); }); } diff --git a/apps/meteor/client/meteor/login/LoginCancelledError.ts b/apps/meteor/client/meteor/login/LoginCancelledError.ts new file mode 100644 index 0000000000000..e448f6221a894 --- /dev/null +++ b/apps/meteor/client/meteor/login/LoginCancelledError.ts @@ -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); + } +} diff --git a/apps/meteor/client/meteor/login/oauth.ts b/apps/meteor/client/meteor/login/oauth.ts index 1d9192b74b094..b42c2d93b1cd3 100644 --- a/apps/meteor/client/meteor/login/oauth.ts +++ b/apps/meteor/client/meteor/login/oauth.ts @@ -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 = (error: T): Accounts.LoginCancelledError | T => { +export const convertError = (error: T): LoginCancelledError | T => { if (isLoginCancelledError(error)) { - return new Accounts.LoginCancelledError(error.reason); + return new LoginCancelledError(error.reason); } return error; diff --git a/apps/meteor/client/meteor/overrides/killMeteorStream.ts b/apps/meteor/client/meteor/overrides/killMeteorStream.ts index 128f797e92138..897fac79b115c 100644 --- a/apps/meteor/client/meteor/overrides/killMeteorStream.ts +++ b/apps/meteor/client/meteor/overrides/killMeteorStream.ts @@ -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; - _methodsBlockingQuiescence: Record; - _messagesBufferedUntilQuiescence: unknown[]; - _outstandingMethodBlocks: unknown[]; - _methodInvokers: Record; - }; + const conn = Meteor.connection; conn._subsBeingRevived = Object.create(null); conn._methodsBlockingQuiescence = Object.create(null); diff --git a/apps/meteor/client/meteor/overrides/stubMeteorStream.ts b/apps/meteor/client/meteor/overrides/stubMeteorStream.ts index fbdebd028eef1..8862926f1759b 100644 --- a/apps/meteor/client/meteor/overrides/stubMeteorStream.ts +++ b/apps/meteor/client/meteor/overrides/stubMeteorStream.ts @@ -26,42 +26,14 @@ import { isSdkTransportEnabled } from '../../lib/sdk/sdkTransportEnabled'; * - connect/pong frames — discarded; the SDK socket has its own handshake. */ -type MeteorIDDPStream = { - currentStatus: { - status: string; - connected: boolean; - retryCount: number; - retryTime?: number; - reason?: string; - }; - eventCallbacks?: Record void>>; - statusListeners?: { changed(): void }; - on(event: string, callback: (...args: unknown[]) => void): void; - forEachCallback(name: string, cb: (callback: (...args: unknown[]) => void) => void): void; - send(data: string): void; - status(): MeteorIDDPStream['currentStatus']; - statusChanged(): void; - reconnect(options?: unknown): void; - disconnect(options?: { _permanent?: boolean; _error?: unknown }): void; - _lostConnection(error?: unknown): void; -}; - -type MeteorConnectionInternals = { - _stream: MeteorIDDPStream; - _streamHandlers: { - onMessage(raw: string): void; - onReset(): void; - }; -}; - if (isSdkTransportEnabled()) { installStubMeteorStream(); } function installStubMeteorStream(): void { - const conn = Meteor.connection as unknown as MeteorConnectionInternals; + const conn = Meteor.connection; - const realStream = conn._stream; + const realStream = conn._stream!; // Carry Meteor's already-registered handlers (registered in the Connection // constructor BEFORE we got a chance to swap `_stream`) over to the stub — @@ -76,11 +48,11 @@ function installStubMeteorStream(): void { // already closed / never opened } - const eventCallbacks: Record void>> = Object.create(null); + const eventCallbacks: Record void>> = Object.create(null); for (const [name, callbacks] of Object.entries(inheritedCallbacks)) { - eventCallbacks[name] = (callbacks as Array<(...args: unknown[]) => void>).slice(); + eventCallbacks[name] = callbacks.slice(); } - const fire = (name: string, ...args: unknown[]): void => { + const fire = (name: string, ...args: any[]): void => { const list = eventCallbacks[name]; if (!list) return; list.slice().forEach((cb) => cb(...args)); @@ -89,14 +61,14 @@ function installStubMeteorStream(): void { const TrackerDependency = (Tracker as unknown as { Dependency?: new () => { changed(): void } }).Dependency; const statusListeners = TrackerDependency ? new TrackerDependency() : undefined; - const stub: MeteorIDDPStream = { + conn._stream = { currentStatus: { status: 'connected', connected: true, retryCount: 0, }, - eventCallbacks, + eventCallbacks: eventCallbacks as NonNullable['eventCallbacks'], statusListeners, on(name, callback) { @@ -141,8 +113,6 @@ function installStubMeteorStream(): void { }, }; - conn._stream = stub; - const bridgePongFor = (id?: string): void => { conn._streamHandlers.onMessage( DDPCommon.stringifyDDP({ msg: 'pong', ...(id != null && { id }) } as unknown as Parameters[0]), diff --git a/apps/meteor/client/meteor/overrides/subscribeViaSDK.ts b/apps/meteor/client/meteor/overrides/subscribeViaSDK.ts index e14b23a447a82..aee4279676c34 100644 --- a/apps/meteor/client/meteor/overrides/subscribeViaSDK.ts +++ b/apps/meteor/client/meteor/overrides/subscribeViaSDK.ts @@ -45,7 +45,7 @@ const extractCallbacks = (args: unknown[]): { params: unknown[]; callbacks: Subs type MeteorSubscriptionHandle = Meteor.SubscriptionHandle; if (isSdkTransportEnabled()) { - (Meteor.connection as any).subscribe = ((name: string, ...rest: unknown[]): MeteorSubscriptionHandle => { + Meteor.connection.subscribe = (name: string, ...rest: unknown[]): MeteorSubscriptionHandle => { const { params, callbacks } = extractCallbacks(rest); const subscription = getDdpSdk().client.subscribe(name, ...params); @@ -61,5 +61,5 @@ if (isSdkTransportEnabled()) { }, ready: () => subscription.isReady, } as MeteorSubscriptionHandle; - }) as Meteor.IMeteorConnection['subscribe']; + }; } diff --git a/apps/meteor/client/meteor/startup/accounts.ts b/apps/meteor/client/meteor/startup/accounts.ts index d95c3d18fd77a..ed6d3c3e70514 100644 --- a/apps/meteor/client/meteor/startup/accounts.ts +++ b/apps/meteor/client/meteor/startup/accounts.ts @@ -1,4 +1,3 @@ -import { Accounts } from 'meteor/accounts-base'; import { Meteor } from 'meteor/meteor'; import { sdk } from '../../../app/utils/client/lib/SDKClient'; @@ -9,6 +8,15 @@ import { dispatchToastMessage } from '../../lib/toast'; import { userIdStore } from '../../lib/user'; import { useUserDataSyncReady } from '../../lib/userData'; +// Meteor's accounts-password package registers `verifyEmail` server-side; declare +// it here so the typed `sdk.call` accepts it from client code. +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + verifyEmail(token: string): void; + } +} + const whenMainReady = (): Promise => { const isMainReady = (): boolean => { const uid = userIdStore.getState(); @@ -40,16 +48,15 @@ const whenMainReady = (): Promise => { }); }; -getDdpSdk().account.onEmailVerificationLink((token: string) => { - Accounts.verifyEmail(token, async (error) => { +getDdpSdk().account.onEmailVerificationLink(async (token: string) => { + try { + await sdk.call('verifyEmail', token); await whenMainReady(); - - if (error) { - dispatchToastMessage({ type: 'error', message: error }); - throw new Meteor.Error('verify-email', 'E-mail not verified'); - } else { - void sdk.call('afterVerifyEmail'); - dispatchToastMessage({ type: 'success', message: t('Email_verified') }); - } - }); + void sdk.call('afterVerifyEmail'); + dispatchToastMessage({ type: 'success', message: t('Email_verified') }); + } catch (error) { + await whenMainReady(); + dispatchToastMessage({ type: 'error', message: error }); + throw new Meteor.Error('verify-email', 'E-mail not verified'); + } }); diff --git a/apps/meteor/client/providers/AuthenticationProvider/AuthenticationProvider.tsx b/apps/meteor/client/providers/AuthenticationProvider/AuthenticationProvider.tsx index aeb6e7e576555..0bf8b1c30bc8f 100644 --- a/apps/meteor/client/providers/AuthenticationProvider/AuthenticationProvider.tsx +++ b/apps/meteor/client/providers/AuthenticationProvider/AuthenticationProvider.tsx @@ -74,7 +74,7 @@ const AuthenticationProvider = ({ children }: AuthenticationProviderProps): Reac const loginWithService = `loginWith${loginMethods[serviceName] || capitalize(String(serviceName || ''))}`; - const method: (config: unknown, cb: (error: any) => void) => Promise = (Meteor as any)[loginWithService] as any; + const method: (config: unknown, cb: (error: any) => void) => Promise = (Meteor as any)[loginWithService]; if (!method) { return () => Promise.reject(new Error('Login method not found')); @@ -131,7 +131,7 @@ const AuthenticationProvider = ({ children }: AuthenticationProviderProps): Reac // ignore } try { - (Meteor.connection as unknown as { setUserId: (uid: string | null) => void }).setUserId(null); + Meteor.connection.setUserId(null); } catch { // ignore } diff --git a/apps/meteor/client/views/room/RoomOpenerEmbedded.tsx b/apps/meteor/client/views/room/RoomOpenerEmbedded.tsx index 919990af1a985..aaeee3f060cdc 100644 --- a/apps/meteor/client/views/room/RoomOpenerEmbedded.tsx +++ b/apps/meteor/client/views/room/RoomOpenerEmbedded.tsx @@ -1,8 +1,7 @@ -import type { RoomType } from '@rocket.chat/core-typings'; +import type { ISubscription, RoomType } from '@rocket.chat/core-typings'; import { Box, States, StatesIcon, StatesSubtitle, StatesTitle } from '@rocket.chat/fuselage'; import { Header } from '@rocket.chat/ui-client'; -import { useEndpoint, useStream, useUserId } from '@rocket.chat/ui-contexts'; -import { useQuery } from '@tanstack/react-query'; +import { useStream, useUserId } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import { lazy, Suspense, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; @@ -10,15 +9,12 @@ import { useTranslation } from 'react-i18next'; import NotSubscribedRoom from './NotSubscribedRoom'; import RoomSkeleton from './RoomSkeleton'; import { useOpenRoom } from './hooks/useOpenRoom'; -import { LegacyRoomManager } from '../../../app/ui-utils/client'; import { SubscriptionsCachedStore } from '../../cachedStores'; import { getErrorMessage } from '../../lib/errorHandling'; import { NotAuthorizedError } from '../../lib/errors/NotAuthorizedError'; import { NotSubscribedToRoomError } from '../../lib/errors/NotSubscribedToRoomError'; import { OldUrlRoomError } from '../../lib/errors/OldUrlRoomError'; import { RoomNotFoundError } from '../../lib/errors/RoomNotFoundError'; -import { subscriptionsQueryKeys } from '../../lib/queryKeys'; -import { mapSubscriptionFromApi } from '../../lib/utils/mapSubscriptionFromApi'; const RoomProvider = lazy(() => import('./providers/RoomProvider')); const RoomNotFound = lazy(() => import('./RoomNotFound')); @@ -34,47 +30,23 @@ type RoomOpenerProps = { const RoomOpenerEmbedded = ({ type, reference }: RoomOpenerProps): ReactElement => { const { data, error, isSuccess, isError, isLoading } = useOpenRoom({ type, reference }); const uid = useUserId(); - - const getSubscription = useEndpoint('GET', '/v1/subscriptions.getOne'); - const subscribeToNotifyUser = useStream('notify-user'); const rid = data?.rid; - const { data: subscriptionData, refetch } = useQuery({ - queryKey: rid ? subscriptionsQueryKeys.subscription(rid) : [], - queryFn: () => { - if (!rid) { - throw new Error('Room not found'); - } - return getSubscription({ roomId: rid }); - }, - enabled: !!rid, - }); useEffect(() => { - if (!subscriptionData?.subscription) { + if (!uid || !rid) { return; } - SubscriptionsCachedStore.upsertSubscription(mapSubscriptionFromApi(subscriptionData.subscription)); - - // yes this must be done here, this is already called in useOpenRoom, but it skips subscription streams because of the subscriptions list is empty - // now that we inserted the subscription, we can open the room - LegacyRoomManager.open({ typeName: type + reference, rid: subscriptionData.subscription.rid }); - }, [subscriptionData, type, rid, reference]); - - useEffect(() => { - if (!uid) { - return; - } return subscribeToNotifyUser(`${uid}/subscriptions-changed`, (event, sub) => { if (sub.rid !== rid || event === 'removed') { return; } - refetch(); + SubscriptionsCachedStore.upsertSubscription(sub as ISubscription); }); - }, [refetch, rid, subscribeToNotifyUser, uid]); + }, [rid, subscribeToNotifyUser, uid]); const { t } = useTranslation(); diff --git a/apps/meteor/client/views/root/MainLayout/EmbeddedPreload.tsx b/apps/meteor/client/views/root/MainLayout/EmbeddedPreload.tsx index 873ade90377dd..35edbf4e78ee1 100644 --- a/apps/meteor/client/views/root/MainLayout/EmbeddedPreload.tsx +++ b/apps/meteor/client/views/root/MainLayout/EmbeddedPreload.tsx @@ -1,19 +1,75 @@ +import { useEndpoint, useMethod, useRouter, useUserId } from '@rocket.chat/ui-contexts'; +import { useQuery } from '@tanstack/react-query'; import type { ReactElement, ReactNode } from 'react'; -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; import { RoomsCachedStore, SubscriptionsCachedStore } from '../../../cachedStores'; +import { roomsQueryKeys } from '../../../lib/queryKeys'; +import { roomCoordinator } from '../../../lib/rooms/roomCoordinator'; +import { mapSubscriptionFromApi } from '../../../lib/utils/mapSubscriptionFromApi'; import PageLoading from '../PageLoading'; import { useMainReady } from '../hooks/useMainReady'; const EmbeddedPreload = ({ children }: { children: ReactNode }): ReactElement => { const ready = useMainReady(); + const router = useRouter(); + const uid = useUserId(); + + const roomParams = useMemo(() => { + const routeName = router.getRouteName(); + if (!routeName) { + return null; + } + + const identifier = roomCoordinator.getRouteNameIdentifier(routeName); + if (!identifier) { + return null; + } + + const directives = roomCoordinator.getRoomDirectives(identifier); + if (!directives?.extractOpenRoomParams) { + return null; + } + + return directives.extractOpenRoomParams(router.getRouteParameters()); + }, [router]); + + const getRoomByTypeAndName = useMethod('getRoomByTypeAndName'); + const getSubscription = useEndpoint('GET', '/v1/subscriptions.getOne'); + + const shouldFetch = !!roomParams && !!uid; + + const { isLoading, isSuccess, isError } = useQuery({ + queryKey: roomParams ? roomsQueryKeys.roomReference(roomParams.reference, roomParams.type, uid ?? undefined) : [], + queryFn: async () => { + if (!roomParams) { + return null; + } + + const roomData = await getRoomByTypeAndName(roomParams.type, roomParams.reference); + if (!roomData?._id) { + return null; + } + + const subResult = await getSubscription({ roomId: roomData._id }); + if (subResult.subscription) { + SubscriptionsCachedStore.upsertSubscription(mapSubscriptionFromApi(subResult.subscription)); + } + + return subResult; + }, + enabled: shouldFetch, + retry: false, + }); useEffect(() => { - SubscriptionsCachedStore.setReady(true); - RoomsCachedStore.setReady(true); - }, [ready]); + if (!shouldFetch || isSuccess || isError) { + SubscriptionsCachedStore.setReady(true); + RoomsCachedStore.setReady(true); + } + }, [shouldFetch, isSuccess, isError]); - if (!ready) { + if (!ready || (shouldFetch && isLoading)) { return ; } diff --git a/apps/meteor/definition/externals/meteor/meteor.d.ts b/apps/meteor/definition/externals/meteor/meteor.d.ts index 477107d55d338..a2b3f400acb39 100644 --- a/apps/meteor/definition/externals/meteor/meteor.d.ts +++ b/apps/meteor/definition/externals/meteor/meteor.d.ts @@ -64,6 +64,42 @@ declare module 'meteor/meteor' { methods: string[]; } + interface IDDPStream { + eventCallbacks: { + message: Array<(data: string) => void>; + reset: Array<() => void>; + disconnect: Array<() => void>; + }; + socket?: { + onmessage: (data: { type: string; data: string }) => void; + _didMessage: (data: string) => void; + send: (data: string) => void; + }; + _launchConnectionAsync?: () => void; + on: { + (key: 'message', callback: (data: string) => void): void; + (key: 'reset', callback: () => void): void; + (key: 'disconnect', callback: () => void): void; + }; + disconnect(options?: { _permanent?: boolean; _error?: unknown }): void; + + currentStatus: { + status: string; + connected: boolean; + retryCount: number; + retryTime?: number; + reason?: string; + }; + statusListeners?: { changed(): void }; + forEachCallback(name: string, cb: (callback: (...args: unknown[]) => void) => void): void; + send(data: string): void; + status(): IDDPStream['currentStatus']; + statusChanged(): void; + reconnect(options?: unknown): void; + disconnect(options?: { _permanent?: boolean; _error?: unknown }): void; + _lostConnection(error?: unknown): void; + } + interface IMeteorConnection { httpHeaders: Record; referer: string; @@ -71,26 +107,14 @@ declare module 'meteor/meteor' { _send(message: IDDPMessage): void; _methodInvokers: Record; + _subsBeingRevived: Record; + _methodsBlockingQuiescence: Record; + _messagesBufferedUntilQuiescence: unknown[]; + _outstandingMethodBlocks: unknown[]; _livedata_data(message: IDDPUpdatedMessage): void; - _stream: { - eventCallbacks: { - message: Array<(data: string) => void>; - }; - socket: { - onmessage: (data: { type: string; data: string }) => void; - _didMessage: (data: string) => void; - send: (data: string) => void; - }; - _launchConnectionAsync: () => void; - on: { - (key: 'message', callback: (data: string) => void): void; - (key: 'reset', callback: () => void): void; - }; - }; - - _outstandingMethodBlocks: unknown[]; + _stream: IDDPStream | undefined; // Updated: onMessage is now inside _streamHandlers _streamHandlers: { @@ -120,6 +144,8 @@ declare module 'meteor/meteor' { ): SubscriptionHandle; call(methodName: string, ...args: [...unknown, callback?: (error: Error | null, result: unknown) => void]): void; + + setUserId(uid: string | null): void; } const connection: IMeteorConnection;