refactor: split Actions components

This commit is contained in:
Shusui MOYATANI
2023-12-18 02:46:03 +09:00
parent e06ef4628e
commit e3f30b0e30
7 changed files with 544 additions and 432 deletions

430
src/components/Actions.tsx Normal file
View File

@@ -0,0 +1,430 @@
import {
type JSX,
type Component,
Switch,
Match,
Show,
createSignal,
createMemo,
For,
} from 'solid-js';
import { createMutation } from '@tanstack/solid-query';
import ArrowPathRoundedSquare from 'heroicons/24/outline/arrow-path-rounded-square.svg';
import ChatBubbleLeft from 'heroicons/24/outline/chat-bubble-left.svg';
import EllipsisHorizontal from 'heroicons/24/outline/ellipsis-horizontal.svg';
import HeartOutlined from 'heroicons/24/outline/heart.svg';
import Plus from 'heroicons/24/outline/plus.svg';
import HeartSolid from 'heroicons/24/solid/heart.svg';
import { type Event as NostrEvent, nip19 } from 'nostr-tools';
import ContextMenu, { MenuItem } from '@/components/ContextMenu';
import EmojiDisplay from '@/components/EmojiDisplay';
import EmojiPicker, { EmojiData } from '@/components/EmojiPicker';
import EventDebugModal from '@/components/modal/EventDebugModal';
import UserList from '@/components/modal/UserList';
import useConfig from '@/core/useConfig';
import { useTranslation } from '@/i18n/useTranslation';
import { textNote, reaction } from '@/nostr/event';
import { ReactionTypes } from '@/nostr/event/Reaction';
import useReactionMutation from '@/nostr/mutation/useReactionMutation';
import useRepostMutation from '@/nostr/mutation/useRepostMutation';
import useCommands from '@/nostr/useCommands';
import usePubkey from '@/nostr/usePubkey';
import useReactions from '@/nostr/useReactions';
import useReposts from '@/nostr/useReposts';
import ensureNonNull from '@/utils/ensureNonNull';
import timeout from '@/utils/timeout';
export type ActionProps = {
event: NostrEvent;
onClickReply: () => void;
};
const { noteEncode } = nip19;
const emojiDataToReactionTypes = (emoji: EmojiData): ReactionTypes => {
if (emoji.native != null) {
return { type: 'Emoji', content: emoji.native };
}
if (emoji.src != null) {
return {
type: 'CustomEmoji',
content: `:${emoji.id}:`,
shortcode: emoji.id,
url: emoji.src,
};
}
throw new Error('unknown emoji');
};
const ReactionAction = (props: { event: NostrEvent }) => {
const { config } = useConfig();
const pubkey = usePubkey();
const [reacted, setReacted] = createSignal(false);
const { reactions, isReactedByWithEmoji, isReactedBy } = useReactions(() => ({
eventId: props.event.id,
}));
const isReactedByMe = createMemo(() => {
const p = pubkey();
return (p != null && isReactedBy(p)) || reacted();
});
const isReactedByMeWithEmoji = createMemo(() => {
const p = pubkey();
return p != null && isReactedByWithEmoji(p);
});
const publishReactionMutation = useReactionMutation(() => ({
eventId: props.event.id,
}));
const doReaction = (reactionTypes?: ReactionTypes) => {
if (isReactedByMe()) {
// TODO remove reaction
return;
}
ensureNonNull([pubkey(), props.event.id] as const)(([pubkeyNonNull, eventIdNonNull]) => {
publishReactionMutation.mutate({
relayUrls: config().relayUrls,
pubkey: pubkeyNonNull,
reactionTypes: reactionTypes ?? { type: 'LikeDislike', content: '+' },
eventId: eventIdNonNull,
notifyPubkey: props.event.pubkey,
});
setReacted(true);
});
};
const handleReaction: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (ev) => {
ev.stopPropagation();
doReaction();
};
const handleEmojiSelect = (emoji: EmojiData) => {
doReaction(emojiDataToReactionTypes(emoji));
};
return (
<>
<div
class="flex shrink-0 items-center gap-1"
classList={{
'text-zinc-400': !isReactedByMe() || isReactedByMeWithEmoji(),
'hover:text-rose-400': !isReactedByMe() || isReactedByMeWithEmoji(),
'text-rose-400':
(isReactedByMe() && !isReactedByMeWithEmoji()) || publishReactionMutation.isLoading,
}}
>
<button
class="h-4 w-4"
onClick={handleReaction}
disabled={publishReactionMutation.isLoading}
>
<Show when={isReactedByMe() && !isReactedByMeWithEmoji()} fallback={<HeartOutlined />}>
<HeartSolid />
</Show>
</button>
<Show when={!config().hideCount && !config().showEmojiReaction && reactions().length > 0}>
<div class="text-sm text-zinc-400">{reactions().length}</div>
</Show>
</div>
<Show when={config().useEmojiReaction}>
<div
class="flex shrink-0 items-center gap-1"
classList={{
'text-zinc-400': !isReactedByMe() || !isReactedByMeWithEmoji(),
'hover:text-rose-400': !isReactedByMe() || !isReactedByMeWithEmoji(),
'text-rose-400':
(isReactedByMe() && isReactedByMeWithEmoji()) || publishReactionMutation.isLoading,
}}
>
<EmojiPicker onEmojiSelect={handleEmojiSelect}>
<span class="inline-block h-4 w-4">
<Plus />
</span>
</EmojiPicker>
</div>
</Show>
</>
);
};
const RepostAction = (props: { event: NostrEvent }) => {
const { config } = useConfig();
const pubkey = usePubkey();
const [reposted, setReposted] = createSignal(false);
const { reposts, isRepostedBy } = useReposts(() => ({
eventId: props.event.id,
}));
const isRepostedByMe = createMemo(() => {
const p = pubkey();
return (p != null && isRepostedBy(p)) || reposted();
});
const publishRepostMutation = useRepostMutation(() => ({
eventId: props.event.id,
}));
const handleRepost: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (ev) => {
ev.stopPropagation();
if (isRepostedByMe()) {
// TODO remove reaction
return;
}
ensureNonNull([pubkey(), props.event.id] as const)(([pubkeyNonNull, eventIdNonNull]) => {
publishRepostMutation.mutate({
relayUrls: config().relayUrls,
pubkey: pubkeyNonNull,
eventId: eventIdNonNull,
notifyPubkey: props.event.pubkey,
});
setReposted(true);
});
};
return (
<div
class="flex shrink-0 items-center gap-1"
classList={{
'text-zinc-400': !isRepostedByMe(),
'hover:text-green-400': !isRepostedByMe(),
'text-green-400': isRepostedByMe() || publishRepostMutation.isLoading,
}}
>
<button class="h-4 w-4" onClick={handleRepost} disabled={publishRepostMutation.isLoading}>
<ArrowPathRoundedSquare />
</button>
<Show when={!config().hideCount && reposts().length > 0}>
<div class="text-sm text-zinc-400">{reposts().length}</div>
</Show>
</div>
);
};
const ReactionsModal: Component<{ event: NostrEvent; onClose: () => void }> = (props) => {
const { reactions } = useReactions(() => ({
eventId: props.event.id,
}));
return (
<UserList
data={reactions()}
pubkeyExtractor={(ev) => ev.pubkey}
renderInfo={(ev) => (
<div class="w-6">
<EmojiDisplay reactionTypes={reaction(ev).toReactionTypes()} />
</div>
)}
onClose={props.onClose}
/>
);
};
const RepostsModal: Component<{ event: NostrEvent; onClose: () => void }> = (props) => {
const { reposts } = useReposts(() => ({
eventId: props.event.id,
}));
return <UserList data={reposts()} pubkeyExtractor={(ev) => ev.pubkey} onClose={props.onClose} />;
};
const EmojiReactions: Component<{ event: NostrEvent }> = (props) => {
const { config } = useConfig();
const pubkey = usePubkey();
const [reacted, setReacted] = createSignal(false);
const { reactions, reactionsGrouped, isReactedBy } = useReactions(() => ({
eventId: props.event.id,
}));
const mutation = useReactionMutation(() => ({
eventId: props.event.id,
}));
const isReactedByMe = () => {
const p = pubkey();
if (p == null) return reacted();
return reacted() || isReactedBy(p);
};
const doReaction = (reactionTypes?: ReactionTypes) => {
if (isReactedByMe()) {
// TODO remove reaction
return;
}
ensureNonNull([pubkey(), props.event.id] as const)(([pubkeyNonNull, eventIdNonNull]) => {
mutation.mutate({
relayUrls: config().relayUrls,
pubkey: pubkeyNonNull,
reactionTypes: reactionTypes ?? { type: 'LikeDislike', content: '+' },
eventId: eventIdNonNull,
notifyPubkey: props.event.pubkey,
});
setReacted(true);
});
};
return (
<Show when={config().showEmojiReaction && reactions().length > 0}>
<div class="flex gap-2 overflow-x-auto py-1">
<For each={[...reactionsGrouped().entries()]}>
{([, events]) => {
const isReactedByMeWithThisContent =
events.findIndex((ev) => ev.pubkey === pubkey()) >= 0;
const reactionTypes = reaction(events[0]).toReactionTypes();
return (
<button
class="flex h-6 max-w-[128px] items-center rounded border px-1"
classList={{
'text-zinc-400': !isReactedByMeWithThisContent,
'hover:bg-zinc-50': !isReactedByMeWithThisContent,
'bg-rose-50': isReactedByMeWithThisContent,
'border-rose-200': isReactedByMeWithThisContent,
'text-rose-400': isReactedByMeWithThisContent,
}}
type="button"
onClick={(ev) => {
ev.stopPropagation();
doReaction(reactionTypes);
}}
disabled={isReactedByMe()}
>
<EmojiDisplay reactionTypes={reactionTypes} />
<Show when={!config().hideCount}>
<span class="ml-1 text-sm">{events.length}</span>
</Show>
</button>
);
}}
</For>
</div>
</Show>
);
};
const Actions: Component<ActionProps> = (props) => {
const i18n = useTranslation();
const { config } = useConfig();
const pubkey = usePubkey();
const commands = useCommands();
const [modal, setModal] = createSignal<'EventDebugModal' | 'Reactions' | 'Reposts' | null>(null);
const event = createMemo(() => textNote(props.event));
const closeModal = () => setModal(null);
const deleteMutation = createMutation({
mutationKey: ['deleteEvent', event().id],
mutationFn: (...params: Parameters<typeof commands.deleteEvent>) =>
commands
.deleteEvent(...params)
.then((promeses) => Promise.allSettled(promeses.map(timeout(10000)))),
onSuccess: (results) => {
// TODO タイムラインから削除する
const succeeded = results.filter((res) => res.status === 'fulfilled').length;
const failed = results.length - succeeded;
if (succeeded === results.length) {
window.alert(i18n()('post.deletedSuccessfully'));
} else if (succeeded > 0) {
window.alert(i18n()('post.failedToDeletePartially', { count: failed }));
} else {
window.alert(i18n()('post.failedToDelete'));
}
},
onError: (err) => {
console.error('failed to delete', err);
},
});
const menu: MenuItem[] = [
{
content: () => i18n()('post.copyEventId'),
onSelect: () => {
navigator.clipboard.writeText(noteEncode(props.event.id)).catch((err) => window.alert(err));
},
},
{
content: () => i18n()('post.showJSON'),
onSelect: () => {
setModal('EventDebugModal');
},
},
{
content: () => i18n()('post.showReposts'),
onSelect: () => {
setModal('Reposts');
},
},
{
content: () => i18n()('post.showReactions'),
onSelect: () => {
setModal('Reactions');
},
},
{
when: () => event().pubkey === pubkey(),
content: () => <span class="text-red-500">{i18n()('post.deletePost')}</span>,
onSelect: () => {
const p = pubkey();
if (p == null) return;
if (!window.confirm(i18n()('post.confirmDelete'))) return;
deleteMutation.mutate({
relayUrls: config().relayUrls,
pubkey: p,
eventId: event().id,
});
},
},
];
return (
<>
<EmojiReactions event={props.event} />
<div class="actions flex w-52 items-center justify-between gap-8 pt-1">
<button
class="h-4 w-4 shrink-0 text-zinc-400 hover:text-zinc-500"
onClick={(ev) => {
ev.stopPropagation();
props.onClickReply();
}}
>
<ChatBubbleLeft />
</button>
<RepostAction event={props.event} />
<ReactionAction event={props.event} />
<ContextMenu menu={menu}>
<span class="inline-block h-4 w-4 text-zinc-400 hover:text-zinc-500">
<EllipsisHorizontal />
</span>
</ContextMenu>
</div>
<Switch>
<Match when={modal() === 'EventDebugModal'}>
<EventDebugModal event={props.event} onClose={closeModal} />
</Match>
<Match when={modal() === 'Reactions'}>
<ReactionsModal event={props.event} onClose={closeModal} />
</Match>
<Match when={modal() === 'Reposts'}>
<RepostsModal event={props.event} onClose={closeModal} />
</Match>
</Switch>
</>
);
};
export default Actions;

