/** * @oxpulse/chat-widget — MessageList (W2.2 slice 1-3). * * Renders chat history inside a Shadow DOM container and subscribes to * live updates via the SDK client. Uses duck-typed MessageListClient * interface to avoid direct SDK imports at the widget package level. * * Slice 3 additions: reaction cluster rendering, ReactionQuickBar (renamed * from ReactionPicker, heart-first amendment 2026-07-14), live reaction * updates via onReaction callback. */ import type { AttachmentMeta } from '../utils/attachments.js'; import { type ReplySnapshot } from '../utils/reply-helpers.js'; import { type PrivilegedRole } from './role-badge.js'; import { type PinnedEntry } from './pinned-banner.js'; import { type ThreadRow } from './thread-panel.js'; import type { ProductMeta, WriteFailureOp, WriteFailureReason } from '../types.js'; /** Minimal shape of a MessageRow we need for rendering. */ export interface MessageRow { seq: number; msgId: string; senderUid: string; sealed: ArrayBuffer; plaintext?: ArrayBuffer; /** * U2: set by the SDK (chat-sdk MessageRow.unsealError) when unseal() failed * for this row — the row is preserved rather than dropped. When set, * plaintext is undefined; the render path must show a distinct * failed-decrypt placeholder instead of the (empty) normal body. */ unsealError?: 'replay' | 'auth' | 'unknown'; /** * Set when this message failed to send (upload interrupted on reload, or * upload failed while the page was open). When set, the bubble renders a * failure state with the caption text preserved, plus a retry button * (when retry is meaningful — `retryable: true`) and a dismiss button. */ sendFailed?: { /** Human-readable reason for the failure. */ reason: string; /** Whether retry is meaningful (blob still in memory). When false, * only a dismiss button is shown (the upload was interrupted by a * reload and the blob is unrecoverable). */ retryable: boolean; }; createdAt: string; deletedAt?: string; editedAt?: string; threadRootMsgId: string | null; productRef: string | null; productMeta: ProductMeta | null; text?: string; /** W2.2 slice 4: attachment metadata for bubble rendering. */ attachments?: AttachmentMeta[]; } /** Minimal shape of a MutationEvent we need for re-rendering. */ export interface MutationEvent { msgId: string; op: string; deletedAt?: string; editedAt?: string; /** Present on op="pin" — the user who pinned the message. */ pinnedBy?: string; } /** Live reaction event from subscribe() onReaction callback. */ export interface ReactionEvent { msgId: string; emoji: string; op: 'add' | 'remove'; userUid: string; /** Exact post-mutation total count, when provided by the server/event. * If omitted or zero, MessageList re-fetches getReactions for the source of truth. */ totalCount?: number; } /** * Roster entry as consumed by the widget — display name plus optional avatar * and privileged role. Structural mirror of chat-sdk's `RosterEntry` (the * widget stays SDK-import-free; element.ts bridges the concrete SDK type at * the seam). * * `role` is UX-only (P5): a presentation hint for the role badge, never * client-side authorization for a privileged operation. */ export interface RosterEntry { displayName: string; avatarUrl: string | null; role?: PrivilegedRole; } /** Duck-typed subset of SDKChatClient used by MessageList. */ export interface MessageListClient { list(roomId: string, args: { limit: number; }): Promise<{ items: MessageRow[]; hasNext: boolean; }>; subscribe(roomId: string, args: { onMessage: (row: MessageRow) => void; onMutation?: (event: MutationEvent) => void; onReaction?: (event: ReactionEvent) => void; onRosterSignal?: () => void; onTyping?: (event: { userId: string; ttlSecs?: number; }) => void; onPresence?: (event: { userId: string; lastSeenAt: string; }) => void; onReadReceipt?: (event: { userId: string; lastSeq: number; }) => void; }): () => void; /** Optional — reactions support. If absent, reactions are disabled. */ getReactions?(roomId: string, msgId: string): Promise<{ counts: Record; users: Record; truncated: boolean; }>; sendReaction?(roomId: string, msgId: string, emoji: string): Promise; removeReaction?(roomId: string, msgId: string, emoji: string): Promise; /** * T18: Fetch roster for the room. * Returns a Map (display name + optional avatar URL). Optional — when absent roster is disabled. */ getRoster?(roomId: string): Promise>; /** #121: fetch presence snapshot. */ getPresence?(roomId: string): Promise>; /** #121: send presence heartbeat. Fire-and-forget. */ sendPresence?(roomId: string): Promise; /** #122: mark messages up to seq as read. */ markRead?(roomId: string, seq: number): Promise; /** #126: fetch thread replies. */ getThread?(roomId: string, rootMsgId: string): Promise; /** #126: send a text message (used for thread replies). */ sendText?(roomId: string, args: { senderUid: string; text: string; threadRootMsgId?: string; }): Promise; /** #228: list pinned messages in a room (ordered by pinned_at desc). */ listPins?(roomId: string): Promise; /** #228: pin a message. Idempotent. */ pinMessage?(roomId: string, msgId: string): Promise; /** #228: unpin a message. No-op if not pinned. */ unpinMessage?(roomId: string, msgId: string): Promise; /** * issue #67: fetch an attachment blob WITH authentication. The attachment * GET route is JWT-authenticated (Authorization: Bearer only — no signed * query-token the way the PUT upload URL has), so a bare `` 401s * for every viewer. Optional — when absent, renderAttachment() falls back * to a direct `img.src = att.url` assignment (existing behavior, used by * tests and any environment without an authenticated fetch bridge). */ fetchAttachmentBlob?(url: string, signal?: AbortSignal): Promise; } export interface MessageListOptions { client: MessageListClient; roomId: string; container: HTMLElement; /** * BCP-47 tag or an already-resolved Locale. Optional — defaults via * resolveLocale() (lang → navigator.language prefix → 'en') so direct * construction (tests, advanced consumers) never has to think about it. */ lang?: string; selfUid: string; /** Optional AbortSignal to cancel mount mid-flight (C1). */ signal?: AbortSignal; /** * Shadow host element to mount the ReactionQuickBar into (MAJOR-5). * When provided, the bar's show() mounts into this element instead of #container, * escaping the overflow:hidden clip of the widgetRoot. */ shadowHost?: ShadowRoot; /** * P5: label overrides for the roster role badge (config `roleLabels`), e.g. * `{ moderator: "Seller" }`. Presentation only — falls back to the built-in * i18n label ("mod" / "owner") for a role with no override. */ roleLabels?: Record; /** * W7: Fires when the user clicks the reply button on a bubble. * Provides a snapshot the consumer can feed to Composer.setReplyTarget(). */ onSetReply?: (snapshot: ReplySnapshot) => void; /** Whether reaction UI is enabled. Default: true. */ reactionsEnabled?: boolean; /** Whether pinned-messages banner is enabled. Default: true. */ pinnedMessagesEnabled?: boolean; /** * Write-401 fix (issue #78): fires when a reaction write op * (sendReaction/removeReaction) fails with an auth error — routes * through the SAME token-expired signal the subscribe path uses * (element.ts's shared notifier), wired by the caller rather than * re-implemented here. */ onAuthExpired?: () => void; /** * Write-401 fix (issue #78): failure-counter hook — fires on EVERY * reaction write failure (auth, network, or other), not just 401s, so an * integrator can count silent write failures. */ onWriteFailure?: (op: WriteFailureOp, reason: WriteFailureReason, message: string) => void; /** * Review finding #4: fires when an attachment's authenticated hydration * reaches FINAL failure (after retries exhaust, or immediately for a * permanent HTTP status 403/404/410) — once per attachment per final * failure, NOT per retry. The host element (element.ts) wires this to * dispatch `oxpulse-chat:attachment-error` from the widget host element so * an integrator can surface/telemetry a dead attachment. */ onAttachmentError?: (msgId: string, attachmentId: string) => void; /** * Observability: fires when a row carrying an `unsealError` (chat-sdk's * classifyUnsealError reason 'replay' | 'auth' | 'unknown') is rendered — * a replay-attack signature and a benign timeout are otherwise * indistinguishable to the host. Deduped once per msgId per widget lifetime * (a re-render via #updateBubble does not re-fire). The host element * (element.ts) wires this to dispatch `oxpulse-chat:decrypt-error` from the * widget host element so an integrator can telemetry/alert on decrypt * failures by class — the replay reason is the one that matters most on an * untrusted server. */ onDecryptError?: (msgId: string, seq: number, reason: 'replay' | 'auth' | 'unknown') => void; /** * Fires when the user clicks the retry button on a send-failed message * bubble. Only called when `sendFailed.retryable` is true (the blob is * still in memory). The host (element.ts) re-initiates the send. */ onRetrySendFailed?: (msgId: string) => void; /** * Fires when the user clicks the dismiss button on a send-failed message * bubble. The host (element.ts) dequeues the outbox entry and removes * the row from the list. */ onDismissFailedMessage?: (msgId: string) => void; } /** * Review finding #2: typed error carrying the HTTP status from the authed * attachment fetch, so the retry loop can distinguish permanent (403/404/410 — * the attachment is gone/forbidden) from transient (429/401/network) failures * and skip pointless retries. Thrown by the host element's fetchAttachmentBlob * bridge (element.ts); the retry loop inspects it via isPermanentHydrateError(). */ export declare class AttachmentFetchError extends Error { readonly status: number; constructor(status: number, message?: string); } /** * Safety cap on the live-streamed message window (production-blocking audit * gap): #order/#rows/DOM bubbles grew unboundedly as live messages streamed * in, with no eviction anywhere. A busy central room (thousands of msgs/day) * plus a long-open tab accumulates unbounded memory. This bounds the LIVE * window only — full scroll-back virtualization is a separate future feature * once "load older" pagination UI exists (list()'s hasNext is already * returned but unused today). */ export declare const MAX_LIVE_MESSAGES = 300; /** * Hard ceiling that evicts even while the user is scrolled up reading * history (unpinned). Without this, a visitor who scrolls up once in a busy * central room and never returns to bottom accumulates #order/#rows/DOM * without limit for as long as the tab stays open — the soft * MAX_LIVE_MESSAGES cap above only trims on a PINNED append, so it never * fires for that session. Set well above MAX_LIVE_MESSAGES so an actively * reading user gets a large buffer before anything is yanked out from under * them; only a visitor who has let 600+ messages pile up while scrolled away * pays the cost of a jump, which is strictly better than unbounded growth. */ export declare const MAX_LIVE_MESSAGES_HARD_CEILING: number; /** Heart-add pulse duration (reuse-update 2026-07-14) — ported verbatim from * oxpulse-chat web's Bubble.svelte `.qa-heart.on.pulse { animation: * heart-pulse 240ms ... }` / MessageList.svelte's triggerHeartPulse timer. */ export declare const HEART_PULSE_MS = 240; /** * Write-401 fix (issue #78): how long an optimistic reaction stays visually * applied after an auth-expired write failure before rolling back. * * Rationale: a 401 here means the host's JWT expired. The widget signals * onAuthExpired (same token-expired flow the subscribe path uses) * immediately, but the actual refresh+retry happens by the HOST swapping * the jwt attribute — which re-bootstraps the element and tears this * MessageList instance down, repainting from server state. There is no * useful in-place retry to build here: either the remount wins the race * (the delayed rollback below never runs, the timer is cleared in * destroy()) or it doesn't, in which case the delayed rollback still fires * so the UI does not lie about pending state forever. A short bounded * delay + signal is the whole feature — deliberately not a retry queue. */ export declare const WRITE_AUTH_ROLLBACK_DELAY_MS = 3000; /** * MessageList renders a chat message history inside a given container element. * It fetches initial history via client.list() and subscribes to live updates. * * Slice 3: adds reaction cluster per bubble, ReactionQuickBar, live onReaction. */ export declare class MessageList { #private; constructor(opts: MessageListOptions); /** * Fetch initial history and subscribe to live updates. * Resolves after initial render is complete. */ mount(): Promise; /** * W2.2 slice 5: Returns the highest seq value seen from all received messages. * Used as a resume token when reconnecting (lastSeq param to subscribe()). */ getLastSeq(): number; /** * Route a new message row to the list's internal handler. * Used by the element's reconnect SubscribeFn to deliver messages when * the Reconnector re-establishes the SSE stream after an error. */ handleMessage(row: MessageRow): void; /** * Mark an existing message bubble as send-failed. Called by element.ts * when an oxpulse-chat:send-failed event fires for a message whose * optimistic echo is already in the list. Updates the stored row and * re-renders the bubble in place. */ markSendFailed(msgId: string, reason: string, retryable: boolean): void; /** * Remove a row from the list (DOM + internal state). Called by element.ts * when the user dismisses a send-failed message bubble. */ removeRow(msgId: string): void; /** * Route a live reaction event to the list's internal handler. * Used by the element's reconnect SubscribeFn to keep reactions live * across SSE reconnects. */ handleReaction(event: ReactionEvent): void; /** * #229: Route a live mutation event (edit/delete/pin/unpin) to the list's * internal handler. Used by the element's reconnect SubscribeFn to keep * mutations live across SSE reconnects — mirrors handleMessage/handleReaction. */ handleMutation(event: MutationEvent): void; /** * #232: Scroll to a specific message by msgId and briefly highlight it. * Used by the PinnedBanner's "jump to message" action. If the message is * outside the loaded window, this is a no-op (the banner already shows * "Message not loaded" for off-window pins). */ scrollToMsgId(msgId: string): void; /** Tear down: abort in-flight mount(), unsubscribe, clear DOM, close picker, * disconnect the resize observer (P2). */ destroy(): void; } //# sourceMappingURL=message-list.d.ts.map