snapback2 0.0.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 (132) hide show
  1. package/README.md +127 -0
  2. package/bin/snapback2.mjs +36 -0
  3. package/dist/api.d.ts +20 -0
  4. package/dist/assets.d.ts +34 -0
  5. package/dist/client-assets.d.ts +26 -0
  6. package/dist/client-offline.d.ts +94 -0
  7. package/dist/client-outbox.d.ts +58 -0
  8. package/dist/client-upload.d.ts +41 -0
  9. package/dist/client-wire.d.ts +41 -0
  10. package/dist/client.d.ts +12 -0
  11. package/dist/client.mjs +5 -0
  12. package/dist/client.mjs.map +7 -0
  13. package/dist/compiler-core.mjs +60 -0
  14. package/dist/compiler-core.mjs.map +7 -0
  15. package/dist/compiler-lib.d.ts +71 -0
  16. package/dist/compiler.mjs +5 -0
  17. package/dist/compiler.mjs.map +7 -0
  18. package/dist/drain.d.ts +33 -0
  19. package/dist/expo/files.d.ts +3 -0
  20. package/dist/expo/index.d.ts +16 -0
  21. package/dist/expo/token-store.d.ts +7 -0
  22. package/dist/expo/witness.d.ts +28 -0
  23. package/dist/expo/witness.mjs +2 -0
  24. package/dist/expo/witness.mjs.map +7 -0
  25. package/dist/expo.d.ts +4 -0
  26. package/dist/expo.mjs +9 -0
  27. package/dist/expo.mjs.map +7 -0
  28. package/dist/guide/auth.md +89 -0
  29. package/dist/guide/effects.md +52 -0
  30. package/dist/guide/families.json +446 -0
  31. package/dist/guide/grammar.md +52 -0
  32. package/dist/guide/live-query.md +27 -0
  33. package/dist/guide/offline.md +75 -0
  34. package/dist/guide/personas.md +15 -0
  35. package/dist/guide/quarry.md +297 -0
  36. package/dist/guide/testing.md +57 -0
  37. package/dist/index.d.ts +362 -0
  38. package/dist/index.mjs +2 -0
  39. package/dist/index.mjs.map +7 -0
  40. package/dist/offline-protocol.d.ts +180 -0
  41. package/dist/offline-schema.d.ts +1 -0
  42. package/dist/offline.d.ts +177 -0
  43. package/dist/react-core.d.ts +91 -0
  44. package/dist/react-core.mjs +2 -0
  45. package/dist/react-core.mjs.map +7 -0
  46. package/dist/react-native/components.d.ts +23 -0
  47. package/dist/react-native/files.d.ts +43 -0
  48. package/dist/react-native/source.d.ts +16 -0
  49. package/dist/react-native.d.ts +4 -0
  50. package/dist/react-native.mjs +2 -0
  51. package/dist/react-native.mjs.map +7 -0
  52. package/dist/react.d.ts +19 -0
  53. package/dist/react.mjs +2 -0
  54. package/dist/react.mjs.map +7 -0
  55. package/dist/sqlite-test.mjs +1720 -0
  56. package/dist/sqlite-test.mjs.map +7 -0
  57. package/dist/sse.d.ts +44 -0
  58. package/dist/store/byte-cache.d.ts +162 -0
  59. package/dist/store/canonical.d.ts +2 -0
  60. package/dist/store/indexeddb.d.ts +23 -0
  61. package/dist/store/locks.d.ts +11 -0
  62. package/dist/store/outbox.d.ts +113 -0
  63. package/dist/store/overlay-retirement.d.ts +17 -0
  64. package/dist/store/projection.d.ts +11 -0
  65. package/dist/store/range-store.d.ts +388 -0
  66. package/dist/store/sqlite-driver.conformance.d.ts +7 -0
  67. package/dist/store/sqlite-driver.d.ts +16 -0
  68. package/dist/store/sqlite-expo.d.ts +3 -0
  69. package/dist/store/sqlite.d.ts +31 -0
  70. package/dist/templates/chat/expo/App.tsx +29 -0
  71. package/dist/templates/chat/expo/app.json +11 -0
  72. package/dist/templates/chat/expo/index.js +3 -0
  73. package/dist/templates/chat/expo/shared/log.js +92 -0
  74. package/dist/templates/chat/expo/shared/log.ts +130 -0
  75. package/dist/templates/chat/expo/shared/offline.js +8 -0
  76. package/dist/templates/chat/expo/shared/offline.ts +9 -0
  77. package/dist/templates/chat/expo/src/Chat.tsx +23 -0
  78. package/dist/templates/chat/expo/src/Composer.tsx +23 -0
  79. package/dist/templates/chat/expo/src/MediaView.tsx +29 -0
  80. package/dist/templates/chat/expo/src/Witness.tsx +131 -0
  81. package/dist/templates/chat/expo/src/screens/Inbox.tsx +9 -0
  82. package/dist/templates/chat/expo/src/screens/Search.tsx +10 -0
  83. package/dist/templates/chat/expo/src/screens/Thread.tsx +22 -0
  84. package/dist/templates/chat/expo/src/witness-state.ts +68 -0
  85. package/dist/templates/chat/expo/tsconfig.json +5 -0
  86. package/dist/templates/chat/react/index.html +5 -0
  87. package/dist/templates/chat/react/shared/log.js +92 -0
  88. package/dist/templates/chat/react/shared/log.ts +130 -0
  89. package/dist/templates/chat/react/shared/offline.js +8 -0
  90. package/dist/templates/chat/react/shared/offline.ts +9 -0
  91. package/dist/templates/chat/react/src/App.tsx +36 -0
  92. package/dist/templates/chat/react/src/Composer.tsx +45 -0
  93. package/dist/templates/chat/react/src/Inbox.tsx +24 -0
  94. package/dist/templates/chat/react/src/Search.tsx +26 -0
  95. package/dist/templates/chat/react/src/Thread.tsx +126 -0
  96. package/dist/templates/chat/react/src/env.d.ts +1 -0
  97. package/dist/templates/chat/react/src/main.tsx +20 -0
  98. package/dist/templates/chat/shared/log.js +92 -0
  99. package/dist/templates/chat/shared/log.ts +130 -0
  100. package/dist/templates/chat/shared/offline.js +8 -0
  101. package/dist/templates/chat/shared/offline.ts +9 -0
  102. package/dist/templates/chat/snapback/deliveries.q +33 -0
  103. package/dist/templates/chat/snapback/delivery.ts +30 -0
  104. package/dist/templates/chat/snapback/feeds.q +14 -0
  105. package/dist/templates/chat/snapback/follows.q +11 -0
  106. package/dist/templates/chat/snapback/groups.q +40 -0
  107. package/dist/templates/chat/snapback/messages.q +32 -0
  108. package/dist/templates/chat/snapback/notifications.q +7 -0
  109. package/dist/templates/chat/snapback/posts.q +4 -0
  110. package/dist/templates/chat/snapback/profiles.q +17 -0
  111. package/dist/templates/chat/snapback/schema.q +136 -0
  112. package/dist/templates/chat/snapback/seed.ts +45 -0
  113. package/dist/templates/chat/snapback/tests/chat.test.ts +759 -0
  114. package/dist/templates/react/index.html +5 -0
  115. package/dist/templates/react/src/App.tsx +5 -0
  116. package/dist/templates/react/src/main.tsx +18 -0
  117. package/dist/templates/todos/snapback/schema.q +11 -0
  118. package/dist/templates/todos/snapback/seed.ts +10 -0
  119. package/dist/templates/todos/snapback/tests/todos.test.ts +13 -0
  120. package/dist/templates/todos/snapback/todos.q +6 -0
  121. package/dist/test-runner.mjs +8541 -0
  122. package/dist/test-runner.mjs.map +7 -0
  123. package/dist/test.d.ts +28 -0
  124. package/dist/test.mjs +8541 -0
  125. package/dist/test.mjs.map +7 -0
  126. package/dist/token-store.d.ts +3 -0
  127. package/dist/twin-hydrate.d.ts +43 -0
  128. package/dist/twin.d.ts +70 -0
  129. package/dist/types.d.ts +410 -0
  130. package/dist/witness-test.mjs +2 -0
  131. package/dist/witness-test.mjs.map +7 -0
  132. package/package.json +117 -0
