Coves frontend - a photon fork
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>