@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
|
@@ -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
|
+
}
|