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 blocks and mutes (#10)

- Block/mute records with bidirectional content filtering on feeds, stories, comments, and notifications
- Muted comments returned with `muted` flag for client-side collapse/expand UI
- Blocked comments removed entirely from threads
- Settings > Moderation > Blocked Users / Muted Users management pages
- Block/mute state shown on profile pages with overflow menu actions
- Self-block prevention, query invalidation on mutations
- Lexicons for block record, muteActor/unmuteActor procedures, getBlocks/getMutes queries
- getSuggestedFollows excludes blocked/muted users
- getActorProfile uses authenticated viewer instead of params.viewer
- getMutes cursor aligned with getBlocks (packCursor/unpackCursor)
- Seeds with block test records

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

authored by

Chad Miller
Claude Opus 4.6
and committed by
GitHub
c9581ead dc5a1db3

+1710 -120
+45 -19
app/lib/components/molecules/Comment.svelte
··· 4 4 import RichText from '../atoms/RichText.svelte' 5 5 import { relativeTime } from '$lib/utils' 6 6 import { viewer } from '$lib/stores' 7 + import { VolumeX } from 'lucide-svelte' 7 8 8 9 let { 9 10 comment, ··· 15 16 onDelete?: (uri: string) => void 16 17 } = $props() 17 18 19 + let expanded = $state(false) 20 + 18 21 const isOwner = $derived($viewer?.did === comment.author?.did) 19 22 const timeStr = $derived(relativeTime(comment.createdAt || '')) 20 23 const isReply = $derived(!!comment.replyTo) 24 + const isMuted = $derived(!!comment.muted && !expanded) 21 25 </script> 22 26 23 - <div class="comment" class:reply={isReply}> 24 - <Avatar did={comment.author?.did ?? ''} src={comment.author?.avatar ?? null} size={28} /> 25 - <div class="content"> 26 - <div class="text-line"> 27 - <a href="/profile/{comment.author?.did}" class="handle">{comment.author?.handle ?? comment.author?.did}</a> 28 - <span class="text"><RichText text={comment.text} /></span> 29 - </div> 30 - <div class="meta"> 31 - <span class="time">{timeStr}</span> 32 - {#if onReply} 33 - <button class="meta-btn" onclick={() => onReply?.(comment.replyTo ?? comment.uri, comment.author?.handle ?? '')}>Reply</button> 34 - {/if} 35 - {#if isOwner && onDelete} 36 - <button class="meta-btn delete" onclick={() => onDelete?.(comment.uri)}>Delete</button> 37 - {/if} 27 + {#if comment.muted && !expanded} 28 + <div class="comment muted-comment" class:reply={isReply}> 29 + <button class="muted-toggle" onclick={() => (expanded = true)}> 30 + <VolumeX size={14} /> 31 + <span>Muted comment</span> 32 + </button> 33 + </div> 34 + {:else} 35 + <div class="comment" class:reply={isReply}> 36 + <Avatar did={comment.author?.did ?? ''} src={comment.author?.avatar ?? null} size={28} /> 37 + <div class="content"> 38 + <div class="text-line"> 39 + <a href="/profile/{comment.author?.did}" class="handle">{comment.author?.handle ?? comment.author?.did}</a> 40 + <span class="text"><RichText text={comment.text} /></span> 41 + </div> 42 + <div class="meta"> 43 + <span class="time">{timeStr}</span> 44 + {#if onReply} 45 + <button class="meta-btn" onclick={() => onReply?.(comment.replyTo ?? comment.uri, comment.author?.handle ?? '')}>Reply</button> 46 + {/if} 47 + {#if isOwner && onDelete} 48 + <button class="meta-btn delete" onclick={() => onDelete?.(comment.uri)}>Delete</button> 49 + {/if} 50 + </div> 38 51 </div> 52 + {#if comment.focus?.thumb} 53 + <img class="focus-thumb" src={comment.focus.thumb} alt={comment.focus?.alt ?? ''} /> 54 + {/if} 39 55 </div> 40 - {#if comment.focus?.thumb} 41 - <img class="focus-thumb" src={comment.focus.thumb} alt={comment.focus?.alt ?? ''} /> 42 - {/if} 43 - </div> 56 + {/if} 44 57 45 58 <style> 46 59 .comment { ··· 96 109 object-fit: cover; 97 110 flex-shrink: 0; 98 111 } 112 + .muted-toggle { 113 + display: flex; 114 + align-items: center; 115 + gap: 6px; 116 + background: none; 117 + border: none; 118 + color: var(--text-muted); 119 + font-size: 13px; 120 + cursor: pointer; 121 + padding: 0; 122 + font-family: inherit; 123 + } 124 + .muted-toggle:hover { color: var(--text-secondary); } 99 125 </style>
+2 -2
app/lib/components/organisms/MobileDrawer.svelte
··· 59 59 {/if} 60 60 61 61 {#if $isAuthenticated} 62 - <button class="drawer-link" onclick={() => nav('/settings/profile')}> 63 - <span class="drawer-link-icon"><Settings size={18} /></span> Edit Profile 62 + <button class="drawer-link" onclick={() => nav('/settings')}> 63 + <span class="drawer-link-icon"><Settings size={18} /></span> Settings 64 64 </button> 65 65 {/if} 66 66
+1 -1
app/lib/components/organisms/Sidebar.svelte
··· 48 48 {/if} 49 49 </span> 50 50 </a> 51 - <a href="/settings/profile" class="nav-item" class:active={page.url.pathname.startsWith('/settings')} title="Settings"> 51 + <a href="/settings" class="nav-item" class:active={page.url.pathname.startsWith('/settings')} title="Settings"> 52 52 <Settings size={22} /> 53 53 </a> 54 54 <a href="/create" class="nav-item" class:active={page.url.pathname === '/create'} title="Create">
+47
app/lib/mutations.ts
··· 1 + import { callXrpc } from "$hatk/client"; 2 + import type { QueryClient } from "@tanstack/svelte-query"; 3 + import { get } from "svelte/store"; 4 + import { viewer as viewerStore } from "$lib/stores"; 5 + 6 + function invalidateFeedsAndProfile(did: string, queryClient: QueryClient) { 7 + queryClient.invalidateQueries({ queryKey: ["actorProfile", did] }); 8 + queryClient.invalidateQueries({ queryKey: ["getFeed"] }); 9 + queryClient.invalidateQueries({ queryKey: ["storyAuthors"] }); 10 + queryClient.invalidateQueries({ queryKey: ["notifications"] }); 11 + } 12 + 13 + export async function blockActor(did: string, queryClient: QueryClient) { 14 + if (get(viewerStore)?.did === did) return; 15 + await callXrpc("dev.hatk.createRecord", { 16 + collection: "social.grain.graph.block", 17 + record: { subject: did, createdAt: new Date().toISOString() }, 18 + }); 19 + invalidateFeedsAndProfile(did, queryClient); 20 + queryClient.invalidateQueries({ queryKey: ["blocks"] }); 21 + } 22 + 23 + export async function unblockActor( 24 + did: string, 25 + blockUri: string, 26 + queryClient: QueryClient, 27 + ) { 28 + const rkey = blockUri.split("/").pop()!; 29 + await callXrpc("dev.hatk.deleteRecord", { 30 + collection: "social.grain.graph.block", 31 + rkey, 32 + }); 33 + invalidateFeedsAndProfile(did, queryClient); 34 + queryClient.invalidateQueries({ queryKey: ["blocks"] }); 35 + } 36 + 37 + export async function muteActor(did: string, queryClient: QueryClient) { 38 + await callXrpc("social.grain.graph.muteActor", { actor: did }); 39 + invalidateFeedsAndProfile(did, queryClient); 40 + queryClient.invalidateQueries({ queryKey: ["mutes"] }); 41 + } 42 + 43 + export async function unmuteActor(did: string, queryClient: QueryClient) { 44 + await callXrpc("social.grain.graph.unmuteActor", { actor: did }); 45 + invalidateFeedsAndProfile(did, queryClient); 46 + queryClient.invalidateQueries({ queryKey: ["mutes"] }); 47 + }
+16
app/lib/queries.ts
··· 165 165 staleTime: 60_000, 166 166 }); 167 167 168 + // ─── Blocks / Mutes ───────────────────────────────────────────────── 169 + 170 + export const blocksQuery = (f?: Fetch) => 171 + queryOptions({ 172 + queryKey: ["blocks"], 173 + queryFn: () => callXrpc("social.grain.unspecced.getBlocks", {}, f), 174 + staleTime: 60_000, 175 + }); 176 + 177 + export const mutesQuery = (f?: Fetch) => 178 + queryOptions({ 179 + queryKey: ["mutes"], 180 + queryFn: () => callXrpc("social.grain.unspecced.getMutes", {}, f), 181 + staleTime: 60_000, 182 + }); 183 + 168 184 export const knownFollowersQuery = (did: string, viewer: string, f?: Fetch) => 169 185 queryOptions({ 170 186 queryKey: ["knownFollowers", did, viewer],
+115 -33
app/routes/profile/[did]/+page.svelte
··· 6 6 import OGMeta from '$lib/components/atoms/OGMeta.svelte' 7 7 import Skeleton from '$lib/components/atoms/Skeleton.svelte' 8 8 import FollowButton from '$lib/components/molecules/FollowButton.svelte' 9 + import OverflowMenu from '$lib/components/atoms/OverflowMenu.svelte' 9 10 import RichText from '$lib/components/atoms/RichText.svelte' 10 - import { ArrowUpRight, Grid3x3, Heart, Clock } from 'lucide-svelte' 11 - import { createQuery, createInfiniteQuery } from '@tanstack/svelte-query' 11 + import { ArrowUpRight, Grid3x3, Heart, Clock, Ban, VolumeX } from 'lucide-svelte' 12 + import { createQuery, createInfiniteQuery, useQueryClient } from '@tanstack/svelte-query' 12 13 import { actorProfileQuery, actorFeedQuery, actorFavoritesInfiniteQuery, knownFollowersQuery, storiesQuery } from '$lib/queries' 13 - import { viewer as viewerStore } from '$lib/stores' 14 + import { viewer as viewerStore, requireAuth } from '$lib/stores' 15 + import { blockActor, unblockActor, muteActor, unmuteActor } from '$lib/mutations' 14 16 import StoryViewer from '$lib/components/organisms/StoryViewer.svelte' 15 17 import StoryArchive from '$lib/components/molecules/StoryArchive.svelte' 16 18 import { page } from '$app/state' ··· 59 61 enabled: !!viewerDid && viewerDid !== did, 60 62 })) 61 63 64 + const queryClient = useQueryClient() 65 + 66 + async function handleBlock() { 67 + if (!requireAuth()) return 68 + const p = profile.data as any 69 + if (p?.viewer?.blocking) { 70 + await unblockActor(did, p.viewer.blocking, queryClient) 71 + } else { 72 + await blockActor(did, queryClient) 73 + } 74 + } 75 + 76 + async function handleMute() { 77 + if (!requireAuth()) return 78 + const p = profile.data as any 79 + if (p?.viewer?.muted) { 80 + await unmuteActor(did, queryClient) 81 + } else { 82 + await muteActor(did, queryClient) 83 + } 84 + } 85 + 86 + const blockHide = $derived(!!profile.data?.viewer?.blocking || !!profile.data?.viewer?.blockedBy) 87 + 62 88 const showGermButton = $derived.by(() => { 63 89 const p = profile.data as any 64 90 if (!p?.messageMe || !viewerDid) return false ··· 100 126 <Avatar {did} src={p.avatar ?? null} name={p.displayName} size={64} {hasStory} onclick={hasStory ? () => (showStoryViewer = true) : p.avatar ? () => (lightboxSrc = p.avatar!) : undefined} /> 101 127 {#if viewerDid && viewerDid !== did} 102 128 <div class="actions"> 103 - <FollowButton {did} viewerFollow={p.viewer?.following ?? null} onCountChange={(d) => (followersOffset += d)} /> 129 + {#if !p.viewer?.blocking && !p.viewer?.blockedBy} 130 + <FollowButton {did} viewerFollow={p.viewer?.following ?? null} onCountChange={(d) => (followersOffset += d)} /> 131 + {/if} 132 + <OverflowMenu> 133 + {#if !blockHide} 134 + <button class="menu-item" type="button" onclick={handleMute}> 135 + <VolumeX size={15} /> 136 + {p.viewer?.muted ? 'Unmute' : 'Mute'} 137 + </button> 138 + {/if} 139 + <button class="menu-item danger" type="button" onclick={handleBlock}> 140 + <Ban size={15} /> 141 + {p.viewer?.blocking ? 'Unblock' : 'Block'} 142 + </button> 143 + </OverflowMenu> 104 144 </div> 105 145 {/if} 106 146 </div> 107 147 <div class="profile-name">{p.displayName || did.slice(0, 18)}</div> 108 148 <div class="handle-row"> 109 - {#if p.viewer?.followedBy}<span class="follows-you">Follows you</span>{/if} 149 + {#if !blockHide && p.viewer?.followedBy}<span class="follows-you">Follows you</span>{/if} 110 150 <span class="profile-handle">{p.handle ? `@${p.handle}` : did}</span> 111 151 </div> 112 - <div class="stat-row"> 113 - <span><strong>{(p.galleryCount ?? 0).toLocaleString()}</strong> {Number(p.galleryCount) === 1 ? 'gallery' : 'galleries'}</span> 114 - <a href="/profile/{did}/followers" class="stat-link"><strong>{((p.followersCount ?? 0) + followersOffset).toLocaleString()}</strong> followers</a> 115 - <a href="/profile/{did}/following" class="stat-link"><strong>{(p.followsCount ?? 0).toLocaleString()}</strong> following</a> 116 - </div> 117 - {#if p.description} 118 - <div class="bio"><RichText text={p.description} /></div> 119 - {/if} 120 - <div class="links-row"> 121 - <a class="link-pill" href="https://bsky.app/profile/{p.handle || did}" target="_blank" rel="noopener noreferrer"> 122 - Bluesky <ArrowUpRight size={14} /> 123 - </a> 124 - {#if showGermButton && germUrl} 125 - <a class="link-pill" href={germUrl} target="_blank" rel="noopener noreferrer"> 126 - <img src="/germ-logo.png" alt="" class="germ-logo" /> Germ DM <ArrowUpRight size={14} /> 152 + {#if blockHide} 153 + <div class="block-alert"> 154 + <Ban size={14} /> 155 + {#if p.viewer?.blocking} 156 + <span>Account blocked</span> 157 + {:else} 158 + <span>This user has blocked you</span> 159 + {/if} 160 + </div> 161 + {:else} 162 + <div class="stat-row"> 163 + <span><strong>{(p.galleryCount ?? 0).toLocaleString()}</strong> {Number(p.galleryCount) === 1 ? 'gallery' : 'galleries'}</span> 164 + <a href="/profile/{did}/followers" class="stat-link"><strong>{((p.followersCount ?? 0) + followersOffset).toLocaleString()}</strong> followers</a> 165 + <a href="/profile/{did}/following" class="stat-link"><strong>{(p.followsCount ?? 0).toLocaleString()}</strong> following</a> 166 + </div> 167 + {#if p.description} 168 + <div class="bio"><RichText text={p.description} /></div> 169 + {/if} 170 + <div class="links-row"> 171 + <a class="link-pill" href="https://bsky.app/profile/{p.handle || did}" target="_blank" rel="noopener noreferrer"> 172 + Bluesky <ArrowUpRight size={14} /> 173 + </a> 174 + {#if showGermButton && germUrl} 175 + <a class="link-pill" href={germUrl} target="_blank" rel="noopener noreferrer"> 176 + <img src="/germ-logo.png" alt="" class="germ-logo" /> Germ DM <ArrowUpRight size={14} /> 177 + </a> 178 + {/if} 179 + </div> 180 + {#if (knownFollowers.data?.items ?? []).length > 0} 181 + {@const known = knownFollowers.data?.items ?? []} 182 + <a href="/profile/{did}/known-followers" class="known-followers"> 183 + <div class="known-avatars"> 184 + {#each known.slice(0, 3) as k (k.did)} 185 + <Avatar did={k.did} src={k.avatar ?? null} name={k.displayName} size={20} /> 186 + {/each} 187 + </div> 188 + <span class="known-text"> 189 + Followed by {known.slice(0, 2).map((k) => k.displayName || k.handle).join(', ')}{#if known.length > 2}{' '}and {known.length - 2} other{known.length - 2 !== 1 ? 's' : ''} you follow{/if} 190 + </span> 127 191 </a> 128 192 {/if} 129 - </div> 130 - {#if (knownFollowers.data?.items ?? []).length > 0} 131 - {@const known = knownFollowers.data?.items ?? []} 132 - <a href="/profile/{did}/known-followers" class="known-followers"> 133 - <div class="known-avatars"> 134 - {#each known.slice(0, 3) as k (k.did)} 135 - <Avatar did={k.did} src={k.avatar ?? null} name={k.displayName} size={20} /> 136 - {/each} 137 - </div> 138 - <span class="known-text"> 139 - Followed by {known.slice(0, 2).map((k) => k.displayName || k.handle).join(', ')}{#if known.length > 2}{' '}and {known.length - 2} other{known.length - 2 !== 1 ? 's' : ''} you follow{/if} 140 - </span> 141 - </a> 142 193 {/if} 143 194 </div> 144 195 </div> ··· 157 208 <AvatarLightbox src={lightboxSrc} onclose={() => (lightboxSrc = null)} /> 158 209 {/if} 159 210 211 + {#if !blockHide} 160 212 <div class="view-toggle"> 161 213 <button class="toggle-btn" class:active={viewMode === 'grid'} onclick={() => setTab('grid')} aria-label="Grid view"> 162 214 <Grid3x3 size={20} /> ··· 184 236 /> 185 237 {:else} 186 238 <GalleryGrid items={feed.data?.items ?? []} loading={feed.isLoading} /> 239 + {/if} 187 240 {/if} 188 241 189 242 {#if showStoryViewer} ··· 288 341 font-weight: 500; 289 342 } 290 343 .bsky-link:hover { text-decoration: underline; } 344 + .block-alert { 345 + display: inline-flex; 346 + align-items: center; 347 + gap: 6px; 348 + margin-top: 10px; 349 + padding: 6px 12px; 350 + border-radius: 6px; 351 + background: var(--bg-elevated); 352 + border: 1px solid var(--border); 353 + font-size: 13px; 354 + color: var(--text-muted); 355 + } 356 + .menu-item { 357 + display: flex; 358 + align-items: center; 359 + gap: 8px; 360 + width: 100%; 361 + padding: 8px 12px; 362 + border: none; 363 + background: none; 364 + color: var(--text-primary); 365 + font-size: 13px; 366 + font-family: inherit; 367 + cursor: pointer; 368 + border-radius: 6px; 369 + transition: background 0.15s; 370 + } 371 + .menu-item:hover { background: var(--bg-hover); } 372 + .menu-item.danger { color: #f87171; } 291 373 </style>
+56
app/routes/settings/+page.svelte
··· 1 + <script lang="ts"> 2 + import DetailHeader from '$lib/components/molecules/DetailHeader.svelte' 3 + import { UserPen, Shield, ChevronRight } from 'lucide-svelte' 4 + </script> 5 + 6 + <DetailHeader label="Settings" /> 7 + 8 + <div class="settings-page"> 9 + <div class="settings-group"> 10 + <a href="/settings/profile" class="settings-row"> 11 + <UserPen size={18} /> 12 + <span class="settings-label">Edit Profile</span> 13 + <ChevronRight size={16} class="chevron" /> 14 + </a> 15 + <a href="/settings/moderation" class="settings-row"> 16 + <Shield size={18} /> 17 + <span class="settings-label">Moderation</span> 18 + <ChevronRight size={16} class="chevron" /> 19 + </a> 20 + </div> 21 + </div> 22 + 23 + <style> 24 + .settings-page { 25 + max-width: 600px; 26 + margin: 0 auto; 27 + padding: 16px; 28 + } 29 + .settings-group { 30 + border: 1px solid var(--border); 31 + border-radius: 10px; 32 + overflow: hidden; 33 + } 34 + .settings-row { 35 + display: flex; 36 + align-items: center; 37 + gap: 12px; 38 + padding: 14px 16px; 39 + color: var(--text-primary); 40 + text-decoration: none; 41 + transition: background 0.12s; 42 + } 43 + .settings-row:not(:last-child) { 44 + border-bottom: 1px solid var(--border); 45 + } 46 + .settings-row:hover { 47 + background: var(--bg-hover); 48 + } 49 + .settings-label { 50 + flex: 1; 51 + font-size: 15px; 52 + } 53 + .settings-row :global(.chevron) { 54 + color: var(--text-muted); 55 + } 56 + </style>
+56
app/routes/settings/moderation/+page.svelte
··· 1 + <script lang="ts"> 2 + import DetailHeader from '$lib/components/molecules/DetailHeader.svelte' 3 + import { Ban, VolumeX, ChevronRight } from 'lucide-svelte' 4 + </script> 5 + 6 + <DetailHeader label="Moderation" /> 7 + 8 + <div class="settings-page"> 9 + <div class="settings-group"> 10 + <a href="/settings/moderation/blocked" class="settings-row"> 11 + <Ban size={18} /> 12 + <span class="settings-label">Blocked Users</span> 13 + <ChevronRight size={16} class="chevron" /> 14 + </a> 15 + <a href="/settings/moderation/muted" class="settings-row"> 16 + <VolumeX size={18} /> 17 + <span class="settings-label">Muted Users</span> 18 + <ChevronRight size={16} class="chevron" /> 19 + </a> 20 + </div> 21 + </div> 22 + 23 + <style> 24 + .settings-page { 25 + max-width: 600px; 26 + margin: 0 auto; 27 + padding: 16px; 28 + } 29 + .settings-group { 30 + border: 1px solid var(--border); 31 + border-radius: 10px; 32 + overflow: hidden; 33 + } 34 + .settings-row { 35 + display: flex; 36 + align-items: center; 37 + gap: 12px; 38 + padding: 14px 16px; 39 + color: var(--text-primary); 40 + text-decoration: none; 41 + transition: background 0.12s; 42 + } 43 + .settings-row:not(:last-child) { 44 + border-bottom: 1px solid var(--border); 45 + } 46 + .settings-row:hover { 47 + background: var(--bg-hover); 48 + } 49 + .settings-label { 50 + flex: 1; 51 + font-size: 15px; 52 + } 53 + .settings-row :global(.chevron) { 54 + color: var(--text-muted); 55 + } 56 + </style>
+69
app/routes/settings/moderation/blocked/+page.svelte
··· 1 + <script lang="ts"> 2 + import DetailHeader from '$lib/components/molecules/DetailHeader.svelte' 3 + import ProfileCard from '$lib/components/molecules/ProfileCard.svelte' 4 + import Skeleton from '$lib/components/atoms/Skeleton.svelte' 5 + import Button from '$lib/components/atoms/Button.svelte' 6 + import { createQuery, useQueryClient } from '@tanstack/svelte-query' 7 + import { blocksQuery } from '$lib/queries' 8 + import { unblockActor } from '$lib/mutations' 9 + 10 + const blocks = createQuery(() => blocksQuery()) 11 + const queryClient = useQueryClient() 12 + 13 + let unblocking = $state<Set<string>>(new Set()) 14 + 15 + async function handleUnblock(did: string, blockUri: string) { 16 + unblocking.add(did) 17 + unblocking = new Set(unblocking) 18 + try { 19 + await unblockActor(did, blockUri, queryClient) 20 + queryClient.invalidateQueries({ queryKey: ['blocks'] }) 21 + } finally { 22 + unblocking.delete(did) 23 + unblocking = new Set(unblocking) 24 + } 25 + } 26 + </script> 27 + 28 + <DetailHeader label="Blocked Users" /> 29 + 30 + {#if blocks.isLoading} 31 + {#each {length: 5} as _} 32 + <div class="skeleton-row"> 33 + <Skeleton circle height="40px" /> 34 + <div> 35 + <Skeleton width="120px" height="15px" /> 36 + <div style="margin-top:6px"><Skeleton width="80px" height="13px" /></div> 37 + </div> 38 + </div> 39 + {/each} 40 + {:else if (blocks.data?.items ?? []).length === 0} 41 + <div class="empty-state">You haven't blocked anyone.</div> 42 + {:else} 43 + {#each blocks.data?.items ?? [] as person (person.did)} 44 + <div class="block-row"> 45 + <div class="block-profile"><ProfileCard profile={person} /></div> 46 + <div class="block-action"> 47 + <Button variant="secondary" size="sm" disabled={unblocking.has(person.did)} onclick={() => handleUnblock(person.did, person.blockUri)}> 48 + Unblock 49 + </Button> 50 + </div> 51 + </div> 52 + {/each} 53 + {/if} 54 + 55 + <style> 56 + .empty-state { padding: 48px; text-align: center; color: var(--text-secondary); } 57 + .skeleton-row { 58 + display: flex; gap: 12px; align-items: center; 59 + padding: 12px 16px; border-bottom: 1px solid var(--border); 60 + } 61 + .block-row { 62 + display: flex; 63 + align-items: center; 64 + border-bottom: 1px solid var(--border); 65 + } 66 + .block-profile { flex: 1; min-width: 0; } 67 + .block-profile :global(.profile-card) { border-bottom: none; } 68 + .block-action { padding-right: 16px; flex-shrink: 0; } 69 + </style>
+69
app/routes/settings/moderation/muted/+page.svelte
··· 1 + <script lang="ts"> 2 + import DetailHeader from '$lib/components/molecules/DetailHeader.svelte' 3 + import ProfileCard from '$lib/components/molecules/ProfileCard.svelte' 4 + import Skeleton from '$lib/components/atoms/Skeleton.svelte' 5 + import Button from '$lib/components/atoms/Button.svelte' 6 + import { createQuery, useQueryClient } from '@tanstack/svelte-query' 7 + import { mutesQuery } from '$lib/queries' 8 + import { unmuteActor } from '$lib/mutations' 9 + 10 + const mutes = createQuery(() => mutesQuery()) 11 + const queryClient = useQueryClient() 12 + 13 + let unmuting = $state<Set<string>>(new Set()) 14 + 15 + async function handleUnmute(did: string) { 16 + unmuting.add(did) 17 + unmuting = new Set(unmuting) 18 + try { 19 + await unmuteActor(did, queryClient) 20 + queryClient.invalidateQueries({ queryKey: ['mutes'] }) 21 + } finally { 22 + unmuting.delete(did) 23 + unmuting = new Set(unmuting) 24 + } 25 + } 26 + </script> 27 + 28 + <DetailHeader label="Muted Users" /> 29 + 30 + {#if mutes.isLoading} 31 + {#each {length: 5} as _} 32 + <div class="skeleton-row"> 33 + <Skeleton circle height="40px" /> 34 + <div> 35 + <Skeleton width="120px" height="15px" /> 36 + <div style="margin-top:6px"><Skeleton width="80px" height="13px" /></div> 37 + </div> 38 + </div> 39 + {/each} 40 + {:else if (mutes.data?.items ?? []).length === 0} 41 + <div class="empty-state">You haven't muted anyone.</div> 42 + {:else} 43 + {#each mutes.data?.items ?? [] as person (person.did)} 44 + <div class="mute-row"> 45 + <div class="mute-profile"><ProfileCard profile={person} /></div> 46 + <div class="mute-action"> 47 + <Button variant="secondary" size="sm" disabled={unmuting.has(person.did)} onclick={() => handleUnmute(person.did)}> 48 + Unmute 49 + </Button> 50 + </div> 51 + </div> 52 + {/each} 53 + {/if} 54 + 55 + <style> 56 + .empty-state { padding: 48px; text-align: center; color: var(--text-secondary); } 57 + .skeleton-row { 58 + display: flex; gap: 12px; align-items: center; 59 + padding: 12px 16px; border-bottom: 1px solid var(--border); 60 + } 61 + .mute-row { 62 + display: flex; 63 + align-items: center; 64 + border-bottom: 1px solid var(--border); 65 + } 66 + .mute-profile { flex: 1; min-width: 0; } 67 + .mute-profile :global(.profile-card) { border-bottom: none; } 68 + .mute-action { padding-right: 16px; flex-shrink: 0; } 69 + </style>
+16
db/schema.sql
··· 16 16 exp TEXT 17 17 ); 18 18 19 + CREATE TABLE _mutes ( 20 + did TEXT NOT NULL, 21 + subject TEXT NOT NULL, 22 + created_at TEXT NOT NULL, 23 + PRIMARY KEY (did, subject) 24 + ); 25 + 19 26 CREATE TABLE _oauth_codes ( 20 27 code TEXT PRIMARY KEY, 21 28 request_uri TEXT NOT NULL, ··· 303 310 parent_uri TEXT NOT NULL, 304 311 parent_did TEXT NOT NULL, 305 312 val TEXT 313 + ); 314 + 315 + CREATE TABLE "social.grain.graph.block" ( 316 + uri TEXT PRIMARY KEY, 317 + cid TEXT, 318 + did TEXT NOT NULL, 319 + indexed_at TEXT NOT NULL, 320 + subject TEXT NOT NULL, 321 + created_at TEXT NOT NULL 306 322 ); 307 323 308 324 CREATE TABLE "social.grain.graph.follow" (
+648
docs/plans/2026-04-09-blocks-and-mutes.md
··· 1 + # Blocks and Mutes Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Add Bluesky-style blocking and muting so users can control who they see and who can interact with them. 6 + 7 + **Architecture:** Blocks are public AT Protocol records (like follows) stored in the user's repo — bidirectional, meaning neither party sees the other's content. Mutes are private server-side state (not records) — only hide content from the muter, the muted user never knows. Both filter feeds, notifications, and profile interactions. 8 + 9 + **Tech Stack:** hatk (defineQuery, defineFeed, lexicons), SvelteKit 2, tanstack-query, Svelte 5 runes 10 + 11 + --- 12 + 13 + ### Task 1: Block Lexicon 14 + 15 + **Files:** 16 + - Create: `lexicons/social/grain/graph/block.json` 17 + 18 + **Step 1: Create the block record lexicon** 19 + 20 + ```json 21 + { 22 + "lexicon": 1, 23 + "id": "social.grain.graph.block", 24 + "defs": { 25 + "main": { 26 + "key": "tid", 27 + "type": "record", 28 + "record": { 29 + "type": "object", 30 + "required": ["subject", "createdAt"], 31 + "properties": { 32 + "subject": { 33 + "type": "string", 34 + "format": "did" 35 + }, 36 + "createdAt": { 37 + "type": "string", 38 + "format": "datetime" 39 + } 40 + } 41 + } 42 + } 43 + } 44 + } 45 + ``` 46 + 47 + This mirrors `social.grain.graph.follow` exactly — same structure, same key type (tid). 48 + 49 + **Step 2: Regenerate types** 50 + 51 + Run: `npx hatk generate types` 52 + Expected: `Generated ./hatk.generated.ts` with new `GrainGraphBlock` type 53 + 54 + **Step 3: Commit** 55 + 56 + ```bash 57 + git add lexicons/social/grain/graph/block.json hatk.generated.ts hatk.generated.client.ts 58 + git commit -m "feat: add social.grain.graph.block lexicon" 59 + ``` 60 + 61 + --- 62 + 63 + ### Task 2: Mute XRPC Procedures 64 + 65 + Mutes are server-side state, not records. We need a `_mutes` table and two XRPC procedures. 66 + 67 + **Files:** 68 + - Create: `server/xrpc/muteActor.ts` 69 + - Create: `server/xrpc/unmuteActor.ts` 70 + - Create: `lexicons/social/grain/graph/muteActor.json` 71 + - Create: `lexicons/social/grain/graph/unmuteActor.json` 72 + 73 + **Step 1: Create the muteActor lexicon** 74 + 75 + ```json 76 + { 77 + "lexicon": 1, 78 + "id": "social.grain.graph.muteActor", 79 + "defs": { 80 + "main": { 81 + "type": "procedure", 82 + "description": "Mute an actor. Mutes are private and only affect the muter's feeds.", 83 + "input": { 84 + "encoding": "application/json", 85 + "schema": { 86 + "type": "object", 87 + "required": ["actor"], 88 + "properties": { 89 + "actor": { 90 + "type": "string", 91 + "format": "did" 92 + } 93 + } 94 + } 95 + } 96 + } 97 + } 98 + } 99 + ``` 100 + 101 + **Step 2: Create the unmuteActor lexicon** 102 + 103 + ```json 104 + { 105 + "lexicon": 1, 106 + "id": "social.grain.graph.unmuteActor", 107 + "defs": { 108 + "main": { 109 + "type": "procedure", 110 + "description": "Unmute an actor.", 111 + "input": { 112 + "encoding": "application/json", 113 + "schema": { 114 + "type": "object", 115 + "required": ["actor"], 116 + "properties": { 117 + "actor": { 118 + "type": "string", 119 + "format": "did" 120 + } 121 + } 122 + } 123 + } 124 + } 125 + } 126 + } 127 + ``` 128 + 129 + **Step 3: Implement muteActor handler** 130 + 131 + ```typescript 132 + // server/xrpc/muteActor.ts 133 + import { defineQuery, InvalidRequestError } from "$hatk"; 134 + 135 + export default defineQuery("social.grain.graph.muteActor", async (ctx) => { 136 + const { ok, params, db, viewer } = ctx; 137 + if (!viewer) throw new InvalidRequestError("Authentication required"); 138 + 139 + const { actor } = params; 140 + if (actor === viewer.did) throw new InvalidRequestError("Cannot mute yourself"); 141 + 142 + await db.query( 143 + `INSERT INTO _mutes (did, subject, created_at) 144 + VALUES ($1, $2, $3) 145 + ON CONFLICT (did, subject) DO NOTHING`, 146 + [viewer.did, actor, new Date().toISOString()], 147 + ); 148 + 149 + return ok({}); 150 + }); 151 + ``` 152 + 153 + **Step 4: Implement unmuteActor handler** 154 + 155 + ```typescript 156 + // server/xrpc/unmuteActor.ts 157 + import { defineQuery, InvalidRequestError } from "$hatk"; 158 + 159 + export default defineQuery("social.grain.graph.unmuteActor", async (ctx) => { 160 + const { ok, params, db, viewer } = ctx; 161 + if (!viewer) throw new InvalidRequestError("Authentication required"); 162 + 163 + await db.query( 164 + `DELETE FROM _mutes WHERE did = $1 AND subject = $2`, 165 + [viewer.did, params.actor], 166 + ); 167 + 168 + return ok({}); 169 + }); 170 + ``` 171 + 172 + **Step 5: Create the _mutes table** 173 + 174 + hatk auto-creates tables for records, but since mutes aren't records we need a migration. Check if hatk has a migration mechanism, or create the table via a startup hook. If neither exists, we can create it via a raw SQL initialization. 175 + 176 + The table schema: 177 + ```sql 178 + CREATE TABLE IF NOT EXISTS _mutes ( 179 + did TEXT NOT NULL, 180 + subject TEXT NOT NULL, 181 + created_at TEXT NOT NULL, 182 + PRIMARY KEY (did, subject) 183 + ); 184 + CREATE INDEX IF NOT EXISTS idx_mutes_did ON _mutes (did); 185 + CREATE INDEX IF NOT EXISTS idx_mutes_subject ON _mutes (subject); 186 + ``` 187 + 188 + Check how the project handles custom tables (look for any existing `CREATE TABLE` statements or migration files). If hatk supports `ctx.db.query` for DDL, use an `on-start` hook or similar. 189 + 190 + **Step 6: Regenerate types and commit** 191 + 192 + Run: `npx hatk generate types` 193 + 194 + ```bash 195 + git add lexicons/social/grain/graph/muteActor.json lexicons/social/grain/graph/unmuteActor.json \ 196 + server/xrpc/muteActor.ts server/xrpc/unmuteActor.ts hatk.generated.ts hatk.generated.client.ts 197 + git commit -m "feat: add mute/unmute actor XRPC procedures" 198 + ``` 199 + 200 + --- 201 + 202 + ### Task 3: Extend Viewer State with Block/Mute Info 203 + 204 + **Files:** 205 + - Modify: `lexicons/social/grain/actor/defs.json` — add `blocking`, `blockedBy`, `muted` to `viewerState` 206 + - Modify: `server/xrpc/getActorProfile.ts` — query block/mute relationships 207 + 208 + **Step 1: Update viewerState in lexicon** 209 + 210 + In `lexicons/social/grain/actor/defs.json`, update the `viewerState` definition: 211 + 212 + ```json 213 + "viewerState": { 214 + "type": "object", 215 + "description": "Metadata about the requesting account's relationship with the subject account. Only has meaningful content for authed requests.", 216 + "properties": { 217 + "following": { "type": "string", "format": "at-uri" }, 218 + "followedBy": { "type": "string", "format": "at-uri" }, 219 + "blocking": { "type": "string", "format": "at-uri" }, 220 + "blockedBy": { "type": "boolean" }, 221 + "muted": { "type": "boolean" } 222 + } 223 + } 224 + ``` 225 + 226 + - `blocking`: AT-URI of the viewer's block record (so client can delete it to unblock) 227 + - `blockedBy`: boolean — the subject blocks the viewer 228 + - `muted`: boolean — the viewer has muted the subject 229 + 230 + **Step 2: Add block/mute queries to getActorProfile.ts** 231 + 232 + In the `Promise.all` block (after the follow queries), add: 233 + 234 + ```typescript 235 + // viewer blocks actor 236 + ctx.db.query( 237 + `SELECT uri FROM "social.grain.graph.block" WHERE did = $1 AND subject = $2 LIMIT 1`, 238 + [viewer, actor], 239 + ) as Promise<{ uri: string }[]>, 240 + 241 + // actor blocks viewer 242 + ctx.db.query( 243 + `SELECT uri FROM "social.grain.graph.block" WHERE did = $1 AND subject = $2 LIMIT 1`, 244 + [actor, viewer], 245 + ) as Promise<{ uri: string }[]>, 246 + 247 + // viewer mutes actor 248 + ctx.db.query( 249 + `SELECT 1 FROM _mutes WHERE did = $1 AND subject = $2 LIMIT 1`, 250 + [viewer, actor], 251 + ) as Promise<{ 1: number }[]>, 252 + ``` 253 + 254 + Then in the response, extend the viewer object: 255 + 256 + ```typescript 257 + viewer: { 258 + ...(viewerFollowing ? { following: viewerFollowing } : {}), 259 + ...(followedBy ? { followedBy } : {}), 260 + ...(viewerBlocking ? { blocking: viewerBlocking } : {}), 261 + ...(blockedBy ? { blockedBy: true } : {}), 262 + ...(viewerMuted ? { muted: true } : {}), 263 + }, 264 + ``` 265 + 266 + **Important behavior when blocked:** When `blockedBy` is true, strip out `following`/`followedBy` from viewer state (Bluesky convention — blocks hide the follow relationship). When `blocking` is set, also hide follows. 267 + 268 + **Step 3: Regenerate types and commit** 269 + 270 + Run: `npx hatk generate types` 271 + 272 + ```bash 273 + git add lexicons/social/grain/actor/defs.json server/xrpc/getActorProfile.ts \ 274 + hatk.generated.ts hatk.generated.client.ts 275 + git commit -m "feat: return block/mute status in profile viewer state" 276 + ``` 277 + 278 + --- 279 + 280 + ### Task 4: Filter Feeds by Blocks and Mutes 281 + 282 + All feeds should exclude galleries from users the viewer has blocked or muted, and from users who have blocked the viewer. 283 + 284 + **Files:** 285 + - Create: `server/filters/blockMute.ts` — shared SQL filter helper 286 + - Modify: `server/feeds/following.ts` 287 + - Modify: `server/feeds/recent.ts` 288 + - Modify: `server/feeds/foryou.ts` 289 + - Modify: `server/feeds/camera.ts` 290 + - Modify: `server/feeds/hashtag.ts` 291 + - Modify: `server/feeds/location.ts` 292 + 293 + **Step 1: Create shared filter helper** 294 + 295 + ```typescript 296 + // server/filters/blockMute.ts 297 + 298 + /** 299 + * Returns a SQL fragment that excludes rows from blocked/muted users. 300 + * @param didColumn - the column containing the gallery creator's DID (e.g. "t.did") 301 + * @param viewerParam - the SQL parameter number for the viewer DID (e.g. "$1") 302 + */ 303 + export function blockMuteFilter(didColumn: string, viewerParam: string): string { 304 + return ` 305 + AND ${didColumn} NOT IN ( 306 + SELECT subject FROM "social.grain.graph.block" WHERE did = ${viewerParam} 307 + ) 308 + AND ${didColumn} NOT IN ( 309 + SELECT did FROM "social.grain.graph.block" WHERE subject = ${viewerParam} 310 + ) 311 + AND ${didColumn} NOT IN ( 312 + SELECT subject FROM _mutes WHERE did = ${viewerParam} 313 + ) 314 + `; 315 + } 316 + ``` 317 + 318 + Three subqueries: 319 + 1. Users the viewer has blocked 320 + 2. Users who have blocked the viewer (bidirectional) 321 + 3. Users the viewer has muted 322 + 323 + **Step 2: Add filter to following feed** 324 + 325 + In `server/feeds/following.ts`, import `blockMuteFilter` and add it to the query. The actor param is `$1`, so: 326 + 327 + ```typescript 328 + import { blockMuteFilter } from "../filters/blockMute.ts"; 329 + 330 + // In generate(): 331 + const { rows, cursor } = await ctx.paginate<{ uri: string }>( 332 + `SELECT t.uri, t.cid, t.created_at FROM "social.grain.gallery" t 333 + LEFT JOIN _repos r ON t.did = r.did 334 + WHERE (r.status IS NULL OR r.status != 'takendown') 335 + AND t.did IN (SELECT subject FROM "social.grain.graph.follow" WHERE did = $1) 336 + AND ${hideLabelsFilter("t.uri")} 337 + AND (SELECT count(*) FROM "social.grain.gallery.item" gi WHERE gi.gallery = t.uri) > 0 338 + ${blockMuteFilter("t.did", "$1")}`, 339 + { orderBy: "t.created_at", params: [actor] }, 340 + ); 341 + ``` 342 + 343 + **Step 3: Add filter to recent feed** 344 + 345 + The recent feed has no actor param. We need to conditionally add the filter only when there's a viewer. Pass the viewer DID as a param: 346 + 347 + ```typescript 348 + import { blockMuteFilter } from "../filters/blockMute.ts"; 349 + 350 + async generate(ctx) { 351 + const viewer = ctx.params.actor; // viewer DID if authenticated 352 + const filterSql = viewer ? blockMuteFilter("t.did", "$1") : ""; 353 + const filterParams = viewer ? [viewer] : []; 354 + 355 + const { rows, cursor } = await ctx.paginate<{ uri: string }>( 356 + `SELECT t.uri, t.cid, t.created_at FROM "social.grain.gallery" t 357 + LEFT JOIN _repos r ON t.did = r.did 358 + WHERE (r.status IS NULL OR r.status != 'takendown') 359 + AND ${hideLabelsFilter("t.uri")} 360 + AND (SELECT count(*) FROM "social.grain.gallery.item" gi WHERE gi.gallery = t.uri) > 0 361 + ${filterSql}`, 362 + { orderBy: "t.created_at", ...(filterParams.length ? { params: filterParams } : {}) }, 363 + ); 364 + 365 + return ctx.ok({ uris: rows.map((r) => r.uri), cursor }); 366 + }, 367 + ``` 368 + 369 + **Step 4: Repeat for camera, hashtag, location, foryou feeds** 370 + 371 + Same pattern — import `blockMuteFilter`, add the SQL fragment. For the For You feed which does scoring in JS, filter blocked/muted users from the candidate set before scoring. 372 + 373 + Check each feed's param structure to use the correct parameter number. 374 + 375 + **Step 5: Commit** 376 + 377 + ```bash 378 + git add server/filters/blockMute.ts server/feeds/*.ts 379 + git commit -m "feat: filter blocked and muted users from all feeds" 380 + ``` 381 + 382 + --- 383 + 384 + ### Task 5: Filter Notifications by Blocks and Mutes 385 + 386 + **Files:** 387 + - Modify: `server/xrpc/getNotifications.ts` 388 + 389 + **Step 1: Add block/mute filtering to notification queries** 390 + 391 + Import the filter helper and add exclusions to each UNION branch in `notificationUnion()`. Each branch has a `did` column representing the notification author. Add: 392 + 393 + ```sql 394 + AND did NOT IN (SELECT subject FROM "social.grain.graph.block" WHERE did = $1) 395 + AND did NOT IN (SELECT did FROM "social.grain.graph.block" WHERE subject = $1) 396 + AND did NOT IN (SELECT subject FROM _mutes WHERE did = $1) 397 + ``` 398 + 399 + Since `$1` is already the viewer DID in all branches, this works directly. 400 + 401 + **Step 2: Commit** 402 + 403 + ```bash 404 + git add server/xrpc/getNotifications.ts 405 + git commit -m "feat: filter blocked and muted users from notifications" 406 + ``` 407 + 408 + --- 409 + 410 + ### Task 6: Block/Mute UI — Profile Menu 411 + 412 + **Files:** 413 + - Modify: `app/routes/profile/[did]/+page.svelte` — add OverflowMenu with block/mute options 414 + - Modify: `app/lib/queries.ts` — add block/mute mutation helpers if needed 415 + 416 + **Step 1: Add three-dot menu to profile page** 417 + 418 + In the `.actions` div where FollowButton lives, add an OverflowMenu with Block and Mute options. Pattern from GalleryCard.svelte: 419 + 420 + ```svelte 421 + <OverflowMenu> 422 + <button class="menu-item" type="button" onclick={handleMute}> 423 + {p.viewer?.muted ? 'Unmute' : 'Mute'} @{p.handle} 424 + </button> 425 + <button class="menu-item danger" type="button" onclick={handleBlock}> 426 + {p.viewer?.blocking ? 'Unblock' : 'Block'} @{p.handle} 427 + </button> 428 + </OverflowMenu> 429 + ``` 430 + 431 + **Step 2: Implement block handler** 432 + 433 + Block creates a record (like follow): 434 + 435 + ```typescript 436 + async function handleBlock() { 437 + if (!requireAuth()) return 438 + if (p.viewer?.blocking) { 439 + // Unblock — delete the record 440 + const rkey = p.viewer.blocking.split('/').pop()! 441 + await callXrpc('dev.hatk.deleteRecord', { 442 + collection: 'social.grain.graph.block', 443 + rkey, 444 + }) 445 + } else { 446 + // Block — create the record 447 + await callXrpc('dev.hatk.createRecord', { 448 + collection: 'social.grain.graph.block', 449 + record: { subject: did, createdAt: new Date().toISOString() }, 450 + }) 451 + } 452 + queryClient.invalidateQueries({ queryKey: ['actorProfile', did] }) 453 + } 454 + ``` 455 + 456 + **Step 3: Implement mute handler** 457 + 458 + Mute calls the procedure: 459 + 460 + ```typescript 461 + async function handleMute() { 462 + if (!requireAuth()) return 463 + if (p.viewer?.muted) { 464 + await callXrpc('social.grain.graph.unmuteActor', { actor: did }) 465 + } else { 466 + await callXrpc('social.grain.graph.muteActor', { actor: did }) 467 + } 468 + queryClient.invalidateQueries({ queryKey: ['actorProfile', did] }) 469 + } 470 + ``` 471 + 472 + **Step 4: Show blocked state on profile** 473 + 474 + When `p.viewer?.blockedBy` or `p.viewer?.blocking`, show a notice instead of the user's content: 475 + 476 + ```svelte 477 + {#if p.viewer?.blocking} 478 + <div class="block-notice"> 479 + <p>You have blocked this user.</p> 480 + <button onclick={handleBlock}>Unblock</button> 481 + </div> 482 + {:else if p.viewer?.blockedBy} 483 + <div class="block-notice"> 484 + <p>This user has blocked you.</p> 485 + </div> 486 + {/if} 487 + ``` 488 + 489 + Hide the gallery tab content, followers count, etc. when either party blocks the other. 490 + 491 + **Step 5: Commit** 492 + 493 + ```bash 494 + git add app/routes/profile/[did]/+page.svelte app/lib/queries.ts 495 + git commit -m "feat: add block and mute UI to profile page" 496 + ``` 497 + 498 + --- 499 + 500 + ### Task 7: Block/Mute Settings Pages 501 + 502 + **Files:** 503 + - Create: `app/routes/settings/blocked/+page.svelte` — list of blocked users 504 + - Create: `app/routes/settings/blocked/+page.ts` — loader 505 + - Create: `app/routes/settings/muted/+page.svelte` — list of muted users 506 + - Create: `app/routes/settings/muted/+page.ts` — loader 507 + - Create: `server/xrpc/getBlocks.ts` — XRPC to list blocked users 508 + - Create: `server/xrpc/getMutes.ts` — XRPC to list muted users 509 + - Create: `lexicons/social/grain/unspecced/getBlocks.json` 510 + - Create: `lexicons/social/grain/unspecced/getMutes.json` 511 + 512 + **Step 1: Create getBlocks XRPC** 513 + 514 + ```typescript 515 + // server/xrpc/getBlocks.ts 516 + import { defineQuery, InvalidRequestError } from "$hatk"; 517 + import type { GrainActorProfile } from "$hatk"; 518 + import { views } from "$hatk"; 519 + 520 + export default defineQuery("social.grain.unspecced.getBlocks", async (ctx) => { 521 + const { ok, params, db, viewer, lookup, blobUrl } = ctx; 522 + if (!viewer) throw new InvalidRequestError("Authentication required"); 523 + 524 + const limit = params.limit ?? 50; 525 + const rows = (await db.query( 526 + `SELECT b.subject, b.uri, b.created_at 527 + FROM "social.grain.graph.block" b 528 + WHERE b.did = $1 529 + ORDER BY b.created_at DESC 530 + LIMIT $2`, 531 + [viewer.did, limit], 532 + )) as { subject: string; uri: string; created_at: string }[]; 533 + 534 + const dids = rows.map((r) => r.subject); 535 + const profiles = await lookup<GrainActorProfile>("social.grain.actor.profile", "did", dids); 536 + 537 + const blocks = rows.map((row) => { 538 + const profile = profiles.get(row.subject); 539 + return { 540 + blockUri: row.uri, 541 + actor: profile 542 + ? views.grainActorDefsProfileView({ 543 + cid: profile.cid, 544 + did: profile.did, 545 + handle: profile.handle ?? profile.did, 546 + displayName: profile.value.displayName, 547 + avatar: blobUrl(profile.did, profile.value.avatar) ?? undefined, 548 + }) 549 + : views.grainActorDefsProfileView({ 550 + cid: "", 551 + did: row.subject, 552 + handle: row.subject, 553 + }), 554 + }; 555 + }); 556 + 557 + return ok({ blocks }); 558 + }); 559 + ``` 560 + 561 + **Step 2: Create getMutes XRPC** (similar pattern, querying `_mutes` table) 562 + 563 + **Step 3: Create settings pages** 564 + 565 + Each page shows a list of blocked/muted users with their avatar, name, handle, and an Unblock/Unmute button. Follow the pattern of existing settings pages in the app. 566 + 567 + **Step 4: Add navigation links to settings** 568 + 569 + Check the existing settings layout/nav and add "Blocked users" and "Muted users" links. 570 + 571 + **Step 5: Commit** 572 + 573 + ```bash 574 + git add server/xrpc/getBlocks.ts server/xrpc/getMutes.ts \ 575 + lexicons/social/grain/unspecced/getBlocks.json lexicons/social/grain/unspecced/getMutes.json \ 576 + app/routes/settings/blocked/ app/routes/settings/muted/ \ 577 + hatk.generated.ts hatk.generated.client.ts 578 + git commit -m "feat: add blocked/muted user settings pages" 579 + ``` 580 + 581 + --- 582 + 583 + ### Task 8: Prevent Interactions When Blocked 584 + 585 + **Files:** 586 + - Modify: `server/xrpc/getGalleryThread.ts` (or equivalent gallery detail handler) — check blocks before returning 587 + - Modify: gallery comment submission handler — reject comments from blocked users 588 + - Modify: favorite handler — reject favorites from blocked users 589 + 590 + **Step 1: Add block checks to interaction endpoints** 591 + 592 + When user A has blocked user B: 593 + - B cannot favorite A's galleries 594 + - B cannot comment on A's galleries 595 + - B cannot follow A (and vice versa) 596 + 597 + Check the commit hooks (`server/hooks/`) for any `on-commit-*` handlers that should also respect blocks. 598 + 599 + In comment/favorite creation, add a pre-check: 600 + 601 + ```typescript 602 + // Check if gallery owner has blocked the commenter 603 + const blockRows = await db.query( 604 + `SELECT 1 FROM "social.grain.graph.block" 605 + WHERE (did = $1 AND subject = $2) OR (did = $2 AND subject = $1) LIMIT 1`, 606 + [galleryOwnerDid, viewer.did], 607 + ); 608 + if (blockRows.length > 0) { 609 + throw new InvalidRequestError("Blocked"); 610 + } 611 + ``` 612 + 613 + **Step 2: Commit** 614 + 615 + ```bash 616 + git add server/xrpc/*.ts server/hooks/*.ts 617 + git commit -m "feat: prevent interactions between blocked users" 618 + ``` 619 + 620 + --- 621 + 622 + ### Task 9: Seed Data and Manual Testing 623 + 624 + **Files:** 625 + - Modify: `seeds/seed.ts` — add block and mute relationships for testing 626 + 627 + **Step 1: Add test data** 628 + 629 + Add blocks and mutes to the seed: 630 + - Alice blocks Eve (or a new test user) 631 + - Bob mutes Carol 632 + - This lets us verify feed filtering and profile states 633 + 634 + **Step 2: Test manually** 635 + 636 + 1. Reseed: `npx hatk seed` (or however seeds are run) 637 + 2. Log in as Alice → verify Eve's galleries don't appear in feeds 638 + 3. View Eve's profile → should show "You have blocked this user" 639 + 4. Log in as Bob → verify Carol's galleries are hidden from feeds 640 + 5. View Carol's profile → should look normal (mutes are invisible to muted user) 641 + 6. Check notifications → blocked/muted user actions should be hidden 642 + 643 + **Step 3: Commit** 644 + 645 + ```bash 646 + git add seeds/seed.ts 647 + git commit -m "test: add block and mute seed data" 648 + ```
+1
hatk.config.ts
··· 15 15 "repo:social.grain.favorite", 16 16 "repo:social.grain.comment", 17 17 "repo:social.grain.story", 18 + "repo:social.grain.graph.block", 18 19 "repo:app.bsky.feed.post?action=create", 19 20 ].join(" "); 20 21
+2 -2
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, Declaration, CreateReport, DescribeCollections, DescribeFeeds, DescribeLabels, GetFeed, GetPreferences, GetRecord, GetRecords, PutPreference, SearchRecords, UploadBlob, Search, GrainActorProfile, Comment, Favorite, Gallery, Item, GrainGraphFollow, Photo, Exif, Story, DeleteGallery, GetActorFavorites, GetActorProfile, GetCameras, GetFollowers, GetFollowing, GetGallery, GetGalleryThread, GetKnownFollowers, GetLocations, GetNotifications, GetStories, GetStory, GetStoryArchive, GetStoryAuthors, GetSuggestedFollows, SearchActorsTypeahead, 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, DeclarationMessageMe, RepoRef, LabelDefinition, LabelLocale, Result, MentionLabel, EmbedInfo, SearchAspectRatio, SubscopeInfo, GrainActorDefsProfileView, GrainActorDefsProfileViewDetailed, GrainActorDefsMessageMe, GrainActorDefsViewerState, CommentView, GrainDefsAspectRatio, GalleryView, CrossPostInfo, GrainGalleryDefsViewerState, PhotoView, ExifView, GalleryState, StoryView, CameraItem, GetFollowersFollowerItem, GetFollowersViewerState, FollowingItem, GetFollowingViewerState, GetKnownFollowersFollowerItem, LocationItem, NotificationItem, StoryAuthor, SuggestedItem, ProfileSearchResult } from './hatk.generated.ts' 6 + export type { BskyActorProfile, Post, Postgate, Threadgate, BskyGraphFollow, Declaration, CreateReport, DescribeCollections, DescribeFeeds, DescribeLabels, GetFeed, GetPreferences, GetRecord, GetRecords, PutPreference, SearchRecords, UploadBlob, Search, GrainActorProfile, Comment, Favorite, Gallery, Item, Block, GrainGraphFollow, MuteActor, UnmuteActor, Photo, Exif, Story, DeleteGallery, GetActorFavorites, GetActorProfile, GetBlocks, GetCameras, GetFollowers, GetFollowing, GetGallery, GetGalleryThread, GetKnownFollowers, GetLocations, GetMutes, GetNotifications, GetStories, GetStory, GetStoryArchive, GetStoryAuthors, GetSuggestedFollows, SearchActorsTypeahead, 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, DeclarationMessageMe, RepoRef, LabelDefinition, LabelLocale, Result, MentionLabel, EmbedInfo, SearchAspectRatio, SubscopeInfo, GrainActorDefsProfileView, GrainActorDefsProfileViewDetailed, GrainActorDefsMessageMe, GrainActorDefsViewerState, CommentView, GrainDefsAspectRatio, GalleryView, CrossPostInfo, GrainGalleryDefsViewerState, PhotoView, ExifView, GalleryState, StoryView, BlockItem, CameraItem, GetFollowersFollowerItem, GetFollowersViewerState, FollowingItem, GetFollowingViewerState, GetKnownFollowersFollowerItem, LocationItem, MuteItem, NotificationItem, StoryAuthor, SuggestedItem, ProfileSearchResult } from './hatk.generated.ts' 7 7 8 - const _procedures = new Set(['dev.hatk.createRecord', 'dev.hatk.createReport', 'dev.hatk.deleteRecord', 'dev.hatk.putPreference', 'dev.hatk.putRecord', 'social.grain.unspecced.deleteGallery']) 8 + const _procedures = new Set(['dev.hatk.createRecord', 'dev.hatk.createReport', 'dev.hatk.deleteRecord', 'dev.hatk.putPreference', 'dev.hatk.putRecord', 'social.grain.graph.muteActor', 'social.grain.graph.unmuteActor', 'social.grain.unspecced.deleteGallery']) 9 9 const _blobInputs = new Set(['dev.hatk.uploadBlob']) 10 10 11 11 type CallArg<K extends keyof XrpcSchema> =
+24 -2
hatk.generated.ts
··· 46 46 const searchRecordsLex = {"lexicon":1,"id":"dev.hatk.searchRecords","defs":{"main":{"type":"query","description":"Full-text search across a collection.","parameters":{"type":"params","required":["collection","q"],"properties":{"collection":{"type":"string"},"q":{"type":"string","description":"Search query"},"limit":{"type":"integer","minimum":1,"maximum":100,"default":20},"cursor":{"type":"string"},"fuzzy":{"type":"boolean","default":true}}},"output":{"encoding":"application/json","schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"unknown"}},"cursor":{"type":"string"}}}}}}} as const 47 47 const uploadBlobLex = {"lexicon":1,"id":"dev.hatk.uploadBlob","defs":{"main":{"type":"procedure","description":"Upload a blob via the user's PDS.","input":{"encoding":"*/*"},"output":{"encoding":"application/json","schema":{"type":"object","required":["blob"],"properties":{"blob":{"type":"blob"}}}}}}} as const 48 48 const searchLex = {"lexicon":1,"id":"parts.page.mention.search","defs":{"main":{"type":"query","description":"Search a mention service for matching results.","parameters":{"type":"params","required":["service"],"properties":{"service":{"type":"string","format":"at-uri","description":"AT URI of the parts.page.mention.service record"},"search":{"type":"string","description":"Search query string"},"scope":{"type":"string","description":"Optional scope identifier to narrow results"},"limit":{"type":"integer","minimum":1,"maximum":50,"default":20}}},"output":{"encoding":"application/json","schema":{"type":"object","required":["results"],"properties":{"results":{"type":"array","items":{"type":"ref","ref":"#result"},"maxLength":50}}}}},"result":{"type":"object","required":["uri","name"],"properties":{"uri":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"labels":{"type":"array","items":{"type":"ref","ref":"#mentionLabel"}},"href":{"type":"string","format":"uri"},"icon":{"type":"string","format":"uri"},"embed":{"type":"ref","ref":"#embedInfo"},"subscope":{"type":"ref","ref":"#subscopeInfo"}}},"mentionLabel":{"type":"object","properties":{"text":{"type":"string"}}},"embedInfo":{"type":"object","required":["src"],"properties":{"src":{"type":"string","format":"uri"},"width":{"type":"integer","minimum":16,"maximum":3200},"height":{"type":"integer","minimum":16,"maximum":3200},"aspectRatio":{"type":"ref","ref":"#aspectRatio"}}},"aspectRatio":{"type":"object","required":["width","height"],"properties":{"width":{"type":"integer"},"height":{"type":"integer"}}},"subscopeInfo":{"type":"object","required":["scope","label"],"properties":{"scope":{"type":"string"},"label":{"type":"string","maxLength":100}}}}} as const 49 - const grainActorDefsLex = {"lexicon":1,"id":"social.grain.actor.defs","defs":{"profileView":{"type":"object","required":["cid","did","handle"],"properties":{"cid":{"type":"string","format":"cid"},"did":{"type":"string","format":"did"},"handle":{"type":"string","format":"handle"},"displayName":{"type":"string","maxGraphemes":64,"maxLength":640},"description":{"type":"string","maxLength":2560,"maxGraphemes":256},"labels":{"type":"array","items":{"ref":"com.atproto.label.defs#label","type":"ref"}},"avatar":{"type":"string","format":"uri"},"createdAt":{"type":"string","format":"datetime"}}},"profileViewDetailed":{"type":"object","required":["cid","did","handle"],"properties":{"cid":{"type":"string","format":"cid"},"did":{"type":"string","format":"did"},"handle":{"type":"string","format":"handle"},"displayName":{"type":"string","maxGraphemes":64,"maxLength":640},"description":{"type":"string","maxGraphemes":256,"maxLength":2560},"avatar":{"type":"string","format":"uri"},"cameras":{"type":"array","items":{"type":"string"},"description":"List of camera make and models used by this actor derived from EXIF data of photos linked to galleries."},"followersCount":{"type":"integer"},"followsCount":{"type":"integer"},"galleryCount":{"type":"integer"},"indexedAt":{"type":"string","format":"datetime"},"createdAt":{"type":"string","format":"datetime"},"messageMe":{"type":"ref","ref":"#messageMe"},"viewer":{"type":"ref","ref":"#viewerState"},"labels":{"type":"array","items":{"type":"ref","ref":"com.atproto.label.defs#label"}}}},"messageMe":{"type":"object","required":["showButtonTo","messageMeUrl"],"properties":{"messageMeUrl":{"type":"string","format":"uri"},"showButtonTo":{"type":"string","knownValues":["usersIFollow","everyone"]}}},"viewerState":{"type":"object","description":"Metadata about the requesting account's relationship with the subject account. Only has meaningful content for authed requests.","properties":{"following":{"type":"string","format":"at-uri"},"followedBy":{"type":"string","format":"at-uri"}}}}} as const 49 + const grainActorDefsLex = {"lexicon":1,"id":"social.grain.actor.defs","defs":{"profileView":{"type":"object","required":["cid","did","handle"],"properties":{"cid":{"type":"string","format":"cid"},"did":{"type":"string","format":"did"},"handle":{"type":"string","format":"handle"},"displayName":{"type":"string","maxGraphemes":64,"maxLength":640},"description":{"type":"string","maxLength":2560,"maxGraphemes":256},"labels":{"type":"array","items":{"ref":"com.atproto.label.defs#label","type":"ref"}},"avatar":{"type":"string","format":"uri"},"createdAt":{"type":"string","format":"datetime"}}},"profileViewDetailed":{"type":"object","required":["cid","did","handle"],"properties":{"cid":{"type":"string","format":"cid"},"did":{"type":"string","format":"did"},"handle":{"type":"string","format":"handle"},"displayName":{"type":"string","maxGraphemes":64,"maxLength":640},"description":{"type":"string","maxGraphemes":256,"maxLength":2560},"avatar":{"type":"string","format":"uri"},"cameras":{"type":"array","items":{"type":"string"},"description":"List of camera make and models used by this actor derived from EXIF data of photos linked to galleries."},"followersCount":{"type":"integer"},"followsCount":{"type":"integer"},"galleryCount":{"type":"integer"},"indexedAt":{"type":"string","format":"datetime"},"createdAt":{"type":"string","format":"datetime"},"messageMe":{"type":"ref","ref":"#messageMe"},"viewer":{"type":"ref","ref":"#viewerState"},"labels":{"type":"array","items":{"type":"ref","ref":"com.atproto.label.defs#label"}}}},"messageMe":{"type":"object","required":["showButtonTo","messageMeUrl"],"properties":{"messageMeUrl":{"type":"string","format":"uri"},"showButtonTo":{"type":"string","knownValues":["usersIFollow","everyone"]}}},"viewerState":{"type":"object","description":"Metadata about the requesting account's relationship with the subject account. Only has meaningful content for authed requests.","properties":{"following":{"type":"string","format":"at-uri"},"followedBy":{"type":"string","format":"at-uri"},"blocking":{"type":"string","format":"at-uri"},"blockedBy":{"type":"boolean"},"muted":{"type":"boolean"}}}}} as const 50 50 const grainActorProfileLex = {"lexicon":1,"id":"social.grain.actor.profile","defs":{"main":{"type":"record","description":"A declaration of a basic account profile.","key":"literal:self","record":{"type":"object","properties":{"displayName":{"type":"string","maxGraphemes":64,"maxLength":640},"description":{"type":"string","description":"Free-form profile description text.","maxGraphemes":256,"maxLength":2560},"avatar":{"type":"blob","description":"Small image to be displayed next to posts from account. AKA, 'profile picture'","accept":["image/png","image/jpeg"],"maxSize":1000000},"createdAt":{"type":"string","format":"datetime"}}}}}} as const 51 51 const commentLex = {"lexicon":1,"id":"social.grain.comment","defs":{"main":{"type":"record","key":"tid","record":{"type":"object","required":["text","subject","createdAt"],"properties":{"text":{"type":"string","maxLength":3000,"maxGraphemes":300},"facets":{"type":"array","description":"Annotations of description text (mentions and URLs, hashtags, etc)","items":{"type":"ref","ref":"app.bsky.richtext.facet"}},"subject":{"type":"string","format":"at-uri"},"focus":{"type":"string","format":"at-uri"},"replyTo":{"type":"string","format":"at-uri"},"createdAt":{"type":"string","format":"datetime"}}}}}} as const 52 - const grainCommentDefsLex = {"lexicon":1,"id":"social.grain.comment.defs","defs":{"commentView":{"type":"object","required":["uri","cid","author","text","createdAt"],"properties":{"uri":{"type":"string","format":"at-uri"},"cid":{"type":"string","format":"cid"},"author":{"type":"ref","ref":"social.grain.actor.defs#profileView"},"record":{"type":"unknown"},"text":{"type":"string","maxLength":3000,"maxGraphemes":300},"facets":{"type":"array","description":"Annotations of description text (mentions and URLs, hashtags, etc)","items":{"type":"ref","ref":"app.bsky.richtext.facet"}},"subject":{"type":"union","refs":["social.grain.gallery.defs#galleryView"],"description":"The subject of the comment, which can be a gallery or a photo."},"focus":{"type":"union","refs":["social.grain.photo.defs#photoView"],"description":"The photo that the comment is focused on, if applicable."},"replyTo":{"type":"string","format":"at-uri","description":"The URI of the comment this comment is replying to, if applicable."},"createdAt":{"type":"string","format":"datetime"}}}}} as const 52 + const grainCommentDefsLex = {"lexicon":1,"id":"social.grain.comment.defs","defs":{"commentView":{"type":"object","required":["uri","cid","author","text","createdAt"],"properties":{"uri":{"type":"string","format":"at-uri"},"cid":{"type":"string","format":"cid"},"author":{"type":"ref","ref":"social.grain.actor.defs#profileView"},"record":{"type":"unknown"},"text":{"type":"string","maxLength":3000,"maxGraphemes":300},"facets":{"type":"array","description":"Annotations of description text (mentions and URLs, hashtags, etc)","items":{"type":"ref","ref":"app.bsky.richtext.facet"}},"subject":{"type":"union","refs":["social.grain.gallery.defs#galleryView"],"description":"The subject of the comment, which can be a gallery or a photo."},"focus":{"type":"union","refs":["social.grain.photo.defs#photoView"],"description":"The photo that the comment is focused on, if applicable."},"replyTo":{"type":"string","format":"at-uri","description":"The URI of the comment this comment is replying to, if applicable."},"createdAt":{"type":"string","format":"datetime"},"muted":{"type":"boolean","description":"True if the viewer has muted the comment author. Client should show collapsed with option to expand."}}}}} as const 53 53 const grainDefsLex = {"lexicon":1,"id":"social.grain.defs","defs":{"aspectRatio":{"type":"object","description":"width:height represents an aspect ratio. It may be approximate, and may not correspond to absolute dimensions in any given unit.","required":["width","height"],"properties":{"width":{"type":"integer","minimum":1},"height":{"type":"integer","minimum":1}}}}} as const 54 54 const favoriteLex = {"lexicon":1,"id":"social.grain.favorite","defs":{"main":{"type":"record","key":"tid","record":{"type":"object","required":["createdAt","subject"],"properties":{"createdAt":{"type":"string","format":"datetime"},"subject":{"type":"string","format":"at-uri"}}}}}} as const 55 55 const galleryLex = {"lexicon":1,"id":"social.grain.gallery","defs":{"main":{"type":"record","key":"tid","record":{"type":"object","required":["title","createdAt"],"properties":{"title":{"type":"string","maxLength":100},"description":{"type":"string","maxLength":1000},"facets":{"type":"array","description":"Annotations of description text (mentions, URLs, hashtags, etc)","items":{"type":"ref","ref":"app.bsky.richtext.facet"}},"labels":{"type":"union","description":"Self-label values for this post. Effectively content warnings.","refs":["com.atproto.label.defs#selfLabels"]},"location":{"type":"ref","ref":"community.lexicon.location.hthree"},"address":{"type":"ref","ref":"community.lexicon.location.address"},"updatedAt":{"type":"string","format":"datetime"},"createdAt":{"type":"string","format":"datetime"}}}}}} as const 56 56 const grainGalleryDefsLex = {"lexicon":1,"id":"social.grain.gallery.defs","defs":{"galleryView":{"type":"object","required":["uri","cid","creator","record","indexedAt"],"properties":{"uri":{"type":"string","format":"at-uri"},"cid":{"type":"string","format":"cid"},"title":{"type":"string"},"description":{"type":"string"},"cameras":{"type":"array","description":"List of camera make and models used in this gallery derived from EXIF data.","items":{"type":"string"}},"location":{"type":"ref","ref":"community.lexicon.location.hthree"},"address":{"type":"ref","ref":"community.lexicon.location.address"},"facets":{"type":"array","description":"Annotations of description text (mentions, URLs, hashtags, etc)","items":{"type":"ref","ref":"app.bsky.richtext.facet"}},"creator":{"type":"ref","ref":"social.grain.actor.defs#profileView"},"record":{"type":"unknown"},"items":{"type":"array","items":{"type":"union","refs":["social.grain.photo.defs#photoView"]}},"favCount":{"type":"integer"},"commentCount":{"type":"integer"},"labels":{"type":"array","items":{"type":"ref","ref":"com.atproto.label.defs#label"}},"createdAt":{"type":"string","format":"datetime"},"indexedAt":{"type":"string","format":"datetime"},"viewer":{"type":"ref","ref":"#viewerState"},"crossPost":{"type":"ref","ref":"#crossPostInfo"}}},"crossPostInfo":{"type":"object","required":["url"],"properties":{"url":{"type":"string","format":"uri","description":"URL to the cross-posted Bluesky post."}}},"viewerState":{"type":"object","description":"Metadata about the requesting account's relationship with the subject content. Only has meaningful content for authed requests.","properties":{"fav":{"type":"string","format":"at-uri"}}}}} as const 57 57 const itemLex = {"lexicon":1,"id":"social.grain.gallery.item","defs":{"main":{"type":"record","key":"tid","record":{"type":"object","required":["createdAt","gallery","item"],"properties":{"createdAt":{"type":"string","format":"datetime"},"gallery":{"type":"string","format":"at-uri"},"item":{"type":"string","format":"at-uri"},"position":{"type":"integer","default":0}}}}}} as const 58 + const blockLex = {"lexicon":1,"id":"social.grain.graph.block","defs":{"main":{"key":"tid","type":"record","record":{"type":"object","required":["subject","createdAt"],"properties":{"subject":{"type":"string","format":"did"},"createdAt":{"type":"string","format":"datetime"}}}}}} as const 58 59 const grainGraphFollowLex = {"lexicon":1,"id":"social.grain.graph.follow","defs":{"main":{"key":"tid","type":"record","record":{"type":"object","required":["subject","createdAt"],"properties":{"subject":{"type":"string","format":"did"},"createdAt":{"type":"string","format":"datetime"}}}}}} as const 60 + const muteActorLex = {"lexicon":1,"id":"social.grain.graph.muteActor","defs":{"main":{"type":"procedure","description":"Mute an actor. Mutes are private and only affect the muter's feeds.","input":{"encoding":"application/json","schema":{"type":"object","required":["actor"],"properties":{"actor":{"type":"string","format":"did"}}}},"output":{"encoding":"application/json","schema":{"type":"object","properties":{}}}}}} as const 61 + const unmuteActorLex = {"lexicon":1,"id":"social.grain.graph.unmuteActor","defs":{"main":{"type":"procedure","description":"Unmute an actor.","input":{"encoding":"application/json","schema":{"type":"object","required":["actor"],"properties":{"actor":{"type":"string","format":"did"}}}},"output":{"encoding":"application/json","schema":{"type":"object","properties":{}}}}}} as const 59 62 const photoLex = {"lexicon":1,"id":"social.grain.photo","defs":{"main":{"type":"record","key":"tid","record":{"type":"object","required":["photo","aspectRatio","createdAt"],"properties":{"photo":{"type":"blob","accept":["image/*"],"maxSize":1000000},"alt":{"type":"string","description":"Alt text description of the image, for accessibility."},"aspectRatio":{"type":"ref","ref":"social.grain.defs#aspectRatio"},"createdAt":{"type":"string","format":"datetime"}}}}}} as const 60 63 const grainPhotoDefsLex = {"lexicon":1,"id":"social.grain.photo.defs","defs":{"photoView":{"type":"object","required":["uri","cid","thumb","fullsize","aspectRatio"],"properties":{"uri":{"type":"string","format":"at-uri"},"cid":{"type":"string","format":"cid"},"thumb":{"type":"string","format":"uri","description":"Fully-qualified URL where a thumbnail of the image can be fetched. For example, CDN location provided by the App View."},"fullsize":{"type":"string","format":"uri","description":"Fully-qualified URL where a large version of the image can be fetched. May or may not be the exact original blob. For example, CDN location provided by the App View."},"alt":{"type":"string","description":"Alt text description of the image, for accessibility."},"aspectRatio":{"type":"ref","ref":"social.grain.defs#aspectRatio"},"exif":{"type":"ref","ref":"social.grain.photo.defs#exifView","description":"EXIF metadata for the photo, if available."},"gallery":{"type":"ref","ref":"#galleryState"}}},"exifView":{"type":"object","required":["uri","cid","photo","record","createdAt"],"properties":{"uri":{"type":"string","format":"at-uri"},"cid":{"type":"string","format":"cid"},"photo":{"type":"string","format":"at-uri"},"record":{"type":"unknown"},"createdAt":{"type":"string","format":"datetime"},"dateTimeOriginal":{"type":"string"},"exposureTime":{"type":"string"},"fNumber":{"type":"string"},"flash":{"type":"string"},"focalLengthIn35mmFormat":{"type":"string"},"iSO":{"type":"integer"},"lensMake":{"type":"string"},"lensModel":{"type":"string"},"make":{"type":"string"},"model":{"type":"string"}}},"galleryState":{"type":"object","required":["item","itemCreatedAt","itemPosition"],"description":"Metadata about the photo's relationship with the subject content. Only has meaningful content when photo is attached to a gallery.","properties":{"item":{"type":"string","format":"at-uri"},"itemCreatedAt":{"type":"string","format":"datetime"},"itemPosition":{"type":"integer"}}}}} as const 61 64 const exifLex = {"lexicon":1,"id":"social.grain.photo.exif","defs":{"main":{"type":"record","description":"Basic EXIF metadata for a photo. Integers are scaled by 1000000 to accommodate decimal values and potentially other tags in the future.","key":"tid","record":{"type":"object","required":["photo","createdAt"],"properties":{"photo":{"type":"string","format":"at-uri"},"createdAt":{"type":"string","format":"datetime"},"dateTimeOriginal":{"type":"string","format":"datetime"},"exposureTime":{"type":"integer"},"fNumber":{"type":"integer"},"flash":{"type":"string"},"focalLengthIn35mmFormat":{"type":"integer"},"iSO":{"type":"integer"},"lensMake":{"type":"string"},"lensModel":{"type":"string"},"make":{"type":"string"},"model":{"type":"string"}}}}}} as const ··· 64 67 const deleteGalleryLex = {"lexicon":1,"id":"social.grain.unspecced.deleteGallery","defs":{"main":{"type":"procedure","description":"Delete a gallery and all associated records (items, photos, EXIF, favorites, comments).","input":{"encoding":"application/json","schema":{"type":"object","required":["rkey"],"properties":{"rkey":{"type":"string","description":"Record key of the gallery to delete."}}}},"output":{"encoding":"application/json","schema":{"type":"object","properties":{}}}}}} as const 65 68 const getActorFavoritesLex = {"lexicon":1,"id":"social.grain.unspecced.getActorFavorites","defs":{"main":{"type":"query","description":"Get galleries favorited by the authenticated actor. Only the actor themselves can view their favorites.","parameters":{"type":"params","required":["actor"],"properties":{"actor":{"type":"string","format":"did"},"limit":{"type":"integer","minimum":1,"maximum":100,"default":30},"cursor":{"type":"string"}}},"output":{"encoding":"application/json","schema":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"type":"ref","ref":"social.grain.gallery.defs#galleryView"}},"cursor":{"type":"string"}}}}}}} as const 66 69 const getActorProfileLex = {"lexicon":1,"id":"social.grain.unspecced.getActorProfile","defs":{"main":{"type":"query","description":"Get an actor's profile with gallery stats and follow relationships.","parameters":{"type":"params","required":["actor"],"properties":{"actor":{"type":"string","format":"did"},"viewer":{"type":"string","format":"did"}}},"output":{"encoding":"application/json","schema":{"type":"ref","ref":"social.grain.actor.defs#profileViewDetailed"}}}}} as const 70 + const getBlocksLex = {"lexicon":1,"id":"social.grain.unspecced.getBlocks","defs":{"main":{"type":"query","description":"Get the viewer's blocked users.","parameters":{"type":"params","properties":{"limit":{"type":"integer","minimum":1,"maximum":100,"default":50},"cursor":{"type":"string"}}},"output":{"encoding":"application/json","schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"ref","ref":"social.grain.unspecced.getBlocks#blockItem"}},"cursor":{"type":"string"}}}}},"blockItem":{"type":"object","required":["did","blockUri"],"properties":{"did":{"type":"string","format":"did"},"handle":{"type":"string"},"displayName":{"type":"string"},"avatar":{"type":"string"},"blockUri":{"type":"string","format":"at-uri"}}}}} as const 67 71 const getCamerasLex = {"lexicon":1,"id":"social.grain.unspecced.getCameras","defs":{"main":{"type":"query","description":"Get top cameras by photo count.","output":{"encoding":"application/json","schema":{"type":"object","properties":{"cameras":{"type":"array","items":{"type":"ref","ref":"social.grain.unspecced.getCameras#cameraItem"}}}}}},"cameraItem":{"type":"object","required":["camera","photoCount"],"properties":{"camera":{"type":"string"},"photoCount":{"type":"integer"}}}}} as const 68 72 const getFollowersLex = {"lexicon":1,"id":"social.grain.unspecced.getFollowers","defs":{"main":{"type":"query","description":"Get followers for a given user.","parameters":{"type":"params","required":["actor"],"properties":{"actor":{"type":"string","format":"did"},"viewer":{"type":"string","format":"did"},"limit":{"type":"integer","minimum":1,"maximum":100,"default":50},"cursor":{"type":"string"}}},"output":{"encoding":"application/json","schema":{"type":"object","properties":{"totalCount":{"type":"integer"},"items":{"type":"array","items":{"type":"ref","ref":"social.grain.unspecced.getFollowers#followerItem"}},"cursor":{"type":"string"}}}}},"followerItem":{"type":"object","required":["did"],"properties":{"did":{"type":"string","format":"did"},"handle":{"type":"string"},"displayName":{"type":"string"},"description":{"type":"string"},"avatar":{"type":"string"},"viewer":{"type":"ref","ref":"social.grain.unspecced.getFollowers#viewerState"}}},"viewerState":{"type":"object","properties":{"following":{"type":"string","format":"at-uri"}}}}} as const 69 73 const getFollowingLex = {"lexicon":1,"id":"social.grain.unspecced.getFollowing","defs":{"main":{"type":"query","description":"Get users that a given user follows.","parameters":{"type":"params","required":["actor"],"properties":{"actor":{"type":"string","format":"did"},"viewer":{"type":"string","format":"did"},"limit":{"type":"integer","minimum":1,"maximum":100,"default":50},"cursor":{"type":"string"}}},"output":{"encoding":"application/json","schema":{"type":"object","properties":{"totalCount":{"type":"integer"},"items":{"type":"array","items":{"type":"ref","ref":"social.grain.unspecced.getFollowing#followingItem"}},"cursor":{"type":"string"}}}}},"followingItem":{"type":"object","required":["did"],"properties":{"did":{"type":"string","format":"did"},"handle":{"type":"string"},"displayName":{"type":"string"},"description":{"type":"string"},"avatar":{"type":"string"},"viewer":{"type":"ref","ref":"social.grain.unspecced.getFollowing#viewerState"}}},"viewerState":{"type":"object","properties":{"following":{"type":"string","format":"at-uri"}}}}} as const ··· 71 75 const getGalleryThreadLex = {"lexicon":1,"id":"social.grain.unspecced.getGalleryThread","defs":{"main":{"type":"query","description":"Get comments for a gallery, sorted oldest-first with author profiles.","parameters":{"type":"params","required":["gallery"],"properties":{"gallery":{"type":"string","format":"at-uri","description":"The gallery URI to fetch comments for."},"limit":{"type":"integer","minimum":1,"maximum":100,"default":20},"cursor":{"type":"string"}}},"output":{"encoding":"application/json","schema":{"type":"object","required":["comments"],"properties":{"comments":{"type":"array","items":{"type":"ref","ref":"social.grain.comment.defs#commentView"}},"cursor":{"type":"string"},"totalCount":{"type":"integer"}}}}}}} as const 72 76 const getKnownFollowersLex = {"lexicon":1,"id":"social.grain.unspecced.getKnownFollowers","defs":{"main":{"type":"query","description":"Get followers of a given actor that the viewer also follows.","parameters":{"type":"params","required":["actor","viewer"],"properties":{"actor":{"type":"string","format":"did"},"viewer":{"type":"string","format":"did"},"limit":{"type":"integer","minimum":1,"maximum":50,"default":50}}},"output":{"encoding":"application/json","schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"ref","ref":"social.grain.unspecced.getKnownFollowers#followerItem"}}}}}},"followerItem":{"type":"object","required":["did"],"properties":{"did":{"type":"string","format":"did"},"handle":{"type":"string"},"displayName":{"type":"string"},"description":{"type":"string"},"avatar":{"type":"string"}}}}} as const 73 77 const getLocationsLex = {"lexicon":1,"id":"social.grain.unspecced.getLocations","defs":{"main":{"type":"query","description":"Get top locations by gallery count.","output":{"encoding":"application/json","schema":{"type":"object","properties":{"locations":{"type":"array","items":{"type":"ref","ref":"social.grain.unspecced.getLocations#locationItem"}}}}}},"locationItem":{"type":"object","required":["name","h3Index","galleryCount"],"properties":{"name":{"type":"string"},"h3Index":{"type":"string"},"galleryCount":{"type":"integer"}}}}} as const 78 + const getMutesLex = {"lexicon":1,"id":"social.grain.unspecced.getMutes","defs":{"main":{"type":"query","description":"Get the viewer's muted users.","parameters":{"type":"params","properties":{"limit":{"type":"integer","minimum":1,"maximum":100,"default":50},"cursor":{"type":"string"}}},"output":{"encoding":"application/json","schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"ref","ref":"social.grain.unspecced.getMutes#muteItem"}},"cursor":{"type":"string"}}}}},"muteItem":{"type":"object","required":["did"],"properties":{"did":{"type":"string","format":"did"},"handle":{"type":"string"},"displayName":{"type":"string"},"avatar":{"type":"string"}}}}} as const 74 79 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 75 80 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 76 81 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 ··· 132 137 'social.grain.gallery': typeof galleryLex 133 138 'social.grain.gallery.defs': typeof grainGalleryDefsLex 134 139 'social.grain.gallery.item': typeof itemLex 140 + 'social.grain.graph.block': typeof blockLex 135 141 'social.grain.graph.follow': typeof grainGraphFollowLex 142 + 'social.grain.graph.muteActor': typeof muteActorLex 143 + 'social.grain.graph.unmuteActor': typeof unmuteActorLex 136 144 'social.grain.photo': typeof photoLex 137 145 'social.grain.photo.defs': typeof grainPhotoDefsLex 138 146 'social.grain.photo.exif': typeof exifLex ··· 141 149 'social.grain.unspecced.deleteGallery': typeof deleteGalleryLex 142 150 'social.grain.unspecced.getActorFavorites': typeof getActorFavoritesLex 143 151 'social.grain.unspecced.getActorProfile': typeof getActorProfileLex 152 + 'social.grain.unspecced.getBlocks': typeof getBlocksLex 144 153 'social.grain.unspecced.getCameras': typeof getCamerasLex 145 154 'social.grain.unspecced.getFollowers': typeof getFollowersLex 146 155 'social.grain.unspecced.getFollowing': typeof getFollowingLex ··· 148 157 'social.grain.unspecced.getGalleryThread': typeof getGalleryThreadLex 149 158 'social.grain.unspecced.getKnownFollowers': typeof getKnownFollowersLex 150 159 'social.grain.unspecced.getLocations': typeof getLocationsLex 160 + 'social.grain.unspecced.getMutes': typeof getMutesLex 151 161 'social.grain.unspecced.getNotifications': typeof getNotificationsLex 152 162 'social.grain.unspecced.getStories': typeof getStoriesLex 153 163 'social.grain.unspecced.getStory': typeof getStoryLex ··· 184 194 export type Favorite = Prettify<LexRecord<typeof favoriteLex, Registry>> 185 195 export type Gallery = Prettify<LexRecord<typeof galleryLex, Registry>> 186 196 export type Item = Prettify<LexRecord<typeof itemLex, Registry>> 197 + export type Block = Prettify<LexRecord<typeof blockLex, Registry>> 187 198 export type GrainGraphFollow = Prettify<LexRecord<typeof grainGraphFollowLex, Registry>> 199 + export type MuteActor = Prettify<LexProcedure<typeof muteActorLex, Registry>> 200 + export type UnmuteActor = Prettify<LexProcedure<typeof unmuteActorLex, Registry>> 188 201 export type Photo = Prettify<LexRecord<typeof photoLex, Registry>> 189 202 export type Exif = Prettify<LexRecord<typeof exifLex, Registry>> 190 203 export type Story = Prettify<LexRecord<typeof storyLex, Registry>> 191 204 export type DeleteGallery = Prettify<LexProcedure<typeof deleteGalleryLex, Registry>> 192 205 export type GetActorFavorites = Prettify<LexQuery<typeof getActorFavoritesLex, Registry>> 193 206 export type GetActorProfile = Prettify<LexQuery<typeof getActorProfileLex, Registry>> 207 + export type GetBlocks = Prettify<LexQuery<typeof getBlocksLex, Registry>> 194 208 export type GetCameras = Prettify<LexQuery<typeof getCamerasLex, Registry>> 195 209 export type GetFollowers = Prettify<LexQuery<typeof getFollowersLex, Registry>> 196 210 export type GetFollowing = Prettify<LexQuery<typeof getFollowingLex, Registry>> ··· 198 212 export type GetGalleryThread = Prettify<LexQuery<typeof getGalleryThreadLex, Registry>> 199 213 export type GetKnownFollowers = Prettify<LexQuery<typeof getKnownFollowersLex, Registry>> 200 214 export type GetLocations = Prettify<LexQuery<typeof getLocationsLex, Registry>> 215 + export type GetMutes = Prettify<LexQuery<typeof getMutesLex, Registry>> 201 216 export type GetNotifications = Prettify<LexQuery<typeof getNotificationsLex, Registry>> 202 217 export type GetStories = Prettify<LexQuery<typeof getStoriesLex, Registry>> 203 218 export type GetStory = Prettify<LexQuery<typeof getStoryLex, Registry>> ··· 220 235 'social.grain.favorite': Favorite 221 236 'social.grain.gallery': Gallery 222 237 'social.grain.gallery.item': Item 238 + 'social.grain.graph.block': Block 223 239 'social.grain.graph.follow': GrainGraphFollow 224 240 'social.grain.photo': Photo 225 241 'social.grain.photo.exif': Exif ··· 370 386 export type ExifView = Prettify<LexDef<typeof grainPhotoDefsLex, 'exifView', Registry>> 371 387 export type GalleryState = Prettify<LexDef<typeof grainPhotoDefsLex, 'galleryState', Registry>> 372 388 export type StoryView = Prettify<LexDef<typeof grainStoryDefsLex, 'storyView', Registry>> 389 + export type BlockItem = Prettify<LexDef<typeof getBlocksLex, 'blockItem', Registry>> 373 390 export type CameraItem = Prettify<LexDef<typeof getCamerasLex, 'cameraItem', Registry>> 374 391 export type GetFollowersFollowerItem = Prettify<LexDef<typeof getFollowersLex, 'followerItem', Registry>> 375 392 export type GetFollowersViewerState = Prettify<LexDef<typeof getFollowersLex, 'viewerState', Registry>> ··· 377 394 export type GetFollowingViewerState = Prettify<LexDef<typeof getFollowingLex, 'viewerState', Registry>> 378 395 export type GetKnownFollowersFollowerItem = Prettify<LexDef<typeof getKnownFollowersLex, 'followerItem', Registry>> 379 396 export type LocationItem = Prettify<LexDef<typeof getLocationsLex, 'locationItem', Registry>> 397 + export type MuteItem = Prettify<LexDef<typeof getMutesLex, 'muteItem', Registry>> 380 398 export type NotificationItem = Prettify<LexDef<typeof getNotificationsLex, 'notificationItem', Registry>> 381 399 export type StoryAuthor = Prettify<LexDef<typeof getStoryAuthorsLex, 'storyAuthor', Registry>> 382 400 export type SuggestedItem = Prettify<LexDef<typeof getSuggestedFollowsLex, 'suggestedItem', Registry>> ··· 400 418 'dev.hatk.searchRecords': SearchRecords 401 419 'dev.hatk.uploadBlob': UploadBlob 402 420 'parts.page.mention.search': Search 421 + 'social.grain.graph.muteActor': MuteActor 422 + 'social.grain.graph.unmuteActor': UnmuteActor 403 423 'social.grain.unspecced.deleteGallery': DeleteGallery 404 424 'social.grain.unspecced.getActorFavorites': GetActorFavorites 405 425 'social.grain.unspecced.getActorProfile': GetActorProfile 426 + 'social.grain.unspecced.getBlocks': GetBlocks 406 427 'social.grain.unspecced.getCameras': GetCameras 407 428 'social.grain.unspecced.getFollowers': GetFollowers 408 429 'social.grain.unspecced.getFollowing': GetFollowing ··· 410 431 'social.grain.unspecced.getGalleryThread': GetGalleryThread 411 432 'social.grain.unspecced.getKnownFollowers': GetKnownFollowers 412 433 'social.grain.unspecced.getLocations': GetLocations 434 + 'social.grain.unspecced.getMutes': GetMutes 413 435 'social.grain.unspecced.getNotifications': GetNotifications 414 436 'social.grain.unspecced.getStories': GetStories 415 437 'social.grain.unspecced.getStory': GetStory
+4 -1
lexicons/social/grain/actor/defs.json
··· 79 79 "description": "Metadata about the requesting account's relationship with the subject account. Only has meaningful content for authed requests.", 80 80 "properties": { 81 81 "following": { "type": "string", "format": "at-uri" }, 82 - "followedBy": { "type": "string", "format": "at-uri" } 82 + "followedBy": { "type": "string", "format": "at-uri" }, 83 + "blocking": { "type": "string", "format": "at-uri" }, 84 + "blockedBy": { "type": "boolean" }, 85 + "muted": { "type": "boolean" } 83 86 } 84 87 } 85 88 }
+4
lexicons/social/grain/comment/defs.json
··· 41 41 "createdAt": { 42 42 "type": "string", 43 43 "format": "datetime" 44 + }, 45 + "muted": { 46 + "type": "boolean", 47 + "description": "True if the viewer has muted the comment author. Client should show collapsed with option to expand." 44 48 } 45 49 } 46 50 }
+24
lexicons/social/grain/graph/block.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.grain.graph.block", 4 + "defs": { 5 + "main": { 6 + "key": "tid", 7 + "type": "record", 8 + "record": { 9 + "type": "object", 10 + "required": ["subject", "createdAt"], 11 + "properties": { 12 + "subject": { 13 + "type": "string", 14 + "format": "did" 15 + }, 16 + "createdAt": { 17 + "type": "string", 18 + "format": "datetime" 19 + } 20 + } 21 + } 22 + } 23 + } 24 + }
+30
lexicons/social/grain/graph/muteActor.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.grain.graph.muteActor", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Mute an actor. Mutes are private and only affect the muter's feeds.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["actor"], 13 + "properties": { 14 + "actor": { 15 + "type": "string", 16 + "format": "did" 17 + } 18 + } 19 + } 20 + }, 21 + "output": { 22 + "encoding": "application/json", 23 + "schema": { 24 + "type": "object", 25 + "properties": {} 26 + } 27 + } 28 + } 29 + } 30 + }
+30
lexicons/social/grain/graph/unmuteActor.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.grain.graph.unmuteActor", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Unmute an actor.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["actor"], 13 + "properties": { 14 + "actor": { 15 + "type": "string", 16 + "format": "did" 17 + } 18 + } 19 + } 20 + }, 21 + "output": { 22 + "encoding": "application/json", 23 + "schema": { 24 + "type": "object", 25 + "properties": {} 26 + } 27 + } 28 + } 29 + } 30 + }
+41
lexicons/social/grain/unspecced/getBlocks.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.grain.unspecced.getBlocks", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get the viewer's blocked users.", 8 + "parameters": { 9 + "type": "params", 10 + "properties": { 11 + "limit": { "type": "integer", "minimum": 1, "maximum": 100, "default": 50 }, 12 + "cursor": { "type": "string" } 13 + } 14 + }, 15 + "output": { 16 + "encoding": "application/json", 17 + "schema": { 18 + "type": "object", 19 + "properties": { 20 + "items": { 21 + "type": "array", 22 + "items": { "type": "ref", "ref": "social.grain.unspecced.getBlocks#blockItem" } 23 + }, 24 + "cursor": { "type": "string" } 25 + } 26 + } 27 + } 28 + }, 29 + "blockItem": { 30 + "type": "object", 31 + "required": ["did", "blockUri"], 32 + "properties": { 33 + "did": { "type": "string", "format": "did" }, 34 + "handle": { "type": "string" }, 35 + "displayName": { "type": "string" }, 36 + "avatar": { "type": "string" }, 37 + "blockUri": { "type": "string", "format": "at-uri" } 38 + } 39 + } 40 + } 41 + }
+40
lexicons/social/grain/unspecced/getMutes.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.grain.unspecced.getMutes", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get the viewer's muted users.", 8 + "parameters": { 9 + "type": "params", 10 + "properties": { 11 + "limit": { "type": "integer", "minimum": 1, "maximum": 100, "default": 50 }, 12 + "cursor": { "type": "string" } 13 + } 14 + }, 15 + "output": { 16 + "encoding": "application/json", 17 + "schema": { 18 + "type": "object", 19 + "properties": { 20 + "items": { 21 + "type": "array", 22 + "items": { "type": "ref", "ref": "social.grain.unspecced.getMutes#muteItem" } 23 + }, 24 + "cursor": { "type": "string" } 25 + } 26 + } 27 + } 28 + }, 29 + "muteItem": { 30 + "type": "object", 31 + "required": ["did"], 32 + "properties": { 33 + "did": { "type": "string", "format": "did" }, 34 + "handle": { "type": "string" }, 35 + "displayName": { "type": "string" }, 36 + "avatar": { "type": "string" } 37 + } 38 + } 39 + } 40 + }
+21
seeds/seed.ts
··· 755 755 { rkey: "comment-5" }, 756 756 ); 757 757 758 + // ── Blocks ── 759 + // Dave blocks Alice (tests "blockedBy" state when Alice views Dave's profile) 760 + await createRecord( 761 + dave, 762 + "social.grain.graph.block", 763 + { subject: alice.did, createdAt: ago(3) }, 764 + { rkey: "block-alice" }, 765 + ); 766 + 767 + // Bob blocks Carol (tests "blocking" state when Bob views Carol's profile, 768 + // and feed/notification filtering between them) 769 + await createRecord( 770 + bob, 771 + "social.grain.graph.block", 772 + { subject: carol.did, createdAt: ago(2) }, 773 + { rkey: "block-carol" }, 774 + ); 775 + 776 + // Note: Mutes are stored in the server-side _mutes table (not AT Protocol records) 777 + // and cannot be seeded via createRecord. Test mutes manually via the UI or XRPC. 778 + 758 779 // ── Stories ── 759 780 760 781 await createRecord(
+8 -2
server/feeds/camera.ts
··· 4 4 import { defineFeed } from "$hatk"; 5 5 import { hydrateGalleries } from "../hydrate/galleries.ts"; 6 6 import { hideLabelsFilter } from "../labels/_hidden.ts"; 7 + import { blockMuteFilter } from "../filters/blockMute.ts"; 7 8 8 9 export default defineFeed({ 9 10 collection: "social.grain.gallery", ··· 14 15 async generate(ctx) { 15 16 const camera = ctx.params.camera; 16 17 if (!camera) return ctx.ok({ uris: [] }); 18 + 19 + const viewer = ctx.viewer?.did; 20 + const bmFilter = viewer ? `AND ${blockMuteFilter("t.did", "$2")}` : ""; 21 + const bmParams = viewer ? [viewer] : []; 17 22 18 23 const { rows, cursor } = await ctx.paginate<{ uri: string }>( 19 24 `SELECT t.uri, t.cid, t.created_at FROM "social.grain.gallery" t ··· 25 30 WHERE gi.gallery = t.uri AND (e.make || ' ' || e.model) = $1 26 31 ) 27 32 AND ${hideLabelsFilter("t.uri")} 28 - AND (SELECT count(*) FROM "social.grain.gallery.item" gi WHERE gi.gallery = t.uri) > 0`, 29 - { orderBy: "t.created_at", params: [camera] }, 33 + AND (SELECT count(*) FROM "social.grain.gallery.item" gi WHERE gi.gallery = t.uri) > 0 34 + ${bmFilter}`, 35 + { orderBy: "t.created_at", params: [camera, ...bmParams] }, 30 36 ); 31 37 32 38 return ctx.ok({ uris: rows.map((r) => r.uri), cursor });
+3 -1
server/feeds/following.ts
··· 1 1 import { defineFeed } from "$hatk"; 2 2 import { hydrateGalleries } from "../hydrate/galleries.ts"; 3 3 import { hideLabelsFilter } from "../labels/_hidden.ts"; 4 + import { blockMuteFilter } from "../filters/blockMute.ts"; 4 5 5 6 export default defineFeed({ 6 7 collection: "social.grain.gallery", ··· 18 19 WHERE (r.status IS NULL OR r.status != 'takendown') 19 20 AND t.did IN (SELECT subject FROM "social.grain.graph.follow" WHERE did = $1) 20 21 AND ${hideLabelsFilter("t.uri")} 21 - AND (SELECT count(*) FROM "social.grain.gallery.item" gi WHERE gi.gallery = t.uri) > 0`, 22 + AND (SELECT count(*) FROM "social.grain.gallery.item" gi WHERE gi.gallery = t.uri) > 0 23 + AND ${blockMuteFilter("t.did", "$1")}`, 22 24 { orderBy: "t.created_at", params: [actor] }, 23 25 ); 24 26
+4 -1
server/feeds/foryou.ts
··· 1 1 import { defineFeed } from "$hatk"; 2 2 import { hydrateGalleries } from "../hydrate/galleries.ts"; 3 3 import { hideLabelsFilter } from "../labels/_hidden.ts"; 4 + import { blockMuteFilter } from "../filters/blockMute.ts"; 4 5 5 6 // ─── Scoring parameters (spacecowboy17's optimized A/B values) ─────── 6 7 const HALF_LIFE_HOURS = 6; ··· 106 107 AND t.did != $1 107 108 AND (r.status IS NULL OR r.status != 'takendown') 108 109 AND ${hideLabelsFilter("t.uri")} 109 - AND (SELECT count(*) FROM "social.grain.gallery.item" gi WHERE gi.gallery = t.uri) > 0`, 110 + AND (SELECT count(*) FROM "social.grain.gallery.item" gi WHERE gi.gallery = t.uri) > 0 111 + AND ${blockMuteFilter("t.did", "$1")}`, 110 112 [actor, ...colikerList, ...seedUris], 111 113 ) as Promise<{ coliker: string; gallery_uri: string; gallery_created_at: string }[]>, 112 114 ··· 239 241 AND t.created_at > $2 240 242 AND ${hideLabelsFilter("t.uri")} 241 243 AND (SELECT count(*) FROM "social.grain.gallery.item" gi WHERE gi.gallery = t.uri) > 0 244 + AND ${blockMuteFilter("t.did", "$1")} 242 245 GROUP BY t.uri 243 246 ORDER BY fav_count DESC, t.created_at DESC 244 247 LIMIT $3 OFFSET $4`,
+7 -2
server/feeds/hashtag.ts
··· 1 1 import { defineFeed } from "$hatk"; 2 2 import { hydrateGalleries } from "../hydrate/galleries.ts"; 3 3 import { hideLabelsFilter } from "../labels/_hidden.ts"; 4 + import { blockMuteFilter } from "../filters/blockMute.ts"; 4 5 5 6 export default defineFeed({ 6 7 collection: "social.grain.gallery", ··· 13 14 if (!tag) return ctx.ok({ uris: [] }); 14 15 15 16 const pattern = `%#${tag}%`; 17 + const viewer = ctx.viewer?.did; 18 + const bmFilter = viewer ? `AND ${blockMuteFilter("t.did", "$2")}` : ""; 19 + const bmParams = viewer ? [viewer] : []; 16 20 17 21 const { rows, cursor } = await ctx.paginate<{ uri: string }>( 18 22 `SELECT t.uri, t.cid, t.created_at FROM "social.grain.gallery" t ··· 23 27 OR EXISTS (SELECT 1 FROM "social.grain.comment" c WHERE c.subject = t.uri AND c.text LIKE $1) 24 28 ) 25 29 AND ${hideLabelsFilter("t.uri")} 26 - AND (SELECT count(*) FROM "social.grain.gallery.item" gi WHERE gi.gallery = t.uri) > 0`, 27 - { orderBy: "t.created_at", params: [pattern] }, 30 + AND (SELECT count(*) FROM "social.grain.gallery.item" gi WHERE gi.gallery = t.uri) > 0 31 + ${bmFilter}`, 32 + { orderBy: "t.created_at", params: [pattern, ...bmParams] }, 28 33 ); 29 34 30 35 return ctx.ok({ uris: rows.map((r) => r.uri), cursor });
+12 -2
server/feeds/location.ts
··· 8 8 import { hydrateGalleries } from "../hydrate/galleries.ts"; 9 9 import { getResolution, cellToParent } from "h3-js"; 10 10 import { hideLabelsFilter } from "../labels/_hidden.ts"; 11 + import { blockMuteFilter } from "../filters/blockMute.ts"; 11 12 12 13 export default defineFeed({ 13 14 collection: "social.grain.gallery", ··· 25 26 // For city-level queries, we need to check if the gallery's H3 cell 26 27 // is a child of the requested city cell. We do this in application code 27 28 // since SQLite doesn't have H3 functions. 29 + const viewer = ctx.viewer?.did; 30 + 28 31 if (isCityLevel) { 29 32 // Fetch all galleries with locations, filter by H3 parent in JS, then paginate 30 33 const limit = ctx.params.limit ? Number(ctx.params.limit) : 30; 34 + const bmFilter = viewer ? `AND ${blockMuteFilter("t.did", "$1")}` : ""; 35 + const bmParams = viewer ? [viewer] : []; 31 36 const allRows = (await ctx.db.query( 32 37 `SELECT t.uri, t.created_at, json_extract(t.location, '$.value') AS location 33 38 FROM "social.grain.gallery" t ··· 36 41 AND t.location IS NOT NULL 37 42 AND ${hideLabelsFilter("t.uri")} 38 43 AND (SELECT count(*) FROM "social.grain.gallery.item" gi WHERE gi.gallery = t.uri) > 0 44 + ${bmFilter} 39 45 ORDER BY t.created_at DESC`, 46 + bmParams, 40 47 )) as { uri: string; created_at: string; location: string }[]; 41 48 42 49 const filtered = allRows.filter((r) => { ··· 67 74 } 68 75 69 76 // Venue-level: exact match 77 + const bmFilterVenue = viewer ? `AND ${blockMuteFilter("t.did", "$2")}` : ""; 78 + const bmParamsVenue = viewer ? [viewer] : []; 70 79 const { rows, cursor } = await ctx.paginate<{ uri: string }>( 71 80 `SELECT t.uri, t.cid, t.created_at FROM "social.grain.gallery" t 72 81 LEFT JOIN _repos r ON t.did = r.did 73 82 WHERE (r.status IS NULL OR r.status != 'takendown') 74 83 AND json_extract(t.location, '$.value') = $1 75 84 AND ${hideLabelsFilter("t.uri")} 76 - AND (SELECT count(*) FROM "social.grain.gallery.item" gi WHERE gi.gallery = t.uri) > 0`, 77 - { orderBy: "t.created_at", params: [location] }, 85 + AND (SELECT count(*) FROM "social.grain.gallery.item" gi WHERE gi.gallery = t.uri) > 0 86 + ${bmFilterVenue}`, 87 + { orderBy: "t.created_at", params: [location, ...bmParamsVenue] }, 78 88 ); 79 89 80 90 return ctx.ok({ uris: rows.map((r) => r.uri), cursor });
+8 -2
server/feeds/recent.ts
··· 1 1 import { defineFeed } from "$hatk"; 2 2 import { hydrateGalleries } from "../hydrate/galleries.ts"; 3 3 import { hideLabelsFilter } from "../labels/_hidden.ts"; 4 + import { blockMuteFilter } from "../filters/blockMute.ts"; 4 5 5 6 export default defineFeed({ 6 7 collection: "social.grain.gallery", ··· 9 10 hydrate: hydrateGalleries, 10 11 11 12 async generate(ctx) { 13 + const viewer = ctx.viewer?.did; 14 + const bmFilter = viewer ? `AND ${blockMuteFilter("t.did", "$1")}` : ""; 15 + const bmParams = viewer ? [viewer] : []; 16 + 12 17 const { rows, cursor } = await ctx.paginate<{ uri: string }>( 13 18 `SELECT t.uri, t.cid, t.created_at FROM "social.grain.gallery" t 14 19 LEFT JOIN _repos r ON t.did = r.did 15 20 WHERE (r.status IS NULL OR r.status != 'takendown') 16 21 AND ${hideLabelsFilter("t.uri")} 17 - AND (SELECT count(*) FROM "social.grain.gallery.item" gi WHERE gi.gallery = t.uri) > 0`, 18 - { orderBy: "t.created_at" }, 22 + AND (SELECT count(*) FROM "social.grain.gallery.item" gi WHERE gi.gallery = t.uri) > 0 23 + ${bmFilter}`, 24 + { orderBy: "t.created_at", ...(bmParams.length ? { params: bmParams } : {}) }, 19 25 ); 20 26 21 27 return ctx.ok({ uris: rows.map((r) => r.uri), cursor });
+33
server/filters/blockMute.ts
··· 1 + /** 2 + * Returns a SQL fragment that excludes rows from blocked (bidirectional) and muted users. 3 + * @param didColumn - the column containing the actor's DID (e.g. "t.did") 4 + * @param viewerParam - the SQL parameter number for the viewer DID (e.g. "$1") 5 + */ 6 + export function blockMuteFilter(didColumn: string, viewerParam: string): string { 7 + return ` 8 + ${didColumn} NOT IN ( 9 + SELECT subject FROM "social.grain.graph.block" WHERE did = ${viewerParam} 10 + ) 11 + AND ${didColumn} NOT IN ( 12 + SELECT did FROM "social.grain.graph.block" WHERE subject = ${viewerParam} 13 + ) 14 + AND ${didColumn} NOT IN ( 15 + SELECT subject FROM _mutes WHERE did = ${viewerParam} 16 + ) 17 + `; 18 + } 19 + 20 + /** 21 + * Returns a SQL fragment that excludes rows from blocked users only (bidirectional). 22 + * Use this when muted content should still be returned (e.g. comments with a muted flag). 23 + */ 24 + export function blockFilter(didColumn: string, viewerParam: string): string { 25 + return ` 26 + ${didColumn} NOT IN ( 27 + SELECT subject FROM "social.grain.graph.block" WHERE did = ${viewerParam} 28 + ) 29 + AND ${didColumn} NOT IN ( 30 + SELECT did FROM "social.grain.graph.block" WHERE subject = ${viewerParam} 31 + ) 32 + `; 33 + }
+2 -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).then(async (externalLabels: Map<string, Label[]>) => { 120 + ctx.labels(galleryUris).then(async (raw) => { 121 + const externalLabels = raw as Map<string, Label[]>; 121 122 if (galleryUris.length === 0) return externalLabels; 122 123 const selfLabelRows = (await ctx.db.query( 123 124 `SELECT parent_uri, val FROM "social.grain.gallery__labels_self_labels"
+14
server/setup/01-mutes-table.ts
··· 1 + import { defineSetup } from "$hatk"; 2 + 3 + export default defineSetup(async (ctx) => { 4 + await ctx.db.run(` 5 + CREATE TABLE IF NOT EXISTS _mutes ( 6 + did TEXT NOT NULL, 7 + subject TEXT NOT NULL, 8 + created_at TEXT NOT NULL, 9 + PRIMARY KEY (did, subject) 10 + ) 11 + `); 12 + await ctx.db.run(`CREATE INDEX IF NOT EXISTS idx_mutes_did ON _mutes (did)`); 13 + await ctx.db.run(`CREATE INDEX IF NOT EXISTS idx_mutes_subject ON _mutes (subject)`); 14 + });
+28 -6
server/xrpc/getActorProfile.ts
··· 2 2 import type { GrainActorProfile, Declaration } from "$hatk"; 3 3 4 4 export default defineQuery("social.grain.unspecced.getActorProfile", async (ctx) => { 5 - const { ok, params, isTakendown, lookup, count, blobUrl } = ctx; 6 - const { viewer } = params; 5 + const { ok, params, isTakendown, lookup, count, blobUrl, viewer: authViewer } = ctx; 6 + const viewer = authViewer?.did ?? params.viewer; 7 7 8 8 // Resolve handle to DID if needed 9 9 let actor = params.actor; ··· 30 30 followsCounts, 31 31 viewerFollowingRows, 32 32 followedByRows, 33 + viewerBlockingRows, 34 + blockedByRows, 35 + viewerMutedRows, 33 36 ] = await Promise.all([ 34 37 lookup<GrainActorProfile>("social.grain.actor.profile", "did", [actor]), 35 38 lookup<Declaration>("com.germnetwork.declaration", "did", [actor]), ··· 64 67 `SELECT uri FROM "social.grain.graph.follow" WHERE did = $1 AND subject = $2 LIMIT 1`, 65 68 [actor, viewer], 66 69 ) as Promise<{ uri: string }[]>, 70 + ctx.db.query( 71 + `SELECT uri FROM "social.grain.graph.block" WHERE did = $1 AND subject = $2 LIMIT 1`, 72 + [viewer, actor], 73 + ) as Promise<{ uri: string }[]>, 74 + ctx.db.query( 75 + `SELECT uri FROM "social.grain.graph.block" WHERE did = $1 AND subject = $2 LIMIT 1`, 76 + [actor, viewer], 77 + ) as Promise<{ uri: string }[]>, 78 + ctx.db.query( 79 + `SELECT 1 as v FROM _mutes WHERE did = $1 AND subject = $2 LIMIT 1`, 80 + [viewer, actor], 81 + ) as Promise<{ v: number }[]>, 67 82 ] 68 - : [Promise.resolve([]), Promise.resolve([])]), 83 + : [Promise.resolve([]), Promise.resolve([]), Promise.resolve([]), Promise.resolve([]), Promise.resolve([])]), 69 84 ]); 70 85 const viewerFollowing = (viewerFollowingRows as { uri: string }[])[0]?.uri ?? null; 71 86 const followedBy = (followedByRows as { uri: string }[])[0]?.uri ?? null; 87 + const viewerBlocking = (viewerBlockingRows as { uri: string }[])?.[0]?.uri ?? null; 88 + const blockedBy = !!(blockedByRows as { uri: string }[])?.[0]?.uri; 89 + const viewerMuted = !!(viewerMutedRows as { v: number }[])?.[0]; 72 90 73 91 const profile = profiles.get(actor); 74 92 const germDecl = germDeclarations.get(actor); ··· 104 122 followsCount, 105 123 createdAt: profile.value.createdAt, 106 124 messageMe, 107 - ...(viewer && viewer !== actor && (viewerFollowing || followedBy) 125 + ...(viewer && viewer !== actor && (viewerFollowing || followedBy || viewerBlocking || blockedBy || viewerMuted) 108 126 ? { 109 127 viewer: { 110 - ...(viewerFollowing ? { following: viewerFollowing } : {}), 111 - ...(followedBy ? { followedBy: followedBy } : {}), 128 + // Hide follow relationships when either party blocks the other 129 + ...(!viewerBlocking && !blockedBy && viewerFollowing ? { following: viewerFollowing } : {}), 130 + ...(!viewerBlocking && !blockedBy && followedBy ? { followedBy: followedBy } : {}), 131 + ...(viewerBlocking ? { blocking: viewerBlocking } : {}), 132 + ...(blockedBy ? { blockedBy: true } : {}), 133 + ...(viewerMuted ? { muted: true } : {}), 112 134 }, 113 135 } 114 136 : {}),
+55
server/xrpc/getBlocks.ts
··· 1 + import { defineQuery, InvalidRequestError, type GrainActorProfile } from "$hatk"; 2 + 3 + export default defineQuery("social.grain.unspecced.getBlocks", async (ctx) => { 4 + const { ok, db, lookup, blobUrl, packCursor, unpackCursor, viewer } = ctx; 5 + if (!viewer) throw new InvalidRequestError("Authentication required"); 6 + 7 + const { limit = 50, cursor } = ctx.params; 8 + const offset = cursor ? Number(unpackCursor(cursor)?.primary ?? 0) : 0; 9 + 10 + const rows = (await db.query( 11 + `SELECT uri, subject, created_at, cid FROM "social.grain.graph.block" 12 + WHERE did = $1 13 + ORDER BY created_at DESC 14 + LIMIT $2 OFFSET $3`, 15 + [viewer.did, Number(limit) + 1, offset], 16 + )) as { uri: string; subject: string; created_at: string; cid: string }[]; 17 + 18 + const hasMore = rows.length > Number(limit); 19 + const page = hasMore ? rows.slice(0, Number(limit)) : rows; 20 + const dids = page.map((r) => r.subject); 21 + 22 + const profiles = await lookup<GrainActorProfile>( 23 + "social.grain.actor.profile", 24 + "did", 25 + dids, 26 + ); 27 + 28 + const handleRows = 29 + dids.length > 0 30 + ? ((await db.query( 31 + `SELECT did, handle FROM _repos WHERE did IN (${dids.map((_, i) => `$${i + 1}`).join(",")})`, 32 + dids, 33 + )) as { did: string; handle: string }[]) 34 + : []; 35 + const handleMap = new Map(handleRows.map((r) => [r.did, r.handle])); 36 + 37 + const items = page.map((row) => { 38 + const p = profiles.get(row.subject); 39 + return { 40 + did: row.subject, 41 + handle: p?.handle ?? handleMap.get(row.subject) ?? row.subject, 42 + displayName: p?.value.displayName, 43 + avatar: p ? (blobUrl(row.subject, p.value.avatar, "avatar") ?? undefined) : undefined, 44 + blockUri: row.uri, 45 + }; 46 + }); 47 + 48 + const nextOffset = offset + Number(limit); 49 + const lastRow = page[page.length - 1]; 50 + 51 + return ok({ 52 + items, 53 + ...(hasMore && lastRow ? { cursor: packCursor(nextOffset, lastRow.cid) } : {}), 54 + }); 55 + });
+66 -36
server/xrpc/getGalleryThread.ts
··· 2 2 import type { GrainActorProfile, Photo } from "$hatk"; 3 3 import { views } from "$hatk"; 4 4 import { NOT_ORPHANED } from "../hydrate/comments.ts"; 5 + import { blockFilter } from "../filters/blockMute.ts"; 5 6 6 7 export default defineQuery("social.grain.unspecced.getGalleryThread", async (ctx) => { 7 - const { ok, params, db, lookup, blobUrl, getRecords } = ctx; 8 + const { ok, params, db, lookup, blobUrl, getRecords, viewer } = ctx; 8 9 const { gallery, limit = 20, cursor } = params; 10 + 11 + const viewerDid = viewer?.did; 12 + 13 + // Build block filter — blocked comments are removed entirely 14 + const countParams: any[] = [gallery]; 15 + let countBmParam = ""; 16 + if (viewerDid) { 17 + countParams.push(viewerDid); 18 + countBmParam = `AND ${blockFilter("c.did", `$${countParams.length}`)}`; 19 + } 9 20 10 21 // Count total comments for this gallery, excluding orphaned replies 11 22 const countRows = (await db.query( 12 23 `SELECT count(*) as cnt FROM "social.grain.comment" c 13 - WHERE c.subject = $1 AND ${NOT_ORPHANED}`, 14 - [gallery], 24 + WHERE c.subject = $1 AND ${NOT_ORPHANED} ${countBmParam}`, 25 + countParams, 15 26 )) as { cnt: number }[]; 16 27 const totalCount = countRows[0]?.cnt ?? 0; 17 28 18 29 // Fetch comments with cursor-based pagination (oldest first), excluding orphaned replies 30 + const queryParams: any[] = [gallery]; 19 31 let query = `SELECT c.uri, c.did, c.cid, c.text, c.facets, c.focus, c.reply_to, c.created_at 20 32 FROM "social.grain.comment" c 21 33 WHERE c.subject = $1 AND ${NOT_ORPHANED}`; 22 - const queryParams: any[] = [gallery]; 23 34 24 35 if (cursor) { 25 36 query += ` AND c.created_at > $2`; 26 37 queryParams.push(cursor); 38 + } 39 + 40 + if (viewerDid) { 41 + queryParams.push(viewerDid); 42 + query += ` AND ${blockFilter("c.did", `$${queryParams.length}`)}`; 27 43 } 28 44 29 45 query += ` ORDER BY c.created_at ASC LIMIT $${queryParams.length + 1}`; ··· 48 64 const dids = [...new Set(items.map((r) => r.did))]; 49 65 const profiles = await lookup<GrainActorProfile>("social.grain.actor.profile", "did", dids); 50 66 67 + // Check which comment authors the viewer has muted 68 + let mutedDids = new Set<string>(); 69 + if (viewerDid && dids.length > 0) { 70 + const ph = dids.map((_, i) => `$${i + 2}`).join(","); 71 + const mutedRows = (await db.query( 72 + `SELECT subject FROM _mutes WHERE did = $1 AND subject IN (${ph})`, 73 + [viewerDid, ...dids], 74 + )) as { subject: string }[]; 75 + mutedDids = new Set(mutedRows.map((r) => r.subject)); 76 + } 77 + 51 78 // Hydrate focus photos 52 79 const focusUris = items.map((r) => r.focus).filter(Boolean) as string[]; 53 80 const focusPhotos = ··· 58 85 const parsedFacets = row.facets ? JSON.parse(row.facets) : undefined; 59 86 const focusPhoto = row.focus ? focusPhotos.get(row.focus) : null; 60 87 61 - return views.commentView({ 62 - uri: row.uri, 63 - cid: row.cid, 64 - text: row.text, 65 - facets: parsedFacets, 66 - replyTo: row.reply_to ?? undefined, 67 - createdAt: row.created_at, 68 - author: author 69 - ? views.grainActorDefsProfileView({ 70 - cid: author.cid, 71 - did: author.did, 72 - handle: author.handle ?? author.did, 73 - displayName: author.value.displayName, 74 - avatar: blobUrl(author.did, author.value.avatar) ?? undefined, 75 - }) 76 - : views.grainActorDefsProfileView({ 77 - cid: row.cid, 78 - did: row.did, 79 - handle: row.did, 80 - }), 81 - ...(focusPhoto 82 - ? { 83 - focus: views.photoView({ 84 - uri: focusPhoto.uri, 85 - cid: focusPhoto.cid, 86 - thumb: blobUrl(focusPhoto.did, focusPhoto.value.photo, "feed_thumbnail") ?? "", 87 - fullsize: blobUrl(focusPhoto.did, focusPhoto.value.photo, "feed_fullsize") ?? "", 88 - alt: focusPhoto.value.alt, 89 - aspectRatio: focusPhoto.value.aspectRatio ?? { width: 4, height: 3 }, 88 + return { 89 + ...views.commentView({ 90 + uri: row.uri, 91 + cid: row.cid, 92 + text: row.text, 93 + facets: parsedFacets, 94 + replyTo: row.reply_to ?? undefined, 95 + createdAt: row.created_at, 96 + author: author 97 + ? views.grainActorDefsProfileView({ 98 + cid: author.cid, 99 + did: author.did, 100 + handle: author.handle ?? author.did, 101 + displayName: author.value.displayName, 102 + avatar: blobUrl(author.did, author.value.avatar) ?? undefined, 103 + }) 104 + : views.grainActorDefsProfileView({ 105 + cid: row.cid, 106 + did: row.did, 107 + handle: row.did, 90 108 }), 91 - } 92 - : {}), 93 - }); 109 + ...(focusPhoto 110 + ? { 111 + focus: views.photoView({ 112 + uri: focusPhoto.uri, 113 + cid: focusPhoto.cid, 114 + thumb: blobUrl(focusPhoto.did, focusPhoto.value.photo, "feed_thumbnail") ?? "", 115 + fullsize: blobUrl(focusPhoto.did, focusPhoto.value.photo, "feed_fullsize") ?? "", 116 + alt: focusPhoto.value.alt, 117 + aspectRatio: focusPhoto.value.aspectRatio ?? { width: 4, height: 3 }, 118 + }), 119 + } 120 + : {}), 121 + }), 122 + ...(mutedDids.has(row.did) ? { muted: true } : {}), 123 + }; 94 124 }); 95 125 96 126 return ok({ comments, ...(nextCursor ? { cursor: nextCursor } : {}), totalCount });
+54
server/xrpc/getMutes.ts
··· 1 + import { defineQuery, InvalidRequestError, type GrainActorProfile } from "$hatk"; 2 + 3 + export default defineQuery("social.grain.unspecced.getMutes", async (ctx) => { 4 + const { ok, db, lookup, blobUrl, packCursor, unpackCursor, viewer } = ctx; 5 + if (!viewer) throw new InvalidRequestError("Authentication required"); 6 + 7 + const { limit = 50, cursor } = ctx.params; 8 + const offset = cursor ? Number(unpackCursor(cursor)?.primary ?? 0) : 0; 9 + 10 + const rows = (await db.query( 11 + `SELECT subject, created_at FROM _mutes 12 + WHERE did = $1 13 + ORDER BY created_at DESC 14 + LIMIT $2 OFFSET $3`, 15 + [viewer.did, Number(limit) + 1, offset], 16 + )) as { subject: string; created_at: string }[]; 17 + 18 + const hasMore = rows.length > Number(limit); 19 + const page = hasMore ? rows.slice(0, Number(limit)) : rows; 20 + const dids = page.map((r) => r.subject); 21 + 22 + const profiles = await lookup<GrainActorProfile>( 23 + "social.grain.actor.profile", 24 + "did", 25 + dids, 26 + ); 27 + 28 + const handleRows = 29 + dids.length > 0 30 + ? ((await db.query( 31 + `SELECT did, handle FROM _repos WHERE did IN (${dids.map((_, i) => `$${i + 1}`).join(",")})`, 32 + dids, 33 + )) as { did: string; handle: string }[]) 34 + : []; 35 + const handleMap = new Map(handleRows.map((r) => [r.did, r.handle])); 36 + 37 + const items = page.map((row) => { 38 + const p = profiles.get(row.subject); 39 + return { 40 + did: row.subject, 41 + handle: p?.handle ?? handleMap.get(row.subject) ?? row.subject, 42 + displayName: p?.value.displayName, 43 + avatar: p ? (blobUrl(row.subject, p.value.avatar, "avatar") ?? undefined) : undefined, 44 + }; 45 + }); 46 + 47 + const nextOffset = offset + Number(limit); 48 + const lastRow = page[page.length - 1]; 49 + 50 + return ok({ 51 + items, 52 + ...(hasMore && lastRow ? { cursor: packCursor(nextOffset, lastRow.created_at) } : {}), 53 + }); 54 + });
+14 -6
server/xrpc/getNotifications.ts
··· 2 2 import type { GrainActorProfile, Photo, Gallery } from "$hatk"; 3 3 import { views } from "$hatk"; 4 4 5 + function blockMuteNotifFilter(didCol = "did"): string { 6 + return ` 7 + AND ${didCol} NOT IN (SELECT subject FROM "social.grain.graph.block" WHERE did = $1) 8 + AND ${didCol} NOT IN (SELECT did FROM "social.grain.graph.block" WHERE subject = $1) 9 + AND ${didCol} NOT IN (SELECT subject FROM _mutes WHERE did = $1) 10 + `; 11 + } 12 + 5 13 /** Builds the UNION ALL query for notification sources. Pass select columns or `count(*) as cnt`. */ 6 14 function notificationUnion(select: "count" | "full", extraFilter: string): string { 7 15 const favCols = ··· 37 45 return ` 38 46 SELECT ${favCols} FROM "social.grain.favorite" 39 47 WHERE subject IN (SELECT uri FROM "social.grain.gallery" WHERE did = $1) 40 - AND did != $1 ${extraFilter} 48 + AND did != $1 ${blockMuteNotifFilter()} ${extraFilter} 41 49 42 50 UNION ALL 43 51 44 52 SELECT ${commentCols} FROM "social.grain.comment" 45 53 WHERE subject IN (SELECT uri FROM "social.grain.gallery" WHERE did = $1) 46 - AND did != $1 AND reply_to IS NULL ${extraFilter} 54 + AND did != $1 AND reply_to IS NULL ${blockMuteNotifFilter()} ${extraFilter} 47 55 48 56 UNION ALL 49 57 50 58 SELECT ${replyCols} FROM "social.grain.comment" c 51 59 WHERE c.reply_to IN (SELECT uri FROM "social.grain.comment" WHERE did = $1) 52 - AND c.did != $1 ${extraFilter} 60 + AND c.did != $1 ${blockMuteNotifFilter("c.did")} ${extraFilter} 53 61 54 62 UNION ALL 55 63 56 64 SELECT ${followCols} FROM "social.grain.graph.follow" 57 - WHERE subject = $1 AND did != $1 ${extraFilter} 65 + WHERE subject = $1 AND did != $1 ${blockMuteNotifFilter()} ${extraFilter} 58 66 59 67 UNION ALL 60 68 ··· 62 70 WHERE facets LIKE '%' || $1 || '%' AND did != $1 63 71 AND subject NOT IN (SELECT uri FROM "social.grain.gallery" WHERE did = $1) 64 72 AND reply_to NOT IN (SELECT uri FROM "social.grain.comment" WHERE did = $1) 65 - ${extraFilter} 73 + ${blockMuteNotifFilter()} ${extraFilter} 66 74 67 75 UNION ALL 68 76 69 77 SELECT ${mentionGalleryCols} FROM "social.grain.gallery" 70 - WHERE facets LIKE '%' || $1 || '%' AND did != $1 ${extraFilter} 78 + WHERE facets LIKE '%' || $1 || '%' AND did != $1 ${blockMuteNotifFilter()} ${extraFilter} 71 79 `; 72 80 } 73 81
+7 -1
server/xrpc/getStoryAuthors.ts
··· 2 2 import { views } from "$hatk"; 3 3 import type { GrainActorProfile } from "$hatk"; 4 4 import { hideLabelsFilter } from "../labels/_hidden.ts"; 5 + import { blockMuteFilter } from "../filters/blockMute.ts"; 5 6 6 7 const TWENTY_FOUR_HOURS = 24 * 60 * 60 * 1000; 7 8 8 9 export default defineQuery("social.grain.unspecced.getStoryAuthors", async (ctx) => { 9 10 const { db, ok } = ctx; 11 + const viewer = ctx.viewer?.did; 10 12 const cutoff = new Date(Date.now() - TWENTY_FOUR_HOURS).toISOString(); 11 13 14 + const bmFilter = viewer ? `AND ${blockMuteFilter("s.did", "$2")}` : ""; 15 + const params = viewer ? [cutoff, viewer] : [cutoff]; 16 + 12 17 // Aggregate in SQL, excluding stories with hide-severity labels 13 18 const rows = (await db.query( 14 19 `SELECT s.did, COUNT(*) AS story_count, MAX(s.created_at) AS latest_at ··· 17 22 WHERE s.created_at > $1 18 23 AND (r.status IS NULL OR r.status != 'takendown') 19 24 AND ${hideLabelsFilter("s.uri")} 25 + ${bmFilter} 20 26 GROUP BY s.did 21 27 ORDER BY latest_at DESC`, 22 - [cutoff], 28 + params, 23 29 )) as { did: string; story_count: number; latest_at: string }[]; 24 30 25 31 const dids = rows.map((r) => r.did);
+3
server/xrpc/getSuggestedFollows.ts
··· 1 1 import { defineQuery, type GrainActorProfile } from "$hatk"; 2 + import { blockMuteFilter } from "../filters/blockMute.ts"; 2 3 3 4 export default defineQuery("social.grain.unspecced.getSuggestedFollows", async (ctx) => { 4 5 const { ok, params, lookup, blobUrl, db } = ctx; ··· 17 18 AND bf.subject NOT IN ( 18 19 SELECT gf.subject FROM "social.grain.graph.follow" gf WHERE gf.did = $1 19 20 ) 21 + AND ${blockMuteFilter("bf.subject", "$1")} 20 22 LIMIT $2`, 21 23 [actor, Number(limit)], 22 24 )) as { subject: string }[]; ··· 39 41 AND gp.did NOT IN ( 40 42 SELECT gf.subject FROM "social.grain.graph.follow" gf WHERE gf.did = $1 41 43 ) 44 + AND ${blockMuteFilter("gp.did", "$1")} 42 45 ORDER BY follower_count DESC 43 46 LIMIT $${exclude.length + 1}`, 44 47 [...exclude, remaining],
+18
server/xrpc/muteActor.ts
··· 1 + import { defineProcedure } from "$hatk"; 2 + 3 + export default defineProcedure("social.grain.graph.muteActor", async (ctx) => { 4 + const { ok, db, viewer } = ctx; 5 + if (!viewer) throw new Error("Authentication required"); 6 + 7 + const { actor } = ctx.input; 8 + if (actor === viewer.did) throw new Error("Cannot mute yourself"); 9 + 10 + await db.run( 11 + `INSERT INTO _mutes (did, subject, created_at) 12 + VALUES ($1, $2, $3) 13 + ON CONFLICT (did, subject) DO NOTHING`, 14 + [viewer.did, actor, new Date().toISOString()], 15 + ); 16 + 17 + return ok({}); 18 + });
+13
server/xrpc/unmuteActor.ts
··· 1 + import { defineProcedure } from "$hatk"; 2 + 3 + export default defineProcedure("social.grain.graph.unmuteActor", async (ctx) => { 4 + const { ok, db, viewer } = ctx; 5 + if (!viewer) throw new Error("Authentication required"); 6 + 7 + await db.run( 8 + `DELETE FROM _mutes WHERE did = $1 AND subject = $2`, 9 + [viewer.did, ctx.input.actor], 10 + ); 11 + 12 + return ok({}); 13 + });