Skip to content
Open
Show file tree
Hide file tree
Changes from 11 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
22 changes: 22 additions & 0 deletions UBIQUITOUS_LANGUAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,22 @@
| **Pinned** | Message flagged as important and pinned to the Room by a user | Bookmarked |
| **Starred** | Message bookmarked by the current user for personal reference | Saved |

## Message Loading

| Term | Definition | Aliases to avoid |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ---------------------- |
| **Message Window** | The contiguous range of Messages the Room view currently observes and renders (distinct from what is synced to the database) | Page, feed |
| **Live Tail** | The newest end of a Room's Messages; a Message Window at the Live Tail receives new Messages automatically | Bottom, latest |
| **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 | — |
| **Anchored Window** | A Message Window pinned around a Jump to Message target instead of the Live Tail; deliberately does not follow new Messages | — |
| **Chunk** | A contiguous run of Messages synced from the server into the local database, bracketed by Loader Rows where more exists | Batch, page |
| **Gap** | A region between two Chunks where Messages exist on the server but not yet locally; represented by a Loader Row | Hole |
| **Loader Row** | A placeholder Message record marking a Gap; becoming visible triggers a server fetch | Load-more, spinner row |
| **Older Loader** | A Loader Row marking older Messages (types `MORE`, `PREVIOUS_CHUNK`) — resolving it fetches Messages before it | Load previous |
| **Newer Loader** | A Loader Row marking newer Messages (type `NEXT_CHUNK`) — resolving it fetches Messages after it | Load next |
| **Room History** | Older Messages of a Room fetched on demand from the server (distinct from **Server History**) | Message history |
| **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 |

## Users & Roles

| Term | Definition | Aliases to avoid |
Expand Down Expand Up @@ -130,6 +146,9 @@
- An **Omnichannel Room** connects exactly one **Visitor** with zero or one **Agents** (via **Served By**)
- An **Agent** belongs to one or more **Departments**
- An **Inquiry** becomes an **Omnichannel Room** when picked up by an **Agent**
- A **Room** view shows a **Live Window** by default; a **Jump to Message** replaces it with an **Anchored Window**
- A **Gap** is bracketed by **Loader Rows**; resolving a Loader Row fetches a **Chunk** and may shrink or close the Gap
- **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

## Example dialogue

