diff --git a/CLAUDE.md b/CLAUDE.md index be62489363e..9af5b2b0b40 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,10 +40,6 @@ pnpm storybook:start # Start Metro with Storybook UI pnpm storybook-generate # Generate story snapshots ``` -## Worktree contract - -When using worktrees (`wt`), the post-start hook runs `pnpm install` only. `wt step copy-ignored` is opt-in and only used when the worktree will perform a native build (iOS/Android/Pods/Gradle/Bundler caches). - ## Code Style - **Prettier**: tabs, single quotes, 130 char width, no trailing commas, arrow parens avoid, bracket same line diff --git a/README.md b/README.md index 681b064bdaf..ed7fb227d16 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Also check the [#react-native](https://open.rocket.chat/channel/react-native) co Are you a dev and would like to help? Found a bug that you would like to report or a missing feature that you would like to work on? Great! We have written down a [Contribution guide](https://github.com/RocketChat/Rocket.Chat.ReactNative/blob/develop/CONTRIBUTING.md) so you can start easily. ## Whitelabel -Do you want to make the app run on your own server only? [Follow our whitelabel documentation.](https://developer.rocket.chat/mobile-app/mobile-app-white-labelling) +Do you want to make the app run on your own server only? [Follow our whitelabel documentation.](https://developer.rocket.chat/mobile-app-white-labelling) ## Engage with us ### Share your story diff --git a/android/app/build.gradle b/android/app/build.gradle index 7e6f259f09c..ca8eb025fd0 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -90,7 +90,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode VERSIONCODE as Integer - versionName "4.73.0" + versionName "4.73.1" vectorDrawables.useSupportLibrary = true manifestPlaceholders = [BugsnagAPIKey: BugsnagAPIKey as String] resValue "string", "rn_config_reader_custom_package", "chat.rocket.reactnative" diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/NotificationIntentHandler.kt b/android/app/src/main/java/chat/rocket/reactnative/notification/NotificationIntentHandler.kt index b1a2e427d66..1b0da552c38 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/notification/NotificationIntentHandler.kt +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/NotificationIntentHandler.kt @@ -170,3 +170,4 @@ class NotificationIntentHandler { } } } + diff --git a/app/actions/actionsTypes.ts b/app/actions/actionsTypes.ts index 938ff7b9d5d..ec43ae0f1e7 100644 --- a/app/actions/actionsTypes.ts +++ b/app/actions/actionsTypes.ts @@ -22,7 +22,9 @@ export const ROOM = createRequestTypes('ROOM', [ 'FORWARD', 'USER_TYPING', 'HISTORY_REQUEST', - 'HISTORY_FINISHED' + 'HISTORY_FINISHED', + 'HISTORY_UI_LOADER_PUSH', + 'HISTORY_UI_LOADER_POP' ]); export const INQUIRY = createRequestTypes('INQUIRY', [ ...defaultTypes, diff --git a/app/actions/room.ts b/app/actions/room.ts index a4811411344..57f88675fa2 100644 --- a/app/actions/room.ts +++ b/app/actions/room.ts @@ -59,6 +59,14 @@ export interface IRoomHistoryFinished extends Action { loaderId: string; } +export interface IRoomHistoryUiLoaderPush extends Action { + loaderId: string; +} + +export interface IRoomHistoryUiLoaderPop extends Action { + loaderId: string; +} + export type TActionsRoom = TSubscribeRoom & TUnsubscribeRoom & ILeaveRoom & @@ -66,7 +74,9 @@ export type TActionsRoom = TSubscribeRoom & IForwardRoom & IUserTyping & IRoomHistoryRequest & - IRoomHistoryFinished; + IRoomHistoryFinished & + IRoomHistoryUiLoaderPush & + IRoomHistoryUiLoaderPop; export function subscribeRoom(rid: string): TSubscribeRoom { return { @@ -138,3 +148,17 @@ export function roomHistoryFinished({ loaderId }: { loaderId: string }): IRoomHi loaderId }; } + +export function roomHistoryUiLoaderPush({ loaderId }: { loaderId: string }): IRoomHistoryUiLoaderPush { + return { + type: ROOM.HISTORY_UI_LOADER_PUSH, + loaderId + }; +} + +export function roomHistoryUiLoaderPop({ loaderId }: { loaderId: string }): IRoomHistoryUiLoaderPop { + return { + type: ROOM.HISTORY_UI_LOADER_POP, + loaderId + }; +} diff --git a/app/containers/markdown/index.tsx b/app/containers/markdown/index.tsx index c641b3f4cf8..6a9d45d2b90 100644 --- a/app/containers/markdown/index.tsx +++ b/app/containers/markdown/index.tsx @@ -18,6 +18,7 @@ import Paragraph from './components/Paragraph'; import { Code } from './components/code'; import Heading from './components/Heading'; import log from '../../lib/methods/helpers/log'; +import styles from './styles'; export { default as MarkdownPreview } from './components/Preview'; @@ -35,6 +36,71 @@ interface IMarkdownProps { textStyle?: StyleProp; } +type MarkdownBlock = Root[number]; + +const PARSE_CACHE_MAX = 200; +const parseCache = new Map(); + +const parseMessage = (msg: string): Root => { + const cached = parseCache.get(msg); + if (cached) { + return cached; + } + + const result = parse(msg); + + if (parseCache.size >= PARSE_CACHE_MAX) { + const oldestKey = parseCache.keys().next().value; + if (oldestKey !== undefined) { + parseCache.delete(oldestKey); + } + } + + parseCache.set(msg, result); + return result; +}; + +const resolveTokens = (msg: string, md: Root | undefined, isTranslated?: boolean): Root => { + if (!isTranslated && md) { + return md; + } + + return parseMessage(typeof msg === 'string' ? msg : String(msg || '')); +}; + +const MarkdownBlockView = ({ block }: { block: MarkdownBlock }) => { + 'use memo'; + + switch (block.type) { + case 'BIG_EMOJI': + return ; + case 'UNORDERED_LIST': + return ; + case 'ORDERED_LIST': + return ; + case 'TASKS': + return ; + case 'QUOTE': + return ; + case 'PARAGRAPH': + return ; + case 'CODE': + return ; + case 'HEADING': + return ; + case 'LINE_BREAK': + return ; + // This prop exists, but not even on the web it is treated, so... + // https://github.com/RocketChat/Rocket.Chat/blob/develop/packages/gazzodown/src/Markup.tsx + // case 'LIST_ITEM': + // return ; + case 'KATEX': + return ; + default: + return null; + } +}; + const Markdown: React.FC = ({ msg, md, @@ -48,60 +114,40 @@ const Markdown: React.FC = ({ isTranslated, textStyle }: IMarkdownProps) => { - if (!msg) return null; + 'use memo'; + + let tokens: Root | null = null; + + if (msg) { + try { + const result = resolveTokens(msg, md, isTranslated); + tokens = isEmpty(result) ? null : result; + } catch (e) { + log(e); + } + } + + const contextValue = { + mentions, + channels, + useRealName, + username, + navToRoomInfo, + getCustomEmoji, + onLinkPress, + textStyle + }; - let tokens; - try { - tokens = !isTranslated && md ? md : parse(typeof msg === 'string' ? msg : String(msg || '')); - } catch (e) { - log(e); + if (!tokens) { return null; } - if (isEmpty(tokens)) return null; return ( - - - {tokens?.map(block => { - switch (block.type) { - case 'BIG_EMOJI': - return ; - case 'UNORDERED_LIST': - return ; - case 'ORDERED_LIST': - return ; - case 'TASKS': - return ; - case 'QUOTE': - return ; - case 'PARAGRAPH': - return ; - case 'CODE': - return ; - case 'HEADING': - return ; - case 'LINE_BREAK': - return ; - // This prop exists, but not even on the web it is treated, so... - // https://github.com/RocketChat/Rocket.Chat/blob/develop/packages/gazzodown/src/Markup.tsx - // case 'LIST_ITEM': - // return ; - case 'KATEX': - return ; - default: - return null; - } - })} + + + {tokens.map((block, index) => ( + + ))} ); diff --git a/app/containers/markdown/styles.ts b/app/containers/markdown/styles.ts index 72b5300e444..761d27a505f 100644 --- a/app/containers/markdown/styles.ts +++ b/app/containers/markdown/styles.ts @@ -8,6 +8,9 @@ const codeFontFamily = Platform.select({ }); export default StyleSheet.create({ + blocks: { + gap: 2 + }, container: { alignItems: 'flex-start', flexDirection: 'row' diff --git a/app/lib/hooks/useVideoConf/index.tsx b/app/lib/hooks/useVideoConf/index.tsx index d70f0650d04..e3e1c9b8b8c 100644 --- a/app/lib/hooks/useVideoConf/index.tsx +++ b/app/lib/hooks/useVideoConf/index.tsx @@ -1,4 +1,4 @@ -import { useCameraPermissions } from 'expo-camera'; +import { Camera } from 'expo-camera'; import React, { useMemo } from 'react'; import { useActionSheet } from '../../../containers/ActionSheet'; @@ -33,7 +33,6 @@ export const useVideoConf = ( const serverVersion = useAppSelector(state => state.server.version); const { callEnabled, disabledTooltip, roomType } = useVideoConfCall(rid); - const [permission, requestPermission] = useCameraPermissions(); const { showActionSheet } = useActionSheet(); const isServer5OrNewer = useMemo(() => compareServerVersion(serverVersion, 'greaterThanOrEqualTo', '5.0.0'), [serverVersion]); @@ -66,9 +65,10 @@ export const useVideoConf = ( fullContainer: true }); + const permission = await Camera.getCameraPermissionsAsync(); if (!permission?.granted) { try { - await requestPermission(); + await Camera.requestCameraPermissionsAsync(); handleAndroidBltPermission(); } catch (error) { log(error); diff --git a/app/lib/hooks/useVideoConf/useVideoConfCall.ts b/app/lib/hooks/useVideoConf/useVideoConfCall.ts index 46808004094..e792b33354e 100644 --- a/app/lib/hooks/useVideoConf/useVideoConfCall.ts +++ b/app/lib/hooks/useVideoConf/useVideoConfCall.ts @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import { shallowEqual } from 'react-redux'; import { SubscriptionType } from '../../../definitions'; import { getUserSelector } from '../../../selectors/login'; @@ -8,7 +9,6 @@ import { compareServerVersion } from '../../methods/helpers/compareServerVersion import { isReadOnly } from '../../methods/helpers/isReadOnly'; import { useAppSelector } from '../useAppSelector'; import { usePermissions } from '../usePermissions'; -import { useSetting } from '../useSetting'; export const useVideoConfCall = ( rid: string @@ -17,18 +17,31 @@ export const useVideoConfCall = ( const [disabledTooltip, setDisabledTooltip] = useState(false); const [roomType, setRoomType] = useState(); + // Read all call-related settings in a single subscription instead of one useSetting per key. + const settings = useAppSelector( + state => ({ + jitsiEnabled: state.settings.Jitsi_Enabled, + jitsiEnableTeams: state.settings.Jitsi_Enable_Teams, + jitsiEnableChannels: state.settings.Jitsi_Enable_Channels, + videoConfEnableDMs: state.settings.VideoConf_Enable_DMs, + videoConfEnableChannels: state.settings.VideoConf_Enable_Channels, + videoConfEnableTeams: state.settings.VideoConf_Enable_Teams, + videoConfEnableGroups: state.settings.VideoConf_Enable_Groups, + omnichannelCallProvider: state.settings.Omnichannel_call_provider + }), + shallowEqual + ); + // OLD SETTINGS - const jitsiEnabled = useSetting('Jitsi_Enabled'); - const jitsiEnableTeams = useSetting('Jitsi_Enable_Teams'); - const jitsiEnableChannels = useSetting('Jitsi_Enable_Channels'); + const { jitsiEnabled, jitsiEnableTeams, jitsiEnableChannels } = settings; // NEW SETTINGS // Only disable video conf if the settings are explicitly FALSE - any falsy value counts as true - const enabledDMs = useSetting('VideoConf_Enable_DMs') !== false; - const enabledChannel = useSetting('VideoConf_Enable_Channels') !== false; - const enabledTeams = useSetting('VideoConf_Enable_Teams') !== false; - const enabledGroups = useSetting('VideoConf_Enable_Groups') !== false; - const enabledLiveChat = useSetting('Omnichannel_call_provider') === 'default-provider'; + const enabledDMs = settings.videoConfEnableDMs !== false; + const enabledChannel = settings.videoConfEnableChannels !== false; + const enabledTeams = settings.videoConfEnableTeams !== false; + const enabledGroups = settings.videoConfEnableGroups !== false; + const enabledLiveChat = settings.omnichannelCallProvider === 'default-provider'; const serverVersion = useAppSelector(state => state.server.version); const isServer5OrNewer = compareServerVersion(serverVersion, 'greaterThanOrEqualTo', '5.0.0'); diff --git a/app/lib/methods/helpers/announceSearchResultsForAccessibility.ts b/app/lib/methods/helpers/announceSearchResultsForAccessibility.ts new file mode 100644 index 00000000000..7968561e0c7 --- /dev/null +++ b/app/lib/methods/helpers/announceSearchResultsForAccessibility.ts @@ -0,0 +1,13 @@ +import { AccessibilityInfo } from 'react-native'; + +import I18n from '../../../i18n'; + +export const announceSearchResultsForAccessibility = (count: number): void => { + if (count < 1) { + AccessibilityInfo.announceForAccessibility(I18n.t('No_results_found')); + return; + } + + const message = count === 1 ? I18n.t('One_result_found') : I18n.t('Search_Results_found', { count: count.toString() }); + AccessibilityInfo.announceForAccessibility(message); +}; diff --git a/app/lib/methods/helpers/index.ts b/app/lib/methods/helpers/index.ts index c77650e294c..60a9546a068 100644 --- a/app/lib/methods/helpers/index.ts +++ b/app/lib/methods/helpers/index.ts @@ -18,3 +18,4 @@ export * from './image'; export * from './emitter'; export * from './parseJson'; export * from './fileDownload'; +export * from './announceSearchResultsForAccessibility'; diff --git a/app/lib/methods/helpers/normalizeDeepLinkingServerHost.ts b/app/lib/methods/helpers/normalizeDeepLinkingServerHost.ts index 6614c205c6f..35719781c9f 100644 --- a/app/lib/methods/helpers/normalizeDeepLinkingServerHost.ts +++ b/app/lib/methods/helpers/normalizeDeepLinkingServerHost.ts @@ -13,7 +13,7 @@ export function normalizeDeepLinkingServerHost(rawHost: string): string { } else { host = `https://${host}`; } - } else { + } else if (!/^http:\/\/localhost(:\d+)?/.test(host)) { host = host.replace('http://', 'https://'); } if (host.slice(-1) === '/') { diff --git a/app/lib/methods/loadMessagesForRoom.test.ts b/app/lib/methods/loadMessagesForRoom.test.ts new file mode 100644 index 00000000000..2b8cd7e7882 --- /dev/null +++ b/app/lib/methods/loadMessagesForRoom.test.ts @@ -0,0 +1,235 @@ +import { loadMessagesForRoom } from './loadMessagesForRoom'; +import sdk from '../services/sdk'; +import { ROOM } from '../../actions/actionsTypes'; +import { getMessageById } from '../database/services/Message'; +import { getSubscriptionByRoomId } from '../database/services/Subscription'; +import updateMessages from './updateMessages'; +import { store } from '../store/auxStore'; + +jest.mock('../services/sdk', () => ({ + __esModule: true, + default: { + get: jest.fn() + } +})); + +jest.mock('../database/services/Message', () => ({ + getMessageById: jest.fn() +})); + +jest.mock('../database/services/Subscription', () => ({ + getSubscriptionByRoomId: jest.fn(() => Promise.resolve(null)) +})); + +jest.mock('../store/auxStore', () => ({ + store: { + getState: jest.fn(() => ({ + settings: { Hide_System_Messages: ['uj'] } + })), + dispatch: jest.fn() + } +})); + +jest.mock('./updateMessages', () => jest.fn()); + +const mockedSdkGet = sdk.get as jest.MockedFunction; +const mockedGetMessageById = getMessageById as jest.MockedFunction; +const mockedUpdateMessages = updateMessages as jest.MockedFunction; +const mockedDispatch = store.dispatch as jest.MockedFunction; +const mockedGetSubscriptionByRoomId = getSubscriptionByRoomId as jest.MockedFunction; + +const buildMessage = ({ id, ts, t }: { id: string; ts: string; t?: string }) => + ({ + _id: id, + rid: 'ROOM_ID', + ts, + ...(t ? { t } : {}) + } as any); + +describe('loadMessagesForRoom', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockedGetMessageById.mockResolvedValue(null); + mockedUpdateMessages.mockResolvedValue(0); + mockedGetSubscriptionByRoomId.mockResolvedValue(null as any); + }); + + const buildHiddenBatch = (prefix: string, baseSeconds: number) => + Array.from({ length: 50 }, (_, index) => + buildMessage({ + id: `${prefix}-${index + 1}`, + ts: new Date(Date.UTC(2024, 0, 1, 0, 0, baseSeconds - index)).toISOString(), + t: 'uj' + }) + ); + + it('fetches additional history batches until it fills the visible page when hidden system messages consume the first batch', async () => { + const firstBatch = Array.from({ length: 50 }, (_, index) => + buildMessage({ + id: `first-${index + 1}`, + ts: new Date(Date.UTC(2024, 0, 1, 0, 0, 50 - index)).toISOString(), + t: index < 49 ? 'uj' : undefined + }) + ); + const secondBatch = Array.from({ length: 50 }, (_, index) => + buildMessage({ + id: `second-${index + 1}`, + ts: new Date(Date.UTC(2023, 11, 31, 23, 59, 50 - index)).toISOString(), + t: index === 49 ? 'uj' : undefined + }) + ); + + mockedSdkGet + .mockResolvedValueOnce({ success: true, messages: firstBatch } as any) + .mockResolvedValueOnce({ success: true, messages: secondBatch } as any); + + await loadMessagesForRoom({ + rid: 'ROOM_ID', + t: 'c' + }); + + expect(mockedSdkGet).toHaveBeenCalledTimes(2); + expect(mockedSdkGet).toHaveBeenNthCalledWith( + 2, + 'channels.history', + expect.objectContaining({ + roomId: 'ROOM_ID', + latest: firstBatch[firstBatch.length - 1].ts + }) + ); + + expect(mockedUpdateMessages).toHaveBeenCalledTimes(2); + expect(mockedUpdateMessages).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + rid: 'ROOM_ID', + update: expect.arrayContaining([ + expect.objectContaining({ _id: 'first-50' }), + expect.objectContaining({ _id: 'load-more-first-50', t: 'load_more' }) + ]) + }) + ); + expect(mockedUpdateMessages).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + rid: 'ROOM_ID', + update: expect.arrayContaining([ + expect.objectContaining({ _id: 'first-50' }), + expect.objectContaining({ _id: 'second-49' }), + expect.objectContaining({ _id: 'load-more-second-50', t: 'load_more' }) + ]) + }) + ); + + expect(mockedDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: ROOM.HISTORY_UI_LOADER_PUSH, + loaderId: 'load-more-first-50' + }) + ); + expect(mockedDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: ROOM.HISTORY_UI_LOADER_POP, + loaderId: 'load-more-first-50' + }) + ); + }); + + it('stops fetching after MAX_BATCHES even when the visible page is still unfilled', async () => { + // Every batch is fully hidden, so visibleMainMessagesCount never reaches COUNT + mockedSdkGet.mockResolvedValue({ success: true, messages: buildHiddenBatch('batch', 50) } as any); + + await loadMessagesForRoom({ rid: 'ROOM_ID', t: 'c' }); + + // MAX_BATCHES = 10 + expect(mockedSdkGet).toHaveBeenCalledTimes(10); + }); + + it('does not append a trailing load-more when the last batch was not full', async () => { + const partialBatch = Array.from({ length: 30 }, (_, index) => + buildMessage({ + id: `partial-${index + 1}`, + ts: new Date(Date.UTC(2024, 0, 1, 0, 0, 30 - index)).toISOString() + }) + ); + + mockedSdkGet.mockResolvedValueOnce({ success: true, messages: partialBatch } as any); + + await loadMessagesForRoom({ rid: 'ROOM_ID', t: 'c' }); + + expect(mockedSdkGet).toHaveBeenCalledTimes(1); + expect(mockedUpdateMessages).toHaveBeenCalledTimes(1); + const finalUpdate = mockedUpdateMessages.mock.calls[0][0] as { update: { _id: string; t?: string }[] }; + expect(finalUpdate.update).toHaveLength(30); + expect(finalUpdate.update.find(m => m.t === 'load_more')).toBeUndefined(); + expect(mockedDispatch).not.toHaveBeenCalledWith(expect.objectContaining({ type: ROOM.HISTORY_UI_LOADER_PUSH })); + }); + + it('pops the ui loader when a recursive batch fetch fails after the loader was pushed', async () => { + const firstBatch = buildHiddenBatch('first', 50); + const networkError = new Error('boom'); + + mockedSdkGet.mockResolvedValueOnce({ success: true, messages: firstBatch } as any).mockRejectedValueOnce(networkError); + + await expect(loadMessagesForRoom({ rid: 'ROOM_ID', t: 'c' })).rejects.toBe(networkError); + + expect(mockedDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: ROOM.HISTORY_UI_LOADER_PUSH, + loaderId: 'load-more-first-50' + }) + ); + expect(mockedDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: ROOM.HISTORY_UI_LOADER_POP, + loaderId: 'load-more-first-50' + }) + ); + }); + + it('falls back to settings.Hide_System_Messages when sub.sysMes is a boolean (not an array)', async () => { + mockedGetSubscriptionByRoomId.mockResolvedValue({ sysMes: true } as any); + + const firstBatch = buildHiddenBatch('first', 50); + const secondBatch = Array.from({ length: 50 }, (_, index) => + buildMessage({ + id: `second-${index + 1}`, + ts: new Date(Date.UTC(2023, 11, 31, 23, 59, 50 - index)).toISOString() + }) + ); + + mockedSdkGet + .mockResolvedValueOnce({ success: true, messages: firstBatch } as any) + .mockResolvedValueOnce({ success: true, messages: secondBatch } as any); + + await loadMessagesForRoom({ rid: 'ROOM_ID', t: 'c' }); + + // settings.Hide_System_Messages = ['uj'] (from top-level mock) → first batch hidden → must recurse + expect(mockedSdkGet).toHaveBeenCalledTimes(2); + }); + + it('does not insert an intermediate load-more when a loaderItem is provided', async () => { + const firstBatch = buildHiddenBatch('first', 50); + const secondBatch = Array.from({ length: 50 }, (_, index) => + buildMessage({ + id: `second-${index + 1}`, + ts: new Date(Date.UTC(2023, 11, 31, 23, 59, 50 - index)).toISOString() + }) + ); + + mockedSdkGet + .mockResolvedValueOnce({ success: true, messages: firstBatch } as any) + .mockResolvedValueOnce({ success: true, messages: secondBatch } as any); + + await loadMessagesForRoom({ + rid: 'ROOM_ID', + t: 'c', + loaderItem: { id: 'tapped-load-more' } as any + }); + + // Only the outer write — the intermediate batch-1 loader insert is skipped + expect(mockedUpdateMessages).toHaveBeenCalledTimes(1); + expect(mockedDispatch).not.toHaveBeenCalledWith(expect.objectContaining({ type: ROOM.HISTORY_UI_LOADER_PUSH })); + expect(mockedDispatch).not.toHaveBeenCalledWith(expect.objectContaining({ type: ROOM.HISTORY_UI_LOADER_POP })); + }); +}); diff --git a/app/lib/methods/loadMessagesForRoom.ts b/app/lib/methods/loadMessagesForRoom.ts index 10aa131ec5e..ce3d213c978 100644 --- a/app/lib/methods/loadMessagesForRoom.ts +++ b/app/lib/methods/loadMessagesForRoom.ts @@ -1,28 +1,55 @@ import dayjs from '../dayjs'; import { MessageTypeLoad } from '../constants/messageTypeLoad'; +import { roomHistoryUiLoaderPop, roomHistoryUiLoaderPush } from '../../actions/room'; import { type IMessage, type TMessageModel } from '../../definitions'; import log from './helpers/log'; import { getMessageById } from '../database/services/Message'; +import { getSubscriptionByRoomId } from '../database/services/Subscription'; import { type RoomTypes, roomTypeToApiType } from './roomTypeToApiType'; import sdk from '../services/sdk'; +import { store } from '../store/auxStore'; import updateMessages from './updateMessages'; import { generateLoadMoreId } from './helpers/generateLoadMoreId'; const COUNT = 50; +const MAX_BATCHES = 10; -async function load({ rid: roomId, latest, t }: { rid: string; latest?: Date; t: RoomTypes }): Promise { - const apiType = roomTypeToApiType(t); +const isVisibleMainRoomMessage = (message: IMessage, hideSystemMessages: string[]) => + !message.tmid && (!message.t || !hideSystemMessages.includes(message.t)); + +async function resolveHideSystemMessages(rid: string): Promise { + const sub = await getSubscriptionByRoomId(rid); + if (Array.isArray(sub?.sysMes)) { + return sub.sysMes; + } + const fromSettings = store.getState().settings.Hide_System_Messages; + return Array.isArray(fromSettings) ? fromSettings : []; +} + +async function load(args: { + rid: string; + latest?: Date; + t: RoomTypes; + loaderItem?: TMessageModel; + onUiLoaderPushed?: (loaderId: string) => void; +}): Promise<{ messages: IMessage[]; lastBatchWasFull: boolean }> { + const roomId = args.rid; + const hideSystemMessages = await resolveHideSystemMessages(roomId); + const apiType = roomTypeToApiType(args.t); if (!apiType) { - return []; + return { messages: [], lastBatchWasFull: false }; } const allMessages: IMessage[] = []; - let mainMessagesCount = 0; + let visibleMainMessagesCount = 0; + let batchesFetched = 0; + let lastBatchWasFull = false; async function fetchBatch(lastTs?: string): Promise { - if (allMessages.length >= COUNT) { + if (visibleMainMessagesCount >= COUNT || batchesFetched >= MAX_BATCHES) { return; } + batchesFetched += 1; const params = { roomId, showThreadMessages: false, count: COUNT, ...(lastTs && { latest: lastTs }) }; @@ -47,52 +74,77 @@ async function load({ rid: roomId, latest, t }: { rid: string; latest?: Date; t: const batch = data.messages as IMessage[]; allMessages.push(...batch); + lastBatchWasFull = batch.length === COUNT; - const mainMessagesInBatch = batch.filter(message => !message.tmid); - mainMessagesCount += mainMessagesInBatch.length; + const visibleMainMessagesInBatch = batch.filter(message => isVisibleMainRoomMessage(message, hideSystemMessages)); + visibleMainMessagesCount += visibleMainMessagesInBatch.length; - const needsMoreMainMessages = mainMessagesCount < COUNT; + const needsMoreVisibleMainMessages = visibleMainMessagesCount < COUNT && batch.length === COUNT; - if (needsMoreMainMessages) { + if (needsMoreVisibleMainMessages) { const lastMessage = batch[batch.length - 1]; + + if (!args.loaderItem && batchesFetched === 1) { + const loadMoreMessage = { + _id: generateLoadMoreId(lastMessage._id as string), + rid: lastMessage.rid, + ts: dayjs(lastMessage.ts).subtract(1, 'millisecond').toString(), + t: MessageTypeLoad.MORE, + msg: lastMessage.msg + } as IMessage; + + await updateMessages({ + rid: roomId, + update: [...allMessages, loadMoreMessage], + loaderItem: args.loaderItem + }); + store.dispatch(roomHistoryUiLoaderPush({ loaderId: loadMoreMessage._id })); + args.onUiLoaderPushed?.(loadMoreMessage._id); + } + await fetchBatch(lastMessage.ts as string); } } - const startTimestamp = latest ? new Date(latest).toISOString() : undefined; + const startTimestamp = args.latest ? new Date(args.latest).toISOString() : undefined; await fetchBatch(startTimestamp); - return allMessages; + return { messages: allMessages, lastBatchWasFull }; } -export function loadMessagesForRoom(args: { +export async function loadMessagesForRoom(args: { rid: string; t: RoomTypes; latest?: Date; loaderItem?: TMessageModel; }): Promise { - return new Promise(async (resolve, reject) => { - try { - const data = await load(args); - if (data?.length) { - const lastMessage = data[data.length - 1]; - const lastMessageRecord = await getMessageById(lastMessage._id as string); - if (!lastMessageRecord && data.length === COUNT) { - const loadMoreMessage = { - _id: generateLoadMoreId(lastMessage._id as string), - rid: lastMessage.rid, - ts: dayjs(lastMessage.ts).subtract(1, 'millisecond').toString(), - t: MessageTypeLoad.MORE, - msg: lastMessage.msg - } as IMessage; - data.push(loadMoreMessage); - } - await updateMessages({ rid: args.rid, update: data, loaderItem: args.loaderItem }); - return resolve(); + let uiLoaderId: string | null = null; + try { + const { messages, lastBatchWasFull } = await load({ + ...args, + onUiLoaderPushed: id => { + uiLoaderId = id; } - return resolve(); - } catch (e) { - log(e); - reject(e); + }); + if (messages?.length) { + const lastMessage = messages[messages.length - 1]; + const lastMessageRecord = await getMessageById(lastMessage._id as string); + if (!lastMessageRecord && lastBatchWasFull) { + messages.push({ + _id: generateLoadMoreId(lastMessage._id as string), + rid: lastMessage.rid, + ts: dayjs(lastMessage.ts).subtract(1, 'millisecond').toString(), + t: MessageTypeLoad.MORE, + msg: lastMessage.msg + } as IMessage); + } + await updateMessages({ rid: args.rid, update: messages, loaderItem: args.loaderItem }); + } + } catch (e) { + log(e); + throw e; + } finally { + if (uiLoaderId) { + store.dispatch(roomHistoryUiLoaderPop({ loaderId: uiLoaderId })); } - }); + } } diff --git a/app/lib/services/connect.test.ts b/app/lib/services/connect.test.ts index 01b2fc2dd73..9d20bbf30ca 100644 --- a/app/lib/services/connect.test.ts +++ b/app/lib/services/connect.test.ts @@ -1,6 +1,7 @@ import { connect, determineAuthType, disconnect } from './connect'; import { mediaSessionInstance } from './voip/MediaSessionInstance'; import { pendingHangups } from './voip/pendingHangups'; +import { unsubscribeRooms } from '../methods/subscribeRooms'; jest.mock('./voip/MediaSessionInstance', () => ({ mediaSessionInstance: { reset: jest.fn(), drainPendingHangups: jest.fn() } @@ -481,4 +482,34 @@ describe('connect — pendingHangups drain on reconnect', () => { }); }); +describe('connect — rooms subscription guard reset on close', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockOnStreamDataStops.length = 0; + mockStoreGetState.mockReturnValue({ + meteor: { connected: false }, + login: { user: null, isAuthenticated: false }, + settings: {} + }); + }); + + // Regression: a long background marks the DDP socket stale, so foregrounding triggers + // `checkAndReopen` → `forceReopen`, which wipes the SDK subscriptions and emits 'close' while + // bypassing `connect()`. The rooms-list `stream-notify-user` feed only re-subscribes when the + // module-level guard in `subscribeRooms` is clear, and `unsubscribeRooms()` is what clears it. + // If the 'close' handler stops calling `unsubscribeRooms()`, the guard stays set after reconnect + // and the rooms list silently stops updating (subscriptions/favorites/reads). + it('calls unsubscribeRooms when the socket "close" fires', async () => { + await connect({ server: 'https://example.com' }); + + // connect() itself calls unsubscribeRooms() once while tearing down prior listeners; ignore it. + (unsubscribeRooms as jest.Mock).mockClear(); + + const closeHandler = getHandlersByEvent('close')[0]; + closeHandler(); + + expect(unsubscribeRooms).toHaveBeenCalledTimes(1); + }); +}); + // Note: Apple authentication when isIOS is true is tested in connect.ios.test.ts diff --git a/app/lib/services/connect.ts b/app/lib/services/connect.ts index 9ba42c8d781..12348aeaf98 100644 --- a/app/lib/services/connect.ts +++ b/app/lib/services/connect.ts @@ -132,6 +132,11 @@ function connect({ server, logoutOnError = false }: { server: string; logoutOnEr let pendingHangupsDrainArmed = false; closeListener = sdk.current.onStreamData('close', () => { + // Reset the rooms-subscription guard on every socket close. `forceReopen` (triggered by + // `checkAndReopen` after a long background) wipes the SDK subscriptions and emits 'close' + // but bypasses `connect()`, so without this the guard in `subscribeRooms` stays set and + // `stream-notify-user` is never re-subscribed — the rooms list silently stops updating. + unsubscribeRooms(); pendingHangupsDrainArmed = true; store.dispatch(disconnectAction()); }); diff --git a/app/reducers/room.test.ts b/app/reducers/room.test.ts index 8d9620e25a6..ab39c239851 100644 --- a/app/reducers/room.test.ts +++ b/app/reducers/room.test.ts @@ -5,6 +5,8 @@ import { removedRoom, roomHistoryFinished, roomHistoryRequest, + roomHistoryUiLoaderPop, + roomHistoryUiLoaderPush, subscribeRoom, unsubscribeRoom } from '../actions/room'; @@ -69,4 +71,15 @@ describe('test room reducer', () => { const { historyLoaders } = mockedStore.getState().room; expect(historyLoaders).toEqual([]); }); + + it('should append loader id after HISTORY_UI_LOADER_PUSH', () => { + mockedStore.dispatch(roomHistoryUiLoaderPush({ loaderId: 'ui-loader' })); + expect(mockedStore.getState().room.historyLoaders).toContain('ui-loader'); + }); + + it('should remove loader id after HISTORY_UI_LOADER_POP', () => { + mockedStore.dispatch(roomHistoryUiLoaderPush({ loaderId: 'pop-me' })); + mockedStore.dispatch(roomHistoryUiLoaderPop({ loaderId: 'pop-me' })); + expect(mockedStore.getState().room.historyLoaders).not.toContain('pop-me'); + }); }); diff --git a/app/reducers/room.ts b/app/reducers/room.ts index e96d5fbc8a7..3ae9d84193a 100644 --- a/app/reducers/room.ts +++ b/app/reducers/room.ts @@ -68,6 +68,18 @@ export default function (state = initialState, action: TActionsRoom): IRoom { ...state, historyLoaders: state.historyLoaders.filter(loaderId => loaderId !== action.loaderId) }; + case ROOM.HISTORY_UI_LOADER_PUSH: + return { + ...state, + historyLoaders: state.historyLoaders.includes(action.loaderId) + ? state.historyLoaders + : [...state.historyLoaders, action.loaderId] + }; + case ROOM.HISTORY_UI_LOADER_POP: + return { + ...state, + historyLoaders: state.historyLoaders.filter(loaderId => loaderId !== action.loaderId) + }; default: return state; } diff --git a/app/sagas/__tests__/deepLinking.test.ts b/app/sagas/__tests__/deepLinking.test.ts index 0388dc2a2e9..c7d33d68f0e 100644 --- a/app/sagas/__tests__/deepLinking.test.ts +++ b/app/sagas/__tests__/deepLinking.test.ts @@ -47,6 +47,17 @@ jest.mock('../../lib/services/restApi', () => ({ notifyUser: jest.fn() })); +// handleNavigateCallRoom reads database.active.get('subscriptions').find(rid). +// Configured per test via jest.mocked(database.active.get) in beforeEach. +jest.mock('../../lib/database', () => ({ + __esModule: true, + default: { + active: { + get: jest.fn() + } + } +})); + jest.mock('../../lib/methods/videoConf', () => ({ videoConfJoin: jest.fn() })); @@ -84,10 +95,12 @@ jest.mock('../../lib/methods/helpers', () => ({ import { applyMiddleware, createStore } from 'redux'; import createSagaMiddleware from 'redux-saga'; -import { deepLinkingOpen } from '../../actions/deepLinking'; +import { deepLinkingOpen, deepLinkingClickCallPush } from '../../actions/deepLinking'; import { loginSuccess } from '../../actions/login'; import { selectServerSuccess } from '../../actions/server'; +import { connectSuccess } from '../../actions/connect'; import { appStart } from '../../actions/app'; +import { LOGIN } from '../../actions/actionsTypes'; import { RootEnum } from '../../definitions'; import reducers from '../../reducers'; import deepLinkingRoot from '../deepLinking'; @@ -95,8 +108,11 @@ import UserPreferences from '../../lib/methods/userPreferences'; import { getServerById } from '../../lib/database/services/Server'; import { canOpenRoom } from '../../lib/methods/canOpenRoom'; import { getServerInfo } from '../../lib/methods/getServerInfo'; -import { goRoom } from '../../lib/methods/helpers/goRoom'; +import { goRoom, navigateToRoom } from '../../lib/methods/helpers/goRoom'; import { waitForNavigationReady } from '../../lib/navigation/appNavigation'; +import sdk from '../../lib/services/sdk'; +import EventEmitter from '../../lib/methods/helpers/events'; +import database from '../../lib/database'; // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -115,6 +131,19 @@ function setupStore(preloadedState?: PreloadedState) { return store; } +/** setupStore that also records dispatched actions (incl. saga puts) for assertions. */ +function setupRecordingStore(preloadedState?: PreloadedState) { + const actions: { type: string }[] = []; + const recorder = () => (next: (a: any) => any) => (action: any) => { + actions.push(action); + return next(action); + }; + const sagaMiddleware = createSagaMiddleware(); + const store = createStore(reducers, preloadedState, applyMiddleware(recorder, sagaMiddleware)); + sagaMiddleware.run(deepLinkingRoot); + return { store, actions }; +} + // ─── Factories ──────────────────────────────────────────────────────────────── const HOST = 'https://open.rocket.chat'; @@ -202,6 +231,11 @@ describe('deepLinking saga — Regression race (new server + token + room path)' store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST })); await flushSagaMicrotasks(); + // The saga waits for METEOR.SUCCESS ('connected') before dispatching + // loginRequest, so it never logs in on a still-connecting socket. + store.dispatch(connectSuccess()); + await flushSagaMicrotasks(); + // Saga is now waiting for LOGIN.SUCCESS expect(jest.mocked(goRoom)).not.toHaveBeenCalled(); @@ -221,6 +255,56 @@ describe('deepLinking saga — Regression race (new server + token + room path)' expect(jest.mocked(goRoom)).toHaveBeenCalledTimes(1); }); + // Ordering race: socket connects before SERVER.SELECT_SUCCESS; the guard must + // skip the already-fired METEOR.SUCCESS take instead of hanging. + it('completes the chain when METEOR.SUCCESS fires before SERVER.SELECT_SUCCESS', async () => { + const store = setupStore(); + + store.dispatch(deepLinkingOpen(makeParamsWithToken())); + await flushSagaMicrotasks(); + await jest.advanceTimersByTimeAsync(1000); + await flushSagaMicrotasks(); + + // Socket connects first — before SERVER.SELECT_SUCCESS is dispatched. + store.dispatch(connectSuccess()); + await flushSagaMicrotasks(); + + store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST })); + await flushSagaMicrotasks(); + + store.dispatch(loginSuccess({ id: 'user-1', token: makeStoredUser() } as any)); + await flushSagaMicrotasks(); + + store.dispatch(appStart({ root: RootEnum.ROOT_INSIDE })); + await flushSagaMicrotasks(); + await flushSagaMicrotasks(); + + expect(jest.mocked(goRoom)).toHaveBeenCalledTimes(1); + }); + + // loginRequest must not fire until the socket is connected (locks the gate). + it('does not dispatch loginRequest until METEOR.SUCCESS', async () => { + const { store, actions } = setupRecordingStore(); + const loginRequested = () => actions.some(a => a.type === LOGIN.REQUEST); + + store.dispatch(deepLinkingOpen(makeParamsWithToken())); + await flushSagaMicrotasks(); + await jest.advanceTimersByTimeAsync(1000); + await flushSagaMicrotasks(); + + store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST })); + await flushSagaMicrotasks(); + + // Server selected but socket not connected yet → still parked at the gate. + expect(loginRequested()).toBe(false); + + store.dispatch(connectSuccess()); + await flushSagaMicrotasks(); + + // Socket connected → gate released, loginRequest dispatched. + expect(loginRequested()).toBe(true); + }); + /** * Regression negative: dispatch SERVER.SELECT_SUCCESS, LOGIN.SUCCESS. * Flush microtasks. Assert goRoom NOT yet called. @@ -238,6 +322,11 @@ describe('deepLinking saga — Regression race (new server + token + room path)' store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST })); await flushSagaMicrotasks(); + // The saga waits for METEOR.SUCCESS ('connected') before dispatching + // loginRequest, so it never logs in on a still-connecting socket. + store.dispatch(connectSuccess()); + await flushSagaMicrotasks(); + store.dispatch(loginSuccess({ id: 'user-1', token: makeStoredUser() } as any)); await flushSagaMicrotasks(); @@ -270,6 +359,11 @@ describe('deepLinking saga — Regression race (new server + token + room path)' store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST })); await flushSagaMicrotasks(); + // The saga waits for METEOR.SUCCESS ('connected') before dispatching + // loginRequest, so it never logs in on a still-connecting socket. + store.dispatch(connectSuccess()); + await flushSagaMicrotasks(); + // Dispatch LOGIN.SUCCESS AND APP.START(ROOT_INSIDE) synchronously before any flush. // The reducer processes both dispatches before the saga's select runs, // so the select sees ROOT_INSIDE and skips the take. @@ -299,6 +393,11 @@ describe('deepLinking saga — Regression race (new server + token + room path)' store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST })); await flushSagaMicrotasks(); + // The saga waits for METEOR.SUCCESS ('connected') before dispatching + // loginRequest, so it never logs in on a still-connecting socket. + store.dispatch(connectSuccess()); + await flushSagaMicrotasks(); + store.dispatch(loginSuccess({ id: 'user-1', token: makeStoredUser() } as any)); await flushSagaMicrotasks(); @@ -334,6 +433,11 @@ describe('deepLinking saga — Regression race (new server + token + room path)' store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST })); await flushSagaMicrotasks(); + // The saga waits for METEOR.SUCCESS ('connected') before dispatching + // loginRequest, so it never logs in on a still-connecting socket. + store.dispatch(connectSuccess()); + await flushSagaMicrotasks(); + store.dispatch(loginSuccess({ id: 'user-1', token: makeStoredUser() } as any)); await flushSagaMicrotasks(); @@ -353,3 +457,205 @@ describe('deepLinking saga — Regression race (new server + token + room path)' expect(jest.mocked(goRoom)).toHaveBeenCalledTimes(1); }); }); + +/** + * Scenario: The user opens the app for the first time on a workspace that the + * SDK websocket is already connected to (e.g. they registered a server), then + * a deeplink with an auth token arrives for that same host. + */ +describe('deepLinking saga — server already connected, should skip changing server', () => { + beforeEach(() => { + jest.useFakeTimers(); + + jest.mocked(UserPreferences.getString).mockReset(); + jest.mocked(getServerById).mockReset(); + jest.mocked(canOpenRoom).mockReset(); + jest.mocked(getServerInfo).mockReset(); + jest.mocked(goRoom).mockReset(); + jest.mocked(waitForNavigationReady).mockReset(); + + // A different server is currently set; no stored credentials for HOST. + // This ensures we reach the else-branch (different server path) and then + // fall through to the getServerInfo / hostAlreadyConnected check. + jest.mocked(UserPreferences.getString).mockImplementation((key: string) => { + if (key === 'currentServer') return 'https://other.server.com'; + return null; + }); + jest.mocked(getServerById).mockResolvedValue(null); + jest.mocked(getServerInfo).mockResolvedValue({ success: true, version: '6.0.0' } as any); + jest.mocked(canOpenRoom).mockResolvedValue({ rid: 'room-1', name: 'general', t: 'c' } as any); + jest.mocked(waitForNavigationReady).mockResolvedValue(undefined); + jest.mocked(goRoom).mockResolvedValue(undefined); + + // Key setup: SDK websocket is already open to HOST + (sdk.current as any).client.host = HOST; + }); + + afterEach(() => { + jest.useRealTimers(); + // Reset so other describe blocks see the default empty host + (sdk.current as any).client.host = ''; + }); + + /** + * Regression positive: the full chain completes without SERVER.SELECT_SUCCESS. + * Before the fix this would hang because selectServer dispatches SELECT_CANCEL + * (not SELECT_SUCCESS) when the server is already connected. + */ + it('calls goRoom after LOGIN.SUCCESS + APP.START(ROOT_INSIDE) without needing SERVER.SELECT_SUCCESS', async () => { + const store = setupStore(); + + store.dispatch(deepLinkingOpen(makeParamsWithToken())); + // Two flushes drain the getServerById and getServerInfo promise microtasks. + // No jest.advanceTimersByTimeAsync needed — delay(1000) is skipped when + // hostAlreadyConnected is true. + await flushSagaMicrotasks(); + await flushSagaMicrotasks(); + + // Saga must be parked at take(LOGIN.SUCCESS), not take(SERVER.SELECT_SUCCESS) + expect(jest.mocked(goRoom)).not.toHaveBeenCalled(); + + // Drive the rest of the chain WITHOUT dispatching selectServerSuccess + store.dispatch(loginSuccess({ id: 'user-1', token: makeStoredUser() } as any)); + await flushSagaMicrotasks(); + + expect(jest.mocked(goRoom)).not.toHaveBeenCalled(); + + store.dispatch(appStart({ root: RootEnum.ROOT_INSIDE })); + await flushSagaMicrotasks(); + await flushSagaMicrotasks(); + + expect(jest.mocked(goRoom)).toHaveBeenCalledTimes(1); + }); + + /** + * Side-effect guard: the saga must NOT emit a 'NewServer' event when the + * SDK is already connected — doing so would trigger the selectServer saga, + * which would dispatch SELECT_CANCEL and leave deeplink auth stuck. + */ + it('does not emit NewServer when the SDK is already connected to the deeplink host', async () => { + const emitSpy = jest.spyOn(EventEmitter, 'emit'); + + const store = setupStore(); + store.dispatch(deepLinkingOpen(makeParamsWithToken())); + await flushSagaMicrotasks(); + await flushSagaMicrotasks(); + + expect(emitSpy).not.toHaveBeenCalledWith('NewServer', expect.anything()); + + // Complete the flow to confirm the saga finishes correctly + store.dispatch(loginSuccess({ id: 'user-1', token: makeStoredUser() } as any)); + store.dispatch(appStart({ root: RootEnum.ROOT_INSIDE })); + await flushSagaMicrotasks(); + await flushSagaMicrotasks(); + + expect(jest.mocked(goRoom)).toHaveBeenCalledTimes(1); + emitSpy.mockRestore(); + }); +}); + +// ─── handleClickCallPush (OPEN_VIDEO_CONF) — new server + token ────────────────── + +describe('deepLinking saga — handleClickCallPush (new server + token + call room)', () => { + /** Call-push params: host + token + the rid handleNavigateCallRoom looks up. */ + const makeCallParams = (overrides: Record = {}) => makeParamsWithToken({ rid: 'room-1', ...overrides }); + + beforeEach(() => { + jest.useFakeTimers(); + + jest.mocked(UserPreferences.getString).mockReset(); + jest.mocked(getServerById).mockReset(); + jest.mocked(getServerInfo).mockReset(); + jest.mocked(navigateToRoom).mockReset(); + jest.mocked(database.active.get).mockReset(); + + // Unknown server with a token → reaches the SELECT_SUCCESS/METEOR.SUCCESS gate. + jest.mocked(UserPreferences.getString).mockImplementation((key: string) => { + if (key === 'currentServer') return 'https://other.server.com'; + return null; + }); + jest.mocked(getServerById).mockResolvedValue(null); + jest.mocked(getServerInfo).mockResolvedValue({ success: true, version: '6.0.0' } as any); + + // handleNavigateCallRoom resolves the subscription for params.rid. + jest.mocked(database.active.get).mockReturnValue({ + find: jest.fn().mockResolvedValue({ rid: 'room-1', name: 'general', t: 'c' }) + } as any); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + // Ordering race (call-push path): socket connects before SERVER.SELECT_SUCCESS; + // the guard must skip the already-fired METEOR.SUCCESS take instead of hanging. + it('completes the call-room chain when METEOR.SUCCESS fires before SERVER.SELECT_SUCCESS', async () => { + const store = setupStore(); + + store.dispatch(deepLinkingClickCallPush(makeCallParams())); + await flushSagaMicrotasks(); + await jest.advanceTimersByTimeAsync(1000); + await flushSagaMicrotasks(); + + // Socket connects first — before SERVER.SELECT_SUCCESS is dispatched. + store.dispatch(connectSuccess()); + await flushSagaMicrotasks(); + + store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST })); + await flushSagaMicrotasks(); + + store.dispatch(loginSuccess({ id: 'user-1', token: makeStoredUser() } as any)); + await flushSagaMicrotasks(); + await flushSagaMicrotasks(); + + expect(jest.mocked(navigateToRoom)).toHaveBeenCalledTimes(1); + }); + + // Happy path: full chain in normal order navigates once. + it('navigates to the call room once after the full chain completes', async () => { + const store = setupStore(); + + store.dispatch(deepLinkingClickCallPush(makeCallParams())); + await flushSagaMicrotasks(); + await jest.advanceTimersByTimeAsync(1000); + await flushSagaMicrotasks(); + + store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST })); + await flushSagaMicrotasks(); + + // Still parked at the METEOR.SUCCESS gate — no navigation yet. + expect(jest.mocked(navigateToRoom)).not.toHaveBeenCalled(); + + store.dispatch(connectSuccess()); + await flushSagaMicrotasks(); + + store.dispatch(loginSuccess({ id: 'user-1', token: makeStoredUser() } as any)); + await flushSagaMicrotasks(); + await flushSagaMicrotasks(); + + expect(jest.mocked(navigateToRoom)).toHaveBeenCalledTimes(1); + }); + + // loginRequest must not fire until the socket is connected (locks the gate). + it('does not dispatch loginRequest until METEOR.SUCCESS', async () => { + const { store, actions } = setupRecordingStore(); + const loginRequested = () => actions.some(a => a.type === LOGIN.REQUEST); + + store.dispatch(deepLinkingClickCallPush(makeCallParams())); + await flushSagaMicrotasks(); + await jest.advanceTimersByTimeAsync(1000); + await flushSagaMicrotasks(); + + store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST })); + await flushSagaMicrotasks(); + + // Server selected but socket not connected yet → still parked at the gate. + expect(loginRequested()).toBe(false); + + store.dispatch(connectSuccess()); + await flushSagaMicrotasks(); + + // Socket connected → gate released, loginRequest dispatched. + expect(loginRequested()).toBe(true); + }); +}); diff --git a/app/sagas/deepLinking.js b/app/sagas/deepLinking.js index c09d1d8c4ae..a7f3cded386 100644 --- a/app/sagas/deepLinking.js +++ b/app/sagas/deepLinking.js @@ -225,13 +225,24 @@ const handleOpen = function* handleOpen({ params }) { yield fallbackNavigation(); return; } - yield put(appStart({ root: RootEnum.ROOT_OUTSIDE })); - yield put(serverInitAdd(server)); - yield delay(1000); - EventEmitter.emit('NewServer', { server: host }); + // if the host is different from the current one, we need to connect to it before navigating + const hostAlreadyConnected = sdk.current?.client?.host === host; + if (!hostAlreadyConnected) { + yield put(appStart({ root: RootEnum.ROOT_OUTSIDE })); + yield put(serverInitAdd(server)); + yield delay(1000); + EventEmitter.emit('NewServer', { server: host }); + } if (params.token) { - yield take(types.SERVER.SELECT_SUCCESS); + if (!hostAlreadyConnected) { + yield take(types.SERVER.SELECT_SUCCESS); + // SERVER.SELECT_SUCCESS doesn't mean 'connected'; skip the take if it already is. + const connected = yield select(state => state.meteor.connected); + if (!connected) { + yield take(types.METEOR.SUCCESS); + } + } yield put(loginRequest({ resume: params.token }, true)); yield take(types.LOGIN.SUCCESS); yield put(appReady({})); @@ -326,6 +337,11 @@ const handleClickCallPush = function* handleClickCallPush({ params }) { EventEmitter.emit('NewServer', { server: host }); if (params.token) { yield take(types.SERVER.SELECT_SUCCESS); + // SERVER.SELECT_SUCCESS doesn't mean 'connected'; skip the take if it already is. + const connected = yield select(state => state.meteor.connected); + if (!connected) { + yield take(types.METEOR.SUCCESS); + } yield put(loginRequest({ resume: params.token }, true)); yield take(types.LOGIN.SUCCESS); yield handleNavigateCallRoom({ params }); diff --git a/app/views/DirectoryView/hooks/useDirectorySearch.ts b/app/views/DirectoryView/hooks/useDirectorySearch.ts new file mode 100644 index 00000000000..1eff11ed0de --- /dev/null +++ b/app/views/DirectoryView/hooks/useDirectorySearch.ts @@ -0,0 +1,100 @@ +import { useEffect, useState } from 'react'; + +import { type IServerRoom } from '../../../definitions'; +import { announceSearchResultsForAccessibility } from '../../../lib/methods/helpers/announceSearchResultsForAccessibility'; +import { useDebounce } from '../../../lib/methods/helpers/debounce'; +import log, { events, logEvent } from '../../../lib/methods/helpers/log'; +import { getDirectory } from '../../../lib/services/restApi'; + +export const useDirectorySearch = (directoryDefaultView: string) => { + 'use memo'; + + const [data, setData] = useState([]); + const [loading, setLoading] = useState(false); + const [text, setText] = useState(''); + const [total, setTotal] = useState(-1); + const [globalUsers, setGlobalUsers] = useState(true); + const [type, setType] = useState(directoryDefaultView); + + // useDebounce keeps a ref to the latest callback, so this always reads fresh state + const load = useDebounce(async ({ newSearch = false }: { newSearch?: boolean } = {}) => { + if (!newSearch && (loading || data.length === total)) { + return; + } + + if (newSearch) { + setData([]); + setTotal(-1); + } + setLoading(true); + + try { + const directories = await getDirectory({ + text, + type, + workspace: globalUsers ? 'all' : 'local', + offset: newSearch ? 0 : data.length, + count: 50, + sort: type === 'users' ? { username: 1 } : { usersCount: -1 } + }); + if (directories.success) { + setData(prev => [...(newSearch ? [] : prev), ...(directories.result as IServerRoom[])]); + setTotal(directories.total); + setLoading(false); + // Announce the full total on a fresh search; loadMore pages shouldn't re-announce + if (newSearch) { + announceSearchResultsForAccessibility(directories.total); + } + } else { + setLoading(false); + } + } catch (e) { + log(e); + setLoading(false); + } + }, 200); + + const search = () => load({ newSearch: true }); + const loadMore = () => load({}); + + const onSearchChangeText = (newText: string) => { + setText(newText); + search(); + }; + + const changeType = (newType: string) => { + setType(newType); + + if (newType === 'users') { + logEvent(events.DIRECTORY_SEARCH_USERS); + } else if (newType === 'channels') { + logEvent(events.DIRECTORY_SEARCH_CHANNELS); + } else if (newType === 'teams') { + logEvent(events.DIRECTORY_SEARCH_TEAMS); + } + + search(); + }; + + const toggleWorkspace = () => { + setGlobalUsers(prev => !prev); + search(); + }; + + // Run the initial search when the hook mounts; `search` is stable, so this fires once + useEffect(() => { + search(); + }, []); + + return { + data, + loading, + type, + globalUsers, + search, + loadMore, + onSearchChangeText, + changeType, + toggleWorkspace + }; +}; diff --git a/app/views/DirectoryView/index.tsx b/app/views/DirectoryView/index.tsx index 795f2742e47..b07cec610ba 100644 --- a/app/views/DirectoryView/index.tsx +++ b/app/views/DirectoryView/index.tsx @@ -1,10 +1,10 @@ -import React from 'react'; -import { AccessibilityInfo, FlatList, type ListRenderItem } from 'react-native'; -import { connect } from 'react-redux'; +import React, { useLayoutEffect } from 'react'; +import { FlatList, type ListRenderItem } from 'react-native'; +import { shallowEqual } from 'react-redux'; import { type NativeStackNavigationOptions, type NativeStackNavigationProp } from '@react-navigation/native-stack'; import { type CompositeNavigationProp } from '@react-navigation/native'; -import { hideActionSheetRef, showActionSheetRef } from '../../containers/ActionSheet'; +import { useActionSheet } from '../../containers/ActionSheet'; import { type ChatsStackParamList } from '../../stacks/types'; import { type MasterDetailInsideStackParamList } from '../../stacks/MasterDetailStack/types'; import * as List from '../../containers/List'; @@ -14,67 +14,65 @@ import I18n from '../../i18n'; import SearchBox from '../../containers/SearchBox'; import ActivityIndicator from '../../containers/ActivityIndicator'; import * as HeaderButton from '../../containers/Header/components/HeaderButton'; -import { debounce } from '../../lib/methods/helpers'; -import log, { events, logEvent } from '../../lib/methods/helpers/log'; -import { type TSupportedThemes, withTheme } from '../../theme'; -import { themes } from '../../lib/constants/colors'; -import { getUserSelector } from '../../selectors/login'; +import { useTheme } from '../../theme'; import SafeAreaView from '../../containers/SafeAreaView'; -import { goRoom, type TGoRoomItem } from '../../lib/methods/helpers/goRoom'; -import { type IApplicationState, type IServerRoom, type IUser, SubscriptionType } from '../../definitions'; +import { goRoom as goRoomMethod, type TGoRoomItem } from '../../lib/methods/helpers/goRoom'; +import { type IServerRoom, SubscriptionType } from '../../definitions'; import styles from './styles'; import Options from './Options'; -import { getDirectory, getRoomByTypeAndName } from '../../lib/services/restApi'; +import { getRoomByTypeAndName } from '../../lib/services/restApi'; import { createDirectMessage } from '../../lib/methods/createDirectMessage'; import { getSubscriptionByRoomId } from '../../lib/database/services/Subscription'; +import { useAppSelector } from '../../lib/hooks/useAppSelector'; +import { useDirectorySearch } from './hooks/useDirectorySearch'; interface IDirectoryViewProps { navigation: CompositeNavigationProp< NativeStackNavigationProp, NativeStackNavigationProp >; - baseUrl: string; - isFederationEnabled: boolean; - user: IUser; - theme: TSupportedThemes; - directoryDefaultView: string; - isMasterDetail: boolean; } -interface IDirectoryViewState { - data: IServerRoom[]; - loading: boolean; - text: string; - total: number; - globalUsers: boolean; - type: string; -} +const DirectoryView = ({ navigation }: IDirectoryViewProps): React.ReactElement => { + const { colors } = useTheme(); + const { showActionSheet, hideActionSheet } = useActionSheet(); + + const { isFederationEnabled, directoryDefaultView, isMasterDetail } = useAppSelector( + state => ({ + isFederationEnabled: state.settings.FEDERATION_Enabled as boolean, + directoryDefaultView: state.settings.Accounts_Directory_DefaultView as string, + isMasterDetail: state.app.isMasterDetail + }), + shallowEqual + ); -class DirectoryView extends React.Component { - constructor(props: IDirectoryViewProps) { - super(props); - this.state = { - data: [], - loading: false, - text: '', - total: -1, - globalUsers: true, - type: props.directoryDefaultView + const { data, loading, type, globalUsers, search, loadMore, onSearchChangeText, changeType, toggleWorkspace } = + useDirectorySearch(directoryDefaultView); + + useLayoutEffect(() => { + const showFilters = () => { + showActionSheet({ + children: ( + { + changeType(newType); + hideActionSheet(); + }} + toggleWorkspace={toggleWorkspace} + isFederationEnabled={isFederationEnabled} + /> + ), + enableContentPanningGesture: false + }); }; - this.setHeader(); - } - componentDidMount() { - this.load({}); - } - - setHeader = () => { - const { navigation, isMasterDetail } = this.props; const options: NativeStackNavigationOptions = { title: I18n.t('Directory'), headerRight: () => ( - + ) }; @@ -83,134 +81,40 @@ class DirectoryView extends React.Component { + goRoomMethod({ item, isMasterDetail }); }; - onSearchChangeText = (text: string) => { - this.setState({ text }, this.search); - }; - - load = debounce(async ({ newSearch = false }) => { - if (newSearch) { - this.setState({ data: [], total: -1, loading: false }); - } - - const { - loading, - text, - total, - data: { length } - } = this.state; - if (loading || length === total) { - return; - } - - this.setState({ loading: true }); - - try { - const { type, globalUsers } = this.state; - let { data } = this.state; - // TODO: workaround to fix Fabric batch behavior. It should be fixed when we migrate to function components - if (newSearch) { - data = []; - } - const directories = await getDirectory({ - text, - type, - workspace: globalUsers ? 'all' : 'local', - offset: data.length, - count: 50, - sort: type === 'users' ? { username: 1 } : { usersCount: -1 } - }); - if (directories.success) { - this.setState(prev => ({ - data: [...prev.data, ...(directories.result as IServerRoom[])], - loading: false, - total: directories.total - })); - this.announceSearchResults(directories.count); - } else { - this.setState({ loading: false }); - } - } catch (e) { - log(e); - this.setState({ loading: false }); - } - }, 200); - - search = () => { - this.load({ newSearch: true }); - }; - - announceSearchResults = (count: number) => { - if (!count) { - AccessibilityInfo.announceForAccessibility(I18n.t('No_results_found')); - return; - } - const message = count === 1 ? I18n.t('One_result_found') : I18n.t('Search_Results_found', { count: count.toString() }); - AccessibilityInfo.announceForAccessibility(message); - }; - - changeType = (type: string) => { - this.setState({ type, data: [] }, () => this.search()); - - if (type === 'users') { - logEvent(events.DIRECTORY_SEARCH_USERS); - } else if (type === 'channels') { - logEvent(events.DIRECTORY_SEARCH_CHANNELS); - } else if (type === 'teams') { - logEvent(events.DIRECTORY_SEARCH_TEAMS); - } - hideActionSheetRef(); - }; - - toggleWorkspace = () => { - this.setState( - ({ globalUsers }) => ({ globalUsers: !globalUsers, data: [] }), - () => this.search() - ); - }; - - showFilters = () => { - const { type, globalUsers } = this.state; - const { isFederationEnabled } = this.props; - showActionSheetRef({ - children: ( - - ), - enableContentPanningGesture: false - }); - }; - - goRoom = (item: TGoRoomItem) => { - const { isMasterDetail } = this.props; - goRoom({ item, isMasterDetail }); - }; - - onPressItem = async (item: IServerRoom) => { + const onPressItem = async (item: IServerRoom) => { try { - const { type } = this.state; if (type === 'users') { const result = await createDirectMessage(item.username as string); if (result.success) { - this.goRoom({ rid: result.room._id, name: item.username, t: SubscriptionType.DIRECT }); + goRoom({ rid: result.room._id, name: item.username, t: SubscriptionType.DIRECT }); } return; } const subscription = await getSubscriptionByRoomId(item._id); if (subscription) { - this.goRoom(subscription); + goRoom(subscription); return; } if (['p', 'c'].includes(item.t) && !item.teamMain) { const result = await getRoomByTypeAndName(item.t, item.name || item.fname); if (result) { - this.goRoom({ + goRoom({ rid: item._id, name: item.name, joinCodeRequired: result.joinCodeRequired, @@ -219,7 +123,7 @@ class DirectoryView extends React.Component ( - <> - - - - ); - - renderItem: ListRenderItem = ({ item, index }) => { - const { data, type } = this.state; - const { baseUrl, user, theme } = this.props; - + const renderItem: ListRenderItem = ({ item, index }) => { let style; if (index === data.length - 1) { style = { ...sharedStyles.separatorBottom, - borderColor: themes[theme].strokeLight + borderColor: colors.strokeLight }; } const commonProps = { title: item.name as string, - onPress: () => this.onPressItem(item), - baseUrl, + onPress: () => onPressItem(item), testID: `directory-view-item-${item.name}`, style, - user, - theme, rid: item._id }; @@ -298,35 +189,25 @@ class DirectoryView extends React.Component { - const { data, loading } = this.state; - const { theme } = this.props; - return ( - - item._id} - ListHeaderComponent={this.renderHeader} - renderItem={this.renderItem} - ItemSeparatorComponent={List.Separator} - keyboardShouldPersistTaps='always' - ListFooterComponent={loading ? : null} - onEndReached={() => this.load({})} - /> - - ); - }; -} + return ( + + + -const mapStateToProps = (state: IApplicationState) => ({ - baseUrl: state.server.server, - user: getUserSelector(state), - isFederationEnabled: state.settings.FEDERATION_Enabled as boolean, - directoryDefaultView: state.settings.Accounts_Directory_DefaultView as string, - isMasterDetail: state.app.isMasterDetail -}); + item._id} + renderItem={renderItem} + ItemSeparatorComponent={List.Separator} + keyboardShouldPersistTaps='always' + ListFooterComponent={loading ? : null} + onEndReached={() => loadMore()} + /> + + ); +}; -export default connect(mapStateToProps)(withTheme(DirectoryView)); +export default DirectoryView; diff --git a/app/views/RoomView/List/hooks/useMessages.test.tsx b/app/views/RoomView/List/hooks/useMessages.test.tsx index e5dea919ccc..7472e199a48 100644 --- a/app/views/RoomView/List/hooks/useMessages.test.tsx +++ b/app/views/RoomView/List/hooks/useMessages.test.tsx @@ -9,8 +9,9 @@ import database from '../../../../lib/database'; import { getMessageById } from '../../../../lib/database/services/Message'; import { getThreadById } from '../../../../lib/database/services/Thread'; import { MessageTypeLoad } from '../../../../lib/constants/messageTypeLoad'; +import { readThreads } from '../../../../lib/services/restApi'; import { mockedStore } from '../../../../reducers/mockedStore'; -import { MAX_AUTO_LOADS } from '../constants'; +import { MAX_AUTO_LOADS, QUERY_SIZE } from '../constants'; import { useMessages } from './useMessages'; jest.mock('../../../../lib/database', () => ({ @@ -45,6 +46,7 @@ jest.mock('../../../../lib/methods/helpers', () => { const mockDbGet = database.active.get as unknown as jest.Mock; const mockGetThreadById = jest.mocked(getThreadById); const mockGetMessageById = jest.mocked(getMessageById); +const mockReadThreads = jest.mocked(readThreads); const baseArgs = { rid: 'ROOM_ID', @@ -63,22 +65,38 @@ const msg = (overrides: Partial & { id: string }): TAnyMessage describe('useMessages', () => { let emittedRows: TAnyMessageModel[]; let emitVisibleRows: ((rows: TAnyMessageModel[]) => void) | null; + let queryCalls: unknown[][]; + let unsubscribeSpies: jest.Mock[]; const wrapper = ({ children }: { children: React.ReactNode }) => {children}; beforeEach(() => { emittedRows = []; emitVisibleRows = null; + queryCalls = []; + unsubscribeSpies = []; jest.clearAllMocks(); + // Reset historyLoaders so prior test dispatches don't trip the in-flight guard + mockedStore + .getState() + .room.historyLoaders.slice() + .forEach(loaderId => { + mockedStore.dispatch({ type: ROOM.HISTORY_FINISHED, loaderId }); + }); mockDbGet.mockImplementation(() => ({ - query: jest.fn().mockReturnValue({ - observe: () => ({ - subscribe: (onNext: (rows: TAnyMessageModel[]) => void) => { - emitVisibleRows = onNext; - onNext(emittedRows); - return { unsubscribe: jest.fn() }; - } - }) + query: jest.fn((...args: unknown[]) => { + queryCalls.push(args); + return { + observe: () => ({ + subscribe: (onNext: (rows: TAnyMessageModel[]) => void) => { + emitVisibleRows = onNext; + onNext(emittedRows); + const unsubscribe = jest.fn(); + unsubscribeSpies.push(unsubscribe); + return { unsubscribe }; + } + }) + }; }) })); }); @@ -354,4 +372,152 @@ describe('useMessages', () => { expect(result.current[0].map(m => m.id)).toContain('fallback-parent'); }); }); + + it('does not dispatch room history request when hideSystemMessages is empty even if a load row is present', async () => { + const dispatchSpy = jest.spyOn(mockedStore, 'dispatch'); + emittedRows = [msg({ id: 'load-more-x', t: MessageTypeLoad.MORE })]; + const { result } = renderUseMessages({ + serverVersion: '6.0.0', + hideSystemMessages: [] + }); + await waitFor(() => { + expect(result.current[0].length).toBeGreaterThan(0); + }); + expect(getHistoryDispatchCount(dispatchSpy)).toBe(0); + dispatchSpy.mockRestore(); + }); + + it('does not dispatch room history request when there is no load-type row in visible messages', async () => { + const dispatchSpy = jest.spyOn(mockedStore, 'dispatch'); + emittedRows = [msg({ id: 'a', t: undefined }), msg({ id: 'b', t: undefined })]; + const { result } = renderUseMessages({ + serverVersion: '6.0.0', + hideSystemMessages: ['uj'] + }); + await waitFor(() => { + expect(result.current[0].length).toBeGreaterThan(0); + }); + expect(getHistoryDispatchCount(dispatchSpy)).toBe(0); + dispatchSpy.mockRestore(); + }); + + it('dispatches room history request for PREVIOUS_CHUNK load type', async () => { + const dispatchSpy = jest.spyOn(mockedStore, 'dispatch'); + emittedRows = [msg({ id: 'prev-chunk-id', t: MessageTypeLoad.PREVIOUS_CHUNK })]; + renderUseMessages({ + serverVersion: '6.0.0', + hideSystemMessages: ['uj'] + }); + await waitFor(() => { + expect(dispatchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: ROOM.HISTORY_REQUEST, + loaderId: 'prev-chunk-id' + }) + ); + }); + dispatchSpy.mockRestore(); + }); + + it('dispatches room history request for NEXT_CHUNK load type', async () => { + const dispatchSpy = jest.spyOn(mockedStore, 'dispatch'); + emittedRows = [msg({ id: 'next-chunk-id', t: MessageTypeLoad.NEXT_CHUNK })]; + renderUseMessages({ + serverVersion: '6.0.0', + hideSystemMessages: ['uj'] + }); + await waitFor(() => { + expect(dispatchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: ROOM.HISTORY_REQUEST, + loaderId: 'next-chunk-id' + }) + ); + }); + dispatchSpy.mockRestore(); + }); + + it('does not dispatch roomHistoryRequest when a fetch for the same loaderId is already in flight', async () => { + // Simulate loadMessagesForRoom having already pushed the UI loader before the DB subscription emits + mockedStore.dispatch({ type: ROOM.HISTORY_UI_LOADER_PUSH, loaderId: 'load-more-x' }); + const dispatchSpy = jest.spyOn(mockedStore, 'dispatch'); + emittedRows = [msg({ id: 'load-more-x', t: MessageTypeLoad.MORE })]; + const { result } = renderUseMessages({ + serverVersion: '6.0.0', + hideSystemMessages: ['uj'] + }); + await waitFor(() => { + expect(result.current[0].length).toBeGreaterThan(0); + }); + expect(getHistoryDispatchCount(dispatchSpy)).toBe(0); + dispatchSpy.mockRestore(); + }); + + it('does not dispatch room history request when serverVersion is null', async () => { + const dispatchSpy = jest.spyOn(mockedStore, 'dispatch'); + emittedRows = [msg({ id: 'load-more-x', t: MessageTypeLoad.MORE })]; + const { result } = renderUseMessages({ + serverVersion: null, + hideSystemMessages: ['uj'] + }); + await waitFor(() => { + expect(result.current[0].length).toBeGreaterThan(0); + }); + expect(getHistoryDispatchCount(dispatchSpy)).toBe(0); + dispatchSpy.mockRestore(); + }); + + it('unsubscribes from the observable when the hook unmounts', async () => { + emittedRows = [msg({ id: 'm1' })]; + const { unmount } = renderUseMessages(); + await waitFor(() => { + expect(unsubscribeSpies.length).toBeGreaterThan(0); + }); + const lastUnsubscribe = unsubscribeSpies[unsubscribeSpies.length - 1]; + unmount(); + expect(lastUnsubscribe).toHaveBeenCalled(); + }); + + it('grows the query take size when fetchMessages is called multiple times', async () => { + emittedRows = [msg({ id: 'm1' })]; + const { result } = renderUseMessages(); + await waitFor(() => { + expect(queryCalls.length).toBeGreaterThan(0); + }); + + const initialTake = queryCalls[queryCalls.length - 1].at(-1); + expect(initialTake).toEqual(expect.objectContaining({ type: 'take' })); + + await act(async () => { + await result.current[2](); + }); + + await waitFor(() => { + expect(queryCalls.length).toBeGreaterThanOrEqual(2); + }); + + // Each call to fetchMessages bumps count.current by QUERY_SIZE — verify the take values differ by QUERY_SIZE. + const firstTake = JSON.stringify(queryCalls[0].at(-1)); + const secondTake = JSON.stringify(queryCalls[1].at(-1)); + expect(firstTake).not.toEqual(secondTake); + // Smoke check: the constant exists and is the expected step. + expect(QUERY_SIZE).toBe(50); + }); + + it('calls readThreads when tmid is set', async () => { + emittedRows = [msg({ id: 'tm1', tmid: 'THREAD_ID' })]; + renderUseMessages({ tmid: 'THREAD_ID' }); + await waitFor(() => { + expect(mockReadThreads).toHaveBeenCalledWith('THREAD_ID'); + }); + }); + + it('does not call readThreads when tmid is not set', async () => { + emittedRows = [msg({ id: 'm1' })]; + const { result } = renderUseMessages(); + await waitFor(() => { + expect(result.current[0].length).toBeGreaterThan(0); + }); + expect(mockReadThreads).not.toHaveBeenCalled(); + }); }); diff --git a/app/views/RoomView/List/hooks/useMessages.ts b/app/views/RoomView/List/hooks/useMessages.ts index d85f10b5778..528c0bdb533 100644 --- a/app/views/RoomView/List/hooks/useMessages.ts +++ b/app/views/RoomView/List/hooks/useMessages.ts @@ -1,9 +1,9 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { Q } from '@nozbe/watermelondb'; import { type Subscription } from 'rxjs'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useStore } from 'react-redux'; -import { type RoomType, type TAnyMessageModel } from '../../../../definitions'; +import { type IApplicationState, type RoomType, type TAnyMessageModel } from '../../../../definitions'; import database from '../../../../lib/database'; import { getMessageById } from '../../../../lib/database/services/Message'; import { getThreadById } from '../../../../lib/database/services/Thread'; @@ -37,6 +37,7 @@ export const useMessages = ({ const lastDispatchedLoaderId = useRef(null); const autoLoadCount = useRef(0); const dispatch = useDispatch(); + const store = useStore(); const unsubscribe = useCallback(() => { subscription.current?.unsubscribe(); @@ -162,11 +163,15 @@ export const useMessages = ({ const loaderId = visibleMessages.find(m => m.t && MESSAGE_TYPE_ANY_LOAD.includes(m.t as MessageTypeLoad))?.id; if (loaderId && loaderId !== lastDispatchedLoaderId.current) { + // Skip if a fetch for this loader is already in flight (push happens before the DB subscription emits) + if (store.getState().room.historyLoaders.includes(loaderId)) { + return; + } lastDispatchedLoaderId.current = loaderId; autoLoadCount.current += 1; dispatch(roomHistoryRequest({ rid, t, loaderId })); } - }, [serverVersion, rid, t, hideSystemMessages, visibleMessages, dispatch]); + }, [serverVersion, rid, t, hideSystemMessages, visibleMessages, dispatch, store]); return [visibleMessages, messagesIds, fetchMessages] as const; }; diff --git a/app/views/RoomView/components/HeaderCallButton.tsx b/app/views/RoomView/components/HeaderCallButton.tsx index 99358afd668..be9429a52df 100644 --- a/app/views/RoomView/components/HeaderCallButton.tsx +++ b/app/views/RoomView/components/HeaderCallButton.tsx @@ -3,7 +3,6 @@ import React, { useEffect, useRef } from 'react'; import * as HeaderButton from '../../../containers/Header/components/HeaderButton'; import { useVideoConf } from '../../../lib/hooks/useVideoConf'; import { useNewMediaCall } from '../../../lib/hooks/useNewMediaCall'; -import { useIsInActiveVoipCall } from '../../../lib/services/voip/isInActiveVoipCall'; const DOUBLE_TAP_WINDOW_MS = 300; @@ -20,7 +19,6 @@ export const HeaderCallButton = ({ const { showInitCallActionSheet, callEnabled, disabledTooltip } = useVideoConf(rid); const { openNewMediaCall, startCallImmediate, hasMediaCallPermission, isInActiveCall } = useNewMediaCall(rid); - const isInActiveVoipCall = useIsInActiveVoipCall(); const lastTapRef = useRef(0); const pendingTimerRef = useRef | null>(null); @@ -67,7 +65,7 @@ export const HeaderCallButton = ({ return ( { const [state, dispatch] = useReducer(searchReducer, initialState); - const announceSearchResultsForAccessibility = (count: number) => { - if (count < 1) { - AccessibilityInfo.announceForAccessibility(i18n.t('No_results_found')); - return; - } - - const message = count === 1 ? i18n.t('One_result_found') : i18n.t('Search_Results_found', { count }); - AccessibilityInfo.announceForAccessibility(message); - }; - const search = useDebounce(async (text: string) => { if (!state.searchEnabled) return; dispatch({ type: 'SET_SEARCHING' }); diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 3858813e5d5..651983a6475 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -3031,7 +3031,7 @@ PODS: - Yoga - RNConfigReader (1.0.0): - React - - RNCPicker (2.11.1): + - RNCPicker (2.11.4): - boost - DoubleConversion - fast_float @@ -4116,7 +4116,7 @@ SPEC CHECKSUMS: RNCClipboard: e560338bf6cc4656a09ff90610b62ddc0dbdad65 RNCMaskedView: d707a83784c67099b54b37d056ababb2767ce15e RNConfigReader: 27bab37cca5e6b87766ffd73b8b8818ee46e3416 - RNCPicker: 70fd0622147f0ca1b9c5e1be2069a4fb2e8ec461 + RNCPicker: 35fc66f352403cdfe99d53b541f5180482ca2bc5 RNDateTimePicker: ca1dc7e24d0b4839877f0ab619e7bca5db715289 RNDeviceInfo: 900bd20e1fd3bfd894e7384cc4a83880c0341bd3 RNFBAnalytics: 2e8b8ffcd2bb3d59a43ecbe09571c73c56edef7a diff --git a/ios/RocketChatRN.xcodeproj/project.pbxproj b/ios/RocketChatRN.xcodeproj/project.pbxproj index 479b20bd6a7..809a9d2ae41 100644 --- a/ios/RocketChatRN.xcodeproj/project.pbxproj +++ b/ios/RocketChatRN.xcodeproj/project.pbxproj @@ -2089,7 +2089,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 4.73.0; + MARKETING_VERSION = 4.73.1; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; @@ -2142,7 +2142,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 4.73.0; + MARKETING_VERSION = 4.73.1; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; PRODUCT_BUNDLE_IDENTIFIER = chat.rocket.ios.NotificationService; diff --git a/ios/RocketChatRN/Info.plist b/ios/RocketChatRN/Info.plist index 4cbd734c77a..27009fc93cb 100644 --- a/ios/RocketChatRN/Info.plist +++ b/ios/RocketChatRN/Info.plist @@ -26,7 +26,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 4.73.0 + 4.73.1 CFBundleSignature ???? CFBundleURLTypes diff --git a/ios/ShareRocketChatRN/Info.plist b/ios/ShareRocketChatRN/Info.plist index 42342a3ac2f..38593972404 100644 --- a/ios/ShareRocketChatRN/Info.plist +++ b/ios/ShareRocketChatRN/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType XPC! CFBundleShortVersionString - 4.73.0 + 4.73.1 CFBundleVersion 1 KeychainGroup diff --git a/package.json b/package.json index 216f33912c3..dce60f16892 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rocket-chat-reactnative", - "version": "4.73.0", + "version": "4.73.1", "private": true, "packageManager": "pnpm@10.33.4", "scripts": { @@ -41,7 +41,7 @@ "@react-native-firebase/app": "^21.12.2", "@react-native-firebase/crashlytics": "^21.12.2", "@react-native-masked-view/masked-view": "^0.3.1", - "@react-native-picker/picker": "2.11.1", + "@react-native-picker/picker": "2.11.4", "@react-native/codegen": "^0.80.0", "@react-navigation/drawer": "^7.5.5", "@react-navigation/elements": "^2.6.1", diff --git a/patches/@rocket.chat+sdk+1.3.3-mobile.patch b/patches/@rocket.chat+sdk+1.3.3-mobile.patch index a5462330ed5..3cac42dab33 100644 --- a/patches/@rocket.chat+sdk+1.3.3-mobile.patch +++ b/patches/@rocket.chat+sdk+1.3.3-mobile.patch @@ -1,8 +1,51 @@ diff --git a/node_modules/@rocket.chat/sdk/lib/drivers/ddp.ts b/node_modules/@rocket.chat/sdk/lib/drivers/ddp.ts -index 19d31ae..3f33982 100644 +index 19d31ae..07bda5f 100644 --- a/node_modules/@rocket.chat/sdk/lib/drivers/ddp.ts +++ b/node_modules/@rocket.chat/sdk/lib/drivers/ddp.ts -@@ -175,15 +175,115 @@ export class Socket extends EventEmitter { +@@ -101,9 +101,25 @@ export class Socket extends EventEmitter { + this.logger.error(err) + return reject(err) + } ++ // Tear down the previous connection before replacing it. ++ // The `this.connected` early-return above means we only reach here when the ++ // existing socket isn't healthy, so detaching its handlers and closing it stops ++ // a stale or still-connecting socket from later firing onClose and clobbering the ++ // live connection. Mirrors forceReopen's teardown. ++ if (this.connection) { ++ try { ++ this.connection.onopen = null as any ++ this.connection.onmessage = null as any ++ this.connection.onerror = null as any ++ this.connection.onclose = null as any ++ this.connection.close(userDisconnectCloseCode) ++ } catch (err) { ++ this.logger.debug(`[ddp] open: previous connection teardown failed: ${(err as Error).message}`) ++ } ++ } + this.connection = connection + this.connection.onmessage = this.onMessage.bind(this) +- this.connection.onclose = this.onClose.bind(this) ++ this.connection.onclose = (ev: any) => this.onClose(ev, connection) // pass closing socket so onClose can compare identity + this.connection.onopen = this.onOpen.bind(this, resolve) + this.emit('connecting') + }) +@@ -125,7 +141,14 @@ export class Socket extends EventEmitter { + } + + /** Emit close event so it can be used for promise resolve in close() */ +- onClose = (e: any) => { ++ onClose = (e: any, closedConnection?: WebSocket) => { ++ // Ignore close events from a socket we've already replaced (an ++ // orphan). Only the current connection's close should flip app state or trigger a ++ // reopen; otherwise a zombie socket's late close clobbers the live connection and ++ // the app falsely shows "Waiting for network". ++ if (closedConnection && closedConnection !== this.connection) { ++ return ++ } + this.emit('close', e) + try { + if (e?.code !== userDisconnectCloseCode) { +@@ -175,15 +198,115 @@ export class Socket extends EventEmitter { return Promise.resolve() } @@ -125,7 +168,7 @@ index 19d31ae..3f33982 100644 } /** Clear connection and try to connect again. */ -@@ -549,7 +649,9 @@ export class DDPDriver extends EventEmitter implements ISocket, IDriver { +@@ -549,7 +672,9 @@ export class DDPDriver extends EventEmitter implements ISocket, IDriver { 'uiInteraction', 'e2ekeyRequest', 'userData', diff --git a/patches/expo-file-system+19.0.21.patch b/patches/expo-file-system+19.0.21.patch index 3a46543db5b..8633be6f991 100644 --- a/patches/expo-file-system+19.0.21.patch +++ b/patches/expo-file-system+19.0.21.patch @@ -23,4 +23,4 @@ index f6d43bf..197bebf 100644 + @SuppressLint("WrongConstant", "DiscouragedApi") override fun definition() = ModuleDefinition { - Name("ExponentFileSystem") \ No newline at end of file + Name("ExponentFileSystem") diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 510de2a6bf2..862110b7d22 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -64,8 +64,8 @@ importers: specifier: ^0.3.1 version: 0.3.2(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@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) '@react-native-picker/picker': - specifier: 2.11.1 - version: 2.11.1(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@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) + specifier: 2.11.4 + version: 2.11.4(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@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) '@react-native/codegen': specifier: ^0.80.0 version: 0.80.2(@babel/core@7.25.9) @@ -278,7 +278,7 @@ importers: version: 1.6.1(react-native-gesture-handler@2.28.0(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@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))(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@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) react-native-picker-select: specifier: 9.0.1 - version: 9.0.1(@react-native-picker/picker@2.11.1(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@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)) + version: 9.0.1(@react-native-picker/picker@2.11.4(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@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)) react-native-popover-view: specifier: 5.1.7 version: 5.1.7 @@ -2269,8 +2269,8 @@ packages: react: '>=16' react-native: '>=0.57' - '@react-native-picker/picker@2.11.1': - resolution: {integrity: sha512-ThklnkK4fV3yynnIIRBkxxjxR4IFbdMNJVF6tlLdOJ/zEFUEFUEdXY0KmH0iYzMwY8W4/InWsLiA7AkpAbnexA==} + '@react-native-picker/picker@2.11.4': + resolution: {integrity: sha512-Kf8h1AMnBo54b1fdiVylP2P/iFcZqzpMYcglC28EEFB1DEnOjsNr6Ucqc+3R9e91vHxEDnhZFbYDmAe79P2gjA==} peerDependencies: react: '*' react-native: '*' @@ -10178,7 +10178,7 @@ snapshots: react: 19.1.0 react-native: 0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0) - '@react-native-picker/picker@2.11.1(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@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)': + '@react-native-picker/picker@2.11.4(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@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)': dependencies: react: 19.1.0 react-native: 0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0) @@ -15183,9 +15183,9 @@ snapshots: react-native: 0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0) react-native-gesture-handler: 2.28.0(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@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) - react-native-picker-select@9.0.1(@react-native-picker/picker@2.11.1(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@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)): + react-native-picker-select@9.0.1(@react-native-picker/picker@2.11.4(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@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)): dependencies: - '@react-native-picker/picker': 2.11.1(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@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) + '@react-native-picker/picker': 2.11.4(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@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) lodash.isequal: 4.5.0 react-native-popover-view@5.1.7: