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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { joinRoom as joinRoomService } from '../../../lib/services/restApi';
import { takeInquiry, takeResume } from '../../../ee/omnichannel/lib';
import { type IRoomViewState } from '../definitions';
import { peekOrCreateRoomStore, releaseRoomStore } from './RoomStore';
import { joinRoom as joinRoomService } from '../../../../lib/services/restApi';
import { takeInquiry, takeResume } from '../../../../ee/omnichannel/lib';
import { type IRoomViewState } from '../../definitions';
import { peekOrCreateRoomStore, releaseRoomStore } from '../../stores/RoomStore';

jest.mock('../../../lib/database', () => ({
jest.mock('../../../../lib/database', () => ({
__esModule: true,
default: {
active: {
Expand All @@ -20,23 +20,23 @@ jest.mock('../../../lib/database', () => ({
}
}
}));
jest.mock('../services/getMessages', () => ({ __esModule: true, default: jest.fn(() => Promise.resolve()) }));
jest.mock('../../../lib/methods/loadThreadMessages', () => ({ loadThreadMessages: jest.fn(() => Promise.resolve()) }));
jest.mock('../../../lib/methods/readMessages', () => ({ readMessages: jest.fn(() => Promise.resolve()) }));
jest.mock('../../../lib/methods/helpers', () => ({
jest.mock('../../services/getMessages', () => ({ __esModule: true, default: jest.fn(() => Promise.resolve()) }));
jest.mock('../../../../lib/methods/loadThreadMessages', () => ({ loadThreadMessages: jest.fn(() => Promise.resolve()) }));
jest.mock('../../../../lib/methods/readMessages', () => ({ readMessages: jest.fn(() => Promise.resolve()) }));
jest.mock('../../../../lib/methods/helpers', () => ({
getUidDirectMessage: jest.fn(),
isGroupChat: jest.fn(() => false),
canAutoTranslate: jest.fn(() => false)
}));
jest.mock('../../../lib/methods/isInviteSubscription', () => ({ isInviteSubscription: jest.fn(() => false) }));
jest.mock('../../../lib/methods/helpers/log', () => ({
jest.mock('../../../../lib/methods/isInviteSubscription', () => ({ isInviteSubscription: jest.fn(() => false) }));
jest.mock('../../../../lib/methods/helpers/log', () => ({
__esModule: true,
default: jest.fn(),
logEvent: jest.fn(),
events: {}
}));
jest.mock('../../../lib/services/restApi', () => ({ joinRoom: jest.fn(() => Promise.resolve()), getUserInfo: jest.fn() }));
jest.mock('../../../ee/omnichannel/lib', () => ({
jest.mock('../../../../lib/services/restApi', () => ({ joinRoom: jest.fn(() => Promise.resolve()), getUserInfo: jest.fn() }));
jest.mock('../../../../ee/omnichannel/lib', () => ({
takeInquiry: jest.fn(() => Promise.resolve()),
takeResume: jest.fn(() => Promise.resolve())
}));
Expand Down
9 changes: 0 additions & 9 deletions app/views/RoomView/services/anchorResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,8 @@ import { MessageTypeLoad } from '../../../lib/constants/messageTypeLoad';
import { tsToMs } from '../../../lib/dayjs';
import { type AnchorMessage } from '../definitions';

// Pure anchor math for the bounded Message Window — no React, no DB (unit-tested with plain objects).
// Returns an upper ts bound (ms): null = Live Window, finite = Anchored Window.
export const isNewerLoader = (message: AnchorMessage): boolean => message.t === MessageTypeLoad.NEXT_CHUNK;

// Nearest Newer Loader ABOVE the target = upper bracket of its Chunk. null = target absent, or
// contiguous with the Live Tail (stay a Live Window).
export function anchorForTarget(messages: AnchorMessage[], targetId: string): number | null {
const target = messages.find(m => m.id === targetId);
if (!target) {
Expand All @@ -30,9 +26,6 @@ export function anchorForTarget(messages: AnchorMessage[], targetId: string): nu
return bound;
}

// anchorForTarget for a freshly-fetched server Chunk. Target present with no Loader above = Chunk
// reaches the Live Tail, stay live (anchoring would pin an unreleasable window, blocking new messages).
// Target absent / empty Chunk = anchor at the target's own ts so the window still re-seeds onto it.
export function anchorForServerChunk(messages: AnchorMessage[], targetId: string, targetTs: Date | number): number | null {
const bound = anchorForTarget(messages, targetId);
if (bound !== null) {
Expand All @@ -42,8 +35,6 @@ export function anchorForServerChunk(messages: AnchorMessage[], targetId: string
return targetInChunk ? null : tsToMs(targetTs);
}

// Max loader ts while any Newer Loader remains (never release across an open Gap); null only once
// none remain. Clamp to currentHighTs so the bound never moves backwards.
export function raiseOrRelease(messages: AnchorMessage[], currentHighTs: number | null): number | null {
const loaders = messages.filter(isNewerLoader).map(m => tsToMs(m.ts));
if (!loaders.length) {
Expand Down
29 changes: 6 additions & 23 deletions app/views/RoomView/services/blockAction.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,13 @@
import { triggerBlockAction } from '../../../lib/methods/triggerActions';
import { ContainerTypes } from '../../../containers/UIKit/interfaces';
import { ContainerTypes, type ITriggerBlockAction } from '../../../containers/UIKit/interfaces';

export const blockAction = ({
actionId,
appId,
value,
blockId,
rid,
mid
}: {
actionId: string;
appId: string;
value: any;
blockId: string;
rid: string;
mid: string;
}): ReturnType<typeof triggerBlockAction> =>
type TBlockActionParams = Omit<ITriggerBlockAction, 'container' | 'mid'> & { mid: string };

export const blockAction = (params: TBlockActionParams): ReturnType<typeof triggerBlockAction> =>
triggerBlockAction({
blockId,
actionId,
value,
mid,
rid,
appId,
...params,
container: {
type: ContainerTypes.MESSAGE,
id: mid
id: params.mid
}
});
8 changes: 0 additions & 8 deletions app/views/RoomView/services/getLocalAnchor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,6 @@ import { MessageTypeLoad } from '../../../lib/constants/messageTypeLoad';
import { tsToMs } from '../../../lib/dayjs';
import { type TAnyMessageModel } from '../../../definitions';

/**
* The single Newer Loader above `aboveTs`: `nearest` = lowest ts (the upper bracket of the target's
* Chunk), `closestToLiveTail` = highest ts (the boundary loader the rejoin climbs toward).
*/
export const findNewerLoaderAbove = async (
rid: string,
aboveTs: Date | number | string,
Expand All @@ -28,10 +24,6 @@ export const findNewerLoaderAbove = async (
return rows[0] ?? null;
};

/**
* ts of the nearest Newer Loader above the target = the upper bracket of its Chunk; null when the
* cached region runs contiguous to the Live Tail (caller falls back to the target's own ts).
*/
const getLocalAnchorTs = async (rid: string, targetTs: Date | number | string): Promise<number | null> => {
const loader = await findNewerLoaderAbove(rid, targetTs, 'nearest');

Expand Down
1 change: 0 additions & 1 deletion app/views/RoomView/services/getMessageInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ const getMessageInfo = async (messageId: string): Promise<TGetMessageInfoResult
rid: message?.subscription?.id,
tmid: message.tmid,
msg: message.msg,
// ts lets a locally-cached but out-of-window target derive its own Anchored Window bound.
ts: message.ts
};
}
Expand Down
10 changes: 2 additions & 8 deletions app/views/RoomView/services/joinRoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,7 @@ import log, { events, logEvent } from '../../../lib/methods/helpers/log';
import { joinRoom as joinRoomService } from '../../../lib/services/restApi';
import { type IJoinRoomContext, type IRoomViewState } from '../definitions';

export const joinRoomImpl = async (
room: IRoomViewState['room'],
{ requestJoinCode, onJoin }: IJoinRoomContext
): Promise<void> => {
export const joinRoom = async (room: IRoomViewState['room'], { requestJoinCode, onJoin }: IJoinRoomContext): Promise<void> => {
logEvent(events.ROOM_JOIN);
try {
if (room.t === 'l') {
Expand All @@ -28,10 +25,7 @@ export const joinRoomImpl = async (
}
};

export const resumeRoomImpl = async (
room: IRoomViewState['room'],
{ onJoin }: Pick<IJoinRoomContext, 'onJoin'>
): Promise<void> => {
export const resumeRoom = async (room: IRoomViewState['room'], onJoin: () => void): Promise<void> => {
logEvent(events.ROOM_RESUME);
try {
if (room.t === 'l') {
Expand Down
23 changes: 8 additions & 15 deletions app/views/RoomView/services/jumpToMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ import getMessageInfo from './getMessageInfo';
import getLocalAnchorTs from './getLocalAnchor';
import { type IJumpToMessageArgs } from '../definitions';

const FABRIC_COMMIT_DELAY = 100;

const waitForFabricCommit = (): Promise<void> =>
new Promise(resolve => {
setTimeout(resolve, FABRIC_COMMIT_DELAY);
});

export const jumpToMessage = async ({
messageId,
isFromReply,
Expand Down Expand Up @@ -42,21 +49,8 @@ export const jumpToMessage = async ({
await navToThread(message);
}
} else if (inThisRoom && t === 'thread' && message.id !== tmid) {
/**
* if the user is within a thread and the message that he is trying to jump to, is a message in the main room
*/
await navToRoom(message);
} else {
/**
* if it's from server, we don't have it saved locally and so we fetch surroundings
* we test if it's not from threads because we're fetching from threads currently with `loadThreadMessages`
*
* The fetched Chunk lets us re-anchor the Message Window onto the target in ONE step: if a
* Newer Loader brackets the target's Chunk it is non-contiguous with the Live Tail, so we
* derive a finite upper ts bound (highTs) for an Anchored Window centered on it. A
* contiguous target resolves to null and stays a Live Window. Thread/local targets are
* never anchored.
*/
const inWindow = listContainerRef.current?.isMessageInWindow(message.id) ?? false;
const highTs = await resolveJumpAnchor(
rid,
Expand All @@ -67,8 +61,7 @@ export const jumpToMessage = async ({
if (isCancelled()) {
return;
}
// Synchronization needed for Fabric to work
await new Promise(res => setTimeout(res, 100));
await waitForFabricCommit();
if (isCancelled()) {
return;
}
Expand Down
26 changes: 10 additions & 16 deletions app/views/RoomView/services/pushThreadRoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,8 @@ export const pushThreadRoom = async ({ rid, item, roomUserId, navigation, onCanc
}

if (item.tmid) {
let name = '';
let jumpToMessageId = '';
if ('id' in item) {
name = 'tmsg' in item ? (item.tmsg ?? '') : '';
jumpToMessageId = item.id;
}
const jumpToMessageId = 'id' in item ? item.id : '';
const knownName = 'id' in item && 'tmsg' in item ? (item.tmsg ?? '') : '';
let cancelled = false;
sendLoadingEvent({
visible: true,
Expand All @@ -36,19 +32,17 @@ export const pushThreadRoom = async ({ rid, item, roomUserId, navigation, onCanc
});
let threadName: string | undefined;
try {
threadName = await fetchThreadName(rid, item.tmid, jumpToMessageId, name);
} finally {
if (!threadName || cancelled) {
sendLoadingEvent({ visible: false });
}
threadName = await fetchThreadName(rid, item.tmid, jumpToMessageId, knownName);
} catch (e) {
sendLoadingEvent({ visible: false });
throw e;
}
if (!threadName || cancelled) {
sendLoadingEvent({ visible: false });
return;
}
name = threadName;
if ('id' in item && 't' in item && item.t === E2E_MESSAGE_TYPE && 'e2e' in item && item.e2e !== E2E_STATUS.DONE) {
name = I18n.t('Encrypted_message');
}
const isUndecryptable =
'id' in item && 't' in item && item.t === E2E_MESSAGE_TYPE && 'e2e' in item && item.e2e !== E2E_STATUS.DONE;
if (!jumpToMessageId) {
setTimeout(() => {
sendLoadingEvent({ visible: false });
Expand All @@ -57,7 +51,7 @@ export const pushThreadRoom = async ({ rid, item, roomUserId, navigation, onCanc
return navigation.push('RoomView', {
rid,
tmid: item.tmid,
name,
name: isUndecryptable ? I18n.t('Encrypted_message') : threadName,
t: SubscriptionType.THREAD,
roomUserId,
jumpToMessageId
Expand Down
21 changes: 3 additions & 18 deletions app/views/RoomView/services/resolveJumpAnchor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,6 @@ import { tsToMs } from '../../../lib/dayjs';
import { anchorForServerChunk } from './anchorResolver';
import { type AnchorMessage, type IJumpAnchorDeps, type IJumpTarget } from '../definitions';

/**
* Upper ts bound (ms) for the re-seeded window, or null to stay on the Live Tail.
* Null for thread targets, missing rid, or inWindow. Otherwise:
* - fromServer: fetches one Chunk; a Newer Loader brackets the target (null if contiguous to Live Tail).
* - cached: reuses the existing Newer Loader bracket, or falls back to the target's own ts.
*/
export const resolveJumpAnchor = async (
rid: string | undefined,
target: IJumpTarget,
Expand All @@ -26,22 +20,13 @@ export const resolveJumpAnchor = async (
t: m.t,
ts: tsToMs(m.ts)
}));
const bound = anchorForServerChunk(anchorMessages, target.id, tsToMs(target.ts));
if (__DEV__ && bound !== null) {
const collisions = anchorMessages.filter(m => tsToMs(m.ts) === bound).length;
if (collisions > 1) {
console.warn(`[RoomView] jump anchor ts shared by ${collisions} rows; may land on wrong message`);
}
}
return bound;
return anchorForServerChunk(anchorMessages, target.id, tsToMs(target.ts));
}

// The local path can't detect the equal-ts collision the server path warns about above:
// getLocalAnchorTs returns only a scalar ts, so two cached rows sharing it are indistinguishable here.
const localAnchor = await deps.getLocalAnchorTs(rid, target.ts);
if (localAnchor != null) {
return localAnchor;
}
const ms = tsToMs(target.ts);
return Number.isFinite(ms) ? ms : null;
const targetMs = tsToMs(target.ts);
return Number.isFinite(targetMs) ? targetMs : null;
};
1 change: 0 additions & 1 deletion app/views/RoomView/services/sendRoomMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ interface ISendRoomMessageParams {
tmid?: string;
user: Parameters<typeof sendMessage>[3];
tshow?: boolean;
// The screen owns `lastSeen`, so clearing its unread divider on a successful send is a callback.
onMessageSent: () => void;
resetAction: () => void;
}
Expand Down
19 changes: 9 additions & 10 deletions app/views/RoomView/stores/ComposerStore.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,10 @@ import { type ComposerState, type ComposerStore, type TComposerExternalState } f
import { useRoomWithUpdateFromStore } from './RoomStoreContext';

export const createComposerStore = (initial: TComposerExternalState) =>
createStore<ComposerState>()((set, get) => ({
createStore<ComposerState>()(set => ({
...initial,
isAutocompleteVisible: false,
updateAutocompleteVisible: (updatedAutocompleteVisible: boolean) => {
if (updatedAutocompleteVisible !== get().isAutocompleteVisible) {
set({ isAutocompleteVisible: updatedAutocompleteVisible });
}
}
updateAutocompleteVisible: (isAutocompleteVisible: boolean) => set({ isAutocompleteVisible })
}));

export const ComposerStoreContext = createContext<ComposerStore | null>(null);
Expand All @@ -30,11 +26,14 @@ const useComposerStore = <T,>(selector: (state: ComposerState) => T): T => useSt
export const ComposerProvider = ({ children, ...state }: { children: ReactNode } & TComposerExternalState): ReactElement => {
const [store] = useState(() => createComposerStore(state));

// `state` is exactly TComposerExternalState (children is destructured out), so this syncs every
// externally-suppliable field and none of the store-owned ones (isAutocompleteVisible/updateAutocompleteVisible).
// React Compiler keeps `state` referentially stable until a field changes, so the sync fires only then.
useEffect(() => {
store.setState(state);
const current = store.getState();
const changedFields = Object.fromEntries(
Object.entries(state).filter(([field, value]) => current[field as keyof ComposerState] !== value)
) as Partial<ComposerState>;
if (Object.keys(changedFields).length) {
store.setState(changedFields);
}
}, [store, state]);

return <ComposerStoreContext.Provider value={store}>{children}</ComposerStoreContext.Provider>;
Expand Down
8 changes: 0 additions & 8 deletions app/views/RoomView/stores/RoomScreenContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,6 @@ import { createContext, useContext } from 'react';

import { type IRoomScreenContextValue } from '../definitions';

// The RoomView screen's own state, as opposed to the room's. Opening a thread mounts a second
// RoomView on the parent's rid, so both screens share one rid-keyed RoomStore — anything that must
// differ between them cannot live there. `loading` is one screen's init run, and `lastSeen` (the
// unread divider anchor) is one screen's divider, so a send from the thread screen clears its own
// anchor and leaves the room screen's divider where it was.
//
// This context is per RoomView instance, not per rid: each screen provides its own value to its own
// subtree.
export const RoomScreenContext = createContext<IRoomScreenContextValue | null>(null);

export const useRoomScreen = (): IRoomScreenContextValue => {
Expand Down
Loading
Loading