feat: add support for commenting and reacting on external content
This commit is contained in:
parent
5ba5c26fcd
commit
0bb62dd3fb
76 changed files with 1635 additions and 639 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
42
src/lib/external-content.ts
Normal file
42
src/lib/external-content.ts
Normal 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`
|
||||
}
|
||||
}
|
||||
|
|
@ -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 : '')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue