feat: display custom emoji (NIP-30)

This commit is contained in:
Shusui MOYATANI
2023-05-16 03:49:59 +09:00
parent 9567016206
commit 68d1cb19c0
5 changed files with 121 additions and 23 deletions

View File

@@ -40,6 +40,19 @@ const TextNoteContentDisplay = (props: TextNoteContentDisplayProps) => {
if (item.type === 'PlainText') { if (item.type === 'PlainText') {
return <PlainTextDisplay plainText={item} />; return <PlainTextDisplay plainText={item} />;
} }
if (item.type === 'URL') {
if (isImageUrl(item.content)) {
return (
<ImageDisplay
url={item.content}
initialHidden={
!config().showImage || event().contentWarning().contentWarning || !props.embedding
}
/>
);
}
return <SafeLink class="text-blue-500 underline" href={item.content} />;
}
if (item.type === 'TagReference') { if (item.type === 'TagReference') {
const resolved = resolveTagReference(item, props.event); const resolved = resolveTagReference(item, props.event);
if (resolved == null) return null; if (resolved == null) return null;
@@ -88,18 +101,16 @@ const TextNoteContentDisplay = (props: TextNoteContentDisplayProps) => {
</button> </button>
); );
} }
if (item.type === 'URL') { if (item.type === 'CustomEmoji') {
if (isImageUrl(item.content)) { const emojiUrl = event().getEmojiUrl(item.shortcode);
return ( if (emojiUrl == null) return item.content;
<ImageDisplay return (
url={item.content} <img
initialHidden={ class="inline-block h-6 max-w-[64px] align-middle"
!config().showImage || event().contentWarning().contentWarning || !props.embedding src={emojiUrl}
} alt={item.shortcode}
/> />
); );
}
return <SafeLink class="text-blue-500 underline" href={item.content} />;
} }
console.error('Not all ParsedTextNoteNodes are covered', item); console.error('Not all ParsedTextNoteNodes are covered', item);
return null; return null;

View File

@@ -2,6 +2,8 @@ import uniq from 'lodash/uniq';
import { Kind, Event as NostrEvent } from 'nostr-tools'; import { Kind, Event as NostrEvent } from 'nostr-tools';
import { z } from 'zod'; import { z } from 'zod';
import { isImageUrl } from '@/utils/imageUrl';
export type EventMarker = 'reply' | 'root' | 'mention'; export type EventMarker = 'reply' | 'root' | 'mention';
// NIP-10 // 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<typeof EmojiTagSchema>;
const ensureSchema =
<T>(schema: z.Schema<T>) =>
(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) => { const eventWrapper = (event: NostrEvent) => {
let memoizedMarkedEventTags: MarkedEventTag[] | undefined; let memoizedMarkedEventTags: MarkedEventTag[] | undefined;
@@ -73,6 +93,9 @@ const eventWrapper = (event: NostrEvent) => {
eTags(): string[][] { eTags(): string[][] {
return event.tags.filter(([tagName, eventId]) => tagName === 'e' && isValidId(eventId)); return event.tags.filter(([tagName, eventId]) => tagName === 'e' && isValidId(eventId));
}, },
emojiTags(): EmojiTag[] {
return event.tags.filter(ensureSchema(EmojiTagSchema));
},
taggedEventIds(): string[] { taggedEventIds(): string[] {
return this.eTags().map(([, eventId]) => eventId); return this.eTags().map(([, eventId]) => eventId);
}, },
@@ -152,6 +175,12 @@ const eventWrapper = (event: NostrEvent) => {
if (index < 0 || index >= event.tags.length) return false; if (index < 0 || index >= event.tags.length) return false;
return event.content.includes(`#[${index}]`); 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;
},
}; };
}; };

View File

