mirror of
https://github.com/aljazceru/rabbit.git
synced 2025-12-17 14:04:21 +01:00
feat: display custom emoji reactions
This commit is contained in:
29
src/components/EmojiDisplay.tsx
Normal file
29
src/components/EmojiDisplay.tsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
74
src/nostr/event/Reaction.ts
Normal file
74
src/nostr/event/Reaction.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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[][]) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user