draft-event.ts raw
1 import { ApplicationDataKey, EMBEDDED_EVENT_REGEX, ExtendedKind, POLL_TYPE } from '@/constants'
2 import client from '@/services/client.service'
3 import customEmojiService from '@/services/custom-emoji.service'
4 import mediaUpload from '@/services/media-upload.service'
5 import {
6 TDMDeletedState,
7 TDraftEvent,
8 TEmoji,
9 TMailboxRelay,
10 TMailboxRelayScope,
11 TPollCreateData,
12 TRelaySet
13 } from '@/types'
14 import { sha256 } from '@noble/hashes/sha2'
15 import dayjs from 'dayjs'
16 import { Event, kinds, nip19 } from 'nostr-tools'
17 import {
18 getReplaceableCoordinate,
19 getReplaceableCoordinateFromEvent,
20 getRootTag,
21 isProtectedEvent,
22 isReplaceableEvent
23 } from './event'
24 import { determineExternalContentKind } from './external-content'
25 import { randomString } from './random'
26 import { generateBech32IdFromETag, tagNameEquals } from './tag'
27
28 const draftEventCache: Map<string, string> = new Map()
29
30 export function deleteDraftEventCache(draftEvent: TDraftEvent) {
31 const key = generateDraftEventCacheKey(draftEvent)
32 draftEventCache.delete(key)
33 }
34
35 function setDraftEventCache(baseDraft: Omit<TDraftEvent, 'created_at'>): TDraftEvent {
36 const cacheKey = generateDraftEventCacheKey(baseDraft)
37 const cache = draftEventCache.get(cacheKey)
38 if (cache) {
39 return JSON.parse(cache)
40 }
41 const draftEvent = { ...baseDraft, created_at: dayjs().unix() }
42 draftEventCache.set(cacheKey, JSON.stringify(draftEvent))
43
44 return draftEvent
45 }
46
47 function generateDraftEventCacheKey(draft: Omit<TDraftEvent, 'created_at'>) {
48 const str = JSON.stringify({
49 content: draft.content,
50 kind: draft.kind,
51 tags: draft.tags
52 })
53
54 const encoder = new TextEncoder()
55 const data = encoder.encode(str)
56 const hashBuffer = sha256(data)
57 const hashArray = Array.from(new Uint8Array(hashBuffer))
58 return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
59 }
60
61 // https://github.com/nostr-protocol/nips/blob/master/25.md
62 export function createReactionDraftEvent(event: Event, emoji: TEmoji | string = '+'): TDraftEvent {
63 const tags: string[][] = []
64 tags.push(buildETag(event.id, event.pubkey))
65 tags.push(buildPTag(event.pubkey))
66 if (event.kind !== kinds.ShortTextNote) {
67 tags.push(buildKTag(event.kind))
68 }
69
70 if (isReplaceableEvent(event.kind)) {
71 tags.push(buildATag(event))
72 }
73
74 let content: string
75 if (typeof emoji === 'string') {
76 content = emoji
77 } else {
78 content = `:${emoji.shortcode}:`
79 tags.push(buildEmojiTag(emoji))
80 }
81
82 return {
83 kind: kinds.Reaction,
84 content,
85 tags,
86 created_at: dayjs().unix()
87 }
88 }
89
90 export function createExternalContentReactionDraftEvent(
91 externalContent: string,
92 emoji: TEmoji | string = '+'
93 ): TDraftEvent {
94 const tags: string[][] = []
95 tags.push(buildITag(externalContent))
96 const kind = determineExternalContentKind(externalContent)
97 if (kind) {
98 tags.push(buildKTag(kind))
99 }
100
101 let content: string
102 if (typeof emoji === 'string') {
103 content = emoji
104 } else {
105 content = `:${emoji.shortcode}:`
106 tags.push(buildEmojiTag(emoji))
107 }
108
109 return {
110 kind: ExtendedKind.EXTERNAL_CONTENT_REACTION,
111 content,
112 tags,
113 created_at: dayjs().unix()
114 }
115 }
116
117 // https://github.com/nostr-protocol/nips/blob/master/18.md
118 export function createRepostDraftEvent(event: Event): TDraftEvent {
119 const isProtected = isProtectedEvent(event)
120 const tags = [buildETag(event.id, event.pubkey), buildPTag(event.pubkey)]
121
122 if (event.kind === kinds.ShortTextNote) {
123 return {
124 kind: kinds.Repost,
125 content: isProtected ? '' : JSON.stringify(event),
126 tags,
127 created_at: dayjs().unix()
128 }
129 }
130
131 tags.push(buildKTag(event.kind))
132
133 const isReplaceable = isReplaceableEvent(event.kind)
134 if (isReplaceable) {
135 tags.push(buildATag(event))
136 }
137
138 return {
139 kind: kinds.GenericRepost,
140 content: isProtected || isReplaceable ? '' : JSON.stringify(event),
141 tags,
142 created_at: dayjs().unix()
143 }
144 }
145
146 export async function createShortTextNoteDraftEvent(
147 content: string,
148 mentions: string[],
149 options: {
150 parentEvent?: Event
151 addClientTag?: boolean
152 protectedEvent?: boolean
153 isNsfw?: boolean
154 } = {}
155 ): Promise<TDraftEvent> {
156 const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(content)
157 const { quoteTags, rootTag, parentTag } = await extractRelatedEventIds(
158 transformedEmojisContent,
159 options.parentEvent
160 )
161 const hashtags = extractHashtags(transformedEmojisContent)
162
163 const tags = emojiTags.concat(hashtags.map((hashtag) => buildTTag(hashtag)))
164
165 // imeta tags
166 const images = extractImagesFromContent(transformedEmojisContent)
167 if (images && images.length) {
168 tags.push(...generateImetaTags(images))
169 }
170
171 // q tags
172 tags.push(...quoteTags)
173
174 // thread tags
175 if (rootTag) {
176 tags.push(rootTag)
177 }
178
179 if (parentTag) {
180 tags.push(parentTag)
181 }
182
183 // p tags
184 tags.push(...mentions.map((pubkey) => buildPTag(pubkey)))
185
186 if (options.addClientTag) {
187 tags.push(buildClientTag())
188 }
189
190 if (options.isNsfw) {
191 tags.push(buildNsfwTag())
192 }
193
194 if (options.protectedEvent) {
195 tags.push(buildProtectedTag())
196 }
197
198 const baseDraft = {
199 kind: kinds.ShortTextNote,
200 content: transformedEmojisContent,
201 tags
202 }
203 return setDraftEventCache(baseDraft)
204 }
205
206 // https://github.com/nostr-protocol/nips/blob/master/51.md
207 export function createRelaySetDraftEvent(relaySet: Omit<TRelaySet, 'aTag'>): TDraftEvent {
208 return {
209 kind: kinds.Relaysets,
210 content: '',
211 tags: [
212 buildDTag(relaySet.id),
213 buildTitleTag(relaySet.name),
214 ...relaySet.relayUrls.map((url) => buildRelayTag(url))
215 ],
216 created_at: dayjs().unix()
217 }
218 }
219
220 export async function createCommentDraftEvent(
221 content: string,
222 parentStuff: Event | string,
223 mentions: string[],
224 options: {
225 addClientTag?: boolean
226 protectedEvent?: boolean
227 isNsfw?: boolean
228 } = {}
229 ): Promise<TDraftEvent> {
230 const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(content)
231 const {
232 quoteTags,
233 rootEventId,
234 rootCoordinateTag,
235 rootKind,
236 rootPubkey,
237 rootUrl,
238 parentEvent,
239 externalContent
240 } = await extractCommentMentions(transformedEmojisContent, parentStuff)
241 const hashtags = extractHashtags(transformedEmojisContent)
242
243 const tags = emojiTags.concat(hashtags.map((hashtag) => buildTTag(hashtag))).concat(quoteTags)
244
245 const images = extractImagesFromContent(transformedEmojisContent)
246 if (images && images.length) {
247 tags.push(...generateImetaTags(images))
248 }
249
250 tags.push(
251 ...mentions
252 .filter((pubkey) => pubkey !== parentEvent?.pubkey)
253 .map((pubkey) => buildPTag(pubkey))
254 )
255
256 if (rootCoordinateTag) {
257 tags.push(rootCoordinateTag)
258 } else if (rootEventId) {
259 tags.push(buildETag(rootEventId, rootPubkey, '', true))
260 }
261 if (rootPubkey) {
262 tags.push(buildPTag(rootPubkey, true))
263 }
264 if (rootKind) {
265 tags.push(buildKTag(rootKind, true))
266 }
267 if (rootUrl) {
268 tags.push(buildITag(rootUrl, true))
269 }
270 tags.push(
271 ...(parentEvent
272 ? [
273 isReplaceableEvent(parentEvent.kind)
274 ? buildATag(parentEvent)
275 : buildETag(parentEvent.id, parentEvent.pubkey),
276 buildPTag(parentEvent.pubkey)
277 ]
278 : externalContent
279 ? [buildITag(externalContent)]
280 : [])
281 )
282 const parentKind = parentEvent
283 ? parentEvent.kind
284 : externalContent
285 ? determineExternalContentKind(externalContent)
286 : undefined
287 if (parentKind) {
288 tags.push(buildKTag(parentKind))
289 }
290
291 if (options.addClientTag) {
292 tags.push(buildClientTag())
293 }
294
295 if (options.isNsfw) {
296 tags.push(buildNsfwTag())
297 }
298
299 if (options.protectedEvent) {
300 tags.push(buildProtectedTag())
301 }
302
303 const baseDraft = {
304 kind: ExtendedKind.COMMENT,
305 content: transformedEmojisContent,
306 tags
307 }
308 return setDraftEventCache(baseDraft)
309 }
310
311 // https://github.com/nostr-protocol/nips/blob/master/84.md
312 export function createHighlightDraftEvent(
313 highlightedText: string,
314 comment: string = '',
315 sourceEvent: Event,
316 mentions: string[],
317 options: {
318 addClientTag?: boolean
319 protectedEvent?: boolean
320 isNsfw?: boolean
321 } = {}
322 ): TDraftEvent {
323 const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(comment)
324 const quoteTags = extractQuoteTags(comment)
325 const hashtags = extractHashtags(transformedEmojisContent)
326
327 const tags = emojiTags.concat(hashtags.map((hashtag) => buildTTag(hashtag)))
328
329 // imeta tags
330 const images = extractImagesFromContent(transformedEmojisContent)
331 if (images && images.length) {
332 tags.push(...generateImetaTags(images))
333 }
334
335 // q tags
336 tags.push(...quoteTags)
337
338 // p tags
339 tags.push(
340 ...mentions
341 .filter((pubkey) => pubkey !== sourceEvent.pubkey)
342 .map((pubkey) => ['p', pubkey, '', 'mention'])
343 )
344
345 // Add comment tag if comment exists
346 if (transformedEmojisContent) {
347 tags.push(['comment', transformedEmojisContent])
348 }
349
350 // Add source reference
351 const hint = client.getEventHint(sourceEvent.id)
352 if (isReplaceableEvent(sourceEvent.kind)) {
353 tags.push(['a', getReplaceableCoordinateFromEvent(sourceEvent), hint, 'source'])
354 } else {
355 tags.push(['e', sourceEvent.id, hint, 'source'])
356 }
357 tags.push(['p', sourceEvent.pubkey, '', 'author'])
358
359 if (options.addClientTag) {
360 tags.push(buildClientTag())
361 }
362
363 if (options.isNsfw) {
364 tags.push(buildNsfwTag())
365 }
366
367 if (options.protectedEvent) {
368 tags.push(buildProtectedTag())
369 }
370
371 const baseDraft = {
372 kind: kinds.Highlights,
373 content: highlightedText,
374 tags
375 }
376 return setDraftEventCache(baseDraft)
377 }
378
379 export function createRelayListDraftEvent(mailboxRelays: TMailboxRelay[]): TDraftEvent {
380 return {
381 kind: kinds.RelayList,
382 content: '',
383 tags: mailboxRelays.map(({ url, scope }) => buildRTag(url, scope)),
384 created_at: dayjs().unix()
385 }
386 }
387
388 export function createFollowListDraftEvent(tags: string[][], content?: string): TDraftEvent {
389 return {
390 kind: kinds.Contacts,
391 content: content ?? '',
392 created_at: dayjs().unix(),
393 tags
394 }
395 }
396
397 export function createMuteListDraftEvent(tags: string[][], content?: string): TDraftEvent {
398 return {
399 kind: kinds.Mutelist,
400 content: content ?? '',
401 created_at: dayjs().unix(),
402 tags
403 }
404 }
405
406 export function createProfileDraftEvent(content: string, tags: string[][] = []): TDraftEvent {
407 return {
408 kind: kinds.Metadata,
409 content,
410 tags,
411 created_at: dayjs().unix()
412 }
413 }
414
415 export function createFavoriteRelaysDraftEvent(
416 favoriteRelays: string[],
417 relaySetEventsOrATags: Event[] | string[][]
418 ): TDraftEvent {
419 const tags: string[][] = []
420 favoriteRelays.forEach((url) => {
421 tags.push(buildRelayTag(url))
422 })
423 relaySetEventsOrATags.forEach((eventOrATag) => {
424 if (Array.isArray(eventOrATag)) {
425 tags.push(eventOrATag)
426 } else {
427 tags.push(buildATag(eventOrATag))
428 }
429 })
430 return {
431 kind: ExtendedKind.FAVORITE_RELAYS,
432 content: '',
433 tags,
434 created_at: dayjs().unix()
435 }
436 }
437
438 export function createSeenNotificationsAtDraftEvent(): TDraftEvent {
439 return {
440 kind: kinds.Application,
441 content: 'Records read time to sync notification status across devices.',
442 tags: [buildDTag(ApplicationDataKey.NOTIFICATIONS_SEEN_AT)],
443 created_at: dayjs().unix()
444 }
445 }
446
447 export function createSettingsDraftEvent(content: string): TDraftEvent {
448 return {
449 kind: kinds.Application,
450 content,
451 tags: [buildDTag(ApplicationDataKey.SETTINGS)],
452 created_at: dayjs().unix()
453 }
454 }
455
456 export function createDeletedMessagesDraftEvent(deletedState: TDMDeletedState): TDraftEvent {
457 return {
458 kind: kinds.Application,
459 content: JSON.stringify(deletedState),
460 tags: [buildDTag(ApplicationDataKey.DM_DELETED_MESSAGES)],
461 created_at: dayjs().unix()
462 }
463 }
464
465 export function createBookmarkDraftEvent(tags: string[][], content = ''): TDraftEvent {
466 return {
467 kind: kinds.BookmarkList,
468 content,
469 tags,
470 created_at: dayjs().unix()
471 }
472 }
473
474 export function createPinListDraftEvent(tags: string[][], content = ''): TDraftEvent {
475 return {
476 kind: kinds.Pinlist,
477 content,
478 tags,
479 created_at: dayjs().unix()
480 }
481 }
482
483 export function createUserEmojiListDraftEvent(tags: string[][], content = ''): TDraftEvent {
484 return {
485 kind: kinds.UserEmojiList,
486 content,
487 tags,
488 created_at: dayjs().unix()
489 }
490 }
491
492 export function createBlossomServerListDraftEvent(servers: string[]): TDraftEvent {
493 return {
494 kind: ExtendedKind.BLOSSOM_SERVER_LIST,
495 content: '',
496 tags: servers.map((server) => buildServerTag(server)),
497 created_at: dayjs().unix()
498 }
499 }
500
501 export async function createPollDraftEvent(
502 author: string,
503 question: string,
504 mentions: string[],
505 { isMultipleChoice, relays, options, endsAt }: TPollCreateData,
506 {
507 addClientTag,
508 isNsfw
509 }: {
510 addClientTag?: boolean
511 isNsfw?: boolean
512 } = {}
513 ): Promise<TDraftEvent> {
514 const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(question)
515 const { quoteTags } = await extractRelatedEventIds(transformedEmojisContent)
516 const hashtags = extractHashtags(transformedEmojisContent)
517
518 const tags = emojiTags.concat(hashtags.map((hashtag) => buildTTag(hashtag)))
519
520 // imeta tags
521 const images = extractImagesFromContent(transformedEmojisContent)
522 if (images && images.length) {
523 tags.push(...generateImetaTags(images))
524 }
525
526 // q tags
527 tags.push(...quoteTags)
528
529 // p tags
530 tags.push(...mentions.map((pubkey) => buildPTag(pubkey)))
531
532 const validOptions = options.filter((opt) => opt.trim())
533 tags.push(...validOptions.map((option) => ['option', randomString(9), option.trim()]))
534 tags.push(['polltype', isMultipleChoice ? POLL_TYPE.MULTIPLE_CHOICE : POLL_TYPE.SINGLE_CHOICE])
535
536 if (endsAt) {
537 tags.push(['endsAt', endsAt.toString()])
538 }
539
540 if (relays.length) {
541 relays.forEach((relay) => tags.push(buildRelayTag(relay)))
542 } else {
543 const relayList = await client.fetchRelayList(author)
544 relayList.read.slice(0, 4).forEach((relay) => {
545 tags.push(buildRelayTag(relay))
546 })
547 }
548
549 if (addClientTag) {
550 tags.push(buildClientTag())
551 }
552
553 if (isNsfw) {
554 tags.push(buildNsfwTag())
555 }
556
557 const baseDraft = {
558 content: transformedEmojisContent.trim(),
559 kind: ExtendedKind.POLL,
560 tags
561 }
562 return setDraftEventCache(baseDraft)
563 }
564
565 export function createPollResponseDraftEvent(
566 pollEvent: Event,
567 selectedOptionIds: string[]
568 ): TDraftEvent {
569 return {
570 content: '',
571 kind: ExtendedKind.POLL_RESPONSE,
572 tags: [
573 buildETag(pollEvent.id, pollEvent.pubkey),
574 buildPTag(pollEvent.pubkey),
575 ...selectedOptionIds.map((optionId) => buildResponseTag(optionId))
576 ],
577 created_at: dayjs().unix()
578 }
579 }
580
581 export function createDeletionRequestDraftEvent(event: Event): TDraftEvent {
582 const tags: string[][] = [buildKTag(event.kind)]
583 if (isReplaceableEvent(event.kind)) {
584 tags.push(['a', getReplaceableCoordinateFromEvent(event)])
585 } else {
586 tags.push(['e', event.id])
587 }
588
589 return {
590 kind: kinds.EventDeletion,
591 content: 'Request for deletion of the event.',
592 tags,
593 created_at: dayjs().unix()
594 }
595 }
596
597 export function createReportDraftEvent(event: Event, reason: string): TDraftEvent {
598 const tags: string[][] = []
599 if (event.kind === kinds.Metadata) {
600 tags.push(['p', event.pubkey, reason])
601 } else {
602 tags.push(['p', event.pubkey])
603 tags.push(['e', event.id, reason])
604 if (isReplaceableEvent(event.kind)) {
605 tags.push(['a', getReplaceableCoordinateFromEvent(event), reason])
606 }
607 }
608
609 return {
610 kind: kinds.Report,
611 content: '',
612 tags,
613 created_at: dayjs().unix()
614 }
615 }
616
617 export function createRelayReviewDraftEvent(
618 relay: string,
619 review: string,
620 stars: number
621 ): TDraftEvent {
622 return {
623 kind: ExtendedKind.RELAY_REVIEW,
624 content: review,
625 tags: [
626 ['d', relay],
627 ['rating', (stars / 5).toString()]
628 ],
629 created_at: dayjs().unix()
630 }
631 }
632
633 // https://github.com/nostr-protocol/nips/blob/master/43.md
634 export function createJoinDraftEvent(inviteCode: string): TDraftEvent {
635 return {
636 kind: 28934,
637 created_at: Math.floor(Date.now() / 1000),
638 tags: [['claim', inviteCode], ['-']],
639 content: ''
640 }
641 }
642
643 export function createLeaveDraftEvent(): TDraftEvent {
644 return {
645 kind: 28936,
646 created_at: Math.floor(Date.now() / 1000),
647 tags: [['-']],
648 content: ''
649 }
650 }
651
652 function generateImetaTags(imageUrls: string[]) {
653 return imageUrls
654 .map((imageUrl) => {
655 const tag = mediaUpload.getImetaTagByUrl(imageUrl)
656 return tag ?? null
657 })
658 .filter(Boolean) as string[][]
659 }
660
661 async function extractRelatedEventIds(content: string, parentEvent?: Event) {
662 let rootTag: string[] | null = null
663 let parentTag: string[] | null = null
664
665 const quoteTags = extractQuoteTags(content)
666
667 if (parentEvent) {
668 const _rootTag = getRootTag(parentEvent)
669 if (_rootTag?.type === 'e') {
670 parentTag = buildETagWithMarker(parentEvent.id, parentEvent.pubkey, '', 'reply')
671
672 const [, rootEventHexId, hint, , rootEventPubkey] = _rootTag.tag
673 if (rootEventPubkey) {
674 rootTag = buildETagWithMarker(rootEventHexId, rootEventPubkey, hint, 'root')
675 } else {
676 const rootEventId = generateBech32IdFromETag(_rootTag.tag)
677 const rootEvent = rootEventId ? await client.fetchEvent(rootEventId) : undefined
678 rootTag = rootEvent
679 ? buildETagWithMarker(rootEvent.id, rootEvent.pubkey, hint, 'root')
680 : buildETagWithMarker(rootEventHexId, rootEventPubkey, hint, 'root')
681 }
682 } else if (_rootTag?.type === 'a') {
683 // Legacy
684 parentTag = buildETagWithMarker(parentEvent.id, parentEvent.pubkey, '', 'reply')
685 const [, coordinate, hint] = _rootTag.tag
686 rootTag = buildLegacyRootATag(coordinate, hint)
687 } else {
688 // reply to root event
689 rootTag = buildETagWithMarker(parentEvent.id, parentEvent.pubkey, '', 'root')
690 }
691 }
692
693 return {
694 quoteTags,
695 rootTag,
696 parentTag
697 }
698 }
699
700 async function extractCommentMentions(content: string, parentStuff: Event | string) {
701 const { parentEvent, externalContent } =
702 typeof parentStuff === 'string'
703 ? { parentEvent: undefined, externalContent: parentStuff }
704 : { parentEvent: parentStuff, externalContent: undefined }
705 const isComment =
706 parentEvent && [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT].includes(parentEvent.kind)
707 const rootCoordinateTag = parentEvent
708 ? isComment
709 ? parentEvent.tags.find(tagNameEquals('A'))
710 : isReplaceableEvent(parentEvent.kind)
711 ? buildATag(parentEvent, true)
712 : undefined
713 : undefined
714 const rootEventId = isComment ? parentEvent.tags.find(tagNameEquals('E'))?.[1] : parentEvent?.id
715 const rootKind = isComment
716 ? parentEvent.tags.find(tagNameEquals('K'))?.[1]
717 : parentEvent
718 ? parentEvent.kind
719 : determineExternalContentKind(parentStuff as string)
720 const rootPubkey = isComment
721 ? parentEvent.tags.find(tagNameEquals('P'))?.[1]
722 : parentEvent?.pubkey
723 const rootUrl = isComment ? parentEvent.tags.find(tagNameEquals('I'))?.[1] : externalContent
724
725 const quoteTags = extractQuoteTags(content)
726
727 return {
728 quoteTags,
729 rootEventId,
730 rootCoordinateTag,
731 rootKind,
732 rootPubkey,
733 rootUrl,
734 parentEvent,
735 externalContent
736 }
737 }
738
739 function extractQuoteTags(content: string) {
740 const quoteSet = new Set<string>()
741 const quoteTags: string[][] = []
742 const matches = content.match(EMBEDDED_EVENT_REGEX)
743 for (const m of matches || []) {
744 try {
745 const id = m.split(':')[1]
746 const { type, data } = nip19.decode(id)
747 if (type === 'nevent') {
748 const id = data.id
749 if (!quoteSet.has(id)) {
750 quoteSet.add(id)
751 const relay = data.relays?.[0] ?? client.getEventHint(id)
752 quoteTags.push(buildQTag(id, relay, data.author))
753 }
754 } else if (type === 'note') {
755 const id = data
756 if (!quoteSet.has(id)) {
757 quoteSet.add(id)
758 const relay = client.getEventHint(id)
759 quoteTags.push(buildQTag(id, relay))
760 }
761 } else if (type === 'naddr') {
762 const coordinate = getReplaceableCoordinate(data.kind, data.pubkey, data.identifier)
763 if (!quoteSet.has(coordinate)) {
764 quoteSet.add(coordinate)
765 const relay = data.relays?.[0]
766 quoteTags.push(buildQTag(coordinate, relay))
767 }
768 }
769 } catch (e) {
770 console.error(e)
771 }
772 }
773
774 return quoteTags
775 }
776
777 function extractHashtags(content: string) {
778 const hashtags: string[] = []
779 const matches = content.match(/#[\p{L}\p{N}\p{M}]+/gu)
780 matches?.forEach((m) => {
781 const hashtag = m.slice(1).toLowerCase()
782 if (hashtag) {
783 hashtags.push(hashtag)
784 }
785 })
786 return hashtags
787 }
788
789 function extractImagesFromContent(content: string) {
790 return content.match(/https?:\/\/[^\s"']+\.(jpg|jpeg|png|gif|webp|heic)/gi)
791 }
792
793 export function transformCustomEmojisInContent(content: string) {
794 const emojiTags: string[][] = []
795 let processedContent = content
796 const matches = content.match(/:[a-zA-Z0-9]+:/g)
797
798 const emojiIdSet = new Set<string>()
799 matches?.forEach((m) => {
800 if (emojiIdSet.has(m)) return
801 emojiIdSet.add(m)
802
803 const emoji = customEmojiService.getEmojiById(m.slice(1, -1))
804 if (emoji) {
805 emojiTags.push(buildEmojiTag(emoji))
806 processedContent = processedContent.replace(new RegExp(m, 'g'), `:${emoji.shortcode}:`)
807 }
808 })
809
810 return {
811 emojiTags,
812 content: processedContent
813 }
814 }
815
816 export function buildATag(event: Event, upperCase: boolean = false) {
817 const coordinate = getReplaceableCoordinateFromEvent(event)
818 const hint = client.getEventHint(event.id)
819 return trimTagEnd([upperCase ? 'A' : 'a', coordinate, hint])
820 }
821
822 function buildDTag(identifier: string) {
823 return ['d', identifier]
824 }
825
826 export function buildETag(
827 eventHexId: string,
828 pubkey: string = '',
829 hint: string = '',
830 upperCase: boolean = false
831 ) {
832 if (!hint) {
833 hint = client.getEventHint(eventHexId)
834 }
835 return trimTagEnd([upperCase ? 'E' : 'e', eventHexId, hint, pubkey])
836 }
837
838 function buildETagWithMarker(
839 eventHexId: string,
840 pubkey: string = '',
841 hint: string = '',
842 marker: 'root' | 'reply' | '' = ''
843 ) {
844 if (!hint) {
845 hint = client.getEventHint(eventHexId)
846 }
847 return trimTagEnd(['e', eventHexId, hint, marker, pubkey])
848 }
849
850 function buildLegacyRootATag(coordinate: string, hint: string = '') {
851 if (!hint) {
852 const evt = client.getReplaeableEventFromCache(coordinate)
853 if (evt) {
854 hint = client.getEventHint(evt.id)
855 }
856 }
857 return trimTagEnd(['a', coordinate, hint, 'root'])
858 }
859
860 function buildITag(url: string, upperCase: boolean = false) {
861 return [upperCase ? 'I' : 'i', url]
862 }
863
864 function buildKTag(kind: number | string, upperCase: boolean = false) {
865 return [upperCase ? 'K' : 'k', kind.toString()]
866 }
867
868 function buildPTag(pubkey: string, upperCase: boolean = false) {
869 return [upperCase ? 'P' : 'p', pubkey]
870 }
871
872 function buildQTag(eventHexIdOrCoordinate: string, relay?: string, pubkey?: string) {
873 const tag: string[] = ['q', eventHexIdOrCoordinate]
874 if (!relay) {
875 return tag
876 }
877 tag.push(relay)
878 if (!pubkey) {
879 return tag
880 }
881 tag.push(pubkey)
882 return tag
883 }
884
885 function buildRTag(url: string, scope: TMailboxRelayScope) {
886 return scope !== 'both' ? ['r', url, scope] : ['r', url]
887 }
888
889 function buildTTag(hashtag: string) {
890 return ['t', hashtag]
891 }
892
893 function buildEmojiTag(emoji: TEmoji) {
894 return ['emoji', emoji.shortcode, emoji.url]
895 }
896
897 function buildTitleTag(title: string) {
898 return ['title', title]
899 }
900
901 function buildRelayTag(url: string) {
902 return ['relay', url]
903 }
904
905 function buildServerTag(url: string) {
906 return ['server', url]
907 }
908
909 function buildResponseTag(value: string) {
910 return ['response', value]
911 }
912
913 function buildClientTag() {
914 return ['client', 'smesh', 'https://smesh.mleku.dev']
915 }
916
917 function buildNsfwTag() {
918 return ['content-warning', 'NSFW']
919 }
920
921 function buildProtectedTag() {
922 return ['-']
923 }
924
925 function trimTagEnd(tag: string[]) {
926 let endIndex = tag.length - 1
927 while (endIndex >= 0 && tag[endIndex] === '') {
928 endIndex--
929 }
930
931 return tag.slice(0, endIndex + 1)
932 }
933