Skip to content

Commit b7fd78f

Browse files
authored
feat: bounded message window for centered Jump to Message (#7388)
1 parent f019d9b commit b7fd78f

22 files changed

Lines changed: 2142 additions & 291 deletions

UBIQUITOUS_LANGUAGE.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,22 @@
3636
| **Pinned** | Message flagged as important and pinned to the Room by a user | Bookmarked |
3737
| **Starred** | Message bookmarked by the current user for personal reference | Saved |
3838

39+
## Message Loading
40+
41+
| Term | Definition | Aliases to avoid |
42+
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ---------------------- |
43+
| **Message Window** | The contiguous range of Messages the Room view currently observes and renders (distinct from what is synced to the database) | Page, feed |
44+
| **Live Tail** | The newest end of a Room's Messages; a Message Window at the Live Tail receives new Messages automatically | Bottom, latest |
45+
| **Live Window** | A Message Window whose newest edge is the Live Tail — grows older as you scroll up and follows new Messages at the bottom ||
46+
| **Anchored Window** | A Message Window pinned around a Jump to Message target instead of the Live Tail; deliberately does not follow new Messages ||
47+
| **Chunk** | A contiguous run of Messages synced from the server into the local database, bracketed by Loader Rows where more exists | Batch, page |
48+
| **Gap** | A region between two Chunks where Messages exist on the server but not yet locally; represented by a Loader Row | Hole |
49+
| **Loader Row** | A placeholder Message record marking a Gap; becoming visible triggers a server fetch | Load-more, spinner row |
50+
| **Older Loader** | A Loader Row marking older Messages (types `MORE`, `PREVIOUS_CHUNK`) — resolving it fetches Messages before it | Load previous |
51+
| **Newer Loader** | A Loader Row marking newer Messages (type `NEXT_CHUNK`) — resolving it fetches Messages after it | Load next |
52+
| **Room History** | Older Messages of a Room fetched on demand from the server (distinct from **Server History**) | Message history |
53+
| **Jump to Message** | Re-position the Room view onto a target Message that may be far from the Live Tail or not yet synced — fetches a surrounding Chunk | Scroll to message |
54+
3955
## Users & Roles
4056

4157
| Term | Definition | Aliases to avoid |
@@ -130,6 +146,9 @@
130146
- An **Omnichannel Room** connects exactly one **Visitor** with zero or one **Agents** (via **Served By**)
131147
- An **Agent** belongs to one or more **Departments**
132148
- An **Inquiry** becomes an **Omnichannel Room** when picked up by an **Agent**
149+
- A **Room** view shows a **Live Window** by default; a **Jump to Message** replaces it with an **Anchored Window**
150+
- A **Gap** is bracketed by **Loader Rows**; resolving a Loader Row fetches a **Chunk** and may shrink or close the Gap
151+
- **Jump to Message** fetches a **Chunk** centered on the target (`loadSurroundingMessages`), bracketed by an **Older Loader** and a **Newer Loader** when more Messages exist on either side
133152

134153
## Example dialogue
135154

@@ -147,3 +166,6 @@
147166
- **"Account"** is sometimes used loosely to mean either **User** (the identity) or **Server** (the connected instance). These are distinct: a **User** authenticates on a **Server**.
148167
- **"Channel"** in everyday speech can mean any Room, but in domain terms it strictly means a public Room (type `'c'`). A private Room is a **Group** (type `'p'`).
149168
- **"Forward"** in omnichannel context means **Transfer** (reassigning a room to another agent/department). The codebase uses both `forwardRoom` and "transfer" — prefer **Transfer** as the domain term.
169+
- **"History"** is overloaded: **Server History** is the recent-Servers reconnection list; **Room History** is older Messages fetched on demand. The action `roomHistoryRequest` and saga `ROOM.HISTORY_REQUEST` refer to **Room History**.
170+
- **"Window"** is used metaphorically in the Subscriptions dialogue ("a Subscription is the user's window into it"); a **Message Window** is the concrete observed Message range in the Room view. Disambiguate when both could be meant.
171+
- **"Load more"** is directional: older Messages are an **Older Loader** (`MORE`/`PREVIOUS_CHUNK`), newer Messages are a **Newer Loader** (`NEXT_CHUNK`). Avoid bare "load more".

app/lib/dayjs/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,8 @@ dayjs.extend(calendar);
3737
dayjs.extend(relativeTime);
3838
dayjs.extend(localizedFormat);
3939

40+
// WatermelonDB hands a message `ts` back as Date, ms number, or ISO string depending on the read path;
41+
// dayjs parses all three where Number(ts) / new Date(ts) each NaN on one. Normalize to ms since epoch.
42+
export const tsToMs = (ts: Date | number | string): number => dayjs(ts).valueOf();
43+
4044
export default dayjs;

app/views/RoomView/List/components/List.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,19 +23,22 @@ const styles = StyleSheet.create({
2323
}
2424
});
2525