View File

@@ -1,54 +1,20 @@
import {
Show,
For,
createSignal,
createMemo,
type JSX,
type Component,
Switch,
Match,
} from 'solid-js';
import { Show, For, createSignal, createMemo, type Component } from 'solid-js';
import { createMutation } from '@tanstack/solid-query';
import ArrowPathRoundedSquare from 'heroicons/24/outline/arrow-path-rounded-square.svg';
import ChatBubbleLeft from 'heroicons/24/outline/chat-bubble-left.svg';
import EllipsisHorizontal from 'heroicons/24/outline/ellipsis-horizontal.svg';
import HeartOutlined from 'heroicons/24/outline/heart.svg';
import Plus from 'heroicons/24/outline/plus.svg';
import HeartSolid from 'heroicons/24/solid/heart.svg';
import { nip19, type Event as NostrEvent } from 'nostr-tools';
import { type Event as NostrEvent } from 'nostr-tools';
import ContextMenu, { MenuItem } from '@/components/ContextMenu';
import EmojiDisplay from '@/components/EmojiDisplay';
import EmojiPicker, { EmojiData } from '@/components/EmojiPicker';
import Actions from '@/components/Actions';
// eslint-disable-next-line import/no-cycle
import EventDisplayById from '@/components/event/EventDisplayById';
import ContentWarningDisplay from '@/components/event/textNote/ContentWarningDisplay';
import EmojiReactions from '@/components/event/textNote/EmojiReactions';
import GeneralUserMentionDisplay from '@/components/event/textNote/GeneralUserMentionDisplay';
import TextNoteContentDisplay from '@/components/event/textNote/TextNoteContentDisplay';
import EventDebugModal from '@/components/modal/EventDebugModal';
import UserList from '@/components/modal/UserList';
import NotePostForm from '@/components/NotePostForm';
import Post from '@/components/Post';
import { useTimelineContext } from '@/components/timeline/TimelineContext';
import LazyLoad from '@/components/utils/LazyLoad';
import useConfig from '@/core/useConfig';
import useModalState from '@/hooks/useModalState';
import { useTranslation } from '@/i18n/useTranslation';
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';
import useReposts from '@/nostr/useReposts';
import ensureNonNull from '@/utils/ensureNonNull';
import timeout from '@/utils/timeout';
export type ActionProps = {
event: NostrEvent;
onClickReply: () => void;
};
import { textNote } from '@/nostr/event';
export type TextNoteProps = {
event: NostrEvent;
@@ -56,336 +22,6 @@ export type TextNoteProps = {
actions?: boolean;
};
const { noteEncode } = nip19;
const emojiDataToReactionTypes = (emoji: EmojiData): ReactionTypes => {
if (emoji.native != null) {
return { type: 'Emoji', content: emoji.native };
}
if (emoji.src != null) {
return {
type: 'CustomEmoji',
content: `:${emoji.id}:`,
shortcode: emoji.id,
url: emoji.src,
};
}
throw new Error('unknown emoji');
};
const Actions: Component<ActionProps> = (props) => {
const i18n = useTranslation();
const { config } = useConfig();
const pubkey = usePubkey();
const commands = useCommands();
const [modal, setModal] = createSignal<'EventDebugModal' | 'Reactions' | 'Reposts' | null>(null);
const [reacted, setReacted] = createSignal(false);
const [reposted, setReposted] = createSignal(false);
const event = createMemo(() => textNote(props.event));
const closeModal = () => setModal(null);
const {
reactions,
reactionsGrouped,
isReactedBy,
isReactedByWithEmoji,
invalidateReactions,
query: reactionsQuery,
} = useReactions(() => ({
eventId: props.event.id,
}));
const {
reposts,
isRepostedBy,
invalidateReposts,
query: repostsQuery,
} = useReposts(() => ({
eventId: props.event.id,
}));
const isReactedByMe = createMemo(() => {
const p = pubkey();
return (p != null && isReactedBy(p)) || reacted();
});
const isReactedByMeWithEmoji = createMemo(() => {
const p = pubkey();
return p != null && isReactedByWithEmoji(p);
});
const isRepostedByMe = createMemo(() => {
const p = pubkey();
return (p != null && isRepostedBy(p)) || reposted();
});
const publishReactionMutation = createMutation({
mutationKey: ['publishReaction', event().id],
mutationFn: (...params: Parameters<typeof commands.publishReaction>) =>
commands
.publishReaction(...params)
.then((promeses) => Promise.allSettled(promeses.map(timeout(5000)))),
onSuccess: (results) => {
const succeeded = results.filter((res) => res.status === 'fulfilled').length;
const failed = results.length - succeeded;
if (succeeded === results.length) {
console.log('Succeeded to publish a reaction');
} else if (succeeded > 0) {
console.warn(`failed to publish a reaction on ${failed} relays`);
} else {
console.error('failed to publish reaction on all relays');
}
},
onError: (err) => {
console.error('failed to publish reaction: ', err);
},
onSettled: () => {
invalidateReactions()
.then(() => reactionsQuery.refetch())
.catch((err) => console.error('failed to refetch reactions', err));
},
});
const publishRepostMutation = createMutation({
mutationKey: ['publishRepost', event().id],
mutationFn: (...params: Parameters<typeof commands.publishRepost>) =>
commands
.publishRepost(...params)
.then((promeses) => Promise.allSettled(promeses.map(timeout(10000)))),
onSuccess: (results) => {
const succeeded = results.filter((res) => res.status === 'fulfilled').length;
const failed = results.length - succeeded;
if (succeeded === results.length) {
console.log('Succeeded to publish a repost');
} else if (succeeded > 0) {
console.warn(`Failed to publish a repost on ${failed} relays`);
} else {
console.error('Failed to publish a repost on all relays');
}
},
onError: (err) => {
console.error('failed to publish repost: ', err);
},
onSettled: () => {
invalidateReposts()
.then(() => repostsQuery.refetch())
.catch((err) => console.error('failed to refetch reposts', err));
},
});
const deleteMutation = createMutation({
mutationKey: ['deleteEvent', event().id],
mutationFn: (...params: Parameters<typeof commands.deleteEvent>) =>
commands
.deleteEvent(...params)
.then((promeses) => Promise.allSettled(promeses.map(timeout(10000)))),
onSuccess: (results) => {
// TODO タイムラインから削除する
const succeeded = results.filter((res) => res.status === 'fulfilled').length;
const failed = results.length - succeeded;
if (succeeded === results.length) {
window.alert(i18n()('post.deletedSuccessfully'));
} else if (succeeded > 0) {
window.alert(i18n()('post.failedToDeletePartially', { count: failed }));
} else {
window.alert(i18n()('post.failedToDelete'));
}
},
onError: (err) => {
console.error('failed to delete', err);
},
});
const handleRepost: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (ev) => {
ev.stopPropagation();
if (isRepostedByMe()) {
// TODO remove reaction
return;
}
ensureNonNull([pubkey(), props.event.id] as const)(([pubkeyNonNull, eventIdNonNull]) => {
publishRepostMutation.mutate({
relayUrls: config().relayUrls,
pubkey: pubkeyNonNull,
eventId: eventIdNonNull,
notifyPubkey: props.event.pubkey,
});
setReposted(true);
});
};
const doReaction = (reactionTypes?: ReactionTypes) => {
if (isReactedByMe()) {
// TODO remove reaction
return;
}
ensureNonNull([pubkey(), props.event.id] as const)(([pubkeyNonNull, eventIdNonNull]) => {
publishReactionMutation.mutate({
relayUrls: config().relayUrls,
pubkey: pubkeyNonNull,
reactionTypes: reactionTypes ?? { type: 'LikeDislike', content: '+' },
eventId: eventIdNonNull,
notifyPubkey: props.event.pubkey,
});
setReacted(true);
});
};
const handleReaction: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (ev) => {
ev.stopPropagation();
doReaction();
};
const handleEmojiSelect = (emoji: EmojiData) => {
doReaction(emojiDataToReactionTypes(emoji));
};
const menu: MenuItem[] = [
{
content: () => i18n()('post.copyEventId'),
onSelect: () => {
navigator.clipboard.writeText(noteEncode(props.event.id)).catch((err) => window.alert(err));
},
},
{
content: () => i18n()('post.showJSON'),
onSelect: () => {
setModal('EventDebugModal');
},
},
{
content: () => i18n()('post.showReposts'),
onSelect: () => {
setModal('Reposts');
},
},
{
content: () => i18n()('post.showReactions'),
onSelect: () => {
setModal('Reactions');
},
},
{
when: () => event().pubkey === pubkey(),
content: () => <span class="text-red-500">{i18n()('post.deletePost')}</span>,
onSelect: () => {
const p = pubkey();
if (p == null) return;
if (!window.confirm(i18n()('post.confirmDelete'))) return;
deleteMutation.mutate({
relayUrls: config().relayUrls,
pubkey: p,
eventId: event().id,
});
},
},
];
return (
<>
<Show when={config().showEmojiReaction && reactions().length > 0}>
<EmojiReactions reactionsGrouped={reactionsGrouped()} onReaction={doReaction} />
</Show>
<div class="actions flex w-52 items-center justify-between gap-8 pt-1">
<button
class="h-4 w-4 shrink-0 text-zinc-400 hover:text-zinc-500"
onClick={(ev) => {
ev.stopPropagation();
props.onClickReply();
}}
>
<ChatBubbleLeft />
</button>
<div
class="flex shrink-0 items-center gap-1"
classList={{
'text-zinc-400': !isRepostedByMe(),
'hover:text-green-400': !isRepostedByMe(),
'text-green-400': isRepostedByMe() || publishRepostMutation.isLoading,
}}
>
<button class="h-4 w-4" onClick={handleRepost} disabled={publishRepostMutation.isLoading}>
<ArrowPathRoundedSquare />
</button>
<Show when={!config().hideCount && reposts().length > 0}>
<div class="text-sm text-zinc-400">{reposts().length}</div>
</Show>
</div>
<div
class="flex shrink-0 items-center gap-1"
classList={{
'text-zinc-400': !isReactedByMe() || isReactedByMeWithEmoji(),
'hover:text-rose-400': !isReactedByMe() || isReactedByMeWithEmoji(),
'text-rose-400':
(isReactedByMe() && !isReactedByMeWithEmoji()) || publishReactionMutation.isLoading,
}}
>
<button
class="h-4 w-4"
onClick={handleReaction}
disabled={publishReactionMutation.isLoading}
>
<Show when={isReactedByMe() && !isReactedByMeWithEmoji()} fallback={<HeartOutlined />}>
<HeartSolid />
</Show>
</button>
<Show when={!config().hideCount && !config().showEmojiReaction && reactions().length > 0}>
<div class="text-sm text-zinc-400">{reactions().length}</div>
</Show>
</div>
<Show when={config().useEmojiReaction}>
<div
class="flex shrink-0 items-center gap-1"
classList={{
'text-zinc-400': !isReactedByMe() || !isReactedByMeWithEmoji(),
'hover:text-rose-400': !isReactedByMe() || !isReactedByMeWithEmoji(),
'text-rose-400':
(isReactedByMe() && isReactedByMeWithEmoji()) || publishReactionMutation.isLoading,
}}
>
<EmojiPicker onEmojiSelect={handleEmojiSelect}>
<span class="inline-block h-4 w-4">
<Plus />
</span>
</EmojiPicker>
</div>
</Show>
<div>
<ContextMenu menu={menu}>
<span class="inline-block h-4 w-4 text-zinc-400 hover:text-zinc-500">
<EllipsisHorizontal />
</span>
</ContextMenu>
</div>
</div>
<Switch>
<Match when={modal() === 'EventDebugModal'}>
<EventDebugModal event={props.event} onClose={closeModal} />
</Match>
<Match when={modal() === 'Reactions'}>
<UserList
data={reactions()}
pubkeyExtractor={(ev) => ev.pubkey}
renderInfo={(ev) => (
<div class="w-6">
<EmojiDisplay reactionTypes={reaction(ev).toReactionTypes()} />
</div>
)}
onClose={closeModal}
/>
</Match>
<Match when={modal() === 'Reposts'}>
<UserList data={reposts()} pubkeyExtractor={(ev) => ev.pubkey} onClose={closeModal} />
</Match>
</Switch>
</>
);
};
const TextNote: Component<TextNoteProps> = (props) => {
const i18n = useTranslation();
const { showProfile } = useModalState();

View File

@@ -1,53 +0,0 @@
import { Component, For, Show } from 'solid-js';
import { Event as NostrEvent } from 'nostr-tools';
import EmojiDisplay from '@/components/EmojiDisplay';
import useConfig from '@/core/useConfig';
import { reaction } from '@/nostr/event';
import { ReactionTypes } from '@/nostr/event/Reaction';
import usePubkey from '@/nostr/usePubkey';
type EmojiReactionsProps = {
reactionsGrouped: Map<string, NostrEvent[]>;
onReaction: (reaction: ReactionTypes) => void;
};
const EmojiReactions: Component<EmojiReactionsProps> = (props) => {
const { config } = useConfig();
const pubkey = usePubkey();
return (
<div class="flex gap-2 overflow-x-auto py-1">
<For each={[...props.reactionsGrouped.entries()]}>
{([, events]) => {
const isReactedByMeWithThisContent =
events.findIndex((ev) => ev.pubkey === pubkey()) >= 0;
const reactionTypes = reaction(events[0]).toReactionTypes();
return (
<button
class="flex h-6 max-w-[128px] items-center rounded border px-1"
classList={{
'text-zinc-400': !isReactedByMeWithThisContent,
'hover:bg-zinc-50': !isReactedByMeWithThisContent,
'bg-rose-50': isReactedByMeWithThisContent,
'border-rose-200': isReactedByMeWithThisContent,
'text-rose-400': isReactedByMeWithThisContent,
}}
type="button"
onClick={() => props.onReaction(reactionTypes)}
>
<EmojiDisplay reactionTypes={reactionTypes} />
<Show when={!config().hideCount}>
<span class="ml-1 text-sm">{events.length}</span>
</Show>
</button>
);
}}
</For>
</div>
);
};
export default EmojiReactions;

View File

@@ -0,0 +1,51 @@
import { createMemo } from 'solid-js';
import { createMutation, useQueryClient } from '@tanstack/solid-query';
import useCommands from '@/nostr/useCommands';
import { queryKeyUseReactions } from '@/nostr/useReactions';
import timeout from '@/utils/timeout';
type UseReactionMutationProps = {
eventId: string;
};
const useReactionMutation = (propsProvider: () => UseReactionMutationProps) => {
const queryClient = useQueryClient();
const props = createMemo(propsProvider);
const commands = useCommands();
const mutation = createMutation({
mutationKey: ['useReactionMutation', props().eventId] as const,
mutationFn: (...params: Parameters<typeof commands.publishReaction>) =>
commands
.publishReaction(...params)
.then((promises) => Promise.allSettled(promises.map(timeout(5000)))),
onSuccess: (results) => {
const succeeded = results.filter((res) => res.status === 'fulfilled').length;
const failed = results.length - succeeded;
if (succeeded === results.length) {
console.log('Succeeded to publish a reaction');
} else if (succeeded > 0) {
console.warn(`failed to publish a reaction on ${failed} relays`);
} else {
console.error('failed to publish reaction on all relays');
}
},
onError: (err) => {
console.error('failed to publish reaction: ', err);
},
onSettled: () => {
const queryKey = queryKeyUseReactions({ eventId: props().eventId });
queryClient
.refetchQueries({ queryKey })
.then(() => queryClient.invalidateQueries({ queryKey }))
.catch((err) => console.error('failed to refetch reactions', err));
},
});
return mutation;
};
export default useReactionMutation;

View File

@@ -0,0 +1,51 @@
import { createMemo } from 'solid-js';
import { createMutation, useQueryClient } from '@tanstack/solid-query';
import useCommands from '@/nostr/useCommands';
import { queryKeyUseReposts } from '@/nostr/useReposts';
import timeout from '@/utils/timeout';
type UseRepostMutationProps = {
eventId: string;
};
const useRepostMutation = (propsProvider: () => UseRepostMutationProps) => {
const queryClient = useQueryClient();
const props = createMemo(propsProvider);
const commands = useCommands();
const mutation = createMutation({
mutationKey: ['useRepostMutation', props().eventId] as const,
mutationFn: (...params: Parameters<typeof commands.publishRepost>) =>
commands
.publishRepost(...params)
.then((promises) => Promise.allSettled(promises.map(timeout(10000)))),
onSuccess: (results) => {
const succeeded = results.filter((res) => res.status === 'fulfilled').length;
const failed = results.length - succeeded;
if (succeeded === results.length) {
console.log('Succeeded to publish a repost');
} else if (succeeded > 0) {
console.warn(`Failed to publish a repost on ${failed} relays`);
} else {
console.error('Failed to publish a repost on all relays');
}
},
onError: (err) => {
console.error('failed to publish repost: ', err);
},
onSettled: () => {
const queryKey = queryKeyUseReposts({ eventId: props().eventId });
queryClient
.refetchQueries({ queryKey })
.then(() => queryClient.invalidateQueries({ queryKey }))
.catch((err) => console.error('failed to refetch repost', err));
},
});
return mutation;
};
export default useRepostMutation;

View File

@@ -17,17 +17,18 @@ export type UseReactions = {
reactionsGrouped: () => Map<string, NostrEvent[]>;
isReactedBy: (pubkey: string) => boolean;
isReactedByWithEmoji: (pubkey: string) => boolean;
invalidateReactions: () => Promise<void>;
query: CreateQueryResult<NostrEvent[]>;
};
const EmojiRegex = /\p{Emoji_Presentation}/u;
export const queryKeyUseReactions = (props: UseReactionsProps | null) =>
['useReactions', props] as const;
const useReactions = (propsProvider: () => UseReactionsProps | null): UseReactions => {
const { shouldMuteEvent } = useConfig();
const queryClient = useQueryClient();
const props = createMemo(propsProvider);
const genQueryKey = createMemo(() => ['useReactions', props()] as const);
const genQueryKey = createMemo(() => queryKeyUseReactions(propsProvider()));
const query = createQuery(
genQueryKey,
@@ -71,14 +72,11 @@ const useReactions = (propsProvider: () => UseReactionsProps | null): UseReactio
reactions().findIndex((event) => event.pubkey === pubkey && EmojiRegex.test(event.content)) !==
-1;
const invalidateReactions = (): Promise<void> => queryClient.invalidateQueries(genQueryKey());
return {
reactions,
reactionsGrouped,
isReactedBy,
isReactedByWithEmoji,
invalidateReactions,
query,
};
};

View File

@@ -14,15 +14,16 @@ export type UseRepostsProps = {
export type UseReposts = {
reposts: () => NostrEvent[];
isRepostedBy: (pubkey: string) => boolean;
invalidateReposts: () => Promise<void>;
query: CreateQueryResult<NostrEvent[]>;
};
export const queryKeyUseReposts = (props: UseRepostsProps) => ['useReposts', props] as const;
const useReposts = (propsProvider: () => UseRepostsProps): UseReposts => {
const { shouldMuteEvent } = useConfig();
const queryClient = useQueryClient();
const props = createMemo(propsProvider);
const genQueryKey = createMemo(() => ['useReposts', props()] as const);
const genQueryKey = createMemo(() => queryKeyUseReposts(props()));
const query = createQuery(
genQueryKey,
@@ -49,9 +50,7 @@ const useReposts = (propsProvider: () => UseRepostsProps): UseReposts => {
const isRepostedBy = (pubkey: string): boolean =>
reposts().findIndex((event) => event.pubkey === pubkey) !== -1;
const invalidateReposts = (): Promise<void> => queryClient.invalidateQueries(genQueryKey());
return { reposts, isRepostedBy, invalidateReposts, query };
return { reposts, isRepostedBy, query };
};
export default useReposts;