mirror of
https://github.com/aljazceru/rabbit.git
synced 2025-12-17 05:54:19 +01:00
refactor: split Actions components
This commit is contained in:
430
src/components/Actions.tsx
Normal file
430
src/components/Actions.tsx
Normal 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;
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
51
src/nostr/mutation/useReactionMutation.ts
Normal file
51
src/nostr/mutation/useReactionMutation.ts
Normal 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;
|
||||
51
src/nostr/mutation/useRepostMutation.ts
Normal file
51
src/nostr/mutation/useRepostMutation.ts
Normal 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;
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user