26-
const List = ({ listRef, jumpToBottom, ...props }: IListProps) => {
27-
const [visible, setVisible] = useState(false);
26+
const List = ({ listRef, jumpToBottom, isAnchored, ...props }: IListProps) => {
27+
const [scrolledPastLimit, setScrolledPastLimit] = useState(false);
2828
const { isAutocompleteVisible } = useRoomContext();
2929
const scrollHandler = useAnimatedScrollHandler({
3030
onScroll: event => {
3131
if (event.contentOffset.y > SCROLL_LIMIT) {
32-
scheduleOnRN(setVisible, true);
32+
scheduleOnRN(setScrolledPastLimit, true);
3333
} else {
34-
scheduleOnRN(setVisible, false);
34+
scheduleOnRN(setScrolledPastLimit, false);
3535
}
3636
}
3737
});
3838

39+
// Anchored window: loaded rows' bottom edge isn't the Live Tail, so force the FAB visible to keep a path back to live.
40+
const visible = scrolledPastLimit || !!isAnchored;
41+
3942
const isScreenReaderEnabled = useIsScreenReaderEnabled();
4043

4144
const renderScrollComponent = !isIOS && (isScreenReaderEnabled || isExternalKeyboardConnected());
@@ -57,7 +60,7 @@ const List = ({ listRef, jumpToBottom, ...props }: IListProps) => {
5760
: undefined
5861
}
5962
removeClippedSubviews={isIOS}
60-
initialNumToRender={7}
63+
initialNumToRender={20}
6164
onEndReachedThreshold={0.5}
6265
maxToRenderPerBatch={5}
6366
windowSize={10}
Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
11
export const QUERY_SIZE = 50;
22
export const MAX_AUTO_LOADS = 10;
33

4-
export const VIEWABILITY_CONFIG = {
5-
itemVisiblePercentThreshold: 10
6-
};
7-
84
export const SCROLL_LIMIT = 200;
95

106
export const EDGE_DISTANCE = 15;

app/views/RoomView/List/definitions.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,19 @@ export type TMessagesIdsRef = RefObject<string[]>;
1111
export interface IListProps extends FlatListProps<TAnyMessageModel> {
1212
listRef: TListRef;
1313
jumpToBottom: () => void;
14+
// Anchored Window: loaded rows' bottom isn't the Live Tail, so the scroll-offset
15+
// heuristic alone would hide the jump-to-bottom FAB. Keep it visible so "back to live" stays one tap.
16+
isAnchored?: boolean;
1417
}
1518

1619
export interface IListContainerRef {
17-
jumpToMessage: (messageId: string) => Promise<void>;
20+
// highTs: upper ts bound (ms) for an Anchored Window on the target's Chunk; null/undefined keeps a
21+
// Live Window (contiguous / thread / local targets).
22+
jumpToMessage: (messageId: string, highTs?: number | null) => Promise<void>;
1823
cancelJumpToMessage: () => void;
24+
// True when messageId is in the rendered window, so the orchestration skips re-anchoring for an
25+
// already-visible target (a quoted reply nearby scrolls in place, Live Tail intact).
26+
isMessageInWindow: (messageId: string) => boolean;
1927
}
2028

2129
export interface IListContainerProps {

app/views/RoomView/List/hooks/useMessages.test.tsx

Lines changed: 233 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,12 @@ describe('useMessages', () => {
6767
let emitVisibleRows: ((rows: TAnyMessageModel[]) => void) | null;
6868
let queryCalls: unknown[][];
6969
let unsubscribeSpies: jest.Mock[];
70+
// Rows served to the targeted one-shot read used by the rejoin raise (the region above the current
71+
// bound that the bounded observation cannot see). Each call to query(...).fetch() captures its clauses.
72+
let fetchRows: TAnyMessageModel[];
73+
let fetchCalls: unknown[][];
74+
// Count returned by the release path's fetchCount() (number of cached rows above the old bound).
75+
let fetchCountValue: number;
7076

7177
const wrapper = ({ children }: { children: ReactNode }) => <Provider store={mockedStore}>{children}</Provider>;
7278

@@ -75,6 +81,9 @@ describe('useMessages', () => {
7581
emitVisibleRows = null;
7682
queryCalls = [];
7783
unsubscribeSpies = [];
84+
fetchRows = [];
85+
fetchCalls = [];
86+
fetchCountValue = 0;
7887
jest.clearAllMocks();
7988
// Reset historyLoaders so prior test dispatches don't trip the in-flight guard
8089
mockedStore
@@ -95,7 +104,14 @@ describe('useMessages', () => {
95104
unsubscribeSpies.push(unsubscribe);
96105
return { unsubscribe };
97106
}
98-
})
107+
}),
108+
// Targeted one-shot read for the rejoin raise (region above the current bound).
109+
fetch: jest.fn(() => {
110+
fetchCalls.push(args);
111+
return Promise.resolve(fetchRows);
112+
}),
113+
// Count of cached rows above the old bound, read by the release path to size the Live Window.
114+
fetchCount: jest.fn(() => Promise.resolve(fetchCountValue))
99115
};
100116
})
101117
}));
@@ -520,4 +536,220 @@ describe('useMessages', () => {
520536
});
521537
expect(mockReadThreads).not.toHaveBeenCalled();
522538
});
539+
540+
const findBoundClause = (clauses: unknown[]) =>
541+
clauses.find(
542+
(clause): clause is { type: 'where'; left: string; comparison: { operator: string; right: { value: number } } } =>
543+
!!clause &&
544+
typeof clause === 'object' &&
545+
(clause as { type?: string }).type === 'where' &&
546+
(clause as { left?: string }).left === 'ts' &&
547+
(clause as { comparison?: { operator?: string } }).comparison?.operator === 'lte'
548+
);
549+
550+
it('does not apply an upper-bound ts clause when highTs is null (default Live Window)', async () => {
551+
emittedRows = [msg({ id: 'm1' })];
552+
renderUseMessages();
553+
await waitFor(() => {
554+
expect(queryCalls.length).toBeGreaterThan(0);
555+
});
556+
expect(findBoundClause(queryCalls[queryCalls.length - 1])).toBeUndefined();
557+
});
558+
559+
it('applies the upper-bound ts clause only after an anchor is set, with take still last', async () => {
560+
emittedRows = [msg({ id: 'm1' })];
561+
const { result } = renderUseMessages();
562+
await waitFor(() => {
563+
expect(queryCalls.length).toBeGreaterThan(0);
564+
});
565+
566+
// Default Live Window: no bound clause.
567+
expect(findBoundClause(queryCalls[queryCalls.length - 1])).toBeUndefined();
568+
569+
act(() => {
570+
result.current[3].setHighTs(1500);
571+
});
572+
573+
await waitFor(() => {
574+
const lastCall = queryCalls[queryCalls.length - 1];
575+
expect(findBoundClause(lastCall)).toBeDefined();
576+
});
577+
578+
const lastCall = queryCalls[queryCalls.length - 1];
579+
const bound = findBoundClause(lastCall);
580+
expect(bound?.comparison.right.value).toBe(1500);
581+
// take must remain the last clause so the existing pagination test stays valid.
582+
expect(lastCall.at(-1)).toEqual(expect.objectContaining({ type: 'take' }));
583+
});
584+
585+
it('seeds the window to a single page (QUERY_SIZE) when an anchor is set rather than growing', async () => {
586+
emittedRows = [msg({ id: 'm1' })];
587+
const { result } = renderUseMessages();
588+
await waitFor(() => {
589+
expect(queryCalls.length).toBeGreaterThan(0);
590+
});
591+
592+
// Grow the Live Window a couple of pages first.
593+
await act(async () => {
594+
await result.current[2]();
595+
});
596+
await act(async () => {
597+
await result.current[2]();
598+
});
599+
600+
act(() => {
601+
result.current[3].setHighTs(1500);
602+
});
603+
604+
await waitFor(() => {
605+
expect(findBoundClause(queryCalls[queryCalls.length - 1])).toBeDefined();
606+
});
607+
608+
const take = queryCalls[queryCalls.length - 1].find(
609+
(clause): clause is { type: 'take'; count: number } =>
610+
!!clause && typeof clause === 'object' && (clause as { type?: string }).type === 'take'
611+
);
612+
expect(take?.count).toBe(QUERY_SIZE);
613+
});
614+
615+
it('exposes highTs and setHighTs as the 4th tuple element', async () => {
616+
emittedRows = [msg({ id: 'm1' })];
617+
const { result } = renderUseMessages();
618+
await waitFor(() => {
619+
expect(queryCalls.length).toBeGreaterThan(0);
620+
});
621+
expect(result.current[3].highTs).toBeNull();
622+
expect(typeof result.current[3].setHighTs).toBe('function');
623+
624+
act(() => {
625+
result.current[3].setHighTs(1500);
626+
});
627+
628+
await waitFor(() => {
629+
expect(result.current[3].highTs).toBe(1500);
630+
});
631+
});
632+
633+
const findTakeClause = (clauses: unknown[]) =>
634+
clauses.find(
635+
(clause): clause is { type: 'take'; count: number } =>
636+
!!clause && typeof clause === 'object' && (clause as { type?: string }).type === 'take'
637+
);
638+
639+
// ms-since-epoch as the model's Date ts. Anchor bounds (highTs) are compared in ms, so a Date
640+
// whose getTime() equals the chosen ms keeps `ts === highTs` boundary detection exact.
641+
const at = (ms: number) => new Date(ms);
642+
// Boundary Newer Loader of the Anchored Window: the row that sits exactly on the bound (ts === highTs).
643+
const newerLoaderAt = (id: string, ms: number) => msg({ id, t: MessageTypeLoad.NEXT_CHUNK, ts: at(ms) });
644+
645+
it('raises the bound and GROWS the window when the boundary Newer Loader is consumed and another remains above', async () => {
646+
emittedRows = [msg({ id: 'm1', ts: at(1000) }), newerLoaderAt('loader-H', 1500)];
647+
const { result } = renderUseMessages();
648+
649+
// Anchor the window at the boundary loader's ts.
650+
act(() => {
651+
result.current[3].setHighTs(1500);
652+
});
653+
await waitFor(() => {
654+
expect(findBoundClause(queryCalls[queryCalls.length - 1])?.comparison.right.value).toBe(1500);
655+
});
656+
const takeBeforeRaise = findTakeClause(queryCalls[queryCalls.length - 1])?.count;
657+
expect(takeBeforeRaise).toBe(QUERY_SIZE);
658+
659+
// The targeted read above the bound reveals the next batch plus a NEW Newer Loader at ts 1900.
660+
fetchRows = [msg({ id: 'm2', ts: at(1700) }), newerLoaderAt('loader-H2', 1900)];
661+
662+
// loadNextMessages REMOVED the boundary loader: re-emit WITHOUT it (still under the old bound).
663+
emitRows([msg({ id: 'm1', ts: at(1000) })]);
664+
665+
// Rejoin RAISE: bound climbs to the surviving loader's ts (1900) AND the window grows by a page.
666+
await waitFor(() => {
667+
expect(result.current[3].highTs).toBe(1900);
668+
});
669+
const lastCall = queryCalls[queryCalls.length - 1];
670+
expect(findBoundClause(lastCall)?.comparison.right.value).toBe(1900);
671+
expect(findTakeClause(lastCall)?.count).toBe(QUERY_SIZE * 2);
672+
});
673+
674+
it('releases the anchor to a Live Window when the boundary Newer Loader is consumed and the Gap has closed', async () => {
675+
emittedRows = [msg({ id: 'm1', ts: at(1000) }), newerLoaderAt('loader-H', 1500)];
676+
const { result } = renderUseMessages();
677+
678+
act(() => {
679+
result.current[3].setHighTs(1500);
680+
});
681+
await waitFor(() => {
682+
expect(findBoundClause(queryCalls[queryCalls.length - 1])?.comparison.right.value).toBe(1500);
683+
});
684+
685+
// The targeted read reveals the next batch but NO new Newer Loader: the Gap to the Live Tail closed.
686+
fetchRows = [msg({ id: 'm2', ts: at(1700) }), msg({ id: 'm3', ts: at(1800) })];
687+
688+
// loadNextMessages consumed the boundary loader: re-emit WITHOUT it.
689+
emitRows([msg({ id: 'm1', ts: at(1000) })]);
690+
691+
// Rejoin RELEASE: bound becomes null → Live Window. The captured query drops the Q.lte clause.
692+
await waitFor(() => {
693+
expect(result.current[3].highTs).toBeNull();
694+
});
695+
expect(findBoundClause(queryCalls[queryCalls.length - 1])).toBeUndefined();
696+
});
697+
698+
it('grows the released Live Window to preserve the reading position instead of snapping to the Live Tail', async () => {
699+
// Anchored deep below a large cached newer island. When the Gap closes and the window releases,
700+
// take(count) must span from the Live Tail down past the original target — otherwise the target is
701+
// evicted and the list snaps to the tail (NATIVE-1229 #3 reading-position loss).
702+
emittedRows = [msg({ id: 'm1', ts: at(1000) }), newerLoaderAt('loader-H', 1500)];
703+
const { result } = renderUseMessages();
704+
705+
act(() => {
706+
result.current[3].setHighTs(1500);
707+
});
708+
await waitFor(() => {
709+
expect(findBoundClause(queryCalls[queryCalls.length - 1])?.comparison.right.value).toBe(1500);
710+
});
711+
const anchoredTake = findTakeClause(queryCalls[queryCalls.length - 1])?.count ?? 0;
712+
713+
// Gap closed (no Newer Loader above the bound), but 120 messages sit above it (the cached island).
714+
fetchRows = [msg({ id: 'm2', ts: at(1700) }), msg({ id: 'm3', ts: at(1800) })];
715+
fetchCountValue = 120;
716+
717+
// loadNextMessages consumed the boundary loader: re-emit without it.
718+
emitRows([msg({ id: 'm1', ts: at(1000) })]);
719+
720+
await waitFor(() => {
721+
expect(result.current[3].highTs).toBeNull();
722+
});
723+
724+
// The released Live Window's take spans the anchored window PLUS the 120 messages above it, so the
725+
// deep target survives the release rather than falling outside take(count).
726+
const releasedTake = findTakeClause(queryCalls[queryCalls.length - 1])?.count ?? 0;
727+
expect(releasedTake).toBeGreaterThanOrEqual(anchoredTake + 120);
728+
});
729+
730+
it('never releases across an open Gap: keeps highTs finite while a Newer Loader survives above the bound', async () => {
731+
emittedRows = [msg({ id: 'm1', ts: at(1000) }), newerLoaderAt('loader-H', 1500)];
732+
const { result } = renderUseMessages();
733+
734+
act(() => {
735+
result.current[3].setHighTs(1500);
736+
});
737+
await waitFor(() => {
738+
expect(findBoundClause(queryCalls[queryCalls.length - 1])?.comparison.right.value).toBe(1500);
739+
});
740+
741+
// The targeted read still shows a Newer Loader above the bound — the Gap is NOT closed.
742+
fetchRows = [msg({ id: 'm2', ts: at(1700) }), newerLoaderAt('loader-H2', 1900)];
743+
744+
// Consume the boundary loader.
745+
emitRows([msg({ id: 'm1', ts: at(1000) })]);
746+
747+
// The Gap is still open, so the window must NOT release to a Live Window: highTs stays finite
748+
// (it climbs to the surviving loader instead of becoming null), and the upper bound persists.
749+
await waitFor(() => {
750+
expect(result.current[3].highTs).toBe(1900);
751+
});
752+
expect(result.current[3].highTs).not.toBeNull();
753+
expect(findBoundClause(queryCalls[queryCalls.length - 1])).toBeDefined();
754+
});
523755
});

0 commit comments

Comments
 (0)