@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,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @feat:chat
|
|
3
|
+
* Messages, whole: the inbox on the left and the open conversation on the
|
|
4
|
+
* right (Genesis `Split`); on a narrow screen one at a time, with a back
|
|
5
|
+
* button from the conversation to the inbox. Search across every conversation
|
|
6
|
+
* sits above the inbox; a result opens its conversation at the message.
|
|
7
|
+
*/
|
|
8
|
+
import { useState, type ReactNode } from "react";
|
|
9
|
+
import { MessageSquare, Search, X } from "lucide-react";
|
|
10
|
+
import { Button, EmptyState, Split, cn } from "@marktiderman/genesis-ui";
|
|
11
|
+
import { renderSlot, type SlotOverride } from "@marktiderman/genesis-core/slots";
|
|
12
|
+
import type { ChatInboxItem } from "../core/types";
|
|
13
|
+
import { useInbox } from "../react/index";
|
|
14
|
+
import { ChatInbox, inboxTitle, type ChatInboxProps } from "./inbox";
|
|
15
|
+
import { ChatSearch } from "./search";
|
|
16
|
+
import type { ResolveUser } from "./format";
|
|
17
|
+
import { ChatThread, type ChatThreadProps } from "./thread";
|
|
18
|
+
|
|
19
|
+
export interface ChatPanelNoSelectionProps {
|
|
20
|
+
/** Words under the title; "Pick one on the left to read and reply." by default. */
|
|
21
|
+
hint?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ChatPanelProps {
|
|
25
|
+
resolveUser: ResolveUser;
|
|
26
|
+
/** The open conversation (controlled). */
|
|
27
|
+
selectedId?: string | null;
|
|
28
|
+
/** The conversation open at first (uncontrolled). */
|
|
29
|
+
defaultSelectedId?: string | null;
|
|
30
|
+
/** Called when a conversation is opened, or with null on back. Put it in the URL to make it bookmarkable. */
|
|
31
|
+
onSelectedChange?: (conversationId: string | null) => void;
|
|
32
|
+
/** Above the inbox list: a title, a "New message" button. */
|
|
33
|
+
inboxHeader?: ReactNode;
|
|
34
|
+
inboxProps?: Partial<Omit<ChatInboxProps, "resolveUser" | "onSelect" | "selectedId">>;
|
|
35
|
+
threadProps?: Partial<Omit<ChatThreadProps, "conversationId" | "resolveUser" | "onBack">>;
|
|
36
|
+
/** What the right pane shows before a conversation is chosen. */
|
|
37
|
+
slots?: { noSelection?: SlotOverride<ChatPanelNoSelectionProps> };
|
|
38
|
+
/** Search across every conversation, above the inbox. Default true. */
|
|
39
|
+
search?: boolean;
|
|
40
|
+
className?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function NoSelection({ hint }: ChatPanelNoSelectionProps) {
|
|
44
|
+
return (
|
|
45
|
+
<div className="flex h-full items-center justify-center">
|
|
46
|
+
<EmptyState icon={MessageSquare} title="Choose a conversation" description={hint ?? "Pick one on the left to read and reply."} />
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function ChatPanel({
|
|
52
|
+
resolveUser,
|
|
53
|
+
selectedId: controlled,
|
|
54
|
+
defaultSelectedId = null,
|
|
55
|
+
onSelectedChange,
|
|
56
|
+
inboxHeader,
|
|
57
|
+
inboxProps,
|
|
58
|
+
threadProps,
|
|
59
|
+
slots,
|
|
60
|
+
search = true,
|
|
61
|
+
className,
|
|
62
|
+
}: ChatPanelProps) {
|
|
63
|
+
const [own, setOwn] = useState<string | null>(defaultSelectedId);
|
|
64
|
+
const [searching, setSearching] = useState(false);
|
|
65
|
+
const [jump, setJump] = useState<{ messageId: string; seq?: number; threadRootId?: string | null } | null>(null);
|
|
66
|
+
const { items } = useInbox({ kind: inboxProps?.kind });
|
|
67
|
+
const titleOf = (id: string) => {
|
|
68
|
+
const item = items.find((i: ChatInboxItem) => i.conversationId === id);
|
|
69
|
+
return item ? inboxTitle(item, resolveUser, inboxProps?.fallbackTitle) : "a conversation";
|
|
70
|
+
};
|
|
71
|
+
const selected = controlled !== undefined ? controlled : own;
|
|
72
|
+
const select = (id: string | null) => {
|
|
73
|
+
if (controlled === undefined) setOwn(id);
|
|
74
|
+
onSelectedChange?.(id);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<Split
|
|
79
|
+
ratio="1/3"
|
|
80
|
+
at="md"
|
|
81
|
+
gap="none"
|
|
82
|
+
testID="genesis-chat-panel"
|
|
83
|
+
className={cn(
|
|
84
|
+
"h-full min-h-0 overflow-hidden rounded-lg border bg-background",
|
|
85
|
+
"[&>[data-slot=split-start]]:min-h-0 [&>[data-slot=split-end]]:min-h-0 max-md:[&>[data-slot]]:flex-1",
|
|
86
|
+
selected ? "max-md:[&>[data-slot=split-start]]:hidden" : "max-md:[&>[data-slot=split-end]]:hidden",
|
|
87
|
+
className,
|
|
88
|
+
)}
|
|
89
|
+
start={
|
|
90
|
+
<div className="flex h-full min-h-0 flex-col md:border-r">
|
|
91
|
+
{inboxHeader || search ? (
|
|
92
|
+
<div className="flex shrink-0 items-center gap-2 border-b px-3 py-2">
|
|
93
|
+
<div className="min-w-0 flex-1">{inboxHeader}</div>
|
|
94
|
+
{search ? (
|
|
95
|
+
<Button
|
|
96
|
+
type="button"
|
|
97
|
+
variant="ghost"
|
|
98
|
+
size="icon"
|
|
99
|
+
className="size-8 shrink-0"
|
|
100
|
+
onClick={() => setSearching((s) => !s)}
|
|
101
|
+
aria-label={searching ? "Close search" : "Search messages"}
|
|
102
|
+
aria-pressed={searching}
|
|
103
|
+
data-testid="genesis-chat-panel-search"
|
|
104
|
+
>
|
|
105
|
+
{searching ? <X /> : <Search />}
|
|
106
|
+
</Button>
|
|
107
|
+
) : null}
|
|
108
|
+
</div>
|
|
109
|
+
) : null}
|
|
110
|
+
{searching ? (
|
|
111
|
+
<ChatSearch
|
|
112
|
+
resolveUser={resolveUser}
|
|
113
|
+
autoFocus
|
|
114
|
+
conversationTitle={titleOf}
|
|
115
|
+
onOpen={(hit) => {
|
|
116
|
+
setJump({ messageId: hit.messageId, seq: hit.seq, threadRootId: hit.threadRootId });
|
|
117
|
+
select(hit.conversationId);
|
|
118
|
+
}}
|
|
119
|
+
className="min-h-0 flex-1"
|
|
120
|
+
/>
|
|
121
|
+
) : (
|
|
122
|
+
<ChatInbox {...inboxProps} resolveUser={resolveUser} selectedId={selected} onSelect={(id) => { setJump(null); select(id); }} className={cn("min-h-0 flex-1 overflow-y-auto", inboxProps?.className)} />
|
|
123
|
+
)}
|
|
124
|
+
</div>
|
|
125
|
+
}
|
|
126
|
+
end={
|
|
127
|
+
selected ? (
|
|
128
|
+
<ChatThread key={selected} {...threadProps} jumpTo={jump} conversationId={selected} resolveUser={resolveUser} onBack={() => select(null)} backClassName="md:hidden" />
|
|
129
|
+
) : (
|
|
130
|
+
renderSlot(NoSelection, slots?.noSelection, {})
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
/>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @feat:chat
|
|
3
|
+
* Scheduled send on the web: the send button's menu (tomorrow 9 AM, Monday 9 AM, or a date
|
|
4
|
+
* and time you pick), and `ChatScheduledList`, your messages waiting to go, each with Cancel.
|
|
5
|
+
*/
|
|
6
|
+
import { useEffect, useMemo, useState } from "react";
|
|
7
|
+
import { AlertCircle, CalendarClock, ChevronUp, Clock, X } from "lucide-react";
|
|
8
|
+
import {
|
|
9
|
+
Button,
|
|
10
|
+
Dialog,
|
|
11
|
+
DialogContent,
|
|
12
|
+
DialogDescription,
|
|
13
|
+
DialogFooter,
|
|
14
|
+
DialogHeader,
|
|
15
|
+
DialogTitle,
|
|
16
|
+
DropdownMenu,
|
|
17
|
+
DropdownMenuContent,
|
|
18
|
+
DropdownMenuItem,
|
|
19
|
+
DropdownMenuLabel,
|
|
20
|
+
DropdownMenuSeparator,
|
|
21
|
+
DropdownMenuTrigger,
|
|
22
|
+
EmptyState,
|
|
23
|
+
Input,
|
|
24
|
+
Label,
|
|
25
|
+
Skeleton,
|
|
26
|
+
cn,
|
|
27
|
+
toast,
|
|
28
|
+
} from "@marktiderman/genesis-ui";
|
|
29
|
+
import { isSchedulable, scheduleLabel, scheduleQuickPicks } from "../core/extras";
|
|
30
|
+
import { previewText } from "../core/thread";
|
|
31
|
+
import { useScheduled } from "../react/index";
|
|
32
|
+
|
|
33
|
+
export interface ChatScheduleMenuProps {
|
|
34
|
+
/** Called with the chosen time. */
|
|
35
|
+
onSchedule: (at: Date) => void;
|
|
36
|
+
disabled?: boolean;
|
|
37
|
+
/** For tests and demos. */
|
|
38
|
+
now?: Date;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** `YYYY-MM-DDTHH:mm` in local time, for a datetime-local input. */
|
|
42
|
+
export function toLocalInput(d: Date): string {
|
|
43
|
+
const p = (n: number) => String(n).padStart(2, "0");
|
|
44
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The chevron beside Send: "Schedule message" with quick picks and a custom time. */
|
|
48
|
+
export function ChatScheduleMenu({ onSchedule, disabled, now }: ChatScheduleMenuProps) {
|
|
49
|
+
const [picking, setPicking] = useState(false);
|
|
50
|
+
const picks = useMemo(() => scheduleQuickPicks(now ?? new Date()), [now]);
|
|
51
|
+
return (
|
|
52
|
+
<>
|
|
53
|
+
<DropdownMenu>
|
|
54
|
+
<DropdownMenuTrigger asChild>
|
|
55
|
+
<Button
|
|
56
|
+
type="button"
|
|
57
|
+
variant="ghost"
|
|
58
|
+
size="icon"
|
|
59
|
+
className="-ml-1 size-8 shrink-0 self-center rounded-full text-muted-foreground"
|
|
60
|
+
disabled={disabled}
|
|
61
|
+
aria-label="Send options"
|
|
62
|
+
data-testid="genesis-chat-send-menu"
|
|
63
|
+
>
|
|
64
|
+
<ChevronUp />
|
|
65
|
+
</Button>
|
|
66
|
+
</DropdownMenuTrigger>
|
|
67
|
+
<DropdownMenuContent align="end" side="top" className="w-60">
|
|
68
|
+
<DropdownMenuLabel className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
69
|
+
<CalendarClock className="size-3.5" aria-hidden /> Schedule message
|
|
70
|
+
</DropdownMenuLabel>
|
|
71
|
+
{picks.map((p) => (
|
|
72
|
+
<DropdownMenuItem key={p.label} className="gap-2" onSelect={() => onSchedule(p.at)} data-testid="genesis-chat-schedule-pick">
|
|
73
|
+
<Clock className="size-4" aria-hidden /> {p.label}
|
|
74
|
+
</DropdownMenuItem>
|
|
75
|
+
))}
|
|
76
|
+
<DropdownMenuSeparator />
|
|
77
|
+
<DropdownMenuItem className="gap-2" onSelect={() => setPicking(true)} data-testid="genesis-chat-schedule-custom">
|
|
78
|
+
<CalendarClock className="size-4" aria-hidden /> Pick a date and time…
|
|
79
|
+
</DropdownMenuItem>
|
|
80
|
+
</DropdownMenuContent>
|
|
81
|
+
</DropdownMenu>
|
|
82
|
+
<ScheduleDialog open={picking} onOpenChange={setPicking} onSchedule={onSchedule} now={now} />
|
|
83
|
+
</>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function ScheduleDialog({ open, onOpenChange, onSchedule, now }: { open: boolean; onOpenChange: (o: boolean) => void; onSchedule: (at: Date) => void; now?: Date }) {
|
|
88
|
+
const base = now ?? new Date();
|
|
89
|
+
const [value, setValue] = useState(() => toLocalInput(new Date(base.getTime() + 60 * 60_000)));
|
|
90
|
+
// The default is an hour from when it opens, not from when the composer first drew.
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
if (open) setValue(toLocalInput(new Date((now ?? new Date()).getTime() + 60 * 60_000)));
|
|
93
|
+
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
94
|
+
const at = new Date(value);
|
|
95
|
+
const ok = isSchedulable(at, now ?? new Date());
|
|
96
|
+
return (
|
|
97
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
98
|
+
<DialogContent className="sm:max-w-sm">
|
|
99
|
+
<DialogHeader>
|
|
100
|
+
<DialogTitle>Schedule message</DialogTitle>
|
|
101
|
+
<DialogDescription>It sends at this time, in your time zone, as if you'd sent it then.</DialogDescription>
|
|
102
|
+
</DialogHeader>
|
|
103
|
+
<form
|
|
104
|
+
className="flex flex-col gap-2"
|
|
105
|
+
onSubmit={(e) => {
|
|
106
|
+
e.preventDefault();
|
|
107
|
+
if (!ok) return;
|
|
108
|
+
onSchedule(at);
|
|
109
|
+
onOpenChange(false);
|
|
110
|
+
}}
|
|
111
|
+
>
|
|
112
|
+
<Label htmlFor="genesis-chat-schedule-at">Date and time</Label>
|
|
113
|
+
<Input
|
|
114
|
+
id="genesis-chat-schedule-at"
|
|
115
|
+
type="datetime-local"
|
|
116
|
+
value={value}
|
|
117
|
+
min={toLocalInput(new Date(base.getTime() + 60_000))}
|
|
118
|
+
onChange={(e) => setValue(e.target.value)}
|
|
119
|
+
aria-invalid={!ok}
|
|
120
|
+
aria-describedby="genesis-chat-schedule-hint"
|
|
121
|
+
data-testid="genesis-chat-schedule-input"
|
|
122
|
+
/>
|
|
123
|
+
<p id="genesis-chat-schedule-hint" className={cn("text-xs", ok ? "text-muted-foreground" : "text-destructive")}>
|
|
124
|
+
{ok ? scheduleLabel(at, now ?? new Date()) : "Pick a time from a minute to a year from now."}
|
|
125
|
+
</p>
|
|
126
|
+
<DialogFooter className="mt-2">
|
|
127
|
+
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
|
128
|
+
Cancel
|
|
129
|
+
</Button>
|
|
130
|
+
<Button type="submit" disabled={!ok} data-testid="genesis-chat-schedule-confirm">
|
|
131
|
+
Schedule
|
|
132
|
+
</Button>
|
|
133
|
+
</DialogFooter>
|
|
134
|
+
</form>
|
|
135
|
+
</DialogContent>
|
|
136
|
+
</Dialog>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface ChatScheduledListProps {
|
|
141
|
+
/** One conversation's; all of yours without it. */
|
|
142
|
+
conversationId?: string | null;
|
|
143
|
+
/** Names a conversation in the all-conversations list. */
|
|
144
|
+
conversationTitle?: (conversationId: string) => string;
|
|
145
|
+
className?: string;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Your scheduled messages, soonest first, each with when it goes and Cancel (Remove for one that failed). */
|
|
149
|
+
export function ChatScheduledList({ conversationId, conversationTitle, className }: ChatScheduledListProps) {
|
|
150
|
+
const { items, isLoading, cancel } = useScheduled(conversationId);
|
|
151
|
+
if (isLoading) {
|
|
152
|
+
return (
|
|
153
|
+
<div className={cn("flex flex-col gap-2 p-3", className)} aria-busy aria-label="Loading scheduled messages">
|
|
154
|
+
<Skeleton className="h-12 w-full" />
|
|
155
|
+
<Skeleton className="h-12 w-full" />
|
|
156
|
+
</div>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
if (items.length === 0) {
|
|
160
|
+
return <EmptyState icon={CalendarClock} title="Nothing scheduled" description="Use the arrow beside Send to schedule a message." className={className} />;
|
|
161
|
+
}
|
|
162
|
+
return (
|
|
163
|
+
<ul className={cn("flex flex-col divide-y", className)} aria-label="Scheduled messages" data-testid="genesis-chat-scheduled-list">
|
|
164
|
+
{items.map((s) => (
|
|
165
|
+
<li key={s.id} className="flex items-start gap-3 px-3 py-2.5" data-testid="genesis-chat-scheduled-item">
|
|
166
|
+
{s.status === "failed" ? <AlertCircle className="mt-0.5 size-4 shrink-0 text-destructive" aria-hidden /> : <Clock className="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden />}
|
|
167
|
+
<div className="min-w-0 flex-1">
|
|
168
|
+
<div className="text-xs font-medium text-muted-foreground">
|
|
169
|
+
{s.status === "failed" ? "Couldn't send" : scheduleLabel(s.sendAt)}
|
|
170
|
+
{!conversationId && conversationTitle ? ` · ${conversationTitle(s.conversationId)}` : null}
|
|
171
|
+
</div>
|
|
172
|
+
<div className="truncate text-sm">{previewText(s.body, s.attachments[0]?.kind ?? null)}</div>
|
|
173
|
+
{s.error ? <div className="text-xs text-destructive">{s.error}</div> : null}
|
|
174
|
+
</div>
|
|
175
|
+
{s.status === "failed" ? (
|
|
176
|
+
<Button
|
|
177
|
+
type="button"
|
|
178
|
+
variant="ghost"
|
|
179
|
+
size="sm"
|
|
180
|
+
className="h-7 shrink-0 gap-1 text-xs"
|
|
181
|
+
onClick={() => void cancel(s.id).then(() => toast.success("Removed"), () => toast.error("Couldn't remove it"))}
|
|
182
|
+
aria-label="Remove the message that couldn't send"
|
|
183
|
+
data-testid="genesis-chat-scheduled-remove"
|
|
184
|
+
>
|
|
185
|
+
<X className="size-3.5" aria-hidden /> Remove
|
|
186
|
+
</Button>
|
|
187
|
+
) : s.status === "scheduled" ? (
|
|
188
|
+
<Button
|
|
189
|
+
type="button"
|
|
190
|
+
variant="ghost"
|
|
191
|
+
size="sm"
|
|
192
|
+
className="h-7 shrink-0 gap-1 text-xs"
|
|
193
|
+
onClick={() => void cancel(s.id).then(() => toast.success("Scheduled message cancelled"), () => toast.error("Couldn't cancel it"))}
|
|
194
|
+
aria-label={`Cancel the message scheduled for ${scheduleLabel(s.sendAt)}`}
|
|
195
|
+
data-testid="genesis-chat-scheduled-cancel"
|
|
196
|
+
>
|
|
197
|
+
<X className="size-3.5" aria-hidden /> Cancel
|
|
198
|
+
</Button>
|
|
199
|
+
) : null}
|
|
200
|
+
</li>
|
|
201
|
+
))}
|
|
202
|
+
</ul>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @feat:chat
|
|
3
|
+
* `ChatSearch`: a search box and its results, across all your conversations or in one. Each
|
|
4
|
+
* result shows who, when, where (across conversations) and the snippet with the matched words
|
|
5
|
+
* highlighted; hits in a voice note's or video's transcript are marked. Choosing one calls
|
|
6
|
+
* `onOpen`, which the thread uses to jump to the message. Arrow keys move through the results.
|
|
7
|
+
*/
|
|
8
|
+
import { useRef, useState, type KeyboardEvent } from "react";
|
|
9
|
+
import { Mic, Search, X } from "lucide-react";
|
|
10
|
+
import { Badge, Button, EmptyState, Input, Skeleton, cn } from "@marktiderman/genesis-ui";
|
|
11
|
+
import { snippetParts } from "../core/extras";
|
|
12
|
+
import type { ChatSearchHit } from "../core/types";
|
|
13
|
+
import { useSearch } from "../react/index";
|
|
14
|
+
import { relativeTime, type ResolveUser } from "./format";
|
|
15
|
+
|
|
16
|
+
export interface ChatSearchProps {
|
|
17
|
+
resolveUser: ResolveUser;
|
|
18
|
+
/** Search one conversation; all of yours without it. */
|
|
19
|
+
conversationId?: string | null;
|
|
20
|
+
/** Called with the chosen result. */
|
|
21
|
+
onOpen: (hit: ChatSearchHit) => void;
|
|
22
|
+
/** Names a conversation, on results across conversations. */
|
|
23
|
+
conversationTitle?: (conversationId: string) => string;
|
|
24
|
+
/** Start with the box focused. */
|
|
25
|
+
autoFocus?: boolean;
|
|
26
|
+
debounceMs?: number;
|
|
27
|
+
className?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A snippet with its « » marks as <mark>. */
|
|
31
|
+
export function SearchSnippet({ snippet, className }: { snippet: string; className?: string }) {
|
|
32
|
+
return (
|
|
33
|
+
<span className={className}>
|
|
34
|
+
{snippetParts(snippet).map((p, i) =>
|
|
35
|
+
p.match ? (
|
|
36
|
+
<mark key={i} className="rounded-sm bg-warning/30 px-0.5 text-foreground">
|
|
37
|
+
{p.text}
|
|
38
|
+
</mark>
|
|
39
|
+
) : (
|
|
40
|
+
<span key={i}>{p.text}</span>
|
|
41
|
+
),
|
|
42
|
+
)}
|
|
43
|
+
</span>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function ChatSearch({ resolveUser, conversationId, onOpen, conversationTitle, autoFocus, debounceMs, className }: ChatSearchProps) {
|
|
48
|
+
const [value, setValue] = useState("");
|
|
49
|
+
const { hits, query, isSearching } = useSearch(value, { conversationId, debounceMs });
|
|
50
|
+
const list = useRef<HTMLUListElement>(null);
|
|
51
|
+
|
|
52
|
+
// Native buttons in a list; the arrows are added here (no Radix primitive is a vertical list of buttons).
|
|
53
|
+
const onKeyDown = (e: KeyboardEvent<HTMLElement>) => {
|
|
54
|
+
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
|
|
55
|
+
const buttons = Array.from(list.current?.querySelectorAll<HTMLButtonElement>("button[data-search-hit]") ?? []);
|
|
56
|
+
if (!buttons.length) return;
|
|
57
|
+
e.preventDefault();
|
|
58
|
+
const at = buttons.indexOf(document.activeElement as HTMLButtonElement);
|
|
59
|
+
const to = e.key === "ArrowDown" ? Math.min(buttons.length - 1, at + 1) : at <= 0 ? -1 : at - 1;
|
|
60
|
+
if (to < 0) (e.currentTarget.querySelector("input") as HTMLInputElement | null)?.focus();
|
|
61
|
+
else buttons[to]?.focus();
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const short = value.trim().length < 2;
|
|
65
|
+
return (
|
|
66
|
+
<div className={cn("flex min-h-0 flex-col", className)} onKeyDown={onKeyDown} data-testid="genesis-chat-search" role="search">
|
|
67
|
+
<div className="relative p-2">
|
|
68
|
+
<Search className="pointer-events-none absolute left-4.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
|
|
69
|
+
<Input
|
|
70
|
+
type="search"
|
|
71
|
+
value={value}
|
|
72
|
+
onChange={(e) => setValue(e.target.value)}
|
|
73
|
+
placeholder={conversationId ? "Search this conversation" : "Search messages"}
|
|
74
|
+
aria-label={conversationId ? "Search this conversation" : "Search messages"}
|
|
75
|
+
autoFocus={autoFocus}
|
|
76
|
+
className="pl-8 pr-8"
|
|
77
|
+
data-testid="genesis-chat-search-input"
|
|
78
|
+
/>
|
|
79
|
+
{value ? (
|
|
80
|
+
<Button type="button" variant="ghost" size="icon" className="absolute right-3 top-1/2 size-6 -translate-y-1/2" onClick={() => setValue("")} aria-label="Clear search">
|
|
81
|
+
<X />
|
|
82
|
+
</Button>
|
|
83
|
+
) : null}
|
|
84
|
+
</div>
|
|
85
|
+
<div className="sr-only" aria-live="polite">
|
|
86
|
+
{short || isSearching ? "" : hits.length === 0 ? "No results" : hits.length === 1 ? "1 result" : `${hits.length} results`}
|
|
87
|
+
</div>
|
|
88
|
+
{short ? null : isSearching && hits.length === 0 ? (
|
|
89
|
+
<div className="flex flex-col gap-2 p-3" aria-busy aria-label="Searching">
|
|
90
|
+
<Skeleton className="h-12 w-full" />
|
|
91
|
+
<Skeleton className="h-12 w-full" />
|
|
92
|
+
</div>
|
|
93
|
+
) : hits.length === 0 ? (
|
|
94
|
+
<EmptyState icon={Search} title="No results" description={`Nothing matches “${query}”.`} />
|
|
95
|
+
) : (
|
|
96
|
+
<ul ref={list} className="min-h-0 flex-1 overflow-y-auto" aria-label="Results">
|
|
97
|
+
{hits.map((h) => {
|
|
98
|
+
const who = h.senderId ? resolveUser(h.senderId).name : "Someone";
|
|
99
|
+
return (
|
|
100
|
+
<li key={`${h.messageId}:${h.inTranscript}`}>
|
|
101
|
+
<button
|
|
102
|
+
type="button"
|
|
103
|
+
data-search-hit
|
|
104
|
+
onClick={() => onOpen(h)}
|
|
105
|
+
className="flex w-full flex-col gap-0.5 px-3 py-2 text-left hover:bg-accent focus-visible:bg-accent focus-visible:outline-none"
|
|
106
|
+
data-testid="genesis-chat-search-hit"
|
|
107
|
+
>
|
|
108
|
+
<span className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
109
|
+
<span className="truncate font-medium text-foreground">{who}</span>
|
|
110
|
+
{!conversationId && conversationTitle ? <span className="truncate">in {conversationTitle(h.conversationId)}</span> : null}
|
|
111
|
+
<span className="ml-auto shrink-0">{relativeTime(h.createdAt)}</span>
|
|
112
|
+
</span>
|
|
113
|
+
<span className="flex items-start gap-1.5 text-sm">
|
|
114
|
+
{h.inTranscript ? (
|
|
115
|
+
<Badge variant="secondary" className="mt-0.5 shrink-0 gap-1 px-1.5 py-0 text-[10px]" data-testid="genesis-chat-search-transcript">
|
|
116
|
+
<Mic className="size-3" aria-hidden /> Transcript
|
|
117
|
+
</Badge>
|
|
118
|
+
) : null}
|
|
119
|
+
<SearchSnippet snippet={h.snippet} className="line-clamp-2" />
|
|
120
|
+
</span>
|
|
121
|
+
</button>
|
|
122
|
+
</li>
|
|
123
|
+
);
|
|
124
|
+
})}
|
|
125
|
+
</ul>
|
|
126
|
+
)}
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @feat:chat
|
|
3
|
+
* What every bubble of one thread shares: who you are, how people are named,
|
|
4
|
+
* your role, signed file URLs, and the actions, so a row needs only its item.
|
|
5
|
+
*/
|
|
6
|
+
import { createContext, useContext, type ComponentType } from "react";
|
|
7
|
+
import type { ChatLinkPreview, ChatMessage, ChatRole } from "../core/types";
|
|
8
|
+
import type { ChatAttachmentKind } from "../react/index";
|
|
9
|
+
import type { ChatAttachmentSlotProps } from "./attachments";
|
|
10
|
+
import type { LinkPreviewImages } from "./cards";
|
|
11
|
+
import type { ResolveUser } from "./format";
|
|
12
|
+
|
|
13
|
+
export interface ThreadContextValue {
|
|
14
|
+
me: string;
|
|
15
|
+
resolveUser: ResolveUser;
|
|
16
|
+
role: ChatRole | null;
|
|
17
|
+
/** More than two people: names and avatars show on others' messages. */
|
|
18
|
+
isGroup: boolean;
|
|
19
|
+
/** Owner or admin: may delete anyone's message. */
|
|
20
|
+
canModerate: boolean;
|
|
21
|
+
canPin: boolean;
|
|
22
|
+
canPost: boolean;
|
|
23
|
+
pins: Set<string>;
|
|
24
|
+
urls: Record<string, string>;
|
|
25
|
+
byId: Map<string, ChatMessage>;
|
|
26
|
+
quickReactions: readonly string[];
|
|
27
|
+
attachmentSlot?: ComponentType<ChatAttachmentSlotProps> | null;
|
|
28
|
+
/** Cards for the app's registered attachment kinds. */
|
|
29
|
+
attachmentKinds?: Record<string, ChatAttachmentKind>;
|
|
30
|
+
/** Link previews by message id, in link order. */
|
|
31
|
+
previews: Record<string, ChatLinkPreview[]>;
|
|
32
|
+
/** Where the full emoji picker loads its data from; no full picker without it. */
|
|
33
|
+
emojibaseUrl?: string;
|
|
34
|
+
/** Link preview images: off by default (see LinkPreviewImages). */
|
|
35
|
+
linkPreviewImages?: LinkPreviewImages;
|
|
36
|
+
linkPreviewProxyUrl?: (url: string) => string | null | undefined;
|
|
37
|
+
highlightedId: string | null;
|
|
38
|
+
editingId: string | null;
|
|
39
|
+
reply(m: ChatMessage): void;
|
|
40
|
+
focusComposer(): void;
|
|
41
|
+
startEdit(id: string | null): void;
|
|
42
|
+
saveEdit(m: ChatMessage, body: string): Promise<void>;
|
|
43
|
+
askDelete(m: ChatMessage): void;
|
|
44
|
+
askReport(m: ChatMessage): void;
|
|
45
|
+
react(m: ChatMessage, emoji: string, on: boolean): void;
|
|
46
|
+
pin(m: ChatMessage, on: boolean): void;
|
|
47
|
+
save(m: ChatMessage): void;
|
|
48
|
+
copy(m: ChatMessage): void;
|
|
49
|
+
jumpTo(messageId: string): void;
|
|
50
|
+
retry(clientId: string): void;
|
|
51
|
+
discard(clientId: string): void;
|
|
52
|
+
openThread?: (m: ChatMessage) => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const ThreadContext = createContext<ThreadContextValue | null>(null);
|
|
56
|
+
|
|
57
|
+
export function useThreadContext(): ThreadContextValue {
|
|
58
|
+
const ctx = useContext(ThreadContext);
|
|
59
|
+
if (!ctx) throw new Error("Chat bubbles render inside <ChatThread>.");
|
|
60
|
+
return ctx;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The six quick reactions. */
|
|
64
|
+
export const QUICK_REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🙏"] as const;
|