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 (
+
+ );
}
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;
}