feat: add support for commenting and reacting on external content

This commit is contained in:
codytseng 2025-11-15 16:26:19 +08:00
parent 5ba5c26fcd
commit 0bb62dd3fb
76 changed files with 1635 additions and 639 deletions

View file

@ -6,6 +6,7 @@ import {
LN_INVOICE_REGEX,
URL_REGEX,
WS_URL_REGEX,
X_URL_REGEX,
YOUTUBE_URL_REGEX
} from '@/constants'
import { isImage, isMedia } from './url'
@ -24,6 +25,7 @@ export type TEmbeddedNodeType =
| 'emoji'
| 'invoice'
| 'youtube'
| 'x-post'
export type TEmbeddedNode =
| {
@ -96,6 +98,8 @@ export const EmbeddedUrlParser: TContentParser = (content: string) => {
type = 'media'
} else if (url.match(YOUTUBE_URL_REGEX)) {
type = 'youtube'
} else if (url.match(X_URL_REGEX)) {
type = 'x-post'
}
// Add the match as specific type

View file

@ -20,6 +20,7 @@ import {
isProtectedEvent,
isReplaceableEvent
} from './event'
import { determineExternalContentKind } from './external-content'
import { randomString } from './random'
import { generateBech32IdFromETag, tagNameEquals } from './tag'
@ -85,6 +86,33 @@ export function createReactionDraftEvent(event: Event, emoji: TEmoji | string =
}
}
export function createExternalContentReactionDraftEvent(
externalContent: string,
emoji: TEmoji | string = '+'
): TDraftEvent {
const tags: string[][] = []
tags.push(buildITag(externalContent))
const kind = determineExternalContentKind(externalContent)
if (kind) {
tags.push(buildKTag(kind))
}
let content: string
if (typeof emoji === 'string') {
content = emoji
} else {
content = `:${emoji.shortcode}:`
tags.push(buildEmojiTag(emoji))
}
return {
kind: ExtendedKind.EXTERNAL_CONTENT_REACTION,
content,
tags,
created_at: dayjs().unix()
}
}
// https://github.com/nostr-protocol/nips/blob/master/18.md
export function createRepostDraftEvent(event: Event): TDraftEvent {
const isProtected = isProtectedEvent(event)
@ -177,7 +205,7 @@ export function createRelaySetDraftEvent(relaySet: Omit<TRelaySet, 'aTag'>): TDr
export async function createCommentDraftEvent(
content: string,
parentEvent: Event,
parentStuff: Event | string,
mentions: string[],
options: {
addClientTag?: boolean
@ -193,8 +221,10 @@ export async function createCommentDraftEvent(
rootCoordinateTag,
rootKind,
rootPubkey,
rootUrl
} = await extractCommentMentions(transformedEmojisContent, parentEvent)
rootUrl,
parentEvent,
externalContent
} = await extractCommentMentions(transformedEmojisContent, parentStuff)
const hashtags = extractHashtags(transformedEmojisContent)
const tags = emojiTags
@ -208,7 +238,9 @@ export async function createCommentDraftEvent(
}
tags.push(
...mentions.filter((pubkey) => pubkey !== parentEvent.pubkey).map((pubkey) => buildPTag(pubkey))
...mentions
.filter((pubkey) => pubkey !== parentEvent?.pubkey)
.map((pubkey) => buildPTag(pubkey))
)
if (rootCoordinateTag) {
@ -226,14 +258,25 @@ export async function createCommentDraftEvent(
tags.push(buildITag(rootUrl, true))
}
tags.push(
...[
isReplaceableEvent(parentEvent.kind)
? buildATag(parentEvent)
: buildETag(parentEvent.id, parentEvent.pubkey),
buildKTag(parentEvent.kind),
buildPTag(parentEvent.pubkey)
]
...(parentEvent
? [
isReplaceableEvent(parentEvent.kind)
? buildATag(parentEvent)
: buildETag(parentEvent.id, parentEvent.pubkey),
buildPTag(parentEvent.pubkey)
]
: externalContent
? [buildITag(externalContent)]
: [])
)
const parentKind = parentEvent
? parentEvent.kind
: externalContent
? determineExternalContentKind(externalContent)
: undefined
if (parentKind) {
tags.push(buildKTag(parentKind))
}
if (options.addClientTag) {
tags.push(buildClientTag())
@ -580,19 +623,32 @@ async function extractRelatedEventIds(content: string, parentEvent?: Event) {
}
}
async function extractCommentMentions(content: string, parentEvent: Event) {
async function extractCommentMentions(content: string, parentStuff: Event | string) {
const quoteEventHexIds: string[] = []
const quoteReplaceableCoordinates: string[] = []
const isComment = [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT].includes(parentEvent.kind)
const rootCoordinateTag = isComment
? parentEvent.tags.find(tagNameEquals('A'))
: isReplaceableEvent(parentEvent.kind)
? buildATag(parentEvent, true)
: undefined
const rootEventId = isComment ? parentEvent.tags.find(tagNameEquals('E'))?.[1] : parentEvent.id
const rootKind = isComment ? parentEvent.tags.find(tagNameEquals('K'))?.[1] : parentEvent.kind
const rootPubkey = isComment ? parentEvent.tags.find(tagNameEquals('P'))?.[1] : parentEvent.pubkey
const rootUrl = isComment ? parentEvent.tags.find(tagNameEquals('I'))?.[1] : undefined
const { parentEvent, externalContent } =
typeof parentStuff === 'string'
? { parentEvent: undefined, externalContent: parentStuff }
: { parentEvent: parentStuff, externalContent: undefined }
const isComment =
parentEvent && [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT].includes(parentEvent.kind)
const rootCoordinateTag = parentEvent
? isComment
? parentEvent.tags.find(tagNameEquals('A'))
: isReplaceableEvent(parentEvent.kind)
? buildATag(parentEvent, true)
: undefined
: undefined
const rootEventId = isComment ? parentEvent.tags.find(tagNameEquals('E'))?.[1] : parentEvent?.id
const rootKind = isComment
? parentEvent.tags.find(tagNameEquals('K'))?.[1]
: parentEvent
? parentEvent.kind
: determineExternalContentKind(parentStuff as string)
const rootPubkey = isComment
? parentEvent.tags.find(tagNameEquals('P'))?.[1]
: parentEvent?.pubkey
const rootUrl = isComment ? parentEvent.tags.find(tagNameEquals('I'))?.[1] : externalContent
const addToSet = (arr: string[], item: string) => {
if (!arr.includes(item)) arr.push(item)
@ -626,7 +682,8 @@ async function extractCommentMentions(content: string, parentEvent: Event) {
rootKind,
rootPubkey,
rootUrl,
parentEvent
parentEvent,
externalContent
}
}

View file

@ -94,12 +94,23 @@ export function getParentATag(event?: Event) {
return event.tags.find(tagNameEquals('a')) ?? event.tags.find(tagNameEquals('A'))
}
export function getParentITag(event?: Event) {
if (
!event ||
![kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT].includes(event.kind)
) {
return undefined
}
return event.tags.find(tagNameEquals('i')) ?? event.tags.find(tagNameEquals('I'))
}
export function getParentEventHexId(event?: Event) {
const tag = getParentETag(event)
return tag?.[1]
}
export function getParentTag(event?: Event): { type: 'e' | 'a'; tag: string[] } | undefined {
export function getParentTag(event?: Event): { type: 'e' | 'a' | 'i'; tag: string[] } | undefined {
if (!event) return undefined
if (event.kind === kinds.ShortTextNote) {
@ -114,8 +125,13 @@ export function getParentTag(event?: Event): { type: 'e' | 'a'; tag: string[] }
return tag ? { type: 'a', tag } : undefined
}
const tag = getParentETag(event)
return tag ? { type: 'e', tag } : undefined
const parentETag = getParentETag(event)
if (parentETag) {
return { type: 'e', tag: parentETag }
}
const parentITag = getParentITag(event)
return parentITag ? { type: 'i', tag: parentITag } : undefined
}
export function getParentBech32Id(event?: Event) {
@ -159,12 +175,23 @@ export function getRootATag(event?: Event) {
return event.tags.find(tagNameEquals('A'))
}
export function getRootITag(event?: Event) {
if (
!event ||
![kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT].includes(event.kind)
) {
return undefined
}
return event.tags.find(tagNameEquals('I'))
}
export function getRootEventHexId(event?: Event) {
const tag = getRootETag(event)
return tag?.[1]
}
export function getRootTag(event?: Event): { type: 'e' | 'a'; tag: string[] } | undefined {
export function getRootTag(event?: Event): { type: 'e' | 'a' | 'i'; tag: string[] } | undefined {
if (!event) return undefined
if (event.kind === kinds.ShortTextNote) {
@ -179,8 +206,13 @@ export function getRootTag(event?: Event): { type: 'e' | 'a'; tag: string[] } |
return tag ? { type: 'a', tag } : undefined
}
const tag = getRootETag(event)
return tag ? { type: 'e', tag } : undefined
const rootETag = getRootETag(event)
if (rootETag) {
return { type: 'e', tag: rootETag }
}
const rootITag = getRootITag(event)
return rootITag ? { type: 'i', tag: rootITag } : undefined
}
export function getRootBech32Id(event?: Event) {
@ -192,13 +224,21 @@ export function getRootBech32Id(event?: Event) {
: generateBech32IdFromATag(rootTag.tag)
}
export function getParentStuff(event: Event) {
const parentEventId = getParentBech32Id(event)
if (parentEventId) return { parentEventId }
const parentITag = getParentITag(event)
return { parentExternalContent: parentITag?.[1] }
}
// For internal identification of events
export function getEventKey(event: Event) {
return isReplaceableEvent(event.kind) ? getReplaceableCoordinateFromEvent(event) : event.id
}
// Only used for e, E, a, A tags
export function getEventKeyFromTag([, tagValue]: (string | undefined)[]) {
// Only used for e, E, a, A, i, I tags
export function getKeyFromTag([, tagValue]: (string | undefined)[]) {
return tagValue
}

View file

@ -0,0 +1,42 @@
export function determineExternalContentKind(externalContent: string): string | undefined {
if (externalContent.startsWith('http')) {
return 'web'
}
if (externalContent.startsWith('isbn:')) {
return 'isbn'
}
if (externalContent.startsWith('isan:')) {
return 'isan'
}
if (externalContent.startsWith('doi:')) {
return 'doi'
}
if (externalContent.startsWith('#')) {
return '#'
}
if (externalContent.startsWith('podcast:guid:')) {
return 'podcast:guid'
}
if (externalContent.startsWith('podcast:item:guid:')) {
return 'podcast:item:guid'
}
if (externalContent.startsWith('podcast:publisher:guid:')) {
return 'podcast:publisher:guid'
}
// Handle blockchain transaction format: <blockchain>:[<chainId>:]tx:<txid>
// Match pattern: blockchain name, optional chain ID, "tx:", transaction ID
const blockchainTxMatch = externalContent.match(/^([a-z]+):(?:[^:]+:)?tx:[a-f0-9]+$/i)
if (blockchainTxMatch) {
const blockchain = blockchainTxMatch[1].toLowerCase()
return `${blockchain}:tx`
}
// Handle blockchain address format: <blockchain>:[<chainId>:]address:<address>
// Match pattern: blockchain name, optional chain ID, "address:", address
const blockchainAddressMatch = externalContent.match(/^([a-z]+):(?:[^:]+:)?address:[a-zA-Z0-9]+$/i)
if (blockchainAddressMatch) {
const blockchain = blockchainAddressMatch[1].toLowerCase()
return `${blockchain}:address`
}
}

View file

@ -11,13 +11,11 @@ export const toNote = (eventOrId: Event | string) => {
export const toNoteList = ({
hashtag,
search,
externalContentId,
domain,
kinds
}: {
hashtag?: string
search?: string
externalContentId?: string
domain?: string
kinds?: number[]
}) => {
@ -28,7 +26,6 @@ export const toNoteList = ({
kinds.forEach((k) => query.append('k', k.toString()))
}
if (search) query.set('s', search)
if (externalContentId) query.set('i', externalContentId)
if (domain) query.set('d', domain)
return `${path}?${query.toString()}`
}
@ -62,6 +59,7 @@ export const toSearch = (params?: TSearchParams) => {
}
return `/search?${query.toString()}`
}
export const toExternalContent = (id: string) => `/external-content?id=${encodeURIComponent(id)}`
export const toSettings = () => '/settings'
export const toRelaySettings = (tag?: 'mailbox' | 'favorite-relays') => {
return '/settings/relays' + (tag ? '#' + tag : '')

View file

@ -15,7 +15,12 @@ export function isOnionUrl(url: string): boolean {
export function normalizeUrl(url: string): string {
try {
if (url.indexOf('://') === -1) {
if (url.startsWith('localhost:') || url.startsWith('localhost/')) {
if (
url.startsWith('localhost:') ||
url.startsWith('localhost/') ||
url.startsWith('127.') ||
url.startsWith('192.168.')
) {
url = 'ws://' + url
} else {
url = 'wss://' + url