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: add story archive with hydration refactor

Add story archive feature allowing users to browse all past stories
(beyond 24h) on their own profile via a new Clock tab. Includes new
getStoryArchive XRPC endpoint with cursor pagination, StoryArchive
thumbnail grid component, and single-story StoryViewer mode.

Extract shared story hydration into server/hydrate/stories.ts and
move gallery hydration from server/feeds/_hydrate.ts to
server/hydrate/galleries.ts for consistency.

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

+1098 -139
+155
app/lib/components/molecules/StoryArchive.svelte
··· 1 + <script lang="ts"> 2 + import { createQuery } from '@tanstack/svelte-query' 3 + import type { StoryView } from '$hatk/client' 4 + import { storyArchiveQuery } from '$lib/queries' 5 + import { callXrpc } from '$hatk/client' 6 + import StoryViewer from '$lib/components/organisms/StoryViewer.svelte' 7 + 8 + let { did }: { did: string } = $props() 9 + 10 + const initial = createQuery(() => storyArchiveQuery(did)) 11 + 12 + let allStories = $state<StoryView[]>([]) 13 + let cursor = $state<string | undefined>(undefined) 14 + let loadingMore = $state(false) 15 + let hasLoadedMore = $state(false) 16 + let viewingStory = $state<{ uri: string } | null>(null) 17 + 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 + function formatDate(dateStr: string): string { 41 + return new Date(dateStr).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) 42 + } 43 + </script> 44 + 45 + {#if initial.isLoading} 46 + <div class="archive-grid"> 47 + {#each { length: 6 } as _} 48 + <div class="cell placeholder"></div> 49 + {/each} 50 + </div> 51 + {:else if allStories.length === 0} 52 + <div class="empty">No stories yet.</div> 53 + {:else} 54 + <div class="archive-grid"> 55 + {#each allStories as story (story.uri)} 56 + <button class="cell" onclick={() => (viewingStory = { uri: story.uri })}> 57 + <img 58 + src={story.thumb} 59 + alt="" 60 + decoding="async" 61 + loading="lazy" 62 + onload={(e) => (e.currentTarget as HTMLImageElement).classList.add('loaded')} 63 + /> 64 + <span class="date-badge">{formatDate(story.createdAt)}</span> 65 + </button> 66 + {/each} 67 + </div> 68 + 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> 74 + </div> 75 + {/if} 76 + {/if} 77 + 78 + {#if viewingStory} 79 + <StoryViewer initialDid={did} singleStory={viewingStory} onclose={() => (viewingStory = null)} /> 80 + {/if} 81 + 82 + <style> 83 + .archive-grid { 84 + display: grid; 85 + grid-template-columns: repeat(3, 1fr); 86 + gap: 2px; 87 + } 88 + .cell { 89 + display: block; 90 + aspect-ratio: 3 / 4; 91 + background: var(--bg-elevated); 92 + position: relative; 93 + overflow: hidden; 94 + border: none; 95 + padding: 0; 96 + cursor: pointer; 97 + } 98 + .cell img { 99 + display: block; 100 + width: 100%; 101 + height: 100%; 102 + object-fit: cover; 103 + opacity: 0; 104 + transition: opacity 0.2s ease; 105 + } 106 + .cell img:global(.loaded) { 107 + opacity: 1; 108 + } 109 + .placeholder { 110 + animation: pulse 1.5s ease-in-out infinite; 111 + } 112 + @keyframes pulse { 113 + 0%, 100% { opacity: 0.4; } 114 + 50% { opacity: 0.7; } 115 + } 116 + .date-badge { 117 + position: absolute; 118 + bottom: 6px; 119 + left: 6px; 120 + font-size: 11px; 121 + font-weight: 600; 122 + color: #fff; 123 + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.7); 124 + } 125 + .load-more { 126 + display: flex; 127 + 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; 148 + } 149 + .empty { 150 + padding: 32px; 151 + text-align: center; 152 + color: var(--text-muted); 153 + font-size: 14px; 154 + } 155 + </style>
+22 -7
app/lib/components/organisms/StoryViewer.svelte
··· 3 3 import { X, MapPin, Trash2, AlertTriangle } from 'lucide-svelte' 4 4 import { goto } from '$app/navigation' 5 5 import { callXrpc } from '$hatk/client' 6 - import { storiesQuery, storyAuthorsQuery } from '$lib/queries' 6 + import { storiesQuery, storyAuthorsQuery, storyQuery } from '$lib/queries' 7 7 import { viewer as viewerStore } from '$lib/stores' 8 8 import { resolveLabels, labelDefsQuery } from '$lib/labels' 9 9 import ReportButton from '$lib/components/molecules/ReportButton.svelte' ··· 12 12 let { 13 13 initialDid, 14 14 onclose, 15 + singleStory, 15 16 }: { 16 17 initialDid: string 17 18 onclose: () => void 19 + singleStory?: { uri: string } | null 18 20 } = $props() 19 21 20 22 const queryClient = useQueryClient() ··· 52 54 }) 53 55 54 56 const currentDid = $derived(authorDids[currentAuthorIndex] ?? initialDid) 55 - const stories = createQuery(() => storiesQuery(currentDid)) 57 + const stories = createQuery(() => ({ 58 + ...storiesQuery(currentDid), 59 + enabled: !singleStory, 60 + })) 61 + const singleStoryData = createQuery(() => ({ 62 + ...storyQuery(singleStory?.uri ?? ''), 63 + enabled: !!singleStory, 64 + })) 56 65 57 - const currentStory = $derived(stories.data?.[currentStoryIndex]) 58 - const totalStories = $derived(stories.data?.length ?? 0) 66 + const currentStory = $derived( 67 + singleStory ? (singleStoryData.data ?? undefined) : stories.data?.[currentStoryIndex] 68 + ) 69 + const totalStories = $derived(singleStory ? 1 : (stories.data?.length ?? 0)) 59 70 const isOwn = $derived(currentDid === $viewerStore?.did) 60 71 const bskyUrl = $derived((currentStory as any)?.crossPost?.url ?? null) 61 72 ··· 108 119 currentStoryIndex = Math.max(0, currentStoryIndex - 1) 109 120 } 110 121 await queryClient.invalidateQueries({ queryKey: ['stories', currentDid] }) 122 + queryClient.invalidateQueries({ queryKey: ['stories', 'archive'] }) 111 123 queryClient.invalidateQueries({ queryKey: ['storyAuthors'] }) 112 124 } catch (err) { 113 125 console.error('Failed to delete story:', err) ··· 123 135 const mins = Math.floor(diff / (1000 * 60)) 124 136 return `${mins}m` 125 137 } 126 - return `${hours}h` 138 + if (hours < 24) { 139 + return `${hours}h` 140 + } 141 + return new Date(dateStr).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) 127 142 } 128 143 129 144 function startTimer() { ··· 149 164 if (currentStoryIndex < totalStories - 1) { 150 165 currentStoryIndex++ 151 166 progress = 0 152 - } else if (currentAuthorIndex < authorDids.length - 1) { 167 + } else if (!singleStory && currentAuthorIndex < authorDids.length - 1) { 153 168 currentAuthorIndex++ 154 169 // currentStoryIndex reset by $effect above 155 170 } else { ··· 161 176 if (currentStoryIndex > 0) { 162 177 currentStoryIndex-- 163 178 progress = 0 164 - } else if (currentAuthorIndex > 0) { 179 + } else if (!singleStory && currentAuthorIndex > 0) { 165 180 currentAuthorIndex-- 166 181 // currentStoryIndex reset by $effect above 167 182 }
+12
app/lib/queries.ts
··· 88 88 staleTime: 30_000, 89 89 }); 90 90 91 + export const storyArchiveQuery = (did: string, cursor?: string, f?: Fetch) => 92 + queryOptions({ 93 + queryKey: ["stories", "archive", did, cursor], 94 + queryFn: () => 95 + callXrpc( 96 + "social.grain.unspecced.getStoryArchive", 97 + { actor: did, ...(cursor ? { cursor } : {}) }, 98 + f, 99 + ).then((r) => r ?? { stories: [], cursor: undefined }), 100 + staleTime: 60_000, 101 + }); 102 + 91 103 // ─── Preferences ──────────────────────────────────────────────────── 92 104 93 105 export const preferencesQuery = (f?: Fetch) =>
+13 -5
app/routes/profile/[did]/+page.svelte
··· 8 8 import Skeleton from '$lib/components/atoms/Skeleton.svelte' 9 9 import FollowButton from '$lib/components/molecules/FollowButton.svelte' 10 10 import RichText from '$lib/components/atoms/RichText.svelte' 11 - import { ExternalLink, Grid3x3, List } from 'lucide-svelte' 11 + import { ExternalLink, Grid3x3, List, Clock } from 'lucide-svelte' 12 12 import { createQuery } from '@tanstack/svelte-query' 13 13 import { actorProfileQuery, actorFeedQuery, knownFollowersQuery, storiesQuery } from '$lib/queries' 14 14 import { viewer as viewerStore } from '$lib/stores' 15 15 import StoryViewer from '$lib/components/organisms/StoryViewer.svelte' 16 + import StoryArchive from '$lib/components/molecules/StoryArchive.svelte' 16 17 17 18 let { data } = $props() 18 19 let lightboxSrc: string | null = $state(null) 19 - let viewMode: 'grid' | 'list' = $state('grid') 20 + let viewMode: 'grid' | 'list' | 'stories' = $state('grid') 20 21 let followersOffset = $state(0) 21 22 let showStoryViewer = $state(false) 22 - 23 23 const did = $derived(data.did) 24 - $effect(() => { void did; void profile.data; followersOffset = 0 }) 24 + $effect(() => { void did; void profile.data; followersOffset = 0; viewMode = 'grid' }) 25 25 const viewerDid = $derived($viewerStore?.did) 26 + const isOwnProfile = $derived(viewerDid === did) 26 27 27 28 const profile = createQuery(() => actorProfileQuery(did, viewerDid)) 28 29 const feed = createQuery(() => actorFeedQuery(did)) ··· 113 114 <button class="toggle-btn" class:active={viewMode === 'list'} onclick={() => (viewMode = 'list')} aria-label="List view"> 114 115 <List size={18} /> 115 116 </button> 117 + {#if isOwnProfile} 118 + <button class="toggle-btn" class:active={viewMode === 'stories'} onclick={() => (viewMode = 'stories')} aria-label="Story archive"> 119 + <Clock size={18} /> 120 + </button> 121 + {/if} 116 122 </div> 117 123 118 - {#if viewMode === 'grid'} 124 + {#if viewMode === 'stories' && isOwnProfile} 125 + <StoryArchive {did} /> 126 + {:else if viewMode === 'grid'} 119 127 <GalleryGrid items={feed.data?.items ?? []} loading={feed.isLoading} /> 120 128 {:else} 121 129 {#if feed.isLoading}
+684
docs/plans/2026-03-29-story-archive-plan.md
··· 1 + # Story Archive Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Let users browse all their past stories (beyond 24h) in a thumbnail grid on their own profile page. 6 + 7 + **Architecture:** New XRPC endpoint mirrors getStories without the 24-hour cutoff, with cursor pagination. A StoryArchive grid component renders thumbnails inline on the profile page (own profile only). StoryViewer gains a single-story mode for opening archived stories. 8 + 9 + **Tech Stack:** hatk (XRPC server framework), Svelte 5, TanStack Query, existing StoryView types. 10 + 11 + --- 12 + 13 + ### Task 1: Lexicon + Server Endpoint 14 + 15 + **Files:** 16 + - Create: `lexicons/social/grain/unspecced/getStoryArchive.json` 17 + - Create: `server/xrpc/getStoryArchive.ts` 18 + 19 + **Step 1: Create the lexicon file** 20 + 21 + Create `lexicons/social/grain/unspecced/getStoryArchive.json`: 22 + 23 + ```json 24 + { 25 + "lexicon": 1, 26 + "id": "social.grain.unspecced.getStoryArchive", 27 + "defs": { 28 + "main": { 29 + "type": "query", 30 + "description": "Get all stories for an actor, including expired ones. For archive browsing.", 31 + "parameters": { 32 + "type": "params", 33 + "required": ["actor"], 34 + "properties": { 35 + "actor": { "type": "string", "format": "did" }, 36 + "limit": { "type": "integer", "minimum": 1, "maximum": 100, "default": 50 }, 37 + "cursor": { "type": "string" } 38 + } 39 + }, 40 + "output": { 41 + "encoding": "application/json", 42 + "schema": { 43 + "type": "object", 44 + "required": ["stories"], 45 + "properties": { 46 + "stories": { 47 + "type": "array", 48 + "items": { "type": "ref", "ref": "social.grain.story.defs#storyView" } 49 + }, 50 + "cursor": { "type": "string" } 51 + } 52 + } 53 + } 54 + } 55 + } 56 + } 57 + ``` 58 + 59 + **Step 2: Create the server endpoint** 60 + 61 + Create `server/xrpc/getStoryArchive.ts`. This mirrors `server/xrpc/getStories.ts` with these differences: 62 + - No 24-hour cutoff 63 + - `ORDER BY created_at DESC` (newest first, not ASC) 64 + - Cursor-based pagination using `created_at` timestamp 65 + 66 + ```typescript 67 + import { defineQuery } from "$hatk"; 68 + import { views } from "$hatk"; 69 + import type { GrainActorProfile, Story, Label } from "$hatk"; 70 + import { HIDE_LABELS } from "../labels/_hidden.ts"; 71 + import { lookupCrossPosts } from "../feeds/_hydrate.ts"; 72 + 73 + export default defineQuery("social.grain.unspecced.getStoryArchive", async (ctx) => { 74 + const { db, ok } = ctx; 75 + const actor = ctx.params.actor; 76 + if (!actor) return ok({ stories: [] }); 77 + 78 + const limit = Math.min(Number(ctx.params.limit) || 50, 100); 79 + const cursor = ctx.params.cursor as string | undefined; 80 + 81 + const queryParams: (string | number)[] = [actor, limit + 1]; 82 + let cursorClause = ""; 83 + if (cursor) { 84 + cursorClause = ` AND s.created_at < $3`; 85 + queryParams.push(cursor); 86 + } 87 + 88 + const rows = (await db.query( 89 + `SELECT s.uri, s.cid, s.did, s.media, s.aspect_ratio, s.location, s.address, s.created_at 90 + FROM "social.grain.story" s 91 + LEFT JOIN _repos r ON s.did = r.did 92 + WHERE s.did = $1 93 + AND (r.status IS NULL OR r.status != 'takendown') 94 + ${cursorClause} 95 + ORDER BY s.created_at DESC 96 + LIMIT $2`, 97 + queryParams, 98 + )) as { 99 + uri: string; 100 + cid: string; 101 + did: string; 102 + media: string; 103 + aspect_ratio: string; 104 + location: string | null; 105 + address: string | null; 106 + created_at: string; 107 + }[]; 108 + 109 + const hasMore = rows.length > limit; 110 + const pageRows = hasMore ? rows.slice(0, limit) : rows; 111 + 112 + // Resolve author profile 113 + const profiles = await ctx.lookup<GrainActorProfile>("social.grain.actor.profile", "did", [ 114 + actor, 115 + ]); 116 + const author = profiles.get(actor); 117 + const profileView = author 118 + ? views.grainActorDefsProfileView({ 119 + cid: author.cid, 120 + did: author.did, 121 + handle: author.handle ?? author.did, 122 + displayName: author.value.displayName, 123 + avatar: ctx.blobUrl(author.did, author.value.avatar) ?? undefined, 124 + }) 125 + : views.grainActorDefsProfileView({ 126 + cid: "", 127 + did: actor, 128 + handle: actor, 129 + }); 130 + 131 + // Hydrate external labels 132 + const storyUris = pageRows.map((r) => r.uri); 133 + const labelsByUri = 134 + storyUris.length > 0 135 + ? ((await ctx.labels(storyUris)) as Map<string, Label[]>) 136 + : new Map<string, Label[]>(); 137 + 138 + // Filter stories with hide-severity labels 139 + const visibleRows = pageRows.filter((row) => { 140 + const labels = labelsByUri.get(row.uri); 141 + if (!labels) return true; 142 + const latestByVal = new Map<string, Label>(); 143 + for (const l of labels) { 144 + const prev = latestByVal.get(l.val); 145 + if (!prev || l.cts > prev.cts) latestByVal.set(l.val, l); 146 + } 147 + return ![...latestByVal.values()].some((l) => HIDE_LABELS.has(l.val) && !l.neg); 148 + }); 149 + 150 + // Cross-post lookup 151 + const crossPosts = await lookupCrossPosts(db, visibleRows, "story"); 152 + 153 + const stories = visibleRows.map((row) => { 154 + let blobRef: any; 155 + try { 156 + blobRef = typeof row.media === "string" ? JSON.parse(row.media) : row.media; 157 + } catch { 158 + blobRef = row.media; 159 + } 160 + 161 + let aspectRatio: { width: number; height: number }; 162 + try { 163 + aspectRatio = 164 + typeof row.aspect_ratio === "string" ? JSON.parse(row.aspect_ratio) : row.aspect_ratio; 165 + } catch { 166 + aspectRatio = { width: 4, height: 3 }; 167 + } 168 + 169 + let location: Story["location"] | null = null; 170 + if (row.location) { 171 + try { 172 + location = typeof row.location === "string" ? JSON.parse(row.location) : row.location; 173 + } catch { 174 + location = null; 175 + } 176 + } 177 + 178 + let address: Story["address"] | null = null; 179 + if (row.address) { 180 + try { 181 + address = typeof row.address === "string" ? JSON.parse(row.address) : row.address; 182 + } catch { 183 + address = null; 184 + } 185 + } 186 + 187 + return views.storyView({ 188 + uri: row.uri, 189 + cid: row.cid, 190 + creator: profileView, 191 + thumb: ctx.blobUrl(row.did, blobRef, "feed_thumbnail") ?? "", 192 + fullsize: ctx.blobUrl(row.did, blobRef, "feed_fullsize") ?? "", 193 + aspectRatio, 194 + ...(location 195 + ? { 196 + location: { name: location.name, value: location.value }, 197 + ...(address ? { address } : {}), 198 + } 199 + : {}), 200 + createdAt: row.created_at, 201 + ...(labelsByUri.has(row.uri) ? { labels: labelsByUri.get(row.uri) } : {}), 202 + ...(crossPosts.has(row.uri) ? { crossPost: { url: crossPosts.get(row.uri)! } } : {}), 203 + }); 204 + }); 205 + 206 + const nextCursor = hasMore ? pageRows[pageRows.length - 1].created_at : undefined; 207 + 208 + return ok({ stories, ...(nextCursor ? { cursor: nextCursor } : {}) }); 209 + }); 210 + ``` 211 + 212 + **Step 3: Regenerate types** 213 + 214 + Run: `npx hatk generate types` 215 + Expected: Output includes `GetStoryArchive` in the generated types list. 216 + 217 + **Step 4: Commit** 218 + 219 + ```bash 220 + git add lexicons/social/grain/unspecced/getStoryArchive.json server/xrpc/getStoryArchive.ts hatk.generated.ts hatk.generated.client.ts 221 + git commit -m "feat: add getStoryArchive endpoint (no 24h cutoff, cursor pagination)" 222 + ``` 223 + 224 + --- 225 + 226 + ### Task 2: Client Query 227 + 228 + **Files:** 229 + - Modify: `app/lib/queries.ts` (add after `storiesQuery` around line 89) 230 + 231 + **Step 1: Add `storyArchiveQuery` to queries.ts** 232 + 233 + Add after the existing `storiesQuery` definition: 234 + 235 + ```typescript 236 + export const storyArchiveQuery = (did: string, cursor?: string, f?: Fetch) => 237 + queryOptions({ 238 + queryKey: ["stories", "archive", did, cursor], 239 + queryFn: () => 240 + callXrpc("social.grain.unspecced.getStoryArchive", { actor: did, ...(cursor ? { cursor } : {}) }, f).then( 241 + (r) => r ?? { stories: [], cursor: undefined }, 242 + ), 243 + staleTime: 60_000, 244 + }); 245 + ``` 246 + 247 + **Step 2: Verify types** 248 + 249 + Run: `npx svelte-check --workspace app 2>&1 | grep -E 'ERROR|COMPLETED'` 250 + Expected: 0 ERRORS 251 + 252 + **Step 3: Commit** 253 + 254 + ```bash 255 + git add app/lib/queries.ts 256 + git commit -m "feat: add storyArchiveQuery client query" 257 + ``` 258 + 259 + --- 260 + 261 + ### Task 3: StoryViewer — Single-Story Mode + Date Formatting 262 + 263 + **Files:** 264 + - Modify: `app/lib/components/organisms/StoryViewer.svelte` 265 + 266 + **Step 1: Add `singleStory` prop** 267 + 268 + Add a new optional prop to the `$props()` destructuring at lines 12-18: 269 + 270 + ```typescript 271 + let { 272 + initialDid, 273 + onclose, 274 + singleStory, 275 + }: { 276 + initialDid: string 277 + onclose: () => void 278 + singleStory?: { uri: string } | null 279 + } = $props() 280 + ``` 281 + 282 + **Step 2: Override story loading in single-story mode** 283 + 284 + When `singleStory` is provided, use `storyQuery` instead of `storiesQuery` and disable author swiping. 285 + 286 + Add a new import at line 6: 287 + 288 + ```typescript 289 + import { storiesQuery, storyAuthorsQuery, storyQuery } from '$lib/queries' 290 + ``` 291 + 292 + Add after the `storyAuthors` query (around line 23): 293 + 294 + ```typescript 295 + // Single-story mode: load just one story by URI 296 + const singleStoryData = createQuery(() => ({ 297 + ...storyQuery(singleStory?.uri ?? ''), 298 + enabled: !!singleStory, 299 + })) 300 + ``` 301 + 302 + Override `stories` and `authorDids` derivations. Replace the existing `stories` line (line 55) and `totalStories` (line 58) with: 303 + 304 + ```typescript 305 + const stories = createQuery(() => 306 + singleStory ? { ...storyQuery(singleStory.uri), enabled: true } : storiesQuery(currentDid) 307 + ) 308 + 309 + const currentStory = $derived( 310 + singleStory 311 + ? (singleStoryData.data ?? undefined) 312 + : stories.data?.[currentStoryIndex] 313 + ) 314 + const totalStories = $derived(singleStory ? 1 : (stories.data?.length ?? 0)) 315 + ``` 316 + 317 + **Step 3: Disable author swiping in single-story mode** 318 + 319 + In the `next()` function (line 148), wrap the author-advance logic: 320 + 321 + ```typescript 322 + function next() { 323 + if (currentStoryIndex < totalStories - 1) { 324 + currentStoryIndex++ 325 + progress = 0 326 + } else if (!singleStory && currentAuthorIndex < authorDids.length - 1) { 327 + currentAuthorIndex++ 328 + } else { 329 + onclose() 330 + } 331 + } 332 + ``` 333 + 334 + In the `prev()` function (line 160): 335 + 336 + ```typescript 337 + function prev() { 338 + if (currentStoryIndex > 0) { 339 + currentStoryIndex-- 340 + progress = 0 341 + } else if (!singleStory && currentAuthorIndex > 0) { 342 + currentAuthorIndex-- 343 + } 344 + } 345 + ``` 346 + 347 + **Step 4: Update `timeAgo` for old stories** 348 + 349 + Replace the `timeAgo` function (lines 119-127) with: 350 + 351 + ```typescript 352 + function timeAgo(dateStr: string): string { 353 + const diff = Date.now() - new Date(dateStr).getTime() 354 + const hours = Math.floor(diff / (1000 * 60 * 60)) 355 + if (hours < 1) { 356 + const mins = Math.floor(diff / (1000 * 60)) 357 + return `${mins}m` 358 + } 359 + if (hours < 24) { 360 + return `${hours}h` 361 + } 362 + return new Date(dateStr).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) 363 + } 364 + ``` 365 + 366 + **Step 5: Verify types** 367 + 368 + Run: `npx svelte-check --workspace app 2>&1 | grep -E 'ERROR|COMPLETED'` 369 + Expected: 0 ERRORS 370 + 371 + **Step 6: Commit** 372 + 373 + ```bash 374 + git add app/lib/components/organisms/StoryViewer.svelte 375 + git commit -m "feat: add single-story mode and full date display to StoryViewer" 376 + ``` 377 + 378 + --- 379 + 380 + ### Task 4: StoryArchive Grid Component 381 + 382 + **Files:** 383 + - Create: `app/lib/components/molecules/StoryArchive.svelte` 384 + 385 + **Step 1: Create the component** 386 + 387 + This component shows a 3-column grid of square story thumbnails with date overlays. It uses cursor pagination with a "Load more" button. Tapping a thumbnail opens StoryViewer in single-story mode. 388 + 389 + ```svelte 390 + <script lang="ts"> 391 + import { createQuery } from '@tanstack/svelte-query' 392 + import type { StoryView } from '$hatk/client' 393 + import { storyArchiveQuery } from '$lib/queries' 394 + import StoryViewer from '$lib/components/organisms/StoryViewer.svelte' 395 + import { callXrpc } from '$hatk/client' 396 + 397 + let { did }: { did: string } = $props() 398 + 399 + const initial = createQuery(() => storyArchiveQuery(did)) 400 + 401 + let allStories = $state<StoryView[]>([]) 402 + let cursor = $state<string | undefined>(undefined) 403 + let loadingMore = $state(false) 404 + let viewingStory = $state<{ uri: string } | null>(null) 405 + 406 + // Sync initial query data into local state 407 + $effect(() => { 408 + const data = initial.data as { stories?: StoryView[]; cursor?: string } | undefined 409 + if (data?.stories) { 410 + allStories = data.stories 411 + cursor = data.cursor 412 + } 413 + }) 414 + 415 + async function loadMore() { 416 + if (!cursor || loadingMore) return 417 + loadingMore = true 418 + try { 419 + const result = await callXrpc('social.grain.unspecced.getStoryArchive', { actor: did, cursor }) as { stories?: StoryView[]; cursor?: string } 420 + allStories = [...allStories, ...(result.stories ?? [])] 421 + cursor = result.cursor 422 + } finally { 423 + loadingMore = false 424 + } 425 + } 426 + 427 + function formatDate(dateStr: string): string { 428 + return new Date(dateStr).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) 429 + } 430 + </script> 431 + 432 + {#if initial.isLoading} 433 + <div class="archive-grid"> 434 + {#each { length: 6 } as _} 435 + <div class="cell placeholder"></div> 436 + {/each} 437 + </div> 438 + {:else if allStories.length === 0} 439 + <div class="empty">No stories yet.</div> 440 + {:else} 441 + <div class="archive-grid"> 442 + {#each allStories as story (story.uri)} 443 + <button class="cell" onclick={() => (viewingStory = { uri: story.uri })}> 444 + <img 445 + src={story.thumb} 446 + alt="" 447 + decoding="async" 448 + loading="lazy" 449 + onload={(e) => (e.currentTarget as HTMLImageElement).classList.add('loaded')} 450 + /> 451 + <span class="date-badge">{formatDate(story.createdAt)}</span> 452 + </button> 453 + {/each} 454 + </div> 455 + 456 + {#if cursor} 457 + <div class="load-more"> 458 + <button class="load-more-btn" onclick={loadMore} disabled={loadingMore}> 459 + {loadingMore ? 'Loading…' : 'Load more'} 460 + </button> 461 + </div> 462 + {/if} 463 + {/if} 464 + 465 + {#if viewingStory} 466 + <StoryViewer initialDid={did} singleStory={viewingStory} onclose={() => (viewingStory = null)} /> 467 + {/if} 468 + 469 + <style> 470 + .archive-grid { 471 + display: grid; 472 + grid-template-columns: repeat(3, 1fr); 473 + gap: 2px; 474 + } 475 + .cell { 476 + display: block; 477 + aspect-ratio: 1; 478 + background: var(--bg-elevated); 479 + position: relative; 480 + overflow: hidden; 481 + border: none; 482 + padding: 0; 483 + cursor: pointer; 484 + } 485 + .cell img { 486 + display: block; 487 + width: 100%; 488 + height: 100%; 489 + object-fit: cover; 490 + opacity: 0; 491 + transition: opacity 0.2s ease; 492 + } 493 + .cell img:global(.loaded) { 494 + opacity: 1; 495 + } 496 + .placeholder { 497 + animation: pulse 1.5s ease-in-out infinite; 498 + } 499 + @keyframes pulse { 500 + 0%, 100% { opacity: 0.4; } 501 + 50% { opacity: 0.7; } 502 + } 503 + .date-badge { 504 + position: absolute; 505 + bottom: 6px; 506 + left: 6px; 507 + font-size: 11px; 508 + font-weight: 600; 509 + color: #fff; 510 + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.7); 511 + } 512 + .load-more { 513 + display: flex; 514 + justify-content: center; 515 + padding: 16px; 516 + } 517 + .load-more-btn { 518 + background: none; 519 + border: 1px solid var(--border); 520 + border-radius: 20px; 521 + padding: 8px 24px; 522 + font-size: 13px; 523 + font-weight: 600; 524 + color: var(--text-secondary); 525 + cursor: pointer; 526 + font-family: inherit; 527 + } 528 + .load-more-btn:hover { 529 + background: var(--bg-hover); 530 + color: var(--text-primary); 531 + } 532 + .load-more-btn:disabled { 533 + opacity: 0.5; 534 + cursor: not-allowed; 535 + } 536 + .empty { 537 + padding: 32px; 538 + text-align: center; 539 + color: var(--text-muted); 540 + font-size: 14px; 541 + } 542 + </style> 543 + ``` 544 + 545 + **Step 2: Verify types** 546 + 547 + Run: `npx svelte-check --workspace app 2>&1 | grep -E 'ERROR|COMPLETED'` 548 + Expected: 0 ERRORS 549 + 550 + **Step 3: Commit** 551 + 552 + ```bash 553 + git add app/lib/components/molecules/StoryArchive.svelte 554 + git commit -m "feat: add StoryArchive thumbnail grid component" 555 + ``` 556 + 557 + --- 558 + 559 + ### Task 5: Wire Into Profile Page 560 + 561 + **Files:** 562 + - Modify: `app/routes/profile/[did]/+page.svelte` 563 + 564 + **Step 1: Add imports and state** 565 + 566 + Add to the imports (after the StoryViewer import, line 15): 567 + 568 + ```typescript 569 + import StoryArchive from '$lib/components/molecules/StoryArchive.svelte' 570 + import { Archive } from 'lucide-svelte' 571 + ``` 572 + 573 + Add state variable (after `showStoryViewer` at line 21): 574 + 575 + ```typescript 576 + let showArchive = $state(false) 577 + ``` 578 + 579 + Add derived (after `hasStory` at line 30): 580 + 581 + ```typescript 582 + const isOwnProfile = $derived(viewerDid === did) 583 + ``` 584 + 585 + **Step 2: Add archive button to the profile header** 586 + 587 + Insert after the `.links-row` div (after line 83, before the knownFollowers block): 588 + 589 + ```svelte 590 + {#if isOwnProfile} 591 + <button class="archive-btn" onclick={() => (showArchive = !showArchive)}> 592 + <Archive size={14} /> 593 + Story Archive 594 + </button> 595 + {/if} 596 + ``` 597 + 598 + **Step 3: Add archive section to the page body** 599 + 600 + Insert after the view-toggle section (after line 116, before the grid/list content): 601 + 602 + ```svelte 603 + {#if showArchive && isOwnProfile} 604 + <div class="archive-section"> 605 + <div class="archive-header"> 606 + <h3 class="archive-title">Story Archive</h3> 607 + <button class="archive-close" onclick={() => (showArchive = false)}>&times;</button> 608 + </div> 609 + <StoryArchive {did} /> 610 + </div> 611 + {/if} 612 + ``` 613 + 614 + **Step 4: Add styles** 615 + 616 + Add to the `<style>` block: 617 + 618 + ```css 619 + .archive-btn { 620 + display: inline-flex; 621 + align-items: center; 622 + gap: 6px; 623 + padding: 6px 14px; 624 + border-radius: 20px; 625 + background: var(--bg-elevated); 626 + border: 1px solid var(--border); 627 + font-size: 13px; 628 + font-weight: 500; 629 + color: var(--text-secondary); 630 + cursor: pointer; 631 + font-family: inherit; 632 + transition: all 0.12s; 633 + } 634 + .archive-btn:hover { 635 + background: var(--bg-hover); 636 + color: var(--text-primary); 637 + } 638 + .archive-section { 639 + border-bottom: 1px solid var(--border); 640 + } 641 + .archive-header { 642 + display: flex; 643 + align-items: center; 644 + justify-content: space-between; 645 + padding: 12px 16px; 646 + } 647 + .archive-title { 648 + font-size: 15px; 649 + font-weight: 600; 650 + margin: 0; 651 + } 652 + .archive-close { 653 + background: none; 654 + border: none; 655 + color: var(--text-muted); 656 + font-size: 20px; 657 + cursor: pointer; 658 + padding: 4px 8px; 659 + line-height: 1; 660 + } 661 + .archive-close:hover { 662 + color: var(--text-primary); 663 + } 664 + ``` 665 + 666 + **Step 5: Reset archive on profile change** 667 + 668 + Update the existing `$effect` at line 24 to also reset archive state: 669 + 670 + ```typescript 671 + $effect(() => { void did; void profile.data; followersOffset = 0; showArchive = false }) 672 + ``` 673 + 674 + **Step 6: Verify types** 675 + 676 + Run: `npx svelte-check --workspace app 2>&1 | grep -E 'ERROR|COMPLETED'` 677 + Expected: 0 ERRORS 678 + 679 + **Step 7: Commit** 680 + 681 + ```bash 682 + git add app/routes/profile/[did]/+page.svelte 683 + git commit -m "feat: add Story Archive section to own profile page" 684 + ```
+1 -1
hatk.generated.client.ts
··· 3 3 // to avoid pulling in server-only dependencies. 4 4 export type { XrpcSchema } from './hatk.generated.ts' 5 5 import type { XrpcSchema } from './hatk.generated.ts' 6 - export type { BskyActorProfile, Post, Postgate, Threadgate, BskyGraphFollow, CreateReport, DescribeCollections, DescribeFeeds, DescribeLabels, GetFeed, GetPreferences, GetRecord, GetRecords, PutPreference, SearchRecords, UploadBlob, GrainActorProfile, Comment, Favorite, Gallery, Item, GrainGraphFollow, Photo, Exif, Story, DeleteGallery, GetActorProfile, GetCameras, GetFollowers, GetFollowing, GetGallery, GetGalleryThread, GetKnownFollowers, GetLocations, GetNotifications, GetStories, GetStory, GetStoryAuthors, GetSuggestedFollows, SearchGalleries, SearchProfiles, RecordRegistry, CreateRecord, DeleteRecord, PutRecord, Nux, MutedWord, SavedFeed, StatusView, BskyActorDefsProfileView, BskyActorDefsViewerState, FeedViewPref, LabelersPref, InterestsPref, KnownFollowers, MutedWordsPref, SavedFeedsPref, ThreadViewPref, DeclaredAgePref, HiddenPostsPref, LabelerPrefItem, AdultContentPref, BskyAppStatePref, ContentLabelPref, ProfileViewBasic, SavedFeedsPrefV2, VerificationView, ProfileAssociated, VerificationPrefs, VerificationState, PersonalDetailsPref, BskyActorDefsProfileViewDetailed, BskyAppProgressGuide, LiveEventPreferences, ProfileAssociatedChat, ProfileAssociatedGerm, PostInteractionSettingsPref, ProfileAssociatedActivitySubscription, BskyEmbedDefsAspectRatio, ExternalView, External, ViewExternal, ImagesView, Image, ViewImage, RecordView, ViewRecord, ViewBlocked, ViewDetached, ViewNotFound, RecordWithMediaView, VideoView, Caption, PostView, BskyFeedDefsReplyRef, ReasonPin, BlockedPost, Interaction, BskyFeedDefsViewerState, FeedViewPost, NotFoundPost, ReasonRepost, BlockedAuthor, GeneratorView, ThreadContext, ThreadViewPost, ThreadgateView, SkeletonFeedPost, SkeletonReasonPin, GeneratorViewerState, SkeletonReasonRepost, Entity, PostReplyRef, TextSlice, DisableRule, ListRule, MentionRule, FollowerRule, FollowingRule, ListView, ListItemView, Relationship, ListViewBasic, NotFoundActor, ListViewerState, StarterPackView, StarterPackViewBasic, LabelerView, LabelerPolicies, LabelerViewerState, LabelerViewDetailed, Preference, Preferences, RecordDeleted, ChatPreference, ActivitySubscription, FilterablePreference, SubjectActivitySubscription, Tag, Link, Mention, ByteSlice, Label, SelfLabels, SelfLabel, LabelValueDefinition, LabelValueDefinitionStrings, RepoRef, LabelDefinition, LabelLocale, GrainActorDefsProfileView, GrainActorDefsProfileViewDetailed, GrainActorDefsViewerState, CommentView, GrainDefsAspectRatio, GalleryView, CrossPostInfo, GrainGalleryDefsViewerState, PhotoView, ExifView, GalleryState, StoryView, CameraItem, GetFollowersFollowerItem, FollowingItem, GetKnownFollowersFollowerItem, LocationItem, NotificationItem, StoryAuthor, SuggestedItem, ProfileSearchResult } from './hatk.generated.ts' 6 + export type { BskyActorProfile, Post, Postgate, Threadgate, BskyGraphFollow, CreateReport, DescribeCollections, DescribeFeeds, DescribeLabels, GetFeed, GetPreferences, GetRecord, GetRecords, PutPreference, SearchRecords, UploadBlob, GrainActorProfile, Comment, Favorite, Gallery, Item, GrainGraphFollow, Photo, Exif, Story, DeleteGallery, GetActorProfile, GetCameras, GetFollowers, GetFollowing, GetGallery, GetGalleryThread, GetKnownFollowers, GetLocations, GetNotifications, GetStories, GetStory, GetStoryArchive, GetStoryAuthors, GetSuggestedFollows, SearchGalleries, SearchProfiles, RecordRegistry, CreateRecord, DeleteRecord, PutRecord, Nux, MutedWord, SavedFeed, StatusView, BskyActorDefsProfileView, BskyActorDefsViewerState, FeedViewPref, LabelersPref, InterestsPref, KnownFollowers, MutedWordsPref, SavedFeedsPref, ThreadViewPref, DeclaredAgePref, HiddenPostsPref, LabelerPrefItem, AdultContentPref, BskyAppStatePref, ContentLabelPref, ProfileViewBasic, SavedFeedsPrefV2, VerificationView, ProfileAssociated, VerificationPrefs, VerificationState, PersonalDetailsPref, BskyActorDefsProfileViewDetailed, BskyAppProgressGuide, LiveEventPreferences, ProfileAssociatedChat, ProfileAssociatedGerm, PostInteractionSettingsPref, ProfileAssociatedActivitySubscription, BskyEmbedDefsAspectRatio, ExternalView, External, ViewExternal, ImagesView, Image, ViewImage, RecordView, ViewRecord, ViewBlocked, ViewDetached, ViewNotFound, RecordWithMediaView, VideoView, Caption, PostView, BskyFeedDefsReplyRef, ReasonPin, BlockedPost, Interaction, BskyFeedDefsViewerState, FeedViewPost, NotFoundPost, ReasonRepost, BlockedAuthor, GeneratorView, ThreadContext, ThreadViewPost, ThreadgateView, SkeletonFeedPost, SkeletonReasonPin, GeneratorViewerState, SkeletonReasonRepost, Entity, PostReplyRef, TextSlice, DisableRule, ListRule, MentionRule, FollowerRule, FollowingRule, ListView, ListItemView, Relationship, ListViewBasic, NotFoundActor, ListViewerState, StarterPackView, StarterPackViewBasic, LabelerView, LabelerPolicies, LabelerViewerState, LabelerViewDetailed, Preference, Preferences, RecordDeleted, ChatPreference, ActivitySubscription, FilterablePreference, SubjectActivitySubscription, Tag, Link, Mention, ByteSlice, Label, SelfLabels, SelfLabel, LabelValueDefinition, LabelValueDefinitionStrings, RepoRef, LabelDefinition, LabelLocale, GrainActorDefsProfileView, GrainActorDefsProfileViewDetailed, GrainActorDefsViewerState, CommentView, GrainDefsAspectRatio, GalleryView, CrossPostInfo, GrainGalleryDefsViewerState, PhotoView, ExifView, GalleryState, StoryView, CameraItem, GetFollowersFollowerItem, FollowingItem, GetKnownFollowersFollowerItem, LocationItem, NotificationItem, StoryAuthor, SuggestedItem, ProfileSearchResult } from './hatk.generated.ts' 7 7 8 8 const _procedures = new Set(['dev.hatk.createRecord', 'dev.hatk.createReport', 'dev.hatk.deleteRecord', 'dev.hatk.putPreference', 'dev.hatk.putRecord', 'social.grain.unspecced.deleteGallery']) 9 9 const _blobInputs = new Set(['dev.hatk.uploadBlob'])
+4
hatk.generated.ts
··· 71 71 const getNotificationsLex = {"lexicon":1,"id":"social.grain.unspecced.getNotifications","defs":{"main":{"type":"query","description":"Get notifications for the authenticated user.","parameters":{"type":"params","properties":{"limit":{"type":"integer","minimum":1,"maximum":100,"default":20},"cursor":{"type":"string"},"countOnly":{"type":"boolean","description":"If true, only return unseenCount without hydrating notifications."}}},"output":{"encoding":"application/json","schema":{"type":"object","required":["notifications"],"properties":{"notifications":{"type":"array","items":{"type":"ref","ref":"#notificationItem"}},"cursor":{"type":"string"},"unseenCount":{"type":"integer"}}}}},"notificationItem":{"type":"object","required":["uri","reason","createdAt","author"],"properties":{"uri":{"type":"string","format":"at-uri"},"reason":{"type":"string","knownValues":["gallery-favorite","gallery-comment","gallery-comment-mention","gallery-mention","reply","follow"]},"createdAt":{"type":"string","format":"datetime"},"author":{"type":"ref","ref":"social.grain.actor.defs#profileView"},"galleryUri":{"type":"string","format":"at-uri"},"galleryTitle":{"type":"string"},"galleryThumb":{"type":"string"},"commentText":{"type":"string"},"replyToText":{"type":"string"}}}}} as const 72 72 const getStoriesLex = {"lexicon":1,"id":"social.grain.unspecced.getStories","defs":{"main":{"type":"query","description":"Get a user's active stories (posted within the last 24 hours).","parameters":{"type":"params","required":["actor"],"properties":{"actor":{"type":"string","format":"did"}}},"output":{"encoding":"application/json","schema":{"type":"object","required":["stories"],"properties":{"stories":{"type":"array","items":{"type":"ref","ref":"social.grain.story.defs#storyView"}}}}}}}} as const 73 73 const getStoryLex = {"lexicon":1,"id":"social.grain.unspecced.getStory","defs":{"main":{"type":"query","parameters":{"type":"params","required":["story"],"properties":{"story":{"type":"string","format":"at-uri"}}},"output":{"encoding":"application/json","schema":{"type":"object","properties":{"story":{"type":"ref","ref":"social.grain.story.defs#storyView"}}}}}}} as const 74 + const getStoryArchiveLex = {"lexicon":1,"id":"social.grain.unspecced.getStoryArchive","defs":{"main":{"type":"query","description":"Get all stories for an actor, including expired ones. For archive browsing.","parameters":{"type":"params","required":["actor"],"properties":{"actor":{"type":"string","format":"did"},"limit":{"type":"integer","minimum":1,"maximum":100,"default":50},"cursor":{"type":"string"}}},"output":{"encoding":"application/json","schema":{"type":"object","required":["stories"],"properties":{"stories":{"type":"array","items":{"type":"ref","ref":"social.grain.story.defs#storyView"}},"cursor":{"type":"string"}}}}}}} as const 74 75 const getStoryAuthorsLex = {"lexicon":1,"id":"social.grain.unspecced.getStoryAuthors","defs":{"main":{"type":"query","description":"Get authors who have active stories (posted within the last 24 hours).","output":{"encoding":"application/json","schema":{"type":"object","required":["authors"],"properties":{"authors":{"type":"array","items":{"type":"ref","ref":"social.grain.unspecced.getStoryAuthors#storyAuthor"}}}}}},"storyAuthor":{"type":"object","required":["profile","storyCount","latestAt"],"properties":{"profile":{"type":"ref","ref":"social.grain.actor.defs#profileView"},"storyCount":{"type":"integer"},"latestAt":{"type":"string","format":"datetime"}}}}} as const 75 76 const getSuggestedFollowsLex = {"lexicon":1,"id":"social.grain.unspecced.getSuggestedFollows","defs":{"main":{"type":"query","description":"Get suggested profiles to follow based on bsky follow graph.","parameters":{"type":"params","required":["actor"],"properties":{"actor":{"type":"string","format":"did"},"limit":{"type":"integer","minimum":1,"maximum":20,"default":10}}},"output":{"encoding":"application/json","schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"ref","ref":"social.grain.unspecced.getSuggestedFollows#suggestedItem"}}}}}},"suggestedItem":{"type":"object","required":["did"],"properties":{"did":{"type":"string","format":"did"},"handle":{"type":"string"},"displayName":{"type":"string"},"description":{"type":"string"},"avatar":{"type":"string"},"followersCount":{"type":"integer"}}}}} as const 76 77 const searchGalleriesLex = {"lexicon":1,"id":"social.grain.unspecced.searchGalleries","defs":{"main":{"type":"query","description":"Full-text search for galleries, returning full gallery views.","parameters":{"type":"params","required":["q"],"properties":{"q":{"type":"string","description":"Search query"},"limit":{"type":"integer","minimum":1,"maximum":100,"default":30},"cursor":{"type":"string"},"fuzzy":{"type":"boolean","default":true}}},"output":{"encoding":"application/json","schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"ref","ref":"social.grain.gallery.defs#galleryView"}},"cursor":{"type":"string"}}}}}}} as const ··· 143 144 'social.grain.unspecced.getNotifications': typeof getNotificationsLex 144 145 'social.grain.unspecced.getStories': typeof getStoriesLex 145 146 'social.grain.unspecced.getStory': typeof getStoryLex 147 + 'social.grain.unspecced.getStoryArchive': typeof getStoryArchiveLex 146 148 'social.grain.unspecced.getStoryAuthors': typeof getStoryAuthorsLex 147 149 'social.grain.unspecced.getSuggestedFollows': typeof getSuggestedFollowsLex 148 150 'social.grain.unspecced.searchGalleries': typeof searchGalleriesLex ··· 188 190 export type GetNotifications = Prettify<LexQuery<typeof getNotificationsLex, Registry>> 189 191 export type GetStories = Prettify<LexQuery<typeof getStoriesLex, Registry>> 190 192 export type GetStory = Prettify<LexQuery<typeof getStoryLex, Registry>> 193 + export type GetStoryArchive = Prettify<LexQuery<typeof getStoryArchiveLex, Registry>> 191 194 export type GetStoryAuthors = Prettify<LexQuery<typeof getStoryAuthorsLex, Registry>> 192 195 export type GetSuggestedFollows = Prettify<LexQuery<typeof getSuggestedFollowsLex, Registry>> 193 196 export type SearchGalleries = Prettify<LexQuery<typeof searchGalleriesLex, Registry>> ··· 386 389 'social.grain.unspecced.getNotifications': GetNotifications 387 390 'social.grain.unspecced.getStories': GetStories 388 391 'social.grain.unspecced.getStory': GetStory 392 + 'social.grain.unspecced.getStoryArchive': GetStoryArchive 389 393 'social.grain.unspecced.getStoryAuthors': GetStoryAuthors 390 394 'social.grain.unspecced.getSuggestedFollows': GetSuggestedFollows 391 395 'social.grain.unspecced.searchGalleries': SearchGalleries
+33
lexicons/social/grain/unspecced/getStoryArchive.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.grain.unspecced.getStoryArchive", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get all stories for an actor, including expired ones. For archive browsing.", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["actor"], 11 + "properties": { 12 + "actor": { "type": "string", "format": "did" }, 13 + "limit": { "type": "integer", "minimum": 1, "maximum": 100, "default": 50 }, 14 + "cursor": { "type": "string" } 15 + } 16 + }, 17 + "output": { 18 + "encoding": "application/json", 19 + "schema": { 20 + "type": "object", 21 + "required": ["stories"], 22 + "properties": { 23 + "stories": { 24 + "type": "array", 25 + "items": { "type": "ref", "ref": "social.grain.story.defs#storyView" } 26 + }, 27 + "cursor": { "type": "string" } 28 + } 29 + } 30 + } 31 + } 32 + } 33 + }
server/feeds/_hydrate.ts server/hydrate/galleries.ts
+1 -1
server/feeds/actor.ts
··· 1 1 import { defineFeed } from "$hatk"; 2 - import { hydrateGalleries } from "./_hydrate.ts"; 2 + import { hydrateGalleries } from "../hydrate/galleries.ts"; 3 3 import { hideLabelsFilter } from "../labels/_hidden.ts"; 4 4 5 5 export default defineFeed({
+1 -1
server/feeds/camera.ts
··· 2 2 // GET /xrpc/dev.hatk.getFeed?feed=camera&camera=Sony+A7III&limit=50 3 3 4 4 import { defineFeed } from "$hatk"; 5 - import { hydrateGalleries } from "./_hydrate.ts"; 5 + import { hydrateGalleries } from "../hydrate/galleries.ts"; 6 6 import { hideLabelsFilter } from "../labels/_hidden.ts"; 7 7 8 8 export default defineFeed({
+1 -1
server/feeds/following.ts
··· 1 1 import { defineFeed } from "$hatk"; 2 - import { hydrateGalleries } from "./_hydrate.ts"; 2 + import { hydrateGalleries } from "../hydrate/galleries.ts"; 3 3 import { hideLabelsFilter } from "../labels/_hidden.ts"; 4 4 5 5 export default defineFeed({
+1 -1
server/feeds/hashtag.ts
··· 1 1 import { defineFeed } from "$hatk"; 2 - import { hydrateGalleries } from "./_hydrate.ts"; 2 + import { hydrateGalleries } from "../hydrate/galleries.ts"; 3 3 import { hideLabelsFilter } from "../labels/_hidden.ts"; 4 4 5 5 export default defineFeed({
+1 -1
server/feeds/location.ts
··· 5 5 // For city-level queries, matches galleries whose venue H3 is a child of the city cell. 6 6 7 7 import { defineFeed } from "$hatk"; 8 - import { hydrateGalleries } from "./_hydrate.ts"; 8 + import { hydrateGalleries } from "../hydrate/galleries.ts"; 9 9 import { getResolution, cellToParent } from "h3-js"; 10 10 import { hideLabelsFilter } from "../labels/_hidden.ts"; 11 11
+1 -1
server/feeds/recent.ts
··· 1 1 import { defineFeed } from "$hatk"; 2 - import { hydrateGalleries } from "./_hydrate.ts"; 2 + import { hydrateGalleries } from "../hydrate/galleries.ts"; 3 3 import { hideLabelsFilter } from "../labels/_hidden.ts"; 4 4 5 5 export default defineFeed({
+121
server/hydrate/stories.ts
··· 1 + import { views } from "$hatk"; 2 + import type { GrainActorProfile, Story, Label, Row, BaseContext } from "$hatk"; 3 + import { HIDE_LABELS } from "../labels/_hidden.ts"; 4 + import { lookupCrossPosts } from "./galleries.ts"; 5 + 6 + export type StoryRow = { 7 + uri: string; 8 + cid: string; 9 + did: string; 10 + media: string; 11 + aspect_ratio: string; 12 + location: string | null; 13 + address: string | null; 14 + created_at: string; 15 + }; 16 + 17 + /** 18 + * Hydrate raw story rows into StoryView objects. 19 + * Resolves the author profile, filters by label moderation, and maps to views. 20 + */ 21 + export async function hydrateStories( 22 + ctx: BaseContext, 23 + actor: string, 24 + rows: StoryRow[], 25 + ) { 26 + // Resolve author profile 27 + const profiles = await ctx.lookup<GrainActorProfile>("social.grain.actor.profile", "did", [ 28 + actor, 29 + ]); 30 + const author = profiles.get(actor); 31 + const profileView = author 32 + ? views.grainActorDefsProfileView({ 33 + cid: author.cid, 34 + did: author.did, 35 + handle: author.handle ?? author.did, 36 + displayName: author.value.displayName, 37 + avatar: ctx.blobUrl(author.did, author.value.avatar) ?? undefined, 38 + }) 39 + : views.grainActorDefsProfileView({ 40 + cid: "", 41 + did: actor, 42 + handle: actor, 43 + }); 44 + 45 + // Hydrate external labels 46 + const storyUris = rows.map((r) => r.uri); 47 + const labelsByUri = 48 + storyUris.length > 0 49 + ? ((await ctx.labels(storyUris)) as Map<string, Label[]>) 50 + : new Map<string, Label[]>(); 51 + 52 + // Filter stories with hide-severity labels (latest entry per val wins) 53 + const visibleRows = rows.filter((row) => { 54 + const labels = labelsByUri.get(row.uri); 55 + if (!labels) return true; 56 + const latestByVal = new Map<string, Label>(); 57 + for (const l of labels) { 58 + const prev = latestByVal.get(l.val); 59 + if (!prev || l.cts > prev.cts) latestByVal.set(l.val, l); 60 + } 61 + return ![...latestByVal.values()].some((l) => HIDE_LABELS.has(l.val) && !l.neg); 62 + }); 63 + 64 + // Cross-post lookup 65 + const crossPosts = await lookupCrossPosts(ctx.db, visibleRows, "story"); 66 + 67 + const stories = visibleRows.map((row) => { 68 + let blobRef: any; 69 + try { 70 + blobRef = typeof row.media === "string" ? JSON.parse(row.media) : row.media; 71 + } catch { 72 + blobRef = row.media; 73 + } 74 + 75 + let aspectRatio: { width: number; height: number }; 76 + try { 77 + aspectRatio = 78 + typeof row.aspect_ratio === "string" ? JSON.parse(row.aspect_ratio) : row.aspect_ratio; 79 + } catch { 80 + aspectRatio = { width: 4, height: 3 }; 81 + } 82 + 83 + let location: Story["location"] | null = null; 84 + if (row.location) { 85 + try { 86 + location = typeof row.location === "string" ? JSON.parse(row.location) : row.location; 87 + } catch { 88 + location = null; 89 + } 90 + } 91 + 92 + let address: Story["address"] | null = null; 93 + if (row.address) { 94 + try { 95 + address = typeof row.address === "string" ? JSON.parse(row.address) : row.address; 96 + } catch { 97 + address = null; 98 + } 99 + } 100 + 101 + return views.storyView({ 102 + uri: row.uri, 103 + cid: row.cid, 104 + creator: profileView, 105 + thumb: ctx.blobUrl(row.did, blobRef, "feed_thumbnail") ?? "", 106 + fullsize: ctx.blobUrl(row.did, blobRef, "feed_fullsize") ?? "", 107 + aspectRatio, 108 + ...(location 109 + ? { 110 + location: { name: location.name, value: location.value }, 111 + ...(address ? { address } : {}), 112 + } 113 + : {}), 114 + createdAt: row.created_at, 115 + ...(labelsByUri.has(row.uri) ? { labels: labelsByUri.get(row.uri) } : {}), 116 + ...(crossPosts.has(row.uri) ? { crossPost: { url: crossPosts.get(row.uri)! } } : {}), 117 + }); 118 + }); 119 + 120 + return stories; 121 + }
+1 -1
server/xrpc/getGallery.ts
··· 1 1 import { defineQuery, InvalidRequestError } from "$hatk"; 2 2 import type { Gallery } from "$hatk"; 3 - import { hydrateGalleries } from "../feeds/_hydrate.ts"; 3 + import { hydrateGalleries } from "../hydrate/galleries.ts"; 4 4 5 5 export default defineQuery("social.grain.unspecced.getGallery", async (ctx) => { 6 6 const { ok, params, db } = ctx;
+3 -115
server/xrpc/getStories.ts
··· 1 1 import { defineQuery } from "$hatk"; 2 - import { views } from "$hatk"; 3 - import type { GrainActorProfile, Story, Label } from "$hatk"; 4 - import { HIDE_LABELS } from "../labels/_hidden.ts"; 5 - import { lookupCrossPosts } from "../feeds/_hydrate.ts"; 2 + import { hydrateStories, type StoryRow } from "../hydrate/stories.ts"; 6 3 7 4 const TWENTY_FOUR_HOURS = 24 * 60 * 60 * 1000; 8 5 ··· 13 10 14 11 const cutoff = new Date(Date.now() - TWENTY_FOUR_HOURS).toISOString(); 15 12 16 - // hatk stores aspect_ratio and location as JSON TEXT columns, media as JSON blob ref 17 13 const rows = (await db.query( 18 14 `SELECT s.uri, s.cid, s.did, s.media, s.aspect_ratio, s.location, s.address, s.created_at 19 15 FROM "social.grain.story" s ··· 22 18 AND (r.status IS NULL OR r.status != 'takendown') 23 19 ORDER BY s.created_at ASC`, 24 20 [actor, cutoff], 25 - )) as { 26 - uri: string; 27 - cid: string; 28 - did: string; 29 - media: string; 30 - aspect_ratio: string; 31 - location: string | null; 32 - address: string | null; 33 - created_at: string; 34 - }[]; 35 - 36 - // Resolve author profile 37 - const profiles = await ctx.lookup<GrainActorProfile>("social.grain.actor.profile", "did", [ 38 - actor, 39 - ]); 40 - const author = profiles.get(actor); 41 - const profileView = author 42 - ? views.grainActorDefsProfileView({ 43 - cid: author.cid, 44 - did: author.did, 45 - handle: author.handle ?? author.did, 46 - displayName: author.value.displayName, 47 - avatar: ctx.blobUrl(author.did, author.value.avatar) ?? undefined, 48 - }) 49 - : views.grainActorDefsProfileView({ 50 - cid: "", 51 - did: actor, 52 - handle: actor, 53 - }); 54 - 55 - // Hydrate external labels for story URIs 56 - const storyUris = rows.map((r) => r.uri); 57 - const labelsByUri = 58 - storyUris.length > 0 59 - ? ((await ctx.labels(storyUris)) as Map<string, Label[]>) 60 - : new Map<string, Label[]>(); 61 - 62 - // Filter out stories with active hide-severity labels (latest entry per val wins) 63 - const visibleRows = rows.filter((row) => { 64 - const labels = labelsByUri.get(row.uri); 65 - if (!labels) return true; 66 - const latestByVal = new Map<string, Label>(); 67 - for (const l of labels) { 68 - const prev = latestByVal.get(l.val); 69 - if (!prev || l.cts > prev.cts) latestByVal.set(l.val, l); 70 - } 71 - return ![...latestByVal.values()].some((l) => HIDE_LABELS.has(l.val) && !l.neg); 72 - }); 73 - 74 - // Cross-post lookup 75 - const crossPosts = await lookupCrossPosts(db, visibleRows, "story"); 76 - 77 - const stories = visibleRows.map((row) => { 78 - // Parse the JSON blob reference for URL generation 79 - let blobRef: any; 80 - try { 81 - blobRef = typeof row.media === "string" ? JSON.parse(row.media) : row.media; 82 - } catch { 83 - blobRef = row.media; 84 - } 85 - 86 - // Parse aspect_ratio JSON (stored as e.g. {"width":4,"height":3}) 87 - let aspectRatio: { width: number; height: number }; 88 - try { 89 - aspectRatio = 90 - typeof row.aspect_ratio === "string" ? JSON.parse(row.aspect_ratio) : row.aspect_ratio; 91 - } catch { 92 - aspectRatio = { width: 4, height: 3 }; 93 - } 21 + )) as StoryRow[]; 94 22 95 - // Parse location JSON if present 96 - let location: Story["location"] | null = null; 97 - if (row.location) { 98 - try { 99 - location = typeof row.location === "string" ? JSON.parse(row.location) : row.location; 100 - } catch { 101 - location = null; 102 - } 103 - } 104 - 105 - // Parse address JSON if present 106 - let address: Story["address"] | null = null; 107 - if (row.address) { 108 - try { 109 - address = typeof row.address === "string" ? JSON.parse(row.address) : row.address; 110 - } catch { 111 - address = null; 112 - } 113 - } 114 - 115 - return views.storyView({ 116 - uri: row.uri, 117 - cid: row.cid, 118 - creator: profileView, 119 - thumb: ctx.blobUrl(row.did, blobRef, "feed_thumbnail") ?? "", 120 - fullsize: ctx.blobUrl(row.did, blobRef, "feed_fullsize") ?? "", 121 - aspectRatio, 122 - ...(location 123 - ? { 124 - location: { 125 - name: location.name, 126 - value: location.value, 127 - }, 128 - ...(address ? { address } : {}), 129 - } 130 - : {}), 131 - createdAt: row.created_at, 132 - ...(labelsByUri.has(row.uri) ? { labels: labelsByUri.get(row.uri) } : {}), 133 - ...(crossPosts.has(row.uri) ? { crossPost: { url: crossPosts.get(row.uri)! } } : {}), 134 - }); 135 - }); 23 + const stories = await hydrateStories(ctx, actor, rows); 136 24 137 25 return ok({ stories }); 138 26 });
+3 -3
server/xrpc/getStory.ts
··· 1 1 import { defineQuery } from "$hatk"; 2 2 import { views } from "$hatk"; 3 3 import type { GrainActorProfile, Story } from "$hatk"; 4 - import { lookupCrossPosts } from "../feeds/_hydrate.ts"; 4 + import { lookupCrossPosts } from "../hydrate/galleries.ts"; 5 5 6 6 export default defineQuery("social.grain.unspecced.getStory", async (ctx) => { 7 7 const { db, ok } = ctx; 8 8 const storyUri = ctx.params.story; 9 - if (!storyUri) return ok({ story: null }); 9 + if (!storyUri) return ok({}); 10 10 11 11 const rows = (await db.query( 12 12 `SELECT uri, cid, did, media, aspect_ratio, location, address, created_at ··· 25 25 }[]; 26 26 27 27 const row = rows[0]; 28 - if (!row) return ok({ story: null }); 28 + if (!row) return ok({}); 29 29 30 30 // Resolve author profile 31 31 const profiles = await ctx.lookup<GrainActorProfile>("social.grain.actor.profile", "did", [
+39
server/xrpc/getStoryArchive.ts
··· 1 + import { defineQuery } from "$hatk"; 2 + import { hydrateStories, type StoryRow } from "../hydrate/stories.ts"; 3 + 4 + export default defineQuery("social.grain.unspecced.getStoryArchive", async (ctx) => { 5 + const { db, ok } = ctx; 6 + const actor = ctx.params.actor; 7 + if (!actor) return ok({ stories: [] }); 8 + 9 + const limit = Math.min(Number(ctx.params.limit) || 50, 100); 10 + const cursor = ctx.params.cursor as string | undefined; 11 + 12 + const queryParams: (string | number)[] = [actor, limit + 1]; 13 + let cursorClause = ""; 14 + if (cursor) { 15 + cursorClause = ` AND s.created_at < $3`; 16 + queryParams.push(cursor); 17 + } 18 + 19 + const rows = (await db.query( 20 + `SELECT s.uri, s.cid, s.did, s.media, s.aspect_ratio, s.location, s.address, s.created_at 21 + FROM "social.grain.story" s 22 + LEFT JOIN _repos r ON s.did = r.did 23 + WHERE s.did = $1 24 + AND (r.status IS NULL OR r.status != 'takendown') 25 + ${cursorClause} 26 + ORDER BY s.created_at DESC 27 + LIMIT $2`, 28 + queryParams, 29 + )) as StoryRow[]; 30 + 31 + const hasMore = rows.length > limit; 32 + const pageRows = hasMore ? rows.slice(0, limit) : rows; 33 + 34 + const stories = await hydrateStories(ctx, actor, pageRows); 35 + 36 + const nextCursor = hasMore ? pageRows[pageRows.length - 1].created_at : undefined; 37 + 38 + return ok({ stories, ...(nextCursor ? { cursor: nextCursor } : {}) }); 39 + });
+1 -1
server/xrpc/searchGalleries.ts
··· 1 1 import { defineQuery } from "$hatk"; 2 - import { hydrateGalleries } from "../feeds/_hydrate.ts"; 2 + import { hydrateGalleries } from "../hydrate/galleries.ts"; 3 3 4 4 export default defineQuery("social.grain.unspecced.searchGalleries", async (ctx) => { 5 5 const { params, search, ok } = ctx;