# Blocks and Mutes Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Add Bluesky-style blocking and muting so users can control who they see and who can interact with them. **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. **Tech Stack:** hatk (defineQuery, defineFeed, lexicons), SvelteKit 2, tanstack-query, Svelte 5 runes --- ### Task 1: Block Lexicon **Files:** - Create: `lexicons/social/grain/graph/block.json` **Step 1: Create the block record lexicon** ```json { "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" } } } } } } ``` This mirrors `social.grain.graph.follow` exactly — same structure, same key type (tid). **Step 2: Regenerate types** Run: `npx hatk generate types` Expected: `Generated ./hatk.generated.ts` with new `GrainGraphBlock` type **Step 3: Commit** ```bash git add lexicons/social/grain/graph/block.json hatk.generated.ts hatk.generated.client.ts git commit -m "feat: add social.grain.graph.block lexicon" ``` --- ### Task 2: Mute XRPC Procedures Mutes are server-side state, not records. We need a `_mutes` table and two XRPC procedures. **Files:** - Create: `server/xrpc/muteActor.ts` - Create: `server/xrpc/unmuteActor.ts` - Create: `lexicons/social/grain/graph/muteActor.json` - Create: `lexicons/social/grain/graph/unmuteActor.json` **Step 1: Create the muteActor lexicon** ```json { "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" } } } } } } } ``` **Step 2: Create the unmuteActor lexicon** ```json { "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" } } } } } } } ``` **Step 3: Implement muteActor handler** ```typescript // server/xrpc/muteActor.ts import { defineQuery, InvalidRequestError } from "$hatk"; export default defineQuery("social.grain.graph.muteActor", async (ctx) => { const { ok, params, db, viewer } = ctx; if (!viewer) throw new InvalidRequestError("Authentication required"); const { actor } = params; if (actor === viewer.did) throw new InvalidRequestError("Cannot mute yourself"); await db.query( `INSERT INTO _mutes (did, subject, created_at) VALUES ($1, $2, $3) ON CONFLICT (did, subject) DO NOTHING`, [viewer.did, actor, new Date().toISOString()], ); return ok({}); }); ``` **Step 4: Implement unmuteActor handler** ```typescript // server/xrpc/unmuteActor.ts import { defineQuery, InvalidRequestError } from "$hatk"; export default defineQuery("social.grain.graph.unmuteActor", async (ctx) => { const { ok, params, db, viewer } = ctx; if (!viewer) throw new InvalidRequestError("Authentication required"); await db.query(`DELETE FROM _mutes WHERE did = $1 AND subject = $2`, [viewer.did, params.actor]); return ok({}); }); ``` **Step 5: Create the \_mutes table** 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. The table schema: ```sql CREATE TABLE IF NOT EXISTS _mutes ( did TEXT NOT NULL, subject TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY (did, subject) ); CREATE INDEX IF NOT EXISTS idx_mutes_did ON _mutes (did); CREATE INDEX IF NOT EXISTS idx_mutes_subject ON _mutes (subject); ``` 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. **Step 6: Regenerate types and commit** Run: `npx hatk generate types` ```bash git add lexicons/social/grain/graph/muteActor.json lexicons/social/grain/graph/unmuteActor.json \ server/xrpc/muteActor.ts server/xrpc/unmuteActor.ts hatk.generated.ts hatk.generated.client.ts git commit -m "feat: add mute/unmute actor XRPC procedures" ``` --- ### Task 3: Extend Viewer State with Block/Mute Info **Files:** - Modify: `lexicons/social/grain/actor/defs.json` — add `blocking`, `blockedBy`, `muted` to `viewerState` - Modify: `server/xrpc/getActorProfile.ts` — query block/mute relationships **Step 1: Update viewerState in lexicon** In `lexicons/social/grain/actor/defs.json`, update the `viewerState` definition: ```json "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" } } } ``` - `blocking`: AT-URI of the viewer's block record (so client can delete it to unblock) - `blockedBy`: boolean — the subject blocks the viewer - `muted`: boolean — the viewer has muted the subject **Step 2: Add block/mute queries to getActorProfile.ts** In the `Promise.all` block (after the follow queries), add: ```typescript // viewer blocks actor ctx.db.query( `SELECT uri FROM "social.grain.graph.block" WHERE did = $1 AND subject = $2 LIMIT 1`, [viewer, actor], ) as Promise<{ uri: string }[]>, // actor blocks viewer ctx.db.query( `SELECT uri FROM "social.grain.graph.block" WHERE did = $1 AND subject = $2 LIMIT 1`, [actor, viewer], ) as Promise<{ uri: string }[]>, // viewer mutes actor ctx.db.query( `SELECT 1 FROM _mutes WHERE did = $1 AND subject = $2 LIMIT 1`, [viewer, actor], ) as Promise<{ 1: number }[]>, ``` Then in the response, extend the viewer object: ```typescript viewer: { ...(viewerFollowing ? { following: viewerFollowing } : {}), ...(followedBy ? { followedBy } : {}), ...(viewerBlocking ? { blocking: viewerBlocking } : {}), ...(blockedBy ? { blockedBy: true } : {}), ...(viewerMuted ? { muted: true } : {}), }, ``` **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. **Step 3: Regenerate types and commit** Run: `npx hatk generate types` ```bash git add lexicons/social/grain/actor/defs.json server/xrpc/getActorProfile.ts \ hatk.generated.ts hatk.generated.client.ts git commit -m "feat: return block/mute status in profile viewer state" ``` --- ### Task 4: Filter Feeds by Blocks and Mutes All feeds should exclude galleries from users the viewer has blocked or muted, and from users who have blocked the viewer. **Files:** - Create: `server/filters/blockMute.ts` — shared SQL filter helper - Modify: `server/feeds/following.ts` - Modify: `server/feeds/recent.ts` - Modify: `server/feeds/foryou.ts` - Modify: `server/feeds/camera.ts` - Modify: `server/feeds/hashtag.ts` - Modify: `server/feeds/location.ts` **Step 1: Create shared filter helper** ```typescript // server/filters/blockMute.ts /** * Returns a SQL fragment that excludes rows from blocked/muted users. * @param didColumn - the column containing the gallery creator's DID (e.g. "t.did") * @param viewerParam - the SQL parameter number for the viewer DID (e.g. "$1") */ export function blockMuteFilter(didColumn: string, viewerParam: string): string { return ` AND ${didColumn} NOT IN ( SELECT subject FROM "social.grain.graph.block" WHERE did = ${viewerParam} ) AND ${didColumn} NOT IN ( SELECT did FROM "social.grain.graph.block" WHERE subject = ${viewerParam} ) AND ${didColumn} NOT IN ( SELECT subject FROM _mutes WHERE did = ${viewerParam} ) `; } ``` Three subqueries: 1. Users the viewer has blocked 2. Users who have blocked the viewer (bidirectional) 3. Users the viewer has muted **Step 2: Add filter to following feed** In `server/feeds/following.ts`, import `blockMuteFilter` and add it to the query. The actor param is `$1`, so: ```typescript import { blockMuteFilter } from "../filters/blockMute.ts"; // In generate(): const { rows, cursor } = await ctx.paginate<{ uri: string }>( `SELECT t.uri, t.cid, t.created_at FROM "social.grain.gallery" t LEFT JOIN _repos r ON t.did = r.did WHERE (r.status IS NULL OR r.status != 'takendown') AND t.did IN (SELECT subject FROM "social.grain.graph.follow" WHERE did = $1) AND ${hideLabelsFilter("t.uri")} AND (SELECT count(*) FROM "social.grain.gallery.item" gi WHERE gi.gallery = t.uri) > 0 ${blockMuteFilter("t.did", "$1")}`, { orderBy: "t.created_at", params: [actor] }, ); ``` **Step 3: Add filter to recent feed** 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: ```typescript import { blockMuteFilter } from "../filters/blockMute.ts"; async generate(ctx) { const viewer = ctx.params.actor; // viewer DID if authenticated const filterSql = viewer ? blockMuteFilter("t.did", "$1") : ""; const filterParams = viewer ? [viewer] : []; const { rows, cursor } = await ctx.paginate<{ uri: string }>( `SELECT t.uri, t.cid, t.created_at FROM "social.grain.gallery" t LEFT JOIN _repos r ON t.did = r.did WHERE (r.status IS NULL OR r.status != 'takendown') AND ${hideLabelsFilter("t.uri")} AND (SELECT count(*) FROM "social.grain.gallery.item" gi WHERE gi.gallery = t.uri) > 0 ${filterSql}`, { orderBy: "t.created_at", ...(filterParams.length ? { params: filterParams } : {}) }, ); return ctx.ok({ uris: rows.map((r) => r.uri), cursor }); }, ``` **Step 4: Repeat for camera, hashtag, location, foryou feeds** 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. Check each feed's param structure to use the correct parameter number. **Step 5: Commit** ```bash git add server/filters/blockMute.ts server/feeds/*.ts git commit -m "feat: filter blocked and muted users from all feeds" ``` --- ### Task 5: Filter Notifications by Blocks and Mutes **Files:** - Modify: `server/xrpc/getNotifications.ts` **Step 1: Add block/mute filtering to notification queries** Import the filter helper and add exclusions to each UNION branch in `notificationUnion()`. Each branch has a `did` column representing the notification author. Add: ```sql AND did NOT IN (SELECT subject FROM "social.grain.graph.block" WHERE did = $1) AND did NOT IN (SELECT did FROM "social.grain.graph.block" WHERE subject = $1) AND did NOT IN (SELECT subject FROM _mutes WHERE did = $1) ``` Since `$1` is already the viewer DID in all branches, this works directly. **Step 2: Commit** ```bash git add server/xrpc/getNotifications.ts git commit -m "feat: filter blocked and muted users from notifications" ``` --- ### Task 6: Block/Mute UI — Profile Menu **Files:** - Modify: `app/routes/profile/[did]/+page.svelte` — add OverflowMenu with block/mute options - Modify: `app/lib/queries.ts` — add block/mute mutation helpers if needed **Step 1: Add three-dot menu to profile page** In the `.actions` div where FollowButton lives, add an OverflowMenu with Block and Mute options. Pattern from GalleryCard.svelte: ```svelte ``` **Step 2: Implement block handler** Block creates a record (like follow): ```typescript async function handleBlock() { if (!requireAuth()) return; if (p.viewer?.blocking) { // Unblock — delete the record const rkey = p.viewer.blocking.split("/").pop()!; await callXrpc("dev.hatk.deleteRecord", { collection: "social.grain.graph.block", rkey, }); } else { // Block — create the record await callXrpc("dev.hatk.createRecord", { collection: "social.grain.graph.block", record: { subject: did, createdAt: new Date().toISOString() }, }); } queryClient.invalidateQueries({ queryKey: ["actorProfile", did] }); } ``` **Step 3: Implement mute handler** Mute calls the procedure: ```typescript async function handleMute() { if (!requireAuth()) return; if (p.viewer?.muted) { await callXrpc("social.grain.graph.unmuteActor", { actor: did }); } else { await callXrpc("social.grain.graph.muteActor", { actor: did }); } queryClient.invalidateQueries({ queryKey: ["actorProfile", did] }); } ``` **Step 4: Show blocked state on profile** When `p.viewer?.blockedBy` or `p.viewer?.blocking`, show a notice instead of the user's content: ```svelte {#if p.viewer?.blocking}

