1// updated src/lib/thread.ts
2
3import { parseCanonicalResourceUri, type Did, type ResourceUri } from '@atcute/lexicons';
4import type { Account } from './accounts';
5import { expect } from './result';
6import type { PostWithUri } from './at/fetch';
7import { isBlockedBy } from './state.svelte';
8
9export type ThreadPost = {
10 data: PostWithUri;
11 account: Did;
12 did: Did;
13 rkey: string;
14 parentUri: ResourceUri | null;
15 depth: number;
16 newestTime: number;
17 isBlocked?: boolean;
18};
19
20export type Thread = {
21 rootUri: ResourceUri;
22 posts: ThreadPost[];
23 newestTime: number;
24 branchParentPost?: ThreadPost;
25};
26
27export const buildThreads = (
28 account: Did,
29 timeline: Set<ResourceUri>,
30 posts: Map<Did, Map<ResourceUri, PostWithUri>>
31): Thread[] => {
32 const threadMap = new Map<ResourceUri, ThreadPost[]>();
33
34 // group posts by root uri into "thread" chains
35 for (const uri of timeline) {
36 const parsedUri = expect(parseCanonicalResourceUri(uri));
37 const data = posts.get(parsedUri.repo)?.get(uri);
38 if (!data) continue;
39
40 const rootUri = (data.record.reply?.root.uri as ResourceUri) || uri;
41 const parentUri = (data.record.reply?.parent.uri as ResourceUri) || null;
42
43 const post: ThreadPost = {
44 data,
45 account,
46 did: parsedUri.repo,
47 rkey: parsedUri.rkey,
48 parentUri,
49 depth: 0,
50 newestTime: new Date(data.record.createdAt).getTime(),
51 isBlocked: isBlockedBy(parsedUri.repo, account)
52 };
53
54 if (!threadMap.has(rootUri)) threadMap.set(rootUri, []);
55
56 threadMap.get(rootUri)!.push(post);
57 }
58
59 const threads: Thread[] = [];
60
61 for (const [rootUri, posts] of threadMap) {
62 const uriToPost = new Map(posts.map((p) => [p.data.uri, p]));
63 const childrenMap = new Map<ResourceUri | null, ThreadPost[]>();
64
65 // calculate depths
66 for (const post of posts) {
67 let depth = 0;
68 let currentUri = post.parentUri;
69
70 while (currentUri && uriToPost.has(currentUri)) {
71 depth++;
72 currentUri = uriToPost.get(currentUri)!.parentUri;
73 }
74
75 post.depth = depth;
76
77 if (!childrenMap.has(post.parentUri)) childrenMap.set(post.parentUri, []);
78 childrenMap.get(post.parentUri)!.push(post);
79 }
80
81 childrenMap
82 .values()
83 .forEach((children) => children.sort((a, b) => b.newestTime - a.newestTime));
84
85 const createThread = (
86 posts: ThreadPost[],
87 rootUri: ResourceUri,
88 branchParentUri?: ResourceUri
89 ): Thread => {
90 return {
91 rootUri,
92 posts,
93 newestTime: Math.max(...posts.map((p) => p.newestTime)),
94 branchParentPost: branchParentUri ? uriToPost.get(branchParentUri) : undefined
95 };
96 };
97
98 const collectSubtree = (startPost: ThreadPost): ThreadPost[] => {
99 const result: ThreadPost[] = [];
100 const addWithChildren = (post: ThreadPost) => {
101 result.push(post);
102 const children = childrenMap.get(post.data.uri) || [];
103 children.forEach(addWithChildren);
104 };
105 addWithChildren(startPost);
106 return result;
107 };
108
109 // find posts with >2 children to split them into separate chains
110 const branchingPoints = Array.from(childrenMap.entries())
111 .filter(([, children]) => children.length > 1)
112 .map(([uri]) => uri);
113
114 if (branchingPoints.length === 0) {
115 const roots = childrenMap.get(null) || [];
116 const allPosts = roots.flatMap((root) => collectSubtree(root));
117 threads.push(createThread(allPosts, rootUri));
118 } else {
119 for (const branchParentUri of branchingPoints) {
120 const branches = childrenMap.get(branchParentUri) || [];
121
122 const sortedBranches = [...branches].sort((a, b) => a.newestTime - b.newestTime);
123
124 sortedBranches.forEach((branchRoot, index) => {
125 const isOldestBranch = index === 0;
126 const branchPosts: ThreadPost[] = [];
127
128 // the oldest branch has the full context
129 // todo: consider letting the user decide this..?
130 if (isOldestBranch && branchParentUri !== null) {
131 const parentChain: ThreadPost[] = [];
132 let currentUri: ResourceUri | null = branchParentUri;
133 while (currentUri && uriToPost.has(currentUri)) {
134 parentChain.unshift(uriToPost.get(currentUri)!);
135 currentUri = uriToPost.get(currentUri)!.parentUri;
136 }
137 branchPosts.push(...parentChain);
138 }
139
140 branchPosts.push(...collectSubtree(branchRoot));
141
142 const minDepth = Math.min(...branchPosts.map((p) => p.depth));
143 branchPosts.forEach((p) => (p.depth = p.depth - minDepth));
144
145 threads.push(
146 createThread(
147 branchPosts,
148 branchRoot.data.uri,
149 isOldestBranch ? undefined : (branchParentUri ?? undefined)
150 )
151 );
152 });
153 }
154 }
155 }
156
157 threads.sort((a, b) => b.newestTime - a.newestTime);
158
159 return threads;
160};
161
162export const isOwnPost = (post: ThreadPost, accounts: Account[]) =>
163 accounts.some((account) => account.did === post.did);
164export const hasNonOwnPost = (posts: ThreadPost[], accounts: Account[]) =>
165 posts.some((post) => !isOwnPost(post, accounts));
166
167// todo: add more filtering options
168export type FilterOptions = {
169 viewOwnPosts: boolean;
170};
171
172export const filterThreads = (threads: Thread[], accounts: Account[], opts: FilterOptions) =>
173 threads.filter((thread) => {
174 if (thread.posts.length === 0) return false;
175 if (!opts.viewOwnPosts) return hasNonOwnPost(thread.posts, accounts);
176 return true;
177 });