From 68d1cb19c0fe104860181bcdf24cf88a17e0b333 Mon Sep 17 00:00:00 2001 From: Shusui MOYATANI Date: Tue, 16 May 2023 03:49:59 +0900 Subject: [PATCH] feat: display custom emoji (NIP-30) --- .../event/textNote/TextNoteContentDisplay.tsx | 35 ++++++++++----- src/nostr/event.ts | 29 ++++++++++++ src/nostr/parseTextNote.test.ts | 34 ++++++++++++++ src/nostr/parseTextNote.ts | 44 ++++++++++++++----- src/utils/imageUrl.ts | 2 +- 5 files changed, 121 insertions(+), 23 deletions(-) diff --git a/src/components/event/textNote/TextNoteContentDisplay.tsx b/src/components/event/textNote/TextNoteContentDisplay.tsx index e88d421..fc48881 100644 --- a/src/components/event/textNote/TextNoteContentDisplay.tsx +++ b/src/components/event/textNote/TextNoteContentDisplay.tsx @@ -40,6 +40,19 @@ const TextNoteContentDisplay = (props: TextNoteContentDisplayProps) => { if (item.type === 'PlainText') { return ; } + if (item.type === 'URL') { + if (isImageUrl(item.content)) { + return ( + + ); + } + return ; + } if (item.type === 'TagReference') { const resolved = resolveTagReference(item, props.event); if (resolved == null) return null; @@ -88,18 +101,16 @@ const TextNoteContentDisplay = (props: TextNoteContentDisplayProps) => { ); } - if (item.type === 'URL') { - if (isImageUrl(item.content)) { - return ( - - ); - } - return ; + if (item.type === 'CustomEmoji') { + const emojiUrl = event().getEmojiUrl(item.shortcode); + if (emojiUrl == null) return item.content; + return ( + {item.shortcode} + ); } console.error('Not all ParsedTextNoteNodes are covered', item); return null; diff --git a/src/nostr/event.ts b/src/nostr/event.ts index 6dfa14a..3b2f529 100644 --- a/src/nostr/event.ts +++ b/src/nostr/event.ts @@ -2,6 +2,8 @@ import uniq from 'lodash/uniq'; import { Kind, Event as NostrEvent } from 'nostr-tools'; import { z } from 'zod'; +import { isImageUrl } from '@/utils/imageUrl'; + export type EventMarker = 'reply' | 'root' | 'mention'; // NIP-10 @@ -42,6 +44,24 @@ const eventSchema = z.object({ }); */ +const EmojiTagSchema = z.tuple([ + z.literal('emoji'), + z.string().regex(/^[a-zA-Z0-9]+$/, { message: 'shortcode should be alpahnumeric' }), + z.string().url().refine(isImageUrl), +]); + +export type EmojiTag = z.infer; + +const ensureSchema = + (schema: z.Schema) => + (value: any): value is T => { + const result = schema.safeParse(value); + if (!result.success) { + console.warn('failed to parse value', value, schema); + } + return result.success; + }; + const eventWrapper = (event: NostrEvent) => { let memoizedMarkedEventTags: MarkedEventTag[] | undefined; @@ -73,6 +93,9 @@ const eventWrapper = (event: NostrEvent) => { eTags(): string[][] { return event.tags.filter(([tagName, eventId]) => tagName === 'e' && isValidId(eventId)); }, + emojiTags(): EmojiTag[] { + return event.tags.filter(ensureSchema(EmojiTagSchema)); + }, taggedEventIds(): string[] { return this.eTags().map(([, eventId]) => eventId); }, @@ -152,6 +175,12 @@ const eventWrapper = (event: NostrEvent) => { if (index < 0 || index >= event.tags.length) return false; return event.content.includes(`#[${index}]`); }, + getEmojiUrl(shortcode: string): string | null { + const emojiTag = this.emojiTags().find(([, code]) => code === shortcode); + if (emojiTag == null) return null; + const [, , url] = emojiTag; + return url; + }, }; }; diff --git a/src/nostr/parseTextNote.test.ts b/src/nostr/parseTextNote.test.ts index 1fd9c97..7b57d49 100644 --- a/src/nostr/parseTextNote.test.ts +++ b/src/nostr/parseTextNote.test.ts @@ -186,6 +186,40 @@ describe('parseTextNote', () => { const expected: ParsedTextNoteNode[] = [{ type: 'PlainText', content }]; assert.deepStrictEqual(parsed, expected); }); + + it.each([ + { + given: ':bunhd:', + expected: [{ type: 'CustomEmoji', content: ':bunhd:', shortcode: 'bunhd' }], + }, + { + given: 'Good morning! :pv:!', + expected: [ + { type: 'PlainText', content: 'Good morning! ' }, + { type: 'CustomEmoji', content: ':pv:', shortcode: 'pv' }, + { type: 'PlainText', content: '!' }, + ], + }, + { + given: ':hello:world:', + expected: [ + { type: 'CustomEmoji', content: ':hello:', shortcode: 'hello' }, + { type: 'PlainText', content: 'world:' }, + ], + }, + ])('should parse text note which includes custom emoji ($emoji)', ({ given, expected }) => { + const parsed = parseTextNote(given); + assert.deepStrictEqual(parsed, expected); + }); + + it.each([':bunhd_hop:', ':hello', '::: NOSTR :::'])( + 'should parse text note which includes invalid custom emoji ($emoji)', + (content) => { + const parsed = parseTextNote(content); + const expected: ParsedTextNoteNode[] = [{ type: 'PlainText', content }]; + assert.deepStrictEqual(parsed, expected); + }, + ); }); describe('resolveTagReference', () => { diff --git a/src/nostr/parseTextNote.ts b/src/nostr/parseTextNote.ts index da5ffa3..331e884 100644 --- a/src/nostr/parseTextNote.ts +++ b/src/nostr/parseTextNote.ts @@ -12,10 +12,15 @@ export type PlainText = { content: string; }; +export type UrlText = { + type: 'URL'; + content: string; +}; + export type TagReference = { type: 'TagReference'; - tagIndex: number; content: string; + tagIndex: number; }; export type Bech32Entity = { @@ -34,12 +39,20 @@ export type HashTag = { tagName: string; }; -export type UrlText = { - type: 'URL'; +// NIP-30 +export type CustomEmoji = { + type: 'CustomEmoji'; content: string; + shortcode: string; }; -export type ParsedTextNoteNode = PlainText | TagReference | Bech32Entity | HashTag | UrlText; +export type ParsedTextNoteNode = + | PlainText + | UrlText + | TagReference + | Bech32Entity + | HashTag + | CustomEmoji; export type ParsedTextNote = ParsedTextNoteNode[]; @@ -58,21 +71,23 @@ export type MentionedUser = { pubkey: string; }; +const urlRegex = + /(?(?:https?|wss?):\/\/[-a-zA-Z0-9.]+(:\d{1,5})?(?:\/[-[\]~!$&'()*+.,:;@&=%\w]+|\/)*(?:\?[-[\]~!$&'()*+.,/:;%@&=\w?]+)?(?:#[-[\]~!$&'()*+.,/:;%@\w&=?#]+)?)/g; const tagRefRegex = /(?:#\[(?\d+)\])/g; -const hashTagRegex = /#(?[\p{Letter}\p{Number}_]+)/gu; // raw NIP-19 codes, NIP-21 links (NIP-27) // nrelay and naddr is not supported by nostr-tools const mentionRegex = /(?(?nostr:)?(?(?:npub|note|nprofile|nevent)1[ac-hj-np-z02-9]+))/gi; -const urlRegex = - /(?(?:https?|wss?):\/\/[-a-zA-Z0-9.]+(:\d{1,5})?(?:\/[-[\]~!$&'()*+.,:;@&=%\w]+|\/)*(?:\?[-[\]~!$&'()*+.,/:;%@&=\w?]+)?(?:#[-[\]~!$&'()*+.,/:;%@\w&=?#]+)?)/g; +const hashTagRegex = /#(?[\p{Letter}\p{Number}_]+)/gu; +const customEmojiRegex = /:(?[a-zA-Z0-9]+):/gu; const parseTextNote = (textNoteContent: string) => { const matches = [ - ...textNoteContent.matchAll(tagRefRegex), - ...textNoteContent.matchAll(hashTagRegex), - ...textNoteContent.matchAll(mentionRegex), ...textNoteContent.matchAll(urlRegex), + ...textNoteContent.matchAll(tagRefRegex), + ...textNoteContent.matchAll(mentionRegex), + ...textNoteContent.matchAll(hashTagRegex), + ...textNoteContent.matchAll(customEmojiRegex), ].sort((a, b) => (a.index as number) - (b.index as number)); let pos = 0; const result: ParsedTextNote = []; @@ -137,6 +152,15 @@ const parseTextNote = (textNoteContent: string) => { tagName, }; result.push(hashtag); + } else if (match.groups?.emoji) { + pushPlainText(index); + const shortcode = match.groups?.emoji; + const customEmoji: CustomEmoji = { + type: 'CustomEmoji', + content: match[0], + shortcode, + }; + result.push(customEmoji); } pos = index + match[0].length; }); diff --git a/src/utils/imageUrl.ts b/src/utils/imageUrl.ts index 26023ce..4cbcd9f 100644 --- a/src/utils/imageUrl.ts +++ b/src/utils/imageUrl.ts @@ -1,7 +1,7 @@ export const isImageUrl = (urlString: string): boolean => { try { const url = new URL(urlString); - return /\.(jpeg|jpg|png|gif|webp)$/i.test(url.pathname); + return /\.(jpeg|jpg|png|gif|webp|apng)$/i.test(url.pathname); } catch { return false; }