Coves frontend - a photon fork

feat(feed): fix pagination, auth error handling, and empty states

Thread loadFeed callback through PostListShell and VirtualFeed to
restore infinite scroll pagination on home and community pages. Add
auth error detection (401/403) that shows session-expired message
with login button. Improve feed robustness with null-safe responses,
cursor-based hasMore logic, and proper error re-throwing.

Changes:
- Add loadFeed prop to PostListShell and pass to VirtualFeed
- Create loadFeed functions in home and community page loaders
- Detect XrpcError 401/403 and show login prompt instead of retry
- Move loading=false to finally block in VirtualFeed
- Guard against null feed responses with ?? []
- Add empty state placeholder on home page
- Fix /communities link to /explore/communities
- Update empty feed description copy

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Bretton 257fc6da c5ba47bf

+94 -40
+1 -1
src/lib/app/i18n/en.json
··· 236 236 "endFeed": "You have reached the end of {{community_name; undefined:the feed.; default:{{community_name}}.}}", 237 237 "empty": { 238 238 "title": "No posts", 239 - "description": "There are no posts that match this filter." 239 + "description": "There are no posts in this feed." 240 240 } 241 241 }, 242 242 "modlog": {
+1
src/lib/feature/feeds/feed.svelte.ts
··· 46 46 } catch (err) { 47 47 console.error('[Feed] fetch failed:', err) 48 48 this.error = err 49 + throw err 49 50 } 50 51 } 51 52
+32 -16
src/lib/feature/post/feed/VirtualFeed.svelte
··· 1 1 <script lang="ts"> 2 2 import { browser } from '$app/environment' 3 + import { XrpcError } from '$lib/api/coves/xrpc' 3 4 import type { FeedViewPost, FeedPaginationParams } from '$lib/api/coves/types' 4 5 import { errorMessage } from '$lib/app/error' 5 6 import { t } from '$lib/app/i18n' ··· 49 50 scrollToIndex: (index: number, window?: boolean) => void 50 51 }>() 51 52 52 - let error = $state() 53 + let error = $state<unknown>() 54 + let isAuthError = $derived( 55 + error instanceof XrpcError && 56 + (error.status === 401 || error.status === 403), 57 + ) 53 58 let loading = $state(false) 54 - let hasMore = $state(true) 59 + let hasMore = $state(!!loadFeed) 55 60 56 - let seenUris = new SvelteSet<string>(posts.map((fp) => fp.post.uri as string)) 61 + let seenUris = new SvelteSet<string>( 62 + (posts ?? []).map((fp) => fp.post.uri as string), 63 + ) 57 64 58 65 async function loadMore(): Promise<void> { 59 66 if (!hasMore || loading || !loadFeed) return ··· 65 72 66 73 error = null 67 74 68 - hasMore = response.feed.length !== 0 75 + hasMore = response.feed.length !== 0 && !!response.cursor 69 76 70 77 if (response.cursor) { 71 78 params = { ...params, cursor: response.cursor } ··· 79 86 return true 80 87 }), 81 88 ) 82 - 83 - loading = false 84 89 } catch (e) { 85 90 console.error('Failed to load more posts:', e) 86 91 error = e 92 + } finally { 87 93 loading = false 88 94 } 89 95 } ··· 160 166 description={$t('routes.frontpage.empty.description')} 161 167 > 162 168 <Button 163 - href="/communities" 169 + href="/explore/communities" 164 170 rounding="pill" 165 171 color="primary" 166 172 icon={ArrowTopRightOnSquare} ··· 216 222 micro 217 223 class="inline-block rounded-lg clear-both float-left mr-2" 218 224 /> 219 - {errorMessage(error)} 225 + {#if isAuthError} 226 + {$t('toast.sessionExpired')} 227 + {:else} 228 + {errorMessage(error)} 229 + {/if} 220 230 </div> 221 - <Button 222 - color="primary" 223 - {loading} 224 - disabled={loading} 225 - onclick={() => loadMore()} 226 - > 227 - {$t('message.retry')} 228 - </Button> 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} 229 245 </Material> 230 246 {:else if hasMore} 231 247 <div class="w-full h-32 grid place-items-center">
+5 -1
src/lib/ui/layout/pages/PostListShell.svelte
··· 22 22 extended?: Snippet 23 23 getParams: FeedPaginationParams | Record<string, unknown> 24 24 header?: boolean 25 + loadFeed?: ( 26 + params: FeedPaginationParams, 27 + ) => Promise<{ feed: FeedViewPost[]; cursor?: string }> 25 28 } 26 29 27 30 let { ··· 32 35 extended: passedExtended, 33 36 getParams, 34 37 header = true, 38 + loadFeed, 35 39 }: Props = $props() 36 40 37 41 $effect(() => { ··· 84 88 </Header> 85 89 {/if} 86 90 87 - <FeedComponent bind:posts bind:params={getParams} /> 91 + <FeedComponent bind:posts bind:params={getParams} {loadFeed} /> 88 92 <svelte:element 89 93 this={settings.infiniteScroll && !settings.posts.noVirtualize 90 94 ? 'noscript'
+25 -2
src/routes/+page.svelte
··· 10 10 import ViewSelect from '$lib/feature/filter/ViewSelect.svelte' 11 11 import PostFeed from '$lib/feature/post/feed/PostFeed.svelte' 12 12 import VirtualFeed from '$lib/feature/post/feed/VirtualFeed.svelte' 13 + import Placeholder from '$lib/ui/info/Placeholder.svelte' 13 14 import Skeleton from '$lib/ui/generic/Skeleton.svelte' 14 15 import { Header, Pageination } from '$lib/ui/layout' 15 16 import { Button } from 'mono-svelte' 17 + import { ArchiveBox, ArrowTopRightOnSquare } from 'svelte-hero-icons/dist' 16 18 17 19 let { data = $bindable() } = $props() 18 20 ··· 55 57 56 58 {#await data.feed.value} 57 59 <div class="space-y-4"> 58 - {#each new Array(5) as _, index}{_} 60 + {#each new Array(5) as _, index} 59 61 <div 60 62 class="animate-pop-in" 61 63 style="animation-delay: {index * 50}ms; opacity: 0; width: {(1 / ··· 68 70 </div> 69 71 {:then feed} 70 72 {#if feed} 71 - <FeedComponent bind:posts={feed.feed} bind:params={feed.params} /> 73 + <FeedComponent 74 + bind:posts={feed.feed} 75 + bind:params={feed.params} 76 + loadFeed={data.loadFeed} 77 + /> 72 78 <svelte:element 73 79 this={settings.infiniteScroll && !settings.posts.noVirtualize 74 80 ? 'noscript' ··· 81 87 back={false} 82 88 /> 83 89 </svelte:element> 90 + {:else} 91 + <div class="h-full grid place-items-center my-8"> 92 + <Placeholder 93 + icon={ArchiveBox} 94 + title={$t('routes.frontpage.empty.title')} 95 + description={$t('routes.frontpage.empty.description')} 96 + > 97 + <Button 98 + href="/explore/communities" 99 + rounding="pill" 100 + color="primary" 101 + icon={ArrowTopRightOnSquare} 102 + > 103 + {$t('nav.communities')} 104 + </Button> 105 + </Placeholder> 106 + </div> 84 107 {/if} 85 108 {:catch error} 86 109 <div class="flex flex-col items-center gap-4 py-8 text-center">
+20 -19
src/routes/+page.ts
··· 1 + import type { FeedPaginationParams } from '$lib/api/coves/types' 1 2 import { coves } from '$lib/api/client.svelte' 2 3 import { profile } from '$lib/app/auth.svelte' 3 4 import { t } from '$lib/app/i18n' ··· 18 19 const listing = mapListing(listingType, profile.isAuthenticated) 19 20 20 21 const feedData = feed(route.id, async (params) => { 21 - const isTimeline = params.listing === 'timeline' 22 + const { listing, ...rest } = params 23 + const isTimeline = listing === 'timeline' 22 24 const response = isTimeline 23 - ? await coves({ func: fetch }).getTimeline({ 24 - sort: params.sort, 25 - timeframe: params.timeframe, 26 - limit: params.limit, 27 - cursor: params.cursor, 28 - }) 29 - : await coves({ func: fetch }).getDiscover({ 30 - sort: params.sort, 31 - timeframe: params.timeframe, 32 - limit: params.limit, 33 - cursor: params.cursor, 34 - }) 25 + ? await coves({ func: fetch }).getTimeline(rest) 26 + : await coves({ func: fetch }).getDiscover(rest) 35 27 36 28 return { 37 - feed: response.feed, 29 + feed: response.feed ?? [], 38 30 cursor: response.cursor, 39 31 params: { ...params, cursor: response.cursor }, 40 32 } ··· 46 38 limit: 20, 47 39 }) 48 40 41 + const filters = new ReactiveState({ 42 + sort: mapped.sort, 43 + timeframe: mapped.timeframe, 44 + type_: listing, 45 + }) 46 + 49 47 return { 50 48 feed: new ReactiveState((await awaitIfServer(feedData)).data), 51 - filters: new ReactiveState({ 52 - sort: mapped.sort, 53 - timeframe: mapped.timeframe, 54 - type_: listing, 55 - }), 49 + filters, 50 + loadFeed: async (params: FeedPaginationParams) => { 51 + const isTimeline = filters.value.type_ === 'timeline' 52 + const response = isTimeline 53 + ? await coves({ func: fetch }).getTimeline(params) 54 + : await coves({ func: fetch }).getDiscover(params) 55 + return { feed: response.feed ?? [], cursor: response.cursor } 56 + }, 56 57 contextual: { 57 58 actions: [ 58 59 {
+1
src/routes/c/[handle]/+page.svelte
··· 53 53 params={{ 54 54 sort: data.params.sort, 55 55 }} 56 + loadFeed={data.loadFeed} 56 57 > 57 58 {#snippet extended()} 58 59 {#if data.community}
+9 -1
src/routes/c/[handle]/+page.ts
··· 1 + import type { FeedPaginationParams } from '$lib/api/coves/types' 1 2 import { coves } from '$lib/api/client.svelte' 2 3 import { settings } from '$lib/app/settings.svelte' 3 4 import { mapSort } from '$lib/app/sort' ··· 30 31 ]) 31 32 32 33 return { 33 - feed: feedResponse.feed, 34 + feed: feedResponse.feed ?? [], 34 35 community: communityData, 35 36 cursor: feedResponse.cursor, 36 37 params: { ...p, cursor: feedResponse.cursor }, ··· 45 46 46 47 return { 47 48 ...feedData, 49 + loadFeed: async (params: FeedPaginationParams) => { 50 + const response = await coves({ func: fetch }).getCommunityFeed({ 51 + ...params, 52 + community: communityHandle, 53 + }) 54 + return { feed: response.feed ?? [], cursor: response.cursor } 55 + }, 48 56 slots: { 49 57 sidebar: { 50 58 component: CommunityCard,