appview-less bluesky client
24
fork

Configure Feed

Select the types of activity you want to include in your feed.

at 0eaf2dff795c9df008f40a99ca4e2714395bd79d 229 lines 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 { 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>