@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.
Files changed (195) hide show
  1. package/CHANGELOG.md +1 -0
  2. package/MODULE.md +118 -0
  3. package/README.md +126 -0
  4. package/dist/api/chatApi.d.ts +49 -0
  5. package/dist/api/chatApi.d.ts.map +1 -0
  6. package/dist/api/chatApi.js +46 -0
  7. package/dist/api/chatApi.js.map +1 -0
  8. package/dist/api/extensions.d.ts +19 -0
  9. package/dist/api/extensions.d.ts.map +1 -0
  10. package/dist/api/extensions.js +19 -0
  11. package/dist/api/extensions.js.map +1 -0
  12. package/dist/api/generated/schema.d.ts +547 -0
  13. package/dist/api/generated/schema.d.ts.map +1 -0
  14. package/dist/api/generated/schema.js +2 -0
  15. package/dist/api/generated/schema.js.map +1 -0
  16. package/dist/api/types.d.ts +75 -0
  17. package/dist/api/types.d.ts.map +1 -0
  18. package/dist/api/types.js +2 -0
  19. package/dist/api/types.js.map +1 -0
  20. package/dist/default/ConversationListPanel.d.ts +22 -0
  21. package/dist/default/ConversationListPanel.d.ts.map +1 -0
  22. package/dist/default/ConversationListPanel.js +49 -0
  23. package/dist/default/ConversationListPanel.js.map +1 -0
  24. package/dist/default/ConversationThreadPanel.d.ts +21 -0
  25. package/dist/default/ConversationThreadPanel.d.ts.map +1 -0
  26. package/dist/default/ConversationThreadPanel.js +51 -0
  27. package/dist/default/ConversationThreadPanel.js.map +1 -0
  28. package/dist/default/ErrorAlert.d.ts +8 -0
  29. package/dist/default/ErrorAlert.d.ts.map +1 -0
  30. package/dist/default/ErrorAlert.js +26 -0
  31. package/dist/default/ErrorAlert.js.map +1 -0
  32. package/dist/default/StartChatButton.d.ts +17 -0
  33. package/dist/default/StartChatButton.d.ts.map +1 -0
  34. package/dist/default/StartChatButton.js +18 -0
  35. package/dist/default/StartChatButton.js.map +1 -0
  36. package/dist/default/index.d.ts +21 -0
  37. package/dist/default/index.d.ts.map +1 -0
  38. package/dist/default/index.js +18 -0
  39. package/dist/default/index.js.map +1 -0
  40. package/dist/flows/errors.d.ts +12 -0
  41. package/dist/flows/errors.d.ts.map +1 -0
  42. package/dist/flows/errors.js +13 -0
  43. package/dist/flows/errors.js.map +1 -0
  44. package/dist/flows/freshness.d.ts +69 -0
  45. package/dist/flows/freshness.d.ts.map +1 -0
  46. package/dist/flows/freshness.js +227 -0
  47. package/dist/flows/freshness.js.map +1 -0
  48. package/dist/flows/registry.d.ts +26 -0
  49. package/dist/flows/registry.d.ts.map +1 -0
  50. package/dist/flows/registry.js +24 -0
  51. package/dist/flows/registry.js.map +1 -0
  52. package/dist/headless/ChatProvider.d.ts +22 -0
  53. package/dist/headless/ChatProvider.d.ts.map +1 -0
  54. package/dist/headless/ChatProvider.js +18 -0
  55. package/dist/headless/ChatProvider.js.map +1 -0
  56. package/dist/headless/ConversationList.d.ts +51 -0
  57. package/dist/headless/ConversationList.d.ts.map +1 -0
  58. package/dist/headless/ConversationList.js +47 -0
  59. package/dist/headless/ConversationList.js.map +1 -0
  60. package/dist/headless/ConversationThread.d.ts +50 -0
  61. package/dist/headless/ConversationThread.d.ts.map +1 -0
  62. package/dist/headless/ConversationThread.js +64 -0
  63. package/dist/headless/ConversationThread.js.map +1 -0
  64. package/dist/headless/MessageComposer.d.ts +39 -0
  65. package/dist/headless/MessageComposer.d.ts.map +1 -0
  66. package/dist/headless/MessageComposer.js +45 -0
  67. package/dist/headless/MessageComposer.js.map +1 -0
  68. package/dist/headless/StartDirectChat.d.ts +40 -0
  69. package/dist/headless/StartDirectChat.d.ts.map +1 -0
  70. package/dist/headless/StartDirectChat.js +46 -0
  71. package/dist/headless/StartDirectChat.js.map +1 -0
  72. package/dist/i18n/errorsMap.d.ts +10 -0
  73. package/dist/i18n/errorsMap.d.ts.map +1 -0
  74. package/dist/i18n/errorsMap.js +20 -0
  75. package/dist/i18n/errorsMap.js.map +1 -0
  76. package/dist/i18n/es.d.ts +23 -0
  77. package/dist/i18n/es.d.ts.map +1 -0
  78. package/dist/i18n/es.js +75 -0
  79. package/dist/i18n/es.js.map +1 -0
  80. package/dist/i18n/generated/errors.es.gen.d.ts +16 -0
  81. package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -0
  82. package/dist/i18n/generated/errors.es.gen.js +58 -0
  83. package/dist/i18n/generated/errors.es.gen.js.map +1 -0
  84. package/dist/i18n/generated/errors.gen.d.ts +359 -0
  85. package/dist/i18n/generated/errors.gen.d.ts.map +1 -0
  86. package/dist/i18n/generated/errors.gen.js +183 -0
  87. package/dist/i18n/generated/errors.gen.js.map +1 -0
  88. package/dist/i18n/generated/errors.ru.gen.d.ts +16 -0
  89. package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -0
  90. package/dist/i18n/generated/errors.ru.gen.js +58 -0
  91. package/dist/i18n/generated/errors.ru.gen.js.map +1 -0
  92. package/dist/i18n/keys.d.ts +78 -0
  93. package/dist/i18n/keys.d.ts.map +1 -0
  94. package/dist/i18n/keys.js +130 -0
  95. package/dist/i18n/keys.js.map +1 -0
  96. package/dist/i18n/ru.d.ts +28 -0
  97. package/dist/i18n/ru.d.ts.map +1 -0
  98. package/dist/i18n/ru.js +80 -0
  99. package/dist/i18n/ru.js.map +1 -0
  100. package/dist/index.d.ts +61 -0
  101. package/dist/index.d.ts.map +1 -0
  102. package/dist/index.js +55 -0
  103. package/dist/index.js.map +1 -0
  104. package/dist/model/context.d.ts +11 -0
  105. package/dist/model/context.d.ts.map +1 -0
  106. package/dist/model/context.js +16 -0
  107. package/dist/model/context.js.map +1 -0
  108. package/dist/model/limits.d.ts +12 -0
  109. package/dist/model/limits.d.ts.map +1 -0
  110. package/dist/model/limits.js +12 -0
  111. package/dist/model/limits.js.map +1 -0
  112. package/dist/model/mutations.d.ts +66 -0
  113. package/dist/model/mutations.d.ts.map +1 -0
  114. package/dist/model/mutations.js +156 -0
  115. package/dist/model/mutations.js.map +1 -0
  116. package/dist/model/queries.d.ts +33 -0
  117. package/dist/model/queries.d.ts.map +1 -0
  118. package/dist/model/queries.js +93 -0
  119. package/dist/model/queries.js.map +1 -0
  120. package/dist/model/queryKeys.d.ts +8 -0
  121. package/dist/model/queryKeys.d.ts.map +1 -0
  122. package/dist/model/queryKeys.js +26 -0
  123. package/dist/model/queryKeys.js.map +1 -0
  124. package/dist/model/readMarker.d.ts +25 -0
  125. package/dist/model/readMarker.d.ts.map +1 -0
  126. package/dist/model/readMarker.js +31 -0
  127. package/dist/model/readMarker.js.map +1 -0
  128. package/dist/model/runtime.d.ts +50 -0
  129. package/dist/model/runtime.d.ts.map +1 -0
  130. package/dist/model/runtime.js +27 -0
  131. package/dist/model/runtime.js.map +1 -0
  132. package/dist/model/threadWindow.d.ts +95 -0
  133. package/dist/model/threadWindow.d.ts.map +1 -0
  134. package/dist/model/threadWindow.js +100 -0
  135. package/dist/model/threadWindow.js.map +1 -0
  136. package/dist/nav/manifest.d.ts +23 -0
  137. package/dist/nav/manifest.d.ts.map +1 -0
  138. package/dist/nav/manifest.js +15 -0
  139. package/dist/nav/manifest.js.map +1 -0
  140. package/dist/realtime/chatSocket.d.ts +93 -0
  141. package/dist/realtime/chatSocket.d.ts.map +1 -0
  142. package/dist/realtime/chatSocket.js +199 -0
  143. package/dist/realtime/chatSocket.js.map +1 -0
  144. package/dist/realtime/frames.d.ts +123 -0
  145. package/dist/realtime/frames.d.ts.map +1 -0
  146. package/dist/realtime/frames.js +123 -0
  147. package/dist/realtime/frames.js.map +1 -0
  148. package/dist/realtime/streams.d.ts +49 -0
  149. package/dist/realtime/streams.d.ts.map +1 -0
  150. package/dist/realtime/streams.js +52 -0
  151. package/dist/realtime/streams.js.map +1 -0
  152. package/llms.txt +89 -0
  153. package/manifest.json +733 -0
  154. package/nav-manifest.json +25 -0
  155. package/package.json +114 -0
  156. package/src/analytics/generated/events.json +7 -0
  157. package/src/api/chatApi.ts +142 -0
  158. package/src/api/extensions.ts +18 -0
  159. package/src/api/generated/schema.ts +549 -0
  160. package/src/api/types.ts +87 -0
  161. package/src/default/ConversationListPanel.tsx +163 -0
  162. package/src/default/ConversationThreadPanel.tsx +245 -0
  163. package/src/default/ErrorAlert.tsx +43 -0
  164. package/src/default/StartChatButton.tsx +78 -0
  165. package/src/default/index.ts +20 -0
  166. package/src/flows/errors.ts +16 -0
  167. package/src/flows/freshness.ts +315 -0
  168. package/src/flows/registry.ts +36 -0
  169. package/src/headless/ChatProvider.tsx +23 -0
  170. package/src/headless/ConversationList.tsx +92 -0
  171. package/src/headless/ConversationThread.tsx +107 -0
  172. package/src/headless/MessageComposer.tsx +86 -0
  173. package/src/headless/StartDirectChat.tsx +75 -0
  174. package/src/i18n/errorsMap.ts +31 -0
  175. package/src/i18n/es.ts +95 -0
  176. package/src/i18n/generated/errors.es.gen.ts +64 -0
  177. package/src/i18n/generated/errors.gen.ts +214 -0
  178. package/src/i18n/generated/errors.json +408 -0
  179. package/src/i18n/generated/errors.ru.gen.ts +64 -0
  180. package/src/i18n/keys.ts +152 -0
  181. package/src/i18n/ru.ts +98 -0
  182. package/src/index.ts +189 -0
  183. package/src/model/context.tsx +25 -0
  184. package/src/model/limits.ts +11 -0
  185. package/src/model/mutations.ts +208 -0
  186. package/src/model/queries.ts +120 -0
  187. package/src/model/queryKeys.ts +32 -0
  188. package/src/model/readMarker.ts +32 -0
  189. package/src/model/runtime.ts +90 -0
  190. package/src/model/threadWindow.ts +173 -0
  191. package/src/nav/manifest.ts +36 -0
  192. package/src/realtime/chatSocket.ts +304 -0
  193. package/src/realtime/frames.ts +229 -0
  194. package/src/realtime/streams.ts +85 -0
  195. 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
+ };