mirror of
https://github.com/aljazceru/rabbit.git
synced 2025-12-18 06:24:25 +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') {
|
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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user