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

View File

@@ -1,8 +1,11 @@
import { Kind, Event as NostrEvent } from 'nostr-tools';
import GenericEvent from '@/nostr/event/GenericEvent';
import Reaction from '@/nostr/event/Reaction';
import TextNote from '@/nostr/event/TextNote';
export const genericEvent = (event: NostrEvent): GenericEvent => new GenericEvent(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';
const TagsSchema = z.array(z.array(z.string()));
export const TagsSchema = z.array(z.array(z.string()));
export default class Tags extends TagsBase {
constructor(readonly tags: string[][]) {

View File

@@ -1,6 +1,18 @@
import uniq from 'lodash/uniq';
import { z } from 'zod';
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 {
abstract get tags(): string[][];
@@ -25,6 +37,10 @@ export default abstract class TagsBase {
return this.tags.filter(([tagName, eventId]) => tagName === 'e' && isValidId(eventId));
}
emojiTags(): EmojiTag[] {
return this.tags.filter(ensureSchema(EmojiTagSchema));
}
taggedPubkeys(): string[] {
return uniq(this.pTags().map(([, pubkey]) => pubkey));
}
@@ -39,4 +55,12 @@ export default abstract class TagsBase {
if (ids.length === 0) return undefined;
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;
};
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[] => {
// 'eTags' cannot be used here because it does not preserve originalIndex.
const events = tags
@@ -184,15 +174,4 @@ export default class TextNote extends GenericEvent {
(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 { ProfileWithOtherProperties, Profile } from '@/nostr/event/Profile';
import { ReactionTypes } from '@/nostr/event/Reaction';
import usePool from '@/nostr/usePool';
import epoch from '@/utils/epoch';
@@ -126,25 +127,30 @@ const useCommands = () => {
relayUrls,
pubkey,
eventId,
content,
reactionTypes,
notifyPubkey,
}: {
relayUrls: string[];
pubkey: string;
eventId: string;
content: string;
reactionTypes: ReactionTypes;
notifyPubkey: string;
}): 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 = {
kind: 7,
pubkey,
created_at: epoch(),
tags: [
['e', eventId, ''],
['p', notifyPubkey],
],
content,
tags,
content: reactionTypes.content,
};
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 useConfig from '@/core/useConfig';
import { reaction } from '@/nostr/event';
import { eventsQuery } from '@/nostr/query';
import { BatchedEventsTask } from '@/nostr/useBatchedEvents';
@@ -13,7 +14,7 @@ export type UseReactionsProps = {
export type UseReactions = {
reactions: () => NostrEvent[];
reactionsGroupedByContent: () => Map<string, NostrEvent[]>;
reactionsGrouped: () => Map<string, NostrEvent[]>;
isReactedBy: (pubkey: string) => boolean;
isReactedByWithEmoji: (pubkey: string) => boolean;
invalidateReactions: () => Promise<void>;
@@ -50,12 +51,15 @@ const useReactions = (propsProvider: () => UseReactionsProps | null): UseReactio
return data.filter((ev) => !shouldMuteEvent(ev));
};
const reactionsGroupedByContent = () => {
const reactionsGrouped = () => {
const result = new Map<string, NostrEvent[]>();
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);
result.set(event.content, events);
result.set(key, events);
});
return result;
};
@@ -71,7 +75,7 @@ const useReactions = (propsProvider: () => UseReactionsProps | null): UseReactio
return {
reactions,
reactionsGroupedByContent,
reactionsGrouped,
isReactedBy,
isReactedByWithEmoji,
invalidateReactions,