@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
@@ -0,0 +1,163 @@
1
+ /**
2
+ * `<ConversationListPanel/>` — the default skin for the inbox, and the screen
3
+ * this pair's nav manifest mounts (`chat.conversations`, member surface).
4
+ *
5
+ * Built entirely on the headless `<ConversationList>`: this file makes visual
6
+ * decisions and nothing else.
7
+ */
8
+ import type { ReactElement } from "react";
9
+ import { Badge, Button, Card, Empty, List, Space, Spin, Typography } from "antd";
10
+ import { matchList, useErrorDisplay, useI18n, useT } from "@stapel/core";
11
+ import type { Conversation } from "../api/types.js";
12
+ import { ConversationList } from "../headless/ConversationList.js";
13
+ import { CHAT_I18N_KEYS } from "../i18n/keys.js";
14
+ import { ErrorAlert } from "./ErrorAlert.js";
15
+
16
+ const KIND_KEYS: Record<string, string> = {
17
+ direct: CHAT_I18N_KEYS.kindDirect,
18
+ group: CHAT_I18N_KEYS.kindGroup,
19
+ support: CHAT_I18N_KEYS.kindSupport,
20
+ };
21
+
22
+ export interface ConversationListPanelProps {
23
+ /** Page size for the underlying list. */
24
+ limit?: number;
25
+ /**
26
+ * Where a row leads, as an href — the SSR-friendly half. A storefront
27
+ * renders real links so a conversation is right-clickable and indexable by
28
+ * the browser's own history, not only by a click handler.
29
+ */
30
+ openHref?: (conversationId: string) => string;
31
+ /** Where a row leads, in a SPA. Used when `openHref` is not given. */
32
+ onOpen?: (conversationId: string) => void;
33
+ }
34
+
35
+ function relativeTime(locale: string, iso: string): string {
36
+ // A timestamp is data, not copy: `Intl` localizes it from the host's own
37
+ // locale, so it needs no i18n key and cannot go stale in a catalogue.
38
+ const parsed = Date.parse(iso);
39
+ if (Number.isNaN(parsed)) return "";
40
+ return new Intl.DateTimeFormat(locale, {
41
+ dateStyle: "short",
42
+ timeStyle: "short",
43
+ }).format(parsed);
44
+ }
45
+
46
+ export function ConversationListPanel(
47
+ props: ConversationListPanelProps = {}
48
+ ): ReactElement {
49
+ const t = useT();
50
+ const { locale } = useI18n();
51
+ // Never the raw `.message` — for a response with no error envelope that is
52
+ // the transport's own "Request failed with status 500".
53
+ const errorDisplay = useErrorDisplay(CHAT_I18N_KEYS.unknownError);
54
+ const { openHref, onOpen } = props;
55
+
56
+ const renderRow = (row: Conversation): ReactElement => {
57
+ const kindKey = KIND_KEYS[row.kind] ?? CHAT_I18N_KEYS.kindDirect;
58
+ const title = t(kindKey);
59
+ return (
60
+ <List.Item
61
+ data-testid="chat-conversation-row"
62
+ extra={
63
+ row.unread_count > 0 ? (
64
+ <Badge
65
+ count={row.unread_count}
66
+ // The badge is a number; the accessible sentence is the key.
67
+ title={t(CHAT_I18N_KEYS.listUnread, { count: row.unread_count })}
68
+ />
69
+ ) : null
70
+ }
71
+ >
72
+ <List.Item.Meta
73
+ title={
74
+ openHref ? (
75
+ <Typography.Link href={openHref(row.id)}>{title}</Typography.Link>
76
+ ) : onOpen ? (
77
+ <Button
78
+ type="link"
79
+ style={{ padding: 0 }}
80
+ onClick={() => onOpen(row.id)}
81
+ data-analytics="none"
82
+ data-analytics-reason="navigation into a thread — the host app wraps this with its own tracked(); pairs carry no @stapel/analytics runtime dependency by architecture"
83
+ >
84
+ {title}
85
+ </Button>
86
+ ) : (
87
+ // Neither route given: a title, not a control that goes
88
+ // nowhere. A dead affordance is worse than none.
89
+ <Typography.Text strong>{title}</Typography.Text>
90
+ )
91
+ }
92
+ description={
93
+ <Typography.Text type="secondary">
94
+ {relativeTime(locale, row.updated_at)}
95
+ </Typography.Text>
96
+ }
97
+ />
98
+ </List.Item>
99
+ );
100
+ };
101
+
102
+ return (
103
+ <ConversationList {...(props.limit !== undefined ? { limit: props.limit } : {})}>
104
+ {({ state, hasNextPage, isFetchingNextPage, fetchNextPage, refetch }) => (
105
+ <Card data-testid="chat-conversation-list">
106
+ <Typography.Title level={4} style={{ marginTop: 0 }}>
107
+ {t(CHAT_I18N_KEYS.listTitle)}
108
+ </Typography.Title>
109
+
110
+ {matchList(state, {
111
+ loading: () => <Spin style={{ marginTop: 16 }} />,
112
+ // One arm owns the failure; the empty copy is unreachable from
113
+ // here, so an outage can never render as "no conversations yet".
114
+ failed: (error) => (
115
+ <div style={{ marginTop: 16 }} data-testid="chat-conversation-list-error">
116
+ <ErrorAlert error={errorDisplay(error)} />
117
+ <Button
118
+ style={{ marginTop: 12 }}
119
+ onClick={refetch}
120
+ data-analytics="none"
121
+ data-analytics-reason="recovery affordance for a failed read — host app wraps with its own tracked()"
122
+ >
123
+ {t(CHAT_I18N_KEYS.listRetry)}
124
+ </Button>
125
+ </div>
126
+ ),
127
+ empty: () => (
128
+ <Empty
129
+ style={{ marginTop: 16 }}
130
+ data-testid="chat-conversation-list-empty"
131
+ description={t(CHAT_I18N_KEYS.listEmpty)}
132
+ />
133
+ ),
134
+ ready: (rows) => (
135
+ <Space direction="vertical" style={{ width: "100%" }}>
136
+ <List<Conversation>
137
+ style={{ marginTop: 16 }}
138
+ dataSource={[...rows]}
139
+ rowKey={(row) => row.id}
140
+ renderItem={renderRow}
141
+ />
142
+ {hasNextPage ? (
143
+ <Button
144
+ loading={isFetchingNextPage}
145
+ onClick={() => fetchNextPage()}
146
+ data-analytics="none"
147
+ data-analytics-reason="business action — host app wraps with its own tracked()"
148
+ >
149
+ {t(CHAT_I18N_KEYS.listLoadMore)}
150
+ </Button>
151
+ ) : (
152
+ <Typography.Text type="secondary">
153
+ {t(CHAT_I18N_KEYS.listEnd)}
154
+ </Typography.Text>
155
+ )}
156
+ </Space>
157
+ ),
158
+ })}
159
+ </Card>
160
+ )}
161
+ </ConversationList>
162
+ );
163
+ }
@@ -0,0 +1,245 @@
1
+ /**
2
+ * `<ConversationThreadPanel/>` — the default skin for one thread: the messages,
3
+ * the "show earlier" control, a live/polling indicator, and the composer.
4
+ *
5
+ * The transport indicator is the ONLY place a person is told how freshness
6
+ * arrives, and it is a label, never a behaviour: nothing else on this screen
7
+ * branches on it. That is the seam holding — the same component renders a
8
+ * socket-fed thread and a polled one.
9
+ */
10
+ import type { CSSProperties, ReactElement } from "react";
11
+ import { Button, Card, Empty, Flex, Input, Space, Spin, Tag, Typography } from "antd";
12
+ import {
13
+ matchList,
14
+ useActionGate,
15
+ useErrorDisplay,
16
+ useI18n,
17
+ useT,
18
+ } from "@stapel/core";
19
+ import type { ChatMessage } from "../api/types.js";
20
+ import { ConversationThread } from "../headless/ConversationThread.js";
21
+ import { MessageComposer } from "../headless/MessageComposer.js";
22
+ import type { ChatTransport } from "../flows/freshness.js";
23
+ import { CHAT_I18N_KEYS } from "../i18n/keys.js";
24
+ import { ErrorAlert } from "./ErrorAlert.js";
25
+
26
+ const TRANSPORT_KEYS: Record<ChatTransport, string> = {
27
+ socket: CHAT_I18N_KEYS.transportLive,
28
+ polling: CHAT_I18N_KEYS.transportPolling,
29
+ idle: CHAT_I18N_KEYS.transportIdle,
30
+ };
31
+
32
+ export interface ConversationThreadPanelProps {
33
+ conversationId: string;
34
+ /** The reader, when the host knows them — used only to align their own
35
+ * lines. Absent, every message is rendered the same way rather than
36
+ * guessed at. */
37
+ viewerId?: string | null;
38
+ limit?: number;
39
+ maxLength?: number;
40
+ }
41
+
42
+ function MessageRow(props: {
43
+ message: ChatMessage;
44
+ viewerId: string | null | undefined;
45
+ locale: string;
46
+ }): ReactElement {
47
+ const t = useT();
48
+ const { message, viewerId, locale } = props;
49
+ const isSystem = message.kind === "system";
50
+ const isOwn =
51
+ !isSystem && viewerId != null && message.sender_id === viewerId;
52
+ const bubble: CSSProperties = {
53
+ alignSelf: isOwn ? "flex-end" : "flex-start",
54
+ maxWidth: "42rem",
55
+ };
56
+ const stamp = Number.isNaN(Date.parse(message.created_at))
57
+ ? ""
58
+ : new Intl.DateTimeFormat(locale, { timeStyle: "short" }).format(
59
+ Date.parse(message.created_at)
60
+ );
61
+ return (
62
+ <div style={bubble} data-testid="chat-message" data-seq={message.seq}>
63
+ {isSystem ? (
64
+ <Typography.Text type="secondary">
65
+ {t(CHAT_I18N_KEYS.threadSystem)}
66
+ </Typography.Text>
67
+ ) : null}
68
+ <Typography.Paragraph style={{ marginBottom: 0 }}>
69
+ {message.body}
70
+ </Typography.Paragraph>
71
+ <Typography.Text type="secondary" style={{ fontSize: 12 }}>
72
+ {stamp}
73
+ </Typography.Text>
74
+ </div>
75
+ );
76
+ }
77
+
78
+ function Composer(props: {
79
+ conversationId: string;
80
+ maxLength: number | undefined;
81
+ }): ReactElement {
82
+ const t = useT();
83
+ const errorDisplay = useErrorDisplay(CHAT_I18N_KEYS.unknownError);
84
+ return (
85
+ <MessageComposer
86
+ conversationId={props.conversationId}
87
+ {...(props.maxLength !== undefined ? { maxLength: props.maxLength } : {})}
88
+ >
89
+ {({ value, setValue, availability, isSending, error, send, maxLength, length }) => (
90
+ <ComposerBody
91
+ value={value}
92
+ setValue={setValue}
93
+ isSending={isSending}
94
+ send={send}
95
+ maxLength={maxLength}
96
+ length={length}
97
+ availability={availability}
98
+ errorNode={<ErrorAlert error={errorDisplay(error)} />}
99
+ t={t}
100
+ />
101
+ )}
102
+ </MessageComposer>
103
+ );
104
+ }
105
+
106
+ /** Split out so `useActionGate` is called from a component, not a callback. */
107
+ function ComposerBody(props: {
108
+ value: string;
109
+ setValue: (next: string) => void;
110
+ isSending: boolean;
111
+ send: () => void;
112
+ maxLength: number;
113
+ length: number;
114
+ availability: Parameters<typeof useActionGate>[0];
115
+ errorNode: ReactElement | null;
116
+ t: (key: string, params?: Readonly<Record<string, unknown>>) => string;
117
+ }): ReactElement {
118
+ const gate = useActionGate(props.availability);
119
+ const { t } = props;
120
+ return (
121
+ <Space direction="vertical" style={{ width: "100%" }}>
122
+ {props.errorNode}
123
+ <Input.TextArea
124
+ value={props.value}
125
+ onChange={(event) => props.setValue(event.target.value)}
126
+ placeholder={t(CHAT_I18N_KEYS.composerPlaceholder)}
127
+ autoSize={{ minRows: 2, maxRows: 6 }}
128
+ data-testid="chat-composer-input"
129
+ />
130
+ <Flex align="center" gap={12} wrap="wrap">
131
+ <Button
132
+ type="primary"
133
+ disabled={gate.disabled}
134
+ loading={props.isSending}
135
+ onClick={props.send}
136
+ data-testid="chat-composer-send"
137
+ data-analytics="none"
138
+ data-analytics-reason="business action — host app wraps with its own tracked()"
139
+ >
140
+ {props.isSending
141
+ ? t(CHAT_I18N_KEYS.composerSending)
142
+ : t(CHAT_I18N_KEYS.composerSend)}
143
+ </Button>
144
+ {/* A switched-off control states its reason as TEXT: a disabled
145
+ button takes no pointer events, so a tooltip would be a reason
146
+ nobody can read. */}
147
+ {gate.reason ? (
148
+ <Typography.Text type="secondary" data-testid="chat-composer-blocked">
149
+ {gate.reason}
150
+ </Typography.Text>
151
+ ) : null}
152
+ <Typography.Text type="secondary" style={{ marginLeft: "auto" }}>
153
+ {`${props.length}/${props.maxLength}`}
154
+ </Typography.Text>
155
+ </Flex>
156
+ </Space>
157
+ );
158
+ }
159
+
160
+ export function ConversationThreadPanel(
161
+ props: ConversationThreadPanelProps
162
+ ): ReactElement {
163
+ const t = useT();
164
+ const { locale } = useI18n();
165
+ const errorDisplay = useErrorDisplay(CHAT_I18N_KEYS.unknownError);
166
+
167
+ return (
168
+ <ConversationThread
169
+ conversationId={props.conversationId}
170
+ {...(props.limit !== undefined ? { limit: props.limit } : {})}
171
+ >
172
+ {({ state, hasOlder, isLoadingOlder, loadOlder, refetch, transport }) => (
173
+ <Card data-testid="chat-thread">
174
+ <Flex justify="space-between" align="center" style={{ marginBottom: 12 }}>
175
+ <Typography.Title level={4} style={{ margin: 0 }}>
176
+ {t(CHAT_I18N_KEYS.listTitle)}
177
+ </Typography.Title>
178
+ <Tag data-testid="chat-transport" data-transport={transport}>
179
+ {t(TRANSPORT_KEYS[transport])}
180
+ </Tag>
181
+ </Flex>
182
+
183
+ {matchList(state, {
184
+ loading: () => <Spin />,
185
+ failed: (error) => (
186
+ <div data-testid="chat-thread-error">
187
+ <ErrorAlert error={errorDisplay(error)} />
188
+ <Button
189
+ style={{ marginTop: 12 }}
190
+ onClick={refetch}
191
+ data-analytics="none"
192
+ data-analytics-reason="recovery affordance for a failed read — host app wraps with its own tracked()"
193
+ >
194
+ {t(CHAT_I18N_KEYS.threadRetry)}
195
+ </Button>
196
+ </div>
197
+ ),
198
+ empty: () => (
199
+ <Empty
200
+ data-testid="chat-thread-empty"
201
+ description={t(CHAT_I18N_KEYS.threadEmpty)}
202
+ />
203
+ ),
204
+ ready: (messages) => (
205
+ <Space direction="vertical" style={{ width: "100%" }}>
206
+ {hasOlder ? (
207
+ <Button
208
+ loading={isLoadingOlder}
209
+ onClick={loadOlder}
210
+ data-testid="chat-thread-older"
211
+ data-analytics="none"
212
+ data-analytics-reason="business action — host app wraps with its own tracked()"
213
+ >
214
+ {t(CHAT_I18N_KEYS.threadLoadOlder)}
215
+ </Button>
216
+ ) : (
217
+ <Typography.Text type="secondary">
218
+ {t(CHAT_I18N_KEYS.threadBeginning)}
219
+ </Typography.Text>
220
+ )}
221
+ <Flex vertical gap={8} style={{ width: "100%" }}>
222
+ {messages.map((message) => (
223
+ <MessageRow
224
+ key={message.id}
225
+ message={message}
226
+ viewerId={props.viewerId}
227
+ locale={locale}
228
+ />
229
+ ))}
230
+ </Flex>
231
+ </Space>
232
+ ),
233
+ })}
234
+
235
+ <div style={{ marginTop: 16 }}>
236
+ <Composer
237
+ conversationId={props.conversationId}
238
+ maxLength={props.maxLength}
239
+ />
240
+ </div>
241
+ </Card>
242
+ )}
243
+ </ConversationThread>
244
+ );
245
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The one error surface this pair's default skins render, so the split copy
3
+ * core produces reaches a screen the same way everywhere.
4
+ *
5
+ * Core's `describeFlowError` splits a failure into the sentence a person reads
6
+ * and the technical detail a support agent quotes. The split is only worth
7
+ * anything if a skin renders both halves at their own weights: `message` at
8
+ * normal weight, `detail` muted and small, where an eye skips it and a support
9
+ * agent finds it.
10
+ *
11
+ * `undefined` in — including a `detail` core left `undefined` because there
12
+ * was nothing worth quoting — renders nothing rather than an empty muted line.
13
+ */
14
+ import { Alert, Typography } from "antd";
15
+ import type { CSSProperties, ReactElement } from "react";
16
+ import type { FlowErrorDisplay } from "@stapel/core";
17
+
18
+ export function ErrorAlert(props: {
19
+ error: FlowErrorDisplay | undefined;
20
+ style?: CSSProperties | undefined;
21
+ testId?: string | undefined;
22
+ }): ReactElement | null {
23
+ const { error } = props;
24
+ if (!error) return null;
25
+ return (
26
+ <Alert
27
+ type="error"
28
+ showIcon
29
+ {...(props.style ? { style: props.style } : {})}
30
+ {...(props.testId ? { "data-testid": props.testId } : {})}
31
+ message={error.message}
32
+ {...(error.detail
33
+ ? {
34
+ description: (
35
+ <Typography.Text type="secondary" style={{ fontSize: 12 }}>
36
+ {error.detail}
37
+ </Typography.Text>
38
+ ),
39
+ }
40
+ : {})}
41
+ />
42
+ );
43
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * `<StartChatButton/>` — "message the seller" on a listing card, skinned.
3
+ *
4
+ * The control is either pressable or switched off WITH a sentence beside it
5
+ * (no seller on the listing; the listing is your own). It never renders as a
6
+ * grey rectangle with no explanation.
7
+ */
8
+ import type { ReactElement } from "react";
9
+ import { Button, Space, Typography } from "antd";
10
+ import { useActionGate, useErrorDisplay, useT } from "@stapel/core";
11
+ import type { ActionAvailability } from "@stapel/core";
12
+ import type { Conversation } from "../api/types.js";
13
+ import { StartDirectChat } from "../headless/StartDirectChat.js";
14
+ import { CHAT_I18N_KEYS } from "../i18n/keys.js";
15
+ import { ErrorAlert } from "./ErrorAlert.js";
16
+
17
+ export interface StartChatButtonProps {
18
+ sellerId: string | null | undefined;
19
+ viewerId?: string | null;
20
+ onOpened?: (conversation: Conversation) => void;
21
+ block?: boolean;
22
+ }
23
+
24
+ function StartChatBody(props: {
25
+ availability: ActionAvailability;
26
+ isStarting: boolean;
27
+ error: unknown;
28
+ start: () => void;
29
+ block: boolean | undefined;
30
+ }): ReactElement {
31
+ const t = useT();
32
+ const gate = useActionGate(props.availability);
33
+ const errorDisplay = useErrorDisplay(CHAT_I18N_KEYS.unknownError);
34
+ return (
35
+ <Space direction="vertical" style={{ width: props.block ? "100%" : undefined }}>
36
+ <Button
37
+ type="primary"
38
+ block={props.block ?? false}
39
+ disabled={gate.disabled}
40
+ loading={props.isStarting}
41
+ onClick={props.start}
42
+ data-testid="chat-start-button"
43
+ data-analytics="none"
44
+ data-analytics-reason="business action — host app wraps with its own tracked()"
45
+ >
46
+ {props.isStarting
47
+ ? t(CHAT_I18N_KEYS.startStarting)
48
+ : t(CHAT_I18N_KEYS.startButton)}
49
+ </Button>
50
+ {gate.reason ? (
51
+ <Typography.Text type="secondary" data-testid="chat-start-blocked">
52
+ {gate.reason}
53
+ </Typography.Text>
54
+ ) : null}
55
+ <ErrorAlert error={errorDisplay(props.error)} />
56
+ </Space>
57
+ );
58
+ }
59
+
60
+ export function StartChatButton(props: StartChatButtonProps): ReactElement {
61
+ return (
62
+ <StartDirectChat
63
+ sellerId={props.sellerId}
64
+ {...(props.viewerId !== undefined ? { viewerId: props.viewerId } : {})}
65
+ {...(props.onOpened !== undefined ? { onOpened: props.onOpened } : {})}
66
+ >
67
+ {({ availability, isStarting, error, start }) => (
68
+ <StartChatBody
69
+ availability={availability}
70
+ isStarting={isStarting}
71
+ error={error}
72
+ start={start}
73
+ block={props.block}
74
+ />
75
+ )}
76
+ </StartDirectChat>
77
+ );
78
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * `@stapel/chat-react/default` — the opt-in antd skin for this pair (mirrors
3
+ * auth-react's `/default` split, §54): a separate entry point so consumers who
4
+ * bring their own visuals never pull `antd` into their bundle; importing this
5
+ * subpath is the opt-in.
6
+ *
7
+ * ```tsx
8
+ * import {
9
+ * ConversationListPanel,
10
+ * ConversationThreadPanel,
11
+ * StartChatButton,
12
+ * } from "@stapel/chat-react/default";
13
+ * ```
14
+ */
15
+ export { ConversationListPanel } from "./ConversationListPanel.js";
16
+ export type { ConversationListPanelProps } from "./ConversationListPanel.js";
17
+ export { ConversationThreadPanel } from "./ConversationThreadPanel.js";
18
+ export type { ConversationThreadPanelProps } from "./ConversationThreadPanel.js";
19
+ export { StartChatButton } from "./StartChatButton.js";
20
+ export type { StartChatButtonProps } from "./StartChatButton.js";
@@ -0,0 +1,16 @@
1
+ import { toFlowError as coreToFlowError } from "@stapel/core";
2
+ import type { FlowError } from "@stapel/core";
3
+
4
+ export type { FlowError } from "@stapel/core";
5
+ export { isErrorCode } from "@stapel/core";
6
+
7
+ /**
8
+ * Fold any thrown value into a {@link FlowError} using this pair's own
9
+ * module-scoped fallback key (`chat.error.unknown`, an en string in
10
+ * `chatI18nBundleEn`) so a non-`StapelApiError` fault still renders real copy
11
+ * rather than a raw key. The primitive lives in `@stapel/core`
12
+ * (frontend-core-architecture §4b); this wrapper only pins the fallback.
13
+ */
14
+ export function toFlowError(error: unknown): FlowError {
15
+ return coreToFlowError(error, "chat.error.unknown");
16
+ }