diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts index 6d87548..a4b6c49 100644 --- a/src/lib/draft-event.ts +++ b/src/lib/draft-event.ts @@ -1,7 +1,7 @@ import { ApplicationDataKey, ExtendedKind } from '@/constants' import client from '@/services/client.service' import mediaUpload from '@/services/media-upload.service' -import { TDraftEvent, TEmoji, TMailboxRelay, TRelaySet } from '@/types' +import { TDraftEvent, TEmoji, TMailboxRelay, TMailboxRelayScope, TRelaySet } from '@/types' import dayjs from 'dayjs' import { Event, kinds, nip19 } from 'nostr-tools' import { @@ -11,24 +11,18 @@ import { isReplaceableEvent } from './event' import { generateBech32IdFromETag, tagNameEquals } from './tag' -import { normalizeHttpUrl } from './url' // https://github.com/nostr-protocol/nips/blob/master/25.md export function createReactionDraftEvent(event: Event, emoji: TEmoji | string = '+'): TDraftEvent { const tags: string[][] = [] - const hint = client.getEventHint(event.id) - tags.push(['e', event.id, hint, event.pubkey]) - tags.push(['p', event.pubkey]) + tags.push(buildETag(event.id, event.pubkey)) + tags.push(buildPTag(event.pubkey)) if (event.kind !== kinds.ShortTextNote) { - tags.push(['k', event.kind.toString()]) + tags.push(buildKTag(event.kind)) } if (isReplaceableEvent(event.kind)) { - tags.push( - hint - ? ['a', getReplaceableEventCoordinate(event), hint] - : ['a', getReplaceableEventCoordinate(event)] - ) + tags.push(buildATag(event)) } let content: string @@ -36,7 +30,7 @@ export function createReactionDraftEvent(event: Event, emoji: TEmoji | string = content = emoji } else { content = `:${emoji.shortcode}:` - tags.push(['emoji', emoji.shortcode, emoji.url]) + tags.push(buildEmojiTag(emoji)) } return { @@ -50,10 +44,7 @@ export function createReactionDraftEvent(event: Event, emoji: TEmoji | string = // https://github.com/nostr-protocol/nips/blob/master/18.md export function createRepostDraftEvent(event: Event): TDraftEvent { const isProtected = isProtectedEvent(event) - const tags = [ - ['e', event.id, client.getEventHint(event.id), '', event.pubkey], - ['p', event.pubkey] - ] + const tags = [buildETag(event.id, event.pubkey), buildPTag(event.pubkey)] return { kind: kinds.Repost, @@ -80,7 +71,7 @@ export async function createShortTextNoteDraftEvent( ) const hashtags = extractHashtags(content) - const tags = hashtags.map((hashtag) => ['t', hashtag]) + const tags = hashtags.map((hashtag) => buildTTag(hashtag)) // imeta tags const images = extractImagesFromContent(content) @@ -89,7 +80,7 @@ export async function createShortTextNoteDraftEvent( } // q tags - tags.push(...quoteEventIds.map((eventId) => ['q', eventId, client.getEventHint(eventId)])) + tags.push(...quoteEventIds.map((eventId) => buildQTag(eventId))) // e tags if (rootETag.length) { @@ -101,18 +92,18 @@ export async function createShortTextNoteDraftEvent( } // p tags - tags.push(...mentions.map((pubkey) => ['p', pubkey])) + tags.push(...mentions.map((pubkey) => buildPTag(pubkey))) if (options.addClientTag) { - tags.push(['client', 'jumble']) + tags.push(buildClientTag()) } if (options.isNsfw) { - tags.push(['content-warning', 'NSFW']) + tags.push(buildNsfwTag()) } if (options.protectedEvent) { - tags.push(['-']) + tags.push(buildProtectedTag()) } const baseDraft = { @@ -137,9 +128,9 @@ export function createRelaySetDraftEvent(relaySet: TRelaySet): TDraftEvent { kind: kinds.Relaysets, content: '', tags: [ - ['d', relaySet.id], - ['title', relaySet.name], - ...relaySet.relayUrls.map((url) => ['relay', url]) + buildDTag(relaySet.id), + buildTitleTag(relaySet.name), + ...relaySet.relayUrls.map((url) => buildRelayTag(url)) ], created_at: dayjs().unix() } @@ -161,17 +152,17 @@ export async function createPictureNoteDraftEvent( } const tags = pictureInfos - .map((info) => ['imeta', ...info.tags.map(([n, v]) => `${n} ${v}`)]) - .concat(hashtags.map((hashtag) => ['t', hashtag])) - .concat(quoteEventIds.map((eventId) => ['q', eventId, client.getEventHint(eventId)])) - .concat(mentions.map((pubkey) => ['p', pubkey])) + .map((info) => buildImetaTag(info.tags)) + .concat(hashtags.map((hashtag) => buildTTag(hashtag))) + .concat(quoteEventIds.map((eventId) => buildQTag(eventId))) + .concat(mentions.map((pubkey) => buildPTag(pubkey))) if (options.addClientTag) { - tags.push(['client', 'jumble']) + tags.push(buildClientTag()) } if (options.protectedEvent) { - tags.push(['-']) + tags.push(buildProtectedTag()) } return { @@ -193,69 +184,57 @@ export async function createCommentDraftEvent( isNsfw?: boolean } = {} ): Promise { - const { - quoteEventIds, - rootEventId, - rootCoordinateTag, - rootKind, - rootPubkey, - rootUrl, - parentEventId, - parentCoordinate, - parentKind, - parentPubkey - } = await extractCommentMentions(content, parentEvent) + const { quoteEventIds, rootEventId, rootCoordinateTag, rootKind, rootPubkey, rootUrl } = + await extractCommentMentions(content, parentEvent) const hashtags = extractHashtags(content) const tags = hashtags - .map((hashtag) => ['t', hashtag]) - .concat(quoteEventIds.map((eventId) => ['q', eventId, client.getEventHint(eventId)])) + .map((hashtag) => buildTTag(hashtag)) + .concat(quoteEventIds.map((eventId) => buildQTag(eventId))) const images = extractImagesFromContent(content) if (images && images.length) { tags.push(...generateImetaTags(images)) } - tags.push(...mentions.filter((pubkey) => pubkey !== parentPubkey).map((pubkey) => ['p', pubkey])) + tags.push( + ...mentions.filter((pubkey) => pubkey !== parentEvent.pubkey).map((pubkey) => buildPTag(pubkey)) + ) if (rootCoordinateTag) { tags.push(rootCoordinateTag) } else if (rootEventId) { - tags.push( - rootPubkey - ? ['E', rootEventId, client.getEventHint(rootEventId), rootPubkey] - : ['E', rootEventId, client.getEventHint(rootEventId)] - ) + tags.push(buildETag(rootEventId, rootPubkey, '', true)) } if (rootPubkey) { - tags.push(['P', rootPubkey]) + tags.push(buildPTag(rootPubkey, true)) } if (rootKind) { - tags.push(['K', rootKind.toString()]) + tags.push(buildKTag(rootKind, true)) } if (rootUrl) { - tags.push(['I', rootUrl]) + tags.push(buildITag(rootUrl, true)) } tags.push( ...[ - parentCoordinate - ? ['a', parentCoordinate, client.getEventHint(parentEventId)] - : ['e', parentEventId, client.getEventHint(parentEventId), parentPubkey], - ['k', parentKind.toString()], - ['p', parentPubkey] + isReplaceableEvent(parentEvent.kind) + ? buildATag(parentEvent) + : buildETag(parentEvent.id, parentEvent.pubkey), + buildKTag(parentEvent.kind), + buildPTag(parentEvent.pubkey) ] ) if (options.addClientTag) { - tags.push(['client', 'jumble']) + tags.push(buildClientTag()) } if (options.isNsfw) { - tags.push(['content-warning', 'NSFW']) + tags.push(buildNsfwTag()) } if (options.protectedEvent) { - tags.push(['-']) + tags.push(buildProtectedTag()) } const baseDraft = { @@ -278,9 +257,7 @@ export function createRelayListDraftEvent(mailboxRelays: TMailboxRelay[]): TDraf return { kind: kinds.RelayList, content: '', - tags: mailboxRelays.map(({ url, scope }) => - scope === 'both' ? ['r', url] : ['r', url, scope] - ), + tags: mailboxRelays.map(({ url, scope }) => buildRTag(url, scope)), created_at: dayjs().unix() } } @@ -318,10 +295,10 @@ export function createFavoriteRelaysDraftEvent( ): TDraftEvent { const tags: string[][] = [] favoriteRelays.forEach((url) => { - tags.push(['relay', url]) + tags.push(buildRelayTag(url)) }) relaySetEvents.forEach((event) => { - tags.push(['a', getReplaceableEventCoordinate(event)]) + tags.push(buildATag(event)) }) return { kind: ExtendedKind.FAVORITE_RELAYS, @@ -335,7 +312,7 @@ export function createSeenNotificationsAtDraftEvent(): TDraftEvent { return { kind: kinds.Application, content: 'Records read time to sync notification status across devices.', - tags: [['d', ApplicationDataKey.NOTIFICATIONS_SEEN_AT]], + tags: [buildDTag(ApplicationDataKey.NOTIFICATIONS_SEEN_AT)], created_at: dayjs().unix() } } @@ -353,7 +330,7 @@ export function createBlossomServerListDraftEvent(servers: string[]): TDraftEven return { kind: ExtendedKind.BLOSSOM_SERVER_LIST, content: '', - tags: servers.map((server) => ['server', normalizeHttpUrl(server)]), + tags: servers.map((server) => buildServerTag(server)), created_at: dayjs().unix() } } @@ -394,39 +371,21 @@ async function extractRelatedEventIds(content: string, parentEvent?: Event) { if (parentEvent) { const _rootETag = getRootETag(parentEvent) if (_rootETag) { - parentETag = [ - 'e', - parentEvent.id, - client.getEventHint(parentEvent.id), - 'reply', - parentEvent.pubkey - ] + parentETag = buildETagWithMarker(parentEvent.id, parentEvent.pubkey, '', 'reply') const [, rootEventHexId, hint, , rootEventPubkey] = _rootETag if (rootEventPubkey) { - rootETag = [ - 'e', - rootEventHexId, - hint ?? client.getEventHint(rootEventHexId), - 'root', - rootEventPubkey - ] + rootETag = buildETagWithMarker(rootEventHexId, rootEventPubkey, hint, 'root') } else { const rootEventId = generateBech32IdFromETag(_rootETag) const rootEvent = rootEventId ? await client.fetchEvent(rootEventId) : undefined rootETag = rootEvent - ? ['e', rootEvent.id, hint ?? client.getEventHint(rootEvent.id), 'root', rootEvent.pubkey] - : ['e', rootEventHexId, hint ?? client.getEventHint(rootEventHexId), 'root'] + ? buildETagWithMarker(rootEvent.id, rootEvent.pubkey, hint, 'root') + : buildETagWithMarker(rootEventHexId, rootEventPubkey, hint, 'root') } } else { // reply to root event - rootETag = [ - 'e', - parentEvent.id, - client.getEventHint(parentEvent.id), - 'root', - parentEvent.pubkey - ] + rootETag = buildETagWithMarker(parentEvent.id, parentEvent.pubkey, '', 'root') } } @@ -439,12 +398,11 @@ async function extractRelatedEventIds(content: string, parentEvent?: Event) { async function extractCommentMentions(content: string, parentEvent: Event) { const quoteEventIds: string[] = [] - const parentEventIsReplaceable = isReplaceableEvent(parentEvent.kind) const rootCoordinateTag = parentEvent.kind === ExtendedKind.COMMENT ? parentEvent.tags.find(tagNameEquals('A')) : isReplaceableEvent(parentEvent.kind) - ? ['A', getReplaceableEventCoordinate(parentEvent), client.getEventHint(parentEvent.id)] + ? buildATag(parentEvent, true) : undefined const rootEventId = parentEvent.kind === ExtendedKind.COMMENT @@ -463,13 +421,6 @@ async function extractCommentMentions(content: string, parentEvent: Event) { ? parentEvent.tags.find(tagNameEquals('I'))?.[1] : undefined - const parentEventId = parentEvent.id - const parentCoordinate = parentEventIsReplaceable - ? getReplaceableEventCoordinate(parentEvent) - : undefined - const parentKind = parentEvent.kind - const parentPubkey = parentEvent.pubkey - const addToSet = (arr: string[], item: string) => { if (!arr.includes(item)) arr.push(item) } @@ -496,10 +447,7 @@ async function extractCommentMentions(content: string, parentEvent: Event) { rootKind, rootPubkey, rootUrl, - parentEventId, - parentCoordinate, - parentKind, - parentPubkey + parentEvent } } @@ -518,3 +466,102 @@ function extractHashtags(content: string) { function extractImagesFromContent(content: string) { return content.match(/https?:\/\/[^\s"']+\.(jpg|jpeg|png|gif|webp|heic)/gi) } + +function buildATag(event: Event, upperCase: boolean = false) { + const coordinate = getReplaceableEventCoordinate(event) + const hint = client.getEventHint(event.id) + return trimTagEnd([upperCase ? 'A' : 'a', coordinate, hint]) +} + +function buildDTag(identifier: string) { + return ['d', identifier] +} + +function buildETag( + eventHexId: string, + pubkey: string = '', + hint: string = '', + upperCase: boolean = false +) { + if (!hint) { + hint = client.getEventHint(eventHexId) + } + return trimTagEnd([upperCase ? 'E' : 'e', eventHexId, hint, pubkey]) +} + +function buildETagWithMarker( + eventHexId: string, + pubkey: string = '', + hint: string = '', + marker: 'root' | 'reply' | '' = '' +) { + if (!hint) { + hint = client.getEventHint(eventHexId) + } + return trimTagEnd(['e', eventHexId, hint, marker, pubkey]) +} + +function buildITag(url: string, upperCase: boolean = false) { + return [upperCase ? 'I' : 'i', url] +} + +function buildKTag(kind: number | string, upperCase: boolean = false) { + return [upperCase ? 'K' : 'k', kind.toString()] +} + +function buildPTag(pubkey: string, upperCase: boolean = false) { + return [upperCase ? 'P' : 'p', pubkey] +} + +function buildQTag(eventHexId: string) { + return trimTagEnd(['q', eventHexId, client.getEventHint(eventHexId)]) // TODO: pubkey +} + +function buildRTag(url: string, scope: TMailboxRelayScope) { + return scope === 'both' ? ['r', url, scope] : ['r', url] +} + +function buildTTag(hashtag: string) { + return ['t', hashtag] +} + +function buildEmojiTag(emoji: TEmoji) { + return ['emoji', emoji.shortcode, emoji.url] +} + +function buildTitleTag(title: string) { + return ['title', title] +} + +function buildRelayTag(url: string) { + return ['relay', url] +} + +function buildServerTag(url: string) { + return ['server', url] +} + +function buildImetaTag(nip94Tags: string[][]) { + return ['imeta', ...nip94Tags.map(([n, v]) => `${n} ${v}`)] +} + +function buildClientTag() { + return ['client', 'jumble'] +} + +function buildNsfwTag() { + return ['content-warning', 'NSFW'] +} + +function buildProtectedTag() { + return ['-'] +} + +function trimTagEnd(tag: string[]) { + let endIndex = tag.length - 1 + while (endIndex >= 0 && tag[endIndex] === '') { + endIndex-- + } + + return tag.slice(0, endIndex + 1) +} diff --git a/src/pages/secondary/PostSettingsPage/BlossomServerListSetting.tsx b/src/pages/secondary/PostSettingsPage/BlossomServerListSetting.tsx index 48e6be8..44f1717 100644 --- a/src/pages/secondary/PostSettingsPage/BlossomServerListSetting.tsx +++ b/src/pages/secondary/PostSettingsPage/BlossomServerListSetting.tsx @@ -5,6 +5,7 @@ import { Separator } from '@/components/ui/separator' import { RECOMMENDED_BLOSSOM_SERVERS } from '@/constants' import { createBlossomServerListDraftEvent } from '@/lib/draft-event' import { getServersFromServerTags } from '@/lib/tag' +import { normalizeHttpUrl } from '@/lib/url' import { cn } from '@/lib/utils' import { useNostr } from '@/providers/NostrProvider' import client from '@/services/client.service' @@ -56,7 +57,9 @@ export default function BlossomServerListSetting() { const handleUrlInputKeyDown = (event: React.KeyboardEvent) => { if (event.key === 'Enter') { event.preventDefault() - addBlossomUrl(url) + const normalizedUrl = normalizeHttpUrl(url.trim()) + if (!normalizedUrl) return + addBlossomUrl(normalizedUrl) } } @@ -167,7 +170,15 @@ export default function BlossomServerListSetting() { placeholder={t('Enter Blossom server URL')} onKeyDown={handleUrlInputKeyDown} /> -