@@ -186,6 +186,40 @@ describe('parseTextNote', () => {
const expected: ParsedTextNoteNode[] = [{ type: 'PlainText', content }]; const expected: ParsedTextNoteNode[] = [{ type: 'PlainText', content }];
assert.deepStrictEqual(parsed, expected); 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', () => { describe('resolveTagReference', () => {

View File

@@ -12,10 +12,15 @@ export type PlainText = {
content: string; content: string;
}; };
export type UrlText = {
type: 'URL';
content: string;
};
export type TagReference = { export type TagReference = {
type: 'TagReference'; type: 'TagReference';
tagIndex: number;
content: string; content: string;
tagIndex: number;
}; };
export type Bech32Entity = { export type Bech32Entity = {
@@ -34,12 +39,20 @@ export type HashTag = {
tagName: string; tagName: string;
}; };
export type UrlText = { // NIP-30
type: 'URL'; export type CustomEmoji = {
type: 'CustomEmoji';
content: string; 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[]; export type ParsedTextNote = ParsedTextNoteNode[];
@@ -58,21 +71,23 @@ export type MentionedUser = {
pubkey: string; pubkey: string;
}; };
const urlRegex =
/(?<url>(?:https?|wss?):\/\/[-a-zA-Z0-9.]+(:\d{1,5})?(?:\/[-[\]~!$&'()*+.,:;@&=%\w]+|\/)*(?:\?[-[\]~!$&'()*+.,/:;%@&=\w?]+)?(?:#[-[\]~!$&'()*+.,/:;%@\w&=?#]+)?)/g;
const tagRefRegex = /(?:#\[(?<idx>\d+)\])/g; const tagRefRegex = /(?:#\[(?<idx>\d+)\])/g;
const hashTagRegex = /#(?<hashtag>[\p{Letter}\p{Number}_]+)/gu;
// raw NIP-19 codes, NIP-21 links (NIP-27) // raw NIP-19 codes, NIP-21 links (NIP-27)
// nrelay and naddr is not supported by nostr-tools // nrelay and naddr is not supported by nostr-tools
const mentionRegex = const mentionRegex =
/(?<mention>(?<nip19>nostr:)?(?<bech32>(?:npub|note|nprofile|nevent)1[ac-hj-np-z02-9]+))/gi; /(?<mention>(?<nip19>nostr:)?(?<bech32>(?:npub|note|nprofile|nevent)1[ac-hj-np-z02-9]+))/gi;
const urlRegex = const hashTagRegex = /#(?<hashtag>[\p{Letter}\p{Number}_]+)/gu;
/(?<url>(?:https?|wss?):\/\/[-a-zA-Z0-9.]+(:\d{1,5})?(?:\/[-[\]~!$&'()*+.,:;@&=%\w]+|\/)*(?:\?[-[\]~!$&'()*+.,/:;%@&=\w?]+)?(?:#[-[\]~!$&'()*+.,/:;%@\w&=?#]+)?)/g; const customEmojiRegex = /:(?<emoji>[a-zA-Z0-9]+):/gu;
const parseTextNote = (textNoteContent: string) => { const parseTextNote = (textNoteContent: string) => {
const matches = [ const matches = [
...textNoteContent.matchAll(tagRefRegex),
...textNoteContent.matchAll(hashTagRegex),
...textNoteContent.matchAll(mentionRegex),
...textNoteContent.matchAll(urlRegex), ...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)); ].sort((a, b) => (a.index as number) - (b.index as number));
let pos = 0; let pos = 0;
const result: ParsedTextNote = []; const result: ParsedTextNote = [];
@@ -137,6 +152,15 @@ const parseTextNote = (textNoteContent: string) => {
tagName, tagName,
}; };
result.push(hashtag); 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; pos = index + match[0].length;
}); });

View File

@@ -1,7 +1,7 @@
export const isImageUrl = (urlString: string): boolean => { export const isImageUrl = (urlString: string): boolean => {
try { try {
const url = new URL(urlString); 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 { } catch {
return false; return false;
} }