@ttt-productions/chat-react 0.12.6 → 0.12.8

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.
@@ -61,22 +61,53 @@ function parseJson(raw) {
61
61
  * - `messageId` = the server seq (string) — stable + dedup key.
62
62
  * - `replyTo` JSON `{ messageSeq, preview }` → UI `{ messageId, senderId:'', messagePreview }`.
63
63
  * - The DO row carries only the attachment LIFECYCLE (`attachmentState` +
64
- * `attachmentMeta.mediaAssetId`), not the full `ChatAttachment` (id/name/size/
65
- * storagePath). The full attachment-saga projection is the next chain's work
66
- * (P5/P6); until then the placeholder lifecycle rides in `meta.attachment*` so
67
- * the renderer can show the pending/ready/failed bubble without a stored URL —
68
- * the render-time URL comes from `mediaAssetId` via the attachment-URL resolver
69
- * context (identity/media invariant: no URLs are ever stored).
64
+ * `attachmentMeta` with `mediaAssetId`/`attachmentKind`/`failureReason`), never a
65
+ * full stored `ChatAttachment` — the projection below rebuilds the renderable
66
+ * `attachment` from that lifecycle, and the render-time URL comes from
67
+ * `mediaAssetId` via the attachment-URL resolver context (identity/media
68
+ * invariant: no URLs are ever stored). The raw lifecycle also stays on
69
+ * `meta.attachment*` for consumers that key on it.
70
70
  */
