@ttt-productions/chat-react 0.13.2 → 0.13.4
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/README.md +13 -11
- package/dist/context/ChatNameResolverContext.d.ts +3 -3
- package/dist/context/ChatNameResolverContext.js +3 -3
- package/dist/context/ChatNameResolverContext.js.map +1 -1
- package/dist/hooks/useChatMessages.d.ts.map +1 -1
- package/dist/hooks/useChatMessages.js +4 -13
- package/dist/hooks/useChatMessages.js.map +1 -1
- package/dist/index.d.ts +2 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -3
- package/dist/index.js.map +1 -1
- package/dist/realtime/channel-client.d.ts +21 -4
- package/dist/realtime/channel-client.d.ts.map +1 -1
- package/dist/realtime/channel-client.js +109 -12
- package/dist/realtime/channel-client.js.map +1 -1
- package/dist/realtime/diagnostics.d.ts +7 -0
- package/dist/realtime/diagnostics.d.ts.map +1 -1
- package/dist/realtime/diagnostics.js +7 -0
- package/dist/realtime/diagnostics.js.map +1 -1
- package/dist/realtime/inbox-client.d.ts.map +1 -1
- package/dist/realtime/inbox-client.js +29 -8
- package/dist/realtime/inbox-client.js.map +1 -1
- package/dist/realtime/map.d.ts +3 -5
- package/dist/realtime/map.d.ts.map +1 -1
- package/dist/realtime/map.js +3 -18
- package/dist/realtime/map.js.map +1 -1
- package/dist/realtime/shared.d.ts +21 -0
- package/dist/realtime/shared.d.ts.map +1 -1
- package/dist/realtime/shared.js +22 -0
- package/dist/realtime/shared.js.map +1 -1
- package/dist/realtime/useRealtimeChatMessages.d.ts +1 -4
- package/dist/realtime/useRealtimeChatMessages.d.ts.map +1 -1
- package/dist/realtime/useRealtimeChatMessages.js +1 -3
- package/dist/realtime/useRealtimeChatMessages.js.map +1 -1
- package/dist/realtime/wire.d.ts +6 -5
- package/dist/realtime/wire.d.ts.map +1 -1
- package/dist/realtime/wire.js.map +1 -1
- package/dist/types.d.ts +1 -52
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/ui/ChatShell.d.ts +2 -2
- package/dist/ui/ChatShell.d.ts.map +1 -1
- package/dist/ui/ChatShell.js +6 -10
- package/dist/ui/ChatShell.js.map +1 -1
- package/dist/ui/Composer.d.ts +7 -6
- package/dist/ui/Composer.d.ts.map +1 -1
- package/dist/ui/Composer.js +2 -18
- package/dist/ui/Composer.js.map +1 -1
- package/dist/ui/MessageItemDefault.d.ts.map +1 -1
- package/dist/ui/MessageItemDefault.js +7 -12
- package/dist/ui/MessageItemDefault.js.map +1 -1
- package/dist/ui/MessageText.d.ts +19 -0
- package/dist/ui/MessageText.d.ts.map +1 -0
- package/dist/ui/MessageText.js +7 -0
- package/dist/ui/MessageText.js.map +1 -0
- package/package.json +6 -6
- package/src/styles/chat.css +0 -40
- package/dist/mentions/MentionAutocomplete.d.ts +0 -22
- package/dist/mentions/MentionAutocomplete.d.ts.map +0 -1
- package/dist/mentions/MentionAutocomplete.js +0 -21
- package/dist/mentions/MentionAutocomplete.js.map +0 -1
- package/dist/mentions/MessageText.d.ts +0 -27
- package/dist/mentions/MessageText.d.ts.map +0 -1
- package/dist/mentions/MessageText.js +0 -20
- package/dist/mentions/MessageText.js.map +0 -1
- package/dist/mentions/use-mention-autocomplete.d.ts +0 -82
- package/dist/mentions/use-mention-autocomplete.d.ts.map +0 -1
- package/dist/mentions/use-mention-autocomplete.js +0 -323
- package/dist/mentions/use-mention-autocomplete.js.map +0 -1
package/README.md
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
# @ttt-productions/chat-react
|
|
2
2
|
|
|
3
3
|
Chat **React UI** for TTT Productions apps: chat shell, composer, message list,
|
|
4
|
-
the
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
the name-resolver context, and the realtime-newest-window + infinite-older
|
|
5
|
+
hooks. Chat is PLAIN-TEXT-only — a conversation's files live in the consuming
|
|
6
|
+
app's Conversation Files surface, never in the message timeline, and message text
|
|
7
|
+
carries no token grammar (no mentions, no autocomplete, no chips).
|
|
8
8
|
|
|
9
9
|
Built on the pure [`@ttt-productions/chat-core`](../chat-core) package
|
|
10
|
-
(contracts,
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
(contracts, grouping). A non-React consumer (a Cloud Function, a script, a
|
|
11
|
+
future native/TV client) installs `chat-core` and pulls in none of this frontend
|
|
12
|
+
tree.
|
|
13
13
|
|
|
14
14
|
## Entry points
|
|
15
15
|
|
|
16
16
|
- `.` — React components, hooks, the Firebase-client adapter config types
|
|
17
|
-
(`ChatCoreConfig
|
|
18
|
-
|
|
17
|
+
(`ChatCoreConfig`), and the React render types (`MessageRenderer`,
|
|
18
|
+
`MessageRendererRegistry`).
|
|
19
19
|
- `./styles` — chat CSS. Import once in your app layout:
|
|
20
20
|
`import "@ttt-productions/chat-react/styles";`
|
|
21
21
|
|
|
@@ -27,5 +27,7 @@ are optional peers — provided by the consuming app.
|
|
|
27
27
|
## Boundary
|
|
28
28
|
|
|
29
29
|
`chat-react` does not import `ttt-core`, does not hardcode TTT origins, and does
|
|
30
|
-
not build TTT storage paths — it runs no upload path at all.
|
|
31
|
-
|
|
30
|
+
not build TTT storage paths — it runs no upload path at all. It owns no mention
|
|
31
|
+
machinery either: mentions belong to the app's Square-posts surface, not to chat.
|
|
32
|
+
And it owns no reply-to machinery: every send path takes text alone, because the
|
|
33
|
+
product has no affordance for replying to a specific message.
|
|
@@ -16,9 +16,9 @@ export type ChatNameResolverProviderProps = {
|
|
|
16
16
|
*/
|
|
17
17
|
export declare function ChatNameResolverProvider(props: ChatNameResolverProviderProps): React.JSX.Element;
|
|
18
18
|
/**
|
|
19
|
-
* Strict hook — throws if no provider is wrapped. Used inside
|
|
20
|
-
* own renderers (MessageItemDefault
|
|
21
|
-
*
|
|
19
|
+
* Strict hook — throws if no provider is wrapped. Used inside this package's
|
|
20
|
+
* own renderers (MessageItemDefault) so a missing setup surfaces immediately on
|
|
21
|
+
* first render rather than silently rendering "User".
|
|
22
22
|
*/
|
|
23
23
|
export declare function useChatNameResolver(): ChatNameResolverContextValue;
|
|
24
24
|
/**
|
|
@@ -13,9 +13,9 @@ export function ChatNameResolverProvider(props) {
|
|
|
13
13
|
return (_jsx(ChatNameResolverContext.Provider, { value: value, children: children }));
|
|
14
14
|
}
|
|
15
15
|
/**
|
|
16
|
-
* Strict hook — throws if no provider is wrapped. Used inside
|
|
17
|
-
* own renderers (MessageItemDefault
|
|
18
|
-
*
|
|
16
|
+
* Strict hook — throws if no provider is wrapped. Used inside this package's
|
|
17
|
+
* own renderers (MessageItemDefault) so a missing setup surfaces immediately on
|
|
18
|
+
* first render rather than silently rendering "User".
|
|
19
19
|
*/
|
|
20
20
|
export function useChatNameResolver() {
|
|
21
21
|
const ctx = React.useContext(ChatNameResolverContext);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ChatNameResolverContext.js","sourceRoot":"","sources":["../../src/context/ChatNameResolverContext.tsx"],"names":[],"mappings":"AAAA,YAAY,CAAC;;AAEb,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAQ/B,MAAM,uBAAuB,GAAG,KAAK,CAAC,aAAa,CAAsC,IAAI,CAAC,CAAC;AAQ/F;;;;GAIG;AACH,MAAM,UAAU,wBAAwB,CAAC,KAAoC;IAC3E,MAAM,EAAE,WAAW,EAAE,OAAO,EAAE,QAAQ,EAAE,GAAG,KAAK,CAAC;IACjD,MAAM,KAAK,GAAG,KAAK,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC,EAAE,WAAW,EAAE,OAAO,EAAE,CAAC,EAAE,CAAC,WAAW,EAAE,OAAO,CAAC,CAAC,CAAC;IACtF,OAAO,CACL,KAAC,uBAAuB,CAAC,QAAQ,IAAC,KAAK,EAAE,KAAK,YAC3C,QAAQ,GACwB,CACpC,CAAC;AACJ,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,mBAAmB;IACjC,MAAM,GAAG,GAAG,KAAK,CAAC,UAAU,CAAC,uBAAuB,CAAC,CAAC;IACtD,IAAI,CAAC,GAAG,EAAE,CAAC;QACT,MAAM,IAAI,KAAK,CACb,6GAA6G,CAC9G,CAAC;IACJ,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,2BAA2B;IACzC,OAAO,KAAK,CAAC,UAAU,CAAC,uBAAuB,CAAC,CAAC;AACnD,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,qBAAqB,CAAC,QAAgB;IACpD,MAAM,EAAE,WAAW,EAAE,GAAG,mBAAmB,EAAE,CAAC;IAC9C,OAAO,WAAW,CAAC,QAAQ,CAAC,IAAI,MAAM,CAAC;AACzC,CAAC","sourcesContent":["\"use client\";\n\nimport * as React from \"react\";\nimport type { ChatNameResolver, ChatPrewarmSenders } from \"@ttt-productions/chat-core\";\n\ntype ChatNameResolverContextValue = {\n resolveName: ChatNameResolver;\n prewarm?: ChatPrewarmSenders;\n};\n\nconst ChatNameResolverContext = React.createContext<ChatNameResolverContextValue | null>(null);\n\nexport type ChatNameResolverProviderProps = {\n resolveName: ChatNameResolver;\n prewarm?: ChatPrewarmSenders;\n children: React.ReactNode;\n};\n\n/**\n * Wraps any chat-core consumer (typically the whole app shell) and provides\n * a name resolver. Required for any tree that renders <ChatShell>, <MessageList>,\n * or <MessageItemDefault>.\n */\nexport function ChatNameResolverProvider(props: ChatNameResolverProviderProps) {\n const { resolveName, prewarm, children } = props;\n const value = React.useMemo(() => ({ resolveName, prewarm }), [resolveName, prewarm]);\n return (\n <ChatNameResolverContext.Provider value={value}>\n {children}\n </ChatNameResolverContext.Provider>\n );\n}\n\n/**\n * Strict hook — throws if no provider is wrapped. Used inside
|
|
1
|
+
{"version":3,"file":"ChatNameResolverContext.js","sourceRoot":"","sources":["../../src/context/ChatNameResolverContext.tsx"],"names":[],"mappings":"AAAA,YAAY,CAAC;;AAEb,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAQ/B,MAAM,uBAAuB,GAAG,KAAK,CAAC,aAAa,CAAsC,IAAI,CAAC,CAAC;AAQ/F;;;;GAIG;AACH,MAAM,UAAU,wBAAwB,CAAC,KAAoC;IAC3E,MAAM,EAAE,WAAW,EAAE,OAAO,EAAE,QAAQ,EAAE,GAAG,KAAK,CAAC;IACjD,MAAM,KAAK,GAAG,KAAK,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC,EAAE,WAAW,EAAE,OAAO,EAAE,CAAC,EAAE,CAAC,WAAW,EAAE,OAAO,CAAC,CAAC,CAAC;IACtF,OAAO,CACL,KAAC,uBAAuB,CAAC,QAAQ,IAAC,KAAK,EAAE,KAAK,YAC3C,QAAQ,GACwB,CACpC,CAAC;AACJ,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,mBAAmB;IACjC,MAAM,GAAG,GAAG,KAAK,CAAC,UAAU,CAAC,uBAAuB,CAAC,CAAC;IACtD,IAAI,CAAC,GAAG,EAAE,CAAC;QACT,MAAM,IAAI,KAAK,CACb,6GAA6G,CAC9G,CAAC;IACJ,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,2BAA2B;IACzC,OAAO,KAAK,CAAC,UAAU,CAAC,uBAAuB,CAAC,CAAC;AACnD,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,qBAAqB,CAAC,QAAgB;IACpD,MAAM,EAAE,WAAW,EAAE,GAAG,mBAAmB,EAAE,CAAC;IAC9C,OAAO,WAAW,CAAC,QAAQ,CAAC,IAAI,MAAM,CAAC;AACzC,CAAC","sourcesContent":["\"use client\";\n\nimport * as React from \"react\";\nimport type { ChatNameResolver, ChatPrewarmSenders } from \"@ttt-productions/chat-core\";\n\ntype ChatNameResolverContextValue = {\n resolveName: ChatNameResolver;\n prewarm?: ChatPrewarmSenders;\n};\n\nconst ChatNameResolverContext = React.createContext<ChatNameResolverContextValue | null>(null);\n\nexport type ChatNameResolverProviderProps = {\n resolveName: ChatNameResolver;\n prewarm?: ChatPrewarmSenders;\n children: React.ReactNode;\n};\n\n/**\n * Wraps any chat-core consumer (typically the whole app shell) and provides\n * a name resolver. Required for any tree that renders <ChatShell>, <MessageList>,\n * or <MessageItemDefault>.\n */\nexport function ChatNameResolverProvider(props: ChatNameResolverProviderProps) {\n const { resolveName, prewarm, children } = props;\n const value = React.useMemo(() => ({ resolveName, prewarm }), [resolveName, prewarm]);\n return (\n <ChatNameResolverContext.Provider value={value}>\n {children}\n </ChatNameResolverContext.Provider>\n );\n}\n\n/**\n * Strict hook — throws if no provider is wrapped. Used inside this package's\n * own renderers (MessageItemDefault) so a missing setup surfaces immediately on\n * first render rather than silently rendering \"User\".\n */\nexport function useChatNameResolver(): ChatNameResolverContextValue {\n const ctx = React.useContext(ChatNameResolverContext);\n if (!ctx) {\n throw new Error(\n \"[chat-core] ChatNameResolverProvider is required. Wrap your ChatShell tree with <ChatNameResolverProvider>.\"\n );\n }\n return ctx;\n}\n\n/**\n * Optional variant — returns null instead of throwing when no provider is\n * wrapped. Used internally by useChatMessages so headless callers (tests,\n * data-only consumers that don't render UI) can still use the hook.\n */\nexport function useOptionalChatNameResolver(): ChatNameResolverContextValue | null {\n return React.useContext(ChatNameResolverContext);\n}\n\n/**\n * Convenience hook for renderers. Returns a resolved string — never null —\n * by falling back to \"User\" when the cache hasn't filled yet. Re-renders\n * automatically once the upstream cache settles.\n */\nexport function useResolvedSenderName(senderId: string): string {\n const { resolveName } = useChatNameResolver();\n return resolveName(senderId) ?? \"User\";\n}\n"]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useChatMessages.d.ts","sourceRoot":"","sources":["../../src/hooks/useChatMessages.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAChE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAQlD,MAAM,MAAM,qBAAqB,GAAG;IAClC,OAAO,EAAE,OAAO,CAAC;IACjB,gBAAgB,EAAE,OAAO,CAAC;IAC1B,QAAQ,EAAE,aAAa,EAAE,CAAC;IAC1B,UAAU,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAChC,QAAQ,EAAE,OAAO,CAAC;IAClB,eAAe,EAAE,OAAO,CAAC;CAC1B,CAAC;
|
|
1
|
+
{"version":3,"file":"useChatMessages.d.ts","sourceRoot":"","sources":["../../src/hooks/useChatMessages.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAChE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAQlD,MAAM,MAAM,qBAAqB,GAAG;IAClC,OAAO,EAAE,OAAO,CAAC;IACjB,gBAAgB,EAAE,OAAO,CAAC;IAC1B,QAAQ,EAAE,aAAa,EAAE,CAAC;IAC1B,UAAU,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAChC,QAAQ,EAAE,OAAO,CAAC;IAClB,eAAe,EAAE,OAAO,CAAC;CAC1B,CAAC;AAgCF,wBAAgB,eAAe,CAAC,MAAM,EAAE,cAAc,GAAG,qBAAqB,CAsF7E"}
|
|
@@ -13,15 +13,9 @@ function mapMsg(data, threadId) {
|
|
|
13
13
|
if (!createdAt) {
|
|
14
14
|
console.error('[useChatMessages] Message missing valid createdAt:', data.id, d.createdAt);
|
|
15
15
|
}
|
|
16
|
-
// replyTo
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
? {
|
|
20
|
-
messageId: d.replyTo.messageId,
|
|
21
|
-
senderId: d.replyTo.senderId ?? "",
|
|
22
|
-
messagePreview: d.replyTo.messagePreview,
|
|
23
|
-
}
|
|
24
|
-
: undefined;
|
|
16
|
+
// A stored `replyTo` (if any legacy doc still carries one) is deliberately NOT
|
|
17
|
+
// mapped: chat has no reply-authoring affordance, so the UI has no reply
|
|
18
|
+
// renderer to feed (DJ ruling 2026-07-29).
|
|
25
19
|
return {
|
|
26
20
|
messageId: data.id,
|
|
27
21
|
threadId,
|
|
@@ -29,7 +23,6 @@ function mapMsg(data, threadId) {
|
|
|
29
23
|
senderId: d.senderId,
|
|
30
24
|
text: d.text ?? "",
|
|
31
25
|
type: d.type,
|
|
32
|
-
replyTo,
|
|
33
26
|
isSystemMessage: d.isSystemMessage ?? undefined,
|
|
34
27
|
// Moderation tombstone flag (backend-written) — carried through so renderers
|
|
35
28
|
// can tombstone the content.
|
|
@@ -64,15 +57,13 @@ export function useChatMessages(config) {
|
|
|
64
57
|
getSortValue: (data) => toMillis(data.createdAt) ?? 0,
|
|
65
58
|
});
|
|
66
59
|
const messages = React.useMemo(() => (allowed ? items : []), [allowed, items]);
|
|
67
|
-
// Pre-warm name cache for all visible senders
|
|
60
|
+
// Pre-warm name cache for all visible senders.
|
|
68
61
|
// Optional resolver: silent no-op if no provider is wrapped.
|
|
69
62
|
const resolverCtx = useOptionalChatNameResolver();
|
|
70
63
|
const senderIds = React.useMemo(() => {
|
|
71
64
|
const set = new Set();
|
|
72
65
|
for (const m of messages) {
|
|
73
66
|
set.add(m.senderId);
|
|
74
|
-
if (m.replyTo?.senderId)
|
|
75
|
-
set.add(m.replyTo.senderId);
|
|
76
67
|
}
|
|
77
68
|
return Array.from(set);
|
|
78
69
|
}, [messages]);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useChatMessages.js","sourceRoot":"","sources":["../../src/hooks/useChatMessages.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAE/B,OAAO,EAAE,QAAQ,EAAE,MAAM,mCAAmC,CAAC;AAC7D,OAAO,EAAE,wBAAwB,EAAE,MAAM,mCAAmC,CAAC;AAG7E,OAAO,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAC;AAC3D,OAAO,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAC;AAC1D,OAAO,EAAE,2BAA2B,EAAE,MAAM,uCAAuC,CAAC;AAEpF,4CAA4C;AAC5C,MAAM,aAAa,GAAG,GAAG,CAAC;AAW1B,SAAS,MAAM,CACb,IAAmC,EACnC,QAAgB;IAEhB,MAAM,CAAC,GAAG,IAAW,CAAC;IAEtB,MAAM,SAAS,GAAG,QAAQ,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;IAExC,IAAI,CAAC,SAAS,EAAE,CAAC;QACf,OAAO,CAAC,KAAK,CAAC,oDAAoD,EAAE,IAAI,CAAC,EAAE,EAAE,CAAC,CAAC,SAAS,CAAC,CAAC;IAC5F,CAAC;IAED
|
|
1
|
+
{"version":3,"file":"useChatMessages.js","sourceRoot":"","sources":["../../src/hooks/useChatMessages.ts"],"names":[],"mappings":"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","sourcesContent":["\"use client\";\n\nimport * as React from \"react\";\nimport type { DocumentData } from \"firebase/firestore\";\nimport { toMillis } from \"@ttt-productions/firebase-helpers\";\nimport { useFirestoreLiveInfinite } from \"@ttt-productions/query-core/react\";\nimport type { ChatMessageV1 } from \"@ttt-productions/chat-core\";\nimport type { ChatCoreConfig } from \"../types.js\";\nimport { canAccessThread } from \"./useChatThreadAccess.js\";\nimport { messagesColPath } from \"../firestore/queries.js\";\nimport { useOptionalChatNameResolver } from \"../context/ChatNameResolverContext.js\";\n\n// Safety limit to prevent memory exhaustion\nconst MAX_PAGE_SIZE = 100;\n\nexport type UseChatMessagesResult = {\n allowed: boolean;\n isInitialLoading: boolean;\n messages: ChatMessageV1[];\n fetchOlder: () => Promise<void>;\n hasOlder: boolean;\n isFetchingOlder: boolean;\n};\n\nfunction mapMsg(\n data: DocumentData & { id: string },\n threadId: string\n): ChatMessageV1 {\n const d = data as any;\n\n const createdAt = toMillis(d.createdAt);\n\n if (!createdAt) {\n console.error('[useChatMessages] Message missing valid createdAt:', data.id, d.createdAt);\n }\n\n // A stored `replyTo` (if any legacy doc still carries one) is deliberately NOT\n // mapped: chat has no reply-authoring affordance, so the UI has no reply\n // renderer to feed (DJ ruling 2026-07-29).\n return {\n messageId: data.id,\n threadId,\n createdAt: createdAt || 0,\n senderId: d.senderId,\n text: d.text ?? \"\",\n type: d.type,\n isSystemMessage: d.isSystemMessage ?? undefined,\n // Moderation tombstone flag (backend-written) — carried through so renderers\n // can tombstone the content.\n hidden: d.hidden ?? undefined,\n meta: d.meta,\n };\n}\n\nexport function useChatMessages(config: ChatCoreConfig): UseChatMessagesResult {\n const {\n chatCollectionPath,\n messagesSubcollection = \"messages\",\n threadId,\n pageSize: requestedPageSize = 20,\n currentUserId,\n isAdmin,\n accessMode,\n threadAllowedUserIds,\n createdAtField = \"createdAt\",\n } = config;\n\n const pageSize = Math.min(requestedPageSize, MAX_PAGE_SIZE);\n\n if (requestedPageSize > MAX_PAGE_SIZE) {\n console.warn(\n `[useChatMessages] Requested pageSize ${requestedPageSize} exceeds maximum ${MAX_PAGE_SIZE}. Using ${MAX_PAGE_SIZE}.`\n );\n }\n\n const allowed = React.useMemo(\n () =>\n canAccessThread({\n accessMode,\n isAdmin,\n currentUserId,\n allowedUserIds: threadAllowedUserIds,\n }),\n [accessMode, isAdmin, currentUserId, threadAllowedUserIds]\n );\n\n // Stable key segment for the message collection path (string | string[]).\n const pathKey = React.useMemo(\n () =>\n JSON.stringify(\n Array.isArray(chatCollectionPath) ? chatCollectionPath : [chatCollectionPath]\n ),\n [chatCollectionPath]\n );\n\n // Slash-joined collection path for the generic hook (handles nested paths).\n const collectionPath = React.useMemo(\n () => messagesColPath(chatCollectionPath, threadId, messagesSubcollection).join(\"/\"),\n [chatCollectionPath, threadId, messagesSubcollection]\n );\n\n const { items, isInitialLoading, fetchOlder, hasOlder, isFetchingOlder } =\n useFirestoreLiveInfinite<ChatMessageV1>({\n collectionPath,\n queryKey: [\"chat-core\", \"messages\", pathKey, threadId, messagesSubcollection, pageSize],\n orderByField: createdAtField,\n pageSize,\n enabled: allowed,\n sort: \"asc\",\n select: (data) => mapMsg(data, threadId),\n getSortValue: (data) => toMillis(data.createdAt) ?? 0,\n });\n\n const messages = React.useMemo(() => (allowed ? items : []), [allowed, items]);\n\n // Pre-warm name cache for all visible senders.\n // Optional resolver: silent no-op if no provider is wrapped.\n const resolverCtx = useOptionalChatNameResolver();\n const senderIds = React.useMemo(() => {\n const set = new Set<string>();\n for (const m of messages) {\n set.add(m.senderId);\n }\n return Array.from(set);\n }, [messages]);\n\n React.useEffect(() => {\n if (resolverCtx?.prewarm && senderIds.length > 0) {\n resolverCtx.prewarm(senderIds);\n }\n }, [resolverCtx, senderIds]);\n\n return {\n allowed,\n isInitialLoading: allowed ? isInitialLoading : false,\n messages,\n fetchOlder,\n hasOlder,\n isFetchingOlder,\n };\n}"]}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type { ChatCoreConfig, ChatTransportMode, ChatRealtimeTransportConfig,
|
|
1
|
+
export type { ChatCoreConfig, ChatTransportMode, ChatRealtimeTransportConfig, MessageRenderer, MessageRendererRegistry, } from "./types.js";
|
|
2
2
|
export type { ChatNameResolverProviderProps } from "./context/ChatNameResolverContext.js";
|
|
3
3
|
export { ChatNameResolverProvider, useChatNameResolver, useOptionalChatNameResolver, useResolvedSenderName, } from "./context/ChatNameResolverContext.js";
|
|
4
4
|
export type { UseChatMessagesResult } from "./hooks/useChatMessages.js";
|
|
@@ -14,7 +14,5 @@ export { Composer } from "./ui/Composer.js";
|
|
|
14
14
|
export type { MessageItemDefaultProps } from "./ui/MessageItemDefault.js";
|
|
15
15
|
export { MessageItemDefault } from "./ui/MessageItemDefault.js";
|
|
16
16
|
export { MessageActions, ThreadActions } from "./ui/menus.js";
|
|
17
|
-
export { MessageText, type MessageTextProps } from "./
|
|
18
|
-
export { MentionAutocomplete, type MentionAutocompleteProps, } from "./mentions/MentionAutocomplete.js";
|
|
19
|
-
export { useMentionAutocomplete, type UseMentionAutocompleteArgs, type UseMentionAutocompleteResult, type AutocompleteState, type AutocompleteResultGroup, } from "./mentions/use-mention-autocomplete.js";
|
|
17
|
+
export { MessageText, type MessageTextProps } from "./ui/MessageText.js";
|
|
20
18
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AASA,YAAY,EACV,cAAc,EACd,iBAAiB,EACjB,2BAA2B,EAC3B,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AASA,YAAY,EACV,cAAc,EACd,iBAAiB,EACjB,2BAA2B,EAC3B,eAAe,EACf,uBAAuB,GACxB,MAAM,YAAY,CAAC;AAGpB,YAAY,EAAE,6BAA6B,EAAE,MAAM,sCAAsC,CAAC;AAC1F,OAAO,EACL,wBAAwB,EACxB,mBAAmB,EACnB,2BAA2B,EAC3B,qBAAqB,GACtB,MAAM,sCAAsC,CAAC;AAE9C,YAAY,EAAE,qBAAqB,EAAE,MAAM,4BAA4B,CAAC;AACxE,OAAO,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAC7D,OAAO,EAAE,eAAe,EAAE,MAAM,gCAAgC,CAAC;AAKjE,OAAO,EACL,wBAAwB,EACxB,iBAAiB,EACjB,aAAa,EACb,WAAW,EACX,uBAAuB,EACvB,oBAAoB,EACpB,qBAAqB,EACrB,uBAAuB,EACvB,gBAAgB,EAChB,gBAAgB,EAChB,YAAY,EACZ,YAAY,EACZ,UAAU,EACV,UAAU,EACV,6BAA6B,GAC9B,MAAM,qBAAqB,CAAC;AAC7B,YAAY,EACV,kBAAkB,EAClB,uBAAuB,EACvB,2BAA2B,EAC3B,yBAAyB,EACzB,6BAA6B,EAC7B,kBAAkB,EAClB,mBAAmB,EACnB,gBAAgB,EAChB,iBAAiB,EACjB,6BAA6B,EAC7B,aAAa,EACb,eAAe,EACf,cAAc,EACd,cAAc,EACd,aAAa,EACb,cAAc,EACd,eAAe,EACf,cAAc,EACd,iBAAiB,EACjB,iBAAiB,EACjB,WAAW,GACZ,MAAM,qBAAqB,CAAC;AAU7B,YAAY,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAC9C,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAClD,YAAY,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAC5C,YAAY,EAAE,uBAAuB,EAAE,MAAM,4BAA4B,CAAC;AAC1E,OAAO,EAAE,kBAAkB,EAAE,MAAM,4BAA4B,CAAC;AAChE,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9D,OAAO,EAAE,WAAW,EAAE,KAAK,gBAAgB,EAAE,MAAM,qBAAqB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -11,7 +11,5 @@ export { MessageList } from "./ui/MessageList.js";
|
|
|
11
11
|
export { Composer } from "./ui/Composer.js";
|
|
12
12
|
export { MessageItemDefault } from "./ui/MessageItemDefault.js";
|
|
13
13
|
export { MessageActions, ThreadActions } from "./ui/menus.js";
|
|
14
|
-
export { MessageText } from "./
|
|
15
|
-
export { MentionAutocomplete, } from "./mentions/MentionAutocomplete.js";
|
|
16
|
-
export { useMentionAutocomplete, } from "./mentions/use-mention-autocomplete.js";
|
|
14
|
+
export { MessageText } from "./ui/MessageText.js";
|
|
17
15
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAmBb,OAAO,EACL,wBAAwB,EACxB,mBAAmB,EACnB,2BAA2B,EAC3B,qBAAqB,GACtB,MAAM,sCAAsC,CAAC;AAG9C,OAAO,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAC7D,OAAO,EAAE,eAAe,EAAE,MAAM,gCAAgC,CAAC;AAEjE,+EAA+E;AAC/E,kFAAkF;AAClF,oFAAoF;AACpF,OAAO,EACL,wBAAwB,EACxB,iBAAiB,EACjB,aAAa,EACb,WAAW,EACX,uBAAuB,EACvB,oBAAoB,EACpB,qBAAqB,EACrB,uBAAuB,EACvB,gBAAgB,EAChB,gBAAgB,EAChB,YAAY,EACZ,YAAY,EACZ,UAAU,EACV,UAAU,EACV,6BAA6B,GAC9B,MAAM,qBAAqB,CAAC;AAkC7B,OAAO,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAC9C,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAElD,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAE5C,OAAO,EAAE,kBAAkB,EAAE,MAAM,4BAA4B,CAAC;AAChE,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9D,OAAO,EAAE,WAAW,EAAyB,MAAM,qBAAqB,CAAC","sourcesContent":["\"use client\";\n\n// @ttt-productions/chat-react — chat React UI, hooks, the Firebase-client\n// adapter config types, and React render types. Depends on the pure\n// @ttt-productions/chat-core for contracts, parser, and grouping.\n//\n// Apps import chat CSS via \"@ttt-productions/chat-react/styles\".\n\n// React-coupled config + render types (previously on chat-core root).\nexport type {\n ChatCoreConfig,\n ChatTransportMode,\n ChatRealtimeTransportConfig,\n MessageRenderer,\n MessageRendererRegistry,\n} from \"./types.js\";\n\n// Name-resolver context.\nexport type { ChatNameResolverProviderProps } from \"./context/ChatNameResolverContext.js\";\nexport {\n ChatNameResolverProvider,\n useChatNameResolver,\n useOptionalChatNameResolver,\n useResolvedSenderName,\n} from \"./context/ChatNameResolverContext.js\";\n\nexport type { UseChatMessagesResult } from \"./hooks/useChatMessages.js\";\nexport { useChatMessages } from \"./hooks/useChatMessages.js\";\nexport { canAccessThread } from \"./hooks/useChatThreadAccess.js\";\n\n// Realtime (Cloudflare Durable Object) transport — the client half of the chat\n// worker wire protocol. Opt-in via `ChatCoreConfig.transport === 'realtime'`; the\n// firestore transport stays the unchanged default. See docs/packages/chat-react.md.\nexport {\n createRealtimeChatClient,\n createInboxClient,\n ChannelClient,\n InboxClient,\n useRealtimeChatMessages,\n browserSocketFactory,\n ChatAccessDeniedError,\n isChatAccessDeniedError,\n CHAT_SUBPROTOCOL,\n CHAT_CLOSE_CODES,\n CLIENT_FRAME,\n SERVER_FRAME,\n buildFrame,\n parseFrame,\n CHAT_CLIENT_DIAGNOSTIC_EVENTS,\n} from \"./realtime/index.js\";\nexport type {\n RealtimeChatClient,\n RealtimeTransportConfig,\n ChatClientDiagnosticsOption,\n ChatClientDiagnosticsSink,\n ChatClientDiagnosticEventName,\n ChannelClientState,\n ChannelClientConfig,\n InboxClientState,\n InboxClientConfig,\n UseRealtimeChatMessagesResult,\n GrantProvider,\n TransportTimers,\n RealtimeStatus,\n RealtimeSocket,\n SocketFactory,\n SocketHandlers,\n ChannelRefTuple,\n WireMessageRow,\n WireInboxSnapshot,\n WireRegistryEntry,\n ServerFrame,\n} from \"./realtime/index.js\";\n\n// TODO (re-export when a consumer needs them): the moderation-overlay helpers\n// `RevisionKind`, `ModerationOverlay`, `applyModerationOverlay`, `overlayFromRow`,\n// and `MODERATION_REDACTED_TEXT` live in `./realtime` but are intentionally NOT\n// re-exported from this package root yet — the consuming app's renderer reads moderation\n// state off `message.meta` (the realtime client applies the overlay internally), so\n// nothing imports them from here today. Add them to the block above when a consumer\n// genuinely needs to apply the overlay directly. (Comment-only; rides the next publish.)\n\nexport type { ChatShellProps } from \"./ui/ChatShell.js\";\nexport { ChatShell } from \"./ui/ChatShell.js\";\nexport { MessageList } from \"./ui/MessageList.js\";\nexport type { ComposerProps } from \"./ui/Composer.js\";\nexport { Composer } from \"./ui/Composer.js\";\nexport type { MessageItemDefaultProps } from \"./ui/MessageItemDefault.js\";\nexport { MessageItemDefault } from \"./ui/MessageItemDefault.js\";\nexport { MessageActions, ThreadActions } from \"./ui/menus.js\";\nexport { MessageText, type MessageTextProps } from \"./ui/MessageText.js\";\n"]}
|
|
@@ -73,6 +73,13 @@ export declare class ChannelClient {
|
|
|
73
73
|
private reauthAttempted;
|
|
74
74
|
private presenceSubscribed;
|
|
75
75
|
private heartbeatTimer;
|
|
76
|
+
/**
|
|
77
|
+
* When the CURRENT socket last proved it was alive: set on open, refreshed by
|
|
78
|
+
* every successfully parsed inbound frame (`heartbeat-ack` included — that IS
|
|
79
|
+
* the DO's liveness answer). Null whenever no socket is live, so the watchdog
|
|
80
|
+
* can never judge a socket that does not exist. See {@link INBOUND_SILENCE_LIMIT_MS}.
|
|
81
|
+
*/
|
|
82
|
+
private lastInboundAt;
|
|
76
83
|
private typingTimers;
|
|
77
84
|
private lastTypingSentAt;
|
|
78
85
|
private reconnectTimer;
|
|
@@ -275,10 +282,6 @@ export declare class ChannelClient {
|
|
|
275
282
|
send(args: {
|
|
276
283
|
clientMessageId: string;
|
|
277
284
|
text: string;
|
|
278
|
-
replyTo?: {
|
|
279
|
-
messageSeq: number;
|
|
280
|
-
preview: string;
|
|
281
|
-
} | null;
|
|
282
285
|
}): boolean;
|
|
283
286
|
/**
|
|
284
287
|
* Explicit read ack (delivery != read). `focused` => the channel is open + at latest.
|
|
@@ -296,6 +299,20 @@ export declare class ChannelClient {
|
|
|
296
299
|
private requestHistory;
|
|
297
300
|
private startHeartbeat;
|
|
298
301
|
private stopHeartbeat;
|
|
302
|
+
/** True when the live socket has produced no parseable frame for too long. */
|
|
303
|
+
private isInboundSilent;
|
|
304
|
+
/**
|
|
305
|
+
* The channel DO auto-acks every heartbeat, so silence past
|
|
306
|
+
* {@link INBOUND_SILENCE_LIMIT_MS} means this socket is dead even though no
|
|
307
|
+
* `onclose` ever fired (the half-open-connection case that otherwise strands
|
|
308
|
+
* the UI on a socket that will never deliver another message).
|
|
309
|
+
*
|
|
310
|
+
* Close it LOCALLY and deliberately WITHOUT setting `closedByUs`, so the close
|
|
311
|
+
* lands on `onClose`'s ordinary transient path — which already owns the
|
|
312
|
+
* reconnect scheduling, the backoff, and the resume-cursor recovery. Nothing
|
|
313
|
+
* about reconnect policy is duplicated here.
|
|
314
|
+
*/
|
|
315
|
+
private closeDeadSocket;
|
|
299
316
|
/** Permanently close this client (auth-user switch / unmount). Idempotent. */
|
|
300
317
|
close(): void;
|
|
301
318
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"channel-client.d.ts","sourceRoot":"","sources":["../../src/realtime/channel-client.ts"],"names":[],"mappings":"AAkBA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAahE,OAAO,KAAK,EAAkB,aAAa,EAAE,MAAM,aAAa,CAAC;AASjE,OAAO,KAAK,EAAE,aAAa,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAClF,OAAO,EAKL,KAAK,2BAA2B,EACjC,MAAM,kBAAkB,CAAC;
|
|
1
|
+
{"version":3,"file":"channel-client.d.ts","sourceRoot":"","sources":["../../src/realtime/channel-client.ts"],"names":[],"mappings":"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"}
|
|
@@ -18,7 +18,7 @@ import { createReconnectController } from '@ttt-productions/realtime-core';
|
|
|
18
18
|
import { CLIENT_FRAME, CHAT_CLOSE_CODES, buildFrame, parseFrame, isInboxSnapshot, ChatSendRejectedPayloadSchema, } from './wire.js';
|
|
19
19
|
import { wireRowToMessage, optimisticMessage, seqToMessageId, applyModerationOverlay, overlayFromRow, } from './map.js';
|
|
20
20
|
import { createDiagnosticsEmitter, safeDiagnosticLabel, CHAT_CLIENT_DIAGNOSTIC_EVENTS as DIAG, } from './diagnostics.js';
|
|
21
|
-
import { defaultTimers, isChatAccessDeniedError, HEARTBEAT_MS, TYPING_COALESCE_MS, HISTORY_PAGE_MAX, MAX_PENDING_SEND_ATTEMPTS, PENDING_SEND_MAX_AGE_MS, SERVER_RETRYABLE_MAX_AGE_MS, SEND_RETRY_MIN_DELAY_MS, SEND_RETRY_MAX_DELAY_MS, } from './shared.js';
|
|
21
|
+
import { defaultTimers, isChatAccessDeniedError, isTerminalErrorCode, TERMINAL_ERROR_CODE, HEARTBEAT_MS, TYPING_COALESCE_MS, HISTORY_PAGE_MAX, MAX_PENDING_SEND_ATTEMPTS, PENDING_SEND_MAX_AGE_MS, SERVER_RETRYABLE_MAX_AGE_MS, SEND_RETRY_MIN_DELAY_MS, SEND_RETRY_MAX_DELAY_MS, } from './shared.js';
|
|
22
22
|
const INITIAL = {
|
|
23
23
|
status: 'idle',
|
|
24
24
|
messages: [],
|
|
@@ -39,6 +39,37 @@ const INITIAL = {
|
|
|
39
39
|
const MEMBERSHIP_PENDING_CODE = 'membership-pending';
|
|
40
40
|
/** Fallback retry delay when the frame omits/breaks `retryAfterMs`. */
|
|
41
41
|
const MEMBERSHIP_PENDING_DEFAULT_RETRY_MS = 2000;
|
|
42
|
+
/**
|
|
43
|
+
* Dead-socket watchdog: how long inbound silence may last before this client
|
|
44
|
+
* treats the socket as dead and closes it locally.
|
|
45
|
+
*
|
|
46
|
+
* CHANNEL-ONLY, and this is why it is sound here: the channel DO answers every
|
|
47
|
+
* 20 s client `heartbeat` through the hibernation AUTO-RESPONSE API, so a healthy
|
|
48
|
+
* channel socket is NEVER silent for a full heartbeat window. Inbound silence past
|
|
49
|
+
* this threshold is therefore provable death (a half-open TCP connection whose
|
|
50
|
+
* `onclose` never fires), not idleness. The INBOX socket has no such auto-response
|
|
51
|
+
* and a healthy idle inbox is legitimately silent for hours — applying this rule
|
|
52
|
+
* there would endlessly reconnect healthy sockets, so it is deliberately absent
|
|
53
|
+
* from `inbox-client.ts`.
|
|
54
|
+
*
|
|
55
|
+
* PRIVATE CLIENT POLICY, not a wire constant: nothing on the worker side agrees to
|
|
56
|
+
* it, so it does not belong in `@ttt-productions/chat-schemas` or in this package's
|
|
57
|
+
* exported surface. The check rides the existing 20 s heartbeat tick (no second
|
|
58
|
+
* timer), so detection lands on the NEXT tick after the deadline rather than at
|
|
59
|
+
* exactly 50 s — intended: 2.5 missed windows is unambiguous, and it costs no
|
|
60
|
+
* extra timer.
|
|
61
|
+
*/
|
|
62
|
+
const INBOUND_SILENCE_LIMIT_MS = 50_000;
|
|
63
|
+
/**
|
|
64
|
+
* Close code used for the watchdog's LOCAL close. A browser `WebSocket.close()`
|
|
65
|
+
* accepts only 1000 or 3000–4999, and the private 4xxx range is the chat wire's
|
|
66
|
+
* (`CHAT_CLOSE_CODES`) — a client-invented code there could collide with a future
|
|
67
|
+
* server code. 1000 keeps the close frame valid and, being neither 4401 nor 4403,
|
|
68
|
+
* routes through `onClose`'s ordinary transient path, which owns reconnect,
|
|
69
|
+
* backoff, and resume. The reason string + the liveness diagnostic are what
|
|
70
|
+
* identify it.
|
|
71
|
+
*/
|
|
72
|
+
const WATCHDOG_CLOSE_CODE = 1000;
|
|
42
73
|
export class ChannelClient {
|
|
43
74
|
config;
|
|
44
75
|
timers;
|
|
@@ -55,6 +86,13 @@ export class ChannelClient {
|
|
|
55
86
|
reauthAttempted = false;
|
|
56
87
|
presenceSubscribed = false;
|
|
57
88
|
heartbeatTimer = null;
|
|
89
|
+
/**
|
|
90
|
+
* When the CURRENT socket last proved it was alive: set on open, refreshed by
|
|
91
|
+
* every successfully parsed inbound frame (`heartbeat-ack` included — that IS
|
|
92
|
+
* the DO's liveness answer). Null whenever no socket is live, so the watchdog
|
|
93
|
+
* can never judge a socket that does not exist. See {@link INBOUND_SILENCE_LIMIT_MS}.
|
|
94
|
+
*/
|
|
95
|
+
lastInboundAt = null;
|
|
58
96
|
typingTimers = new Map();
|
|
59
97
|
lastTypingSentAt = Number.NEGATIVE_INFINITY;
|
|
60
98
|
reconnectTimer = null;
|
|
@@ -124,6 +162,23 @@ export class ChannelClient {
|
|
|
124
162
|
// legitimate reconnect.
|
|
125
163
|
if (this.state.status !== 'idle' && this.state.status !== 'closed')
|
|
126
164
|
return;
|
|
165
|
+
// REVIVE the controller. `controller.close()` is PERMANENT: while it is in
|
|
166
|
+
// state `closed`, `start()` and `onOpen()` no-op and every later `onClose()`
|
|
167
|
+
// returns null. So without this reset a close() → connect() client opens a
|
|
168
|
+
// socket and works normally until its FIRST transient close, at which point
|
|
169
|
+
// `scheduleReconnect()` gets null and parks it terminally in `closed` with
|
|
170
|
+
// reconnect disabled and no surfaced error code. The idempotency guard above
|
|
171
|
+
// is what keeps this from disturbing a live lifecycle: only an `idle` or
|
|
172
|
+
// fully torn-down (`closed`) client ever reaches here.
|
|
173
|
+
this.controller.reset();
|
|
174
|
+
// A terminal verdict (4403 REVOKED / ChatAccessDeniedError) is terminal WITHIN
|
|
175
|
+
// its lifecycle; an explicit connect() is a fresh attempt the authority may
|
|
176
|
+
// legitimately re-deny, so the previous lifecycle's code must not persist into
|
|
177
|
+
// this one (the hook maps `access-denied` to `allowed: false`). Loaded
|
|
178
|
+
// messages and the initial-load flag are deliberately KEPT — a lifecycle
|
|
179
|
+
// restart must not churn the UI back to an empty opening state.
|
|
180
|
+
if (isTerminalErrorCode(this.state.lastErrorCode))
|
|
181
|
+
this.setState({ lastErrorCode: null });
|
|
127
182
|
this.closedByUs = false;
|
|
128
183
|
this.reconnectCause = 'initial';
|
|
129
184
|
this.controller.start();
|
|
@@ -159,6 +214,9 @@ export class ChannelClient {
|
|
|
159
214
|
}
|
|
160
215
|
if (this.closedByUs)
|
|
161
216
|
return;
|
|
217
|
+
// Socket REPLACEMENT: the previous socket's liveness record dies with it, so
|
|
218
|
+
// the watchdog never judges a new socket against an old deadline.
|
|
219
|
+
this.lastInboundAt = null;
|
|
162
220
|
const url = `${this.config.endpoint.replace(/\/$/, '')}/channel`;
|
|
163
221
|
this.socket = this.config.socketFactory({
|
|
164
222
|
url,
|
|
@@ -174,6 +232,9 @@ export class ChannelClient {
|
|
|
174
232
|
onOpen() {
|
|
175
233
|
this.controller.onOpen();
|
|
176
234
|
this.reauthAttempted = false;
|
|
235
|
+
// Start the liveness clock at open — the first heartbeat window is measured
|
|
236
|
+
// from here, not from a stale previous socket.
|
|
237
|
+
this.lastInboundAt = this.timers.now();
|
|
177
238
|
this.setState({ status: 'open' });
|
|
178
239
|
this.diag?.(DIAG.SOCKET_OPEN, {
|
|
179
240
|
attempt: this.connectAttempt,
|
|
@@ -243,7 +304,6 @@ export class ChannelClient {
|
|
|
243
304
|
const sent = this.sendFrame(CLIENT_FRAME.SEND, {
|
|
244
305
|
clientMessageId: pending.clientMessageId,
|
|
245
306
|
text: pending.text,
|
|
246
|
-
replyTo: pending.replyTo,
|
|
247
307
|
});
|
|
248
308
|
if (sent)
|
|
249
309
|
pending.attempts += 1;
|
|
@@ -418,7 +478,6 @@ export class ChannelClient {
|
|
|
418
478
|
const sent = this.sendFrame(CLIENT_FRAME.SEND, {
|
|
419
479
|
clientMessageId: current.clientMessageId,
|
|
420
480
|
text: current.text,
|
|
421
|
-
replyTo: current.replyTo,
|
|
422
481
|
});
|
|
423
482
|
this.diag?.(DIAG.SEND_RETRY, {
|
|
424
483
|
clientMessageId: current.clientMessageId,
|
|
@@ -487,7 +546,6 @@ export class ChannelClient {
|
|
|
487
546
|
const sent = this.sendFrame(CLIENT_FRAME.SEND, {
|
|
488
547
|
clientMessageId: pending.clientMessageId,
|
|
489
548
|
text: pending.text,
|
|
490
|
-
replyTo: pending.replyTo,
|
|
491
549
|
});
|
|
492
550
|
pending.attempts = sent ? 1 : 0;
|
|
493
551
|
this.diag?.(DIAG.SEND_RETRY, {
|
|
@@ -515,6 +573,7 @@ export class ChannelClient {
|
|
|
515
573
|
onClose(code, _reason) {
|
|
516
574
|
this.stopHeartbeat();
|
|
517
575
|
this.socket = null;
|
|
576
|
+
this.lastInboundAt = null;
|
|
518
577
|
this.lastCloseCode = code;
|
|
519
578
|
if (this.closedByUs) {
|
|
520
579
|
this.diag?.(DIAG.SOCKET_CLOSE, { code, closedByUs: true, outcome: 'closed', resumeSeq: this.resumeSeq });
|
|
@@ -539,7 +598,7 @@ export class ChannelClient {
|
|
|
539
598
|
this.controller.close();
|
|
540
599
|
this.diag?.(DIAG.SOCKET_CLOSE, { code, closedByUs: false, outcome: 'revoked', resumeSeq: this.resumeSeq });
|
|
541
600
|
this.failAllPendingSends();
|
|
542
|
-
this.setState({ status: 'closed', lastErrorCode:
|
|
601
|
+
this.setState({ status: 'closed', lastErrorCode: TERMINAL_ERROR_CODE.REVOKED });
|
|
543
602
|
return;
|
|
544
603
|
}
|
|
545
604
|
this.reconnectCause = 'transient-close';
|
|
@@ -582,15 +641,21 @@ export class ChannelClient {
|
|
|
582
641
|
this.closedByUs = true;
|
|
583
642
|
this.controller.close();
|
|
584
643
|
this.failAllPendingSends();
|
|
585
|
-
this.setState({ status: 'closed', lastErrorCode:
|
|
644
|
+
this.setState({ status: 'closed', lastErrorCode: TERMINAL_ERROR_CODE.ACCESS_DENIED });
|
|
586
645
|
}
|
|
587
646
|
// ---- inbound frames ----
|
|
588
647
|
onMessage(data) {
|
|
589
648
|
const frame = parseFrame(data);
|
|
590
649
|
if (!frame || !frame.type) {
|
|
650
|
+
// Unparseable: bytes arrived, but nothing proves they came from a DO
|
|
651
|
+
// speaking this protocol, so this must NOT refresh the liveness deadline.
|
|
591
652
|
this.diag?.(DIAG.FRAME_DROPPED, { kind: 'unparseable', reason: 'bad-envelope' });
|
|
592
653
|
return;
|
|
593
654
|
}
|
|
655
|
+
// A well-formed envelope IS the liveness proof — including the `heartbeat-ack`
|
|
656
|
+
// the DO auto-responds with, and a forward-compat type this build does not
|
|
657
|
+
// handle yet (a newer DO's frames must never be read as a dead socket).
|
|
658
|
+
this.lastInboundAt = this.timers.now();
|
|
594
659
|
const payload = frame.payload ?? {};
|
|
595
660
|
switch (frame.type) {
|
|
596
661
|
case 'message':
|
|
@@ -988,7 +1053,6 @@ export class ChannelClient {
|
|
|
988
1053
|
const sent = this.sendFrame(CLIENT_FRAME.SEND, {
|
|
989
1054
|
clientMessageId: args.clientMessageId,
|
|
990
1055
|
text: args.text,
|
|
991
|
-
replyTo: args.replyTo ?? null,
|
|
992
1056
|
});
|
|
993
1057
|
if (!sent) {
|
|
994
1058
|
this.diag?.(DIAG.SEND_FAILED, {
|
|
@@ -1007,7 +1071,6 @@ export class ChannelClient {
|
|
|
1007
1071
|
this.pendingSends.set(args.clientMessageId, {
|
|
1008
1072
|
clientMessageId: args.clientMessageId,
|
|
1009
1073
|
text: args.text,
|
|
1010
|
-
replyTo: args.replyTo ?? null,
|
|
1011
1074
|
sentAt: now,
|
|
1012
1075
|
attempts: 1,
|
|
1013
1076
|
failed: false,
|
|
@@ -1020,14 +1083,11 @@ export class ChannelClient {
|
|
|
1020
1083
|
senderId: this.config.currentUserId,
|
|
1021
1084
|
text: args.text,
|
|
1022
1085
|
createdAt: now,
|
|
1023
|
-
replyTo: args.replyTo ? { messageId: seqToMessageId(args.replyTo.messageSeq), messagePreview: args.replyTo.preview } : null,
|
|
1024
1086
|
});
|
|
1025
1087
|
const next = [...this.state.messages, echo].sort(sortBySeq);
|
|
1026
1088
|
this.setState({ messages: next, lastErrorCode: null });
|
|
1027
1089
|
this.diag?.(DIAG.SEND_OPTIMISTIC, {
|
|
1028
1090
|
clientMessageId: args.clientMessageId,
|
|
1029
|
-
hasReplyTo: Boolean(args.replyTo),
|
|
1030
|
-
replyToSeq: args.replyTo?.messageSeq ?? null,
|
|
1031
1091
|
pendingSends: this.pendingSends.size,
|
|
1032
1092
|
});
|
|
1033
1093
|
return true;
|
|
@@ -1072,12 +1132,17 @@ export class ChannelClient {
|
|
|
1072
1132
|
const sent = this.sendFrame(CLIENT_FRAME.HISTORY, { beforeSeq, limit: HISTORY_PAGE_MAX });
|
|
1073
1133
|
this.diag?.(DIAG.HISTORY_REQUEST, { beforeSeq, limit: HISTORY_PAGE_MAX, sent });
|
|
1074
1134
|
}
|
|
1075
|
-
// ---- heartbeat (20s) ----
|
|
1135
|
+
// ---- heartbeat (20s) + dead-socket watchdog ----
|
|
1076
1136
|
startHeartbeat() {
|
|
1077
1137
|
this.stopHeartbeat();
|
|
1078
1138
|
const tick = () => {
|
|
1079
1139
|
if (!this.socket || !this.socket.isOpen)
|
|
1080
1140
|
return;
|
|
1141
|
+
// Liveness is checked BEFORE the write: a socket already proven dead must
|
|
1142
|
+
// not be handed another frame. Closing here does not reschedule — onClose
|
|
1143
|
+
// stops the heartbeat and owns what happens next.
|
|
1144
|
+
if (this.isInboundSilent())
|
|
1145
|
+
return this.closeDeadSocket();
|
|
1081
1146
|
this.sendFrame(CLIENT_FRAME.HEARTBEAT, {});
|
|
1082
1147
|
this.heartbeatTimer = this.timers.setTimeout(tick, HEARTBEAT_MS);
|
|
1083
1148
|
};
|
|
@@ -1089,12 +1154,44 @@ export class ChannelClient {
|
|
|
1089
1154
|
this.heartbeatTimer = null;
|
|
1090
1155
|
}
|
|
1091
1156
|
}
|
|
1157
|
+
/** True when the live socket has produced no parseable frame for too long. */
|
|
1158
|
+
isInboundSilent() {
|
|
1159
|
+
if (this.lastInboundAt == null)
|
|
1160
|
+
return false;
|
|
1161
|
+
return this.timers.now() - this.lastInboundAt >= INBOUND_SILENCE_LIMIT_MS;
|
|
1162
|
+
}
|
|
1163
|
+
/**
|
|
1164
|
+
* The channel DO auto-acks every heartbeat, so silence past
|
|
1165
|
+
* {@link INBOUND_SILENCE_LIMIT_MS} means this socket is dead even though no
|
|
1166
|
+
* `onclose` ever fired (the half-open-connection case that otherwise strands
|
|
1167
|
+
* the UI on a socket that will never deliver another message).
|
|
1168
|
+
*
|
|
1169
|
+
* Close it LOCALLY and deliberately WITHOUT setting `closedByUs`, so the close
|
|
1170
|
+
* lands on `onClose`'s ordinary transient path — which already owns the
|
|
1171
|
+
* reconnect scheduling, the backoff, and the resume-cursor recovery. Nothing
|
|
1172
|
+
* about reconnect policy is duplicated here.
|
|
1173
|
+
*/
|
|
1174
|
+
closeDeadSocket() {
|
|
1175
|
+
const socket = this.socket;
|
|
1176
|
+
this.diag?.(DIAG.SOCKET_LIVENESS_TIMEOUT, {
|
|
1177
|
+
silentMs: this.lastInboundAt == null ? null : this.timers.now() - this.lastInboundAt,
|
|
1178
|
+
thresholdMs: INBOUND_SILENCE_LIMIT_MS,
|
|
1179
|
+
attempt: this.connectAttempt,
|
|
1180
|
+
resumeSeq: this.resumeSeq,
|
|
1181
|
+
pendingSends: this.pendingSends.size,
|
|
1182
|
+
});
|
|
1183
|
+
this.lastInboundAt = null;
|
|
1184
|
+
socket?.close(WATCHDOG_CLOSE_CODE, 'client liveness timeout');
|
|
1185
|
+
}
|
|
1092
1186
|
// ---- teardown ----
|
|
1093
1187
|
/** Permanently close this client (auth-user switch / unmount). Idempotent. */
|
|
1094
1188
|
close() {
|
|
1095
1189
|
this.closedByUs = true;
|
|
1096
1190
|
this.controller.close();
|
|
1097
1191
|
this.stopHeartbeat();
|
|
1192
|
+
// The heartbeat timer IS the watchdog's timer — stopping it above ends both.
|
|
1193
|
+
// Clearing the record too means a later connect() starts from a clean deadline.
|
|
1194
|
+
this.lastInboundAt = null;
|
|
1098
1195
|
for (const t of this.typingTimers.values())
|
|
1099
1196
|
this.timers.clearTimeout(t);
|
|
1100
1197
|
this.typingTimers.clear();
|