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 feed status bar with pull-to-refresh on mobile

Removes the gallery count/refresh bar and adds a reusable
PullToRefresh molecule that refreshes both stories and feed.

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

+107 -35
+87
app/lib/components/molecules/PullToRefresh.svelte
··· 1 + <script lang="ts"> 2 + import type { Snippet } from 'svelte' 3 + 4 + let { 5 + onRefresh, 6 + children, 7 + }: { 8 + onRefresh: () => Promise<void> 9 + children: Snippet 10 + } = $props() 11 + 12 + let refreshing = $state(false) 13 + let pullY = $state(0) 14 + let pulling = $state(false) 15 + let startY = 0 16 + 17 + function onTouchStart(e: TouchEvent) { 18 + const scroller = document.querySelector('main.col-center') 19 + if (scroller && scroller.scrollTop === 0) { 20 + startY = e.touches[0].clientY 21 + pulling = true 22 + } 23 + } 24 + 25 + function onTouchMove(e: TouchEvent) { 26 + if (!pulling) return 27 + const dy = e.touches[0].clientY - startY 28 + if (dy > 0) { 29 + pullY = Math.min(dy * 0.4, 80) 30 + } else { 31 + pullY = 0 32 + } 33 + } 34 + 35 + async function onTouchEnd() { 36 + if (!pulling) return 37 + pulling = false 38 + if (pullY >= 50) { 39 + refreshing = true 40 + pullY = 50 41 + await onRefresh() 42 + refreshing = false 43 + } 44 + pullY = 0 45 + } 46 + </script> 47 + 48 + <!-- svelte-ignore a11y_no_static_element_interactions --> 49 + <div 50 + ontouchstart={onTouchStart} 51 + ontouchmove={onTouchMove} 52 + ontouchend={onTouchEnd} 53 + > 54 + 55 + {#if pullY > 0 || refreshing} 56 + <div class="pull-indicator" style:height="{pullY}px"> 57 + <svg class="pull-spinner" class:spinning={refreshing} width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 58 + <path d="M21 12a9 9 0 1 1-6.219-8.56" /> 59 + </svg> 60 + </div> 61 + {/if} 62 + 63 + {@render children()} 64 + 65 + </div> 66 + 67 + <style> 68 + .pull-indicator { 69 + display: flex; 70 + align-items: flex-end; 71 + justify-content: center; 72 + padding-bottom: 8px; 73 + color: var(--text-muted); 74 + overflow: hidden; 75 + transition: height 0.2s ease; 76 + } 77 + .pull-spinner { 78 + transition: transform 0.2s; 79 + } 80 + .pull-spinner.spinning { 81 + animation: spin 0.8s linear infinite; 82 + } 83 + @keyframes spin { 84 + from { transform: rotate(0deg); } 85 + to { transform: rotate(360deg); } 86 + } 87 + </style>
-28
app/lib/components/organisms/FeedList.svelte
··· 1 1 <script lang="ts"> 2 - import { RefreshCw } from 'lucide-svelte' 3 2 import type { GalleryView, PhotoView } from '$hatk/client' 4 3 import GalleryCard from '../molecules/GalleryCard.svelte' 5 4 import CommentSheet from './CommentSheet.svelte' ··· 90 89 }) 91 90 </script> 92 91 93 - {#if !loading || items.length > 0} 94 - <div class="feed-status"> 95 - <span>{items.length} {items.length === 1 ? 'gallery' : 'galleries'}{cursor ? '+' : ''}</span> 96 - <button class="refresh" onclick={() => load()} title="Refresh"><RefreshCw size={14} /></button> 97 - </div> 98 - {/if} 99 - 100 92 {#if loading && items.length === 0} 101 93 {#each {length: 3} as _} 102 94 <GalleryCardSkeleton /> ··· 133 125 {/if} 134 126 135 127 <style> 136 - .feed-status { 137 - display: flex; 138 - align-items: center; 139 - gap: 12px; 140 - padding: 10px 16px; 141 - font-size: 12px; 142 - color: var(--text-muted); 143 - border-bottom: 1px solid var(--border); 144 - } 145 - .refresh { 146 - margin-left: auto; 147 - cursor: pointer; 148 - font-size: 14px; 149 - color: var(--text-muted); 150 - transition: color 0.15s; 151 - background: none; 152 - border: none; 153 - font-family: inherit; 154 - } 155 - .refresh:hover { color: var(--grain); } 156 128 .error-state, .empty-state { 157 129 padding: 48px; 158 130 text-align: center;
+20 -7
app/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 - import { createQuery } from '@tanstack/svelte-query' 2 + import { createQuery, useQueryClient } from '@tanstack/svelte-query' 3 3 import FeedList from '$lib/components/organisms/FeedList.svelte' 4 4 import FeedTabs from '$lib/components/molecules/FeedTabs.svelte' 5 5 import StoryStrip from '$lib/components/molecules/StoryStrip.svelte' 6 + import PullToRefresh from '$lib/components/molecules/PullToRefresh.svelte' 6 7 import StoryViewer from '$lib/components/organisms/StoryViewer.svelte' 7 8 import StoryCreate from '$lib/components/molecules/StoryCreate.svelte' 8 9 import { recentFeedQuery } from '$lib/queries' 9 10 import OGMeta from '$lib/components/atoms/OGMeta.svelte' 10 11 12 + const queryClient = useQueryClient() 11 13 const feed = createQuery(() => recentFeedQuery()) 12 14 13 15 let showViewer = $state(false) 14 16 let viewerDid = $state('') 15 17 let showCreate = $state(false) 16 18 19 + async function refresh() { 20 + await Promise.all([ 21 + queryClient.invalidateQueries({ queryKey: ['getFeed'] }), 22 + queryClient.invalidateQueries({ queryKey: ['storyAuthors'] }), 23 + ]) 24 + } 25 + 17 26 function openViewer(did: string) { 18 27 viewerDid = did 19 28 showViewer = true ··· 33 42 </script> 34 43 35 44 <OGMeta title="grain" /> 45 + 36 46 <FeedTabs /> 37 - <StoryStrip onCreateStory={openCreate} onViewStory={openViewer} /> 38 - {#if feed.isLoading} 39 - <FeedList feed="recent" skeleton /> 40 - {:else} 41 - <FeedList feed="recent" initialItems={feed.data?.items ?? []} initialCursor={feed.data?.cursor} /> 42 - {/if} 47 + 48 + <PullToRefresh onRefresh={refresh}> 49 + <StoryStrip onCreateStory={openCreate} onViewStory={openViewer} /> 50 + {#if feed.isLoading} 51 + <FeedList feed="recent" skeleton /> 52 + {:else} 53 + <FeedList feed="recent" initialItems={feed.data?.items ?? []} initialCursor={feed.data?.cursor} /> 54 + {/if} 55 + </PullToRefresh> 43 56 44 57 {#if showViewer} 45 58 <StoryViewer initialDid={viewerDid} onclose={closeViewer} />