Skip to content
Open
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