@@ -0,0 +1,92 @@
1
+ function conversations(rows) {
2
+ const byConversation = /* @__PURE__ */ new Map();
3
+ for (const row of rows) {
4
+ const current = byConversation.get(row.conversationId) ?? {
5
+ conversationId: row.conversationId,
6
+ latestAt: row.at,
7
+ latestBody: void 0,
8
+ messages: 0
9
+ };
10
+ if (row.kind === "message") current.messages += 1;
11
+ if (row.at >= current.latestAt) {
12
+ current.latestAt = row.at;
13
+ current.latestBody = row.message?.body ?? current.latestBody;
14
+ }
15
+ byConversation.set(row.conversationId, current);
16
+ }
17
+ return [...byConversation.values()].sort((a, b) => b.latestAt - a.latestAt);
18
+ }
19
+ function postArgs(entry) {
20
+ const args = entry.args;
21
+ return entry.op === "deliveries.post" && args && typeof args.conversationId === "string" && typeof args.body === "string" ? { conversationId: args.conversationId, body: args.body } : void 0;
22
+ }
23
+ function editArgs(entry) {
24
+ const args = entry.args;
25
+ return entry.op === "deliveries.edit" && args && typeof args.messageId === "string" && typeof args.body === "string" ? { messageId: args.messageId, body: args.body, baseBody: args.baseBody ?? "" } : void 0;
26
+ }
27
+ function intentLabel(entry) {
28
+ switch (entry.state) {
29
+ case "queued":
30
+ return entry.reason === "fence" ? "Will be checked by the server" : "Sending\u2026";
31
+ case "committed":
32
+ return "Sent";
33
+ case "expired":
34
+ return "Expired";
35
+ case "rejected": {
36
+ const response = entry.response;
37
+ if (response?.reason === "conflict" || response?.code === "CONFLICT") return "Not sent \xB7 Message changed";
38
+ return `Not sent \xB7 ${response?.rewrite ?? response?.message ?? response?.code ?? "refused"}`;
39
+ }
40
+ }
41
+ }
42
+ function threadBubbles(rows, entries, conversationId) {
43
+ const hidden = new Set(rows.filter((row) => row.kind === "removed" || row.kind === "expired").map((row) => row.messageId));
44
+ const edits = new Map(entries.map((entry) => [editArgs(entry)?.messageId, entry]).filter(([id]) => id !== void 0));
45
+ const seen = /* @__PURE__ */ new Set();
46
+ const bubbles = [];
47
+ const pending = new Map(entries.filter((entry) => entry.state === "queued" && postArgs(entry) !== void 0).map((entry) => [entry.ids[0] ?? entry.intent, entry]));
48
+ const chronological = [...rows].sort((a, b) => a.at - b.at || (a.messageId < b.messageId ? -1 : a.messageId > b.messageId ? 1 : 0));
49
+ for (const row of chronological) {
50
+ if (row.kind !== "message" || !row.message || hidden.has(row.messageId) || seen.has(row.messageId)) continue;
51
+ seen.add(row.messageId);
52
+ const predicted = pending.get(row.messageId);
53
+ if (predicted) {
54
+ bubbles.push({ key: row.messageId, kind: "pending", entry: predicted, body: postArgs(predicted).body });
55
+ continue;
56
+ }
57
+ const edit = edits.get(row.messageId);
58
+ bubbles.push({ key: row.messageId, kind: "row", message: row.message, at: row.at, ...edit ? { edit } : {} });
59
+ }
60
+ for (const entry of entries) {
61
+ const args = postArgs(entry);
62
+ if (!args || args.conversationId !== conversationId) continue;
63
+ const key = entry.ids[0] ?? entry.intent;
64
+ if (seen.has(key)) continue;
65
+ bubbles.push({ key, kind: "pending", entry, body: args.body });
66
+ }
67
+ return bubbles;
68
+ }
69
+ function settledByRows(rows, entries) {
70
+ const held = new Set(rows.map((row) => row.messageId));
71
+ return entries.filter((entry) => entry.state === "committed" && postArgs(entry) !== void 0 && held.has(entry.ids[0] ?? ""));
72
+ }
73
+ function searchHeld(rows, term) {
74
+ const needle = term.trim().toLowerCase();
75
+ if (!needle) return [];
76
+ const seen = /* @__PURE__ */ new Set();
77
+ return rows.filter((row) => {
78
+ const body = row.message?.body;
79
+ if (row.kind !== "message" || !body || seen.has(row.messageId) || !body.toLowerCase().includes(needle)) return false;
80
+ seen.add(row.messageId);
81
+ return true;
82
+ });
83
+ }
84
+ export {
85
+ conversations,
86
+ editArgs,
87
+ intentLabel,
88
+ postArgs,
89
+ searchHeld,
90
+ settledByRows,
91
+ threadBubbles
92
+ };
@@ -0,0 +1,130 @@
1
+ // The delivery log is the app's source of truth on the device (LLP 1012 §3):
2
+ // every screen is a pure function over held log rows plus the durable outbox.
3
+ import type { Api, OutboxEntry } from "snapback2/client";
4
+
5
+ type ResultOf<T> = T extends { readonly __result?: infer Result } ? NonNullable<Result> : never;
6
+ export type LogRow = ResultOf<Api["deliveries"]["inbox"]>[number];
7
+ export type MessageRow = NonNullable<LogRow["message"]>;
8
+
9
+ export interface ConversationSummary {
10
+ conversationId: string;
11
+ latestAt: number;
12
+ latestBody: string | undefined;
13
+ messages: number;
14
+ }
15
+
16
+ /** One line per conversation, newest activity first, from the held log. */
17
+ export function conversations(rows: readonly LogRow[]): ConversationSummary[] {
18
+ const byConversation = new Map<string, ConversationSummary>();
19
+ for (const row of rows) {
20
+ const current = byConversation.get(row.conversationId) ?? {
21
+ conversationId: row.conversationId,
22
+ latestAt: row.at,
23
+ latestBody: undefined,
24
+ messages: 0,
25
+ };
26
+ if (row.kind === "message") current.messages += 1;
27
+ if (row.at >= current.latestAt) {
28
+ current.latestAt = row.at;
29
+ current.latestBody = row.message?.body ?? current.latestBody;
30
+ }
31
+ byConversation.set(row.conversationId, current);
32
+ }
33
+ return [...byConversation.values()].sort((a, b) => b.latestAt - a.latestAt);
34
+ }
35
+
36
+ export type PostArgs = { conversationId: string; body: string };
37
+ export type EditArgs = { messageId: string; body: string; baseBody: string };
38
+
39
+ export function postArgs(entry: OutboxEntry): PostArgs | undefined {
40
+ const args = entry.args as Partial<PostArgs> | null;
41
+ return entry.op === "deliveries.post" && args && typeof args.conversationId === "string" && typeof args.body === "string"
42
+ ? { conversationId: args.conversationId, body: args.body }
43
+ : undefined;
44
+ }
45
+
46
+ export function editArgs(entry: OutboxEntry): EditArgs | undefined {
47
+ const args = entry.args as Partial<EditArgs> | null;
48
+ return entry.op === "deliveries.edit" && args && typeof args.messageId === "string" && typeof args.body === "string"
49
+ ? { messageId: args.messageId, body: args.body, baseBody: args.baseBody ?? "" }
50
+ : undefined;
51
+ }
52
+
53
+ /** The label a settled or queued intent shows; never a client clock. */
54
+ export function intentLabel(entry: OutboxEntry): string {
55
+ switch (entry.state) {
56
+ case "queued": return entry.reason === "fence" ? "Will be checked by the server" : "Sending…";
57
+ case "committed": return "Sent";
58
+ case "expired": return "Expired";
59
+ case "rejected": {
60
+ const response = entry.response as { reason?: string; code?: string; rewrite?: string; message?: string } | undefined;
61
+ // @ref LLP 1012#7-writes-offline — a trace conflict, or the recipe's own
62
+ // compare-and-set (`require msg.body = baseBody else CONFLICT`), reads the same.
63
+ if (response?.reason === "conflict" || response?.code === "CONFLICT") return "Not sent · Message changed";
64
+ return `Not sent · ${response?.rewrite ?? response?.message ?? response?.code ?? "refused"}`;
65
+ }
66
+ }
67
+ }
68
+
69
+ export type Bubble =
70
+ | { key: string; kind: "row"; message: MessageRow; at: number; edit?: OutboxEntry }
71
+ | { key: string; kind: "pending"; entry: OutboxEntry; body: string };
72
+
73
+ /**
74
+ * The thread as bubbles: one per message id from the log (tombstoned ids
75
+ * hidden), then the outbox's intents for this conversation. A pending post is
76
+ * keyed by its first pinned id, the id the server row will carry, so the same
77
+ * bubble becomes the row on `committed` and nothing jumps.
78
+ */
79
+ export function threadBubbles(rows: readonly LogRow[], entries: readonly OutboxEntry[], conversationId: string): Bubble[] {
80
+ const hidden = new Set(rows.filter((row) => row.kind === "removed" || row.kind === "expired").map((row) => row.messageId));
81
+ const edits = new Map(entries.map((entry) => [editArgs(entry)?.messageId, entry] as const).filter(([id]) => id !== undefined));
82
+ const seen = new Set<string>();
83
+ const bubbles: Bubble[] = [];
84
+ // @ref LLP 1012#7-writes-offline — a queued send the twin predicted is inside
85
+ // the log's rows under its pinned id; it is still the pending bubble, with
86
+ // `args.body` and "Sending…" and no time, until the server's row settles it.
87
+ const pending = new Map(entries
88
+ .filter((entry) => entry.state === "queued" && postArgs(entry) !== undefined)
89
+ .map((entry) => [entry.ids[0] ?? entry.intent, entry] as const));
90
+ // The log's thread page is newest-first (`last 50`); a thread reads oldest-first.
91
+ const chronological = [...rows].sort((a, b) => a.at - b.at || (a.messageId < b.messageId ? -1 : a.messageId > b.messageId ? 1 : 0));
92
+ for (const row of chronological) {
93
+ if (row.kind !== "message" || !row.message || hidden.has(row.messageId) || seen.has(row.messageId)) continue;
94
+ seen.add(row.messageId);
95
+ const predicted = pending.get(row.messageId);
96
+ if (predicted) {
97
+ bubbles.push({ key: row.messageId, kind: "pending", entry: predicted, body: postArgs(predicted)!.body });
98
+ continue;
99
+ }
100
+ const edit = edits.get(row.messageId);
101
+ bubbles.push({ key: row.messageId, kind: "row", message: row.message, at: row.at, ...(edit ? { edit } : {}) });
102
+ }
103
+ for (const entry of entries) {
104
+ const args = postArgs(entry);
105
+ if (!args || args.conversationId !== conversationId) continue;
106
+ const key = entry.ids[0] ?? entry.intent;
107
+ if (seen.has(key)) continue;
108
+ bubbles.push({ key, kind: "pending", entry, body: args.body });
109
+ }
110
+ return bubbles;
111
+ }
112
+
113
+ /** Committed posts whose server row is now held can leave the outbox. */
114
+ export function settledByRows(rows: readonly LogRow[], entries: readonly OutboxEntry[]): OutboxEntry[] {
115
+ const held = new Set(rows.map((row) => row.messageId));
116
+ return entries.filter((entry) => entry.state === "committed" && postArgs(entry) !== undefined && held.has(entry.ids[0] ?? ""));
117
+ }
118
+
119
+ /** Offline search is a filter over the downloaded log (LLP 1012 §6); it finds nothing evicted. */
120
+ export function searchHeld(rows: readonly LogRow[], term: string): LogRow[] {
121
+ const needle = term.trim().toLowerCase();
122
+ if (!needle) return [];
123
+ const seen = new Set<string>();
124
+ return rows.filter((row) => {
125
+ const body = row.message?.body;
126
+ if (row.kind !== "message" || !body || seen.has(row.messageId) || !body.toLowerCase().includes(needle)) return false;
127
+ seen.add(row.messageId);
128
+ return true;
129
+ });
130
+ }
@@ -0,0 +1,8 @@
1
+ const chatOffline = {
2
+ onTombstone: (item) => item.table === "deliveries" && (item.row.kind === "removed" || item.row.kind === "expired") && typeof item.row.messageId === "string" ? [{ table: "messages", id: item.row.messageId }] : [],
3
+ // Optional on the recipe's message row; absent/null is ordinary media.
4
+ viewOnce: (table, row) => table === "messages" && row.viewOnce === true
5
+ };
6
+ export {
7
+ chatOffline
8
+ };
@@ -0,0 +1,9 @@
1
+ type StoredRow = { table: string; row: Record<string, unknown> };
2
+
3
+ export const chatOffline = {
4
+ onTombstone: (item: StoredRow) =>
5
+ item.table === "deliveries" && (item.row.kind === "removed" || item.row.kind === "expired")
6
+ && typeof item.row.messageId === "string" ? [{ table: "messages", id: item.row.messageId }] : [],
7
+ // Optional on the recipe's message row; absent/null is ordinary media.
8
+ viewOnce: (table: string, row: Record<string, unknown>) => table === "messages" && row.viewOnce === true,
9
+ };
@@ -0,0 +1,36 @@
1
+ import { useState } from "react";
2
+ import { api } from "snapback2/client";
3
+ import { DevOverlay, useAuth, useConnection, useQuery } from "snapback2/react";
4
+ import { Inbox } from "./Inbox";
5
+ import { Search } from "./Search";
6
+ import { Thread } from "./Thread";
7
+ import type { LogRow } from "../shared/log.js";
8
+
9
+ // @ref LLP 1012#3-the-model-on-one-page-and-the-program-shape — one drained
10
+ // delivery log is "everything on the phone"; the inbox, every thread, and
11
+ // search read the same held rows.
12
+ export function App() {
13
+ const connection = useConnection();
14
+ const auth = useAuth();
15
+ const inbox = useQuery(api.deliveries.inbox, { c: null }, { drain: true });
16
+ const rows: LogRow[] = "data" in inbox && Array.isArray(inbox.data) ? inbox.data : [];
17
+ const [selected, setSelected] = useState<string>();
18
+ const [note, setNote] = useState("");
19
+ const caughtUp = "caughtUp" in inbox && inbox.caughtUp === true;
20
+ return (
21
+ <main>
22
+ <header>
23
+ <h1>Snapback 2 chat</h1>
24
+ <p role="status" data-connection={connection}>
25
+ {connection === "connected" ? "Connected" : connection === "offline" ? "Offline" : "Reconnecting…"}
26
+ {caughtUp ? " · log downloaded" : inbox.state === "hydrating" ? " · loading" : ""}
27
+ </p>
28
+ {note ? <p role="alert">{note}</p> : null}
29
+ </header>
30
+ <Search rows={rows} onOpen={setSelected} />
31
+ <Inbox rows={rows} selected={selected} onSelect={setSelected} />
32
+ {selected ? <Thread conversationId={selected} viewer={auth.viewer} onNote={setNote} /> : <p>Pick a conversation.</p>}
33
+ <DevOverlay />
34
+ </main>
35
+ );
36
+ }
@@ -0,0 +1,45 @@
1
+ import { useState, type FormEvent } from "react";
2
+ import { api, isSnapbackRefusal } from "snapback2/client";
3
+ import { useMutation, useOutbox } from "snapback2/react";
4
+
5
+ // @ref LLP 1012#7-writes-offline — `run()` resolves `queued` when the owner is
6
+ // unreachable; the durable outbox, not this component, owns the message from then on.
7
+ // A device with a store writes only when this op has a replay grant, a certified
8
+ // bound, and enough ids for its slice: until then the send is disabled with the
9
+ // reason shown, never retried blindly, and a refusal's rewrite is surfaced as the note.
10
+ export function Composer({ conversationId, onNote }: { conversationId: string; onNote(note: string): void }) {
11
+ const post = useMutation(api.deliveries.post);
12
+ const { fence } = useOutbox();
13
+ const [body, setBody] = useState("");
14
+
15
+ const submit = async (event: FormEvent<HTMLFormElement>) => {
16
+ event.preventDefault();
17
+ const text = body.trim();
18
+ if (!text) return;
19
+ setBody("");
20
+ try {
21
+ const result = await post.run({ conversationId, body: text });
22
+ if (result.state === "queued") onNote(fence === "past" ? "Queued until the next sync." : "Queued; it sends when the connection returns.");
23
+ else if (result.state === "rejected") onNote(`Not sent: ${result.rewrite ?? result.message}`);
24
+ else onNote("");
25
+ } catch (error) {
26
+ onNote(isSnapbackRefusal(error) ? (error.envelope.rewrite ?? error.message) : String(error));
27
+ }
28
+ };
29
+
30
+ return (
31
+ <form aria-label="Composer" onSubmit={(event) => { void submit(event); }}>
32
+ <input
33
+ aria-label="Message"
34
+ value={body}
35
+ placeholder="Message"
36
+ onChange={(event) => setBody(event.target.value)}
37
+ />
38
+ <button type="submit" disabled={post.inFlight || !post.ready}>Send</button>
39
+ {!post.ready
40
+ ? <small data-waiting="grant">Sending opens once the log has downloaded.</small>
41
+ : fence === "past" ? <small data-waiting="fence">Queued until the next sync.</small>
42
+ : post.pending.length > 0 ? <small>{`${post.pending.length} queued`}</small> : null}
43
+ </form>
44
+ );
45
+ }
@@ -0,0 +1,24 @@
1
+ import { useMemo } from "react";
2
+ import { conversations, type LogRow } from "../shared/log.js";
3
+
4
+ export function Inbox({ rows, selected, onSelect }: {
5
+ rows: readonly LogRow[];
6
+ selected: string | undefined;
7
+ onSelect(conversationId: string): void;
8
+ }) {
9
+ const items = useMemo(() => conversations(rows), [rows]);
10
+ return (
11
+ <nav aria-label="Inbox">
12
+ <ul>
13
+ {items.map((item) => (
14
+ <li key={item.conversationId} data-conversation={item.conversationId} aria-current={item.conversationId === selected ? "true" : undefined}>
15
+ <button type="button" onClick={() => onSelect(item.conversationId)}>
16
+ <strong>{item.conversationId}</strong>
17
+ <span>{item.latestBody ?? `${item.messages} messages`}</span>
18
+ </button>
19
+ </li>
20
+ ))}
21
+ </ul>
22
+ </nav>
23
+ );
24
+ }
@@ -0,0 +1,26 @@
1
+ import { useMemo, useState } from "react";
2
+ import { searchHeld, type LogRow } from "../shared/log.js";
3
+
4
+ // @ref LLP 1012#6-reads-offline — a search op has no held answer for a new
5
+ // term, so the recipe filters the downloaded log and says so.
6
+ export function Search({ rows, onOpen }: { rows: readonly LogRow[]; onOpen(conversationId: string): void }) {
7
+ const [term, setTerm] = useState("");
8
+ const hits = useMemo(() => searchHeld(rows, term), [rows, term]);
9
+ return (
10
+ <section aria-label="Search">
11
+ <input aria-label="Search downloaded messages" value={term} placeholder="Search" onChange={(event) => setTerm(event.target.value)} />
12
+ {term.trim() ? (
13
+ <>
14
+ <p className="scope">in downloaded messages</p>
15
+ <ul className="hits">
16
+ {hits.map((row) => (
17
+ <li key={row.messageId} data-message={row.messageId}>
18
+ <button type="button" onClick={() => onOpen(row.conversationId)}>{row.message?.body}</button>
19
+ </li>
20
+ ))}
21
+ </ul>
22
+ </>
23
+ ) : null}
24
+ </section>
25
+ );
26
+ }
@@ -0,0 +1,126 @@
1
+ import { useEffect, useMemo } from "react";
2
+ import { api, isSnapbackRefusal } from "snapback2/client";
3
+ import { useMutation, useOutbox, useQuery } from "snapback2/react";
4
+ import { Composer } from "./Composer";
5
+ import { intentLabel, settledByRows, threadBubbles, type Bubble, type LogRow } from "../shared/log.js";
6
+
7
+ function when(at: number): string {
8
+ return new Date(at).toLocaleTimeString();
9
+ }
10
+
11
+ function describe(error: unknown): string {
12
+ return isSnapbackRefusal(error) ? (error.envelope.rewrite ?? error.message) : String(error);
13
+ }
14
+
15
+ // @ref LLP 1012#7-writes-offline — a pending bubble shows `args.body` and
16
+ // "Sending…" with no time; the same key becomes the server row on `committed`.
17
+ function BubbleView({ bubble, viewer, onDismiss, onEdit, onUnsend }: {
18
+ bubble: Bubble;
19
+ viewer: string | undefined;
20
+ onDismiss(intent: string): void;
21
+ onEdit(messageId: string, body: string): void;
22
+ onUnsend(messageId: string): void;
23
+ }) {
24
+ if (bubble.kind === "pending") {
25
+ const settled = bubble.entry.state !== "queued";
26
+ return (
27
+ <li data-message={bubble.key} data-state={bubble.entry.state} className="bubble pending">
28
+ <p>{bubble.body}</p>
29
+ <small>{intentLabel(bubble.entry)}</small>
30
+ {settled ? <button type="button" onClick={() => onDismiss(bubble.entry.intent)}>Dismiss</button> : null}
31
+ </li>
32
+ );
33
+ }
34
+ const own = viewer !== undefined && bubble.message.authorId === viewer;
35
+ const body = bubble.message.body ?? "";
36
+ return (
37
+ <li data-message={bubble.key} data-state="row" className={own ? "bubble own" : "bubble"}>
38
+ <p>{body || (bubble.message.photo ? "[photo]" : bubble.message.clip ? "[clip]" : "")}</p>
39
+ <time dateTime={new Date(bubble.at).toISOString()}>{when(bubble.at)}</time>
40
+ {bubble.edit ? (
41
+ <small data-edit={bubble.edit.state}>
42
+ {`Edit ${intentLabel(bubble.edit).toLowerCase()}`}
43
+ {bubble.edit.state !== "queued"
44
+ ? <button type="button" onClick={() => onDismiss(bubble.edit!.intent)}>Dismiss</button>
45
+ : null}
46
+ </small>
47
+ ) : null}
48
+ {own && body ? (
49
+ <span className="actions">
50
+ <button type="button" onClick={() => onEdit(bubble.key, body)}>Edit</button>
51
+ <button type="button" onClick={() => onUnsend(bubble.key)}>Unsend</button>
52
+ </span>
53
+ ) : null}
54
+ </li>
55
+ );
56
+ }
57
+
58
+ export function Thread({ conversationId, viewer, onNote }: {
59
+ conversationId: string;
60
+ viewer: string | undefined;
61
+ onNote(note: string): void;
62
+ }) {
63
+ const thread = useQuery(api.deliveries.thread, { conversationId, c: null });
64
+ const { entries, dismiss } = useOutbox();
65
+ const edit = useMutation(api.deliveries.edit);
66
+ const unsend = useMutation(api.deliveries.unsend);
67
+ const rows: LogRow[] = "data" in thread && Array.isArray(thread.data) ? thread.data : [];
68
+ const bubbles = useMemo(() => threadBubbles(rows, entries, conversationId), [rows, entries, conversationId]);
69
+ useEffect(() => {
70
+ for (const entry of settledByRows(rows, entries)) void dismiss(entry.intent);
71
+ }, [rows, entries, dismiss]);
72
+
73
+ const status = thread.state === "hydrating"
74
+ ? "Loading…"
75
+ : thread.state === "refused"
76
+ ? `Refused: ${thread.refusal.rewrite ?? thread.refusal.message}`
77
+ : thread.state === "denied"
78
+ ? "You are not a member of this conversation."
79
+ : [
80
+ thread.state === "partial" ? "Some messages are no longer held." : undefined,
81
+ "stale" in thread && thread.stale ? "Showing downloaded messages." : undefined,
82
+ ].filter(Boolean).join(" ");
83
+
84
+ const onEdit = async (messageId: string, body: string) => {
85
+ const next = typeof window === "undefined" ? null : window.prompt("Edit message", body);
86
+ if (next === null || next === body) return;
87
+ try {
88
+ const result = await edit.run({ messageId, body: next, baseBody: body });
89
+ if (result.state === "queued") onNote("Edit queued; it sends when the connection returns.");
90
+ else if (result.state === "rejected") onNote(`Edit not sent: ${result.rewrite ?? result.message}`);
91
+ } catch (error) {
92
+ onNote(describe(error));
93
+ }
94
+ };
95
+ const onUnsend = async (messageId: string) => {
96
+ try {
97
+ const result = await unsend.run({ messageId });
98
+ if (result.state === "queued") onNote("Unsend queued; it sends when the connection returns.");
99
+ else if (result.state === "rejected") onNote(`Unsend refused: ${result.rewrite ?? result.message}`);
100
+ } catch (error) {
101
+ onNote(describe(error));
102
+ }
103
+ };
104
+
105
+ return (
106
+ <section aria-label="Thread">
107
+ <header>
108
+ <h2>{conversationId}</h2>
109
+ {status ? <p role="status">{status}</p> : null}
110
+ </header>
111
+ <ol className="bubbles">
112
+ {bubbles.map((bubble) => (
113
+ <BubbleView
114
+ key={bubble.key}
115
+ bubble={bubble}
116
+ viewer={viewer}
117
+ onDismiss={(intent) => { void dismiss(intent); }}
118
+ onEdit={(messageId, body) => { void onEdit(messageId, body); }}
119
+ onUnsend={(messageId) => { void onUnsend(messageId); }}
120
+ />
121
+ ))}
122
+ </ol>
123
+ <Composer conversationId={conversationId} onNote={onNote} />
124
+ </section>
125
+ );
126
+ }
@@ -0,0 +1 @@
1
+ interface ImportMeta { readonly env: { readonly VITE_SNAPBACK_URL?: string } }
@@ -0,0 +1,20 @@
1
+ import { StrictMode } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { indexedDbRangeStore, SnapbackProvider } from "snapback2/react";
4
+ import { App } from "./App";
5
+
6
+ const url = import.meta.env.VITE_SNAPBACK_URL ?? "http://127.0.0.1:3210";
7
+ // @ref LLP 1012#3-the-model-on-one-page-and-the-program-shape — a `removed` or
8
+ // `expired` log row evicts the body it names in the same swap it arrives in.
9
+ const offline = {
10
+ store: indexedDbRangeStore(),
11
+ onTombstone: (item: { table: string; row: Record<string, unknown> }) =>
12
+ item.table === "deliveries"
13
+ && (item.row.kind === "removed" || item.row.kind === "expired")
14
+ && typeof item.row.messageId === "string"
15
+ ? [{ table: "messages", id: item.row.messageId }]
16
+ : [],
17
+ };
18
+ createRoot(document.getElementById("root")!).render(
19
+ <StrictMode><SnapbackProvider url={url} as="alice" offline={offline}><App /></SnapbackProvider></StrictMode>,
20
+ );
@@ -0,0 +1,92 @@
1
+ function conversations(rows) {
2
+ const byConversation = /* @__PURE__ */ new Map();
3
+ for (const row of rows) {
4
+ const current = byConversation.get(row.conversationId) ?? {
5
+ conversationId: row.conversationId,
6
+ latestAt: row.at,
7
+ latestBody: void 0,
8
+ messages: 0
9
+ };
10
+ if (row.kind === "message") current.messages += 1;
11
+ if (row.at >= current.latestAt) {
12
+ current.latestAt = row.at;
13
+ current.latestBody = row.message?.body ?? current.latestBody;
14
+ }
15
+ byConversation.set(row.conversationId, current);
16
+ }
17
+ return [...byConversation.values()].sort((a, b) => b.latestAt - a.latestAt);
18
+ }
19
+ function postArgs(entry) {
20
+ const args = entry.args;
21
+ return entry.op === "deliveries.post" && args && typeof args.conversationId === "string" && typeof args.body === "string" ? { conversationId: args.conversationId, body: args.body } : void 0;
22
+ }
23
+ function editArgs(entry) {
24
+ const args = entry.args;
25
+ return entry.op === "deliveries.edit" && args && typeof args.messageId === "string" && typeof args.body === "string" ? { messageId: args.messageId, body: args.body, baseBody: args.baseBody ?? "" } : void 0;
26
+ }
27
+ function intentLabel(entry) {
28
+ switch (entry.state) {
29
+ case "queued":
30
+ return entry.reason === "fence" ? "Will be checked by the server" : "Sending\u2026";
31
+ case "committed":
32
+ return "Sent";
33
+ case "expired":
34
+ return "Expired";
35
+ case "rejected": {
36
+ const response = entry.response;
37
+ if (response?.reason === "conflict" || response?.code === "CONFLICT") return "Not sent \xB7 Message changed";
38
+ return `Not sent \xB7 ${response?.rewrite ?? response?.message ?? response?.code ?? "refused"}`;
39
+ }
40
+ }
41
+ }
42
+ function threadBubbles(rows, entries, conversationId) {
43
+ const hidden = new Set(rows.filter((row) => row.kind === "removed" || row.kind === "expired").map((row) => row.messageId));
44
+ const edits = new Map(entries.map((entry) => [editArgs(entry)?.messageId, entry]).filter(([id]) => id !== void 0));
45
+ const seen = /* @__PURE__ */ new Set();
46
+ const bubbles = [];
47
+ const pending = new Map(entries.filter((entry) => entry.state === "queued" && postArgs(entry) !== void 0).map((entry) => [entry.ids[0] ?? entry.intent, entry]));
48
+ const chronological = [...rows].sort((a, b) => a.at - b.at || (a.messageId < b.messageId ? -1 : a.messageId > b.messageId ? 1 : 0));
49
+ for (const row of chronological) {
50
+ if (row.kind !== "message" || !row.message || hidden.has(row.messageId) || seen.has(row.messageId)) continue;
51
+ seen.add(row.messageId);
52
+ const predicted = pending.get(row.messageId);
53
+ if (predicted) {
54
+ bubbles.push({ key: row.messageId, kind: "pending", entry: predicted, body: postArgs(predicted).body });
55
+ continue;
56
+ }
57
+ const edit = edits.get(row.messageId);
58
+ bubbles.push({ key: row.messageId, kind: "row", message: row.message, at: row.at, ...edit ? { edit } : {} });
59
+ }
60
+ for (const entry of entries) {
61
+ const args = postArgs(entry);
62
+ if (!args || args.conversationId !== conversationId) continue;
63
+ const key = entry.ids[0] ?? entry.intent;
64
+ if (seen.has(key)) continue;
65
+ bubbles.push({ key, kind: "pending", entry, body: args.body });
66
+ }
67
+ return bubbles;
68
+ }
69
+ function settledByRows(rows, entries) {
70
+ const held = new Set(rows.map((row) => row.messageId));
71
+ return entries.filter((entry) => entry.state === "committed" && postArgs(entry) !== void 0 && held.has(entry.ids[0] ?? ""));
72
+ }
73
+ function searchHeld(rows, term) {
74
+ const needle = term.trim().toLowerCase();
75
+ if (!needle) return [];
76
+ const seen = /* @__PURE__ */ new Set();
77
+ return rows.filter((row) => {
78
+ const body = row.message?.body;
79
+ if (row.kind !== "message" || !body || seen.has(row.messageId) || !body.toLowerCase().includes(needle)) return false;
80
+ seen.add(row.messageId);
81
+ return true;
82
+ });
83
+ }
84
+ export {
85
+ conversations,
86
+ editArgs,
87
+ intentLabel,
88
+ postArgs,
89
+ searchHeld,
90
+ settledByRows,
91
+ threadBubbles
92
+ };