heartbeads 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/.next/BUILD_ID +1 -0
  2. package/.next/app-build-manifest.json +49 -0
  3. package/.next/app-path-routes-manifest.json +1 -0
  4. package/.next/build-manifest.json +32 -0
  5. package/.next/export-marker.json +1 -0
  6. package/.next/images-manifest.json +1 -0
  7. package/.next/next-minimal-server.js.nft.json +1 -0
  8. package/.next/next-server.js.nft.json +1 -0
  9. package/.next/package.json +1 -0
  10. package/.next/prerender-manifest.json +1 -0
  11. package/.next/react-loadable-manifest.json +8 -0
  12. package/.next/required-server-files.json +1 -0
  13. package/.next/routes-manifest.json +1 -0
  14. package/.next/server/app/_not-found/page.js +1 -0
  15. package/.next/server/app/_not-found/page.js.nft.json +1 -0
  16. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -0
  17. package/.next/server/app/_not-found.html +1 -0
  18. package/.next/server/app/_not-found.meta +6 -0
  19. package/.next/server/app/_not-found.rsc +9 -0
  20. package/.next/server/app/api/auth/route.js +1 -0
  21. package/.next/server/app/api/auth/route.js.nft.json +1 -0
  22. package/.next/server/app/api/beads/route.js +8 -0
  23. package/.next/server/app/api/beads/route.js.nft.json +1 -0
  24. package/.next/server/app/api/beads/stream/route.js +10 -0
  25. package/.next/server/app/api/beads/stream/route.js.nft.json +1 -0
  26. package/.next/server/app/api/beads.body +1 -0
  27. package/.next/server/app/api/beads.meta +1 -0
  28. package/.next/server/app/api/config/route.js +8 -0
  29. package/.next/server/app/api/config/route.js.nft.json +1 -0
  30. package/.next/server/app/api/docs/page.js +120 -0
  31. package/.next/server/app/api/docs/page.js.nft.json +1 -0
  32. package/.next/server/app/api/docs/page_client-reference-manifest.js +1 -0
  33. package/.next/server/app/api/docs.html +120 -0
  34. package/.next/server/app/api/docs.meta +5 -0
  35. package/.next/server/app/api/docs.rsc +70 -0
  36. package/.next/server/app/api/login/route.js +1 -0
  37. package/.next/server/app/api/login/route.js.nft.json +1 -0
  38. package/.next/server/app/api/logout/route.js +1 -0
  39. package/.next/server/app/api/logout/route.js.nft.json +1 -0
  40. package/.next/server/app/api/oauth/callback/route.js +1 -0
  41. package/.next/server/app/api/oauth/callback/route.js.nft.json +1 -0
  42. package/.next/server/app/api/oauth/client-metadata.json/route.js +1 -0
  43. package/.next/server/app/api/oauth/client-metadata.json/route.js.nft.json +1 -0
  44. package/.next/server/app/api/oauth/jwks.json/route.js +1 -0
  45. package/.next/server/app/api/oauth/jwks.json/route.js.nft.json +1 -0
  46. package/.next/server/app/api/records/route.js +1 -0
  47. package/.next/server/app/api/records/route.js.nft.json +1 -0
  48. package/.next/server/app/api/status/route.js +1 -0
  49. package/.next/server/app/api/status/route.js.nft.json +1 -0
  50. package/.next/server/app/api/v1/graph/route.js +1 -0
  51. package/.next/server/app/api/v1/graph/route.js.nft.json +1 -0
  52. package/.next/server/app/api/v1/issues/[id]/route.js +1 -0
  53. package/.next/server/app/api/v1/issues/[id]/route.js.nft.json +1 -0
  54. package/.next/server/app/api/v1/ready/route.js +1 -0
  55. package/.next/server/app/api/v1/ready/route.js.nft.json +1 -0
  56. package/.next/server/app/index.html +1 -0
  57. package/.next/server/app/index.meta +5 -0
  58. package/.next/server/app/index.rsc +9 -0
  59. package/.next/server/app/login/page.js +1 -0
  60. package/.next/server/app/login/page.js.nft.json +1 -0
  61. package/.next/server/app/login/page_client-reference-manifest.js +1 -0
  62. package/.next/server/app/login.html +1 -0
  63. package/.next/server/app/login.meta +5 -0
  64. package/.next/server/app/login.rsc +9 -0
  65. package/.next/server/app/opengraph-image.png/route.js +1 -0
  66. package/.next/server/app/opengraph-image.png/route.js.nft.json +1 -0
  67. package/.next/server/app/opengraph-image.png.body +0 -0
  68. package/.next/server/app/opengraph-image.png.meta +1 -0
  69. package/.next/server/app/page.js +24 -0
  70. package/.next/server/app/page.js.nft.json +1 -0
  71. package/.next/server/app/page_client-reference-manifest.js +1 -0
  72. package/.next/server/app/twitter-image.png/route.js +1 -0
  73. package/.next/server/app/twitter-image.png/route.js.nft.json +1 -0
  74. package/.next/server/app/twitter-image.png.body +0 -0
  75. package/.next/server/app/twitter-image.png.meta +1 -0
  76. package/.next/server/app-paths-manifest.json +22 -0
  77. package/.next/server/chunks/247.js +12 -0
  78. package/.next/server/chunks/29.js +1 -0
  79. package/.next/server/chunks/343.js +1 -0
  80. package/.next/server/chunks/460.js +12 -0
  81. package/.next/server/chunks/533.js +38 -0
  82. package/.next/server/chunks/542.js +27 -0
  83. package/.next/server/chunks/590.js +6 -0
  84. package/.next/server/chunks/615.js +15 -0
  85. package/.next/server/chunks/696.js +25 -0
  86. package/.next/server/chunks/719.js +2 -0
  87. package/.next/server/chunks/739.js +1 -0
  88. package/.next/server/chunks/950.js +2 -0
  89. package/.next/server/chunks/font-manifest.json +1 -0
  90. package/.next/server/edge-runtime-webpack.js +2 -0
  91. package/.next/server/edge-runtime-webpack.js.map +1 -0
  92. package/.next/server/font-manifest.json +1 -0
  93. package/.next/server/functions-config-manifest.json +1 -0
  94. package/.next/server/interception-route-rewrite-manifest.js +1 -0
  95. package/.next/server/middleware-build-manifest.js +1 -0
  96. package/.next/server/middleware-manifest.json +32 -0
  97. package/.next/server/middleware-react-loadable-manifest.js +1 -0
  98. package/.next/server/middleware.js +14 -0
  99. package/.next/server/middleware.js.map +1 -0
  100. package/.next/server/next-font-manifest.js +1 -0
  101. package/.next/server/next-font-manifest.json +1 -0
  102. package/.next/server/pages/404.html +1 -0
  103. package/.next/server/pages/500.html +1 -0
  104. package/.next/server/pages/_app.js +1 -0
  105. package/.next/server/pages/_app.js.nft.json +1 -0
  106. package/.next/server/pages/_document.js +1 -0
  107. package/.next/server/pages/_document.js.nft.json +1 -0
  108. package/.next/server/pages/_error.js +1 -0
  109. package/.next/server/pages/_error.js.nft.json +1 -0
  110. package/.next/server/pages-manifest.json +1 -0
  111. package/.next/server/server-reference-manifest.js +1 -0
  112. package/.next/server/server-reference-manifest.json +1 -0
  113. package/.next/server/webpack-runtime.js +1 -0
  114. package/.next/static/chunks/149.a3e3a5dc03e21086.js +1 -0
  115. package/.next/static/chunks/2200cc46-7c93a0e00b0bb825.js +1 -0
  116. package/.next/static/chunks/788-aa413085174e935a.js +1 -0
  117. package/.next/static/chunks/945-3ff1d381a0af1ecd.js +2 -0
  118. package/.next/static/chunks/971-bb44d52bcd9ee2a9.js +1 -0
  119. package/.next/static/chunks/app/_not-found/page-200b7a7a6cfc29df.js +1 -0
  120. package/.next/static/chunks/app/api/docs/page-1dc18f40154cdce6.js +1 -0
  121. package/.next/static/chunks/app/layout-13e3cdaaa416edb6.js +1 -0
  122. package/.next/static/chunks/app/login/page-60d930d64f021753.js +1 -0
  123. package/.next/static/chunks/app/not-found-ae1139bed2018dd8.js +1 -0
  124. package/.next/static/chunks/app/page-583300dd8af66e5a.js +1 -0
  125. package/.next/static/chunks/framework-6e06c675866dc992.js +1 -0
  126. package/.next/static/chunks/main-app-8b0c4a1007dbb7f4.js +1 -0
  127. package/.next/static/chunks/main-e680fb049d7426e1.js +1 -0
  128. package/.next/static/chunks/pages/_app-0c3037849002a4aa.js +1 -0
  129. package/.next/static/chunks/pages/_error-a647cd2c75dc4dc7.js +1 -0
  130. package/.next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  131. package/.next/static/chunks/webpack-117444a4bfe51057.js +1 -0
  132. package/.next/static/css/8c1b520a38ba4ccd.css +3 -0
  133. package/.next/static/vFM69sDrBUf_9ULwPmVAE/_buildManifest.js +1 -0
  134. package/.next/static/vFM69sDrBUf_9ULwPmVAE/_ssgManifest.js +1 -0
  135. package/README.md +389 -0
  136. package/app/api/auth/route.ts +103 -0
  137. package/app/api/beads/route.ts +27 -0
  138. package/app/api/beads/stream/route.ts +83 -0
  139. package/app/api/config/route.ts +48 -0
  140. package/app/api/docs/page.tsx +497 -0
  141. package/app/api/login/route.ts +42 -0
  142. package/app/api/logout/route.ts +14 -0
  143. package/app/api/oauth/callback/route.ts +97 -0
  144. package/app/api/oauth/client-metadata.json/route.ts +33 -0
  145. package/app/api/oauth/jwks.json/route.ts +32 -0
  146. package/app/api/records/route.ts +168 -0
  147. package/app/api/status/route.ts +25 -0
  148. package/app/api/v1/graph/route.ts +251 -0
  149. package/app/api/v1/issues/[id]/route.ts +158 -0
  150. package/app/api/v1/ready/route.ts +229 -0
  151. package/app/globals.css +230 -0
  152. package/app/layout.tsx +51 -0
  153. package/app/login/page.tsx +164 -0
  154. package/app/not-found.tsx +91 -0
  155. package/app/opengraph-image.png +0 -0
  156. package/app/page.tsx +2041 -0
  157. package/app/twitter-image.png +0 -0
  158. package/bin/heartbeads.mjs +225 -0
  159. package/components/ActivityItem.tsx +326 -0
  160. package/components/ActivityOverlay.tsx +125 -0
  161. package/components/ActivityPanel.tsx +345 -0
  162. package/components/AllCommentsPanel.tsx +270 -0
  163. package/components/AuthButton.tsx +202 -0
  164. package/components/BeadTooltip.tsx +246 -0
  165. package/components/BeadsGraph.tsx +2493 -0
  166. package/components/BeadsLogo.tsx +94 -0
  167. package/components/CommentTooltip.tsx +338 -0
  168. package/components/ContextMenu.tsx +272 -0
  169. package/components/DescriptionModal.tsx +595 -0
  170. package/components/GraphStats.tsx +121 -0
  171. package/components/HeartIcon.tsx +33 -0
  172. package/components/HelpPanel.tsx +339 -0
  173. package/components/MobileActionSheet.tsx +255 -0
  174. package/components/NodeDetail.tsx +793 -0
  175. package/components/SettingsModal.tsx +315 -0
  176. package/components/StatusLegend.tsx +99 -0
  177. package/components/TimelineBar.tsx +116 -0
  178. package/components/TutorialOverlay.tsx +235 -0
  179. package/hooks/useBeadsComments.ts +81 -0
  180. package/hooks/useIsMobile.ts +19 -0
  181. package/lib/activity.ts +377 -0
  182. package/lib/agent.ts +29 -0
  183. package/lib/api-helpers.ts +46 -0
  184. package/lib/auth/client.ts +221 -0
  185. package/lib/auth.tsx +159 -0
  186. package/lib/comments.ts +413 -0
  187. package/lib/diff-beads.ts +128 -0
  188. package/lib/discover.ts +228 -0
  189. package/lib/env.ts +33 -0
  190. package/lib/gate.ts +55 -0
  191. package/lib/parse-beads.ts +234 -0
  192. package/lib/session.ts +52 -0
  193. package/lib/settings.ts +42 -0
  194. package/lib/timeline.ts +138 -0
  195. package/lib/tts.ts +397 -0
  196. package/lib/types.ts +271 -0
  197. package/lib/utils.ts +48 -0
  198. package/lib/watch-beads.ts +97 -0
  199. package/next.config.mjs +4 -0
  200. package/package.json +81 -0
  201. package/postcss.config.mjs +9 -0
  202. package/public/image.png +0 -0
  203. package/scripts/generate-jwk.js +38 -0
  204. package/tailwind.config.ts +41 -0
  205. package/tsconfig.json +24 -0
