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 label moderation support for stories and feeds

- Add labels field to storyView lexicon
- Hydrate and filter hide-severity labels server-side in getStories
and getStoryAuthors (spam, copyright, gore, !hide, !takedown, etc.)
- Add warn-content/warn-media label UI to StoryViewer with timer
pausing while warnings are shown
- Move gallery label filtering into feed SQL queries via shared
hideLabelsFilter() helper for correct pagination
- Add repo takedown checks to story endpoints

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

+172 -21
+104 -9
app/lib/components/organisms/StoryViewer.svelte
··· 1 1 <script lang="ts"> 2 2 import { createQuery, useQueryClient } from '@tanstack/svelte-query' 3 - import { X, MapPin, Trash2 } from 'lucide-svelte' 3 + import { X, MapPin, Trash2, AlertTriangle } from 'lucide-svelte' 4 4 import { goto } from '$app/navigation' 5 5 import { callXrpc } from '$hatk/client' 6 6 import { storiesQuery, storyAuthorsQuery } from '$lib/queries' 7 7 import { viewer as viewerStore } from '$lib/stores' 8 + import { resolveLabels, labelDefsQuery } from '$lib/labels' 8 9 import ReportButton from '$lib/components/molecules/ReportButton.svelte' 9 10 10 11 let { ··· 56 57 const totalStories = $derived(stories.data?.length ?? 0) 57 58 const isOwn = $derived(currentDid === $viewerStore?.did) 58 59 let deleting = $state(false) 60 + 61 + // Label moderation 62 + const labelDefs = createQuery(() => labelDefsQuery()) 63 + const labelResult = $derived(resolveLabels(currentStory?.labels, labelDefs.data ?? [])) 64 + let labelRevealed = $state(false) 65 + 66 + // Reset revealed state when story changes 67 + let prevStoryUri = $state('') 68 + $effect(() => { 69 + const uri = currentStory?.uri ?? '' 70 + if (uri !== prevStoryUri) { 71 + prevStoryUri = uri 72 + labelRevealed = false 73 + } 74 + }) 75 + 76 + // Pause timer while a label warning is shown 77 + const labelWarningActive = $derived( 78 + !labelRevealed && (labelResult.action === 'warn-content' || labelResult.action === 'warn-media') 79 + ) 80 + $effect(() => { 81 + paused = labelWarningActive 82 + }) 59 83 60 84 async function deleteStory() { 61 85 if (!currentStory || deleting) return ··· 231 255 </div> 232 256 233 257 <!-- Image --> 234 - <div class="story-image-wrapper"> 235 - <img 236 - class="story-image" 237 - src={currentStory.fullsize} 238 - alt="" 239 - style="aspect-ratio: {currentStory.aspectRatio.width}/{currentStory.aspectRatio.height}" 240 - /> 241 - </div> 258 + {#if labelResult.action === 'warn-content' && !labelRevealed} 259 + <div class="story-content-warning"> 260 + <AlertTriangle size={20} /> 261 + <span class="cw-label">{labelResult.name}</span> 262 + <p class="cw-text">This content has been flagged for review.</p> 263 + <button class="cw-reveal" onclick={(e) => { e.stopPropagation(); labelRevealed = true }}>Show content</button> 264 + </div> 265 + {:else} 266 + <div class="story-image-wrapper" class:media-blurred={labelResult.action === 'warn-media' && !labelRevealed}> 267 + {#if labelResult.action === 'warn-media' && !labelRevealed} 268 + <button class="media-warning" onclick={(e) => { e.stopPropagation(); labelRevealed = true }}> 269 + <AlertTriangle size={16} /> 270 + <span>{labelResult.name}</span> 271 + </button> 272 + {/if} 273 + <img 274 + class="story-image" 275 + src={currentStory.fullsize} 276 + alt="" 277 + style="aspect-ratio: {currentStory.aspectRatio.width}/{currentStory.aspectRatio.height}" 278 + /> 279 + </div> 280 + {/if} 242 281 243 282 <!-- Location overlay --> 244 283 {#if currentStory.location} ··· 365 404 max-width: 100%; 366 405 max-height: 100%; 367 406 object-fit: contain; 407 + } 408 + 409 + /* Label moderation */ 410 + .story-content-warning { 411 + flex: 1; 412 + display: flex; 413 + flex-direction: column; 414 + align-items: center; 415 + justify-content: center; 416 + gap: 8px; 417 + color: rgba(255, 255, 255, 0.8); 418 + font-size: 14px; 419 + text-align: center; 420 + padding: 24px; 421 + } 422 + .cw-label { 423 + font-weight: 600; 424 + } 425 + .cw-text { 426 + margin: 0; 427 + color: rgba(255, 255, 255, 0.5); 428 + font-size: 13px; 429 + } 430 + .cw-reveal { 431 + margin-top: 8px; 432 + background: rgba(255, 255, 255, 0.15); 433 + border: 1px solid rgba(255, 255, 255, 0.2); 434 + color: white; 435 + padding: 8px 16px; 436 + border-radius: 8px; 437 + font-size: 13px; 438 + cursor: pointer; 439 + } 440 + .media-blurred { 441 + position: relative; 442 + } 443 + .media-blurred .story-image { 444 + filter: blur(24px); 445 + } 446 + .media-warning { 447 + position: absolute; 448 + top: 50%; 449 + left: 50%; 450 + transform: translate(-50%, -50%); 451 + z-index: 5; 452 + display: flex; 453 + align-items: center; 454 + gap: 6px; 455 + background: rgba(0, 0, 0, 0.6); 456 + border: 1px solid rgba(255, 255, 255, 0.2); 457 + color: white; 458 + padding: 8px 14px; 459 + border-radius: 8px; 460 + font-size: 13px; 461 + cursor: pointer; 462 + backdrop-filter: blur(4px); 368 463 } 369 464 370 465 /* Location */
+5 -1
lexicons/social/grain/story/defs.json
··· 22 22 "aspectRatio": { "type": "ref", "ref": "social.grain.defs#aspectRatio" }, 23 23 "location": { "type": "ref", "ref": "community.lexicon.location.hthree" }, 24 24 "address": { "type": "ref", "ref": "community.lexicon.location.address" }, 25 - "createdAt": { "type": "string", "format": "datetime" } 25 + "createdAt": { "type": "string", "format": "datetime" }, 26 + "labels": { 27 + "type": "array", 28 + "items": { "type": "ref", "ref": "com.atproto.label.defs#label" } 29 + } 26 30 } 27 31 } 28 32 }
+2
server/feeds/actor.ts
··· 1 1 import { defineFeed } from "$hatk"; 2 2 import { hydrateGalleries } from "./_hydrate.ts"; 3 + import { hideLabelsFilter } from "../labels/_hidden.ts"; 3 4 4 5 export default defineFeed({ 5 6 collection: "social.grain.gallery", ··· 34 35 `SELECT t.uri, t.cid, t.created_at 35 36 FROM "social.grain.gallery" t 36 37 WHERE t.did = $1 38 + AND ${hideLabelsFilter("t.uri")} 37 39 AND (SELECT count(*) FROM "social.grain.gallery.item" gi WHERE gi.gallery = t.uri) > 0`, 38 40 { params: [actor], orderBy: "t.created_at" }, 39 41 );
+2
server/feeds/camera.ts
··· 3 3 4 4 import { defineFeed } from "$hatk"; 5 5 import { hydrateGalleries } from "./_hydrate.ts"; 6 + import { hideLabelsFilter } from "../labels/_hidden.ts"; 6 7 7 8 export default defineFeed({ 8 9 collection: "social.grain.gallery", ··· 23 24 JOIN "social.grain.photo.exif" e ON e.photo = gi.item 24 25 WHERE gi.gallery = t.uri AND (e.make || ' ' || e.model) = $1 25 26 ) 27 + AND ${hideLabelsFilter("t.uri")} 26 28 AND (SELECT count(*) FROM "social.grain.gallery.item" gi WHERE gi.gallery = t.uri) > 0`, 27 29 { orderBy: "t.created_at", params: [camera] }, 28 30 );
+2
server/feeds/following.ts
··· 1 1 import { defineFeed } from "$hatk"; 2 2 import { hydrateGalleries } from "./_hydrate.ts"; 3 + import { hideLabelsFilter } from "../labels/_hidden.ts"; 3 4 4 5 export default defineFeed({ 5 6 collection: "social.grain.gallery", ··· 16 17 LEFT JOIN _repos r ON t.did = r.did 17 18 WHERE (r.status IS NULL OR r.status != 'takendown') 18 19 AND t.did IN (SELECT subject FROM "social.grain.graph.follow" WHERE did = $1) 20 + AND ${hideLabelsFilter("t.uri")} 19 21 AND (SELECT count(*) FROM "social.grain.gallery.item" gi WHERE gi.gallery = t.uri) > 0`, 20 22 { orderBy: "t.created_at", params: [actor] }, 21 23 );
+2
server/feeds/hashtag.ts
··· 1 1 import { defineFeed } from "$hatk"; 2 2 import { hydrateGalleries } from "./_hydrate.ts"; 3 + import { hideLabelsFilter } from "../labels/_hidden.ts"; 3 4 4 5 export default defineFeed({ 5 6 collection: "social.grain.gallery", ··· 21 22 t.description LIKE $1 22 23 OR EXISTS (SELECT 1 FROM "social.grain.comment" c WHERE c.subject = t.uri AND c.text LIKE $1) 23 24 ) 25 + AND ${hideLabelsFilter("t.uri")} 24 26 AND (SELECT count(*) FROM "social.grain.gallery.item" gi WHERE gi.gallery = t.uri) > 0`, 25 27 { orderBy: "t.created_at", params: [pattern] }, 26 28 );
+3
server/feeds/location.ts
··· 7 7 import { defineFeed } from "$hatk"; 8 8 import { hydrateGalleries } from "./_hydrate.ts"; 9 9 import { getResolution, cellToParent } from "h3-js"; 10 + import { hideLabelsFilter } from "../labels/_hidden.ts"; 10 11 11 12 export default defineFeed({ 12 13 collection: "social.grain.gallery", ··· 32 33 LEFT JOIN _repos r ON t.did = r.did 33 34 WHERE (r.status IS NULL OR r.status != 'takendown') 34 35 AND t.location IS NOT NULL 36 + AND ${hideLabelsFilter("t.uri")} 35 37 AND (SELECT count(*) FROM "social.grain.gallery.item" gi WHERE gi.gallery = t.uri) > 0`, 36 38 { orderBy: "t.created_at" }, 37 39 ); ··· 58 60 LEFT JOIN _repos r ON t.did = r.did 59 61 WHERE (r.status IS NULL OR r.status != 'takendown') 60 62 AND json_extract(t.location, '$.value') = $1 63 + AND ${hideLabelsFilter("t.uri")} 61 64 AND (SELECT count(*) FROM "social.grain.gallery.item" gi WHERE gi.gallery = t.uri) > 0`, 62 65 { orderBy: "t.created_at", params: [location] }, 63 66 );
+2
server/feeds/recent.ts
··· 1 1 import { defineFeed } from "$hatk"; 2 2 import { hydrateGalleries } from "./_hydrate.ts"; 3 + import { hideLabelsFilter } from "../labels/_hidden.ts"; 3 4 4 5 export default defineFeed({ 5 6 collection: "social.grain.gallery", ··· 12 13 `SELECT t.uri, t.cid, t.created_at FROM "social.grain.gallery" t 13 14 LEFT JOIN _repos r ON t.did = r.did 14 15 WHERE (r.status IS NULL OR r.status != 'takendown') 16 + AND ${hideLabelsFilter("t.uri")} 15 17 AND (SELECT count(*) FROM "social.grain.gallery.item" gi WHERE gi.gallery = t.uri) > 0`, 16 18 { orderBy: "t.created_at" }, 17 19 );
+17
server/labels/_hidden.ts
··· 1 + /** Label values that cause content to be filtered from feeds (imperative + defaultSetting:"hide"). */ 2 + export const HIDE_LABELS = new Set([ 3 + "!hide", 4 + "!takedown", 5 + "spam", 6 + "copyright", 7 + "gore", 8 + "nsfl", 9 + "dmca-violation", 10 + "doxxing", 11 + ]); 12 + 13 + /** SQL fragment: NOT EXISTS subquery filtering rows with hide-severity labels. `uriExpr` is the column to match against (e.g. "t.uri"). */ 14 + export function hideLabelsFilter(uriExpr: string): string { 15 + const inList = [...HIDE_LABELS].map((v) => `'${v}'`).join(","); 16 + return `NOT EXISTS (SELECT 1 FROM _labels l WHERE l.uri = ${uriExpr} AND l.val IN (${inList}) AND l.neg = 0)`; 17 + }
+24 -6
server/xrpc/getStories.ts
··· 1 1 import { defineQuery } from "$hatk"; 2 2 import { views } from "$hatk"; 3 - import type { GrainActorProfile, Story } from "$hatk"; 3 + import type { GrainActorProfile, Story, Label } from "$hatk"; 4 + import { HIDE_LABELS } from "../labels/_hidden.ts"; 4 5 5 6 const TWENTY_FOUR_HOURS = 24 * 60 * 60 * 1000; 6 7 ··· 13 14 14 15 // hatk stores aspect_ratio and location as JSON TEXT columns, media as JSON blob ref 15 16 const rows = (await db.query( 16 - `SELECT uri, cid, did, media, aspect_ratio, location, address, created_at 17 - FROM "social.grain.story" 18 - WHERE did = $1 AND created_at > $2 19 - ORDER BY created_at ASC`, 17 + `SELECT s.uri, s.cid, s.did, s.media, s.aspect_ratio, s.location, s.address, s.created_at 18 + FROM "social.grain.story" s 19 + LEFT JOIN _repos r ON s.did = r.did 20 + WHERE s.did = $1 AND s.created_at > $2 21 + AND (r.status IS NULL OR r.status != 'takendown') 22 + ORDER BY s.created_at ASC`, 20 23 [actor, cutoff], 21 24 )) as { 22 25 uri: string; ··· 48 51 handle: actor, 49 52 }); 50 53 51 - const stories = rows.map((row) => { 54 + // Hydrate external labels for story URIs 55 + const storyUris = rows.map((r) => r.uri); 56 + const labelsByUri = 57 + storyUris.length > 0 58 + ? ((await ctx.labels(storyUris)) as Map<string, Label[]>) 59 + : new Map<string, Label[]>(); 60 + 61 + // Filter out stories with hide-severity labels 62 + const visibleRows = rows.filter((row) => { 63 + const labels = labelsByUri.get(row.uri); 64 + if (!labels) return true; 65 + return !labels.some((l) => HIDE_LABELS.has(l.val) && !l.neg); 66 + }); 67 + 68 + const stories = visibleRows.map((row) => { 52 69 // Parse the JSON blob reference for URL generation 53 70 let blobRef: any; 54 71 try { ··· 103 120 } 104 121 : {}), 105 122 createdAt: row.created_at, 123 + ...(labelsByUri.has(row.uri) ? { labels: labelsByUri.get(row.uri) } : {}), 106 124 }); 107 125 }); 108 126
+9 -5
server/xrpc/getStoryAuthors.ts
··· 1 1 import { defineQuery } from "$hatk"; 2 2 import { views } from "$hatk"; 3 3 import type { GrainActorProfile } from "$hatk"; 4 + import { hideLabelsFilter } from "../labels/_hidden.ts"; 4 5 5 6 const TWENTY_FOUR_HOURS = 24 * 60 * 60 * 1000; 6 7 ··· 8 9 const { db, ok } = ctx; 9 10 const cutoff = new Date(Date.now() - TWENTY_FOUR_HOURS).toISOString(); 10 11 11 - // Get authors with active stories, ordered by most recent 12 + // Aggregate in SQL, excluding stories with hide-severity labels 12 13 const rows = (await db.query( 13 - `SELECT did, COUNT(*) AS story_count, MAX(created_at) AS latest_at 14 - FROM "social.grain.story" 15 - WHERE created_at > $1 16 - GROUP BY did 14 + `SELECT s.did, COUNT(*) AS story_count, MAX(s.created_at) AS latest_at 15 + FROM "social.grain.story" s 16 + LEFT JOIN _repos r ON s.did = r.did 17 + WHERE s.created_at > $1 18 + AND (r.status IS NULL OR r.status != 'takendown') 19 + AND ${hideLabelsFilter("s.uri")} 20 + GROUP BY s.did 17 21 ORDER BY latest_at DESC`, 18 22 [cutoff], 19 23 )) as { did: string; story_count: number; latest_at: string }[];