@marktiderman/genesis-chat 0.2.0 → 0.2.2
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 +82 -7
- package/dist/{chunk-5D5A7I4Q.js → chunk-A4YJX7KT.js} +130 -16
- 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-7S6HYNWW.js → chunk-GBJ3YI4E.js} +72 -5
- package/dist/chunk-GBJ3YI4E.js.map +1 -0
- package/dist/{chunk-PZTOY2LY.js → chunk-JU2FAPBI.js} +46 -13
- package/dist/chunk-JU2FAPBI.js.map +1 -0
- package/dist/{client-DMa2j7kz.d.ts → client-DUIG1hum.d.ts} +36 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +2 -2
- package/dist/native.d.ts +36 -7
- package/dist/native.js +239 -116
- package/dist/native.js.map +1 -1
- package/dist/{outbox-B0y3phE0.d.ts → outbox-B9NAWQ58.d.ts} +74 -6
- package/dist/push.d.ts +2 -2
- package/dist/react.d.ts +48 -21
- package/dist/react.js +3 -3
- package/dist/server.d.ts +43 -2
- package/dist/server.js +115 -15
- package/dist/server.js.map +1 -1
- package/dist/{thread-DWXHKPdw.d.ts → thread-oPra7pkW.d.ts} +1 -1
- package/dist/web.d.ts +31 -11
- package/dist/web.js +97 -48
- package/dist/web.js.map +1 -1
- package/migrations/0003_genesis_chat_search_threads.sql +85 -0
- package/migrations/tests/0002_genesis_chat_coaching.test.sql +7 -3
- package/migrations/tests/0003_genesis_chat_search_threads.test.sql +50 -0
- package/package.json +13 -9
- package/src/__tests__/coaching-core.test.ts +3 -1
- package/src/__tests__/coaching-react.test.tsx +73 -2
- package/src/__tests__/coaching-web.test.tsx +78 -6
- package/src/__tests__/fake-xhr.ts +54 -0
- package/src/__tests__/outbox-hosted.test.ts +269 -0
- package/src/__tests__/review-279.test.ts +41 -0
- package/src/__tests__/review-fixes-web.test.tsx +13 -10
- package/src/__tests__/server-fake-video.test.ts +81 -0
- package/src/__tests__/server-fakes.ts +7 -1
- package/src/__tests__/server-safety.test.ts +17 -4
- package/src/__tests__/web.test.tsx +16 -0
- package/src/core/client.ts +28 -2
- package/src/core/outbox.ts +222 -16
- package/src/core/transfer.ts +71 -0
- package/src/core/types.ts +17 -0
- package/src/index.ts +1 -0
- package/src/native/__tests__/emoji.test.tsx +16 -0
- package/src/native/__tests__/organise-screens.test.tsx +80 -0
- package/src/native/__tests__/video-media.test.tsx +81 -4
- package/src/native/attachment-cards.tsx +22 -6
- package/src/native/inbox-screen.tsx +12 -6
- package/src/native/index.ts +2 -2
- package/src/native/message-actions-sheet.tsx +10 -5
- package/src/native/message-bubble.tsx +3 -11
- package/src/native/modules.ts +2 -0
- package/src/native/schedule-sheet.tsx +23 -9
- package/src/native/search-screen.tsx +26 -5
- package/src/native/thread-screen.tsx +30 -3
- package/src/native/topics.tsx +70 -15
- package/src/native/video-player.tsx +46 -6
- package/src/native/video-recorder.tsx +6 -3
- package/src/native/voice-player.tsx +18 -0
- package/src/react/index.tsx +103 -19
- package/src/react/previews.ts +51 -0
- package/src/server/fake-video.ts +171 -0
- package/src/server/index.ts +1 -0
- package/src/web/attachments.tsx +6 -6
- package/src/web/bubble.tsx +22 -0
- package/src/web/inbox.tsx +7 -6
- package/src/web/memory-client.ts +11 -3
- package/src/web/panel.tsx +2 -2
- package/src/web/schedule.tsx +14 -2
- package/src/web/thread.tsx +36 -8
- package/src/web/video.tsx +28 -34
- package/dist/chunk-5D5A7I4Q.js.map +0 -1
- package/dist/chunk-7S6HYNWW.js.map +0 -1
- package/dist/chunk-PZTOY2LY.js.map +0 -1
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
-- Genesis Chat 0003: a search hit inside a reply thread says which thread
|
|
2
|
+
--
|
|
3
|
+
-- Builds on 0001 and 0002 (never edited once released). genesis_chat.search_messages now also
|
|
4
|
+
-- returns thread_root_id, so the app opens the thread a reply lives in instead of reporting the
|
|
5
|
+
-- message missing. A return type can't change in place, so the function is dropped and made
|
|
6
|
+
-- again, with its grants given again by name.
|
|
7
|
+
--
|
|
8
|
+
-- Also: any change to a message's moderation (a moderator job, an admin edit, not only
|
|
9
|
+
-- moderate_message) sends readers a live hint, so a hidden message updates without a relaunch.
|
|
10
|
+
--
|
|
11
|
+
-- Use: `genesis add chat` copies this after 0002. Verify: tests/0003_genesis_chat_search_threads.test.sql.
|
|
12
|
+
|
|
13
|
+
-- Messages (and voice or video transcripts) you can read that match `p_query`, newest first.
|
|
14
|
+
-- Within one conversation, or across all of yours. The snippet marks matches with « and »; the
|
|
15
|
+
-- text's own « and » become ‹ and › first, so a message can't fake a highlight. A reply in a
|
|
16
|
+
-- thread carries its thread_root_id.
|
|
17
|
+
drop function genesis_chat.search_messages(text, uuid, integer);
|
|
18
|
+
create function genesis_chat.search_messages(p_query text, p_conversation_id uuid default null, p_limit integer default 30)
|
|
19
|
+
returns table (message_id uuid, conversation_id uuid, seq bigint, sender_id uuid, created_at timestamptz, snippet text, in_transcript boolean, thread_root_id uuid)
|
|
20
|
+
language plpgsql
|
|
21
|
+
stable
|
|
22
|
+
security definer
|
|
23
|
+
set search_path = ''
|
|
24
|
+
as $$
|
|
25
|
+
declare
|
|
26
|
+
q tsquery;
|
|
27
|
+
begin
|
|
28
|
+
if auth.uid() is null or length(btrim(coalesce(p_query, ''))) < 2 then
|
|
29
|
+
return;
|
|
30
|
+
end if;
|
|
31
|
+
q := websearch_to_tsquery('simple', p_query);
|
|
32
|
+
return query
|
|
33
|
+
with mine as (
|
|
34
|
+
select me.conversation_id from genesis_chat.members me
|
|
35
|
+
where me.user_id = auth.uid() and me.left_at is null and me.role <> 'observer'
|
|
36
|
+
and (p_conversation_id is null or me.conversation_id = p_conversation_id)
|
|
37
|
+
union
|
|
38
|
+
select p_conversation_id where p_conversation_id is not null and genesis_chat.i_observe(p_conversation_id)
|
|
39
|
+
), hits as (
|
|
40
|
+
select m.id, m.conversation_id, m.seq, m.sender_id, m.created_at,
|
|
41
|
+
ts_headline('simple', translate(m.body, '«»', '‹›'), q, 'MaxWords=18, MinWords=6, StartSel=«, StopSel=»') as snippet, false as in_transcript,
|
|
42
|
+
m.thread_root_id
|
|
43
|
+
from genesis_chat.messages m join mine using (conversation_id)
|
|
44
|
+
where m.search @@ q and m.deleted_at is null
|
|
45
|
+
and (m.visibility = 'everyone' or genesis_chat.i_observe(m.conversation_id))
|
|
46
|
+
and (m.moderation is distinct from 'hidden' or m.sender_id = auth.uid() or genesis_chat.i_observe(m.conversation_id))
|
|
47
|
+
union all
|
|
48
|
+
select m.id, m.conversation_id, m.seq, m.sender_id, m.created_at,
|
|
49
|
+
ts_headline('simple', translate(a.transcript, '«»', '‹›'), q, 'MaxWords=18, MinWords=6, StartSel=«, StopSel=»'), true,
|
|
50
|
+
m.thread_root_id
|
|
51
|
+
from genesis_chat.attachments a
|
|
52
|
+
join genesis_chat.messages m on m.id = a.message_id
|
|
53
|
+
join mine on mine.conversation_id = m.conversation_id
|
|
54
|
+
where a.transcript is not null and a.transcript_search @@ q and m.deleted_at is null
|
|
55
|
+
and (m.visibility = 'everyone' or genesis_chat.i_observe(m.conversation_id))
|
|
56
|
+
and (m.moderation is distinct from 'hidden' or m.sender_id = auth.uid() or genesis_chat.i_observe(m.conversation_id))
|
|
57
|
+
)
|
|
58
|
+
select distinct on (h.created_at, h.id) h.id, h.conversation_id, h.seq, h.sender_id, h.created_at, h.snippet, h.in_transcript, h.thread_root_id
|
|
59
|
+
from hits h
|
|
60
|
+
order by h.created_at desc, h.id
|
|
61
|
+
limit least(greatest(coalesce(p_limit, 30), 1), 100);
|
|
62
|
+
end;
|
|
63
|
+
$$;
|
|
64
|
+
|
|
65
|
+
revoke execute on function genesis_chat.search_messages(text, uuid, integer) from public, anon;
|
|
66
|
+
grant execute on function genesis_chat.search_messages(text, uuid, integer) to authenticated, service_role;
|
|
67
|
+
|
|
68
|
+
-- ── Moderation changes reach readers live ─────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
create or replace function genesis_chat.message_moderation_changed()
|
|
71
|
+
returns trigger
|
|
72
|
+
language plpgsql
|
|
73
|
+
security definer
|
|
74
|
+
set search_path = ''
|
|
75
|
+
as $$
|
|
76
|
+
begin
|
|
77
|
+
perform genesis_chat.broadcast('genesis-chat:conv:' || new.conversation_id, 'message',
|
|
78
|
+
jsonb_build_object('id', new.id, 'seq', new.seq, 'conversation_id', new.conversation_id, 'op', 'update'));
|
|
79
|
+
return null;
|
|
80
|
+
end;
|
|
81
|
+
$$;
|
|
82
|
+
revoke execute on function genesis_chat.message_moderation_changed() from public, anon, authenticated;
|
|
83
|
+
create trigger messages_moderation_changed after update of moderation on genesis_chat.messages
|
|
84
|
+
for each row when (new.moderation is distinct from old.moderation)
|
|
85
|
+
execute function genesis_chat.message_moderation_changed();
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
-- People: a1 owns a group with b2 and e5 in it; c3 is outside; d4 is staff (an observer).
|
|
7
7
|
begin;
|
|
8
8
|
create extension if not exists pgtap with schema extensions;
|
|
9
|
-
select plan(
|
|
9
|
+
select plan(66);
|
|
10
10
|
|
|
11
11
|
insert into auth.users (id, aud, role, email, instance_id)
|
|
12
12
|
select ('00000000-0000-4000-8000-00000000c2' || n)::uuid, 'authenticated', 'authenticated', 'genesis-chat2-' || n || '@example.test', '00000000-0000-0000-0000-000000000000'
|
|
@@ -257,9 +257,13 @@ select ok(not exists (select 1 from genesis_chat.inbox(p_exclude_kinds => array[
|
|
|
257
257
|
'and leaves out the kinds asked to');
|
|
258
258
|
select ok(genesis_chat.unread_total(p_exclude_kinds => array['cohort', 'direct']) = 0, 'so does the badge');
|
|
259
259
|
|
|
260
|
+
select pg_temp.sign_in('a1');
|
|
261
|
+
insert into t select 'late', (genesis_chat.send_message(pg_temp.v('dm1'), gen_random_uuid(), 'One more thing')).id;
|
|
260
262
|
select pg_temp.as_server();
|
|
261
|
-
select ok((select
|
|
262
|
-
'the push badge
|
|
263
|
+
select ok((select unread_total > 0 from genesis_chat.push_targets(pg_temp.v('late')) where user_id = pg_temp.person('b2')),
|
|
264
|
+
'the push badge counts a new message');
|
|
265
|
+
select is((select unread_total from genesis_chat.push_targets(pg_temp.v('late'), array['cohort', 'direct']) where user_id = pg_temp.person('b2')), 0,
|
|
266
|
+
'and leaves out the kinds the app leaves out of its own');
|
|
263
267
|
|
|
264
268
|
select * from finish();
|
|
265
269
|
rollback;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
-- @feat:chat
|
|
2
|
+
-- pgTAP test for 0003_genesis_chat_search_threads.sql (with 0001 and 0002 applied).
|
|
3
|
+
-- Copy into your app's `supabase/tests/database/` and run `supabase test db`.
|
|
4
|
+
-- Writes only its own users, kinds and conversations, and rolls everything back.
|
|
5
|
+
begin;
|
|
6
|
+
create extension if not exists pgtap with schema extensions;
|
|
7
|
+
select plan(6);
|
|
8
|
+
|
|
9
|
+
insert into auth.users (id, aud, role, email, instance_id)
|
|
10
|
+
select ('00000000-0000-4000-8000-00000000c3' || n)::uuid, 'authenticated', 'authenticated', 'genesis-chat3-' || n || '@example.test', '00000000-0000-0000-0000-000000000000'
|
|
11
|
+
from unnest(array['a1', 'b2', 'c3']) n;
|
|
12
|
+
|
|
13
|
+
create function pg_temp.person(n text) returns uuid language sql immutable as $$ select ('00000000-0000-4000-8000-00000000c3' || n)::uuid $$;
|
|
14
|
+
create function pg_temp.sign_in(n text) returns void language plpgsql as $$
|
|
15
|
+
begin
|
|
16
|
+
perform set_config('request.jwt.claims', json_build_object('sub', pg_temp.person(n), 'role', 'authenticated')::text, true);
|
|
17
|
+
perform set_config('request.jwt.claim.sub', pg_temp.person(n)::text, true);
|
|
18
|
+
perform set_config('role', 'authenticated', true);
|
|
19
|
+
end $$;
|
|
20
|
+
grant execute on function pg_temp.person(text), pg_temp.sign_in(text) to authenticated, service_role;
|
|
21
|
+
|
|
22
|
+
create temp table t (k text primary key, v uuid);
|
|
23
|
+
grant select, insert on t to authenticated, service_role;
|
|
24
|
+
create function pg_temp.v(k_ text) returns uuid language sql stable as $$ select v from t where k = k_ $$;
|
|
25
|
+
grant execute on function pg_temp.v(text) to authenticated, service_role;
|
|
26
|
+
|
|
27
|
+
create function public.genesis_chat3_test_may_start(member_ids uuid[], subject_type text, subject_id text, user_id uuid)
|
|
28
|
+
returns boolean language sql stable set search_path = '' as $$ select true $$;
|
|
29
|
+
select genesis_chat.register_kind('group', 'Group', p_membership => 'admins',
|
|
30
|
+
p_start_check => 'public.genesis_chat3_test_may_start(uuid[], text, text, uuid)');
|
|
31
|
+
|
|
32
|
+
select pg_temp.sign_in('a1');
|
|
33
|
+
insert into t select 'grp', genesis_chat.start_conversation('group', array[pg_temp.person('b2')], 'Search lab');
|
|
34
|
+
insert into t select 'root', (genesis_chat.send_message(pg_temp.v('grp'), gen_random_uuid(), 'a thread starter about kayaks')).id;
|
|
35
|
+
select genesis_chat.send_message(pg_temp.v('grp'), gen_random_uuid(), 'a reply about zebras', p_thread_root_id => pg_temp.v('root'));
|
|
36
|
+
|
|
37
|
+
select pg_temp.sign_in('b2');
|
|
38
|
+
select is((select thread_root_id from genesis_chat.search_messages('zebras')), pg_temp.v('root'), 'a reply in a thread comes with its thread');
|
|
39
|
+
select is((select thread_root_id from genesis_chat.search_messages('kayaks')), null, 'a message on the main timeline with none');
|
|
40
|
+
select pg_temp.sign_in('c3');
|
|
41
|
+
select ok(not exists (select 1 from genesis_chat.search_messages('zebras')), 'someone outside still finds nothing');
|
|
42
|
+
reset role;
|
|
43
|
+
select ok(not has_function_privilege('anon', 'genesis_chat.search_messages(text, uuid, integer)', 'execute'), 'anon can''t search');
|
|
44
|
+
select ok(has_function_privilege('authenticated', 'genesis_chat.search_messages(text, uuid, integer)', 'execute'), 'signed-in people can');
|
|
45
|
+
|
|
46
|
+
select ok(exists (select 1 from pg_trigger where tgname = 'messages_moderation_changed' and tgrelid = 'genesis_chat.messages'::regclass),
|
|
47
|
+
'a change to a message''s moderation sends readers a live hint');
|
|
48
|
+
|
|
49
|
+
select * from finish();
|
|
50
|
+
rollback;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marktiderman/genesis-chat",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -61,13 +61,13 @@
|
|
|
61
61
|
"README.md"
|
|
62
62
|
],
|
|
63
63
|
"dependencies": {
|
|
64
|
-
"@marktiderman/genesis-features": "^0.
|
|
64
|
+
"@marktiderman/genesis-features": "^0.4.0",
|
|
65
65
|
"frimousse": "0.4.0"
|
|
66
66
|
},
|
|
67
67
|
"peerDependencies": {
|
|
68
|
-
"@marktiderman/genesis-core": "^1.
|
|
69
|
-
"@marktiderman/genesis-ui": "^2.
|
|
70
|
-
"@marktiderman/genesis-ui-native": "^2.4.
|
|
68
|
+
"@marktiderman/genesis-core": "^1.9.0",
|
|
69
|
+
"@marktiderman/genesis-ui": "^2.15.0",
|
|
70
|
+
"@marktiderman/genesis-ui-native": "^2.4.1",
|
|
71
71
|
"@supabase/supabase-js": "^2.118.0",
|
|
72
72
|
"@tanstack/react-query": "^5.0.0",
|
|
73
73
|
"hls.js": "^1.5.0",
|
|
@@ -140,10 +140,10 @@
|
|
|
140
140
|
}
|
|
141
141
|
},
|
|
142
142
|
"devDependencies": {
|
|
143
|
-
"@marktiderman/genesis-core": "^1.
|
|
144
|
-
"@marktiderman/genesis-notifications": "^0.1.
|
|
145
|
-
"@marktiderman/genesis-ui": "^2.
|
|
146
|
-
"@marktiderman/genesis-ui-native": "^2.4.
|
|
143
|
+
"@marktiderman/genesis-core": "^1.9.0",
|
|
144
|
+
"@marktiderman/genesis-notifications": "^0.1.2",
|
|
145
|
+
"@marktiderman/genesis-ui": "^2.15.0",
|
|
146
|
+
"@marktiderman/genesis-ui-native": "^2.4.1",
|
|
147
147
|
"@supabase/supabase-js": "^2.118.0",
|
|
148
148
|
"@tanstack/react-query": "^5.75.0",
|
|
149
149
|
"@testing-library/react": "^16.3.2",
|
|
@@ -193,6 +193,10 @@
|
|
|
193
193
|
{
|
|
194
194
|
"file": "migrations/0002_genesis_chat_coaching.sql",
|
|
195
195
|
"test": "migrations/tests/0002_genesis_chat_coaching.test.sql"
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
"file": "migrations/0003_genesis_chat_search_threads.sql",
|
|
199
|
+
"test": "migrations/tests/0003_genesis_chat_search_threads.test.sql"
|
|
196
200
|
}
|
|
197
201
|
],
|
|
198
202
|
"install": [
|
|
@@ -69,8 +69,10 @@ describe("mappers for 0002", () => {
|
|
|
69
69
|
userId: "u", awayMessage: "Back Monday", awayUntil: null, timeZone: "Europe/London", hours: { mon: [["09:00", "17:00"]] },
|
|
70
70
|
});
|
|
71
71
|
expect(toSearchHit({ message_id: "m", conversation_id: "c", seq: "4", sender_id: null, created_at: "t", snippet: "a «b»", in_transcript: true })).toEqual({
|
|
72
|
-
messageId: "m", conversationId: "c", seq: 4, senderId: null, createdAt: "t", snippet: "a «b»", inTranscript: true,
|
|
72
|
+
messageId: "m", conversationId: "c", seq: 4, senderId: null, createdAt: "t", snippet: "a «b»", inTranscript: true, threadRootId: null,
|
|
73
73
|
});
|
|
74
|
+
// A reply in a thread says which thread, so the app can open it.
|
|
75
|
+
expect(toSearchHit({ message_id: "m", conversation_id: "c", seq: 5, created_at: "t", snippet: "x", in_transcript: false, thread_root_id: "root" }).threadRootId).toBe("root");
|
|
74
76
|
const p = toLinkPreview({ url: "https://x.test/a", status: "ok", title: "T", image_url: "http://x.test/i.png", fetched_at: "t" });
|
|
75
77
|
expect(p.imageUrl).toBeNull();
|
|
76
78
|
expect(toLinkPreview({ url: "https://x.test/a", image_url: "https://x.test/i.png", fetched_at: "t" }).imageUrl).toBe("https://x.test/i.png");
|
|
@@ -9,6 +9,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
9
9
|
import type { OutboxStore, QueuedMessage } from "../core/outbox";
|
|
10
10
|
import {
|
|
11
11
|
ChatProvider,
|
|
12
|
+
type ChatProviderProps,
|
|
12
13
|
useAvailability,
|
|
13
14
|
useBroadcast,
|
|
14
15
|
useChat,
|
|
@@ -27,7 +28,7 @@ const ME = "00000000-0000-4000-8000-0000000000a1";
|
|
|
27
28
|
const SAM = "00000000-0000-4000-8000-0000000000b2";
|
|
28
29
|
const LEE = "00000000-0000-4000-8000-0000000000c3";
|
|
29
30
|
|
|
30
|
-
function setup(over: Partial<MemoryChatOptions> = {}, store?: OutboxStore) {
|
|
31
|
+
function setup(over: Partial<MemoryChatOptions> = {}, store?: OutboxStore, extra: Partial<ChatProviderProps> = {}) {
|
|
31
32
|
const client = createMemoryChatClient({
|
|
32
33
|
me: ME,
|
|
33
34
|
conversations: [
|
|
@@ -50,7 +51,7 @@ function setup(over: Partial<MemoryChatOptions> = {}, store?: OutboxStore) {
|
|
|
50
51
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
51
52
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
52
53
|
<QueryClientProvider client={queryClient}>
|
|
53
|
-
<ChatProvider client={client} userId={ME} outboxStore={store}>
|
|
54
|
+
<ChatProvider client={client} userId={ME} outboxStore={store} {...extra}>
|
|
54
55
|
{children}
|
|
55
56
|
</ChatProvider>
|
|
56
57
|
</QueryClientProvider>
|
|
@@ -83,6 +84,55 @@ describe("hooks for 0002", () => {
|
|
|
83
84
|
await waitFor(() => expect(result.current.items).toHaveLength(0));
|
|
84
85
|
});
|
|
85
86
|
|
|
87
|
+
it("useScheduled removes one that failed when due, as cancel_scheduled allows", async () => {
|
|
88
|
+
const { wrapper, client } = setup();
|
|
89
|
+
const { result } = renderHook(() => useScheduled("c1"), { wrapper });
|
|
90
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
91
|
+
await act(async () => {
|
|
92
|
+
await result.current.schedule({ conversationId: "c1", body: "Later", sendAt: new Date(Date.now() + 3_600_000) });
|
|
93
|
+
});
|
|
94
|
+
client.failNextSends(1);
|
|
95
|
+
await client.sendDue(new Date(Date.now() + 7_200_000));
|
|
96
|
+
await act(() => result.current.refetch());
|
|
97
|
+
await waitFor(() => expect(result.current.items[0]?.status).toBe("failed"));
|
|
98
|
+
await act(() => result.current.cancel(result.current.items[0]!.id));
|
|
99
|
+
await waitFor(() => expect(result.current.items).toHaveLength(0));
|
|
100
|
+
expect(client.calls.filter((c) => c.method === "cancelScheduled")).toHaveLength(1);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("useScheduled takes native file URIs, read through the provider's readFile, as send does", async () => {
|
|
104
|
+
const readFile = vi.fn(async () => new Blob(["jpeg"], { type: "image/jpeg" }));
|
|
105
|
+
const { wrapper, client } = setup({}, undefined, { readFile });
|
|
106
|
+
const { result } = renderHook(() => useScheduled("c1"), { wrapper });
|
|
107
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
108
|
+
await act(async () => {
|
|
109
|
+
await result.current.schedule({ conversationId: "c1", body: "", sendAt: new Date(Date.now() + 3_600_000) }, [
|
|
110
|
+
{ data: "file:///photos/a.jpg", name: "a.jpg", contentType: "image/jpeg" },
|
|
111
|
+
{ data: "file:///rec/v.m4a", name: "v.m4a", contentType: "audio/mp4", meta: { kind: "voice", durationMs: 2000 } },
|
|
112
|
+
]);
|
|
113
|
+
});
|
|
114
|
+
expect(readFile).toHaveBeenCalledWith("file:///photos/a.jpg");
|
|
115
|
+
expect(readFile).toHaveBeenCalledWith("file:///rec/v.m4a");
|
|
116
|
+
expect(client.calls.filter((c) => c.method === "upload")).toHaveLength(2);
|
|
117
|
+
await waitFor(() => expect(result.current.items).toHaveLength(1));
|
|
118
|
+
expect(result.current.items[0]!.attachments).toEqual([
|
|
119
|
+
expect.objectContaining({ kind: "image", mime: "image/jpeg", path: expect.stringContaining("a.jpg") }),
|
|
120
|
+
expect.objectContaining({ kind: "voice", durationMs: 2000, path: expect.stringContaining("v.m4a") }),
|
|
121
|
+
]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("useScheduled uploads through the provider's upload hook when there is one", async () => {
|
|
125
|
+
const upload = vi.fn(async (conversationId: string, file: { name: string }) => `${conversationId}/me/${file.name}`);
|
|
126
|
+
const { wrapper } = setup({}, undefined, { upload });
|
|
127
|
+
const { result } = renderHook(() => useScheduled("c1"), { wrapper });
|
|
128
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
129
|
+
await act(async () => {
|
|
130
|
+
await result.current.schedule({ conversationId: "c1", body: "", sendAt: new Date(Date.now() + 3_600_000) }, [{ data: "file:///a.jpg", name: "a.jpg", contentType: "image/jpeg" }]);
|
|
131
|
+
});
|
|
132
|
+
expect(upload).toHaveBeenCalledWith("c1", expect.objectContaining({ data: "file:///a.jpg" }), expect.anything());
|
|
133
|
+
await waitFor(() => expect(result.current.items[0]?.attachments[0]?.path).toBe("c1/me/a.jpg"));
|
|
134
|
+
});
|
|
135
|
+
|
|
86
136
|
it("useAvailability reads a conversation's and saves yours", async () => {
|
|
87
137
|
const { wrapper } = setup();
|
|
88
138
|
const { result } = renderHook(() => useAvailability("c1"), { wrapper });
|
|
@@ -199,6 +249,27 @@ describe("review fixes in the hooks", () => {
|
|
|
199
249
|
expect(result.current.canPost).toBe(true);
|
|
200
250
|
});
|
|
201
251
|
|
|
252
|
+
it("retries every lookup of the conversation, not only access", async () => {
|
|
253
|
+
const { wrapper, client } = setup();
|
|
254
|
+
const realMembers = client.members;
|
|
255
|
+
client.members = vi.fn(async () => {
|
|
256
|
+
throw new Error("Failed to fetch");
|
|
257
|
+
});
|
|
258
|
+
const { result } = renderHook(() => useConversation("c1"), { wrapper });
|
|
259
|
+
await waitFor(() => expect(client.members).toHaveBeenCalled());
|
|
260
|
+
client.members = vi.fn(realMembers);
|
|
261
|
+
await waitFor(() => expect(result.current.messages.length).toBeGreaterThan(0));
|
|
262
|
+
const realMessages = client.messages;
|
|
263
|
+
client.messages = vi.fn(realMessages);
|
|
264
|
+
await act(async () => {
|
|
265
|
+
await result.current.refetchAccess();
|
|
266
|
+
});
|
|
267
|
+
expect(client.members).toHaveBeenCalled();
|
|
268
|
+
// Never the message history: a refetch would drop loaded pages and merged live messages.
|
|
269
|
+
expect(client.messages).not.toHaveBeenCalled();
|
|
270
|
+
expect(typeof result.current.refetchConversation).toBe("function");
|
|
271
|
+
});
|
|
272
|
+
|
|
202
273
|
const savedStore = (q: QueuedMessage[]) => {
|
|
203
274
|
let saved = q;
|
|
204
275
|
return { load: async () => saved, save: async (x: QueuedMessage[]) => void (saved = x), get: () => saved };
|
|
@@ -4,19 +4,21 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
6
6
|
import { act, cleanup, configure, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
|
7
|
-
import type
|
|
7
|
+
import { useEffect, type ReactNode } from "react";
|
|
8
8
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
9
|
-
import { ChatProvider, type ChatProviderProps } from "../react";
|
|
9
|
+
import { ChatProvider, useChat, type ChatProviderProps } from "../react";
|
|
10
10
|
import {
|
|
11
11
|
ChatAvailabilitySettings,
|
|
12
12
|
ChatBroadcast,
|
|
13
13
|
ChatLinkPreview,
|
|
14
|
+
ChatScheduledList,
|
|
14
15
|
ChatThread,
|
|
15
16
|
createMemoryChatClient,
|
|
16
17
|
type MemoryChatClient,
|
|
17
18
|
type MemoryChatOptions,
|
|
18
19
|
type MemoryConversationSeed,
|
|
19
20
|
} from "../web";
|
|
21
|
+
import { installFakeXHR } from "./fake-xhr";
|
|
20
22
|
|
|
21
23
|
afterEach(() => {
|
|
22
24
|
cleanup();
|
|
@@ -147,6 +149,19 @@ describe("scheduled send", () => {
|
|
|
147
149
|
await waitFor(() => expect(screen.queryByTestId("genesis-chat-scheduled-count")).toBeNull());
|
|
148
150
|
});
|
|
149
151
|
|
|
152
|
+
it("offers Remove for a scheduled message that failed when due", async () => {
|
|
153
|
+
const client = make(dm());
|
|
154
|
+
await client.schedule({ conversationId: "c1", clientId: "00000000-0000-4000-8000-00000000s001", body: "Too late", sendAt: new Date(Date.now() + 3_600_000) });
|
|
155
|
+
client.failNextSends(1);
|
|
156
|
+
await client.sendDue(new Date(Date.now() + 7_200_000));
|
|
157
|
+
mount(client, <ChatScheduledList conversationId="c1" />);
|
|
158
|
+
expect((await screen.findByTestId("genesis-chat-scheduled-item")).textContent).toContain("Couldn't send");
|
|
159
|
+
expect(screen.queryByTestId("genesis-chat-scheduled-cancel")).toBeNull();
|
|
160
|
+
fireEvent.click(screen.getByTestId("genesis-chat-scheduled-remove"));
|
|
161
|
+
await waitFor(() => expect(client.calls.some((c) => c.method === "cancelScheduled")).toBe(true));
|
|
162
|
+
await waitFor(() => expect(screen.queryByTestId("genesis-chat-scheduled-item")).toBeNull());
|
|
163
|
+
});
|
|
164
|
+
|
|
150
165
|
it("schedules at a date and time you pick", async () => {
|
|
151
166
|
const client = make(dm());
|
|
152
167
|
mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} />);
|
|
@@ -216,6 +231,37 @@ describe("availability", () => {
|
|
|
216
231
|
});
|
|
217
232
|
});
|
|
218
233
|
|
|
234
|
+
describe("upload progress", () => {
|
|
235
|
+
it("draws a progress bar on a pending message while its file uploads", async () => {
|
|
236
|
+
const client = make(dm());
|
|
237
|
+
let report!: (sent: number, total: number) => void;
|
|
238
|
+
let finish!: () => void;
|
|
239
|
+
const upload = vi.fn(
|
|
240
|
+
(_c: string, file: { name: string }, o?: { onProgress?: (s: number, t: number) => void }) =>
|
|
241
|
+
new Promise<string>((r) => {
|
|
242
|
+
report = o!.onProgress!;
|
|
243
|
+
finish = () => r(`c1/${ME}/${file.name}`);
|
|
244
|
+
}),
|
|
245
|
+
);
|
|
246
|
+
function SendOne() {
|
|
247
|
+
const { outbox } = useChat();
|
|
248
|
+
useEffect(() => {
|
|
249
|
+
void outbox.send({ conversationId: "c1", body: "" }, [{ data: new Blob(["0123456789"]), name: "a.pdf", contentType: "application/pdf" }]);
|
|
250
|
+
}, [outbox]);
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
mount(client, <><ChatThread conversationId="c1" resolveUser={resolveUser} /><SendOne /></>, { upload });
|
|
254
|
+
await waitFor(() => expect(upload).toHaveBeenCalled());
|
|
255
|
+
act(() => report(3, 10));
|
|
256
|
+
const bar = await screen.findByTestId("genesis-chat-upload-progress");
|
|
257
|
+
expect(bar.getAttribute("aria-valuenow")).toBe("30");
|
|
258
|
+
act(() => report(8, 10));
|
|
259
|
+
await waitFor(() => expect(screen.getByTestId("genesis-chat-upload-progress").getAttribute("aria-valuenow")).toBe("80"));
|
|
260
|
+
await act(async () => finish());
|
|
261
|
+
await waitFor(() => expect(screen.queryByTestId("genesis-chat-upload-progress")).toBeNull());
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
219
265
|
describe("search", () => {
|
|
220
266
|
it("finds a message further up, loads to it and highlights it", async () => {
|
|
221
267
|
const messages = [
|
|
@@ -234,6 +280,29 @@ describe("search", () => {
|
|
|
234
280
|
await waitFor(() => expect(document.querySelector("#genesis-chat-msg-old .ring-2")).not.toBeNull());
|
|
235
281
|
});
|
|
236
282
|
|
|
283
|
+
it("opens the thread of a hit that is a reply in one, scrolled to the reply", async () => {
|
|
284
|
+
const client = make(
|
|
285
|
+
dm({
|
|
286
|
+
messages: [
|
|
287
|
+
{ id: "root", senderId: SAM, body: "How did the week go?", createdAt: at(1), threadCount: 1 },
|
|
288
|
+
{ id: "reply", senderId: ME, body: "Saw a zebra on my run", createdAt: at(2), threadRootId: "root" },
|
|
289
|
+
],
|
|
290
|
+
}),
|
|
291
|
+
);
|
|
292
|
+
const onOpenThread = vi.fn();
|
|
293
|
+
mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} onOpenThread={onOpenThread} />);
|
|
294
|
+
fireEvent.click(await screen.findByTestId("genesis-chat-thread-search"));
|
|
295
|
+
fireEvent.change(await screen.findByTestId("genesis-chat-search-input"), { target: { value: "zebra" } });
|
|
296
|
+
fireEvent.click(await screen.findByTestId("genesis-chat-search-hit"));
|
|
297
|
+
await waitFor(() => expect(onOpenThread).toHaveBeenCalledTimes(1));
|
|
298
|
+
const [root, opts] = onOpenThread.mock.calls[0]! as [{ id: string }, { jumpTo: { messageId: string; seq: number } }];
|
|
299
|
+
expect(root.id).toBe("root");
|
|
300
|
+
expect(opts.jumpTo.messageId).toBe("reply");
|
|
301
|
+
// And here, the thread's first message.
|
|
302
|
+
await waitFor(() => expect(document.querySelector("#genesis-chat-msg-root .ring-2")).not.toBeNull());
|
|
303
|
+
expect(screen.queryByText("That message isn't here any more")).toBeNull();
|
|
304
|
+
});
|
|
305
|
+
|
|
237
306
|
it("marks hits in a transcript", async () => {
|
|
238
307
|
const client = make(dm({ messages: [{ id: "v", senderId: SAM, body: "", createdAt: at(1), attachments: [{ kind: "voice", transcript: "call the dentist" }] }] }));
|
|
239
308
|
mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} />);
|
|
@@ -397,13 +466,12 @@ describe("video in the composer", () => {
|
|
|
397
466
|
});
|
|
398
467
|
}
|
|
399
468
|
|
|
400
|
-
it("records,
|
|
469
|
+
it("records, sends a pending Mux video at once, and PUTs its bytes to the app's videoUpload meanwhile", async () => {
|
|
401
470
|
const time = { now: 2_000_000 };
|
|
402
471
|
vi.spyOn(Date, "now").mockImplementation(() => time.now);
|
|
403
472
|
vi.stubGlobal("MediaRecorder", FakeRecorder);
|
|
404
473
|
const camera = stubCamera();
|
|
405
|
-
const
|
|
406
|
-
vi.stubGlobal("fetch", fetchMock);
|
|
474
|
+
const XHR = installFakeXHR();
|
|
407
475
|
const create = vi.fn(async () => ({ url: "https://storage.mux.test/upload/1", providerId: "up_1", provider: "mux" as const }));
|
|
408
476
|
const client = make(dm());
|
|
409
477
|
mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} />, { videoUpload: { create } });
|
|
@@ -411,7 +479,11 @@ describe("video in the composer", () => {
|
|
|
411
479
|
await waitFor(() => expect(client.calls.some((c) => c.method === "send")).toBe(true));
|
|
412
480
|
expect(camera.getUserMedia).toHaveBeenCalledWith(expect.objectContaining({ audio: true, video: expect.anything() }));
|
|
413
481
|
expect(create).toHaveBeenCalledWith("c1");
|
|
414
|
-
|
|
482
|
+
// The message went before the bytes: the PUT is still open.
|
|
483
|
+
await waitFor(() => expect(XHR.all.length).toBe(1));
|
|
484
|
+
const put = XHR.all[0]!;
|
|
485
|
+
expect(put).toMatchObject({ method: "PUT", url: "https://storage.mux.test/upload/1", headers: { "Content-Type": "video/webm" }, sent: true, status: 0 });
|
|
486
|
+
act(() => put.respond(200));
|
|
415
487
|
const sent = client.calls.find((c) => c.method === "send")!.args[0] as { body: string; attachments: Record<string, unknown>[] };
|
|
416
488
|
expect(sent.attachments[0]).toMatchObject({ kind: "video", provider: "mux", providerId: "up_1", status: "pending", durationMs: 4000, mime: "video/webm" });
|
|
417
489
|
// Nothing went to storage, and the camera was released.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** @feat:chat A scriptable XMLHttpRequest: the test answers each request and reports its progress. */
|
|
2
|
+
import { vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
export class FakeXHR {
|
|
5
|
+
static all: FakeXHR[] = [];
|
|
6
|
+
method = "";
|
|
7
|
+
url = "";
|
|
8
|
+
headers: Record<string, string> = {};
|
|
9
|
+
body: unknown = undefined;
|
|
10
|
+
sent = false;
|
|
11
|
+
aborted = false;
|
|
12
|
+
status = 0;
|
|
13
|
+
responseText = "";
|
|
14
|
+
upload: { onprogress: ((e: ProgressEvent) => void) | null } = { onprogress: null };
|
|
15
|
+
onload: (() => void) | null = null;
|
|
16
|
+
onerror: (() => void) | null = null;
|
|
17
|
+
onabort: (() => void) | null = null;
|
|
18
|
+
constructor() {
|
|
19
|
+
FakeXHR.all.push(this);
|
|
20
|
+
}
|
|
21
|
+
open(method: string, url: string) {
|
|
22
|
+
this.method = method;
|
|
23
|
+
this.url = url;
|
|
24
|
+
}
|
|
25
|
+
setRequestHeader(k: string, v: string) {
|
|
26
|
+
this.headers[k] = v;
|
|
27
|
+
}
|
|
28
|
+
send(body: unknown) {
|
|
29
|
+
this.body = body;
|
|
30
|
+
this.sent = true;
|
|
31
|
+
}
|
|
32
|
+
abort() {
|
|
33
|
+
this.aborted = true;
|
|
34
|
+
this.onabort?.();
|
|
35
|
+
}
|
|
36
|
+
progress(loaded: number, total: number) {
|
|
37
|
+
this.upload.onprogress?.({ loaded, total, lengthComputable: true } as ProgressEvent);
|
|
38
|
+
}
|
|
39
|
+
respond(status = 200, text = "") {
|
|
40
|
+
this.status = status;
|
|
41
|
+
this.responseText = text;
|
|
42
|
+
this.onload?.();
|
|
43
|
+
}
|
|
44
|
+
fail() {
|
|
45
|
+
this.onerror?.();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Installs FakeXHR as XMLHttpRequest (undone by vi.unstubAllGlobals). */
|
|
50
|
+
export function installFakeXHR(): typeof FakeXHR {
|
|
51
|
+
FakeXHR.all = [];
|
|
52
|
+
vi.stubGlobal("XMLHttpRequest", FakeXHR);
|
|
53
|
+
return FakeXHR;
|
|
54
|
+
}
|