@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.
Files changed (175) hide show
  1. package/README.md +379 -13
  2. package/dist/chunk-A4YJX7KT.js +544 -0
  3. package/dist/chunk-A4YJX7KT.js.map +1 -0
  4. package/dist/chunk-AZS3HNLM.js +30 -0
  5. package/dist/chunk-AZS3HNLM.js.map +1 -0
  6. package/dist/chunk-BJCE5O7V.js +70 -0
  7. package/dist/chunk-BJCE5O7V.js.map +1 -0
  8. package/dist/chunk-GBJ3YI4E.js +966 -0
  9. package/dist/chunk-GBJ3YI4E.js.map +1 -0
  10. package/dist/chunk-JU2FAPBI.js +609 -0
  11. package/dist/chunk-JU2FAPBI.js.map +1 -0
  12. package/dist/chunk-VEPZROQB.js +187 -0
  13. package/dist/chunk-VEPZROQB.js.map +1 -0
  14. package/dist/{client-BtdALFCx.d.ts → client-DUIG1hum.d.ts} +187 -11
  15. package/dist/index.d.ts +78 -163
  16. package/dist/index.js +3 -2
  17. package/dist/native.d.ts +1939 -1
  18. package/dist/native.js +5023 -0
  19. package/dist/native.js.map +1 -1
  20. package/dist/outbox-B9NAWQ58.d.ts +219 -0
  21. package/dist/push.d.ts +206 -0
  22. package/dist/push.js +3 -0
  23. package/dist/push.js.map +1 -0
  24. package/dist/react.d.ts +176 -7
  25. package/dist/react.js +3 -3
  26. package/dist/server.d.ts +551 -21
  27. package/dist/server.js +976 -34
  28. package/dist/server.js.map +1 -1
  29. package/dist/thread-oPra7pkW.d.ts +57 -0
  30. package/dist/web.d.ts +335 -12
  31. package/dist/web.js +1784 -64
  32. package/dist/web.js.map +1 -1
  33. package/migrations/0002_genesis_chat_coaching.sql +934 -0
  34. package/migrations/0003_genesis_chat_search_threads.sql +85 -0
  35. package/migrations/tests/0002_genesis_chat_coaching.test.sql +269 -0
  36. package/migrations/tests/0003_genesis_chat_search_threads.test.sql +50 -0
  37. package/package.json +78 -6
  38. package/src/__tests__/coaching-core.test.ts +334 -0
  39. package/src/__tests__/coaching-react.test.tsx +319 -0
  40. package/src/__tests__/coaching-web.test.tsx +521 -0
  41. package/src/__tests__/core.test.ts +91 -0
  42. package/src/__tests__/fake-xhr.ts +54 -0
  43. package/src/__tests__/outbox-hosted.test.ts +269 -0
  44. package/src/__tests__/outbox.test.ts +122 -0
  45. package/src/__tests__/realtime.test.ts +70 -0
  46. package/src/__tests__/review-279.test.ts +41 -0
  47. package/src/__tests__/review-fixes-core.test.ts +193 -0
  48. package/src/__tests__/review-fixes-web.test.tsx +405 -0
  49. package/src/__tests__/server-effects.test.ts +158 -0
  50. package/src/__tests__/server-fake-video.test.ts +81 -0
  51. package/src/__tests__/server-fakes.ts +96 -0
  52. package/src/__tests__/server-integrations.test.ts +247 -0
  53. package/src/__tests__/server-providers.test.ts +246 -0
  54. package/src/__tests__/server-runner.test.ts +277 -0
  55. package/src/__tests__/server-safety.test.ts +303 -0
  56. package/src/__tests__/web-markdown.test.tsx +88 -0
  57. package/src/__tests__/web.test.tsx +349 -0
  58. package/src/core/availability.ts +223 -0
  59. package/src/core/client.ts +783 -0
  60. package/src/core/extras.ts +106 -0
  61. package/src/core/media.ts +144 -0
  62. package/src/core/outbox.ts +635 -0
  63. package/src/core/thread.ts +171 -0
  64. package/src/core/transfer.ts +71 -0
  65. package/src/core/types.ts +328 -0
  66. package/src/index.ts +14 -0
  67. package/src/native/__tests__/auto-retry.test.tsx +128 -0
  68. package/src/native/__tests__/composer-mic-lock.test.tsx +115 -0
  69. package/src/native/__tests__/composer-state.test.ts +188 -0
  70. package/src/native/__tests__/composer.test.tsx +133 -0
  71. package/src/native/__tests__/emoji.test.tsx +254 -0
  72. package/src/native/__tests__/inbox-screen.test.tsx +48 -0
  73. package/src/native/__tests__/message-bubble.test.tsx +127 -0
  74. package/src/native/__tests__/organise-logic.test.ts +164 -0
  75. package/src/native/__tests__/organise-screens.test.tsx +424 -0
  76. package/src/native/__tests__/photo-viewer.test.tsx +226 -0
  77. package/src/native/__tests__/push-payload.test.ts +66 -0
  78. package/src/native/__tests__/push.test.ts +209 -0
  79. package/src/native/__tests__/recorder-machine.test.ts +96 -0
  80. package/src/native/__tests__/rows-actions.test.ts +76 -0
  81. package/src/native/__tests__/thread-layout.test.ts +41 -0
  82. package/src/native/__tests__/thread-screen.test.tsx +116 -0
  83. package/src/native/__tests__/tus.test.ts +154 -0
  84. package/src/native/__tests__/use-voice-recorder.test.tsx +316 -0
  85. package/src/native/__tests__/video-media.test.tsx +498 -0
  86. package/src/native/__tests__/video-recorder-state.test.ts +87 -0
  87. package/src/native/__tests__/video-upload.test.ts +227 -0
  88. package/src/native/access-error.tsx +31 -0
  89. package/src/native/actions.ts +42 -0
  90. package/src/native/attachment-cards.tsx +132 -0
  91. package/src/native/auto-retry.ts +69 -0
  92. package/src/native/availability.tsx +376 -0
  93. package/src/native/broadcast-sheet.tsx +193 -0
  94. package/src/native/composer-state.ts +302 -0
  95. package/src/native/composer.tsx +453 -0
  96. package/src/native/context.tsx +70 -0
  97. package/src/native/copy.ts +296 -0
  98. package/src/native/emoji-data.ts +21 -0
  99. package/src/native/emoji-picker.tsx +229 -0
  100. package/src/native/emoji.ts +118 -0
  101. package/src/native/icon-button.tsx +40 -0
  102. package/src/native/inbox-screen.tsx +122 -0
  103. package/src/native/index.ts +184 -0
  104. package/src/native/link-preview.tsx +104 -0
  105. package/src/native/media-colors.ts +15 -0
  106. package/src/native/media.tsx +187 -0
  107. package/src/native/message-actions-sheet.tsx +126 -0
  108. package/src/native/message-bubble.tsx +366 -0
  109. package/src/native/moderation.tsx +42 -0
  110. package/src/native/modules.ts +277 -0
  111. package/src/native/organise-copy.ts +195 -0
  112. package/src/native/photo-viewer.tsx +531 -0
  113. package/src/native/push.ts +375 -0
  114. package/src/native/recorder-machine.ts +176 -0
  115. package/src/native/rows.ts +81 -0
  116. package/src/native/schedule-sheet.tsx +328 -0
  117. package/src/native/search-screen.tsx +189 -0
  118. package/src/native/stack-options.ts +36 -0
  119. package/src/native/start-sheet.tsx +87 -0
  120. package/src/native/thread-screen.tsx +647 -0
  121. package/src/native/topics.tsx +234 -0
  122. package/src/native/tus.ts +282 -0
  123. package/src/native/use-composer.ts +71 -0
  124. package/src/native/use-jump.ts +91 -0
  125. package/src/native/use-voice-recorder.ts +234 -0
  126. package/src/native/video-player.tsx +410 -0
  127. package/src/native/video-recorder-state.ts +140 -0
  128. package/src/native/video-recorder.tsx +274 -0
  129. package/src/native/video-upload-tray.tsx +77 -0
  130. package/src/native/video-upload.ts +432 -0
  131. package/src/native/voice-player.tsx +301 -0
  132. package/src/react/index.tsx +894 -0
  133. package/src/react/previews.ts +51 -0
  134. package/src/server/assistant.ts +235 -0
  135. package/src/server/crypto.ts +86 -0
  136. package/src/server/expo.ts +119 -0
  137. package/src/server/fake-video.ts +171 -0
  138. package/src/server/handler.ts +32 -0
  139. package/src/server/index.ts +58 -0
  140. package/src/server/moderation.ts +155 -0
  141. package/src/server/mux.ts +290 -0
  142. package/src/server/notifications.ts +130 -0
  143. package/src/server/runner.ts +468 -0
  144. package/src/server/transcribers.ts +111 -0
  145. package/src/server/types.ts +171 -0
  146. package/src/server/unfurl.ts +480 -0
  147. package/src/server/upload.ts +158 -0
  148. package/src/web/attachments.tsx +379 -0
  149. package/src/web/availability.tsx +299 -0
  150. package/src/web/broadcast.tsx +166 -0
  151. package/src/web/bubble.tsx +585 -0
  152. package/src/web/cards.tsx +136 -0
  153. package/src/web/composer.tsx +571 -0
  154. package/src/web/emoji-picker.tsx +84 -0
  155. package/src/web/error-boundary.tsx +33 -0
  156. package/src/web/format.ts +55 -0
  157. package/src/web/inbox.tsx +196 -0
  158. package/src/web/index.ts +75 -0
  159. package/src/web/markdown.tsx +245 -0
  160. package/src/web/memory-client.ts +714 -0
  161. package/src/web/panel.tsx +135 -0
  162. package/src/web/schedule.tsx +204 -0
  163. package/src/web/search.tsx +129 -0
  164. package/src/web/thread-context.ts +64 -0
  165. package/src/web/thread.tsx +830 -0
  166. package/src/web/topics.tsx +160 -0
  167. package/src/web/use-voice-recorder.ts +264 -0
  168. package/src/web/video.tsx +516 -0
  169. package/src/web/voice-note.tsx +197 -0
  170. package/dist/chunk-27UTR5FW.js +0 -322
  171. package/dist/chunk-27UTR5FW.js.map +0 -1
  172. package/dist/chunk-JQXZUEEL.js +0 -525
  173. package/dist/chunk-JQXZUEEL.js.map +0 -1
  174. package/dist/chunk-SAY24E6V.js +0 -309
  175. package/dist/chunk-SAY24E6V.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();
