refactor: extract tag building functions

This commit is contained in:
codytseng
2025-07-22 23:10:02 +08:00
parent 6d5d4d36c1
commit c511f5cb5a
2 changed files with 165 additions and 107 deletions

View File

@@ -1,7 +1,7 @@
import { ApplicationDataKey, ExtendedKind } from '@/constants' import { ApplicationDataKey, ExtendedKind } from '@/constants'
import client from '@/services/client.service' import client from '@/services/client.service'
import mediaUpload from '@/services/media-upload.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 dayjs from 'dayjs'
import { Event, kinds, nip19 } from 'nostr-tools' import { Event, kinds, nip19 } from 'nostr-tools'
import { import {
@@ -11,24 +11,18 @@ import {
isReplaceableEvent isReplaceableEvent
} from './event' } from './event'
import { generateBech32IdFromETag, tagNameEquals } from './tag' import { generateBech32IdFromETag, tagNameEquals } from './tag'
import { normalizeHttpUrl } from './url'
// https://github.com/nostr-protocol/nips/blob/master/25.md // https://github.com/nostr-protocol/nips/blob/master/25.md
export function createReactionDraftEvent(event: Event, emoji: TEmoji | string = '+'): TDraftEvent { export function createReactionDraftEvent(event: Event, emoji: TEmoji | string = '+'): TDraftEvent {
const tags: string[][] = [] const tags: string[][] = []
const hint = client.getEventHint(event.id) tags.push(buildETag(event.id, event.pubkey))
tags.push(['e', event.id, hint, event.pubkey]) tags.push(buildPTag(event.pubkey))
tags.push(['p', event.pubkey])
if (event.kind !== kinds.ShortTextNote) { if (event.kind !== kinds.ShortTextNote) {
tags.push(['k', event.kind.toString()]) tags.push(buildKTag(event.kind))
} }
if (isReplaceableEvent(event.kind)) { if (isReplaceableEvent(event.kind)) {
tags.push( tags.push(buildATag(event))
hint
? ['a', getReplaceableEventCoordinate(event), hint]
: ['a', getReplaceableEventCoordinate(event)]
)
} }
let content: string let content: string
@@ -36,7 +30,7 @@ export function createReactionDraftEvent(event: Event, emoji: TEmoji | string =
content = emoji content = emoji
} else { } else {
content = `:${emoji.shortcode}:` content = `:${emoji.shortcode}:`
tags.push(['emoji', emoji.shortcode, emoji.url]) tags.push(buildEmojiTag(emoji))
} }
return { return {
@@ -50,10 +44,7 @@ export function createReactionDraftEvent(event: Event, emoji: TEmoji | string =
// https://github.com/nostr-protocol/nips/blob/master/18.md // https://github.com/nostr-protocol/nips/blob/master/18.md
export function createRepostDraftEvent(event: Event): TDraftEvent { export function createRepostDraftEvent(event: Event): TDraftEvent {
const isProtected = isProtectedEvent(event) const isProtected = isProtectedEvent(event)
const tags = [ const tags = [buildETag(event.id, event.pubkey), buildPTag(event.pubkey)]
['e', event.id, client.getEventHint(event.id), '', event.pubkey],
['p', event.pubkey]
]
return { return {
kind: kinds.Repost, kind: kinds.Repost,
@@ -80,7 +71,7 @@ export async function createShortTextNoteDraftEvent(
) )
const hashtags = extractHashtags(content) const hashtags = extractHashtags(content)
const tags = hashtags.map((hashtag) => ['t', hashtag]) const tags = hashtags.map((hashtag) => buildTTag(hashtag))
// imeta tags // imeta tags
const images = extractImagesFromContent(content) const images = extractImagesFromContent(content)
@@ -89,7 +80,7 @@ export async function createShortTextNoteDraftEvent(
} }
// q tags // q tags
tags.push(...quoteEventIds.map((eventId) => ['q', eventId, client.getEventHint(eventId)])) tags.push(...quoteEventIds.map((eventId) => buildQTag(eventId)))
// e tags // e tags
if (rootETag.length) { if (rootETag.length) {
@@ -101,18 +92,18 @@ export async function createShortTextNoteDraftEvent(
} }
// p tags // p tags
tags.push(...mentions.map((pubkey) => ['p', pubkey])) tags.push(...mentions.map((pubkey) => buildPTag(pubkey)))
if (options.addClientTag) { if (options.addClientTag) {
tags.push(['client', 'jumble']) tags.push(buildClientTag())
} }
if (options.isNsfw) { if (options.isNsfw) {
tags.push(['content-warning', 'NSFW']) tags.push(buildNsfwTag())
} }
if (options.protectedEvent) { if (options.protectedEvent) {
tags.push(['-']) tags.push(buildProtectedTag())
} }
const baseDraft = { const baseDraft = {
@@ -137,9 +128,9 @@ export function createRelaySetDraftEvent(relaySet: TRelaySet): TDraftEvent {
kind: kinds.Relaysets, kind: kinds.Relaysets,
content: '', content: '',
tags: [ tags: [
['d', relaySet.id], buildDTag(relaySet.id),
['title', relaySet.name], buildTitleTag(relaySet.name),
...relaySet.relayUrls.map((url) => ['relay', url]) ...relaySet.relayUrls.map((url) => buildRelayTag(url))
], ],
created_at: dayjs().unix() created_at: dayjs().unix()
} }
@@ -161,17 +152,17 @@ export async function createPictureNoteDraftEvent(
} }
const tags = pictureInfos const tags = pictureInfos
.map((info) => ['imeta', ...info.tags.map(([n, v]) => `${n} ${v}`)]) .map((info) => buildImetaTag(info.tags))
.concat(hashtags.map((hashtag) => ['t', hashtag])) .concat(hashtags.map((hashtag) => buildTTag(hashtag)))
.concat(quoteEventIds.map((eventId) => ['q', eventId, client.getEventHint(eventId)])) .concat(quoteEventIds.map((eventId) => buildQTag(eventId)))
.concat(mentions.map((pubkey) => ['p', pubkey])) .concat(mentions.map((pubkey) => buildPTag(pubkey)))
if (options.addClientTag) { if (options.addClientTag) {
tags.push(['client', 'jumble']) tags.push(buildClientTag())
} }
if (options.protectedEvent) { if (options.protectedEvent) {
tags.push(['-']) tags.push(buildProtectedTag())
} }
return { return {
@@ -193,69 +184,57 @@ export async function createCommentDraftEvent(
isNsfw?: boolean isNsfw?: boolean
} = {} } = {}
): Promise<TDraftEvent> { ): Promise<TDraftEvent> {
const { const { quoteEventIds, rootEventId, rootCoordinateTag, rootKind, rootPubkey, rootUrl } =
quoteEventIds, await extractCommentMentions(content, parentEvent)
rootEventId,
rootCoordinateTag,
rootKind,
rootPubkey,
rootUrl,
parentEventId,
parentCoordinate,
parentKind,
parentPubkey
} = await extractCommentMentions(content, parentEvent)
const hashtags = extractHashtags(content) const hashtags = extractHashtags(content)
const tags = hashtags const tags = hashtags
.map((hashtag) => ['t', hashtag]) .map((hashtag) => buildTTag(hashtag))
.concat(quoteEventIds.map((eventId) => ['q', eventId, client.getEventHint(eventId)])) .concat(quoteEventIds.map((eventId) => buildQTag(eventId)))
const images = extractImagesFromContent(content) const images = extractImagesFromContent(content)
if (images && images.length) { if (images && images.length) {
tags.push(...generateImetaTags(images)) 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) { if (rootCoordinateTag) {
tags.push(rootCoordinateTag) tags.push(rootCoordinateTag)
} else if (rootEventId) { } else if (rootEventId) {
tags.push( tags.push(buildETag(rootEventId, rootPubkey, '', true))
rootPubkey
? ['E', rootEventId, client.getEventHint(rootEventId), rootPubkey]
: ['E', rootEventId, client.getEventHint(rootEventId)]
)
} }
if (rootPubkey) { if (rootPubkey) {
tags.push(['P', rootPubkey]) tags.push(buildPTag(rootPubkey, true))
} }
if (rootKind) { if (rootKind) {
tags.push(['K', rootKind.toString()]) tags.push(buildKTag(rootKind, true))
} }
if (rootUrl) { if (rootUrl) {
tags.push(['I', rootUrl]) tags.push(buildITag(rootUrl, true))
} }
tags.push( tags.push(
...[ ...[
parentCoordinate isReplaceableEvent(parentEvent.kind)
? ['a', parentCoordinate, client.getEventHint(parentEventId)] ? buildATag(parentEvent)
: ['e', parentEventId, client.getEventHint(parentEventId), parentPubkey], : buildETag(parentEvent.id, parentEvent.pubkey),
['k', parentKind.toString()], buildKTag(parentEvent.kind),
['p', parentPubkey] buildPTag(parentEvent.pubkey)
] ]
) )
if (options.addClientTag) { if (options.addClientTag) {
tags.push(['client', 'jumble']) tags.push(buildClientTag())
} }
if (options.isNsfw) { if (options.isNsfw) {
tags.push(['content-warning', 'NSFW']) tags.push(buildNsfwTag())
} }
if (options.protectedEvent) { if (options.protectedEvent) {
tags.push(['-']) tags.push(buildProtectedTag())
} }
const baseDraft = { const baseDraft = {
@@ -278,9 +257,7 @@ export function createRelayListDraftEvent(mailboxRelays: TMailboxRelay[]): TDraf
return { return {
kind: kinds.RelayList, kind: kinds.RelayList,
content: '', content: '',
tags: mailboxRelays.map(({ url, scope }) => tags: mailboxRelays.map(({ url, scope }) => buildRTag(url, scope)),
scope === 'both' ? ['r', url] : ['r', url, scope]
),
created_at: dayjs().unix() created_at: dayjs().unix()
} }
} }
@@ -318,10 +295,10 @@ export function createFavoriteRelaysDraftEvent(
): TDraftEvent { ): TDraftEvent {
const tags: string[][] = [] const tags: string[][] = []
favoriteRelays.forEach((url) => { favoriteRelays.forEach((url) => {
tags.push(['relay', url]) tags.push(buildRelayTag(url))
}) })
relaySetEvents.forEach((event) => { relaySetEvents.forEach((event) => {
tags.push(['a', getReplaceableEventCoordinate(event)]) tags.push(buildATag(event))
}) })
return { return {
kind: ExtendedKind.FAVORITE_RELAYS, kind: ExtendedKind.FAVORITE_RELAYS,
@@ -335,7 +312,7 @@ export function createSeenNotificationsAtDraftEvent(): TDraftEvent {
return { return {
kind: kinds.Application, kind: kinds.Application,
content: 'Records read time to sync notification status across devices.', 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() created_at: dayjs().unix()
} }
} }
@@ -353,7 +330,7 @@ export function createBlossomServerListDraftEvent(servers: string[]): TDraftEven
return { return {
kind: ExtendedKind.BLOSSOM_SERVER_LIST, kind: ExtendedKind.BLOSSOM_SERVER_LIST,
content: '', content: '',
tags: servers.map((server) => ['server', normalizeHttpUrl(server)]), tags: servers.map((server) => buildServerTag(server)),
created_at: dayjs().unix() created_at: dayjs().unix()
} }
} }
@@ -394,39 +371,21 @@ async function extractRelatedEventIds(content: string, parentEvent?: Event) {
if (parentEvent) { if (parentEvent) {
const _rootETag = getRootETag(parentEvent) const _rootETag = getRootETag(parentEvent)
if (_rootETag) { if (_rootETag) {
parentETag = [ parentETag = buildETagWithMarker(parentEvent.id, parentEvent.pubkey, '', 'reply')
'e',
parentEvent.id,
client.getEventHint(parentEvent.id),
'reply',
parentEvent.pubkey
]
const [, rootEventHexId, hint, , rootEventPubkey] = _rootETag const [, rootEventHexId, hint, , rootEventPubkey] = _rootETag
if (rootEventPubkey) { if (rootEventPubkey) {
rootETag = [ rootETag = buildETagWithMarker(rootEventHexId, rootEventPubkey, hint, 'root')
'e',
rootEventHexId,
hint ?? client.getEventHint(rootEventHexId),
'root',
rootEventPubkey
]
} else { } else {
const rootEventId = generateBech32IdFromETag(_rootETag) const rootEventId = generateBech32IdFromETag(_rootETag)
const rootEvent = rootEventId ? await client.fetchEvent(rootEventId) : undefined const rootEvent = rootEventId ? await client.fetchEvent(rootEventId) : undefined
rootETag = rootEvent rootETag = rootEvent
? ['e', rootEvent.id, hint ?? client.getEventHint(rootEvent.id), 'root', rootEvent.pubkey] ? buildETagWithMarker(rootEvent.id, rootEvent.pubkey, hint, 'root')
: ['e', rootEventHexId, hint ?? client.getEventHint(rootEventHexId), 'root'] : buildETagWithMarker(rootEventHexId, rootEventPubkey, hint, 'root')
} }
} else { } else {
// reply to root event // reply to root event
rootETag = [ rootETag = buildETagWithMarker(parentEvent.id, parentEvent.pubkey, '', 'root')
'e',
parentEvent.id,
client.getEventHint(parentEvent.id),
'root',
parentEvent.pubkey
]
} }
} }
@@ -439,12 +398,11 @@ async function extractRelatedEventIds(content: string, parentEvent?: Event) {
async function extractCommentMentions(content: string, parentEvent: Event) { async function extractCommentMentions(content: string, parentEvent: Event) {
const quoteEventIds: string[] = [] const quoteEventIds: string[] = []
const parentEventIsReplaceable = isReplaceableEvent(parentEvent.kind)
const rootCoordinateTag = const rootCoordinateTag =
parentEvent.kind === ExtendedKind.COMMENT parentEvent.kind === ExtendedKind.COMMENT
? parentEvent.tags.find(tagNameEquals('A')) ? parentEvent.tags.find(tagNameEquals('A'))
: isReplaceableEvent(parentEvent.kind) : isReplaceableEvent(parentEvent.kind)
? ['A', getReplaceableEventCoordinate(parentEvent), client.getEventHint(parentEvent.id)] ? buildATag(parentEvent, true)
: undefined : undefined
const rootEventId = const rootEventId =
parentEvent.kind === ExtendedKind.COMMENT parentEvent.kind === ExtendedKind.COMMENT
@@ -463,13 +421,6 @@ async function extractCommentMentions(content: string, parentEvent: Event) {
? parentEvent.tags.find(tagNameEquals('I'))?.[1] ? parentEvent.tags.find(tagNameEquals('I'))?.[1]
: undefined : 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) => { const addToSet = (arr: string[], item: string) => {
if (!arr.includes(item)) arr.push(item) if (!arr.includes(item)) arr.push(item)
} }
@@ -496,10 +447,7 @@ async function extractCommentMentions(content: string, parentEvent: Event) {
rootKind, rootKind,
rootPubkey, rootPubkey,
rootUrl, rootUrl,
parentEventId, parentEvent
parentCoordinate,
parentKind,
parentPubkey
} }
} }
@@ -518,3 +466,102 @@ function extractHashtags(content: string) {
function extractImagesFromContent(content: string) { function extractImagesFromContent(content: string) {
return content.match(/https?:\/\/[^\s"']+\.(jpg|jpeg|png|gif|webp|heic)/gi) 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)
}

View File

@@ -5,6 +5,7 @@ import { Separator } from '@/components/ui/separator'
import { RECOMMENDED_BLOSSOM_SERVERS } from '@/constants' import { RECOMMENDED_BLOSSOM_SERVERS } from '@/constants'
import { createBlossomServerListDraftEvent } from '@/lib/draft-event' import { createBlossomServerListDraftEvent } from '@/lib/draft-event'
import { getServersFromServerTags } from '@/lib/tag' import { getServersFromServerTags } from '@/lib/tag'
import { normalizeHttpUrl } from '@/lib/url'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
@@ -56,7 +57,9 @@ export default function BlossomServerListSetting() {
const handleUrlInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => { const handleUrlInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') { if (event.key === 'Enter') {
event.preventDefault() 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')} placeholder={t('Enter Blossom server URL')}
onKeyDown={handleUrlInputKeyDown} onKeyDown={handleUrlInputKeyDown}
/> />
<Button type="button" onClick={() => addBlossomUrl(url)} title={t('Add')}> <Button
type="button"
onClick={() => {
const normalizedUrl = normalizeHttpUrl(url.trim())
if (!normalizedUrl) return
addBlossomUrl(normalizedUrl)
}}
title={t('Add')}
>
{adding && <Loader className="animate-spin" />} {adding && <Loader className="animate-spin" />}
{t('Add')} {t('Add')}
</Button> </Button>