diff --git a/src/app.ts b/src/app.ts index c3212f8..e637cb4 100644 --- a/src/app.ts +++ b/src/app.ts @@ -23,6 +23,7 @@ import { accountLookupController, accountSearchController, accountStatusesController, + blockController, createAccountController, favouritesController, followController, @@ -135,6 +136,7 @@ app.patch( app.get('/api/v1/accounts/search', accountSearchController); app.get('/api/v1/accounts/lookup', accountLookupController); app.get('/api/v1/accounts/relationships', relationshipsController); +app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/block', blockController); app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/follow', followController); app.get('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/followers', followersController); app.get('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/following', followingController); diff --git a/src/client.ts b/src/client.ts index fbcfaa5..511cbc7 100644 --- a/src/client.ts +++ b/src/client.ts @@ -52,6 +52,7 @@ function getEvents(filters: Filter[], opts: GetEventsOpts = /** Publish an event to the given relays, or the entire pool. */ function storeEvent(event: Event, opts: StoreEventOpts = {}): Promise { const { relays = activeRelays } = opts; + const debug = Debug('ditto:client:publish'); debug('EVENT', event); pool.publish(event, relays); return Promise.resolve(); diff --git a/src/controllers/api/accounts.ts b/src/controllers/api/accounts.ts index 5e345ae..9e1acc8 100644 --- a/src/controllers/api/accounts.ts +++ b/src/controllers/api/accounts.ts @@ -4,12 +4,13 @@ import { eventsDB } from '@/db/events.ts'; import { insertUser } from '@/db/users.ts'; import { findReplyTag, nip19, z } from '@/deps.ts'; import { type DittoFilter } from '@/filter.ts'; -import { getAuthor, getFollowedPubkeys, getFollows } from '@/queries.ts'; +import { getAuthor, getFollowedPubkeys } from '@/queries.ts'; import { booleanParamSchema, fileSchema } from '@/schema.ts'; import { jsonMetaContentSchema } from '@/schemas/nostr.ts'; +import { addTag } from '@/tags.ts'; import { uploadFile } from '@/upload.ts'; -import { isFollowing, lookupAccount, nostrNow } from '@/utils.ts'; -import { paginated, paginationSchema, parseBody } from '@/utils/web.ts'; +import { lookupAccount, nostrNow } from '@/utils.ts'; +import { paginated, paginationSchema, parseBody, updateListEvent } from '@/utils/web.ts'; import { createEvent } from '@/utils/web.ts'; import { renderEventAccounts } from '@/views.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; @@ -216,18 +217,11 @@ const followController: AppController = async (c) => { const sourcePubkey = c.get('pubkey')!; const targetPubkey = c.req.param('pubkey'); - const source = await getFollows(sourcePubkey); - - if (!source || !isFollowing(source, targetPubkey)) { - await createEvent({ - kind: 3, - content: '', - tags: [ - ...(source?.tags ?? []), - ['p', targetPubkey], - ], - }, c); - } + await updateListEvent( + { kinds: [3], authors: [sourcePubkey] }, + (tags) => addTag(tags, ['p', targetPubkey]), + c, + ); const relationship = await renderRelationship(sourcePubkey, targetPubkey); return c.json(relationship); @@ -252,6 +246,20 @@ const followingController: AppController = async (c) => { return c.json(accounts.filter(Boolean)); }; +const blockController: AppController = async (c) => { + const sourcePubkey = c.get('pubkey')!; + const targetPubkey = c.req.param('pubkey'); + + await updateListEvent( + { kinds: [10000], authors: [sourcePubkey] }, + (tags) => addTag(tags, ['p', targetPubkey]), + c, + ); + + const relationship = await renderRelationship(sourcePubkey, targetPubkey); + return c.json(relationship); +}; + const favouritesController: AppController = async (c) => { const pubkey = c.get('pubkey')!; const params = paginationSchema.parse(c.req.query()); @@ -281,6 +289,7 @@ export { accountLookupController, accountSearchController, accountStatusesController, + blockController, createAccountController, favouritesController, followController, diff --git a/src/db/events.ts b/src/db/events.ts index a20c650..79f670f 100644 --- a/src/db/events.ts +++ b/src/db/events.ts @@ -257,6 +257,7 @@ async function getEvents( filters: DittoFilter[], opts: GetEventsOpts = {}, ): Promise[]> { + if (opts.signal?.aborted) return Promise.resolve([]); if (!filters.length) return Promise.resolve([]); debug('REQ', JSON.stringify(filters)); let query = getEventsQuery(filters); diff --git a/src/queries.ts b/src/queries.ts index 92500d3..95020d0 100644 --- a/src/queries.ts +++ b/src/queries.ts @@ -78,7 +78,7 @@ const getAuthor = async (pubkey: string, opts: GetEventOpts<0> = {}): Promise | undefined> => { +const getFollows = async (pubkey: string, signal?: AbortSignal): Promise | undefined> => { const [event] = await eventsDB.getEvents([{ authors: [pubkey], kinds: [3], limit: 1 }], { limit: 1, signal }); return event; }; diff --git a/src/tags.test.ts b/src/tags.test.ts new file mode 100644 index 0000000..c4d3214 --- /dev/null +++ b/src/tags.test.ts @@ -0,0 +1,25 @@ +import { assertEquals } from '@/deps-test.ts'; + +import { addTag, deleteTag, getTagSet } from './tags.ts'; + +Deno.test('getTagSet', () => { + assertEquals(getTagSet([], 'p'), new Set()); + assertEquals(getTagSet([['p', '123']], 'p'), new Set(['123'])); + assertEquals(getTagSet([['p', '123'], ['p', '456']], 'p'), new Set(['123', '456'])); + assertEquals(getTagSet([['p', '123'], ['p', '456'], ['q', '789']], 'p'), new Set(['123', '456'])); +}); + +Deno.test('addTag', () => { + assertEquals(addTag([], ['p', '123']), [['p', '123']]); + assertEquals(addTag([['p', '123']], ['p', '123']), [['p', '123']]); + assertEquals(addTag([['p', '123'], ['p', '456']], ['p', '123']), [['p', '123'], ['p', '456']]); + assertEquals(addTag([['p', '123'], ['p', '456']], ['p', '789']), [['p', '123'], ['p', '456'], ['p', '789']]); +}); + +Deno.test('deleteTag', () => { + assertEquals(deleteTag([], ['p', '123']), []); + assertEquals(deleteTag([['p', '123']], ['p', '123']), []); + assertEquals(deleteTag([['p', '123']], ['p', '456']), [['p', '123']]); + assertEquals(deleteTag([['p', '123'], ['p', '123']], ['p', '123']), []); + assertEquals(deleteTag([['p', '123'], ['p', '456']], ['p', '456']), [['p', '123']]); +}); diff --git a/src/tags.ts b/src/tags.ts index 98efc7d..6027808 100644 --- a/src/tags.ts +++ b/src/tags.ts @@ -11,4 +11,24 @@ function getTagSet(tags: string[][], tagName: string): Set { return set; } -export { getTagSet }; +/** Check if the tag exists by its name and value. */ +function hasTag(tags: string[][], tag: string[]): boolean { + return tags.some(([name, value]) => name === tag[0] && value === tag[1]); +} + +/** Delete all occurences of the tag by its name/value pair. */ +function deleteTag(tags: readonly string[][], tag: string[]): string[][] { + return tags.filter(([name, value]) => !(name === tag[0] && value === tag[1])); +} + +/** Add a tag to the list, replacing the name/value pair if it already exists. */ +function addTag(tags: readonly string[][], tag: string[]): string[][] { + const tagIndex = tags.findIndex(([name, value]) => name === tag[0] && value === tag[1]); + if (tagIndex === -1) { + return [...tags, tag]; + } else { + return [...tags.slice(0, tagIndex), tag, ...tags.slice(tagIndex + 1)]; + } +} + +export { addTag, deleteTag, getTagSet, hasTag }; diff --git a/src/utils.ts b/src/utils.ts index 1f10cd3..1294e00 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -95,13 +95,6 @@ const relaySchema = z.string().max(255).startsWith('wss://').url(); /** Check whether the value is a valid relay URL. */ const isRelay = (relay: string): relay is `wss://${string}` => relaySchema.safeParse(relay).success; -/** Check whether source is following target. */ -function isFollowing(source: Event<3>, targetPubkey: string): boolean { - return Boolean( - source.tags.find(([tagName, tagValue]) => tagName === 'p' && tagValue === targetPubkey), - ); -} - /** Deduplicate events by ID. */ function dedupeEvents(events: Event[]): Event[] { return [...new Map(events.map((event) => [event.id, event])).values()]; @@ -156,7 +149,6 @@ export { eventDateComparator, eventMatchesTemplate, findTag, - isFollowing, isNostrId, isRelay, isURL, diff --git a/src/utils/web.ts b/src/utils/web.ts index b02209f..1e7be07 100644 --- a/src/utils/web.ts +++ b/src/utils/web.ts @@ -1,13 +1,22 @@ +import { type AppContext } from '@/app.ts'; import { Conf } from '@/config.ts'; -import { type Context, type Event, EventTemplate, HTTPException, parseFormData, type TypeFest, z } from '@/deps.ts'; +import { + type Context, + type Event, + EventTemplate, + Filter, + HTTPException, + parseFormData, + type TypeFest, + z, +} from '@/deps.ts'; import * as pipeline from '@/pipeline.ts'; import { signAdminEvent, signEvent } from '@/sign.ts'; import { nostrNow } from '@/utils.ts'; - -import type { AppContext } from '@/app.ts'; +import { eventsDB } from '@/db/events.ts'; /** EventTemplate with defaults. */ -type EventStub = TypeFest.SetOptional, 'created_at' | 'tags'>; +type EventStub = TypeFest.SetOptional, 'content' | 'created_at' | 'tags'>; /** Publish an event through the pipeline. */ async function createEvent(t: EventStub, c: AppContext): Promise> { @@ -18,6 +27,7 @@ async function createEvent(t: EventStub, c: AppContext): Pr } const event = await signEvent({ + content: '', created_at: nostrNow(), tags: [], ...t, @@ -26,9 +36,38 @@ async function createEvent(t: EventStub, c: AppContext): Pr return publishEvent(event, c); } +/** Filter for fetching an existing event to update. */ +interface UpdateEventFilter extends Filter { + kinds: [K]; + limit?: 1; +} + +/** Fetch existing event, update it, then publish the new event. */ +async function updateEvent>( + filter: UpdateEventFilter, + fn: (prev: Event | undefined) => E, + c: AppContext, +): Promise> { + const [prev] = await eventsDB.getEvents([filter], { limit: 1 }); + return createEvent(fn(prev), c); +} + +/** Fetch existing event, update its tags, then publish the new event. */ +function updateListEvent( + filter: UpdateEventFilter, + fn: (tags: string[][]) => string[][], + c: AppContext, +): Promise> { + return updateEvent(filter, (prev) => ({ + kind: filter.kinds[0], + content: prev?.content ?? '', + tags: fn(prev?.tags ?? []), + }), c); +} /** Publish an admin event through the pipeline. */ async function createAdminEvent(t: EventStub, c: AppContext): Promise> { const event = await signAdminEvent({ + content: '', created_at: nostrNow(), tags: [], ...t, @@ -139,4 +178,6 @@ export { type PaginationParams, paginationSchema, parseBody, + updateEvent, + updateListEvent, }; diff --git a/src/views/mastodon/relationships.ts b/src/views/mastodon/relationships.ts index 91d33ea..e5ce280 100644 --- a/src/views/mastodon/relationships.ts +++ b/src/views/mastodon/relationships.ts @@ -1,20 +1,22 @@ -import { getFollows } from '@/queries.ts'; -import { isFollowing } from '@/utils.ts'; +import { eventsDB } from '@/db/events.ts'; +import { hasTag } from '@/tags.ts'; async function renderRelationship(sourcePubkey: string, targetPubkey: string) { - const [source, target] = await Promise.all([ - getFollows(sourcePubkey), - getFollows(targetPubkey), + const [event3, target3, event10000, target10000] = await eventsDB.getEvents([ + { kinds: [3], authors: [sourcePubkey], limit: 1 }, + { kinds: [3], authors: [targetPubkey], limit: 1 }, + { kinds: [10000], authors: [sourcePubkey], limit: 1 }, + { kinds: [10000], authors: [targetPubkey], limit: 1 }, ]); return { id: targetPubkey, - following: source ? isFollowing(source, targetPubkey) : false, + following: event3 ? hasTag(event3.tags, ['p', targetPubkey]) : false, showing_reblogs: true, notifying: false, - followed_by: target ? isFollowing(target, sourcePubkey) : false, - blocking: false, - blocked_by: false, + followed_by: target3 ? hasTag(target3?.tags, ['p', sourcePubkey]) : false, + blocking: event10000 ? hasTag(event10000.tags, ['p', targetPubkey]) : false, + blocked_by: target10000 ? hasTag(target10000.tags, ['p', sourcePubkey]) : false, muting: false, muting_notifications: false, requested: false,