grain.social is a photo sharing platform built on atproto. grain.social
atproto photography appview
57
fork

Configure Feed

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

feat: replace load-more buttons with infinite scroll

Convert actorFeedQuery and storyArchiveQuery to infinite queries,
extract reusable infiniteScroll Svelte action, and standardize
page size to 30 across all paginated lists.

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

+78 -123
+20
app/lib/actions/infinite-scroll.ts
··· 1 + /** Svelte action: calls `onIntersect` when the element scrolls into view. */ 2 + export function infiniteScroll(node: HTMLElement, onIntersect: () => void) { 3 + const observer = new IntersectionObserver( 4 + (entries) => { 5 + if (entries[0]?.isIntersecting) { 6 + onIntersect() 7 + // Re-observe so it fires again if the sentinel stays in viewport 8 + // (e.g. when a page returns too few items to scroll it out of view) 9 + observer.unobserve(node) 10 + requestAnimationFrame(() => observer.observe(node)) 11 + } 12 + }, 13 + { rootMargin: '200px' }, 14 + ) 15 + observer.observe(node) 16 + return { 17 + update(newFn: () => void) { onIntersect = newFn }, 18 + destroy() { observer.disconnect() }, 19 + } 20 + }
+11 -57
app/lib/components/molecules/StoryArchive.svelte
··· 1 1 <script lang="ts"> 2 - import { createQuery } from '@tanstack/svelte-query' 3 - import type { StoryView } from '$hatk/client' 2 + import { createInfiniteQuery } from '@tanstack/svelte-query' 4 3 import { storyArchiveQuery } from '$lib/queries' 5 - import { callXrpc } from '$hatk/client' 4 + import Spinner from '$lib/components/atoms/Spinner.svelte' 6 5 import StoryViewer from '$lib/components/organisms/StoryViewer.svelte' 6 + import { infiniteScroll } from '$lib/actions/infinite-scroll' 7 7 8 8 let { did }: { did: string } = $props() 9 9 10 - const initial = createQuery(() => storyArchiveQuery(did)) 10 + const archive = createInfiniteQuery(() => storyArchiveQuery(did)) 11 + const allStories = $derived(archive.data?.pages.flatMap((p) => p.stories ?? []) ?? []) 11 12 12 - let allStories = $state<StoryView[]>([]) 13 - let cursor = $state<string | undefined>(undefined) 14 - let loadingMore = $state(false) 15 - let hasLoadedMore = $state(false) 16 13 let viewingStory = $state<{ uri: string } | null>(null) 17 14 18 - // Sync initial query data into local state (skip if user has loaded more pages) 19 - $effect(() => { 20 - const data = initial.data as { stories?: StoryView[]; cursor?: string } | undefined 21 - if (data?.stories && !hasLoadedMore) { 22 - allStories = data.stories 23 - cursor = data.cursor 24 - } 25 - }) 26 - 27 - async function loadMore() { 28 - if (!cursor || loadingMore) return 29 - loadingMore = true 30 - try { 31 - const result = await callXrpc('social.grain.unspecced.getStoryArchive', { actor: did, cursor }) as { stories?: StoryView[]; cursor?: string } 32 - allStories = [...allStories, ...(result.stories ?? [])] 33 - cursor = result.cursor 34 - hasLoadedMore = true 35 - } finally { 36 - loadingMore = false 37 - } 38 - } 39 - 40 15 function formatDate(dateStr: string): string { 41 16 return new Date(dateStr).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) 42 17 } 43 18 </script> 44 19 45 - {#if initial.isLoading} 20 + {#if archive.isLoading} 46 21 <div class="archive-grid"> 47 22 {#each { length: 6 } as _} 48 23 <div class="cell placeholder"></div> ··· 66 41 {/each} 67 42 </div> 68 43 69 - {#if cursor} 70 - <div class="load-more"> 71 - <button class="load-more-btn" onclick={loadMore} disabled={loadingMore}> 72 - {loadingMore ? 'Loading\u2026' : 'Load more'} 73 - </button> 44 + {#if archive.hasNextPage} 45 + <div use:infiniteScroll={() => archive.fetchNextPage()} class="sentinel"> 46 + {#if archive.isFetchingNextPage}<Spinner />{/if} 74 47 </div> 75 48 {/if} 76 49 {/if} ··· 122 95 color: #fff; 123 96 text-shadow: 0 1px 3px rgba(0, 0, 0, 0.7); 124 97 } 125 - .load-more { 98 + .sentinel { 126 99 display: flex; 127 100 justify-content: center; 128 - padding: 16px; 129 - } 130 - .load-more-btn { 131 - background: none; 132 - border: 1px solid var(--border); 133 - border-radius: 20px; 134 - padding: 8px 24px; 135 - font-size: 13px; 136 - font-weight: 600; 137 - color: var(--text-secondary); 138 - cursor: pointer; 139 - font-family: inherit; 140 - } 141 - .load-more-btn:hover { 142 - background: var(--bg-hover); 143 - color: var(--text-primary); 144 - } 145 - .load-more-btn:disabled { 146 - opacity: 0.5; 147 - cursor: not-allowed; 101 + padding: 20px; 148 102 } 149 103 .empty { 150 104 padding: 32px;
+10 -23
app/lib/components/organisms/FeedList.svelte
··· 6 6 import GalleryCardSkeleton from '../molecules/GalleryCardSkeleton.svelte' 7 7 import { queryFeed } from '$lib/feed' 8 8 import { isAuthenticated } from '$lib/stores' 9 + import Spinner from '../atoms/Spinner.svelte' 10 + import { infiniteScroll } from '$lib/actions/infinite-scroll' 9 11 10 12 let { 11 13 feed, ··· 50 52 loading = true 51 53 error = null 52 54 try { 53 - const result = await queryFeed(feed, { limit: '50', ...params }) 55 + const result = await queryFeed(feed, { limit: '30', ...params }) 54 56 items = result.items ?? [] 55 57 cursor = result.cursor 56 58 } catch (e: any) { ··· 64 66 if (!cursor || loadingMore) return 65 67 loadingMore = true 66 68 try { 67 - const result = await queryFeed(feed, { limit: '50', cursor, ...params }) 69 + const result = await queryFeed(feed, { limit: '30', cursor, ...params }) 68 70 items = [...items, ...(result.items ?? [])] 69 71 cursor = result.cursor 70 72 } finally { ··· 116 118 /> 117 119 118 120 {#if cursor} 119 - <div class="load-more"> 120 - <button class="load-more-btn" onclick={() => loadMore()} disabled={loadingMore}> 121 - {loadingMore ? 'Loading\u2026' : 'Load more'} 122 - </button> 121 + <div use:infiniteScroll={loadMore} class="sentinel"> 122 + {#if loadingMore}<Spinner />{/if} 123 123 </div> 124 124 {/if} 125 125 {/if} ··· 132 132 } 133 133 .error-state { color: var(--danger); } 134 134 .empty-state { display: flex; flex-direction: column; align-items: center; gap: 12px; } 135 - .load-more { padding: 16px; text-align: center; } 136 - .load-more-btn { 137 - padding: 10px 24px; 138 - border-radius: 20px; 139 - background: var(--bg-elevated); 140 - border: 1px solid var(--border); 141 - color: var(--text-secondary); 142 - font-family: var(--font-body); 143 - font-size: 14px; 144 - font-weight: 500; 145 - cursor: pointer; 146 - transition: all 0.12s; 135 + .sentinel { 136 + display: flex; 137 + justify-content: center; 138 + padding: 20px; 147 139 } 148 - .load-more-btn:hover:not(:disabled) { 149 - background: var(--bg-hover); 150 - color: var(--text-primary); 151 - } 152 - .load-more-btn:disabled { opacity: 0.5; cursor: not-allowed; } 153 140 </style>
+8 -21
app/lib/components/organisms/GalleryGrid.svelte
··· 1 1 <script lang="ts"> 2 2 import type { GalleryView, PhotoView } from '$hatk/client' 3 3 import Skeleton from '../atoms/Skeleton.svelte' 4 + import Spinner from '../atoms/Spinner.svelte' 4 5 import { resolveLabels, labelDefsQuery } from '$lib/labels' 5 6 import { createQuery } from '@tanstack/svelte-query' 6 7 import { Info } from 'lucide-svelte' 8 + import { infiniteScroll } from '$lib/actions/infinite-scroll' 7 9 8 10 const labelDefs = createQuery(() => labelDefsQuery()) 9 11 ··· 69 71 {/each} 70 72 </div> 71 73 {#if hasMore} 72 - <div class="load-more"> 73 - <button class="load-more-btn" onclick={() => onLoadMore?.()} disabled={loadingMore}> 74 - {loadingMore ? 'Loading\u2026' : 'Load more'} 75 - </button> 74 + <div use:infiniteScroll={() => onLoadMore?.()} class="sentinel"> 75 + {#if loadingMore}<Spinner />{/if} 76 76 </div> 77 77 {/if} 78 78 {/if} ··· 134 134 font-size: 11px; 135 135 font-weight: 500; 136 136 } 137 - .load-more { padding: 16px; text-align: center; } 138 - .load-more-btn { 139 - padding: 10px 24px; 140 - border-radius: 20px; 141 - background: var(--bg-elevated); 142 - border: 1px solid var(--border); 143 - color: var(--text-secondary); 144 - font-family: var(--font-body); 145 - font-size: 14px; 146 - font-weight: 500; 147 - cursor: pointer; 148 - transition: all 0.12s; 137 + .sentinel { 138 + display: flex; 139 + justify-content: center; 140 + padding: 20px; 149 141 } 150 - .load-more-btn:hover:not(:disabled) { 151 - background: var(--bg-hover); 152 - color: var(--text-primary); 153 - } 154 - .load-more-btn:disabled { opacity: 0.5; cursor: not-allowed; } 155 142 .empty-state { 156 143 padding: 48px; 157 144 text-align: center;
+17 -8
app/lib/queries.ts
··· 26 26 staleTime: 60_000, 27 27 }); 28 28 29 - export const actorFeedQuery = (did: string, limit = 50, f?: Fetch) => 30 - queryOptions({ 29 + export const actorFeedQuery = (did: string, f?: Fetch) => 30 + infiniteQueryOptions({ 31 31 queryKey: ["getFeed", "actor", did], 32 - queryFn: () => callXrpc("dev.hatk.getFeed", { feed: "actor", actor: did, limit }, f), 32 + initialPageParam: undefined as string | undefined, 33 + queryFn: ({ pageParam }) => 34 + callXrpc( 35 + "dev.hatk.getFeed", 36 + { feed: "actor", actor: did, limit: 30, ...(pageParam ? { cursor: pageParam } : {}) }, 37 + f, 38 + ), 39 + getNextPageParam: (lastPage) => lastPage?.cursor, 33 40 staleTime: 60_000, 34 41 }); 35 42 ··· 111 118 staleTime: 30_000, 112 119 }); 113 120 114 - export const storyArchiveQuery = (did: string, cursor?: string, f?: Fetch) => 115 - queryOptions({ 116 - queryKey: ["stories", "archive", did, cursor], 117 - queryFn: () => 121 + export const storyArchiveQuery = (did: string, f?: Fetch) => 122 + infiniteQueryOptions({ 123 + queryKey: ["stories", "archive", did], 124 + initialPageParam: undefined as string | undefined, 125 + queryFn: ({ pageParam }) => 118 126 callXrpc( 119 127 "social.grain.unspecced.getStoryArchive", 120 - { actor: did, ...(cursor ? { cursor } : {}) }, 128 + { actor: did, limit: 30, ...(pageParam ? { cursor: pageParam } : {}) }, 121 129 f, 122 130 ).then((r) => r ?? { stories: [], cursor: undefined }), 131 + getNextPageParam: (lastPage) => lastPage?.cursor, 123 132 staleTime: 60_000, 124 133 }); 125 134
+2 -11
app/routes/notifications/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import { onMount } from 'svelte' 3 3 import { createQuery, useQueryClient } from '@tanstack/svelte-query' 4 + import { infiniteScroll } from '$lib/actions/infinite-scroll' 4 5 import { notificationsQuery } from '$lib/queries' 5 6 import { markNotificationsSeen } from '$lib/preferences' 6 7 import { viewer as viewerStore } from '$lib/stores' ··· 23 24 let allItems: NotifItem[] = $state([]) 24 25 let currentCursor: string | undefined = $state(undefined) 25 26 let hasMore = $state(true) 26 - let sentinel: HTMLDivElement | undefined = $state(undefined) 27 27 28 28 $effect(() => { 29 29 if (notifications.data) { ··· 56 56 } 57 57 } 58 58 59 - $effect(() => { 60 - if (!sentinel) return 61 - const observer = new IntersectionObserver( 62 - (entries) => { if (entries[0]?.isIntersecting) loadMore() }, 63 - { rootMargin: '200px' }, 64 - ) 65 - observer.observe(sentinel) 66 - return () => observer.disconnect() 67 - }) 68 59 </script> 69 60 70 61 <OGMeta title="Notifications - grain" /> ··· 80 71 <NotificationItem {notif} /> 81 72 {/each} 82 73 {#if hasMore} 83 - <div bind:this={sentinel} class="sentinel"> 74 + <div use:infiniteScroll={loadMore} class="sentinel"> 84 75 {#if loadingMore}<Spinner />{/if} 85 76 </div> 86 77 {/if}
+9 -2
app/routes/profile/[did]/+page.svelte
··· 48 48 const isOwnProfile = $derived(viewerDid === did) 49 49 50 50 const profile = createQuery(() => actorProfileQuery(did, viewerDid)) 51 - const feed = createQuery(() => actorFeedQuery(did)) 51 + const feed = createInfiniteQuery(() => actorFeedQuery(did)) 52 + const feedItems = $derived(feed.data?.pages.flatMap((p) => p.items ?? []) ?? []) 52 53 const favorites = createInfiniteQuery(() => ({ 53 54 ...actorFavoritesInfiniteQuery(did), 54 55 enabled: isOwnProfile, ··· 235 236 onLoadMore={() => favorites.fetchNextPage()} 236 237 /> 237 238 {:else} 238 - <GalleryGrid items={feed.data?.items ?? []} loading={feed.isLoading} /> 239 + <GalleryGrid 240 + items={feedItems} 241 + loading={feed.isLoading} 242 + hasMore={feed.hasNextPage} 243 + loadingMore={feed.isFetchingNextPage} 244 + onLoadMore={() => feed.fetchNextPage()} 245 + /> 239 246 {/if} 240 247 {/if} 241 248
+1 -1
app/routes/profile/[did]/+page.ts
··· 7 7 const { queryClient, viewer } = await parent(); 8 8 const prefetch = Promise.all([ 9 9 queryClient.prefetchQuery(actorProfileQuery(did, viewer?.did, fetch)), 10 - queryClient.prefetchQuery(actorFeedQuery(did, 50, fetch)), 10 + queryClient.prefetchInfiniteQuery(actorFeedQuery(did, fetch)), 11 11 ]); 12 12 if (!browser) await prefetch; 13 13 return { did };