@ttt-productions/chat-react 0.12.8 → 0.12.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -9,6 +9,15 @@ export interface InboxClientState {
|
|
|
9
9
|
hasUnread: boolean;
|
|
10
10
|
/** The set of channelRefs that currently carry an unread dot (per-row dots). */
|
|
11
11
|
unreadChannelRefs: string[];
|
|
12
|
+
/**
|
|
13
|
+
* The last TERMINAL error code (mirrors `ChannelClientState.lastErrorCode`):
|
|
14
|
+
* `'access-denied'` when the inbox grant provider throws `ChatAccessDeniedError`
|
|
15
|
+
* (a genuine, terminal policy "no" — e.g. a banned/suspended account), and
|
|
16
|
+
* `'revoked'` on a 4403 REVOKED close. Null while healthy and across transient
|
|
17
|
+
* reconnects. The app subscribes to inbox state directly (there is no inbox React
|
|
18
|
+
* hook in this package), so this stable code is the surface — it invents no new UI.
|
|
19
|
+
*/
|
|
20
|
+
lastErrorCode: string | null;
|
|
12
21
|
}
|
|
13
22
|
export interface InboxClientConfig {
|
|
14
23
|
endpoint: string;
|
|
@@ -43,6 +52,15 @@ export declare class InboxClient {
|
|
|
43
52
|
private openSocket;
|
|
44
53
|
private onOpen;
|
|
45
54
|
private onClose;
|
|
55
|
+
/**
|
|
56
|
+
* Terminal access denial surfaced by the inbox grant provider
|
|
57
|
+
* (`ChatAccessDeniedError`). Same terminal posture as the 4403 REVOKED close: mark
|
|
58
|
+
* closed-by-us, close the reconnect controller, and surface the stable
|
|
59
|
+
* `access-denied` code. The inbox client tracks NO optimistic/pending sends
|
|
60
|
+
* (mark-read is fire-and-forget with no local pending state), so — unlike
|
|
61
|
+
* ChannelClient — there is nothing to fail here; only the lifecycle stops.
|
|
62
|
+
*/
|
|
63
|
+
private denyAccessTerminally;
|
|
46
64
|
private scheduleReconnect;
|
|
47
65
|
private onMessage;
|
|
48
66
|
private applySnapshot;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"inbox-client.d.ts","sourceRoot":"","sources":["../../src/realtime/inbox-client.ts"],"names":[],"mappings":"AAWA,OAAO,EAOL,KAAK,iBAAiB,EACvB,MAAM,WAAW,CAAC;AACnB,OAAO,KAAK,EAAkB,aAAa,EAAE,MAAM,aAAa,CAAC;AACjE,OAAO,KAAK,EAAE,aAAa,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAGlF,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,cAAc,CAAC;IACvB,uFAAuF;IACvF,QAAQ,EAAE,iBAAiB,EAAE,CAAC;IAC9B,0EAA0E;IAC1E,SAAS,EAAE,OAAO,CAAC;IACnB,gFAAgF;IAChF,iBAAiB,EAAE,MAAM,EAAE,CAAC;
|
|
1
|
+
{"version":3,"file":"inbox-client.d.ts","sourceRoot":"","sources":["../../src/realtime/inbox-client.ts"],"names":[],"mappings":"AAWA,OAAO,EAOL,KAAK,iBAAiB,EACvB,MAAM,WAAW,CAAC;AACnB,OAAO,KAAK,EAAkB,aAAa,EAAE,MAAM,aAAa,CAAC;AACjE,OAAO,KAAK,EAAE,aAAa,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAGlF,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,cAAc,CAAC;IACvB,uFAAuF;IACvF,QAAQ,EAAE,iBAAiB,EAAE,CAAC;IAC9B,0EAA0E;IAC1E,SAAS,EAAE,OAAO,CAAC;IACnB,gFAAgF;IAChF,iBAAiB,EAAE,MAAM,EAAE,CAAC;IAC5B;;;;;;;OAOG;IACH,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9B;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,MAAM,CAAC;IACjB,iFAAiF;IACjF,aAAa,EAAE,MAAM,CAAC;IACtB,4EAA4E;IAC5E,aAAa,EAAE,aAAa,CAAC;IAC7B,aAAa,EAAE,aAAa,CAAC;IAC7B,MAAM,CAAC,EAAE,eAAe,CAAC;IACzB,SAAS,CAAC,EAAE;QAAE,WAAW,CAAC,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,MAAM,CAAA;KAAE,CAAC;CAC/G;AAID,qBAAa,WAAW;IAUV,OAAO,CAAC,QAAQ,CAAC,MAAM;IATnC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAkB;IACzC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAsB;IACjD,OAAO,CAAC,MAAM,CAA+B;IAC7C,OAAO,CAAC,KAAK,CAA6B;IAC1C,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA4C;IACtE,OAAO,CAAC,eAAe,CAAS;IAChC,OAAO,CAAC,cAAc,CAA0D;IAChF,OAAO,CAAC,UAAU,CAAS;gBAEE,MAAM,EAAE,iBAAiB;IAKtD,QAAQ,IAAI,gBAAgB;IAI5B,SAAS,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,gBAAgB,KAAK,IAAI,GAAG,MAAM,IAAI;IAKxD,OAAO,CAAC,QAAQ;IAKV,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;YAOhB,UAAU;IA2BxB,OAAO,CAAC,MAAM;IAQd,OAAO,CAAC,OAAO;IAqBf;;;;;;;OAOG;IACH,OAAO,CAAC,oBAAoB;IAM5B,OAAO,CAAC,iBAAiB;IAczB,OAAO,CAAC,SAAS;IAejB,OAAO,CAAC,aAAa;IAerB,OAAO,CAAC,SAAS;IAMjB,yEAAyE;IACzE,gBAAgB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO;IAI7C;;;;;;;OAOG;IACH,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO;IAIrC,kEAAkE;IAClE,KAAK,IAAI,IAAI;CAad"}
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
// 4401 re-grant once, 4403 stop, auth-switch teardown).
|
|
10
10
|
import { createReconnectController } from '@ttt-productions/realtime-core';
|
|
11
11
|
import { CLIENT_FRAME, CHAT_CLOSE_CODES, buildFrame, parseFrame, isInboxSnapshot, } from './wire.js';
|
|
12
|
-
import { defaultTimers } from './shared.js';
|
|
13
|
-
const INITIAL = { status: 'idle', registry: [], hasUnread: false, unreadChannelRefs: [] };
|
|
12
|
+
import { defaultTimers, isChatAccessDeniedError } from './shared.js';
|
|
13
|
+
const INITIAL = { status: 'idle', registry: [], hasUnread: false, unreadChannelRefs: [], lastErrorCode: null };
|
|
14
14
|
export class InboxClient {
|
|
15
15
|
config;
|
|
16
16
|
timers;
|
|
@@ -49,7 +49,14 @@ export class InboxClient {
|
|
|
49
49
|
try {
|
|
50
50
|
grantToken = await this.config.grantProvider();
|
|
51
51
|
}
|
|
52
|
-
catch {
|
|
52
|
+
catch (err) {
|
|
53
|
+
// A TERMINAL access denial (the app translated an authoritative policy "no"
|
|
54
|
+
// — e.g. Firebase `permission-denied` for a banned/suspended account — into the
|
|
55
|
+
// package-owned ChatAccessDeniedError) must NOT reconnect: the next mint would
|
|
56
|
+
// just re-deny, reconnect-looping forever and warning every cycle. Every other
|
|
57
|
+
// mint failure (network/transient) stays retryable and backs off as before.
|
|
58
|
+
if (isChatAccessDeniedError(err))
|
|
59
|
+
return this.denyAccessTerminally();
|
|
53
60
|
return this.scheduleReconnect();
|
|
54
61
|
}
|
|
55
62
|
if (this.closedByUs)
|
|
@@ -88,11 +95,24 @@ export class InboxClient {
|
|
|
88
95
|
if (code === CHAT_CLOSE_CODES.REVOKED) {
|
|
89
96
|
this.closedByUs = true;
|
|
90
97
|
this.controller.close();
|
|
91
|
-
this.setState({ status: 'closed' });
|
|
98
|
+
this.setState({ status: 'closed', lastErrorCode: 'revoked' });
|
|
92
99
|
return;
|
|
93
100
|
}
|
|
94
101
|
this.scheduleReconnect();
|
|
95
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* Terminal access denial surfaced by the inbox grant provider
|
|
105
|
+
* (`ChatAccessDeniedError`). Same terminal posture as the 4403 REVOKED close: mark
|
|
106
|
+
* closed-by-us, close the reconnect controller, and surface the stable
|
|
107
|
+
* `access-denied` code. The inbox client tracks NO optimistic/pending sends
|
|
108
|
+
* (mark-read is fire-and-forget with no local pending state), so — unlike
|
|
109
|
+
* ChannelClient — there is nothing to fail here; only the lifecycle stops.
|
|
110
|
+
*/
|
|
111
|
+
denyAccessTerminally() {
|
|
112
|
+
this.closedByUs = true;
|
|
113
|
+
this.controller.close();
|
|
114
|
+
this.setState({ status: 'closed', lastErrorCode: 'access-denied' });
|
|
115
|
+
}
|
|
96
116
|
scheduleReconnect() {
|
|
97
117
|
const delay = this.controller.onClose();
|
|
98
118
|
if (delay == null) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"inbox-client.js","sourceRoot":"","sources":["../../src/realtime/inbox-client.ts"],"names":[],"mappings":"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","sourcesContent":["// The Inbox realtime client: a SEPARATE WebSocket (scope `inbox`) to the user's\n// inbox DO. It maintains the channel/invite registry + unread projection that\n// drives the inbox view — DOTS ONLY, no counts (Contract C / \"Unread + inbox\").\n//\n// The inbox DO pushes a full `snapshot` ({ registry, hasUnread }) on connect, on\n// `resume`, and on every live delta (a projection apply). This client just\n// mirrors the latest snapshot into an observable store + a per-channel unread set.\n// Same connection lifecycle as the channel client (subprotocol auth, reconnect,\n// 4401 re-grant once, 4403 stop, auth-switch teardown).\n\nimport { createReconnectController, type ReconnectController } from '@ttt-productions/realtime-core';\nimport {\n CLIENT_FRAME,\n CHAT_CLOSE_CODES,\n buildFrame,\n parseFrame,\n isInboxSnapshot,\n type WireInboxSnapshot,\n type WireRegistryEntry,\n} from './wire.js';\nimport type { RealtimeSocket, SocketFactory } from './socket.js';\nimport type { GrantProvider, TransportTimers, RealtimeStatus } from './shared.js';\nimport { defaultTimers } from './shared.js';\n\nexport interface InboxClientState {\n status: RealtimeStatus;\n /** Active (non-tombstoned) channel/invite registry entries — inbox-view visibility. */\n registry: WireRegistryEntry[];\n /** True if ANY active channel/invite has unread (drives the dock dot). */\n hasUnread: boolean;\n /** The set of channelRefs that currently carry an unread dot (per-row dots). */\n unreadChannelRefs: string[];\n}\n\nexport interface InboxClientConfig {\n endpoint: string;\n /** The authenticated uid (the inbox owner; the DO serves no one else's data). */\n currentUserId: string;\n /** Mints/refreshes an INBOX-scope grant (scope `{ kind:'inbox', uid }`). */\n grantProvider: GrantProvider;\n socketFactory: SocketFactory;\n timers?: TransportTimers;\n reconnect?: { baseDelayMs?: number; maxDelayMs?: number; maxAttempts?: number | null; random?: () => number };\n}\n\nconst INITIAL: InboxClientState = { status: 'idle', registry: [], hasUnread: false, unreadChannelRefs: [] };\n\nexport class InboxClient {\n private readonly timers: TransportTimers;\n private readonly controller: ReconnectController;\n private socket: RealtimeSocket | null = null;\n private state: InboxClientState = INITIAL;\n private readonly listeners = new Set<(s: InboxClientState) => void>();\n private reauthAttempted = false;\n private reconnectTimer: ReturnType<TransportTimers['setTimeout']> | null = null;\n private closedByUs = false;\n\n constructor(private readonly config: InboxClientConfig) {\n this.timers = config.timers ?? defaultTimers;\n this.controller = createReconnectController(config.reconnect ?? {});\n }\n\n getState(): InboxClientState {\n return this.state;\n }\n\n subscribe(fn: (s: InboxClientState) => void): () => void {\n this.listeners.add(fn);\n return () => this.listeners.delete(fn);\n }\n\n private setState(patch: Partial<InboxClientState>): void {\n this.state = { ...this.state, ...patch };\n for (const fn of this.listeners) fn(this.state);\n }\n\n async connect(): Promise<void> {\n this.closedByUs = false;\n this.controller.start();\n this.setState({ status: 'connecting' });\n await this.openSocket();\n }\n\n private async openSocket(): Promise<void> {\n let grantToken: string;\n try {\n grantToken = await this.config.grantProvider();\n } catch {\n return this.scheduleReconnect();\n }\n if (this.closedByUs) return;\n const url = `${this.config.endpoint.replace(/\\/$/, '')}/inbox`;\n this.socket = this.config.socketFactory({\n url,\n grantToken,\n handlers: {\n onOpen: () => this.onOpen(),\n onMessage: (data) => this.onMessage(data),\n onClose: (code, reason) => this.onClose(code, reason),\n onError: () => undefined,\n },\n });\n }\n\n private onOpen(): void {\n this.controller.onOpen();\n this.reauthAttempted = false;\n this.setState({ status: 'open' });\n // The DO sends a snapshot on accept; a `resume` re-requests it after a reconnect gap.\n this.sendFrame(CLIENT_FRAME.RESUME, {});\n }\n\n private onClose(code: number, _reason: string): void {\n this.socket = null;\n if (this.closedByUs) {\n this.setState({ status: 'closed' });\n return;\n }\n if (code === CHAT_CLOSE_CODES.AUTH_EXPIRED && !this.reauthAttempted) {\n this.reauthAttempted = true;\n this.setState({ status: 'reconnecting' });\n void this.openSocket();\n return;\n }\n if (code === CHAT_CLOSE_CODES.REVOKED) {\n this.closedByUs = true;\n this.controller.close();\n this.setState({ status: 'closed' });\n return;\n }\n this.scheduleReconnect();\n }\n\n private scheduleReconnect(): void {\n const delay = this.controller.onClose();\n if (delay == null) {\n this.setState({ status: 'closed' });\n return;\n }\n this.setState({ status: 'reconnecting' });\n this.reconnectTimer = this.timers.setTimeout(() => {\n this.reconnectTimer = null;\n if (this.closedByUs) return;\n void this.openSocket();\n }, delay);\n }\n\n private onMessage(data: string): void {\n const frame = parseFrame(data);\n if (!frame || !frame.payload) return;\n // The inbox DO pushes a full `snapshot`; a future lightweight `unread` push\n // carries either a full snapshot or a `{ hasUnread }` dock-dot patch. Route both\n // instead of silently dropping the `unread` type (C-M2).\n if (frame.type !== 'snapshot' && frame.type !== 'unread') return;\n const payload = frame.payload as unknown as WireInboxSnapshot | { hasUnread: boolean };\n if (isInboxSnapshot(payload as WireInboxSnapshot)) {\n this.applySnapshot(payload as WireInboxSnapshot);\n } else if (frame.type === 'unread' && typeof (payload as { hasUnread?: unknown }).hasUnread === 'boolean') {\n this.setState({ hasUnread: Boolean((payload as { hasUnread: boolean }).hasUnread) });\n }\n }\n\n private applySnapshot(snap: WireInboxSnapshot): void {\n // Keep every non-tombstoned entry — including ARCHIVED rows — so the inbox view can\n // render both the active list and the Archived toggle. `archived` is a distinct\n // dimension from `tombstoned`; archived rows are still `state: 'active'`.\n const registry = snap.registry.filter((e) => e.state === 'active');\n // The DO's `hasUnread` is the authoritative dock dot. Per-row dots are derived from\n // the same projection but exclude ARCHIVED rows (archive = done — an archived row\n // never shows a dot). The DO clears unread on archive, so this is belt-and-braces.\n this.setState({\n registry,\n hasUnread: Boolean(snap.hasUnread),\n unreadChannelRefs: deriveUnreadRefs(snap),\n });\n }\n\n private sendFrame(type: string, payload: Record<string, unknown>): boolean {\n if (!this.socket || !this.socket.isOpen) return false;\n this.socket.send(buildFrame(type, payload));\n return true;\n }\n\n /** True if a specific channel/invite currently carries an unread dot. */\n channelHasUnread(channelRef: string): boolean {\n return this.state.unreadChannelRefs.includes(channelRef);\n }\n\n /**\n * Clear a channel/invite's unread WITHOUT opening it (the inbox-view mark-read\n * controls). Sends `mark-read` on the inbox socket; the inbox DO advances the\n * member's read cursor to tail on the channel DO and then pushes a fresh\n * authoritative snapshot, which is what removes the row — there is NO optimistic\n * local clear, so a failed mark-read honestly leaves the dot in place.\n * Returns false when the socket is not open (the caller may surface a retry).\n */\n markRead(channelRef: string): boolean {\n return this.sendFrame(CLIENT_FRAME.MARK_READ, { channelRef });\n }\n\n /** Permanently close (auth-user switch / unmount). Idempotent. */\n close(): void {\n this.closedByUs = true;\n this.controller.close();\n if (this.reconnectTimer != null) {\n this.timers.clearTimeout(this.reconnectTimer);\n this.reconnectTimer = null;\n }\n if (this.socket) {\n this.socket.close(1000, 'client teardown');\n this.socket = null;\n }\n this.setState({ status: 'closed' });\n }\n}\n\n/**\n * Derive the per-channel unread set from a snapshot. The current inbox DO snapshot\n * exposes a single `hasUnread` boolean (dock dot) + the active registry; it does\n * NOT yet enumerate which entries are unread. We surface a per-entry `unread`\n * field when the DO provides it (forward-compatible), else fall back to empty\n * per-row dots (the dock dot still lights). See the ASSUMPTIONS note in the docs.\n *\n * ARCHIVED rows are excluded from the unread roll-up (archive = done — the DO clears\n * unread on archive; the client also never counts them).\n */\nfunction deriveUnreadRefs(snap: WireInboxSnapshot): string[] {\n const refs: string[] = [];\n for (const e of snap.registry) {\n if (e.state !== 'active') continue;\n if (e.archived === true) continue;\n if (e.unread === true) refs.push(e.channelRef);\n }\n return refs;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"inbox-client.js","sourceRoot":"","sources":["../../src/realtime/inbox-client.ts"],"names":[],"mappings":"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","sourcesContent":["// The Inbox realtime client: a SEPARATE WebSocket (scope `inbox`) to the user's\n// inbox DO. It maintains the channel/invite registry + unread projection that\n// drives the inbox view — DOTS ONLY, no counts (Contract C / \"Unread + inbox\").\n//\n// The inbox DO pushes a full `snapshot` ({ registry, hasUnread }) on connect, on\n// `resume`, and on every live delta (a projection apply). This client just\n// mirrors the latest snapshot into an observable store + a per-channel unread set.\n// Same connection lifecycle as the channel client (subprotocol auth, reconnect,\n// 4401 re-grant once, 4403 stop, auth-switch teardown).\n\nimport { createReconnectController, type ReconnectController } from '@ttt-productions/realtime-core';\nimport {\n CLIENT_FRAME,\n CHAT_CLOSE_CODES,\n buildFrame,\n parseFrame,\n isInboxSnapshot,\n type WireInboxSnapshot,\n type WireRegistryEntry,\n} from './wire.js';\nimport type { RealtimeSocket, SocketFactory } from './socket.js';\nimport type { GrantProvider, TransportTimers, RealtimeStatus } from './shared.js';\nimport { defaultTimers, isChatAccessDeniedError } from './shared.js';\n\nexport interface InboxClientState {\n status: RealtimeStatus;\n /** Active (non-tombstoned) channel/invite registry entries — inbox-view visibility. */\n registry: WireRegistryEntry[];\n /** True if ANY active channel/invite has unread (drives the dock dot). */\n hasUnread: boolean;\n /** The set of channelRefs that currently carry an unread dot (per-row dots). */\n unreadChannelRefs: string[];\n /**\n * The last TERMINAL error code (mirrors `ChannelClientState.lastErrorCode`):\n * `'access-denied'` when the inbox grant provider throws `ChatAccessDeniedError`\n * (a genuine, terminal policy \"no\" — e.g. a banned/suspended account), and\n * `'revoked'` on a 4403 REVOKED close. Null while healthy and across transient\n * reconnects. The app subscribes to inbox state directly (there is no inbox React\n * hook in this package), so this stable code is the surface — it invents no new UI.\n */\n lastErrorCode: string | null;\n}\n\nexport interface InboxClientConfig {\n endpoint: string;\n /** The authenticated uid (the inbox owner; the DO serves no one else's data). */\n currentUserId: string;\n /** Mints/refreshes an INBOX-scope grant (scope `{ kind:'inbox', uid }`). */\n grantProvider: GrantProvider;\n socketFactory: SocketFactory;\n timers?: TransportTimers;\n reconnect?: { baseDelayMs?: number; maxDelayMs?: number; maxAttempts?: number | null; random?: () => number };\n}\n\nconst INITIAL: InboxClientState = { status: 'idle', registry: [], hasUnread: false, unreadChannelRefs: [], lastErrorCode: null };\n\nexport class InboxClient {\n private readonly timers: TransportTimers;\n private readonly controller: ReconnectController;\n private socket: RealtimeSocket | null = null;\n private state: InboxClientState = INITIAL;\n private readonly listeners = new Set<(s: InboxClientState) => void>();\n private reauthAttempted = false;\n private reconnectTimer: ReturnType<TransportTimers['setTimeout']> | null = null;\n private closedByUs = false;\n\n constructor(private readonly config: InboxClientConfig) {\n this.timers = config.timers ?? defaultTimers;\n this.controller = createReconnectController(config.reconnect ?? {});\n }\n\n getState(): InboxClientState {\n return this.state;\n }\n\n subscribe(fn: (s: InboxClientState) => void): () => void {\n this.listeners.add(fn);\n return () => this.listeners.delete(fn);\n }\n\n private setState(patch: Partial<InboxClientState>): void {\n this.state = { ...this.state, ...patch };\n for (const fn of this.listeners) fn(this.state);\n }\n\n async connect(): Promise<void> {\n this.closedByUs = false;\n this.controller.start();\n this.setState({ status: 'connecting' });\n await this.openSocket();\n }\n\n private async openSocket(): Promise<void> {\n let grantToken: string;\n try {\n grantToken = await this.config.grantProvider();\n } catch (err) {\n // A TERMINAL access denial (the app translated an authoritative policy \"no\"\n // — e.g. Firebase `permission-denied` for a banned/suspended account — into the\n // package-owned ChatAccessDeniedError) must NOT reconnect: the next mint would\n // just re-deny, reconnect-looping forever and warning every cycle. Every other\n // mint failure (network/transient) stays retryable and backs off as before.\n if (isChatAccessDeniedError(err)) return this.denyAccessTerminally();\n return this.scheduleReconnect();\n }\n if (this.closedByUs) return;\n const url = `${this.config.endpoint.replace(/\\/$/, '')}/inbox`;\n this.socket = this.config.socketFactory({\n url,\n grantToken,\n handlers: {\n onOpen: () => this.onOpen(),\n onMessage: (data) => this.onMessage(data),\n onClose: (code, reason) => this.onClose(code, reason),\n onError: () => undefined,\n },\n });\n }\n\n private onOpen(): void {\n this.controller.onOpen();\n this.reauthAttempted = false;\n this.setState({ status: 'open' });\n // The DO sends a snapshot on accept; a `resume` re-requests it after a reconnect gap.\n this.sendFrame(CLIENT_FRAME.RESUME, {});\n }\n\n private onClose(code: number, _reason: string): void {\n this.socket = null;\n if (this.closedByUs) {\n this.setState({ status: 'closed' });\n return;\n }\n if (code === CHAT_CLOSE_CODES.AUTH_EXPIRED && !this.reauthAttempted) {\n this.reauthAttempted = true;\n this.setState({ status: 'reconnecting' });\n void this.openSocket();\n return;\n }\n if (code === CHAT_CLOSE_CODES.REVOKED) {\n this.closedByUs = true;\n this.controller.close();\n this.setState({ status: 'closed', lastErrorCode: 'revoked' });\n return;\n }\n this.scheduleReconnect();\n }\n\n /**\n * Terminal access denial surfaced by the inbox grant provider\n * (`ChatAccessDeniedError`). Same terminal posture as the 4403 REVOKED close: mark\n * closed-by-us, close the reconnect controller, and surface the stable\n * `access-denied` code. The inbox client tracks NO optimistic/pending sends\n * (mark-read is fire-and-forget with no local pending state), so — unlike\n * ChannelClient — there is nothing to fail here; only the lifecycle stops.\n */\n private denyAccessTerminally(): void {\n this.closedByUs = true;\n this.controller.close();\n this.setState({ status: 'closed', lastErrorCode: 'access-denied' });\n }\n\n private scheduleReconnect(): void {\n const delay = this.controller.onClose();\n if (delay == null) {\n this.setState({ status: 'closed' });\n return;\n }\n this.setState({ status: 'reconnecting' });\n this.reconnectTimer = this.timers.setTimeout(() => {\n this.reconnectTimer = null;\n if (this.closedByUs) return;\n void this.openSocket();\n }, delay);\n }\n\n private onMessage(data: string): void {\n const frame = parseFrame(data);\n if (!frame || !frame.payload) return;\n // The inbox DO pushes a full `snapshot`; a future lightweight `unread` push\n // carries either a full snapshot or a `{ hasUnread }` dock-dot patch. Route both\n // instead of silently dropping the `unread` type (C-M2).\n if (frame.type !== 'snapshot' && frame.type !== 'unread') return;\n const payload = frame.payload as unknown as WireInboxSnapshot | { hasUnread: boolean };\n if (isInboxSnapshot(payload as WireInboxSnapshot)) {\n this.applySnapshot(payload as WireInboxSnapshot);\n } else if (frame.type === 'unread' && typeof (payload as { hasUnread?: unknown }).hasUnread === 'boolean') {\n this.setState({ hasUnread: Boolean((payload as { hasUnread: boolean }).hasUnread) });\n }\n }\n\n private applySnapshot(snap: WireInboxSnapshot): void {\n // Keep every non-tombstoned entry — including ARCHIVED rows — so the inbox view can\n // render both the active list and the Archived toggle. `archived` is a distinct\n // dimension from `tombstoned`; archived rows are still `state: 'active'`.\n const registry = snap.registry.filter((e) => e.state === 'active');\n // The DO's `hasUnread` is the authoritative dock dot. Per-row dots are derived from\n // the same projection but exclude ARCHIVED rows (archive = done — an archived row\n // never shows a dot). The DO clears unread on archive, so this is belt-and-braces.\n this.setState({\n registry,\n hasUnread: Boolean(snap.hasUnread),\n unreadChannelRefs: deriveUnreadRefs(snap),\n });\n }\n\n private sendFrame(type: string, payload: Record<string, unknown>): boolean {\n if (!this.socket || !this.socket.isOpen) return false;\n this.socket.send(buildFrame(type, payload));\n return true;\n }\n\n /** True if a specific channel/invite currently carries an unread dot. */\n channelHasUnread(channelRef: string): boolean {\n return this.state.unreadChannelRefs.includes(channelRef);\n }\n\n /**\n * Clear a channel/invite's unread WITHOUT opening it (the inbox-view mark-read\n * controls). Sends `mark-read` on the inbox socket; the inbox DO advances the\n * member's read cursor to tail on the channel DO and then pushes a fresh\n * authoritative snapshot, which is what removes the row — there is NO optimistic\n * local clear, so a failed mark-read honestly leaves the dot in place.\n * Returns false when the socket is not open (the caller may surface a retry).\n */\n markRead(channelRef: string): boolean {\n return this.sendFrame(CLIENT_FRAME.MARK_READ, { channelRef });\n }\n\n /** Permanently close (auth-user switch / unmount). Idempotent. */\n close(): void {\n this.closedByUs = true;\n this.controller.close();\n if (this.reconnectTimer != null) {\n this.timers.clearTimeout(this.reconnectTimer);\n this.reconnectTimer = null;\n }\n if (this.socket) {\n this.socket.close(1000, 'client teardown');\n this.socket = null;\n }\n this.setState({ status: 'closed' });\n }\n}\n\n/**\n * Derive the per-channel unread set from a snapshot. The current inbox DO snapshot\n * exposes a single `hasUnread` boolean (dock dot) + the active registry; it does\n * NOT yet enumerate which entries are unread. We surface a per-entry `unread`\n * field when the DO provides it (forward-compatible), else fall back to empty\n * per-row dots (the dock dot still lights). See the ASSUMPTIONS note in the docs.\n *\n * ARCHIVED rows are excluded from the unread roll-up (archive = done — the DO clears\n * unread on archive; the client also never counts them).\n */\nfunction deriveUnreadRefs(snap: WireInboxSnapshot): string[] {\n const refs: string[] = [];\n for (const e of snap.registry) {\n if (e.state !== 'active') continue;\n if (e.archived === true) continue;\n if (e.unread === true) refs.push(e.channelRef);\n }\n return refs;\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ttt-productions/chat-react",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.9",
|
|
4
4
|
"description": "Chat React UI, hooks, and Firebase-client adapter for TTT Productions apps (realtime newest window + infinite older pagination)",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|