@ttt-productions/chat-react 0.13.6 → 0.13.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.
- package/dist/context/ChatNameResolverContext.d.ts +24 -16
- package/dist/context/ChatNameResolverContext.d.ts.map +1 -1
- package/dist/context/ChatNameResolverContext.js +15 -19
- package/dist/context/ChatNameResolverContext.js.map +1 -1
- package/dist/hooks/useChatMessages.d.ts +9 -0
- package/dist/hooks/useChatMessages.d.ts.map +1 -1
- package/dist/hooks/useChatMessages.js +10 -10
- package/dist/hooks/useChatMessages.js.map +1 -1
- package/dist/index.d.ts +6 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/realtime/channel-client.d.ts +32 -0
- package/dist/realtime/channel-client.d.ts.map +1 -1
- package/dist/realtime/channel-client.js +114 -15
- package/dist/realtime/channel-client.js.map +1 -1
- package/dist/realtime/inbox-client.d.ts +50 -6
- package/dist/realtime/inbox-client.d.ts.map +1 -1
- package/dist/realtime/inbox-client.js +133 -18
- package/dist/realtime/inbox-client.js.map +1 -1
- package/dist/realtime/index.d.ts +3 -3
- package/dist/realtime/index.d.ts.map +1 -1
- package/dist/realtime/index.js +1 -1
- package/dist/realtime/index.js.map +1 -1
- package/dist/realtime/initial-load-budget.d.ts +31 -0
- package/dist/realtime/initial-load-budget.d.ts.map +1 -0
- package/dist/realtime/initial-load-budget.js +69 -0
- package/dist/realtime/initial-load-budget.js.map +1 -0
- package/dist/realtime/map.d.ts.map +1 -1
- package/dist/realtime/map.js +3 -2
- package/dist/realtime/map.js.map +1 -1
- package/dist/realtime/shared.d.ts +13 -0
- package/dist/realtime/shared.d.ts.map +1 -1
- package/dist/realtime/shared.js +18 -0
- package/dist/realtime/shared.js.map +1 -1
- package/dist/realtime/transport.d.ts +6 -3
- package/dist/realtime/transport.d.ts.map +1 -1
- package/dist/realtime/transport.js +1 -0
- package/dist/realtime/transport.js.map +1 -1
- package/dist/realtime/useRealtimeChatMessages.d.ts +4 -0
- package/dist/realtime/useRealtimeChatMessages.d.ts.map +1 -1
- package/dist/realtime/useRealtimeChatMessages.js +7 -8
- package/dist/realtime/useRealtimeChatMessages.js.map +1 -1
- package/dist/realtime/wire.d.ts +11 -6
- package/dist/realtime/wire.d.ts.map +1 -1
- package/dist/realtime/wire.js +2 -2
- package/dist/realtime/wire.js.map +1 -1
- package/dist/types.d.ts +16 -19
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/ui/ChatShell.d.ts +27 -1
- package/dist/ui/ChatShell.d.ts.map +1 -1
- package/dist/ui/ChatShell.js +27 -7
- package/dist/ui/ChatShell.js.map +1 -1
- package/dist/ui/Composer.d.ts.map +1 -1
- package/dist/ui/Composer.js +4 -2
- package/dist/ui/Composer.js.map +1 -1
- package/dist/ui/MessageItemDefault.d.ts +1 -1
- package/dist/ui/MessageItemDefault.d.ts.map +1 -1
- package/dist/ui/MessageItemDefault.js +10 -6
- package/dist/ui/MessageItemDefault.js.map +1 -1
- package/dist/ui/MessageList.d.ts +7 -1
- package/dist/ui/MessageList.d.ts.map +1 -1
- package/dist/ui/MessageList.js +6 -5
- package/dist/ui/MessageList.js.map +1 -1
- package/dist/ui/SenderName.d.ts +9 -0
- package/dist/ui/SenderName.d.ts.map +1 -0
- package/dist/ui/SenderName.js +16 -0
- package/dist/ui/SenderName.js.map +1 -0
- package/package.json +12 -12
- package/dist/hooks/useChatThreadAccess.d.ts +0 -8
- package/dist/hooks/useChatThreadAccess.d.ts.map +0 -1
- package/dist/hooks/useChatThreadAccess.js +0 -19
- package/dist/hooks/useChatThreadAccess.js.map +0 -1
package/dist/types.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,8EAA8E;AAC9E,6EAA6E;AAC7E,4DAA4D","sourcesContent":["// React- and Firebase-client-coupled chat types. These were previously on the\n// chat-core root; they live here so the pure chat-core package stays free of\n// react / firebase/firestore / firebase/storage references.\n\nimport type { ReactNode } from \"react\";\nimport type {
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,8EAA8E;AAC9E,6EAA6E;AAC7E,4DAA4D","sourcesContent":["// React- and Firebase-client-coupled chat types. These were previously on the\n// chat-core root; they live here so the pure chat-core package stays free of\n// react / firebase/firestore / firebase/storage references.\n\nimport type { ReactNode } from \"react\";\nimport type { ChatMessageV1 } from \"@ttt-productions/chat-core\";\nimport type { ChatConversationRef } from \"@ttt-productions/chat-schemas\";\n\n// ============================================\n// CONFIG\n// ============================================\n//\n// Chat carries PLAIN TEXT only. There is no upload adapter, attachment config,\n// or mention/token config here: a conversation's files are owned by the\n// consuming app's Conversation Files surface, which runs the canonical upload\n// pipeline outside the chat timeline.\n\n/**\n * Which transport backs a chat thread.\n * - `firestore` — the Firestore newest-window + cursor path (default).\n * - `realtime` — the Cloudflare Durable Object socket; the grant is the data-access\n * authority there.\n */\nexport type ChatTransportMode = 'firestore' | 'realtime';\n\n/**\n * Realtime (Durable Object) transport handle. The concrete socket client is built\n * in this package (`realtime/transport.ts`); chat-react consumes it through this\n * config so the UI stays transport-agnostic. The app builds the `client` via\n * `createRealtimeChatClient({ endpoint, channelRef, grantProvider, ... })` and\n * passes it here. `client` stays structurally typed (`unknown` at the config seam)\n * so `ChatCoreConfig` does not pull the realtime module's types into every firestore\n * consumer — the realtime hook narrows it at the call site.\n */\nexport interface ChatRealtimeTransportConfig {\n /** The conversation the socket is scoped to, as the app maps it onto the neutral reference. */\n channelRef: ChatConversationRef;\n /**\n * The realtime client handle from `createRealtimeChatClient(...)`. Drives the\n * channel socket (subscribe / send / ack / typing / presence / history). The UI\n * never reaches into the socket directly — it goes through `useRealtimeChatMessages`.\n */\n client: unknown;\n}\n\nexport type ChatCoreConfig = {\n chatCollectionPath: string | string[];\n messagesSubcollection?: string; // default: \"messages\"\n threadId: string;\n currentUserId: string;\n currentUserDisplayName?: string;\n isAdmin: boolean;\n /**\n * Which transport backs this thread. Defaults to `'firestore'` (the current\n * path) so existing call sites are unchanged. See {@link ChatTransportMode}.\n */\n transport?: ChatTransportMode;\n /** Required iff `transport === 'realtime'`; ignored on the firestore transport. */\n realtime?: ChatRealtimeTransportConfig;\n /**\n * Whether the current user may read this conversation — the app's ONE access fact,\n * decided by the app's own rule for the conversation's kind (the package never decides\n * access). False renders the no-access state and reads nothing. On the `realtime`\n * transport the grant is the data authority, and a terminal denial from the grant\n * closes the conversation even when this is true.\n */\n allowed: boolean;\n createdAtField?: string; // default: \"createdAt\"\n pageSize?: number;\n};\n\n// ============================================\n// RENDERING\n// ============================================\n\nexport type MessageRenderer = (m: ChatMessageV1) => ReactNode;\n\nexport type MessageRendererRegistry = Record<string, MessageRenderer>;\n"]}
|
package/dist/ui/ChatShell.d.ts
CHANGED
|
@@ -1,8 +1,34 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
2
|
import type { ModerationHandlers } from "@ttt-productions/chat-core";
|
|
3
3
|
import type { ChatCoreConfig, MessageRendererRegistry } from "../types.js";
|
|
4
|
+
/**
|
|
5
|
+
* A read the chat could not complete, handed to the app's `renderLoadError`:
|
|
6
|
+
* - `initial` — nothing loaded: the first open failed (the realtime client's attempts
|
|
7
|
+
* failed for its budget, or the Firestore listener failed before any message). It
|
|
8
|
+
* takes the place of the message list.
|
|
9
|
+
* - `live` — the Firestore listener failed after messages loaded. The messages stay,
|
|
10
|
+
* with this band above them.
|
|
11
|
+
* - `older` — an older page failed to load. It renders at the top of the list, which
|
|
12
|
+
* stops asking for older pages until `retry`.
|
|
13
|
+
* `error` is the read's own error (null on the realtime transport); `retry` re-runs what
|
|
14
|
+
* failed — the realtime transport also keeps retrying in the background. `retrying` is true
|
|
15
|
+
* while a retry of this failure is running and the failure is still shown (an older page being
|
|
16
|
+
* read again), so the app's Retry shows its pending state. A retried initial or live failure
|
|
17
|
+
* gives way to the opening state instead.
|
|
18
|
+
*/
|
|
19
|
+
export type ChatLoadFailure = {
|
|
20
|
+
scope: "initial" | "live" | "older";
|
|
21
|
+
error: Error | null;
|
|
22
|
+
retry: () => void;
|
|
23
|
+
retrying: boolean;
|
|
24
|
+
};
|
|
4
25
|
export type ChatShellProps = {
|
|
5
26
|
config: ChatCoreConfig;
|
|
27
|
+
/**
|
|
28
|
+
* Renders every failed read (see {@link ChatLoadFailure}) — the app's own error state and
|
|
29
|
+
* copy. Required: a chat that cannot load must say so, and the words are the app's.
|
|
30
|
+
*/
|
|
31
|
+
renderLoadError: (failure: ChatLoadFailure) => React.ReactNode;
|
|
6
32
|
header?: React.ReactNode;
|
|
7
33
|
onSend?: (text: string) => Promise<void>;
|
|
8
34
|
renderMessage?: (m: any) => React.ReactNode;
|
|
@@ -14,7 +40,7 @@ export type ChatShellProps = {
|
|
|
14
40
|
renderBelowMessages?: () => React.ReactNode;
|
|
15
41
|
/** If provided, replaces the Composer entirely */
|
|
16
42
|
renderFooter?: () => React.ReactNode;
|
|
17
|
-
onSenderClick?: (senderId: string
|
|
43
|
+
onSenderClick?: (senderId: string) => void;
|
|
18
44
|
composerDisabled?: boolean;
|
|
19
45
|
/** Fill the parent's height instead of the default fixed-height card. Lays the Card out
|
|
20
46
|
* as `flex flex-col h-full` with the message list flexing to fill (scrolls inside), so a
|
|
@@ -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,EAAiB,kBAAkB,EAAE,MAAM,4BAA4B,CAAC;AACpF,OAAO,KAAK,EACV,cAAc,EACd,uBAAuB,EACxB,MAAM,aAAa,CAAC;AAUrB,MAAM,MAAM,cAAc,GAAG;IAC3B,MAAM,EAAE,cAAc,CAAC;
|
|
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,EAAiB,kBAAkB,EAAE,MAAM,4BAA4B,CAAC;AACpF,OAAO,KAAK,EACV,cAAc,EACd,uBAAuB,EACxB,MAAM,aAAa,CAAC;AAUrB;;;;;;;;;;;;;;GAcG;AACH,MAAM,MAAM,eAAe,GAAG;IAC5B,KAAK,EAAE,SAAS,GAAG,MAAM,GAAG,OAAO,CAAC;IACpC,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;IACpB,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,QAAQ,EAAE,OAAO,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,cAAc,GAAG;IAC3B,MAAM,EAAE,cAAc,CAAC;IAEvB;;;OAGG;IACH,eAAe,EAAE,CAAC,OAAO,EAAE,eAAe,KAAK,KAAK,CAAC,SAAS,CAAC;IAG/D,MAAM,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IAKzB,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAGzC,aAAa,CAAC,EAAE,CAAC,CAAC,EAAE,GAAG,KAAK,KAAK,CAAC,SAAS,CAAC;IAC5C,gBAAgB,CAAC,EAAE,uBAAuB,CAAC;IAC3C,QAAQ,CAAC,EAAE,kBAAkB,CAAC;IAE9B,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,KAAK,IAAI,CAAC;IAG3C,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAE3B;;;6FAGyF;IACzF,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;qDACiD;IACjD,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B,CAAC;AA8BF;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,cAAc,qBAK9C"}
|
package/dist/ui/ChatShell.js
CHANGED
|
@@ -27,7 +27,20 @@ function FirestoreChatShell(props) {
|
|
|
27
27
|
if (onSend)
|
|
28
28
|
await onSend(text);
|
|
29
29
|
}, [onSend]);
|
|
30
|
-
|
|
30
|
+
const { error, olderError, retry, messages, isFetchingOlder } = r;
|
|
31
|
+
const loadFailure = React.useMemo(() => (error ? { scope: messages.length > 0 ? "live" : "initial", error, retry, retrying: false } : null), [error, messages.length, retry]);
|
|
32
|
+
const olderFailure = React.useMemo(() => (olderError ? { scope: "older", error: olderError, retry, retrying: isFetchingOlder } : null), [olderError, retry, isFetchingOlder]);
|
|
33
|
+
return (_jsx(ChatShellView, { ...props, resolved: {
|
|
34
|
+
allowed: r.allowed,
|
|
35
|
+
isInitialLoading: r.isInitialLoading,
|
|
36
|
+
messages: r.messages,
|
|
37
|
+
fetchOlder: r.fetchOlder,
|
|
38
|
+
hasOlder: r.hasOlder,
|
|
39
|
+
isFetchingOlder: r.isFetchingOlder,
|
|
40
|
+
loadFailure,
|
|
41
|
+
olderFailure,
|
|
42
|
+
send,
|
|
43
|
+
} }));
|
|
31
44
|
}
|
|
32
45
|
function RealtimeChatShell(props) {
|
|
33
46
|
const { config, onSend } = props;
|
|
@@ -36,6 +49,8 @@ function RealtimeChatShell(props) {
|
|
|
36
49
|
throw new Error("[ChatShell] transport 'realtime' requires config.realtime.client (createRealtimeChatClient(...))");
|
|
37
50
|
}
|
|
38
51
|
const r = useRealtimeChatMessages(client);
|
|
52
|
+
const { initialLoadFailed, retry } = r;
|
|
53
|
+
const loadFailure = React.useMemo(() => (initialLoadFailed ? { scope: "initial", error: null, retry, retrying: false } : null), [initialLoadFailed, retry]);
|
|
39
54
|
const send = React.useCallback(async (text) => {
|
|
40
55
|
const ok = r.send(text);
|
|
41
56
|
// C-B8: surface a closed-socket failure so the Composer keeps the user's text
|
|
@@ -46,12 +61,15 @@ function RealtimeChatShell(props) {
|
|
|
46
61
|
await onSend(text); // optional mirror
|
|
47
62
|
}, [r, onSend]);
|
|
48
63
|
return (_jsx(ChatShellView, { ...props, resolved: {
|
|
49
|
-
|
|
64
|
+
// The app's access fact AND the grant's: a terminal denial closes it either way.
|
|
65
|
+
allowed: config.allowed && r.allowed,
|
|
50
66
|
isInitialLoading: r.isInitialLoading,
|
|
51
67
|
messages: r.messages,
|
|
52
68
|
fetchOlder: r.fetchOlder,
|
|
53
69
|
hasOlder: r.hasOlder,
|
|
54
70
|
isFetchingOlder: r.isFetchingOlder,
|
|
71
|
+
loadFailure,
|
|
72
|
+
olderFailure: null,
|
|
55
73
|
send,
|
|
56
74
|
retrySend: r.retrySend,
|
|
57
75
|
readAck: r.readAck,
|
|
@@ -61,8 +79,10 @@ function RealtimeChatShell(props) {
|
|
|
61
79
|
} }));
|
|
62
80
|
}
|
|
63
81
|
function ChatShellView(props) {
|
|
64
|
-
const { config, header, renderMessage, messageRenderers, handlers, composerPlaceholder, autoFocus = false, renderAboveMessages, renderBelowMessages, renderFooter, onSenderClick, composerDisabled, fillHeight = false, scrollClassName, resolved, } = props;
|
|
65
|
-
const { allowed, isInitialLoading, messages, fetchOlder, hasOlder, isFetchingOlder, send, retrySend, readAck, status, typing, signalTyping } = resolved;
|
|
82
|
+
const { config, header, renderMessage, messageRenderers, handlers, composerPlaceholder, autoFocus = false, renderAboveMessages, renderBelowMessages, renderFooter, onSenderClick, composerDisabled, fillHeight = false, scrollClassName, renderLoadError, resolved, } = props;
|
|
83
|
+
const { allowed, isInitialLoading, messages, fetchOlder, hasOlder, isFetchingOlder, loadFailure, olderFailure, send, retrySend, readAck, status, typing, signalTyping } = resolved;
|
|
84
|
+
// Nothing loaded and the read failed: the failure takes the list's place.
|
|
85
|
+
const initialFailure = loadFailure?.scope === "initial" ? loadFailure : null;
|
|
66
86
|
const [showScrollToBottom, setShowScrollToBottom] = React.useState(false);
|
|
67
87
|
const [atBottom, setAtBottom] = React.useState(true);
|
|
68
88
|
const [focused, setFocused] = React.useState(true);
|
|
@@ -121,7 +141,7 @@ function ChatShellView(props) {
|
|
|
121
141
|
if (!allowed) {
|
|
122
142
|
return _jsx("div", { className: "p-4 text-sm opacity-70", children: "You don't have access to this thread." });
|
|
123
143
|
}
|
|
124
|
-
if (isInitialLoading) {
|
|
144
|
+
if (isInitialLoading || initialFailure) {
|
|
125
145
|
// Before the first authoritative snapshot/history result, show an HONEST working
|
|
126
146
|
// state — a visible "Opening chat…" spinner in a polite live region — NOT a blank
|
|
127
147
|
// parchment/skeleton box (which read as an empty chat) and NOT the reconnect banner
|
|
@@ -136,9 +156,9 @@ function ChatShellView(props) {
|
|
|
136
156
|
// conversation-files section) must not paint an empty bordered strip.
|
|
137
157
|
const aboveMessages = renderAboveMessages?.() ?? null;
|
|
138
158
|
return aboveMessages != null && aboveMessages !== false ? (_jsx("div", { className: "border-b", children: aboveMessages })) : null;
|
|
139
|
-
})(), _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(Spinner, { size: "sm" }), _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" }) }))] }));
|
|
159
|
+
})(), _jsx(CardContent, { className: contentClassName, children: initialFailure ? (renderLoadError(initialFailure)) : (_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(Spinner, { size: "sm" }), _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" }) }))] }));
|
|
140
160
|
}
|
|
141
|
-
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, disabled: composerDisabled, autoFocus: autoFocus, placeholder: composerPlaceholder }) })] }))] }));
|
|
161
|
+
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() })), loadFailure?.scope === "live" && _jsx("div", { className: "border-b", children: renderLoadError(loadFailure) }), _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, olderLoadErrorRow: olderFailure ? renderLoadError(olderFailure) : null }) }), 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, disabled: composerDisabled, autoFocus: autoFocus, placeholder: composerPlaceholder }) })] }))] }));
|
|
142
162
|
}
|
|
143
163
|
/**
|
|
144
164
|
* R13 — connection-state banner. The realtime path sets `status`; once the first
|
package/dist/ui/ChatShell.js.map
CHANGED
|
@@ -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, ModerationHandlers } from \"@ttt-productions/chat-core\";\nimport type {\n ChatCoreConfig,\n MessageRendererRegistry,\n} from \"../types.js\";\nimport { Card, CardHeader, CardContent, CardFooter, Skeleton, Spinner } 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) => Promise<void>;\n\n // Message rendering\n renderMessage?: (m: any) => React.ReactNode;\n messageRenderers?: MessageRendererRegistry;\n handlers?: ModerationHandlers;\n\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) => 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) => {\n if (onSend) await onSend(text);\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) => {\n const ok = r.send(text);\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); // 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 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'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 {(() => {\n // The wrapper (and its border) renders only when the slot actually\n // produced content — a slot returning null (e.g. a collapsed\n // conversation-files section) must not paint an empty bordered strip.\n const aboveMessages = renderAboveMessages?.() ?? null;\n return aboveMessages != null && aboveMessages !== false ? (\n <div className=\"border-b\">{aboveMessages}</div>\n ) : null;\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 <Spinner size=\"sm\" />\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 disabled={composerDisabled}\n autoFocus={autoFocus}\n placeholder={composerPlaceholder}\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, ModerationHandlers } from \"@ttt-productions/chat-core\";\nimport type {\n ChatCoreConfig,\n MessageRendererRegistry,\n} from \"../types.js\";\nimport { Card, CardHeader, CardContent, CardFooter, Skeleton, Spinner } 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\n/**\n * A read the chat could not complete, handed to the app's `renderLoadError`:\n * - `initial` — nothing loaded: the first open failed (the realtime client's attempts\n * failed for its budget, or the Firestore listener failed before any message). It\n * takes the place of the message list.\n * - `live` — the Firestore listener failed after messages loaded. The messages stay,\n * with this band above them.\n * - `older` — an older page failed to load. It renders at the top of the list, which\n * stops asking for older pages until `retry`.\n * `error` is the read's own error (null on the realtime transport); `retry` re-runs what\n * failed — the realtime transport also keeps retrying in the background. `retrying` is true\n * while a retry of this failure is running and the failure is still shown (an older page being\n * read again), so the app's Retry shows its pending state. A retried initial or live failure\n * gives way to the opening state instead.\n */\nexport type ChatLoadFailure = {\n scope: \"initial\" | \"live\" | \"older\";\n error: Error | null;\n retry: () => void;\n retrying: boolean;\n};\n\nexport type ChatShellProps = {\n config: ChatCoreConfig;\n\n /**\n * Renders every failed read (see {@link ChatLoadFailure}) — the app's own error state and\n * copy. Required: a chat that cannot load must say so, and the words are the app's.\n */\n renderLoadError: (failure: ChatLoadFailure) => React.ReactNode;\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) => Promise<void>;\n\n // Message rendering\n renderMessage?: (m: any) => React.ReactNode;\n messageRenderers?: MessageRendererRegistry;\n handlers?: ModerationHandlers;\n\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) => 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 failed read that replaces or annotates the list (initial / live), or null. */\n loadFailure: ChatLoadFailure | null;\n /** The failed older page, or null. */\n olderFailure: ChatLoadFailure | null;\n /** The send handler the Composer calls (socket send on realtime, prop on firestore). */\n send: (text: string) => 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) => {\n if (onSend) await onSend(text);\n },\n [onSend],\n );\n const { error, olderError, retry, messages, isFetchingOlder } = r;\n const loadFailure = React.useMemo<ChatLoadFailure | null>(\n () => (error ? { scope: messages.length > 0 ? \"live\" : \"initial\", error, retry, retrying: false } : null),\n [error, messages.length, retry],\n );\n const olderFailure = React.useMemo<ChatLoadFailure | null>(\n () => (olderError ? { scope: \"older\", error: olderError, retry, retrying: isFetchingOlder } : null),\n [olderError, retry, isFetchingOlder],\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 loadFailure,\n olderFailure,\n send,\n }}\n />\n );\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 { initialLoadFailed, retry } = r;\n const loadFailure = React.useMemo<ChatLoadFailure | null>(\n () => (initialLoadFailed ? { scope: \"initial\", error: null, retry, retrying: false } : null),\n [initialLoadFailed, retry],\n );\n const send = React.useCallback(\n async (text: string) => {\n const ok = r.send(text);\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); // optional mirror\n },\n [r, onSend],\n );\n return (\n <ChatShellView\n {...props}\n resolved={{\n // The app's access fact AND the grant's: a terminal denial closes it either way.\n allowed: config.allowed && r.allowed,\n isInitialLoading: r.isInitialLoading,\n messages: r.messages,\n fetchOlder: r.fetchOlder,\n hasOlder: r.hasOlder,\n isFetchingOlder: r.isFetchingOlder,\n loadFailure,\n olderFailure: null,\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 composerPlaceholder,\n autoFocus = false,\n renderAboveMessages,\n renderBelowMessages,\n renderFooter,\n onSenderClick,\n composerDisabled,\n fillHeight = false,\n scrollClassName,\n renderLoadError,\n resolved,\n } = props;\n\n const { allowed, isInitialLoading, messages, fetchOlder, hasOlder, isFetchingOlder, loadFailure, olderFailure, send, retrySend, readAck, status, typing, signalTyping } = resolved;\n // Nothing loaded and the read failed: the failure takes the list's place.\n const initialFailure = loadFailure?.scope === \"initial\" ? loadFailure : null;\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't have access to this thread.</div>;\n }\n\n if (isInitialLoading || initialFailure) {\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 {(() => {\n // The wrapper (and its border) renders only when the slot actually\n // produced content — a slot returning null (e.g. a collapsed\n // conversation-files section) must not paint an empty bordered strip.\n const aboveMessages = renderAboveMessages?.() ?? null;\n return aboveMessages != null && aboveMessages !== false ? (\n <div className=\"border-b\">{aboveMessages}</div>\n ) : null;\n })()}\n <CardContent className={contentClassName}>\n {initialFailure ? (\n renderLoadError(initialFailure)\n ) : (\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 <Spinner size=\"sm\" />\n <span>Opening chat…</span>\n </div>\n )}\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 {/* The listener failed after messages loaded: they stay, with the failure above them. */}\n {loadFailure?.scope === \"live\" && <div className=\"border-b\">{renderLoadError(loadFailure)}</div>}\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 olderLoadErrorRow={olderFailure ? renderLoadError(olderFailure) : null}\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 disabled={composerDisabled}\n autoFocus={autoFocus}\n placeholder={composerPlaceholder}\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":"Composer.d.ts","sourceRoot":"","sources":["../../src/ui/Composer.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"Composer.d.ts","sourceRoot":"","sources":["../../src/ui/Composer.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAU/B,MAAM,MAAM,aAAa,GAAG;IAC1B;;;;;;;OAOG;IACH,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAExC;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IAEtB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,CAAC;AAEF,wBAAgB,QAAQ,CAAC,KAAK,EAAE,aAAa,qBAuG5C"}
|
package/dist/ui/Composer.js
CHANGED
|
@@ -4,6 +4,7 @@ import * as React from "react";
|
|
|
4
4
|
import { Button, Textarea } from "@ttt-productions/ui-core/react";
|
|
5
5
|
import { cn } from "@ttt-productions/ui-core";
|
|
6
6
|
import { useOptionalLocalUploadGuard } from "@ttt-productions/upload-ui/react/guard";
|
|
7
|
+
import { CHAT_MESSAGE_TEXT_MAX_LENGTH } from "@ttt-productions/chat-schemas";
|
|
7
8
|
function genId() {
|
|
8
9
|
return `${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
|
|
9
10
|
}
|
|
@@ -20,6 +21,7 @@ export function Composer(props) {
|
|
|
20
21
|
// fire on hard navs and lets GuardedLink-style navigation confirm first.
|
|
21
22
|
// Optional accessor — consumers without the provider degrade to old behavior.
|
|
22
23
|
const navigationGuard = useOptionalLocalUploadGuard();
|
|
24
|
+
const counterId = React.useId();
|
|
23
25
|
// focus stability: never steal focus unless explicitly enabled
|
|
24
26
|
React.useEffect(() => {
|
|
25
27
|
if (!autoFocus)
|
|
@@ -52,7 +54,7 @@ export function Composer(props) {
|
|
|
52
54
|
}
|
|
53
55
|
};
|
|
54
56
|
const isDisabled = disabled || isSending;
|
|
55
|
-
return (_jsxs("div", { className: "chat-composer", children: [sendError && (_jsx("div", { className: "px-1 pb-1 text-sm text-destructive", role: "alert", children: sendError })), _jsxs("div", { className: "flex items-end gap-2", children: [_jsx(Textarea, { ref: ref, value: text, onChange: (e) => {
|
|
57
|
+
return (_jsxs("div", { className: "chat-composer", children: [sendError && (_jsx("div", { className: "px-1 pb-1 text-sm text-destructive", role: "alert", children: sendError })), _jsxs("div", { className: "flex items-end gap-2", children: [_jsx(Textarea, { ref: ref, value: text, maxLength: CHAT_MESSAGE_TEXT_MAX_LENGTH, "aria-describedby": text.length > 0 ? counterId : undefined, onChange: (e) => {
|
|
56
58
|
setText(e.target.value);
|
|
57
59
|
if (sendError)
|
|
58
60
|
setSendError(null);
|
|
@@ -64,6 +66,6 @@ export function Composer(props) {
|
|
|
64
66
|
e.preventDefault();
|
|
65
67
|
send();
|
|
66
68
|
}
|
|
67
|
-
} }), _jsx(Button, { type: "button", variant: "default", disabled: isDisabled || !text.trim(), pending: isSending, onClick: send, children: "Send" })] })] }));
|
|
69
|
+
} }), _jsx(Button, { type: "button", variant: "default", disabled: isDisabled || !text.trim(), pending: isSending, onClick: send, children: "Send" })] }), text.length > 0 && (_jsxs("div", { id: counterId, className: "px-1 pt-1 text-xs text-right text-muted-foreground", children: [text.length, "/", CHAT_MESSAGE_TEXT_MAX_LENGTH] }))] }));
|
|
68
70
|
}
|
|
69
71
|
//# sourceMappingURL=Composer.js.map
|
package/dist/ui/Composer.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Composer.js","sourceRoot":"","sources":["../../src/ui/Composer.tsx"],"names":[],"mappings":"AAAA,YAAY,CAAC;;AAEb,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAC/B,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,gCAAgC,CAAC;AAClE,OAAO,EAAE,EAAE,EAAE,MAAM,0BAA0B,CAAC;AAC9C,OAAO,EAAE,2BAA2B,EAAE,MAAM,wCAAwC,CAAC;
|
|
1
|
+
{"version":3,"file":"Composer.js","sourceRoot":"","sources":["../../src/ui/Composer.tsx"],"names":[],"mappings":"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","sourcesContent":["\"use client\";\n\nimport * as React from \"react\";\nimport { Button, Textarea } from \"@ttt-productions/ui-core/react\";\nimport { cn } from \"@ttt-productions/ui-core\";\nimport { useOptionalLocalUploadGuard } from \"@ttt-productions/upload-ui/react/guard\";\nimport { CHAT_MESSAGE_TEXT_MAX_LENGTH } from \"@ttt-productions/chat-schemas\";\n\nfunction genId(): string {\n return `${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;\n}\n\nexport type ComposerProps = {\n /**\n * Text send — the composer's ONE argument. The composer is PLAIN-TEXT-ONLY:\n * files are shared through the conversation's Conversation Files surface,\n * never through the message timeline, so there is no attachment path here;\n * message text carries no inline token grammar, so what the user typed is what\n * is sent; and there is no reply-target picker, so a send never names another\n * message (DJ ruling 2026-07-29).\n */\n onSend: (text: string) => Promise<void>;\n\n /**\n * Signal the local user is typing (R14). Called as the user edits the textarea; the\n * realtime transport coalesces to ≤1 frame per ~2s, so calling per keystroke is safe.\n * Optional — absent on the firestore transport (no typing broadcast).\n */\n onTyping?: () => void;\n\n disabled?: boolean;\n autoFocus?: boolean;\n placeholder?: string;\n};\n\nexport function Composer(props: ComposerProps) {\n const {\n onSend,\n onTyping,\n disabled,\n autoFocus = false,\n placeholder = \"Type a message...\",\n } = props;\n\n const [text, setText] = React.useState(\"\");\n const [isSending, setIsSending] = React.useState(false);\n const [sendError, setSendError] = React.useState<string | null>(null);\n const ref = React.useRef<HTMLTextAreaElement>(null);\n // Navigation guard for the in-flight send: a send that has left the composer\n // but not yet committed is killable by navigation/sign-out with zero warning\n // (observed live: the callable aborted, the message silently never delivered).\n // Registering with the app's one guarded-navigation system makes beforeunload\n // fire on hard navs and lets GuardedLink-style navigation confirm first.\n // Optional accessor — consumers without the provider degrade to old behavior.\n const navigationGuard = useOptionalLocalUploadGuard();\n const counterId = React.useId();\n\n // focus stability: never steal focus unless explicitly enabled\n React.useEffect(() => {\n if (!autoFocus) return;\n ref.current?.focus();\n }, [autoFocus]);\n\n const send = async () => {\n const v = text.trim();\n if (!v) return;\n\n setIsSending(true);\n setSendError(null);\n const sendGuardId = `chat-send-${genId()}`;\n navigationGuard?.registerUpload(sendGuardId);\n\n try {\n await onSend(text);\n setText(\"\");\n } catch (err) {\n // C-B8: a failed send (e.g. the realtime socket was closed) must NOT clear the\n // user's text — `setText(\"\")` only runs on the success path above — and must NOT\n // re-throw into the click handler (an unhandled rejection). Surface it so the\n // user can retry with their text intact.\n console.error(\"[Composer] Send failed:\", err);\n setSendError(\"Couldn't send. Check your connection and try again.\");\n } finally {\n navigationGuard?.unregisterUpload(sendGuardId);\n setIsSending(false);\n }\n };\n\n const isDisabled = disabled || isSending;\n\n return (\n <div className=\"chat-composer\">\n {sendError && (\n <div className=\"px-1 pb-1 text-sm text-destructive\" role=\"alert\">\n {sendError}\n </div>\n )}\n\n <div className=\"flex items-end gap-2\">\n <Textarea\n ref={ref}\n value={text}\n maxLength={CHAT_MESSAGE_TEXT_MAX_LENGTH}\n aria-describedby={text.length > 0 ? counterId : undefined}\n onChange={(e) => {\n setText(e.target.value);\n if (sendError) setSendError(null);\n // R14: broadcast a typing signal (transport-coalesced) while there is content.\n if (onTyping && e.target.value.trim().length > 0) onTyping();\n }}\n placeholder={placeholder}\n disabled={isDisabled}\n rows={1}\n className={cn(\"min-h-[40px] resize-none\")}\n onKeyDown={(e) => {\n if (e.key === \"Enter\" && !e.shiftKey) {\n e.preventDefault();\n send();\n }\n }}\n />\n <Button\n type=\"button\"\n variant=\"default\"\n disabled={isDisabled || !text.trim()}\n pending={isSending}\n onClick={send}\n >\n Send\n </Button>\n </div>\n {text.length > 0 && (\n <div id={counterId} className=\"px-1 pt-1 text-xs text-right text-muted-foreground\">\n {text.length}/{CHAT_MESSAGE_TEXT_MAX_LENGTH}\n </div>\n )}\n </div>\n );\n}\n"]}
|
|
@@ -5,7 +5,7 @@ export type MessageItemDefaultProps = {
|
|
|
5
5
|
isAdmin: boolean;
|
|
6
6
|
handlers?: ModerationHandlers;
|
|
7
7
|
isContinuation?: boolean;
|
|
8
|
-
onSenderClick?: (senderId: string
|
|
8
|
+
onSenderClick?: (senderId: string) => void;
|
|
9
9
|
/** Retry a failed realtime send by its original clientMessageId (wired by
|
|
10
10
|
* ChatShell on the realtime transport; absent on firestore). Enables the
|
|
11
11
|
* retry affordance on a `meta.sendFailed` bubble. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"MessageItemDefault.d.ts","sourceRoot":"","sources":["../../src/ui/MessageItemDefault.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,aAAa,EAAE,kBAAkB,EAAE,MAAM,4BAA4B,CAAC;
|
|
1
|
+
{"version":3,"file":"MessageItemDefault.d.ts","sourceRoot":"","sources":["../../src/ui/MessageItemDefault.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,aAAa,EAAE,kBAAkB,EAAE,MAAM,4BAA4B,CAAC;AAoEpF,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,KAAK,IAAI,CAAC;IAE3C;;0DAEsD;IACtD,WAAW,CAAC,EAAE,CAAC,eAAe,EAAE,MAAM,KAAK,IAAI,CAAC;CACjD,CAAC;AAEF,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,uBAAuB,+BAsGhE"}
|
|
@@ -5,7 +5,8 @@ import { cn } from "@ttt-productions/ui-core";
|
|
|
5
5
|
import { Button, Spinner } from "@ttt-productions/ui-core/react";
|
|
6
6
|
import { AlertTriangle } from "lucide-react";
|
|
7
7
|
import { MessageActions } from "./menus.js";
|
|
8
|
-
import {
|
|
8
|
+
import { SenderName } from "./SenderName.js";
|
|
9
|
+
import { useSenderNameResolution } from "../context/ChatNameResolverContext.js";
|
|
9
10
|
// A chat message renders TEXT (plus its send-state rows). Files live in the
|
|
10
11
|
// conversation's Conversation Files surface, never in the timeline — there is no
|
|
11
12
|
// attachment body here. There is no reply-quote body either: no chat surface has
|
|
@@ -48,8 +49,11 @@ function SystemMessage({ m }) {
|
|
|
48
49
|
return (_jsxs("div", { className: "chat-system-message", children: [m.text && _jsx("span", { children: m.text }), _jsx("span", { className: "chat-system-message-time", children: new Date(m.createdAt).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }) })] }));
|
|
49
50
|
}
|
|
50
51
|
export function MessageItemDefault(props) {
|
|
51
|
-
const { m, currentUserId, isAdmin, handlers, isContinuation, onSenderClick, onRetrySend } = props;
|
|
52
|
-
|
|
52
|
+
const { m, currentUserId, isAdmin, handlers, isContinuation, onSenderClick: senderClick, onRetrySend } = props;
|
|
53
|
+
// Only a resolved name is a click target: the app's unresolved state (a Retry included) is never
|
|
54
|
+
// nested inside a button.
|
|
55
|
+
const nameResolved = useSenderNameResolution(m.senderId).status === "resolved";
|
|
56
|
+
const onSenderClick = nameResolved ? senderClick : undefined;
|
|
53
57
|
// System messages render differently
|
|
54
58
|
if (m.isSystemMessage) {
|
|
55
59
|
return _jsx(SystemMessage, { m: m });
|
|
@@ -66,9 +70,9 @@ export function MessageItemDefault(props) {
|
|
|
66
70
|
// both the failure copy and whether a Retry affordance is offered.
|
|
67
71
|
const sendFailureCode = typeof m.meta?.sendFailureCode === "string" ? m.meta.sendFailureCode : null;
|
|
68
72
|
const showRetry = sendFailed && isRetryableFailure(m.meta);
|
|
69
|
-
return (_jsxs("div", { className: cn("flex flex-col w-fit max-w-[85%]", mine ? "ml-auto items-end" : "mr-auto items-start", isContinuation ? "chat-continuation-gap" : "chat-group-gap"), children: [_jsxs("div", { className: cn("chat-bubble", mine ? "chat-bubble--mine" : "chat-bubble--theirs", sendPending && "chat-bubble--pending", sendFailed && "chat-bubble--failed"), children: [!isContinuation && (_jsxs("div", { className: "flex items-center gap-2 text-xs opacity-80 mb-1", children: [_jsx("span", { className: cn("font-medium", onSenderClick && "cursor-pointer hover:underline"), onClick: onSenderClick ? () => onSenderClick(m.senderId
|
|
73
|
+
return (_jsxs("div", { className: cn("flex flex-col w-fit max-w-[85%]", mine ? "ml-auto items-end" : "mr-auto items-start", isContinuation ? "chat-continuation-gap" : "chat-group-gap"), children: [_jsxs("div", { className: cn("chat-bubble", mine ? "chat-bubble--mine" : "chat-bubble--theirs", sendPending && "chat-bubble--pending", sendFailed && "chat-bubble--failed"), children: [!isContinuation && (_jsxs("div", { className: "flex items-center gap-2 text-xs opacity-80 mb-1", children: [_jsx("span", { className: cn("font-medium", onSenderClick && "cursor-pointer hover:underline"), onClick: onSenderClick ? () => onSenderClick(m.senderId) : undefined, role: onSenderClick ? "button" : undefined, tabIndex: onSenderClick ? 0 : undefined, onKeyDown: onSenderClick ? (e) => {
|
|
70
74
|
if (e.key === "Enter" || e.key === " ")
|
|
71
|
-
onSenderClick(m.senderId
|
|
72
|
-
} : undefined, children:
|
|
75
|
+
onSenderClick(m.senderId);
|
|
76
|
+
} : undefined, children: _jsx(SenderName, { senderId: m.senderId }) }), _jsx("span", { children: "\u00B7" }), _jsx("span", { children: new Date(m.createdAt).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }) })] })), m.text && (_jsx("p", { className: "text-sm whitespace-pre-wrap", children: _jsx(MessageText, { text: m.text }) }))] }), sendPending && (_jsxs("div", { className: "chat-send-status mt-0.5", role: "status", "aria-live": "polite", children: [_jsx(Spinner, { size: "xs", className: "shrink-0" }), _jsx("span", { children: "Sending\u2026" })] })), sendFailed && (_jsxs("div", { className: "chat-send-status chat-send-status--failed mt-0.5", role: "alert", children: [_jsx(AlertTriangle, { className: "h-3 w-3 shrink-0" }), _jsx("span", { children: sendFailureCopy(sendFailureCode) }), showRetry && onRetrySend && clientMessageId && (_jsx(Button, { type: "button", variant: "ghost", size: "sm", className: "h-5 px-1.5 text-[0.6875rem]", onClick: () => onRetrySend(clientMessageId), children: "Retry" }))] })), !isContinuation && (_jsx("div", { className: "mt-1", children: _jsx(MessageActions, { messageId: m.messageId, isAdmin: isAdmin, handlers: handlers }) }))] }));
|
|
73
77
|
}
|
|
74
78
|
//# sourceMappingURL=MessageItemDefault.js.map
|
|
@@ -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, ModerationHandlers } from \"@ttt-productions/chat-core\";\nimport { MessageText } from \"./MessageText.js\";\nimport { cn } from \"@ttt-productions/ui-core\";\nimport { Button, Spinner } from \"@ttt-productions/ui-core/react\";\nimport { AlertTriangle } from \"lucide-react\";\nimport { MessageActions } from \"./menus.js\";\nimport { useResolvedSenderName } from \"../context/ChatNameResolverContext.js\";\n\n// A chat message renders TEXT (plus its send-state rows). Files live in the\n// conversation's Conversation Files surface, never in the timeline — there is no\n// attachment body here. There is no reply-quote body either: no chat surface has\n// an authoring affordance for replying to a specific message, so a quote\n// renderer would be unreachable machinery (DJ ruling 2026-07-29).\n\n// ============================================\n// Correlated send-rejection copy + retry policy\n// ============================================\n\n// User-facing copy per correlated `send-rejected` code (stamped on meta.sendFailureCode\n// by the realtime channel client). A failed send with no code (a transport/exhausted\n// reconnect failure) falls back to the neutral \"Couldn't send\".\nconst SEND_FAILURE_COPY: Record<string, string> = {\n \"blocked-word\": \"Message contains blocked language\",\n \"archived\": \"This channel is read-only\",\n \"deleted\": \"This channel is read-only\",\n \"membership-pending\": \"Chat is still preparing\",\n \"wordlist-unavailable\": \"Chat safety check is temporarily unavailable\",\n \"flood\": \"Please wait before sending again\",\n \"slow-mode\": \"Please wait before sending again\",\n};\n\nfunction sendFailureCopy(code: string | null): string {\n return (code && SEND_FAILURE_COPY[code]) || \"Couldn't send\";\n}\n\n// Retry policy comes from meta.sendRetryable, stamped by the channel client from\n// the wire contract's canonical retryability table (chat-schemas enforces the\n// frame's `retryable` matches the table, so this is the ONE source of truth —\n// never a second hardcoded code list here, which would have to move in lockstep\n// with the contract). `false` = terminal (an unchanged resend is guaranteed to\n// fail — no Retry action); `true` or ABSENT (a code-less transport/exhausted\n// reconnect failure) keeps Retry, reusing the ORIGINAL clientMessageId.\nfunction isRetryableFailure(meta: Record<string, unknown> | undefined): boolean {\n return meta?.sendRetryable !== false;\n}\n\n// ============================================\n// System message\n// ============================================\n\n// System lines are the conversation's action record (agreed / retracted / offer updated /\n// finalized…), so each carries its time — same hour:minute format as the message bubbles.\nfunction SystemMessage({ m }: { m: ChatMessageV1 }) {\n return (\n <div className=\"chat-system-message\">\n {m.text && <span>{m.text}</span>}\n <span className=\"chat-system-message-time\">\n {new Date(m.createdAt).toLocaleTimeString([], { hour: \"numeric\", minute: \"2-digit\" })}\n </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\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 // Correlated rejection reason (from the `send-rejected` frame), if any — drives\n // both the failure copy and whether a Retry affordance is offered.\n const sendFailureCode =\n typeof m.meta?.sendFailureCode === \"string\" ? m.meta.sendFailureCode : null;\n const showRetry = sendFailed && isRetryableFailure(m.meta);\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.text && (\n <p className=\"text-sm whitespace-pre-wrap\">\n <MessageText text={m.text} />\n </p>\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 <Spinner size=\"xs\" className=\"shrink-0\" />\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>{sendFailureCopy(sendFailureCode)}</span>\n {showRetry && 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, ModerationHandlers } from \"@ttt-productions/chat-core\";\nimport { MessageText } from \"./MessageText.js\";\nimport { cn } from \"@ttt-productions/ui-core\";\nimport { Button, Spinner } from \"@ttt-productions/ui-core/react\";\nimport { AlertTriangle } from \"lucide-react\";\nimport { MessageActions } from \"./menus.js\";\nimport { SenderName } from \"./SenderName.js\";\nimport { useSenderNameResolution } from \"../context/ChatNameResolverContext.js\";\n\n// A chat message renders TEXT (plus its send-state rows). Files live in the\n// conversation's Conversation Files surface, never in the timeline — there is no\n// attachment body here. There is no reply-quote body either: no chat surface has\n// an authoring affordance for replying to a specific message, so a quote\n// renderer would be unreachable machinery (DJ ruling 2026-07-29).\n\n// ============================================\n// Correlated send-rejection copy + retry policy\n// ============================================\n\n// User-facing copy per correlated `send-rejected` code (stamped on meta.sendFailureCode\n// by the realtime channel client). A failed send with no code (a transport/exhausted\n// reconnect failure) falls back to the neutral \"Couldn't send\".\nconst SEND_FAILURE_COPY: Record<string, string> = {\n \"blocked-word\": \"Message contains blocked language\",\n \"archived\": \"This channel is read-only\",\n \"deleted\": \"This channel is read-only\",\n \"membership-pending\": \"Chat is still preparing\",\n \"wordlist-unavailable\": \"Chat safety check is temporarily unavailable\",\n \"flood\": \"Please wait before sending again\",\n \"slow-mode\": \"Please wait before sending again\",\n};\n\nfunction sendFailureCopy(code: string | null): string {\n return (code && SEND_FAILURE_COPY[code]) || \"Couldn't send\";\n}\n\n// Retry policy comes from meta.sendRetryable, stamped by the channel client from\n// the wire contract's canonical retryability table (chat-schemas enforces the\n// frame's `retryable` matches the table, so this is the ONE source of truth —\n// never a second hardcoded code list here, which would have to move in lockstep\n// with the contract). `false` = terminal (an unchanged resend is guaranteed to\n// fail — no Retry action); `true` or ABSENT (a code-less transport/exhausted\n// reconnect failure) keeps Retry, reusing the ORIGINAL clientMessageId.\nfunction isRetryableFailure(meta: Record<string, unknown> | undefined): boolean {\n return meta?.sendRetryable !== false;\n}\n\n// ============================================\n// System message\n// ============================================\n\n// System lines are the conversation's action record (agreed / retracted / offer updated /\n// finalized…), so each carries its time — same hour:minute format as the message bubbles.\nfunction SystemMessage({ m }: { m: ChatMessageV1 }) {\n return (\n <div className=\"chat-system-message\">\n {m.text && <span>{m.text}</span>}\n <span className=\"chat-system-message-time\">\n {new Date(m.createdAt).toLocaleTimeString([], { hour: \"numeric\", minute: \"2-digit\" })}\n </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 — the app resolves anything it needs about the sender from the id.\n onSenderClick?: (senderId: 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\nexport function MessageItemDefault(props: MessageItemDefaultProps) {\n const { m, currentUserId, isAdmin, handlers, isContinuation, onSenderClick: senderClick, onRetrySend } = props;\n // Only a resolved name is a click target: the app's unresolved state (a Retry included) is never\n // nested inside a button.\n const nameResolved = useSenderNameResolution(m.senderId).status === \"resolved\";\n const onSenderClick = nameResolved ? senderClick : undefined;\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 // Correlated rejection reason (from the `send-rejected` frame), if any — drives\n // both the failure copy and whether a Retry affordance is offered.\n const sendFailureCode =\n typeof m.meta?.sendFailureCode === \"string\" ? m.meta.sendFailureCode : null;\n const showRetry = sendFailed && isRetryableFailure(m.meta);\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) : 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);\n } : undefined}\n >\n <SenderName senderId={m.senderId} />\n </span>\n <span>·</span>\n <span>{new Date(m.createdAt).toLocaleTimeString([], { hour: \"numeric\", minute: \"2-digit\" })}</span>\n </div>\n )}\n\n {m.text && (\n <p className=\"text-sm whitespace-pre-wrap\">\n <MessageText text={m.text} />\n </p>\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 <Spinner size=\"xs\" className=\"shrink-0\" />\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>{sendFailureCopy(sendFailureCode)}</span>\n {showRetry && 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"]}
|
package/dist/ui/MessageList.d.ts
CHANGED
|
@@ -25,8 +25,14 @@ export declare function MessageList(props: {
|
|
|
25
25
|
* `fillHeight` wires Card → CardContent → MessageList for this). */
|
|
26
26
|
fillHeight?: boolean;
|
|
27
27
|
handlers?: ModerationHandlers;
|
|
28
|
-
onSenderClick?: (senderId: string
|
|
28
|
+
onSenderClick?: (senderId: string) => void;
|
|
29
29
|
/** Retry a failed realtime send by clientMessageId (realtime transport only). */
|
|
30
30
|
onRetrySend?: (clientMessageId: string) => void;
|
|
31
|
+
/**
|
|
32
|
+
* The failed older-page row (the consumer's error state with its retry). While set, it
|
|
33
|
+
* renders at the top of the list and the list stops requesting older pages — a failed
|
|
34
|
+
* page is retried only through the row, never by scrolling into it again.
|
|
35
|
+
*/
|
|
36
|
+
olderLoadErrorRow?: React.ReactNode;
|
|
31
37
|
}): React.JSX.Element;
|
|
32
38
|
//# sourceMappingURL=MessageList.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"MessageList.d.ts","sourceRoot":"","sources":["../../src/ui/MessageList.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAC/B,OAAO,KAAK,EAAE,aAAa,EAAE,kBAAkB,EAAE,MAAM,4BAA4B,CAAC;AAEpF,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,aAAa,CAAC;AAI3D,wBAAgB,WAAW,CAAC,KAAK,EAAE;IACjC,QAAQ,EAAE,aAAa,EAAE,CAAC;IAC1B,aAAa,EAAE,MAAM,CAAC;IACtB,OAAO,EAAE,OAAO,CAAC;IAEjB,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,IAAI,CAAC;IAEzB,aAAa,CAAC,EAAE,CAAC,CAAC,EAAE,aAAa,KAAK,KAAK,CAAC,SAAS,CAAC;IACtD,gBAAgB,CAAC,EAAE,uBAAuB,CAAC;IAE3C,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B,gBAAgB,CAAC,EAAE,MAAM,IAAI,CAAC;IAC9B;4FACwF;IACxF,gBAAgB,CAAC,EAAE,CAAC,QAAQ,EAAE,OAAO,KAAK,IAAI,CAAC;IAC/C;;4BAEwB;IACxB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;yEAGqE;IACrE,UAAU,CAAC,EAAE,OAAO,CAAC;IAErB,QAAQ,CAAC,EAAE,kBAAkB,CAAC;IAC9B,aAAa,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,
|
|
1
|
+
{"version":3,"file":"MessageList.d.ts","sourceRoot":"","sources":["../../src/ui/MessageList.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAC/B,OAAO,KAAK,EAAE,aAAa,EAAE,kBAAkB,EAAE,MAAM,4BAA4B,CAAC;AAEpF,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,aAAa,CAAC;AAI3D,wBAAgB,WAAW,CAAC,KAAK,EAAE;IACjC,QAAQ,EAAE,aAAa,EAAE,CAAC;IAC1B,aAAa,EAAE,MAAM,CAAC;IACtB,OAAO,EAAE,OAAO,CAAC;IAEjB,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,IAAI,CAAC;IAEzB,aAAa,CAAC,EAAE,CAAC,CAAC,EAAE,aAAa,KAAK,KAAK,CAAC,SAAS,CAAC;IACtD,gBAAgB,CAAC,EAAE,uBAAuB,CAAC;IAE3C,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B,gBAAgB,CAAC,EAAE,MAAM,IAAI,CAAC;IAC9B;4FACwF;IACxF,gBAAgB,CAAC,EAAE,CAAC,QAAQ,EAAE,OAAO,KAAK,IAAI,CAAC;IAC/C;;4BAEwB;IACxB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;yEAGqE;IACrE,UAAU,CAAC,EAAE,OAAO,CAAC;IAErB,QAAQ,CAAC,EAAE,kBAAkB,CAAC;IAC9B,aAAa,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IAC3C,iFAAiF;IACjF,WAAW,CAAC,EAAE,CAAC,eAAe,EAAE,MAAM,KAAK,IAAI,CAAC;IAChD;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;CACrC,qBAyMA"}
|
package/dist/ui/MessageList.js
CHANGED
|
@@ -5,7 +5,8 @@ import { isContinuation } from "@ttt-productions/chat-core";
|
|
|
5
5
|
import { Button, Spinner } from "@ttt-productions/ui-core/react";
|
|
6
6
|
import { MessageItemDefault } from "./MessageItemDefault.js";
|
|
7
7
|
export function MessageList(props) {
|
|
8
|
-
const { messages, currentUserId, isAdmin, isFetchingOlder, hasOlder, onLoadOlder, renderMessage, messageRenderers, showScrollToBottom, onScrollToBottom, onAtBottomChange, scrollClassName, fillHeight = false, handlers, onSenderClick, onRetrySend, } = props;
|
|
8
|
+
const { messages, currentUserId, isAdmin, isFetchingOlder, hasOlder, onLoadOlder, renderMessage, messageRenderers, showScrollToBottom, onScrollToBottom, onAtBottomChange, scrollClassName, fillHeight = false, handlers, onSenderClick, onRetrySend, olderLoadErrorRow, } = props;
|
|
9
|
+
const olderLoadFailed = olderLoadErrorRow != null;
|
|
9
10
|
const scrollClass = scrollClassName ?? (fillHeight ? "flex-1 min-h-0" : "h-[400px]");
|
|
10
11
|
const outerClass = fillHeight ? "relative flex flex-col flex-1 min-h-0" : "relative";
|
|
11
12
|
const scrollRef = React.useRef(null);
|
|
@@ -37,14 +38,14 @@ export function MessageList(props) {
|
|
|
37
38
|
const e = entries[0];
|
|
38
39
|
if (!e?.isIntersecting)
|
|
39
40
|
return;
|
|
40
|
-
if (!hasOlder || isFetchingOlder)
|
|
41
|
+
if (!hasOlder || isFetchingOlder || olderLoadFailed)
|
|
41
42
|
return;
|
|
42
43
|
prevScrollHeightRef.current = root.scrollHeight;
|
|
43
44
|
onLoadOlder?.();
|
|
44
45
|
}, { root, threshold: 0 });
|
|
45
46
|
io.observe(target);
|
|
46
47
|
return () => io.disconnect();
|
|
47
|
-
}, [hasOlder, isFetchingOlder, onLoadOlder]);
|
|
48
|
+
}, [hasOlder, isFetchingOlder, olderLoadFailed, onLoadOlder]);
|
|
48
49
|
// Scroll management — FIXES accordion scroll jump
|
|
49
50
|
React.useLayoutEffect(() => {
|
|
50
51
|
const el = scrollRef.current;
|
|
@@ -98,7 +99,7 @@ export function MessageList(props) {
|
|
|
98
99
|
setUnseenCount(0);
|
|
99
100
|
};
|
|
100
101
|
let lastDay = null;
|
|
101
|
-
return (_jsxs("div", { className: outerClass, children: [_jsxs("div", { ref: scrollRef, className: `${scrollClass} overflow-y-auto p-4`, onScroll: onScroll, children: [isFetchingOlder
|
|
102
|
+
return (_jsxs("div", { className: outerClass, children: [_jsxs("div", { ref: scrollRef, className: `${scrollClass} overflow-y-auto p-4`, onScroll: onScroll, children: [olderLoadFailed ? (_jsx("div", { className: "mb-2", children: olderLoadErrorRow })) : isFetchingOlder ? (_jsx("div", { className: "flex justify-center mb-2", children: _jsx(Spinner, { size: "xs", label: "Loading older messages" }) })) : null, _jsx("div", { ref: topSentinelRef }), messages.length === 0 ? (_jsx("div", { className: "h-full flex items-center justify-center text-sm opacity-70", children: "No messages yet" })) : (_jsx("div", { className: "flex flex-col", children: messages.map((m, idx) => {
|
|
102
103
|
const day = new Date(m.createdAt).toDateString();
|
|
103
104
|
const showDay = day !== lastDay;
|
|
104
105
|
lastDay = day;
|
|
@@ -109,7 +110,7 @@ export function MessageList(props) {
|
|
|
109
110
|
const body = renderMessage?.(m) ??
|
|
110
111
|
byType ?? (_jsx(MessageItemDefault, { m: m, currentUserId: currentUserId, isAdmin: isAdmin, handlers: handlers, isContinuation: continuation, onSenderClick: onSenderClick, onRetrySend: onRetrySend }));
|
|
111
112
|
return (_jsxs(React.Fragment, { children: [showDay && (_jsx("div", { className: "my-2 flex justify-center", children: _jsx("div", { className: "chat-date-separator", children: new Date(m.createdAt).toLocaleDateString() }) })), body] }, m.messageId));
|
|
112
|
-
}) }))] }), (unseenCount > 0 || showScrollToBottom) && (_jsx(Button, { type: "button", variant: "outline", size: "sm", "aria-label": unseenCount > 0 ? `${unseenCount} new messages, scroll to latest` : "Scroll to latest", className: "absolute bottom-4 left-1/2 -translate-x-1/2 rounded-full
|
|
113
|
+
}) }))] }), (unseenCount > 0 || showScrollToBottom) && (_jsx(Button, { type: "button", variant: "outline", size: "sm", "aria-label": unseenCount > 0 ? `${unseenCount} new messages, scroll to latest` : "Scroll to latest", className: "absolute bottom-4 left-1/2 -translate-x-1/2 rounded-full elevation-raised gap-1", onClick: () => {
|
|
113
114
|
scrollToBottom();
|
|
114
115
|
onScrollToBottom?.();
|
|
115
116
|
}, children: unseenCount > 0
|