replies timeline only, appview-less bluesky client
at main 7.1 kB view raw
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>