Expand All @@ -147,3 +166,6 @@
- **"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**.
- **"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'`).
- **"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.
- **"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**.
- **"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.
- **"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".
6 changes: 5 additions & 1 deletion app/lib/services/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,11 @@ class Sdk {
methodCall(...args: any[]): Promise<any> {
return new Promise(async (resolve, reject) => {
try {
const result = await this.current.methodCall(...args, this.code || '');
// Only append the TOTP code when a 2FA flow is in progress. Appending an empty
// string unconditionally pushes a junk trailing positional arg into every method
// call, which breaks methods whose signature grows a typed trailing param
// (e.g. loadSurroundingMessages' `showThreadMessages: boolean`).
const result = await this.current.methodCall(...args, ...(this.code ? [this.code] : []));
return resolve(result);
} catch (e: any) {
if (e.error && (e.error === 'totp-required' || e.error === 'totp-invalid')) {
Expand Down
13 changes: 9 additions & 4 deletions app/views/RoomView/List/components/List.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,24 @@ const styles = StyleSheet.create({
}
});

const List = ({ listRef, jumpToBottom, ...props }: IListProps) => {
const [visible, setVisible] = useState(false);
const List = ({ listRef, jumpToBottom, isAnchored, ...props }: IListProps) => {
const [scrolledPastLimit, setScrolledPastLimit] = useState(false);
const { isAutocompleteVisible } = useRoomContext();
const scrollHandler = useAnimatedScrollHandler({
onScroll: event => {
if (event.contentOffset.y > SCROLL_LIMIT) {
scheduleOnRN(setVisible, true);
scheduleOnRN(setScrolledPastLimit, true);
} else {
scheduleOnRN(setVisible, false);
scheduleOnRN(setScrolledPastLimit, false);
}
}
});

// In a Live Window the FAB tracks scroll distance from the Live Tail. In an Anchored (historical)
// Window the loaded rows' bottom edge is NOT the Live Tail, so offset alone would hide the FAB right
// at the "Load newer" boundary — leaving no one-tap way back to live. Force it visible while anchored.
const visible = scrolledPastLimit || !!isAnchored;

const isScreenReaderEnabled = useIsScreenReaderEnabled();

const renderScrollComponent = !isIOS && (isScreenReaderEnabled || isExternalKeyboardConnected());
Expand Down
12 changes: 11 additions & 1 deletion app/views/RoomView/List/definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,21 @@ export type TMessagesIdsRef = RefObject<string[]>;
export interface IListProps extends FlatListProps<TAnyMessageModel> {
listRef: TListRef;
jumpToBottom: () => void;
// True while the Message Window is an Anchored (historical) Window. The bottom edge of the loaded
// rows is NOT the Live Tail there, so the scroll-offset heuristic alone would hide the jump-to-bottom
// FAB exactly where the user needs it. Keep it visible whenever anchored so "back to live" is one tap.
isAnchored?: boolean;
}

export interface IListContainerRef {
jumpToMessage: (messageId: string) => Promise<void>;
// highTs is the upper ts bound (ms) for an Anchored Window centered on the target's Chunk, or
// null/undefined to keep a Live Window (contiguous / thread / local targets).
jumpToMessage: (messageId: string, highTs?: number | null) => Promise<void>;
cancelJumpToMessage: () => void;
// True when messageId is in the currently-rendered Message Window. Lets the jump orchestration skip
// re-anchoring (and the visible re-seed) for an already-visible target, so a quoted reply to a nearby
// message still scrolls in place and the Live Tail is left intact.
isMessageInWindow: (messageId: string) => boolean;
}

export interface IListContainerProps {
Expand Down
69 changes: 69 additions & 0 deletions app/views/RoomView/List/hooks/anchorResolver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { MessageTypeLoad } from '../../../../lib/constants/messageTypeLoad';
import { anchorForTarget, raiseOrRelease, type AnchorMessage } from './anchorResolver';

const at = (id: string, ts: number, t?: string): AnchorMessage => ({ id, ts, t });

const newerLoader = (id: string, ts: number): AnchorMessage => ({ id, ts, t: MessageTypeLoad.NEXT_CHUNK });

describe('anchorForTarget', () => {
it('returns null when the target is contiguous with the Live Tail (no Newer Loader above it)', () => {
const messages: AnchorMessage[] = [at('target', 1000), at('newer', 2000), newerLoader('older-loader', 500)];
expect(anchorForTarget(messages, 'target')).toBeNull();
});

it('returns the bounding Newer Loader ts (ms) when one sits above the target', () => {
const messages: AnchorMessage[] = [at('target', 1000), newerLoader('next-chunk', 1500)];
expect(anchorForTarget(messages, 'target')).toBe(1500);
});

it('chooses the nearest Newer Loader above the target when several exist', () => {
const messages: AnchorMessage[] = [
at('target', 1000),
newerLoader('far', 5000),
newerLoader('near', 1200),
newerLoader('mid', 3000)
];
expect(anchorForTarget(messages, 'target')).toBe(1200);
});

it('ignores Newer Loaders at or below the target ts', () => {
const messages: AnchorMessage[] = [at('target', 1000), newerLoader('below', 800), newerLoader('above', 2000)];
expect(anchorForTarget(messages, 'target')).toBe(2000);
});

it('returns null when the target is absent', () => {
const messages: AnchorMessage[] = [at('a', 1000), newerLoader('loader', 2000)];
expect(anchorForTarget(messages, 'missing')).toBeNull();
});

it('normalizes ts whether given as a Date or a number', () => {
const target: AnchorMessage = { id: 'target', ts: new Date(1000) };
const loader: AnchorMessage = { id: 'loader', ts: new Date(1500), t: MessageTypeLoad.NEXT_CHUNK };
expect(anchorForTarget([target, loader], 'target')).toBe(1500);
});
});

describe('raiseOrRelease', () => {
it('raises the bound to the Newer Loader nearest the Live Tail when one is present', () => {
const messages: AnchorMessage[] = [at('m', 1000), newerLoader('a', 2000), newerLoader('b', 4000)];
expect(raiseOrRelease(messages, 1500)).toBe(4000);
});

it('releases to a Live Window (null) when no Newer Loader remains — the Gap to the Live Tail has closed', () => {
const messages: AnchorMessage[] = [at('m', 1000), at('n', 2000)];
expect(raiseOrRelease(messages, 1500)).toBeNull();
});

it('never releases across an open Gap: returns non-null while a Newer Loader exists', () => {
const messages: AnchorMessage[] = [at('m', 1000), newerLoader('loader', 2000)];
expect(raiseOrRelease(messages, 1500)).not.toBeNull();
});

it('normalizes ts whether given as a Date or a number', () => {
const messages: AnchorMessage[] = [
{ id: 'loader', ts: new Date(3000), t: MessageTypeLoad.NEXT_CHUNK },
{ id: 'm', ts: new Date(1000) }
];
expect(raiseOrRelease(messages, 500)).toBe(3000);
});
});
74 changes: 74 additions & 0 deletions app/views/RoomView/List/hooks/anchorResolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { MessageTypeLoad } from '../../../../lib/constants/messageTypeLoad';

/**
* Pure anchor-resolver for the bounded Message Window.
*
* The Room view observation can carry an optional UPPER `ts` bound (`highTs`). When it is
* `null` the window is a Live Window (newest-first, follows the Live Tail). When it is a finite
* number (ms since epoch) the window is an Anchored Window pinned below the Live Tail.
*
* This module decides what that bound should be, purely from the currently-visible rows:
* - `anchorForTarget` picks the bound for a fresh Jump to Message onto a target.
* - `raiseOrRelease` climbs the bound toward the Live Tail as Newer Loaders are consumed, and
* releases to a Live Window only once the Gap to the Live Tail has fully closed.
*
* It is intentionally free of React and the database so it can be unit-tested with plain objects.
* A Newer Loader is a row whose `t === MessageTypeLoad.NEXT_CHUNK` (see UBIQUITOUS_LANGUAGE.md).
*/
export interface AnchorMessage {
id: string;
t?: string | null;
ts: Date | number;
}

const toMs = (ts: Date | number): number => (ts instanceof Date ? ts.getTime() : Number(ts));

const isNewerLoader = (message: AnchorMessage): boolean => message.t === MessageTypeLoad.NEXT_CHUNK;

/**
* Resolve the upper bound for a Jump to Message onto `targetId`.
*
* Returns the ts (ms) of the nearest Newer Loader sitting ABOVE the target — the upper bracket of
* the target's Chunk. Returns `null` when the target is absent, or when no Newer Loader sits above
* it (the target is contiguous with the Live Tail, so the window should stay a Live Window).
*/
export function anchorForTarget(messages: AnchorMessage[], targetId: string): number | null {
const target = messages.find(m => m.id === targetId);
if (!target) {
return null;
}

const targetTs = toMs(target.ts);
let bound: number | null = null;

for (const message of messages) {
if (!isNewerLoader(message)) {
continue;
}
const ts = toMs(message.ts);
if (ts > targetTs && (bound === null || ts < bound)) {
bound = ts;
}
}

return bound;
}

/**
* Climb the bound toward the Live Tail, or release the window to live.
*
* Returns the ts (ms) of the Newer Loader nearest the Live Tail (the maximum) while any Newer
* Loader is still present — this guarantees we NEVER release across an open Gap. Returns `null`
* only once no Newer Loader remains, i.e. the Gap to the Live Tail has closed.
*
* `currentHighTs` is part of the signature for symmetry and to document the monotonic climb: in
* practice the returned value is >= `currentHighTs`.
*/
export function raiseOrRelease(messages: AnchorMessage[], currentHighTs: number | null): number | null {
const loaders = messages.filter(isNewerLoader).map(m => toMs(m.ts));
if (!loaders.length) {
return null;
}
// Climb toward the Live Tail; clamp to currentHighTs so the bound never moves backwards.
return Math.max(...loaders, currentHighTs ?? -Infinity);
}
Loading
Loading