@marktiderman/genesis-chat 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +304 -13
- package/dist/{chunk-SAY24E6V.js → chunk-5D5A7I4Q.js} +166 -45
- package/dist/chunk-5D5A7I4Q.js.map +1 -0
- package/dist/{chunk-JQXZUEEL.js → chunk-7S6HYNWW.js} +382 -8
- package/dist/chunk-7S6HYNWW.js.map +1 -0
- package/dist/chunk-BJCE5O7V.js +70 -0
- package/dist/chunk-BJCE5O7V.js.map +1 -0
- package/dist/chunk-PZTOY2LY.js +576 -0
- package/dist/chunk-PZTOY2LY.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-DMa2j7kz.d.ts} +152 -11
- package/dist/index.d.ts +78 -163
- package/dist/index.js +3 -2
- package/dist/native.d.ts +1910 -1
- package/dist/native.js +4900 -0
- package/dist/native.js.map +1 -1
- package/dist/outbox-B0y3phE0.d.ts +151 -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 +149 -7
- package/dist/react.js +3 -3
- package/dist/server.d.ts +510 -21
- package/dist/server.js +863 -21
- package/dist/server.js.map +1 -1
- package/dist/thread-DWXHKPdw.d.ts +57 -0
- package/dist/web.d.ts +313 -10
- package/dist/web.js +1731 -60
- package/dist/web.js.map +1 -1
- package/migrations/0002_genesis_chat_coaching.sql +934 -0
- package/migrations/tests/0002_genesis_chat_coaching.test.sql +265 -0
- package/package.json +74 -6
- package/src/__tests__/coaching-core.test.ts +332 -0
- package/src/__tests__/coaching-react.test.tsx +248 -0
- package/src/__tests__/coaching-web.test.tsx +449 -0
- package/src/__tests__/core.test.ts +91 -0
- package/src/__tests__/outbox.test.ts +122 -0
- package/src/__tests__/realtime.test.ts +70 -0
- package/src/__tests__/review-fixes-core.test.ts +193 -0
- package/src/__tests__/review-fixes-web.test.tsx +402 -0
- package/src/__tests__/server-effects.test.ts +158 -0
- package/src/__tests__/server-fakes.ts +90 -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 +290 -0
- package/src/__tests__/web-markdown.test.tsx +88 -0
- package/src/__tests__/web.test.tsx +333 -0
- package/src/core/availability.ts +223 -0
- package/src/core/client.ts +757 -0
- package/src/core/extras.ts +106 -0
- package/src/core/media.ts +144 -0
- package/src/core/outbox.ts +429 -0
- package/src/core/thread.ts +171 -0
- package/src/core/types.ts +311 -0
- package/src/index.ts +13 -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 +238 -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 +344 -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 +421 -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 +116 -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 +116 -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 +121 -0
- package/src/native/message-bubble.tsx +374 -0
- package/src/native/moderation.tsx +42 -0
- package/src/native/modules.ts +275 -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 +314 -0
- package/src/native/search-screen.tsx +168 -0
- package/src/native/stack-options.ts +36 -0
- package/src/native/start-sheet.tsx +87 -0
- package/src/native/thread-screen.tsx +620 -0
- package/src/native/topics.tsx +179 -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 +370 -0
- package/src/native/video-recorder-state.ts +140 -0
- package/src/native/video-recorder.tsx +271 -0
- package/src/native/video-upload-tray.tsx +77 -0
- package/src/native/video-upload.ts +432 -0
- package/src/native/voice-player.tsx +283 -0
- package/src/react/index.tsx +810 -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/handler.ts +32 -0
- package/src/server/index.ts +57 -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 +563 -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 +195 -0
- package/src/web/index.ts +75 -0
- package/src/web/markdown.tsx +245 -0
- package/src/web/memory-client.ts +706 -0
- package/src/web/panel.tsx +135 -0
- package/src/web/schedule.tsx +192 -0
- package/src/web/search.tsx +129 -0
- package/src/web/thread-context.ts +64 -0
- package/src/web/thread.tsx +802 -0
- package/src/web/topics.tsx +160 -0
- package/src/web/use-voice-recorder.ts +264 -0
- package/src/web/video.tsx +522 -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.map +0 -1
- package/dist/chunk-SAY24E6V.js.map +0 -1
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
// @feat:chat
|
|
3
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
4
|
+
import { cleanup, render, waitFor } from "@testing-library/react";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { GenesisModulesProvider, NativeToastProvider } from "@marktiderman/genesis-ui-native/components";
|
|
7
|
+
import type { ChatClient } from "../../core/client";
|
|
8
|
+
import { ChatProvider } from "../../react/index";
|
|
9
|
+
import { ChatNativeProvider } from "../context";
|
|
10
|
+
import { ChatInboxScreen } from "../inbox-screen";
|
|
11
|
+
|
|
12
|
+
vi.mock("react-native", async (original) => ({ ...(await original<object>()), useWindowDimensions: () => ({ width: 390, height: 844, scale: 3, fontScale: 1 }) }));
|
|
13
|
+
|
|
14
|
+
afterEach(cleanup);
|
|
15
|
+
|
|
16
|
+
const ME = "11111111-1111-4111-8111-111111111111";
|
|
17
|
+
|
|
18
|
+
function fakeClient() {
|
|
19
|
+
const inbox = vi.fn(async (_opts: Record<string, unknown>) => []);
|
|
20
|
+
const client = {
|
|
21
|
+
inbox,
|
|
22
|
+
subscribeInbox: vi.fn(() => ({ unsubscribe: vi.fn() })),
|
|
23
|
+
unreadTotal: vi.fn(async () => 0),
|
|
24
|
+
} as unknown as ChatClient;
|
|
25
|
+
return { client, inbox };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("ChatInboxScreen", () => {
|
|
29
|
+
it("passes kinds and excludeKinds to the inbox, so an app can keep support tickets out of Messages", async () => {
|
|
30
|
+
const { client, inbox } = fakeClient();
|
|
31
|
+
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
32
|
+
render(
|
|
33
|
+
<QueryClientProvider client={qc}>
|
|
34
|
+
<GenesisModulesProvider modules={{}}>
|
|
35
|
+
<ChatProvider client={client} userId={ME}>
|
|
36
|
+
<NativeToastProvider>
|
|
37
|
+
<ChatNativeProvider>
|
|
38
|
+
<ChatInboxScreen onOpen={() => {}} excludeKinds={["support"]} kinds={["direct", "group"]} />
|
|
39
|
+
</ChatNativeProvider>
|
|
40
|
+
</NativeToastProvider>
|
|
41
|
+
</ChatProvider>
|
|
42
|
+
</GenesisModulesProvider>
|
|
43
|
+
</QueryClientProvider>,
|
|
44
|
+
);
|
|
45
|
+
await waitFor(() => expect(inbox).toHaveBeenCalled());
|
|
46
|
+
expect(inbox.mock.calls[0]![0]).toMatchObject({ kinds: ["direct", "group"], excludeKinds: ["support"] });
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
// @feat:chat
|
|
3
|
+
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import type { ChatAttachment, ChatMessage, PendingMessage } from "../../core/types";
|
|
6
|
+
import { ChatNativeProvider } from "../context";
|
|
7
|
+
import { MessageBubble } from "../message-bubble";
|
|
8
|
+
import type { ChatNativeModules } from "../modules";
|
|
9
|
+
import { act } from "@testing-library/react";
|
|
10
|
+
import { invertedRows, type ThreadRow } from "../rows";
|
|
11
|
+
|
|
12
|
+
// The shared React Native shim has no useWindowDimensions; attachments size pictures by it.
|
|
13
|
+
vi.mock("react-native", async (original) => ({ ...(await original<object>()), useWindowDimensions: () => ({ width: 390, height: 844, scale: 3, fontScale: 1 }) }));
|
|
14
|
+
|
|
15
|
+
afterEach(cleanup);
|
|
16
|
+
|
|
17
|
+
const ME = "11111111-1111-4111-8111-111111111111";
|
|
18
|
+
|
|
19
|
+
function failedRow(files: PendingMessage["files"]): Extract<ThreadRow, { type: "message" }> {
|
|
20
|
+
const pending: PendingMessage = {
|
|
21
|
+
clientId: "c1",
|
|
22
|
+
input: { conversationId: "conv", clientId: "c1", body: "" },
|
|
23
|
+
senderId: ME,
|
|
24
|
+
createdAt: "2026-10-25T10:00:00Z",
|
|
25
|
+
status: "failed",
|
|
26
|
+
error: "Attach the file again to send",
|
|
27
|
+
files,
|
|
28
|
+
};
|
|
29
|
+
return invertedRows([{ type: "pending", pending }], { me: ME, group: false }).find((r) => r.type === "message") as Extract<ThreadRow, { type: "message" }>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function show(row: Extract<ThreadRow, { type: "message" }>, handlers: Partial<Parameters<typeof MessageBubble>[0]> = {}) {
|
|
33
|
+
render(<MessageBubble row={row} me={ME} urls={{}} group={false} reducedMotion testID="b" {...handlers} />);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("a failed message", () => {
|
|
37
|
+
it("whose file was lost offers Attach again, not Retry, with the files it lost", () => {
|
|
38
|
+
const onReattach = vi.fn();
|
|
39
|
+
const files = [{ name: "photo.png", contentType: "image/png", kind: "image", lost: true }];
|
|
40
|
+
show(failedRow(files), { onReattach, onRetry: vi.fn(), onDiscard: vi.fn() });
|
|
41
|
+
expect(screen.getByTestId("b-failed").textContent).toBe("Attach the file again to send");
|
|
42
|
+
expect(screen.queryByTestId("b-retry")).toBeNull();
|
|
43
|
+
fireEvent.click(screen.getByTestId("b-reattach"));
|
|
44
|
+
expect(onReattach).toHaveBeenCalledWith("c1", files);
|
|
45
|
+
expect(screen.getByTestId("b-discard")).toBeTruthy();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("whose files are all still there offers Retry", () => {
|
|
49
|
+
const onRetry = vi.fn();
|
|
50
|
+
show(failedRow([{ name: "photo.jpg", contentType: "image/jpeg", kind: "image" }]), { onRetry, onReattach: vi.fn() });
|
|
51
|
+
expect(screen.queryByTestId("b-reattach")).toBeNull();
|
|
52
|
+
fireEvent.click(screen.getByTestId("b-retry"));
|
|
53
|
+
expect(onRetry).toHaveBeenCalledWith("c1");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("draws a pending voice note from PendingMessage.files, with its duration", () => {
|
|
57
|
+
show(failedRow([{ name: "voice.m4a", contentType: "audio/mp4", kind: "voice", meta: { durationMs: 9000, waveform: [10, 50] } }]));
|
|
58
|
+
expect(screen.getByTestId("b-voice-0")).toBeTruthy();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
function attachment(id: string, kind: "image" | "voice", path: string): ChatAttachment {
|
|
63
|
+
return {
|
|
64
|
+
id, messageId: "m1", position: 0, kind, ref: {}, provider: "storage", path, providerId: null, playbackId: null, url: null, status: "ready",
|
|
65
|
+
name: kind === "image" ? `${id}.jpg` : "voice.m4a", mime: null, bytes: null, width: 10, height: 10, durationMs: 4000, waveform: [10, 60],
|
|
66
|
+
posterPath: null, thumbhash: null, transcript: null, transcriptStatus: null,
|
|
67
|
+
} as ChatAttachment;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function messageRow(attachments: ChatAttachment[]): Extract<ThreadRow, { type: "message" }> {
|
|
71
|
+
const message = {
|
|
72
|
+
id: "m1", conversationId: "conv", seq: 1, clientId: "cm1", senderId: "22222222-2222-4222-8222-222222222222", senderRole: "member", kind: "text",
|
|
73
|
+
body: "look", mentions: [], replyToId: null, threadRootId: null, threadCount: 0, threadLastAt: null, topic: null, visibility: "everyone",
|
|
74
|
+
silent: false, meta: {}, createdAt: "2026-10-25T10:00:00Z", editedAt: null, deletedAt: null, attachments, reactions: [],
|
|
75
|
+
} as unknown as ChatMessage;
|
|
76
|
+
return invertedRows([{ type: "message", message }], { me: ME, group: false }).find((r) => r.type === "message") as Extract<ThreadRow, { type: "message" }>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function fakeAudio() {
|
|
80
|
+
const player = { play: vi.fn(), pause: vi.fn(), seekTo: vi.fn(async () => undefined), setPlaybackRate: vi.fn() };
|
|
81
|
+
const audio = {
|
|
82
|
+
useAudioRecorder: vi.fn(),
|
|
83
|
+
useAudioPlayer: vi.fn(() => player),
|
|
84
|
+
useAudioPlayerStatus: vi.fn(() => ({ currentTime: 0, duration: 4, playing: false, didJustFinish: false, isLoaded: true, playbackRate: 1 })),
|
|
85
|
+
RecordingPresets: { HIGH_QUALITY: {} },
|
|
86
|
+
requestRecordingPermissionsAsync: vi.fn(),
|
|
87
|
+
setAudioModeAsync: vi.fn(),
|
|
88
|
+
};
|
|
89
|
+
return { audio: audio as unknown as ChatNativeModules["audio"], player };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const fireA11y = (el: HTMLElement, name: string) => act(() => void el.dispatchEvent(new CustomEvent("accessibilityaction", { detail: name })));
|
|
93
|
+
|
|
94
|
+
describe("a bubble with media, for VoiceOver", () => {
|
|
95
|
+
it("carries the hidden controls as its own actions: play and speed for a voice note, open for each photo", () => {
|
|
96
|
+
const { audio, player } = fakeAudio();
|
|
97
|
+
const onOpenImage = vi.fn();
|
|
98
|
+
const urls = { "p/voice.m4a": "https://x/voice.m4a", "p/a.jpg": "https://x/a.jpg", "p/b.jpg": "https://x/b.jpg" };
|
|
99
|
+
render(
|
|
100
|
+
<ChatNativeProvider modules={{ audio }}>
|
|
101
|
+
<MessageBubble
|
|
102
|
+
row={messageRow([attachment("v", "voice", "p/voice.m4a"), attachment("a", "image", "p/a.jpg"), attachment("b", "image", "p/b.jpg")])}
|
|
103
|
+
me={ME}
|
|
104
|
+
urls={urls}
|
|
105
|
+
group={false}
|
|
106
|
+
reducedMotion
|
|
107
|
+
onOpenImage={onOpenImage}
|
|
108
|
+
onReply={vi.fn()}
|
|
109
|
+
testID="b"
|
|
110
|
+
/>
|
|
111
|
+
</ChatNativeProvider>,
|
|
112
|
+
);
|
|
113
|
+
const bubble = screen.getByTestId("b-bubble");
|
|
114
|
+
expect(bubble.getAttribute("data-a11y-actions")).toBe("voice-0,voice-rate-0,photo-0,photo-1,reply");
|
|
115
|
+
fireA11y(bubble, "photo-1");
|
|
116
|
+
expect(onOpenImage).toHaveBeenCalledWith("https://x/b.jpg", "Photo, b.jpg");
|
|
117
|
+
fireA11y(bubble, "voice-0");
|
|
118
|
+
expect(player.play).toHaveBeenCalledTimes(1);
|
|
119
|
+
fireA11y(bubble, "voice-rate-0");
|
|
120
|
+
expect(player.setPlaybackRate).toHaveBeenCalledWith(1.5);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("offers no play action without expo-audio, where the note can't play", () => {
|
|
124
|
+
render(<MessageBubble row={messageRow([attachment("v", "voice", "p/voice.m4a")])} me={ME} urls={{ "p/voice.m4a": "https://x/v" }} group={false} reducedMotion testID="b" />);
|
|
125
|
+
expect(screen.getByTestId("b-bubble").getAttribute("data-a11y-actions") ?? "").not.toContain("voice");
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// @feat:chat
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import type { ChatInboxItem, ChatMessage, ChatTopic } from "../../core/types";
|
|
4
|
+
import { availabilityInput, awayLineFor, clockToDate, dateToClock, defaultWeek, formFrom, hoursFromWeek, sameTimeDays, weekFromHours } from "../availability";
|
|
5
|
+
import { broadcastOutcome, broadcastRows } from "../broadcast-sheet";
|
|
6
|
+
import { emptyComposer, type ComposerState } from "../composer-state";
|
|
7
|
+
import { chatNativeCopy } from "../copy";
|
|
8
|
+
import { moderationNote } from "../moderation";
|
|
9
|
+
import { defaultScheduleAt, scheduleInputFrom, withDay, withTime } from "../schedule-sheet";
|
|
10
|
+
import { snippetText } from "../search-screen";
|
|
11
|
+
import { canManageTopics, checkTopicName, topicClosedFor, topicMenuFor } from "../topics";
|
|
12
|
+
import { rowIndexOf } from "../use-jump";
|
|
13
|
+
import type { ThreadRow } from "../rows";
|
|
14
|
+
|
|
15
|
+
const topic = (key: string, closed = false): ChatTopic => ({
|
|
16
|
+
conversationId: "c", topic: key, title: key, position: 0, closedAt: closed ? "2026-10-01T00:00:00Z" : null, createdBy: null, createdAt: "2026-10-01T00:00:00Z",
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("topics", () => {
|
|
20
|
+
it("are managed by owners and admins only", () => {
|
|
21
|
+
expect(canManageTopics("owner")).toBe(true);
|
|
22
|
+
expect(canManageTopics("admin")).toBe(true);
|
|
23
|
+
expect(canManageTopics("member")).toBe(false);
|
|
24
|
+
expect(canManageTopics("observer")).toBe(false);
|
|
25
|
+
expect(canManageTopics(null)).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("closed, take posts only from owners, admins and observers", () => {
|
|
29
|
+
expect(topicClosedFor(topic("a", true), "member")).toBe(true);
|
|
30
|
+
expect(topicClosedFor(topic("a", true), "owner")).toBe(false);
|
|
31
|
+
expect(topicClosedFor(topic("a", true), "admin")).toBe(false);
|
|
32
|
+
expect(topicClosedFor(topic("a", true), "observer")).toBe(false);
|
|
33
|
+
expect(topicClosedFor(topic("a"), "member")).toBe(false);
|
|
34
|
+
expect(topicClosedFor(null, "member")).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("offer New always, and Rename with Close or Reopen for the topic shown", () => {
|
|
38
|
+
expect(topicMenuFor(null)).toEqual(["new"]);
|
|
39
|
+
expect(topicMenuFor(topic("a"))).toEqual(["new", "rename", "close"]);
|
|
40
|
+
expect(topicMenuFor(topic("a", true))).toEqual(["new", "rename", "reopen"]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("key a new topic from its name, refuse a clash or an empty name, and keep the key on a rename", () => {
|
|
44
|
+
expect(checkTopicName("Week 1: Habits", [])).toEqual({ key: "week-1-habits" });
|
|
45
|
+
expect(checkTopicName(" ", [])).toEqual({ error: "empty" });
|
|
46
|
+
expect(checkTopicName("Week 1: habits!", [topic("week-1-habits")])).toEqual({ error: "exists" });
|
|
47
|
+
expect(checkTopicName("New name", [topic("week-1-habits")], "week-1-habits")).toEqual({ key: "week-1-habits" });
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("scheduled send", () => {
|
|
52
|
+
const composer = (text: string, files = 0): ComposerState => ({
|
|
53
|
+
...emptyComposer,
|
|
54
|
+
text,
|
|
55
|
+
attachments: Array.from({ length: files }, (_, i) => ({ key: `k${i}`, file: { data: "file:///a.jpg", name: "a.jpg", contentType: "image/jpeg" } })),
|
|
56
|
+
}) as ComposerState;
|
|
57
|
+
const at = new Date("2026-10-26T09:00:00");
|
|
58
|
+
|
|
59
|
+
it("schedules what's written, in the topic shown, with its reply and mentions", () => {
|
|
60
|
+
const state = { ...composer("hi @[Sam](user:22222222-2222-4222-8222-222222222222)"), replyTo: { messageId: "m1", name: "Sam", preview: "x" } } as ComposerState;
|
|
61
|
+
const made = scheduleInputFrom(state, { conversationId: "c", topic: "week-1", at });
|
|
62
|
+
expect(made).toEqual({
|
|
63
|
+
input: { conversationId: "c", body: "hi @[Sam](user:22222222-2222-4222-8222-222222222222)", mentions: ["22222222-2222-4222-8222-222222222222"], replyToId: "m1", topic: "week-1", sendAt: at },
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("won't schedule nothing, or files (native files are URIs; scheduling uploads bytes only)", () => {
|
|
68
|
+
expect(scheduleInputFrom(composer(" "), { conversationId: "c", at })).toEqual({ error: "empty" });
|
|
69
|
+
expect(scheduleInputFrom(composer("with a photo", 1), { conversationId: "c", at })).toEqual({ error: "files" });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("starts the custom time an hour ahead on the next five minutes, and sets day and time apart", () => {
|
|
73
|
+
const d = defaultScheduleAt(new Date("2026-10-25T10:02:30"));
|
|
74
|
+
expect([d.getHours(), d.getMinutes(), d.getSeconds()]).toEqual([11, 5, 0]);
|
|
75
|
+
const base = new Date("2026-10-25T14:30:00");
|
|
76
|
+
const day = withDay(base, new Date("2026-12-01T03:00:00"));
|
|
77
|
+
expect([day.getMonth(), day.getDate(), day.getHours(), day.getMinutes()]).toEqual([11, 1, 14, 30]);
|
|
78
|
+
const time = withTime(base, new Date("2020-01-01T08:15:00"));
|
|
79
|
+
expect([time.getDate(), time.getHours(), time.getMinutes()]).toEqual([25, 8, 15]);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("availability", () => {
|
|
84
|
+
it("reads saved hours as a week and writes the week back, keeping extra ranges", () => {
|
|
85
|
+
const saved = { mon: [["09:00", "12:00"], ["13:00", "17:00"]] as [string, string][], wed: [["10:00", "14:00"]] as [string, string][] };
|
|
86
|
+
const week = weekFromHours(saved);
|
|
87
|
+
expect(week.mon).toEqual({ on: true, from: "09:00", to: "12:00" });
|
|
88
|
+
expect(week.tue.on).toBe(false);
|
|
89
|
+
expect(hoursFromWeek({ ...week, mon: { ...week.mon, from: "08:00" } }, saved)).toEqual({ mon: [["08:00", "12:00"], ["13:00", "17:00"]], wed: [["10:00", "14:00"]] });
|
|
90
|
+
expect(Object.keys(hoursFromWeek(defaultWeek(), null))).toEqual(["mon", "tue", "wed", "thu", "fri"]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("refuses a day that starts and ends at the same time", () => {
|
|
94
|
+
const week = defaultWeek();
|
|
95
|
+
expect(sameTimeDays(week)).toEqual([]);
|
|
96
|
+
expect(sameTimeDays({ ...week, tue: { on: true, from: "09:00", to: "09:00" }, sat: { on: false, from: "09:00", to: "09:00" } })).toEqual(["tue"]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("saves the form as setAvailability expects (blank message and no hours as null)", () => {
|
|
100
|
+
const until = new Date("2026-11-02T09:00:00Z");
|
|
101
|
+
const form = { ...formFrom(null, "Europe/London"), message: " Back Monday ", until, useHours: false };
|
|
102
|
+
expect(availabilityInput(form, null)).toEqual({ awayMessage: "Back Monday", awayUntil: until.toISOString(), timeZone: "Europe/London", hours: null });
|
|
103
|
+
expect(availabilityInput({ ...form, message: " ", until: null, useHours: true }, null)).toMatchObject({ awayMessage: null, awayUntil: null, hours: { mon: [["09:00", "17:00"]] } });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("round-trips a clock time through a picker's Date", () => {
|
|
107
|
+
expect(dateToClock(clockToDate("07:45"))).toBe("07:45");
|
|
108
|
+
expect(dateToClock(clockToDate("bad"))).toBe("09:00");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("names the other person when away, everyone away in a group, and skips you and observers", () => {
|
|
112
|
+
const now = new Date("2026-10-25T12:00:00Z");
|
|
113
|
+
const away = { userId: "sam", awayMessage: "On a retreat", awayUntil: null, timeZone: "UTC", hours: null };
|
|
114
|
+
const names: Record<string, string> = { me: "Me", sam: "Sam", lee: "Lee", obs: "Obs" };
|
|
115
|
+
const forUser = (id: string) => (id === "sam" || id === "lee" || id === "obs" || id === "me" ? { ...away, userId: id } : null);
|
|
116
|
+
expect(awayLineFor([{ userId: "me", role: "member" }, { userId: "sam", role: "member" }], "me", forUser, (id) => names[id]!, now)).toBe("Sam is away: On a retreat");
|
|
117
|
+
expect(awayLineFor([{ userId: "me", role: "member" }, { userId: "sam", role: "member" }, { userId: "lee", role: "member" }, { userId: "obs", role: "observer" }], "me", forUser, (id) => names[id]!, now)).toBe("Sam and Lee are away");
|
|
118
|
+
expect(awayLineFor([{ userId: "me", role: "member" }, { userId: "x", role: "member" }], "me", forUser, (id) => id, now)).toBeNull();
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("broadcast", () => {
|
|
123
|
+
const item = (id: string, title: string | null, other: string | null, archived = false) => ({ conversationId: id, title, otherUserId: other, archived }) as ChatInboxItem;
|
|
124
|
+
it("lists the inbox by name, without archived ones, filtered by the query", () => {
|
|
125
|
+
const items = [item("1", null, "zoe"), item("2", "Coaching group", null), item("3", null, "amy"), item("4", null, "old", true)];
|
|
126
|
+
const person = (id: string | null) => (id ? { name: id === "zoe" ? "Zoe" : id === "amy" ? "Amy" : "Old" } : undefined);
|
|
127
|
+
expect(broadcastRows(items, { person, untitled: "Conversation" }).map((r) => r.title)).toEqual(["Amy", "Coaching group", "Zoe"]);
|
|
128
|
+
expect(broadcastRows(items, { person, untitled: "Conversation", query: "zo" }).map((r) => r.title)).toEqual(["Zoe"]);
|
|
129
|
+
expect(broadcastRows(items, { person, untitled: "Conversation", filter: (i) => i.otherUserId !== null }).map((r) => r.title)).toEqual(["Amy", "Zoe"]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("says how many it reached", () => {
|
|
133
|
+
expect(broadcastOutcome(3, 3, chatNativeCopy)).toEqual({ title: "Sent to 3 conversations", partial: false });
|
|
134
|
+
expect(broadcastOutcome(1, 1, chatNativeCopy)).toEqual({ title: "Sent to 1 conversation", partial: false });
|
|
135
|
+
expect(broadcastOutcome(2, 3, chatNativeCopy)).toEqual({ title: "Sent to 2 of 3. You can't post in the others.", partial: true });
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("moderation", () => {
|
|
140
|
+
const m = (moderation: ChatMessage["moderation"], deletedAt: string | null = null) => ({ moderation, deletedAt });
|
|
141
|
+
it("tells a hidden message's sender only they see it, and marks hidden and flagged ones for observers", () => {
|
|
142
|
+
expect(moderationNote(m("hidden"), { mine: true, observer: false })).toBe("hidden-mine");
|
|
143
|
+
expect(moderationNote(m("hidden"), { mine: false, observer: true })).toBe("hidden");
|
|
144
|
+
expect(moderationNote(m("flagged"), { mine: false, observer: true })).toBe("flagged");
|
|
145
|
+
expect(moderationNote(m("flagged"), { mine: true, observer: false })).toBeNull();
|
|
146
|
+
expect(moderationNote(m(null), { mine: false, observer: true })).toBeNull();
|
|
147
|
+
expect(moderationNote(m("hidden", "2026-10-01T00:00:00Z"), { mine: true, observer: false })).toBeNull();
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe("search and jumping", () => {
|
|
152
|
+
it("reads a snippet without its marks", () => {
|
|
153
|
+
expect(snippetText("…a «word» here")).toBe("…a word here");
|
|
154
|
+
});
|
|
155
|
+
it("finds a message's row past day and unread rows", () => {
|
|
156
|
+
const rows = [
|
|
157
|
+
{ type: "day", key: "d", label: "Today" },
|
|
158
|
+
{ type: "unread", key: "u" },
|
|
159
|
+
{ type: "message", key: "c1", item: { type: "message", message: { id: "m1" } } },
|
|
160
|
+
] as unknown as ThreadRow[];
|
|
161
|
+
expect(rowIndexOf(rows, "m1")).toBe(2);
|
|
162
|
+
expect(rowIndexOf(rows, "m9")).toBe(-1);
|
|
163
|
+
});
|
|
164
|
+
});
|