@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,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment happy-dom
|
|
3
|
+
* @feat:chat
|
|
4
|
+
*/
|
|
5
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
6
|
+
import { act, cleanup, configure, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
|
7
|
+
import type { ReactNode } from "react";
|
|
8
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
9
|
+
import { ChatProvider } from "../react";
|
|
10
|
+
import { ChatComposer, ChatInbox, ChatThread, VoiceNote, createMemoryChatClient, type MemoryChatClient, type MemoryConversationSeed } from "../web";
|
|
11
|
+
|
|
12
|
+
afterEach(cleanup);
|
|
13
|
+
// Generous waits: these run in parallel with other suites in CI.
|
|
14
|
+
configure({ asyncUtilTimeout: 4000 });
|
|
15
|
+
|
|
16
|
+
const ME = "00000000-0000-4000-8000-0000000000a1";
|
|
17
|
+
const SAM = "00000000-0000-4000-8000-0000000000b2";
|
|
18
|
+
const LEE = "00000000-0000-4000-8000-0000000000c3";
|
|
19
|
+
const names: Record<string, string> = { [ME]: "Me", [SAM]: "Sam", [LEE]: "Lee" };
|
|
20
|
+
const resolveUser = (id: string) => ({ name: names[id] ?? "Someone" });
|
|
21
|
+
const at = (min: number) => new Date(Date.now() - (60 - min) * 60_000).toISOString();
|
|
22
|
+
|
|
23
|
+
function mount(client: MemoryChatClient, ui: ReactNode) {
|
|
24
|
+
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
25
|
+
return render(
|
|
26
|
+
<QueryClientProvider client={queryClient}>
|
|
27
|
+
<ChatProvider client={client} userId={ME}>
|
|
28
|
+
{ui}
|
|
29
|
+
</ChatProvider>
|
|
30
|
+
</QueryClientProvider>,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const group = (over: Partial<MemoryConversationSeed> = {}): MemoryConversationSeed => ({
|
|
35
|
+
id: "c1",
|
|
36
|
+
title: "Morning crew",
|
|
37
|
+
members: [{ userId: ME }, { userId: SAM }, { userId: LEE }],
|
|
38
|
+
messages: [
|
|
39
|
+
{ id: "m1", kind: "system", body: "Sam started the conversation", createdAt: at(1) },
|
|
40
|
+
{ id: "m2", senderId: SAM, body: "Hello **there**", createdAt: at(2), reactions: [{ emoji: "👍", userId: SAM }] },
|
|
41
|
+
{ id: "m3", senderId: ME, body: "Hi Sam", createdAt: at(3) },
|
|
42
|
+
{ id: "m4", senderId: LEE, body: "", deletedAt: at(5), createdAt: at(4) },
|
|
43
|
+
],
|
|
44
|
+
...over,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("ChatThread", () => {
|
|
48
|
+
it("draws theirs, mine, system, deleted and internal notes, each labelled for screen readers", async () => {
|
|
49
|
+
const client = createMemoryChatClient({
|
|
50
|
+
me: ME,
|
|
51
|
+
conversations: [
|
|
52
|
+
group({
|
|
53
|
+
myRole: "observer",
|
|
54
|
+
members: [{ userId: SAM }, { userId: LEE }, { userId: "00000000-0000-4000-8000-0000000000d4" }],
|
|
55
|
+
messages: [
|
|
56
|
+
...group().messages!,
|
|
57
|
+
{ id: "m5", senderId: ME, body: "Check in with Sam", visibility: "observers", createdAt: at(6) },
|
|
58
|
+
],
|
|
59
|
+
}),
|
|
60
|
+
],
|
|
61
|
+
});
|
|
62
|
+
mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} />);
|
|
63
|
+
|
|
64
|
+
expect(await screen.findByText("Sam started the conversation")).toBeTruthy();
|
|
65
|
+
const theirs = screen.getByTestId("genesis-chat-theirs");
|
|
66
|
+
expect(theirs.textContent).toContain("Hello there");
|
|
67
|
+
expect(theirs.querySelector("strong")?.textContent).toBe("there");
|
|
68
|
+
expect(screen.getAllByTestId("genesis-chat-mine")[0]!.textContent).toContain("Hi Sam");
|
|
69
|
+
expect(screen.getByTestId("genesis-chat-deleted").textContent).toContain("Message deleted");
|
|
70
|
+
const note = screen.getByTestId("genesis-chat-note");
|
|
71
|
+
expect(note.textContent).toContain("Internal note");
|
|
72
|
+
expect(note.textContent).toContain("Check in with Sam");
|
|
73
|
+
// Every bubble carries "Name, time: text".
|
|
74
|
+
const labels = screen.getAllByRole("article").map((a) => a.getAttribute("aria-label"));
|
|
75
|
+
expect(labels.some((l) => /^Sam, .+: Hello there$/.test(l ?? ""))).toBe(true);
|
|
76
|
+
expect(labels.some((l) => /^You, .+: Hi Sam$/.test(l ?? ""))).toBe(true);
|
|
77
|
+
expect(labels.some((l) => /internal note/.test(l ?? ""))).toBe(true);
|
|
78
|
+
// In a group, others' names show.
|
|
79
|
+
expect(screen.getAllByText("Sam").length).toBeGreaterThan(0);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("sends on Enter, shows it as sending, then as sent", async () => {
|
|
83
|
+
const client = createMemoryChatClient({ me: ME, conversations: [group()] });
|
|
84
|
+
mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} />);
|
|
85
|
+
const input = await screen.findByTestId("genesis-chat-input");
|
|
86
|
+
// The send waits until the test lets it go, so the sending state is on screen when it's checked.
|
|
87
|
+
const release = client.holdNextSend();
|
|
88
|
+
fireEvent.change(input, { target: { value: "On my way" } });
|
|
89
|
+
fireEvent.keyDown(input, { key: "Enter" });
|
|
90
|
+
|
|
91
|
+
expect(await screen.findByTestId("genesis-chat-pending-sending")).toBeTruthy();
|
|
92
|
+
expect(await screen.findByText("Sending…")).toBeTruthy();
|
|
93
|
+
expect((input as HTMLTextAreaElement).value).toBe("");
|
|
94
|
+
release();
|
|
95
|
+
await waitFor(() => expect(screen.queryByTestId("genesis-chat-pending-sending")).toBeNull());
|
|
96
|
+
const sends = client.calls.filter((c) => c.method === "send");
|
|
97
|
+
expect(sends).toHaveLength(1);
|
|
98
|
+
expect(sends[0]!.args[0]).toMatchObject({ conversationId: "c1", body: "On my way", visibility: "everyone" });
|
|
99
|
+
expect(screen.getAllByTestId("genesis-chat-mine").some((b) => b.textContent?.includes("On my way"))).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("Shift+Enter is a new line, not a send", async () => {
|
|
103
|
+
const client = createMemoryChatClient({ me: ME, conversations: [group()] });
|
|
104
|
+
mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} />);
|
|
105
|
+
const input = await screen.findByTestId("genesis-chat-input");
|
|
106
|
+
fireEvent.change(input, { target: { value: "line one" } });
|
|
107
|
+
fireEvent.keyDown(input, { key: "Enter", shiftKey: true });
|
|
108
|
+
expect(client.calls.some((c) => c.method === "send")).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("a failed send says Not sent, and Retry sends it again with the same clientId", async () => {
|
|
112
|
+
const client = createMemoryChatClient({ me: ME, conversations: [group()] });
|
|
113
|
+
client.failNextSends(1);
|
|
114
|
+
mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} />);
|
|
115
|
+
const input = await screen.findByTestId("genesis-chat-input");
|
|
116
|
+
fireEvent.change(input, { target: { value: "Are you there?" } });
|
|
117
|
+
fireEvent.click(screen.getByTestId("genesis-chat-send"));
|
|
118
|
+
|
|
119
|
+
const failed = await screen.findByTestId("genesis-chat-pending-failed");
|
|
120
|
+
expect(within(failed).getByRole("alert").textContent).toMatch(/Not sent/);
|
|
121
|
+
await act(async () => {
|
|
122
|
+
fireEvent.click(within(failed).getByTestId("genesis-chat-retry"));
|
|
123
|
+
});
|
|
124
|
+
await waitFor(() => expect(screen.queryByTestId("genesis-chat-pending-failed")).toBeNull());
|
|
125
|
+
const sends = client.calls.filter((c) => c.method === "send").map((c) => c.args[0] as { clientId: string });
|
|
126
|
+
expect(sends).toHaveLength(2);
|
|
127
|
+
expect(sends[0]!.clientId).toBe(sends[1]!.clientId);
|
|
128
|
+
expect(screen.getAllByTestId("genesis-chat-mine").some((b) => b.textContent?.includes("Are you there?"))).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("Delete on a failed message throws it away", async () => {
|
|
132
|
+
const client = createMemoryChatClient({ me: ME, conversations: [group()] });
|
|
133
|
+
client.failNextSends(1);
|
|
134
|
+
mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} />);
|
|
135
|
+
const input = await screen.findByTestId("genesis-chat-input");
|
|
136
|
+
fireEvent.change(input, { target: { value: "never mind" } });
|
|
137
|
+
fireEvent.keyDown(input, { key: "Enter" });
|
|
138
|
+
const failed = await screen.findByTestId("genesis-chat-pending-failed");
|
|
139
|
+
await act(async () => {
|
|
140
|
+
fireEvent.click(within(failed).getByTestId("genesis-chat-discard"));
|
|
141
|
+
});
|
|
142
|
+
await waitFor(() => expect(screen.queryByTestId("genesis-chat-pending-failed")).toBeNull());
|
|
143
|
+
expect(screen.queryByText("never mind")).toBeNull();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("clicking a reaction chip toggles yours", async () => {
|
|
147
|
+
const client = createMemoryChatClient({ me: ME, conversations: [group()] });
|
|
148
|
+
mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} />);
|
|
149
|
+
const chip = await screen.findByTestId("genesis-chat-reaction-👍");
|
|
150
|
+
expect(chip.getAttribute("aria-pressed")).toBe("false");
|
|
151
|
+
expect(chip.textContent).toContain("1");
|
|
152
|
+
await act(async () => {
|
|
153
|
+
fireEvent.click(chip);
|
|
154
|
+
});
|
|
155
|
+
expect(client.calls.find((c) => c.method === "react")?.args).toEqual(["m2", "👍", true]);
|
|
156
|
+
await waitFor(() => expect(screen.getByTestId("genesis-chat-reaction-👍").getAttribute("aria-pressed")).toBe("true"));
|
|
157
|
+
expect(screen.getByTestId("genesis-chat-reaction-👍").textContent).toContain("2");
|
|
158
|
+
|
|
159
|
+
await act(async () => {
|
|
160
|
+
fireEvent.click(screen.getByTestId("genesis-chat-reaction-👍"));
|
|
161
|
+
});
|
|
162
|
+
expect(client.calls.filter((c) => c.method === "react").at(-1)?.args).toEqual(["m2", "👍", false]);
|
|
163
|
+
await waitFor(() => expect(screen.getByTestId("genesis-chat-reaction-👍").getAttribute("aria-pressed")).toBe("false"));
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("a reply quote scrolls to and highlights the original", async () => {
|
|
167
|
+
const client = createMemoryChatClient({
|
|
168
|
+
me: ME,
|
|
169
|
+
conversations: [group({ messages: [...group().messages!, { id: "m6", senderId: SAM, body: "Yes!", replyToId: "m3", createdAt: at(7) }] })],
|
|
170
|
+
});
|
|
171
|
+
mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} />);
|
|
172
|
+
const quote = await screen.findByTestId("genesis-chat-reply-quote");
|
|
173
|
+
expect(quote.textContent).toContain("You");
|
|
174
|
+
expect(quote.textContent).toContain("Hi Sam");
|
|
175
|
+
fireEvent.click(quote);
|
|
176
|
+
await waitFor(() => expect(document.querySelector("#genesis-chat-msg-m3 .ring-2")).not.toBeNull());
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("says why you can't post when only admins can", async () => {
|
|
180
|
+
const client = createMemoryChatClient({ me: ME, conversations: [group({ canPost: false, settings: { who_posts: "admins" } })] });
|
|
181
|
+
mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} />);
|
|
182
|
+
expect((await screen.findByTestId("genesis-chat-cannot-post")).textContent).toContain("Only admins can post here");
|
|
183
|
+
expect(screen.queryByTestId("genesis-chat-input")).toBeNull();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("tells someone with no role here they can't post, instead of loading forever", async () => {
|
|
187
|
+
const client = createMemoryChatClient({ me: ME, conversations: [group({ canPost: false, myRole: null })] });
|
|
188
|
+
mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} />);
|
|
189
|
+
expect((await screen.findByTestId("genesis-chat-cannot-post")).textContent).toContain("can't post");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("waits only while the conversation loads, and says can't post once it settles to nothing", () => {
|
|
193
|
+
const client = createMemoryChatClient({ me: ME, conversations: [] });
|
|
194
|
+
const state = { send: vi.fn(), signalTyping: vi.fn(), signalRecording: vi.fn(), role: null, canPost: false, accessLoading: false, conversation: null };
|
|
195
|
+
mount(client, <ChatComposer conversation={{ ...state, conversationLoading: true }} />);
|
|
196
|
+
expect(screen.queryByTestId("genesis-chat-cannot-post")).toBeNull();
|
|
197
|
+
cleanup();
|
|
198
|
+
mount(client, <ChatComposer conversation={{ ...state, conversationLoading: false }} />);
|
|
199
|
+
expect(screen.getByTestId("genesis-chat-cannot-post").textContent).toContain("can't post");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("announces an incoming message and shows who is typing", async () => {
|
|
203
|
+
const client = createMemoryChatClient({ me: ME, conversations: [group()] });
|
|
204
|
+
mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} />);
|
|
205
|
+
await screen.findByText("Hi Sam");
|
|
206
|
+
act(() => client.activity("c1", LEE, "typing"));
|
|
207
|
+
await waitFor(() => expect(screen.getByTestId("genesis-chat-activity").textContent).toContain("Lee is typing…"));
|
|
208
|
+
act(() => void client.receive("c1", LEE, "Coming now"));
|
|
209
|
+
await waitFor(() => expect(screen.getByTestId("genesis-chat-announcer").textContent).toBe("Lee: Coming now"));
|
|
210
|
+
expect(screen.getByTestId("genesis-chat-announcer").getAttribute("aria-live")).toBe("polite");
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe("ChatInbox", () => {
|
|
215
|
+
it("lists conversations with the other person's name, You: previews and unread counts, and selects on click", async () => {
|
|
216
|
+
const client = createMemoryChatClient({
|
|
217
|
+
me: ME,
|
|
218
|
+
conversations: [
|
|
219
|
+
{ id: "d1", members: [{ userId: ME }, { userId: SAM }], messages: [{ senderId: ME, body: "See you **soon**", createdAt: at(10) }], pinned: true },
|
|
220
|
+
group({ members: [{ userId: ME, lastReadSeq: 0 }, { userId: SAM }, { userId: LEE }], muted: true }),
|
|
221
|
+
],
|
|
222
|
+
});
|
|
223
|
+
const picked: string[] = [];
|
|
224
|
+
mount(client, <ChatInbox resolveUser={resolveUser} onSelect={(id) => picked.push(id)} selectedId="d1" />);
|
|
225
|
+
const row = await screen.findByTestId("genesis-chat-inbox-row-d1");
|
|
226
|
+
expect(row.textContent).toContain("Sam");
|
|
227
|
+
expect(row.textContent).toContain("You: See you soon");
|
|
228
|
+
expect(row.getAttribute("aria-current")).toBe("true");
|
|
229
|
+
expect(row.getAttribute("aria-label")).toMatch(/pinned/);
|
|
230
|
+
const groupRow = screen.getByTestId("genesis-chat-inbox-row-c1");
|
|
231
|
+
expect(groupRow.textContent).toContain("Morning crew");
|
|
232
|
+
expect(groupRow.getAttribute("aria-label")).toMatch(/2 unread.*muted/);
|
|
233
|
+
fireEvent.click(groupRow);
|
|
234
|
+
expect(picked).toEqual(["c1"]);
|
|
235
|
+
row.focus();
|
|
236
|
+
fireEvent.keyDown(row, { key: "ArrowDown" });
|
|
237
|
+
expect(document.activeElement).toBe(groupRow);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("previews a message that is only an app card by its kind's title", async () => {
|
|
241
|
+
const client = createMemoryChatClient({
|
|
242
|
+
me: ME,
|
|
243
|
+
conversations: [{ id: "d1", members: [{ userId: ME }, { userId: SAM }], messages: [{ senderId: SAM, body: "", createdAt: at(10), attachments: [{ kind: "check_in" }] }] }],
|
|
244
|
+
});
|
|
245
|
+
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
246
|
+
render(
|
|
247
|
+
<QueryClientProvider client={queryClient}>
|
|
248
|
+
<ChatProvider client={client} userId={ME} attachmentKinds={{ check_in: { title: () => "Check-in" } }}>
|
|
249
|
+
<ChatInbox resolveUser={resolveUser} onSelect={() => undefined} />
|
|
250
|
+
</ChatProvider>
|
|
251
|
+
</QueryClientProvider>,
|
|
252
|
+
);
|
|
253
|
+
expect((await screen.findByTestId("genesis-chat-inbox-row-d1")).textContent).toContain("Check-in");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("shows an empty state when there are none", async () => {
|
|
257
|
+
const client = createMemoryChatClient({ me: ME, conversations: [] });
|
|
258
|
+
mount(client, <ChatInbox resolveUser={resolveUser} onSelect={() => undefined} />);
|
|
259
|
+
expect(await screen.findByText("No conversations yet")).toBeTruthy();
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
describe("VoiceNote", () => {
|
|
264
|
+
it("cycles the speed 1× → 1.5× → 2× → 1× and sets the audio's rate", () => {
|
|
265
|
+
const { container } = render(<VoiceNote src="https://files.example/v.webm" durationMs={42_000} waveform={[10, 50, 100, 30]} transcript="Hello there" />);
|
|
266
|
+
const rate = screen.getByTestId("genesis-chat-voice-rate");
|
|
267
|
+
const audio = container.querySelector("audio")!;
|
|
268
|
+
expect(rate.textContent).toBe("1×");
|
|
269
|
+
fireEvent.click(rate);
|
|
270
|
+
expect(rate.textContent).toBe("1.5×");
|
|
271
|
+
expect(audio.playbackRate).toBe(1.5);
|
|
272
|
+
fireEvent.click(rate);
|
|
273
|
+
expect(rate.textContent).toBe("2×");
|
|
274
|
+
expect(audio.playbackRate).toBe(2);
|
|
275
|
+
fireEvent.click(rate);
|
|
276
|
+
expect(rate.textContent).toBe("1×");
|
|
277
|
+
expect(screen.getByText("0:42")).toBeTruthy();
|
|
278
|
+
expect(screen.getByText("Transcript")).toBeTruthy();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("says Transcribing… while the transcript is pending", () => {
|
|
282
|
+
render(<VoiceNote src={null} durationMs={5000} transcriptStatus="pending" />);
|
|
283
|
+
expect(screen.getByText("Transcribing…")).toBeTruthy();
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
describe("voice notes in the composer", () => {
|
|
288
|
+
const realMedia = Object.getOwnPropertyDescriptor(navigator, "mediaDevices");
|
|
289
|
+
afterEach(() => {
|
|
290
|
+
vi.restoreAllMocks();
|
|
291
|
+
vi.unstubAllGlobals();
|
|
292
|
+
if (realMedia) Object.defineProperty(navigator, "mediaDevices", realMedia);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
const stubMic = (getUserMedia: () => Promise<unknown>) =>
|
|
296
|
+
Object.defineProperty(navigator, "mediaDevices", { configurable: true, value: { getUserMedia } });
|
|
297
|
+
|
|
298
|
+
class FakeRecorder {
|
|
299
|
+
static isTypeSupported = (t: string) => t === "audio/webm;codecs=opus";
|
|
300
|
+
state: "inactive" | "recording" = "inactive";
|
|
301
|
+
mimeType: string;
|
|
302
|
+
ondataavailable: ((e: { data: Blob }) => void) | null = null;
|
|
303
|
+
onstop: (() => void) | null = null;
|
|
304
|
+
constructor(_stream: unknown, opts?: { mimeType?: string }) {
|
|
305
|
+
this.mimeType = opts?.mimeType ?? "";
|
|
306
|
+
}
|
|
307
|
+
start() {
|
|
308
|
+
this.state = "recording";
|
|
309
|
+
}
|
|
310
|
+
stop() {
|
|
311
|
+
this.state = "inactive";
|
|
312
|
+
this.ondataavailable?.({ data: new Blob(["opus"], { type: "audio/webm" }) });
|
|
313
|
+
this.onstop?.();
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
it("records with MediaRecorder and sends a voice attachment with its duration and waveform", async () => {
|
|
318
|
+
let now = 1_000_000;
|
|
319
|
+
vi.spyOn(Date, "now").mockImplementation(() => now);
|
|
320
|
+
vi.stubGlobal("MediaRecorder", FakeRecorder);
|
|
321
|
+
stubMic(async () => ({ getTracks: () => [{ stop() {} }] }));
|
|
322
|
+
const client = createMemoryChatClient({ me: ME, conversations: [group()] });
|
|
323
|
+
mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} />);
|
|
324
|
+
|
|
325
|
+
fireEvent.click(await screen.findByTestId("genesis-chat-mic"));
|
|
326
|
+
await screen.findByTestId("genesis-chat-recording");
|
|
327
|
+
now += 3_000;
|
|
328
|
+
await act(async () => {
|
|
329
|
+
fireEvent.click(screen.getByTestId("genesis-chat-voice-send"));
|
|
330
|
+
});
|
|
331
|
+
await waitFor(() => expect(client.calls.some((c) => c.method === "send")).toBe(true));
|
|
332
|
+
const input = client.calls.find((c) => c.method === "send")!.args[0] as { body: string; attachments: { kind: string; durationMs: number; waveform: number[]; mime: string }[] };
|
|
333
|
+
expect(input.body).toBe("");
|
|
334
|
+
expect(input.attachments[0]).toMatchObject({ kind: "voice", durationMs: 3000, mime: "audio/webm" });
|
|
335
|
+
expect(input.attachments[0]!.waveform).toHaveLength(48);
|
|
336
|
+
expect(client.calls.find((c) => c.method === "upload")!.args[1]).toMatch(/^voice-.*\.webm$/);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("says so clearly when the microphone is blocked", async () => {
|
|
340
|
+
vi.stubGlobal("MediaRecorder", FakeRecorder);
|
|
341
|
+
stubMic(async () => {
|
|
342
|
+
throw Object.assign(new Error("denied"), { name: "NotAllowedError" });
|
|
343
|
+
});
|
|
344
|
+
const client = createMemoryChatClient({ me: ME, conversations: [group()] });
|
|
345
|
+
mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} />);
|
|
346
|
+
fireEvent.click(await screen.findByTestId("genesis-chat-mic"));
|
|
347
|
+
expect((await screen.findByTestId("genesis-chat-mic-error")).textContent).toMatch(/Microphone access is blocked/);
|
|
348
|
+
});
|
|
349
|
+
});
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @feat:chat
|
|
3
|
+
* Availability, the same on every platform: is someone inside their working hours (read in
|
|
4
|
+
* their own time zone), are they away, and the one line a thread shows about it ("Mark
|
|
5
|
+
* replies Mon–Fri, 9–5 (Eastern)", "Away until Monday: Back Monday"). Pure: `now` is passed
|
|
6
|
+
* in, and time zones come from Intl.
|
|
7
|
+
*/
|
|
8
|
+
import type { ChatAvailability, Weekday, WorkingHours } from "./types";
|
|
9
|
+
|
|
10
|
+
export const WEEKDAYS: readonly Weekday[] = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
|
|
11
|
+
|
|
12
|
+
const SHORT: Record<Weekday, string> = { mon: "Mon", tue: "Tue", wed: "Wed", thu: "Thu", fri: "Fri", sat: "Sat", sun: "Sun" };
|
|
13
|
+
|
|
14
|
+
/** "09:30" → 570; a bad value (anything but such a string) → null. "24:00" is allowed as the end of a day. */
|
|
15
|
+
export function minutesOf(hhmm: unknown): number | null {
|
|
16
|
+
if (typeof hhmm !== "string") return null;
|
|
17
|
+
const m = /^(\d{1,2}):(\d{2})$/.exec(hhmm.trim());
|
|
18
|
+
if (!m) return null;
|
|
19
|
+
const h = Number(m[1]);
|
|
20
|
+
const min = Number(m[2]);
|
|
21
|
+
if (h > 24 || min > 59 || (h === 24 && min > 0)) return null;
|
|
22
|
+
return h * 60 + min;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** At most this many ranges a day, as the database allows. */
|
|
26
|
+
export const MAX_RANGES_PER_DAY = 4;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Working hours as they may safely be read: only mon..sun, each an array of `["HH:MM", "HH:MM"]`
|
|
30
|
+
* string pairs (at most four a day); every other day, range or value is dropped. Null when
|
|
31
|
+
* nothing is left. Hours come from other people, so they are never trusted as typed.
|
|
32
|
+
*/
|
|
33
|
+
export function normalizeHours(raw: unknown): WorkingHours | null {
|
|
34
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
35
|
+
const src = raw as Record<string, unknown>;
|
|
36
|
+
const out: WorkingHours = {};
|
|
37
|
+
let any = false;
|
|
38
|
+
for (const day of WEEKDAYS) {
|
|
39
|
+
if (!Object.prototype.hasOwnProperty.call(src, day)) continue;
|
|
40
|
+
const ranges = src[day];
|
|
41
|
+
if (!Array.isArray(ranges)) continue;
|
|
42
|
+
const kept: [string, string][] = [];
|
|
43
|
+
for (const r of ranges) {
|
|
44
|
+
if (!Array.isArray(r) || r.length !== 2) continue;
|
|
45
|
+
const [from, to] = r as unknown[];
|
|
46
|
+
if (typeof from !== "string" || typeof to !== "string" || minutesOf(from) == null || minutesOf(to) == null) continue;
|
|
47
|
+
kept.push([from, to]);
|
|
48
|
+
if (kept.length >= MAX_RANGES_PER_DAY) break;
|
|
49
|
+
}
|
|
50
|
+
if (kept.length) {
|
|
51
|
+
out[day] = kept;
|
|
52
|
+
any = true;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return any ? out : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The weekday and minute of the day at `now` in `timeZone` (the device's zone when null or unknown). */
|
|
59
|
+
export function localClock(now: Date, timeZone?: string | null): { day: Weekday; minutes: number } {
|
|
60
|
+
let parts: Intl.DateTimeFormatPart[];
|
|
61
|
+
try {
|
|
62
|
+
parts = new Intl.DateTimeFormat("en-US", {
|
|
63
|
+
timeZone: timeZone || undefined,
|
|
64
|
+
weekday: "short",
|
|
65
|
+
hour: "2-digit",
|
|
66
|
+
minute: "2-digit",
|
|
67
|
+
hourCycle: "h23",
|
|
68
|
+
}).formatToParts(now);
|
|
69
|
+
} catch {
|
|
70
|
+
parts = new Intl.DateTimeFormat("en-US", { weekday: "short", hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).formatToParts(now);
|
|
71
|
+
}
|
|
72
|
+
const get = (t: string) => parts.find((p) => p.type === t)?.value ?? "";
|
|
73
|
+
const day = get("weekday").slice(0, 3).toLowerCase() as Weekday;
|
|
74
|
+
const hour = Number(get("hour")) % 24;
|
|
75
|
+
return { day: WEEKDAYS.includes(day) ? day : "mon", minutes: hour * 60 + Number(get("minute")) };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Is `now` inside these working hours, read in `timeZone`? No hours set means always.
|
|
80
|
+
* A range whose end is at or before its start runs overnight into the next day
|
|
81
|
+
* (`fri: [["22:00","02:00"]]` covers Friday 22:00 to Saturday 02:00).
|
|
82
|
+
*/
|
|
83
|
+
export function isWithinWorkingHours(raw: WorkingHours | null | undefined, timeZone: string | null | undefined, now: Date = new Date()): boolean {
|
|
84
|
+
const hours = normalizeHours(raw);
|
|
85
|
+
if (!hours) return true;
|
|
86
|
+
const { day, minutes } = localClock(now, timeZone);
|
|
87
|
+
const yesterday = WEEKDAYS[(WEEKDAYS.indexOf(day) + 6) % 7]!;
|
|
88
|
+
for (const [from, to] of hours[day] ?? []) {
|
|
89
|
+
const a = minutesOf(from);
|
|
90
|
+
const b = minutesOf(to);
|
|
91
|
+
if (a == null || b == null) continue;
|
|
92
|
+
if (b > a ? minutes >= a && minutes < b : minutes >= a) return true;
|
|
93
|
+
}
|
|
94
|
+
for (const [from, to] of hours[yesterday] ?? []) {
|
|
95
|
+
const a = minutesOf(from);
|
|
96
|
+
const b = minutesOf(to);
|
|
97
|
+
if (a == null || b == null) continue;
|
|
98
|
+
if (b <= a && minutes < b) return true;
|
|
99
|
+
}
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Away now: an away message with no end date, or an end date still ahead. */
|
|
104
|
+
export function isAway(a: Pick<ChatAvailability, "awayMessage" | "awayUntil"> | null | undefined, now: Date = new Date()): boolean {
|
|
105
|
+
if (!a) return false;
|
|
106
|
+
if (a.awayUntil) return Date.parse(a.awayUntil) > now.getTime();
|
|
107
|
+
return Boolean(a.awayMessage?.trim());
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** "9", "9:30", "5" (12-hour, no am/pm: the range reads "9–5"). */
|
|
111
|
+
function clock(hhmm: string): string {
|
|
112
|
+
const total = minutesOf(hhmm) ?? 0;
|
|
113
|
+
const h = Math.floor(total / 60) % 24;
|
|
114
|
+
const m = total % 60;
|
|
115
|
+
const h12 = h % 12 === 0 ? 12 : h % 12;
|
|
116
|
+
return m ? `${h12}:${String(m).padStart(2, "0")}` : String(h12);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** "Mon–Fri, 9–5", "Mon–Thu, 9–5; Fri, 9–1". Days with the same hours are grouped. Empty when none. */
|
|
120
|
+
export function hoursSummary(raw: WorkingHours | null | undefined): string {
|
|
121
|
+
const hours = normalizeHours(raw);
|
|
122
|
+
if (!hours) return "";
|
|
123
|
+
const key = (d: Weekday) => (hours[d] ?? []).map(([a, b]) => `${clock(a)}–${clock(b)}`).join(", ");
|
|
124
|
+
const runs: { from: Weekday; to: Weekday; text: string }[] = [];
|
|
125
|
+
for (const d of WEEKDAYS) {
|
|
126
|
+
const text = key(d);
|
|
127
|
+
if (!text) continue;
|
|
128
|
+
const last = runs[runs.length - 1];
|
|
129
|
+
if (last && last.text === text && WEEKDAYS.indexOf(last.to) === WEEKDAYS.indexOf(d) - 1) last.to = d;
|
|
130
|
+
else runs.push({ from: d, to: d, text });
|
|
131
|
+
}
|
|
132
|
+
return runs
|
|
133
|
+
.map((r) => {
|
|
134
|
+
const days = r.from === r.to ? SHORT[r.from] : WEEKDAYS.indexOf(r.to) - WEEKDAYS.indexOf(r.from) === 1 ? `${SHORT[r.from]}, ${SHORT[r.to]}` : `${SHORT[r.from]}–${SHORT[r.to]}`;
|
|
135
|
+
return `${days}, ${r.text}`;
|
|
136
|
+
})
|
|
137
|
+
.join("; ");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** "Eastern" for America/New_York (the zone's generic name, less " Time"); the city when Intl has no name. */
|
|
141
|
+
export function timeZoneLabel(timeZone: string | null | undefined, locale = "en-US"): string {
|
|
142
|
+
if (!timeZone) return "";
|
|
143
|
+
try {
|
|
144
|
+
const name = new Intl.DateTimeFormat(locale, { timeZone, timeZoneName: "longGeneric" })
|
|
145
|
+
.formatToParts(new Date())
|
|
146
|
+
.find((p) => p.type === "timeZoneName")?.value;
|
|
147
|
+
if (name && !/^GMT|^UTC/.test(name)) return name.replace(/ Time$/, "");
|
|
148
|
+
} catch {
|
|
149
|
+
// an unknown zone: fall through
|
|
150
|
+
}
|
|
151
|
+
return (timeZone.split("/").pop() ?? timeZone).replace(/_/g, " ");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** "3:00 PM" today, "Monday" within the week, else "12 Mar". */
|
|
155
|
+
export function untilLabel(iso: string, now: Date = new Date(), locale?: string, timeZone?: string | null): string {
|
|
156
|
+
const d = new Date(iso);
|
|
157
|
+
if (!Number.isFinite(d.getTime())) return "later";
|
|
158
|
+
try {
|
|
159
|
+
return untilIn(d, now, locale, timeZone || undefined);
|
|
160
|
+
} catch {
|
|
161
|
+
// An unknown zone or locale: the device's.
|
|
162
|
+
return untilIn(d, now, undefined, undefined);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function untilIn(d: Date, now: Date, locale: string | undefined, tz: string | undefined): string {
|
|
167
|
+
const dayKey = (x: Date) => new Intl.DateTimeFormat("en-CA", { timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit" }).format(x);
|
|
168
|
+
if (dayKey(d) === dayKey(now)) return d.toLocaleTimeString(locale, { hour: "numeric", minute: "2-digit", timeZone: tz });
|
|
169
|
+
const days = (d.getTime() - now.getTime()) / 86_400_000;
|
|
170
|
+
if (days < 7) return d.toLocaleDateString(locale, { weekday: "long", timeZone: tz });
|
|
171
|
+
return d.toLocaleDateString(locale, { day: "numeric", month: "short", timeZone: tz });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface AwayLineOptions {
|
|
175
|
+
now?: Date;
|
|
176
|
+
locale?: string;
|
|
177
|
+
/** Show the hours line even inside working hours (a profile, not a banner). */
|
|
178
|
+
always?: boolean;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The line a thread shows about someone, or null when they are around:
|
|
183
|
+
* - away with a date: "Away until Monday: Back Monday" (the away message after the colon);
|
|
184
|
+
* - away with no date: "Mark is away: On a retreat";
|
|
185
|
+
* - outside working hours: "Mark replies Mon–Fri, 9–5 (Eastern)".
|
|
186
|
+
*/
|
|
187
|
+
export function awayLine(a: ChatAvailability | null | undefined, name: string, opts: AwayLineOptions = {}): string | null {
|
|
188
|
+
if (!a) return null;
|
|
189
|
+
const now = opts.now ?? new Date();
|
|
190
|
+
const message = a.awayMessage?.trim();
|
|
191
|
+
if (isAway(a, now)) {
|
|
192
|
+
if (a.awayUntil) return `Away until ${untilLabel(a.awayUntil, now, opts.locale, a.timeZone)}${message ? `: ${message}` : ""}`;
|
|
193
|
+
return `${name} is away${message ? `: ${message}` : ""}`;
|
|
194
|
+
}
|
|
195
|
+
const summary = hoursSummary(a.hours);
|
|
196
|
+
if (!summary) return null;
|
|
197
|
+
if (!opts.always && isWithinWorkingHours(a.hours, a.timeZone, now)) return null;
|
|
198
|
+
const zone = timeZoneLabel(a.timeZone, opts.locale);
|
|
199
|
+
return `${name} replies ${summary}${zone ? ` (${zone})` : ""}`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** "Sam", "Sam and Lee", "Sam, Lee and 3 others": at most two names. */
|
|
203
|
+
export function namesLine(names: string[]): string {
|
|
204
|
+
if (names.length <= 1) return names[0] ?? "";
|
|
205
|
+
if (names.length === 2) return `${names[0]} and ${names[1]}`;
|
|
206
|
+
const rest = names.length - 2;
|
|
207
|
+
return `${names[0]}, ${names[1]} and ${rest === 1 ? "1 other" : `${rest} others`}`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The line for several people (a group's members): one person's full `awayLine`, or for more
|
|
212
|
+
* "Sam and Lee are away", "Sam, Lee and 2 others are outside their hours", or "… are away or
|
|
213
|
+
* outside their hours" when it's a mix. Null when everyone is around.
|
|
214
|
+
*/
|
|
215
|
+
export function awaySummary(people: { availability: ChatAvailability | null | undefined; name: string }[], opts: AwayLineOptions = {}): string | null {
|
|
216
|
+
const now = opts.now ?? new Date();
|
|
217
|
+
const out = people.filter((p) => awayLine(p.availability, p.name, { ...opts, now, always: false }) !== null);
|
|
218
|
+
if (out.length === 0) return null;
|
|
219
|
+
if (out.length === 1) return awayLine(out[0]!.availability, out[0]!.name, { ...opts, now, always: false });
|
|
220
|
+
const away = out.filter((p) => isAway(p.availability, now)).length;
|
|
221
|
+
const what = away === out.length ? "away" : away === 0 ? "outside their hours" : "away or outside their hours";
|
|
222
|
+
return `${namesLine(out.map((p) => p.name))} are ${what}`;
|
|
223
|
+
}
|