1<script lang="ts">
2 import BskyPost from './BskyPost.svelte';
3 import { type State as PostComposerState } from './PostComposer.svelte';
4 import { AtpClient } from '$lib/at/client.svelte';
5 import { accounts } from '$lib/accounts';
6 import { type ResourceUri } from '@atcute/lexicons';
7 import { SvelteSet } from 'svelte/reactivity';
8 import { InfiniteLoader, LoaderState } from 'svelte-infinite';
9 import {
10 postCursors,
11 fetchTimeline,
12 allPosts,
13 timelines,
14 fetchInteractionsToTimelineEnd
15 } from '$lib/state.svelte';
16 import Icon from '@iconify/svelte';
17 import { buildThreads, filterThreads, type ThreadPost } from '$lib/thread';
18 import type { Did } from '@atcute/lexicons/syntax';
19 import NotLoggedIn from './NotLoggedIn.svelte';
20
21 interface Props {
22 client?: AtpClient | null;
23 targetDid?: Did;
24 postComposerState: PostComposerState;
25 class?: string;
26 // whether to show replies that are not the user's own posts
27 showReplies?: boolean;
28 }
29
30 let {
31 client = null,
32 targetDid = undefined,
33 showReplies = true,
34 postComposerState = $bindable(),
35 class: className = ''
36 }: Props = $props();
37
38 let reverseChronological = $state(true);
39 let viewOwnPosts = $state(true);
40 const expandedThreads = new SvelteSet<ResourceUri>();
41
42 const userDid = $derived(client?.user?.did);
43 const did = $derived(targetDid ?? userDid);
44
45 const threads = $derived(
46 // todo: apply showReplies here
47 filterThreads(
48 did && timelines.has(did) ? buildThreads(did, timelines.get(did)!, allPosts) : [],
49 $accounts,
50 { viewOwnPosts }
51 )
52 );
53
54 const loaderState = new LoaderState();
55 let scrollContainer = $state<HTMLDivElement>();
56 let loading = $state(false);
57 let loadError = $state('');
58
59 const loadMore = async () => {
60 if (loading || !client || !did) return;
61
62 loading = true;
63 loaderState.status = 'LOADING';
64
65 try {
66 await fetchTimeline(client, did, 7, showReplies, {
67 downwards: userDid === did ? 'sameAuthor' : 'none'
68 });
69 // only fetch interactions if logged in (because if not who is the interactor)
70 if (client.user && userDid) {
71 if (!fetchingInteractions) {
72 scheduledFetchInteractions = false;
73 fetchingInteractions = true;
74 await fetchInteractionsToTimelineEnd(client, userDid, did);
75 fetchingInteractions = false;
76 } else {
77 scheduledFetchInteractions = true;
78 }
79 }
80 loaderState.loaded();
81 } catch (error) {
82 loadError = `${error}`;
83 loaderState.error();
84 loading = false;
85 return;
86 }
87
88 loading = false;
89 const cursor = postCursors.get(did);
90 if (cursor && cursor.end) loaderState.complete();
91 };
92
93 $effect(() => {
94 if (threads.length === 0 && !loading && userDid && did) {
95 // if we saw all posts dont try to load more.
96 // this only really happens if the user has no posts at all
97 // but we do have to handle it to not cause an infinite loop
98 const cursor = did ? postCursors.get(did) : undefined;
99 if (!cursor?.end) loadMore();
100 }
101 });
102
103 let fetchingInteractions = $state(false);
104 let scheduledFetchInteractions = $state(false);
105 // we want to load interactions when changing logged in user
106 // only on timelines that arent logged in users, because those are already
107 // loaded by loadMore
108 $effect(() => {
109 if (client && scheduledFetchInteractions && userDid && did && did !== userDid) {
110 if (!fetchingInteractions) {
111 scheduledFetchInteractions = false;
112 fetchingInteractions = true;
113 fetchInteractionsToTimelineEnd(client, userDid, did).finally(
114 () => (fetchingInteractions = false)
115 );
116 } else {
117 scheduledFetchInteractions = true;
118 }
119 }
120 });
121</script>
122
123{#snippet replyPost(post: ThreadPost, reverse: boolean = reverseChronological)}
124 <span
125 class="mb-1.5 flex items-center gap-1.5 overflow-hidden text-nowrap wrap-break-word overflow-ellipsis"
126 >
127 <span class="text-sm text-nowrap opacity-60">{reverse ? '↱' : '↳'}</span>
128 <BskyPost mini client={client!} {...post} />
129 </span>
130{/snippet}
131
132{#snippet threadsView()}
133 {#each threads as thread, i (thread.rootUri)}
134 <div class="flex w-full shrink-0 {reverseChronological ? 'flex-col' : 'flex-col-reverse'}">
135 {#if thread.branchParentPost}
136 {@render replyPost(thread.branchParentPost)}
137 {/if}
138 {#each thread.posts as post, idx (post.data.uri)}
139 {@const mini =
140 !expandedThreads.has(thread.rootUri) &&
141 thread.posts.length > 4 &&
142 idx > 0 &&
143 idx < thread.posts.length - 2}
144 {#if !mini}
145 <div class="mb-1.5">
146 <BskyPost
147 client={client!}
148 onQuote={(post) => {
149 postComposerState.focus = 'focused';
150 postComposerState.quoting = post;
151 }}
152 onReply={(post) => {
153 postComposerState.focus = 'focused';
154 postComposerState.replying = post;
155 }}
156 {...post}
157 />
158 </div>
159 {:else if mini}
160 {#if idx === 1}
161 {@render replyPost(post, !reverseChronological)}
162 <button
163 class="mx-1.5 mt-1.5 mb-2.5 flex items-center gap-1.5 text-[color-mix(in_srgb,var(--nucleus-fg)_50%,var(--nucleus-accent))]/70 transition-colors hover:text-(--nucleus-accent)"
164 onclick={() => expandedThreads.add(thread.rootUri)}
165 >
166 <div class="mr-1 h-px w-20 rounded border-y-2 border-dashed opacity-50"></div>
167 <Icon
168 class="shrink-0"
169 icon={reverseChronological
170 ? 'heroicons:bars-arrow-up-solid'
171 : 'heroicons:bars-arrow-down-solid'}
172 width={32}
173 /><span class="shrink-0 pb-1">view full chain</span>
174 <div class="ml-1 h-px w-full rounded border-y-2 border-dashed opacity-50"></div>
175 </button>
176 {:else if idx === thread.posts.length - 3}
177 {@render replyPost(post)}
178 {/if}
179 {/if}
180 {/each}
181 </div>
182 {#if i < threads.length - 1}
183 <div
184 class="mx-8 mt-3 mb-4 h-px bg-linear-to-r from-(--nucleus-accent)/30 to-(--nucleus-accent2)/30"
185 ></div>
186 {/if}
187 {/each}
188{/snippet}
189
190<div
191 class="min-h-full p-2 [scrollbar-color:var(--nucleus-accent)_transparent] {className}"
192 bind:this={scrollContainer}
193>
194 {#if targetDid || $accounts.length > 0}
195 <InfiniteLoader
196 {loaderState}
197 triggerLoad={loadMore}
198 loopDetectionTimeout={0}
199 intersectionOptions={{ root: scrollContainer }}
200 >
201 {@render threadsView()}
202 {#snippet noData()}
203 <div class="flex justify-center py-4">
204 <p class="text-xl opacity-80">
205 all posts seen! <span class="text-2xl">:o</span>
206 </p>
207 </div>
208 {/snippet}
209 {#snippet loading()}
210 <div class="flex justify-center">
211 <div
212 class="h-12 w-12 animate-spin rounded-full border-4 border-t-transparent"
213 style="border-color: var(--nucleus-accent) var(--nucleus-accent) var(--nucleus-accent) transparent;"
214 ></div>
215 </div>
216 {/snippet}
217 {#snippet error()}
218 <div class="flex flex-col gap-4 py-4">
219 <p class="text-xl opacity-80">
220 <span class="text-4xl">x_x</span> <br />
221 {loadError}
222 </p>
223 <div>
224 <button class="flex action-button items-center gap-2" onclick={loadMore}>
225 <Icon class="h-6 w-6" icon="heroicons:arrow-path-16-solid" /> try again
226 </button>
227 </div>
228 </div>
229 {/snippet}
230 </InfiniteLoader>
231 {:else}
232 <NotLoggedIn />
233 {/if}
234</div>