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') {
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') {
const resolved = resolveTagReference(item, props.event);
if (resolved == null) return null;
@@ -88,18 +101,16 @@ const TextNoteContentDisplay = (props: TextNoteContentDisplayProps) => {
</button>
);
}
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 === 'CustomEmoji') {
const emojiUrl = event().getEmojiUrl(item.shortcode);
if (emojiUrl == null) return item.content;
return (
<img
class="inline-block h-6 max-w-[64px] align-middle"
src={emojiUrl}
alt={item.shortcode}
/>
);
}
console.error('Not all ParsedTextNoteNodes are covered', item);
return null;

View File

@@ -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<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) => {
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;
},
};
};

View File

@@ -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', () => {

View File

@@ -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 =
/(?<url>(?:https?|wss?):\/\/[-a-zA-Z0-9.]+(:\d{1,5})?(?:\/[-[\]~!$&'()*+.,:;@&=%\w]+|\/)*(?:\?[-[\]~!$&'()*+.,/:;%@&=\w?]+)?(?:#[-[\]~!$&'()*+.,/:;%@\w&=?#]+)?)/g;
const tagRefRegex = /(?:#\[(?<idx>\d+)\])/g;
const hashTagRegex = /#(?<hashtag>[\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 =
/(?<mention>(?<nip19>nostr:)?(?<bech32>(?:npub|note|nprofile|nevent)1[ac-hj-np-z02-9]+))/gi;
const urlRegex =
/(?<url>(?:https?|wss?):\/\/[-a-zA-Z0-9.]+(:\d{1,5})?(?:\/[-[\]~!$&'()*+.,:;@&=%\w]+|\/)*(?:\?[-[\]~!$&'()*+.,/:;%@&=\w?]+)?(?:#[-[\]~!$&'()*+.,/:;%@\w&=?#]+)?)/g;
const hashTagRegex = /#(?<hashtag>[\p{Letter}\p{Number}_]+)/gu;
const customEmojiRegex = /:(?<emoji>[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;
});

View File

@@ -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;
}