# 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
You have blocked this user.
This user has blocked you.