feat: display custom emoji reactions

This commit is contained in:
Shusui MOYATANI
2023-07-18 03:40:07 +09:00
parent ec6fb06e0d
commit 992f6d01b1
10 changed files with 181 additions and 80 deletions

View File

@@ -0,0 +1,29 @@
import { Component, Match, Switch } from 'solid-js';
import HeartSolid from 'heroicons/24/solid/heart.svg';
import { ReactionTypes } from '@/nostr/event/Reaction';
export type EmojiDisplayProps = {
reactionTypes: ReactionTypes;
};
const isPlus = (r: ReactionTypes) => r.type === 'LikeDislike' && r.content === '+';
const EmojiDisplay: Component<EmojiDisplayProps> = (props) => (
<Switch fallback={<span class="truncate">{props.reactionTypes.content}</span>}>
<Match when={isPlus(props.reactionTypes)}>
<span class="inline-block h-4 w-4 pt-[1px] text-rose-400">
<HeartSolid />
</span>
</Match>
<Match when={props.reactionTypes.type === 'Emoji' && props.reactionTypes} keyed>
{({ content }) => <span class="truncate">{content}</span>}
</Match>
<Match when={props.reactionTypes.type === 'CustomEmoji' && props.reactionTypes} keyed>
{({ shortcode, url }) => <img class="h-4 max-w-[3rem]" src={url} alt={`:${shortcode}}:`} />}
</Match>
</Switch>
);
export default EmojiDisplay;

View File