You have blocked this user.

{:else if p.viewer?.blockedBy}

This user has blocked you.

{/if} ``` Hide the gallery tab content, followers count, etc. when either party blocks the other. **Step 5: Commit** ```bash git add app/routes/profile/[did]/+page.svelte app/lib/queries.ts git commit -m "feat: add block and mute UI to profile page" ``` --- ### Task 7: Block/Mute Settings Pages **Files:** - Create: `app/routes/settings/blocked/+page.svelte` — list of blocked users - Create: `app/routes/settings/blocked/+page.ts` — loader - Create: `app/routes/settings/muted/+page.svelte` — list of muted users - Create: `app/routes/settings/muted/+page.ts` — loader - Create: `server/xrpc/getBlocks.ts` — XRPC to list blocked users - Create: `server/xrpc/getMutes.ts` — XRPC to list muted users - Create: `lexicons/social/grain/unspecced/getBlocks.json` - Create: `lexicons/social/grain/unspecced/getMutes.json` **Step 1: Create getBlocks XRPC** ```typescript // server/xrpc/getBlocks.ts import { defineQuery, InvalidRequestError } from "$hatk"; import type { GrainActorProfile } from "$hatk"; import { views } from "$hatk"; export default defineQuery("social.grain.unspecced.getBlocks", async (ctx) => { const { ok, params, db, viewer, lookup, blobUrl } = ctx; if (!viewer) throw new InvalidRequestError("Authentication required"); const limit = params.limit ?? 50; const rows = (await db.query( `SELECT b.subject, b.uri, b.created_at FROM "social.grain.graph.block" b WHERE b.did = $1 ORDER BY b.created_at DESC LIMIT $2`, [viewer.did, limit], )) as { subject: string; uri: string; created_at: string }[]; const dids = rows.map((r) => r.subject); const profiles = await lookup("social.grain.actor.profile", "did", dids); const blocks = rows.map((row) => { const profile = profiles.get(row.subject); return { blockUri: row.uri, actor: profile ? views.grainActorDefsProfileView({ cid: profile.cid, did: profile.did, handle: profile.handle ?? profile.did, displayName: profile.value.displayName, avatar: blobUrl(profile.did, profile.value.avatar) ?? undefined, }) : views.grainActorDefsProfileView({ cid: "", did: row.subject, handle: row.subject, }), }; }); return ok({ blocks }); }); ``` **Step 2: Create getMutes XRPC** (similar pattern, querying `_mutes` table) **Step 3: Create settings pages** 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. **Step 4: Add navigation links to settings** Check the existing settings layout/nav and add "Blocked users" and "Muted users" links. **Step 5: Commit** ```bash git add server/xrpc/getBlocks.ts server/xrpc/getMutes.ts \ lexicons/social/grain/unspecced/getBlocks.json lexicons/social/grain/unspecced/getMutes.json \ app/routes/settings/blocked/ app/routes/settings/muted/ \ hatk.generated.ts hatk.generated.client.ts git commit -m "feat: add blocked/muted user settings pages" ``` --- ### Task 8: Prevent Interactions When Blocked **Files:** - Modify: `server/xrpc/getGalleryThread.ts` (or equivalent gallery detail handler) — check blocks before returning - Modify: gallery comment submission handler — reject comments from blocked users - Modify: favorite handler — reject favorites from blocked users **Step 1: Add block checks to interaction endpoints** When user A has blocked user B: - B cannot favorite A's galleries - B cannot comment on A's galleries - B cannot follow A (and vice versa) Check the commit hooks (`server/hooks/`) for any `on-commit-*` handlers that should also respect blocks. In comment/favorite creation, add a pre-check: ```typescript // Check if gallery owner has blocked the commenter const blockRows = await db.query( `SELECT 1 FROM "social.grain.graph.block" WHERE (did = $1 AND subject = $2) OR (did = $2 AND subject = $1) LIMIT 1`, [galleryOwnerDid, viewer.did], ); if (blockRows.length > 0) { throw new InvalidRequestError("Blocked"); } ``` **Step 2: Commit** ```bash git add server/xrpc/*.ts server/hooks/*.ts git commit -m "feat: prevent interactions between blocked users" ``` --- ### Task 9: Seed Data and Manual Testing **Files:** - Modify: `seeds/seed.ts` — add block and mute relationships for testing **Step 1: Add test data** Add blocks and mutes to the seed: - Alice blocks Eve (or a new test user) - Bob mutes Carol - This lets us verify feed filtering and profile states **Step 2: Test manually** 1. Reseed: `npx hatk seed` (or however seeds are run) 2. Log in as Alice → verify Eve's galleries don't appear in feeds 3. View Eve's profile → should show "You have blocked this user" 4. Log in as Bob → verify Carol's galleries are hidden from feeds 5. View Carol's profile → should look normal (mutes are invisible to muted user) 6. Check notifications → blocked/muted user actions should be hidden **Step 3: Commit** ```bash git add seeds/seed.ts git commit -m "test: add block and mute seed data" ```