From 3bf1cdecf61c96c87e20653a52ca8c91be4a4c43 Mon Sep 17 00:00:00 2001 From: zeudev Date: Fri, 6 Feb 2026 12:13:32 -0800 Subject: [PATCH 01/43] add profile lexicon + scope, add profile button to AuthModal, add profile server utils + get endpoint, create profile on first login --- app/components/Header/AuthModal.client.vue | 29 +++++-- app/pages/profile/[handle]/index.vue | 55 +++++++++++++ lexicons/dev/npmx/actor/profile.json | 37 +++++++++ server/api/auth/atproto.get.ts | 56 ++++++++++--- server/api/social/profile/[...handle].get.ts | 14 ++++ server/utils/atproto/oauth.ts | 4 +- server/utils/atproto/utils/profile.ts | 85 ++++++++++++++++++++ shared/types/social.ts | 21 +++++ shared/types/userSession.ts | 5 ++ shared/utils/constants.ts | 1 + 10 files changed, 289 insertions(+), 18 deletions(-) create mode 100644 app/pages/profile/[handle]/index.vue create mode 100644 lexicons/dev/npmx/actor/profile.json create mode 100644 server/api/social/profile/[...handle].get.ts create mode 100644 server/utils/atproto/utils/profile.ts diff --git a/app/components/Header/AuthModal.client.vue b/app/components/Header/AuthModal.client.vue index 806fd9f14..4b3a224c3 100644 --- a/app/components/Header/AuthModal.client.vue +++ b/app/components/Header/AuthModal.client.vue @@ -2,6 +2,8 @@ import { useAtproto } from '~/composables/atproto/useAtproto' import { authRedirect } from '~/utils/atproto/helpers' +const authModal = useModal('auth-modal') + const handleInput = shallowRef('') const route = useRoute() const { user, logout } = useAtproto() @@ -33,12 +35,27 @@ async function handleLogin() {

- + +
+ + + + + +
diff --git a/app/pages/profile/[handle]/index.vue b/app/pages/profile/[handle]/index.vue new file mode 100644 index 000000000..07e9abc91 --- /dev/null +++ b/app/pages/profile/[handle]/index.vue @@ -0,0 +1,55 @@ + + + diff --git a/lexicons/dev/npmx/actor/profile.json b/lexicons/dev/npmx/actor/profile.json new file mode 100644 index 000000000..8b2556a55 --- /dev/null +++ b/lexicons/dev/npmx/actor/profile.json @@ -0,0 +1,37 @@ +{ + "lexicon": 1, + "id": "dev.npmx.actor.profile", + "defs": { + "main": { + "key": "literal:self", + "type": "record", + "record": { + "type": "object", + "properties": { + "avatar": { + "type": "blob", + "accept": ["image/png", "image/jpeg"], + "maxSize": 1000000, + "description": "AKA, 'profile picture'" + }, + "website": { + "type": "string", + "format": "uri" + }, + "description": { + "type": "string", + "maxLength": 2560, + "description": "Free-form profile description text.", + "maxGraphemes": 256 + }, + "displayName": { + "type": "string", + "maxLength": 640, + "maxGraphemes": 64 + } + } + }, + "description": "A declaration of an npmx account profile." + } + } +} diff --git a/server/api/auth/atproto.get.ts b/server/api/auth/atproto.get.ts index cfd76b4ec..a81d38d50 100644 --- a/server/api/auth/atproto.get.ts +++ b/server/api/auth/atproto.get.ts @@ -119,21 +119,57 @@ export default defineEventHandler(async event => { const agent = new Agent(authSession) event.context.agent = agent - const response = await fetch( - `https://${SLINGSHOT_HOST}/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${agent.did}`, + const miniDocResponse = await fetch( + `https://${SLINGSHOT_HOST}/xrpc/blue.microcosm.identity.resolveMiniDoc?identifier=${agent.did}`, { headers: { 'User-Agent': 'npmx' } }, ) - if (response.ok) { - const miniDoc: PublicUserSession = await response.json() + + if (miniDocResponse.ok) { + const miniDoc: PublicUserSession = await miniDocResponse.json() let avatar: string | undefined = await getAvatar(authSession.did, miniDoc.pds) - await session.update({ - public: { - ...miniDoc, - avatar, - }, - }) + // get existing npmx profile OR create a new one + const profileUri = `at://${agent.did}/dev.npmx.actor.profile/self` + const profileResponse = await fetch( + `https://${SLINGSHOT_HOST}/xrpc/blue.microcosm.repo.getRecordByUri?at_uri=${profileUri}`, + { headers: { 'User-Agent': 'npmx' } }, + ) + + if (profileResponse.ok) { + const profile = await profileResponse.json() + await session.update({ + public: { + ...miniDoc, + avatar, + }, + profile: profile.value, + }) + } else { + const profile = { + website: '', + displayName: miniDoc.handle, + description: '', + } + + await agent.com.atproto.repo.createRecord({ + repo: miniDoc.handle, + collection: 'dev.npmx.actor.profile', + rkey: 'self', + record: { + $type: 'dev.npmx.actor.profile', + ...profile, + }, + }) + + await session.update({ + public: { + ...miniDoc, + avatar, + }, + profile: profile, + }) + } } else { //If slingshot fails we still want to set some key info we need. const pdsBase = (await authSession.getTokenInfo()).aud diff --git a/server/api/social/profile/[...handle].get.ts b/server/api/social/profile/[...handle].get.ts new file mode 100644 index 000000000..a0640de86 --- /dev/null +++ b/server/api/social/profile/[...handle].get.ts @@ -0,0 +1,14 @@ +export default defineEventHandler(async event => { + const handle = getRouterParam(event, 'handle') + if (!handle) { + throw createError({ + status: 400, + message: 'handle not provided', + }) + } + + const profileUtil = new ProfileUtils() + const profile = await profileUtil.getProfile(handle) + console.log('ENDPOINT', { handle, profile }) + return profile +}) diff --git a/server/utils/atproto/oauth.ts b/server/utils/atproto/oauth.ts index c8fc6cd41..3a79647a4 100644 --- a/server/utils/atproto/oauth.ts +++ b/server/utils/atproto/oauth.ts @@ -4,12 +4,12 @@ import { NodeOAuthClient, AtprotoDohHandleResolver } from '@atproto/oauth-client import { parse } from 'valibot' import { getOAuthLock } from '#server/utils/atproto/lock' import { useOAuthStorage } from '#server/utils/atproto/storage' -import { LIKES_SCOPE } from '#shared/utils/constants' +import { LIKES_SCOPE, PROFILE_SCOPE } from '#shared/utils/constants' import { OAuthMetadataSchema } from '#shared/schemas/oauth' // @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}` +export const scope = `atproto ${LIKES_SCOPE} ${PROFILE_SCOPE}` /** * Resolves a did to a handle via DoH or via the http website calls diff --git a/server/utils/atproto/utils/profile.ts b/server/utils/atproto/utils/profile.ts new file mode 100644 index 000000000..13cbba1d8 --- /dev/null +++ b/server/utils/atproto/utils/profile.ts @@ -0,0 +1,85 @@ +import type { MiniDoc, NPMXProfile } from '~~/shared/types/social' + +//Cache keys and helpers +const CACHE_PREFIX = 'atproto-profile:' +const CACHE_PROFILE_MINI_DOC = (handle: string) => `${CACHE_PREFIX}${handle}:minidoc` +const CACHE_PROFILE_KEY = (did: string) => `${CACHE_PREFIX}${did}:profile` + +const CACHE_MAX_AGE = CACHE_MAX_AGE_ONE_MINUTE * 5 + +/** + * Logic to handle and update profile queries + */ +export class ProfileUtils { + private readonly constellation: Constellation + private readonly cache: CacheAdapter + + constructor() { + this.constellation = new Constellation( + // Passes in a fetch wrapped as cachedfetch since are 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 } + }, + ) + this.cache = getCacheAdapter('generic') + } + + private async slingshotMiniDoc(handle: string) { + const miniDocKey = CACHE_PROFILE_MINI_DOC(handle) + const cachedMiniDoc = await this.cache.get(miniDocKey) + + let miniDoc + if (cachedMiniDoc) { + miniDoc = cachedMiniDoc + } else { + const resolveUrl = `https://${SLINGSHOT_HOST}/xrpc/blue.microcosm.identity.resolveMiniDoc?identifier=${encodeURIComponent(handle)}` + console.log({ resolveUrl }) + const response = await fetch(resolveUrl, { + headers: { 'User-Agent': 'npmx' }, + }) + const value = (await response.json()) as MiniDoc + + miniDoc = value + await this.cache.set(miniDocKey, value, CACHE_MAX_AGE) + } + console.log({ miniDoc }) + + return miniDoc + } + + /** + * Gets an npmx profile based on a handle + * @param handle + * @returns + */ + async getProfile(handle: string) { + const profileKey = CACHE_PROFILE_KEY(handle) + const cachedProfile = await this.cache.get(profileKey) + + let profile: NPMXProfile | undefined + if (cachedProfile) { + profile = cachedProfile + } else { + const miniDoc = await this.slingshotMiniDoc(handle) + const profileUri = `at://${miniDoc.did}/dev.npmx.actor.profile/self` + const response = await fetch( + `https://${SLINGSHOT_HOST}/xrpc/blue.microcosm.repo.getRecordByUri?at_uri=${profileUri}`, + { + headers: { 'User-Agent': 'npmx' }, + }, + ) + if (response.ok) { + const { value } = (await response.json()) as { value: NPMXProfile } + profile = value + await this.cache.set(profileKey, profile, CACHE_MAX_AGE) + } + } + + return profile + } +} diff --git a/shared/types/social.ts b/shared/types/social.ts index a40c1e7b5..fe7734c56 100644 --- a/shared/types/social.ts +++ b/shared/types/social.ts @@ -7,3 +7,24 @@ export type PackageLikes = { // If the logged in user has liked the package, false if not logged in userHasLiked: boolean } + +/** + * A shortened DID Doc for AT Protocol accounts + * Returned by Slingshot's `/xrpc/blue.microcosm.identity.resolveMiniDoc` endpoint + */ +export type MiniDoc = { + did: string + handle: string + pds: string + signing_key: string +} + +/** + * NPMX Profile details + * TODO: avatar + */ +export type NPMXProfile = { + displayName: string + website?: string + description?: string +} diff --git a/shared/types/userSession.ts b/shared/types/userSession.ts index dea7decd3..6b76275c1 100644 --- a/shared/types/userSession.ts +++ b/shared/types/userSession.ts @@ -7,6 +7,11 @@ export interface UserServerSession { pds: string avatar?: string } + profile: { + website?: string + description?: string + displayName?: string + } // Only to be used in the atproto session and state stores // Will need to change to Record and add a current logged in user if we ever want to support // multiple did logins per server session diff --git a/shared/utils/constants.ts b/shared/utils/constants.ts index 1229a2d50..fdd890b10 100644 --- a/shared/utils/constants.ts +++ b/shared/utils/constants.ts @@ -43,6 +43,7 @@ export const PACKAGE_SUBJECT_REF = (packageName: string) => `https://npmx.dev/package/${packageName}` // OAuth scopes as we add new ones we need to check these on certain actions. If not redirect the user to login again to upgrade the scopes export const LIKES_SCOPE = `repo:${dev.npmx.feed.like.$nsid}` +export const PROFILE_SCOPE = `repo:${dev.npmx.actor.profile.$nsid}` // Theming export const ACCENT_COLORS = { From 09c99c1c13a267143143fe8bad454c0dae5c8923 Mon Sep 17 00:00:00 2001 From: Bailey Townsend Date: Fri, 6 Feb 2026 20:54:21 -0600 Subject: [PATCH 02/43] Like endpoint --- .../microcosm/identity/resolveMiniDoc.json | 50 ++++++++++ .../blue/microcosm/links/getBacklinks.json | 87 ++++++++++++++++++ .../microcosm/links/getBacklinksCount.json | 39 ++++++++ .../microcosm/links/getManyToManyCounts.json | 91 +++++++++++++++++++ .../blue/microcosm/repo/get-record-by-uri.ts | 44 +++++++++ .../blue/microcosm/repo/getRecordByUri.json | 50 ++++++++++ server/api/social/profile/[...handle].get.ts | 14 --- .../social/profile/[identifier]/index.get.ts | 14 +++ .../social/profile/[identifier]/likes.get.ts | 10 ++ server/utils/atproto/utils/identity.ts | 40 ++++++++ server/utils/atproto/utils/likes.ts | 23 +++++ 11 files changed, 448 insertions(+), 14 deletions(-) create mode 100644 lexicons/blue/microcosm/identity/resolveMiniDoc.json create mode 100644 lexicons/blue/microcosm/links/getBacklinks.json create mode 100644 lexicons/blue/microcosm/links/getBacklinksCount.json create mode 100644 lexicons/blue/microcosm/links/getManyToManyCounts.json create mode 100644 lexicons/blue/microcosm/repo/get-record-by-uri.ts create mode 100644 lexicons/blue/microcosm/repo/getRecordByUri.json delete mode 100644 server/api/social/profile/[...handle].get.ts create mode 100644 server/api/social/profile/[identifier]/index.get.ts create mode 100644 server/api/social/profile/[identifier]/likes.get.ts create mode 100644 server/utils/atproto/utils/identity.ts diff --git a/lexicons/blue/microcosm/identity/resolveMiniDoc.json b/lexicons/blue/microcosm/identity/resolveMiniDoc.json new file mode 100644 index 000000000..fad411d62 --- /dev/null +++ b/lexicons/blue/microcosm/identity/resolveMiniDoc.json @@ -0,0 +1,50 @@ +{ + "id": "blue.microcosm.identity.resolveMiniDoc", + "defs": { + "main": { + "type": "query", + "description": "Slingshot: like com.atproto.identity.resolveIdentity but instead of the full didDoc it returns an atproto-relevant subset", + "parameters": { + "type": "params", + "required": ["identifier"], + "properties": { + "identifier": { + "type": "string", + "format": "at-identifier", + "description": "handle or DID to resolve" + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["did", "handle", "pds", "signing_key"], + "properties": { + "did": { + "type": "string", + "format": "did", + "description": "DID, bi-directionally verified if a handle was provided in the query" + }, + "handle": { + "type": "string", + "format": "handle", + "description": "the validated handle of the account or 'handle.invalid' if the handle did not bi-directionally match the DID document" + }, + "pds": { + "type": "string", + "format": "uri", + "description": "the identity's PDS URL" + }, + "signing_key": { + "type": "string", + "description": "the atproto signing key publicKeyMultibase" + } + } + } + } + } + }, + "$type": "com.atproto.lexicon.schema", + "lexicon": 1 +} diff --git a/lexicons/blue/microcosm/links/getBacklinks.json b/lexicons/blue/microcosm/links/getBacklinks.json new file mode 100644 index 000000000..e6e3d9d02 --- /dev/null +++ b/lexicons/blue/microcosm/links/getBacklinks.json @@ -0,0 +1,87 @@ +{ + "id": "blue.microcosm.links.getBacklinks", + "defs": { + "main": { + "type": "query", + "description": "Constellation: list records linking to any record, identity, or uri", + "parameters": { + "type": "params", + "required": ["subject", "source"], + "properties": { + "subject": { + "type": "string", + "format": "uri", + "description": "the target being linked to (at-uri, did, or uri)" + }, + "source": { + "type": "string", + "description": "collection and path specification (e.g., 'app.bsky.feed.like:subject.uri')" + }, + "did": { + "type": "array", + "description": "filter links to those from specific users", + "items": { + "type": "string", + "format": "did" + } + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 16, + "description": "number of results to return" + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["total", "records"], + "properties": { + "total": { + "type": "integer", + "description": "total number of matching links" + }, + "records": { + "type": "array", + "items": { + "type": "ref", + "ref": "#linkRecord" + } + }, + "cursor": { + "type": "string", + "description": "pagination cursor" + } + } + } + } + }, + "linkRecord": { + "type": "object", + "description": "a record linking to the subject", + "required": ["did", "collection", "rkey"], + "properties": { + "did": { + "type": "string", + "format": "did", + "description": "the DID of the linking record's repository" + }, + "collection": { + "type": "string", + "format": "nsid", + "description": "the collection of the linking record" + }, + "rkey": { + "type": "string", + "format": "record-key", + "description": "the record key of the linking record" + } + } + } + }, + "$type": "com.atproto.lexicon.schema", + "lexicon": 1 +} diff --git a/lexicons/blue/microcosm/links/getBacklinksCount.json b/lexicons/blue/microcosm/links/getBacklinksCount.json new file mode 100644 index 000000000..d8ee34f7b --- /dev/null +++ b/lexicons/blue/microcosm/links/getBacklinksCount.json @@ -0,0 +1,39 @@ +{ + "id": "blue.microcosm.links.getBacklinksCount", + "defs": { + "main": { + "type": "query", + "description": "Constellation: count records that link to another record", + "parameters": { + "type": "params", + "required": ["subject", "source"], + "properties": { + "subject": { + "type": "string", + "format": "at-uri", + "description": "the target being linked to (at-uri, did, or uri)" + }, + "source": { + "type": "string", + "description": "collection and path specification for the primary link" + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["total"], + "properties": { + "total": { + "type": "integer", + "description": "total number of matching links" + } + } + } + } + } + }, + "$type": "com.atproto.lexicon.schema", + "lexicon": 1 +} diff --git a/lexicons/blue/microcosm/links/getManyToManyCounts.json b/lexicons/blue/microcosm/links/getManyToManyCounts.json new file mode 100644 index 000000000..3363509ae --- /dev/null +++ b/lexicons/blue/microcosm/links/getManyToManyCounts.json @@ -0,0 +1,91 @@ +{ + "id": "blue.microcosm.links.getManyToManyCounts", + "defs": { + "main": { + "type": "query", + "description": "Constellation: count many-to-many relationships with secondary link paths", + "parameters": { + "type": "params", + "required": ["subject", "source", "pathToOther"], + "properties": { + "subject": { + "type": "string", + "format": "uri", + "description": "the primary target being linked to (at-uri, did, or uri)" + }, + "source": { + "type": "string", + "description": "collection and path specification for the primary link" + }, + "pathToOther": { + "type": "string", + "description": "path to the secondary link in the many-to-many record (e.g., 'otherThing.uri')" + }, + "did": { + "type": "array", + "description": "filter links to those from specific users", + "items": { + "type": "string", + "format": "did" + } + }, + "otherSubject": { + "type": "array", + "description": "filter secondary links to specific subjects", + "items": { + "type": "string" + } + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 16, + "description": "number of results to return" + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["counts_by_other_subject"], + "properties": { + "counts_by_other_subject": { + "type": "array", + "items": { + "type": "ref", + "ref": "#countBySubject" + } + }, + "cursor": { + "type": "string", + "description": "pagination cursor" + } + } + } + } + }, + "countBySubject": { + "type": "object", + "description": "count of links to a secondary subject", + "required": ["subject", "total", "distinct"], + "properties": { + "subject": { + "type": "string", + "description": "the secondary subject being counted" + }, + "total": { + "type": "integer", + "description": "total number of links to this subject" + }, + "distinct": { + "type": "integer", + "description": "number of distinct DIDs linking to this subject" + } + } + } + }, + "$type": "com.atproto.lexicon.schema", + "lexicon": 1 +} diff --git a/lexicons/blue/microcosm/repo/get-record-by-uri.ts b/lexicons/blue/microcosm/repo/get-record-by-uri.ts new file mode 100644 index 000000000..01b4f2fd2 --- /dev/null +++ b/lexicons/blue/microcosm/repo/get-record-by-uri.ts @@ -0,0 +1,44 @@ +import { + document, + object, + params, + query, + required, + string, + unknown, +} from '@atcute/lexicon-doc/builder' + +export default document({ + id: 'blue.microcosm.repo.getRecordByUri', + defs: { + main: query({ + description: + 'Slingshot: ergonomic complement to com.atproto.repo.getRecord which accepts an at-uri instead of individual repo/collection/rkey params', + parameters: params({ + properties: { + at_uri: required( + string({ + format: 'at-uri', + description: 'the at-uri of the record (identifier can be a DID or handle)', + }), + ), + cid: string({ + format: 'cid', + description: + 'optional CID of the version of the record. if not specified, return the most recent version. if specified and a newer version exists, returns 404.', + }), + }, + }), + output: { + encoding: 'application/json', + schema: object({ + properties: { + uri: required(string({ format: 'at-uri', description: 'at-uri for this record' })), + cid: string({ format: 'cid', description: 'CID for this exact version of the record' }), + value: required(unknown({ description: 'the record itself' })), + }, + }), + }, + }), + }, +}) diff --git a/lexicons/blue/microcosm/repo/getRecordByUri.json b/lexicons/blue/microcosm/repo/getRecordByUri.json new file mode 100644 index 000000000..e99472e0b --- /dev/null +++ b/lexicons/blue/microcosm/repo/getRecordByUri.json @@ -0,0 +1,50 @@ +{ + "id": "blue.microcosm.repo.getRecordByUri", + "defs": { + "main": { + "type": "query", + "description": "Slingshot: ergonomic complement to com.atproto.repo.getRecord which accepts an at-uri instead of individual repo/collection/rkey params", + "parameters": { + "type": "params", + "required": ["at_uri"], + "properties": { + "at_uri": { + "type": "string", + "format": "at-uri", + "description": "the at-uri of the record (identifier can be a DID or handle)" + }, + "cid": { + "type": "string", + "format": "cid", + "description": "optional CID of the version of the record. if not specified, return the most recent version. if specified and a newer version exists, returns 404." + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["uri", "value"], + "properties": { + "uri": { + "type": "string", + "format": "at-uri", + "description": "at-uri for this record" + }, + "cid": { + "type": "string", + "format": "cid", + "description": "CID for this exact version of the record" + }, + "value": { + "type": "unknown", + "description": "the record itself" + } + } + } + } + } + }, + "$type": "com.atproto.lexicon.schema", + "lexicon": 1 +} diff --git a/server/api/social/profile/[...handle].get.ts b/server/api/social/profile/[...handle].get.ts deleted file mode 100644 index a0640de86..000000000 --- a/server/api/social/profile/[...handle].get.ts +++ /dev/null @@ -1,14 +0,0 @@ -export default defineEventHandler(async event => { - const handle = getRouterParam(event, 'handle') - if (!handle) { - throw createError({ - status: 400, - message: 'handle not provided', - }) - } - - const profileUtil = new ProfileUtils() - const profile = await profileUtil.getProfile(handle) - console.log('ENDPOINT', { handle, profile }) - return profile -}) diff --git a/server/api/social/profile/[identifier]/index.get.ts b/server/api/social/profile/[identifier]/index.get.ts new file mode 100644 index 000000000..73d88eae4 --- /dev/null +++ b/server/api/social/profile/[identifier]/index.get.ts @@ -0,0 +1,14 @@ +export default defineEventHandler(async event => { + const identifier = getRouterParam(event, 'identifier') + if (!identifier) { + throw createError({ + status: 400, + message: 'identifier not provided', + }) + } + + const profileUtil = new ProfileUtils() + const profile = await profileUtil.getProfile(identifier) + console.log('ENDPOINT', { identifier, profile }) + return profile +}) diff --git a/server/api/social/profile/[identifier]/likes.get.ts b/server/api/social/profile/[identifier]/likes.get.ts new file mode 100644 index 000000000..1a2745f2f --- /dev/null +++ b/server/api/social/profile/[identifier]/likes.get.ts @@ -0,0 +1,10 @@ +import { IdentityUtils } from '#server/utils/atproto/utils/identity' + +export default defineEventHandler(async event => { + const identifier = getRouterParam(event, 'identifier') + const utils = new IdentityUtils() + const minidoc = await utils.getMiniDoc(identifier || '') + const likesUtil = new PackageLikesUtils() + + return likesUtil.getUserLikes(minidoc) +}) diff --git a/server/utils/atproto/utils/identity.ts b/server/utils/atproto/utils/identity.ts new file mode 100644 index 000000000..05c33df72 --- /dev/null +++ b/server/utils/atproto/utils/identity.ts @@ -0,0 +1,40 @@ +import { Client } from '@atproto/lex' +import { ensureValidAtIdentifier } from '@atproto/syntax' +import * as blue from '#shared/types/lexicons/blue' + +const HEADERS = { 'User-Agent': 'npmx' } + +// Aggersive cache on identity since that doesn't change a ton +const CACHE_MAX_AGE_IDENTITY = CACHE_MAX_AGE_ONE_HOUR * 6 + +const CACHE_KEY_IDENTITY = (identity: string) => `identity:${identity}` + +export class IdentityUtils { + private readonly cache: CacheAdapter + private readonly slingShotClient: Client + constructor() { + this.cache = getCacheAdapter('generic') + this.slingShotClient = new Client(`https://${SLINGSHOT_HOST}`, { + headers: HEADERS, + }) + } + + /** + * Gets the user's mini doc from slingshot + * @param identifier - A users did or handle + * @returns + */ + async getMiniDoc(identifier: string): Promise { + ensureValidAtIdentifier(identifier) + const cacheKey = CACHE_KEY_IDENTITY(identifier) + const cached = await this.cache.get(cacheKey) + if (cached) { + return cached + } + const result = await this.slingShotClient.call(blue.microcosm.identity.resolveMiniDoc, { + identifier: identifier, + }) + await this.cache.set(cacheKey, result, CACHE_MAX_AGE_IDENTITY) + return result + } +} diff --git a/server/utils/atproto/utils/likes.ts b/server/utils/atproto/utils/likes.ts index fe71c2592..1bf3919c5 100644 --- a/server/utils/atproto/utils/likes.ts +++ b/server/utils/atproto/utils/likes.ts @@ -1,5 +1,8 @@ import { $nsid as likeNsid } from '#shared/types/lexicons/dev/npmx/feed/like.defs' import type { Backlink } from '#shared/utils/constellation' +import type * as blue from '#shared/types/lexicons/blue' +import * as dev from '#shared/types/lexicons/dev' +import { Client } from '@atproto/lex' //Cache keys and helpers const CACHE_PREFIX = 'atproto-likes:' @@ -248,4 +251,24 @@ export class PackageLikesUtils { userHasLiked: false, } } + + /** + * Gets a list of likes for a user. Newest first + * @param miniDoc + * @param limit + * @returns + */ + async getUserLikes( + miniDoc: blue.microcosm.identity.resolveMiniDoc.OutputBody, + limit: number = 10, + ) { + const client = new Client(miniDoc.pds, { + headers: { 'User-Agent': 'npmx' }, + }) + const result = client.list(dev.npmx.feed.like, { + limit, + repo: miniDoc.did, + }) + return result + } } From 95919986d24dfd1551c3848d6696fd680568a7d8 Mon Sep 17 00:00:00 2001 From: zeudev Date: Mon, 9 Feb 2026 23:50:16 -0800 Subject: [PATCH 03/43] init useProfileLikes composable, add likes grid to profile page --- app/composables/atproto/useProfileLikes.ts | 30 +++++++++++ app/pages/profile/[handle]/index.vue | 53 ++++++++++++++++--- .../social/profile/[identifier]/index.get.ts | 1 - server/utils/atproto/utils/likes.ts | 3 +- server/utils/atproto/utils/profile.ts | 2 - 5 files changed, 77 insertions(+), 12 deletions(-) create mode 100644 app/composables/atproto/useProfileLikes.ts diff --git a/app/composables/atproto/useProfileLikes.ts b/app/composables/atproto/useProfileLikes.ts new file mode 100644 index 000000000..b57b71bee --- /dev/null +++ b/app/composables/atproto/useProfileLikes.ts @@ -0,0 +1,30 @@ +export type LikesResult = { + records: { + value: { + subjectRef: string + } + }[] +} + +export function useProfileLikes(handle: MaybeRefOrGetter) { + const cachedFetch = useCachedFetch() + const asyncData = useLazyAsyncData( + `profile:${toValue(handle)}:likes`, + async (_nuxtApp, { signal }) => { + const { data: likes, isStale } = await cachedFetch( + `/api/social/profile/${toValue(handle)}/likes`, + { signal }, + ) + + return { likes, isStale } + }, + ) + + if (import.meta.client) { + onMounted(() => { + asyncData.refresh() + }) + } + + return asyncData +} diff --git a/app/pages/profile/[handle]/index.vue b/app/pages/profile/[handle]/index.vue index 07e9abc91..f19b0bf74 100644 --- a/app/pages/profile/[handle]/index.vue +++ b/app/pages/profile/[handle]/index.vue @@ -5,6 +5,14 @@ import { normalizeSearchParam } from '#shared/utils/url' const route = useRoute('/profile/[handle]') const router = useRouter() +type LikesResult = { + records: { + value: { + subjectRef: string + } + }[] +} + const handle = computed(() => route.params.handle) const { data: profile }: { data?: NPMXProfile } = useFetch( @@ -15,11 +23,18 @@ const { data: profile }: { data?: NPMXProfile } = useFetch( }, ) +const { data: likes, status } = await useProfileLikes(handle) + useSeoMeta({ title: () => `${handle.value} - npmx`, description: () => `npmx profile by ${handle.value}`, }) +function extractPackageFromRef(ref: string) { + const { pkg } = /https:\/\/npmx.dev\/package\/(?.*)/.exec(ref).groups + return pkg +} + /** defineOgImageComponent('Default', { title: () => `~${username.value}`, @@ -42,14 +57,36 @@ defineOgImageComponent('Default', { - -
-
-

- {{ $t('user.page.no_packages') }} ~{{ handle }} -

-

{{ $t('user.page.no_packages_hint') }}

+
+

+ Likes ({{ likes.likes.records.length ?? 0 }}) +

+
+

Loading...

+
+
+

Error

+
+
+ + +

+ {{ extractPackageFromRef(like.value.subjectRef) }} +

+

+
+
-
+ diff --git a/server/api/social/profile/[identifier]/index.get.ts b/server/api/social/profile/[identifier]/index.get.ts index 73d88eae4..73667f443 100644 --- a/server/api/social/profile/[identifier]/index.get.ts +++ b/server/api/social/profile/[identifier]/index.get.ts @@ -9,6 +9,5 @@ export default defineEventHandler(async event => { const profileUtil = new ProfileUtils() const profile = await profileUtil.getProfile(identifier) - console.log('ENDPOINT', { identifier, profile }) return profile }) diff --git a/server/utils/atproto/utils/likes.ts b/server/utils/atproto/utils/likes.ts index 1bf3919c5..ffa639091 100644 --- a/server/utils/atproto/utils/likes.ts +++ b/server/utils/atproto/utils/likes.ts @@ -265,10 +265,11 @@ export class PackageLikesUtils { const client = new Client(miniDoc.pds, { headers: { 'User-Agent': 'npmx' }, }) - const result = client.list(dev.npmx.feed.like, { + const result = await client.list(dev.npmx.feed.like, { limit, repo: miniDoc.did, }) + return result } } diff --git a/server/utils/atproto/utils/profile.ts b/server/utils/atproto/utils/profile.ts index 13cbba1d8..267ae172a 100644 --- a/server/utils/atproto/utils/profile.ts +++ b/server/utils/atproto/utils/profile.ts @@ -38,7 +38,6 @@ export class ProfileUtils { miniDoc = cachedMiniDoc } else { const resolveUrl = `https://${SLINGSHOT_HOST}/xrpc/blue.microcosm.identity.resolveMiniDoc?identifier=${encodeURIComponent(handle)}` - console.log({ resolveUrl }) const response = await fetch(resolveUrl, { headers: { 'User-Agent': 'npmx' }, }) @@ -47,7 +46,6 @@ export class ProfileUtils { miniDoc = value await this.cache.set(miniDocKey, value, CACHE_MAX_AGE) } - console.log({ miniDoc }) return miniDoc } From c467c3e63c7c28e9311a23380b7fffa20884f71c Mon Sep 17 00:00:00 2001 From: zeudev Date: Tue, 10 Feb 2026 04:40:11 -0800 Subject: [PATCH 04/43] create and implement BasicCard for likes grid --- app/components/Package/BasicCard.vue | 22 ++++++++++++++++++++ app/pages/profile/[handle]/index.vue | 30 ++++++++-------------------- 2 files changed, 30 insertions(+), 22 deletions(-) create mode 100644 app/components/Package/BasicCard.vue diff --git a/app/components/Package/BasicCard.vue b/app/components/Package/BasicCard.vue new file mode 100644 index 000000000..230b4f361 --- /dev/null +++ b/app/components/Package/BasicCard.vue @@ -0,0 +1,22 @@ + + + diff --git a/app/pages/profile/[handle]/index.vue b/app/pages/profile/[handle]/index.vue index f19b0bf74..944550711 100644 --- a/app/pages/profile/[handle]/index.vue +++ b/app/pages/profile/[handle]/index.vue @@ -23,18 +23,13 @@ const { data: profile }: { data?: NPMXProfile } = useFetch( }, ) -const { data: likes, status } = await useProfileLikes(handle) +const { data: likesData, status } = await useProfileLikes(handle) useSeoMeta({ title: () => `${handle.value} - npmx`, description: () => `npmx profile by ${handle.value}`, }) -function extractPackageFromRef(ref: string) { - const { pkg } = /https:\/\/npmx.dev\/package\/(?.*)/.exec(ref).groups - return pkg -} - /** defineOgImageComponent('Default', { title: () => `~${username.value}`, @@ -57,13 +52,13 @@ defineOgImageComponent('Default', {
-
+

- Likes ({{ likes.likes.records.length ?? 0 }}) + Likes ({{ likesData.likes.records.length ?? 0 }})

Loading...

@@ -72,20 +67,11 @@ defineOgImageComponent('Default', {

Error

- - -

- {{ extractPackageFromRef(like.value.subjectRef) }} -

-

-
-
+
From dfc4d7dccbbcebf881d918d14660d37346d8a150 Mon Sep 17 00:00:00 2001 From: zeudev Date: Thu, 12 Feb 2026 02:16:56 -0800 Subject: [PATCH 05/43] rename BasicCard to LikeCard, copy paste like button to card --- app/components/Package/BasicCard.vue | 22 ------ app/components/Package/LikeCard.vue | 103 +++++++++++++++++++++++++++ app/pages/profile/[handle]/index.vue | 4 +- 3 files changed, 105 insertions(+), 24 deletions(-) delete mode 100644 app/components/Package/BasicCard.vue create mode 100644 app/components/Package/LikeCard.vue diff --git a/app/components/Package/BasicCard.vue b/app/components/Package/BasicCard.vue deleted file mode 100644 index 230b4f361..000000000 --- a/app/components/Package/BasicCard.vue +++ /dev/null @@ -1,22 +0,0 @@ - - - diff --git a/app/components/Package/LikeCard.vue b/app/components/Package/LikeCard.vue new file mode 100644 index 000000000..446b30d02 --- /dev/null +++ b/app/components/Package/LikeCard.vue @@ -0,0 +1,103 @@ + + + diff --git a/app/pages/profile/[handle]/index.vue b/app/pages/profile/[handle]/index.vue index 944550711..218abb1c5 100644 --- a/app/pages/profile/[handle]/index.vue +++ b/app/pages/profile/[handle]/index.vue @@ -67,10 +67,10 @@ defineOgImageComponent('Default', {

Error

-
From 58a3b0c144f8acb69b1a31ed40330348e8176075 Mon Sep 17 00:00:00 2001 From: zeudev Date: Thu, 12 Feb 2026 02:29:59 -0800 Subject: [PATCH 06/43] implement like update --- app/components/Package/LikeCard.vue | 64 ++++++++++++++++------------- 1 file changed, 35 insertions(+), 29 deletions(-) diff --git a/app/components/Package/LikeCard.vue b/app/components/Package/LikeCard.vue index 446b30d02..0bf8c1f3b 100644 --- a/app/components/Package/LikeCard.vue +++ b/app/components/Package/LikeCard.vue @@ -1,4 +1,6 @@