appview-less bluesky client
1import { type Did, type ResourceUri } from '@atcute/lexicons';
2import type { Account } from './accounts';
3import type { PostWithUri } from './at/fetch';
4import { getBlockRelationship, postsByRootUri } from './state.svelte';
5import { timestampFromCursor } from '$lib';
6
7export type ThreadPost = {
8 data: PostWithUri;
9 account: Did;
10 did: Did;
11 rkey: string;
12 parentUri: ResourceUri | null;
13 depth: number;
14 newestTime: number;
15 blockRelationship?: { userBlocked: boolean; blockedByTarget: boolean };
16 isMuted?: boolean;
17};
18
19export type Thread = {
20 rootUri: ResourceUri;
21 posts: ThreadPost[];
22 newestTime: number;
23 branchParentPost?: ThreadPost;
24};
25
26export const isOwnPost = (post: ThreadPost, accounts: Account[]) =>
27 accounts.some((account) => account.did === post.did);
28export const hasNonOwnPost = (posts: ThreadPost[], accounts: Account[]) =>
29 posts.some((post) => !isOwnPost(post, accounts));
30
31export type FilterOptions = {
32 viewOwnPosts: boolean;
33 filterReplies?: boolean;
34 filterRootsToDids?: Set<Did>;
35};
36
37type ThreadGroup = {
38 rootUri: ResourceUri;
39 posts: ThreadPost[];
40 newestTime: number;
41 isReply: boolean;
42 rootDid: Did | null;
43};
44
45export const buildThreadsFiltered = (
46 account: Did,
47 timeline: Set<ResourceUri>,
48 posts: Map<ResourceUri, PostWithUri>,
49 mutes: Set<Did>,
50 accounts: Account[],
51 opts: FilterOptions,
52 limit?: number
53): Thread[] => {
54 const blockCache = new Map<Did, { userBlocked: boolean; blockedByTarget: boolean }>();
55 const getBlockRel = (target: Did) => {
56 let rel = blockCache.get(target);
57 if (!rel) {
58 rel = getBlockRelationship(account, target);
59 blockCache.set(target, rel);
60 }
61 return rel;
62 };
63
64 // phase 1: find distinct root URIs from timeline and build groups using pre-computed index
65 const rootUrisFromTimeline = new Set<ResourceUri>();
66 for (const uri of timeline) {
67 const did = extractDidFromUri(uri);
68 if (!did) continue;
69 const data = posts.get(uri);
70 if (!data) continue;
71 const rootUri = (data.record.reply?.root.uri as ResourceUri) || uri;
72 rootUrisFromTimeline.add(rootUri);
73 }
74
75 const groups = new Map<ResourceUri, ThreadGroup>();
76 for (const rootUri of rootUrisFromTimeline) {
77 const postUris = postsByRootUri.get(rootUri);
78 if (!postUris) continue;
79
80 let group: ThreadGroup | undefined;
81
82 for (const uri of postUris) {
83 if (!timeline.has(uri)) continue;
84 const did = extractDidFromUri(uri);
85 if (!did) continue;
86 const parts = uri.split('/');
87 if (parts.length < 5) continue;
88 const rkey = parts[4];
89
90 const data = posts.get(uri);
91 if (!data) continue;
92
93 const parentUri = (data.record.reply?.parent.uri as ResourceUri) || null;
94 const isReply = !!data.record.reply;
95
96 const cursorTime = timestampFromCursor(rkey);
97 const postTime = cursorTime ? cursorTime / 1000 : new Date(data.record.createdAt).getTime();
98
99 if (!group) {
100 group = {
101 rootUri,
102 posts: [],
103 newestTime: postTime,
104 isReply,
105 rootDid: extractDidFromUri(rootUri)
106 };
107 groups.set(rootUri, group);
108 }
109
110 const blockRel = getBlockRel(did);
111 group.posts.push({
112 data,
113 account,
114 did,
115 rkey,
116 parentUri,
117 depth: 0,
118 newestTime: postTime,
119 blockRelationship: blockRel,
120 isMuted: mutes.has(did)
121 });
122
123 if (postTime > group.newestTime) group.newestTime = postTime;
124 }
125 }
126
127 // phase 2: sort groups by newest time descending
128 const sortedGroups = Array.from(groups.values()).sort((a, b) => b.newestTime - a.newestTime);
129
130 // phase 3: process groups with pre-filtering and early exit
131 const threads: Thread[] = [];
132
133 const shouldIncludeGroup = (group: ThreadGroup): boolean => {
134 if (opts.filterReplies && group.isReply) return false;
135
136 if (opts.filterRootsToDids) {
137 if (
138 group.rootDid &&
139 !opts.filterRootsToDids.has(group.rootDid) &&
140 !accounts.some((a) => a.did === group.rootDid)
141 ) {
142 return false;
143 }
144 }
145
146 return true;
147 };
148
149 const processGroup = (group: ThreadGroup): Thread[] => {
150 const groupPosts = group.posts;
151 const uriToPost = new Map(groupPosts.map((p) => [p.data.uri, p]));
152 const childrenMap = new Map<ResourceUri | null, ThreadPost[]>();
153
154 for (const post of groupPosts) {
155 let depth = 0;
156 let currentUri = post.parentUri;
157 while (currentUri && uriToPost.has(currentUri)) {
158 depth++;
159 currentUri = uriToPost.get(currentUri)!.parentUri;
160 }
161 post.depth = depth;
162
163 if (!childrenMap.has(post.parentUri)) childrenMap.set(post.parentUri, []);
164 childrenMap.get(post.parentUri)!.push(post);
165 }
166
167 childrenMap.values().forEach((children) => children.sort((a, b) => b.newestTime - a.newestTime));
168
169 const createThread = (
170 posts: ThreadPost[],
171 rootUri: ResourceUri,
172 branchParentUri?: ResourceUri
173 ): Thread => ({
174 rootUri,
175 posts,
176 newestTime: Math.max(...posts.map((p) => p.newestTime)),
177 branchParentPost: branchParentUri ? uriToPost.get(branchParentUri) : undefined
178 });
179
180 const collectSubtree = (startPost: ThreadPost): ThreadPost[] => {
181 const result: ThreadPost[] = [];
182 const addWithChildren = (post: ThreadPost) => {
183 result.push(post);
184 const children = childrenMap.get(post.data.uri) || [];
185 children.forEach(addWithChildren);
186 };
187 addWithChildren(startPost);
188 return result;
189 };
190
191 const branchingPoints = Array.from(childrenMap.entries())
192 .filter(([, children]) => children.length > 1)
193 .map(([uri]) => uri);
194
195 const result: Thread[] = [];
196
197 if (branchingPoints.length === 0) {
198 const roots = childrenMap.get(null) || [];
199 const allPosts = roots.flatMap((root) => collectSubtree(root));
200 result.push(createThread(allPosts, group.rootUri));
201 } else {
202 for (const branchParentUri of branchingPoints) {
203 const branches = childrenMap.get(branchParentUri) || [];
204 const sortedBranches = [...branches].sort((a, b) => a.newestTime - b.newestTime);
205
206 sortedBranches.forEach((branchRoot, index) => {
207 const isOldestBranch = index === 0;
208 const branchPosts: ThreadPost[] = [];
209
210 if (isOldestBranch && branchParentUri !== null) {
211 const parentChain: ThreadPost[] = [];
212 let currentUri: ResourceUri | null = branchParentUri;
213 while (currentUri && uriToPost.has(currentUri)) {
214 parentChain.unshift(uriToPost.get(currentUri)!);
215 currentUri = uriToPost.get(currentUri)!.parentUri;
216 }
217 branchPosts.push(...parentChain);
218 }
219
220 branchPosts.push(...collectSubtree(branchRoot));
221
222 const minDepth = Math.min(...branchPosts.map((p) => p.depth));
223 branchPosts.forEach((p) => (p.depth = p.depth - minDepth));
224
225 result.push(
226 createThread(
227 branchPosts,
228 branchRoot.data.uri,
229 isOldestBranch ? undefined : (branchParentUri ?? undefined)
230 )
231 );
232 });
233 }
234 }
235
236 return result;
237 };
238
239 const passesPostFilter = (thread: Thread): boolean => {
240 if (thread.posts.length === 0) return false;
241 if (!opts.viewOwnPosts && hasNonOwnPost(thread.posts, accounts)) return false;
242 return true;
243 };
244
245 for (const group of sortedGroups) {
246 if (!shouldIncludeGroup(group)) continue;
247
248 const groupThreads = processGroup(group);
249
250 for (const thread of groupThreads) {
251 if (!passesPostFilter(thread)) continue;
252 threads.push(thread);
253 if (limit && threads.length >= limit) return threads;
254 }
255 }
256
257 return threads;
258};
259
260const extractDidFromUri = (uri: ResourceUri): Did | null => {
261 const match = uri.match(/^at:\/\/(did:plc:[a-z0-9]+)/);
262 return match ? (match[1] as Did) : null;
263};
264