71
71
  export function wireRowToMessage(row, threadId) {
72
72
  const reply = parseJson(row.replyTo);
73
73
  const meta = parseJson(row.attachmentMeta);
74
+ // Project the DO's attachment LIFECYCLE into the renderable `attachment` object —
75
+ // the renderer keys on `m.attachment` (AttachmentView: pending spinner / failed
76
+ // bubble / ready media via the attachment-URL resolver + `mediaAssetId`). Before
77
+ // this projection existed, a wire-delivered attachment message (live broadcast,
78
+ // resume snapshot, history page) mapped to a message with NO `attachment` and
79
+ // rendered as an empty bubble — the recipient never saw the media at all (found
80
+ // live 2026-07-20, hosted path 11.3). `name`/`size`/`storagePath` are wire-absent
81
+ // and render-irrelevant (labels are kind-scrubbed; storagePath is never a serving
82
+ // contract) — placeholders keep the ChatAttachment shape without inventing data.
83
+ const state = row.attachmentState === 'pending' || row.attachmentState === 'ready' || row.attachmentState === 'failed'
84
+ ? row.attachmentState
85
+ : null;
86
+ const kind = meta?.attachmentKind === 'image' || meta?.attachmentKind === 'video' || meta?.attachmentKind === 'audio'
87
+ ? meta.attachmentKind
88
+ : // Rows written before the DO stored `attachmentKind` carry none — 'image' keeps
89
+ // them rendering through the media path (the resolver serves whatever the asset
90
+ // is); new rows always carry the real kind.
91
+ 'image';
92
+ const attachment = state
93
+ ? {
94
+ id: row.clientMessageId,
95
+ name: '',
96
+ type: kind,
97
+ size: 0,
98
+ storagePath: '',
99
+ ...(meta?.mediaAssetId ? { mediaAssetId: meta.mediaAssetId } : {}),
100
+ status: state,
101
+ ...(meta?.failureReason ? { failureReason: meta.failureReason } : {}),
102
+ }
103
+ : null;
74
104
  return {
75
105
  messageId: seqToMessageId(row.seq),
76
106
  threadId,
77
107
  createdAt: row.createdAt,
78
108
  senderId: row.senderUid,
79
109
  text: row.text,
110
+ ...(attachment ? { attachment } : {}),
80
111
  replyTo: reply
81
112
  ? { messageId: seqToMessageId(reply.messageSeq), senderId: '', messagePreview: reply.preview }
82
113
  : undefined,
@@ -1 +1 @@
1
- {"version":3,"file":"map.js","sourceRoot":"","sources":["../../src/realtime/map.ts"],"names":[],"mappings":"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","sourcesContent":["// Map the DO wire row (`WireMessageRow`) into the UI `ChatMessageV1` shape so the\n// UI stays transport-agnostic — the MessageList renders the same shape whether\n// the bytes came from Firestore or the DO socket.\n//\n// Identity invariant: the DO stores uid references only (`senderUid`), never a\n// display name — names resolve at render time through the ChatNameResolver\n// context, identical to the Firestore path.\n\nimport type { ChatMessageV1 } from '@ttt-productions/chat-core';\nimport { MODERATION_REDACTED_TEXT } from '@ttt-productions/chat-schemas';\nimport type { RevisionKind, WireMessageRow } from './wire.js';\n\n// The redacted-text constant lives in the wire contract; re-export to keep this\n// module's public surface stable.\nexport { MODERATION_REDACTED_TEXT };\n\n/** The effective moderation overlay for a message: the max-`messageRevision` kind. */\nexport interface ModerationOverlay {\n kind: RevisionKind;\n messageRevision: number;\n}\n\n/**\n * Apply a moderation overlay to a mapped message so the ORIGINAL content never\n * renders for a hidden/deleted message. Pure — returns a new message; the input\n * is not mutated. The overlay state rides on `meta` (`meta` is opaque to\n * chat-core), so the renderer can branch on `meta.moderated` / `meta.moderationKind`.\n * - `delete` / `moderate` → blank/replace the text + mark moderated.\n * - `edit` → the row already carries the overlaid text (the DO rewrites `text`),\n * so only the marker is stamped.\n * - `restore` → original content (the DO row is the original); no redaction.\n */\nexport function applyModerationOverlay(message: ChatMessageV1, overlay: ModerationOverlay | null): ChatMessageV1 {\n if (!overlay) return message;\n const redacted = overlay.kind === 'delete' || overlay.kind === 'moderate';\n return {\n ...message,\n ...(redacted ? { text: MODERATION_REDACTED_TEXT } : {}),\n meta: {\n ...message.meta,\n moderationKind: overlay.kind,\n messageRevision: overlay.messageRevision,\n // `restore` is the absence of active moderation — it reverts to the original.\n moderated: overlay.kind !== 'restore',\n },\n };\n}\n\n/** The overlay carried inline on a DO row (the getHistory/getDeltaSince merge), or null. */\nexport function overlayFromRow(row: WireMessageRow): ModerationOverlay | null {\n if (row.moderationKind == null) return null;\n return { kind: row.moderationKind, messageRevision: row.messageRevision ?? 0 };\n}\n\n/** The DO `seq` is the stable per-channel message id (monotonic, epoch-spanning). */\nexport function seqToMessageId(seq: number): string {\n return String(seq);\n}\n\ninterface WireReplyTo {\n messageSeq: number;\n preview: string;\n}\n\ninterface WireAttachmentMeta {\n senderOnly?: boolean;\n mediaAssetId?: string;\n failureReason?: string;\n [k: string]: unknown;\n}\n\nfunction parseJson<T>(raw: string | null): T | null {\n if (raw == null) return null;\n try {\n return JSON.parse(raw) as T;\n } catch {\n return null;\n }\n}\n\n/**\n * Convert a DO message row to the UI message. `threadId` is the caller's logical\n * thread id (the UI's thread key); the DO row carries no threadId of its own.\n * - `messageId` = the server seq (string) — stable + dedup key.\n * - `replyTo` JSON `{ messageSeq, preview }` → UI `{ messageId, senderId:'', messagePreview }`.\n * - The DO row carries only the attachment LIFECYCLE (`attachmentState` +\n * `attachmentMeta.mediaAssetId`), not the full `ChatAttachment` (id/name/size/\n * storagePath). The full attachment-saga projection is the next chain's work\n * (P5/P6); until then the placeholder lifecycle rides in `meta.attachment*` so\n * the renderer can show the pending/ready/failed bubble without a stored URL —\n * the render-time URL comes from `mediaAssetId` via the attachment-URL resolver\n * context (identity/media invariant: no URLs are ever stored).\n */\nexport function wireRowToMessage(row: WireMessageRow, threadId: string): ChatMessageV1 {\n const reply = parseJson<WireReplyTo>(row.replyTo);\n const meta = parseJson<WireAttachmentMeta>(row.attachmentMeta);\n\n return {\n messageId: seqToMessageId(row.seq),\n threadId,\n createdAt: row.createdAt,\n senderId: row.senderUid,\n text: row.text,\n replyTo: reply\n ? { messageId: seqToMessageId(reply.messageSeq), senderId: '', messagePreview: reply.preview }\n : undefined,\n isSystemMessage: row.senderUid === 'system' || undefined,\n meta: {\n seq: row.seq,\n epoch: row.epoch,\n ...(row.attachmentState ? { attachmentState: row.attachmentState } : {}),\n ...(meta ? { attachmentMeta: meta } : {}),\n },\n };\n}\n\n/** The optimistic local echo a `send` produces before the server `ack` reconciles the seq. */\nexport function optimisticMessage(args: {\n clientMessageId: string;\n threadId: string;\n senderId: string;\n text: string;\n createdAt: number;\n replyTo?: { messageId: string; messagePreview?: string } | null;\n}): ChatMessageV1 {\n return {\n messageId: `optimistic:${args.clientMessageId}`,\n threadId: args.threadId,\n createdAt: args.createdAt,\n senderId: args.senderId,\n text: args.text,\n replyTo: args.replyTo\n ? { messageId: args.replyTo.messageId, senderId: '', messagePreview: args.replyTo.messagePreview ?? '' }\n : undefined,\n meta: { clientMessageId: args.clientMessageId, optimistic: true },\n };\n}\n"]}
1
+ {"version":3,"file":"map.js","sourceRoot":"","sources":["../../src/realtime/map.ts"],"names":[],"mappings":"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","sourcesContent":["// Map the DO wire row (`WireMessageRow`) into the UI `ChatMessageV1` shape so the\n// UI stays transport-agnostic — the MessageList renders the same shape whether\n// the bytes came from Firestore or the DO socket.\n//\n// Identity invariant: the DO stores uid references only (`senderUid`), never a\n// display name — names resolve at render time through the ChatNameResolver\n// context, identical to the Firestore path.\n\nimport type { ChatAttachment, ChatMessageV1 } from '@ttt-productions/chat-core';\nimport { MODERATION_REDACTED_TEXT } from '@ttt-productions/chat-schemas';\nimport type { RevisionKind, WireMessageRow } from './wire.js';\n\n// The redacted-text constant lives in the wire contract; re-export to keep this\n// module's public surface stable.\nexport { MODERATION_REDACTED_TEXT };\n\n/** The effective moderation overlay for a message: the max-`messageRevision` kind. */\nexport interface ModerationOverlay {\n kind: RevisionKind;\n messageRevision: number;\n}\n\n/**\n * Apply a moderation overlay to a mapped message so the ORIGINAL content never\n * renders for a hidden/deleted message. Pure — returns a new message; the input\n * is not mutated. The overlay state rides on `meta` (`meta` is opaque to\n * chat-core), so the renderer can branch on `meta.moderated` / `meta.moderationKind`.\n * - `delete` / `moderate` → blank/replace the text + mark moderated.\n * - `edit` → the row already carries the overlaid text (the DO rewrites `text`),\n * so only the marker is stamped.\n * - `restore` → original content (the DO row is the original); no redaction.\n */\nexport function applyModerationOverlay(message: ChatMessageV1, overlay: ModerationOverlay | null): ChatMessageV1 {\n if (!overlay) return message;\n const redacted = overlay.kind === 'delete' || overlay.kind === 'moderate';\n return {\n ...message,\n ...(redacted ? { text: MODERATION_REDACTED_TEXT } : {}),\n meta: {\n ...message.meta,\n moderationKind: overlay.kind,\n messageRevision: overlay.messageRevision,\n // `restore` is the absence of active moderation — it reverts to the original.\n moderated: overlay.kind !== 'restore',\n },\n };\n}\n\n/** The overlay carried inline on a DO row (the getHistory/getDeltaSince merge), or null. */\nexport function overlayFromRow(row: WireMessageRow): ModerationOverlay | null {\n if (row.moderationKind == null) return null;\n return { kind: row.moderationKind, messageRevision: row.messageRevision ?? 0 };\n}\n\n/** The DO `seq` is the stable per-channel message id (monotonic, epoch-spanning). */\nexport function seqToMessageId(seq: number): string {\n return String(seq);\n}\n\ninterface WireReplyTo {\n messageSeq: number;\n preview: string;\n}\n\ninterface WireAttachmentMeta {\n senderOnly?: boolean;\n mediaAssetId?: string;\n failureReason?: string;\n /** 'image' | 'video' | 'audio' — stored by the Channel DO from the placeholder\n * command's validated contentType kind; inherited by the ready flip. */\n attachmentKind?: string;\n [k: string]: unknown;\n}\n\nfunction parseJson<T>(raw: string | null): T | null {\n if (raw == null) return null;\n try {\n return JSON.parse(raw) as T;\n } catch {\n return null;\n }\n}\n\n/**\n * Convert a DO message row to the UI message. `threadId` is the caller's logical\n * thread id (the UI's thread key); the DO row carries no threadId of its own.\n * - `messageId` = the server seq (string) — stable + dedup key.\n * - `replyTo` JSON `{ messageSeq, preview }` → UI `{ messageId, senderId:'', messagePreview }`.\n * - The DO row carries only the attachment LIFECYCLE (`attachmentState` +\n * `attachmentMeta` with `mediaAssetId`/`attachmentKind`/`failureReason`), never a\n * full stored `ChatAttachment` — the projection below rebuilds the renderable\n * `attachment` from that lifecycle, and the render-time URL comes from\n * `mediaAssetId` via the attachment-URL resolver context (identity/media\n * invariant: no URLs are ever stored). The raw lifecycle also stays on\n * `meta.attachment*` for consumers that key on it.\n */\nexport function wireRowToMessage(row: WireMessageRow, threadId: string): ChatMessageV1 {\n const reply = parseJson<WireReplyTo>(row.replyTo);\n const meta = parseJson<WireAttachmentMeta>(row.attachmentMeta);\n\n // Project the DO's attachment LIFECYCLE into the renderable `attachment` object —\n // the renderer keys on `m.attachment` (AttachmentView: pending spinner / failed\n // bubble / ready media via the attachment-URL resolver + `mediaAssetId`). Before\n // this projection existed, a wire-delivered attachment message (live broadcast,\n // resume snapshot, history page) mapped to a message with NO `attachment` and\n // rendered as an empty bubble — the recipient never saw the media at all (found\n // live 2026-07-20, hosted path 11.3). `name`/`size`/`storagePath` are wire-absent\n // and render-irrelevant (labels are kind-scrubbed; storagePath is never a serving\n // contract) — placeholders keep the ChatAttachment shape without inventing data.\n const state =\n row.attachmentState === 'pending' || row.attachmentState === 'ready' || row.attachmentState === 'failed'\n ? row.attachmentState\n : null;\n const kind =\n meta?.attachmentKind === 'image' || meta?.attachmentKind === 'video' || meta?.attachmentKind === 'audio'\n ? meta.attachmentKind\n : // Rows written before the DO stored `attachmentKind` carry none — 'image' keeps\n // them rendering through the media path (the resolver serves whatever the asset\n // is); new rows always carry the real kind.\n 'image';\n const attachment: ChatAttachment | null = state\n ? {\n id: row.clientMessageId,\n name: '',\n type: kind,\n size: 0,\n storagePath: '',\n ...(meta?.mediaAssetId ? { mediaAssetId: meta.mediaAssetId } : {}),\n status: state,\n ...(meta?.failureReason ? { failureReason: meta.failureReason } : {}),\n }\n : null;\n\n return {\n messageId: seqToMessageId(row.seq),\n threadId,\n createdAt: row.createdAt,\n senderId: row.senderUid,\n text: row.text,\n ...(attachment ? { attachment } : {}),\n replyTo: reply\n ? { messageId: seqToMessageId(reply.messageSeq), senderId: '', messagePreview: reply.preview }\n : undefined,\n isSystemMessage: row.senderUid === 'system' || undefined,\n meta: {\n seq: row.seq,\n epoch: row.epoch,\n ...(row.attachmentState ? { attachmentState: row.attachmentState } : {}),\n ...(meta ? { attachmentMeta: meta } : {}),\n },\n };\n}\n\n/** The optimistic local echo a `send` produces before the server `ack` reconciles the seq. */\nexport function optimisticMessage(args: {\n clientMessageId: string;\n threadId: string;\n senderId: string;\n text: string;\n createdAt: number;\n replyTo?: { messageId: string; messagePreview?: string } | null;\n}): ChatMessageV1 {\n return {\n messageId: `optimistic:${args.clientMessageId}`,\n threadId: args.threadId,\n createdAt: args.createdAt,\n senderId: args.senderId,\n text: args.text,\n replyTo: args.replyTo\n ? { messageId: args.replyTo.messageId, senderId: '', messagePreview: args.replyTo.messagePreview ?? '' }\n : undefined,\n meta: { clientMessageId: args.clientMessageId, optimistic: true },\n };\n}\n"]}
@@ -6,6 +6,30 @@
6
6
  * Query staleTime < grant exp, and returning a FRESH token when re-invoked.
7
7
  */
8
8
  export type GrantProvider = () => Promise<string>;
9
+ /**
10
+ * Package-owned, Firebase-FREE terminal access-denial signal. A grant provider
11
+ * throws this (from `GrantProvider`) when the authoritative authorization answer
12
+ * is a genuine, terminal "no" — as opposed to a transient/retryable mint failure.
13
+ *
14
+ * The transport treats it specially: instead of the normal reconnect backoff it
15
+ * STOPS reconnecting, closes the lifecycle, fails any pending optimistic sends,
16
+ * and surfaces the stable `access-denied` error code (which the React hook maps to
17
+ * `allowed: false` so the shell renders its no-access surface instead of an eternal
18
+ * loader). Every other thrown error stays transient and reconnects as before.
19
+ *
20
+ * This is deliberately generic: the consuming app translates its own backend
21
+ * denial (e.g. a Firebase `functions/permission-denied`) into this class at the
22
+ * grant-provider boundary. The package never imports or names Firebase. Consumers
23
+ * that cannot subclass may instead throw any error carrying `isChatAccessDenied:
24
+ * true` — `isChatAccessDeniedError` recognizes both (also cross-realm safe).
25
+ */
26
+ export declare class ChatAccessDeniedError extends Error {
27
+ /** Duck-typed marker so detection survives module-realm / re-throw boundaries. */
28
+ readonly isChatAccessDenied = true;
29
+ constructor(message?: string);
30
+ }
31
+ /** True when `err` is a terminal chat access denial (class instance or duck-typed marker). */
32
+ export declare function isChatAccessDeniedError(err: unknown): err is ChatAccessDeniedError;
9
33
  /** Injectable timers so the transport runs deterministically under fake timers in tests. */
10
34
  export interface TransportTimers {
11
35
  setTimeout: (fn: () => void, ms: number) => ReturnType<typeof setTimeout>;
@@ -1 +1 @@
1
- {"version":3,"file":"shared.d.ts","sourceRoot":"","sources":["../../src/realtime/shared.ts"],"names":[],"mappings":"AASA;;;;;;GAMG;AACH,MAAM,MAAM,aAAa,GAAG,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC;AAElD,4FAA4F;AAC5F,MAAM,WAAW,eAAe;IAC9B,UAAU,EAAE,CAAC,EAAE,EAAE,MAAM,IAAI,EAAE,EAAE,EAAE,MAAM,KAAK,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;IAC1E,YAAY,EAAE,CAAC,CAAC,EAAE,UAAU,CAAC,OAAO,UAAU,CAAC,KAAK,IAAI,CAAC;IACzD,GAAG,EAAE,MAAM,MAAM,CAAC;CACnB;AAED,eAAO,MAAM,aAAa,EAAE,eAI3B,CAAC;AAEF,4CAA4C;AAC5C,MAAM,MAAM,cAAc,GAAG,MAAM,GAAG,YAAY,GAAG,MAAM,GAAG,cAAc,GAAG,QAAQ,CAAC;AAIxF,OAAO,EAAE,YAAY,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;AAEnG;;;;GAIG;AACH,eAAO,MAAM,yBAAyB,IAAI,CAAC;AAE3C;;;GAGG;AACH,eAAO,MAAM,uBAAuB,QAAS,CAAC"}
1
+ {"version":3,"file":"shared.d.ts","sourceRoot":"","sources":["../../src/realtime/shared.ts"],"names":[],"mappings":"AASA;;;;;;GAMG;AACH,MAAM,MAAM,aAAa,GAAG,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC;AAElD;;;;;;;;;;;;;;;;GAgBG;AACH,qBAAa,qBAAsB,SAAQ,KAAK;IAC9C,kFAAkF;IAClF,QAAQ,CAAC,kBAAkB,QAAQ;gBACvB,OAAO,SAAuB;CAI3C;AAED,8FAA8F;AAC9F,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,OAAO,GAAG,GAAG,IAAI,qBAAqB,CAOlF;AAED,4FAA4F;AAC5F,MAAM,WAAW,eAAe;IAC9B,UAAU,EAAE,CAAC,EAAE,EAAE,MAAM,IAAI,EAAE,EAAE,EAAE,MAAM,KAAK,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;IAC1E,YAAY,EAAE,CAAC,CAAC,EAAE,UAAU,CAAC,OAAO,UAAU,CAAC,KAAK,IAAI,CAAC;IACzD,GAAG,EAAE,MAAM,MAAM,CAAC;CACnB;AAED,eAAO,MAAM,aAAa,EAAE,eAI3B,CAAC;AAEF,4CAA4C;AAC5C,MAAM,MAAM,cAAc,GAAG,MAAM,GAAG,YAAY,GAAG,MAAM,GAAG,cAAc,GAAG,QAAQ,CAAC;AAIxF,OAAO,EAAE,YAAY,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;AAEnG;;;;GAIG;AACH,eAAO,MAAM,yBAAyB,IAAI,CAAC;AAE3C;;;GAGG;AACH,eAAO,MAAM,uBAAuB,QAAS,CAAC"}
@@ -6,6 +6,38 @@
6
6
  // existing import sites are unchanged. The worker runtime is the authority and
7
7
  // rejects anything out of bounds, but the client uses these to coalesce and
8
8
  // paginate politely. The remaining constants below are client-only retry policy.
9
+ /**
10
+ * Package-owned, Firebase-FREE terminal access-denial signal. A grant provider
11
+ * throws this (from `GrantProvider`) when the authoritative authorization answer
12
+ * is a genuine, terminal "no" — as opposed to a transient/retryable mint failure.
13
+ *
14
+ * The transport treats it specially: instead of the normal reconnect backoff it
15
+ * STOPS reconnecting, closes the lifecycle, fails any pending optimistic sends,
16
+ * and surfaces the stable `access-denied` error code (which the React hook maps to
17
+ * `allowed: false` so the shell renders its no-access surface instead of an eternal
18
+ * loader). Every other thrown error stays transient and reconnects as before.
19
+ *
20
+ * This is deliberately generic: the consuming app translates its own backend
21
+ * denial (e.g. a Firebase `functions/permission-denied`) into this class at the
22
+ * grant-provider boundary. The package never imports or names Firebase. Consumers
23
+ * that cannot subclass may instead throw any error carrying `isChatAccessDenied:
24
+ * true` — `isChatAccessDeniedError` recognizes both (also cross-realm safe).
25
+ */
26
+ export class ChatAccessDeniedError extends Error {
27
+ /** Duck-typed marker so detection survives module-realm / re-throw boundaries. */
28
+ isChatAccessDenied = true;
29
+ constructor(message = 'Chat access denied') {
30
+ super(message);
31
+ this.name = 'ChatAccessDeniedError';
32
+ }
33
+ }
34
+ /** True when `err` is a terminal chat access denial (class instance or duck-typed marker). */
35
+ export function isChatAccessDeniedError(err) {
36
+ return (err instanceof ChatAccessDeniedError ||
37
+ (typeof err === 'object' &&
38
+ err !== null &&
39
+ err.isChatAccessDenied === true));
40
+ }
9
41
  export const defaultTimers = {
10
42
  setTimeout: (fn, ms) => setTimeout(fn, ms),
11
43
  clearTimeout: (h) => clearTimeout(h),
@@ -1 +1 @@
1
- {"version":3,"file":"shared.js","sourceRoot":"","sources":["../../src/realtime/shared.ts"],"names":[],"mappings":"AAAA,4EAA4E;AAC5E,EAAE;AACF,kEAAkE;AAClE,yDAAyD;AACzD,gFAAgF;AAChF,+EAA+E;AAC/E,4EAA4E;AAC5E,iFAAiF;AAkBjF,MAAM,CAAC,MAAM,aAAa,GAAoB;IAC5C,UAAU,EAAE,CAAC,EAAE,EAAE,EAAE,EAAE,EAAE,CAAC,UAAU,CAAC,EAAE,EAAE,EAAE,CAAC;IAC1C,YAAY,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,YAAY,CAAC,CAAC,CAAC;IACpC,GAAG,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,GAAG,EAAE;CACtB,CAAC;AAKF,6EAA6E;AAC7E,kCAAkC;AAClC,OAAO,EAAE,YAAY,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;AAEnG;;;;GAIG;AACH,MAAM,CAAC,MAAM,yBAAyB,GAAG,CAAC,CAAC;AAE3C;;;GAGG;AACH,MAAM,CAAC,MAAM,uBAAuB,GAAG,MAAM,CAAC","sourcesContent":["// Shared injected contracts + tunable constants for the realtime transport.\n//\n// The client-agreed limits (`HEARTBEAT_MS`, `TYPING_COALESCE_MS`,\n// `HISTORY_PAGE_MAX`) come from the one wire contract in\n// `@ttt-productions/chat-schemas`; they are re-exported here so the transport's\n// existing import sites are unchanged. The worker runtime is the authority and\n// rejects anything out of bounds, but the client uses these to coalesce and\n// paginate politely. The remaining constants below are client-only retry policy.\n\n/**\n * Mints a short-lived chat grant token for the socket's scope. The app wires this\n * to its `mintChatGrant` callable (channel grant for a ChannelClient, inbox grant\n * for an InboxClient). Called on every connect AND once on a 4401 re-grant. The\n * app owns the Firestore permission check, the scope shape, caching to a React\n * Query staleTime < grant exp, and returning a FRESH token when re-invoked.\n */\nexport type GrantProvider = () => Promise<string>;\n\n/** Injectable timers so the transport runs deterministically under fake timers in tests. */\nexport interface TransportTimers {\n setTimeout: (fn: () => void, ms: number) => ReturnType<typeof setTimeout>;\n clearTimeout: (h: ReturnType<typeof setTimeout>) => void;\n now: () => number;\n}\n\nexport const defaultTimers: TransportTimers = {\n setTimeout: (fn, ms) => setTimeout(fn, ms),\n clearTimeout: (h) => clearTimeout(h),\n now: () => Date.now(),\n};\n\n/** Connection status surfaced to the UI. */\nexport type RealtimeStatus = 'idle' | 'connecting' | 'open' | 'reconnecting' | 'closed';\n\n// The client-agreed limits live in the wire contract; re-export to keep this\n// module's public surface stable.\nexport { HEARTBEAT_MS, TYPING_COALESCE_MS, HISTORY_PAGE_MAX } from '@ttt-productions/chat-schemas';\n\n/**\n * Max reconnect resends of one un-acked send before its optimistic row is flipped\n * to a visible failed state (retry affordance) instead of a permanent ghost. Each\n * reconnect that resumes with the pending send still un-acked counts one attempt.\n */\nexport const MAX_PENDING_SEND_ATTEMPTS = 5;\n\n/**\n * Max wall-clock age of an un-acked pending send before it is flipped to failed on\n * the next resume, even if the attempt cap hasn't been hit (a long offline window).\n */\nexport const PENDING_SEND_MAX_AGE_MS = 60_000;\n"]}
1
+ {"version":3,"file":"shared.js","sourceRoot":"","sources":["../../src/realtime/shared.ts"],"names":[],"mappings":"AAAA,4EAA4E;AAC5E,EAAE;AACF,kEAAkE;AAClE,yDAAyD;AACzD,gFAAgF;AAChF,+EAA+E;AAC/E,4EAA4E;AAC5E,iFAAiF;AAWjF;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,OAAO,qBAAsB,SAAQ,KAAK;IAC9C,kFAAkF;IACzE,kBAAkB,GAAG,IAAI,CAAC;IACnC,YAAY,OAAO,GAAG,oBAAoB;QACxC,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,uBAAuB,CAAC;IACtC,CAAC;CACF;AAED,8FAA8F;AAC9F,MAAM,UAAU,uBAAuB,CAAC,GAAY;IAClD,OAAO,CACL,GAAG,YAAY,qBAAqB;QACpC,CAAC,OAAO,GAAG,KAAK,QAAQ;YACtB,GAAG,KAAK,IAAI;YACX,GAAwC,CAAC,kBAAkB,KAAK,IAAI,CAAC,CACzE,CAAC;AACJ,CAAC;AASD,MAAM,CAAC,MAAM,aAAa,GAAoB;IAC5C,UAAU,EAAE,CAAC,EAAE,EAAE,EAAE,EAAE,EAAE,CAAC,UAAU,CAAC,EAAE,EAAE,EAAE,CAAC;IAC1C,YAAY,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,YAAY,CAAC,CAAC,CAAC;IACpC,GAAG,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,GAAG,EAAE;CACtB,CAAC;AAKF,6EAA6E;AAC7E,kCAAkC;AAClC,OAAO,EAAE,YAAY,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;AAEnG;;;;GAIG;AACH,MAAM,CAAC,MAAM,yBAAyB,GAAG,CAAC,CAAC;AAE3C;;;GAGG;AACH,MAAM,CAAC,MAAM,uBAAuB,GAAG,MAAM,CAAC","sourcesContent":["// Shared injected contracts + tunable constants for the realtime transport.\n//\n// The client-agreed limits (`HEARTBEAT_MS`, `TYPING_COALESCE_MS`,\n// `HISTORY_PAGE_MAX`) come from the one wire contract in\n// `@ttt-productions/chat-schemas`; they are re-exported here so the transport's\n// existing import sites are unchanged. The worker runtime is the authority and\n// rejects anything out of bounds, but the client uses these to coalesce and\n// paginate politely. The remaining constants below are client-only retry policy.\n\n/**\n * Mints a short-lived chat grant token for the socket's scope. The app wires this\n * to its `mintChatGrant` callable (channel grant for a ChannelClient, inbox grant\n * for an InboxClient). Called on every connect AND once on a 4401 re-grant. The\n * app owns the Firestore permission check, the scope shape, caching to a React\n * Query staleTime < grant exp, and returning a FRESH token when re-invoked.\n */\nexport type GrantProvider = () => Promise<string>;\n\n/**\n * Package-owned, Firebase-FREE terminal access-denial signal. A grant provider\n * throws this (from `GrantProvider`) when the authoritative authorization answer\n * is a genuine, terminal \"no\" — as opposed to a transient/retryable mint failure.\n *\n * The transport treats it specially: instead of the normal reconnect backoff it\n * STOPS reconnecting, closes the lifecycle, fails any pending optimistic sends,\n * and surfaces the stable `access-denied` error code (which the React hook maps to\n * `allowed: false` so the shell renders its no-access surface instead of an eternal\n * loader). Every other thrown error stays transient and reconnects as before.\n *\n * This is deliberately generic: the consuming app translates its own backend\n * denial (e.g. a Firebase `functions/permission-denied`) into this class at the\n * grant-provider boundary. The package never imports or names Firebase. Consumers\n * that cannot subclass may instead throw any error carrying `isChatAccessDenied:\n * true` — `isChatAccessDeniedError` recognizes both (also cross-realm safe).\n */\nexport class ChatAccessDeniedError extends Error {\n /** Duck-typed marker so detection survives module-realm / re-throw boundaries. */\n readonly isChatAccessDenied = true;\n constructor(message = 'Chat access denied') {\n super(message);\n this.name = 'ChatAccessDeniedError';\n }\n}\n\n/** True when `err` is a terminal chat access denial (class instance or duck-typed marker). */\nexport function isChatAccessDeniedError(err: unknown): err is ChatAccessDeniedError {\n return (\n err instanceof ChatAccessDeniedError ||\n (typeof err === 'object' &&\n err !== null &&\n (err as { isChatAccessDenied?: unknown }).isChatAccessDenied === true)\n );\n}\n\n/** Injectable timers so the transport runs deterministically under fake timers in tests. */\nexport interface TransportTimers {\n setTimeout: (fn: () => void, ms: number) => ReturnType<typeof setTimeout>;\n clearTimeout: (h: ReturnType<typeof setTimeout>) => void;\n now: () => number;\n}\n\nexport const defaultTimers: TransportTimers = {\n setTimeout: (fn, ms) => setTimeout(fn, ms),\n clearTimeout: (h) => clearTimeout(h),\n now: () => Date.now(),\n};\n\n/** Connection status surfaced to the UI. */\nexport type RealtimeStatus = 'idle' | 'connecting' | 'open' | 'reconnecting' | 'closed';\n\n// The client-agreed limits live in the wire contract; re-export to keep this\n// module's public surface stable.\nexport { HEARTBEAT_MS, TYPING_COALESCE_MS, HISTORY_PAGE_MAX } from '@ttt-productions/chat-schemas';\n\n/**\n * Max reconnect resends of one un-acked send before its optimistic row is flipped\n * to a visible failed state (retry affordance) instead of a permanent ghost. Each\n * reconnect that resumes with the pending send still un-acked counts one attempt.\n */\nexport const MAX_PENDING_SEND_ATTEMPTS = 5;\n\n/**\n * Max wall-clock age of an un-acked pending send before it is flipped to failed on\n * the next resume, even if the attempt cap hasn't been hit (a long offline window).\n */\nexport const PENDING_SEND_MAX_AGE_MS = 60_000;\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"useRealtimeChatMessages.d.ts","sourceRoot":"","sources":["../../src/realtime/useRealtimeChatMessages.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAChE,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAC;AACzD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAG9D,MAAM,MAAM,6BAA6B,GAAG;IAC1C,OAAO,EAAE,OAAO,CAAC;IACjB,gBAAgB,EAAE,OAAO,CAAC;IAC1B,QAAQ,EAAE,aAAa,EAAE,CAAC;IAC1B,UAAU,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAChC,QAAQ,EAAE,OAAO,CAAC;IAClB,eAAe,EAAE,OAAO,CAAC;IACzB,mFAAmF;IACnF,MAAM,EAAE,kBAAkB,CAAC,QAAQ,CAAC,CAAC;IACrC;4CACwC;IACxC,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,iHAAiH;IACjH,IAAI,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,KAAK,OAAO,CAAC;IAC1F;;;;;uCAKmC;IACnC,SAAS,EAAE,CAAC,eAAe,EAAE,MAAM,KAAK,OAAO,CAAC;IAChD,sIAAsI;IACtI,OAAO,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC;IACxD,YAAY,EAAE,MAAM,IAAI,CAAC;IACzB;;yEAEqE;IACrE,iBAAiB,EAAE,MAAM,IAAI,CAAC;IAC9B,mBAAmB,EAAE,MAAM,IAAI,CAAC;CACjC,CAAC;AAEF,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,kBAAkB,GAAG,6BAA6B,CAuEjG"}
1
+ {"version":3,"file":"useRealtimeChatMessages.d.ts","sourceRoot":"","sources":["../../src/realtime/useRealtimeChatMessages.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAChE,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAC;AACzD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAG9D,MAAM,MAAM,6BAA6B,GAAG;IAC1C,OAAO,EAAE,OAAO,CAAC;IACjB,gBAAgB,EAAE,OAAO,CAAC;IAC1B,QAAQ,EAAE,aAAa,EAAE,CAAC;IAC1B,UAAU,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAChC,QAAQ,EAAE,OAAO,CAAC;IAClB,eAAe,EAAE,OAAO,CAAC;IACzB,mFAAmF;IACnF,MAAM,EAAE,kBAAkB,CAAC,QAAQ,CAAC,CAAC;IACrC;4CACwC;IACxC,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,iHAAiH;IACjH,IAAI,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,KAAK,OAAO,CAAC;IAC1F;;;;;uCAKmC;IACnC,SAAS,EAAE,CAAC,eAAe,EAAE,MAAM,KAAK,OAAO,CAAC;IAChD,sIAAsI;IACtI,OAAO,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC;IACxD,YAAY,EAAE,MAAM,IAAI,CAAC;IACzB;;yEAEqE;IACrE,iBAAiB,EAAE,MAAM,IAAI,CAAC;IAC9B,mBAAmB,EAAE,MAAM,IAAI,CAAC;CACjC,CAAC;AAEF,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,kBAAkB,GAAG,6BAA6B,CA6EjG"}
@@ -44,11 +44,17 @@ export function useRealtimeChatMessages(client) {
44
44
  const presenceSubscribe = React.useCallback(() => client.channel.presenceSubscribe(), [client]);
45
45
  const presenceUnsubscribe = React.useCallback(() => client.channel.presenceUnsubscribe(), [client]);
46
46
  return {
47
- // On the realtime transport DATA ACCESS is enforced by the DO grant, so the
48
- // hook is always "allowed" client-side (the socket simply won't open / will be
49
- // closed 4403 if the grant is denied) — accessMode is presentation-only here.
50
- allowed: true,
51
- isInitialLoading: state.status === "connecting" || state.status === "idle",
47
+ // On the realtime transport DATA ACCESS is enforced by the DO grant, so the hook
48
+ // is "allowed" client-side UNLESS the grant provider surfaced a TERMINAL access
49
+ // denial (ChatAccessDeniedError → stable 'access-denied' code). That single case
50
+ // maps to allowed:false so ChatShell renders its no-access surface instead of an
51
+ // eternal loader; a transient close / 4403-revoke keeps allowed:true and rides the
52
+ // reconnect/disconnected banner. Otherwise accessMode is presentation-only here.
53
+ allowed: state.lastErrorCode !== "access-denied",
54
+ // Derive initial loading from authoritative-data completion, NOT connection status:
55
+ // a socket can be 'open' with no snapshot yet, and a post-load reconnect must not
56
+ // fall back into the opening state. `hasLoadedInitialData` encodes the full table.
57
+ isInitialLoading: !state.hasLoadedInitialData,
52
58
  messages: state.messages,
53
59
  fetchOlder,
54
60
  hasOlder: state.hasOlder,
@@ -1 +1 @@
1
- {"version":3,"file":"useRealtimeChatMessages.js","sourceRoot":"","sources":["../../src/realtime/useRealtimeChatMessages.ts"],"names":[],"mappings":"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","sourcesContent":["\"use client\";\n\n// React hook for the REALTIME transport, returning the SAME shape as\n// `useChatMessages` (the firestore path) so `ChatShell` is transport-agnostic.\n// It SUBSCRIBES to a `RealtimeChatClient`'s observable state. It does NOT own the\n// socket lifecycle: the caller (the app hook that CREATES the client, e.g.\n// `useRealtimeChannelClient`) is the single connect/close owner (C-B7) — connecting\n// here too opened a second WebSocket. The client instance is owned by the caller\n// (passed via config) so its identity is the subscription key — a new client for a\n// new uid replaces the old one (Contract A auth-user-switch teardown happens in the\n// owning hook).\n\nimport * as React from \"react\";\nimport type { ChatMessageV1 } from \"@ttt-productions/chat-core\";\nimport type { RealtimeChatClient } from \"./transport.js\";\nimport type { ChannelClientState } from \"./channel-client.js\";\nimport { useOptionalChatNameResolver } from \"../context/ChatNameResolverContext.js\";\n\nexport type UseRealtimeChatMessagesResult = {\n allowed: boolean;\n isInitialLoading: boolean;\n messages: ChatMessageV1[];\n fetchOlder: () => Promise<void>;\n hasOlder: boolean;\n isFetchingOlder: boolean;\n /** Realtime extras (not present on the firestore result) — optional for the UI. */\n status: ChannelClientState[\"status\"];\n /** The last structured error code the DO sent (e.g. a close reason). Drives the\n * disconnected-banner detail (R13). */\n lastErrorCode: string | null;\n typing: string[];\n presence: string[];\n /** Returns false when the socket was closed and nothing was sent (C-B8) — the caller keeps the composer text. */\n send: (text: string, replyTo?: { messageSeq: number; preview: string } | null) => boolean;\n /** Retry a failed/un-acked send by its ORIGINAL clientMessageId (read from the\n * failed row's `meta.clientMessageId`) — the transport re-queues the tracked\n * pending send with the SAME id so the DO's send-idempotency dedups any copy\n * that already landed. Returns false for an unknown id (already acked/\n * reconciled). ChatShell wires this to MessageItemDefault's failed-bubble\n * retry affordance by default. */\n retrySend: (clientMessageId: string) => boolean;\n /** Returns whether the ack frame was sent (false on a closed socket) so the caller advances its local cursor only on success (M2). */\n readAck: (readSeq: number, focused: boolean) => boolean;\n signalTyping: () => void;\n /** Watched-only presence (R14): the UI subscribes ONLY while a member-list / online\n * indicator is open, and unsubscribes when it closes, so an unwatched channel runs\n * zero presence alarms. `presence` stays empty until subscribed. */\n presenceSubscribe: () => void;\n presenceUnsubscribe: () => void;\n};\n\nexport function useRealtimeChatMessages(client: RealtimeChatClient): UseRealtimeChatMessagesResult {\n const [state, setState] = React.useState<ChannelClientState>(() => client.getState());\n\n // Subscribe only; the owning hook drives connect/close (single lifecycle owner, C-B7).\n // Re-subscribes on client identity change (auth-user switch).\n React.useEffect(() => {\n const unsub = client.subscribe(setState);\n setState(client.getState());\n return unsub;\n }, [client]);\n\n // Pre-warm the name cache for visible senders (parity with the firestore hook).\n const resolverCtx = useOptionalChatNameResolver();\n const senderIds = React.useMemo(() => {\n const set = new Set<string>();\n for (const m of state.messages) {\n set.add(m.senderId);\n if (m.replyTo?.senderId) set.add(m.replyTo.senderId);\n }\n return Array.from(set);\n }, [state.messages]);\n\n React.useEffect(() => {\n if (resolverCtx?.prewarm && senderIds.length > 0) resolverCtx.prewarm(senderIds);\n }, [resolverCtx, senderIds]);\n\n const fetchOlder = React.useCallback(async () => {\n client.channel.fetchOlder();\n }, [client]);\n\n const send = React.useCallback(\n (text: string, replyTo?: { messageSeq: number; preview: string } | null): boolean =>\n client.channel.send({ clientMessageId: makeClientMessageId(), text, replyTo: replyTo ?? null }),\n [client],\n );\n\n const retrySend = React.useCallback(\n (clientMessageId: string): boolean => client.channel.retrySend(clientMessageId),\n [client],\n );\n\n const readAck = React.useCallback(\n (readSeq: number, focused: boolean) => client.channel.readAck(readSeq, focused),\n [client],\n );\n\n const signalTyping = React.useCallback(() => client.channel.typing(), [client]);\n const presenceSubscribe = React.useCallback(() => client.channel.presenceSubscribe(), [client]);\n const presenceUnsubscribe = React.useCallback(() => client.channel.presenceUnsubscribe(), [client]);\n\n return {\n // On the realtime transport DATA ACCESS is enforced by the DO grant, so the\n // hook is always \"allowed\" client-side (the socket simply won't open / will be\n // closed 4403 if the grant is denied) — accessMode is presentation-only here.\n allowed: true,\n isInitialLoading: state.status === \"connecting\" || state.status === \"idle\",\n messages: state.messages,\n fetchOlder,\n hasOlder: state.hasOlder,\n isFetchingOlder: state.isFetchingOlder,\n status: state.status,\n lastErrorCode: state.lastErrorCode,\n typing: state.typing,\n presence: state.presence,\n send,\n retrySend,\n readAck,\n signalTyping,\n presenceSubscribe,\n presenceUnsubscribe,\n };\n}\n\n/** A client message id for optimistic-send idempotency (SQLite unique (senderUid, clientMessageId)). */\nfunction makeClientMessageId(): string {\n const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;\n if (c?.randomUUID) return c.randomUUID();\n return `cmid-${Date.now()}-${Math.random().toString(36).slice(2)}`;\n}\n"]}
1
+ {"version":3,"file":"useRealtimeChatMessages.js","sourceRoot":"","sources":["../../src/realtime/useRealtimeChatMessages.ts"],"names":[],"mappings":"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","sourcesContent":["\"use client\";\n\n// React hook for the REALTIME transport, returning the SAME shape as\n// `useChatMessages` (the firestore path) so `ChatShell` is transport-agnostic.\n// It SUBSCRIBES to a `RealtimeChatClient`'s observable state. It does NOT own the\n// socket lifecycle: the caller (the app hook that CREATES the client, e.g.\n// `useRealtimeChannelClient`) is the single connect/close owner (C-B7) — connecting\n// here too opened a second WebSocket. The client instance is owned by the caller\n// (passed via config) so its identity is the subscription key — a new client for a\n// new uid replaces the old one (Contract A auth-user-switch teardown happens in the\n// owning hook).\n\nimport * as React from \"react\";\nimport type { ChatMessageV1 } from \"@ttt-productions/chat-core\";\nimport type { RealtimeChatClient } from \"./transport.js\";\nimport type { ChannelClientState } from \"./channel-client.js\";\nimport { useOptionalChatNameResolver } from \"../context/ChatNameResolverContext.js\";\n\nexport type UseRealtimeChatMessagesResult = {\n allowed: boolean;\n isInitialLoading: boolean;\n messages: ChatMessageV1[];\n fetchOlder: () => Promise<void>;\n hasOlder: boolean;\n isFetchingOlder: boolean;\n /** Realtime extras (not present on the firestore result) — optional for the UI. */\n status: ChannelClientState[\"status\"];\n /** The last structured error code the DO sent (e.g. a close reason). Drives the\n * disconnected-banner detail (R13). */\n lastErrorCode: string | null;\n typing: string[];\n presence: string[];\n /** Returns false when the socket was closed and nothing was sent (C-B8) — the caller keeps the composer text. */\n send: (text: string, replyTo?: { messageSeq: number; preview: string } | null) => boolean;\n /** Retry a failed/un-acked send by its ORIGINAL clientMessageId (read from the\n * failed row's `meta.clientMessageId`) — the transport re-queues the tracked\n * pending send with the SAME id so the DO's send-idempotency dedups any copy\n * that already landed. Returns false for an unknown id (already acked/\n * reconciled). ChatShell wires this to MessageItemDefault's failed-bubble\n * retry affordance by default. */\n retrySend: (clientMessageId: string) => boolean;\n /** Returns whether the ack frame was sent (false on a closed socket) so the caller advances its local cursor only on success (M2). */\n readAck: (readSeq: number, focused: boolean) => boolean;\n signalTyping: () => void;\n /** Watched-only presence (R14): the UI subscribes ONLY while a member-list / online\n * indicator is open, and unsubscribes when it closes, so an unwatched channel runs\n * zero presence alarms. `presence` stays empty until subscribed. */\n presenceSubscribe: () => void;\n presenceUnsubscribe: () => void;\n};\n\nexport function useRealtimeChatMessages(client: RealtimeChatClient): UseRealtimeChatMessagesResult {\n const [state, setState] = React.useState<ChannelClientState>(() => client.getState());\n\n // Subscribe only; the owning hook drives connect/close (single lifecycle owner, C-B7).\n // Re-subscribes on client identity change (auth-user switch).\n React.useEffect(() => {\n const unsub = client.subscribe(setState);\n setState(client.getState());\n return unsub;\n }, [client]);\n\n // Pre-warm the name cache for visible senders (parity with the firestore hook).\n const resolverCtx = useOptionalChatNameResolver();\n const senderIds = React.useMemo(() => {\n const set = new Set<string>();\n for (const m of state.messages) {\n set.add(m.senderId);\n if (m.replyTo?.senderId) set.add(m.replyTo.senderId);\n }\n return Array.from(set);\n }, [state.messages]);\n\n React.useEffect(() => {\n if (resolverCtx?.prewarm && senderIds.length > 0) resolverCtx.prewarm(senderIds);\n }, [resolverCtx, senderIds]);\n\n const fetchOlder = React.useCallback(async () => {\n client.channel.fetchOlder();\n }, [client]);\n\n const send = React.useCallback(\n (text: string, replyTo?: { messageSeq: number; preview: string } | null): boolean =>\n client.channel.send({ clientMessageId: makeClientMessageId(), text, replyTo: replyTo ?? null }),\n [client],\n );\n\n const retrySend = React.useCallback(\n (clientMessageId: string): boolean => client.channel.retrySend(clientMessageId),\n [client],\n );\n\n const readAck = React.useCallback(\n (readSeq: number, focused: boolean) => client.channel.readAck(readSeq, focused),\n [client],\n );\n\n const signalTyping = React.useCallback(() => client.channel.typing(), [client]);\n const presenceSubscribe = React.useCallback(() => client.channel.presenceSubscribe(), [client]);\n const presenceUnsubscribe = React.useCallback(() => client.channel.presenceUnsubscribe(), [client]);\n\n return {\n // On the realtime transport DATA ACCESS is enforced by the DO grant, so the hook\n // is \"allowed\" client-side UNLESS the grant provider surfaced a TERMINAL access\n // denial (ChatAccessDeniedError → stable 'access-denied' code). That single case\n // maps to allowed:false so ChatShell renders its no-access surface instead of an\n // eternal loader; a transient close / 4403-revoke keeps allowed:true and rides the\n // reconnect/disconnected banner. Otherwise accessMode is presentation-only here.\n allowed: state.lastErrorCode !== \"access-denied\",\n // Derive initial loading from authoritative-data completion, NOT connection status:\n // a socket can be 'open' with no snapshot yet, and a post-load reconnect must not\n // fall back into the opening state. `hasLoadedInitialData` encodes the full table.\n isInitialLoading: !state.hasLoadedInitialData,\n messages: state.messages,\n fetchOlder,\n hasOlder: state.hasOlder,\n isFetchingOlder: state.isFetchingOlder,\n status: state.status,\n lastErrorCode: state.lastErrorCode,\n typing: state.typing,\n presence: state.presence,\n send,\n retrySend,\n readAck,\n signalTyping,\n presenceSubscribe,\n presenceUnsubscribe,\n };\n}\n\n/** A client message id for optimistic-send idempotency (SQLite unique (senderUid, clientMessageId)). */\nfunction makeClientMessageId(): string {\n const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;\n if (c?.randomUUID) return c.randomUUID();\n return `cmid-${Date.now()}-${Math.random().toString(36).slice(2)}`;\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"ChatShell.d.ts","sourceRoot":"","sources":["../../src/ui/ChatShell.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAC/B,OAAO,KAAK,EAAE,aAAa,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,MAAM,4BAA4B,CAAC;AACtG,OAAO,KAAK,EACV,cAAc,EACd,oBAAoB,EACpB,uBAAuB,EACxB,MAAM,aAAa,CAAC;AAUrB,MAAM,MAAM,cAAc,GAAG;IAC3B,MAAM,EAAE,cAAc,CAAC;IAGvB,MAAM,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IAKzB,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,aAAa,CAAC,SAAS,CAAC,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAG7E,aAAa,CAAC,EAAE,CAAC,CAAC,EAAE,GAAG,KAAK,KAAK,CAAC,SAAS,CAAC;IAC5C,gBAAgB,CAAC,EAAE,uBAAuB,CAAC;IAC3C,QAAQ,CAAC,EAAE,kBAAkB,CAAC;IAG9B,gBAAgB,CAAC,EAAE,oBAAoB,CAAC;IACxC,cAAc,CAAC,EAAE,gBAAgB,CAAC;IAClC,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,SAAS,CAAC,EAAE,OAAO,CAAC;IAGpB,mBAAmB,CAAC,EAAE,MAAM,KAAK,CAAC,SAAS,CAAC;IAC5C,mBAAmB,CAAC,EAAE,MAAM,KAAK,CAAC,SAAS,CAAC;IAC5C,kDAAkD;IAClD,YAAY,CAAC,EAAE,MAAM,KAAK,CAAC,SAAS,CAAC;IAGrC,aAAa,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,KAAK,IAAI,CAAC;IAGhE,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAE3B;;;6FAGyF;IACzF,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;qDACiD;IACjD,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B,CAAC;AA0BF;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,cAAc,qBAK9C"}
1
+ {"version":3,"file":"ChatShell.d.ts","sourceRoot":"","sources":["../../src/ui/ChatShell.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAC/B,OAAO,KAAK,EAAE,aAAa,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,MAAM,4BAA4B,CAAC;AACtG,OAAO,KAAK,EACV,cAAc,EACd,oBAAoB,EACpB,uBAAuB,EACxB,MAAM,aAAa,CAAC;AAWrB,MAAM,MAAM,cAAc,GAAG;IAC3B,MAAM,EAAE,cAAc,CAAC;IAGvB,MAAM,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IAKzB,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,aAAa,CAAC,SAAS,CAAC,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAG7E,aAAa,CAAC,EAAE,CAAC,CAAC,EAAE,GAAG,KAAK,KAAK,CAAC,SAAS,CAAC;IAC5C,gBAAgB,CAAC,EAAE,uBAAuB,CAAC;IAC3C,QAAQ,CAAC,EAAE,kBAAkB,CAAC;IAG9B,gBAAgB,CAAC,EAAE,oBAAoB,CAAC;IACxC,cAAc,CAAC,EAAE,gBAAgB,CAAC;IAClC,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,SAAS,CAAC,EAAE,OAAO,CAAC;IAGpB,mBAAmB,CAAC,EAAE,MAAM,KAAK,CAAC,SAAS,CAAC;IAC5C,mBAAmB,CAAC,EAAE,MAAM,KAAK,CAAC,SAAS,CAAC;IAC5C,kDAAkD;IAClD,YAAY,CAAC,EAAE,MAAM,KAAK,CAAC,SAAS,CAAC;IAGrC,aAAa,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,KAAK,IAAI,CAAC;IAGhE,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAE3B;;;6FAGyF;IACzF,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;qDACiD;IACjD,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B,CAAC;AA0BF;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,cAAc,qBAK9C"}
@@ -3,6 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import * as React from "react";
4
4
  import { Card, CardHeader, CardContent, CardFooter, Skeleton } from "@ttt-productions/ui-core/react";
5
5
  import { KeyboardAvoidingView } from "@ttt-productions/mobile-core/react";
6
+ import { Loader2 } from "lucide-react";
6
7
  import { useChatMessages } from "../hooks/useChatMessages.js";
7
8
  import { useRealtimeChatMessages } from "../realtime/useRealtimeChatMessages.js";
8
9
  import { MessageList } from "./MessageList.js";
@@ -126,14 +127,15 @@ function ChatShellView(props) {
126
127
  return _jsx("div", { className: "p-4 text-sm opacity-70", children: "You don't have access to this thread." });
127
128
  }
128
129
  if (isInitialLoading) {
129
- // Render the consumer's header + render-slots even while the message list is still
130
- // loading — ONLY the MessageList region is skeletonized. Previously this early-return
131
- // was an all-skeleton Card that SWALLOWED renderAboveMessages/renderBelowMessages/
132
- // renderFooter, so a consumer that hangs critical controls off those slots (e.g. the
133
- // guild-invite binding-agreement Agree/Decline/stake-share controls) showed nothing for
134
- // the entire connect window while the realtime socket sat in 'connecting' (F4.1). The
135
- // agreement flow must work whether or not realtime chat is connected.
136
- return (_jsxs(Card, { className: cardClassName, children: [(header || handlers) && (_jsxs(CardHeader, { className: "flex flex-row items-start justify-between gap-4 space-y-0", children: [_jsx("div", { children: header }), _jsx("div", { className: "flex flex-col items-end gap-2", children: _jsx(ThreadActions, { threadId: config.threadId, isAdmin: config.isAdmin, handlers: handlers }) })] })), _jsx(ChatConnectionBanner, { status: status }), renderAboveMessages && (_jsx("div", { className: "border-b", children: renderAboveMessages() })), _jsx(CardContent, { className: contentClassName, children: _jsx(Skeleton, { className: "h-[240px] w-full" }) }), renderBelowMessages && (_jsx("div", { className: "border-t", children: renderBelowMessages() })), renderFooter ? (_jsx(CardFooter, { className: "border-t", children: renderFooter() })) : (_jsx(CardFooter, { className: "border-t", children: _jsx(Skeleton, { className: "h-10 w-full" }) }))] }));
130
+ // Before the first authoritative snapshot/history result, show an HONEST working
131
+ // state — a visible "Opening chat…" spinner in a polite live region — NOT a blank
132
+ // parchment/skeleton box (which read as an empty chat) and NOT the reconnect banner
133
+ // (which must not compete with the initial-opening message). Only the MessageList
134
+ // region is replaced: the consumer's header + actions + the three render-slots +
135
+ // footer all still render, because a consumer that hangs critical controls off those
136
+ // slots (e.g. the guild-invite binding-agreement Agree/Decline/stake-share controls)
137
+ // must work whether or not realtime chat has connected yet (F4.1).
138
+ return (_jsxs(Card, { className: cardClassName, children: [(header || handlers) && (_jsxs(CardHeader, { className: "flex flex-row items-start justify-between gap-4 space-y-0", children: [_jsx("div", { children: header }), _jsx("div", { className: "flex flex-col items-end gap-2", children: _jsx(ThreadActions, { threadId: config.threadId, isAdmin: config.isAdmin, handlers: handlers }) })] })), renderAboveMessages && (_jsx("div", { className: "border-b", children: renderAboveMessages() })), _jsx(CardContent, { className: contentClassName, children: _jsxs("div", { className: "flex flex-col items-center justify-center gap-2 py-12 text-sm text-muted-foreground", role: "status", "aria-live": "polite", children: [_jsx(Loader2, { className: "h-5 w-5 animate-spin", "aria-hidden": "true" }), _jsx("span", { children: "Opening chat\u2026" })] }) }), renderBelowMessages && (_jsx("div", { className: "border-t", children: renderBelowMessages() })), renderFooter ? (_jsx(CardFooter, { className: "border-t", children: renderFooter() })) : (_jsx(CardFooter, { className: "border-t", children: _jsx(Skeleton, { className: "h-10 w-full" }) }))] }));
137
139
  }
138
140
  return (_jsxs(Card, { className: cardClassName, children: [(header || handlers) && (_jsxs(CardHeader, { className: "flex flex-row items-start justify-between gap-4 space-y-0", children: [_jsx("div", { children: header }), _jsx("div", { className: "flex flex-col items-end gap-2", children: _jsx(ThreadActions, { threadId: config.threadId, isAdmin: config.isAdmin, handlers: handlers }) })] })), _jsx(ChatConnectionBanner, { status: status }), renderAboveMessages && (_jsx("div", { className: "border-b", children: renderAboveMessages() })), _jsx(CardContent, { className: contentClassName, children: _jsx(MessageList, { messages: messages, currentUserId: config.currentUserId, isAdmin: config.isAdmin, isFetchingOlder: isFetchingOlder, hasOlder: hasOlder, onLoadOlder: () => fetchOlder(), renderMessage: renderMessage, messageRenderers: messageRenderers, showScrollToBottom: showScrollToBottom, onScrollToBottom: () => setShowScrollToBottom(false), onAtBottomChange: setAtBottom, fillHeight: fillHeight, scrollClassName: scrollClassName, handlers: handlers, onSenderClick: onSenderClick, onRetrySend: retrySend }) }), renderBelowMessages && (_jsx("div", { className: "border-t", children: renderBelowMessages() })), renderFooter ? (_jsx(CardFooter, { className: "border-t", children: renderFooter() })) : (_jsxs(CardFooter, { className: "border-t flex-col items-stretch gap-1", children: [_jsx(TypingIndicator, { typing: typing }), _jsx(KeyboardAvoidingView, { padding: true, offset: 8, className: "w-full", children: _jsx(Composer, { onSend: send, onTyping: signalTyping, attachmentConfig: attachmentConfig, sendAttachment: sendAttachment, disabled: composerDisabled, autoFocus: autoFocus, placeholder: composerPlaceholder, mentionConfig: config.mentionConfig }) })] }))] }));
139
141
  }
@@ -1 +1 @@
1
- {"version":3,"file":"ChatShell.js","sourceRoot":"","sources":["../../src/ui/ChatShell.tsx"],"names":[],"mappings":"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","sourcesContent":["\"use client\";\n\nimport * as React from \"react\";\nimport type { ChatMessageV1, SendAttachmentFn, ModerationHandlers } from \"@ttt-productions/chat-core\";\nimport type {\n ChatCoreConfig,\n ChatAttachmentConfig,\n MessageRendererRegistry,\n} from \"../types.js\";\nimport { Card, CardHeader, CardContent, CardFooter, Skeleton } from \"@ttt-productions/ui-core/react\";\nimport { KeyboardAvoidingView } from \"@ttt-productions/mobile-core/react\";\nimport { useChatMessages } from \"../hooks/useChatMessages.js\";\nimport { useRealtimeChatMessages } from \"../realtime/useRealtimeChatMessages.js\";\nimport type { RealtimeChatClient } from \"../realtime/transport.js\";\nimport { MessageList } from \"./MessageList.js\";\nimport { Composer } from \"./Composer.js\";\nimport { ThreadActions } from \"./menus.js\";\n\nexport type ChatShellProps = {\n config: ChatCoreConfig;\n\n // Header\n header?: React.ReactNode;\n\n // Send handler — text-only. On the realtime transport this is OPTIONAL: the\n // shell sends through the DO socket. If provided, it is still called (so a\n // consumer can mirror to analytics), but the socket send is authoritative.\n onSend?: (text: string, replyTo?: ChatMessageV1[\"replyTo\"]) => Promise<void>;\n\n // Message rendering\n renderMessage?: (m: any) => React.ReactNode;\n messageRenderers?: MessageRendererRegistry;\n handlers?: ModerationHandlers;\n\n // Composer attachment config\n attachmentConfig?: ChatAttachmentConfig;\n sendAttachment?: SendAttachmentFn;\n composerPlaceholder?: string;\n autoFocus?: boolean;\n\n // Three render slots\n renderAboveMessages?: () => React.ReactNode;\n renderBelowMessages?: () => React.ReactNode;\n /** If provided, replaces the Composer entirely */\n renderFooter?: () => React.ReactNode;\n\n // Sender interaction\n onSenderClick?: (senderId: string, displayName: string) => void;\n\n // Disable\n composerDisabled?: boolean;\n\n /** Fill the parent's height instead of the default fixed-height card. Lays the Card out\n * as `flex flex-col h-full` with the message list flexing to fill (scrolls inside), so a\n * full-height page panel doesn't grow unbounded or waste space on a fixed 400px box.\n * The consumer must give ChatShell a bounded-height parent (e.g. an `h-full` panel). */\n fillHeight?: boolean;\n /** Advanced override for the scrollable region's class (forwarded to MessageList).\n * Usually unnecessary — prefer `fillHeight`. */\n scrollClassName?: string;\n};\n\n/** The resolved data a transport hook hands the presentational view. */\ntype ResolvedChat = {\n allowed: boolean;\n isInitialLoading: boolean;\n messages: ChatMessageV1[];\n fetchOlder: () => Promise<void>;\n hasOlder: boolean;\n isFetchingOlder: boolean;\n /** The send handler the Composer calls (socket send on realtime, prop on firestore). */\n send: (text: string, replyTo?: ChatMessageV1[\"replyTo\"]) => Promise<void>;\n /** Retry a failed realtime send by its original clientMessageId (realtime only;\n * absent on firestore). Wired to MessageItemDefault's failed-bubble retry. */\n retrySend?: (clientMessageId: string) => void;\n /** Advance the authoritative read cursor (realtime only; absent on the firestore path).\n * Returns whether the ack was actually sent so the caller advances its local cursor only on success (M2). */\n readAck?: (latestSeq: number, focused: boolean) => boolean;\n /** Realtime connection status — drives the disconnected banner (R13). Absent on firestore. */\n status?: string;\n /** uids currently typing (realtime only) — drives the typing indicator (R14). */\n typing?: string[];\n /** Signal the local user is typing (coalesced by the transport). Wired to the Composer (R14). */\n signalTyping?: () => void;\n};\n\n/**\n * ChatShell dispatches on `config.transport`. Hooks must be unconditional, so the\n * two transports live in separate inner components (each calls exactly ONE data\n * hook) and both render the shared presentational `ChatShellView`. The default\n * (undefined / 'firestore') transport is byte-for-byte the previous behavior.\n */\nexport function ChatShell(props: ChatShellProps) {\n if (props.config.transport === \"realtime\") {\n return <RealtimeChatShell {...props} />;\n }\n return <FirestoreChatShell {...props} />;\n}\n\nfunction FirestoreChatShell(props: ChatShellProps) {\n const { config, onSend } = props;\n const r = useChatMessages(config);\n const send = React.useCallback(\n async (text: string, replyTo?: ChatMessageV1[\"replyTo\"]) => {\n if (onSend) await onSend(text, replyTo);\n },\n [onSend],\n );\n return <ChatShellView {...props} resolved={{ ...r, send }} />;\n}\n\nfunction RealtimeChatShell(props: ChatShellProps) {\n const { config, onSend } = props;\n const client = config.realtime?.client as RealtimeChatClient | undefined;\n if (!client) {\n throw new Error(\n \"[ChatShell] transport 'realtime' requires config.realtime.client (createRealtimeChatClient(...))\",\n );\n }\n const r = useRealtimeChatMessages(client);\n const send = React.useCallback(\n async (text: string, replyTo?: ChatMessageV1[\"replyTo\"]) => {\n // Map the UI replyTo (messageId-based) to the wire replyTo (seq + preview).\n const wireReply =\n replyTo && replyTo.messageId\n ? { messageSeq: Number(replyTo.messageId), preview: replyTo.messagePreview ?? \"\" }\n : null;\n const ok = r.send(text, Number.isFinite(wireReply?.messageSeq) ? wireReply : null);\n // C-B8: surface a closed-socket failure so the Composer keeps the user's text\n // instead of clearing it on a no-op send.\n if (!ok) throw new Error(\"chat-send-failed\");\n if (onSend) await onSend(text, replyTo); // optional mirror\n },\n [r, onSend],\n );\n return (\n <ChatShellView\n {...props}\n resolved={{\n allowed: r.allowed,\n isInitialLoading: r.isInitialLoading,\n messages: r.messages,\n fetchOlder: r.fetchOlder,\n hasOlder: r.hasOlder,\n isFetchingOlder: r.isFetchingOlder,\n send,\n retrySend: r.retrySend,\n readAck: r.readAck,\n status: r.status,\n typing: r.typing,\n signalTyping: r.signalTyping,\n }}\n />\n );\n}\n\nfunction ChatShellView(props: ChatShellProps & { resolved: ResolvedChat }) {\n const {\n config,\n header,\n renderMessage,\n messageRenderers,\n handlers,\n attachmentConfig,\n sendAttachment,\n composerPlaceholder,\n autoFocus = false,\n renderAboveMessages,\n renderBelowMessages,\n renderFooter,\n onSenderClick,\n composerDisabled,\n fillHeight = false,\n scrollClassName,\n resolved,\n } = props;\n\n const { allowed, isInitialLoading, messages, fetchOlder, hasOlder, isFetchingOlder, send, retrySend, readAck, status, typing, signalTyping } = resolved;\n\n const [showScrollToBottom, setShowScrollToBottom] = React.useState(false);\n const [atBottom, setAtBottom] = React.useState(true);\n const [focused, setFocused] = React.useState(true);\n const lastAckedRef = React.useRef(0);\n const leaseHeldRef = React.useRef(false);\n\n // The latest authoritative seq currently rendered (optimistic rows have no seq).\n const latestSeq = React.useMemo(() => {\n let max = 0;\n for (const m of messages) {\n const seq = typeof m.meta?.seq === \"number\" ? (m.meta?.seq as number) : 0;\n if (seq > max) max = seq;\n }\n return max;\n }, [messages]);\n\n // Track tab focus/visibility so the read-ack \"focused\" lease releases on blur/hide.\n React.useEffect(() => {\n const compute = () =>\n typeof document === \"undefined\" ||\n (document.visibilityState !== \"hidden\" && (typeof document.hasFocus !== \"function\" || document.hasFocus()));\n setFocused(compute());\n const onChange = () => setFocused(compute());\n window.addEventListener(\"focus\", onChange);\n window.addEventListener(\"blur\", onChange);\n document.addEventListener(\"visibilitychange\", onChange);\n return () => {\n window.removeEventListener(\"focus\", onChange);\n window.removeEventListener(\"blur\", onChange);\n document.removeEventListener(\"visibilitychange\", onChange);\n };\n }, []);\n\n // C-B6: advance the authoritative read cursor when the reader is actually at the\n // latest message (channel mounted + tab focused + scrolled to bottom); release the\n // focus lease exactly once when they stop. No-op on the firestore path (no readAck).\n React.useEffect(() => {\n if (!readAck || latestSeq <= 0) return;\n if (focused && atBottom) {\n leaseHeldRef.current = true;\n if (latestSeq > lastAckedRef.current) {\n // Advance the local marker ONLY if the frame was actually sent — a drop during a\n // disconnect window must stay un-acked so it retries (the client also re-sends the\n // cursor on reconnect). (M2)\n if (readAck(latestSeq, true)) lastAckedRef.current = latestSeq;\n }\n } else if (leaseHeldRef.current) {\n leaseHeldRef.current = false;\n readAck(latestSeq, false);\n }\n }, [readAck, latestSeq, atBottom, focused]);\n\n // In fill mode the Card flexes to its parent's height and the message list fills the\n // space between header and composer; otherwise it's the default natural-height card.\n const cardClassName = fillHeight ? \"w-full flex flex-col h-full min-h-0\" : \"w-full\";\n const contentClassName = fillHeight ? \"p-0 flex-1 min-h-0 flex flex-col\" : \"p-0\";\n\n if (!allowed) {\n return <div className=\"p-4 text-sm opacity-70\">You don&apos;t have access to this thread.</div>;\n }\n\n if (isInitialLoading) {\n // Render the consumer's header + render-slots even while the message list is still\n // loading — ONLY the MessageList region is skeletonized. Previously this early-return\n // was an all-skeleton Card that SWALLOWED renderAboveMessages/renderBelowMessages/\n // renderFooter, so a consumer that hangs critical controls off those slots (e.g. the\n // guild-invite binding-agreement Agree/Decline/stake-share controls) showed nothing for\n // the entire connect window while the realtime socket sat in 'connecting' (F4.1). The\n // agreement flow must work whether or not realtime chat is connected.\n return (\n <Card className={cardClassName}>\n {(header || handlers) && (\n <CardHeader className=\"flex flex-row items-start justify-between gap-4 space-y-0\">\n <div>{header}</div>\n <div className=\"flex flex-col items-end gap-2\">\n <ThreadActions threadId={config.threadId} isAdmin={config.isAdmin} handlers={handlers} />\n </div>\n </CardHeader>\n )}\n <ChatConnectionBanner status={status} />\n {renderAboveMessages && (\n <div className=\"border-b\">{renderAboveMessages()}</div>\n )}\n <CardContent className={contentClassName}>\n <Skeleton className=\"h-[240px] w-full\" />\n </CardContent>\n {renderBelowMessages && (\n <div className=\"border-t\">{renderBelowMessages()}</div>\n )}\n {renderFooter ? (\n <CardFooter className=\"border-t\">{renderFooter()}</CardFooter>\n ) : (\n <CardFooter className=\"border-t\">\n <Skeleton className=\"h-10 w-full\" />\n </CardFooter>\n )}\n </Card>\n );\n }\n\n return (\n <Card className={cardClassName}>\n {(header || handlers) && (\n <CardHeader className=\"flex flex-row items-start justify-between gap-4 space-y-0\">\n <div>{header}</div>\n <div className=\"flex flex-col items-end gap-2\">\n <ThreadActions threadId={config.threadId} isAdmin={config.isAdmin} handlers={handlers} />\n </div>\n </CardHeader>\n )}\n\n {/* R13: a disconnected/reconnecting banner so a configured-but-unreachable Worker\n never presents a healthy-looking empty thread. Only the realtime path sets `status`. */}\n <ChatConnectionBanner status={status} />\n\n {renderAboveMessages && (\n <div className=\"border-b\">{renderAboveMessages()}</div>\n )}\n\n <CardContent className={contentClassName}>\n <MessageList\n messages={messages}\n currentUserId={config.currentUserId}\n isAdmin={config.isAdmin}\n isFetchingOlder={isFetchingOlder}\n hasOlder={hasOlder}\n onLoadOlder={() => fetchOlder()}\n renderMessage={renderMessage}\n messageRenderers={messageRenderers}\n showScrollToBottom={showScrollToBottom}\n onScrollToBottom={() => setShowScrollToBottom(false)}\n onAtBottomChange={setAtBottom}\n fillHeight={fillHeight}\n scrollClassName={scrollClassName}\n handlers={handlers}\n onSenderClick={onSenderClick}\n onRetrySend={retrySend}\n />\n </CardContent>\n\n {renderBelowMessages && (\n <div className=\"border-t\">{renderBelowMessages()}</div>\n )}\n\n {renderFooter ? (\n <CardFooter className=\"border-t\">\n {renderFooter()}\n </CardFooter>\n ) : (\n <CardFooter className=\"border-t flex-col items-stretch gap-1\">\n {/* R14: typing dots above the composer (realtime only — `typing` is undefined on firestore). */}\n <TypingIndicator typing={typing} />\n <KeyboardAvoidingView padding offset={8} className=\"w-full\">\n <Composer\n onSend={send}\n onTyping={signalTyping}\n attachmentConfig={attachmentConfig}\n sendAttachment={sendAttachment}\n disabled={composerDisabled}\n autoFocus={autoFocus}\n placeholder={composerPlaceholder}\n mentionConfig={config.mentionConfig}\n />\n </KeyboardAvoidingView>\n </CardFooter>\n )}\n </Card>\n );\n}\n\n/**\n * R13 — connection-state banner. The realtime path sets `status`; once the first\n * snapshot has loaded (ChatShellView is past `isInitialLoading`), a dropped/unreachable\n * socket surfaces here instead of a silently-empty thread. Renders nothing on the\n * firestore path (`status` undefined) or while live (`open`).\n */\nfunction ChatConnectionBanner({ status }: { status?: string }) {\n if (status !== \"reconnecting\" && status !== \"closed\") return null;\n return (\n <div\n className=\"px-3 py-1.5 text-xs text-center bg-muted text-muted-foreground border-b\"\n role=\"status\"\n aria-live=\"polite\"\n >\n {status === \"reconnecting\"\n ? \"Reconnecting… messages may be delayed.\"\n : \"Disconnected — messages may be out of date.\"}\n </div>\n );\n}\n\n/** R14 — typing indicator. `typing` is the set of OTHER users typing (the transport\n * excludes the local user). Generic copy (no name resolution) for v1. */\nfunction TypingIndicator({ typing }: { typing?: string[] }) {\n if (!typing || typing.length === 0) return null;\n return (\n <div className=\"text-xs italic text-muted-foreground\" aria-live=\"polite\">\n {typing.length === 1 ? \"Someone is typing…\" : \"Several people are typing…\"}\n </div>\n );\n}\n"]}
1
+ {"version":3,"file":"ChatShell.js","sourceRoot":"","sources":["../../src/ui/ChatShell.tsx"],"names":[],"mappings":"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","sourcesContent":["\"use client\";\n\nimport * as React from \"react\";\nimport type { ChatMessageV1, SendAttachmentFn, ModerationHandlers } from \"@ttt-productions/chat-core\";\nimport type {\n ChatCoreConfig,\n ChatAttachmentConfig,\n MessageRendererRegistry,\n} from \"../types.js\";\nimport { Card, CardHeader, CardContent, CardFooter, Skeleton } from \"@ttt-productions/ui-core/react\";\nimport { KeyboardAvoidingView } from \"@ttt-productions/mobile-core/react\";\nimport { Loader2 } from \"lucide-react\";\nimport { useChatMessages } from \"../hooks/useChatMessages.js\";\nimport { useRealtimeChatMessages } from \"../realtime/useRealtimeChatMessages.js\";\nimport type { RealtimeChatClient } from \"../realtime/transport.js\";\nimport { MessageList } from \"./MessageList.js\";\nimport { Composer } from \"./Composer.js\";\nimport { ThreadActions } from \"./menus.js\";\n\nexport type ChatShellProps = {\n config: ChatCoreConfig;\n\n // Header\n header?: React.ReactNode;\n\n // Send handler — text-only. On the realtime transport this is OPTIONAL: the\n // shell sends through the DO socket. If provided, it is still called (so a\n // consumer can mirror to analytics), but the socket send is authoritative.\n onSend?: (text: string, replyTo?: ChatMessageV1[\"replyTo\"]) => Promise<void>;\n\n // Message rendering\n renderMessage?: (m: any) => React.ReactNode;\n messageRenderers?: MessageRendererRegistry;\n handlers?: ModerationHandlers;\n\n // Composer attachment config\n attachmentConfig?: ChatAttachmentConfig;\n sendAttachment?: SendAttachmentFn;\n composerPlaceholder?: string;\n autoFocus?: boolean;\n\n // Three render slots\n renderAboveMessages?: () => React.ReactNode;\n renderBelowMessages?: () => React.ReactNode;\n /** If provided, replaces the Composer entirely */\n renderFooter?: () => React.ReactNode;\n\n // Sender interaction\n onSenderClick?: (senderId: string, displayName: string) => void;\n\n // Disable\n composerDisabled?: boolean;\n\n /** Fill the parent's height instead of the default fixed-height card. Lays the Card out\n * as `flex flex-col h-full` with the message list flexing to fill (scrolls inside), so a\n * full-height page panel doesn't grow unbounded or waste space on a fixed 400px box.\n * The consumer must give ChatShell a bounded-height parent (e.g. an `h-full` panel). */\n fillHeight?: boolean;\n /** Advanced override for the scrollable region's class (forwarded to MessageList).\n * Usually unnecessary — prefer `fillHeight`. */\n scrollClassName?: string;\n};\n\n/** The resolved data a transport hook hands the presentational view. */\ntype ResolvedChat = {\n allowed: boolean;\n isInitialLoading: boolean;\n messages: ChatMessageV1[];\n fetchOlder: () => Promise<void>;\n hasOlder: boolean;\n isFetchingOlder: boolean;\n /** The send handler the Composer calls (socket send on realtime, prop on firestore). */\n send: (text: string, replyTo?: ChatMessageV1[\"replyTo\"]) => Promise<void>;\n /** Retry a failed realtime send by its original clientMessageId (realtime only;\n * absent on firestore). Wired to MessageItemDefault's failed-bubble retry. */\n retrySend?: (clientMessageId: string) => void;\n /** Advance the authoritative read cursor (realtime only; absent on the firestore path).\n * Returns whether the ack was actually sent so the caller advances its local cursor only on success (M2). */\n readAck?: (latestSeq: number, focused: boolean) => boolean;\n /** Realtime connection status — drives the disconnected banner (R13). Absent on firestore. */\n status?: string;\n /** uids currently typing (realtime only) — drives the typing indicator (R14). */\n typing?: string[];\n /** Signal the local user is typing (coalesced by the transport). Wired to the Composer (R14). */\n signalTyping?: () => void;\n};\n\n/**\n * ChatShell dispatches on `config.transport`. Hooks must be unconditional, so the\n * two transports live in separate inner components (each calls exactly ONE data\n * hook) and both render the shared presentational `ChatShellView`. The default\n * (undefined / 'firestore') transport is byte-for-byte the previous behavior.\n */\nexport function ChatShell(props: ChatShellProps) {\n if (props.config.transport === \"realtime\") {\n return <RealtimeChatShell {...props} />;\n }\n return <FirestoreChatShell {...props} />;\n}\n\nfunction FirestoreChatShell(props: ChatShellProps) {\n const { config, onSend } = props;\n const r = useChatMessages(config);\n const send = React.useCallback(\n async (text: string, replyTo?: ChatMessageV1[\"replyTo\"]) => {\n if (onSend) await onSend(text, replyTo);\n },\n [onSend],\n );\n return <ChatShellView {...props} resolved={{ ...r, send }} />;\n}\n\nfunction RealtimeChatShell(props: ChatShellProps) {\n const { config, onSend } = props;\n const client = config.realtime?.client as RealtimeChatClient | undefined;\n if (!client) {\n throw new Error(\n \"[ChatShell] transport 'realtime' requires config.realtime.client (createRealtimeChatClient(...))\",\n );\n }\n const r = useRealtimeChatMessages(client);\n const send = React.useCallback(\n async (text: string, replyTo?: ChatMessageV1[\"replyTo\"]) => {\n // Map the UI replyTo (messageId-based) to the wire replyTo (seq + preview).\n const wireReply =\n replyTo && replyTo.messageId\n ? { messageSeq: Number(replyTo.messageId), preview: replyTo.messagePreview ?? \"\" }\n : null;\n const ok = r.send(text, Number.isFinite(wireReply?.messageSeq) ? wireReply : null);\n // C-B8: surface a closed-socket failure so the Composer keeps the user's text\n // instead of clearing it on a no-op send.\n if (!ok) throw new Error(\"chat-send-failed\");\n if (onSend) await onSend(text, replyTo); // optional mirror\n },\n [r, onSend],\n );\n return (\n <ChatShellView\n {...props}\n resolved={{\n allowed: r.allowed,\n isInitialLoading: r.isInitialLoading,\n messages: r.messages,\n fetchOlder: r.fetchOlder,\n hasOlder: r.hasOlder,\n isFetchingOlder: r.isFetchingOlder,\n send,\n retrySend: r.retrySend,\n readAck: r.readAck,\n status: r.status,\n typing: r.typing,\n signalTyping: r.signalTyping,\n }}\n />\n );\n}\n\nfunction ChatShellView(props: ChatShellProps & { resolved: ResolvedChat }) {\n const {\n config,\n header,\n renderMessage,\n messageRenderers,\n handlers,\n attachmentConfig,\n sendAttachment,\n composerPlaceholder,\n autoFocus = false,\n renderAboveMessages,\n renderBelowMessages,\n renderFooter,\n onSenderClick,\n composerDisabled,\n fillHeight = false,\n scrollClassName,\n resolved,\n } = props;\n\n const { allowed, isInitialLoading, messages, fetchOlder, hasOlder, isFetchingOlder, send, retrySend, readAck, status, typing, signalTyping } = resolved;\n\n const [showScrollToBottom, setShowScrollToBottom] = React.useState(false);\n const [atBottom, setAtBottom] = React.useState(true);\n const [focused, setFocused] = React.useState(true);\n const lastAckedRef = React.useRef(0);\n const leaseHeldRef = React.useRef(false);\n\n // The latest authoritative seq currently rendered (optimistic rows have no seq).\n const latestSeq = React.useMemo(() => {\n let max = 0;\n for (const m of messages) {\n const seq = typeof m.meta?.seq === \"number\" ? (m.meta?.seq as number) : 0;\n if (seq > max) max = seq;\n }\n return max;\n }, [messages]);\n\n // Track tab focus/visibility so the read-ack \"focused\" lease releases on blur/hide.\n React.useEffect(() => {\n const compute = () =>\n typeof document === \"undefined\" ||\n (document.visibilityState !== \"hidden\" && (typeof document.hasFocus !== \"function\" || document.hasFocus()));\n setFocused(compute());\n const onChange = () => setFocused(compute());\n window.addEventListener(\"focus\", onChange);\n window.addEventListener(\"blur\", onChange);\n document.addEventListener(\"visibilitychange\", onChange);\n return () => {\n window.removeEventListener(\"focus\", onChange);\n window.removeEventListener(\"blur\", onChange);\n document.removeEventListener(\"visibilitychange\", onChange);\n };\n }, []);\n\n // C-B6: advance the authoritative read cursor when the reader is actually at the\n // latest message (channel mounted + tab focused + scrolled to bottom); release the\n // focus lease exactly once when they stop. No-op on the firestore path (no readAck).\n React.useEffect(() => {\n if (!readAck || latestSeq <= 0) return;\n if (focused && atBottom) {\n leaseHeldRef.current = true;\n if (latestSeq > lastAckedRef.current) {\n // Advance the local marker ONLY if the frame was actually sent — a drop during a\n // disconnect window must stay un-acked so it retries (the client also re-sends the\n // cursor on reconnect). (M2)\n if (readAck(latestSeq, true)) lastAckedRef.current = latestSeq;\n }\n } else if (leaseHeldRef.current) {\n leaseHeldRef.current = false;\n readAck(latestSeq, false);\n }\n }, [readAck, latestSeq, atBottom, focused]);\n\n // In fill mode the Card flexes to its parent's height and the message list fills the\n // space between header and composer; otherwise it's the default natural-height card.\n const cardClassName = fillHeight ? \"w-full flex flex-col h-full min-h-0\" : \"w-full\";\n const contentClassName = fillHeight ? \"p-0 flex-1 min-h-0 flex flex-col\" : \"p-0\";\n\n if (!allowed) {\n return <div className=\"p-4 text-sm opacity-70\">You don&apos;t have access to this thread.</div>;\n }\n\n if (isInitialLoading) {\n // Before the first authoritative snapshot/history result, show an HONEST working\n // state — a visible \"Opening chat…\" spinner in a polite live region — NOT a blank\n // parchment/skeleton box (which read as an empty chat) and NOT the reconnect banner\n // (which must not compete with the initial-opening message). Only the MessageList\n // region is replaced: the consumer's header + actions + the three render-slots +\n // footer all still render, because a consumer that hangs critical controls off those\n // slots (e.g. the guild-invite binding-agreement Agree/Decline/stake-share controls)\n // must work whether or not realtime chat has connected yet (F4.1).\n return (\n <Card className={cardClassName}>\n {(header || handlers) && (\n <CardHeader className=\"flex flex-row items-start justify-between gap-4 space-y-0\">\n <div>{header}</div>\n <div className=\"flex flex-col items-end gap-2\">\n <ThreadActions threadId={config.threadId} isAdmin={config.isAdmin} handlers={handlers} />\n </div>\n </CardHeader>\n )}\n {renderAboveMessages && (\n <div className=\"border-b\">{renderAboveMessages()}</div>\n )}\n <CardContent className={contentClassName}>\n <div\n className=\"flex flex-col items-center justify-center gap-2 py-12 text-sm text-muted-foreground\"\n role=\"status\"\n aria-live=\"polite\"\n >\n <Loader2 className=\"h-5 w-5 animate-spin\" aria-hidden=\"true\" />\n <span>Opening chat…</span>\n </div>\n </CardContent>\n {renderBelowMessages && (\n <div className=\"border-t\">{renderBelowMessages()}</div>\n )}\n {renderFooter ? (\n <CardFooter className=\"border-t\">{renderFooter()}</CardFooter>\n ) : (\n <CardFooter className=\"border-t\">\n <Skeleton className=\"h-10 w-full\" />\n </CardFooter>\n )}\n </Card>\n );\n }\n\n return (\n <Card className={cardClassName}>\n {(header || handlers) && (\n <CardHeader className=\"flex flex-row items-start justify-between gap-4 space-y-0\">\n <div>{header}</div>\n <div className=\"flex flex-col items-end gap-2\">\n <ThreadActions threadId={config.threadId} isAdmin={config.isAdmin} handlers={handlers} />\n </div>\n </CardHeader>\n )}\n\n {/* R13: a disconnected/reconnecting banner so a configured-but-unreachable Worker\n never presents a healthy-looking empty thread. Only the realtime path sets `status`. */}\n <ChatConnectionBanner status={status} />\n\n {renderAboveMessages && (\n <div className=\"border-b\">{renderAboveMessages()}</div>\n )}\n\n <CardContent className={contentClassName}>\n <MessageList\n messages={messages}\n currentUserId={config.currentUserId}\n isAdmin={config.isAdmin}\n isFetchingOlder={isFetchingOlder}\n hasOlder={hasOlder}\n onLoadOlder={() => fetchOlder()}\n renderMessage={renderMessage}\n messageRenderers={messageRenderers}\n showScrollToBottom={showScrollToBottom}\n onScrollToBottom={() => setShowScrollToBottom(false)}\n onAtBottomChange={setAtBottom}\n fillHeight={fillHeight}\n scrollClassName={scrollClassName}\n handlers={handlers}\n onSenderClick={onSenderClick}\n onRetrySend={retrySend}\n />\n </CardContent>\n\n {renderBelowMessages && (\n <div className=\"border-t\">{renderBelowMessages()}</div>\n )}\n\n {renderFooter ? (\n <CardFooter className=\"border-t\">\n {renderFooter()}\n </CardFooter>\n ) : (\n <CardFooter className=\"border-t flex-col items-stretch gap-1\">\n {/* R14: typing dots above the composer (realtime only — `typing` is undefined on firestore). */}\n <TypingIndicator typing={typing} />\n <KeyboardAvoidingView padding offset={8} className=\"w-full\">\n <Composer\n onSend={send}\n onTyping={signalTyping}\n attachmentConfig={attachmentConfig}\n sendAttachment={sendAttachment}\n disabled={composerDisabled}\n autoFocus={autoFocus}\n placeholder={composerPlaceholder}\n mentionConfig={config.mentionConfig}\n />\n </KeyboardAvoidingView>\n </CardFooter>\n )}\n </Card>\n );\n}\n\n/**\n * R13 — connection-state banner. The realtime path sets `status`; once the first\n * snapshot has loaded (ChatShellView is past `isInitialLoading`), a dropped/unreachable\n * socket surfaces here instead of a silently-empty thread. Renders nothing on the\n * firestore path (`status` undefined) or while live (`open`).\n */\nfunction ChatConnectionBanner({ status }: { status?: string }) {\n if (status !== \"reconnecting\" && status !== \"closed\") return null;\n return (\n <div\n className=\"px-3 py-1.5 text-xs text-center bg-muted text-muted-foreground border-b\"\n role=\"status\"\n aria-live=\"polite\"\n >\n {status === \"reconnecting\"\n ? \"Reconnecting… messages may be delayed.\"\n : \"Disconnected — messages may be out of date.\"}\n </div>\n );\n}\n\n/** R14 — typing indicator. `typing` is the set of OTHER users typing (the transport\n * excludes the local user). Generic copy (no name resolution) for v1. */\nfunction TypingIndicator({ typing }: { typing?: string[] }) {\n if (!typing || typing.length === 0) return null;\n return (\n <div className=\"text-xs italic text-muted-foreground\" aria-live=\"polite\">\n {typing.length === 1 ? \"Someone is typing…\" : \"Several people are typing…\"}\n </div>\n );\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"MessageItemDefault.d.ts","sourceRoot":"","sources":["../../src/ui/MessageItemDefault.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,aAAa,EAAkB,kBAAkB,EAAE,MAAM,4BAA4B,CAAC;AAiHpG,MAAM,MAAM,uBAAuB,GAAG;IACpC,CAAC,EAAE,aAAa,CAAC;IACjB,aAAa,EAAE,MAAM,CAAC;IACtB,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,CAAC,EAAE,kBAAkB,CAAC;IAG9B,cAAc,CAAC,EAAE,OAAO,CAAC;IAGzB,aAAa,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,KAAK,IAAI,CAAC;IAEhE;;0DAEsD;IACtD,WAAW,CAAC,EAAE,CAAC,eAAe,EAAE,MAAM,KAAK,IAAI,CAAC;CAEjD,CAAC;AAEF,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,uBAAuB,+BAsGhE"}
1
+ {"version":3,"file":"MessageItemDefault.d.ts","sourceRoot":"","sources":["../../src/ui/MessageItemDefault.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,aAAa,EAAkB,kBAAkB,EAAE,MAAM,4BAA4B,CAAC;AAiIpG,MAAM,MAAM,uBAAuB,GAAG;IACpC,CAAC,EAAE,aAAa,CAAC;IACjB,aAAa,EAAE,MAAM,CAAC;IACtB,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,CAAC,EAAE,kBAAkB,CAAC;IAG9B,cAAc,CAAC,EAAE,OAAO,CAAC;IAGzB,aAAa,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,KAAK,IAAI,CAAC;IAEhE;;0DAEsD;IACtD,WAAW,CAAC,EAAE,CAAC,eAAe,EAAE,MAAM,KAAK,IAAI,CAAC;CAEjD,CAAC;AAEF,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,uBAAuB,+BAsGhE"}
@@ -40,8 +40,17 @@ function AttachmentView({ att }) {
40
40
  }
41
41
  // ready (or legacy/absent status) — resolve the display URL from the asset ref.
42
42
  const url = resolveAttachmentUrl(att);
43
- if (!url)
44
- return null;
43
+ if (!url) {
44
+ // Terminal-ready row, but the authorized URL is still SETTLING (the grant/URL
45
+ // resolver has nothing yet). Returning null left only the surrounding message
46
+ // metadata → an observed empty bubble; a broken/grant-less <img> would flash a
47
+ // 403. Render a neutral loading placeholder in the existing loading visual
48
+ // language (spinner + kind-appropriate generic label + "Loading…", never
49
+ // "Sending…", NO filename) and do NOT mount the media/download renderer until a
50
+ // non-empty authorized URL exists. Sender-only pending/processing/failed states
51
+ // above are unchanged — this begins only after a terminal-ready row is received.
52
+ return (_jsxs("div", { className: "chat-attachment-pending", children: [_jsx(Loader2, { className: "h-4 w-4 shrink-0 animate-spin" }), _jsx("span", { className: "truncate", children: getAttachmentLabel(att.type) }), _jsx("span", { className: "chat-attachment-status-label", children: "Loading\u2026" })] }));
53
+ }
45
54
  if (att.type === "image") {
46
55
  return _jsx(Media, { type: "image", url: url, alt: "Image attachment", className: "chat-attachment-media" });
47
56
  }
@@ -1 +1 @@
1
- {"version":3,"file":"MessageItemDefault.js","sourceRoot":"","sources":["../../src/ui/MessageItemDefault.tsx"],"names":[],"mappings":"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","sourcesContent":["\"use client\";\n\nimport type { ChatMessageV1, ChatAttachment, ModerationHandlers } from \"@ttt-productions/chat-core\";\nimport { MessageText } from \"../mentions/MessageText.js\";\nimport { cn } from \"@ttt-productions/ui-core\";\nimport { Button } from \"@ttt-productions/ui-core/react\";\nimport { MediaViewer } from \"@ttt-productions/media-viewer/react\";\nimport { FileText, Loader2, AlertTriangle } from \"lucide-react\";\nimport { MessageActions } from \"./menus.js\";\nimport { useResolvedSenderName } from \"../context/ChatNameResolverContext.js\";\nimport { useChatAttachmentUrlResolver } from \"../context/ChatAttachmentUrlContext.js\";\nimport { useChatAttachmentMediaComponent } from \"../context/ChatAttachmentMediaContext.js\";\n\n// ============================================\n// Attachment rendering\n// ============================================\n\nfunction getAttachmentLabel(type: ChatAttachment[\"type\"]): string {\n switch (type) {\n case \"image\": return \"Image attachment\";\n case \"video\": return \"Video attachment\";\n case \"audio\": return \"Audio attachment\";\n default: return \"File attachment\";\n }\n}\n\nfunction AttachmentView({ att }: { att: ChatAttachment }) {\n // Display URL is built at render time by the app-injected resolver from\n // att.mediaAssetId — attachments never store URLs (protected-gateway model).\n const resolveAttachmentUrl = useChatAttachmentUrlResolver();\n // App-injected media component (optional) — lets chat attachments ride the\n // app's ONE display path (recovery adapter, diagnostics, telemetry). Falls\n // back to the package's own MediaViewer when no provider is present.\n const Media = useChatAttachmentMediaComponent() ?? MediaViewer;\n // In-flight placeholder — bytes uploaded, processing/moderation pending. No\n // `mediaAssetId` yet. Rendered only to the sender (other participants can't\n // read a pending doc — Firestore rules scope it until `status === 'ready'`).\n if (att.status === \"pending\") {\n return (\n <div className=\"chat-attachment-pending\">\n <Loader2 className=\"h-4 w-4 shrink-0 animate-spin\" />\n <span className=\"truncate\">{getAttachmentLabel(att.type)}</span>\n <span className=\"chat-attachment-status-label\">Sending…</span>\n </div>\n );\n }\n // Processing/moderation rejected the attachment — sender-only.\n if (att.status === \"failed\") {\n return (\n <div className=\"chat-attachment-rejected\">\n <AlertTriangle className=\"h-4 w-4 shrink-0\" />\n <span className=\"truncate\">{getAttachmentLabel(att.type)}</span>\n <span className=\"chat-attachment-status-label\">\n {att.failureReason ?? \"Couldn't send this attachment\"}\n </span>\n </div>\n );\n }\n // ready (or legacy/absent status) — resolve the display URL from the asset ref.\n const url = resolveAttachmentUrl(att);\n if (!url) return null;\n if (att.type === \"image\") {\n return <Media type=\"image\" url={url} alt=\"Image attachment\" className=\"chat-attachment-media\" />;\n }\n if (att.type === \"video\") {\n return <Media type=\"video\" url={url} controls className=\"chat-attachment-media\" />;\n }\n if (att.type === \"audio\") {\n return <Media type=\"audio\" url={url} controls className=\"chat-attachment-media\" />;\n }\n // text/markdown — download link\n return (\n <a\n href={url}\n download={att.name}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"chat-attachment-text-link\"\n >\n <FileText className=\"h-4 w-4 shrink-0\" />\n <span className=\"truncate\">Download attachment</span>\n </a>\n );\n}\n\n// ============================================\n// Reply-to quote\n// ============================================\n\nfunction ReplyQuote({ replyTo }: { replyTo: NonNullable<ChatMessageV1[\"replyTo\"]> }) {\n const replyName = useResolvedSenderName(replyTo.senderId);\n return (\n <div className=\"chat-reply-quote\">\n <span className=\"chat-reply-quote-sender\">{replyName}</span>\n <span className=\"chat-reply-quote-preview\"><MessageText text={replyTo.messagePreview} /></span>\n </div>\n );\n}\n\n// ============================================\n// System message\n// ============================================\n\nfunction SystemMessage({ m }: { m: ChatMessageV1 }) {\n return (\n <div className=\"chat-system-message\">\n {m.text && <span>{m.text}</span>}\n </div>\n );\n}\n\n// ============================================\n// Default message item\n// ============================================\n\nexport type MessageItemDefaultProps = {\n m: ChatMessageV1;\n currentUserId: string;\n isAdmin: boolean;\n handlers?: ModerationHandlers;\n\n // Grouping\n isContinuation?: boolean;\n\n // Sender interaction\n onSenderClick?: (senderId: string, displayName: string) => void;\n\n /** Retry a failed realtime send by its original clientMessageId (wired by\n * ChatShell on the realtime transport; absent on firestore). Enables the\n * retry affordance on a `meta.sendFailed` bubble. */\n onRetrySend?: (clientMessageId: string) => void;\n\n};\n\nexport function MessageItemDefault(props: MessageItemDefaultProps) {\n const { m, currentUserId, isAdmin, handlers, isContinuation, onSenderClick, onRetrySend } = props;\n const senderName = useResolvedSenderName(m.senderId);\n\n // System messages render differently\n if (m.isSystemMessage) {\n return <SystemMessage m={m} />;\n }\n\n const mine = m.senderId === currentUserId;\n\n // Realtime optimistic-send lifecycle (meta is stamped by the channel client):\n // an un-acked echo renders subtly pending; one past the retry/age cap renders a\n // visibly failed bubble with a retry affordance (same clientMessageId — the DO\n // dedups, so a retry can never double-send).\n const sendFailed = m.meta?.sendFailed === true;\n const sendPending = m.meta?.optimistic === true && !sendFailed;\n const clientMessageId =\n typeof m.meta?.clientMessageId === \"string\" ? m.meta.clientMessageId : null;\n\n return (\n <div\n className={cn(\n \"flex flex-col w-fit max-w-[85%]\",\n mine ? \"ml-auto items-end\" : \"mr-auto items-start\",\n isContinuation ? \"chat-continuation-gap\" : \"chat-group-gap\"\n )}\n >\n <div\n className={cn(\n \"chat-bubble\",\n mine ? \"chat-bubble--mine\" : \"chat-bubble--theirs\",\n sendPending && \"chat-bubble--pending\",\n sendFailed && \"chat-bubble--failed\"\n )}\n >\n {!isContinuation && (\n <div className=\"flex items-center gap-2 text-xs opacity-80 mb-1\">\n <span\n className={cn(\"font-medium\", onSenderClick && \"cursor-pointer hover:underline\")}\n onClick={onSenderClick ? () => onSenderClick(m.senderId, senderName) : undefined}\n role={onSenderClick ? \"button\" : undefined}\n tabIndex={onSenderClick ? 0 : undefined}\n onKeyDown={onSenderClick ? (e) => {\n if (e.key === \"Enter\" || e.key === \" \") onSenderClick(m.senderId, senderName);\n } : undefined}\n >\n {senderName}\n </span>\n <span>·</span>\n <span>{new Date(m.createdAt).toLocaleTimeString([], { hour: \"numeric\", minute: \"2-digit\" })}</span>\n </div>\n )}\n\n {m.replyTo && <ReplyQuote replyTo={m.replyTo} />}\n\n {m.text && (\n <p className=\"text-sm whitespace-pre-wrap\">\n <MessageText text={m.text} />\n </p>\n )}\n\n {m.attachment && (\n <div className=\"mt-2\">\n <AttachmentView att={m.attachment} />\n </div>\n )}\n </div>\n\n {/* Send-state status rows render regardless of grouping — a pending/failed\n send must never be indistinguishable from a delivered one. */}\n {sendPending && (\n <div className=\"chat-send-status mt-0.5\" role=\"status\" aria-live=\"polite\">\n <Loader2 className=\"h-3 w-3 shrink-0 animate-spin\" />\n <span>Sending…</span>\n </div>\n )}\n {sendFailed && (\n <div className=\"chat-send-status chat-send-status--failed mt-0.5\" role=\"alert\">\n <AlertTriangle className=\"h-3 w-3 shrink-0\" />\n <span>Couldn&apos;t send</span>\n {onRetrySend && clientMessageId && (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"h-5 px-1.5 text-[0.6875rem]\"\n onClick={() => onRetrySend(clientMessageId)}\n >\n Retry\n </Button>\n )}\n </div>\n )}\n\n {!isContinuation && (\n <div className=\"mt-1\">\n <MessageActions messageId={m.messageId} isAdmin={isAdmin} handlers={handlers} />\n </div>\n )}\n </div>\n );\n}\n"]}
1
+ {"version":3,"file":"MessageItemDefault.js","sourceRoot":"","sources":["../../src/ui/MessageItemDefault.tsx"],"names":[],"mappings":"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","sourcesContent":["\"use client\";\n\nimport type { ChatMessageV1, ChatAttachment, ModerationHandlers } from \"@ttt-productions/chat-core\";\nimport { MessageText } from \"../mentions/MessageText.js\";\nimport { cn } from \"@ttt-productions/ui-core\";\nimport { Button } from \"@ttt-productions/ui-core/react\";\nimport { MediaViewer } from \"@ttt-productions/media-viewer/react\";\nimport { FileText, Loader2, AlertTriangle } from \"lucide-react\";\nimport { MessageActions } from \"./menus.js\";\nimport { useResolvedSenderName } from \"../context/ChatNameResolverContext.js\";\nimport { useChatAttachmentUrlResolver } from \"../context/ChatAttachmentUrlContext.js\";\nimport { useChatAttachmentMediaComponent } from \"../context/ChatAttachmentMediaContext.js\";\n\n// ============================================\n// Attachment rendering\n// ============================================\n\nfunction getAttachmentLabel(type: ChatAttachment[\"type\"]): string {\n switch (type) {\n case \"image\": return \"Image attachment\";\n case \"video\": return \"Video attachment\";\n case \"audio\": return \"Audio attachment\";\n default: return \"File attachment\";\n }\n}\n\nfunction AttachmentView({ att }: { att: ChatAttachment }) {\n // Display URL is built at render time by the app-injected resolver from\n // att.mediaAssetId — attachments never store URLs (protected-gateway model).\n const resolveAttachmentUrl = useChatAttachmentUrlResolver();\n // App-injected media component (optional) — lets chat attachments ride the\n // app's ONE display path (recovery adapter, diagnostics, telemetry). Falls\n // back to the package's own MediaViewer when no provider is present.\n const Media = useChatAttachmentMediaComponent() ?? MediaViewer;\n // In-flight placeholder — bytes uploaded, processing/moderation pending. No\n // `mediaAssetId` yet. Rendered only to the sender (other participants can't\n // read a pending doc — Firestore rules scope it until `status === 'ready'`).\n if (att.status === \"pending\") {\n return (\n <div className=\"chat-attachment-pending\">\n <Loader2 className=\"h-4 w-4 shrink-0 animate-spin\" />\n <span className=\"truncate\">{getAttachmentLabel(att.type)}</span>\n <span className=\"chat-attachment-status-label\">Sending…</span>\n </div>\n );\n }\n // Processing/moderation rejected the attachment — sender-only.\n if (att.status === \"failed\") {\n return (\n <div className=\"chat-attachment-rejected\">\n <AlertTriangle className=\"h-4 w-4 shrink-0\" />\n <span className=\"truncate\">{getAttachmentLabel(att.type)}</span>\n <span className=\"chat-attachment-status-label\">\n {att.failureReason ?? \"Couldn't send this attachment\"}\n </span>\n </div>\n );\n }\n // ready (or legacy/absent status) — resolve the display URL from the asset ref.\n const url = resolveAttachmentUrl(att);\n if (!url) {\n // Terminal-ready row, but the authorized URL is still SETTLING (the grant/URL\n // resolver has nothing yet). Returning null left only the surrounding message\n // metadata → an observed empty bubble; a broken/grant-less <img> would flash a\n // 403. Render a neutral loading placeholder in the existing loading visual\n // language (spinner + kind-appropriate generic label + \"Loading…\", never\n // \"Sending…\", NO filename) and do NOT mount the media/download renderer until a\n // non-empty authorized URL exists. Sender-only pending/processing/failed states\n // above are unchanged — this begins only after a terminal-ready row is received.\n return (\n <div className=\"chat-attachment-pending\">\n <Loader2 className=\"h-4 w-4 shrink-0 animate-spin\" />\n <span className=\"truncate\">{getAttachmentLabel(att.type)}</span>\n <span className=\"chat-attachment-status-label\">Loading…</span>\n </div>\n );\n }\n if (att.type === \"image\") {\n return <Media type=\"image\" url={url} alt=\"Image attachment\" className=\"chat-attachment-media\" />;\n }\n if (att.type === \"video\") {\n return <Media type=\"video\" url={url} controls className=\"chat-attachment-media\" />;\n }\n if (att.type === \"audio\") {\n return <Media type=\"audio\" url={url} controls className=\"chat-attachment-media\" />;\n }\n // text/markdown — download link\n return (\n <a\n href={url}\n download={att.name}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"chat-attachment-text-link\"\n >\n <FileText className=\"h-4 w-4 shrink-0\" />\n <span className=\"truncate\">Download attachment</span>\n </a>\n );\n}\n\n// ============================================\n// Reply-to quote\n// ============================================\n\nfunction ReplyQuote({ replyTo }: { replyTo: NonNullable<ChatMessageV1[\"replyTo\"]> }) {\n const replyName = useResolvedSenderName(replyTo.senderId);\n return (\n <div className=\"chat-reply-quote\">\n <span className=\"chat-reply-quote-sender\">{replyName}</span>\n <span className=\"chat-reply-quote-preview\"><MessageText text={replyTo.messagePreview} /></span>\n </div>\n );\n}\n\n// ============================================\n// System message\n// ============================================\n\nfunction SystemMessage({ m }: { m: ChatMessageV1 }) {\n return (\n <div className=\"chat-system-message\">\n {m.text && <span>{m.text}</span>}\n </div>\n );\n}\n\n// ============================================\n// Default message item\n// ============================================\n\nexport type MessageItemDefaultProps = {\n m: ChatMessageV1;\n currentUserId: string;\n isAdmin: boolean;\n handlers?: ModerationHandlers;\n\n // Grouping\n isContinuation?: boolean;\n\n // Sender interaction\n onSenderClick?: (senderId: string, displayName: string) => void;\n\n /** Retry a failed realtime send by its original clientMessageId (wired by\n * ChatShell on the realtime transport; absent on firestore). Enables the\n * retry affordance on a `meta.sendFailed` bubble. */\n onRetrySend?: (clientMessageId: string) => void;\n\n};\n\nexport function MessageItemDefault(props: MessageItemDefaultProps) {\n const { m, currentUserId, isAdmin, handlers, isContinuation, onSenderClick, onRetrySend } = props;\n const senderName = useResolvedSenderName(m.senderId);\n\n // System messages render differently\n if (m.isSystemMessage) {\n return <SystemMessage m={m} />;\n }\n\n const mine = m.senderId === currentUserId;\n\n // Realtime optimistic-send lifecycle (meta is stamped by the channel client):\n // an un-acked echo renders subtly pending; one past the retry/age cap renders a\n // visibly failed bubble with a retry affordance (same clientMessageId — the DO\n // dedups, so a retry can never double-send).\n const sendFailed = m.meta?.sendFailed === true;\n const sendPending = m.meta?.optimistic === true && !sendFailed;\n const clientMessageId =\n typeof m.meta?.clientMessageId === \"string\" ? m.meta.clientMessageId : null;\n\n return (\n <div\n className={cn(\n \"flex flex-col w-fit max-w-[85%]\",\n mine ? \"ml-auto items-end\" : \"mr-auto items-start\",\n isContinuation ? \"chat-continuation-gap\" : \"chat-group-gap\"\n )}\n >\n <div\n className={cn(\n \"chat-bubble\",\n mine ? \"chat-bubble--mine\" : \"chat-bubble--theirs\",\n sendPending && \"chat-bubble--pending\",\n sendFailed && \"chat-bubble--failed\"\n )}\n >\n {!isContinuation && (\n <div className=\"flex items-center gap-2 text-xs opacity-80 mb-1\">\n <span\n className={cn(\"font-medium\", onSenderClick && \"cursor-pointer hover:underline\")}\n onClick={onSenderClick ? () => onSenderClick(m.senderId, senderName) : undefined}\n role={onSenderClick ? \"button\" : undefined}\n tabIndex={onSenderClick ? 0 : undefined}\n onKeyDown={onSenderClick ? (e) => {\n if (e.key === \"Enter\" || e.key === \" \") onSenderClick(m.senderId, senderName);\n } : undefined}\n >\n {senderName}\n </span>\n <span>·</span>\n <span>{new Date(m.createdAt).toLocaleTimeString([], { hour: \"numeric\", minute: \"2-digit\" })}</span>\n </div>\n )}\n\n {m.replyTo && <ReplyQuote replyTo={m.replyTo} />}\n\n {m.text && (\n <p className=\"text-sm whitespace-pre-wrap\">\n <MessageText text={m.text} />\n </p>\n )}\n\n {m.attachment && (\n <div className=\"mt-2\">\n <AttachmentView att={m.attachment} />\n </div>\n )}\n </div>\n\n {/* Send-state status rows render regardless of grouping — a pending/failed\n send must never be indistinguishable from a delivered one. */}\n {sendPending && (\n <div className=\"chat-send-status mt-0.5\" role=\"status\" aria-live=\"polite\">\n <Loader2 className=\"h-3 w-3 shrink-0 animate-spin\" />\n <span>Sending…</span>\n </div>\n )}\n {sendFailed && (\n <div className=\"chat-send-status chat-send-status--failed mt-0.5\" role=\"alert\">\n <AlertTriangle className=\"h-3 w-3 shrink-0\" />\n <span>Couldn&apos;t send</span>\n {onRetrySend && clientMessageId && (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"h-5 px-1.5 text-[0.6875rem]\"\n onClick={() => onRetrySend(clientMessageId)}\n >\n Retry\n </Button>\n )}\n </div>\n )}\n\n {!isContinuation && (\n <div className=\"mt-1\">\n <MessageActions messageId={m.messageId} isAdmin={isAdmin} handlers={handlers} />\n </div>\n )}\n </div>\n );\n}\n"]}