From 81eeaa564a7948552c4d5f9a1117e5da8927ea3e Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Sun, 8 Nov 2026 18:20:46 -0500 Subject: [PATCH 1/6] refactor(social): share the uncached Constellation client PackageLikesUtils built it inline; collections need the same client. --- server/utils/atproto/utils/likes.ts | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/server/utils/atproto/utils/likes.ts b/server/utils/atproto/utils/likes.ts index 54101e2b8e..be63cc1411 100644 --- a/server/utils/atproto/utils/likes.ts +++ b/server/utils/atproto/utils/likes.ts @@ -40,6 +40,20 @@ export function aggregateBacklinksByDay( .map(([day, likes]) => ({ day, likes })) } +/** For callers that cache Constellation results themselves. */ +export function createUncachedConstellation() { + return new Constellation( + async ( + url: string, + options: Parameters[1] = {}, + _ttl?: number, + ): Promise> => { + const data = (await $fetch(url, options)) as T + return { data, isStale: false, cachedAt: null } + }, + ) +} + /** The subset of Constellation that PackageLikesUtils actually needs. */ export type ConstellationLike = Pick @@ -51,19 +65,8 @@ export class PackageLikesUtils { private readonly cache: CacheAdapter constructor(deps?: { constellation?: ConstellationLike; cache?: CacheAdapter }) { - this.constellation = - deps?.constellation ?? - new Constellation( - // Passes in a fetch wrapped as CachedFetch because we're already doing some heavy caching here - async ( - url: string, - options: Parameters[1] = {}, - _ttl?: number, - ): Promise> => { - const data = (await $fetch(url, options)) as T - return { data, isStale: false, cachedAt: null } - }, - ) + // Uncached because we're already doing some heavy caching here + this.constellation = deps?.constellation ?? createUncachedConstellation() this.cache = deps?.cache ?? getCacheAdapter('generic') } From 5beabf359e800b813c1f39ee2b403f8ba0d9fcbf Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Sun, 8 Nov 2026 18:21:01 -0500 Subject: [PATCH 2/6] feat(social): read likes and follows of collections Like and follow counts, and whether the viewer has liked or followed, come from Constellation, counted once per account. Profiles can list the collections an account follows. --- .../collections/[identifier]/[rkey].get.ts | 10 +- .../[identifier]/followed-collections.get.ts | 16 ++ server/utils/atproto/utils/collections.ts | 176 +++++++++++++++++- shared/types/social.ts | 10 + test/unit/server/utils/collections.spec.ts | 139 +++++++++++++- 5 files changed, 340 insertions(+), 11 deletions(-) create mode 100644 server/api/social/profile/[identifier]/followed-collections.get.ts diff --git a/server/api/social/collections/[identifier]/[rkey].get.ts b/server/api/social/collections/[identifier]/[rkey].get.ts index 213b329309..99c1f1e5c7 100644 --- a/server/api/social/collections/[identifier]/[rkey].get.ts +++ b/server/api/social/collections/[identifier]/[rkey].get.ts @@ -1,14 +1,18 @@ /** * GET /api/social/collections/:identifier/:rkey * - * A collection with its items resolved. + * A collection with its items resolved, plus like and included-in counts. */ -export default defineEventHandler(async event => { +export default eventHandlerWithOAuthSession(async (event, oAuthSession) => { const { identifier, rkey } = getCollectionRouteParams(event) let collection try { - collection = await new CollectionsUtils().getCollection(identifier, rkey) + collection = await new CollectionsUtils().getCollection( + identifier, + rkey, + oAuthSession?.did.toString(), + ) } catch (error: unknown) { handleApiError(error, { statusCode: 502, message: 'Failed to get collection' }) } diff --git a/server/api/social/profile/[identifier]/followed-collections.get.ts b/server/api/social/profile/[identifier]/followed-collections.get.ts new file mode 100644 index 0000000000..10a43617ae --- /dev/null +++ b/server/api/social/profile/[identifier]/followed-collections.get.ts @@ -0,0 +1,16 @@ +/** + * GET /api/social/profile/:identifier/followed-collections + * + * Collections the account follows, newest follow first, from its PDS. A page can come back + * shorter than `limit` when followed collections were deleted or hidden. + */ +export default defineEventHandler(async event => { + const identifier = getRouterParam(event, 'identifier')! + const { cursor, limit } = getPaginationQuery(event) + + try { + return await new CollectionsUtils().listFollowedCollections(identifier, limit, cursor) + } catch (error: unknown) { + handleApiError(error, { statusCode: 502, message: 'Failed to list followed collections' }) + } +}) diff --git a/server/utils/atproto/utils/collections.ts b/server/utils/atproto/utils/collections.ts index 2b67bdc316..178f91b95f 100644 --- a/server/utils/atproto/utils/collections.ts +++ b/server/utils/atproto/utils/collections.ts @@ -8,16 +8,29 @@ import type { CollectionView, MiniDoc, OwnCollection, + ReactionState, } from '#shared/types/social' import { parseSubjectRef } from '#shared/utils/collections' import { mapWithConcurrency } from '#shared/utils/async' import { CACHE_MAX_AGE_ONE_MINUTE, COLLECTION_SUBJECT_REF } from '#shared/utils/constants' +import type { Backlink, Constellation } from '#shared/utils/constellation' const HEADERS = { 'User-Agent': 'npmx' } const COLLECTION_NSID = dev.npmx.collection.$nsid +// Records that point at a collection and are counted once per account. +const REACTIONS = { + like: { nsid: dev.npmx.feed.like.$nsid, path: 'subjectRef' }, + follow: { nsid: dev.npmx.graph.follow.$nsid, path: 'subject' }, +} as const +export type Reaction = keyof typeof REACTIONS + const CACHE_PREFIX = 'atproto-collections:' const CACHE_RECORD_KEY = (did: string, rkey: string) => `${CACHE_PREFIX}${did}:${rkey}:record` +const CACHE_REACTION_TOTAL_KEY = (reaction: Reaction, uri: string) => + `${CACHE_PREFIX}${uri}:${reaction}:total` +const CACHE_VIEWER_REACTION_KEY = (reaction: Reaction, uri: string, did: string) => + `${CACHE_PREFIX}${uri}:${reaction}:${did}` const CACHE_MAX_AGE = CACHE_MAX_AGE_ONE_MINUTE * 5 // The owner's PDS is whatever their DID doc says, so it may be slow or hostile. @@ -27,6 +40,7 @@ const RESOLVE_CONCURRENCY = 16 export type StoredRecord = { uri: string; cid: string; value: dev.npmx.collection.Main } export type CollectionsDeps = { + constellation: Pick cache: CacheAdapter resolveIdentity: (identifier: string) => Promise /** Raw record from the owner's PDS, or null when it doesn't exist. Not yet validated. */ @@ -39,6 +53,12 @@ export type CollectionsDeps = { limit: number, cursor?: string, ) => Promise<{ cursor?: string; records: StoredRecord[] }> + /** The account's follow records, with subjects not yet validated. */ + listFollows: ( + miniDoc: MiniDoc, + limit: number, + cursor?: string, + ) => Promise<{ cursor?: string; records: { rkey: string; subject: string }[] }> packageExists: (packageName: string) => Promise } @@ -75,6 +95,8 @@ function toSummary(miniDoc: MiniDoc, rkey: string, stored: StoredRecord): Collec function defaultDeps(): CollectionsDeps { const identity = new IdentityUtils() return { + // CollectionsUtils caches what it needs + constellation: createUncachedConstellation(), cache: getCacheAdapter('generic'), resolveIdentity: identifier => identity.getMiniDoc(identifier) as Promise, fetchRecord: async (miniDoc, rkey) => { @@ -114,6 +136,22 @@ function defaultDeps(): CollectionsDeps { }), } }, + listFollows: async (miniDoc, limit, cursor) => { + const client = new Client(miniDoc.pds, { headers: HEADERS }) + const result = await client.list(dev.npmx.graph.follow, { + repo: miniDoc.did as `did:${string}:${string}`, + limit, + cursor, + signal: AbortSignal.timeout(PDS_TIMEOUT_MS), + }) + return { + cursor: result.cursor, + records: result.records.flatMap(record => { + const rkey = record.uri.split('/').pop() + return record.valid && rkey ? [{ rkey, subject: record.value.subject }] : [] + }), + } + }, packageExists: name => fetchNpmPackageExists(name), } } @@ -165,17 +203,99 @@ export class CollectionsUtils { await this.setCached(CACHE_RECORD_KEY(did, rkey), record) } - async getCollection(identifier: string, rkey: string): Promise { + async getCollection( + identifier: string, + rkey: string, + viewerDid?: string, + ): Promise { const miniDoc = await this.deps.resolveIdentity(identifier) const stored = await this.getStoredRecord(miniDoc, rkey) if (!stored) return null - const items = await mapWithConcurrency( - stored.value.items, - item => this.resolveItem(item.subjectRef, item.note), - RESOLVE_CONCURRENCY, - ) - return { ...toSummary(miniDoc, rkey, stored), items } + const [items, likes, follows] = await Promise.all([ + mapWithConcurrency( + stored.value.items, + item => this.resolveItem(item.subjectRef, item.note), + RESOLVE_CONCURRENCY, + ), + this.getReaction('like', stored.uri, viewerDid), + this.getReaction('follow', stored.uri, viewerDid), + ]) + return { ...toSummary(miniDoc, rkey, stored), items, likes, follows } + } + + async getReaction(reaction: Reaction, uri: string, viewerDid?: string): Promise { + const [total, viewerRecord] = await Promise.all([ + this.getReactionTotal(reaction, uri), + viewerDid ? this.getViewerReaction(reaction, uri, viewerDid) : null, + ]) + return { total, viewerHas: viewerRecord !== null } + } + + getReactionTotal(reaction: Reaction, uri: string): Promise { + const { nsid, path } = REACTIONS[reaction] + return this.cached(CACHE_REACTION_TOTAL_KEY(reaction, uri), async () => { + const { data } = await this.deps.constellation.getLinksDistinctDids( + uri, + nsid, + `.${path}`, + 1, + undefined, + 0, + ) + return data.total + }) + } + + /** The viewer's like or follow record for a collection, or null. */ + getViewerReaction(reaction: Reaction, uri: string, viewerDid: string): Promise { + const { nsid, path } = REACTIONS[reaction] + return this.cached(CACHE_VIEWER_REACTION_KEY(reaction, uri, viewerDid), async () => { + const { data } = await this.deps.constellation.getBackLinks( + uri, + nsid, + path, + 1, + undefined, + false, + [[viewerDid]], + 0, + ) + return data.records[0] ?? null + }) + } + + // Constellation takes a few seconds to see a new record, so writes update the cache directly, + // as PackageLikesUtils does for packages. `totalBefore` must be read before the PDS write, or a + // refresh that already saw it would be counted twice. + async recordReaction( + reaction: Reaction, + uri: string, + viewerDid: string, + rkey: string, + totalBefore: number, + ): Promise { + const backlink: Backlink = { did: viewerDid, collection: REACTIONS[reaction].nsid, rkey } + const total = totalBefore + 1 + await Promise.all([ + this.setCached(CACHE_VIEWER_REACTION_KEY(reaction, uri, viewerDid), backlink), + this.setCached(CACHE_REACTION_TOTAL_KEY(reaction, uri), total), + ]) + return { total, viewerHas: true } + } + + async recordUnreaction( + reaction: Reaction, + uri: string, + viewerDid: string, + totalBefore: number, + ): Promise { + const total = Math.max(totalBefore - 1, 0) + await Promise.all([ + this.setCached(CACHE_VIEWER_REACTION_KEY(reaction, uri, viewerDid), null), + this.setCached(CACHE_REACTION_TOTAL_KEY(reaction, uri), total), + ]) + return { total, viewerHas: false } } // Included collections resolve to a summary, not their items, so a view's work is bounded by @@ -233,4 +353,46 @@ export class CollectionsUtils { }), ) } + + /** Collections the account follows, newest follow first. Unresolvable subjects are dropped. */ + async listFollowedCollections( + identifier: string, + limit: number, + cursor?: string, + ): Promise> { + const miniDoc = await this.deps.resolveIdentity(identifier) + const { cursor: nextCursor, records } = await this.deps.listFollows(miniDoc, limit, cursor) + const subjects = new Map() + for (const record of records) { + const subject = parseSubjectRef(record.subject) + if (subject?.kind === 'collection') subjects.set(subject.uri, subject) + } + return { + cursor: nextCursor, + items: await this.hydrate([...subjects.values()], async (owner, subject) => { + const stored = await this.getStoredRecord(owner, subject.rkey) + return stored && toSummary(owner, subject.rkey, stored) + }), + } + } + + /** + * Turns entries into summaries via `build`, dropping any entry whose author no longer resolves + * (deleted, deactivated) or that `build` rejects. + */ + async hydrate( + entries: T[], + build: (miniDoc: MiniDoc, entry: T) => Promise | CollectionSummary, + ): Promise { + const summaries = await Promise.all( + entries.map(async entry => { + try { + return await build(await this.deps.resolveIdentity(entry.did), entry) + } catch { + return null + } + }), + ) + return summaries.filter(summary => summary !== null) + } } diff --git a/shared/types/social.ts b/shared/types/social.ts index e7f8edf3ce..0f26d738df 100644 --- a/shared/types/social.ts +++ b/shared/types/social.ts @@ -69,8 +69,18 @@ export type CollectionItemView = /** One of the viewer's own collections, with what's in it. */ export type OwnCollection = CollectionSummary & { subjectRefs: string[] } +/** Likes or follows of a collection, counted once per account. */ +export type ReactionState = { + total: number + // False when not logged in. For follows, only about follow records: owners follow their own + // collections implicitly. + viewerHas: boolean +} + export type CollectionView = CollectionSummary & { items: CollectionItemView[] + likes: ReactionState + follows: ReactionState } /** diff --git a/test/unit/server/utils/collections.spec.ts b/test/unit/server/utils/collections.spec.ts index 08bd774cda..0a85e2d4d7 100644 --- a/test/unit/server/utils/collections.spec.ts +++ b/test/unit/server/utils/collections.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vite-plus/test' import type { CacheAdapter } from '#server/utils/cache/shared' import type { MiniDoc } from '#shared/types/social' +import type { Backlink } from '#shared/utils/constellation' import { COLLECTION_SUBJECT_REF, PACKAGE_SUBJECT_REF } from '#shared/utils/constants' const { CollectionsUtils } = await import('#server/utils/atproto/utils/collections') @@ -42,19 +43,33 @@ type RecordMap = Record const key = (did: string, rkey: string) => `${did}/${rkey}` function setup(records: RecordMap, overrides: Partial = {}) { + const constellation = { + getBackLinks: vi.fn(async (..._args: unknown[]) => ({ + data: { total: 0, records: [] as Backlink[], cursor: undefined as string | undefined }, + isStale: false, + cachedAt: null, + })), + getLinksDistinctDids: vi.fn(async (..._args: unknown[]) => ({ + data: { total: 0, linking_dids: [] as string[], cursor: undefined as string | undefined }, + isStale: false, + cachedAt: null, + })), + } const fetchRecord = vi.fn(async (doc: MiniDoc, rkey: string) => { const value = records[key(doc.did, rkey)] return value ? { uri: COLLECTION_SUBJECT_REF(doc.did, rkey), cid: `cid-${rkey}`, value } : null }) const deps: Deps = { + constellation: constellation as unknown as Deps['constellation'], cache: memoryCache(), resolveIdentity: async identifier => miniDoc(identifier), fetchRecord, listRecords: async () => ({ records: [] }), + listFollows: async () => ({ records: [] }), packageExists: async name => name !== 'does-not-exist', ...overrides, } - return { utils: new CollectionsUtils(deps), fetchRecord } + return { utils: new CollectionsUtils(deps), constellation, fetchRecord } } describe('CollectionsUtils.getCollection', () => { @@ -147,6 +162,46 @@ describe('CollectionsUtils.getCollection', () => { ]) }) + it('aggregates like and follow stats', async () => { + const { utils, constellation } = setup({ [key(ALICE, RKEY_A)]: record('A', []) }) + constellation.getLinksDistinctDids.mockImplementation(async (...args: unknown[]) => ({ + data: { + total: args[1] === 'dev.npmx.feed.like' ? 7 : 2, + linking_dids: [], + cursor: undefined, + }, + isStale: false, + cachedAt: null, + })) + constellation.getBackLinks.mockImplementation(async (...args: unknown[]) => ({ + data: { + total: args[1] === 'dev.npmx.feed.like' ? 1 : 3, + records: + args[1] === 'dev.npmx.feed.like' + ? [{ did: BOB, collection: 'dev.npmx.feed.like', rkey: '3likerkey' }] + : [], + cursor: undefined, + }, + isStale: false, + cachedAt: null, + })) + + const view = await utils.getCollection(ALICE, RKEY_A, BOB) + + expect(view).toMatchObject({ + likes: { total: 7, viewerHas: true }, + follows: { total: 2, viewerHas: false }, + }) + expect(constellation.getLinksDistinctDids).toHaveBeenCalledWith( + COLLECTION_SUBJECT_REF(ALICE, RKEY_A), + 'dev.npmx.feed.like', + '.subjectRef', + 1, + undefined, + 0, + ) + }) + it('caches records, including misses', async () => { const { utils, fetchRecord } = setup({ [key(ALICE, RKEY_A)]: record('A', []) }) await utils.getCollection(ALICE, RKEY_A) @@ -157,6 +212,88 @@ describe('CollectionsUtils.getCollection', () => { }) }) +describe('CollectionsUtils reactions', () => { + const uri = COLLECTION_SUBJECT_REF(ALICE, RKEY_A) + + it.each([ + ['like', 'dev.npmx.feed.like', '.subjectRef'], + ['follow', 'dev.npmx.graph.follow', '.subject'], + ] as const)( + 'counts a %s immediately, before Constellation sees it', + async (reaction, nsid, path) => { + const { utils, constellation } = setup({}) + constellation.getLinksDistinctDids.mockResolvedValue({ + data: { total: 2, linking_dids: [], cursor: undefined }, + isStale: false, + cachedAt: null, + }) + + const before = await utils.getReactionTotal(reaction, uri) + expect(await utils.recordReaction(reaction, uri, BOB, '3recordkey', before)).toEqual({ + total: 3, + viewerHas: true, + }) + expect(await utils.getReaction(reaction, uri, BOB)).toEqual({ total: 3, viewerHas: true }) + expect(await utils.getViewerReaction(reaction, uri, BOB)).toEqual({ + did: BOB, + collection: nsid, + rkey: '3recordkey', + }) + expect(constellation.getLinksDistinctDids).toHaveBeenCalledWith( + uri, + nsid, + path, + 1, + undefined, + 0, + ) + + expect(await utils.recordUnreaction(reaction, uri, BOB, 3)).toEqual({ + total: 2, + viewerHas: false, + }) + expect(await utils.getViewerReaction(reaction, uri, BOB)).toBeNull() + expect(constellation.getLinksDistinctDids).toHaveBeenCalledTimes(1) + }, + ) + + it('keeps likes and follows apart', async () => { + const { utils } = setup({}) + await utils.recordReaction('like', uri, BOB, '3likerkey', 0) + expect(await utils.getReaction('follow', uri, BOB)).toEqual({ total: 0, viewerHas: false }) + }) + + it('never goes below zero', async () => { + const { utils } = setup({}) + expect(await utils.recordUnreaction('like', uri, BOB, 0)).toEqual({ + total: 0, + viewerHas: false, + }) + }) +}) + +describe('CollectionsUtils.listFollowedCollections', () => { + it('hydrates followed collections and drops unknown, missing and duplicate subjects', async () => { + const { utils } = setup( + { [key(ALICE, RKEY_A)]: record('Parsers', []) }, + { + listFollows: async () => ({ + cursor: 'next', + records: [ + { rkey: 'f1', subject: COLLECTION_SUBJECT_REF(ALICE, RKEY_A) }, + { rkey: 'f2', subject: COLLECTION_SUBJECT_REF(ALICE, RKEY_A) }, + { rkey: 'f3', subject: COLLECTION_SUBJECT_REF(ALICE, RKEY_B) }, + { rkey: 'f4', subject: 'at://did:plc:alice/app.bsky.feed.post/3abc' }, + ], + }), + }, + ) + const page = await utils.listFollowedCollections(BOB, 10) + expect(page.cursor).toBe('next') + expect(page.items.map(c => c.name)).toEqual(['Parsers']) + }) +}) + describe('CollectionsUtils.listUserCollections', () => { it('returns summaries with the record key', async () => { const { utils } = setup( From 3871e70da875ce4ecb426ce6e3b5ae951a80c558 Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Sun, 8 Nov 2026 18:21:16 -0500 Subject: [PATCH 3/6] feat(social): like and follow collections A like reuses dev.npmx.feed.like with the collection's AT-URI as subjectRef; a follow is a dev.npmx.graph.follow record. Both are idempotent, and our own writes update the cached counts, since Constellation lags. Signing in now asks for the follows scope. --- .../[identifier]/[rkey]/follow.delete.ts | 6 ++ .../[identifier]/[rkey]/follow.post.ts | 6 ++ .../[identifier]/[rkey]/like.delete.ts | 6 ++ .../[identifier]/[rkey]/like.post.ts | 6 ++ server/utils/atproto/oauth.ts | 9 ++- .../utils/atproto/utils/collection-writes.ts | 62 +++++++++++++++-- shared/utils/constants.ts | 1 + .../server/utils/collection-writes.spec.ts | 68 +++++++++++++++++++ 8 files changed, 158 insertions(+), 6 deletions(-) create mode 100644 server/api/social/collections/[identifier]/[rkey]/follow.delete.ts create mode 100644 server/api/social/collections/[identifier]/[rkey]/follow.post.ts create mode 100644 server/api/social/collections/[identifier]/[rkey]/like.delete.ts create mode 100644 server/api/social/collections/[identifier]/[rkey]/like.post.ts diff --git a/server/api/social/collections/[identifier]/[rkey]/follow.delete.ts b/server/api/social/collections/[identifier]/[rkey]/follow.delete.ts new file mode 100644 index 0000000000..fee72b4f68 --- /dev/null +++ b/server/api/social/collections/[identifier]/[rkey]/follow.delete.ts @@ -0,0 +1,6 @@ +/** + * DELETE /api/social/collections/:identifier/:rkey/follow + */ +export default eventHandlerWithOAuthSession((event, session) => + setReaction(session, getCollectionRouteParams(event), 'follow', false), +) diff --git a/server/api/social/collections/[identifier]/[rkey]/follow.post.ts b/server/api/social/collections/[identifier]/[rkey]/follow.post.ts new file mode 100644 index 0000000000..6f345dee4b --- /dev/null +++ b/server/api/social/collections/[identifier]/[rkey]/follow.post.ts @@ -0,0 +1,6 @@ +/** + * POST /api/social/collections/:identifier/:rkey/follow + */ +export default eventHandlerWithOAuthSession((event, session) => + setReaction(session, getCollectionRouteParams(event), 'follow', true), +) diff --git a/server/api/social/collections/[identifier]/[rkey]/like.delete.ts b/server/api/social/collections/[identifier]/[rkey]/like.delete.ts new file mode 100644 index 0000000000..8a6d56f372 --- /dev/null +++ b/server/api/social/collections/[identifier]/[rkey]/like.delete.ts @@ -0,0 +1,6 @@ +/** + * DELETE /api/social/collections/:identifier/:rkey/like + */ +export default eventHandlerWithOAuthSession((event, session) => + setReaction(session, getCollectionRouteParams(event), 'like', false), +) diff --git a/server/api/social/collections/[identifier]/[rkey]/like.post.ts b/server/api/social/collections/[identifier]/[rkey]/like.post.ts new file mode 100644 index 0000000000..11650c407e --- /dev/null +++ b/server/api/social/collections/[identifier]/[rkey]/like.post.ts @@ -0,0 +1,6 @@ +/** + * POST /api/social/collections/:identifier/:rkey/like + */ +export default eventHandlerWithOAuthSession((event, session) => + setReaction(session, getCollectionRouteParams(event), 'like', true), +) diff --git a/server/utils/atproto/oauth.ts b/server/utils/atproto/oauth.ts index f97d8a58ac..97f5d0cb61 100644 --- a/server/utils/atproto/oauth.ts +++ b/server/utils/atproto/oauth.ts @@ -9,13 +9,18 @@ import type { EventHandlerRequest, H3Event, SessionManager } from 'h3' import { NodeOAuthClient, AtprotoDohHandleResolver } from '@atproto/oauth-client-node' import { getOAuthLock } from '#server/utils/atproto/lock' import { useOAuthStorage } from '#server/utils/atproto/storage' -import { COLLECTIONS_SCOPE, LIKES_SCOPE, PROFILE_SCOPE } from '#shared/utils/constants' +import { + COLLECTIONS_SCOPE, + FOLLOWS_SCOPE, + LIKES_SCOPE, + PROFILE_SCOPE, +} from '#shared/utils/constants' import type { UserServerSession } from '#shared/types/userSession' // @ts-expect-error virtual file from oauth module import { clientUri } from '#oauth/config' // TODO: If you add writing a new record you will need to add a scope for it -export const scope = `atproto ${LIKES_SCOPE} ${PROFILE_SCOPE} ${COLLECTIONS_SCOPE}` +export const scope = `atproto ${LIKES_SCOPE} ${PROFILE_SCOPE} ${COLLECTIONS_SCOPE} ${FOLLOWS_SCOPE}` /** * Resolves a did to a handle via DoH or via the http website calls diff --git a/server/utils/atproto/utils/collection-writes.ts b/server/utils/atproto/utils/collection-writes.ts index 0737f04453..7d03b1f895 100644 --- a/server/utils/atproto/utils/collection-writes.ts +++ b/server/utils/atproto/utils/collection-writes.ts @@ -1,13 +1,25 @@ import type { OAuthSession } from '@atproto/oauth-client-node' import type { InferOutput } from 'valibot' -import { Client, isDatetimeString, toDatetimeString, type UriString } from '@atproto/lex' +import { + Client, + isDatetimeString, + toDatetimeString, + type AtUriString, + type UriString, +} from '@atproto/lex' import * as dev from '#shared/types/lexicons/dev' import type { CollectionBodySchema } from '#shared/schemas/social' -import type { MiniDoc } from '#shared/types/social' -import { COLLECTION_SUBJECT_REF, COLLECTIONS_SCOPE } from '#shared/utils/constants' +import type { MiniDoc, ReactionState } from '#shared/types/social' +import { + COLLECTION_SUBJECT_REF, + COLLECTIONS_SCOPE, + FOLLOWS_SCOPE, + LIKES_SCOPE, +} from '#shared/utils/constants' import { throwOnMissingOAuthScope } from '#server/utils/atproto/oauth' +import type { Reaction } from './collections' -/** Writes to collections, as the signed-in user. */ +/** Writes to collections and their likes and follows, as the signed-in user. */ type CollectionBody = InferOutput type CollectionRef = { identifier: string; rkey: string } @@ -149,3 +161,45 @@ export async function deleteCollection(session: OAuthSession | undefined, ref: C .catch(error => console.error('[collections] Caching a delete failed', error)) return { uri: COLLECTION_SUBJECT_REF(did, ref.rkey) } } + +const REACTION_SCOPES: Record = { like: LIKES_SCOPE, follow: FOLLOWS_SCOPE } + +/** + * Likes or follows (`on`) or un-likes or un-follows a collection. + * Idempotent: asking for the state the viewer is already in changes nothing. + */ +export async function setReaction( + session: OAuthSession | undefined, + ref: CollectionRef, + reaction: Reaction, + on: boolean, +): Promise { + const { client, did } = await getWriter(session, REACTION_SCOPES[reaction]) + const owner = await resolveOwner(ref) + const uri = COLLECTION_SUBJECT_REF(owner.did, ref.rkey) + const utils = new CollectionsUtils() + const existing = await utils.getViewerReaction(reaction, uri, did) + if (Boolean(existing) === on) return await utils.getReaction(reaction, uri, did) + // Owners follow their own collections implicitly, and a record would inflate the count + if (on && reaction === 'follow' && owner.did === did) { + throw createError({ status: 400, message: 'You already follow your own collections' }) + } + const totalBefore = await utils.getReactionTotal(reaction, uri) + + if (!on) { + await (reaction === 'like' + ? client.delete(dev.npmx.feed.like, { rkey: existing!.rkey }) + : client.delete(dev.npmx.graph.follow, { rkey: existing!.rkey })) + return await utils.recordUnreaction(reaction, uri, did, totalBefore) + } + + if (!(await utils.getStoredRecord(owner, ref.rkey))) { + throw createError({ status: 404, message: 'Collection not found' }) + } + const createdAt = toDatetimeString(new Date()) + // A like's subjectRef, not its CID-pinned `subject`: collections change, likes shouldn't break. + const created = await (reaction === 'like' + ? client.create(dev.npmx.feed.like, { createdAt, subjectRef: uri as UriString }) + : client.create(dev.npmx.graph.follow, { createdAt, subject: uri as AtUriString })) + return await utils.recordReaction(reaction, uri, did, created.uri.split('/').pop()!, totalBefore) +} diff --git a/shared/utils/constants.ts b/shared/utils/constants.ts index 26aa00ba0c..e968b375f5 100644 --- a/shared/utils/constants.ts +++ b/shared/utils/constants.ts @@ -66,6 +66,7 @@ export const PACKAGE_SUBJECT_REF = (packageName: string) => export const LIKES_SCOPE = `repo:${dev.npmx.feed.like.$nsid}` export const PROFILE_SCOPE = `repo:${dev.npmx.actor.profile.$nsid}` export const COLLECTIONS_SCOPE = `repo:${dev.npmx.collection.$nsid}` +export const FOLLOWS_SCOPE = `repo:${dev.npmx.graph.follow.$nsid}` export const COLLECTION_SUBJECT_REF = (did: string, rkey: string) => `at://${did}/${dev.npmx.collection.$nsid}/${rkey}` // Keep in sync with lexicons/dev/npmx/collection.json diff --git a/test/unit/server/utils/collection-writes.spec.ts b/test/unit/server/utils/collection-writes.spec.ts index c2b91c2c63..d4cf08b722 100644 --- a/test/unit/server/utils/collection-writes.spec.ts +++ b/test/unit/server/utils/collection-writes.spec.ts @@ -28,6 +28,7 @@ const utils = { getStoredRecord: vi.fn(), getViewerReaction: vi.fn(), getReaction: vi.fn(), + getReactionTotal: vi.fn(async () => 4), recordReaction: vi.fn(async () => ({ total: 1, viewerHas: true })), recordUnreaction: vi.fn(async () => ({ total: 0, viewerHas: false })), } @@ -140,6 +141,73 @@ describe('updateCollection', () => { }) }) +describe('setReaction', () => { + it('follows with a follow record pointing at the collection', async () => { + client.create.mockResolvedValue({ uri: `at://${VIEWER}/dev.npmx.graph.follow/3follow` }) + expect(await writes.setReaction(sessionFor(VIEWER), REF, 'follow', true)).toEqual({ + total: 1, + viewerHas: true, + }) + expect(throwOnMissingOAuthScope).toHaveBeenCalledWith( + expect.anything(), + 'repo:dev.npmx.graph.follow', + ) + expect(client.create).toHaveBeenCalledWith( + expect.objectContaining({ $nsid: 'dev.npmx.graph.follow' }), + expect.objectContaining({ subject: URI }), + ) + expect(utils.recordReaction).toHaveBeenCalledWith('follow', URI, VIEWER, '3follow', 4) + }) + + it('likes via subjectRef', async () => { + client.create.mockResolvedValue({ uri: `at://${VIEWER}/dev.npmx.feed.like/3like` }) + await writes.setReaction(sessionFor(VIEWER), REF, 'like', true) + expect(client.create).toHaveBeenCalledWith( + expect.objectContaining({ $nsid: 'dev.npmx.feed.like' }), + expect.objectContaining({ subjectRef: URI }), + ) + }) + + it('writes nothing when already in the requested state', async () => { + utils.getReaction.mockResolvedValue({ total: 5, viewerHas: false }) + expect(await writes.setReaction(sessionFor(VIEWER), REF, 'follow', false)).toEqual({ + total: 5, + viewerHas: false, + }) + expect(client.create).not.toHaveBeenCalled() + expect(client.delete).not.toHaveBeenCalled() + }) + + it('unfollows by deleting the viewer’s follow record', async () => { + utils.getViewerReaction.mockResolvedValue({ + did: VIEWER, + collection: 'dev.npmx.graph.follow', + rkey: '3follow', + }) + await writes.setReaction(sessionFor(VIEWER), REF, 'follow', false) + expect(client.delete).toHaveBeenCalledWith( + expect.objectContaining({ $nsid: 'dev.npmx.graph.follow' }), + { rkey: '3follow' }, + ) + expect(utils.recordUnreaction).toHaveBeenCalledWith('follow', URI, VIEWER, 4) + }) + + it('refuses to follow your own collection', async () => { + await expect(writes.setReaction(sessionFor(OWNER), REF, 'follow', true)).rejects.toMatchObject({ + statusCode: 400, + }) + expect(client.create).not.toHaveBeenCalled() + }) + + it('refuses to follow a collection that does not exist', async () => { + utils.getStoredRecord.mockResolvedValue(null) + await expect(writes.setReaction(sessionFor(VIEWER), REF, 'follow', true)).rejects.toMatchObject( + { statusCode: 404 }, + ) + expect(client.create).not.toHaveBeenCalled() + }) +}) + describe('buildCollectionRecord', () => { it('stamps createdAt for new records and omits empty optional fields', () => { expect( From d63a09e37ff943f242befabc11f7d8f3608937a6 Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Sun, 8 Nov 2026 18:21:33 -0500 Subject: [PATCH 4/6] feat(ui): like and follow collections A like says the collection is good; a follow subscribes to it. Both update optimistically and roll back on failure, and both are in the command palette on a collection's page. Owners don't see Follow, since they follow their own collections implicitly. The heart animation moves to main.css so package and collection likes share it. --- app/assets/main.css | 52 +++++++++++++ app/components/Collection/FollowButton.vue | 37 +++++++++ app/components/Collection/LikeButton.vue | 57 ++++++++++++++ app/components/Package/Likes.vue | 53 ------------- app/composables/useCollectionReaction.ts | 46 +++++++++++ .../[identifier]/[rkey].stories.ts | 10 ++- app/pages/collections/[identifier]/[rkey].vue | 78 +++++++++++++++++-- app/storybook/mocks/handlers/collections.ts | 20 +++++ app/utils/atproto/collections.ts | 20 ++++- i18n/locales/en.json | 14 ++++ i18n/locales/fr-FR.json | 14 ++++ i18n/schema.json | 42 ++++++++++ test/nuxt/a11y.spec.ts | 26 +++++++ 13 files changed, 409 insertions(+), 60 deletions(-) create mode 100644 app/components/Collection/FollowButton.vue create mode 100644 app/components/Collection/LikeButton.vue create mode 100644 app/composables/useCollectionReaction.ts diff --git a/app/assets/main.css b/app/assets/main.css index bce110f018..56e50038b1 100644 --- a/app/assets/main.css +++ b/app/assets/main.css @@ -401,3 +401,55 @@ html:has(dialog:modal) { .vue-ui-pen-and-paper-group-actions { background-color: transparent !important; } + +/* Like button heart, shared by package and collection likes */ +@keyframes heart-spring { + 0% { + transform: scale(1); + } + 15% { + transform: scale(0.78); + } + 45% { + transform: scale(1.55); + } + 65% { + transform: scale(0.93); + } + 80% { + transform: scale(1.1); + } + 100% { + transform: scale(1); + } +} + +@keyframes heart-unlike { + 0% { + transform: scale(1); + } + 30% { + transform: scale(0.85); + } + 60% { + transform: scale(1.05); + } + 100% { + transform: scale(1); + } +} + +@media (prefers-reduced-motion: reduce) { + @keyframes heart-spring { + from, + to { + transform: scale(1); + } + } + @keyframes heart-unlike { + from, + to { + transform: scale(1); + } + } +} diff --git a/app/components/Collection/FollowButton.vue b/app/components/Collection/FollowButton.vue new file mode 100644 index 0000000000..1d08b3a355 --- /dev/null +++ b/app/components/Collection/FollowButton.vue @@ -0,0 +1,37 @@ + + + diff --git a/app/components/Collection/LikeButton.vue b/app/components/Collection/LikeButton.vue new file mode 100644 index 0000000000..5561826618 --- /dev/null +++ b/app/components/Collection/LikeButton.vue @@ -0,0 +1,57 @@ + + + diff --git a/app/components/Package/Likes.vue b/app/components/Package/Likes.vue index e48f543e62..371886775f 100644 --- a/app/components/Package/Likes.vue +++ b/app/components/Package/Likes.vue @@ -251,56 +251,3 @@ const likeAction = async () => { } } - - diff --git a/app/composables/useCollectionReaction.ts b/app/composables/useCollectionReaction.ts new file mode 100644 index 0000000000..a9ce406bcd --- /dev/null +++ b/app/composables/useCollectionReaction.ts @@ -0,0 +1,46 @@ +import type { Ref } from 'vue' +import type { ReactionState } from '#shared/types/social' +import { toggleCollectionReaction, type Reaction } from '~/utils/atproto/collections' + +/** Optimistic like or follow toggle for a collection; opens sign-in when signed out. */ +export function useCollectionReaction( + state: Ref, + reaction: Reaction, + target: () => { identifier: string; rkey: string }, +) { + const { user } = useAtproto() + const authModal = useModal('auth-modal') + const isPending = shallowRef(false) + + async function toggle() { + const handle = user.value?.handle + if (handle == null) { + authModal.open() + return + } + if (isPending.value) return + + const previous = state.value + state.value = { + total: previous.total + (previous.viewerHas ? -1 : 1), + viewerHas: !previous.viewerHas, + } + isPending.value = true + try { + const { identifier, rkey } = target() + state.value = await toggleCollectionReaction( + identifier, + rkey, + reaction, + previous.viewerHas, + handle, + ) + } catch { + state.value = previous + } finally { + isPending.value = false + } + } + + return { toggle } +} diff --git a/app/pages/collections/[identifier]/[rkey].stories.ts b/app/pages/collections/[identifier]/[rkey].stories.ts index ea8b0976b0..7a328bec42 100644 --- a/app/pages/collections/[identifier]/[rkey].stories.ts +++ b/app/pages/collections/[identifier]/[rkey].stories.ts @@ -4,6 +4,8 @@ import { pageDecorator } from '../../../../.storybook/decorators' import { mockCollectionHandle, mockCollectionHandler, + mockCollectionFollowHandlers, + mockCollectionLikeHandlers, mockCollectionRkey, mockPackageCardHandlers, } from '../../../storybook/mocks/handlers/collections' @@ -17,7 +19,13 @@ const meta = { `/collections/${mockCollectionHandle}/${mockCollectionRkey}`, ), beforeEach({ msw }) { - msw.use(mockCollectionHandler, ...mockPackageCardHandlers, mockAuthSessionHandler()) + msw.use( + mockCollectionHandler, + ...mockPackageCardHandlers, + ...mockCollectionLikeHandlers, + ...mockCollectionFollowHandlers, + mockAuthSessionHandler(), + ) }, parameters: { layout: 'fullscreen' }, decorators: [pageDecorator], diff --git a/app/pages/collections/[identifier]/[rkey].vue b/app/pages/collections/[identifier]/[rkey].vue index 8c24691d2b..36be808ef2 100644 --- a/app/pages/collections/[identifier]/[rkey].vue +++ b/app/pages/collections/[identifier]/[rkey].vue @@ -1,5 +1,5 @@