@stapel/chat-react 0.1.0
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/CHANGELOG.md +1 -0
- package/MODULE.md +118 -0
- package/README.md +126 -0
- package/dist/api/chatApi.d.ts +49 -0
- package/dist/api/chatApi.d.ts.map +1 -0
- package/dist/api/chatApi.js +46 -0
- package/dist/api/chatApi.js.map +1 -0
- package/dist/api/extensions.d.ts +19 -0
- package/dist/api/extensions.d.ts.map +1 -0
- package/dist/api/extensions.js +19 -0
- package/dist/api/extensions.js.map +1 -0
- package/dist/api/generated/schema.d.ts +547 -0
- package/dist/api/generated/schema.d.ts.map +1 -0
- package/dist/api/generated/schema.js +2 -0
- package/dist/api/generated/schema.js.map +1 -0
- package/dist/api/types.d.ts +75 -0
- package/dist/api/types.d.ts.map +1 -0
- package/dist/api/types.js +2 -0
- package/dist/api/types.js.map +1 -0
- package/dist/default/ConversationListPanel.d.ts +22 -0
- package/dist/default/ConversationListPanel.d.ts.map +1 -0
- package/dist/default/ConversationListPanel.js +49 -0
- package/dist/default/ConversationListPanel.js.map +1 -0
- package/dist/default/ConversationThreadPanel.d.ts +21 -0
- package/dist/default/ConversationThreadPanel.d.ts.map +1 -0
- package/dist/default/ConversationThreadPanel.js +51 -0
- package/dist/default/ConversationThreadPanel.js.map +1 -0
- package/dist/default/ErrorAlert.d.ts +8 -0
- package/dist/default/ErrorAlert.d.ts.map +1 -0
- package/dist/default/ErrorAlert.js +26 -0
- package/dist/default/ErrorAlert.js.map +1 -0
- package/dist/default/StartChatButton.d.ts +17 -0
- package/dist/default/StartChatButton.d.ts.map +1 -0
- package/dist/default/StartChatButton.js +18 -0
- package/dist/default/StartChatButton.js.map +1 -0
- package/dist/default/index.d.ts +21 -0
- package/dist/default/index.d.ts.map +1 -0
- package/dist/default/index.js +18 -0
- package/dist/default/index.js.map +1 -0
- package/dist/flows/errors.d.ts +12 -0
- package/dist/flows/errors.d.ts.map +1 -0
- package/dist/flows/errors.js +13 -0
- package/dist/flows/errors.js.map +1 -0
- package/dist/flows/freshness.d.ts +69 -0
- package/dist/flows/freshness.d.ts.map +1 -0
- package/dist/flows/freshness.js +227 -0
- package/dist/flows/freshness.js.map +1 -0
- package/dist/flows/registry.d.ts +26 -0
- package/dist/flows/registry.d.ts.map +1 -0
- package/dist/flows/registry.js +24 -0
- package/dist/flows/registry.js.map +1 -0
- package/dist/headless/ChatProvider.d.ts +22 -0
- package/dist/headless/ChatProvider.d.ts.map +1 -0
- package/dist/headless/ChatProvider.js +18 -0
- package/dist/headless/ChatProvider.js.map +1 -0
- package/dist/headless/ConversationList.d.ts +51 -0
- package/dist/headless/ConversationList.d.ts.map +1 -0
- package/dist/headless/ConversationList.js +47 -0
- package/dist/headless/ConversationList.js.map +1 -0
- package/dist/headless/ConversationThread.d.ts +50 -0
- package/dist/headless/ConversationThread.d.ts.map +1 -0
- package/dist/headless/ConversationThread.js +64 -0
- package/dist/headless/ConversationThread.js.map +1 -0
- package/dist/headless/MessageComposer.d.ts +39 -0
- package/dist/headless/MessageComposer.d.ts.map +1 -0
- package/dist/headless/MessageComposer.js +45 -0
- package/dist/headless/MessageComposer.js.map +1 -0
- package/dist/headless/StartDirectChat.d.ts +40 -0
- package/dist/headless/StartDirectChat.d.ts.map +1 -0
- package/dist/headless/StartDirectChat.js +46 -0
- package/dist/headless/StartDirectChat.js.map +1 -0
- package/dist/i18n/errorsMap.d.ts +10 -0
- package/dist/i18n/errorsMap.d.ts.map +1 -0
- package/dist/i18n/errorsMap.js +20 -0
- package/dist/i18n/errorsMap.js.map +1 -0
- package/dist/i18n/es.d.ts +23 -0
- package/dist/i18n/es.d.ts.map +1 -0
- package/dist/i18n/es.js +75 -0
- package/dist/i18n/es.js.map +1 -0
- package/dist/i18n/generated/errors.es.gen.d.ts +16 -0
- package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.es.gen.js +58 -0
- package/dist/i18n/generated/errors.es.gen.js.map +1 -0
- package/dist/i18n/generated/errors.gen.d.ts +359 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.gen.js +183 -0
- package/dist/i18n/generated/errors.gen.js.map +1 -0
- package/dist/i18n/generated/errors.ru.gen.d.ts +16 -0
- package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.ru.gen.js +58 -0
- package/dist/i18n/generated/errors.ru.gen.js.map +1 -0
- package/dist/i18n/keys.d.ts +78 -0
- package/dist/i18n/keys.d.ts.map +1 -0
- package/dist/i18n/keys.js +130 -0
- package/dist/i18n/keys.js.map +1 -0
- package/dist/i18n/ru.d.ts +28 -0
- package/dist/i18n/ru.d.ts.map +1 -0
- package/dist/i18n/ru.js +80 -0
- package/dist/i18n/ru.js.map +1 -0
- package/dist/index.d.ts +61 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +55 -0
- package/dist/index.js.map +1 -0
- package/dist/model/context.d.ts +11 -0
- package/dist/model/context.d.ts.map +1 -0
- package/dist/model/context.js +16 -0
- package/dist/model/context.js.map +1 -0
- package/dist/model/limits.d.ts +12 -0
- package/dist/model/limits.d.ts.map +1 -0
- package/dist/model/limits.js +12 -0
- package/dist/model/limits.js.map +1 -0
- package/dist/model/mutations.d.ts +66 -0
- package/dist/model/mutations.d.ts.map +1 -0
- package/dist/model/mutations.js +156 -0
- package/dist/model/mutations.js.map +1 -0
- package/dist/model/queries.d.ts +33 -0
- package/dist/model/queries.d.ts.map +1 -0
- package/dist/model/queries.js +93 -0
- package/dist/model/queries.js.map +1 -0
- package/dist/model/queryKeys.d.ts +8 -0
- package/dist/model/queryKeys.d.ts.map +1 -0
- package/dist/model/queryKeys.js +26 -0
- package/dist/model/queryKeys.js.map +1 -0
- package/dist/model/readMarker.d.ts +25 -0
- package/dist/model/readMarker.d.ts.map +1 -0
- package/dist/model/readMarker.js +31 -0
- package/dist/model/readMarker.js.map +1 -0
- package/dist/model/runtime.d.ts +50 -0
- package/dist/model/runtime.d.ts.map +1 -0
- package/dist/model/runtime.js +27 -0
- package/dist/model/runtime.js.map +1 -0
- package/dist/model/threadWindow.d.ts +95 -0
- package/dist/model/threadWindow.d.ts.map +1 -0
- package/dist/model/threadWindow.js +100 -0
- package/dist/model/threadWindow.js.map +1 -0
- package/dist/nav/manifest.d.ts +23 -0
- package/dist/nav/manifest.d.ts.map +1 -0
- package/dist/nav/manifest.js +15 -0
- package/dist/nav/manifest.js.map +1 -0
- package/dist/realtime/chatSocket.d.ts +93 -0
- package/dist/realtime/chatSocket.d.ts.map +1 -0
- package/dist/realtime/chatSocket.js +199 -0
- package/dist/realtime/chatSocket.js.map +1 -0
- package/dist/realtime/frames.d.ts +123 -0
- package/dist/realtime/frames.d.ts.map +1 -0
- package/dist/realtime/frames.js +123 -0
- package/dist/realtime/frames.js.map +1 -0
- package/dist/realtime/streams.d.ts +49 -0
- package/dist/realtime/streams.d.ts.map +1 -0
- package/dist/realtime/streams.js +52 -0
- package/dist/realtime/streams.js.map +1 -0
- package/llms.txt +89 -0
- package/manifest.json +733 -0
- package/nav-manifest.json +25 -0
- package/package.json +114 -0
- package/src/analytics/generated/events.json +7 -0
- package/src/api/chatApi.ts +142 -0
- package/src/api/extensions.ts +18 -0
- package/src/api/generated/schema.ts +549 -0
- package/src/api/types.ts +87 -0
- package/src/default/ConversationListPanel.tsx +163 -0
- package/src/default/ConversationThreadPanel.tsx +245 -0
- package/src/default/ErrorAlert.tsx +43 -0
- package/src/default/StartChatButton.tsx +78 -0
- package/src/default/index.ts +20 -0
- package/src/flows/errors.ts +16 -0
- package/src/flows/freshness.ts +315 -0
- package/src/flows/registry.ts +36 -0
- package/src/headless/ChatProvider.tsx +23 -0
- package/src/headless/ConversationList.tsx +92 -0
- package/src/headless/ConversationThread.tsx +107 -0
- package/src/headless/MessageComposer.tsx +86 -0
- package/src/headless/StartDirectChat.tsx +75 -0
- package/src/i18n/errorsMap.ts +31 -0
- package/src/i18n/es.ts +95 -0
- package/src/i18n/generated/errors.es.gen.ts +64 -0
- package/src/i18n/generated/errors.gen.ts +214 -0
- package/src/i18n/generated/errors.json +408 -0
- package/src/i18n/generated/errors.ru.gen.ts +64 -0
- package/src/i18n/keys.ts +152 -0
- package/src/i18n/ru.ts +98 -0
- package/src/index.ts +189 -0
- package/src/model/context.tsx +25 -0
- package/src/model/limits.ts +11 -0
- package/src/model/mutations.ts +208 -0
- package/src/model/queries.ts +120 -0
- package/src/model/queryKeys.ts +32 -0
- package/src/model/readMarker.ts +32 -0
- package/src/model/runtime.ts +90 -0
- package/src/model/threadWindow.ts +173 -0
- package/src/nav/manifest.ts +36 -0
- package/src/realtime/chatSocket.ts +304 -0
- package/src/realtime/frames.ts +229 -0
- package/src/realtime/streams.ts +85 -0
- package/tsconfig.json +26 -0
package/src/i18n/ru.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { I18nDictionary, I18nEngine } from "@stapel/core";
|
|
2
|
+
import { chatI18nBundleEn } from "./keys.js";
|
|
3
|
+
import { chatErrorBundleRu } from "./generated/errors.ru.gen.js";
|
|
4
|
+
|
|
5
|
+
export { chatErrorBundleRu } from "./generated/errors.ru.gen.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Russian bundle for chat-react — the pair's `ru` locale, shipped as the
|
|
9
|
+
* `@stapel/chat-react/i18n/ru` subpath (i18n-shipping.md §2) so the locale is
|
|
10
|
+
* opt-in: hosts that don't register it never carry these strings (the main
|
|
11
|
+
* entry does not import this module — gated by size-limit + the bundle-purity
|
|
12
|
+
* test).
|
|
13
|
+
*
|
|
14
|
+
* TWO SOURCES, ON PURPOSE. The generated `chatErrorBundleRu` covers the 42
|
|
15
|
+
* cross-cutting keys stapel-core owns and localizes. The 12 keys stapel-chat
|
|
16
|
+
* owns are NOT in it, and cannot be: the module ships no `translations/`
|
|
17
|
+
* directory at all, so the generator emits a `Partial` bundle and says so in
|
|
18
|
+
* its own header (`ERRORS_LOCALE_EXEMPT_OWNERS`, the stapel-forms precedent).
|
|
19
|
+
* They are authored below, beside the UI copy. When upstream ships
|
|
20
|
+
* `translations/errors.ru.json`, these twelve lines are deleted and the
|
|
21
|
+
* generated bundle covers them — the keys and the texts do not move.
|
|
22
|
+
*/
|
|
23
|
+
export const chatI18nBundleRu: I18nDictionary = {
|
|
24
|
+
// Backend error codes — generated ru texts for every key core owns.
|
|
25
|
+
...chatErrorBundleRu,
|
|
26
|
+
|
|
27
|
+
// Backend error codes stapel-chat owns — authored here (see the note above).
|
|
28
|
+
"error.400.chat_attachments_disabled": "Вложения отключены в этой установке",
|
|
29
|
+
"error.400.chat_body_too_long": "Сообщение длиннее допустимого предела",
|
|
30
|
+
"error.400.chat_empty_message":
|
|
31
|
+
"В сообщении должен быть текст или хотя бы одно вложение",
|
|
32
|
+
"error.400.chat_invalid_direct":
|
|
33
|
+
"В личной переписке должен быть ровно один собеседник",
|
|
34
|
+
"error.400.chat_invalid_kind": "Неизвестный тип диалога",
|
|
35
|
+
"error.400.chat_invalid_reply":
|
|
36
|
+
"Сообщение, на которое вы отвечаете, не из этого диалога",
|
|
37
|
+
"error.400.chat_kind_disabled": "Этот тип диалога отключён в этой установке",
|
|
38
|
+
"error.400.chat_not_support":
|
|
39
|
+
"Это действие применимо только к обращениям в поддержку",
|
|
40
|
+
"error.403.chat_not_operator":
|
|
41
|
+
"Это действие доступно только оператору поддержки",
|
|
42
|
+
"error.403.chat_not_participant": "Вы не участник этого диалога",
|
|
43
|
+
"error.404.chat_conversation_not_found": "Диалог не найден",
|
|
44
|
+
"error.409.chat_already_assigned": "Это обращение уже взято в работу",
|
|
45
|
+
|
|
46
|
+
// chat-react UI (hand-written ru mirror of the en copy in keys.ts)
|
|
47
|
+
"chat.error.unknown": "Что-то пошло не так. Попробуйте ещё раз.",
|
|
48
|
+
|
|
49
|
+
"chat.list.title": "Сообщения",
|
|
50
|
+
"chat.list.empty": "Диалогов пока нет.",
|
|
51
|
+
"chat.list.loading": "Загрузка диалогов…",
|
|
52
|
+
"chat.list.load_more": "Показать ещё",
|
|
53
|
+
"chat.list.end": "Это все диалоги.",
|
|
54
|
+
"chat.list.retry": "Повторить",
|
|
55
|
+
"chat.list.unread": "Непрочитанных: {count}",
|
|
56
|
+
"chat.list.open": "Открыть",
|
|
57
|
+
|
|
58
|
+
"chat.kind.direct": "Личная переписка",
|
|
59
|
+
"chat.kind.group": "Групповой диалог",
|
|
60
|
+
"chat.kind.support": "Поддержка",
|
|
61
|
+
|
|
62
|
+
"chat.thread.loading": "Загрузка сообщений…",
|
|
63
|
+
"chat.thread.empty": "Сообщений пока нет. Напишите первым.",
|
|
64
|
+
"chat.thread.retry": "Повторить",
|
|
65
|
+
"chat.thread.load_older": "Показать более ранние",
|
|
66
|
+
"chat.thread.beginning": "Это начало переписки.",
|
|
67
|
+
"chat.thread.system": "Системное сообщение",
|
|
68
|
+
|
|
69
|
+
"chat.composer.placeholder": "Напишите сообщение…",
|
|
70
|
+
"chat.composer.send": "Отправить",
|
|
71
|
+
"chat.composer.sending": "Отправка…",
|
|
72
|
+
"chat.composer.blocked.empty": "Сначала напишите текст.",
|
|
73
|
+
"chat.composer.blocked.too_long":
|
|
74
|
+
"Это длиннее {max} символов — немного сократите.",
|
|
75
|
+
|
|
76
|
+
"chat.start.button": "Написать продавцу",
|
|
77
|
+
"chat.start.starting": "Открываем…",
|
|
78
|
+
"chat.start.blocked.self": "Это ваше собственное объявление.",
|
|
79
|
+
"chat.start.blocked.unknown_seller": "У этого объявления не указан продавец.",
|
|
80
|
+
|
|
81
|
+
"chat.transport.live": "На связи",
|
|
82
|
+
"chat.transport.polling": "Обновляется каждые несколько секунд",
|
|
83
|
+
"chat.transport.idle": "Приостановлено",
|
|
84
|
+
|
|
85
|
+
"chat.nav.conversations": "Сообщения",
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Register the pair's `ru` locale into a core i18n engine (call once at
|
|
90
|
+
* startup, after {@link registerChatI18n}). Layers per the merge-priority
|
|
91
|
+
* convention (i18n-shipping.md §3): the en floor is registered UNDER the ru
|
|
92
|
+
* texts inside the `ru` locale, so a key the ru bundle ever misses degrades to
|
|
93
|
+
* its English text — never to a raw key.
|
|
94
|
+
*/
|
|
95
|
+
export function registerChatI18nRu(engine: I18nEngine): void {
|
|
96
|
+
engine.registerBundle("ru", chatI18nBundleEn);
|
|
97
|
+
engine.registerBundle("ru", chatI18nBundleRu);
|
|
98
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@stapel/chat-react` — the headless React pair for stapel-chat
|
|
3
|
+
* (frontend-standard §2). Business + state only, zero visual opinion. Built on
|
|
4
|
+
* `@stapel/core`'s StapelClient (verification-403 interception, token refresh,
|
|
5
|
+
* i18n, analytics, query layer).
|
|
6
|
+
*
|
|
7
|
+
* WHAT IS DIFFERENT ABOUT THIS PAIR. Its backend has two ways to deliver the
|
|
8
|
+
* same journal: the REST history, and its own resumable WebSocket protocol
|
|
9
|
+
* (`ws/chat/<conversation_id>` — hello{last_seq} → welcome → replay →
|
|
10
|
+
* replay_done → live). Both are wired here, behind ONE seam
|
|
11
|
+
* (`useChatFreshness`), and no component above that seam can tell which is
|
|
12
|
+
* running. Writes go over REST in either case. See `flows/freshness.ts` for
|
|
13
|
+
* the seam and the criterion under which `@stapel/realtime` replaces its
|
|
14
|
+
* insides.
|
|
15
|
+
*
|
|
16
|
+
* Layers: api → model → realtime → flows → headless → i18n. Generated
|
|
17
|
+
* surfaces (error map, manifest, llms.txt) are produced by the monorepo
|
|
18
|
+
* `gen:*` drivers and stand under drift gates.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
// ── api ──────────────────────────────────────────────────────────────────────
|
|
22
|
+
export { createChatApi } from "./api/chatApi.js";
|
|
23
|
+
export type { ChatApi } from "./api/chatApi.js";
|
|
24
|
+
export type {
|
|
25
|
+
Schemas,
|
|
26
|
+
AnchorDirection,
|
|
27
|
+
ChatMessage,
|
|
28
|
+
Conversation,
|
|
29
|
+
ConversationKind,
|
|
30
|
+
ConversationListParams,
|
|
31
|
+
ConversationPage,
|
|
32
|
+
CreateConversationRequest,
|
|
33
|
+
MarkReadRequest,
|
|
34
|
+
MessageHistoryParams,
|
|
35
|
+
MessageKind,
|
|
36
|
+
MessagePage,
|
|
37
|
+
Participant,
|
|
38
|
+
ParticipantRole,
|
|
39
|
+
SendMessageRequest,
|
|
40
|
+
SupportStatus,
|
|
41
|
+
} from "./api/types.js";
|
|
42
|
+
|
|
43
|
+
// ── realtime (the module's own socket protocol, typed) ───────────────────────
|
|
44
|
+
// Exported because a host may want to render the connection state or inject a
|
|
45
|
+
// transport; NOTHING above `flows/freshness.ts` inside this package uses it,
|
|
46
|
+
// which is what keeps the substrate migration to one file.
|
|
47
|
+
export {
|
|
48
|
+
CHAT_WS_CLOSE_NOT_PARTICIPANT,
|
|
49
|
+
CHAT_WS_CLOSE_UNAUTHENTICATED,
|
|
50
|
+
CHAT_WS_REPLAY_LIMIT,
|
|
51
|
+
CHAT_WS_RESYNC,
|
|
52
|
+
decodeServerFrame,
|
|
53
|
+
parseServerFrame,
|
|
54
|
+
} from "./realtime/frames.js";
|
|
55
|
+
export type {
|
|
56
|
+
ChatAckFrame,
|
|
57
|
+
ChatClientFrame,
|
|
58
|
+
ChatErrorFrame,
|
|
59
|
+
ChatHelloFrame,
|
|
60
|
+
ChatMessageFrame,
|
|
61
|
+
ChatPingFrame,
|
|
62
|
+
ChatPongFrame,
|
|
63
|
+
ChatReplayDoneFrame,
|
|
64
|
+
ChatSendFrame,
|
|
65
|
+
ChatServerFrame,
|
|
66
|
+
ChatWelcomeFrame,
|
|
67
|
+
} from "./realtime/frames.js";
|
|
68
|
+
export {
|
|
69
|
+
browserWebSocketFactory,
|
|
70
|
+
canOpenWebSocket,
|
|
71
|
+
createChatSocket,
|
|
72
|
+
} from "./realtime/chatSocket.js";
|
|
73
|
+
export type {
|
|
74
|
+
ChatConnectionState,
|
|
75
|
+
ChatReconnectOptions,
|
|
76
|
+
ChatSocket,
|
|
77
|
+
ChatSocketConnection,
|
|
78
|
+
ChatSocketHandlers,
|
|
79
|
+
ChatSocketOptions,
|
|
80
|
+
ChatSocketRefusal,
|
|
81
|
+
ChatSocketStatus,
|
|
82
|
+
ChatWebSocketFactory,
|
|
83
|
+
} from "./realtime/chatSocket.js";
|
|
84
|
+
export {
|
|
85
|
+
chatConversationStream,
|
|
86
|
+
chatInboxStream,
|
|
87
|
+
chatSocketUrl,
|
|
88
|
+
chatStreamId,
|
|
89
|
+
deriveChatSocketBase,
|
|
90
|
+
} from "./realtime/streams.js";
|
|
91
|
+
export type {
|
|
92
|
+
ChatConversationStream,
|
|
93
|
+
ChatInboxStream,
|
|
94
|
+
ChatStreamKey,
|
|
95
|
+
} from "./realtime/streams.js";
|
|
96
|
+
|
|
97
|
+
// ── flows (the transport seam + the error fold) ──────────────────────────────
|
|
98
|
+
export { createFlowMachine, useFlow, isErrorCode } from "@stapel/core";
|
|
99
|
+
export type {
|
|
100
|
+
FlowMachine,
|
|
101
|
+
FlowMachineOptions,
|
|
102
|
+
FlowStateBase,
|
|
103
|
+
FlowError,
|
|
104
|
+
} from "@stapel/core";
|
|
105
|
+
export { toFlowError } from "./flows/errors.js";
|
|
106
|
+
export { CHAT_FLOWS, flowEndpoints } from "./flows/registry.js";
|
|
107
|
+
export type { ChatFlowId, ChatFlowSpec, FlowEndpoint } from "./flows/registry.js";
|
|
108
|
+
export {
|
|
109
|
+
CONVERSATION_LIST_INTERVAL_MS,
|
|
110
|
+
THREAD_INTERVAL_MS,
|
|
111
|
+
useChatFreshness,
|
|
112
|
+
} from "./flows/freshness.js";
|
|
113
|
+
export type {
|
|
114
|
+
ChatFreshness,
|
|
115
|
+
ChatFreshnessOptions,
|
|
116
|
+
ChatSignal,
|
|
117
|
+
ChatSignalKeyMap,
|
|
118
|
+
ChatTransport,
|
|
119
|
+
} from "./flows/freshness.js";
|
|
120
|
+
|
|
121
|
+
// ── model (runtime wiring, query keys, context, the thread store) ────────────
|
|
122
|
+
export { createChatRuntime } from "./model/runtime.js";
|
|
123
|
+
export type {
|
|
124
|
+
ChatRealtimeConfig,
|
|
125
|
+
ChatRealtimeOptions,
|
|
126
|
+
ChatRuntime,
|
|
127
|
+
CreateChatRuntimeOptions,
|
|
128
|
+
} from "./model/runtime.js";
|
|
129
|
+
export {
|
|
130
|
+
ChatRuntimeContext,
|
|
131
|
+
useChatRuntime,
|
|
132
|
+
useChatApi,
|
|
133
|
+
useChatAnalytics,
|
|
134
|
+
} from "./model/context.js";
|
|
135
|
+
export { chatQueryKeys } from "./model/queryKeys.js";
|
|
136
|
+
export { THREAD_PAGE, useConversation, useConversations, useThread } from "./model/queries.js";
|
|
137
|
+
export {
|
|
138
|
+
useLoadOlderMessages,
|
|
139
|
+
useMarkRead,
|
|
140
|
+
useSendMessage,
|
|
141
|
+
useStartDirectChat,
|
|
142
|
+
} from "./model/mutations.js";
|
|
143
|
+
export type {
|
|
144
|
+
SendMessageVariables,
|
|
145
|
+
StartDirectChatVariables,
|
|
146
|
+
} from "./model/mutations.js";
|
|
147
|
+
export { CHAT_DEFAULT_MAX_BODY_LENGTH } from "./model/limits.js";
|
|
148
|
+
export { nextReadMarker } from "./model/readMarker.js";
|
|
149
|
+
export {
|
|
150
|
+
EMPTY_THREAD_WINDOW,
|
|
151
|
+
mergeMessage,
|
|
152
|
+
mergeNewerPage,
|
|
153
|
+
mergeOlderPage,
|
|
154
|
+
threadFirstSeq,
|
|
155
|
+
threadLastSeq,
|
|
156
|
+
threadWindowFromPage,
|
|
157
|
+
} from "./model/threadWindow.js";
|
|
158
|
+
export type { ChatThreadWindow, ThreadMergeResult } from "./model/threadWindow.js";
|
|
159
|
+
|
|
160
|
+
// ── headless (renderless components) ─────────────────────────────────────────
|
|
161
|
+
export { ChatProvider } from "./headless/ChatProvider.js";
|
|
162
|
+
export { ConversationList } from "./headless/ConversationList.js";
|
|
163
|
+
export type { ConversationListBag } from "./headless/ConversationList.js";
|
|
164
|
+
export { ConversationThread } from "./headless/ConversationThread.js";
|
|
165
|
+
export type { ConversationThreadBag } from "./headless/ConversationThread.js";
|
|
166
|
+
export { MessageComposer } from "./headless/MessageComposer.js";
|
|
167
|
+
export type { MessageComposerBag } from "./headless/MessageComposer.js";
|
|
168
|
+
export { StartDirectChat } from "./headless/StartDirectChat.js";
|
|
169
|
+
export type { StartDirectChatBag } from "./headless/StartDirectChat.js";
|
|
170
|
+
|
|
171
|
+
// ── nav ──────────────────────────────────────────────────────────────────────
|
|
172
|
+
export { navEntries } from "./nav/manifest.js";
|
|
173
|
+
|
|
174
|
+
// ── i18n ─────────────────────────────────────────────────────────────────────
|
|
175
|
+
export { CHAT_I18N_KEYS, chatI18nBundleEn, registerChatI18n } from "./i18n/keys.js";
|
|
176
|
+
export type { ChatI18nKey } from "./i18n/keys.js";
|
|
177
|
+
|
|
178
|
+
// ── errors map (code → status/params/remediation/en; generated) ──────────────
|
|
179
|
+
export {
|
|
180
|
+
CHAT_ERRORS,
|
|
181
|
+
CHAT_ERROR_CODES,
|
|
182
|
+
chatErrorBundleEn,
|
|
183
|
+
explainChatError,
|
|
184
|
+
} from "./i18n/errorsMap.js";
|
|
185
|
+
export type {
|
|
186
|
+
ChatErrorCode,
|
|
187
|
+
ChatErrorSpec,
|
|
188
|
+
Remediation,
|
|
189
|
+
} from "./i18n/errorsMap.js";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Context } from "react";
|
|
2
|
+
import { createModuleContext } from "@stapel/core";
|
|
3
|
+
import type { Analytics, ModuleContextKit } from "@stapel/core";
|
|
4
|
+
import type { ChatApi } from "../api/chatApi.js";
|
|
5
|
+
import type { ChatRuntime } from "./runtime.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The wired ChatRuntime shared through React context by `<ChatProvider>`.
|
|
9
|
+
* Hooks in `model/`, `flows/` and `headless/` read the singletons from here.
|
|
10
|
+
* One reviewed copy of this plumbing lives in `@stapel/core`
|
|
11
|
+
* (`createModuleContext`, slim wave §21/S2); this module binds it under the
|
|
12
|
+
* pair's public names.
|
|
13
|
+
*/
|
|
14
|
+
const kit: ModuleContextKit<ChatRuntime> = createModuleContext<ChatRuntime>("Chat");
|
|
15
|
+
|
|
16
|
+
export const ChatRuntimeContext: Context<ChatRuntime | null> = kit.RuntimeContext;
|
|
17
|
+
|
|
18
|
+
export const useChatRuntime: () => ChatRuntime = kit.useRuntime;
|
|
19
|
+
|
|
20
|
+
export const useChatApi: () => ChatApi = kit.useApi;
|
|
21
|
+
|
|
22
|
+
export const useChatAnalytics: () => Analytics | null = kit.useAnalytics;
|
|
23
|
+
|
|
24
|
+
/** @internal Re-exported as `<ChatProvider>` from `headless/`. */
|
|
25
|
+
export const ModuleProvider: ModuleContextKit<ChatRuntime>["Provider"] = kit.Provider;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mirrors of the backend's CONFIGURABLE limits — axes, not constants.
|
|
3
|
+
*
|
|
4
|
+
* `STAPEL_CHAT.MAX_BODY_LENGTH` (`stapel_chat/conf.py`, surfaced in
|
|
5
|
+
* `docs/capabilities.json`) defaults to 4000 characters and a deployment may
|
|
6
|
+
* narrow it. The server stays authoritative and answers
|
|
7
|
+
* `error.400.chat_body_too_long`; the mirror exists so a person is told before
|
|
8
|
+
* they lose what they typed, not after. A host that changed the axis passes
|
|
9
|
+
* its own value to `<MessageComposer maxLength=…>`.
|
|
10
|
+
*/
|
|
11
|
+
export const CHAT_DEFAULT_MAX_BODY_LENGTH = 4000;
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
2
|
+
import type {
|
|
3
|
+
QueryClient,
|
|
4
|
+
UseMutationOptions,
|
|
5
|
+
UseMutationResult,
|
|
6
|
+
} from "@tanstack/react-query";
|
|
7
|
+
import type { StapelApiError } from "@stapel/core";
|
|
8
|
+
import type { ChatMessage, Conversation } from "../api/types.js";
|
|
9
|
+
import { useChatApi } from "./context.js";
|
|
10
|
+
import { chatQueryKeys } from "./queryKeys.js";
|
|
11
|
+
import { nextReadMarker } from "./readMarker.js";
|
|
12
|
+
import { THREAD_PAGE } from "./queries.js";
|
|
13
|
+
import { mergeMessage, mergeOlderPage } from "./threadWindow.js";
|
|
14
|
+
import type { ChatThreadWindow } from "./threadWindow.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Write hooks (frontend-standard §2 — mutations invalidate on success).
|
|
18
|
+
*
|
|
19
|
+
* Sending is REST, always: the socket's `send` frame refuses with a
|
|
20
|
+
* socket-local code that has no i18n key and no remediation, while the HTTP
|
|
21
|
+
* path answers with the persisted row and a real error envelope. See
|
|
22
|
+
* `realtime/frames.ts`.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** Fold a freshly-known message into the cached window, or re-read on a hole. */
|
|
26
|
+
function absorbMessage(
|
|
27
|
+
queryClient: QueryClient,
|
|
28
|
+
conversationId: string,
|
|
29
|
+
message: ChatMessage
|
|
30
|
+
): void {
|
|
31
|
+
const key = chatQueryKeys.thread(conversationId);
|
|
32
|
+
const window = queryClient.getQueryData<ChatThreadWindow>(key);
|
|
33
|
+
if (!window) return;
|
|
34
|
+
const merged = mergeMessage(window, message);
|
|
35
|
+
if (merged.gap) {
|
|
36
|
+
void queryClient.invalidateQueries({ queryKey: key });
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
queryClient.setQueryData(key, merged.window);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Variables for {@link useSendMessage}. */
|
|
43
|
+
export interface SendMessageVariables {
|
|
44
|
+
readonly body: string;
|
|
45
|
+
/** Quoted message id; must belong to this conversation. */
|
|
46
|
+
readonly replyTo?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Append a message to a thread. On success the persisted row (with its `seq`)
|
|
51
|
+
* is folded straight into the window, so the sender sees their own line
|
|
52
|
+
* without waiting for a poll — and the socket's fan-out copy of the same row
|
|
53
|
+
* is then a duplicate that `mergeMessage` drops.
|
|
54
|
+
*
|
|
55
|
+
* Attachments are NOT wired in this version (spec §4.5): the field exists on
|
|
56
|
+
* the wire, but shipping it would mean shipping CDN upload rights into chat,
|
|
57
|
+
* and a control that is visible but does nothing is worse than one that is
|
|
58
|
+
* absent.
|
|
59
|
+
*/
|
|
60
|
+
export function useSendMessage(
|
|
61
|
+
conversationId: string
|
|
62
|
+
): UseMutationResult<ChatMessage, StapelApiError, SendMessageVariables> {
|
|
63
|
+
const api = useChatApi();
|
|
64
|
+
const queryClient = useQueryClient();
|
|
65
|
+
const options: UseMutationOptions<
|
|
66
|
+
ChatMessage,
|
|
67
|
+
StapelApiError,
|
|
68
|
+
SendMessageVariables
|
|
69
|
+
> = {
|
|
70
|
+
mutationFn: (vars) =>
|
|
71
|
+
api.sendMessage(conversationId, {
|
|
72
|
+
body: vars.body,
|
|
73
|
+
...(vars.replyTo !== undefined ? { reply_to: vars.replyTo } : {}),
|
|
74
|
+
}),
|
|
75
|
+
onSuccess: (message) => {
|
|
76
|
+
absorbMessage(queryClient, conversationId, message);
|
|
77
|
+
// The list orders by `updated_at` and carries the unread badge; both
|
|
78
|
+
// just moved.
|
|
79
|
+
void queryClient.invalidateQueries({
|
|
80
|
+
queryKey: chatQueryKeys.conversations(),
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
return useMutation(options);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Advance the caller's read marker to `upto_seq`.
|
|
89
|
+
*
|
|
90
|
+
* Monotonic on the client as well as on the server: a candidate at or below
|
|
91
|
+
* what this client already reported resolves without a request
|
|
92
|
+
* (`nextReadMarker`). The reported value is remembered in the query cache —
|
|
93
|
+
* see `readMarker.ts` for why it cannot be read back off the wire.
|
|
94
|
+
*/
|
|
95
|
+
export function useMarkRead(
|
|
96
|
+
conversationId: string
|
|
97
|
+
): UseMutationResult<number | null, StapelApiError, number> {
|
|
98
|
+
const api = useChatApi();
|
|
99
|
+
const queryClient = useQueryClient();
|
|
100
|
+
const markerKey = chatQueryKeys.readMarker(conversationId);
|
|
101
|
+
const options: UseMutationOptions<number | null, StapelApiError, number> = {
|
|
102
|
+
mutationFn: async (candidate) => {
|
|
103
|
+
const known = queryClient.getQueryData<number>(markerKey);
|
|
104
|
+
const seq = nextReadMarker(known, candidate);
|
|
105
|
+
if (seq === null) return null;
|
|
106
|
+
await api.markRead(conversationId, seq);
|
|
107
|
+
return seq;
|
|
108
|
+
},
|
|
109
|
+
onSuccess: (seq) => {
|
|
110
|
+
if (seq === null) return;
|
|
111
|
+
queryClient.setQueryData(markerKey, seq);
|
|
112
|
+
// The badge is server-computed; re-read it rather than guessing.
|
|
113
|
+
void queryClient.invalidateQueries({
|
|
114
|
+
queryKey: chatQueryKeys.conversations(),
|
|
115
|
+
});
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
return useMutation(options);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Variables for {@link useStartDirectChat}. */
|
|
122
|
+
export interface StartDirectChatVariables {
|
|
123
|
+
/** The other person — the seller, on a listing page. */
|
|
124
|
+
readonly userId: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Open the direct thread with someone, creating it only if it does not exist.
|
|
129
|
+
*
|
|
130
|
+
* The idempotency is the module's, not ours: a direct thread is keyed by an
|
|
131
|
+
* order-independent `direct_key` over the participant pair (namespaced by
|
|
132
|
+
* scope) under a unique constraint, and the create race is resolved by the
|
|
133
|
+
* constraint — the loser gets the winner's row (`MODULE.md`, "Direct
|
|
134
|
+
* idempotency"). So "message the seller" is safe to press twice, from two
|
|
135
|
+
* tabs, on two listings.
|
|
136
|
+
*
|
|
137
|
+
* TWO PEOPLE, ONE THREAD — INCLUDING ACROSS LISTINGS. The key is the pair,
|
|
138
|
+
* not the listing: a buyer who writes to the same seller about a second item
|
|
139
|
+
* lands in the same conversation. `CreateConversationRequest.scope_key` does
|
|
140
|
+
* not change that (the server ignores the field and resolves the scope
|
|
141
|
+
* itself — `api/extensions.ts`), so a host that wants the listing named must
|
|
142
|
+
* name it in the first message.
|
|
143
|
+
*/
|
|
144
|
+
export function useStartDirectChat(): UseMutationResult<
|
|
145
|
+
Conversation,
|
|
146
|
+
StapelApiError,
|
|
147
|
+
StartDirectChatVariables
|
|
148
|
+
> {
|
|
149
|
+
const api = useChatApi();
|
|
150
|
+
const queryClient = useQueryClient();
|
|
151
|
+
const options: UseMutationOptions<
|
|
152
|
+
Conversation,
|
|
153
|
+
StapelApiError,
|
|
154
|
+
StartDirectChatVariables
|
|
155
|
+
> = {
|
|
156
|
+
mutationFn: (vars) => api.createConversation("direct", [vars.userId]),
|
|
157
|
+
onSuccess: (conversation) => {
|
|
158
|
+
queryClient.setQueryData(
|
|
159
|
+
chatQueryKeys.conversation(conversation.id),
|
|
160
|
+
conversation
|
|
161
|
+
);
|
|
162
|
+
void queryClient.invalidateQueries({
|
|
163
|
+
queryKey: chatQueryKeys.conversations(),
|
|
164
|
+
});
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
return useMutation(options);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Backfill one page of older history onto the front of the window
|
|
172
|
+
* (`direction=next` from the window's oldest seq — "next" is OLDER here,
|
|
173
|
+
* because the history is ordered `-seq`).
|
|
174
|
+
*
|
|
175
|
+
* A mutation rather than a second query: it is an action a person takes
|
|
176
|
+
* ("show me more"), it must not re-run on focus, and its result belongs in
|
|
177
|
+
* the ONE thread cache entry rather than in a second one that would then have
|
|
178
|
+
* to be reconciled with it.
|
|
179
|
+
*/
|
|
180
|
+
export function useLoadOlderMessages(
|
|
181
|
+
conversationId: string,
|
|
182
|
+
limit: number = THREAD_PAGE
|
|
183
|
+
): UseMutationResult<ChatThreadWindow | null, StapelApiError, void> {
|
|
184
|
+
const api = useChatApi();
|
|
185
|
+
const queryClient = useQueryClient();
|
|
186
|
+
const key = chatQueryKeys.thread(conversationId);
|
|
187
|
+
const options: UseMutationOptions<
|
|
188
|
+
ChatThreadWindow | null,
|
|
189
|
+
StapelApiError,
|
|
190
|
+
void
|
|
191
|
+
> = {
|
|
192
|
+
mutationFn: async () => {
|
|
193
|
+
const window = queryClient.getQueryData<ChatThreadWindow>(key);
|
|
194
|
+
if (!window || !window.hasOlder || window.olderAnchor === null) return null;
|
|
195
|
+
const page = await api.messages(conversationId, {
|
|
196
|
+
direction: "next",
|
|
197
|
+
anchor: window.olderAnchor,
|
|
198
|
+
limit,
|
|
199
|
+
});
|
|
200
|
+
return mergeOlderPage(window, page);
|
|
201
|
+
},
|
|
202
|
+
onSuccess: (window) => {
|
|
203
|
+
if (window === null) return;
|
|
204
|
+
queryClient.setQueryData(key, window);
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
return useMutation(options);
|
|
208
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
2
|
+
import type {
|
|
3
|
+
InfiniteData,
|
|
4
|
+
UseInfiniteQueryResult,
|
|
5
|
+
UseQueryResult,
|
|
6
|
+
} from "@tanstack/react-query";
|
|
7
|
+
import { useActiveSessionReady } from "@stapel/core";
|
|
8
|
+
import type { StapelApiError } from "@stapel/core";
|
|
9
|
+
import type { Conversation, ConversationPage } from "../api/types.js";
|
|
10
|
+
import { useChatApi } from "./context.js";
|
|
11
|
+
import { chatQueryKeys } from "./queryKeys.js";
|
|
12
|
+
import {
|
|
13
|
+
mergeNewerPage,
|
|
14
|
+
threadLastSeq,
|
|
15
|
+
threadWindowFromPage,
|
|
16
|
+
} from "./threadWindow.js";
|
|
17
|
+
import type { ChatThreadWindow } from "./threadWindow.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Read hooks over the chat API. Staleness follows core's query defaults;
|
|
21
|
+
* freshness is driven by the transport seam (`flows/freshness.ts`), never by
|
|
22
|
+
* a `refetchInterval` sprinkled on a call site.
|
|
23
|
+
*
|
|
24
|
+
* Every hook is gated on {@link useActiveSessionReady}: chat is a member
|
|
25
|
+
* surface with no natural `enabled` condition of its own, and a top-level
|
|
26
|
+
* read that races a still-bootstrapping session reports "loaded, nothing
|
|
27
|
+
* here" for the length of the bootstrap.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** Default page size for the conversation list. */
|
|
31
|
+
const CONVERSATIONS_PAGE = 20;
|
|
32
|
+
/** Default window size for a thread — one screenful of history and change. */
|
|
33
|
+
export const THREAD_PAGE = 50;
|
|
34
|
+
|
|
35
|
+
/** One conversation (participant-only; 403 otherwise). */
|
|
36
|
+
export function useConversation(
|
|
37
|
+
conversationId: string
|
|
38
|
+
): UseQueryResult<Conversation, StapelApiError> {
|
|
39
|
+
const api = useChatApi();
|
|
40
|
+
const sessionReady = useActiveSessionReady();
|
|
41
|
+
return useQuery({
|
|
42
|
+
queryKey: chatQueryKeys.conversation(conversationId),
|
|
43
|
+
queryFn: () => api.conversation(conversationId),
|
|
44
|
+
enabled: sessionReady && conversationId.length > 0,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The caller's conversations as an infinite (load-more) list, anchored on
|
|
50
|
+
* `updated_at`. `unread_count` is computed server-side per conversation, so
|
|
51
|
+
* the badge needs no second request.
|
|
52
|
+
*/
|
|
53
|
+
export function useConversations(
|
|
54
|
+
limit: number = CONVERSATIONS_PAGE
|
|
55
|
+
): UseInfiniteQueryResult<
|
|
56
|
+
InfiniteData<ConversationPage, string | undefined>,
|
|
57
|
+
StapelApiError
|
|
58
|
+
> {
|
|
59
|
+
const api = useChatApi();
|
|
60
|
+
const sessionReady = useActiveSessionReady();
|
|
61
|
+
return useInfiniteQuery({
|
|
62
|
+
queryKey: chatQueryKeys.conversations(),
|
|
63
|
+
queryFn: ({ pageParam }) =>
|
|
64
|
+
api.conversations({
|
|
65
|
+
direction: "next",
|
|
66
|
+
limit,
|
|
67
|
+
...(pageParam !== undefined ? { anchor: pageParam } : {}),
|
|
68
|
+
}),
|
|
69
|
+
initialPageParam: undefined as string | undefined,
|
|
70
|
+
getNextPageParam: (last) =>
|
|
71
|
+
last.has_next ? (last.next_anchor ?? undefined) : undefined,
|
|
72
|
+
enabled: sessionReady,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The thread — one cache entry holding a contiguous, seq-ordered window
|
|
78
|
+
* (`threadWindow.ts`).
|
|
79
|
+
*
|
|
80
|
+
* ITS REFETCH IS THE POLL. The query function reads the window already in the
|
|
81
|
+
* cache and asks only for what comes after it
|
|
82
|
+
* (`direction=prev&anchor=<tip>`), so `refetch()` means "advance by seq", not
|
|
83
|
+
* "read the newest page again". That is what lets the transport seam stay a
|
|
84
|
+
* signal → refetch bridge: a socket frame and a polling tick take the exact
|
|
85
|
+
* same code path, and the UI cannot tell which arrived.
|
|
86
|
+
*
|
|
87
|
+
* A tail that does not touch the tip (more messages than one page holds, or
|
|
88
|
+
* the paginator's own truncation flag) is a hole. Rather than render a thread
|
|
89
|
+
* with a hole, the window is rebuilt from the newest page — the REST twin of
|
|
90
|
+
* the socket's `error{resync}`, and the reason correctness never depends on
|
|
91
|
+
* delivery.
|
|
92
|
+
*/
|
|
93
|
+
export function useThread(
|
|
94
|
+
conversationId: string,
|
|
95
|
+
limit: number = THREAD_PAGE
|
|
96
|
+
): UseQueryResult<ChatThreadWindow, StapelApiError> {
|
|
97
|
+
const api = useChatApi();
|
|
98
|
+
const queryClient = useQueryClient();
|
|
99
|
+
const sessionReady = useActiveSessionReady();
|
|
100
|
+
const queryKey = chatQueryKeys.thread(conversationId);
|
|
101
|
+
return useQuery({
|
|
102
|
+
queryKey,
|
|
103
|
+
queryFn: async () => {
|
|
104
|
+
const previous = queryClient.getQueryData<ChatThreadWindow>(queryKey);
|
|
105
|
+
const tip = previous ? threadLastSeq(previous) : 0;
|
|
106
|
+
if (!previous || tip === 0) {
|
|
107
|
+
return threadWindowFromPage(await api.messages(conversationId, { limit }));
|
|
108
|
+
}
|
|
109
|
+
const tail = await api.messages(conversationId, {
|
|
110
|
+
direction: "prev",
|
|
111
|
+
anchor: String(tip),
|
|
112
|
+
limit,
|
|
113
|
+
});
|
|
114
|
+
const merged = mergeNewerPage(previous, tail);
|
|
115
|
+
if (!merged.gap) return merged.window;
|
|
116
|
+
return threadWindowFromPage(await api.messages(conversationId, { limit }));
|
|
117
|
+
},
|
|
118
|
+
enabled: sessionReady && conversationId.length > 0,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Namespaced TanStack Query keys (frontend-standard §2 — namespaced keys).
|
|
3
|
+
* Everything under the `"chat"` root so a host can invalidate the whole module
|
|
4
|
+
* or match a single thread. Persist scope is per-user via core's query runtime
|
|
5
|
+
* (`setPersistUser`). Explicit tuple return types satisfy
|
|
6
|
+
* `--isolatedDeclarations`.
|
|
7
|
+
*/
|
|
8
|
+
const ROOT = "chat" as const;
|
|
9
|
+
|
|
10
|
+
export const chatQueryKeys: {
|
|
11
|
+
readonly all: readonly ["chat"];
|
|
12
|
+
conversations(): readonly ["chat", "conversations"];
|
|
13
|
+
conversation(conversationId: string): readonly ["chat", "conversation", string];
|
|
14
|
+
thread(conversationId: string): readonly ["chat", "thread", string];
|
|
15
|
+
readMarker(conversationId: string): readonly ["chat", "read-marker", string];
|
|
16
|
+
} = {
|
|
17
|
+
all: [ROOT],
|
|
18
|
+
// The infinite list shares one root key across pages (its pages live under
|
|
19
|
+
// a single cache entry).
|
|
20
|
+
conversations: () => [ROOT, "conversations"],
|
|
21
|
+
conversation: (conversationId) => [ROOT, "conversation", conversationId],
|
|
22
|
+
// The thread is ONE cache entry holding a merged, seq-ordered window — not
|
|
23
|
+
// a page list. Its query function reads this entry to decide what to ask
|
|
24
|
+
// for next (a tail by seq, not a blind re-read), which is what makes a
|
|
25
|
+
// plain `refetch()` mean "poll by seq" and lets the transport seam stay a
|
|
26
|
+
// signal → refetch bridge.
|
|
27
|
+
thread: (conversationId) => [ROOT, "thread", conversationId],
|
|
28
|
+
// Not a server read: the highest `upto_seq` this client has already
|
|
29
|
+
// reported. Lives in the query cache so it survives a remount and is wiped
|
|
30
|
+
// at logout with everything else (frontend-core-architecture-v2 §43.3).
|
|
31
|
+
readMarker: (conversationId) => [ROOT, "read-marker", conversationId],
|
|
32
|
+
};
|