@marktiderman/genesis-chat 0.1.1 → 0.2.1
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/README.md +379 -13
- package/dist/chunk-A4YJX7KT.js +544 -0
- package/dist/chunk-A4YJX7KT.js.map +1 -0
- package/dist/chunk-AZS3HNLM.js +30 -0
- package/dist/chunk-AZS3HNLM.js.map +1 -0
- package/dist/chunk-BJCE5O7V.js +70 -0
- package/dist/chunk-BJCE5O7V.js.map +1 -0
- package/dist/chunk-GBJ3YI4E.js +966 -0
- package/dist/chunk-GBJ3YI4E.js.map +1 -0
- package/dist/chunk-JU2FAPBI.js +609 -0
- package/dist/chunk-JU2FAPBI.js.map +1 -0
- package/dist/chunk-VEPZROQB.js +187 -0
- package/dist/chunk-VEPZROQB.js.map +1 -0
- package/dist/{client-BtdALFCx.d.ts → client-DUIG1hum.d.ts} +187 -11
- package/dist/index.d.ts +78 -163
- package/dist/index.js +3 -2
- package/dist/native.d.ts +1939 -1
- package/dist/native.js +5023 -0
- package/dist/native.js.map +1 -1
- package/dist/outbox-B9NAWQ58.d.ts +219 -0
- package/dist/push.d.ts +206 -0
- package/dist/push.js +3 -0
- package/dist/push.js.map +1 -0
- package/dist/react.d.ts +176 -7
- package/dist/react.js +3 -3
- package/dist/server.d.ts +551 -21
- package/dist/server.js +976 -34
- package/dist/server.js.map +1 -1
- package/dist/thread-oPra7pkW.d.ts +57 -0
- package/dist/web.d.ts +335 -12
- package/dist/web.js +1784 -64
- package/dist/web.js.map +1 -1
- package/migrations/0002_genesis_chat_coaching.sql +934 -0
- package/migrations/0003_genesis_chat_search_threads.sql +85 -0
- package/migrations/tests/0002_genesis_chat_coaching.test.sql +269 -0
- package/migrations/tests/0003_genesis_chat_search_threads.test.sql +50 -0
- package/package.json +78 -6
- package/src/__tests__/coaching-core.test.ts +334 -0
- package/src/__tests__/coaching-react.test.tsx +319 -0
- package/src/__tests__/coaching-web.test.tsx +521 -0
- package/src/__tests__/core.test.ts +91 -0
- package/src/__tests__/fake-xhr.ts +54 -0
- package/src/__tests__/outbox-hosted.test.ts +269 -0
- package/src/__tests__/outbox.test.ts +122 -0
- package/src/__tests__/realtime.test.ts +70 -0
- package/src/__tests__/review-279.test.ts +41 -0
- package/src/__tests__/review-fixes-core.test.ts +193 -0
- package/src/__tests__/review-fixes-web.test.tsx +405 -0
- package/src/__tests__/server-effects.test.ts +158 -0
- package/src/__tests__/server-fake-video.test.ts +81 -0
- package/src/__tests__/server-fakes.ts +96 -0
- package/src/__tests__/server-integrations.test.ts +247 -0
- package/src/__tests__/server-providers.test.ts +246 -0
- package/src/__tests__/server-runner.test.ts +277 -0
- package/src/__tests__/server-safety.test.ts +303 -0
- package/src/__tests__/web-markdown.test.tsx +88 -0
- package/src/__tests__/web.test.tsx +349 -0
- package/src/core/availability.ts +223 -0
- package/src/core/client.ts +783 -0
- package/src/core/extras.ts +106 -0
- package/src/core/media.ts +144 -0
- package/src/core/outbox.ts +635 -0
- package/src/core/thread.ts +171 -0
- package/src/core/transfer.ts +71 -0
- package/src/core/types.ts +328 -0
- package/src/index.ts +14 -0
- package/src/native/__tests__/auto-retry.test.tsx +128 -0
- package/src/native/__tests__/composer-mic-lock.test.tsx +115 -0
- package/src/native/__tests__/composer-state.test.ts +188 -0
- package/src/native/__tests__/composer.test.tsx +133 -0
- package/src/native/__tests__/emoji.test.tsx +254 -0
- package/src/native/__tests__/inbox-screen.test.tsx +48 -0
- package/src/native/__tests__/message-bubble.test.tsx +127 -0
- package/src/native/__tests__/organise-logic.test.ts +164 -0
- package/src/native/__tests__/organise-screens.test.tsx +424 -0
- package/src/native/__tests__/photo-viewer.test.tsx +226 -0
- package/src/native/__tests__/push-payload.test.ts +66 -0
- package/src/native/__tests__/push.test.ts +209 -0
- package/src/native/__tests__/recorder-machine.test.ts +96 -0
- package/src/native/__tests__/rows-actions.test.ts +76 -0
- package/src/native/__tests__/thread-layout.test.ts +41 -0
- package/src/native/__tests__/thread-screen.test.tsx +116 -0
- package/src/native/__tests__/tus.test.ts +154 -0
- package/src/native/__tests__/use-voice-recorder.test.tsx +316 -0
- package/src/native/__tests__/video-media.test.tsx +498 -0
- package/src/native/__tests__/video-recorder-state.test.ts +87 -0
- package/src/native/__tests__/video-upload.test.ts +227 -0
- package/src/native/access-error.tsx +31 -0
- package/src/native/actions.ts +42 -0
- package/src/native/attachment-cards.tsx +132 -0
- package/src/native/auto-retry.ts +69 -0
- package/src/native/availability.tsx +376 -0
- package/src/native/broadcast-sheet.tsx +193 -0
- package/src/native/composer-state.ts +302 -0
- package/src/native/composer.tsx +453 -0
- package/src/native/context.tsx +70 -0
- package/src/native/copy.ts +296 -0
- package/src/native/emoji-data.ts +21 -0
- package/src/native/emoji-picker.tsx +229 -0
- package/src/native/emoji.ts +118 -0
- package/src/native/icon-button.tsx +40 -0
- package/src/native/inbox-screen.tsx +122 -0
- package/src/native/index.ts +184 -0
- package/src/native/link-preview.tsx +104 -0
- package/src/native/media-colors.ts +15 -0
- package/src/native/media.tsx +187 -0
- package/src/native/message-actions-sheet.tsx +126 -0
- package/src/native/message-bubble.tsx +366 -0
- package/src/native/moderation.tsx +42 -0
- package/src/native/modules.ts +277 -0
- package/src/native/organise-copy.ts +195 -0
- package/src/native/photo-viewer.tsx +531 -0
- package/src/native/push.ts +375 -0
- package/src/native/recorder-machine.ts +176 -0
- package/src/native/rows.ts +81 -0
- package/src/native/schedule-sheet.tsx +328 -0
- package/src/native/search-screen.tsx +189 -0
- package/src/native/stack-options.ts +36 -0
- package/src/native/start-sheet.tsx +87 -0
- package/src/native/thread-screen.tsx +647 -0
- package/src/native/topics.tsx +234 -0
- package/src/native/tus.ts +282 -0
- package/src/native/use-composer.ts +71 -0
- package/src/native/use-jump.ts +91 -0
- package/src/native/use-voice-recorder.ts +234 -0
- package/src/native/video-player.tsx +410 -0
- package/src/native/video-recorder-state.ts +140 -0
- package/src/native/video-recorder.tsx +274 -0
- package/src/native/video-upload-tray.tsx +77 -0
- package/src/native/video-upload.ts +432 -0
- package/src/native/voice-player.tsx +301 -0
- package/src/react/index.tsx +894 -0
- package/src/react/previews.ts +51 -0
- package/src/server/assistant.ts +235 -0
- package/src/server/crypto.ts +86 -0
- package/src/server/expo.ts +119 -0
- package/src/server/fake-video.ts +171 -0
- package/src/server/handler.ts +32 -0
- package/src/server/index.ts +58 -0
- package/src/server/moderation.ts +155 -0
- package/src/server/mux.ts +290 -0
- package/src/server/notifications.ts +130 -0
- package/src/server/runner.ts +468 -0
- package/src/server/transcribers.ts +111 -0
- package/src/server/types.ts +171 -0
- package/src/server/unfurl.ts +480 -0
- package/src/server/upload.ts +158 -0
- package/src/web/attachments.tsx +379 -0
- package/src/web/availability.tsx +299 -0
- package/src/web/broadcast.tsx +166 -0
- package/src/web/bubble.tsx +585 -0
- package/src/web/cards.tsx +136 -0
- package/src/web/composer.tsx +571 -0
- package/src/web/emoji-picker.tsx +84 -0
- package/src/web/error-boundary.tsx +33 -0
- package/src/web/format.ts +55 -0
- package/src/web/inbox.tsx +196 -0
- package/src/web/index.ts +75 -0
- package/src/web/markdown.tsx +245 -0
- package/src/web/memory-client.ts +714 -0
- package/src/web/panel.tsx +135 -0
- package/src/web/schedule.tsx +204 -0
- package/src/web/search.tsx +129 -0
- package/src/web/thread-context.ts +64 -0
- package/src/web/thread.tsx +830 -0
- package/src/web/topics.tsx +160 -0
- package/src/web/use-voice-recorder.ts +264 -0
- package/src/web/video.tsx +516 -0
- package/src/web/voice-note.tsx +197 -0
- package/dist/chunk-27UTR5FW.js +0 -322
- package/dist/chunk-27UTR5FW.js.map +0 -1
- package/dist/chunk-JQXZUEEL.js +0 -525
- package/dist/chunk-JQXZUEEL.js.map +0 -1
- package/dist/chunk-SAY24E6V.js +0 -309
- package/dist/chunk-SAY24E6V.js.map +0 -1
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @feat:chat
|
|
3
|
+
* One row of a thread: a system line, or a bubble (yours on the right in the
|
|
4
|
+
* primary colour, theirs on the left), with its reply quote, body, attachments,
|
|
5
|
+
* time and edited marker, reactions, and actions on hover, right-click or
|
|
6
|
+
* long-press. Pending messages show "Sending…"; failed ones offer Retry and
|
|
7
|
+
* Delete.
|
|
8
|
+
*/
|
|
9
|
+
import { useEffect, useRef, useState, type ComponentType, type MutableRefObject, type ReactNode } from "react";
|
|
10
|
+
import {
|
|
11
|
+
AlertCircle,
|
|
12
|
+
Ban,
|
|
13
|
+
Bookmark,
|
|
14
|
+
Clock,
|
|
15
|
+
Copy,
|
|
16
|
+
EyeOff,
|
|
17
|
+
Flag,
|
|
18
|
+
Lock,
|
|
19
|
+
MessageSquare,
|
|
20
|
+
MoreHorizontal,
|
|
21
|
+
Pencil,
|
|
22
|
+
Pin,
|
|
23
|
+
PinOff,
|
|
24
|
+
Plus,
|
|
25
|
+
Reply,
|
|
26
|
+
ShieldAlert,
|
|
27
|
+
SmilePlus,
|
|
28
|
+
Trash2,
|
|
29
|
+
} from "lucide-react";
|
|
30
|
+
import {
|
|
31
|
+
Badge,
|
|
32
|
+
Button,
|
|
33
|
+
ContextMenu,
|
|
34
|
+
ContextMenuContent,
|
|
35
|
+
ContextMenuItem,
|
|
36
|
+
ContextMenuSeparator,
|
|
37
|
+
ContextMenuTrigger,
|
|
38
|
+
DropdownMenu,
|
|
39
|
+
DropdownMenuContent,
|
|
40
|
+
DropdownMenuItem,
|
|
41
|
+
DropdownMenuSeparator,
|
|
42
|
+
DropdownMenuTrigger,
|
|
43
|
+
Popover,
|
|
44
|
+
PopoverContent,
|
|
45
|
+
PopoverTrigger,
|
|
46
|
+
Progress,
|
|
47
|
+
Textarea,
|
|
48
|
+
Tooltip,
|
|
49
|
+
UserAvatar,
|
|
50
|
+
cn,
|
|
51
|
+
} from "@marktiderman/genesis-ui";
|
|
52
|
+
import { attachmentKindFor } from "../core/media";
|
|
53
|
+
import type { QueuedMessage } from "../core/outbox";
|
|
54
|
+
import { previewText, summarizeReactions, type ThreadItem } from "../core/thread";
|
|
55
|
+
import type { ChatMessage, PendingMessage } from "../core/types";
|
|
56
|
+
import { MessageAttachments, toAttachmentView, type AttachmentView } from "./attachments";
|
|
57
|
+
import { ChatLinkPreview } from "./cards";
|
|
58
|
+
import { ChatEmojiPicker, canPickEmoji } from "./emoji-picker";
|
|
59
|
+
import { clockTime, messageLabel } from "./format";
|
|
60
|
+
import { ChatMarkdown } from "./markdown";
|
|
61
|
+
import { useThreadContext } from "./thread-context";
|
|
62
|
+
|
|
63
|
+
export interface MessageRowProps {
|
|
64
|
+
item: ThreadItem;
|
|
65
|
+
/** First of a run from one sender: the name and avatar show (others' messages, in groups). */
|
|
66
|
+
startsGroup: boolean;
|
|
67
|
+
className?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The pending message as a message, so one bubble draws both. */
|
|
71
|
+
function pendingAsMessage(p: PendingMessage): ChatMessage {
|
|
72
|
+
return {
|
|
73
|
+
id: `pending:${p.clientId}`,
|
|
74
|
+
conversationId: p.input.conversationId,
|
|
75
|
+
seq: Number.MAX_SAFE_INTEGER,
|
|
76
|
+
clientId: p.clientId,
|
|
77
|
+
senderId: p.senderId,
|
|
78
|
+
senderRole: "member",
|
|
79
|
+
kind: "text",
|
|
80
|
+
body: p.input.body ?? "",
|
|
81
|
+
mentions: p.input.mentions ?? [],
|
|
82
|
+
replyToId: p.input.replyToId ?? null,
|
|
83
|
+
threadRootId: p.input.threadRootId ?? null,
|
|
84
|
+
threadCount: 0,
|
|
85
|
+
threadLastAt: null,
|
|
86
|
+
topic: p.input.topic ?? null,
|
|
87
|
+
visibility: p.input.visibility ?? "everyone",
|
|
88
|
+
silent: Boolean(p.input.silent),
|
|
89
|
+
meta: {},
|
|
90
|
+
createdAt: p.createdAt,
|
|
91
|
+
editedAt: null,
|
|
92
|
+
deletedAt: null,
|
|
93
|
+
moderation: null,
|
|
94
|
+
attachments: [],
|
|
95
|
+
reactions: [],
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function pendingViews(p: PendingMessage): AttachmentView[] {
|
|
100
|
+
const files = (p as Partial<QueuedMessage>).files ?? [];
|
|
101
|
+
return files.map((f, i) => ({
|
|
102
|
+
key: `${p.clientId}:${i}`,
|
|
103
|
+
kind: f.meta?.kind ?? attachmentKindFor(f.contentType),
|
|
104
|
+
url: p.previews?.[i] ?? f.previewUrl ?? null,
|
|
105
|
+
posterUrl: null,
|
|
106
|
+
name: f.meta?.name ?? f.name,
|
|
107
|
+
mime: f.contentType,
|
|
108
|
+
bytes: f.meta?.bytes ?? (typeof Blob !== "undefined" && f.data instanceof Blob ? f.data.size : null),
|
|
109
|
+
width: f.meta?.width ?? null,
|
|
110
|
+
height: f.meta?.height ?? null,
|
|
111
|
+
durationMs: f.meta?.durationMs ?? null,
|
|
112
|
+
waveform: f.meta?.waveform ?? null,
|
|
113
|
+
transcript: null,
|
|
114
|
+
transcriptStatus: null,
|
|
115
|
+
pending: p.status === "sending",
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** How much of a sending message's files has uploaded (0–100), once any file reports progress. */
|
|
120
|
+
export function pendingProgress(p: PendingMessage): number | null {
|
|
121
|
+
if (p.status !== "sending") return null;
|
|
122
|
+
const files = p.files ?? [];
|
|
123
|
+
if (!files.some((f) => typeof f.progress === "number")) return null;
|
|
124
|
+
return Math.round((files.reduce((sum, f) => sum + (f.progress ?? 0), 0) / files.length) * 100);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A menu gives focus back to what opened it, unless the action put it somewhere (the composer, the edit box). */
|
|
128
|
+
function returnFocus(e: Event, keepFocus: MutableRefObject<(() => void) | null>) {
|
|
129
|
+
const next = keepFocus.current;
|
|
130
|
+
if (next) {
|
|
131
|
+
e.preventDefault();
|
|
132
|
+
keepFocus.current = null;
|
|
133
|
+
next();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Plain text for the clipboard: mentions as @Name. */
|
|
138
|
+
export function plainText(body: string): string {
|
|
139
|
+
return body.replace(/@\[([^\]]+)\]\(user:[0-9a-f-]{36}\)/gi, "@$1");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function MessageRow({ item, startsGroup, className }: MessageRowProps) {
|
|
143
|
+
const ctx = useThreadContext();
|
|
144
|
+
const keepFocus = useRef<(() => void) | null>(null);
|
|
145
|
+
const pending = item.type === "pending" ? item.pending : null;
|
|
146
|
+
const message = item.type === "message" ? item.message : pendingAsMessage(item.pending);
|
|
147
|
+
|
|
148
|
+
if (message.kind === "system") {
|
|
149
|
+
return (
|
|
150
|
+
<div className={cn("flex justify-center px-4 py-1", className)} role="note" data-testid="genesis-chat-system">
|
|
151
|
+
<span className="max-w-[85%] rounded-full bg-muted px-3 py-1 text-center text-xs text-muted-foreground">
|
|
152
|
+
<ChatMarkdown text={message.body} me={ctx.me} />
|
|
153
|
+
</span>
|
|
154
|
+
</div>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const mine = message.senderId === ctx.me;
|
|
159
|
+
const sender = message.senderId ? ctx.resolveUser(message.senderId) : { name: "Someone" };
|
|
160
|
+
const deleted = Boolean(message.deletedAt);
|
|
161
|
+
const note = message.visibility === "observers";
|
|
162
|
+
const showHeader = ctx.isGroup && !mine && startsGroup;
|
|
163
|
+
const tone: "default" | "primary" = mine && !note && !deleted ? "primary" : "default";
|
|
164
|
+
const views = pending ? pendingViews(pending) : message.attachments.map((a) => toAttachmentView(a, ctx.urls));
|
|
165
|
+
const progress = pending ? pendingProgress(pending) : null;
|
|
166
|
+
const editing = ctx.editingId === message.id;
|
|
167
|
+
const reactions = pending ? [] : summarizeReactions(message, ctx.me);
|
|
168
|
+
const highlighted = ctx.highlightedId === message.id;
|
|
169
|
+
const actionable = !pending && !deleted;
|
|
170
|
+
const hidden = !deleted && message.moderation === "hidden";
|
|
171
|
+
const flagged = !deleted && message.moderation === "flagged";
|
|
172
|
+
const observer = ctx.role === "observer";
|
|
173
|
+
// One preview per message, iMessage-style: the first link the server has fetched.
|
|
174
|
+
const preview = !pending && !deleted && !hidden && message.body ? ctx.previews[message.id]?.[0] : undefined;
|
|
175
|
+
|
|
176
|
+
const bubble = (
|
|
177
|
+
<div
|
|
178
|
+
className={cn(
|
|
179
|
+
"relative min-w-0 max-w-full rounded-2xl px-3 py-2 text-sm leading-relaxed shadow-sm transition-shadow",
|
|
180
|
+
tone === "primary" ? "bg-primary text-primary-foreground" : "bg-muted text-foreground",
|
|
181
|
+
mine ? (startsGroup ? "rounded-tr-md" : "") : startsGroup ? "rounded-tl-md" : "",
|
|
182
|
+
note && "border border-warning/50 bg-warning/15 text-foreground",
|
|
183
|
+
deleted && "border border-dashed bg-transparent text-muted-foreground shadow-none",
|
|
184
|
+
highlighted && "ring-2 ring-ring ring-offset-2 ring-offset-background",
|
|
185
|
+
pending?.status === "failed" && "opacity-80",
|
|
186
|
+
hidden && "opacity-70",
|
|
187
|
+
)}
|
|
188
|
+
data-testid={deleted ? "genesis-chat-deleted" : note ? "genesis-chat-note" : mine ? "genesis-chat-mine" : "genesis-chat-theirs"}
|
|
189
|
+
>
|
|
190
|
+
{note ? (
|
|
191
|
+
<div className="mb-1 flex items-center gap-1 text-xs font-semibold text-foreground/80">
|
|
192
|
+
<Lock className="size-3" aria-hidden /> Internal note
|
|
193
|
+
</div>
|
|
194
|
+
) : null}
|
|
195
|
+
{deleted ? (
|
|
196
|
+
<span className="flex items-center gap-1.5 italic">
|
|
197
|
+
<Ban className="size-3.5" aria-hidden /> Message deleted
|
|
198
|
+
</span>
|
|
199
|
+
) : editing ? (
|
|
200
|
+
<EditBox message={message} />
|
|
201
|
+
) : (
|
|
202
|
+
<>
|
|
203
|
+
{message.replyToId ? <ReplyQuote id={message.replyToId} tone={tone} /> : null}
|
|
204
|
+
{views.length ? (
|
|
205
|
+
<div className={cn(message.body ? "mb-1.5" : "")}>
|
|
206
|
+
<MessageAttachments views={views} message={pending ? undefined : message} mine={mine} tone={tone} slot={ctx.attachmentSlot} kinds={ctx.attachmentKinds} />
|
|
207
|
+
{progress !== null ? (
|
|
208
|
+
<Progress
|
|
209
|
+
value={progress}
|
|
210
|
+
className={cn("mt-1.5 h-1", tone === "primary" && "bg-primary-foreground/30 [&>div]:bg-primary-foreground")}
|
|
211
|
+
role="progressbar"
|
|
212
|
+
aria-label="Uploading"
|
|
213
|
+
aria-valuemin={0}
|
|
214
|
+
aria-valuemax={100}
|
|
215
|
+
aria-valuenow={progress}
|
|
216
|
+
data-testid="genesis-chat-upload-progress"
|
|
217
|
+
/>
|
|
218
|
+
) : null}
|
|
219
|
+
</div>
|
|
220
|
+
) : null}
|
|
221
|
+
{message.body ? <ChatMarkdown text={message.body} me={ctx.me} tone={tone} /> : null}
|
|
222
|
+
{preview ? <ChatLinkPreview preview={preview} images={ctx.linkPreviewImages} proxyUrl={ctx.linkPreviewProxyUrl} tone={tone} /> : null}
|
|
223
|
+
{hidden && mine && !observer ? (
|
|
224
|
+
<span className="mt-1 flex items-center gap-1 text-xs font-medium" data-testid="genesis-chat-hidden-notice">
|
|
225
|
+
<EyeOff className="size-3" aria-hidden /> Hidden by moderation. Only you can see it.
|
|
226
|
+
</span>
|
|
227
|
+
) : null}
|
|
228
|
+
<Meta message={message} pending={pending} tone={tone} />
|
|
229
|
+
</>
|
|
230
|
+
)}
|
|
231
|
+
</div>
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
return (
|
|
235
|
+
<article
|
|
236
|
+
id={`genesis-chat-msg-${message.id}`}
|
|
237
|
+
data-message-id={pending ? undefined : message.id}
|
|
238
|
+
aria-label={`${messageLabel(message, mine ? "You" : sender.name)}${hidden ? " (hidden by moderation)" : flagged && observer ? " (flagged)" : ""}`}
|
|
239
|
+
className={cn("group/msg flex gap-2 px-3", mine ? "flex-row-reverse" : "flex-row", startsGroup ? "mt-2" : "mt-0.5", className)}
|
|
240
|
+
data-testid={pending ? `genesis-chat-pending-${pending.status}` : "genesis-chat-message"}
|
|
241
|
+
>
|
|
242
|
+
{ctx.isGroup && !mine ? (
|
|
243
|
+
showHeader ? (
|
|
244
|
+
<UserAvatar name={sender.name} src={sender.avatarUrl} size="sm" className="mt-5" />
|
|
245
|
+
) : (
|
|
246
|
+
<span className="w-8 shrink-0" aria-hidden />
|
|
247
|
+
)
|
|
248
|
+
) : null}
|
|
249
|
+
<div className={cn("flex min-w-0 max-w-[min(78%,36rem)] flex-col", mine ? "items-end" : "items-start")}>
|
|
250
|
+
{showHeader ? <span className="mb-0.5 px-3 text-xs font-medium text-muted-foreground">{sender.name}</span> : null}
|
|
251
|
+
{observer && (hidden || flagged) ? (
|
|
252
|
+
<Badge
|
|
253
|
+
variant={hidden ? "destructive" : "outline"}
|
|
254
|
+
className="mb-0.5 gap-1 px-1.5 py-0 text-[10px]"
|
|
255
|
+
data-testid={hidden ? "genesis-chat-moderation-hidden" : "genesis-chat-moderation-flagged"}
|
|
256
|
+
>
|
|
257
|
+
{hidden ? <EyeOff className="size-3" aria-hidden /> : <ShieldAlert className="size-3" aria-hidden />}
|
|
258
|
+
{hidden ? "Hidden by moderation" : "Flagged"}
|
|
259
|
+
</Badge>
|
|
260
|
+
) : null}
|
|
261
|
+
<div className={cn("flex max-w-full items-center gap-1", mine ? "flex-row-reverse" : "flex-row")}>
|
|
262
|
+
{actionable ? (
|
|
263
|
+
<ContextMenu>
|
|
264
|
+
<ContextMenuTrigger asChild>{bubble}</ContextMenuTrigger>
|
|
265
|
+
<ContextMenuContent className="w-56" onCloseAutoFocus={(e) => returnFocus(e, keepFocus)}>
|
|
266
|
+
<ActionItems message={message} mine={mine} keepFocus={keepFocus} Item={ContextMenuItem} Separator={ContextMenuSeparator} />
|
|
267
|
+
</ContextMenuContent>
|
|
268
|
+
</ContextMenu>
|
|
269
|
+
) : (
|
|
270
|
+
bubble
|
|
271
|
+
)}
|
|
272
|
+
{actionable && !editing ? <HoverBar message={message} mine={mine} /> : null}
|
|
273
|
+
</div>
|
|
274
|
+
{reactions.length ? (
|
|
275
|
+
<div className={cn("-mt-1 flex flex-wrap gap-1 px-2", mine ? "justify-end" : "justify-start")} data-testid="genesis-chat-reactions">
|
|
276
|
+
{reactions.map((r) => (
|
|
277
|
+
<Tooltip key={r.emoji} content={r.userIds.map((u) => (u === ctx.me ? "You" : ctx.resolveUser(u).name)).join(", ")}>
|
|
278
|
+
<button
|
|
279
|
+
type="button"
|
|
280
|
+
aria-pressed={r.mine}
|
|
281
|
+
aria-label={`${r.emoji} ${r.count}${r.mine ? ", including you" : ""}. ${r.mine ? "Remove" : "Add"} your reaction`}
|
|
282
|
+
onClick={() => ctx.react(message, r.emoji, !r.mine)}
|
|
283
|
+
disabled={!ctx.canPost}
|
|
284
|
+
className={cn(
|
|
285
|
+
"inline-flex h-6 items-center gap-1 rounded-full border bg-background px-2 text-xs shadow-sm transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none",
|
|
286
|
+
r.mine && "border-primary bg-primary/10 text-primary",
|
|
287
|
+
)}
|
|
288
|
+
data-testid={`genesis-chat-reaction-${r.emoji}`}
|
|
289
|
+
>
|
|
290
|
+
<span aria-hidden>{r.emoji}</span>
|
|
291
|
+
<span className="tabular-nums" aria-hidden>
|
|
292
|
+
{r.count}
|
|
293
|
+
</span>
|
|
294
|
+
</button>
|
|
295
|
+
</Tooltip>
|
|
296
|
+
))}
|
|
297
|
+
</div>
|
|
298
|
+
) : null}
|
|
299
|
+
{!pending && message.threadCount > 0 && ctx.openThread ? (
|
|
300
|
+
<Button type="button" variant="link" size="sm" className="h-6 px-2 text-xs" onClick={() => ctx.openThread?.(message)}>
|
|
301
|
+
<MessageSquare className="size-3" aria-hidden />
|
|
302
|
+
{message.threadCount === 1 ? "1 reply" : `${message.threadCount} replies`}
|
|
303
|
+
</Button>
|
|
304
|
+
) : null}
|
|
305
|
+
{pending?.status === "failed" ? (
|
|
306
|
+
<div className="mt-1 flex items-center gap-1 text-xs text-destructive" role="alert">
|
|
307
|
+
<AlertCircle className="size-3.5" aria-hidden />
|
|
308
|
+
<span>Not sent{pending.error && pending.error !== "Not sent" ? `: ${pending.error}` : ""}</span>
|
|
309
|
+
<Button type="button" size="sm" variant="ghost" className="h-6 px-2 text-xs" onClick={() => ctx.retry(pending.clientId)} data-testid="genesis-chat-retry">
|
|
310
|
+
Retry
|
|
311
|
+
</Button>
|
|
312
|
+
<Button type="button" size="sm" variant="ghost" className="h-6 px-2 text-xs text-destructive hover:text-destructive" onClick={() => ctx.discard(pending.clientId)} data-testid="genesis-chat-discard">
|
|
313
|
+
Delete
|
|
314
|
+
</Button>
|
|
315
|
+
</div>
|
|
316
|
+
) : null}
|
|
317
|
+
</div>
|
|
318
|
+
</article>
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function Meta({ message, pending, tone }: { message: ChatMessage; pending: PendingMessage | null; tone: "default" | "primary" }) {
|
|
323
|
+
const muted = tone === "primary" ? "text-primary-foreground/75" : "text-muted-foreground";
|
|
324
|
+
return (
|
|
325
|
+
<span className={cn("float-right ml-3 mt-1 flex translate-y-1 items-center gap-1 text-[11px] leading-none", muted)}>
|
|
326
|
+
{message.editedAt ? <span>edited</span> : null}
|
|
327
|
+
{pending?.status === "sending" ? (
|
|
328
|
+
<>
|
|
329
|
+
<Clock className="size-3" aria-hidden />
|
|
330
|
+
<span>Sending…</span>
|
|
331
|
+
</>
|
|
332
|
+
) : pending?.status === "failed" ? (
|
|
333
|
+
<AlertCircle className="size-3" aria-label="Not sent" />
|
|
334
|
+
) : (
|
|
335
|
+
<time dateTime={message.createdAt}>{clockTime(message.createdAt)}</time>
|
|
336
|
+
)}
|
|
337
|
+
</span>
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function ReplyQuote({ id, tone }: { id: string; tone: "default" | "primary" }) {
|
|
342
|
+
const ctx = useThreadContext();
|
|
343
|
+
const original = ctx.byId.get(id);
|
|
344
|
+
const who = original?.senderId ? (original.senderId === ctx.me ? "You" : ctx.resolveUser(original.senderId).name) : null;
|
|
345
|
+
const text = original ? previewText(original.body, original.attachments[0]?.kind ?? null, Boolean(original.deletedAt)) : "Earlier message";
|
|
346
|
+
return (
|
|
347
|
+
<button
|
|
348
|
+
type="button"
|
|
349
|
+
onClick={() => ctx.jumpTo(id)}
|
|
350
|
+
className={cn(
|
|
351
|
+
"mb-1.5 block w-full min-w-0 rounded-md border-l-4 px-2 py-1 text-left text-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
352
|
+
tone === "primary" ? "border-primary-foreground/60 bg-primary-foreground/15" : "border-primary bg-background/60",
|
|
353
|
+
)}
|
|
354
|
+
aria-label={`Replying to ${who ?? "a message"}: ${text}. Show it`}
|
|
355
|
+
data-testid="genesis-chat-reply-quote"
|
|
356
|
+
>
|
|
357
|
+
{who ? <span className="block font-semibold">{who}</span> : null}
|
|
358
|
+
<span className="line-clamp-2 opacity-80">{text}</span>
|
|
359
|
+
</button>
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function EditBox({ message }: { message: ChatMessage }) {
|
|
364
|
+
const ctx = useThreadContext();
|
|
365
|
+
const [value, setValue] = useState(message.body);
|
|
366
|
+
const [busy, setBusy] = useState(false);
|
|
367
|
+
const ref = useRef<HTMLTextAreaElement>(null);
|
|
368
|
+
useEffect(() => {
|
|
369
|
+
const el = ref.current;
|
|
370
|
+
if (!el) return;
|
|
371
|
+
el.focus();
|
|
372
|
+
el.setSelectionRange(el.value.length, el.value.length);
|
|
373
|
+
}, []);
|
|
374
|
+
const save = async () => {
|
|
375
|
+
const body = value.trim();
|
|
376
|
+
if (!body || body === message.body) return ctx.startEdit(null);
|
|
377
|
+
setBusy(true);
|
|
378
|
+
try {
|
|
379
|
+
await ctx.saveEdit(message, body);
|
|
380
|
+
ctx.startEdit(null);
|
|
381
|
+
} finally {
|
|
382
|
+
setBusy(false);
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
return (
|
|
386
|
+
<div className="flex min-w-[16rem] flex-col gap-2">
|
|
387
|
+
<Textarea
|
|
388
|
+
ref={ref}
|
|
389
|
+
value={value}
|
|
390
|
+
onChange={(e) => setValue(e.target.value)}
|
|
391
|
+
onKeyDown={(e) => {
|
|
392
|
+
if (e.key === "Escape") {
|
|
393
|
+
e.preventDefault();
|
|
394
|
+
ctx.startEdit(null);
|
|
395
|
+
} else if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
|
|
396
|
+
e.preventDefault();
|
|
397
|
+
void save();
|
|
398
|
+
}
|
|
399
|
+
}}
|
|
400
|
+
id={`genesis-chat-edit-${message.id}`}
|
|
401
|
+
aria-label="Edit message"
|
|
402
|
+
className="min-h-[60px] resize-none bg-background text-foreground"
|
|
403
|
+
/>
|
|
404
|
+
<div className="flex justify-end gap-2">
|
|
405
|
+
<Button type="button" size="sm" variant="ghost" onClick={() => ctx.startEdit(null)} className={cn("h-7", "text-current")}>
|
|
406
|
+
Cancel
|
|
407
|
+
</Button>
|
|
408
|
+
<Button type="button" size="sm" variant="secondary" onClick={() => void save()} loading={busy} className="h-7">
|
|
409
|
+
Save
|
|
410
|
+
</Button>
|
|
411
|
+
</div>
|
|
412
|
+
</div>
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
type ItemComponent = ComponentType<{ onSelect?: (e: Event) => void; className?: string; children?: ReactNode; "data-testid"?: string; "aria-label"?: string }>;
|
|
417
|
+
|
|
418
|
+
interface ActionItemsProps {
|
|
419
|
+
message: ChatMessage;
|
|
420
|
+
/** Set when the picked action moves focus itself (Reply, Edit), so the menu doesn't take it back. */
|
|
421
|
+
keepFocus: MutableRefObject<(() => void) | null>;
|
|
422
|
+
mine: boolean;
|
|
423
|
+
Item: ItemComponent;
|
|
424
|
+
Separator: ComponentType<{ className?: string }>;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** The actions menu, the same in the right-click menu and the "More" menu. */
|
|
428
|
+
function ActionItems({ message, mine, keepFocus, Item, Separator }: ActionItemsProps) {
|
|
429
|
+
const ctx = useThreadContext();
|
|
430
|
+
const pinned = ctx.pins.has(message.id);
|
|
431
|
+
const canDelete = mine || ctx.canModerate;
|
|
432
|
+
const canEdit = mine && message.kind === "text" && message.body.length > 0;
|
|
433
|
+
return (
|
|
434
|
+
<>
|
|
435
|
+
{ctx.canPost ? (
|
|
436
|
+
<>
|
|
437
|
+
<div className="flex justify-between gap-0.5 px-1 py-1" role="group" aria-label="React">
|
|
438
|
+
{ctx.quickReactions.map((emoji) => {
|
|
439
|
+
const on = message.reactions.some((r) => r.emoji === emoji && r.userId === ctx.me);
|
|
440
|
+
return (
|
|
441
|
+
<Item
|
|
442
|
+
key={emoji}
|
|
443
|
+
className={cn("justify-center rounded-full px-2 text-lg", on && "bg-primary/10")}
|
|
444
|
+
onSelect={() => ctx.react(message, emoji, !on)}
|
|
445
|
+
aria-label={`${on ? "Remove" : "React with"} ${emoji}`}
|
|
446
|
+
>
|
|
447
|
+
{emoji}
|
|
448
|
+
</Item>
|
|
449
|
+
);
|
|
450
|
+
})}
|
|
451
|
+
</div>
|
|
452
|
+
<Separator />
|
|
453
|
+
<Item className="gap-2" onSelect={() => { keepFocus.current = ctx.focusComposer; ctx.reply(message); }}>
|
|
454
|
+
<Reply className="size-4" aria-hidden /> Reply
|
|
455
|
+
</Item>
|
|
456
|
+
</>
|
|
457
|
+
) : null}
|
|
458
|
+
{message.body ? (
|
|
459
|
+
<Item className="gap-2" onSelect={() => ctx.copy(message)}>
|
|
460
|
+
<Copy className="size-4" aria-hidden /> Copy text
|
|
461
|
+
</Item>
|
|
462
|
+
) : null}
|
|
463
|
+
{canEdit && ctx.canPost ? (
|
|
464
|
+
<Item className="gap-2" onSelect={() => { keepFocus.current = () => document.getElementById(`genesis-chat-edit-${message.id}`)?.focus(); ctx.startEdit(message.id); }}>
|
|
465
|
+
<Pencil className="size-4" aria-hidden /> Edit
|
|
466
|
+
</Item>
|
|
467
|
+
) : null}
|
|
468
|
+
{ctx.canPin ? (
|
|
469
|
+
<Item className="gap-2" onSelect={() => ctx.pin(message, !pinned)}>
|
|
470
|
+
{pinned ? <PinOff className="size-4" aria-hidden /> : <Pin className="size-4" aria-hidden />} {pinned ? "Unpin" : "Pin"}
|
|
471
|
+
</Item>
|
|
472
|
+
) : null}
|
|
473
|
+
<Item className="gap-2" onSelect={() => ctx.save(message)}>
|
|
474
|
+
<Bookmark className="size-4" aria-hidden /> Save
|
|
475
|
+
</Item>
|
|
476
|
+
{!mine && message.senderId ? (
|
|
477
|
+
<Item className="gap-2" onSelect={() => ctx.askReport(message)}>
|
|
478
|
+
<Flag className="size-4" aria-hidden /> Report
|
|
479
|
+
</Item>
|
|
480
|
+
) : null}
|
|
481
|
+
{canDelete ? (
|
|
482
|
+
<>
|
|
483
|
+
<Separator />
|
|
484
|
+
<Item onSelect={() => ctx.askDelete(message)} className="gap-2 text-destructive focus:text-destructive">
|
|
485
|
+
<Trash2 className="size-4" aria-hidden /> Delete
|
|
486
|
+
</Item>
|
|
487
|
+
</>
|
|
488
|
+
) : null}
|
|
489
|
+
</>
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** Desktop: a small bar beside the bubble on hover or focus (React, Reply, More). Touch uses long-press. */
|
|
494
|
+
function HoverBar({ message, mine }: { message: ChatMessage; mine: boolean }) {
|
|
495
|
+
const ctx = useThreadContext();
|
|
496
|
+
const keepFocus = useRef<(() => void) | null>(null);
|
|
497
|
+
const [open, setOpen] = useState<"react" | "more" | null>(null);
|
|
498
|
+
const [full, setFull] = useState(false);
|
|
499
|
+
return (
|
|
500
|
+
<div
|
|
501
|
+
className={cn(
|
|
502
|
+
"flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity focus-within:opacity-100 group-hover/msg:opacity-100 [@media(hover:none)]:hidden",
|
|
503
|
+
open && "opacity-100",
|
|
504
|
+
mine ? "flex-row-reverse" : "flex-row",
|
|
505
|
+
)}
|
|
506
|
+
>
|
|
507
|
+
{ctx.canPost ? (
|
|
508
|
+
<Popover
|
|
509
|
+
open={open === "react"}
|
|
510
|
+
onOpenChange={(o) => {
|
|
511
|
+
setOpen(o ? "react" : null);
|
|
512
|
+
if (!o) setFull(false);
|
|
513
|
+
}}
|
|
514
|
+
>
|
|
515
|
+
<PopoverTrigger asChild>
|
|
516
|
+
<Button type="button" variant="ghost" size="icon" className="size-7 rounded-full text-muted-foreground" aria-label="React">
|
|
517
|
+
<SmilePlus />
|
|
518
|
+
</Button>
|
|
519
|
+
</PopoverTrigger>
|
|
520
|
+
{full ? (
|
|
521
|
+
<PopoverContent side="top" align={mine ? "end" : "start"} className="w-auto overflow-hidden p-0">
|
|
522
|
+
<ChatEmojiPicker
|
|
523
|
+
emojibaseUrl={ctx.emojibaseUrl}
|
|
524
|
+
onSelect={(emoji) => {
|
|
525
|
+
const on = message.reactions.some((r) => r.emoji === emoji && r.userId === ctx.me);
|
|
526
|
+
ctx.react(message, emoji, !on);
|
|
527
|
+
setOpen(null);
|
|
528
|
+
setFull(false);
|
|
529
|
+
}}
|
|
530
|
+
/>
|
|
531
|
+
</PopoverContent>
|
|
532
|
+
) : (
|
|
533
|
+
<PopoverContent side="top" align={mine ? "end" : "start"} className="flex w-auto gap-0.5 rounded-full p-1">
|
|
534
|
+
{ctx.quickReactions.map((emoji) => {
|
|
535
|
+
const on = message.reactions.some((r) => r.emoji === emoji && r.userId === ctx.me);
|
|
536
|
+
return (
|
|
537
|
+
<button
|
|
538
|
+
key={emoji}
|
|
539
|
+
type="button"
|
|
540
|
+
className={cn("flex size-9 items-center justify-center rounded-full text-xl transition-transform hover:scale-125 hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", on && "bg-primary/10")}
|
|
541
|
+
aria-label={`${on ? "Remove" : "React with"} ${emoji}`}
|
|
542
|
+
aria-pressed={on}
|
|
543
|
+
onClick={() => {
|
|
544
|
+
ctx.react(message, emoji, !on);
|
|
545
|
+
setOpen(null);
|
|
546
|
+
}}
|
|
547
|
+
>
|
|
548
|
+
{emoji}
|
|
549
|
+
</button>
|
|
550
|
+
);
|
|
551
|
+
})}
|
|
552
|
+
{canPickEmoji(ctx.emojibaseUrl) ? (
|
|
553
|
+
<button
|
|
554
|
+
type="button"
|
|
555
|
+
className="flex size-9 items-center justify-center rounded-full text-muted-foreground hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
556
|
+
aria-label="More reactions"
|
|
557
|
+
onClick={() => setFull(true)}
|
|
558
|
+
data-testid="genesis-chat-more-reactions"
|
|
559
|
+
>
|
|
560
|
+
<Plus className="size-4" />
|
|
561
|
+
</button>
|
|
562
|
+
) : null}
|
|
563
|
+
</PopoverContent>
|
|
564
|
+
)}
|
|
565
|
+
</Popover>
|
|
566
|
+
) : null}
|
|
567
|
+
{ctx.canPost ? (
|
|
568
|
+
<Button type="button" variant="ghost" size="icon" className="size-7 rounded-full text-muted-foreground" aria-label="Reply" onClick={() => ctx.reply(message)}>
|
|
569
|
+
<Reply />
|
|
570
|
+
</Button>
|
|
571
|
+
) : null}
|
|
572
|
+
<DropdownMenu open={open === "more"} onOpenChange={(o) => setOpen(o ? "more" : null)}>
|
|
573
|
+
<DropdownMenuTrigger asChild>
|
|
574
|
+
<Button type="button" variant="ghost" size="icon" className="size-7 rounded-full text-muted-foreground" aria-label="More actions" data-testid="genesis-chat-more">
|
|
575
|
+
<MoreHorizontal />
|
|
576
|
+
</Button>
|
|
577
|
+
</DropdownMenuTrigger>
|
|
578
|
+
<DropdownMenuContent align={mine ? "end" : "start"} className="w-56" onCloseAutoFocus={(e) => returnFocus(e, keepFocus)}>
|
|
579
|
+
<ActionItems message={message} mine={mine} keepFocus={keepFocus} Item={DropdownMenuItem} Separator={DropdownMenuSeparator} />
|
|
580
|
+
</DropdownMenuContent>
|
|
581
|
+
</DropdownMenu>
|
|
582
|
+
</div>
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
|