diff --git a/src/app.ts b/src/app.ts index cbfe5b1..0d38e34 100644 --- a/src/app.ts +++ b/src/app.ts @@ -53,9 +53,11 @@ import { createStatusController, favouriteController, favouritedByController, + pinController, rebloggedByController, statusController, unbookmarkController, + unpinController, } from './controllers/api/statuses.ts'; import { streamingController } from './controllers/api/streaming.ts'; import { @@ -158,6 +160,8 @@ app.get('/api/v1/statuses/:id{[0-9a-f]{64}}', statusController); app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/favourite', requirePubkey, favouriteController); app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/bookmark', requirePubkey, bookmarkController); app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/unbookmark', requirePubkey, unbookmarkController); +app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/pin', requirePubkey, pinController); +app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/unpin', requirePubkey, unpinController); app.post('/api/v1/statuses', requirePubkey, createStatusController); app.post('/api/v1/media', requireRole('user', { validatePayload: false }), mediaController); diff --git a/src/controllers/api/accounts.ts b/src/controllers/api/accounts.ts index b25dbe4..6ad3944 100644 --- a/src/controllers/api/accounts.ts +++ b/src/controllers/api/accounts.ts @@ -7,12 +7,12 @@ import { type DittoFilter } from '@/filter.ts'; import { getAuthor, getFollowedPubkeys } from '@/queries.ts'; import { booleanParamSchema, fileSchema } from '@/schema.ts'; import { jsonMetaContentSchema } from '@/schemas/nostr.ts'; -import { addTag, deleteTag } from '@/tags.ts'; +import { addTag, deleteTag, getTagSet } from '@/tags.ts'; import { uploadFile } from '@/upload.ts'; import { lookupAccount, nostrNow } from '@/utils.ts'; import { paginated, paginationSchema, parseBody, updateListEvent } from '@/utils/web.ts'; import { createEvent } from '@/utils/web.ts'; -import { renderAccounts, renderEventAccounts } from '@/views.ts'; +import { renderAccounts, renderEventAccounts, renderStatuses } from '@/views.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { renderRelationship } from '@/views/mastodon/relationships.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts'; @@ -134,9 +134,14 @@ const accountStatusesController: AppController = async (c) => { const { since, until } = paginationSchema.parse(c.req.query()); const { pinned, limit, exclude_replies, tagged } = accountStatusesQuerySchema.parse(c.req.query()); - // Nostr doesn't support pinned statuses. if (pinned) { - return c.json([]); + const [pinEvent] = await eventsDB.getEvents([{ kinds: [10001], authors: [pubkey], limit: 1 }]); + if (pinEvent) { + const pinnedEventIds = getTagSet(pinEvent.tags, 'e'); + return renderStatuses(c, [...pinnedEventIds].reverse()); + } else { + return c.json([]); + } } const filter: DittoFilter<1> = { diff --git a/src/controllers/api/statuses.ts b/src/controllers/api/statuses.ts index 1ba82b3..9cb789b 100644 --- a/src/controllers/api/statuses.ts +++ b/src/controllers/api/statuses.ts @@ -207,13 +207,69 @@ const unbookmarkController: AppController = async (c) => { } }; +/** https://docs.joinmastodon.org/methods/statuses/#pin */ +const pinController: AppController = async (c) => { + const pubkey = c.get('pubkey')!; + const eventId = c.req.param('id'); + + const event = await getEvent(eventId, { + kind: 1, + relations: ['author', 'event_stats', 'author_stats'], + }); + + if (event) { + await updateListEvent( + { kinds: [10001], authors: [pubkey] }, + (tags) => addTag(tags, ['e', eventId]), + c, + ); + + const status = await renderStatus(event, pubkey); + if (status) { + status.pinned = true; + } + return c.json(status); + } else { + return c.json({ error: 'Event not found.' }, 404); + } +}; + +/** https://docs.joinmastodon.org/methods/statuses/#unpin */ +const unpinController: AppController = async (c) => { + const pubkey = c.get('pubkey')!; + const eventId = c.req.param('id'); + + const event = await getEvent(eventId, { + kind: 1, + relations: ['author', 'event_stats', 'author_stats'], + }); + + if (event) { + await updateListEvent( + { kinds: [10001], authors: [pubkey] }, + (tags) => deleteTag(tags, ['e', eventId]), + c, + ); + + const status = await renderStatus(event, pubkey); + if (status) { + status.pinned = false; + } + return c.json(status); + } else { + return c.json({ error: 'Event not found.' }, 404); + } +}; + export { bookmarkController, contextController, createStatusController, favouriteController, favouritedByController, + pinController, rebloggedByController, statusController, unbookmarkController, + unpinController, }; diff --git a/src/queries.ts b/src/queries.ts index 95020d0..de60553 100644 --- a/src/queries.ts +++ b/src/queries.ts @@ -4,6 +4,7 @@ import { type Event, findReplyTag } from '@/deps.ts'; import { type AuthorMicrofilter, type DittoFilter, type IdMicrofilter, type Relation } from '@/filter.ts'; import { reqmeister } from '@/reqmeister.ts'; import { type DittoEvent } from '@/store.ts'; +import { getTagSet } from '@/tags.ts'; interface GetEventOpts { /** Signal to abort the request. */ @@ -87,10 +88,7 @@ const getFollows = async (pubkey: string, signal?: AbortSignal): Promise { const event = await getFollows(pubkey, signal); if (!event) return []; - - return event.tags - .filter((tag) => tag[0] === 'p') - .map((tag) => tag[1]); + return [...getTagSet(event.tags, 'p')]; } /** Get pubkeys the user follows, including the user's own pubkey. */ diff --git a/src/views.ts b/src/views.ts index 7c22170..b87baad 100644 --- a/src/views.ts +++ b/src/views.ts @@ -47,6 +47,10 @@ async function renderAccounts(c: AppContext, authors: string[], signal = AbortSi /** Render statuses by event IDs. */ async function renderStatuses(c: AppContext, ids: string[], signal = AbortSignal.timeout(1000)) { + if (!ids.length) { + return c.json([]); + } + const { limit } = paginationSchema.parse(c.req.query()); const events = await eventsDB.getEvents( diff --git a/src/views/mastodon/statuses.ts b/src/views/mastodon/statuses.ts index d869563..d840c36 100644 --- a/src/views/mastodon/statuses.ts +++ b/src/views/mastodon/statuses.ts @@ -38,6 +38,7 @@ async function renderStatus(event: DittoEvent<1>, viewerPubkey?: string) { ? await eventsDB.getEvents([ { kinds: [6], '#e': [event.id], authors: [viewerPubkey], limit: 1 }, { kinds: [7], '#e': [event.id], authors: [viewerPubkey], limit: 1 }, + { kinds: [10001], '#e': [event.id], authors: [viewerPubkey], limit: 1 }, { kinds: [10003], '#e': [event.id], authors: [viewerPubkey], limit: 1 }, ]) : [], @@ -45,6 +46,7 @@ async function renderStatus(event: DittoEvent<1>, viewerPubkey?: string) { const reactionEvent = relatedEvents.find((event) => event.kind === 6); const repostEvent = relatedEvents.find((event) => event.kind === 7); + const pinEvent = relatedEvents.find((event) => event.kind === 10001); const bookmarkEvent = relatedEvents.find((event) => event.kind === 10003); const content = buildInlineRecipients(mentions) + html; @@ -79,6 +81,7 @@ async function renderStatus(event: DittoEvent<1>, viewerPubkey?: string) { reblogged: Boolean(repostEvent), muted: false, bookmarked: Boolean(bookmarkEvent), + pinned: Boolean(pinEvent), reblog: null, application: null, media_attachments: media.map(renderAttachment),