diff --git a/app/containers/Avatar/useAvatarETag.ts b/app/containers/Avatar/useAvatarETag.ts index 4b8a2596c91..a4e9261a33d 100644 --- a/app/containers/Avatar/useAvatarETag.ts +++ b/app/containers/Avatar/useAvatarETag.ts @@ -13,7 +13,7 @@ export const useAvatarETag = ({ id }: { type?: string; - username: string; + username?: string; text: string; rid?: string; id: string; diff --git a/app/containers/LoginServices/serviceLogin.ts b/app/containers/LoginServices/serviceLogin.ts index fad1a839ac3..b3b05928ce1 100644 --- a/app/containers/LoginServices/serviceLogin.ts +++ b/app/containers/LoginServices/serviceLogin.ts @@ -137,7 +137,11 @@ export const onPressAppleLogin = async () => { AppleAuthentication.AppleAuthenticationScope.EMAIL ] }); - await loginOAuthOrSso({ fullName, email, identityToken }); + if (!identityToken) { + logEvent(events.ENTER_WITH_APPLE_F); + return; + } + await loginOAuthOrSso({ fullName: fullName ?? {}, email, identityToken }); } catch { logEvent(events.ENTER_WITH_APPLE_F); } diff --git a/app/containers/TwoFactor/index.tsx b/app/containers/TwoFactor/index.tsx index fdc3bb6da8e..fe158025920 100644 --- a/app/containers/TwoFactor/index.tsx +++ b/app/containers/TwoFactor/index.tsx @@ -16,7 +16,7 @@ import { useTheme } from '../../theme'; import Button from '../Button'; import sharedStyles from '../../views/Styles'; import styles from './styles'; -import { type ICredentials } from '../../definitions'; +import { type ILoginCredentials } from '../../definitions'; import { sendEmailCode } from '../../lib/services/restApi'; import { useMasterDetail } from '../../lib/hooks/useMasterDetail'; import Toast from '../Toast'; @@ -38,7 +38,7 @@ interface IMethods { } interface EventListenerMethod { - params?: ICredentials; + params?: ILoginCredentials; method?: keyof IMethods; submit?: (param: string) => void; cancel?: () => void; @@ -88,12 +88,13 @@ const TwoFactor = memo(() => { const method = data.method ? methods[data.method] : null; const isEmail = data.method === 'email'; const params = data?.params; + const emailCodeRecipient = params && 'user' in params ? params.user : undefined; const sendEmail = async () => { try { - if (params?.user) { + if (emailCodeRecipient) { clearErrors(); - const response = await sendEmailCode(params?.user); + const response = await sendEmailCode(emailCodeRecipient); if (response.success) { showToast(I18n.t('Two_Factor_Success_message')); diff --git a/app/definitions/ICredentials.ts b/app/definitions/ICredentials.ts deleted file mode 100644 index 99cab9ea536..00000000000 --- a/app/definitions/ICredentials.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { type AppleAuthenticationFullName } from 'expo-apple-authentication'; - -export interface ICredentials { - resume?: string; - user?: string; - password?: string; - username?: string; - ldapPass?: string; - ldap?: boolean; - ldapOptions?: object; - crowdPassword?: string; - crowd?: boolean; - code?: string; - totp?: { - login: ICredentials; - code: string; - }; - fullName?: AppleAuthenticationFullName | null; - email?: string | null; - identityToken?: string | null; - credentialToken?: string; - saml?: boolean; - cas?: { credentialToken?: string }; -} diff --git a/app/definitions/ILoggedUser.ts b/app/definitions/ILoggedUser.ts index ecd78aa5fd1..60c3600d5b6 100644 --- a/app/definitions/ILoggedUser.ts +++ b/app/definitions/ILoggedUser.ts @@ -1,13 +1,13 @@ import type Model from '@nozbe/watermelondb/Model'; -import { type IUserEmail, type IUserSettings } from './IUser'; +import { type IUserEmail } from './IUser'; import { type TStatusSource } from './TStatusSource'; import { type TUserStatus } from './TUserStatus'; export interface ILoggedUser { id: string; token: string; - username: string; + username?: string; name?: string; language?: string; status: TUserStatus; @@ -30,18 +30,4 @@ export interface ILoggedUser { requirePasswordChange?: boolean; } -export interface ILoggedUserResultFromServer extends Omit< - ILoggedUser, - 'enableMessageParserEarlyAdoption' | 'showMessageInMainThread' -> { - settings: IUserSettings; -} - -export interface ILoginResultFromServer { - status: string; - authToken: string; - userId: string; - me: ILoggedUserResultFromServer; -} - export type TLoggedUserModel = ILoggedUser & Model; diff --git a/app/definitions/ILoginCredentials.ts b/app/definitions/ILoginCredentials.ts new file mode 100644 index 00000000000..11b9020fe8c --- /dev/null +++ b/app/definitions/ILoginCredentials.ts @@ -0,0 +1,12 @@ +export type { + ICredentialsAppleAPI, + ICredentialsAuthenticated, + ICredentialsCasAPI, + ICredentialsCrowdAPI, + ICredentialsLdapAPI, + ICredentialsOAuth, + ICredentialsPasswordAPI, + ICredentialsSamlAPI, + ICredentialsTotpAPI, + ILoginCredentials +} from '@rocket.chat/sdk/interfaces'; diff --git a/app/definitions/IProfile.ts b/app/definitions/IProfile.ts index 0692e530edc..2f6a2dfddf6 100644 --- a/app/definitions/IProfile.ts +++ b/app/definitions/IProfile.ts @@ -3,7 +3,7 @@ import { type ReactNode } from 'react'; export interface IProfileParams { realname?: string; name?: string; - username: string; + username?: string; email: string | null; newPassword: string; currentPassword: string; diff --git a/app/definitions/index.ts b/app/definitions/index.ts index b2566469043..45d4bdc2dd6 100644 --- a/app/definitions/index.ts +++ b/app/definitions/index.ts @@ -9,7 +9,7 @@ export * from './ERoomType'; export * from './IAttachment'; export * from './ICannedResponse'; export * from './ICertificate'; -export * from './ICredentials'; +export * from './ILoginCredentials'; export * from './IEmoji'; export * from './ILivechatDepartment'; export * from './ILivechatTag'; diff --git a/app/lib/hooks/useUserData.ts b/app/lib/hooks/useUserData.ts index b2cdbb4c7ae..6b1b1a91ab8 100644 --- a/app/lib/hooks/useUserData.ts +++ b/app/lib/hooks/useUserData.ts @@ -30,6 +30,9 @@ const useUserData = (rid: string) => { const result = await getUserInfo(rid); if (result.success) { const { user } = result; + if (!user.username) { + return; + } const username = useRealName && user.name ? user.name : user.username; setUser({ username, diff --git a/app/lib/methods/helpers/events.ts b/app/lib/methods/helpers/events.ts index f8f52140cee..f9953e2c731 100644 --- a/app/lib/methods/helpers/events.ts +++ b/app/lib/methods/helpers/events.ts @@ -1,4 +1,4 @@ -import { type ICredentials } from '../../../definitions'; +import { type ILoginCredentials } from '../../../definitions'; import { type IEmitUserInteraction } from '../../../containers/UIKit/interfaces'; import log from './log'; @@ -13,7 +13,7 @@ type TEventEmitterEmmitArgs = | { visible: boolean; onCancel?: null | Function } | { cancel: () => void } | { submit: (param: string) => void } - | { params: ICredentials } + | { params: ILoginCredentials } | IEmitUserInteraction; class EventEmitter { diff --git a/app/lib/methods/helpers/isReadOnly.ts b/app/lib/methods/helpers/isReadOnly.ts index 226cd5350e9..345f51ecbeb 100644 --- a/app/lib/methods/helpers/isReadOnly.ts +++ b/app/lib/methods/helpers/isReadOnly.ts @@ -2,7 +2,7 @@ import { store as reduxStore } from '../../store/auxStore'; import { type ISubscription } from '../../../definitions'; import { hasPermission } from './helpers'; -const canPostReadOnly = async (room: Partial, username: string) => { +const canPostReadOnly = async (room: Partial, username?: string) => { // RC 6.4.0 const isUnmuted = !!room?.unmuted?.find(m => m === username); // TODO: this is not reactive. If this permission changes, the component won't be updated @@ -11,10 +11,10 @@ const canPostReadOnly = async (room: Partial, username: string) = return permission[0] || isUnmuted; }; -const isMuted = (room: Partial, username: string) => +const isMuted = (room: Partial, username?: string) => room && room.muted && room.muted.find && !!room.muted.find(m => m === username); -export const isReadOnly = async (room: Partial, username: string): Promise => { +export const isReadOnly = async (room: Partial, username?: string): Promise => { if (room.archived) { return true; } diff --git a/app/lib/methods/helpers/parseSamlOrCasRedirect.test.ts b/app/lib/methods/helpers/parseSamlOrCasRedirect.test.ts index 89d5a72f52a..f5c8ddb9f61 100644 --- a/app/lib/methods/helpers/parseSamlOrCasRedirect.test.ts +++ b/app/lib/methods/helpers/parseSamlOrCasRedirect.test.ts @@ -46,11 +46,8 @@ describe('parseSamlOrCasRedirect', () => { expect(parseSamlOrCasRedirect('https://server.example/login', 'cas', 'sso-token')).toBeNull(); }); - it('passes credentialToken through as undefined when ssoToken is not provided', () => { - expect(parseSamlOrCasRedirect('https://server.example/_cas/validate/xyz', 'cas')).toEqual({ - kind: 'cas', - payload: { cas: { credentialToken: undefined } } - }); + it('returns null when authType is cas and no ssoToken is provided', () => { + expect(parseSamlOrCasRedirect('https://server.example/_cas/validate/xyz', 'cas')).toBeNull(); }); it('returns null when authType is cas and the URL only has a SAML-style token', () => { diff --git a/app/lib/methods/helpers/parseSamlOrCasRedirect.ts b/app/lib/methods/helpers/parseSamlOrCasRedirect.ts index 99d9c59c9a0..6db4300999b 100644 --- a/app/lib/methods/helpers/parseSamlOrCasRedirect.ts +++ b/app/lib/methods/helpers/parseSamlOrCasRedirect.ts @@ -1,16 +1,22 @@ import parse from 'url-parse'; -import { type ICredentials } from '../../../definitions'; +import { type ICredentialsCasAPI, type ICredentialsSamlAPI } from '../../../definitions'; -export type SamlOrCasRedirect = { kind: 'saml'; payload: ICredentials } | { kind: 'cas'; payload: ICredentials } | null; +export type SamlOrCasRedirect = + | { kind: 'saml'; payload: ICredentialsSamlAPI } + | { kind: 'cas'; payload: ICredentialsCasAPI } + | null; export const parseSamlOrCasRedirect = (url: string, authType: string, ssoToken?: string): SamlOrCasRedirect => { const parsedUrl = parse(url, true); - if (authType === 'saml' && parsedUrl.query?.saml_idp_credentialToken) { - const token = parsedUrl.query.saml_idp_credentialToken || ssoToken; - return { kind: 'saml', payload: { credentialToken: token, saml: true } }; + const samlCredentialToken = parsedUrl.query?.saml_idp_credentialToken; + if (authType === 'saml' && samlCredentialToken) { + return { kind: 'saml', payload: { credentialToken: samlCredentialToken, saml: true } }; } if (authType === 'cas' && (parsedUrl.pathname?.includes('validate') || parsedUrl.query?.ticket)) { + if (!ssoToken) { + return null; + } return { kind: 'cas', payload: { cas: { credentialToken: ssoToken } } }; } return null; diff --git a/app/lib/methods/logout.ts b/app/lib/methods/logout.ts index 75a1dc71a52..e036669ad24 100644 --- a/app/lib/methods/logout.ts +++ b/app/lib/methods/logout.ts @@ -8,7 +8,6 @@ import database, { getDatabase } from '../database'; import log from './helpers/log'; import { disconnect } from '../services/connect'; import sdk from '../services/sdk'; -import { toSdkCredentials } from '../services/toSdkCredentials'; import { CURRENT_SERVER, E2E_PRIVATE_KEY, E2E_PUBLIC_KEY, E2E_RANDOM_PASSWORD_KEY, TOKEN_KEY } from '../constants/keys'; import UserPreferences from './userPreferences'; import { removePushToken } from '../services/restApi'; @@ -67,18 +66,20 @@ export async function removeServer({ server }: { server: string }): Promise jest.fn().mockImplementation(() => { - const connection = { - send: jest.fn((data: string) => { - const message = JSON.parse(data) as { msg: string; id?: string; method?: string }; - if (message.msg === 'connect') { - setImmediate(() => connection.onmessage({ data: JSON.stringify({ msg: 'connected', session: 'session-id' }) })); - } else if (message.msg === 'ping') { - setImmediate(() => connection.onmessage({ data: JSON.stringify({ msg: 'pong' }) })); - } else if (message.msg === 'sub') { - setImmediate(() => connection.onmessage({ data: JSON.stringify({ msg: 'ready', subs: [message.id] }) })); - } else if (message.msg === 'unsub') { - setImmediate(() => connection.onmessage({ data: JSON.stringify({ msg: 'nosub', id: message.id }) })); - } - }), - close: jest.fn(), - readyState: 1, - onopen: jest.fn(), - onmessage: jest.fn(), - onerror: jest.fn(), - onclose: jest.fn() - }; - mockConnections.push(connection); - return connection; + const sdkIntegration = jest.requireActual('../../../testUtils/sdkIntegration'); + return new sdkIntegration.MockConnection(mockConnections); }) ); @@ -95,83 +73,32 @@ import { getMessageById } from '../../../database/services/Message'; import buildMessage from '../../helpers/buildMessage'; import { subscribeRoom, unsubscribeRoom } from '../../../../actions/room'; import { clearUserTyping } from '../../../../actions/usersTyping'; -import type { IApplicationState } from '../../../../definitions'; +import { + flush, + framesOn, + makeCollection as makeBaseCollection, + makeReduxStore, + receiveFrame +} from '../../../testUtils/sdkIntegration'; +import type { IMockCollection, MockConnection } from '../../../testUtils/sdkIntegration'; +import type * as SdkIntegration from '../../../testUtils/sdkIntegration'; // eslint-disable-next-line @typescript-eslint/no-var-requires const database = require('../../../database').default as { active: { get: jest.Mock; write: jest.Mock; batch: jest.Mock }; }; -interface MockConnection { - send: jest.Mock; - close: jest.Mock; - readyState: number; - onopen: () => void; - onmessage: (event: { data: string }) => void; - onerror: () => void; - onclose: () => void; -} - -interface WireFrame { - msg: string; - id?: string; - name?: string; - params?: unknown[]; -} - const mockConnections: MockConnection[] = []; -function makeReduxStore() { - const listeners = new Set<() => void>(); - const state = { - login: { user: null as Record | null, isAuthenticated: false }, - server: { version: '5.0.0' }, - settings: {} as Record, - room: { subscribedRoom: 'room-rid' as string | null } - }; - return { - state, - store: { - getState: () => state, - dispatch: jest.fn(), - subscribe: (listener: () => void) => { - listeners.add(listener); - return () => listeners.delete(listener); - } - } as unknown as Store - }; -} - -async function flush(turns = 10) { - for (let i = 0; i < turns; i++) { - await Promise.resolve(); - await jest.advanceTimersByTimeAsync(0); - } -} - -function framesOn(connection: MockConnection, msg: string) { - return connection.send.mock.calls - .map(([data]: [string]) => JSON.parse(data) as WireFrame) - .filter(message => message.msg === msg); -} - -function receiveFrame(connection: MockConnection, frame: Record) { - connection.onmessage({ data: JSON.stringify(frame) }); -} - -function makeCollection(name: string) { - return { - name, - find: jest.fn(), - query: jest.fn(() => ({ fetch: jest.fn(() => Promise.resolve([])) })), - create: jest.fn(), - prepareCreate: jest.fn((fn: (record: Record) => void) => { - const record = { _raw: { id: '' }, subscription: { id: '' } }; - fn(record); - return record; - }), - schema: { columnArray: [] } - }; +function makeCollection(name: string): IMockCollection { + const collection = makeBaseCollection(name); + collection.prepareCreate.mockImplementation((fn: (record: Record) => void) => { + const record = { _raw: { id: '' }, subscription: { id: '' } }; + fn(record); + return record; + }); + collection.schema = { columnArray: [] }; + return collection; } const MESSAGE = { diff --git a/app/lib/services/__tests__/connect.integration.test.ts b/app/lib/services/__tests__/connect.integration.test.ts index c9653a43f65..512c959ed20 100644 --- a/app/lib/services/__tests__/connect.integration.test.ts +++ b/app/lib/services/__tests__/connect.integration.test.ts @@ -1,5 +1,3 @@ -import type { Store } from 'redux'; - jest.unmock('@rocket.chat/sdk'); import { connect, login, loginWithPassword } from '../connect'; @@ -11,56 +9,16 @@ import { setActiveUsers } from '../../../actions/activeUsers'; import { updateSettings } from '../../../actions/settings'; import { updatePermission } from '../../../actions/permissions'; import { _activeUsers, _setUserTimer } from '../../methods/setUser'; -import type { IApplicationState } from '../../../definitions'; - -interface MockConnection { - send: jest.Mock; - close: jest.Mock; - readyState: number; - onopen: () => void; - onmessage: (event: { data: string }) => void; - onerror: () => void; - onclose: (event?: { code?: number }) => void; -} - -interface WireFrame { - msg: string; - id?: string; - name?: string; - method?: string; - params?: unknown[]; -} +import { flush, framesOn, makeCollection, makeReduxStore, receiveFrame } from '../../testUtils/sdkIntegration'; +import type { MockConnection } from '../../testUtils/sdkIntegration'; +import type * as SdkIntegration from '../../testUtils/sdkIntegration'; const mockConnections: MockConnection[] = []; -const DDP_LOGIN_RESULT = { id: 'user-id', token: 'auth-token' }; - jest.mock('universal-websocket-client', () => jest.fn().mockImplementation(() => { - const connection = { - send: jest.fn((data: string) => { - const message = JSON.parse(data) as { msg: string; id?: string; method?: string }; - if (message.msg === 'connect') { - setImmediate(() => connection.onmessage({ data: JSON.stringify({ msg: 'connected', session: 'session-id' }) })); - } else if (message.msg === 'ping') { - setImmediate(() => connection.onmessage({ data: JSON.stringify({ msg: 'pong' }) })); - } else if (message.msg === 'sub') { - setImmediate(() => connection.onmessage({ data: JSON.stringify({ msg: 'ready', subs: [message.id] }) })); - } else if (message.msg === 'method' && message.method === 'login') { - setImmediate(() => - connection.onmessage({ data: JSON.stringify({ msg: 'result', id: message.id, result: DDP_LOGIN_RESULT }) }) - ); - } - }), - close: jest.fn(), - readyState: 1, - onopen: jest.fn(), - onmessage: jest.fn(), - onerror: jest.fn(), - onclose: jest.fn() - }; - mockConnections.push(connection); - return connection; + const sdkIntegration = jest.requireActual('../../testUtils/sdkIntegration'); + return new sdkIntegration.MockConnection(mockConnections); }) ); @@ -126,56 +84,6 @@ const REST_LOGIN_ME = { requirePasswordChange: false }; -function makeReduxStore() { - const listeners = new Set<() => void>(); - const state = { - meteor: { connected: false }, - login: { user: null as Record | null, isAuthenticated: false }, - server: { version: '5.0.0' }, - settings: {} as Record, - room: { subscribedRoom: null as string | null } - }; - return { - state, - store: { - getState: () => state, - dispatch: jest.fn(), - subscribe: (listener: () => void) => { - listeners.add(listener); - return () => listeners.delete(listener); - } - } as unknown as Store & { dispatch: jest.Mock } - }; -} - -async function flush(turns = 10) { - for (let i = 0; i < turns; i++) { - await Promise.resolve(); - await jest.advanceTimersByTimeAsync(0); - } -} - -function framesOn(connection: MockConnection, msg: string) { - return connection.send.mock.calls - .map(([data]: [string]) => JSON.parse(data) as WireFrame) - .filter(message => message.msg === msg); -} - -function receiveFrame(connection: MockConnection, frame: Record) { - connection.onmessage({ data: JSON.stringify(frame) }); -} - -function makeCollection(name: string) { - return { - name, - find: jest.fn(), - query: jest.fn(() => ({ fetch: jest.fn(() => Promise.resolve([])) })), - create: jest.fn(), - prepareCreate: jest.fn(), - schema: {} - }; -} - let redux: ReturnType; let collections: Record>; diff --git a/app/lib/services/__tests__/socketHealth.integration.test.ts b/app/lib/services/__tests__/socketHealth.integration.test.ts index 13fef8a84dc..90eadc278e0 100644 --- a/app/lib/services/__tests__/socketHealth.integration.test.ts +++ b/app/lib/services/__tests__/socketHealth.integration.test.ts @@ -1,67 +1,21 @@ import sdk from '../sdk'; import { recoverSocket } from '../socketHealth'; - -// eslint-disable-next-line @typescript-eslint/no-var-requires -const { Driver } = require('@rocket.chat/sdk/lib/drivers/driver') as { - Driver: new (options: { host: string; logger: unknown }) => SdkDriver; -}; - -interface MockConnection { - send: jest.Mock; - close: jest.Mock; - readyState: number; - onopen: () => void; - onmessage: (event: { data: string }) => void; - onerror: () => void; - onclose: () => void; -} - -interface WireFrame { - msg: string; - id?: string; - name?: string; - params?: string[]; -} - -interface SdkDriver { - userId: string; - pingInterval: number; - reopenNow(): Promise; - waitForNotifyUserMediaSubs(timeoutMs?: number): Promise; - ddp: { - lastPing: number; - pingTimeout?: ReturnType; - openTimeout?: ReturnType; - open(): Promise; - send(message: Record): Promise; - subscriptions: Record; - }; -} +import { + addMediaSubs, + backdateLastPing, + buildConnectedDriver, + framesOn, + stopAnsweringFrames +} from '../../testUtils/sdkIntegration'; +import type { MockConnection, ISdkDriver } from '../../testUtils/sdkIntegration'; +import type * as SdkIntegration from '../../testUtils/sdkIntegration'; const mockConnections: MockConnection[] = []; jest.mock('universal-websocket-client', () => jest.fn().mockImplementation(() => { - const connection = { - send: jest.fn((data: string) => { - const message = JSON.parse(data) as { msg: string; id?: string }; - if (message.msg === 'connect') { - setImmediate(() => connection.onmessage({ data: JSON.stringify({ msg: 'connected', session: 'session-id' }) })); - } else if (message.msg === 'ping') { - setImmediate(() => connection.onmessage({ data: JSON.stringify({ msg: 'pong' }) })); - } else if (message.msg === 'sub') { - setImmediate(() => connection.onmessage({ data: JSON.stringify({ msg: 'ready', subs: [message.id] }) })); - } - }), - close: jest.fn(), - readyState: 1, - onopen: jest.fn(), - onmessage: jest.fn(), - onerror: jest.fn(), - onclose: jest.fn() - }; - mockConnections.push(connection); - return connection; + const sdkIntegration = jest.requireActual('../../testUtils/sdkIntegration'); + return new sdkIntegration.MockConnection(mockConnections); }) ); @@ -74,58 +28,20 @@ const USER_ID = 'user-id'; const PING_INTERVAL = 10000; const CLOSED = 3; -const logger = { debug: jest.fn(), info: jest.fn(), error: jest.fn(), warn: jest.fn() }; - -async function buildConnectedDriver() { - const driver = new Driver({ host: 'localhost:3000', logger }); - driver.userId = USER_ID; - const openPromise = driver.ddp.open(); - mockConnections[0].onopen(); - await jest.advanceTimersByTimeAsync(0); - await openPromise; - return driver; -} - -function addMediaSubs(driver: SdkDriver) { - ['media-signal', 'media-calls'].forEach((name, index) => { - const id = `sub-${index}`; - driver.ddp.subscriptions[id] = { - id, - name: 'stream-notify-user', - params: [`${USER_ID}/${name}`], - unsubscribe: jest.fn() - }; - }); -} - -function backdateLastPing(driver: SdkDriver, ageMs: number) { - driver.ddp.lastPing = Date.now() - ageMs; -} - -function stopAnsweringFrames(connection: MockConnection) { - connection.send.mockImplementation(() => undefined); -} - -function framesOn(connection: MockConnection, msg: string) { - return connection.send.mock.calls - .map(([data]: [string]) => JSON.parse(data) as WireFrame) - .filter(message => message.msg === msg); -} - describe('recoverSocket against the real SDK socket', () => { - let driver: SdkDriver; + let driver: ISdkDriver; beforeEach(async () => { jest.clearAllMocks(); jest.useFakeTimers(); mockConnections.length = 0; - driver = await buildConnectedDriver(); - (sdk as unknown as { current: { ddp: SdkDriver } }).current = { ddp: driver }; + driver = await buildConnectedDriver(mockConnections, USER_ID); + (sdk as unknown as { current: { driver: ISdkDriver } }).current = { driver }; }); afterEach(() => { - if (driver.ddp.pingTimeout) clearTimeout(driver.ddp.pingTimeout); - if (driver.ddp.openTimeout) clearTimeout(driver.ddp.openTimeout); + if (driver.socket.pingTimeout) clearTimeout(driver.socket.pingTimeout); + if (driver.socket.openTimeout) clearTimeout(driver.socket.openTimeout); jest.useRealTimers(); }); @@ -209,7 +125,7 @@ describe('recoverSocket against the real SDK socket', () => { it('rejects an in-flight DDP method call when recovery reopens the socket', async () => { let rejected = false; - const inFlight = driver.ddp.send({ msg: 'method', method: 'getRoomByTypeAndName', params: [] }).catch(() => { + const inFlight = driver.socket.send({ msg: 'method', method: 'getRoomByTypeAndName', params: [] }).catch(() => { rejected = true; }); await jest.advanceTimersByTimeAsync(0); @@ -230,7 +146,7 @@ describe('recoverSocket against the real SDK socket', () => { it('re-sends the media subscriptions on the new socket reusing their ids', async () => { backdateLastPing(driver, PING_INTERVAL * 3); - addMediaSubs(driver); + addMediaSubs(driver, USER_ID); const recovery = recoverSocket(); await jest.advanceTimersByTimeAsync(0); @@ -238,7 +154,7 @@ describe('recoverSocket against the real SDK socket', () => { await jest.advanceTimersByTimeAsync(0); await expect(recovery).resolves.toBe('reopened'); - const resubscribed = driver.waitForNotifyUserMediaSubs(); + const resubscribed = driver.waitForNotifyUserMediaSubs!(); await jest.advanceTimersByTimeAsync(200); await expect(resubscribed).resolves.toBe(true); @@ -273,11 +189,11 @@ describe('recoverSocket against the real SDK socket', () => { await jest.advanceTimersByTimeAsync(0); await expect(recovery).resolves.toBe('reopened'); - const resubscribed = driver.waitForNotifyUserMediaSubs(1000); + const resubscribed = driver.waitForNotifyUserMediaSubs!(1000); await jest.advanceTimersByTimeAsync(100); expect(framesOn(mockConnections[1], 'sub')).toHaveLength(0); - addMediaSubs(driver); + addMediaSubs(driver, USER_ID); await jest.advanceTimersByTimeAsync(200); await expect(resubscribed).resolves.toBe(true); @@ -289,7 +205,7 @@ describe('recoverSocket against the real SDK socket', () => { it('resolves false when the reopened socket never acks the re-sub', async () => { backdateLastPing(driver, PING_INTERVAL * 3); - addMediaSubs(driver); + addMediaSubs(driver, USER_ID); const recovery = recoverSocket(); await jest.advanceTimersByTimeAsync(0); @@ -299,7 +215,7 @@ describe('recoverSocket against the real SDK socket', () => { stopAnsweringFrames(mockConnections[1]); - const resubscribed = driver.waitForNotifyUserMediaSubs(500); + const resubscribed = driver.waitForNotifyUserMediaSubs!(500); await jest.advanceTimersByTimeAsync(500); await expect(resubscribed).resolves.toBe(false); diff --git a/app/lib/services/__tests__/socketHealth.test.ts b/app/lib/services/__tests__/socketHealth.test.ts index f29f32e9822..dfb21effd7f 100644 --- a/app/lib/services/__tests__/socketHealth.test.ts +++ b/app/lib/services/__tests__/socketHealth.test.ts @@ -1,7 +1,7 @@ jest.mock('../sdk', () => ({ __esModule: true, default: { - current: { ddp: undefined } + current: { driver: undefined } } })); @@ -12,9 +12,9 @@ import { classifySocketHealth, recoverSocket } from '../socketHealth'; const now = 1_000_000; -const sdkMock = sdk as unknown as { current: { ddp: unknown } | undefined }; +const sdkMock = sdk as unknown as { current: { driver: unknown } | undefined }; -interface MockDdp { +interface MockDriver { connected: boolean; lastPing: number; pingInterval: number; @@ -22,7 +22,7 @@ interface MockDdp { probe: jest.Mock, [number]>; } -function makeDdp(overrides: Partial = {}): MockDdp { +function makeDriver(overrides: Partial = {}): MockDriver { return { connected: true, lastPing: now, @@ -43,52 +43,52 @@ describe('classifySocketHealth', () => { }); it('returns round-trip-check for a connected socket rather than trusting it outright', () => { - const ddp = makeDdp({ connected: true }); - expect(classifySocketHealth(ddp as unknown as Driver)).toBe('round-trip-check'); + const driver = makeDriver({ connected: true }); + expect(classifySocketHealth(driver as unknown as Driver)).toBe('round-trip-check'); }); it('returns reopen for a closed socket even when lastPing is fresh', () => { - const ddp = makeDdp({ connected: false, lastPing: now }); - expect(classifySocketHealth(ddp as unknown as Driver)).toBe('reopen'); + const driver = makeDriver({ connected: false, lastPing: now }); + expect(classifySocketHealth(driver as unknown as Driver)).toBe('reopen'); }); }); describe('recoverSocket', () => { - let ddp: MockDdp; + let driver: MockDriver; beforeEach(() => { - ddp = makeDdp({ lastPing: Date.now() }); - sdkMock.current = { ddp }; + driver = makeDriver({ lastPing: Date.now() }); + sdkMock.current = { driver }; }); it('keeps a socket whose round trip answers', async () => { await expect(recoverSocket()).resolves.toBe('confirmed-alive'); - expect(ddp.reopenNow).not.toHaveBeenCalled(); + expect(driver.reopenNow).not.toHaveBeenCalled(); }); it('runs the round trip with a 2s budget', async () => { await recoverSocket(); - expect(ddp.probe).toHaveBeenCalledWith(2000); + expect(driver.probe).toHaveBeenCalledWith(2000); }); it('reopens when the round trip goes unanswered', async () => { - ddp.probe.mockResolvedValue(false); + driver.probe.mockResolvedValue(false); await expect(recoverSocket()).resolves.toBe('reopened'); - expect(ddp.reopenNow).toHaveBeenCalledTimes(1); + expect(driver.reopenNow).toHaveBeenCalledTimes(1); }); it('reopens a known-dead socket without a round trip', async () => { - ddp.connected = false; + driver.connected = false; await expect(recoverSocket()).resolves.toBe('reopened'); - expect(ddp.probe).not.toHaveBeenCalled(); - expect(ddp.reopenNow).toHaveBeenCalledTimes(1); + expect(driver.probe).not.toHaveBeenCalled(); + expect(driver.reopenNow).toHaveBeenCalledTimes(1); }); - it('reports no-socket when the ddp handle is missing', async () => { - sdkMock.current = { ddp: undefined }; + it('reports no-socket when the driver handle is missing', async () => { + sdkMock.current = { driver: undefined }; await expect(recoverSocket()).resolves.toBe('no-socket'); - expect(ddp.probe).not.toHaveBeenCalled(); - expect(ddp.reopenNow).not.toHaveBeenCalled(); + expect(driver.probe).not.toHaveBeenCalled(); + expect(driver.reopenNow).not.toHaveBeenCalled(); }); it('reports no-socket when there is no sdk instance', async () => { @@ -97,31 +97,31 @@ describe('recoverSocket', () => { }); it('rejects when the round trip throws', async () => { - ddp.probe.mockRejectedValue(new Error('round trip failed')); + driver.probe.mockRejectedValue(new Error('round trip failed')); await expect(recoverSocket()).rejects.toThrow('round trip failed'); }); it('rejects when reopening throws', async () => { - ddp.connected = false; - ddp.reopenNow.mockRejectedValue(new Error('reopen failed')); + driver.connected = false; + driver.reopenNow.mockRejectedValue(new Error('reopen failed')); await expect(recoverSocket()).rejects.toThrow('reopen failed'); }); it('shares one in-flight recovery between overlapping callers', async () => { const outcomes = await Promise.all([recoverSocket(), recoverSocket()]); expect(outcomes).toEqual(['confirmed-alive', 'confirmed-alive']); - expect(ddp.probe).toHaveBeenCalledTimes(1); + expect(driver.probe).toHaveBeenCalledTimes(1); }); it('starts a fresh recovery after the shared one settles', async () => { await recoverSocket(); await recoverSocket(); - expect(ddp.probe).toHaveBeenCalledTimes(2); + expect(driver.probe).toHaveBeenCalledTimes(2); }); it('abandons the aborted caller while the shared recovery runs on', async () => { let answerRoundTrip: (alive: boolean) => void = () => {}; - ddp.probe.mockImplementation(() => new Promise(resolve => (answerRoundTrip = resolve))); + driver.probe.mockImplementation(() => new Promise(resolve => (answerRoundTrip = resolve))); const controller = new AbortController(); const aborted = recoverSocket({ abortSignal: controller.signal }); @@ -132,7 +132,7 @@ describe('recoverSocket', () => { answerRoundTrip(true); await expect(other).resolves.toBe('confirmed-alive'); - expect(ddp.probe).toHaveBeenCalledTimes(1); + expect(driver.probe).toHaveBeenCalledTimes(1); }); it('abandons a pre-aborted caller without touching the socket', async () => { @@ -140,7 +140,7 @@ describe('recoverSocket', () => { controller.abort(); await expect(recoverSocket({ abortSignal: controller.signal })).resolves.toBe('abandoned'); - expect(ddp.probe).not.toHaveBeenCalled(); - expect(ddp.reopenNow).not.toHaveBeenCalled(); + expect(driver.probe).not.toHaveBeenCalled(); + expect(driver.reopenNow).not.toHaveBeenCalled(); }); }); diff --git a/app/lib/services/connect.test.ts b/app/lib/services/connect.test.ts index 6a5db1babce..5e55395e8e0 100644 --- a/app/lib/services/connect.test.ts +++ b/app/lib/services/connect.test.ts @@ -37,6 +37,7 @@ jest.mock('./sdk', () => ({ default: { initialize: (server: string) => mockSdkInitialize(server), disconnect: () => mockSdkDisconnect(), + onStreamData: (event: string, cb: (...args: any[]) => void) => mockOnStreamData(event, cb), get current() { return mockSdkCurrent; } diff --git a/app/lib/services/connect.ts b/app/lib/services/connect.ts index 633a53f4101..79c246de083 100644 --- a/app/lib/services/connect.ts +++ b/app/lib/services/connect.ts @@ -11,13 +11,17 @@ import { twoFactor } from './twoFactor'; import { store } from '../store/auxStore'; import { loginRequest, logout, setLoginServices, setUser } from '../../actions/login'; import { waitForLoginReady } from './waitForLoginReady'; -import sdk from './sdk'; -import { toLoginResult } from './toLoginResult'; -import { toSdkCredentials } from './toSdkCredentials'; +import sdk, { type IStreamDataListener } from './sdk'; import { mediaSessionInstance } from './voip/MediaSessionInstance'; import { pendingHangups } from './voip/pendingHangups'; import I18n from '../../i18n'; -import { type ICredentials, type ILoggedUser, STATUSES } from '../../definitions'; +import { + type ILoginCredentials, + type ICredentialsPasswordAPI, + type ILoggedUser, + STATUSES, + type TUserStatus +} from '../../definitions'; import { connectRequest, connectSuccess, disconnect as disconnectAction } from '../../actions/connect'; import { updatePermission } from '../../actions/permissions'; import EventEmitter from '../methods/helpers/events'; @@ -50,7 +54,7 @@ let pendingHangupsConnectedListener: any; let usersListener: any; let notifyAllListener: any; let rolesListener: any; -let userPresenceListener: any; +let userPresenceListener: Promise | undefined; let notifyLoggedListener: any; let logoutListener: any; @@ -179,7 +183,7 @@ function connect({ server, logoutOnError = false }: { server: string; logoutOnEr ); // RC 4.1 - userPresenceListener = sdk.current.onStreamData('stream-user-presence', (ddpMessage: any) => { + userPresenceListener = sdk.onStreamData('stream-user-presence', (ddpMessage: { fields: { args?: any; uid?: any } }) => { const userStatus = ddpMessage.fields.args[0]; const { uid } = ddpMessage.fields; const [, status, statusText, statusSource, statusExpiresAtRaw] = userStatus; @@ -296,11 +300,11 @@ function stopListener(listener: any): void { listener?.stop(); } -async function login(credentials: ICredentials): Promise { +async function login(credentials: ILoginCredentials): Promise { // RC 0.64.0 - await sdk.current.login(toSdkCredentials(credentials)); + await sdk.current.login(credentials); const serverVersion = store.getState().server.version; - const result = toLoginResult(sdk.current.currentLogin?.result); + const result = sdk.current.currentLogin?.result; if (!result) { throw new Error('Login failed: missing login result'); } @@ -318,7 +322,7 @@ async function login(credentials: ICredentials): Promise { username: result.me.username, name: result.me.name, language: result.me.language, - status: result.me.status, + status: result.me.status as TUserStatus, statusText: result.me.statusText, customFields: result.me.customFields, statusLivechat: result.me.statusLivechat, @@ -335,7 +339,19 @@ async function login(credentials: ICredentials): Promise { return user; } -async function loginTOTP(params: ICredentials, loginEmailPassword?: boolean): Promise { +function toPasswordLogin(params: ILoginCredentials): ICredentialsPasswordAPI | undefined { + if ('ldap' in params) { + return { user: params.username, password: params.ldapPass }; + } + if ('crowd' in params) { + return { user: params.username, password: params.crowdPassword }; + } + if ('password' in params) { + return params; + } +} + +async function loginTOTP(params: ILoginCredentials, loginEmailPassword?: boolean): Promise { try { return await login(params); } catch (e: any) { @@ -347,25 +363,16 @@ async function loginTOTP(params: ICredentials, loginEmailPassword?: boolean): Pr invalid: (details.error || error) === 'totp-invalid' }); - if (loginEmailPassword) { - store.dispatch(setUser({ username: params.user || params.username })); - - // Force normalized params for 2FA starting RC 3.9.0. - const serverVersion = store.getState().server.version; - if (compareServerVersion(serverVersion as string, 'greaterThanOrEqualTo', '3.9.0')) { - const user = params.user ?? params.username; - const password = params.password ?? params.ldapPass ?? params.crowdPassword; - params = { user, password }; - } + const passwordParams = loginEmailPassword ? toPasswordLogin(params) : undefined; + if (passwordParams) { + store.dispatch(setUser({ username: passwordParams.user || passwordParams.username })); - return loginTOTP({ ...params, code: code?.twoFactorCode }, loginEmailPassword); + return loginTOTP({ ...passwordParams, code: code?.twoFactorCode }, loginEmailPassword); } return loginTOTP({ totp: { - login: { - ...params - }, + login: params, code: code?.twoFactorCode } }); @@ -375,7 +382,7 @@ async function loginTOTP(params: ICredentials, loginEmailPassword?: boolean): Pr } function loginWithPassword({ user, password }: { user: string; password: string }): Promise { - let params: ICredentials = { user, password }; + let params: ILoginCredentials = { user, password }; const state = store.getState(); if (state.settings.LDAP_Enable) { @@ -396,7 +403,7 @@ function loginWithPassword({ user, password }: { user: string; password: string return loginTOTP(params, true); } -async function loginOAuthOrSso(params: ICredentials) { +async function loginOAuthOrSso(params: ILoginCredentials) { const result = await loginTOTP(params, false); store.dispatch(loginRequest({ resume: result.token }, false)); } diff --git a/app/lib/services/restApi.ts b/app/lib/services/restApi.ts index dbb08e288cd..0eeb5e53607 100644 --- a/app/lib/services/restApi.ts +++ b/app/lib/services/restApi.ts @@ -562,11 +562,14 @@ export const deleteRoom = (roomId: string, t: RoomTypes) => // RC 0.49.0 sdk.post(`${roomTypeToApiType(t)}.delete`, { roomId }); -export const toggleMuteUserInRoom = (rid: string, username: string, userId: string, mute: boolean) => { +export const toggleMuteUserInRoom = (rid: string, username: string | undefined, userId: string, mute: boolean) => { const serverVersion = reduxStore.getState().server.version; if (compareServerVersion(serverVersion, 'greaterThanOrEqualTo', '6.8.0')) { return sdk.post(mute ? 'rooms.muteUser' : 'rooms.unmuteUser', { roomId: rid, userId }); } + if (!username) { + throw new Error('muteUserInRoom requires a username on servers older than 6.8.0'); + } // RC 0.51.0 return sdk.methodCallWrapper(mute ? 'muteUserInRoom' : 'unmuteUserInRoom', { rid, username }); }; diff --git a/app/lib/services/sdk.ts b/app/lib/services/sdk.ts index d41efa968d6..f9556c15493 100644 --- a/app/lib/services/sdk.ts +++ b/app/lib/services/sdk.ts @@ -15,7 +15,7 @@ import { } from '../../definitions/rest/helpers'; import { compareServerVersion, random } from '../methods/helpers'; -export type TDriver = Rocketchat['ddp']; +export type TDriver = Rocketchat['driver']; export type TStreamDataCallback = (ddpMessage: any) => void; @@ -120,6 +120,7 @@ class Sdk { methodCall(method: string, ...args: any[]): Promise { return new Promise(async (resolve, reject) => { try { + // Clear the 2FA code after use — a stale trailing arg breaks typed method signatures const { code } = this; this.code = null; const result = await this.current.methodCall(method, ...args, ...(code ? [code] : [])); @@ -159,8 +160,8 @@ class Sdk { return this.methodCall(method, ...parsedParams); } - subscribe(topic: string, ...args: any[]): Promise { - return this.current.subscribe(topic, ...args); + subscribe(topic: string, eventName?: string, ...args: any[]): Promise { + return this.current.subscribe(topic, eventName as string, ...args); } subscribeRaw(...args: any[]): Promise { diff --git a/app/lib/services/socketHealth.ts b/app/lib/services/socketHealth.ts index 73b3e77f6d7..51f6779766e 100644 --- a/app/lib/services/socketHealth.ts +++ b/app/lib/services/socketHealth.ts @@ -12,10 +12,9 @@ import sdk, { type TDriver } from './sdk'; */ export type SocketRecoveryPlan = 'reopen' | 'round-trip-check'; -export function classifySocketHealth(ddp: TDriver): SocketRecoveryPlan { - // `ddp.connected` already folds in the ping-age test (transportOpen && alive(), - // where alive() is `now - lastPing <= config.ping * 2`), so a stale ping lands here. - if (!ddp.connected) { +export function classifySocketHealth(driver: TDriver): SocketRecoveryPlan { + // `driver.connected` already folds in the ping-age test, so a stale ping lands here. + if (!driver.connected) { return 'reopen'; } // A connected socket is still verified by a round trip, never trusted outright: @@ -27,7 +26,7 @@ export function classifySocketHealth(ddp: TDriver): SocketRecoveryPlan { * What a recovery attempt reports. * - `'confirmed-alive'` — round trip succeeded; nothing was done. * - `'reopened'` — socket reopened (stale ping, or round trip failed). - * - `'no-socket'` — `sdk.current?.ddp` undefined; nothing to recover. + * - `'no-socket'` — `sdk.current?.driver` undefined; nothing to recover. * - `'abandoned'` — caller's abort signal fired while waiting; the * underlying recovery (shared — see below) runs on. * @@ -44,20 +43,20 @@ function shareRecovery(): Promise { if (inFlightRecovery) { return inFlightRecovery; } - const ddp = sdk.current?.ddp; - if (!ddp) { + const driver = sdk.current?.driver; + if (!driver) { return Promise.resolve('no-socket'); } const recovery = (async (): Promise => { - if (classifySocketHealth(ddp) === 'reopen') { - await ddp.reopenNow(); + if (classifySocketHealth(driver) === 'reopen') { + await driver.reopenNow(); return 'reopened'; } - const alive = await ddp.probe(2000); + const alive = await driver.probe(2000); if (alive) { return 'confirmed-alive'; } - await ddp.reopenNow(); + await driver.reopenNow(); return 'reopened'; })(); inFlightRecovery = recovery; @@ -106,12 +105,3 @@ export function recoverSocket(options?: { abortSignal?: AbortSignal }): Promise< }); return Promise.race([recovery, abandoned]); } - -/** - * Vocabulary: - * - socket health — the classification concern (`classifySocketHealth`). - * - recovery plan — `SocketRecoveryPlan`, the decision. - * - round trip — the liveness check (`ddp.probe` stays as the SDK - * method name; our terms say round trip). - * - recovery outcome — `SocketRecoveryOutcome`, what callers see. - */ diff --git a/app/lib/services/toLoginResult.ts b/app/lib/services/toLoginResult.ts deleted file mode 100644 index 797b8793a18..00000000000 --- a/app/lib/services/toLoginResult.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { type ILoginResultAPI } from '@rocket.chat/sdk/interfaces'; - -import { type ILoginResultFromServer } from '../../definitions/ILoggedUser'; - -export const toLoginResult = (result: ILoginResultAPI | null | undefined): ILoginResultFromServer | undefined => - (result ?? undefined) as unknown as ILoginResultFromServer | undefined; diff --git a/app/lib/services/toSdkCredentials.ts b/app/lib/services/toSdkCredentials.ts deleted file mode 100644 index 38b8076a118..00000000000 --- a/app/lib/services/toSdkCredentials.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { type ICredentials as ISdkCredentials } from '@rocket.chat/sdk/interfaces'; - -import { type ICredentials } from '../../definitions/ICredentials'; - -export const toSdkCredentials = (credentials: ICredentials): ISdkCredentials => credentials as ISdkCredentials; diff --git a/app/lib/services/twoFactor.ts b/app/lib/services/twoFactor.ts index eced04aaeb7..4d2c91dd1dd 100644 --- a/app/lib/services/twoFactor.ts +++ b/app/lib/services/twoFactor.ts @@ -2,7 +2,7 @@ import { settings } from '@rocket.chat/sdk'; import { TWO_FACTOR } from '../../containers/TwoFactor'; import EventEmitter from '../methods/helpers/events'; -import { type ICredentials } from '../../definitions'; +import { type ILoginCredentials } from '../../definitions'; import { TwoFactorCancelledError } from './twoFactorCancelled'; export { TwoFactorCancelledError, isTwoFactorCancelled } from './twoFactorCancelled'; @@ -10,7 +10,7 @@ export { TwoFactorCancelledError, isTwoFactorCancelled } from './twoFactorCancel interface ITwoFactor { method: string; invalid: boolean; - params?: ICredentials; + params?: ILoginCredentials; } export const twoFactor = ({ method, invalid, params }: ITwoFactor): Promise<{ twoFactorCode: string; twoFactorMethod: string }> => diff --git a/app/lib/services/voip/MediaSessionInstance.test.ts b/app/lib/services/voip/MediaSessionInstance.test.ts index 564bddb47d3..a8ab6a0d14d 100644 --- a/app/lib/services/voip/MediaSessionInstance.test.ts +++ b/app/lib/services/voip/MediaSessionInstance.test.ts @@ -70,7 +70,7 @@ jest.mock('../sdk', () => ({ }, get current() { return { - ddp: { + driver: { reopenNow: jest.fn(() => Promise.resolve()), probe: jest.fn(() => Promise.resolve(true)), lastPing: Date.now(), diff --git a/app/lib/services/voip/acceptNativeCall.integration.test.ts b/app/lib/services/voip/acceptNativeCall.integration.test.ts index ef988bc3825..89ffc492f29 100644 --- a/app/lib/services/voip/acceptNativeCall.integration.test.ts +++ b/app/lib/services/voip/acceptNativeCall.integration.test.ts @@ -105,7 +105,7 @@ describe('acceptNativeCallWithReadiness against real login readiness', () => { initStore(redux.store); mockGetCallState.mockReturnValue({ call: null, resetNativeCallId: jest.fn() }); mockRecoverSocket.mockResolvedValue('reopened'); - (sdk as any).current = { ddp: mediaSubsAckAfter(100) }; + (sdk as any).current = { driver: mediaSubsAckAfter(100) }; }); afterEach(() => { @@ -148,7 +148,7 @@ describe('acceptNativeCallWithReadiness against real login readiness', () => { }); it('runs the failure ladder once and leaves nothing behind when readiness never lands', async () => { - (sdk as any).current = { ddp: mediaSubsNeverAck() }; + (sdk as any).current = { driver: mediaSubsNeverAck() }; const resetNativeCallId = jest.fn(); mockGetCallState.mockReturnValue({ call: null, resetNativeCallId }); const mediaSession = makeMediaSession(); diff --git a/app/lib/services/voip/acceptNativeCall.sdk.integration.test.ts b/app/lib/services/voip/acceptNativeCall.sdk.integration.test.ts index e8592a375a8..d13045ecc4e 100644 --- a/app/lib/services/voip/acceptNativeCall.sdk.integration.test.ts +++ b/app/lib/services/voip/acceptNativeCall.sdk.integration.test.ts @@ -3,11 +3,9 @@ import { acceptNativeCallWithReadiness } from './acceptNativeCall'; import { useCallStore } from './useCallStore'; import { terminateNativeCall } from './terminateNativeCall'; import { waitForLoginReady } from '../waitForLoginReady'; - -// eslint-disable-next-line @typescript-eslint/no-var-requires -const { Driver } = require('@rocket.chat/sdk/lib/drivers/driver') as { - Driver: new (options: { host: string; logger: unknown }) => SdkDriver; -}; +import { addMediaSubs, backdateLastPing, buildConnectedDriver, stopAnsweringFrames } from '../../testUtils/sdkIntegration'; +import type { MockConnection, ISdkDriver } from '../../testUtils/sdkIntegration'; +import type * as SdkIntegration from '../../testUtils/sdkIntegration'; jest.mock('../sdk', () => ({ __esModule: true, @@ -31,55 +29,12 @@ jest.mock('../../methods/helpers/log', () => ({ default: jest.fn() })); -interface MockConnection { - send: jest.Mock; - close: jest.Mock; - readyState: number; - onopen: () => void; - onmessage: (event: { data: string }) => void; - onerror: () => void; - onclose: () => void; -} - -interface SdkDriver { - userId: string; - pingInterval: number; - reopenNow(): Promise; - ddp: { - lastPing: number; - pingTimeout?: ReturnType; - openTimeout?: ReturnType; - open(): Promise; - subscriptions: Record; - }; -} - const mockConnections: MockConnection[] = []; jest.mock('universal-websocket-client', () => jest.fn().mockImplementation(() => { - const connection = { - send: jest.fn((data: string) => { - const message = JSON.parse(data) as { msg: string; id?: string }; - if (message.msg === 'connect') { - setImmediate(() => connection.onmessage({ data: JSON.stringify({ msg: 'connected', session: 'session-id' }) })); - } else if (message.msg === 'ping') { - setImmediate(() => connection.onmessage({ data: JSON.stringify({ msg: 'pong' }) })); - } else if (message.msg === 'sub') { - setImmediate(() => connection.onmessage({ data: JSON.stringify({ msg: 'ready', subs: [message.id] }) })); - } else if (message.msg === 'unsub') { - setImmediate(() => connection.onmessage({ data: JSON.stringify({ msg: 'nosub', id: message.id }) })); - } - }), - close: jest.fn(), - readyState: 1, - onopen: jest.fn(), - onmessage: jest.fn(), - onerror: jest.fn(), - onclose: jest.fn() - }; - mockConnections.push(connection); - return connection; + const sdkIntegration = jest.requireActual('../../testUtils/sdkIntegration'); + return new sdkIntegration.MockConnection(mockConnections); }) ); @@ -91,8 +46,6 @@ const CALL_ID = 'call-uuid'; const USER_ID = 'user-id'; const PING_INTERVAL = 10000; -const logger = { debug: jest.fn(), info: jest.fn(), error: jest.fn(), warn: jest.fn() }; - interface IMediaSession { applyRestStateSignals: jest.Mock>; answerCall: jest.Mock, [string]>; @@ -110,51 +63,21 @@ function makeMediaSession(overrides: Partial = {}): IMediaSession }; } -async function buildConnectedDriver() { - const driver = new Driver({ host: 'localhost:3000', logger }); - driver.userId = USER_ID; - const openPromise = driver.ddp.open(); - mockConnections[0].onopen(); - await jest.advanceTimersByTimeAsync(0); - await openPromise; - return driver; -} - -function addMediaSubs(driver: SdkDriver) { - ['media-signal', 'media-calls'].forEach((name, index) => { - const id = `sub-${index}`; - driver.ddp.subscriptions[id] = { - id, - name: 'stream-notify-user', - params: [`${USER_ID}/${name}`], - unsubscribe: jest.fn() - }; - }); -} - -function backdateLastPing(driver: SdkDriver, ageMs: number) { - driver.ddp.lastPing = Date.now() - ageMs; -} - -function stopAnsweringFrames(connection: MockConnection) { - connection.send.mockImplementation(() => undefined); -} - -let driver: SdkDriver; +let driver: ISdkDriver; beforeEach(async () => { jest.clearAllMocks(); jest.useFakeTimers(); mockConnections.length = 0; - driver = await buildConnectedDriver(); - (sdk as unknown as { current: { ddp: SdkDriver } }).current = { ddp: driver }; + driver = await buildConnectedDriver(mockConnections, USER_ID); + (sdk as unknown as { current: { driver: ISdkDriver } }).current = { driver }; mockWaitForLoginReady.mockResolvedValue(true); mockGetState.mockReturnValue({ call: null, resetNativeCallId: jest.fn() }); }); afterEach(() => { - if (driver.ddp.pingTimeout) clearTimeout(driver.ddp.pingTimeout); - if (driver.ddp.openTimeout) clearTimeout(driver.ddp.openTimeout); + if (driver.socket.pingTimeout) clearTimeout(driver.socket.pingTimeout); + if (driver.socket.openTimeout) clearTimeout(driver.socket.openTimeout); jest.useRealTimers(); }); @@ -163,7 +86,7 @@ describe('acceptNativeCallWithReadiness against the real SDK socket', () => { const mediaSession = makeMediaSession(); backdateLastPing(driver, PING_INTERVAL * 3); - addMediaSubs(driver); + addMediaSubs(driver, USER_ID); const accept = acceptNativeCallWithReadiness(CALL_ID, mediaSession); await jest.advanceTimersByTimeAsync(0); @@ -185,7 +108,7 @@ describe('acceptNativeCallWithReadiness against the real SDK socket', () => { mockGetState.mockReturnValue({ call: null, resetNativeCallId }); backdateLastPing(driver, PING_INTERVAL * 3); - addMediaSubs(driver); + addMediaSubs(driver, USER_ID); const accept = acceptNativeCallWithReadiness(CALL_ID, mediaSession); await jest.advanceTimersByTimeAsync(0); @@ -215,7 +138,7 @@ describe('acceptNativeCallWithReadiness against the real SDK socket', () => { await jest.advanceTimersByTimeAsync(100); - addMediaSubs(driver); + addMediaSubs(driver, USER_ID); await jest.advanceTimersByTimeAsync(200); await accept; diff --git a/app/lib/services/voip/acceptNativeCall.test.ts b/app/lib/services/voip/acceptNativeCall.test.ts index 026f4e9dc0b..8347571f4a7 100644 --- a/app/lib/services/voip/acceptNativeCall.test.ts +++ b/app/lib/services/voip/acceptNativeCall.test.ts @@ -9,7 +9,7 @@ const mockWaitForLoginReady = waitForLoginReady as jest.MockedFunction; const mockGetState = useCallStore.getState as jest.Mock; const mockTerminateNativeCall = terminateNativeCall as jest.Mock; -const mockDdp = () => sdk.current?.ddp as any; +const mockDriver = () => sdk.current?.driver as any; jest.mock('./useCallStore', () => ({ useCallStore: { @@ -24,7 +24,7 @@ jest.mock('./terminateNativeCall', () => ({ jest.mock('../sdk', () => ({ __esModule: true, default: { - current: { ddp: {} } + current: { driver: {} } } })); @@ -59,7 +59,7 @@ function makeMediaSession(overrides: Partial = {}): IMediaSession }; } -function makeDdp(overrides: Record = {}) { +function makeDriver(overrides: Record = {}) { return { waitForNotifyUserMediaSubs: jest.fn(() => Promise.resolve(true)), ...overrides @@ -80,7 +80,7 @@ describe('acceptNativeCallWithReadiness', () => { beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); - (sdk as any).current = { ddp: makeDdp() }; + (sdk as any).current = { driver: makeDriver() }; mockRecoverSocket.mockResolvedValue('confirmed-alive'); mockWaitForLoginReady.mockResolvedValue(true); mockGetState.mockReturnValue(makeStoreState()); @@ -163,7 +163,7 @@ describe('acceptNativeCallWithReadiness', () => { }); it('terminates and ends the call when media-subscription ack times out', async () => { - mockDdp().waitForNotifyUserMediaSubs = jest.fn(() => Promise.resolve(false)); + mockDriver().waitForNotifyUserMediaSubs = jest.fn(() => Promise.resolve(false)); const mediaSession = makeMediaSession(); const resetNativeCallId = jest.fn(); mockGetState.mockReturnValue(makeStoreState({ resetNativeCallId })); diff --git a/app/lib/services/voip/acceptNativeCall.ts b/app/lib/services/voip/acceptNativeCall.ts index e51f9a99d77..aa3a02242cf 100644 --- a/app/lib/services/voip/acceptNativeCall.ts +++ b/app/lib/services/voip/acceptNativeCall.ts @@ -15,7 +15,7 @@ export interface NativeCallMediaSession { const activeGates = new Map(); -async function waitForMediaSignalSubs(ddp: TDriver, timeoutMs: number, abortSignal?: AbortSignal): Promise { +async function waitForMediaSignalSubs(driver: TDriver, timeoutMs: number, abortSignal?: AbortSignal): Promise { if (abortSignal?.aborted) { return false; } @@ -25,7 +25,7 @@ async function waitForMediaSignalSubs(ddp: TDriver, timeoutMs: number, abortSign }); try { - return await Promise.race([ddp.waitForNotifyUserMediaSubs(timeoutMs), aborted]); + return await Promise.race([driver.waitForNotifyUserMediaSubs(timeoutMs), aborted]); } catch (error) { log(error); return false; @@ -65,14 +65,14 @@ export async function acceptNativeCallWithReadiness(callId: string, mediaSession return; } - const ddp = sdk.current?.ddp; - if (!ddp) { + const driver = sdk.current?.driver; + if (!driver) { return handleFailure(callId, mediaSession); } const [loginReady, mediaSubsReady] = await Promise.all([ waitForLoginReady(8000, controller.signal), - waitForMediaSignalSubs(ddp, 8000, controller.signal) + waitForMediaSignalSubs(driver, 8000, controller.signal) ]); if (controller.signal.aborted) { diff --git a/app/lib/services/waitForLoginReady.ts b/app/lib/services/waitForLoginReady.ts index 06e6ce494f7..1559d7d6f7c 100644 --- a/app/lib/services/waitForLoginReady.ts +++ b/app/lib/services/waitForLoginReady.ts @@ -1,7 +1,7 @@ import { onAbort } from '../methods/helpers/onAbort'; import { store } from '../store/auxStore'; -// Reads redux rather than `ddp.loggedIn`: `close` clears `meteor.connected`, while `ddp.loggedIn` survives it. +// Reads redux rather than `socket.loggedIn`: `close` clears `meteor.connected`, while `socket.loggedIn` survives it. // Neither survives a silent background death, so callers must bound their wait. export function isLoginReady(): boolean { const state = store.getState(); diff --git a/app/lib/testUtils/sdkIntegration.ts b/app/lib/testUtils/sdkIntegration.ts new file mode 100644 index 00000000000..c8dfc58d4f5 --- /dev/null +++ b/app/lib/testUtils/sdkIntegration.ts @@ -0,0 +1,167 @@ +import type { Store } from 'redux'; + +import type { IApplicationState } from '../../definitions'; + +export interface IDdpMessage { + msg: string; + id?: string; + name?: string; + method?: string; + params?: unknown[]; +} + +export class MockConnection { + send = jest.fn((frame: string) => { + const message = JSON.parse(frame) as IDdpMessage; + if (message.msg === 'connect') { + setImmediate(() => this.onmessage({ data: JSON.stringify({ msg: 'connected', session: 'session-id' }) })); + } else if (message.msg === 'ping') { + setImmediate(() => this.onmessage({ data: JSON.stringify({ msg: 'pong' }) })); + } else if (message.msg === 'sub') { + setImmediate(() => this.onmessage({ data: JSON.stringify({ msg: 'ready', subs: [message.id] }) })); + } else if (message.msg === 'unsub') { + setImmediate(() => this.onmessage({ data: JSON.stringify({ msg: 'nosub', id: message.id }) })); + } else if (message.msg === 'method' && message.method === 'login') { + setImmediate(() => + this.onmessage({ + data: JSON.stringify({ msg: 'result', id: message.id, result: { id: 'user-id', token: 'auth-token' } }) + }) + ); + } + }); + + close = jest.fn(); + readyState = 1; + onopen = () => {}; + onmessage = (_event: { data: string }) => {}; + onerror = () => {}; + onclose = (_event?: { code?: number }) => {}; + + constructor(registry: MockConnection[]) { + registry.push(this); + } +} + +export interface ISdkDriver { + userId: string; + pingInterval: number; + reopenNow(): Promise; + waitForNotifyUserMediaSubs?(timeoutMs?: number): Promise; + socket: { + lastPing: number; + pingTimeout?: ReturnType; + openTimeout?: ReturnType; + open(): Promise; + send(message: Record): Promise; + subscriptions: Record; + }; +} + +export function framesOn(connection: MockConnection, msg: string): IDdpMessage[] { + return connection.send.mock.calls + .map(([frame]: [string]) => JSON.parse(frame) as IDdpMessage) + .filter(message => message.msg === msg); +} + +export function receiveFrame(connection: MockConnection, frame: Record): void { + connection.onmessage({ data: JSON.stringify(frame) }); +} + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { Driver } = require('@rocket.chat/sdk/lib/drivers/driver') as { + Driver: new (options: { host: string; logger: unknown }) => ISdkDriver; +}; + +const driverLogger = { debug: jest.fn(), info: jest.fn(), error: jest.fn(), warn: jest.fn() }; + +export async function buildConnectedDriver(connections: MockConnection[], userId: string): Promise { + const driver = new Driver({ host: 'localhost:3000', logger: driverLogger }); + driver.userId = userId; + const openPromise = driver.socket.open(); + connections[0].onopen(); + await jest.advanceTimersByTimeAsync(0); + await openPromise; + return driver; +} + +export function addMediaSubs(driver: ISdkDriver, userId: string): void { + ['media-signal', 'media-calls'].forEach((name, index) => { + const id = `sub-${index}`; + driver.socket.subscriptions[id] = { + id, + name: 'stream-notify-user', + params: [`${userId}/${name}`], + unsubscribe: jest.fn() + }; + }); +} + +export function backdateLastPing(driver: ISdkDriver, ageMs: number): void { + driver.socket.lastPing = Date.now() - ageMs; +} + +export function stopAnsweringFrames(connection: MockConnection): void { + connection.send.mockImplementation(() => undefined); +} + +export interface IMockCollection { + name: string; + find: jest.Mock; + query: jest.Mock; + create: jest.Mock; + prepareCreate: jest.Mock; + schema: Record; +} + +export function makeCollection(name: string): IMockCollection { + return { + name, + find: jest.fn(), + query: jest.fn(() => ({ fetch: jest.fn(() => Promise.resolve([])) })), + create: jest.fn(), + prepareCreate: jest.fn(), + schema: {} + }; +} + +export async function flush(turns = 10): Promise { + for (let i = 0; i < turns; i++) { + await Promise.resolve(); + await jest.advanceTimersByTimeAsync(0); + } +} + +export interface IMockReduxState { + meteor: { connected: boolean }; + login: { user: Record | null; isAuthenticated: boolean }; + server: { version: string }; + settings: Record; + room: { subscribedRoom: string | null }; +} + +export interface IMockReduxStore { + state: IMockReduxState; + store: Store & { dispatch: jest.Mock }; +} + +export function makeReduxStore(): IMockReduxStore { + const listeners = new Set<() => void>(); + const state: IMockReduxState = { + meteor: { connected: false }, + login: { user: null, isAuthenticated: false }, + server: { version: '5.0.0' }, + settings: {}, + room: { subscribedRoom: null } + }; + return { + state, + store: { + getState: () => state, + dispatch: jest.fn(), + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + } + } as unknown as Store & { dispatch: jest.Mock } + }; +} diff --git a/app/views/AuthenticationWebView.tsx b/app/views/AuthenticationWebView.tsx index 0353eb158fd..e90ca4220d6 100644 --- a/app/views/AuthenticationWebView.tsx +++ b/app/views/AuthenticationWebView.tsx @@ -7,7 +7,7 @@ import parse from 'url-parse'; import ActivityIndicator from '../containers/ActivityIndicator'; import * as HeaderButton from '../containers/Header/components/HeaderButton'; -import { type ICredentials } from '../definitions'; +import { type ILoginCredentials } from '../definitions'; import { userAgent } from '../lib/constants/userAgent'; import { useAppSelector } from '../lib/hooks/useAppSelector'; import { useDebounce } from '../lib/methods/helpers'; @@ -70,9 +70,9 @@ const AuthenticationWebView = ({ route }: AuthenticationWebViewProps) => { const iframeRedirectRegex = new RegExp(`(?=.*(${server}))(?=.*(event|loginToken|token))`, 'g'); // Force 3s delay so the server has time to evaluate the token - const debouncedLogin = useDebounce((params: ICredentials) => login(params), 3000); + const debouncedLogin = useDebounce((params: ILoginCredentials) => login(params), 3000); - const login = async (params: ICredentials) => { + const login = async (params: ILoginCredentials) => { if (loggingRef.current) { return; } diff --git a/app/views/ForwardLivechatView.tsx b/app/views/ForwardLivechatView.tsx index 23befc85275..57c28a3de48 100644 --- a/app/views/ForwardLivechatView.tsx +++ b/app/views/ForwardLivechatView.tsx @@ -65,7 +65,7 @@ const ForwardLivechatView = (): ReactElement => { term }); if (result.success) { - const parsedUsers = result.items.map(user => ({ label: user.username, value: user._id })); + const parsedUsers = result.items.flatMap(user => (user.username ? [{ label: user.username, value: user._id }] : [])); if (!term) { setUsers(parsedUsers); } diff --git a/app/views/ProfileView/methods/buildProfileParams.ts b/app/views/ProfileView/methods/buildProfileParams.ts index 8adfa561568..4fa03121447 100644 --- a/app/views/ProfileView/methods/buildProfileParams.ts +++ b/app/views/ProfileView/methods/buildProfileParams.ts @@ -4,7 +4,7 @@ import { type IProfileParams, type IUser } from '../../../definitions'; interface IProfileFormValues { name: string; - username: string; + username?: string; email: string | null; currentPassword: string | null; bio?: string; diff --git a/app/views/RoomMembersView/helpers.ts b/app/views/RoomMembersView/helpers.ts index 80bd68e6bd3..662bd4a796e 100644 --- a/app/views/RoomMembersView/helpers.ts +++ b/app/views/RoomMembersView/helpers.ts @@ -49,7 +49,7 @@ export const fetchRoomMembersRoles = async (roomType: TRoomType, rid: string, up export const handleMute = async (user: TUserModel, rid: string) => { try { - await toggleMuteUserInRoom(rid, user?.username, user?._id, !user.muted); + await toggleMuteUserInRoom(rid, user.username, user._id, !user.muted); EventEmitter.emit(LISTENER, { message: I18n.t('User_has_been_key', { key: user?.muted ? I18n.t('unmuted') : I18n.t('muted') }) }); @@ -88,6 +88,9 @@ export const handleModerator = async ( }; export const navToDirectMessage = async (item: IUser, isMasterDetail: boolean): Promise => { + if (!item.username) { + return; + } try { const db = database.active; const subsCollection = db.get('subscriptions'); diff --git a/app/views/RoomMembersView/index.tsx b/app/views/RoomMembersView/index.tsx index f448ed61cc7..695639f1298 100644 --- a/app/views/RoomMembersView/index.tsx +++ b/app/views/RoomMembersView/index.tsx @@ -282,7 +282,11 @@ const RoomMembersView = (): ReactElement => { }); }; - const getUserDisplayName = (user: TUserModel) => (useRealName ? user.name : user.username) || user.username; + const getUserDisplayName = (user: TUserModel) => { + const preferred = useRealName ? user.name : user.username; + const fallback = useRealName ? user.username : user.name; + return preferred || fallback || user._id; + }; const onPressUser = (selectedUser: TUserModel) => { const { room, roomRoles, members } = state; diff --git a/app/views/SelectedUsersView/index.tsx b/app/views/SelectedUsersView/index.tsx index 9ffcedcf323..d46cb675167 100644 --- a/app/views/SelectedUsersView/index.tsx +++ b/app/views/SelectedUsersView/index.tsx @@ -94,7 +94,7 @@ const SelectedUsersView = () => { }, [navigation, users.length, maxUsers, buttonText, nextAction]); useEffect(() => { - if (isGroupChat()) { + if (isGroupChat() && user.username) { dispatch(addUser({ _id: user.id, name: user.username, fname: user.name as string })); } }, []); diff --git a/app/views/ShareView/index.tsx b/app/views/ShareView/index.tsx index 3f8ef84aae8..c5822d5f8f1 100644 --- a/app/views/ShareView/index.tsx +++ b/app/views/ShareView/index.tsx @@ -57,11 +57,7 @@ interface IShareViewProps { navigation: NativeStackNavigationProp; route: RouteProp; theme: TSupportedThemes; - user: { - id: string; - username: string; - token: string; - }; + user: IUser; server: string; serverVersion?: string; FileUpload_MediaTypeWhiteList?: string; diff --git a/package.json b/package.json index be7aec19005..9bb0ae07d97 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "@rocket.chat/media-signaling": "1.0.0-rc.1", "@rocket.chat/message-parser": "0.31.36", "@rocket.chat/mobile-crypto": "RocketChat/rocket.chat-mobile-crypto#main", - "@rocket.chat/sdk": "RocketChat/Rocket.Chat.js.SDK#383e457b3bb31598daacf2572d20644c795f58d2", + "@rocket.chat/sdk": "RocketChat/Rocket.Chat.js.SDK#176bdfe4b5cd2f47370266572cbcb94a5eee7322", "@rocket.chat/ui-kit": "^0.39.0", "@zoontek/react-native-navigation-bar": "^1.1.1", "axios": "0.30.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d596939aa31..3eae3f8f453 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -91,8 +91,8 @@ importers: specifier: RocketChat/rocket.chat-mobile-crypto#main version: https://codeload.github.com/RocketChat/rocket.chat-mobile-crypto/tar.gz/69a0a250dd7c6ff0808eb659d7202be1cae7fa1c(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) '@rocket.chat/sdk': - specifier: RocketChat/Rocket.Chat.js.SDK#383e457b3bb31598daacf2572d20644c795f58d2 - version: https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/383e457b3bb31598daacf2572d20644c795f58d2 + specifier: RocketChat/Rocket.Chat.js.SDK#176bdfe4b5cd2f47370266572cbcb94a5eee7322 + version: https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/176bdfe4b5cd2f47370266572cbcb94a5eee7322 '@rocket.chat/ui-kit': specifier: ^0.39.0 version: 0.39.0(@rocket.chat/icons@0.47.0)(@types/node@25.0.3)(typescript@7.0.2) @@ -2633,8 +2633,8 @@ packages: react: '*' react-native: '*' - '@rocket.chat/sdk@https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/383e457b3bb31598daacf2572d20644c795f58d2': - resolution: {gitHosted: true, integrity: sha512-in4lCtRYlY6PcCN3JfEtO3zVqyVjdic9BXxScWiD2y/BwwBqijgRFKckYm44v9GOwMllav92e9Dsfz8GyuBfhw==, tarball: https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/383e457b3bb31598daacf2572d20644c795f58d2} + '@rocket.chat/sdk@https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/176bdfe4b5cd2f47370266572cbcb94a5eee7322': + resolution: {gitHosted: true, integrity: sha512-SAkGojmE6QbNMVNqz6Sgq2QDwcZ0S3kMLyJDNrCbTvcE18PJ9INoanXztzek65J9cZuq5Z2faeENVft8wlqZ+A==, tarball: https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/176bdfe4b5cd2f47370266572cbcb94a5eee7322} version: 1.3.3-mobile '@rocket.chat/ui-kit@0.39.0': @@ -10517,7 +10517,7 @@ snapshots: react: 19.1.0 react-native: 0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0) - '@rocket.chat/sdk@https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/383e457b3bb31598daacf2572d20644c795f58d2': + '@rocket.chat/sdk@https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/176bdfe4b5cd2f47370266572cbcb94a5eee7322': dependencies: js-sha256: 0.9.0 tiny-events: 1.0.1