diff --git a/src/controllers/api/accounts.ts b/src/controllers/api/accounts.ts index f717be39..2d61adcd 100644 --- a/src/controllers/api/accounts.ts +++ b/src/controllers/api/accounts.ts @@ -7,7 +7,6 @@ import { Conf } from '@/config.ts'; import { getAuthor, getFollowedPubkeys } from '@/queries.ts'; import { booleanParamSchema, fileSchema } from '@/schema.ts'; import { Storages } from '@/storages.ts'; -import { addTag, deleteTag, findReplyTag, getTagSet } from '@/tags.ts'; import { uploadFile } from '@/utils/upload.ts'; import { nostrNow } from '@/utils.ts'; import { createEvent, paginated, paginationSchema, parseBody, updateListEvent } from '@/utils/api.ts'; @@ -18,6 +17,7 @@ import { renderRelationship } from '@/views/mastodon/relationships.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; import { bech32ToPubkey } from '@/utils.ts'; +import { addTag, deleteTag, findReplyTag, getTagSet } from '@/utils/tags.ts'; const usernameSchema = z .string().min(1).max(30) diff --git a/src/controllers/api/admin.ts b/src/controllers/api/admin.ts index 77571aa7..d7cd3658 100644 --- a/src/controllers/api/admin.ts +++ b/src/controllers/api/admin.ts @@ -5,8 +5,8 @@ import { Conf } from '@/config.ts'; import { DittoEvent } from '@/interfaces/DittoEvent.ts'; import { booleanParamSchema } from '@/schema.ts'; import { Storages } from '@/storages.ts'; -import { addTag } from '@/tags.ts'; import { paginated, paginationSchema, parseBody, updateListAdminEvent } from '@/utils/api.ts'; +import { addTag } from '@/utils/tags.ts'; import { renderAdminAccount } from '@/views/mastodon/admin-accounts.ts'; const adminAccountQuerySchema = z.object({ diff --git a/src/controllers/api/bookmarks.ts b/src/controllers/api/bookmarks.ts index 76551827..6d80b500 100644 --- a/src/controllers/api/bookmarks.ts +++ b/src/controllers/api/bookmarks.ts @@ -1,6 +1,6 @@ import { type AppController } from '@/app.ts'; import { Storages } from '@/storages.ts'; -import { getTagSet } from '@/tags.ts'; +import { getTagSet } from '@/utils/tags.ts'; import { renderStatuses } from '@/views.ts'; /** https://docs.joinmastodon.org/methods/bookmarks/#get */ diff --git a/src/controllers/api/mutes.ts b/src/controllers/api/mutes.ts index 4afb6c40..31f54ee1 100644 --- a/src/controllers/api/mutes.ts +++ b/src/controllers/api/mutes.ts @@ -1,6 +1,6 @@ import { type AppController } from '@/app.ts'; import { Storages } from '@/storages.ts'; -import { getTagSet } from '@/tags.ts'; +import { getTagSet } from '@/utils/tags.ts'; import { renderAccounts } from '@/views.ts'; /** https://docs.joinmastodon.org/methods/mutes/#get */ diff --git a/src/controllers/api/statuses.ts b/src/controllers/api/statuses.ts index 291d970c..e9c872f8 100644 --- a/src/controllers/api/statuses.ts +++ b/src/controllers/api/statuses.ts @@ -8,15 +8,15 @@ import { Conf } from '@/config.ts'; import { DittoDB } from '@/db/DittoDB.ts'; import { getUnattachedMediaByIds } from '@/db/unattached-media.ts'; import { getAncestors, getAuthor, getDescendants, getEvent } from '@/queries.ts'; -import { addTag, deleteTag } from '@/tags.ts'; -import { createEvent, paginationSchema, parseBody, updateListEvent } from '@/utils/api.ts'; import { renderEventAccounts } from '@/views.ts'; import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts'; -import { getLnurl } from '@/utils/lnurl.ts'; -import { asyncReplaceAll } from '@/utils/text.ts'; import { Storages } from '@/storages.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; +import { createEvent, paginationSchema, parseBody, updateListEvent } from '@/utils/api.ts'; +import { getLnurl } from '@/utils/lnurl.ts'; import { lookupPubkey } from '@/utils/lookup.ts'; +import { addTag, deleteTag } from '@/utils/tags.ts'; +import { asyncReplaceAll } from '@/utils/text.ts'; const createStatusSchema = z.object({ in_reply_to_id: z.string().regex(/[0-9a-f]{64}/).nullish(), diff --git a/src/controllers/api/suggestions.ts b/src/controllers/api/suggestions.ts index 6377bd4f..012244a1 100644 --- a/src/controllers/api/suggestions.ts +++ b/src/controllers/api/suggestions.ts @@ -2,8 +2,8 @@ import { NStore } from '@nostrify/nostrify'; import { AppController } from '@/app.ts'; import { Conf } from '@/config.ts'; -import { getTagSet } from '@/tags.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; +import { getTagSet } from '@/utils/tags.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; export const suggestionsV1Controller: AppController = async (c) => { diff --git a/src/pipeline.ts b/src/pipeline.ts index 15d495e7..bfb0577e 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -13,7 +13,6 @@ import { RelayError } from '@/RelayError.ts'; import { updateStats } from '@/stats.ts'; import { hydrateEvents, purifyEvent } from '@/storages/hydrate.ts'; import { Storages } from '@/storages.ts'; -import { getTagSet } from '@/tags.ts'; import { eventAge, nostrDate, nostrNow, parseNip05, Time } from '@/utils.ts'; import { fetchWorker } from '@/workers/fetch.ts'; import { policyWorker } from '@/workers/policy.ts'; @@ -22,6 +21,7 @@ import { verifyEventWorker } from '@/workers/verify.ts'; import { AdminSigner } from '@/signers/AdminSigner.ts'; import { lnurlCache } from '@/utils/lnurl.ts'; import { nip05Cache } from '@/utils/nip05.ts'; +import { getTagSet } from '@/utils/tags.ts'; import { MuteListPolicy } from '@/policies/MuteListPolicy.ts'; diff --git a/src/policies/MuteListPolicy.ts b/src/policies/MuteListPolicy.ts index cae08eba..130d10df 100644 --- a/src/policies/MuteListPolicy.ts +++ b/src/policies/MuteListPolicy.ts @@ -1,6 +1,6 @@ import { NostrEvent, NostrRelayOK, NPolicy, NStore } from '@nostrify/nostrify'; -import { getTagSet } from '@/tags.ts'; +import { getTagSet } from '@/utils/tags.ts'; export class MuteListPolicy implements NPolicy { constructor(private pubkey: string, private store: NStore) {} diff --git a/src/queries.ts b/src/queries.ts index 76fabfdd..6a197ea7 100644 --- a/src/queries.ts +++ b/src/queries.ts @@ -5,8 +5,8 @@ import { Conf } from '@/config.ts'; import { Storages } from '@/storages.ts'; import { type DittoEvent } from '@/interfaces/DittoEvent.ts'; import { type DittoRelation } from '@/interfaces/DittoFilter.ts'; -import { findReplyTag, getTagSet } from '@/tags.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; +import { findReplyTag, getTagSet } from '@/utils/tags.ts'; const debug = Debug('ditto:queries'); diff --git a/src/stats.ts b/src/stats.ts index 256c570e..6ffe5f7e 100644 --- a/src/stats.ts +++ b/src/stats.ts @@ -8,7 +8,7 @@ import { SetRequired } from 'type-fest'; import { DittoDB } from '@/db/DittoDB.ts'; import { DittoTables } from '@/db/DittoTables.ts'; import { Storages } from '@/storages.ts'; -import { findReplyTag, getTagSet } from '@/tags.ts'; +import { findReplyTag, getTagSet } from '@/utils/tags.ts'; type AuthorStat = keyof Omit; type EventStat = keyof Omit; diff --git a/src/storages/EventsDB.ts b/src/storages/EventsDB.ts index 5a3839a5..a550f39b 100644 --- a/src/storages/EventsDB.ts +++ b/src/storages/EventsDB.ts @@ -7,11 +7,11 @@ import { Kysely } from 'kysely'; import { Conf } from '@/config.ts'; import { DittoTables } from '@/db/DittoTables.ts'; import { normalizeFilters } from '@/filter.ts'; +import { RelayError } from '@/RelayError.ts'; import { purifyEvent } from '@/storages/hydrate.ts'; -import { getTagSet } from '@/tags.ts'; import { isNostrId, isURL } from '@/utils.ts'; import { abortError } from '@/utils/abort.ts'; -import { RelayError } from '@/RelayError.ts'; +import { getTagSet } from '@/utils/tags.ts'; /** Function to decide whether or not to index a tag. */ type TagCondition = ({ event, count, value }: { diff --git a/src/storages/UserStore.ts b/src/storages/UserStore.ts index c5657b6e..43c1771b 100644 --- a/src/storages/UserStore.ts +++ b/src/storages/UserStore.ts @@ -1,7 +1,7 @@ import { NostrEvent, NostrFilter, NStore } from '@nostrify/nostrify'; import { DittoEvent } from '@/interfaces/DittoEvent.ts'; -import { getTagSet } from '@/tags.ts'; +import { getTagSet } from '@/utils/tags.ts'; export class UserStore implements NStore { constructor(private pubkey: string, private store: NStore) {} diff --git a/src/storages/hydrate.ts b/src/storages/hydrate.ts index e5c488e3..68dc0bdb 100644 --- a/src/storages/hydrate.ts +++ b/src/storages/hydrate.ts @@ -6,6 +6,7 @@ import { type DittoEvent } from '@/interfaces/DittoEvent.ts'; import { DittoTables } from '@/db/DittoTables.ts'; import { Conf } from '@/config.ts'; import { refreshAuthorStatsDebounced } from '@/stats.ts'; +import { findQuoteTag } from '@/utils/tags.ts'; interface HydrateOpts { events: DittoEvent[]; @@ -81,7 +82,7 @@ function assembleEvents( event.user = b.find((e) => matchFilter({ kinds: [30361], authors: [admin], '#d': [event.pubkey] }, e)); if (event.kind === 1) { - const id = event.tags.find(([name]) => name === 'q')?.[1]; + const id = findQuoteTag(event.tags)?.[1]; if (id) { event.quote = b.find((e) => matchFilter({ kinds: [1], ids: [id] }, e)); } @@ -169,7 +170,7 @@ function gatherQuotes({ events, store, signal }: HydrateOpts): Promise name === 'q')?.[1]; + const id = findQuoteTag(event.tags)?.[1]; if (id) { ids.add(id); } diff --git a/src/tags.test.ts b/src/tags.test.ts deleted file mode 100644 index e49d31ab..00000000 --- a/src/tags.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { assertEquals } from '@std/assert'; - -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 deleted file mode 100644 index a683393e..00000000 --- a/src/tags.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** Get the values for a tag in a `Set`. */ -function getTagSet(tags: string[][], tagName: string): Set { - const set = new Set(); - - tags.forEach((tag) => { - if (tag[0] === tagName) { - set.add(tag[1]); - } - }); - - return set; -} - -/** 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)]; - } -} - -const isReplyTag = (tag: string[]) => tag[0] === 'e' && tag[3] === 'reply'; -const isRootTag = (tag: string[]) => tag[0] === 'e' && tag[3] === 'root'; -const isLegacyReplyTag = (tag: string[]) => tag[0] === 'e' && !tag[3]; - -function findReplyTag(tags: string[][]) { - return tags.find(isReplyTag) || tags.find(isRootTag) || tags.findLast(isLegacyReplyTag); -} - -export { addTag, deleteTag, findReplyTag, getTagSet, hasTag }; diff --git a/src/utils/tags.test.ts b/src/utils/tags.test.ts new file mode 100644 index 00000000..43e12359 --- /dev/null +++ b/src/utils/tags.test.ts @@ -0,0 +1,45 @@ +import { assertEquals } from '@std/assert'; + +import { addTag, deleteTag, findQuoteTag, findReplyTag, getTagSet, hasTag } from './tags.ts'; + +Deno.test('addTag', () => { + const tags = [['p', 'alex']]; + assertEquals(addTag(tags, ['p', 'alex']), [['p', 'alex']]); + assertEquals(addTag(tags, ['p', 'fiatjaf']), [['p', 'alex'], ['p', 'fiatjaf']]); +}); + +Deno.test('deleteTag', () => { + const tags = [['p', 'alex'], ['p', 'fiatjaf']]; + assertEquals(deleteTag(tags, ['p', 'alex']), [['p', 'fiatjaf']]); + assertEquals(deleteTag(tags, ['p', 'fiatjaf']), [['p', 'alex']]); +}); + +Deno.test('findQuoteTag', () => { + assertEquals(findQuoteTag([['q', '123']]), ['q', '123']); + assertEquals(findQuoteTag([['e', '', '', 'mention', '456']]), ['e', '', '', 'mention', '456']); + assertEquals(findQuoteTag([['e', '', '', 'mention', '456'], ['q', '123']]), ['q', '123']); + assertEquals(findQuoteTag([['q', '123'], ['e', '', '', 'mention', '456']]), ['q', '123']); +}); + +Deno.test('findReplyTag', () => { + const root = ['e', '123', '', 'root']; + const reply = ['e', '456', '', 'reply']; + + assertEquals(findReplyTag([root]), root); + assertEquals(findReplyTag([reply]), reply); + assertEquals(findReplyTag([root, reply]), reply); + assertEquals(findReplyTag([reply, root]), reply); + assertEquals(findReplyTag([['e', '321'], ['e', '789']]), ['e', '789']); + assertEquals(findReplyTag([reply, ['e', '789']]), reply); +}); + +Deno.test('getTagSet', () => { + const tags = [['p', 'alex'], ['p', 'fiatjaf'], ['p', 'alex']]; + assertEquals(getTagSet(tags, 'p'), new Set(['alex', 'fiatjaf'])); +}); + +Deno.test('hasTag', () => { + const tags = [['p', 'alex']]; + assertEquals(hasTag(tags, ['p', 'alex']), true); + assertEquals(hasTag(tags, ['p', 'fiatjaf']), false); +}); diff --git a/src/utils/tags.ts b/src/utils/tags.ts new file mode 100644 index 00000000..6375e815 --- /dev/null +++ b/src/utils/tags.ts @@ -0,0 +1,71 @@ +/** Get the values for a tag in a `Set`. */ +function getTagSet(tags: string[][], tagName: string): Set { + const set = new Set(); + + tags.forEach((tag) => { + if (tag[0] === tagName) { + set.add(tag[1]); + } + }); + + return set; +} + +/** 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)]; + } +} + +/** Tag is a NIP-10 root tag. */ +function isRootTag(tag: string[]): tag is ['e', string, string, 'root', ...string[]] { + return tag[0] === 'e' && tag[3] === 'root'; +} + +/** Tag is a NIP-10 reply tag. */ +function isReplyTag(tag: string[]): tag is ['e', string, string, 'reply', ...string[]] { + return tag[0] === 'e' && tag[3] === 'reply'; +} + +/** Tag is a legacy "e" tag with a "mention" marker. */ +function isLegacyQuoteTag(tag: string[]): tag is ['e', string, string, 'mention', ...string[]] { + return tag[0] === 'e' && tag[3] === 'mention'; +} + +/** Tag is an "e" tag without a NIP-10 marker. */ +function isLegacyReplyTag(tag: string[]): tag is ['e', string, string] { + return tag[0] === 'e' && !tag[3]; +} + +/** Tag is a "q" tag. */ +function isQuoteTag(tag: string[]): tag is ['q', ...string[]] { + return tag[0] === 'q'; +} + +/** Get the "e" tag for the event being replied to, first according to the NIPs then falling back to the legacy way. */ +function findReplyTag(tags: string[][]): ['e', ...string[]] | undefined { + return tags.find(isReplyTag) || tags.find(isRootTag) || tags.findLast(isLegacyReplyTag); +} + +/** Get the "q" tag, falling back to the legacy "e" tag with a "mention" marker. */ +function findQuoteTag( + tags: string[][], +): ['q', ...string[]] | ['e', string, string, 'mention', ...string[]] | undefined { + return tags.find(isQuoteTag) || tags.find(isLegacyQuoteTag); +} + +export { addTag, deleteTag, findQuoteTag, findReplyTag, getTagSet, hasTag }; diff --git a/src/views/mastodon/relationships.ts b/src/views/mastodon/relationships.ts index 2f8ffdde..425ea563 100644 --- a/src/views/mastodon/relationships.ts +++ b/src/views/mastodon/relationships.ts @@ -1,5 +1,5 @@ import { Storages } from '@/storages.ts'; -import { hasTag } from '@/tags.ts'; +import { hasTag } from '@/utils/tags.ts'; async function renderRelationship(sourcePubkey: string, targetPubkey: string) { const db = await Storages.db(); diff --git a/src/views/mastodon/statuses.ts b/src/views/mastodon/statuses.ts index a06aac21..cc7cc36b 100644 --- a/src/views/mastodon/statuses.ts +++ b/src/views/mastodon/statuses.ts @@ -1,13 +1,12 @@ import { NostrEvent } from '@nostrify/nostrify'; -import { isCWTag } from 'https://gitlab.com/soapbox-pub/mostr/-/raw/c67064aee5ade5e01597c6d23e22e53c628ef0e2/src/nostr/tags.ts'; import { nip19 } from 'nostr-tools'; import { Conf } from '@/config.ts'; import { type DittoEvent } from '@/interfaces/DittoEvent.ts'; import { Storages } from '@/storages.ts'; -import { findReplyTag } from '@/tags.ts'; import { nostrDate } from '@/utils.ts'; import { getMediaLinks, parseNoteContent, stripimeta } from '@/utils/note.ts'; +import { findQuoteTag, findReplyTag } from '@/utils/tags.ts'; import { unfurlCardCached } from '@/utils/unfurl.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { renderAttachment } from '@/views/mastodon/attachments.ts'; @@ -30,6 +29,7 @@ async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise< : await accountFromPubkey(event.pubkey); const replyTag = findReplyTag(event.tags); + const quoteTag = findQuoteTag(event.tags); const mentionedPubkeys = [ ...new Set( @@ -73,8 +73,8 @@ async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise< const content = buildInlineRecipients(mentions) + html; - const cw = event.tags.find(isCWTag); - const subject = event.tags.find((tag) => tag[0] === 'subject'); + const cw = event.tags.find(([name]) => name === 'content-warning'); + const subject = event.tags.find(([name]) => name === 'subject'); const imeta: string[][][] = event.tags .filter(([name]) => name === 'imeta') @@ -88,7 +88,7 @@ async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise< card, content, created_at: nostrDate(event.created_at).toISOString(), - in_reply_to_id: replyTag ? replyTag[1] : null, + in_reply_to_id: replyTag?.[1] ?? null, in_reply_to_account_id: null, sensitive: !!cw, spoiler_text: (cw ? cw[1] : subject?.[1]) || '', @@ -110,7 +110,7 @@ async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise< emojis: renderEmojis(event), poll: null, quote: !event.quote ? null : await renderStatus(event.quote, { depth: depth + 1 }), - quote_id: event.tags.find(([name]) => name === 'q')?.[1] ?? null, + quote_id: quoteTag?.[1] ?? null, uri: Conf.external(note), url: Conf.external(note), zapped: Boolean(zapEvent),