@@ -0,0 +1,413 @@
1
+ /**
2
+ * Shared comment-fetching logic for beads.
3
+ *
4
+ * Fetches comments and likes from the Hypergoat GraphQL indexer,
5
+ * resolves Bluesky profiles, builds threaded comment trees, and
6
+ * detects claim info. Used by both the React hook (useBeadsComments)
7
+ * and the public API routes (/api/v1/*).
8
+ */
9
+
10
+ // ============================================================================
11
+ // Types
12
+ // ============================================================================
13
+
14
+ export interface BeadsLike {
15
+ did: string;
16
+ handle: string;
17
+ displayName?: string;
18
+ avatar?: string;
19
+ createdAt: string;
20
+ uri: string; // AT-URI of the like record
21
+ rkey: string;
22
+ }
23
+
24
+ export interface BeadsComment {
25
+ did: string;
26
+ handle: string;
27
+ displayName?: string;
28
+ avatar?: string;
29
+ text: string;
30
+ createdAt: string;
31
+ uri: string; // AT-URI of the comment record
32
+ rkey: string;
33
+ nodeId: string; // The beads issue ID this comment targets
34
+ replyTo?: string; // AT-URI of parent comment (for threading)
35
+ likes: BeadsLike[]; // Likes on this comment
36
+ replies: BeadsComment[]; // Nested child comments (built during assembly)
37
+ }
38
+
39
+ export interface ClaimInfo {
40
+ handle: string;
41
+ did: string;
42
+ displayName?: string;
43
+ avatar?: string;
44
+ claimedAt: string;
45
+ }
46
+
47
+ export interface FetchCommentsResult {
48
+ /** Map from node ID -> threaded comment trees (root comments with nested replies) */
49
+ commentsByNode: Map<string, BeadsComment[]>;
50
+ /** Map from node ID -> total comment count (including replies) */
51
+ commentedNodeIds: Map<string, number>;
52
+ /** Flat list of ALL comments (including replies), newest-first */
53
+ allComments: BeadsComment[];
54
+ }
55
+
56
+ // ============================================================================
57
+ // Profile resolution (cached, deduplicated)
58
+ // ============================================================================
59
+
60
+ interface ResolvedProfile {
61
+ did: string;
62
+ handle: string;
63
+ displayName?: string;
64
+ avatar?: string;
65
+ }
66
+
67
+ const profileCache = new Map<string, ResolvedProfile>();
68
+ const profileInflight = new Map<string, Promise<ResolvedProfile>>();
69
+
70
+ async function resolveProfile(did: string): Promise<ResolvedProfile> {
71
+ const cached = profileCache.get(did);
72
+ if (cached) return cached;
73
+
74
+ const inflight = profileInflight.get(did);
75
+ if (inflight) return inflight;
76
+
77
+ const promise = (async () => {
78
+ try {
79
+ const res = await fetch(
80
+ `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}`
81
+ );
82
+ if (!res.ok) throw new Error(`Profile fetch failed: ${res.status}`);
83
+ const data = await res.json();
84
+ const profile: ResolvedProfile = {
85
+ did,
86
+ handle: data.handle || did,
87
+ displayName: data.displayName || undefined,
88
+ avatar: data.avatar || undefined,
89
+ };
90
+ profileCache.set(did, profile);
91
+ return profile;
92
+ } catch {
93
+ // Fallback: use DID as handle
94
+ const fallback: ResolvedProfile = {
95
+ did,
96
+ handle: did.slice(0, 20) + "...",
97
+ };
98
+ profileCache.set(did, fallback);
99
+ return fallback;
100
+ } finally {
101
+ profileInflight.delete(did);
102
+ }
103
+ })();
104
+
105
+ profileInflight.set(did, promise);
106
+ return promise;
107
+ }
108
+
109
+ // ============================================================================
110
+ // Hypergoat GraphQL fetching
111
+ // ============================================================================
112
+
113
+ const INDEXER_URL =
114
+ "https://hypergoat-app-production.up.railway.app/graphql";
115
+
116
+ const FETCH_RECORDS_QUERY = `
117
+ query FetchRecords($collection: String!, $first: Int, $after: String) {
118
+ records(collection: $collection, first: $first, after: $after) {
119
+ edges {
120
+ node {
121
+ cid
122
+ collection
123
+ did
124
+ rkey
125
+ uri
126
+ value
127
+ }
128
+ }
129
+ pageInfo {
130
+ hasNextPage
131
+ endCursor
132
+ }
133
+ }
134
+ }
135
+ `;
136
+
137
+ interface IndexerRecord {
138
+ cid: string;
139
+ collection: string;
140
+ did: string;
141
+ rkey: string;
142
+ uri: string;
143
+ value: Record<string, unknown>;
144
+ }
145
+
146
+ interface IndexerResponse {
147
+ data?: {
148
+ records?: {
149
+ edges: Array<{ node: IndexerRecord }>;
150
+ pageInfo: { hasNextPage: boolean; endCursor?: string };
151
+ };
152
+ };
153
+ errors?: Array<{ message: string }>;
154
+ }
155
+
156
+ async function fetchRecordsByCollection(
157
+ collection: string
158
+ ): Promise<IndexerRecord[]> {
159
+ const allRecords: IndexerRecord[] = [];
160
+ let cursor: string | undefined;
161
+ let hasMore = true;
162
+ let pages = 0;
163
+ const MAX_PAGES = 5; // Safety limit
164
+
165
+ while (hasMore && pages < MAX_PAGES) {
166
+ const res = await fetch(INDEXER_URL, {
167
+ method: "POST",
168
+ headers: { "Content-Type": "application/json" },
169
+ body: JSON.stringify({
170
+ query: FETCH_RECORDS_QUERY,
171
+ variables: {
172
+ collection,
173
+ first: 100,
174
+ after: cursor,
175
+ },
176
+ }),
177
+ });
178
+
179
+ if (!res.ok) {
180
+ throw new Error(`Indexer fetch failed: ${res.status}`);
181
+ }
182
+
183
+ const json: IndexerResponse = await res.json();
184
+
185
+ if (json.errors?.length) {
186
+ throw new Error(json.errors[0].message);
187
+ }
188
+
189
+ const records = json.data?.records;
190
+ if (!records) break;
191
+
192
+ for (const edge of records.edges) {
193
+ allRecords.push(edge.node);
194
+ }
195
+
196
+ hasMore = records.pageInfo.hasNextPage;
197
+ cursor = records.pageInfo.endCursor;
198
+ pages++;
199
+ }
200
+
201
+ return allRecords;
202
+ }
203
+
204
+ // ============================================================================
205
+ // Core: fetch and process comments
206
+ // ============================================================================
207
+
208
+ /**
209
+ * Fetch all beads comments and likes from Hypergoat, resolve profiles,
210
+ * build threaded trees, and group by node.
211
+ *
212
+ * This is the pure data-fetching function used by both:
213
+ * - `hooks/useBeadsComments.ts` (client-side React hook)
214
+ * - `app/api/v1/*` routes (server-side API)
215
+ */
216
+ export async function fetchBeadsComments(): Promise<FetchCommentsResult> {
217
+ // 1. Fetch comments and likes in parallel from Hypergoat
218
+ const [commentRecords, likeRecords] = await Promise.all([
219
+ fetchRecordsByCollection("org.impactindexer.review.comment"),
220
+ fetchRecordsByCollection("org.impactindexer.review.like"),
221
+ ]);
222
+
223
+ // 2. Filter to only beads-targeted comments
224
+ const beadsCommentRecords = commentRecords.filter((r) => {
225
+ const value = r.value as Record<string, unknown>;
226
+ const subject = value.subject as
227
+ | { uri?: string; type?: string }
228
+ | undefined;
229
+ return (
230
+ subject?.uri &&
231
+ typeof subject.uri === "string" &&
232
+ subject.uri.startsWith("beads:")
233
+ );
234
+ });
235
+
236
+ // 3. Build likes-by-comment-URI map
237
+ const likesByCommentUri = new Map<string, IndexerRecord[]>();
238
+ for (const likeRecord of likeRecords) {
239
+ const value = likeRecord.value as Record<string, unknown>;
240
+ const subject = value.subject as
241
+ | { uri?: string; type?: string }
242
+ | undefined;
243
+ if (
244
+ subject?.uri &&
245
+ typeof subject.uri === "string" &&
246
+ subject.uri.startsWith("at://")
247
+ ) {
248
+ const existing = likesByCommentUri.get(subject.uri) || [];
249
+ existing.push(likeRecord);
250
+ likesByCommentUri.set(subject.uri, existing);
251
+ }
252
+ }
253
+
254
+ // 4. Resolve profiles for all unique DIDs (commenters + likers)
255
+ const allDids = new Set<string>();
256
+ for (const r of beadsCommentRecords) allDids.add(r.did);
257
+ for (const r of likeRecords) allDids.add(r.did);
258
+
259
+ const profiles = await Promise.all(
260
+ [...allDids].map((did) => resolveProfile(did))
261
+ );
262
+ const profileMap = new Map(profiles.map((p) => [p.did, p]));
263
+
264
+ // 5. Build flat comment list with likes attached
265
+ const allFlat: BeadsComment[] = [];
266
+
267
+ for (const record of beadsCommentRecords) {
268
+ const value = record.value as Record<string, unknown>;
269
+ const subject = value.subject as { uri: string };
270
+ const nodeId = subject.uri.replace(/^beads:/, "");
271
+ const text = (value.text as string) || "";
272
+ const createdAt = (value.createdAt as string) || "";
273
+ const replyTo = (value.replyTo as string) || undefined;
274
+ const profile = profileMap.get(record.did);
275
+
276
+ // Build likes for this comment
277
+ const likeRecordsForComment =
278
+ likesByCommentUri.get(record.uri) || [];
279
+ const likes: BeadsLike[] = likeRecordsForComment.map((lr) => {
280
+ const lp = profileMap.get(lr.did);
281
+ const lv = lr.value as Record<string, unknown>;
282
+ return {
283
+ did: lr.did,
284
+ handle: lp?.handle || lr.did.slice(0, 20) + "...",
285
+ displayName: lp?.displayName,
286
+ avatar: lp?.avatar,
287
+ createdAt: (lv.createdAt as string) || "",
288
+ uri: lr.uri,
289
+ rkey: lr.rkey,
290
+ };
291
+ });
292
+
293
+ const comment: BeadsComment = {
294
+ did: record.did,
295
+ handle: profile?.handle || record.did.slice(0, 20) + "...",
296
+ displayName: profile?.displayName,
297
+ avatar: profile?.avatar,
298
+ text,
299
+ createdAt,
300
+ uri: record.uri,
301
+ rkey: record.rkey,
302
+ nodeId,
303
+ replyTo,
304
+ likes,
305
+ replies: [], // Will be filled during tree assembly
306
+ };
307
+
308
+ allFlat.push(comment);
309
+ }
310
+
311
+ // 6. Build thread trees grouped by node
312
+
313
+ // Index all comments by URI for tree assembly
314
+ const commentByUri = new Map<string, BeadsComment>();
315
+ for (const comment of allFlat) {
316
+ commentByUri.set(comment.uri, comment);
317
+ }
318
+
319
+ // Assemble threads: attach replies to parents
320
+ const rootComments: BeadsComment[] = [];
321
+ for (const comment of allFlat) {
322
+ if (comment.replyTo) {
323
+ const parent = commentByUri.get(comment.replyTo);
324
+ if (parent) {
325
+ parent.replies.push(comment);
326
+ continue; // Don't add as root
327
+ }
328
+ // Parent not found — treat as root comment
329
+ }
330
+ rootComments.push(comment);
331
+ }
332
+
333
+ // Sort: root comments newest-first, replies oldest-first (chronological)
334
+ rootComments.sort(
335
+ (a, b) =>
336
+ new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
337
+ );
338
+
339
+ function sortRepliesRecursive(comments: BeadsComment[]) {
340
+ for (const c of comments) {
341
+ c.replies.sort(
342
+ (a, b) =>
343
+ new Date(a.createdAt).getTime() -
344
+ new Date(b.createdAt).getTime()
345
+ );
346
+ sortRepliesRecursive(c.replies);
347
+ }
348
+ }
349
+ sortRepliesRecursive(rootComments);
350
+
351
+ // Group root comments by nodeId
352
+ const commentsByNode = new Map<string, BeadsComment[]>();
353
+ for (const comment of rootComments) {
354
+ const existing = commentsByNode.get(comment.nodeId) || [];
355
+ existing.push(comment);
356
+ commentsByNode.set(comment.nodeId, existing);
357
+ }
358
+
359
+ // 7. Build counts (total comments including replies per node)
360
+ const commentedNodeIds = new Map<string, number>();
361
+ for (const comment of allFlat) {
362
+ commentedNodeIds.set(
363
+ comment.nodeId,
364
+ (commentedNodeIds.get(comment.nodeId) || 0) + 1
365
+ );
366
+ }
367
+
368
+ // 8. Build allComments flat list (newest-first)
369
+ const allComments = [...allFlat].sort(
370
+ (a, b) =>
371
+ new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
372
+ );
373
+
374
+ return { commentsByNode, commentedNodeIds, allComments };
375
+ }
376
+
377
+ // ============================================================================
378
+ // Claim detection
379
+ // ============================================================================
380
+
381
+ /**
382
+ * Extract claim info from comments.
383
+ * A claim comment has text starting with "@" and no spaces (e.g. "@alice.bsky.social").
384
+ * First claim per node wins.
385
+ */
386
+ export function getClaimedNodes(
387
+ allComments: BeadsComment[]
388
+ ): Map<string, ClaimInfo> {
389
+ const claims = new Map<string, ClaimInfo>();
390
+
391
+ // Sort oldest-first so the first claim wins
392
+ const sorted = [...allComments].sort(
393
+ (a, b) =>
394
+ new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
395
+ );
396
+
397
+ for (const comment of sorted) {
398
+ if (claims.has(comment.nodeId)) continue;
399
+
400
+ const text = comment.text.trim();
401
+ if (text.startsWith("@") && text.indexOf(" ") === -1) {
402
+ claims.set(comment.nodeId, {
403
+ handle: comment.handle,
404
+ did: comment.did,
405
+ displayName: comment.displayName,
406
+ avatar: comment.avatar,
407
+ claimedAt: comment.createdAt,
408
+ });
409
+ }
410
+ }
411
+
412
+ return claims;
413
+ }
@@ -0,0 +1,128 @@
1
+ import type { BeadsApiResponse, GraphLink } from "./types";
2
+
3
+ export interface NodeChange {
4
+ field: string; // e.g. "status", "priority", "title"
5
+ from: string; // previous value (stringified)
6
+ to: string; // new value (stringified)
7
+ }
8
+
9
+ export interface BeadsDiff {
10
+ addedNodeIds: Set<string>; // IDs of nodes not in old data
11
+ removedNodeIds: Set<string>; // IDs of nodes not in new data
12
+ changedNodes: Map<string, NodeChange[]>; // ID -> list of field changes
13
+ addedLinkKeys: Set<string>; // "source->target:type" keys
14
+ removedLinkKeys: Set<string>; // "source->target:type" keys
15
+ hasChanges: boolean; // true if anything changed at all
16
+ }
17
+
18
+ /**
19
+ * Build a stable key for a link.
20
+ * Links may have string or object source/target (after force-graph mutation).
21
+ */
22
+ export function linkKey(link: GraphLink): string {
23
+ const src =
24
+ typeof link.source === "object"
25
+ ? (link.source as { id: string }).id
26
+ : link.source;
27
+ const tgt =
28
+ typeof link.target === "object"
29
+ ? (link.target as { id: string }).id
30
+ : link.target;
31
+ return `${src}->${tgt}:${link.type}`;
32
+ }
33
+
34
+ /**
35
+ * Compute the diff between old and new beads data.
36
+ * Compares nodes by ID and links by source->target:type key.
37
+ */
38
+ export function diffBeadsData(
39
+ oldData: BeadsApiResponse | null,
40
+ newData: BeadsApiResponse
41
+ ): BeadsDiff {
42
+ // If no old data, everything is "added"
43
+ if (!oldData) {
44
+ return {
45
+ addedNodeIds: new Set(newData.graphData.nodes.map((n) => n.id)),
46
+ removedNodeIds: new Set(),
47
+ changedNodes: new Map(),
48
+ addedLinkKeys: new Set(newData.graphData.links.map(linkKey)),
49
+ removedLinkKeys: new Set(),
50
+ hasChanges: true,
51
+ };
52
+ }
53
+
54
+ const oldNodeMap = new Map(
55
+ oldData.graphData.nodes.map((n) => [n.id, n])
56
+ );
57
+ const newNodeMap = new Map(
58
+ newData.graphData.nodes.map((n) => [n.id, n])
59
+ );
60
+
61
+ // Node diffs
62
+ const addedNodeIds = new Set<string>();
63
+ const removedNodeIds = new Set<string>();
64
+ const changedNodes = new Map<string, NodeChange[]>();
65
+
66
+ for (const [id, node] of newNodeMap) {
67
+ if (!oldNodeMap.has(id)) {
68
+ addedNodeIds.add(id);
69
+ } else {
70
+ const old = oldNodeMap.get(id)!;
71
+ const changes: NodeChange[] = [];
72
+ if (old.status !== node.status) {
73
+ changes.push({ field: "status", from: old.status, to: node.status });
74
+ }
75
+ if (old.priority !== node.priority) {
76
+ changes.push({
77
+ field: "priority",
78
+ from: String(old.priority),
79
+ to: String(node.priority),
80
+ });
81
+ }
82
+ if (old.title !== node.title) {
83
+ changes.push({ field: "title", from: old.title, to: node.title });
84
+ }
85
+ if ((old.owner || "") !== (node.owner || "")) {
86
+ changes.push({ field: "owner", from: old.owner || "", to: node.owner || "" });
87
+ }
88
+ if (changes.length > 0) {
89
+ changedNodes.set(id, changes);
90
+ }
91
+ }
92
+ }
93
+ for (const id of oldNodeMap.keys()) {
94
+ if (!newNodeMap.has(id)) {
95
+ removedNodeIds.add(id);
96
+ }
97
+ }
98
+
99
+ // Link diffs
100
+ const oldLinkKeys = new Set(oldData.graphData.links.map(linkKey));
101
+ const newLinkKeys = new Set(newData.graphData.links.map(linkKey));
102
+
103
+ const addedLinkKeys = new Set<string>();
104
+ const removedLinkKeys = new Set<string>();
105
+
106
+ for (const key of newLinkKeys) {
107
+ if (!oldLinkKeys.has(key)) addedLinkKeys.add(key);
108
+ }
109
+ for (const key of oldLinkKeys) {
110
+ if (!newLinkKeys.has(key)) removedLinkKeys.add(key);
111
+ }
112
+
113
+ const hasChanges =
114
+ addedNodeIds.size > 0 ||
115
+ removedNodeIds.size > 0 ||
116
+ changedNodes.size > 0 ||
117
+ addedLinkKeys.size > 0 ||
118
+ removedLinkKeys.size > 0;
119
+
120
+ return {
121
+ addedNodeIds,
122
+ removedNodeIds,
123
+ changedNodes,
124
+ addedLinkKeys,
125
+ removedLinkKeys,
126
+ hasChanges,
127
+ };
128
+ }