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.
- package/.next/BUILD_ID +1 -0
- package/.next/app-build-manifest.json +49 -0
- package/.next/app-path-routes-manifest.json +1 -0
- package/.next/build-manifest.json +32 -0
- package/.next/export-marker.json +1 -0
- package/.next/images-manifest.json +1 -0
- package/.next/next-minimal-server.js.nft.json +1 -0
- package/.next/next-server.js.nft.json +1 -0
- package/.next/package.json +1 -0
- package/.next/prerender-manifest.json +1 -0
- package/.next/react-loadable-manifest.json +8 -0
- package/.next/required-server-files.json +1 -0
- package/.next/routes-manifest.json +1 -0
- package/.next/server/app/_not-found/page.js +1 -0
- package/.next/server/app/_not-found/page.js.nft.json +1 -0
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -0
- package/.next/server/app/_not-found.html +1 -0
- package/.next/server/app/_not-found.meta +6 -0
- package/.next/server/app/_not-found.rsc +9 -0
- package/.next/server/app/api/auth/route.js +1 -0
- package/.next/server/app/api/auth/route.js.nft.json +1 -0
- package/.next/server/app/api/beads/route.js +8 -0
- package/.next/server/app/api/beads/route.js.nft.json +1 -0
- package/.next/server/app/api/beads/stream/route.js +10 -0
- package/.next/server/app/api/beads/stream/route.js.nft.json +1 -0
- package/.next/server/app/api/beads.body +1 -0
- package/.next/server/app/api/beads.meta +1 -0
- package/.next/server/app/api/config/route.js +8 -0
- package/.next/server/app/api/config/route.js.nft.json +1 -0
- package/.next/server/app/api/docs/page.js +120 -0
- package/.next/server/app/api/docs/page.js.nft.json +1 -0
- package/.next/server/app/api/docs/page_client-reference-manifest.js +1 -0
- package/.next/server/app/api/docs.html +120 -0
- package/.next/server/app/api/docs.meta +5 -0
- package/.next/server/app/api/docs.rsc +70 -0
- package/.next/server/app/api/login/route.js +1 -0
- package/.next/server/app/api/login/route.js.nft.json +1 -0
- package/.next/server/app/api/logout/route.js +1 -0
- package/.next/server/app/api/logout/route.js.nft.json +1 -0
- package/.next/server/app/api/oauth/callback/route.js +1 -0
- package/.next/server/app/api/oauth/callback/route.js.nft.json +1 -0
- package/.next/server/app/api/oauth/client-metadata.json/route.js +1 -0
- package/.next/server/app/api/oauth/client-metadata.json/route.js.nft.json +1 -0
- package/.next/server/app/api/oauth/jwks.json/route.js +1 -0
- package/.next/server/app/api/oauth/jwks.json/route.js.nft.json +1 -0
- package/.next/server/app/api/records/route.js +1 -0
- package/.next/server/app/api/records/route.js.nft.json +1 -0
- package/.next/server/app/api/status/route.js +1 -0
- package/.next/server/app/api/status/route.js.nft.json +1 -0
- package/.next/server/app/api/v1/graph/route.js +1 -0
- package/.next/server/app/api/v1/graph/route.js.nft.json +1 -0
- package/.next/server/app/api/v1/issues/[id]/route.js +1 -0
- package/.next/server/app/api/v1/issues/[id]/route.js.nft.json +1 -0
- package/.next/server/app/api/v1/ready/route.js +1 -0
- package/.next/server/app/api/v1/ready/route.js.nft.json +1 -0
- package/.next/server/app/index.html +1 -0
- package/.next/server/app/index.meta +5 -0
- package/.next/server/app/index.rsc +9 -0
- package/.next/server/app/login/page.js +1 -0
- package/.next/server/app/login/page.js.nft.json +1 -0
- package/.next/server/app/login/page_client-reference-manifest.js +1 -0
- package/.next/server/app/login.html +1 -0
- package/.next/server/app/login.meta +5 -0
- package/.next/server/app/login.rsc +9 -0
- package/.next/server/app/opengraph-image.png/route.js +1 -0
- package/.next/server/app/opengraph-image.png/route.js.nft.json +1 -0
- package/.next/server/app/opengraph-image.png.body +0 -0
- package/.next/server/app/opengraph-image.png.meta +1 -0
- package/.next/server/app/page.js +24 -0
- package/.next/server/app/page.js.nft.json +1 -0
- package/.next/server/app/page_client-reference-manifest.js +1 -0
- package/.next/server/app/twitter-image.png/route.js +1 -0
- package/.next/server/app/twitter-image.png/route.js.nft.json +1 -0
- package/.next/server/app/twitter-image.png.body +0 -0
- package/.next/server/app/twitter-image.png.meta +1 -0
- package/.next/server/app-paths-manifest.json +22 -0
- package/.next/server/chunks/247.js +12 -0
- package/.next/server/chunks/29.js +1 -0
- package/.next/server/chunks/343.js +1 -0
- package/.next/server/chunks/460.js +12 -0
- package/.next/server/chunks/533.js +38 -0
- package/.next/server/chunks/542.js +27 -0
- package/.next/server/chunks/590.js +6 -0
- package/.next/server/chunks/615.js +15 -0
- package/.next/server/chunks/696.js +25 -0
- package/.next/server/chunks/719.js +2 -0
- package/.next/server/chunks/739.js +1 -0
- package/.next/server/chunks/950.js +2 -0
- package/.next/server/chunks/font-manifest.json +1 -0
- package/.next/server/edge-runtime-webpack.js +2 -0
- package/.next/server/edge-runtime-webpack.js.map +1 -0
- package/.next/server/font-manifest.json +1 -0
- package/.next/server/functions-config-manifest.json +1 -0
- package/.next/server/interception-route-rewrite-manifest.js +1 -0
- package/.next/server/middleware-build-manifest.js +1 -0
- package/.next/server/middleware-manifest.json +32 -0
- package/.next/server/middleware-react-loadable-manifest.js +1 -0
- package/.next/server/middleware.js +14 -0
- package/.next/server/middleware.js.map +1 -0
- package/.next/server/next-font-manifest.js +1 -0
- package/.next/server/next-font-manifest.json +1 -0
- package/.next/server/pages/404.html +1 -0
- package/.next/server/pages/500.html +1 -0
- package/.next/server/pages/_app.js +1 -0
- package/.next/server/pages/_app.js.nft.json +1 -0
- package/.next/server/pages/_document.js +1 -0
- package/.next/server/pages/_document.js.nft.json +1 -0
- package/.next/server/pages/_error.js +1 -0
- package/.next/server/pages/_error.js.nft.json +1 -0
- package/.next/server/pages-manifest.json +1 -0
- package/.next/server/server-reference-manifest.js +1 -0
- package/.next/server/server-reference-manifest.json +1 -0
- package/.next/server/webpack-runtime.js +1 -0
- package/.next/static/chunks/149.a3e3a5dc03e21086.js +1 -0
- package/.next/static/chunks/2200cc46-7c93a0e00b0bb825.js +1 -0
- package/.next/static/chunks/788-aa413085174e935a.js +1 -0
- package/.next/static/chunks/945-3ff1d381a0af1ecd.js +2 -0
- package/.next/static/chunks/971-bb44d52bcd9ee2a9.js +1 -0
- package/.next/static/chunks/app/_not-found/page-200b7a7a6cfc29df.js +1 -0
- package/.next/static/chunks/app/api/docs/page-1dc18f40154cdce6.js +1 -0
- package/.next/static/chunks/app/layout-13e3cdaaa416edb6.js +1 -0
- package/.next/static/chunks/app/login/page-60d930d64f021753.js +1 -0
- package/.next/static/chunks/app/not-found-ae1139bed2018dd8.js +1 -0
- package/.next/static/chunks/app/page-583300dd8af66e5a.js +1 -0
- package/.next/static/chunks/framework-6e06c675866dc992.js +1 -0
- package/.next/static/chunks/main-app-8b0c4a1007dbb7f4.js +1 -0
- package/.next/static/chunks/main-e680fb049d7426e1.js +1 -0
- package/.next/static/chunks/pages/_app-0c3037849002a4aa.js +1 -0
- package/.next/static/chunks/pages/_error-a647cd2c75dc4dc7.js +1 -0
- package/.next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
- package/.next/static/chunks/webpack-117444a4bfe51057.js +1 -0
- package/.next/static/css/8c1b520a38ba4ccd.css +3 -0
- package/.next/static/vFM69sDrBUf_9ULwPmVAE/_buildManifest.js +1 -0
- package/.next/static/vFM69sDrBUf_9ULwPmVAE/_ssgManifest.js +1 -0
- package/README.md +389 -0
- package/app/api/auth/route.ts +103 -0
- package/app/api/beads/route.ts +27 -0
- package/app/api/beads/stream/route.ts +83 -0
- package/app/api/config/route.ts +48 -0
- package/app/api/docs/page.tsx +497 -0
- package/app/api/login/route.ts +42 -0
- package/app/api/logout/route.ts +14 -0
- package/app/api/oauth/callback/route.ts +97 -0
- package/app/api/oauth/client-metadata.json/route.ts +33 -0
- package/app/api/oauth/jwks.json/route.ts +32 -0
- package/app/api/records/route.ts +168 -0
- package/app/api/status/route.ts +25 -0
- package/app/api/v1/graph/route.ts +251 -0
- package/app/api/v1/issues/[id]/route.ts +158 -0
- package/app/api/v1/ready/route.ts +229 -0
- package/app/globals.css +230 -0
- package/app/layout.tsx +51 -0
- package/app/login/page.tsx +164 -0
- package/app/not-found.tsx +91 -0
- package/app/opengraph-image.png +0 -0
- package/app/page.tsx +2041 -0
- package/app/twitter-image.png +0 -0
- package/bin/heartbeads.mjs +225 -0
- package/components/ActivityItem.tsx +326 -0
- package/components/ActivityOverlay.tsx +125 -0
- package/components/ActivityPanel.tsx +345 -0
- package/components/AllCommentsPanel.tsx +270 -0
- package/components/AuthButton.tsx +202 -0
- package/components/BeadTooltip.tsx +246 -0
- package/components/BeadsGraph.tsx +2493 -0
- package/components/BeadsLogo.tsx +94 -0
- package/components/CommentTooltip.tsx +338 -0
- package/components/ContextMenu.tsx +272 -0
- package/components/DescriptionModal.tsx +595 -0
- package/components/GraphStats.tsx +121 -0
- package/components/HeartIcon.tsx +33 -0
- package/components/HelpPanel.tsx +339 -0
- package/components/MobileActionSheet.tsx +255 -0
- package/components/NodeDetail.tsx +793 -0
- package/components/SettingsModal.tsx +315 -0
- package/components/StatusLegend.tsx +99 -0
- package/components/TimelineBar.tsx +116 -0
- package/components/TutorialOverlay.tsx +235 -0
- package/hooks/useBeadsComments.ts +81 -0
- package/hooks/useIsMobile.ts +19 -0
- package/lib/activity.ts +377 -0
- package/lib/agent.ts +29 -0
- package/lib/api-helpers.ts +46 -0
- package/lib/auth/client.ts +221 -0
- package/lib/auth.tsx +159 -0
- package/lib/comments.ts +413 -0
- package/lib/diff-beads.ts +128 -0
- package/lib/discover.ts +228 -0
- package/lib/env.ts +33 -0
- package/lib/gate.ts +55 -0
- package/lib/parse-beads.ts +234 -0
- package/lib/session.ts +52 -0
- package/lib/settings.ts +42 -0
- package/lib/timeline.ts +138 -0
- package/lib/tts.ts +397 -0
- package/lib/types.ts +271 -0
- package/lib/utils.ts +48 -0
- package/lib/watch-beads.ts +97 -0
- package/next.config.mjs +4 -0
- package/package.json +81 -0
- package/postcss.config.mjs +9 -0
- package/public/image.png +0 -0
- package/scripts/generate-jwk.js +38 -0
- package/tailwind.config.ts +41 -0
- package/tsconfig.json +24 -0
package/lib/comments.ts
ADDED
|
@@ -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
|
+
}
|