@gemboss/ui 0.39.0 → 0.41.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 +285 -0
- package/README.md +638 -3
- package/catalog/editor.html +15 -13
- package/catalog/index.html +5235 -1145
- package/gemboss-ui.css +3 -3
- package/package.json +8 -2
- package/parts/components.css +2 -2
- package/parts/core.css +1 -1
- package/parts/shell.css +2 -2
- package/src/assistant/chat-markdown.ts +7 -4
- package/src/assistant.css +1 -1
- package/src/brand-gem.tsx +19 -0
- package/src/brand.tsx +4 -7
- package/src/chat-plain.js +2 -2
- package/src/editor/controls.tsx +50 -14
- package/src/editor/index.ts +3 -3
- package/src/editor/panel.tsx +10 -4
- package/src/editor/repeater.tsx +113 -27
- package/src/editor/swatch.ts +20 -0
- package/src/editor-plain.js +1 -1
- package/src/editor-repeater.css +1 -1
- package/src/editor.css +1 -1
- package/src/gemboss-field.css +1 -1
- package/src/icons.tsx +11 -0
- package/src/index.ts +12 -1
- package/src/reader-dialog.css +1 -0
- package/src/reader-dialog.tsx +26 -0
- package/src/service-switcher.css +1 -1
- package/src/service-switcher.tsx +108 -31
- package/src/shell.css +1 -1
- package/src/site.css +1 -0
- package/src/site.tsx +50 -0
- package/src/team-chat-data.ts +79 -0
- package/src/team-chat-plain.js +1 -0
- package/src/team-chat.css +1 -0
- package/src/team-chat.tsx +825 -0
- package/src/ui.css +1 -1
|
@@ -0,0 +1,825 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
/**
|
|
3
|
+
* The team chat layer (Chris 20/10/2026): GemCare's Slack-like channel chat, moved into the package so GemCare (plain
|
|
4
|
+
* HTML, its own renderers in public/assets/inbox.js) and GemCommunity (Next, React) draw ONE chat, and an upgrade to it
|
|
5
|
+
* happens here once. Look and behaviour are GemCare's (origin/main, 20/10/2026): the channel rail with unread and
|
|
6
|
+
* @mention badges, day separators, the "New messages" line, turns grouped by author, the reply quote, the hover action
|
|
7
|
+
* bar, the thread panel on the right, "jump to the latest". Added for the community: the people panel, and the layout
|
|
8
|
+
* that gives a small screen its space back (README "The team chat layer").
|
|
9
|
+
*
|
|
10
|
+
* Props only. No fetching, no store, no router: the app owns the data and passes callbacks (onOpen, onSend, onReply,
|
|
11
|
+
* onOpenThread, onReact, onToggleCollapse…) and slots (ReactNode props) for its own pieces: attachments, the mention
|
|
12
|
+
* picker, link cards, a "more" menu. What these components keep is the state of the screen itself (the reaction picker
|
|
13
|
+
* a message opened, the composer's text when the app does not hold it, where focus goes when a drawer opens).
|
|
14
|
+
*
|
|
15
|
+
* The markup is a CONTRACT: README "The team chat layer" shows the exact HTML of each part, and
|
|
16
|
+
* team-chat-contract.test.tsx renders every component and holds it to that HTML, so a plain-HTML surface (GemCare) can
|
|
17
|
+
* emit the same thing and the two cannot drift. Change the markup here, and the README block with it.
|
|
18
|
+
*
|
|
19
|
+
* Classes: `gb-tc-*` (team-chat.css, imported below). Reused from the package, not redrawn: `Avatar` (portal.css),
|
|
20
|
+
* `Reactions` / `ReactionPicker` (reactions.css), the kit's `ui-btn` and `ui-check` (components.css, the core).
|
|
21
|
+
*/
|
|
22
|
+
import "./team-chat.css";
|
|
23
|
+
import React, { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
|
24
|
+
import { Icon } from "./icons";
|
|
25
|
+
import { Avatar } from "./portal";
|
|
26
|
+
import { ReactionPicker, Reactions } from "./reactions";
|
|
27
|
+
import { QUICK_REACTIONS, type Reaction } from "./reactions-data";
|
|
28
|
+
|
|
29
|
+
const cx = (...c: Array<string | false | null | undefined>) => c.filter(Boolean).join(" ");
|
|
30
|
+
const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
31
|
+
|
|
32
|
+
// ── Words ──────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
33
|
+
/** Every word the components write themselves. Pass `labels` (any subset) to translate. */
|
|
34
|
+
export type TeamChatLabels = {
|
|
35
|
+
channels: string;
|
|
36
|
+
conversation: string;
|
|
37
|
+
back: string;
|
|
38
|
+
members: string;
|
|
39
|
+
hideMembers: string;
|
|
40
|
+
showMembers: string;
|
|
41
|
+
thread: string;
|
|
42
|
+
closeThread: string;
|
|
43
|
+
replies: (n: number) => string;
|
|
44
|
+
newMessages: string;
|
|
45
|
+
edited: string;
|
|
46
|
+
someone: string;
|
|
47
|
+
actionsFor: (author: string) => string;
|
|
48
|
+
react: (emoji: string, author: string, mine: boolean) => string;
|
|
49
|
+
moreReactions: (author: string) => string;
|
|
50
|
+
reply: string;
|
|
51
|
+
replyTo: (author: string) => string;
|
|
52
|
+
replyInThread: string;
|
|
53
|
+
replyInThreadTo: (author: string) => string;
|
|
54
|
+
openThread: (n: number, last: string) => string;
|
|
55
|
+
jump: string;
|
|
56
|
+
jumpNew: (n: number) => string;
|
|
57
|
+
message: string;
|
|
58
|
+
send: string;
|
|
59
|
+
attach: string;
|
|
60
|
+
stopReplying: string;
|
|
61
|
+
privateChannel: string;
|
|
62
|
+
directMessage: string;
|
|
63
|
+
mentioningYou: (n: number) => string;
|
|
64
|
+
unread: (n: number) => string;
|
|
65
|
+
online: string;
|
|
66
|
+
away: string;
|
|
67
|
+
offline: string;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const TEAM_CHAT_LABELS: TeamChatLabels = {
|
|
71
|
+
channels: "Channels",
|
|
72
|
+
conversation: "Conversation",
|
|
73
|
+
back: "Back to channels",
|
|
74
|
+
members: "Members",
|
|
75
|
+
hideMembers: "Hide members",
|
|
76
|
+
showMembers: "Show members",
|
|
77
|
+
thread: "Thread",
|
|
78
|
+
closeThread: "Close thread",
|
|
79
|
+
replies: (n) => (n === 1 ? "1 reply" : `${n} replies`),
|
|
80
|
+
newMessages: "New messages",
|
|
81
|
+
edited: "(edited)",
|
|
82
|
+
someone: "Someone who has left",
|
|
83
|
+
actionsFor: (a) => `Actions for the message from ${a}`,
|
|
84
|
+
react: (e, a, mine) => `${mine ? "Remove" : "Add"} ${e} on the message from ${a}`,
|
|
85
|
+
moreReactions: (a) => `More reactions for the message from ${a}`,
|
|
86
|
+
reply: "Reply",
|
|
87
|
+
replyTo: (a) => `Reply to the message from ${a}`,
|
|
88
|
+
replyInThread: "Reply in thread",
|
|
89
|
+
replyInThreadTo: (a) => `Reply in thread to the message from ${a}`,
|
|
90
|
+
openThread: (n, last) => `Open the thread, ${n === 1 ? "1 reply" : `${n} replies`}${last ? `, ${last}` : ""}`,
|
|
91
|
+
jump: "Jump to the latest message",
|
|
92
|
+
jumpNew: (n) => `Jump to the latest message, ${n} new`,
|
|
93
|
+
message: "Message",
|
|
94
|
+
send: "Send",
|
|
95
|
+
attach: "Attach a file",
|
|
96
|
+
stopReplying: "Stop replying",
|
|
97
|
+
privateChannel: "private channel",
|
|
98
|
+
directMessage: "direct message",
|
|
99
|
+
mentioningYou: (n) => `${n} mentioning you`,
|
|
100
|
+
unread: (n) => `${n} unread`,
|
|
101
|
+
online: "Online",
|
|
102
|
+
away: "Away",
|
|
103
|
+
offline: "Offline",
|
|
104
|
+
};
|
|
105
|
+
const words = (labels?: Partial<TeamChatLabels>): TeamChatLabels => (labels ? { ...TEAM_CHAT_LABELS, ...labels } : TEAM_CHAT_LABELS);
|
|
106
|
+
|
|
107
|
+
// ── The screen ─────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
108
|
+
export type TeamChatView = "list" | "conversation";
|
|
109
|
+
export type TeamChatPanel = "thread" | "people";
|
|
110
|
+
|
|
111
|
+
export type TeamChatProps = {
|
|
112
|
+
/** The channel list: a `ChannelRail`. */
|
|
113
|
+
rail: React.ReactNode;
|
|
114
|
+
/** The conversation: `ChatHeader`, `MessageList`, `Composer`, drawn inside `section.gb-tc-main`. */
|
|
115
|
+
children: React.ReactNode;
|
|
116
|
+
/** The right panel: a `ThreadPanel` or a `PeoplePanel`. Absent: two columns. */
|
|
117
|
+
panel?: React.ReactNode;
|
|
118
|
+
/** Which one `panel` is (the third column is 380px for a thread, the people panel's own width for people). */
|
|
119
|
+
panelKind?: TeamChatPanel;
|
|
120
|
+
/** Whether the panel is open. A collapsed people panel is a 56px strip on a wide screen and NOT open (no drawer, no
|
|
121
|
+
* scrim) on a narrow one. Default: open when there is a panel. */
|
|
122
|
+
panelOpen?: boolean;
|
|
123
|
+
/** Escape in the panel, or a press on the scrim, while the panel is a drawer (and Escape in a thread always). */
|
|
124
|
+
onClosePanel?: () => void;
|
|
125
|
+
/** At phone width (≤760px of the screen's own width) only one of the two shows, as Slack on a phone. */
|
|
126
|
+
view?: TeamChatView;
|
|
127
|
+
conversationLabel?: string;
|
|
128
|
+
className?: string;
|
|
129
|
+
labels?: Partial<TeamChatLabels>;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/** The width at or below which the right panel is a drawer over the conversation, not a column (team-chat.css). */
|
|
133
|
+
export const TEAM_CHAT_DRAWER_MAX = 1100;
|
|
134
|
+
/** The width at or below which the screen shows the list OR the conversation (team-chat.css). */
|
|
135
|
+
export const TEAM_CHAT_PHONE_MAX = 760;
|
|
136
|
+
|
|
137
|
+
/** The width of the screen itself (a container, not the viewport: in an app shell the chat is narrower than the
|
|
138
|
+
* window), so the JS agrees with team-chat.css's container queries. */
|
|
139
|
+
function useOwnWidth(ref: React.RefObject<HTMLElement | null>) {
|
|
140
|
+
const [w, setW] = useState<number | null>(null);
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
const el = ref.current;
|
|
143
|
+
if (!el || typeof ResizeObserver === "undefined") return;
|
|
144
|
+
const ro = new ResizeObserver(([e]) => setW(e.contentRect.width));
|
|
145
|
+
ro.observe(el);
|
|
146
|
+
return () => ro.disconnect();
|
|
147
|
+
}, [ref]);
|
|
148
|
+
return w;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The chat screen: rail | conversation | right panel (thread OR people). The panel is a third column when the screen
|
|
153
|
+
* is wider than 1100px and a drawer over the conversation when it is not, so a 380px thread never squeezes the
|
|
154
|
+
* conversation to 300px. While it is a drawer it holds focus: on open focus moves into it, the rail and the
|
|
155
|
+
* conversation are `inert` (Tab stays in the drawer), Escape or the scrim closes it, and focus goes back to what opened it.
|
|
156
|
+
*/
|
|
157
|
+
export function TeamChat({ rail, children, panel, panelKind = "thread", panelOpen, onClosePanel, view = "conversation", conversationLabel, className, labels }: TeamChatProps) {
|
|
158
|
+
const t = words(labels);
|
|
159
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
160
|
+
const width = useOwnWidth(ref);
|
|
161
|
+
const open = Boolean(panel) && (panelOpen ?? true);
|
|
162
|
+
const drawer = width !== null && width <= TEAM_CHAT_DRAWER_MAX;
|
|
163
|
+
// On a phone showing the channel list, the panel is not on screen (team-chat.css): nothing to hold focus in.
|
|
164
|
+
const shown = !(width !== null && width <= TEAM_CHAT_PHONE_MAX && view === "list");
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
const root = ref.current;
|
|
167
|
+
if (!root || !open || !drawer || !shown) return;
|
|
168
|
+
const pane = root.querySelector<HTMLElement>(":scope > .gb-tc-thread, :scope > .gb-tc-people");
|
|
169
|
+
if (!pane) return;
|
|
170
|
+
const behind = [...root.querySelectorAll<HTMLElement>(":scope > .gb-tc-rail, :scope > .gb-tc-main")];
|
|
171
|
+
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
172
|
+
for (const el of behind) el.setAttribute("inert", "");
|
|
173
|
+
if (!pane.contains(document.activeElement)) pane.focus({ preventScroll: true });
|
|
174
|
+
return () => {
|
|
175
|
+
for (const el of behind) el.removeAttribute("inert");
|
|
176
|
+
// Back to what opened it, unless focus already went somewhere on purpose (the app moved it).
|
|
177
|
+
const lost = !document.activeElement || document.activeElement === document.body || !document.activeElement.isConnected;
|
|
178
|
+
if (opener && opener.isConnected && !pane.contains(opener) && (lost || pane.contains(document.activeElement))) opener.focus({ preventScroll: true });
|
|
179
|
+
};
|
|
180
|
+
}, [open, drawer, shown, panelKind]);
|
|
181
|
+
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
|
182
|
+
if (e.key !== "Escape" || e.defaultPrevented || !open || !onClosePanel) return;
|
|
183
|
+
const pane = ref.current?.querySelector(":scope > .gb-tc-thread, :scope > .gb-tc-people");
|
|
184
|
+
if (!pane || !pane.contains(e.target as Node)) return;
|
|
185
|
+
if (!drawer && panelKind !== "thread") return; // a people column on a wide screen is not a popup
|
|
186
|
+
e.preventDefault();
|
|
187
|
+
onClosePanel();
|
|
188
|
+
};
|
|
189
|
+
return (
|
|
190
|
+
<div ref={ref} className={cx("gb-tc", className)} data-view={view} data-panel={panel ? panelKind : undefined} onKeyDown={onKeyDown}>
|
|
191
|
+
{rail}
|
|
192
|
+
<section className="gb-tc-main" aria-label={conversationLabel ?? t.conversation}>{children}</section>
|
|
193
|
+
{open ? <div className="gb-tc-scrim" aria-hidden="true" onClick={onClosePanel} /> : null}
|
|
194
|
+
{panel}
|
|
195
|
+
</div>
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── The rail ───────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
200
|
+
export type ChannelKind = "channel" | "private" | "dm";
|
|
201
|
+
const KIND_ICON = { channel: "hash", private: "lock", dm: "user" } as const;
|
|
202
|
+
|
|
203
|
+
export type ChannelRailProps = {
|
|
204
|
+
/** The rail's own heading, e.g. the workspace or "Team chat". */
|
|
205
|
+
title: React.ReactNode;
|
|
206
|
+
/** Beside the title: a "+" (new channel / message) button. */
|
|
207
|
+
actions?: React.ReactNode;
|
|
208
|
+
/** `ChannelSection`s. */
|
|
209
|
+
children: React.ReactNode;
|
|
210
|
+
/** The nav's accessible name. Default "Channels". */
|
|
211
|
+
label?: string;
|
|
212
|
+
className?: string;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
/** The channel list: `nav` with a head (title + actions) and a scrolling body of sections. */
|
|
216
|
+
export function ChannelRail({ title, actions, children, label, className }: ChannelRailProps) {
|
|
217
|
+
return (
|
|
218
|
+
<nav className={cx("gb-tc-rail", className)} aria-label={label ?? TEAM_CHAT_LABELS.channels}>
|
|
219
|
+
<div className="gb-tc-rail__head">
|
|
220
|
+
<h2 className="gb-tc-rail__title">{title}</h2>
|
|
221
|
+
{actions ? <div className="gb-tc-rail__actions">{actions}</div> : null}
|
|
222
|
+
</div>
|
|
223
|
+
<div className="gb-tc-rail__scroll">{children}</div>
|
|
224
|
+
</nav>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** A group of channels under a heading ("Channels", "Direct messages"). */
|
|
229
|
+
export function ChannelSection({ label, children, id }: { label: React.ReactNode; children: React.ReactNode; id?: string }) {
|
|
230
|
+
const auto = useId();
|
|
231
|
+
const hid = `${id ?? auto}-h`;
|
|
232
|
+
return (
|
|
233
|
+
<div className="gb-tc-rail__section" role="group" aria-labelledby={hid}>
|
|
234
|
+
<h3 className="gb-tc-rail__heading" id={hid}>{label}</h3>
|
|
235
|
+
<ul className="gb-tc-rail__list">{children}</ul>
|
|
236
|
+
</div>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export type ChannelItemProps = {
|
|
241
|
+
name: string;
|
|
242
|
+
kind?: ChannelKind;
|
|
243
|
+
/** Unread messages: a count badge, and the name in bold. */
|
|
244
|
+
unread?: number;
|
|
245
|
+
/** Messages that call the viewer by name: their own badge ("@2"), before the count, in another colour. */
|
|
246
|
+
mentions?: number;
|
|
247
|
+
/** The channel on screen: `aria-current`. */
|
|
248
|
+
active?: boolean;
|
|
249
|
+
/** A direct message: whether the other person is online (a dot on the glyph). */
|
|
250
|
+
online?: boolean;
|
|
251
|
+
/** A link (the channel has its own URL) … */
|
|
252
|
+
href?: string;
|
|
253
|
+
/** … drawn by the app's link component (Next's `Link`). */
|
|
254
|
+
linkAs?: React.ElementType;
|
|
255
|
+
/** A button (no URL). */
|
|
256
|
+
onOpen?: () => void;
|
|
257
|
+
labels?: Partial<TeamChatLabels>;
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
/** One row of the rail. Its accessible name says the counts too: a dot cannot be read out. */
|
|
261
|
+
export function ChannelItem({ name, kind = "channel", unread = 0, mentions = 0, active = false, online, href, linkAs, onOpen, labels }: ChannelItemProps) {
|
|
262
|
+
const t = words(labels);
|
|
263
|
+
const label = [name, kind === "private" ? t.privateChannel : kind === "dm" ? t.directMessage : null,
|
|
264
|
+
mentions ? t.mentioningYou(mentions) : null, unread ? t.unread(unread) : null].filter(Boolean).join(", ");
|
|
265
|
+
const inner = (
|
|
266
|
+
<>
|
|
267
|
+
<span className="gb-tc-channel__glyph" aria-hidden="true">
|
|
268
|
+
<Icon name={KIND_ICON[kind]} size={16} />
|
|
269
|
+
{kind === "dm" && online !== undefined ? <span className={PRESENCE_CLASS[online ? "online" : "offline"]} /> : null}
|
|
270
|
+
</span>
|
|
271
|
+
<span className="gb-tc-channel__name">{name}</span>
|
|
272
|
+
{mentions ? <span className="gb-tc-badge gb-tc-badge--mention" aria-hidden="true">@{mentions}</span> : null}
|
|
273
|
+
{unread ? <span className="gb-tc-badge" aria-hidden="true">{unread}</span> : null}
|
|
274
|
+
</>
|
|
275
|
+
);
|
|
276
|
+
const cls = cx("gb-tc-channel", unread > 0 && "gb-tc-channel--unread");
|
|
277
|
+
const Link = linkAs ?? "a";
|
|
278
|
+
return (
|
|
279
|
+
<li>
|
|
280
|
+
{href !== undefined
|
|
281
|
+
? <Link className={cls} href={href} aria-current={active ? "page" : undefined} aria-label={label} onClick={onOpen}>{inner}</Link>
|
|
282
|
+
: <button type="button" className={cls} aria-current={active ? "true" : undefined} aria-label={label} onClick={onOpen}>{inner}</button>}
|
|
283
|
+
</li>
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ── The conversation ───────────────────────────────────────────────────────────────────────────────────────────────
|
|
288
|
+
export type ChatHeaderProps = {
|
|
289
|
+
name: React.ReactNode;
|
|
290
|
+
kind?: ChannelKind;
|
|
291
|
+
/** One line under the name: the topic, "Just the two of you", "12 members". */
|
|
292
|
+
topic?: React.ReactNode;
|
|
293
|
+
/** The phone's way back to the channel list (shown only at phone width). */
|
|
294
|
+
onBack?: () => void;
|
|
295
|
+
/** The "members" toggle: opens and closes the people panel. */
|
|
296
|
+
members?: { count?: number; open: boolean; onToggle: () => void; controls?: string };
|
|
297
|
+
/** More buttons at the right (search, call…). */
|
|
298
|
+
actions?: React.ReactNode;
|
|
299
|
+
headingId?: string;
|
|
300
|
+
labels?: Partial<TeamChatLabels>;
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
export function ChatHeader({ name, kind, topic, onBack, members, actions, headingId, labels }: ChatHeaderProps) {
|
|
304
|
+
const t = words(labels);
|
|
305
|
+
return (
|
|
306
|
+
<header className="gb-tc-head">
|
|
307
|
+
{onBack ? (
|
|
308
|
+
<button type="button" className="ui-btn ui-btn--ghost ui-btn--icon gb-tc-head__back" aria-label={t.back} onClick={onBack}>
|
|
309
|
+
<Icon name="chevronLeft" />
|
|
310
|
+
</button>
|
|
311
|
+
) : null}
|
|
312
|
+
<div className="gb-tc-head__text">
|
|
313
|
+
<h2 className="gb-tc-head__title" id={headingId}>
|
|
314
|
+
{kind ? <span className="gb-tc-head__glyph" aria-hidden="true"><Icon name={KIND_ICON[kind]} size={16} /></span> : null}
|
|
315
|
+
<span className="gb-tc-head__name">{name}</span>
|
|
316
|
+
</h2>
|
|
317
|
+
{topic ? <p className="gb-tc-head__sub">{topic}</p> : null}
|
|
318
|
+
</div>
|
|
319
|
+
{members || actions ? (
|
|
320
|
+
<div className="gb-tc-head__actions">
|
|
321
|
+
{actions}
|
|
322
|
+
{members ? (
|
|
323
|
+
<button type="button" className="ui-btn ui-btn--ghost gb-tc-head__members" aria-expanded={members.open} aria-controls={members.controls}
|
|
324
|
+
aria-label={members.count !== undefined ? `${t.members}, ${members.count}` : t.members} onClick={members.onToggle}>
|
|
325
|
+
<Icon name="users" />
|
|
326
|
+
{members.count !== undefined ? <span className="gb-tc-head__count">{members.count}</span> : null}
|
|
327
|
+
</button>
|
|
328
|
+
) : null}
|
|
329
|
+
</div>
|
|
330
|
+
) : null}
|
|
331
|
+
</header>
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export type MessageListProps = {
|
|
336
|
+
/** The log's accessible name: "Messages in #general". */
|
|
337
|
+
label: string;
|
|
338
|
+
/** `DaySeparator`, `NewMessagesLine`, `Message` (each an <li>). */
|
|
339
|
+
children?: React.ReactNode;
|
|
340
|
+
/** First row: a "Load earlier messages" button. */
|
|
341
|
+
before?: React.ReactNode;
|
|
342
|
+
/** Last row, announced: "Linh is typing…", "GemCare is looking…". */
|
|
343
|
+
after?: React.ReactNode;
|
|
344
|
+
/** No messages yet. */
|
|
345
|
+
empty?: React.ReactNode;
|
|
346
|
+
/** A `JumpToLatest`, placed over the bottom right of the list (not of the screen: the composer's height varies). */
|
|
347
|
+
jump?: React.ReactNode;
|
|
348
|
+
logRef?: React.Ref<HTMLDivElement>;
|
|
349
|
+
onScroll?: React.UIEventHandler<HTMLDivElement>;
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
/** The messages: a scrolling `role="log"` (polite) around an <ol>. */
|
|
353
|
+
export function MessageList({ label, children, before, after, empty, jump, logRef, onScroll }: MessageListProps) {
|
|
354
|
+
const none = React.Children.toArray(children).length === 0;
|
|
355
|
+
return (
|
|
356
|
+
<div className="gb-tc-body">
|
|
357
|
+
<div className="gb-tc-log" ref={logRef} role="log" aria-live="polite" aria-label={label} tabIndex={0} onScroll={onScroll}>
|
|
358
|
+
<ol className="gb-tc-list">
|
|
359
|
+
{before ? <li className="gb-tc-older">{before}</li> : null}
|
|
360
|
+
{none && empty ? <li className="gb-tc-empty">{empty}</li> : children}
|
|
361
|
+
{after ? <li className="gb-tc-status" role="status">{after}</li> : null}
|
|
362
|
+
</ol>
|
|
363
|
+
</div>
|
|
364
|
+
{jump}
|
|
365
|
+
</div>
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** "Today", "Yesterday", "Mon, Sep 28": a pill that stays at the top while its day scrolls under it. */
|
|
370
|
+
export function DaySeparator({ label, dateTime }: { label: React.ReactNode; dateTime?: string }) {
|
|
371
|
+
return <li className="gb-tc-day">{dateTime ? <time className="gb-tc-day__label" dateTime={dateTime}>{label}</time> : <span className="gb-tc-day__label">{label}</span>}</li>;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** The line before the first message the viewer has not read (`teamChatRows` says where). */
|
|
375
|
+
export function NewMessagesLine({ labels }: { labels?: Partial<TeamChatLabels> }) {
|
|
376
|
+
const t = words(labels);
|
|
377
|
+
return <li className="gb-tc-new"><span className="gb-tc-new__label" role="separator" aria-label={t.newMessages}>{t.newMessages}</span></li>;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** The round button back to the latest message, with how many arrived while the reader was up the list. */
|
|
381
|
+
export function JumpToLatest({ count = 0, visible = true, onJump, labels }: { count?: number; visible?: boolean; onJump: () => void; labels?: Partial<TeamChatLabels> }) {
|
|
382
|
+
const t = words(labels);
|
|
383
|
+
return (
|
|
384
|
+
<button type="button" className="gb-tc-jump" hidden={!visible} aria-label={count ? t.jumpNew(count) : t.jump} onClick={onJump}>
|
|
385
|
+
<Icon name="chevronsDown" />
|
|
386
|
+
{count ? <span className="gb-tc-jump__n" aria-hidden="true">{count > 99 ? "99+" : count}</span> : null}
|
|
387
|
+
</button>
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** "Read by everyone" (filled, ticked), some (a slice per share), nobody yet (a ring). Nothing when nobody else is in
|
|
392
|
+
* the channel: a mark nobody can turn green is a lie. */
|
|
393
|
+
export function ReadReceipt({ read, of, label }: { read: number; of: number; label: string }) {
|
|
394
|
+
if (of <= 0) return null;
|
|
395
|
+
const all = read >= of;
|
|
396
|
+
const a = (Math.min(read, of) / of) * Math.PI * 2;
|
|
397
|
+
return (
|
|
398
|
+
<span className={cx("gb-tc-receipt", all && "gb-tc-receipt--all")} role="img" aria-label={label} title={label}>
|
|
399
|
+
<svg viewBox="0 0 14 14" width="14" height="14" aria-hidden="true" focusable="false">
|
|
400
|
+
<circle cx="7" cy="7" r="6" fill={all ? "currentColor" : "none"} stroke="currentColor" strokeWidth="1.4" />
|
|
401
|
+
{all ? <path className="gb-tc-receipt__tick" d="M4.2 7.2l1.9 1.9 3.7-4" fill="none" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
|
|
402
|
+
: read > 0 ? <path d={`M7 7 L7 1 A6 6 0 ${a > Math.PI ? 1 : 0} 1 ${(7 + 6 * Math.sin(a)).toFixed(2)} ${(7 - 6 * Math.cos(a)).toFixed(2)} Z`} fill="currentColor" /> : null}
|
|
403
|
+
</svg>
|
|
404
|
+
</span>
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export type MessageProps = {
|
|
409
|
+
author: string;
|
|
410
|
+
/** The author's picture: an image URL (initials when absent) … */
|
|
411
|
+
avatarSrc?: string | null;
|
|
412
|
+
/** … or the app's own avatar node (an AI mark). */
|
|
413
|
+
avatar?: React.ReactNode;
|
|
414
|
+
/** As the app formats it: "09:41". */
|
|
415
|
+
time: React.ReactNode;
|
|
416
|
+
/** ISO time for <time datetime>. */
|
|
417
|
+
dateTime?: string;
|
|
418
|
+
/** The text (or the app's rich text). Absent: an attachment-only message, no empty line. */
|
|
419
|
+
body?: React.ReactNode;
|
|
420
|
+
/** A continuation of the same person's turn (`teamChatRows`): no avatar, no name; the time shows on hover. */
|
|
421
|
+
grouped?: boolean;
|
|
422
|
+
edited?: boolean;
|
|
423
|
+
/** Calls the viewer by name: an amber bar on the left (a bar, not a fill: a fill fights every other row state). */
|
|
424
|
+
mentionsMe?: boolean;
|
|
425
|
+
/** The message a quote or a link jumped to. */
|
|
426
|
+
highlight?: boolean;
|
|
427
|
+
/** Not on the server yet: faded, no actions. */
|
|
428
|
+
sending?: boolean;
|
|
429
|
+
/** Could not be sent: `status` says so, in the danger colour. */
|
|
430
|
+
failed?: boolean;
|
|
431
|
+
/** "Sending…", "Couldn't send — Retry". */
|
|
432
|
+
status?: React.ReactNode;
|
|
433
|
+
/** After the name: an "AI" badge, "answering Linh". */
|
|
434
|
+
badges?: React.ReactNode;
|
|
435
|
+
/** The message this one answers: who and a line of it. A button: the app jumps to it or opens its thread. */
|
|
436
|
+
replyTo?: { author: string; excerpt: React.ReactNode; onOpen?: () => void; busy?: boolean };
|
|
437
|
+
/** The app's pictures and files. */
|
|
438
|
+
attachments?: React.ReactNode;
|
|
439
|
+
/** The app's link cards. */
|
|
440
|
+
extra?: React.ReactNode;
|
|
441
|
+
/** Reactions put on it (the package's `Reactions`). */
|
|
442
|
+
reactions?: readonly Reaction[];
|
|
443
|
+
/** Put or take back an emoji. Absent: no reaction buttons. */
|
|
444
|
+
onReact?: (emoji: string) => void;
|
|
445
|
+
/** Its thread, under it: "3 replies · Last reply 2 hours ago". */
|
|
446
|
+
thread?: { count: number; last?: string; repliers?: ReadonlyArray<{ name: string; src?: string | null }>; onOpen: () => void };
|
|
447
|
+
/** A `ReadReceipt` (the viewer's own messages). */
|
|
448
|
+
receipt?: React.ReactNode;
|
|
449
|
+
onReply?: () => void;
|
|
450
|
+
onOpenThread?: () => void;
|
|
451
|
+
/** More buttons at the end of the action bar (the app's "⋯" menu). */
|
|
452
|
+
moreActions?: React.ReactNode;
|
|
453
|
+
/** The app's edit box, in place of the body (no action bar while editing). */
|
|
454
|
+
editor?: React.ReactNode;
|
|
455
|
+
/** `data-msg-id`, for the app to find the row (jump to a quoted message). */
|
|
456
|
+
messageId?: string | number;
|
|
457
|
+
id?: string;
|
|
458
|
+
className?: string;
|
|
459
|
+
labels?: Partial<TeamChatLabels>;
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* One message: avatar + name + time on the first of a turn, a compact row after it; the reply quote, the body,
|
|
464
|
+
* "(edited)", the read receipt, attachments, link cards, reactions, the thread preview, and the action bar.
|
|
465
|
+
*
|
|
466
|
+
* The action bar floats at the row's top right and shows on hover or keyboard focus. Under a finger (`hover: none`) it
|
|
467
|
+
* is one tap away: the row takes focus on a tap (`tabindex="-1"`) and the bar shows, without costing a row of 44px
|
|
468
|
+
* buttons under every message. The quick reactions (👍 ✅) drop out where the list is narrow (a container query) and
|
|
469
|
+
* under a finger; "Add reaction" opens the package's picker in the flow, under the message.
|
|
470
|
+
*/
|
|
471
|
+
export function Message(props: MessageProps) {
|
|
472
|
+
const { author, avatarSrc, avatar, time, dateTime, body, grouped, edited, mentionsMe, highlight, sending, failed, status, badges, replyTo,
|
|
473
|
+
attachments, extra, reactions = [], onReact, thread, receipt, onReply, onOpenThread, moreActions, editor, messageId, id, className, labels } = props;
|
|
474
|
+
const t = words(labels);
|
|
475
|
+
const who = author || t.someone;
|
|
476
|
+
const [picking, setPicking] = useState(false);
|
|
477
|
+
const reactBtn = useRef<HTMLButtonElement>(null);
|
|
478
|
+
const mine = (e: string) => reactions.some((r) => r.emoji === e && r.mine);
|
|
479
|
+
const showActs = editor == null && !sending && Boolean(onReact || onReply || onOpenThread || moreActions);
|
|
480
|
+
// Arrow keys move along the bar (a toolbar), Home/End to its ends.
|
|
481
|
+
const onActsKey = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
|
482
|
+
const step = ({ ArrowRight: 1, ArrowLeft: -1 } as Record<string, number>)[e.key];
|
|
483
|
+
const all = [...e.currentTarget.querySelectorAll<HTMLElement>("button:not([disabled]), summary, a[href]")].filter((b) => b.offsetParent !== null);
|
|
484
|
+
const at = all.indexOf(document.activeElement as HTMLElement);
|
|
485
|
+
if (at < 0) return;
|
|
486
|
+
const to = step ? all[(at + step + all.length) % all.length] : e.key === "Home" ? all[0] : e.key === "End" ? all[all.length - 1] : null;
|
|
487
|
+
if (to) { e.preventDefault(); to.focus(); }
|
|
488
|
+
};
|
|
489
|
+
return (
|
|
490
|
+
<li id={id}
|
|
491
|
+
className={cx("gb-tc-msg", grouped && "gb-tc-msg--grouped", mentionsMe && "gb-tc-msg--mention", highlight && "gb-tc-msg--highlight",
|
|
492
|
+
sending && "gb-tc-msg--sending", failed && "gb-tc-msg--failed", picking && "gb-tc-msg--active", editor != null && "gb-tc-msg--editing", className)}
|
|
493
|
+
data-msg-id={messageId} tabIndex={-1}>
|
|
494
|
+
{grouped
|
|
495
|
+
? <time className="gb-tc-msg__hover-time" dateTime={dateTime}>{time}</time>
|
|
496
|
+
: avatar ?? <Avatar name={who} src={avatarSrc} size={32} className="gb-tc-msg__av" />}
|
|
497
|
+
<div className="gb-tc-msg__main">
|
|
498
|
+
{replyTo ? (
|
|
499
|
+
<button type="button" className="gb-tc-quote" aria-busy={replyTo.busy || undefined} onClick={replyTo.onOpen}>
|
|
500
|
+
<span className="gb-tc-quote__who">{replyTo.author || t.someone}</span>
|
|
501
|
+
<span className="gb-tc-quote__text">{replyTo.excerpt}</span>
|
|
502
|
+
</button>
|
|
503
|
+
) : null}
|
|
504
|
+
{grouped ? <span className="gb-tc-sr">{who}, {time}: </span> : (
|
|
505
|
+
<div className="gb-tc-msg__who">
|
|
506
|
+
<span className="gb-tc-msg__name">{who}</span>
|
|
507
|
+
{badges}
|
|
508
|
+
<time className="gb-tc-msg__time" dateTime={dateTime}>{time}</time>
|
|
509
|
+
</div>
|
|
510
|
+
)}
|
|
511
|
+
{editor != null ? <div className="gb-tc-msg__editor">{editor}</div>
|
|
512
|
+
: body != null || edited || receipt ? (
|
|
513
|
+
<div className="gb-tc-msg__body">
|
|
514
|
+
{body}
|
|
515
|
+
{edited ? <span className="gb-tc-msg__edited">{t.edited}</span> : null}
|
|
516
|
+
{receipt}
|
|
517
|
+
</div>
|
|
518
|
+
) : null}
|
|
519
|
+
{status ? <p className="gb-tc-msg__status" role={failed ? "alert" : undefined}>{status}</p> : null}
|
|
520
|
+
{attachments ? <div className="gb-tc-msg__files">{attachments}</div> : null}
|
|
521
|
+
{extra ? <div className="gb-tc-msg__extra">{extra}</div> : null}
|
|
522
|
+
{picking && onReact ? (
|
|
523
|
+
<ReactionPicker items={reactions}
|
|
524
|
+
onPick={(e) => { setPicking(false); onReact(e); reactBtn.current?.focus(); }}
|
|
525
|
+
onClose={() => { setPicking(false); reactBtn.current?.focus(); }} />
|
|
526
|
+
) : null}
|
|
527
|
+
{reactions.length ? <Reactions items={reactions} onToggle={onReact} canReact={Boolean(onReact)} target={`the message from ${who}`} /> : null}
|
|
528
|
+
{thread && thread.count > 0 ? (
|
|
529
|
+
<button type="button" className="gb-tc-threadprev" aria-label={t.openThread(thread.count, thread.last ?? "")} onClick={thread.onOpen}>
|
|
530
|
+
{thread.repliers?.length ? (
|
|
531
|
+
<span className="gb-tc-threadprev__faces" aria-hidden="true">
|
|
532
|
+
{thread.repliers.slice(0, 3).map((r, i) => <Avatar key={`${r.name}-${i}`} name={r.name} src={r.src} size={24} />)}
|
|
533
|
+
</span>
|
|
534
|
+
) : null}
|
|
535
|
+
<span className="gb-tc-threadprev__count">{t.replies(thread.count)}</span>
|
|
536
|
+
{thread.last ? <span className="gb-tc-threadprev__last">{thread.last}</span> : null}
|
|
537
|
+
</button>
|
|
538
|
+
) : null}
|
|
539
|
+
</div>
|
|
540
|
+
{showActs ? (
|
|
541
|
+
<div className="gb-tc-acts" role="toolbar" aria-label={t.actionsFor(who)} onKeyDown={onActsKey}>
|
|
542
|
+
{onReact ? QUICK_REACTIONS.map((e) => (
|
|
543
|
+
<button key={e} type="button" className="gb-tc-act gb-tc-act--quick" aria-pressed={mine(e)} aria-label={t.react(e, who, mine(e))} onClick={() => onReact(e)}>{e}</button>
|
|
544
|
+
)) : null}
|
|
545
|
+
{onReact ? (
|
|
546
|
+
<button ref={reactBtn} type="button" className="gb-tc-act" aria-expanded={picking} aria-label={t.moreReactions(who)} title="Add reaction" onClick={() => setPicking((p) => !p)}>
|
|
547
|
+
<Icon name="reaction" size={18} />
|
|
548
|
+
</button>
|
|
549
|
+
) : null}
|
|
550
|
+
{onReply ? <button type="button" className="gb-tc-act" aria-label={t.replyTo(who)} title={t.reply} onClick={onReply}><Icon name="reply" size={18} /></button> : null}
|
|
551
|
+
{onOpenThread ? <button type="button" className="gb-tc-act" aria-label={t.replyInThreadTo(who)} title={t.replyInThread} onClick={onOpenThread}><Icon name="thread" size={18} /></button> : null}
|
|
552
|
+
{moreActions}
|
|
553
|
+
</div>
|
|
554
|
+
) : null}
|
|
555
|
+
</li>
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// ── The composer ───────────────────────────────────────────────────────────────────────────────────────────────────
|
|
560
|
+
export type ComposerProps = {
|
|
561
|
+
/** Send the text (trimmed). The composer clears itself when it holds the text (no `value`). */
|
|
562
|
+
onSend: (text: string) => void;
|
|
563
|
+
/** Controlled text; leave out to let the composer hold it. */
|
|
564
|
+
value?: string;
|
|
565
|
+
defaultValue?: string;
|
|
566
|
+
onValueChange?: (text: string) => void;
|
|
567
|
+
placeholder?: string;
|
|
568
|
+
/** The field's label (visually hidden). Default "Message". */
|
|
569
|
+
label?: string;
|
|
570
|
+
id?: string;
|
|
571
|
+
disabled?: boolean;
|
|
572
|
+
/** A send is in flight: Send waits. */
|
|
573
|
+
busy?: boolean;
|
|
574
|
+
/** Send with no text (files only). */
|
|
575
|
+
canSendEmpty?: boolean;
|
|
576
|
+
/** "Replying to Linh: …", right above the field (where the writer looks), with a ✕. */
|
|
577
|
+
replyingTo?: React.ReactNode;
|
|
578
|
+
onCancelReply?: () => void;
|
|
579
|
+
/** Files waiting to go. */
|
|
580
|
+
files?: React.ReactNode;
|
|
581
|
+
/** The app's @mention picker, opened above the field. */
|
|
582
|
+
picker?: React.ReactNode;
|
|
583
|
+
/** Show the paperclip; the app opens its file input. */
|
|
584
|
+
onAttach?: () => void;
|
|
585
|
+
/** The thread's "Also send to #general". */
|
|
586
|
+
also?: { label: React.ReactNode; checked: boolean; onChange: (checked: boolean) => void };
|
|
587
|
+
error?: React.ReactNode;
|
|
588
|
+
inputRef?: React.Ref<HTMLTextAreaElement>;
|
|
589
|
+
/** Called first; `preventDefault()` in it (a mention picker took Enter) and the composer does not send. */
|
|
590
|
+
onKeyDown?: React.KeyboardEventHandler<HTMLTextAreaElement>;
|
|
591
|
+
onPaste?: React.ClipboardEventHandler<HTMLTextAreaElement>;
|
|
592
|
+
maxLength?: number;
|
|
593
|
+
/** Rows the field grows to before it scrolls. Default 8. */
|
|
594
|
+
maxRows?: number;
|
|
595
|
+
className?: string;
|
|
596
|
+
labels?: Partial<TeamChatLabels>;
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* The field and its buttons in one box (paperclip · text · send), as Slack: one row even at 320px. Enter sends,
|
|
601
|
+
* Shift+Enter is a new line, and an IME composition (Vietnamese Telex, Japanese) is never cut short by its own Enter.
|
|
602
|
+
* The field grows with its text (`field-sizing: content`; where a browser lacks it, the component sets the height).
|
|
603
|
+
*/
|
|
604
|
+
export function Composer({ onSend, value, defaultValue = "", onValueChange, placeholder, label, id, disabled, busy, canSendEmpty, replyingTo, onCancelReply,
|
|
605
|
+
files, picker, onAttach, also, error, inputRef, onKeyDown, onPaste, maxLength = 4000, maxRows = 8, className, labels }: ComposerProps) {
|
|
606
|
+
const t = words(labels);
|
|
607
|
+
const auto = useId();
|
|
608
|
+
const fieldId = id ?? `${auto}-input`;
|
|
609
|
+
const [own, setOwn] = useState(defaultValue);
|
|
610
|
+
const text = value ?? own;
|
|
611
|
+
const field = useRef<HTMLTextAreaElement | null>(null);
|
|
612
|
+
const setField = (el: HTMLTextAreaElement | null) => {
|
|
613
|
+
field.current = el;
|
|
614
|
+
if (typeof inputRef === "function") inputRef(el);
|
|
615
|
+
else if (inputRef && typeof inputRef === "object") (inputRef as React.MutableRefObject<HTMLTextAreaElement | null>).current = el;
|
|
616
|
+
};
|
|
617
|
+
const set = (v: string) => { if (value === undefined) setOwn(v); onValueChange?.(v); };
|
|
618
|
+
// Grow with the text where `field-sizing: content` is missing (Safari, Firefox in 2026). CSSOM, not a style attribute.
|
|
619
|
+
useIsoLayoutEffect(() => {
|
|
620
|
+
const el = field.current;
|
|
621
|
+
if (!el || (typeof CSS !== "undefined" && CSS.supports?.("field-sizing", "content"))) return;
|
|
622
|
+
el.style.height = "auto";
|
|
623
|
+
const line = parseFloat(getComputedStyle(el).lineHeight) || 20;
|
|
624
|
+
el.style.height = `${Math.min(el.scrollHeight, line * maxRows + 12)}px`;
|
|
625
|
+
}, [text, maxRows]);
|
|
626
|
+
const can = !disabled && !busy && (text.trim() !== "" || Boolean(canSendEmpty));
|
|
627
|
+
const send = () => {
|
|
628
|
+
if (!can) return;
|
|
629
|
+
onSend(text.trim());
|
|
630
|
+
if (value === undefined) setOwn("");
|
|
631
|
+
};
|
|
632
|
+
return (
|
|
633
|
+
<form className={cx("gb-tc-composer", className)} onSubmit={(e) => { e.preventDefault(); send(); }}>
|
|
634
|
+
{replyingTo ? (
|
|
635
|
+
<div className="gb-tc-replying">
|
|
636
|
+
<span className="gb-tc-replying__text">{replyingTo}</span>
|
|
637
|
+
{onCancelReply ? (
|
|
638
|
+
<button type="button" className="ui-btn ui-btn--ghost ui-btn--icon" aria-label={t.stopReplying} onClick={onCancelReply}><Icon name="close" /></button>
|
|
639
|
+
) : null}
|
|
640
|
+
</div>
|
|
641
|
+
) : null}
|
|
642
|
+
{files ? <div className="gb-tc-composer__files">{files}</div> : null}
|
|
643
|
+
{picker ? <div className="gb-tc-composer__picker">{picker}</div> : null}
|
|
644
|
+
<div className="gb-tc-composer__box">
|
|
645
|
+
{onAttach ? (
|
|
646
|
+
<button type="button" className="ui-btn ui-btn--ghost ui-btn--icon gb-tc-composer__attach" aria-label={t.attach} disabled={disabled} onClick={onAttach}>
|
|
647
|
+
<Icon name="paperclip" />
|
|
648
|
+
</button>
|
|
649
|
+
) : null}
|
|
650
|
+
<label className="gb-tc-sr" htmlFor={fieldId}>{label ?? t.message}</label>
|
|
651
|
+
<textarea ref={setField} id={fieldId} className="gb-tc-composer__input" rows={1} maxLength={maxLength} placeholder={placeholder}
|
|
652
|
+
value={text} disabled={disabled} onPaste={onPaste}
|
|
653
|
+
onChange={(e) => set(e.target.value)}
|
|
654
|
+
onKeyDown={(e) => {
|
|
655
|
+
onKeyDown?.(e);
|
|
656
|
+
if (e.defaultPrevented || e.key !== "Enter" || e.shiftKey || e.nativeEvent.isComposing || e.keyCode === 229) return;
|
|
657
|
+
e.preventDefault();
|
|
658
|
+
send();
|
|
659
|
+
}} />
|
|
660
|
+
<button type="submit" className="ui-btn ui-btn--primary ui-btn--icon gb-tc-composer__send" aria-label={t.send} disabled={!can}>
|
|
661
|
+
<Icon name="send" />
|
|
662
|
+
</button>
|
|
663
|
+
</div>
|
|
664
|
+
{also ? (
|
|
665
|
+
<label className="ui-check gb-tc-composer__also">
|
|
666
|
+
<input type="checkbox" checked={also.checked} onChange={(e) => also.onChange(e.target.checked)} />
|
|
667
|
+
<span>{also.label}</span>
|
|
668
|
+
</label>
|
|
669
|
+
) : null}
|
|
670
|
+
{error ? <p className="gb-tc-composer__error" role="alert">{error}</p> : null}
|
|
671
|
+
</form>
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// ── The right panels ───────────────────────────────────────────────────────────────────────────────────────────────
|
|
676
|
+
export type ThreadPanelProps = {
|
|
677
|
+
onClose: () => void;
|
|
678
|
+
/** The thread's parent message (a `Message`), on top. */
|
|
679
|
+
root?: React.ReactNode;
|
|
680
|
+
/** The "N replies" rule under the root. */
|
|
681
|
+
replyCount?: number;
|
|
682
|
+
/** The replies (`Message`s), flat. */
|
|
683
|
+
children?: React.ReactNode;
|
|
684
|
+
/** A `Composer` ("Reply to thread", with `also`). */
|
|
685
|
+
composer?: React.ReactNode;
|
|
686
|
+
/** Under the head: "That message was taken down…", an error. */
|
|
687
|
+
notice?: React.ReactNode;
|
|
688
|
+
/** `error`: the notice in the danger colour (a send that failed, a thread that could not load). */
|
|
689
|
+
noticeTone?: "error";
|
|
690
|
+
title?: React.ReactNode;
|
|
691
|
+
/** "#general". */
|
|
692
|
+
subtitle?: React.ReactNode;
|
|
693
|
+
id?: string;
|
|
694
|
+
className?: string;
|
|
695
|
+
labels?: Partial<TeamChatLabels>;
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
/** The thread, as Lark and Slack: its root on top, a "3 replies" rule, the replies flat, then "Reply to thread". */
|
|
699
|
+
export function ThreadPanel({ onClose, root, replyCount, children, composer, notice, noticeTone, title, subtitle, id, className, labels }: ThreadPanelProps) {
|
|
700
|
+
const t = words(labels);
|
|
701
|
+
const auto = useId();
|
|
702
|
+
const hid = `${id ?? auto}-title`;
|
|
703
|
+
return (
|
|
704
|
+
<aside id={id} className={cx("gb-tc-thread", className)} aria-labelledby={hid} tabIndex={-1}>
|
|
705
|
+
<header className="gb-tc-panel-head">
|
|
706
|
+
<div className="gb-tc-panel-head__text">
|
|
707
|
+
<h2 className="gb-tc-panel-head__title" id={hid}>{title ?? t.thread}</h2>
|
|
708
|
+
{subtitle ? <p className="gb-tc-panel-head__sub">{subtitle}</p> : null}
|
|
709
|
+
</div>
|
|
710
|
+
<button type="button" className="ui-btn ui-btn--ghost ui-btn--icon gb-tc-panel-head__close" aria-label={t.closeThread} onClick={onClose}><Icon name="close" /></button>
|
|
711
|
+
</header>
|
|
712
|
+
{notice ? <div className={cx("gb-tc-thread__note", noticeTone === "error" && "gb-tc-thread__note--error")} role={noticeTone === "error" ? "alert" : undefined}>{notice}</div> : null}
|
|
713
|
+
<div className="gb-tc-body">
|
|
714
|
+
<div className="gb-tc-log" role="log" aria-live="polite" aria-labelledby={hid} tabIndex={0}>
|
|
715
|
+
<ol className="gb-tc-list">
|
|
716
|
+
{root}
|
|
717
|
+
{replyCount !== undefined ? <li className="gb-tc-thread__count"><span>{t.replies(replyCount)}</span></li> : null}
|
|
718
|
+
{children}
|
|
719
|
+
</ol>
|
|
720
|
+
</div>
|
|
721
|
+
</div>
|
|
722
|
+
{composer}
|
|
723
|
+
</aside>
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
export type PeoplePanelProps = {
|
|
728
|
+
/** `PeopleSection`s ("Online — 3", "Members — 12"). */
|
|
729
|
+
children: React.ReactNode;
|
|
730
|
+
title?: React.ReactNode;
|
|
731
|
+
/** A 56px strip of avatars (a column on a wide screen); in the chat at ≤1100px it is the drawer CLOSED. */
|
|
732
|
+
collapsed?: boolean;
|
|
733
|
+
/** The head's collapse button. Absent: no button. */
|
|
734
|
+
onToggleCollapse?: () => void;
|
|
735
|
+
/** Before the collapse button: "Invite". Hidden while collapsed. */
|
|
736
|
+
actions?: React.ReactNode;
|
|
737
|
+
id?: string;
|
|
738
|
+
className?: string;
|
|
739
|
+
labels?: Partial<TeamChatLabels>;
|
|
740
|
+
};
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Who is here: sections of people with their avatar, an online dot, a name and a line under it, and a role chip.
|
|
744
|
+
* A column at the right of the chat, or on its own (GemCommunity's app shell puts it in its right column). Collapsed,
|
|
745
|
+
* it is a strip of avatars; the names stay in the accessibility tree (visually hidden, not removed).
|
|
746
|
+
*/
|
|
747
|
+
export function PeoplePanel({ children, title, collapsed = false, onToggleCollapse, actions, id, className, labels }: PeoplePanelProps) {
|
|
748
|
+
const t = words(labels);
|
|
749
|
+
const auto = useId();
|
|
750
|
+
const base = id ?? auto;
|
|
751
|
+
return (
|
|
752
|
+
<aside id={id} className={cx("gb-tc-people", className)} aria-labelledby={`${base}-title`} tabIndex={-1} data-collapsed={collapsed || undefined}>
|
|
753
|
+
<header className="gb-tc-panel-head">
|
|
754
|
+
<div className="gb-tc-panel-head__text">
|
|
755
|
+
<h2 className="gb-tc-panel-head__title" id={`${base}-title`}>{title ?? t.members}</h2>
|
|
756
|
+
</div>
|
|
757
|
+
{actions && !collapsed ? <div className="gb-tc-panel-head__actions">{actions}</div> : null}
|
|
758
|
+
{onToggleCollapse ? (
|
|
759
|
+
<button type="button" className="ui-btn ui-btn--ghost ui-btn--icon gb-tc-people__toggle" aria-expanded={!collapsed} aria-controls={`${base}-body`}
|
|
760
|
+
aria-label={collapsed ? t.showMembers : t.hideMembers} title={collapsed ? t.showMembers : t.hideMembers} onClick={onToggleCollapse}>
|
|
761
|
+
<Icon name="panelRight" />
|
|
762
|
+
</button>
|
|
763
|
+
) : null}
|
|
764
|
+
</header>
|
|
765
|
+
<div className="gb-tc-people__body" id={`${base}-body`}>{children}</div>
|
|
766
|
+
</aside>
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/** A section of the people panel: its heading reads "Online — 3". */
|
|
771
|
+
export function PeopleSection({ label, count, children, id }: { label: React.ReactNode; count?: number; children: React.ReactNode; id?: string }) {
|
|
772
|
+
const auto = useId();
|
|
773
|
+
const hid = `${id ?? auto}-h`;
|
|
774
|
+
return (
|
|
775
|
+
<div className="gb-tc-people__section" role="group" aria-labelledby={hid}>
|
|
776
|
+
<h3 className="gb-tc-people__heading" id={hid}>{label}{count !== undefined ? ` — ${count}` : null}</h3>
|
|
777
|
+
<ul className="gb-tc-people__list">{children}</ul>
|
|
778
|
+
</div>
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
export type Presence = "online" | "away" | "offline";
|
|
783
|
+
// Whole class names, not `gb-tc-presence--${presence}`: a class built from a stem is one no gate can find in the markup.
|
|
784
|
+
const PRESENCE_CLASS: Record<Presence, string> = { online: "gb-tc-presence gb-tc-presence--online", away: "gb-tc-presence gb-tc-presence--away", offline: "gb-tc-presence gb-tc-presence--offline" };
|
|
785
|
+
export type PersonRowProps = {
|
|
786
|
+
name: string;
|
|
787
|
+
src?: string | null;
|
|
788
|
+
avatar?: React.ReactNode;
|
|
789
|
+
/** One line under the name: a title, a status. */
|
|
790
|
+
subtitle?: React.ReactNode;
|
|
791
|
+
/** A chip at the right: "Admin", "Mod". */
|
|
792
|
+
role?: React.ReactNode;
|
|
793
|
+
presence?: Presence;
|
|
794
|
+
href?: string;
|
|
795
|
+
linkAs?: React.ElementType;
|
|
796
|
+
onSelect?: () => void;
|
|
797
|
+
labels?: Partial<TeamChatLabels>;
|
|
798
|
+
};
|
|
799
|
+
|
|
800
|
+
/** One person: a link (their profile), a button (open a DM), or plain when it does nothing. */
|
|
801
|
+
export function PersonRow({ name, src, avatar, subtitle, role, presence, href, linkAs, onSelect, labels }: PersonRowProps) {
|
|
802
|
+
const t = words(labels);
|
|
803
|
+
const inner = (
|
|
804
|
+
<>
|
|
805
|
+
<span className="gb-tc-person__av">
|
|
806
|
+
{avatar ?? <Avatar name={name} src={src} size={32} />}
|
|
807
|
+
{presence ? <span className={PRESENCE_CLASS[presence]}><span className="gb-tc-sr">{t[presence]}</span></span> : null}
|
|
808
|
+
</span>
|
|
809
|
+
<span className="gb-tc-person__text">
|
|
810
|
+
<span className="gb-tc-person__name">{name}</span>
|
|
811
|
+
{subtitle ? <span className="gb-tc-person__sub">{subtitle}</span> : null}
|
|
812
|
+
</span>
|
|
813
|
+
{role ? <span className="gb-tc-person__role">{role}</span> : null}
|
|
814
|
+
</>
|
|
815
|
+
);
|
|
816
|
+
const cls = cx("gb-tc-person", presence === "offline" && "gb-tc-person--offline");
|
|
817
|
+
const Link = linkAs ?? "a";
|
|
818
|
+
return (
|
|
819
|
+
<li>
|
|
820
|
+
{href !== undefined ? <Link className={cls} href={href} title={name} onClick={onSelect}>{inner}</Link>
|
|
821
|
+
: onSelect ? <button type="button" className={cls} title={name} onClick={onSelect}>{inner}</button>
|
|
822
|
+
: <div className={cls} title={name}>{inner}</div>}
|
|
823
|
+
</li>
|
|
824
|
+
);
|
|
825
|
+
}
|