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