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: content labels with self-label hydration and improved warning UX

Hydrate self-labels from record child tables for galleries and stories.
Add ContentWarningPicker to gallery and story create forms with form field
labels. Replace blur with opaque overlay and Bluesky-style warning bar
across feed, stories, and profile grid. Add label schema for stories.
Clean up orphaned CSS and normalize location input styling.

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

+346 -132
+92
app/lib/components/atoms/ContentWarningPicker.svelte
··· 1 + <script lang="ts"> 2 + import { createQuery } from '@tanstack/svelte-query' 3 + import { labelDefsQuery } from '$lib/labels' 4 + import { ChevronDown } from 'lucide-svelte' 5 + 6 + let { selected = $bindable<string[]>([]) }: { selected: string[] } = $props() 7 + 8 + const selfLabelValues = ['nudity', 'sexual', 'gore'] 9 + const labelDefs = createQuery(() => labelDefsQuery()) 10 + 11 + let expanded = $state(false) 12 + 13 + const options = $derived( 14 + selfLabelValues.map((val) => { 15 + const def = (labelDefs.data ?? []).find((d) => d.identifier === val) 16 + return { value: val, label: def?.locales?.[0]?.name ?? val.charAt(0).toUpperCase() + val.slice(1) } 17 + }) 18 + ) 19 + 20 + function toggle(val: string) { 21 + if (selected.includes(val)) { 22 + selected = selected.filter((v) => v !== val) 23 + } else { 24 + selected = [...selected, val] 25 + } 26 + } 27 + </script> 28 + 29 + <div class="cw-picker"> 30 + <button class="cw-header" onclick={() => (expanded = !expanded)} type="button"> 31 + <span>Content Warning</span> 32 + <ChevronDown size={16} class={expanded ? 'rotated' : ''} /> 33 + </button> 34 + {#if expanded} 35 + <div class="cw-options"> 36 + {#each options as opt} 37 + <label class="cw-option"> 38 + <input type="checkbox" checked={selected.includes(opt.value)} onchange={() => toggle(opt.value)} /> 39 + <span>{opt.label}</span> 40 + </label> 41 + {/each} 42 + </div> 43 + {/if} 44 + </div> 45 + 46 + <style> 47 + .cw-picker { 48 + border: 1px solid var(--border); 49 + border-radius: 8px; 50 + overflow: hidden; 51 + } 52 + .cw-header { 53 + display: flex; 54 + align-items: center; 55 + justify-content: space-between; 56 + width: 100%; 57 + padding: 10px 14px; 58 + background: none; 59 + border: none; 60 + color: var(--text-secondary); 61 + font-size: 14px; 62 + font-family: var(--font-body); 63 + cursor: pointer; 64 + } 65 + .cw-header:hover { 66 + color: var(--text-primary); 67 + } 68 + .cw-header :global(.rotated) { 69 + transform: rotate(180deg); 70 + } 71 + .cw-options { 72 + border-top: 1px solid var(--border); 73 + padding: 8px 14px; 74 + display: flex; 75 + flex-direction: column; 76 + gap: 8px; 77 + } 78 + .cw-option { 79 + display: flex; 80 + align-items: center; 81 + gap: 10px; 82 + cursor: pointer; 83 + font-size: 14px; 84 + color: var(--text-primary); 85 + } 86 + .cw-option input[type="checkbox"] { 87 + width: 18px; 88 + height: 18px; 89 + accent-color: var(--grain-btn); 90 + cursor: pointer; 91 + } 92 + </style>
+4 -4
app/lib/components/atoms/LocationInput.svelte
··· 133 133 } 134 134 .input { 135 135 width: 100%; 136 - background: var(--bg-elevated); 136 + background: none; 137 137 border: 1px solid var(--border); 138 - border-radius: 10px; 138 + border-radius: 8px; 139 139 padding: 10px 36px 10px 34px; 140 140 color: var(--text-primary); 141 - font-family: var(--font-body); 141 + font-family: inherit; 142 142 font-size: 16px; 143 143 outline: none; 144 144 transition: border-color 0.15s; 145 145 } 146 146 .input::placeholder { 147 - color: var(--text-faint); 147 + color: var(--text-muted); 148 148 } 149 149 .input:focus { 150 150 border-color: var(--grain);
+52 -35
app/lib/components/molecules/GalleryCard.svelte
··· 17 17 import { isAuthenticated, requireAuth, viewer } from '$lib/stores' 18 18 import { resolveLabels, labelDefsQuery } from '$lib/labels' 19 19 import { createQuery, useQueryClient } from '@tanstack/svelte-query' 20 - import { EyeOff, AlertTriangle } from 'lucide-svelte' 20 + import { EyeOff, AlertTriangle, Info } from 'lucide-svelte' 21 21 22 22 let { gallery, onCommentClick }: { gallery: GalleryView; onCommentClick?: (focusPhoto: PhotoView | null) => void } = $props() 23 23 ··· 132 132 }) 133 133 </script> 134 134 135 - {#if labelResult.action === 'hide' && !revealed} 135 + {#if (labelResult.action === 'hide' || labelResult.action === 'warn-content') && !revealed} 136 136 <article class="gallery-card gallery-hidden"> 137 - <div class="content-warning"> 138 - <EyeOff size={18} /> 139 - <span>Hidden: {labelResult.name}</span> 140 - <button class="cw-reveal" onclick={() => (revealed = true)}>Show anyway</button> 137 + <div class="media-warning-bar"> 138 + <div class="media-warning-left"> 139 + <Info size={16} /> 140 + <span>{labelResult.name}</span> 141 + </div> 142 + <button class="media-warning-show" onclick={() => (revealed = true)}>Show</button> 141 143 </div> 142 144 </article> 143 145 {:else} 144 146 <article class="gallery-card" class:has-label-badge={labelResult.action === 'badge'}> 145 - {#if labelResult.action === 'warn-content' && !revealed} 146 - <div class="content-warning content-warning-full"> 147 - <AlertTriangle size={20} /> 148 - <span class="cw-label">{labelResult.name}</span> 149 - <p class="cw-text">This content has been flagged for review.</p> 150 - <button class="cw-reveal" onclick={() => (revealed = true)}>Show content</button> 151 - </div> 152 - {/if} 153 - <div class:content-obscured={labelResult.action === 'warn-content' && !revealed}> 147 + <div> 154 148 <header class="card-header"> 155 149 <ProfilePopover did={gallery.creator?.did ?? ''}> 156 150 <a href="/profile/{gallery.creator?.did}" class="author-chip"> ··· 181 175 </header> 182 176 183 177 {#if photos.length > 0} 184 - <div class="carousel-host" class:media-blurred={labelResult.action === 'warn-media' && !revealed}> 178 + <div class="carousel-host" class:media-obscured={labelResult.action === 'warn-media' && !revealed}> 185 179 {#if labelResult.action === 'warn-media' && !revealed} 186 - <button class="media-warning" onclick={() => (revealed = true)}> 187 - <AlertTriangle size={16} /> 188 - <span>{labelResult.name}</span> 189 - </button> 180 + <div class="media-warning-bar"> 181 + <div class="media-warning-left"> 182 + <Info size={16} /> 183 + <span>{labelResult.name}</span> 184 + </div> 185 + <button class="media-warning-show" onclick={() => (revealed = true)}>Show</button> 186 + </div> 190 187 {/if} 191 188 <div 192 189 class="carousel" ··· 621 618 background: var(--bg-hover); 622 619 color: var(--text-primary); 623 620 } 624 - .content-obscured { 625 - display: none; 626 - } 627 - .media-blurred { 621 + .media-obscured { 628 622 position: relative; 629 623 } 630 - .media-blurred .carousel { 631 - filter: blur(40px); 632 - pointer-events: none; 624 + .media-obscured .carousel { 625 + visibility: hidden; 633 626 } 634 - .media-warning { 627 + .media-obscured::before { 628 + content: ''; 635 629 position: absolute; 636 630 inset: 0; 631 + background: var(--bg-elevated); 632 + z-index: 1; 633 + } 634 + .media-warning-bar { 635 + position: absolute; 636 + top: 50%; 637 + left: 12px; 638 + right: 12px; 639 + transform: translateY(-50%); 637 640 z-index: 2; 638 641 display: flex; 639 642 align-items: center; 640 - justify-content: center; 641 - gap: 6px; 643 + justify-content: space-between; 644 + padding: 10px 14px; 645 + background: var(--bg-secondary); 646 + border-radius: 8px; 647 + border: 1px solid var(--border); 648 + } 649 + .media-warning-left { 650 + display: flex; 651 + align-items: center; 652 + gap: 8px; 653 + color: var(--text-secondary); 654 + font-size: 14px; 655 + font-weight: 500; 656 + } 657 + .media-warning-show { 642 658 background: none; 643 659 border: none; 644 - color: var(--text-secondary); 645 - font-size: 13px; 646 - font-weight: 600; 660 + color: var(--grain); 661 + font-size: 14px; 662 + font-weight: 500; 647 663 cursor: pointer; 648 664 font-family: var(--font-body); 665 + padding: 0; 649 666 } 650 - .media-warning:hover { 651 - color: var(--text-primary); 667 + .media-warning-show:hover { 668 + opacity: 0.8; 652 669 } 653 670 .label-badge { 654 671 display: inline-flex;
+28 -2
app/lib/components/molecules/StoryCreate.svelte
··· 5 5 import { processPhotos, type ProcessedPhoto } from '$lib/utils/image-resize' 6 6 import { reverseGeocode, formatLocationName, extractAddress } from '$lib/utils/nominatim' 7 7 import { latLonToH3 } from '$lib/utils/h3' 8 + import Field from '$lib/components/atoms/Field.svelte' 8 9 import LocationInput from '$lib/components/atoms/LocationInput.svelte' 9 10 import type { LocationData } from '$lib/components/atoms/LocationInput.svelte' 10 11 import Button from '$lib/components/atoms/Button.svelte' 11 12 import Checkbox from '$lib/components/atoms/Checkbox.svelte' 13 + import ContentWarningPicker from '$lib/components/atoms/ContentWarningPicker.svelte' 12 14 import { createBskyPost } from '$lib/utils/bsky-post' 13 15 import { viewer } from '$lib/stores' 14 16 ··· 19 21 let processing = $state(false) 20 22 let publishing = $state(false) 21 23 let postToBluesky = $state(false) 24 + let selectedLabels = $state<string[]>([]) 22 25 let error = $state<string | null>(null) 23 26 let fileInput: HTMLInputElement = $state()! 24 27 ··· 94 97 ...(location.address ? { address: location.address } : {}), 95 98 } 96 99 : {}), 100 + ...(selectedLabels.length > 0 101 + ? { 102 + labels: { 103 + $type: 'com.atproto.label.defs#selfLabels', 104 + values: selectedLabels.map((val) => ({ val })), 105 + }, 106 + } 107 + : {}), 97 108 createdAt: now, 98 109 }, 99 110 }) ··· 172 183 <img src={photo.dataUrl} alt="Story preview" /> 173 184 </div> 174 185 <div class="location-field"> 175 - <LocationInput bind:value={location} placeholder="Add location..." /> 186 + <Field label="Location"> 187 + <LocationInput bind:value={location} placeholder="Add location..." /> 188 + </Field> 176 189 </div> 190 + <div class="cw-field"> 191 + <Field label="Labels"> 192 + <ContentWarningPicker bind:selected={selectedLabels} /> 193 + </Field> 194 + </div> 195 + <div class="divider"></div> 177 196 <div class="bsky-field"> 178 197 <Checkbox bind:checked={postToBluesky} label="Post to Bluesky" /> 179 198 </div> ··· 273 292 .location-field { 274 293 padding: 12px 16px; 275 294 } 295 + .cw-field { 296 + padding: 0 16px 8px; 297 + } 298 + .divider { 299 + border-top: 1px solid var(--border); 300 + margin: 4px 16px; 301 + } 276 302 .bsky-field { 277 - padding: 0 16px 12px; 303 + padding: 8px 16px 12px; 278 304 } 279 305 </style>
+35 -11
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 { resolveLabels, labelDefsQuery } from '$lib/labels' 5 + import { createQuery } from '@tanstack/svelte-query' 6 + import { Info } from 'lucide-svelte' 7 + 8 + const labelDefs = createQuery(() => labelDefsQuery()) 4 9 5 10 let { 6 11 items, ··· 31 36 {:else} 32 37 <div class="grid"> 33 38 {#each items as gallery (gallery.uri)} 39 + {@const lr = resolveLabels(gallery.labels, labelDefs.data ?? [])} 34 40 <a class="cell" href="/profile/{gallery.creator?.did}/gallery/{rkey(gallery.uri)}"> 35 - {#if thumb(gallery)} 36 - <img 37 - src={thumb(gallery)} 38 - alt={gallery.title ?? ''} 39 - decoding="async" 40 - loading="lazy" 41 - onload={(e) => (e.currentTarget as HTMLImageElement).classList.add('loaded')} 42 - /> 41 + {#if lr.action === 'warn-media' || lr.action === 'warn-content' || lr.action === 'hide'} 42 + <div class="label-cover"> 43 + <Info size={14} /> 44 + <span>{lr.name}</span> 45 + </div> 46 + {:else} 47 + {#if thumb(gallery)} 48 + <img 49 + src={thumb(gallery)} 50 + alt={gallery.title ?? ''} 51 + decoding="async" 52 + loading="lazy" 53 + onload={(e) => (e.currentTarget as HTMLImageElement).classList.add('loaded')} 54 + /> 55 + {/if} 56 + <div class="overlay"> 57 + <span class="overlay-title">{gallery.title}</span> 58 + </div> 43 59 {/if} 44 - <div class="overlay"> 45 - <span class="overlay-title">{gallery.title}</span> 46 - </div> 47 60 </a> 48 61 {/each} 49 62 </div> ··· 94 107 text-overflow: ellipsis; 95 108 white-space: nowrap; 96 109 width: 100%; 110 + } 111 + .label-cover { 112 + display: flex; 113 + align-items: center; 114 + justify-content: center; 115 + gap: 6px; 116 + width: 100%; 117 + height: 100%; 118 + color: var(--text-secondary); 119 + font-size: 11px; 120 + font-weight: 500; 97 121 } 98 122 .empty-state { 99 123 padding: 48px;
+56 -74
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, AlertTriangle } from 'lucide-svelte' 3 + import { X, MapPin, Trash2, AlertTriangle, Info } from 'lucide-svelte' 4 4 import { goto } from '$app/navigation' 5 5 import { callXrpc } from '$hatk/client' 6 6 import { storiesQuery, storyAuthorsQuery, storyQuery } from '$lib/queries' ··· 87 87 } 88 88 }) 89 89 90 - // Pause timer while a label warning is shown 91 - const labelWarningActive = $derived( 92 - !labelRevealed && (labelResult.action === 'warn-content' || labelResult.action === 'warn-media') 93 - ) 94 - $effect(() => { 95 - paused = labelWarningActive 96 - }) 97 90 98 91 async function deleteStory() { 99 92 if (!currentStory || deleting) return ··· 273 266 </div> 274 267 275 268 <!-- Image --> 276 - {#if labelResult.action === 'warn-content' && !labelRevealed} 277 - <div class="story-content-warning"> 278 - <AlertTriangle size={20} /> 279 - <span class="cw-label">{labelResult.name}</span> 280 - <p class="cw-text">This content has been flagged for review.</p> 281 - <button class="cw-reveal" onclick={(e) => { e.stopPropagation(); labelRevealed = true }}>Show content</button> 282 - </div> 283 - {:else} 284 - <div class="story-image-wrapper" class:media-blurred={labelResult.action === 'warn-media' && !labelRevealed}> 285 - {#if labelResult.action === 'warn-media' && !labelRevealed} 286 - <button class="media-warning" onclick={(e) => { e.stopPropagation(); labelRevealed = true }}> 287 - <AlertTriangle size={16} /> 269 + <div class="story-image-wrapper" class:media-obscured={(labelResult.action === 'warn-media' || labelResult.action === 'warn-content' || labelResult.action === 'hide') && !labelRevealed}> 270 + {#if (labelResult.action === 'warn-media' || labelResult.action === 'warn-content' || labelResult.action === 'hide') && !labelRevealed} 271 + <div class="media-warning-bar"> 272 + <div class="media-warning-left"> 273 + <Info size={16} /> 288 274 <span>{labelResult.name}</span> 289 - </button> 290 - {/if} 291 - <img 292 - class="story-image" 293 - src={currentStory.fullsize} 294 - alt="" 295 - style="aspect-ratio: {currentStory.aspectRatio.width}/{currentStory.aspectRatio.height}" 296 - /> 297 - </div> 298 - {/if} 275 + </div> 276 + <button class="media-warning-show" onclick={(e) => { e.stopPropagation(); labelRevealed = true }}>Show</button> 277 + </div> 278 + {/if} 279 + <img 280 + class="story-image" 281 + src={currentStory.fullsize} 282 + alt="" 283 + style="aspect-ratio: {currentStory.aspectRatio.width}/{currentStory.aspectRatio.height}" 284 + /> 285 + </div> 299 286 300 287 <!-- Bluesky cross-post link --> 301 288 {#if bskyUrl} ··· 432 419 } 433 420 434 421 /* Label moderation */ 435 - .story-content-warning { 436 - flex: 1; 437 - display: flex; 438 - flex-direction: column; 439 - align-items: center; 440 - justify-content: center; 441 - gap: 8px; 442 - color: rgba(255, 255, 255, 0.8); 443 - font-size: 14px; 444 - text-align: center; 445 - padding: 24px; 446 - } 447 - .cw-label { 448 - font-weight: 600; 449 - } 450 - .cw-text { 451 - margin: 0; 452 - color: rgba(255, 255, 255, 0.5); 453 - font-size: 13px; 422 + .media-obscured { 423 + position: relative; 454 424 } 455 - .cw-reveal { 456 - margin-top: 8px; 457 - background: rgba(255, 255, 255, 0.15); 458 - border: 1px solid rgba(255, 255, 255, 0.2); 459 - color: white; 460 - padding: 8px 16px; 461 - border-radius: 8px; 462 - font-size: 13px; 463 - cursor: pointer; 425 + .media-obscured .story-image { 426 + visibility: hidden; 464 427 } 465 - .media-blurred { 466 - position: relative; 467 - } 468 - .media-blurred .story-image { 469 - filter: blur(24px); 428 + .media-obscured::before { 429 + content: ''; 430 + position: absolute; 431 + inset: 0; 432 + background: var(--bg-elevated, #1a1a1a); 433 + z-index: 1; 470 434 } 471 - .media-warning { 435 + .media-warning-bar { 472 436 position: absolute; 473 437 top: 50%; 474 - left: 50%; 475 - transform: translate(-50%, -50%); 476 - z-index: 5; 438 + left: 12px; 439 + right: 12px; 440 + transform: translateY(-50%); 441 + z-index: 2; 477 442 display: flex; 478 443 align-items: center; 479 - gap: 6px; 480 - background: rgba(0, 0, 0, 0.6); 481 - border: 1px solid rgba(255, 255, 255, 0.2); 482 - color: white; 483 - padding: 8px 14px; 444 + justify-content: space-between; 445 + padding: 10px 14px; 446 + background: rgba(255, 255, 255, 0.1); 484 447 border-radius: 8px; 485 - font-size: 13px; 448 + border: 1px solid rgba(255, 255, 255, 0.15); 449 + } 450 + .media-warning-left { 451 + display: flex; 452 + align-items: center; 453 + gap: 8px; 454 + color: rgba(255, 255, 255, 0.7); 455 + font-size: 14px; 456 + font-weight: 500; 457 + } 458 + .media-warning-show { 459 + background: none; 460 + border: none; 461 + color: var(--grain); 462 + font-size: 14px; 463 + font-weight: 500; 486 464 cursor: pointer; 487 - backdrop-filter: blur(4px); 465 + font-family: var(--font-body); 466 + padding: 0; 467 + } 468 + .media-warning-show:hover { 469 + opacity: 0.8; 488 470 } 489 471 490 472 /* Bluesky link */
+23 -3
app/routes/create/+page.svelte
··· 20 20 import RichTextarea from '$lib/components/atoms/RichTextarea.svelte' 21 21 import LocationInput from '$lib/components/atoms/LocationInput.svelte' 22 22 import Checkbox from '$lib/components/atoms/Checkbox.svelte' 23 + import ContentWarningPicker from '$lib/components/atoms/ContentWarningPicker.svelte' 23 24 import type { LocationData } from '$lib/components/atoms/LocationInput.svelte' 24 25 25 26 onMount(() => window.scrollTo(0, 0)) ··· 34 35 let processing = $state(false) 35 36 let publishing = $state(false) 36 37 let postToBluesky = $state(false) 38 + let selectedLabels = $state<string[]>([]) 37 39 let error = $state<string | null>(null) 38 40 39 41 let fileInput: HTMLInputElement = $state()! ··· 260 262 ...(location.address ? { address: location.address } : {}), 261 263 } 262 264 : {}), 265 + ...(selectedLabels.length > 0 266 + ? { 267 + labels: { 268 + $type: 'com.atproto.label.defs#selfLabels', 269 + values: selectedLabels.map((val) => ({ val })), 270 + }, 271 + } 272 + : {}), 263 273 createdAt: now, 264 274 }, 265 275 }) ··· 391 401 {/each} 392 402 </div> 393 403 <div class="form"> 394 - <Field count={title.length} max={100} showCount="always"> 404 + <Field label="Title" count={title.length} max={100} showCount="always"> 395 405 <Input 396 406 type="text" 397 407 placeholder="Add a title..." ··· 399 409 bind:value={title} 400 410 /> 401 411 </Field> 402 - <Field count={description.length} max={1000}> 412 + <Field label="Description" count={description.length} max={1000}> 403 413 <RichTextarea 404 414 placeholder="Add a description. Supports @mentions, #hashtags, and links." 405 415 maxlength={1000} ··· 407 417 rows={6} 408 418 /> 409 419 </Field> 410 - <LocationInput bind:value={location} /> 420 + <Field label="Location"> 421 + <LocationInput bind:value={location} /> 422 + </Field> 423 + <Field label="Labels"> 424 + <ContentWarningPicker bind:selected={selectedLabels} /> 425 + </Field> 426 + <div class="divider"></div> 411 427 <Checkbox bind:checked={postToBluesky} label="Post to Bluesky" /> 412 428 </div> 413 429 {/if} ··· 539 555 display: flex; 540 556 flex-direction: column; 541 557 gap: 16px; 558 + } 559 + .divider { 560 + border-top: 1px solid var(--border); 561 + margin: 4px 0; 542 562 } 543 563 544 564 /* Alt text (step 3) */
+7
db/schema.sql
··· 353 353 aspect_ratio TEXT NOT NULL, 354 354 location TEXT, 355 355 address TEXT, 356 + labels TEXT, 356 357 created_at TEXT NOT NULL 357 358 ); 359 + 360 + CREATE TABLE "social.grain.story__labels_self_labels" ( 361 + parent_uri TEXT NOT NULL, 362 + parent_did TEXT NOT NULL, 363 + val TEXT 364 + );
+5
lexicons/social/grain/story/story.json
··· 17 17 "aspectRatio": { "type": "ref", "ref": "social.grain.defs#aspectRatio" }, 18 18 "location": { "type": "ref", "ref": "community.lexicon.location.hthree" }, 19 19 "address": { "type": "ref", "ref": "community.lexicon.location.address" }, 20 + "labels": { 21 + "type": "union", 22 + "description": "Self-label values for this story. Effectively content warnings.", 23 + "refs": ["com.atproto.label.defs#selfLabels"] 24 + }, 20 25 "createdAt": { "type": "string", "format": "datetime" } 21 26 } 22 27 }
+15 -1
server/hydrate/galleries.ts
··· 117 117 }) 118 118 : Promise.resolve(new Map<string, number>()), 119 119 countComments(ctx.db, galleryUris), 120 - ctx.labels(galleryUris) as Promise<Map<string, Label[]>>, 120 + ctx.labels(galleryUris).then(async (externalLabels: Map<string, Label[]>) => { 121 + if (galleryUris.length === 0) return externalLabels; 122 + const selfLabelRows = (await ctx.db.query( 123 + `SELECT parent_uri, val FROM "social.grain.gallery__labels_self_labels" 124 + WHERE parent_uri IN (${galleryUris.map((_, i) => `$${i + 1}`).join(",")})`, 125 + galleryUris, 126 + )) as { parent_uri: string; val: string }[]; 127 + for (const row of selfLabelRows) { 128 + const label: Label = { src: row.parent_uri.split("/")[2], uri: row.parent_uri, val: row.val, cts: new Date().toISOString() }; 129 + const existing = externalLabels.get(row.parent_uri) ?? []; 130 + existing.push(label); 131 + externalLabels.set(row.parent_uri, existing); 132 + } 133 + return externalLabels; 134 + }) as Promise<Map<string, Label[]>>, 121 135 galleryUris.length > 0 122 136 ? (ctx.db.query( 123 137 `SELECT uri, did, cid, gallery, item, position, created_at
+15
server/hydrate/stories.ts
··· 45 45 ? ((await ctx.labels(storyUris)) as Map<string, Label[]>) 46 46 : new Map<string, Label[]>(); 47 47 48 + // Merge self-labels from records 49 + if (storyUris.length > 0) { 50 + const selfLabelRows = (await ctx.db.query( 51 + `SELECT parent_uri, val FROM "social.grain.story__labels_self_labels" 52 + WHERE parent_uri IN (${storyUris.map((_, i) => `$${i + 1}`).join(",")})`, 53 + storyUris, 54 + )) as { parent_uri: string; val: string }[]; 55 + for (const row of selfLabelRows) { 56 + const label: Label = { src: row.parent_uri.split("/")[2], uri: row.parent_uri, val: row.val, cts: new Date().toISOString() }; 57 + const existing = labelsByUri.get(row.parent_uri) ?? []; 58 + existing.push(label); 59 + labelsByUri.set(row.parent_uri, existing); 60 + } 61 + } 62 + 48 63 // Filter stories with hide-severity labels (latest entry per val wins) 49 64 const visibleRows = rows.filter((row) => { 50 65 const labels = labelsByUri.get(row.uri);
+1 -1
server/labels/gore.ts
··· 6 6 severity: "alert", 7 7 blurs: "media", 8 8 defaultSetting: "hide", 9 - locales: [{ lang: "en", name: "Gore", description: "Graphic or violent imagery." }], 9 + locales: [{ lang: "en", name: "Graphic Media", description: "Graphic or violent imagery." }], 10 10 }, 11 11 });
+13 -1
server/xrpc/getStory.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 4 import { lookupCrossPosts } from "../hydrate/galleries.ts"; 5 5 6 6 export default defineQuery("social.grain.unspecced.getStory", async (ctx) => { ··· 79 79 } 80 80 } 81 81 82 + // Labels: merge external + self-labels 83 + const externalLabels = (await ctx.labels([row.uri])) as Map<string, Label[]>; 84 + const selfLabelRows = (await db.query( 85 + `SELECT parent_uri, val FROM "social.grain.story__labels_self_labels" WHERE parent_uri = $1`, 86 + [row.uri], 87 + )) as { parent_uri: string; val: string }[]; 88 + const labels = externalLabels.get(row.uri) ?? []; 89 + for (const sl of selfLabelRows) { 90 + labels.push({ src: row.did, uri: row.uri, val: sl.val, cts: new Date().toISOString() }); 91 + } 92 + 82 93 // Cross-post lookup 83 94 const crossPostMap = await lookupCrossPosts(db, [row], "story"); 84 95 const crossPostUrl = crossPostMap.get(row.uri); ··· 98 109 } 99 110 : {}), 100 111 ...(crossPost ? { crossPost } : {}), 112 + ...(labels.length > 0 ? { labels } : {}), 101 113 createdAt: row.created_at, 102 114 }); 103 115