mirror of
https://github.com/aljazceru/rabbit.git
synced 2025-12-17 22:14:26 +01:00
feat: display custom emoji (NIP-30)
This commit is contained in:
@@ -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,19 +101,17 @@ const TextNoteContentDisplay = (props: TextNoteContentDisplayProps) => {
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (item.type === 'URL') {
|
||||
if (isImageUrl(item.content)) {
|
||||
if (item.type === 'CustomEmoji') {
|
||||
const emojiUrl = event().getEmojiUrl(item.shortcode);
|
||||
if (emojiUrl == null) return item.content;
|
||||
return (
|
||||
<ImageDisplay
|
||||
url={item.content}
|
||||
initialHidden={
|
||||
!config().showImage || event().contentWarning().contentWarning || !props.embedding
|
||||
}
|
||||
<img
|
||||
class="inline-block h-6 max-w-[64px] align-middle"
|
||||
src={emojiUrl}
|
||||
alt={item.shortcode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <SafeLink class="text-blue-500 underline" href={item.content} />;
|
||||
}
|
||||
console.error('Not all ParsedTextNoteNodes are covered', item);
|
||||
return null;
|
||||
}}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user