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