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 { 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;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
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';
|
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[][]) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user