@@ -1,27 +1,27 @@
import { Switch, Match, type Component, Show } from 'solid-js'; import { type Component, Show } from 'solid-js';
import HeartSolid from 'heroicons/24/solid/heart.svg';
import { type Event as NostrEvent } from 'nostr-tools'; import { type Event as NostrEvent } from 'nostr-tools';
import EmojiDisplay from '@/components/EmojiDisplay';
import TextNoteDisplay from '@/components/event/textNote/TextNoteDisplay'; import TextNoteDisplay from '@/components/event/textNote/TextNoteDisplay';
import UserDisplayName from '@/components/UserDisplayName'; import UserDisplayName from '@/components/UserDisplayName';
import useConfig from '@/core/useConfig'; import useConfig from '@/core/useConfig';
import useModalState from '@/hooks/useModalState'; import useModalState from '@/hooks/useModalState';
import { useTranslation } from '@/i18n/useTranslation'; import { useTranslation } from '@/i18n/useTranslation';
import { genericEvent } from '@/nostr/event'; import { reaction } from '@/nostr/event';
import useEvent from '@/nostr/useEvent'; import useEvent from '@/nostr/useEvent';
import useProfile from '@/nostr/useProfile'; import useProfile from '@/nostr/useProfile';
import ensureNonNull from '@/utils/ensureNonNull'; import ensureNonNull from '@/utils/ensureNonNull';
type ReactionProps = { type ReactionDisplayProps = {
event: NostrEvent; event: NostrEvent;
}; };
const Reaction: Component<ReactionProps> = (props) => { const ReactionDisplay: Component<ReactionDisplayProps> = (props) => {
const i18n = useTranslation(); const i18n = useTranslation();
const { shouldMuteEvent } = useConfig(); const { shouldMuteEvent } = useConfig();
const { showProfile } = useModalState(); const { showProfile } = useModalState();
const event = () => genericEvent(props.event); const event = () => reaction(props.event);
const eventId = () => event().lastTaggedEventId(); const eventId = () => event().lastTaggedEventId();
const { profile } = useProfile(() => ({ const { profile } = useProfile(() => ({
@@ -41,13 +41,7 @@ const Reaction: Component<ReactionProps> = (props) => {
<Show when={!isRemoved() || shouldMuteEvent(props.event)}> <Show when={!isRemoved() || shouldMuteEvent(props.event)}>
<div class="flex gap-1 px-1 text-sm"> <div class="flex gap-1 px-1 text-sm">
<div class="notification-icon flex max-w-[64px] place-items-center"> <div class="notification-icon flex max-w-[64px] place-items-center">
<Switch fallback={<span class="truncate">{props.event.content}</span>}> <EmojiDisplay reactionTypes={event().toReactionTypes()} />
<Match when={props.event.content === '+'}>
<span class="h-4 w-4 pt-[1px] text-rose-400">
<HeartSolid />
</span>
</Match>
</Switch>
</div> </div>
<div class="notification-user flex gap-1 overflow-hidden"> <div class="notification-user flex gap-1 overflow-hidden">
<div class="author-icon h-5 w-5 shrink-0 overflow-hidden object-cover"> <div class="author-icon h-5 w-5 shrink-0 overflow-hidden object-cover">
@@ -84,4 +78,4 @@ const Reaction: Component<ReactionProps> = (props) => {
); );
}; };
export default Reaction; export default ReactionDisplay;

View File

@@ -19,6 +19,7 @@ import HeartSolid from 'heroicons/24/solid/heart.svg';
import { nip19, type Event as NostrEvent } from 'nostr-tools'; import { nip19, type Event as NostrEvent } from 'nostr-tools';
import ContextMenu, { MenuItem } from '@/components/ContextMenu'; import ContextMenu, { MenuItem } from '@/components/ContextMenu';
import EmojiDisplay from '@/components/EmojiDisplay';
import EmojiPicker from '@/components/EmojiPicker'; import EmojiPicker from '@/components/EmojiPicker';
// eslint-disable-next-line import/no-cycle // eslint-disable-next-line import/no-cycle
import EventDisplayById from '@/components/event/EventDisplayById'; import EventDisplayById from '@/components/event/EventDisplayById';
@@ -33,7 +34,8 @@ import { useTimelineContext } from '@/components/timeline/TimelineContext';
import useConfig from '@/core/useConfig'; import useConfig from '@/core/useConfig';
import useModalState from '@/hooks/useModalState'; import useModalState from '@/hooks/useModalState';
import { useTranslation } from '@/i18n/useTranslation'; import { useTranslation } from '@/i18n/useTranslation';
import { textNote } from '@/nostr/event'; import { textNote, reaction } from '@/nostr/event';
import { ReactionTypes } from '@/nostr/event/Reaction';
import useCommands from '@/nostr/useCommands'; import useCommands from '@/nostr/useCommands';
import usePubkey from '@/nostr/usePubkey'; import usePubkey from '@/nostr/usePubkey';
import useReactions from '@/nostr/useReactions'; import useReactions from '@/nostr/useReactions';
@@ -48,8 +50,8 @@ export type TextNoteDisplayProps = {
}; };
type EmojiReactionsProps = { type EmojiReactionsProps = {
reactionsGroupedByContent: Map<string, NostrEvent[]>; reactionsGrouped: Map<string, NostrEvent[]>;
onReaction: (emoji: string) => void; onReaction: (reaction: ReactionTypes) => void;
}; };
const { noteEncode } = nip19; const { noteEncode } = nip19;
@@ -60,10 +62,11 @@ const EmojiReactions: Component<EmojiReactionsProps> = (props) => {
return ( return (
<div class="flex gap-2 overflow-x-auto py-1"> <div class="flex gap-2 overflow-x-auto py-1">
<For each={[...props.reactionsGroupedByContent.entries()]}> <For each={[...props.reactionsGrouped.entries()]}>
{([content, events]) => { {([, events]) => {
const isReactedByMeWithThisContent = const isReactedByMeWithThisContent =
events.findIndex((ev) => ev.pubkey === pubkey()) >= 0; events.findIndex((ev) => ev.pubkey === pubkey()) >= 0;
const reactionTypes = reaction(events[0]).toReactionTypes();
return ( return (
<button <button
@@ -76,16 +79,9 @@ const EmojiReactions: Component<EmojiReactionsProps> = (props) => {
'text-rose-400': isReactedByMeWithThisContent, 'text-rose-400': isReactedByMeWithThisContent,
}} }}
type="button" type="button"
onClick={() => props.onReaction(content)} onClick={() => props.onReaction(reactionTypes)}
> >
<Show <EmojiDisplay reactionTypes={reactionTypes} />
when={content === '+'}
fallback={<span class="truncate text-base">{content}</span>}
>
<span class="inline-block h-3 w-3 pt-[1px] text-rose-400">
<HeartSolid />
</span>
</Show>
<Show when={!config().hideCount}> <Show when={!config().hideCount}>
<span class="ml-1 text-sm">{events.length}</span> <span class="ml-1 text-sm">{events.length}</span>
</Show> </Show>
@@ -119,7 +115,7 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
const { const {
reactions, reactions,
reactionsGroupedByContent, reactionsGrouped,
isReactedBy, isReactedBy,
isReactedByWithEmoji, isReactedByWithEmoji,
invalidateReactions, invalidateReactions,
@@ -289,7 +285,7 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
}); });
}; };
const doReaction = (emoji?: string) => { const doReaction = (reactionTypes?: ReactionTypes) => {
if (isReactedByMe()) { if (isReactedByMe()) {
// TODO remove reaction // TODO remove reaction
return; return;
@@ -299,7 +295,7 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
publishReactionMutation.mutate({ publishReactionMutation.mutate({
relayUrls: config().relayUrls, relayUrls: config().relayUrls,
pubkey: pubkeyNonNull, pubkey: pubkeyNonNull,
content: emoji ?? '+', reactionTypes: reactionTypes ?? { type: 'LikeDislike', content: '+' },
eventId: eventIdNonNull, eventId: eventIdNonNull,
notifyPubkey: props.event.pubkey, notifyPubkey: props.event.pubkey,
}); });
@@ -355,10 +351,7 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
actions={ actions={
<Show when={actions()}> <Show when={actions()}>
<Show when={config().showEmojiReaction && reactions().length > 0}> <Show when={config().showEmojiReaction && reactions().length > 0}>
<EmojiReactions <EmojiReactions reactionsGrouped={reactionsGrouped()} onReaction={doReaction} />
reactionsGroupedByContent={reactionsGroupedByContent()}
onReaction={doReaction}
/>
</Show> </Show>
<div class="actions flex w-52 items-center justify-between gap-8 pt-1"> <div class="actions flex w-52 items-center justify-between gap-8 pt-1">
<button <button
@@ -430,7 +423,9 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
publishReactionMutation.isLoading, publishReactionMutation.isLoading,
}} }}
> >
<EmojiPicker onEmojiSelect={(emoji) => doReaction(emoji)}> <EmojiPicker
onEmojiSelect={(emoji) => doReaction({ type: 'Emoji', content: emoji })}
>
<span class="inline-block h-4 w-4"> <span class="inline-block h-4 w-4">
<Plus /> <Plus />
</span> </span>
@@ -472,16 +467,9 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
<UserList <UserList
data={reactions()} data={reactions()}
pubkeyExtractor={(ev) => ev.pubkey} pubkeyExtractor={(ev) => ev.pubkey}
renderInfo={({ content }) => ( renderInfo={(ev) => (
<div class="w-6"> <div class="w-6">
<Show <EmojiDisplay reactionTypes={reaction(ev).toReactionTypes()} />
when={content === '+'}
fallback={<span class="truncate text-base">{content}</span>}
>
<span class="inline-block h-3 w-3 pt-[1px] text-rose-400">
<HeartSolid />
</span>
</Show>
</div> </div>
)} )}
onClose={closeModal} onClose={closeModal}

View File

@@ -1,8 +1,11 @@
import { Kind, Event as NostrEvent } from 'nostr-tools'; import { Kind, Event as NostrEvent } from 'nostr-tools';
import GenericEvent from '@/nostr/event/GenericEvent'; import GenericEvent from '@/nostr/event/GenericEvent';
import Reaction from '@/nostr/event/Reaction';
import TextNote from '@/nostr/event/TextNote'; import TextNote from '@/nostr/event/TextNote';
export const genericEvent = (event: NostrEvent): GenericEvent => new GenericEvent(event); export const genericEvent = (event: NostrEvent): GenericEvent => new GenericEvent(event);
export const textNote = (event: NostrEvent): TextNote => new TextNote(event); export const textNote = (event: NostrEvent): TextNote => new TextNote(event);
export const reaction = (event: NostrEvent): Reaction => new Reaction(event);

View File

@@ -0,0 +1,74 @@
import { Event as NostrEvent, Kind } from 'nostr-tools';
import GenericEvent from '@/nostr/event/GenericEvent';
const emojiRegex = /^\p{Emoji}[\p{Emoji_Component}\p{Emoji_Modifier}\p{Emoji_Presentation}]*$/u;
const customEmojiRegex = /^:(\w+):$/;
export type ReactionTypes =
| { type: 'Emoji'; content: string }
| { type: 'CustomEmoji'; content: string; shortcode: string; url: string }
| { type: 'LikeDislike'; content: string }
| { type: 'Unknown'; content: string };
const reactionToReactionTypes = (event: Reaction): ReactionTypes => {
if (event.isLikeOrDislike()) {
return { type: 'LikeDislike', content: event.content };
}
if (event.isEmoji()) {
return { type: 'Emoji', content: event.content };
}
if (event.isCustomEmoji()) {
const shortcode = event.getShortcode();
const url = event.getUrl();
if (shortcode != null && url != null) {
return { type: 'CustomEmoji', content: event.content, shortcode, url };
}
}
return { type: 'Unknown', content: event.content };
};
export default class Reaction extends GenericEvent {
constructor(rawEvent: NostrEvent) {
if (rawEvent.kind !== Kind.Reaction) {
throw new TypeError('kind should be 7');
}
super(rawEvent);
}
isLike(): boolean {
return this.content === '+';
}
isDislike(): boolean {
return this.content === '-';
}
isLikeOrDislike(): boolean {
return this.isLike() || this.isDislike();
}
isEmoji(): boolean {
return emojiRegex.test(this.content);
}
isCustomEmoji(): boolean {
return customEmojiRegex.test(this.content);
}
getShortcode(): string | undefined {
const match = this.content.match(customEmojiRegex);
const shortcode = match?.[1];
return shortcode;
}
getUrl(): string | undefined {
const shortcode = this.getShortcode();
if (shortcode == null) return undefined;
return this.getEmojiUrl(shortcode);
}
toReactionTypes(): ReactionTypes {
return reactionToReactionTypes(this);
}
}

View File

@@ -2,7 +2,7 @@ import { z } from 'zod';
import TagsBase from '@/nostr/event/TagsBase'; import TagsBase from '@/nostr/event/TagsBase';
const TagsSchema = z.array(z.array(z.string())); export const TagsSchema = z.array(z.array(z.string()));
export default class Tags extends TagsBase { export default class Tags extends TagsBase {
constructor(readonly tags: string[][]) { constructor(readonly tags: string[][]) {

View File

@@ -1,6 +1,18 @@
import uniq from 'lodash/uniq'; import uniq from 'lodash/uniq';
import { z } from 'zod';
import isValidId from '@/nostr/event/isValidId'; import isValidId from '@/nostr/event/isValidId';
import ensureSchema from '@/utils/ensureSchema';
export const EmojiTagSchema = z.tuple([
z.literal('emoji'),
z.string().regex(/^\w+$/, {
message: 'shortcode can includes only alpahnumeric characters and underscore',
}),
z.string().url(), // .refine(isImageUrl)
]);
export type EmojiTag = z.infer<typeof EmojiTagSchema>;
export default abstract class TagsBase { export default abstract class TagsBase {
abstract get tags(): string[][]; abstract get tags(): string[][];
@@ -25,6 +37,10 @@ export default abstract class TagsBase {
return this.tags.filter(([tagName, eventId]) => tagName === 'e' && isValidId(eventId)); return this.tags.filter(([tagName, eventId]) => tagName === 'e' && isValidId(eventId));
} }
emojiTags(): EmojiTag[] {
return this.tags.filter(ensureSchema(EmojiTagSchema));
}
taggedPubkeys(): string[] { taggedPubkeys(): string[] {
return uniq(this.pTags().map(([, pubkey]) => pubkey)); return uniq(this.pTags().map(([, pubkey]) => pubkey));
} }
@@ -39,4 +55,12 @@ export default abstract class TagsBase {
if (ids.length === 0) return undefined; if (ids.length === 0) return undefined;
return ids[ids.length - 1]; return ids[ids.length - 1];
} }
// TODO move to some abstract class
getEmojiUrl(shortcode: string): string | undefined {
const emojiTag = this.emojiTags().find(([, code]) => code === shortcode);
if (emojiTag == null) return undefined;
const [, , url] = emojiTag;
return url;
}
} }

View File

@@ -32,16 +32,6 @@ export type ContentWarning = {
reason?: string; reason?: string;
}; };
const EmojiTagSchema = z.tuple([
z.literal('emoji'),
z.string().regex(/^\w+$/, {
message: 'shortcode can includes only alpahnumeric characters and underscore',
}),
z.string().url(), // .refine(isImageUrl)
]);
export type EmojiTag = z.infer<typeof EmojiTagSchema>;
export const markedEventTags = (tags: string[][]): MarkedEventTag[] => { export const markedEventTags = (tags: string[][]): MarkedEventTag[] => {
// 'eTags' cannot be used here because it does not preserve originalIndex. // 'eTags' cannot be used here because it does not preserve originalIndex.
const events = tags const events = tags
@@ -184,15 +174,4 @@ export default class TextNote extends GenericEvent {
(node.data.type === 'note' && node.data.data === eventId)), (node.data.type === 'note' && node.data.data === eventId)),
); );
} }
emojiTags(): EmojiTag[] {
return this.rawEvent.tags.filter(ensureSchema(EmojiTagSchema));
}
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

@@ -2,6 +2,7 @@ import { getEventHash, Kind, type UnsignedEvent, type Pub } from 'nostr-tools';
// import '@/types/nostr.d'; // import '@/types/nostr.d';
import { ProfileWithOtherProperties, Profile } from '@/nostr/event/Profile'; import { ProfileWithOtherProperties, Profile } from '@/nostr/event/Profile';
import { ReactionTypes } from '@/nostr/event/Reaction';
import usePool from '@/nostr/usePool'; import usePool from '@/nostr/usePool';
import epoch from '@/utils/epoch'; import epoch from '@/utils/epoch';
@@ -126,25 +127,30 @@ const useCommands = () => {
relayUrls, relayUrls,
pubkey, pubkey,
eventId, eventId,
content, reactionTypes,
notifyPubkey, notifyPubkey,
}: { }: {
relayUrls: string[]; relayUrls: string[];
pubkey: string; pubkey: string;
eventId: string; eventId: string;
content: string; reactionTypes: ReactionTypes;
notifyPubkey: string; notifyPubkey: string;
}): Promise<Promise<void>[]> => { }): Promise<Promise<void>[]> => {
// TODO ensure that content is + or - or emoji. const tags = [
['e', eventId, ''],
['p', notifyPubkey],
];
if (reactionTypes.type === 'CustomEmoji') {
tags.push(['emoji', reactionTypes.shortcode, reactionTypes.url]);
}
const preSignedEvent: UnsignedEvent = { const preSignedEvent: UnsignedEvent = {
kind: 7, kind: 7,
pubkey, pubkey,
created_at: epoch(), created_at: epoch(),
tags: [ tags,
['e', eventId, ''], content: reactionTypes.content,
['p', notifyPubkey],
],
content,
}; };
return publishEvent(relayUrls, preSignedEvent); return publishEvent(relayUrls, preSignedEvent);
}; };

View File

@@ -4,6 +4,7 @@ import { createQuery, useQueryClient, type CreateQueryResult } from '@tanstack/s
import { Event as NostrEvent } from 'nostr-tools'; import { Event as NostrEvent } from 'nostr-tools';
import useConfig from '@/core/useConfig'; import useConfig from '@/core/useConfig';
import { reaction } from '@/nostr/event';
import { eventsQuery } from '@/nostr/query'; import { eventsQuery } from '@/nostr/query';
import { BatchedEventsTask } from '@/nostr/useBatchedEvents'; import { BatchedEventsTask } from '@/nostr/useBatchedEvents';
@@ -13,7 +14,7 @@ export type UseReactionsProps = {
export type UseReactions = { export type UseReactions = {
reactions: () => NostrEvent[]; reactions: () => NostrEvent[];
reactionsGroupedByContent: () => Map<string, NostrEvent[]>; reactionsGrouped: () => Map<string, NostrEvent[]>;
isReactedBy: (pubkey: string) => boolean; isReactedBy: (pubkey: string) => boolean;
isReactedByWithEmoji: (pubkey: string) => boolean; isReactedByWithEmoji: (pubkey: string) => boolean;
invalidateReactions: () => Promise<void>; invalidateReactions: () => Promise<void>;
@@ -50,12 +51,15 @@ const useReactions = (propsProvider: () => UseReactionsProps | null): UseReactio
return data.filter((ev) => !shouldMuteEvent(ev)); return data.filter((ev) => !shouldMuteEvent(ev));
}; };
const reactionsGroupedByContent = () => { const reactionsGrouped = () => {
const result = new Map<string, NostrEvent[]>(); const result = new Map<string, NostrEvent[]>();
reactions().forEach((event) => { reactions().forEach((event) => {
const events = result.get(event.content) ?? []; const key = reaction(event).isCustomEmoji()
? `${event.content}${reaction(event).getUrl() ?? ''}`
: event.content;
const events = result.get(key) ?? [];
events.push(event); events.push(event);
result.set(event.content, events); result.set(key, events);
}); });
return result; return result;
}; };
@@ -71,7 +75,7 @@ const useReactions = (propsProvider: () => UseReactionsProps | null): UseReactio
return { return {
reactions, reactions,
reactionsGroupedByContent, reactionsGrouped,
isReactedBy, isReactedBy,
isReactedByWithEmoji, isReactedByWithEmoji,
invalidateReactions, invalidateReactions,