import { Show, For, createSignal, createMemo, onMount, type JSX, 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 ContextMenu, { MenuItem } from '@/components/ContextMenu'; import EmojiPicker from '@/components/EmojiPicker'; // eslint-disable-next-line import/no-cycle import EventDisplayById from '@/components/event/EventDisplayById'; import ContentWarningDisplay from '@/components/event/textNote/ContentWarningDisplay'; import GeneralUserMentionDisplay from '@/components/event/textNote/GeneralUserMentionDisplay'; import TextNoteContentDisplay from '@/components/event/textNote/TextNoteContentDisplay'; import NotePostForm from '@/components/NotePostForm'; import { useTimelineContext } from '@/components/timeline/TimelineContext'; import useConfig from '@/core/useConfig'; import useFormatDate from '@/hooks/useFormatDate'; import useModalState from '@/hooks/useModalState'; import { textNote } from '@/nostr/event'; import useCommands from '@/nostr/useCommands'; import useProfile from '@/nostr/useProfile'; import usePubkey from '@/nostr/usePubkey'; import useReactions from '@/nostr/useReactions'; import useReposts from '@/nostr/useReposts'; import ensureNonNull from '@/utils/ensureNonNull'; import npubEncodeFallback from '@/utils/npubEncodeFallback'; import timeout from '@/utils/timeout'; export type TextNoteDisplayProps = { event: NostrEvent; embedding?: boolean; actions?: boolean; }; type EmojiReactionsProps = { reactionsGroupedByContent: Map; onReaction: (emoji: string) => void; }; const { noteEncode } = nip19; const EmojiReactions: Component = (props) => { const { config } = useConfig(); const pubkey = usePubkey(); return (
{([content, events]) => { const isReactedByMeWithThisContent = events.findIndex((ev) => ev.pubkey === pubkey()) >= 0; return ( ); }}
); }; const TextNoteDisplay: Component = (props) => { let contentRef: HTMLDivElement | undefined; const { config } = useConfig(); const formatDate = useFormatDate(); const pubkey = usePubkey(); const { showProfile } = useModalState(); const timelineContext = useTimelineContext(); const [reacted, setReacted] = createSignal(false); const [reposted, setReposted] = createSignal(false); const [showReplyForm, setShowReplyForm] = createSignal(false); const closeReplyForm = () => setShowReplyForm(false); const [showOverflow, setShowOverflow] = createSignal(false); const [overflow, setOverflow] = createSignal(false); const event = createMemo(() => textNote(props.event)); const embedding = () => props.embedding ?? true; const actions = () => props.actions ?? true; const { profile: author } = useProfile(() => ({ pubkey: props.event.pubkey, })); const { reactions, reactionsGroupedByContent, isReactedBy, isReactedByWithEmoji, invalidateReactions, query: reactionsQuery, } = useReactions(() => ({ eventId: props.event.id, })); const { reposts, isRepostedBy, invalidateReposts, query: repostsQuery, } = useReposts(() => ({ eventId: props.event.id, })); const commands = useCommands(); const publishReactionMutation = createMutation({ mutationKey: ['publishReaction', event().id], mutationFn: commands.publishReaction.bind(commands), onSuccess: () => { console.log('succeeded to publish reaction'); }, 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: commands.publishRepost.bind(commands), onSuccess: () => { console.log('succeeded to publish reposts'); }, 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) => 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('削除しました(画面の反映にはリロード)'); } else if (succeeded > 0) { window.alert(`${failed}個のリレーで削除に失敗しました`); } else { window.alert('すべてのリレーで削除に失敗しました'); } }, onError: (err) => { console.error('failed to delete', err); }, }); const menu: MenuItem[] = [ { content: () => 'IDをコピー', onSelect: () => { navigator.clipboard.writeText(noteEncode(props.event.id)).catch((err) => window.alert(err)); }, }, { content: () => 'JSONとしてコピー', onSelect: () => { navigator.clipboard .writeText(JSON.stringify(props.event, null, 2)) .catch((err) => window.alert(err)); }, }, { when: () => event().pubkey === pubkey(), content: () => 削除, onSelect: () => { const p = pubkey(); if (p == null) return; if (!window.confirm('本当に削除しますか?')) return; deleteMutation.mutate({ relayUrls: config().relayUrls, pubkey: p, eventId: 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 showReplyEvent = (): string | undefined => { if (embedding()) { const replyingToEvent = event().replyingToEvent(); if (replyingToEvent != null && !event().containsEventMention(replyingToEvent.id)) { return replyingToEvent.id; } const rootEvent = event().rootEvent(); if ( replyingToEvent == null && rootEvent != null && !event().containsEventMention(rootEvent.id) ) { return rootEvent.id; } } return undefined; }; const createdAt = () => formatDate(event().createdAtAsDate()); const handleRepost: JSX.EventHandler = (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 = (emoji?: string) => { if (isReactedByMe()) { // TODO remove reaction return; } ensureNonNull([pubkey(), props.event.id] as const)(([pubkeyNonNull, eventIdNonNull]) => { publishReactionMutation.mutate({ relayUrls: config().relayUrls, pubkey: pubkeyNonNull, content: emoji ?? '+', eventId: eventIdNonNull, notifyPubkey: props.event.pubkey, }); setReacted(true); }); }; const handleReaction: JSX.EventHandler = (ev) => { ev.stopPropagation(); doReaction(); }; onMount(() => { if (contentRef != null) { setOverflow(contentRef.scrollHeight > contentRef.clientHeight); } }); return (
{(id) => (
)}
0}>
{(replyToPubkey: string) => ( )} {'への返信'}
0}>
0}>
{reposts().length}
0 } >
{reactions().length}
doReaction(emoji)}>
); }; export default TextNoteDisplay;