appview-less bluesky client
24
fork

Configure Feed

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

refactor timeline component into reply timeline and feed timeline components

dawn 83cfafbd 46800617

+449 -311
+5
src/components/EndOfList.svelte
··· 1 + <div class="flex justify-center py-4"> 2 + <p class="text-xl opacity-80"> 3 + all posts seen! <span class="text-2xl">:o</span> 4 + </p> 5 + </div>
+182
src/components/FeedTimelineView.svelte
··· 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 { Did, RecordKey } from '@atcute/lexicons/syntax'; 7 + import { InfiniteLoader, LoaderState } from 'svelte-infinite'; 8 + import { 9 + allPosts, 10 + viewClient, 11 + accountPreferences, 12 + feedTimelines, 13 + feedCursors, 14 + fetchFeed, 15 + resetFeed, 16 + checkForNewPosts 17 + } from '$lib/state.svelte'; 18 + import Icon from '@iconify/svelte'; 19 + import NotLoggedIn from './NotLoggedIn.svelte'; 20 + import { fetchFeedGenerator } from '$lib/at/feeds'; 21 + import LoadingSpinner from './LoadingSpinner.svelte'; 22 + import EndOfList from './EndOfList.svelte'; 23 + import LoadError from './LoadError.svelte'; 24 + 25 + interface Props { 26 + client?: AtpClient | null; 27 + postComposerState: PostComposerState; 28 + class?: string; 29 + selectedFeed: string; 30 + } 31 + 32 + let { 33 + client = null, 34 + postComposerState = $bindable(), 35 + selectedFeed, 36 + class: className = '' 37 + }: Props = $props(); 38 + 39 + const userDid = $derived(client?.user?.did); 40 + 41 + let feedServiceDid = $state<string | null>(null); 42 + let newPostsAvailable = $state(false); 43 + 44 + $effect(() => { 45 + selectedFeed; 46 + feedServiceDid = null; 47 + newPostsAvailable = false; 48 + loaderState.reset(); 49 + fetchFeedGenerator(client ?? viewClient, selectedFeed).then((meta) => { 50 + feedServiceDid = meta?.did ?? null; 51 + }); 52 + }); 53 + 54 + $effect(() => { 55 + if (!client || !feedServiceDid) return; 56 + 57 + const check = async () => { 58 + if (!client || !feedServiceDid) return; 59 + newPostsAvailable = await checkForNewPosts(client, selectedFeed, feedServiceDid); 60 + }; 61 + 62 + check(); 63 + const interval = setInterval(check, 15000); 64 + 65 + return () => clearInterval(interval); 66 + }); 67 + 68 + const loaderState = new LoaderState(); 69 + let scrollContainer = $state<HTMLDivElement>(); 70 + let loading = $state(false); 71 + let loadError = $state(''); 72 + 73 + export const clearFeed = () => { 74 + if (!userDid) return; 75 + scrollContainer?.scrollTo({ top: 0, behavior: 'smooth' }); 76 + newPostsAvailable = false; 77 + resetFeed(userDid, selectedFeed); 78 + loaderState.reset(); 79 + loadMore(); 80 + }; 81 + 82 + const feedPosts = $derived.by(() => { 83 + if (!userDid) return []; 84 + const uris = feedTimelines.get(userDid)?.get(selectedFeed) ?? []; 85 + return uris 86 + .map((uri) => { 87 + const did = uri.split('/')[2] as Did; 88 + return allPosts.get(did)?.get(uri); 89 + }) 90 + .filter((p): p is NonNullable<typeof p> => p !== undefined); 91 + }); 92 + 93 + const loadMore = async () => { 94 + if (loading || !client || !userDid || !feedServiceDid) return; 95 + 96 + loading = true; 97 + loaderState.status = 'LOADING'; 98 + 99 + try { 100 + const result = await fetchFeed(client, selectedFeed, feedServiceDid); 101 + loaderState.loaded(); 102 + if (result?.end) loaderState.complete(); 103 + } catch (error) { 104 + loadError = `${error}`; 105 + loaderState.error(); 106 + loading = false; 107 + return; 108 + } 109 + 110 + loading = false; 111 + }; 112 + 113 + $effect(() => { 114 + const isEmpty = feedPosts.length === 0; 115 + if (isEmpty && !loading && userDid && feedServiceDid) { 116 + const cursor = feedCursors.get(userDid)?.get(selectedFeed); 117 + if (!cursor?.end) loadMore(); 118 + } 119 + }); 120 + </script> 121 + 122 + {#snippet feedPostsView()} 123 + {#each feedPosts as post, i (post.uri)} 124 + {@const uriParts = post.uri.split('/')} 125 + {@const postDid = uriParts[2] as Did} 126 + {@const postRkey = uriParts[4] as RecordKey} 127 + <div class="mb-1.5"> 128 + <BskyPost 129 + client={client!} 130 + did={postDid} 131 + rkey={postRkey} 132 + data={post} 133 + onQuote={(p) => { 134 + postComposerState.focus = 'focused'; 135 + postComposerState.quoting = p; 136 + }} 137 + onReply={(p) => { 138 + postComposerState.focus = 'focused'; 139 + postComposerState.replying = p; 140 + }} 141 + /> 142 + </div> 143 + {#if i < feedPosts.length - 1} 144 + <div 145 + class="mx-8 mt-3 mb-4 h-px bg-linear-to-r from-(--nucleus-accent)/30 to-(--nucleus-accent2)/30" 146 + ></div> 147 + {/if} 148 + {/each} 149 + {/snippet} 150 + 151 + <div 152 + class="min-h-full p-2 [scrollbar-color:var(--nucleus-accent)_transparent] {className}" 153 + bind:this={scrollContainer} 154 + > 155 + {#if newPostsAvailable} 156 + <div class="sticky top-2 z-20 mb-4 flex w-full justify-center"> 157 + <button 158 + class="flex action-button items-center gap-2 bg-(--nucleus-bg) hover:scale-115! enabled:hover:bg-(--nucleus-bg)!" 159 + onclick={clearFeed} 160 + > 161 + <Icon icon="heroicons:arrow-up-16-solid" width="20" /> 162 + load new posts 163 + </button> 164 + </div> 165 + {/if} 166 + {#if userDid || $accounts.length > 0} 167 + <InfiniteLoader {loaderState} triggerLoad={loadMore} loopDetectionTimeout={0}> 168 + {@render feedPostsView()} 169 + {#snippet noData()} 170 + <EndOfList /> 171 + {/snippet} 172 + {#snippet loading()} 173 + <LoadingSpinner /> 174 + {/snippet} 175 + {#snippet error()} 176 + <LoadError error={loadError} onRetry={loadMore} /> 177 + {/snippet} 178 + </InfiniteLoader> 179 + {:else} 180 + <NotLoggedIn /> 181 + {/if} 182 + </div>
+22
src/components/LoadError.svelte
··· 1 + <script lang="ts"> 2 + import Icon from '@iconify/svelte'; 3 + 4 + interface Props { 5 + error: string; 6 + onRetry: () => void; 7 + } 8 + 9 + let { error, onRetry }: Props = $props(); 10 + </script> 11 + 12 + <div class="flex flex-col gap-4 py-4"> 13 + <p class="text-xl opacity-80"> 14 + <span class="text-4xl">x_x</span> <br /> 15 + {error} 16 + </p> 17 + <div> 18 + <button class="flex action-button items-center gap-2" onclick={onRetry}> 19 + <Icon class="h-6 w-6" icon="heroicons:arrow-path-16-solid" /> try again 20 + </button> 21 + </div> 22 + </div>
+6
src/components/LoadingSpinner.svelte
··· 1 + <div class="flex justify-center"> 2 + <div 3 + class="h-12 w-12 animate-spin rounded-full border-4 border-t-transparent" 4 + style="border-color: var(--nucleus-accent) var(--nucleus-accent) var(--nucleus-accent) transparent;" 5 + ></div> 6 + </div>
+218
src/components/ReplyTimelineView.svelte
··· 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 + accountPreferences 16 + } from '$lib/state.svelte'; 17 + import Icon from '@iconify/svelte'; 18 + import { buildThreads, filterThreads, type ThreadPost } from '$lib/thread'; 19 + import type { Did } from '@atcute/lexicons/syntax'; 20 + import NotLoggedIn from './NotLoggedIn.svelte'; 21 + import LoadingSpinner from './LoadingSpinner.svelte'; 22 + import EndOfList from './EndOfList.svelte'; 23 + import LoadError from './LoadError.svelte'; 24 + 25 + interface Props { 26 + client?: AtpClient | null; 27 + targetDid?: Did; 28 + postComposerState: PostComposerState; 29 + class?: string; 30 + showReplies?: boolean; 31 + } 32 + 33 + let { 34 + client = null, 35 + targetDid = undefined, 36 + showReplies = true, 37 + postComposerState = $bindable(), 38 + class: className = '' 39 + }: Props = $props(); 40 + 41 + let reverseChronological = $state(true); 42 + let viewOwnPosts = $state(true); 43 + const expandedThreads = new SvelteSet<ResourceUri>(); 44 + 45 + const userDid = $derived(client?.user?.did); 46 + const did = $derived(targetDid ?? userDid); 47 + 48 + const currentPrefs = $derived(userDid ? accountPreferences.get(userDid) : null); 49 + const mutes = $derived(currentPrefs?.mutes ?? []); 50 + 51 + const threads = $derived( 52 + filterThreads( 53 + did && timelines.has(did) ? buildThreads(did, timelines.get(did)!, allPosts, mutes) : [], 54 + $accounts, 55 + { viewOwnPosts } 56 + ) 57 + ); 58 + 59 + const loaderState = new LoaderState(); 60 + let scrollContainer = $state<HTMLDivElement>(); 61 + let loading = $state(false); 62 + let loadError = $state(''); 63 + 64 + let fetchingInteractions = $state(false); 65 + let scheduledFetchInteractions = $state(false); 66 + 67 + const loadMore = async () => { 68 + if (loading || !client || !userDid || !did) return; 69 + 70 + loading = true; 71 + loaderState.status = 'LOADING'; 72 + 73 + try { 74 + await fetchTimeline(client, did, 7, showReplies, { 75 + downwards: userDid === did ? 'sameAuthor' : 'none' 76 + }); 77 + if (client.user && userDid) { 78 + if (!fetchingInteractions) { 79 + scheduledFetchInteractions = false; 80 + fetchingInteractions = true; 81 + await fetchInteractionsToTimelineEnd(client, userDid, did); 82 + fetchingInteractions = false; 83 + } else { 84 + scheduledFetchInteractions = true; 85 + } 86 + } 87 + loaderState.loaded(); 88 + const cursor = postCursors.get(did); 89 + if (cursor?.end) loaderState.complete(); 90 + } catch (error) { 91 + loadError = `${error}`; 92 + loaderState.error(); 93 + loading = false; 94 + return; 95 + } 96 + 97 + loading = false; 98 + }; 99 + 100 + $effect(() => { 101 + const isEmpty = threads.length === 0; 102 + if (isEmpty && !loading && userDid && did) { 103 + const cursor = postCursors.get(did); 104 + if (!cursor?.end) loadMore(); 105 + } 106 + }); 107 + 108 + $effect(() => { 109 + did; 110 + loaderState.reset(); 111 + }); 112 + 113 + // we want to load interactions when changing logged in user 114 + // only on timelines that arent logged in users, because those are already 115 + // loaded by loadMore 116 + $effect(() => { 117 + if (client && scheduledFetchInteractions && userDid && did && did !== userDid) { 118 + if (!fetchingInteractions) { 119 + scheduledFetchInteractions = false; 120 + fetchingInteractions = true; 121 + fetchInteractionsToTimelineEnd(client, userDid, did).finally( 122 + () => (fetchingInteractions = false) 123 + ); 124 + } else { 125 + scheduledFetchInteractions = true; 126 + } 127 + } 128 + }); 129 + </script> 130 + 131 + {#snippet replyPost(post: ThreadPost, reverse: boolean = reverseChronological)} 132 + <span 133 + class="mb-1.5 flex items-center gap-1.5 overflow-hidden text-nowrap wrap-break-word overflow-ellipsis" 134 + > 135 + <span class="text-sm text-nowrap opacity-60">{reverse ? '↱' : '↳'}</span> 136 + <BskyPost mini client={client!} {...post} /> 137 + </span> 138 + {/snippet} 139 + 140 + {#snippet threadsView()} 141 + {#each threads as thread, i (thread.rootUri)} 142 + <div class="flex w-full shrink-0 {reverseChronological ? 'flex-col' : 'flex-col-reverse'}"> 143 + {#if thread.branchParentPost} 144 + {@render replyPost(thread.branchParentPost)} 145 + {/if} 146 + {#each thread.posts as post, idx (post.data.uri)} 147 + {@const mini = 148 + !expandedThreads.has(thread.rootUri) && 149 + thread.posts.length > 4 && 150 + idx > 0 && 151 + idx < thread.posts.length - 2} 152 + {#if !mini} 153 + <div class="mb-1.5"> 154 + <BskyPost 155 + client={client!} 156 + onQuote={(post) => { 157 + postComposerState.focus = 'focused'; 158 + postComposerState.quoting = post; 159 + }} 160 + onReply={(post) => { 161 + postComposerState.focus = 'focused'; 162 + postComposerState.replying = post; 163 + }} 164 + {...post} 165 + /> 166 + </div> 167 + {:else if mini} 168 + {#if idx === 1} 169 + {@render replyPost(post, !reverseChronological)} 170 + <button 171 + 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)" 172 + onclick={() => expandedThreads.add(thread.rootUri)} 173 + > 174 + <div class="mr-1 h-px w-20 rounded border-y-2 border-dashed opacity-50"></div> 175 + <Icon 176 + class="shrink-0" 177 + icon={reverseChronological 178 + ? 'heroicons:bars-arrow-up-solid' 179 + : 'heroicons:bars-arrow-down-solid'} 180 + width={32} 181 + /><span class="shrink-0 pb-1">view full chain</span> 182 + <div class="ml-1 h-px w-full rounded border-y-2 border-dashed opacity-50"></div> 183 + </button> 184 + {:else if idx === thread.posts.length - 3} 185 + {@render replyPost(post)} 186 + {/if} 187 + {/if} 188 + {/each} 189 + </div> 190 + {#if i < threads.length - 1} 191 + <div 192 + class="mx-8 mt-3 mb-4 h-px bg-linear-to-r from-(--nucleus-accent)/30 to-(--nucleus-accent2)/30" 193 + ></div> 194 + {/if} 195 + {/each} 196 + {/snippet} 197 + 198 + <div 199 + class="min-h-full p-2 [scrollbar-color:var(--nucleus-accent)_transparent] {className}" 200 + bind:this={scrollContainer} 201 + > 202 + {#if did || $accounts.length > 0} 203 + <InfiniteLoader {loaderState} triggerLoad={loadMore} loopDetectionTimeout={0}> 204 + {@render threadsView()} 205 + {#snippet noData()} 206 + <EndOfList /> 207 + {/snippet} 208 + {#snippet loading()} 209 + <LoadingSpinner /> 210 + {/snippet} 211 + {#snippet error()} 212 + <LoadError error={loadError} onRetry={loadMore} /> 213 + {/snippet} 214 + </InfiniteLoader> 215 + {:else} 216 + <NotLoggedIn /> 217 + {/if} 218 + </div>
+16 -311
src/components/TimelineView.svelte
··· 1 1 <script lang="ts"> 2 - import BskyPost from './BskyPost.svelte'; 3 2 import { type State as PostComposerState } from './PostComposer.svelte'; 4 3 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 - viewClient, 15 - fetchInteractionsToTimelineEnd, 16 - accountPreferences, 17 - feedTimelines, 18 - feedCursors, 19 - fetchFeed, 20 - resetFeed, 21 - checkForNewPosts 22 - } from '$lib/state.svelte'; 23 - import Icon from '@iconify/svelte'; 24 - import { buildThreads, filterThreads, type ThreadPost } from '$lib/thread'; 25 - import type { Did, RecordKey } from '@atcute/lexicons/syntax'; 26 - import NotLoggedIn from './NotLoggedIn.svelte'; 27 - import { fetchFeedGenerator } from '$lib/at/feeds'; 4 + import type { Did } from '@atcute/lexicons/syntax'; 5 + import FeedTimelineView from './FeedTimelineView.svelte'; 6 + import ReplyTimelineView from './ReplyTimelineView.svelte'; 28 7 29 8 interface Props { 30 9 client?: AtpClient | null; ··· 44 23 class: className = '' 45 24 }: Props = $props(); 46 25 47 - let reverseChronological = $state(true); 48 - let viewOwnPosts = $state(true); 49 - const expandedThreads = new SvelteSet<ResourceUri>(); 50 - 51 - const userDid = $derived(client?.user?.did); 52 - const did = $derived(targetDid ?? userDid); 53 - 54 - const currentPrefs = $derived(userDid ? accountPreferences.get(userDid) : null); 55 - const mutes = $derived(currentPrefs?.mutes ?? []); 56 - 57 - let feedServiceDid = $state<string | null>(null); 58 - let newPostsAvailable = $state(false); 59 - 60 - $effect(() => { 61 - if (selectedFeed) { 62 - fetchFeedGenerator(client ?? viewClient, selectedFeed).then((meta) => { 63 - feedServiceDid = meta?.did ?? null; 64 - }); 65 - } else { 66 - feedServiceDid = null; 67 - } 68 - 69 - newPostsAvailable = false; 70 - if (!client || !selectedFeed) return; 71 - 72 - const check = async () => { 73 - if (!client || !selectedFeed || !feedServiceDid) return; 74 - newPostsAvailable = await checkForNewPosts(client, selectedFeed, feedServiceDid); 75 - }; 76 - 77 - check(); 78 - const interval = setInterval(check, 15000); 79 - 80 - return () => clearInterval(interval); 81 - }); 26 + let feedView = $state<{ clearFeed: () => void }>(); 82 27 83 28 export const clearFeed = () => { 84 - if (!selectedFeed || !userDid) return; 85 - scrollContainer?.scrollTo({ top: 0, behavior: 'smooth' }); 86 - newPostsAvailable = false; 87 - resetFeed(userDid, selectedFeed); 88 - loaderState.reset(); 89 - loadMore(); 90 - }; 91 - 92 - const threads = $derived( 93 - filterThreads( 94 - did && timelines.has(did) ? buildThreads(did, timelines.get(did)!, allPosts, mutes) : [], 95 - $accounts, 96 - { viewOwnPosts } 97 - ) 98 - ); 99 - 100 - const feedPosts = $derived.by(() => { 101 - if (!selectedFeed || !userDid) return []; 102 - const uris = feedTimelines.get(userDid)?.get(selectedFeed) ?? []; 103 - return uris 104 - .map((uri) => { 105 - const did = uri.split('/')[2] as Did; 106 - return allPosts.get(did)?.get(uri); 107 - }) 108 - .filter((p): p is NonNullable<typeof p> => p !== undefined); 109 - }); 110 - 111 - const loaderState = new LoaderState(); 112 - let scrollContainer = $state<HTMLDivElement>(); 113 - let loading = $state(false); 114 - let loadError = $state(''); 115 - 116 - const loadMore = async () => { 117 - if (loading || !client || !userDid) return; 118 - 119 - loading = true; 120 - loaderState.status = 'LOADING'; 121 - 122 - try { 123 - if (selectedFeed && feedServiceDid) { 124 - const result = await fetchFeed(client, selectedFeed, feedServiceDid); 125 - loaderState.loaded(); 126 - if (result?.end) loaderState.complete(); 127 - } else if (did) { 128 - await fetchTimeline(client, did, 7, showReplies, { 129 - downwards: userDid === did ? 'sameAuthor' : 'none' 130 - }); 131 - if (client.user && userDid) { 132 - if (!fetchingInteractions) { 133 - scheduledFetchInteractions = false; 134 - fetchingInteractions = true; 135 - await fetchInteractionsToTimelineEnd(client, userDid, did); 136 - fetchingInteractions = false; 137 - } else { 138 - scheduledFetchInteractions = true; 139 - } 140 - } 141 - loaderState.loaded(); 142 - const cursor = postCursors.get(did); 143 - if (cursor?.end) loaderState.complete(); 144 - } 145 - } catch (error) { 146 - loadError = `${error}`; 147 - loaderState.error(); 148 - loading = false; 149 - return; 150 - } 151 - 152 - loading = false; 29 + feedView?.clearFeed(); 153 30 }; 154 - 155 - $effect(() => { 156 - const isEmpty = selectedFeed ? feedPosts.length === 0 : threads.length === 0; 157 - if (isEmpty && !loading && userDid) { 158 - if (selectedFeed) { 159 - const cursor = feedCursors.get(userDid)?.get(selectedFeed); 160 - if (!cursor?.end) loadMore(); 161 - } else if (did) { 162 - const cursor = postCursors.get(did); 163 - if (!cursor?.end) loadMore(); 164 - } 165 - } 166 - }); 167 - 168 - let fetchingInteractions = $state(false); 169 - let scheduledFetchInteractions = $state(false); 170 - // we want to load interactions when changing logged in user 171 - // only on timelines that arent logged in users, because those are already 172 - // loaded by loadMore 173 - $effect(() => { 174 - if (client && scheduledFetchInteractions && userDid && did && did !== userDid) { 175 - if (!fetchingInteractions) { 176 - scheduledFetchInteractions = false; 177 - fetchingInteractions = true; 178 - fetchInteractionsToTimelineEnd(client, userDid, did).finally( 179 - () => (fetchingInteractions = false) 180 - ); 181 - } else { 182 - scheduledFetchInteractions = true; 183 - } 184 - } 185 - }); 186 31 </script> 187 32 188 - {#snippet replyPost(post: ThreadPost, reverse: boolean = reverseChronological)} 189 - <span 190 - class="mb-1.5 flex items-center gap-1.5 overflow-hidden text-nowrap wrap-break-word overflow-ellipsis" 191 - > 192 - <span class="text-sm text-nowrap opacity-60">{reverse ? '↱' : '↳'}</span> 193 - <BskyPost mini client={client!} {...post} /> 194 - </span> 195 - {/snippet} 196 - 197 - {#snippet threadsView()} 198 - {#each threads as thread, i (thread.rootUri)} 199 - <div class="flex w-full shrink-0 {reverseChronological ? 'flex-col' : 'flex-col-reverse'}"> 200 - {#if thread.branchParentPost} 201 - {@render replyPost(thread.branchParentPost)} 202 - {/if} 203 - {#each thread.posts as post, idx (post.data.uri)} 204 - {@const mini = 205 - !expandedThreads.has(thread.rootUri) && 206 - thread.posts.length > 4 && 207 - idx > 0 && 208 - idx < thread.posts.length - 2} 209 - {#if !mini} 210 - <div class="mb-1.5"> 211 - <BskyPost 212 - client={client!} 213 - onQuote={(post) => { 214 - postComposerState.focus = 'focused'; 215 - postComposerState.quoting = post; 216 - }} 217 - onReply={(post) => { 218 - postComposerState.focus = 'focused'; 219 - postComposerState.replying = post; 220 - }} 221 - {...post} 222 - /> 223 - </div> 224 - {:else if mini} 225 - {#if idx === 1} 226 - {@render replyPost(post, !reverseChronological)} 227 - <button 228 - 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)" 229 - onclick={() => expandedThreads.add(thread.rootUri)} 230 - > 231 - <div class="mr-1 h-px w-20 rounded border-y-2 border-dashed opacity-50"></div> 232 - <Icon 233 - class="shrink-0" 234 - icon={reverseChronological 235 - ? 'heroicons:bars-arrow-up-solid' 236 - : 'heroicons:bars-arrow-down-solid'} 237 - width={32} 238 - /><span class="shrink-0 pb-1">view full chain</span> 239 - <div class="ml-1 h-px w-full rounded border-y-2 border-dashed opacity-50"></div> 240 - </button> 241 - {:else if idx === thread.posts.length - 3} 242 - {@render replyPost(post)} 243 - {/if} 244 - {/if} 245 - {/each} 246 - </div> 247 - {#if i < threads.length - 1} 248 - <div 249 - class="mx-8 mt-3 mb-4 h-px bg-linear-to-r from-(--nucleus-accent)/30 to-(--nucleus-accent2)/30" 250 - ></div> 251 - {/if} 252 - {/each} 253 - {/snippet} 254 - 255 - {#snippet feedPostsView()} 256 - {#each feedPosts as post, i (post.uri)} 257 - {@const uriParts = post.uri.split('/')} 258 - {@const postDid = uriParts[2] as Did} 259 - {@const postRkey = uriParts[4] as RecordKey} 260 - <div class="mb-1.5"> 261 - <BskyPost 262 - client={client!} 263 - did={postDid} 264 - rkey={postRkey} 265 - data={post} 266 - onQuote={(p) => { 267 - postComposerState.focus = 'focused'; 268 - postComposerState.quoting = p; 269 - }} 270 - onReply={(p) => { 271 - postComposerState.focus = 'focused'; 272 - postComposerState.replying = p; 273 - }} 274 - /> 275 - </div> 276 - {#if i < feedPosts.length - 1} 277 - <div 278 - class="mx-8 mt-3 mb-4 h-px bg-linear-to-r from-(--nucleus-accent)/30 to-(--nucleus-accent2)/30" 279 - ></div> 280 - {/if} 281 - {/each} 282 - {/snippet} 283 - 284 - <div 285 - class="min-h-full p-2 [scrollbar-color:var(--nucleus-accent)_transparent] {className}" 286 - bind:this={scrollContainer} 287 - > 288 - {#if newPostsAvailable} 289 - <div class="sticky top-2 z-20 mb-4 flex w-full justify-center"> 290 - <button 291 - class="flex action-button items-center gap-2 bg-(--nucleus-bg) hover:scale-115! enabled:hover:bg-(--nucleus-bg)!" 292 - onclick={clearFeed} 293 - > 294 - <Icon icon="heroicons:arrow-up-16-solid" width="20" /> 295 - load new posts 296 - </button> 297 - </div> 298 - {/if} 299 - {#if targetDid || $accounts.length > 0} 300 - <InfiniteLoader {loaderState} triggerLoad={loadMore} loopDetectionTimeout={0}> 301 - {#if selectedFeed} 302 - {@render feedPostsView()} 303 - {:else} 304 - {@render threadsView()} 305 - {/if} 306 - {#snippet noData()} 307 - <div class="flex justify-center py-4"> 308 - <p class="text-xl opacity-80"> 309 - all posts seen! <span class="text-2xl">:o</span> 310 - </p> 311 - </div> 312 - {/snippet} 313 - {#snippet loading()} 314 - <div class="flex justify-center"> 315 - <div 316 - class="h-12 w-12 animate-spin rounded-full border-4 border-t-transparent" 317 - style="border-color: var(--nucleus-accent) var(--nucleus-accent) var(--nucleus-accent) transparent;" 318 - ></div> 319 - </div> 320 - {/snippet} 321 - {#snippet error()} 322 - <div class="flex flex-col gap-4 py-4"> 323 - <p class="text-xl opacity-80"> 324 - <span class="text-4xl">x_x</span> <br /> 325 - {loadError} 326 - </p> 327 - <div> 328 - <button class="flex action-button items-center gap-2" onclick={loadMore}> 329 - <Icon class="h-6 w-6" icon="heroicons:arrow-path-16-solid" /> try again 330 - </button> 331 - </div> 332 - </div> 333 - {/snippet} 334 - </InfiniteLoader> 335 - {:else} 336 - <NotLoggedIn /> 337 - {/if} 338 - </div> 33 + {#if selectedFeed} 34 + <FeedTimelineView 35 + {client} 36 + {selectedFeed} 37 + bind:postComposerState 38 + class={className} 39 + bind:this={feedView} 40 + /> 41 + {:else} 42 + <ReplyTimelineView {client} {targetDid} {showReplies} bind:postComposerState class={className} /> 43 + {/if}