@@ -0,0 +1,269 @@
1
+ -- @feat:chat
2
+ -- pgTAP test for 0002_genesis_chat_coaching.sql (with 0001 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
+ --
6
+ -- People: a1 owns a group with b2 and e5 in it; c3 is outside; d4 is staff (an observer).
7
+ begin;
8
+ create extension if not exists pgtap with schema extensions;
9
+ select plan(66);
10
+
11
+ insert into auth.users (id, aud, role, email, instance_id)
12
+ select ('00000000-0000-4000-8000-00000000c2' || n)::uuid, 'authenticated', 'authenticated', 'genesis-chat2-' || n || '@example.test', '00000000-0000-0000-0000-000000000000'
13
+ from unnest(array['a1', 'b2', 'c3', 'd4', 'e5']) n;
14
+
15
+ create function pg_temp.person(n text) returns uuid language sql immutable as $$ select ('00000000-0000-4000-8000-00000000c2' || n)::uuid $$;
16
+ create function pg_temp.sign_in(n text) returns void language plpgsql as $$
17
+ begin
18
+ perform set_config('request.jwt.claims', json_build_object('sub', pg_temp.person(n), 'role', 'authenticated')::text, true);
19
+ perform set_config('request.jwt.claim.sub', pg_temp.person(n)::text, true);
20
+ perform set_config('role', 'authenticated', true);
21
+ end $$;
22
+ create function pg_temp.as_server() returns void language plpgsql as $$
23
+ begin
24
+ perform set_config('request.jwt.claims', '{"role":"service_role"}', true);
25
+ perform set_config('request.jwt.claim.sub', '', true);
26
+ perform set_config('role', 'service_role', true);
27
+ end $$;
28
+ grant execute on function pg_temp.person(text), pg_temp.sign_in(text), pg_temp.as_server() to anon, authenticated, service_role;
29
+
30
+ create temp table t (k text primary key, v uuid);
31
+ grant select, insert, update on t to authenticated, service_role;
32
+ create function pg_temp.v(k_ text) returns uuid language sql stable as $$ select v from t where k = k_ $$;
33
+ grant execute on function pg_temp.v(text) to authenticated, service_role;
34
+
35
+ create function public.genesis_chat2_test_may_start(member_ids uuid[], subject_type text, subject_id text, user_id uuid)
36
+ returns boolean language sql stable set search_path = '' as $$ select true $$;
37
+ create function public.genesis_chat2_test_staff(conversation_id uuid, user_id uuid)
38
+ returns boolean language sql stable set search_path = '' as $$ select user_id = '00000000-0000-4000-8000-00000000c2d4'::uuid $$;
39
+ select genesis_chat.register_kind('direct', 'Direct message', p_max_members => 2,
40
+ p_start_check => 'public.genesis_chat2_test_may_start(uuid[], text, text, uuid)');
41
+ select genesis_chat.register_kind('cohort', 'Cohort', p_membership => 'admins',
42
+ p_start_check => 'public.genesis_chat2_test_may_start(uuid[], text, text, uuid)',
43
+ p_observer_check => 'public.genesis_chat2_test_staff(uuid, uuid)', p_observers_post => true);
44
+
45
+ select pg_temp.sign_in('a1');
46
+ insert into t select 'grp', genesis_chat.start_conversation('cohort', array[pg_temp.person('b2'), pg_temp.person('e5')], 'Cohort 3');
47
+
48
+ -- Topics ------------------------------------------------------------------------------------
49
+
50
+ select lives_ok($$ select genesis_chat.set_topic(pg_temp.v('grp'), 'lesson-1', 'Lesson 1: Foundations') $$, 'an owner adds a topic');
51
+ select is((select title from genesis_chat.topics where conversation_id = pg_temp.v('grp') and topic = 'lesson-1'),
52
+ 'Lesson 1: Foundations', 'with its title');
53
+ select pg_temp.sign_in('b2');
54
+ select ok(exists (select 1 from genesis_chat.topics where conversation_id = pg_temp.v('grp')), 'members read the topics');
55
+ select throws_ok($$ select genesis_chat.set_topic(pg_temp.v('grp'), 'lesson-2', 'Mine') $$, '42501', null, 'a member doesn''t add topics');
56
+ select lives_ok($$ select genesis_chat.send_message(pg_temp.v('grp'), gen_random_uuid(), 'Question on lesson 1', p_topic => 'lesson-1') $$,
57
+ 'a member posts in an open topic');
58
+ select pg_temp.sign_in('a1');
59
+ select genesis_chat.set_topic(pg_temp.v('grp'), 'lesson-1', p_closed => true);
60
+ select pg_temp.sign_in('b2');
61
+ select throws_ok($$ select genesis_chat.send_message(pg_temp.v('grp'), gen_random_uuid(), 'Late', p_topic => 'lesson-1') $$, '42501', null,
62
+ 'not in a closed one');
63
+ select pg_temp.sign_in('a1');
64
+ select lives_ok($$ select genesis_chat.send_message(pg_temp.v('grp'), gen_random_uuid(), 'Wrapping up', p_topic => 'lesson-1') $$,
65
+ 'its owner still can');
66
+ select pg_temp.sign_in('c3');
67
+ select is((select count(*)::integer from genesis_chat.topics where conversation_id = pg_temp.v('grp')), 0, 'someone outside sees no topics');
68
+
69
+ -- Scheduled send --------------------------------------------------------------------------------
70
+
71
+ select pg_temp.sign_in('b2');
72
+ insert into t select 'sch', genesis_chat.schedule_message(pg_temp.v('grp'), gen_random_uuid(), now() + interval '1 hour', 'Reminder: check-in due');
73
+ select throws_ok($$ select genesis_chat.schedule_message(pg_temp.v('grp'), gen_random_uuid(), now(), 'Now') $$, '22023', null,
74
+ 'a schedule is at least a minute ahead');
75
+ select pg_temp.sign_in('c3');
76
+ select throws_ok($$ select genesis_chat.schedule_message(pg_temp.v('grp'), gen_random_uuid(), now() + interval '1 hour', 'Hi') $$, '42501', null,
77
+ 'only someone who may post schedules');
78
+ select is((select count(*)::integer from genesis_chat.scheduled), 0, 'and nobody else sees a scheduled message');
79
+ reset role;
80
+ update genesis_chat.scheduled set send_at = now() - interval '1 minute' where id = pg_temp.v('sch');
81
+ select pg_temp.as_server();
82
+ select is((select array_agg(x) from genesis_chat.due_scheduled() x), array[pg_temp.v('sch')], 'it comes due');
83
+ insert into t select 'posted', genesis_chat.post_scheduled(pg_temp.v('sch'));
84
+ select is((select sender_id from genesis_chat.messages where id = pg_temp.v('posted')), pg_temp.person('b2'), 'and is posted as its sender');
85
+ select is(genesis_chat.post_scheduled(pg_temp.v('sch')), pg_temp.v('posted'), 'once');
86
+ select pg_temp.sign_in('e5');
87
+ insert into t select 'sch2', genesis_chat.schedule_message(pg_temp.v('grp'), gen_random_uuid(), now() + interval '1 hour', 'Bye later');
88
+ select genesis_chat.remove_member(pg_temp.v('grp'));
89
+ reset role;
90
+ update genesis_chat.scheduled set send_at = now() - interval '1 minute' where id = pg_temp.v('sch2');
91
+ select pg_temp.as_server();
92
+ select is(genesis_chat.post_scheduled(pg_temp.v('sch2')), null, 'a scheduled message from someone who has since left isn''t posted');
93
+ select ok((select error is not null from genesis_chat.scheduled where id = pg_temp.v('sch2')), 'and says why');
94
+ select pg_temp.sign_in('a1');
95
+ select throws_ok($$ select genesis_chat.post_scheduled(pg_temp.v('sch2')) $$, '42501', null, 'only the server posts scheduled messages');
96
+
97
+ -- A failure that may pass (here a deadlock) is tried again on the next run, not recorded for good.
98
+ select pg_temp.sign_in('b2');
99
+ insert into t select 'sch3', genesis_chat.schedule_message(pg_temp.v('grp'), gen_random_uuid(), now() + interval '1 hour', 'Flaky one');
100
+ reset role;
101
+ update genesis_chat.scheduled set send_at = now() - interval '1 minute' where id = pg_temp.v('sch3');
102
+ create function public.genesis_chat2_test_flaky() returns trigger language plpgsql set search_path = '' as $$
103
+ begin
104
+ if new.body = 'Flaky one' then raise exception 'deadlock detected' using errcode = '40P01'; end if;
105
+ return new;
106
+ end $$;
107
+ create trigger zz_genesis_chat2_test_flaky before insert on genesis_chat.messages
108
+ for each row execute function public.genesis_chat2_test_flaky();
109
+ select pg_temp.as_server();
110
+ select is(genesis_chat.post_scheduled(pg_temp.v('sch3')), null, 'a deadlock posts nothing yet');
111
+ select ok((select error is null and attempts = 1 from genesis_chat.scheduled where id = pg_temp.v('sch3')),
112
+ 'but keeps the message due, counting the try');
113
+ reset role;
114
+ drop trigger zz_genesis_chat2_test_flaky on genesis_chat.messages;
115
+ select pg_temp.as_server();
116
+ select ok(genesis_chat.post_scheduled(pg_temp.v('sch3')) is not null, 'and the next run posts it');
117
+ select pg_temp.sign_in('a1');
118
+
119
+ -- Availability ------------------------------------------------------------------------------------
120
+
121
+ select genesis_chat.set_availability('Back Monday', null, 'America/New_York', '{"mon": [["09:00","17:00"]]}');
122
+ select pg_temp.sign_in('b2');
123
+ select is((select away_message from genesis_chat.availability_for(pg_temp.v('grp')) where user_id = pg_temp.person('a1')),
124
+ 'Back Monday', 'people in a conversation see each other''s away message');
125
+ select pg_temp.sign_in('c3');
126
+ select is((select count(*)::integer from genesis_chat.availability_for(pg_temp.v('grp'))), 0, 'someone outside sees nobody''s');
127
+ select throws_ok($$ select genesis_chat.set_availability(null, null, 'Mars/Olympus', null) $$, '22023', null, 'a time zone must be real');
128
+ select pg_temp.sign_in('a1');
129
+ select throws_ok($$ select genesis_chat.set_availability(null, null, null, '{"mon": [[9, 17]]}') $$, '22023', null, 'hours are strings');
130
+ select throws_ok($$ select genesis_chat.set_availability(null, null, null, '{"tue": 5}') $$, '22023', null, 'a day is a list of ranges');
131
+ select throws_ok($$ select genesis_chat.set_availability(null, null, null, '{"funday": [["09:00","17:00"]]}') $$, '22023', null, 'days are mon..sun');
132
+ select throws_ok($$ select genesis_chat.set_availability(null, null, null, '{"mon": [["9am","5pm"]]}') $$, '22023', null, 'times are HH:MM');
133
+ select throws_ok($$ select genesis_chat.set_availability(null, null, null, '{"mon": [["09:00"]]}') $$, '22023', null, 'a range is a pair');
134
+ select throws_ok($$ select genesis_chat.set_availability(null, null, null, '{"mon": [["01:00","02:00"],["03:00","04:00"],["05:00","06:00"],["07:00","08:00"],["09:00","10:00"]]}') $$,
135
+ '22023', null, 'at most four ranges a day');
136
+ select lives_ok($$ select genesis_chat.set_availability('Back Monday', null, 'America/New_York', '{"mon": [["9:00","17:00"]], "fri": [["22:00","24:00"],["00:00","02:00"]]}') $$,
137
+ 'well-formed hours are kept');
138
+ select pg_temp.as_server();
139
+ select throws_ok($$ update genesis_chat.availability set hours = '{"mon": [[9, 17]]}' where user_id = pg_temp.person('a1') $$, '23514', null,
140
+ 'the table refuses malformed hours from the server too');
141
+
142
+ -- Broadcast ---------------------------------------------------------------------------------------
143
+
144
+ select pg_temp.sign_in('a1');
145
+ insert into t select 'dm1', genesis_chat.start_conversation('direct', array[pg_temp.person('b2')]);
146
+ insert into t select 'dm2', genesis_chat.start_conversation('direct', array[pg_temp.person('e5')]);
147
+ insert into t values ('bc', gen_random_uuid());
148
+ select is(cardinality(genesis_chat.broadcast_message(array[pg_temp.v('dm1'), pg_temp.v('dm2')], pg_temp.v('bc'), 'Week 2 is live')), 2,
149
+ 'a broadcast goes into each conversation');
150
+ select is(cardinality(genesis_chat.broadcast_message(array[pg_temp.v('dm1'), pg_temp.v('dm2')], pg_temp.v('bc'), 'Week 2 is live')), 2,
151
+ 'and sending it again posts nothing new');
152
+ select is((select count(*)::integer from genesis_chat.messages where body = 'Week 2 is live'), 2, 'once per conversation');
153
+ select pg_temp.sign_in('b2');
154
+ select is((select count(*)::integer from genesis_chat.messages where body = 'Week 2 is live'), 1, 'each person sees only their own copy');
155
+
156
+ -- Search ----------------------------------------------------------------------------------------------
157
+
158
+ select pg_temp.sign_in('d4');
159
+ select genesis_chat.send_message(pg_temp.v('grp'), gen_random_uuid(), 'Internal: lesson pacing is off', p_visibility => 'observers');
160
+ select pg_temp.sign_in('b2');
161
+ select ok(exists (select 1 from genesis_chat.search_messages('lesson')), 'a member finds messages they can read');
162
+ select ok(not exists (select 1 from genesis_chat.search_messages('pacing')), 'never an internal note');
163
+ select genesis_chat.send_message(pg_temp.v('grp'), gen_random_uuid(), 'the word quilt beside a «fake» highlight');
164
+ select ok((select snippet like '%«quilt»%' and snippet not like '%«fake»%' from genesis_chat.search_messages('quilt')),
165
+ 'a message''s own « and » can''t fake a highlight');
166
+ select pg_temp.sign_in('c3');
167
+ select ok(not exists (select 1 from genesis_chat.search_messages('lesson')), 'someone outside finds nothing');
168
+
169
+ -- Moderation, link previews, retention ----------------------------------------------------------------
170
+
171
+ select pg_temp.as_server();
172
+ select genesis_chat.set_kind_options('cohort', true, interval '30 days');
173
+ select pg_temp.sign_in('b2');
174
+ insert into t select 'bad', (genesis_chat.send_message(pg_temp.v('grp'), gen_random_uuid(), 'see https://example.com/page')).id;
175
+ select pg_temp.as_server();
176
+ select ok(exists (select 1 from genesis_chat.effects where key = 'moderate:' || pg_temp.v('bad')), 'a kind that moderates queues moderation');
177
+ select ok(exists (select 1 from genesis_chat.effects where kind = 'unfurl' and payload ->> 'url' = 'https://example.com/page'),
178
+ 'a link queues its preview');
179
+ select genesis_chat.moderate_message(pg_temp.v('bad'), 'hidden', 'test');
180
+ select pg_temp.sign_in('a1');
181
+ select is((select count(*)::integer from genesis_chat.messages where id = pg_temp.v('bad')), 0, 'a hidden message leaves members'' view');
182
+ select pg_temp.sign_in('d4');
183
+ select is((select count(*)::integer from genesis_chat.messages where id = pg_temp.v('bad')), 1, 'observers still see it, with a report');
184
+ reset role;
185
+ insert into genesis_chat.attachments (message_id, conversation_id, kind, provider, transcript)
186
+ values (pg_temp.v('bad'), pg_temp.v('grp'), 'voice', 'none', 'the walrus transcript of a hidden note');
187
+ select pg_temp.sign_in('a1');
188
+ select ok(not exists (select 1 from genesis_chat.search_messages('walrus')), 'search never shows members a hidden message''s transcript');
189
+ select pg_temp.sign_in('d4');
190
+ select ok(exists (select 1 from genesis_chat.search_messages('walrus', pg_temp.v('grp'))), 'observers still find it');
191
+
192
+ -- Pushes wait for moderation; a hidden message is never pushed, counted or previewed.
193
+ select pg_temp.as_server();
194
+ select ok((select done_at is not null from genesis_chat.effects where key = 'push:' || pg_temp.v('bad')),
195
+ 'a hidden message''s push is settled unsent');
196
+ select is((select count(*)::integer from genesis_chat.push_targets(pg_temp.v('bad'))), 0, 'and push_targets has nobody for it');
197
+ select is(genesis_chat.unread_count(pg_temp.v('grp'), pg_temp.person('a1'),
198
+ (select seq - 1 from genesis_chat.messages where id = pg_temp.v('bad'))), 0, 'it counts toward nobody''s badge');
199
+ select pg_temp.sign_in('a1');
200
+ select ok((select last_message ->> 'id' from genesis_chat.inbox() where conversation_id = pg_temp.v('grp')) is distinct from pg_temp.v('bad')::text,
201
+ 'nor shows in members'' inbox');
202
+ select pg_temp.sign_in('b2');
203
+ insert into t select 'ok1', (genesis_chat.send_message(pg_temp.v('grp'), gen_random_uuid(), 'All good here')).id;
204
+ select pg_temp.as_server();
205
+ select is((select run_after from genesis_chat.effects where key = 'push:' || pg_temp.v('ok1')), 'infinity'::timestamptz,
206
+ 'a push in a kind that moderates waits for the verdict');
207
+ select genesis_chat.moderate_message(pg_temp.v('ok1'), 'clean');
208
+ select ok((select run_after <= now() and done_at is null from genesis_chat.effects where key = 'push:' || pg_temp.v('ok1')),
209
+ 'and goes once the message is clean');
210
+
211
+ -- A retried or repeated verdict never un-hides a message, nor files the same report twice.
212
+ select genesis_chat.moderate_message(pg_temp.v('bad'), 'hidden', 'again');
213
+ select genesis_chat.moderate_message(pg_temp.v('bad'), 'flagged', 'moderation unavailable');
214
+ select is((select moderation from genesis_chat.messages where id = pg_temp.v('bad')), 'hidden', 'a later verdict never un-hides a message');
215
+ select is((select count(*)::integer from genesis_chat.reports where message_id = pg_temp.v('bad') and snapshot ->> 'automatic' = 'true'), 2,
216
+ 'one report per verdict');
217
+
218
+ -- An edit is moderated again.
219
+ select pg_temp.sign_in('b2');
220
+ select genesis_chat.edit_message(pg_temp.v('ok1'), 'Edited into something else');
221
+ select pg_temp.as_server();
222
+ select ok(exists (select 1 from genesis_chat.effects where kind = 'moderate' and key like 'moderate:' || pg_temp.v('ok1') || ':%'),
223
+ 'an edited message is judged again');
224
+
225
+ -- Link previews are read through a message you can read, never as a table.
226
+ select genesis_chat.save_link_preview('https://example.com/page', 'ok', 'Example page');
227
+ select pg_temp.sign_in('b2');
228
+ select throws_ok($$ select count(*) from genesis_chat.link_previews $$, '42501', null, 'people don''t read the preview cache directly');
229
+ insert into t select 'linked', (genesis_chat.send_message(pg_temp.v('grp'), gen_random_uuid(), 'look at https://example.com/page')).id;
230
+ select is((select title from genesis_chat.link_previews_for(pg_temp.v('linked'))), 'Example page',
231
+ 'a member reads the previews of a message they can read');
232
+ select pg_temp.sign_in('a1');
233
+ select is((select count(*)::integer from genesis_chat.link_previews_for(pg_temp.v('bad'))), 0, 'not those of a hidden message');
234
+ select pg_temp.sign_in('c3');
235
+ select is((select count(*)::integer from genesis_chat.link_previews_for(pg_temp.v('linked'))), 0, 'and someone outside reads none');
236
+ select pg_temp.sign_in('b2');
237
+ select genesis_chat.send_message(pg_temp.v('grp'), gen_random_uuid(), 'reset it at https://example.com/reset?token=abc123');
238
+ select pg_temp.as_server();
239
+ select ok(not exists (select 1 from genesis_chat.effects where kind = 'unfurl' and payload ->> 'url' like '%token=%'),
240
+ 'a link carrying a credential is never fetched');
241
+
242
+ reset role;
243
+ update genesis_chat.messages set created_at = now() - interval '60 days' where conversation_id = pg_temp.v('grp');
244
+ update genesis_chat.conversations set legal_hold = true where id = pg_temp.v('grp');
245
+ select pg_temp.as_server();
246
+ select is(genesis_chat.purge_expired(), 0, 'legal hold keeps everything');
247
+ select genesis_chat.set_legal_hold(pg_temp.v('grp'), false);
248
+ select ok(genesis_chat.purge_expired() > 0, 'without it, messages past the kind''s retention go');
249
+
250
+ -- Inbox kinds ------------------------------------------------------------------------------------------
251
+
252
+ select pg_temp.sign_in('b2');
253
+ select ok(exists (select 1 from genesis_chat.inbox(p_kinds => array['direct'])) and
254
+ not exists (select 1 from genesis_chat.inbox(p_kinds => array['direct']) where kind <> 'direct'),
255
+ 'the inbox narrows to the kinds asked for');
256
+ select ok(not exists (select 1 from genesis_chat.inbox(p_exclude_kinds => array['cohort']) where kind = 'cohort'),
257
+ 'and leaves out the kinds asked to');
258
+ select ok(genesis_chat.unread_total(p_exclude_kinds => array['cohort', 'direct']) = 0, 'so does the badge');
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;
262
+ select pg_temp.as_server();
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');
267
+
268
+ select * from finish();
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.1.1",
3
+ "version": "0.2.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -16,17 +16,22 @@
16
16
  "provenance": false
17
17
  },
