appview-less bluesky client
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 { AtprotoDid } from '@atcute/lexicons/syntax';
19 import NotLoggedIn from './NotLoggedIn.svelte';
20
21 interface Props {
22 client?: AtpClient | null;
23 targetDid?: AtprotoDid;
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 as AtprotoDid, 7, showReplies);
67 // only fetch interactions if logged in (because if not who is the interactor)
68 if (client.user) {
69 if (!fetchingInteractions) {
70 scheduledFetchInteractions = false;
71 fetchingInteractions = true;
72 fetchInteractionsToTimelineEnd(client, did).finally(() => (fetchingInteractions = false));
73 } else {
74 scheduledFetchInteractions = true;
75 }
76 }
77 loaderState.loaded();
78 } catch (error) {
79 loadError = `${error}`;
80 loaderState.error();
81 loading = false;
82 return;
83 }
84
85 loading = false;
86 const cursor = postCursors.get(did as AtprotoDid);
87 if (cursor && cursor.end) loaderState.complete();
88 };
89
90 $effect(() => {
91 if (threads.length === 0 && !loading && userDid && did) {
92 // if we saw all posts dont try to load more.
93 // this only really happens if the user has no posts at all
94 // but we do have to handle it to not cause an infinite loop
95 const cursor = did ? postCursors.get(did as AtprotoDid) : undefined;
96 if (!cursor?.end) loadMore();
97 }
98 });
99
100 let fetchingInteractions = $state(false);
101 let scheduledFetchInteractions = $state(false);
102 // we want to load interactions when changing logged in user on timelines
103 // only on timelines that arent logged in users, because those are already
104 // loaded by loadMore
105 $effect(() => {
106 if (client && did && scheduledFetchInteractions && userDid !== did) {
107 if (!fetchingInteractions) {
108 scheduledFetchInteractions = false;
109 fetchingInteractions = true;
110 fetchInteractionsToTimelineEnd(client, did).finally(() => (fetchingInteractions = false));
111 } else {
112 scheduledFetchInteractions = true;
113 }
114 }
115 });
116</script>
117
118{#snippet replyPost(post: ThreadPost, reverse: boolean = reverseChronological)}
119 <span
120 class="mb-1.5 flex items-center gap-1.5 overflow-hidden text-nowrap wrap-break-word overflow-ellipsis"
121 >
122 <span class="text-sm text-nowrap opacity-60">{reverse ? '↱' : '↳'}</span>
123 <BskyPost mini client={client!} {...post} />
124 </span>
125{/snippet}
126
127{#snippet threadsView()}
128 {#each threads as thread, i (thread.rootUri)}
129 <div class="flex w-full shrink-0 {reverseChronological ? 'flex-col' : 'flex-col-reverse'}">
130 {#if thread.branchParentPost}
131 {@render replyPost(thread.branchParentPost)}
132 {/if}
133 {#each thread.posts as post, idx (post.data.uri)}
134 {@const mini =
135 !expandedThreads.has(thread.rootUri) &&
136 thread.posts.length > 4 &&
137 idx > 0 &&
138 idx < thread.posts.length - 2}
139 {#if !mini}
140 <div class="mb-1.5">
141 <BskyPost
142 client={client!}
143 onQuote={(post) => {
144 postComposerState.focus = 'focused';
145 postComposerState.quoting = post;
146 }}
147 onReply={(post) => {
148 postComposerState.focus = 'focused';
149 postComposerState.replying = post;
150 }}
151 {...post}
152 />
153 </div>
154 {:else if mini}
155 {#if idx === 1}
156 {@render replyPost(post, !reverseChronological)}
157 <button
158 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)"
159 onclick={() => expandedThreads.add(thread.rootUri)}
160 >
161 <div class="mr-1 h-px w-20 rounded border-y-2 border-dashed opacity-50"></div>
162 <Icon
163 class="shrink-0"
164 icon={reverseChronological
165 ? 'heroicons:bars-arrow-up-solid'
166 : 'heroicons:bars-arrow-down-solid'}
167 width={32}
168 /><span class="shrink-0 pb-1">view full chain</span>
169 <div class="ml-1 h-px w-full rounded border-y-2 border-dashed opacity-50"></div>
170 </button>
171 {:else if idx === thread.posts.length - 3}
172 {@render replyPost(post)}
173 {/if}
174 {/if}
175 {/each}
176 </div>
177 {#if i < threads.length - 1}
178 <div
179 class="mx-8 mt-3 mb-4 h-px bg-linear-to-r from-(--nucleus-accent)/30 to-(--nucleus-accent2)/30"
180 ></div>
181 {/if}
182 {/each}
183{/snippet}
184
185<div
186 class="min-h-full p-2 [scrollbar-color:var(--nucleus-accent)_transparent] {className}"
187 bind:this={scrollContainer}
188>
189 {#if targetDid || $accounts.length > 0}
190 <InfiniteLoader
191 {loaderState}
192 triggerLoad={loadMore}
193 loopDetectionTimeout={0}
194 intersectionOptions={{ root: scrollContainer }}
195 >
196 {@render threadsView()}
197 {#snippet noData()}
198 <div class="flex justify-center py-4">
199 <p class="text-xl opacity-80">
200 all posts seen! <span class="text-2xl">:o</span>
201 </p>
202 </div>
203 {/snippet}
204 {#snippet loading()}
205 <div class="flex justify-center">
206 <div
207 class="h-12 w-12 animate-spin rounded-full border-4 border-t-transparent"
208 style="border-color: var(--nucleus-accent) var(--nucleus-accent) var(--nucleus-accent) transparent;"
209 ></div>
210 </div>
211 {/snippet}
212 {#snippet error()}
213 <div class="flex flex-col gap-4 py-4">
214 <p class="text-xl opacity-80">
215 <span class="text-4xl">x_x</span> <br />
216 {loadError}
217 </p>
218 <div>
219 <button class="flex action-button items-center gap-2" onclick={loadMore}>
220 <Icon class="h-6 w-6" icon="heroicons:arrow-path-16-solid" /> try again
221 </button>
222 </div>
223 </div>
224 {/snippet}
225 </InfiniteLoader>
226 {:else}
227 <NotLoggedIn />
228 {/if}
229</div>