Accounts mute and unmute, and list whom they block and mute
ClosedThis adds muteActor and unmuteActor, which write the existing mute table through viewer::writer, and getMutes and getBlocks, which are keyset-paged reads of the viewer's own rows. I found nothing that must change.
- Schema: the
mutetable exists with primary key(did, subject).INSERT OR IGNOREandDELETEtherefore give the idempotent behaviour the body describes. - Cursors: the cursor and
limithandling reusesParams::after,Params::limitandpage. The row-value comparisons match theORDER BY, so pages should neither skip nor repeat rows. - Validation: the actor is checked as a DID, self-mute is refused, and the routes follow the pattern of the other procedures.
- Tests: they cover paging, taken-down accounts staying listed, bad input and auth failures.
I did not run the checks or read their results. Please confirm the harness and rust checks are green before merging, since the coverage gate and the answer-schema check are strict.
Carried forward: the change this pull makes at 3c2008a56e19d9c70dfb0dd50c39d5a591de49ad is the one reviewed at af38c5ea523d0898e6875eaa3ab57104d5ca3cf4, so that review stands. What is new comes from its base.
Landed on feature/rust at fc5f686 through the batch pull Seven reviewed pulls land on feature/rust together, checked once, checked there as a whole. This pull's head is in feature/rust's history.
What changes
Muting.
social.grain.graph.muteActorandunmuteActorkeep a mute in the index'smutetable (crates/grain-serve/src/graph.rs,index/graph.rs). The apps call them with a service token, the web with its cookie from a page of the site, as every procedure is held (viewer::writer). Muting one already muted, or unmuting one not muted, changes nothing; an account cannot mute itself, andactoris a DID, as the lexicon says. What a muted account says the reads already leave out (read::moderation::unmuted).Lists.
social.grain.graph.getMutesandgetBlockslist the viewer's own, the latest first, paged by the cursor every list here takes (<createdAt>::<uri or did>),limit1 to 100. Each item is who the account is (did,handle,displayName,avatar), and a block's itsblockUri. Every one is listed, taken down or not, so that each can be taken back.This is the first of the pulls that close what the clients call and grain-serve did not serve. Each is its own, on
feature/rust, to land alone:gaps/graph(this),gaps/stories(Stories are archived, and what each account watched follows it),gaps/sitemap(The sitemap names the profiles and galleries a search engine may show),gaps/deletes(Galleries and accounts are deleted, and a deleted account only once its host agrees),gaps/cross-post(The web's Bluesky cross-post goes through grain-serve, and a test says so),gaps/web(grain-serve hands the web's pages to its app, at --web).What the clients call, and what grain-serve serves
W is the web as
web/readsandweb/writes-clientleave it; I and A are the iOS and Android apps, which still call the old names (mapped throughlexicons/README.md). "Served" is feature/rust before these pulls.social.grain.actor.getProfile(unspecced.getActorProfile)social.grain.actor.getPreferences(dev.hatk.getPreferences)social.grain.actor.putPreferences(dev.hatk.putPreference)social.grain.actor.getFavorites(getActorFavorites)social.grain.actor.getSuggestedFollowssocial.grain.actor.searchActors(searchProfiles)social.grain.actor.searchActorsTypeaheadsocial.grain.feed.describeFeedssocial.grain.feed.getFeed(dev.hatk.getFeed)social.grain.gallery.getGallery,getFavorites,getCameras,getLocations,searchGalleriessocial.grain.comment.getThread(getCommentThread)social.grain.graph.getFollowers,getFollowing,getKnownFollowerssocial.grain.graph.getBlocksgaps/graphsocial.grain.graph.getMutesgaps/graphsocial.grain.graph.muteActor,unmuteActorgaps/graphsocial.grain.notification.listNotifications,getUnreadCount(getNotifications)social.grain.notification.updateSeen(putPreferenceoflastSeenNotifications)social.grain.notification.registerPush,unregisterPush(dev.hatk.push.*)social.grain.story.getAuthors,getStories,getStorysocial.grain.story.getArchive(getStoryArchive)gaps/storiessocial.grain.story.markViewed(markStoriesViewed)gaps/storiessocial.grain.server.getSitemapgaps/sitemapsocial.grain.gallery.deleteGallerygaps/deletessocial.grain.server.deleteAccountgaps/deletessocial.grain.repo.createRecord,putRecord,deleteRecord,applyWrites,uploadBlobapp.bsky.feed.post(the Bluesky cross-post)dev.hatk.createRecord/putRecord)gaps/cross-post, a test onlydev.hatk.createRecord,putRecord,deleteRecord,applyWrites,uploadBlobcom.atproto.repo.*to their own PDS, thensocial.grain.sync.indexRecords(served)dev.hatk.getRecordcom.atproto.repo.getRecordat the account's PDScom.atproto.moderation.createReport(dev.hatk.createReport)dev.hatk.describeLabelscom.atproto.label.queryLabelsis served; the label definitions are onlysocial.grain.admin.listLabelDefinitions, admins only. Open: see belowcom.atproto.repo.getRecord,com.atproto.sync.getBlobcom.atproto.identity.resolveHandleprivateGallery.*,getSpaceSupport,/space-blob)spaces/galleries)group.*,group.pool.*)spaces/groups,spaces/pools)/oauth/session,/oauth/login,/oauth/switch,/oauth/logout,/oauth-client-metadata.json/og/profile/...gaps/web(GRAIN_WEB)/oauth/par,/oauth/authorize,/oauth/token(the old server as the apps' authorization server)Open, not built here:
lexicons/README.md's plan is for the apps to log in at their own PDS and call grain-serve with service tokens. That is the apps' move, and nothing in grain-serve stands in for it.dev.hatk.describeLabelsgave anyone grain's label definitions. Bluesky's way is the labeler'sapp.bsky.labeler.servicerecord, read byapp.bsky.labeler.getServices; grain publishes none yet. The web ships them in$lib/labels, and the apps can too until grain does.Why
The web (
web/writes-client) and both apps call these, and grain-serve answered 501.knotless has no mutes. Its
dev.knotless.listBlocks(knotless-serve/src/web/account_settings.rs) reads blocks from the account's own repo at its PDS, so a block lists as soon as it is made; grain's are in the index already, which a web write reaches before it answers and an app's reaches throughindexRecords, sogetBlocksreads the index and keeps grain's lexicon, whose shape the web was moved onto.Differences from the old server
handle.invalid, as every view here names one, where the old server gave its DID as its handle.actorthat is not a DID is refused (400), and muting oneself isInvalidRequest, where the old server threw a 500.limitout of 1 to 100 is refused, not clamped.How it was checked
crates/grain-serve/tests/graph.rs, against the reference PDS for the service tokens: muting three accounts and one again, the list paged and its items, unmuting twice, a self-mute, a handle and no actor refused, no token and the wrong method's token refused; blocks listed newest first with their records, another's block left out, one taken down kept, paged, and bad limits and cursors refused.index/graph.rshas a unit test of the table, and the plan check (the_reads_read_by_index) covers both lists' queries. The rest is the runner's.Shortcomings
viewer::writer, so it needs a page of the site, as the other procedures do; not tried in a browser.gaps/deleteslands, whose purge takes them.🤖 Generated with Claude Code