18
18
  "main": "./dist/index.js",
19
+ "react-native": "./src/index.ts",
19
20
  "module": "./dist/index.js",
20
21
  "types": "./dist/index.d.ts",
21
22
  "exports": {
22
23
  "./package.json": "./package.json",
23
24
  ".": {
24
25
  "types": "./dist/index.d.ts",
25
- "import": "./dist/index.js"
26
+ "react-native": "./src/index.ts",
27
+ "import": "./dist/index.js",
28
+ "default": "./dist/index.js"
26
29
  },
27
30
  "./react": {
28
31
  "types": "./dist/react.d.ts",
29
- "import": "./dist/react.js"
32
+ "react-native": "./src/react/index.tsx",
33
+ "import": "./dist/react.js",
34
+ "default": "./dist/react.js"
30
35
  },
31
36
  "./web": {
32
37
  "types": "./dist/web.d.ts",
@@ -34,7 +39,15 @@
34
39
  },
35
40
  "./native": {
36
41
  "types": "./dist/native.d.ts",
37
- "import": "./dist/native.js"
42
+ "react-native": "./src/native/index.ts",
43
+ "import": "./dist/native.js",
44
+ "default": "./dist/native.js"
45
+ },
46
+ "./push": {
47
+ "types": "./dist/push.d.ts",
48
+ "react-native": "./src/native/push.ts",
49
+ "import": "./dist/push.js",
50
+ "default": "./dist/push.js"
38
51
  },
39
52
  "./server": {
40
53
  "types": "./dist/server.d.ts",
@@ -43,20 +56,26 @@
43
56
  },
44
57
  "files": [
45
58
  "dist",
59
+ "src",
46
60
  "migrations",
47
61
  "README.md"
48
62
  ],
49
63
  "dependencies": {
50
- "@marktiderman/genesis-features": "^0.3.0"
64
+ "@marktiderman/genesis-features": "^0.3.0",
65
+ "frimousse": "0.4.0"
51
66
  },
52
67
  "peerDependencies": {
53
68
  "@marktiderman/genesis-core": "^1.7.0",
54
69
  "@marktiderman/genesis-ui": "^2.13.0",
70
+ "@marktiderman/genesis-ui-native": "^2.4.1",
55
71
  "@supabase/supabase-js": "^2.118.0",
56
72
  "@tanstack/react-query": "^5.0.0",
73
+ "hls.js": "^1.5.0",
57
74
  "lucide-react": "^1.0.0",
58
75
  "react": "^19.0.0",
59
- "react-dom": "^19.0.0"
76
+ "react-dom": "^19.0.0",
77
+ "react-native": ">=0.76.0",
78
+ "react-native-safe-area-context": ">=4.0.0"
60
79
  },
61
80
  "peerDependenciesMeta": {
62
81
  "@marktiderman/genesis-core": {
@@ -65,9 +84,42 @@
65
84
  "@marktiderman/genesis-ui": {
66
85
  "optional": true
67
86
  },
87
+ "@marktiderman/genesis-ui-native": {
88
+ "optional": true
89
+ },
90
+ "@shopify/flash-list": {
91
+ "optional": true
92
+ },
68
93
  "@tanstack/react-query": {
69
94
  "optional": true
70
95
  },
96
+ "expo-audio": {
97
+ "optional": true
98
+ },
99
+ "expo-camera": {
100
+ "optional": true
101
+ },
102
+ "expo-clipboard": {
103
+ "optional": true
104
+ },
105
+ "expo-haptics": {
106
+ "optional": true
107
+ },
108
+ "expo-image": {
109
+ "optional": true
110
+ },
111
+ "expo-image-picker": {
112
+ "optional": true
113
+ },
114
+ "expo-video": {
115
+ "optional": true
116
+ },
117
+ "expo-web-browser": {
118
+ "optional": true
119
+ },
120
+ "hls.js": {
121
+ "optional": true
122
+ },
71
123
  "lucide-react": {
72
124
  "optional": true
73
125
  },
@@ -76,11 +128,22 @@
76
128
  },
77
129
  "react-dom": {
78
130
  "optional": true
131
+ },
132
+ "react-native": {
133
+ "optional": true
134
+ },
135
+ "react-native-keyboard-controller": {
136
+ "optional": true
137
+ },
138
+ "react-native-safe-area-context": {
139
+ "optional": true
79
140
  }
80
141
  },
81
142
  "devDependencies": {
82
143
  "@marktiderman/genesis-core": "^1.7.0",
144
+ "@marktiderman/genesis-notifications": "^0.1.1",
83
145
  "@marktiderman/genesis-ui": "^2.13.0",
146
+ "@marktiderman/genesis-ui-native": "^2.4.1",
84
147
  "@supabase/supabase-js": "^2.118.0",
85
148
  "@tanstack/react-query": "^5.75.0",
86
149
  "@testing-library/react": "^16.3.2",
@@ -88,6 +151,7 @@
88
151
  "@types/react": "^19.2.14",
89
152
  "@types/react-dom": "^19.2.3",
90
153
  "happy-dom": "^20.9.0",
154
+ "hls.js": "1.7.3",
91
155
  "lucide-react": "^1.28.0",
92
156
  "react": "^19.2.5",
93
157
  "react-dom": "^19.2.5",
@@ -125,6 +189,14 @@
125
189
  {
126
190
  "file": "migrations/0001_genesis_chat.sql",
127
191
  "test": "migrations/tests/0001_genesis_chat.test.sql"
192
+ },
193
+ {
194
+ "file": "migrations/0002_genesis_chat_coaching.sql",
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"
128
200
  }
129
201
  ],
130
202
  "install": [