diff --git a/app/lib/components/molecules/Comment.svelte b/app/lib/components/molecules/Comment.svelte index 169e5e2..e005ae7 100644 --- a/app/lib/components/molecules/Comment.svelte +++ b/app/lib/components/molecules/Comment.svelte @@ -4,6 +4,7 @@ import RichText from '../atoms/RichText.svelte' import { relativeTime } from '$lib/utils' import { viewer } from '$lib/stores' + import { VolumeX } from 'lucide-svelte' let { comment, @@ -15,32 +16,44 @@ onDelete?: (uri: string) => void } = $props() + let expanded = $state(false) + const isOwner = $derived($viewer?.did === comment.author?.did) const timeStr = $derived(relativeTime(comment.createdAt || '')) const isReply = $derived(!!comment.replyTo) + const isMuted = $derived(!!comment.muted && !expanded) -
- -
- -
- {timeStr} - {#if onReply} - - {/if} - {#if isOwner && onDelete} - - {/if} +{#if comment.muted && !expanded} +
+ +
+{:else} +
+ +
+ +
+ {timeStr} + {#if onReply} + + {/if} + {#if isOwner && onDelete} + + {/if} +
+ {#if comment.focus?.thumb} + {comment.focus?.alt + {/if}
- {#if comment.focus?.thumb} - {comment.focus?.alt - {/if} -
+{/if} diff --git a/app/lib/components/organisms/MobileDrawer.svelte b/app/lib/components/organisms/MobileDrawer.svelte index ac95928..e67bec2 100644 --- a/app/lib/components/organisms/MobileDrawer.svelte +++ b/app/lib/components/organisms/MobileDrawer.svelte @@ -59,8 +59,8 @@ {/if} {#if $isAuthenticated} - {/if} diff --git a/app/lib/components/organisms/Sidebar.svelte b/app/lib/components/organisms/Sidebar.svelte index b27f3bd..6280db6 100644 --- a/app/lib/components/organisms/Sidebar.svelte +++ b/app/lib/components/organisms/Sidebar.svelte @@ -48,7 +48,7 @@ {/if} - + diff --git a/app/lib/mutations.ts b/app/lib/mutations.ts new file mode 100644 index 0000000..d8cebd9 --- /dev/null +++ b/app/lib/mutations.ts @@ -0,0 +1,47 @@ +import { callXrpc } from "$hatk/client"; +import type { QueryClient } from "@tanstack/svelte-query"; +import { get } from "svelte/store"; +import { viewer as viewerStore } from "$lib/stores"; + +function invalidateFeedsAndProfile(did: string, queryClient: QueryClient) { + queryClient.invalidateQueries({ queryKey: ["actorProfile", did] }); + queryClient.invalidateQueries({ queryKey: ["getFeed"] }); + queryClient.invalidateQueries({ queryKey: ["storyAuthors"] }); + queryClient.invalidateQueries({ queryKey: ["notifications"] }); +} + +export async function blockActor(did: string, queryClient: QueryClient) { + if (get(viewerStore)?.did === did) return; + await callXrpc("dev.hatk.createRecord", { + collection: "social.grain.graph.block", + record: { subject: did, createdAt: new Date().toISOString() }, + }); + invalidateFeedsAndProfile(did, queryClient); + queryClient.invalidateQueries({ queryKey: ["blocks"] }); +} + +export async function unblockActor( + did: string, + blockUri: string, + queryClient: QueryClient, +) { + const rkey = blockUri.split("/").pop()!; + await callXrpc("dev.hatk.deleteRecord", { + collection: "social.grain.graph.block", + rkey, + }); + invalidateFeedsAndProfile(did, queryClient); + queryClient.invalidateQueries({ queryKey: ["blocks"] }); +} + +export async function muteActor(did: string, queryClient: QueryClient) { + await callXrpc("social.grain.graph.muteActor", { actor: did }); + invalidateFeedsAndProfile(did, queryClient); + queryClient.invalidateQueries({ queryKey: ["mutes"] }); +} + +export async function unmuteActor(did: string, queryClient: QueryClient) { + await callXrpc("social.grain.graph.unmuteActor", { actor: did }); + invalidateFeedsAndProfile(did, queryClient); + queryClient.invalidateQueries({ queryKey: ["mutes"] }); +} diff --git a/app/lib/queries.ts b/app/lib/queries.ts index 579bac0..0644859 100644 --- a/app/lib/queries.ts +++ b/app/lib/queries.ts @@ -165,6 +165,22 @@ export const followingQuery = (did: string, f?: Fetch) => staleTime: 60_000, }); +// ─── Blocks / Mutes ───────────────────────────────────────────────── + +export const blocksQuery = (f?: Fetch) => + queryOptions({ + queryKey: ["blocks"], + queryFn: () => callXrpc("social.grain.unspecced.getBlocks", {}, f), + staleTime: 60_000, + }); + +export const mutesQuery = (f?: Fetch) => + queryOptions({ + queryKey: ["mutes"], + queryFn: () => callXrpc("social.grain.unspecced.getMutes", {}, f), + staleTime: 60_000, + }); + export const knownFollowersQuery = (did: string, viewer: string, f?: Fetch) => queryOptions({ queryKey: ["knownFollowers", did, viewer], diff --git a/app/routes/profile/[did]/+page.svelte b/app/routes/profile/[did]/+page.svelte index aa09c7c..d39650a 100644 --- a/app/routes/profile/[did]/+page.svelte +++ b/app/routes/profile/[did]/+page.svelte @@ -6,11 +6,13 @@ import OGMeta from '$lib/components/atoms/OGMeta.svelte' import Skeleton from '$lib/components/atoms/Skeleton.svelte' import FollowButton from '$lib/components/molecules/FollowButton.svelte' + import OverflowMenu from '$lib/components/atoms/OverflowMenu.svelte' import RichText from '$lib/components/atoms/RichText.svelte' - import { ArrowUpRight, Grid3x3, Heart, Clock } from 'lucide-svelte' - import { createQuery, createInfiniteQuery } from '@tanstack/svelte-query' + import { ArrowUpRight, Grid3x3, Heart, Clock, Ban, VolumeX } from 'lucide-svelte' + import { createQuery, createInfiniteQuery, useQueryClient } from '@tanstack/svelte-query' import { actorProfileQuery, actorFeedQuery, actorFavoritesInfiniteQuery, knownFollowersQuery, storiesQuery } from '$lib/queries' - import { viewer as viewerStore } from '$lib/stores' + import { viewer as viewerStore, requireAuth } from '$lib/stores' + import { blockActor, unblockActor, muteActor, unmuteActor } from '$lib/mutations' import StoryViewer from '$lib/components/organisms/StoryViewer.svelte' import StoryArchive from '$lib/components/molecules/StoryArchive.svelte' import { page } from '$app/state' @@ -59,6 +61,30 @@ enabled: !!viewerDid && viewerDid !== did, })) + const queryClient = useQueryClient() + + async function handleBlock() { + if (!requireAuth()) return + const p = profile.data as any + if (p?.viewer?.blocking) { + await unblockActor(did, p.viewer.blocking, queryClient) + } else { + await blockActor(did, queryClient) + } + } + + async function handleMute() { + if (!requireAuth()) return + const p = profile.data as any + if (p?.viewer?.muted) { + await unmuteActor(did, queryClient) + } else { + await muteActor(did, queryClient) + } + } + + const blockHide = $derived(!!profile.data?.viewer?.blocking || !!profile.data?.viewer?.blockedBy) + const showGermButton = $derived.by(() => { const p = profile.data as any if (!p?.messageMe || !viewerDid) return false @@ -100,45 +126,70 @@ (showStoryViewer = true) : p.avatar ? () => (lightboxSrc = p.avatar!) : undefined} /> {#if viewerDid && viewerDid !== did}
- (followersOffset += d)} /> + {#if !p.viewer?.blocking && !p.viewer?.blockedBy} + (followersOffset += d)} /> + {/if} + + {#if !blockHide} + + {/if} + +
{/if}
{p.displayName || did.slice(0, 18)}
- {#if p.viewer?.followedBy}Follows you{/if} + {#if !blockHide && p.viewer?.followedBy}Follows you{/if} {p.handle ? `@${p.handle}` : did}
-
- {#if p.description} -
- {/if} - - {#if (knownFollowers.data?.items ?? []).length > 0} - {@const known = knownFollowers.data?.items ?? []} - -
- {#each known.slice(0, 3) as k (k.did)} - - {/each} -
- - 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} - -
{/if}
@@ -157,6 +208,7 @@ (lightboxSrc = null)} /> {/if} + {#if !blockHide}
+
+ + {/each} +{/if} + + diff --git a/app/routes/settings/moderation/muted/+page.svelte b/app/routes/settings/moderation/muted/+page.svelte new file mode 100644 index 0000000..b479b45 --- /dev/null +++ b/app/routes/settings/moderation/muted/+page.svelte @@ -0,0 +1,69 @@ + + + + +{#if mutes.isLoading} + {#each {length: 5} as _} +
+ +
+ +
+
+
+ {/each} +{:else if (mutes.data?.items ?? []).length === 0} +
You haven't muted anyone.
+{:else} + {#each mutes.data?.items ?? [] as person (person.did)} +
+
+
+ +
+
+ {/each} +{/if} + + diff --git a/db/schema.sql b/db/schema.sql index 7e47dde..e59ed87 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -16,6 +16,13 @@ CREATE TABLE _labels ( exp TEXT ); +CREATE TABLE _mutes ( + did TEXT NOT NULL, + subject TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (did, subject) +); + CREATE TABLE _oauth_codes ( code TEXT PRIMARY KEY, request_uri TEXT NOT NULL, @@ -305,6 +312,15 @@ CREATE TABLE "social.grain.gallery__labels_self_labels" ( val TEXT ); +CREATE TABLE "social.grain.graph.block" ( + uri TEXT PRIMARY KEY, + cid TEXT, + did TEXT NOT NULL, + indexed_at TEXT NOT NULL, + subject TEXT NOT NULL, + created_at TEXT NOT NULL +); + CREATE TABLE "social.grain.graph.follow" ( uri TEXT PRIMARY KEY, cid TEXT, diff --git a/docs/plans/2026-04-09-blocks-and-mutes.md b/docs/plans/2026-04-09-blocks-and-mutes.md new file mode 100644 index 0000000..bfa1ffd --- /dev/null +++ b/docs/plans/2026-04-09-blocks-and-mutes.md @@ -0,0 +1,648 @@ +# 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" +``` diff --git a/hatk.config.ts b/hatk.config.ts index 040cf83..fd93b52 100644 --- a/hatk.config.ts +++ b/hatk.config.ts @@ -15,6 +15,7 @@ const grainScopes = [ "repo:social.grain.favorite", "repo:social.grain.comment", "repo:social.grain.story", + "repo:social.grain.graph.block", "repo:app.bsky.feed.post?action=create", ].join(" "); diff --git a/hatk.generated.client.ts b/hatk.generated.client.ts index a7cbd0a..0cbef1b 100644 --- a/hatk.generated.client.ts +++ b/hatk.generated.client.ts @@ -3,9 +3,9 @@ // to avoid pulling in server-only dependencies. export type { XrpcSchema } from './hatk.generated.ts' import type { XrpcSchema } from './hatk.generated.ts' -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' +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' -const _procedures = new Set(['dev.hatk.createRecord', 'dev.hatk.createReport', 'dev.hatk.deleteRecord', 'dev.hatk.putPreference', 'dev.hatk.putRecord', 'social.grain.unspecced.deleteGallery']) +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']) const _blobInputs = new Set(['dev.hatk.uploadBlob']) type CallArg = diff --git a/hatk.generated.ts b/hatk.generated.ts index ce6b2e9..452f5ec 100644 --- a/hatk.generated.ts +++ b/hatk.generated.ts @@ -46,16 +46,19 @@ const putRecordLex = {"lexicon":1,"id":"dev.hatk.putRecord","defs":{"main":{"typ 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 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 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 -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 +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 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 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 -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 +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 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 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 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 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 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 +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 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 +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 +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 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 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 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,6 +67,7 @@ const grainStoryDefsLex = {"lexicon":1,"id":"social.grain.story.defs","defs":{"s 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 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 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 +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 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 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 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,6 +75,7 @@ const getGalleryLex = {"lexicon":1,"id":"social.grain.unspecced.getGallery","def 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 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 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 +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 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 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 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,7 +137,10 @@ type Registry = { 'social.grain.gallery': typeof galleryLex 'social.grain.gallery.defs': typeof grainGalleryDefsLex 'social.grain.gallery.item': typeof itemLex + 'social.grain.graph.block': typeof blockLex 'social.grain.graph.follow': typeof grainGraphFollowLex + 'social.grain.graph.muteActor': typeof muteActorLex + 'social.grain.graph.unmuteActor': typeof unmuteActorLex 'social.grain.photo': typeof photoLex 'social.grain.photo.defs': typeof grainPhotoDefsLex 'social.grain.photo.exif': typeof exifLex @@ -141,6 +149,7 @@ type Registry = { 'social.grain.unspecced.deleteGallery': typeof deleteGalleryLex 'social.grain.unspecced.getActorFavorites': typeof getActorFavoritesLex 'social.grain.unspecced.getActorProfile': typeof getActorProfileLex + 'social.grain.unspecced.getBlocks': typeof getBlocksLex 'social.grain.unspecced.getCameras': typeof getCamerasLex 'social.grain.unspecced.getFollowers': typeof getFollowersLex 'social.grain.unspecced.getFollowing': typeof getFollowingLex @@ -148,6 +157,7 @@ type Registry = { 'social.grain.unspecced.getGalleryThread': typeof getGalleryThreadLex 'social.grain.unspecced.getKnownFollowers': typeof getKnownFollowersLex 'social.grain.unspecced.getLocations': typeof getLocationsLex + 'social.grain.unspecced.getMutes': typeof getMutesLex 'social.grain.unspecced.getNotifications': typeof getNotificationsLex 'social.grain.unspecced.getStories': typeof getStoriesLex 'social.grain.unspecced.getStory': typeof getStoryLex @@ -184,13 +194,17 @@ export type Comment = Prettify> export type Favorite = Prettify> export type Gallery = Prettify> export type Item = Prettify> +export type Block = Prettify> export type GrainGraphFollow = Prettify> +export type MuteActor = Prettify> +export type UnmuteActor = Prettify> export type Photo = Prettify> export type Exif = Prettify> export type Story = Prettify> export type DeleteGallery = Prettify> export type GetActorFavorites = Prettify> export type GetActorProfile = Prettify> +export type GetBlocks = Prettify> export type GetCameras = Prettify> export type GetFollowers = Prettify> export type GetFollowing = Prettify> @@ -198,6 +212,7 @@ export type GetGallery = Prettify> export type GetGalleryThread = Prettify> export type GetKnownFollowers = Prettify> export type GetLocations = Prettify> +export type GetMutes = Prettify> export type GetNotifications = Prettify> export type GetStories = Prettify> export type GetStory = Prettify> @@ -220,6 +235,7 @@ export type RecordRegistry = { 'social.grain.favorite': Favorite 'social.grain.gallery': Gallery 'social.grain.gallery.item': Item + 'social.grain.graph.block': Block 'social.grain.graph.follow': GrainGraphFollow 'social.grain.photo': Photo 'social.grain.photo.exif': Exif @@ -370,6 +386,7 @@ export type PhotoView = Prettify> export type GalleryState = Prettify> export type StoryView = Prettify> +export type BlockItem = Prettify> export type CameraItem = Prettify> export type GetFollowersFollowerItem = Prettify> export type GetFollowersViewerState = Prettify> @@ -377,6 +394,7 @@ export type FollowingItem = Prettify> export type GetKnownFollowersFollowerItem = Prettify> export type LocationItem = Prettify> +export type MuteItem = Prettify> export type NotificationItem = Prettify> export type StoryAuthor = Prettify> export type SuggestedItem = Prettify> @@ -400,9 +418,12 @@ export type XrpcSchema = { 'dev.hatk.searchRecords': SearchRecords 'dev.hatk.uploadBlob': UploadBlob 'parts.page.mention.search': Search + 'social.grain.graph.muteActor': MuteActor + 'social.grain.graph.unmuteActor': UnmuteActor 'social.grain.unspecced.deleteGallery': DeleteGallery 'social.grain.unspecced.getActorFavorites': GetActorFavorites 'social.grain.unspecced.getActorProfile': GetActorProfile + 'social.grain.unspecced.getBlocks': GetBlocks 'social.grain.unspecced.getCameras': GetCameras 'social.grain.unspecced.getFollowers': GetFollowers 'social.grain.unspecced.getFollowing': GetFollowing @@ -410,6 +431,7 @@ export type XrpcSchema = { 'social.grain.unspecced.getGalleryThread': GetGalleryThread 'social.grain.unspecced.getKnownFollowers': GetKnownFollowers 'social.grain.unspecced.getLocations': GetLocations + 'social.grain.unspecced.getMutes': GetMutes 'social.grain.unspecced.getNotifications': GetNotifications 'social.grain.unspecced.getStories': GetStories 'social.grain.unspecced.getStory': GetStory diff --git a/lexicons/social/grain/actor/defs.json b/lexicons/social/grain/actor/defs.json index c55be51..2729384 100644 --- a/lexicons/social/grain/actor/defs.json +++ b/lexicons/social/grain/actor/defs.json @@ -79,7 +79,10 @@ "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" } + "followedBy": { "type": "string", "format": "at-uri" }, + "blocking": { "type": "string", "format": "at-uri" }, + "blockedBy": { "type": "boolean" }, + "muted": { "type": "boolean" } } } } diff --git a/lexicons/social/grain/comment/defs.json b/lexicons/social/grain/comment/defs.json index 67fd107..e6a0617 100644 --- a/lexicons/social/grain/comment/defs.json +++ b/lexicons/social/grain/comment/defs.json @@ -41,6 +41,10 @@ "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." } } } diff --git a/lexicons/social/grain/graph/block.json b/lexicons/social/grain/graph/block.json new file mode 100644 index 0000000..0bb92f9 --- /dev/null +++ b/lexicons/social/grain/graph/block.json @@ -0,0 +1,24 @@ +{ + "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" + } + } + } + } + } +} diff --git a/lexicons/social/grain/graph/muteActor.json b/lexicons/social/grain/graph/muteActor.json new file mode 100644 index 0000000..316311a --- /dev/null +++ b/lexicons/social/grain/graph/muteActor.json @@ -0,0 +1,30 @@ +{ + "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": {} + } + } + } + } +} diff --git a/lexicons/social/grain/graph/unmuteActor.json b/lexicons/social/grain/graph/unmuteActor.json new file mode 100644 index 0000000..7bef1d1 --- /dev/null +++ b/lexicons/social/grain/graph/unmuteActor.json @@ -0,0 +1,30 @@ +{ + "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": {} + } + } + } + } +} diff --git a/lexicons/social/grain/unspecced/getBlocks.json b/lexicons/social/grain/unspecced/getBlocks.json new file mode 100644 index 0000000..10f6785 --- /dev/null +++ b/lexicons/social/grain/unspecced/getBlocks.json @@ -0,0 +1,41 @@ +{ + "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" } + } + } + } +} diff --git a/lexicons/social/grain/unspecced/getMutes.json b/lexicons/social/grain/unspecced/getMutes.json new file mode 100644 index 0000000..2fe7813 --- /dev/null +++ b/lexicons/social/grain/unspecced/getMutes.json @@ -0,0 +1,40 @@ +{ + "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" } + } + } + } +} diff --git a/seeds/seed.ts b/seeds/seed.ts index 5f27a4c..dfea29a 100644 --- a/seeds/seed.ts +++ b/seeds/seed.ts @@ -755,6 +755,27 @@ await createRecord( { rkey: "comment-5" }, ); +// ── Blocks ── +// Dave blocks Alice (tests "blockedBy" state when Alice views Dave's profile) +await createRecord( + dave, + "social.grain.graph.block", + { subject: alice.did, createdAt: ago(3) }, + { rkey: "block-alice" }, +); + +// Bob blocks Carol (tests "blocking" state when Bob views Carol's profile, +// and feed/notification filtering between them) +await createRecord( + bob, + "social.grain.graph.block", + { subject: carol.did, createdAt: ago(2) }, + { rkey: "block-carol" }, +); + +// Note: Mutes are stored in the server-side _mutes table (not AT Protocol records) +// and cannot be seeded via createRecord. Test mutes manually via the UI or XRPC. + // ── Stories ── await createRecord( diff --git a/server/feeds/camera.ts b/server/feeds/camera.ts index e7bc930..9e60474 100644 --- a/server/feeds/camera.ts +++ b/server/feeds/camera.ts @@ -4,6 +4,7 @@ import { defineFeed } from "$hatk"; import { hydrateGalleries } from "../hydrate/galleries.ts"; import { hideLabelsFilter } from "../labels/_hidden.ts"; +import { blockMuteFilter } from "../filters/blockMute.ts"; export default defineFeed({ collection: "social.grain.gallery", @@ -15,6 +16,10 @@ export default defineFeed({ const camera = ctx.params.camera; if (!camera) return ctx.ok({ uris: [] }); + const viewer = ctx.viewer?.did; + const bmFilter = viewer ? `AND ${blockMuteFilter("t.did", "$2")}` : ""; + const bmParams = 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 @@ -25,8 +30,9 @@ export default defineFeed({ WHERE gi.gallery = t.uri AND (e.make || ' ' || e.model) = $1 ) AND ${hideLabelsFilter("t.uri")} - AND (SELECT count(*) FROM "social.grain.gallery.item" gi WHERE gi.gallery = t.uri) > 0`, - { orderBy: "t.created_at", params: [camera] }, + AND (SELECT count(*) FROM "social.grain.gallery.item" gi WHERE gi.gallery = t.uri) > 0 + ${bmFilter}`, + { orderBy: "t.created_at", params: [camera, ...bmParams] }, ); return ctx.ok({ uris: rows.map((r) => r.uri), cursor }); diff --git a/server/feeds/following.ts b/server/feeds/following.ts index d85b4c3..2027338 100644 --- a/server/feeds/following.ts +++ b/server/feeds/following.ts @@ -1,6 +1,7 @@ import { defineFeed } from "$hatk"; import { hydrateGalleries } from "../hydrate/galleries.ts"; import { hideLabelsFilter } from "../labels/_hidden.ts"; +import { blockMuteFilter } from "../filters/blockMute.ts"; export default defineFeed({ collection: "social.grain.gallery", @@ -18,7 +19,8 @@ export default defineFeed({ 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`, + AND (SELECT count(*) FROM "social.grain.gallery.item" gi WHERE gi.gallery = t.uri) > 0 + AND ${blockMuteFilter("t.did", "$1")}`, { orderBy: "t.created_at", params: [actor] }, ); diff --git a/server/feeds/foryou.ts b/server/feeds/foryou.ts index 26fe707..8c40620 100644 --- a/server/feeds/foryou.ts +++ b/server/feeds/foryou.ts @@ -1,6 +1,7 @@ import { defineFeed } from "$hatk"; import { hydrateGalleries } from "../hydrate/galleries.ts"; import { hideLabelsFilter } from "../labels/_hidden.ts"; +import { blockMuteFilter } from "../filters/blockMute.ts"; // ─── Scoring parameters (spacecowboy17's optimized A/B values) ─────── const HALF_LIFE_HOURS = 6; @@ -106,7 +107,8 @@ export default defineFeed({ AND t.did != $1 AND (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`, + AND (SELECT count(*) FROM "social.grain.gallery.item" gi WHERE gi.gallery = t.uri) > 0 + AND ${blockMuteFilter("t.did", "$1")}`, [actor, ...colikerList, ...seedUris], ) as Promise<{ coliker: string; gallery_uri: string; gallery_created_at: string }[]>, @@ -239,6 +241,7 @@ async function coldStartFeed( AND t.created_at > $2 AND ${hideLabelsFilter("t.uri")} AND (SELECT count(*) FROM "social.grain.gallery.item" gi WHERE gi.gallery = t.uri) > 0 + AND ${blockMuteFilter("t.did", "$1")} GROUP BY t.uri ORDER BY fav_count DESC, t.created_at DESC LIMIT $3 OFFSET $4`, diff --git a/server/feeds/hashtag.ts b/server/feeds/hashtag.ts index e71b7cf..444c8a6 100644 --- a/server/feeds/hashtag.ts +++ b/server/feeds/hashtag.ts @@ -1,6 +1,7 @@ import { defineFeed } from "$hatk"; import { hydrateGalleries } from "../hydrate/galleries.ts"; import { hideLabelsFilter } from "../labels/_hidden.ts"; +import { blockMuteFilter } from "../filters/blockMute.ts"; export default defineFeed({ collection: "social.grain.gallery", @@ -13,6 +14,9 @@ export default defineFeed({ if (!tag) return ctx.ok({ uris: [] }); const pattern = `%#${tag}%`; + const viewer = ctx.viewer?.did; + const bmFilter = viewer ? `AND ${blockMuteFilter("t.did", "$2")}` : ""; + const bmParams = viewer ? [viewer] : []; const { rows, cursor } = await ctx.paginate<{ uri: string }>( `SELECT t.uri, t.cid, t.created_at FROM "social.grain.gallery" t @@ -23,8 +27,9 @@ export default defineFeed({ OR EXISTS (SELECT 1 FROM "social.grain.comment" c WHERE c.subject = t.uri AND c.text LIKE $1) ) AND ${hideLabelsFilter("t.uri")} - AND (SELECT count(*) FROM "social.grain.gallery.item" gi WHERE gi.gallery = t.uri) > 0`, - { orderBy: "t.created_at", params: [pattern] }, + AND (SELECT count(*) FROM "social.grain.gallery.item" gi WHERE gi.gallery = t.uri) > 0 + ${bmFilter}`, + { orderBy: "t.created_at", params: [pattern, ...bmParams] }, ); return ctx.ok({ uris: rows.map((r) => r.uri), cursor }); diff --git a/server/feeds/location.ts b/server/feeds/location.ts index 868d06c..4334f93 100644 --- a/server/feeds/location.ts +++ b/server/feeds/location.ts @@ -8,6 +8,7 @@ import { defineFeed } from "$hatk"; import { hydrateGalleries } from "../hydrate/galleries.ts"; import { getResolution, cellToParent } from "h3-js"; import { hideLabelsFilter } from "../labels/_hidden.ts"; +import { blockMuteFilter } from "../filters/blockMute.ts"; export default defineFeed({ collection: "social.grain.gallery", @@ -25,9 +26,13 @@ export default defineFeed({ // For city-level queries, we need to check if the gallery's H3 cell // is a child of the requested city cell. We do this in application code // since SQLite doesn't have H3 functions. + const viewer = ctx.viewer?.did; + if (isCityLevel) { // Fetch all galleries with locations, filter by H3 parent in JS, then paginate const limit = ctx.params.limit ? Number(ctx.params.limit) : 30; + const bmFilter = viewer ? `AND ${blockMuteFilter("t.did", "$1")}` : ""; + const bmParams = viewer ? [viewer] : []; const allRows = (await ctx.db.query( `SELECT t.uri, t.created_at, json_extract(t.location, '$.value') AS location FROM "social.grain.gallery" t @@ -36,7 +41,9 @@ export default defineFeed({ AND t.location IS NOT NULL AND ${hideLabelsFilter("t.uri")} AND (SELECT count(*) FROM "social.grain.gallery.item" gi WHERE gi.gallery = t.uri) > 0 + ${bmFilter} ORDER BY t.created_at DESC`, + bmParams, )) as { uri: string; created_at: string; location: string }[]; const filtered = allRows.filter((r) => { @@ -67,14 +74,17 @@ export default defineFeed({ } // Venue-level: exact match + const bmFilterVenue = viewer ? `AND ${blockMuteFilter("t.did", "$2")}` : ""; + const bmParamsVenue = 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 json_extract(t.location, '$.value') = $1 AND ${hideLabelsFilter("t.uri")} - AND (SELECT count(*) FROM "social.grain.gallery.item" gi WHERE gi.gallery = t.uri) > 0`, - { orderBy: "t.created_at", params: [location] }, + AND (SELECT count(*) FROM "social.grain.gallery.item" gi WHERE gi.gallery = t.uri) > 0 + ${bmFilterVenue}`, + { orderBy: "t.created_at", params: [location, ...bmParamsVenue] }, ); return ctx.ok({ uris: rows.map((r) => r.uri), cursor }); diff --git a/server/feeds/recent.ts b/server/feeds/recent.ts index 5cd27b7..d6731eb 100644 --- a/server/feeds/recent.ts +++ b/server/feeds/recent.ts @@ -1,6 +1,7 @@ import { defineFeed } from "$hatk"; import { hydrateGalleries } from "../hydrate/galleries.ts"; import { hideLabelsFilter } from "../labels/_hidden.ts"; +import { blockMuteFilter } from "../filters/blockMute.ts"; export default defineFeed({ collection: "social.grain.gallery", @@ -9,13 +10,18 @@ export default defineFeed({ hydrate: hydrateGalleries, async generate(ctx) { + const viewer = ctx.viewer?.did; + const bmFilter = viewer ? `AND ${blockMuteFilter("t.did", "$1")}` : ""; + const bmParams = 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`, - { orderBy: "t.created_at" }, + AND (SELECT count(*) FROM "social.grain.gallery.item" gi WHERE gi.gallery = t.uri) > 0 + ${bmFilter}`, + { orderBy: "t.created_at", ...(bmParams.length ? { params: bmParams } : {}) }, ); return ctx.ok({ uris: rows.map((r) => r.uri), cursor }); diff --git a/server/filters/blockMute.ts b/server/filters/blockMute.ts new file mode 100644 index 0000000..43e57df --- /dev/null +++ b/server/filters/blockMute.ts @@ -0,0 +1,33 @@ +/** + * Returns a SQL fragment that excludes rows from blocked (bidirectional) and muted users. + * @param didColumn - the column containing the actor'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 ` + ${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} + ) + `; +} + +/** + * Returns a SQL fragment that excludes rows from blocked users only (bidirectional). + * Use this when muted content should still be returned (e.g. comments with a muted flag). + */ +export function blockFilter(didColumn: string, viewerParam: string): string { + return ` + ${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} + ) + `; +} diff --git a/server/hydrate/galleries.ts b/server/hydrate/galleries.ts index 16a8f34..d86ef65 100644 --- a/server/hydrate/galleries.ts +++ b/server/hydrate/galleries.ts @@ -117,7 +117,8 @@ export async function hydrateGalleries( }) : Promise.resolve(new Map()), countComments(ctx.db, galleryUris), - ctx.labels(galleryUris).then(async (externalLabels: Map) => { + ctx.labels(galleryUris).then(async (raw) => { + const externalLabels = raw as Map; if (galleryUris.length === 0) return externalLabels; const selfLabelRows = (await ctx.db.query( `SELECT parent_uri, val FROM "social.grain.gallery__labels_self_labels" diff --git a/server/setup/01-mutes-table.ts b/server/setup/01-mutes-table.ts new file mode 100644 index 0000000..58010a3 --- /dev/null +++ b/server/setup/01-mutes-table.ts @@ -0,0 +1,14 @@ +import { defineSetup } from "$hatk"; + +export default defineSetup(async (ctx) => { + await ctx.db.run(` + CREATE TABLE IF NOT EXISTS _mutes ( + did TEXT NOT NULL, + subject TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (did, subject) + ) + `); + await ctx.db.run(`CREATE INDEX IF NOT EXISTS idx_mutes_did ON _mutes (did)`); + await ctx.db.run(`CREATE INDEX IF NOT EXISTS idx_mutes_subject ON _mutes (subject)`); +}); diff --git a/server/xrpc/getActorProfile.ts b/server/xrpc/getActorProfile.ts index f60422e..03aba78 100644 --- a/server/xrpc/getActorProfile.ts +++ b/server/xrpc/getActorProfile.ts @@ -2,8 +2,8 @@ import { defineQuery, InvalidRequestError } from "$hatk"; import type { GrainActorProfile, Declaration } from "$hatk"; export default defineQuery("social.grain.unspecced.getActorProfile", async (ctx) => { - const { ok, params, isTakendown, lookup, count, blobUrl } = ctx; - const { viewer } = params; + const { ok, params, isTakendown, lookup, count, blobUrl, viewer: authViewer } = ctx; + const viewer = authViewer?.did ?? params.viewer; // Resolve handle to DID if needed let actor = params.actor; @@ -30,6 +30,9 @@ export default defineQuery("social.grain.unspecced.getActorProfile", async (ctx) followsCounts, viewerFollowingRows, followedByRows, + viewerBlockingRows, + blockedByRows, + viewerMutedRows, ] = await Promise.all([ lookup("social.grain.actor.profile", "did", [actor]), lookup("com.germnetwork.declaration", "did", [actor]), @@ -64,11 +67,26 @@ export default defineQuery("social.grain.unspecced.getActorProfile", async (ctx) `SELECT uri FROM "social.grain.graph.follow" WHERE did = $1 AND subject = $2 LIMIT 1`, [actor, viewer], ) as Promise<{ uri: string }[]>, + ctx.db.query( + `SELECT uri FROM "social.grain.graph.block" WHERE did = $1 AND subject = $2 LIMIT 1`, + [viewer, actor], + ) as Promise<{ uri: string }[]>, + ctx.db.query( + `SELECT uri FROM "social.grain.graph.block" WHERE did = $1 AND subject = $2 LIMIT 1`, + [actor, viewer], + ) as Promise<{ uri: string }[]>, + ctx.db.query( + `SELECT 1 as v FROM _mutes WHERE did = $1 AND subject = $2 LIMIT 1`, + [viewer, actor], + ) as Promise<{ v: number }[]>, ] - : [Promise.resolve([]), Promise.resolve([])]), + : [Promise.resolve([]), Promise.resolve([]), Promise.resolve([]), Promise.resolve([]), Promise.resolve([])]), ]); const viewerFollowing = (viewerFollowingRows as { uri: string }[])[0]?.uri ?? null; const followedBy = (followedByRows as { uri: string }[])[0]?.uri ?? null; + const viewerBlocking = (viewerBlockingRows as { uri: string }[])?.[0]?.uri ?? null; + const blockedBy = !!(blockedByRows as { uri: string }[])?.[0]?.uri; + const viewerMuted = !!(viewerMutedRows as { v: number }[])?.[0]; const profile = profiles.get(actor); const germDecl = germDeclarations.get(actor); @@ -104,11 +122,15 @@ export default defineQuery("social.grain.unspecced.getActorProfile", async (ctx) followsCount, createdAt: profile.value.createdAt, messageMe, - ...(viewer && viewer !== actor && (viewerFollowing || followedBy) + ...(viewer && viewer !== actor && (viewerFollowing || followedBy || viewerBlocking || blockedBy || viewerMuted) ? { viewer: { - ...(viewerFollowing ? { following: viewerFollowing } : {}), - ...(followedBy ? { followedBy: followedBy } : {}), + // Hide follow relationships when either party blocks the other + ...(!viewerBlocking && !blockedBy && viewerFollowing ? { following: viewerFollowing } : {}), + ...(!viewerBlocking && !blockedBy && followedBy ? { followedBy: followedBy } : {}), + ...(viewerBlocking ? { blocking: viewerBlocking } : {}), + ...(blockedBy ? { blockedBy: true } : {}), + ...(viewerMuted ? { muted: true } : {}), }, } : {}), diff --git a/server/xrpc/getBlocks.ts b/server/xrpc/getBlocks.ts new file mode 100644 index 0000000..a8ba443 --- /dev/null +++ b/server/xrpc/getBlocks.ts @@ -0,0 +1,55 @@ +import { defineQuery, InvalidRequestError, type GrainActorProfile } from "$hatk"; + +export default defineQuery("social.grain.unspecced.getBlocks", async (ctx) => { + const { ok, db, lookup, blobUrl, packCursor, unpackCursor, viewer } = ctx; + if (!viewer) throw new InvalidRequestError("Authentication required"); + + const { limit = 50, cursor } = ctx.params; + const offset = cursor ? Number(unpackCursor(cursor)?.primary ?? 0) : 0; + + const rows = (await db.query( + `SELECT uri, subject, created_at, cid FROM "social.grain.graph.block" + WHERE did = $1 + ORDER BY created_at DESC + LIMIT $2 OFFSET $3`, + [viewer.did, Number(limit) + 1, offset], + )) as { uri: string; subject: string; created_at: string; cid: string }[]; + + const hasMore = rows.length > Number(limit); + const page = hasMore ? rows.slice(0, Number(limit)) : rows; + const dids = page.map((r) => r.subject); + + const profiles = await lookup( + "social.grain.actor.profile", + "did", + dids, + ); + + const handleRows = + dids.length > 0 + ? ((await db.query( + `SELECT did, handle FROM _repos WHERE did IN (${dids.map((_, i) => `$${i + 1}`).join(",")})`, + dids, + )) as { did: string; handle: string }[]) + : []; + const handleMap = new Map(handleRows.map((r) => [r.did, r.handle])); + + const items = page.map((row) => { + const p = profiles.get(row.subject); + return { + did: row.subject, + handle: p?.handle ?? handleMap.get(row.subject) ?? row.subject, + displayName: p?.value.displayName, + avatar: p ? (blobUrl(row.subject, p.value.avatar, "avatar") ?? undefined) : undefined, + blockUri: row.uri, + }; + }); + + const nextOffset = offset + Number(limit); + const lastRow = page[page.length - 1]; + + return ok({ + items, + ...(hasMore && lastRow ? { cursor: packCursor(nextOffset, lastRow.cid) } : {}), + }); +}); diff --git a/server/xrpc/getGalleryThread.ts b/server/xrpc/getGalleryThread.ts index d959125..e653b32 100644 --- a/server/xrpc/getGalleryThread.ts +++ b/server/xrpc/getGalleryThread.ts @@ -2,30 +2,46 @@ import { defineQuery } from "$hatk"; import type { GrainActorProfile, Photo } from "$hatk"; import { views } from "$hatk"; import { NOT_ORPHANED } from "../hydrate/comments.ts"; +import { blockFilter } from "../filters/blockMute.ts"; export default defineQuery("social.grain.unspecced.getGalleryThread", async (ctx) => { - const { ok, params, db, lookup, blobUrl, getRecords } = ctx; + const { ok, params, db, lookup, blobUrl, getRecords, viewer } = ctx; const { gallery, limit = 20, cursor } = params; + const viewerDid = viewer?.did; + + // Build block filter — blocked comments are removed entirely + const countParams: any[] = [gallery]; + let countBmParam = ""; + if (viewerDid) { + countParams.push(viewerDid); + countBmParam = `AND ${blockFilter("c.did", `$${countParams.length}`)}`; + } + // Count total comments for this gallery, excluding orphaned replies const countRows = (await db.query( `SELECT count(*) as cnt FROM "social.grain.comment" c - WHERE c.subject = $1 AND ${NOT_ORPHANED}`, - [gallery], + WHERE c.subject = $1 AND ${NOT_ORPHANED} ${countBmParam}`, + countParams, )) as { cnt: number }[]; const totalCount = countRows[0]?.cnt ?? 0; // Fetch comments with cursor-based pagination (oldest first), excluding orphaned replies + const queryParams: any[] = [gallery]; let query = `SELECT c.uri, c.did, c.cid, c.text, c.facets, c.focus, c.reply_to, c.created_at FROM "social.grain.comment" c WHERE c.subject = $1 AND ${NOT_ORPHANED}`; - const queryParams: any[] = [gallery]; if (cursor) { query += ` AND c.created_at > $2`; queryParams.push(cursor); } + if (viewerDid) { + queryParams.push(viewerDid); + query += ` AND ${blockFilter("c.did", `$${queryParams.length}`)}`; + } + query += ` ORDER BY c.created_at ASC LIMIT $${queryParams.length + 1}`; queryParams.push(limit + 1); // fetch one extra for cursor @@ -48,6 +64,17 @@ export default defineQuery("social.grain.unspecced.getGalleryThread", async (ctx const dids = [...new Set(items.map((r) => r.did))]; const profiles = await lookup("social.grain.actor.profile", "did", dids); + // Check which comment authors the viewer has muted + let mutedDids = new Set(); + if (viewerDid && dids.length > 0) { + const ph = dids.map((_, i) => `$${i + 2}`).join(","); + const mutedRows = (await db.query( + `SELECT subject FROM _mutes WHERE did = $1 AND subject IN (${ph})`, + [viewerDid, ...dids], + )) as { subject: string }[]; + mutedDids = new Set(mutedRows.map((r) => r.subject)); + } + // Hydrate focus photos const focusUris = items.map((r) => r.focus).filter(Boolean) as string[]; const focusPhotos = @@ -58,39 +85,42 @@ export default defineQuery("social.grain.unspecced.getGalleryThread", async (ctx const parsedFacets = row.facets ? JSON.parse(row.facets) : undefined; const focusPhoto = row.focus ? focusPhotos.get(row.focus) : null; - return views.commentView({ - uri: row.uri, - cid: row.cid, - text: row.text, - facets: parsedFacets, - replyTo: row.reply_to ?? undefined, - createdAt: row.created_at, - author: author - ? views.grainActorDefsProfileView({ - cid: author.cid, - did: author.did, - handle: author.handle ?? author.did, - displayName: author.value.displayName, - avatar: blobUrl(author.did, author.value.avatar) ?? undefined, - }) - : views.grainActorDefsProfileView({ - cid: row.cid, - did: row.did, - handle: row.did, - }), - ...(focusPhoto - ? { - focus: views.photoView({ - uri: focusPhoto.uri, - cid: focusPhoto.cid, - thumb: blobUrl(focusPhoto.did, focusPhoto.value.photo, "feed_thumbnail") ?? "", - fullsize: blobUrl(focusPhoto.did, focusPhoto.value.photo, "feed_fullsize") ?? "", - alt: focusPhoto.value.alt, - aspectRatio: focusPhoto.value.aspectRatio ?? { width: 4, height: 3 }, + return { + ...views.commentView({ + uri: row.uri, + cid: row.cid, + text: row.text, + facets: parsedFacets, + replyTo: row.reply_to ?? undefined, + createdAt: row.created_at, + author: author + ? views.grainActorDefsProfileView({ + cid: author.cid, + did: author.did, + handle: author.handle ?? author.did, + displayName: author.value.displayName, + avatar: blobUrl(author.did, author.value.avatar) ?? undefined, + }) + : views.grainActorDefsProfileView({ + cid: row.cid, + did: row.did, + handle: row.did, }), - } - : {}), - }); + ...(focusPhoto + ? { + focus: views.photoView({ + uri: focusPhoto.uri, + cid: focusPhoto.cid, + thumb: blobUrl(focusPhoto.did, focusPhoto.value.photo, "feed_thumbnail") ?? "", + fullsize: blobUrl(focusPhoto.did, focusPhoto.value.photo, "feed_fullsize") ?? "", + alt: focusPhoto.value.alt, + aspectRatio: focusPhoto.value.aspectRatio ?? { width: 4, height: 3 }, + }), + } + : {}), + }), + ...(mutedDids.has(row.did) ? { muted: true } : {}), + }; }); return ok({ comments, ...(nextCursor ? { cursor: nextCursor } : {}), totalCount }); diff --git a/server/xrpc/getMutes.ts b/server/xrpc/getMutes.ts new file mode 100644 index 0000000..3903e1d --- /dev/null +++ b/server/xrpc/getMutes.ts @@ -0,0 +1,54 @@ +import { defineQuery, InvalidRequestError, type GrainActorProfile } from "$hatk"; + +export default defineQuery("social.grain.unspecced.getMutes", async (ctx) => { + const { ok, db, lookup, blobUrl, packCursor, unpackCursor, viewer } = ctx; + if (!viewer) throw new InvalidRequestError("Authentication required"); + + const { limit = 50, cursor } = ctx.params; + const offset = cursor ? Number(unpackCursor(cursor)?.primary ?? 0) : 0; + + const rows = (await db.query( + `SELECT subject, created_at FROM _mutes + WHERE did = $1 + ORDER BY created_at DESC + LIMIT $2 OFFSET $3`, + [viewer.did, Number(limit) + 1, offset], + )) as { subject: string; created_at: string }[]; + + const hasMore = rows.length > Number(limit); + const page = hasMore ? rows.slice(0, Number(limit)) : rows; + const dids = page.map((r) => r.subject); + + const profiles = await lookup( + "social.grain.actor.profile", + "did", + dids, + ); + + const handleRows = + dids.length > 0 + ? ((await db.query( + `SELECT did, handle FROM _repos WHERE did IN (${dids.map((_, i) => `$${i + 1}`).join(",")})`, + dids, + )) as { did: string; handle: string }[]) + : []; + const handleMap = new Map(handleRows.map((r) => [r.did, r.handle])); + + const items = page.map((row) => { + const p = profiles.get(row.subject); + return { + did: row.subject, + handle: p?.handle ?? handleMap.get(row.subject) ?? row.subject, + displayName: p?.value.displayName, + avatar: p ? (blobUrl(row.subject, p.value.avatar, "avatar") ?? undefined) : undefined, + }; + }); + + const nextOffset = offset + Number(limit); + const lastRow = page[page.length - 1]; + + return ok({ + items, + ...(hasMore && lastRow ? { cursor: packCursor(nextOffset, lastRow.created_at) } : {}), + }); +}); diff --git a/server/xrpc/getNotifications.ts b/server/xrpc/getNotifications.ts index 393ce4c..7db3303 100644 --- a/server/xrpc/getNotifications.ts +++ b/server/xrpc/getNotifications.ts @@ -2,6 +2,14 @@ import { defineQuery, InvalidRequestError } from "$hatk"; import type { GrainActorProfile, Photo, Gallery } from "$hatk"; import { views } from "$hatk"; +function blockMuteNotifFilter(didCol = "did"): string { + return ` + AND ${didCol} NOT IN (SELECT subject FROM "social.grain.graph.block" WHERE did = $1) + AND ${didCol} NOT IN (SELECT did FROM "social.grain.graph.block" WHERE subject = $1) + AND ${didCol} NOT IN (SELECT subject FROM _mutes WHERE did = $1) + `; +} + /** Builds the UNION ALL query for notification sources. Pass select columns or `count(*) as cnt`. */ function notificationUnion(select: "count" | "full", extraFilter: string): string { const favCols = @@ -37,24 +45,24 @@ function notificationUnion(select: "count" | "full", extraFilter: string): strin return ` SELECT ${favCols} FROM "social.grain.favorite" WHERE subject IN (SELECT uri FROM "social.grain.gallery" WHERE did = $1) - AND did != $1 ${extraFilter} + AND did != $1 ${blockMuteNotifFilter()} ${extraFilter} UNION ALL SELECT ${commentCols} FROM "social.grain.comment" WHERE subject IN (SELECT uri FROM "social.grain.gallery" WHERE did = $1) - AND did != $1 AND reply_to IS NULL ${extraFilter} + AND did != $1 AND reply_to IS NULL ${blockMuteNotifFilter()} ${extraFilter} UNION ALL SELECT ${replyCols} FROM "social.grain.comment" c WHERE c.reply_to IN (SELECT uri FROM "social.grain.comment" WHERE did = $1) - AND c.did != $1 ${extraFilter} + AND c.did != $1 ${blockMuteNotifFilter("c.did")} ${extraFilter} UNION ALL SELECT ${followCols} FROM "social.grain.graph.follow" - WHERE subject = $1 AND did != $1 ${extraFilter} + WHERE subject = $1 AND did != $1 ${blockMuteNotifFilter()} ${extraFilter} UNION ALL @@ -62,12 +70,12 @@ function notificationUnion(select: "count" | "full", extraFilter: string): strin WHERE facets LIKE '%' || $1 || '%' AND did != $1 AND subject NOT IN (SELECT uri FROM "social.grain.gallery" WHERE did = $1) AND reply_to NOT IN (SELECT uri FROM "social.grain.comment" WHERE did = $1) - ${extraFilter} + ${blockMuteNotifFilter()} ${extraFilter} UNION ALL SELECT ${mentionGalleryCols} FROM "social.grain.gallery" - WHERE facets LIKE '%' || $1 || '%' AND did != $1 ${extraFilter} + WHERE facets LIKE '%' || $1 || '%' AND did != $1 ${blockMuteNotifFilter()} ${extraFilter} `; } diff --git a/server/xrpc/getStoryAuthors.ts b/server/xrpc/getStoryAuthors.ts index 944fbd8..1e853b5 100644 --- a/server/xrpc/getStoryAuthors.ts +++ b/server/xrpc/getStoryAuthors.ts @@ -2,13 +2,18 @@ import { defineQuery } from "$hatk"; import { views } from "$hatk"; import type { GrainActorProfile } from "$hatk"; import { hideLabelsFilter } from "../labels/_hidden.ts"; +import { blockMuteFilter } from "../filters/blockMute.ts"; const TWENTY_FOUR_HOURS = 24 * 60 * 60 * 1000; export default defineQuery("social.grain.unspecced.getStoryAuthors", async (ctx) => { const { db, ok } = ctx; + const viewer = ctx.viewer?.did; const cutoff = new Date(Date.now() - TWENTY_FOUR_HOURS).toISOString(); + const bmFilter = viewer ? `AND ${blockMuteFilter("s.did", "$2")}` : ""; + const params = viewer ? [cutoff, viewer] : [cutoff]; + // Aggregate in SQL, excluding stories with hide-severity labels const rows = (await db.query( `SELECT s.did, COUNT(*) AS story_count, MAX(s.created_at) AS latest_at @@ -17,9 +22,10 @@ export default defineQuery("social.grain.unspecced.getStoryAuthors", async (ctx) WHERE s.created_at > $1 AND (r.status IS NULL OR r.status != 'takendown') AND ${hideLabelsFilter("s.uri")} + ${bmFilter} GROUP BY s.did ORDER BY latest_at DESC`, - [cutoff], + params, )) as { did: string; story_count: number; latest_at: string }[]; const dids = rows.map((r) => r.did); diff --git a/server/xrpc/getSuggestedFollows.ts b/server/xrpc/getSuggestedFollows.ts index c54254a..0568a86 100644 --- a/server/xrpc/getSuggestedFollows.ts +++ b/server/xrpc/getSuggestedFollows.ts @@ -1,4 +1,5 @@ import { defineQuery, type GrainActorProfile } from "$hatk"; +import { blockMuteFilter } from "../filters/blockMute.ts"; export default defineQuery("social.grain.unspecced.getSuggestedFollows", async (ctx) => { const { ok, params, lookup, blobUrl, db } = ctx; @@ -17,6 +18,7 @@ export default defineQuery("social.grain.unspecced.getSuggestedFollows", async ( AND bf.subject NOT IN ( SELECT gf.subject FROM "social.grain.graph.follow" gf WHERE gf.did = $1 ) + AND ${blockMuteFilter("bf.subject", "$1")} LIMIT $2`, [actor, Number(limit)], )) as { subject: string }[]; @@ -39,6 +41,7 @@ export default defineQuery("social.grain.unspecced.getSuggestedFollows", async ( AND gp.did NOT IN ( SELECT gf.subject FROM "social.grain.graph.follow" gf WHERE gf.did = $1 ) + AND ${blockMuteFilter("gp.did", "$1")} ORDER BY follower_count DESC LIMIT $${exclude.length + 1}`, [...exclude, remaining], diff --git a/server/xrpc/muteActor.ts b/server/xrpc/muteActor.ts new file mode 100644 index 0000000..0155982 --- /dev/null +++ b/server/xrpc/muteActor.ts @@ -0,0 +1,18 @@ +import { defineProcedure } from "$hatk"; + +export default defineProcedure("social.grain.graph.muteActor", async (ctx) => { + const { ok, db, viewer } = ctx; + if (!viewer) throw new Error("Authentication required"); + + const { actor } = ctx.input; + if (actor === viewer.did) throw new Error("Cannot mute yourself"); + + await db.run( + `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({}); +}); diff --git a/server/xrpc/unmuteActor.ts b/server/xrpc/unmuteActor.ts new file mode 100644 index 0000000..78592e6 --- /dev/null +++ b/server/xrpc/unmuteActor.ts @@ -0,0 +1,13 @@ +import { defineProcedure } from "$hatk"; + +export default defineProcedure("social.grain.graph.unmuteActor", async (ctx) => { + const { ok, db, viewer } = ctx; + if (!viewer) throw new Error("Authentication required"); + + await db.run( + `DELETE FROM _mutes WHERE did = $1 AND subject = $2`, + [viewer.did, ctx.input.actor], + ); + + return ok({}); +});