mirror of
https://github.com/aljazceru/rabbit.git
synced 2025-12-18 06:24:25 +01:00
feat: parse profile, add relay column
This commit is contained in:
@@ -3,7 +3,7 @@ import { type Component, Show } from 'solid-js';
|
||||
import { type Event as NostrEvent } from 'nostr-tools';
|
||||
|
||||
import EmojiDisplay from '@/components/EmojiDisplay';
|
||||
import TextNoteDisplay from '@/components/event/textNote/TextNoteDisplay';
|
||||
import TextNote from '@/components/event/TextNote';
|
||||
import UserDisplayName from '@/components/UserDisplayName';
|
||||
import useConfig from '@/core/useConfig';
|
||||
import useModalState from '@/hooks/useModalState';
|
||||
@@ -75,7 +75,7 @@ const ReactionDisplay: Component<ReactionDisplayProps> = (props) => {
|
||||
}
|
||||
keyed
|
||||
>
|
||||
{(ev) => <TextNoteDisplay event={ev} />}
|
||||
{(ev) => <TextNote event={ev} />}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -1,18 +1,487 @@
|
||||
import { Show, type Component } from 'solid-js';
|
||||
import {
|
||||
Show,
|
||||
For,
|
||||
createSignal,
|
||||
createMemo,
|
||||
type JSX,
|
||||
type Component,
|
||||
Switch,
|
||||
Match,
|
||||
} 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 EmojiDisplay from '@/components/EmojiDisplay';
|
||||
import EmojiPicker, { EmojiData } from '@/components/EmojiPicker';
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
import TextNoteDisplay, { TextNoteDisplayProps } from '@/components/event/textNote/TextNoteDisplay';
|
||||
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 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 TextNoteProps = TextNoteDisplayProps;
|
||||
export type TextNoteProps = {
|
||||
event: NostrEvent;
|
||||
embedding?: boolean;
|
||||
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 TextNote: Component<TextNoteProps> = (props) => {
|
||||
const { shouldMuteEvent } = useConfig();
|
||||
const i18n = useTranslation();
|
||||
const { config } = useConfig();
|
||||
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 [modal, setModal] = createSignal<'EventDebugModal' | 'Reactions' | 'Reposts' | null>(null);
|
||||
|
||||
const closeReplyForm = () => setShowReplyForm(false);
|
||||
const closeModal = () => setModal(null);
|
||||
|
||||
const event = createMemo(() => textNote(props.event));
|
||||
|
||||
const embedding = () => props.embedding ?? true;
|
||||
const actions = () => props.actions ?? true;
|
||||
|
||||
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 commands = useCommands();
|
||||
|
||||
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 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,
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
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 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));
|
||||
};
|
||||
|
||||
return (
|
||||
<Show when={!shouldMuteEvent(props.event)}>
|
||||
<TextNoteDisplay {...props} />
|
||||
</Show>
|
||||
<div class="nostr-textnote">
|
||||
<Post
|
||||
authorPubkey={event().pubkey}
|
||||
createdAt={event().createdAtAsDate()}
|
||||
content={
|
||||
<div class="textnote-content">
|
||||
<Show when={showReplyEvent()} keyed>
|
||||
{(id) => (
|
||||
<div class="mt-1 rounded border p-1">
|
||||
<EventDisplayById eventId={id} actions={false} embedding={false} />
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={event().taggedPubkeys().length > 0}>
|
||||
<div class="text-xs">
|
||||
{i18n()('post.replyToPre')}
|
||||
<For each={event().taggedPubkeys()}>
|
||||
{(replyToPubkey: string) => (
|
||||
<button
|
||||
class="select-text pr-1 text-blue-500 hover:underline"
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation();
|
||||
showProfile(replyToPubkey);
|
||||
}}
|
||||
>
|
||||
<GeneralUserMentionDisplay pubkey={replyToPubkey} />
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
{i18n()('post.replyToPost')}
|
||||
</div>
|
||||
</Show>
|
||||
<ContentWarningDisplay contentWarning={event().contentWarning()}>
|
||||
<div class="content whitespace-pre-wrap break-all">
|
||||
<TextNoteContentDisplay
|
||||
parsed={event().parsed()}
|
||||
embedding={embedding()}
|
||||
initialHidden={event().contentWarning().contentWarning}
|
||||
/>
|
||||
</div>
|
||||
</ContentWarningDisplay>
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
<Show when={actions()}>
|
||||
<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();
|
||||
setShowReplyForm((current) => !current);
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</Show>
|
||||
}
|
||||
footer={
|
||||
<Show when={showReplyForm()}>
|
||||
<NotePostForm
|
||||
mode="reply"
|
||||
replyTo={props.event}
|
||||
onClose={closeReplyForm}
|
||||
onPost={closeReplyForm}
|
||||
/>
|
||||
</Show>
|
||||
}
|
||||
onShowProfile={() => {
|
||||
showProfile(event().pubkey);
|
||||
}}
|
||||
onShowEvent={() => {
|
||||
timelineContext?.setTimeline({ type: 'Replies', event: props.event });
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createSignal, type Component, type JSX, Show } from 'solid-js';
|
||||
|
||||
import { useTranslation } from '@/i18n/useTranslation';
|
||||
import { ContentWarning } from '@/nostr/event/TextNote';
|
||||
import { ContentWarning } from '@/nostr/event/TextNoteLike';
|
||||
|
||||
export type ContentWarningDisplayProps = {
|
||||
contentWarning: ContentWarning;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { For } from 'solid-js';
|
||||
|
||||
import { Kind, Event as NostrEvent } from 'nostr-tools';
|
||||
import { Kind } from 'nostr-tools';
|
||||
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
import EventDisplayById from '@/components/event/EventDisplayById';
|
||||
@@ -11,16 +11,16 @@ import MentionedUserDisplay from '@/components/event/textNote/MentionedUserDispl
|
||||
import VideoDisplay from '@/components/event/textNote/VideoDisplay';
|
||||
import EventLink from '@/components/EventLink';
|
||||
import PreviewedLink from '@/components/utils/PreviewedLink';
|
||||
import { createSearchColumn } from '@/core/column';
|
||||
import { createRelaysColumn, createSearchColumn } from '@/core/column';
|
||||
import useConfig from '@/core/useConfig';
|
||||
import { useRequestCommand } from '@/hooks/useCommandBus';
|
||||
import { textNote } from '@/nostr/event';
|
||||
import { type ParsedTextNoteNode } from '@/nostr/parseTextNote';
|
||||
import { isImageUrl, isVideoUrl } from '@/utils/url';
|
||||
import { ParsedTextNoteResolvedNode, type ParsedTextNoteResolved } from '@/nostr/parseTextNote';
|
||||
import { isImageUrl, isVideoUrl, isWebSocketUrl } from '@/utils/url';
|
||||
|
||||
export type TextNoteContentDisplayProps = {
|
||||
event: NostrEvent;
|
||||
parsed: ParsedTextNoteResolved;
|
||||
embedding: boolean;
|
||||
initialHidden?: boolean;
|
||||
};
|
||||
|
||||
const TextNoteContentDisplay = (props: TextNoteContentDisplayProps) => {
|
||||
@@ -28,22 +28,25 @@ const TextNoteContentDisplay = (props: TextNoteContentDisplayProps) => {
|
||||
|
||||
const request = useRequestCommand();
|
||||
|
||||
const event = () => textNote(props.event);
|
||||
|
||||
const addHashTagColumn = (query: string) => {
|
||||
saveColumn(createSearchColumn({ query }));
|
||||
request({ command: 'moveToLastColumn' }).catch((err) => console.error(err));
|
||||
};
|
||||
|
||||
const addRelayColumn = (url: string) => {
|
||||
saveColumn(createRelaysColumn({ name: url, relayUrls: [url] }));
|
||||
request({ command: 'moveToLastColumn' }).catch((err) => console.error(err));
|
||||
};
|
||||
|
||||
return (
|
||||
<For each={event().parsed()}>
|
||||
{(item: ParsedTextNoteNode) => {
|
||||
<For each={props.parsed}>
|
||||
{(item: ParsedTextNoteResolvedNode) => {
|
||||
if (item.type === 'PlainText') {
|
||||
return <span>{item.content}</span>;
|
||||
}
|
||||
if (item.type === 'URL') {
|
||||
const initialHidden = () =>
|
||||
!config().showMedia || event().contentWarning().contentWarning || !props.embedding;
|
||||
!config().showMedia || !props.embedding || (props.initialHidden ?? false);
|
||||
|
||||
if (isImageUrl(item.content)) {
|
||||
return <ImageDisplay url={item.content} initialHidden={initialHidden()} />;
|
||||
@@ -51,21 +54,30 @@ const TextNoteContentDisplay = (props: TextNoteContentDisplayProps) => {
|
||||
if (isVideoUrl(item.content)) {
|
||||
return <VideoDisplay url={item.content} initialHidden={initialHidden()} />;
|
||||
}
|
||||
if (isWebSocketUrl(item.content)) {
|
||||
return (
|
||||
<button
|
||||
class="select-text text-blue-500 underline"
|
||||
onClick={() => addRelayColumn(item.content)}
|
||||
>
|
||||
{item.content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return <PreviewedLink class="text-blue-500 underline" href={item.content} />;
|
||||
}
|
||||
if (item.type === 'TagReference') {
|
||||
const resolved = event().resolveTagReference(item);
|
||||
if (resolved == null) {
|
||||
if (item.type === 'TagReferenceResolved') {
|
||||
if (item.reference == null) {
|
||||
return <span>{item.content}</span>;
|
||||
}
|
||||
if (resolved.type === 'MentionedUser') {
|
||||
return <MentionedUserDisplay pubkey={resolved.pubkey} />;
|
||||
if (item.reference.type === 'MentionedUser') {
|
||||
return <MentionedUserDisplay pubkey={item.reference.pubkey} />;
|
||||
}
|
||||
if (resolved.type === 'MentionedEvent') {
|
||||
if (item.reference.type === 'MentionedEvent') {
|
||||
if (props.embedding) {
|
||||
return <MentionedEventDisplay mentionedEvent={resolved} />;
|
||||
return <MentionedEventDisplay mentionedEvent={item.reference} />;
|
||||
}
|
||||
return <EventLink eventId={resolved.eventId} />;
|
||||
return <EventLink eventId={item.reference.eventId} />;
|
||||
}
|
||||
}
|
||||
if (item.type === 'Bech32Entity') {
|
||||
@@ -94,6 +106,17 @@ const TextNoteContentDisplay = (props: TextNoteContentDisplayProps) => {
|
||||
if (item.data.type === 'nprofile') {
|
||||
return <MentionedUserDisplay pubkey={item.data.data.pubkey} />;
|
||||
}
|
||||
if (item.data.type === 'nrelay') {
|
||||
const url: string = item.data.data;
|
||||
return (
|
||||
<button
|
||||
class="select-text text-blue-500 underline"
|
||||
onClick={() => addRelayColumn(url)}
|
||||
>
|
||||
{url} ({item.content})
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return <span class="text-blue-500 underline">{item.content}</span>;
|
||||
}
|
||||
if (item.type === 'HashTag') {
|
||||
@@ -106,14 +129,13 @@ const TextNoteContentDisplay = (props: TextNoteContentDisplayProps) => {
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (item.type === 'CustomEmoji') {
|
||||
const emojiUrl = event().getEmojiUrl(item.shortcode);
|
||||
if (emojiUrl == null) return <span>{item.content}</span>;
|
||||
if (item.type === 'CustomEmojiResolved') {
|
||||
if (item.url == null) return <span>{item.content}</span>;
|
||||
// const { imageRef, canvas } = useImageAnimation({ initialPlaying: false });
|
||||
return (
|
||||
<img
|
||||
class="inline-block h-8 max-w-[128px] align-middle"
|
||||
src={emojiUrl}
|
||||
src={item.url}
|
||||
alt={item.content}
|
||||
title={item.shortcode}
|
||||
/>
|
||||
|
||||
@@ -1,525 +0,0 @@
|
||||
import {
|
||||
Show,
|
||||
For,
|
||||
createSignal,
|
||||
createMemo,
|
||||
type JSX,
|
||||
type Component,
|
||||
Switch,
|
||||
Match,
|
||||
} 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 EmojiDisplay from '@/components/EmojiDisplay';
|
||||
import EmojiPicker, { EmojiData } 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 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 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 TextNoteDisplayProps = {
|
||||
event: NostrEvent;
|
||||
embedding?: boolean;
|
||||
actions?: boolean;
|
||||
};
|
||||
|
||||
type EmojiReactionsProps = {
|
||||
reactionsGrouped: Map<string, NostrEvent[]>;
|
||||
onReaction: (reaction: ReactionTypes) => 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 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>
|
||||
);
|
||||
};
|
||||
|
||||
const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
|
||||
const i18n = useTranslation();
|
||||
const { config } = useConfig();
|
||||
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 [modal, setModal] = createSignal<'EventDebugModal' | 'Reactions' | 'Reposts' | null>(null);
|
||||
|
||||
const closeReplyForm = () => setShowReplyForm(false);
|
||||
const closeModal = () => setModal(null);
|
||||
|
||||
const event = createMemo(() => textNote(props.event));
|
||||
|
||||
const embedding = () => props.embedding ?? true;
|
||||
const actions = () => props.actions ?? true;
|
||||
|
||||
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 commands = useCommands();
|
||||
|
||||
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 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,
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
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 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));
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="nostr-textnote">
|
||||
<Post
|
||||
authorPubkey={event().pubkey}
|
||||
createdAt={event().createdAtAsDate()}
|
||||
content={
|
||||
<div class="textnote-content">
|
||||
<Show when={showReplyEvent()} keyed>
|
||||
{(id) => (
|
||||
<div class="mt-1 rounded border p-1">
|
||||
<EventDisplayById eventId={id} actions={false} embedding={false} />
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={event().taggedPubkeys().length > 0}>
|
||||
<div class="text-xs">
|
||||
{i18n()('post.replyToPre')}
|
||||
<For each={event().taggedPubkeys()}>
|
||||
{(replyToPubkey: string) => (
|
||||
<button
|
||||
class="select-text pr-1 text-blue-500 hover:underline"
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation();
|
||||
showProfile(replyToPubkey);
|
||||
}}
|
||||
>
|
||||
<GeneralUserMentionDisplay pubkey={replyToPubkey} />
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
{i18n()('post.replyToPost')}
|
||||
</div>
|
||||
</Show>
|
||||
<ContentWarningDisplay contentWarning={event().contentWarning()}>
|
||||
<div class="content whitespace-pre-wrap break-all">
|
||||
<TextNoteContentDisplay event={props.event} embedding={embedding()} />
|
||||
</div>
|
||||
</ContentWarningDisplay>
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
<Show when={actions()}>
|
||||
<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();
|
||||
setShowReplyForm((current) => !current);
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</Show>
|
||||
}
|
||||
footer={
|
||||
<Show when={showReplyForm()}>
|
||||
<NotePostForm
|
||||
mode="reply"
|
||||
replyTo={props.event}
|
||||
onClose={closeReplyForm}
|
||||
onPost={closeReplyForm}
|
||||
/>
|
||||
</Show>
|
||||
}
|
||||
onShowProfile={() => {
|
||||
showProfile(event().pubkey);
|
||||
}}
|
||||
onShowEvent={() => {
|
||||
timelineContext?.setTimeline({ type: 'Replies', event: props.event });
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TextNoteDisplay;
|
||||
@@ -8,6 +8,7 @@ import CheckCircle from 'heroicons/24/solid/check-circle.svg';
|
||||
import ExclamationCircle from 'heroicons/24/solid/exclamation-circle.svg';
|
||||
|
||||
import ContextMenu, { MenuItem } from '@/components/ContextMenu';
|
||||
import TextNoteContentDisplay from '@/components/event/textNote/TextNoteContentDisplay';
|
||||
import BasicModal from '@/components/modal/BasicModal';
|
||||
import UserList from '@/components/modal/UserList';
|
||||
import Timeline from '@/components/timeline/Timeline';
|
||||
@@ -15,6 +16,8 @@ import SafeLink from '@/components/utils/SafeLink';
|
||||
import useConfig from '@/core/useConfig';
|
||||
import useModalState from '@/hooks/useModalState';
|
||||
import { useTranslation } from '@/i18n/useTranslation';
|
||||
import { genericEvent } from '@/nostr/event';
|
||||
import parseTextNote, { toResolved } from '@/nostr/parseTextNote';
|
||||
import useCommands from '@/nostr/useCommands';
|
||||
import useFollowers from '@/nostr/useFollowers';
|
||||
import useFollowings, { fetchLatestFollowings } from '@/nostr/useFollowings';
|
||||
@@ -55,7 +58,11 @@ const ProfileDisplay: Component<ProfileDisplayProps> = (props) => {
|
||||
const [modal, setModal] = createSignal<'Following' | null>(null);
|
||||
const closeModal = () => setModal(null);
|
||||
|
||||
const { profile, query: profileQuery } = useProfile(() => ({
|
||||
const {
|
||||
profile,
|
||||
event: profileEvent,
|
||||
query: profileQuery,
|
||||
} = useProfile(() => ({
|
||||
pubkey: props.pubkey,
|
||||
}));
|
||||
const { verification, query: verificationQuery } = useVerification(() =>
|
||||
@@ -72,6 +79,16 @@ const ProfileDisplay: Component<ProfileDisplayProps> = (props) => {
|
||||
const isVerified = () => verification()?.pubkey === props.pubkey;
|
||||
const isMuted = () => isPubkeyMuted(props.pubkey);
|
||||
|
||||
const aboutParsed = createMemo(() => {
|
||||
const ev = profileEvent();
|
||||
const about = profile()?.about;
|
||||
if (ev == null || about == null) return undefined;
|
||||
|
||||
const parsed = parseTextNote(about);
|
||||
const resolved = toResolved(parsed, genericEvent(ev));
|
||||
return resolved;
|
||||
});
|
||||
|
||||
const {
|
||||
followingPubkeys: myFollowingPubkeys,
|
||||
invalidateFollowings: invalidateMyFollowings,
|
||||
@@ -368,10 +385,12 @@ const ProfileDisplay: Component<ProfileDisplayProps> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={(profile()?.about ?? '').length > 0}>
|
||||
<div class="max-h-40 shrink-0 overflow-y-auto whitespace-pre-wrap px-4 py-2 text-sm">
|
||||
{profile()?.about}
|
||||
</div>
|
||||
<Show when={aboutParsed()} keyed>
|
||||
{(parsed) => (
|
||||
<div class="max-h-40 shrink-0 overflow-y-auto whitespace-pre-wrap px-4 py-2 text-sm">
|
||||
<TextNoteContentDisplay parsed={parsed} embedding={false} initialHidden />
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<div class="flex border-t px-4 py-2">
|
||||
<button class="flex flex-1 flex-col items-start" onClick={() => setModal('Following')}>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import assert from 'assert';
|
||||
|
||||
import { Kind, type Event as NostrEvent } from 'nostr-tools';
|
||||
import { Kind } from 'nostr-tools';
|
||||
import { describe, it } from 'vitest';
|
||||
|
||||
import TextNote, { MarkedEventTag, markedEventTags } from '@/nostr/event/TextNote';
|
||||
import { TagReference } from '@/nostr/parseTextNote';
|
||||
import TextNote from '@/nostr/event/TextNote';
|
||||
import { MarkedEventTag, markedEventTags } from '@/nostr/event/TextNoteLike';
|
||||
|
||||
describe('markedEventTags', () => {
|
||||
it('should return an empty array if the event has no tags', () => {
|
||||
@@ -196,66 +196,4 @@ describe('TextNote', () => {
|
||||
assert.deepStrictEqual(textnote.replyingToEvent(), expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#resolveTagReference', () => {
|
||||
it('should resolve a tag reference refers a user', () => {
|
||||
const tagReference: TagReference = {
|
||||
type: 'TagReference',
|
||||
tagIndex: 1,
|
||||
content: '#[1]',
|
||||
};
|
||||
const dummyEvent: NostrEvent = {
|
||||
id: '',
|
||||
sig: '',
|
||||
kind: 1,
|
||||
content: '#[1]',
|
||||
tags: [
|
||||
['p', '9366708117c4a7edf9178acdce538c95059b9eb3394808cdd90564094172d972'],
|
||||
['p', '80d3a41d8a00679c0105faac2cdf7643c9ba26835cff096bf7f9c7a0eee8c8fc'],
|
||||
],
|
||||
created_at: 1678377182,
|
||||
pubkey: '9366708117c4a7edf9178acdce538c95059b9eb3394808cdd90564094172d972',
|
||||
};
|
||||
const textNote = new TextNote(dummyEvent);
|
||||
const result = textNote.resolveTagReference(tagReference);
|
||||
const expected = {
|
||||
type: 'MentionedUser',
|
||||
tagIndex: 1,
|
||||
content: '#[1]',
|
||||
pubkey: '80d3a41d8a00679c0105faac2cdf7643c9ba26835cff096bf7f9c7a0eee8c8fc',
|
||||
};
|
||||
|
||||
assert.deepStrictEqual(result, expected);
|
||||
});
|
||||
|
||||
it('should resolve a tag reference refers an other text note', () => {
|
||||
const tagReference: TagReference = {
|
||||
type: 'TagReference',
|
||||
tagIndex: 1,
|
||||
content: '#[1]',
|
||||
};
|
||||
const dummyEvent: NostrEvent = {
|
||||
id: '',
|
||||
sig: '',
|
||||
kind: 1,
|
||||
content: '',
|
||||
tags: [
|
||||
['p', '80d3a41d8a00679c0105faac2cdf7643c9ba26835cff096bf7f9c7a0eee8c8fc'],
|
||||
['e', 'b9cefcb857fa487d5794156e85b30a7f98cb21721040631210262091d86ff6f2', '', 'reply'],
|
||||
],
|
||||
created_at: 1678377182,
|
||||
pubkey: '9366708117c4a7edf9178acdce538c95059b9eb3394808cdd90564094172d972',
|
||||
};
|
||||
const textNote = new TextNote(dummyEvent);
|
||||
const result = textNote.resolveTagReference(tagReference);
|
||||
const expected = {
|
||||
type: 'MentionedEvent',
|
||||
tagIndex: 1,
|
||||
marker: 'reply',
|
||||
content: '#[1]',
|
||||
eventId: 'b9cefcb857fa487d5794156e85b30a7f98cb21721040631210262091d86ff6f2',
|
||||
};
|
||||
assert.deepStrictEqual(result, expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,175 +1,12 @@
|
||||
import { Event as NostrEvent, Kind } from 'nostr-tools';
|
||||
import { type Event as NostrEvent, Kind } from 'nostr-tools';
|
||||
|
||||
import GenericEvent from '@/nostr/event/GenericEvent';
|
||||
import isValidId from '@/nostr/event/isValidId';
|
||||
import parseTextNote, {
|
||||
MentionedEvent,
|
||||
MentionedUser,
|
||||
ParsedTextNote,
|
||||
TagReference,
|
||||
} from '@/nostr/parseTextNote';
|
||||
|
||||
export type EventMarker = 'reply' | 'root' | 'mention';
|
||||
|
||||
// NIP-10
|
||||
export type MarkedEventTag = {
|
||||
id: string;
|
||||
relayUrl?: string | null;
|
||||
index: number;
|
||||
marker?: EventMarker;
|
||||
};
|
||||
|
||||
export type ContactPubkeyTag = {
|
||||
pubkey: string;
|
||||
relayUrl?: string;
|
||||
petname?: string;
|
||||
};
|
||||
|
||||
export type ContentWarning = {
|
||||
contentWarning: boolean;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export const markedEventTags = (tags: string[][]): MarkedEventTag[] => {
|
||||
// 'eTags' cannot be used here because it does not preserve originalIndex.
|
||||
const events = tags
|
||||
.map((tag, originalIndex) => [tag, originalIndex] as const)
|
||||
.filter(([[tagName, eventId]]) => tagName === 'e' && isValidId(eventId));
|
||||
|
||||
// NIP-10: Positional "e" tags (DEPRECATED)
|
||||
const positionToMarker = (marker: string, index: number): EventMarker | undefined => {
|
||||
// NIP-10 styled marker
|
||||
if (marker === 'root' || marker === 'reply' || marker === 'mention') return marker;
|
||||
// One "e" tag
|
||||
if (events.length === 1) return 'reply';
|
||||
// Two "e" tags or many "e" tags : first tag is root
|
||||
if (index === 0) return 'root';
|
||||
// Two "e" tags
|
||||
if (events.length === 2) return 'reply';
|
||||
// Many "e" tags
|
||||
// The last one is reply.
|
||||
if (index === events.length - 1) return 'reply';
|
||||
// The rest are mentions.
|
||||
return 'mention';
|
||||
};
|
||||
|
||||
return events.map(([[, eventId, relayUrl, marker], originalIndex], eTagIndex) => ({
|
||||
id: eventId,
|
||||
relayUrl: (relayUrl?.length ?? 0) > 0 ? relayUrl : null,
|
||||
marker: positionToMarker(marker, eTagIndex),
|
||||
index: originalIndex,
|
||||
}));
|
||||
};
|
||||
|
||||
export default class TextNote extends GenericEvent {
|
||||
#memoizedMarkedEventTags: MarkedEventTag[] | undefined;
|
||||
|
||||
#memoizedParsed: ParsedTextNote | undefined;
|
||||
import TextNoteLike from '@/nostr/event/TextNoteLike';
|
||||
|
||||
export default class TextNote extends TextNoteLike {
|
||||
constructor(rawEvent: NostrEvent) {
|
||||
if (rawEvent.kind !== (Kind.Text as number)) {
|
||||
throw new TypeError('kind should be 1');
|
||||
}
|
||||
super(rawEvent);
|
||||
}
|
||||
|
||||
parsed(): ParsedTextNote {
|
||||
if (this.#memoizedParsed != null) {
|
||||
return this.#memoizedParsed;
|
||||
}
|
||||
this.#memoizedParsed = parseTextNote(this.content);
|
||||
return this.#memoizedParsed;
|
||||
}
|
||||
|
||||
// TODO パーサー側の関心事な気がするのでどこかで移したい
|
||||
resolveTagReference({
|
||||
tagIndex,
|
||||
content,
|
||||
}: TagReference): MentionedUser | MentionedEvent | undefined {
|
||||
const tag = this.rawEvent.tags[tagIndex];
|
||||
if (tag == null) return undefined;
|
||||
|
||||
const tagName = tag[0];
|
||||
|
||||
if (tagName === 'p' && isValidId(tag[1])) {
|
||||
return {
|
||||
type: 'MentionedUser',
|
||||
tagIndex,
|
||||
content,
|
||||
pubkey: tag[1],
|
||||
} satisfies MentionedUser;
|
||||
}
|
||||
|
||||
if (tagName === 'e' && isValidId(tag[1])) {
|
||||
const mention = this.markedEventTags().find((ev) => ev.index === tagIndex);
|
||||
|
||||
return {
|
||||
type: 'MentionedEvent',
|
||||
tagIndex,
|
||||
content,
|
||||
eventId: tag[1],
|
||||
marker: mention?.marker,
|
||||
} satisfies MentionedEvent;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
markedEventTags(): MarkedEventTag[] {
|
||||
if (this.#memoizedMarkedEventTags != null) {
|
||||
return this.#memoizedMarkedEventTags;
|
||||
}
|
||||
this.#memoizedMarkedEventTags = markedEventTags(this.tags);
|
||||
return this.#memoizedMarkedEventTags;
|
||||
}
|
||||
|
||||
replyingToEvent(): MarkedEventTag | undefined {
|
||||
return this.markedEventTags().find(({ marker }) => marker === 'reply');
|
||||
}
|
||||
|
||||
rootEvent(): MarkedEventTag | undefined {
|
||||
return this.markedEventTags().find(({ marker }) => marker === 'root');
|
||||
}
|
||||
|
||||
mentionedEvents(): MarkedEventTag[] {
|
||||
return this.markedEventTags().filter(({ marker }) => marker === 'mention');
|
||||
}
|
||||
|
||||
contentWarning(): ContentWarning {
|
||||
const tag = this.findLastTagByName('content-warning');
|
||||
if (tag == null) return { contentWarning: false };
|
||||
|
||||
const reason = (tag[1]?.length ?? 0) > 0 ? tag[1] : undefined;
|
||||
return { contentWarning: true, reason };
|
||||
}
|
||||
|
||||
/**
|
||||
* containsEventMention returns true if the content includes event
|
||||
*/
|
||||
containsEventMention(eventId: string): boolean {
|
||||
const tagIndex = this.rawEvent.tags.findIndex(
|
||||
([tagName, id]) => tagName === 'e' && id === eventId,
|
||||
);
|
||||
return this.containsEventNote(eventId) || this.containsEventMentionIndex(tagIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* containsEventMentionIndex returns true if the content includes NIP-08 style mention.
|
||||
*/
|
||||
containsEventMentionIndex(index: number): boolean {
|
||||
if (index < 0 || index >= this.rawEvent.tags.length) return false;
|
||||
return this.parsed().some((node) => node.type === 'TagReference' && node.tagIndex === index);
|
||||
}
|
||||
|
||||
/**
|
||||
* containsEventNote returns true if the content includes NIP-19 event mention.
|
||||
*/
|
||||
containsEventNote(eventId: string): boolean {
|
||||
return this.parsed().some(
|
||||
(node) =>
|
||||
node.type === 'Bech32Entity' &&
|
||||
((node.data.type === 'nevent' && node.data.data.id === eventId) ||
|
||||
(node.data.type === 'note' && node.data.data === eventId)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,12 @@ import assert from 'assert';
|
||||
|
||||
import { describe, it } from 'vitest';
|
||||
|
||||
import parseTextNote, { type ParsedTextNoteNode } from '@/nostr/parseTextNote';
|
||||
import Tags from '@/nostr/event/Tags';
|
||||
import parseTextNote, {
|
||||
TagReference,
|
||||
type ParsedTextNoteNode,
|
||||
resolveTagReference,
|
||||
} from '@/nostr/parseTextNote';
|
||||
|
||||
describe('parseTextNote', () => {
|
||||
/*
|
||||
@@ -220,3 +225,47 @@ describe('parseTextNote', () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('#resolveTagReference', () => {
|
||||
it('should resolve a tag reference refers a user', () => {
|
||||
const tagReference: TagReference = {
|
||||
type: 'TagReference',
|
||||
tagIndex: 1,
|
||||
content: '#[1]',
|
||||
};
|
||||
const tags = new Tags([
|
||||
['p', '9366708117c4a7edf9178acdce538c95059b9eb3394808cdd90564094172d972'],
|
||||
['p', '80d3a41d8a00679c0105faac2cdf7643c9ba26835cff096bf7f9c7a0eee8c8fc'],
|
||||
]);
|
||||
const result = resolveTagReference(tags, tagReference);
|
||||
const expected = {
|
||||
type: 'MentionedUser',
|
||||
tagIndex: 1,
|
||||
content: '#[1]',
|
||||
pubkey: '80d3a41d8a00679c0105faac2cdf7643c9ba26835cff096bf7f9c7a0eee8c8fc',
|
||||
};
|
||||
|
||||
assert.deepStrictEqual(result, expected);
|
||||
});
|
||||
|
||||
it('should resolve a tag reference refers an other text note', () => {
|
||||
const tagReference: TagReference = {
|
||||
type: 'TagReference',
|
||||
tagIndex: 1,
|
||||
content: '#[1]',
|
||||
};
|
||||
const tags = new Tags([
|
||||
['p', '80d3a41d8a00679c0105faac2cdf7643c9ba26835cff096bf7f9c7a0eee8c8fc'],
|
||||
['e', 'b9cefcb857fa487d5794156e85b30a7f98cb21721040631210262091d86ff6f2', '', 'reply'],
|
||||
]);
|
||||
const result = resolveTagReference(tags, tagReference);
|
||||
const expected = {
|
||||
type: 'MentionedEvent',
|
||||
tagIndex: 1,
|
||||
marker: 'reply',
|
||||
content: '#[1]',
|
||||
eventId: 'b9cefcb857fa487d5794156e85b30a7f98cb21721040631210262091d86ff6f2',
|
||||
};
|
||||
assert.deepStrictEqual(result, expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { DecodeResult } from 'nostr-tools/lib/nip19';
|
||||
|
||||
import isValidId from '@/nostr/event/isValidId';
|
||||
import TagsBase from '@/nostr/event/TagsBase';
|
||||
|
||||
const { decode } = nip19;
|
||||
|
||||
export type PlainText = {
|
||||
@@ -13,12 +16,18 @@ export type UrlText = {
|
||||
content: string;
|
||||
};
|
||||
|
||||
// NIP-08
|
||||
export type TagReference = {
|
||||
type: 'TagReference';
|
||||
content: string;
|
||||
tagIndex: number;
|
||||
};
|
||||
|
||||
export type TagReferenceResolved = Omit<TagReference, 'type'> & {
|
||||
type: 'TagReferenceResolved';
|
||||
reference: MentionedUser | MentionedEvent | undefined;
|
||||
};
|
||||
|
||||
export type Bech32Entity = {
|
||||
type: 'Bech32Entity';
|
||||
content: string;
|
||||
@@ -39,6 +48,11 @@ export type CustomEmoji = {
|
||||
shortcode: string;
|
||||
};
|
||||
|
||||
export type CustomEmojiResolved = Omit<CustomEmoji, 'type'> & {
|
||||
type: 'CustomEmojiResolved';
|
||||
url: string | undefined;
|
||||
};
|
||||
|
||||
export type ParsedTextNoteNode =
|
||||
| PlainText
|
||||
| UrlText
|
||||
@@ -49,12 +63,22 @@ export type ParsedTextNoteNode =
|
||||
|
||||
export type ParsedTextNote = ParsedTextNoteNode[];
|
||||
|
||||
export type ParsedTextNoteResolvedNode =
|
||||
| Exclude<ParsedTextNoteNode, TagReference | CustomEmoji>
|
||||
| TagReferenceResolved
|
||||
| CustomEmojiResolved;
|
||||
|
||||
export type ParsedTextNoteResolved = ParsedTextNoteResolvedNode[];
|
||||
|
||||
const MarkerValues = ['reply', 'root', 'mention'] as const;
|
||||
type Markers = (typeof MarkerValues)[number];
|
||||
|
||||
export type MentionedEvent = {
|
||||
type: 'MentionedEvent';
|
||||
content: string;
|
||||
tagIndex: number;
|
||||
eventId: string;
|
||||
marker: 'reply' | 'root' | 'mention' | undefined;
|
||||
marker: Markers | undefined;
|
||||
};
|
||||
|
||||
export type MentionedUser = {
|
||||
@@ -164,4 +188,55 @@ const parseTextNote = (textNoteContent: string) => {
|
||||
return result;
|
||||
};
|
||||
|
||||
const isValidMarker = (marker: string | undefined): marker is Markers => {
|
||||
if (marker == null) return false;
|
||||
return (MarkerValues as readonly string[]).includes(marker);
|
||||
};
|
||||
|
||||
export const resolveTagReference = (
|
||||
tags: TagsBase,
|
||||
{ tagIndex, content }: TagReference,
|
||||
): MentionedUser | MentionedEvent | undefined => {
|
||||
const tag = tags.tags[tagIndex];
|
||||
if (tag == null) return undefined;
|
||||
|
||||
const tagName = tag[0];
|
||||
|
||||
if (tagName === 'p' && isValidId(tag[1])) {
|
||||
return {
|
||||
type: 'MentionedUser',
|
||||
tagIndex,
|
||||
content,
|
||||
pubkey: tag[1],
|
||||
} satisfies MentionedUser;
|
||||
}
|
||||
|
||||
if (tagName === 'e' && isValidId(tag[1])) {
|
||||
const marker = isValidMarker(tag[3]) ? tag[3] : undefined;
|
||||
|
||||
return {
|
||||
type: 'MentionedEvent',
|
||||
tagIndex,
|
||||
content,
|
||||
eventId: tag[1],
|
||||
marker,
|
||||
} satisfies MentionedEvent;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const toResolved = (parsed: ParsedTextNote, tags: TagsBase): ParsedTextNoteResolved =>
|
||||
parsed.map((node): ParsedTextNoteResolvedNode => {
|
||||
if (node.type === 'TagReference') {
|
||||
const reference = resolveTagReference(tags, node);
|
||||
return { ...node, type: 'TagReferenceResolved', reference };
|
||||
}
|
||||
if (node.type === 'CustomEmoji') {
|
||||
const url = tags.getEmojiUrl(node.shortcode);
|
||||
return { ...node, type: 'CustomEmojiResolved', url };
|
||||
}
|
||||
return node;
|
||||
});
|
||||
|
||||
export default parseTextNote;
|
||||
|
||||
@@ -16,6 +16,15 @@ export const isVideoUrl = (urlString: string): boolean => {
|
||||
}
|
||||
};
|
||||
|
||||
export const isWebSocketUrl = (urlString: string): boolean => {
|
||||
try {
|
||||
const url = new URL(urlString);
|
||||
return /^wss?:$/.test(url.protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a URL of thumbnail for a given URL.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user