Coves frontend - a photon fork
at main 265 lines 7.4 kB view raw
1<script lang="ts"> 2 import { browser } from '$app/environment' 3 import { XrpcError } from '$lib/api/coves/xrpc' 4 import type { FeedViewPost, FeedPaginationParams } from '$lib/api/coves/types' 5 import { errorMessage } from '$lib/app/error' 6 import { t } from '$lib/app/i18n' 7 import VirtualList from '$lib/app/render/VirtualList.svelte' 8 import { settings } from '$lib/app/settings.svelte' 9 import Placeholder from '$lib/ui/info/Placeholder.svelte' 10 import EndPlaceholder from '$lib/ui/layout/EndPlaceholder.svelte' 11 import { Button, Material, Spinner } from 'mono-svelte' 12 import { onMount, untrack } from 'svelte' 13 import { 14 ArchiveBox, 15 ArrowTopRightOnSquare, 16 ChevronDoubleUp, 17 ExclamationTriangle, 18 Icon, 19 } from 'svelte-hero-icons/dist' 20 import InfiniteScroll from 'svelte-infinite-scroll' 21 import { expoOut } from 'svelte/easing' 22 import { SvelteSet } from 'svelte/reactivity' 23 import { fly } from 'svelte/transition' 24 import { Post } from '..' 25 26 interface Props { 27 posts: FeedViewPost[] 28 params: FeedPaginationParams 29 virtualList?: { itemHeights: (number | null)[] } 30 lastSeen?: number 31 community?: boolean 32 loadFeed?: ( 33 params: FeedPaginationParams, 34 ) => Promise<{ feed: FeedViewPost[]; cursor?: string }> 35 children?: import('svelte').Snippet 36 } 37 38 let { 39 posts = $bindable(), 40 params = $bindable(), 41 virtualList = $bindable(), 42 lastSeen = $bindable(0), 43 community = false, 44 loadFeed, 45 children, 46 }: Props = $props() 47 48 let listEl = $state<HTMLUListElement>() 49 let listComp = $state<{ 50 scrollToIndex: (index: number, window?: boolean) => void 51 }>() 52 53 let error = $state<unknown>() 54 let isAuthError = $derived( 55 error instanceof XrpcError && 56 (error.status === 401 || error.status === 403), 57 ) 58 let loading = $state(false) 59 let hasMore = $state(!!loadFeed) 60 61 let seenUris = new SvelteSet<string>( 62 (posts ?? []).map((fp) => fp.post.uri as string), 63 ) 64 65 async function loadMore(): Promise<void> { 66 if (!hasMore || loading || !loadFeed) return 67 68 try { 69 loading = true 70 71 const response = await loadFeed(params) 72 73 error = null 74 75 hasMore = response.feed.length !== 0 && !!response.cursor 76 77 if (response.cursor) { 78 params = { ...params, cursor: response.cursor } 79 } 80 81 posts.push( 82 ...response.feed.filter((feedPost) => { 83 const uri = feedPost.post.uri as string 84 if (seenUris.has(uri)) return false 85 seenUris.add(uri) 86 return true 87 }), 88 ) 89 } catch (e) { 90 console.error('Failed to load more posts:', e) 91 error = e 92 } finally { 93 loading = false 94 } 95 } 96 97 const callback: IntersectionObserverCallback = (entries, observer) => { 98 entries.forEach((entry) => { 99 if (!entry.isIntersecting) return 100 101 const element = entry.target as HTMLElement 102 const id = element.getAttribute('data-index') 103 104 if (!id) return 105 106 lastSeen = Number(id) 107 108 observer.unobserve(element) 109 }) 110 } 111 112 onMount(() => { 113 const observer = new IntersectionObserver(callback, { 114 threshold: 0.5, 115 }) 116 117 const observePost = (node: Node) => { 118 if ( 119 node instanceof HTMLElement && 120 node.classList.contains('post-container') 121 ) 122 observer.observe(node) 123 } 124 125 const unobservePost = (node: Node) => { 126 if ( 127 node instanceof HTMLElement && 128 node.classList.contains('post-container') 129 ) 130 observer.unobserve(node) 131 } 132 133 document.querySelectorAll('.post-container').forEach(observePost) 134 135 const feed = document.getElementById('feed') 136 if (!feed) return 137 138 new MutationObserver((mutations) => { 139 mutations.forEach(({ addedNodes, removedNodes }) => { 140 addedNodes.forEach(observePost) 141 removedNodes.forEach(unobservePost) 142 }) 143 }).observe(feed, { childList: true, subtree: false }) 144 }) 145 146 $effect(() => { 147 if (listComp) { 148 untrack(() => { 149 if (lastSeen != 0) { 150 listComp?.scrollToIndex(lastSeen, true) 151 } 152 }) 153 } 154 }) 155 156 let initialOffset = $derived(listEl?.offsetTop) 157</script> 158 159<ul class="flex flex-col list-none" bind:this={listEl}> 160 {#key posts} 161 {#if posts?.length == 0} 162 <div class="h-full grid place-items-center my-8"> 163 <Placeholder 164 icon={ArchiveBox} 165 title={$t('routes.frontpage.empty.title')} 166 description={$t('routes.frontpage.empty.description')} 167 > 168 <Button 169 href="/explore/communities" 170 rounding="pill" 171 color="primary" 172 icon={ArrowTopRightOnSquare} 173 > 174 {$t('nav.communities')} 175 </Button> 176 </Placeholder> 177 </div> 178 {:else} 179 <VirtualList 180 id="feed" 181 class="divide-y -mx-3 sm:-mx-6 divide-slate-100 dark:divide-zinc-900" 182 items={posts} 183 {initialOffset} 184 overscan={3} 185 estimatedHeight={settings.view == 'cozy' ? 500 : 150} 186 bind:restore={virtualList} 187 bind:this={listComp} 188 > 189 {#snippet item(row)} 190 {@const feedPost = posts[row]} 191 {@const isPinned = 192 feedPost?.reason?.$type === 'social.coves.feed.defs#reasonPin'} 193 <li 194 in:fly={row < 7 195 ? { duration: 800, easing: expoOut, y: 24, delay: row * 50 } 196 : { opacity: 1, duration: 0 }} 197 data-index={row} 198 class={['relative post-container', row < 7 && '']} 199 > 200 <Post 201 post={feedPost.post} 202 pinned={isPinned} 203 hideCommunity={community} 204 view={isPinned && settings.posts.compactFeatured 205 ? 'compact' 206 : settings.view} 207 class="px-3 sm:px-6 hover:bg-slate-100/30 hover:dark:bg-zinc-900/30 transition-colors" 208 ></Post> 209 </li> 210 {/snippet} 211 </VirtualList> 212 {/if} 213 {/key} 214 215 {#if settings.infiniteScroll && browser && posts.length > 0} 216 {#if error} 217 <Material color="error" class="flex flex-col gap-4"> 218 <div> 219 <Icon 220 src={ExclamationTriangle} 221 size="20" 222 micro 223 class="inline-block rounded-lg clear-both float-left mr-2" 224 /> 225 {#if isAuthError} 226 {$t('toast.sessionExpired')} 227 {:else} 228 {errorMessage(error)} 229 {/if} 230 </div> 231 {#if isAuthError} 232 <Button color="primary" href="/login"> 233 {$t('account.login')} 234 </Button> 235 {:else} 236 <Button 237 color="primary" 238 {loading} 239 disabled={loading} 240 onclick={() => loadMore()} 241 > 242 {$t('message.retry')} 243 </Button> 244 {/if} 245 </Material> 246 {:else if hasMore} 247 <div class="w-full h-32 grid place-items-center"> 248 <Spinner width={24} /> 249 </div> 250 {:else} 251 <div style="border-top-width: 0"> 252 <EndPlaceholder> 253 {$t('routes.frontpage.endFeed')} 254 {#snippet action()} 255 <Button color="tertiary" icon={ChevronDoubleUp}> 256 {$t('routes.post.scrollToTop')} 257 </Button> 258 {/snippet} 259 </EndPlaceholder> 260 </div> 261 {/if} 262 <InfiniteScroll window threshold={300} on:loadMore={loadMore} /> 263 {/if} 264 {@render children?.()} 265</ul>