mirror of
https://github.com/aljazceru/rabbit.git
synced 2025-12-18 14:34: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 { type Event as NostrEvent } from 'nostr-tools';
|
||||||
|
|
||||||
import EmojiDisplay from '@/components/EmojiDisplay';
|
import EmojiDisplay from '@/components/EmojiDisplay';
|
||||||
import TextNoteDisplay from '@/components/event/textNote/TextNoteDisplay';
|
import TextNote from '@/components/event/TextNote';
|
||||||
import UserDisplayName from '@/components/UserDisplayName';
|
import UserDisplayName from '@/components/UserDisplayName';
|
||||||
import useConfig from '@/core/useConfig';
|
import useConfig from '@/core/useConfig';
|
||||||
import useModalState from '@/hooks/useModalState';
|
import useModalState from '@/hooks/useModalState';
|
||||||
@@ -75,7 +75,7 @@ const ReactionDisplay: Component<ReactionDisplayProps> = (props) => {
|
|||||||
}
|
}
|
||||||
keyed
|
keyed
|
||||||
>
|
>
|
||||||
{(ev) => <TextNoteDisplay event={ev} />}
|
{(ev) => <TextNote event={ev} />}
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</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
|
// 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 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 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 (
|
return (
|
||||||
<Show when={!shouldMuteEvent(props.event)}>
|
<div class="nostr-textnote">
|
||||||
<TextNoteDisplay {...props} />
|
<Post
|
||||||
</Show>
|
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 { createSignal, type Component, type JSX, Show } from 'solid-js';
|
||||||
|
|
||||||
import { useTranslation } from '@/i18n/useTranslation';
|
import { useTranslation } from '@/i18n/useTranslation';
|
||||||
import { ContentWarning } from '@/nostr/event/TextNote';
|
import { ContentWarning } from '@/nostr/event/TextNoteLike';
|
||||||
|
|
||||||
export type ContentWarningDisplayProps = {
|
export type ContentWarningDisplayProps = {
|
||||||
contentWarning: ContentWarning;
|
contentWarning: ContentWarning;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { For } from 'solid-js';
|
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
|
// eslint-disable-next-line import/no-cycle
|
||||||
import EventDisplayById from '@/components/event/EventDisplayById';
|
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 VideoDisplay from '@/components/event/textNote/VideoDisplay';
|
||||||
import EventLink from '@/components/EventLink';
|
import EventLink from '@/components/EventLink';
|
||||||
import PreviewedLink from '@/components/utils/PreviewedLink';
|
import PreviewedLink from '@/components/utils/PreviewedLink';
|
||||||
import { createSearchColumn } from '@/core/column';
|
import { createRelaysColumn, createSearchColumn } from '@/core/column';
|
||||||
import useConfig from '@/core/useConfig';
|
import useConfig from '@/core/useConfig';
|
||||||
import { useRequestCommand } from '@/hooks/useCommandBus';
|
import { useRequestCommand } from '@/hooks/useCommandBus';
|
||||||
import { textNote } from '@/nostr/event';
|
import { ParsedTextNoteResolvedNode, type ParsedTextNoteResolved } from '@/nostr/parseTextNote';
|
||||||
import { type ParsedTextNoteNode } from '@/nostr/parseTextNote';
|
import { isImageUrl, isVideoUrl, isWebSocketUrl } from '@/utils/url';
|
||||||
import { isImageUrl, isVideoUrl } from '@/utils/url';
|
|
||||||
|
|
||||||
export type TextNoteContentDisplayProps = {
|
export type TextNoteContentDisplayProps = {
|
||||||
event: NostrEvent;
|
parsed: ParsedTextNoteResolved;
|
||||||
embedding: boolean;
|
embedding: boolean;
|
||||||
|
initialHidden?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TextNoteContentDisplay = (props: TextNoteContentDisplayProps) => {
|
const TextNoteContentDisplay = (props: TextNoteContentDisplayProps) => {
|
||||||
@@ -28,22 +28,25 @@ const TextNoteContentDisplay = (props: TextNoteContentDisplayProps) => {
|
|||||||
|
|
||||||
const request = useRequestCommand();
|
const request = useRequestCommand();
|
||||||
|
|
||||||
const event = () => textNote(props.event);
|
|
||||||
|
|
||||||
const addHashTagColumn = (query: string) => {
|
const addHashTagColumn = (query: string) => {
|
||||||
saveColumn(createSearchColumn({ query }));
|
saveColumn(createSearchColumn({ query }));
|
||||||
request({ command: 'moveToLastColumn' }).catch((err) => console.error(err));
|
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 (
|
return (
|
||||||
<For each={event().parsed()}>
|
<For each={props.parsed}>
|
||||||
{(item: ParsedTextNoteNode) => {
|
{(item: ParsedTextNoteResolvedNode) => {
|
||||||
if (item.type === 'PlainText') {
|
if (item.type === 'PlainText') {
|
||||||
return <span>{item.content}</span>;
|
return <span>{item.content}</span>;
|
||||||
}
|
}
|
||||||
if (item.type === 'URL') {
|
if (item.type === 'URL') {
|
||||||
const initialHidden = () =>
|
const initialHidden = () =>
|
||||||
!config().showMedia || event().contentWarning().contentWarning || !props.embedding;
|
!config().showMedia || !props.embedding || (props.initialHidden ?? false);
|
||||||
|
|
||||||
if (isImageUrl(item.content)) {
|
if (isImageUrl(item.content)) {
|
||||||
return <ImageDisplay url={item.content} initialHidden={initialHidden()} />;
|
return <ImageDisplay url={item.content} initialHidden={initialHidden()} />;
|
||||||
@@ -51,21 +54,30 @@ const TextNoteContentDisplay = (props: TextNoteContentDisplayProps) => {
|
|||||||
if (isVideoUrl(item.content)) {
|
if (isVideoUrl(item.content)) {
|
||||||
return <VideoDisplay url={item.content} initialHidden={initialHidden()} />;
|
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} />;
|
return <PreviewedLink class="text-blue-500 underline" href={item.content} />;
|
||||||
}
|
}
|
||||||
if (item.type === 'TagReference') {
|
if (item.type === 'TagReferenceResolved') {
|
||||||
const resolved = event().resolveTagReference(item);
|
if (item.reference == null) {
|
||||||
if (resolved == null) {
|
|
||||||
return <span>{item.content}</span>;
|
return <span>{item.content}</span>;
|
||||||
}
|
}
|
||||||
if (resolved.type === 'MentionedUser') {
|
if (item.reference.type === 'MentionedUser') {
|
||||||
return <MentionedUserDisplay pubkey={resolved.pubkey} />;
|
return <MentionedUserDisplay pubkey={item.reference.pubkey} />;
|
||||||
}
|
}
|
||||||
if (resolved.type === 'MentionedEvent') {
|
if (item.reference.type === 'MentionedEvent') {
|
||||||
if (props.embedding) {
|
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') {
|
if (item.type === 'Bech32Entity') {
|
||||||
@@ -94,6 +106,17 @@ const TextNoteContentDisplay = (props: TextNoteContentDisplayProps) => {
|
|||||||
if (item.data.type === 'nprofile') {
|
if (item.data.type === 'nprofile') {
|
||||||
return <MentionedUserDisplay pubkey={item.data.data.pubkey} />;
|
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>;
|
return <span class="text-blue-500 underline">{item.content}</span>;
|
||||||
}
|
}
|
||||||
if (item.type === 'HashTag') {
|
if (item.type === 'HashTag') {
|
||||||
@@ -106,14 +129,13 @@ const TextNoteContentDisplay = (props: TextNoteContentDisplayProps) => {
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (item.type === 'CustomEmoji') {
|
if (item.type === 'CustomEmojiResolved') {
|
||||||
const emojiUrl = event().getEmojiUrl(item.shortcode);
|
if (item.url == null) return <span>{item.content}</span>;
|
||||||
if (emojiUrl == null) return <span>{item.content}</span>;
|
|
||||||
// const { imageRef, canvas } = useImageAnimation({ initialPlaying: false });
|
// const { imageRef, canvas } = useImageAnimation({ initialPlaying: false });
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
class="inline-block h-8 max-w-[128px] align-middle"
|
class="inline-block h-8 max-w-[128px] align-middle"
|
||||||
src={emojiUrl}
|
src={item.url}
|
||||||
alt={item.content}
|
alt={item.content}
|
||||||
title={item.shortcode}
|
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 ExclamationCircle from 'heroicons/24/solid/exclamation-circle.svg';
|
||||||
|
|
||||||
import ContextMenu, { MenuItem } from '@/components/ContextMenu';
|
import ContextMenu, { MenuItem } from '@/components/ContextMenu';
|
||||||
|
import TextNoteContentDisplay from '@/components/event/textNote/TextNoteContentDisplay';
|
||||||
import BasicModal from '@/components/modal/BasicModal';
|
import BasicModal from '@/components/modal/BasicModal';
|
||||||
import UserList from '@/components/modal/UserList';
|
import UserList from '@/components/modal/UserList';
|
||||||
import Timeline from '@/components/timeline/Timeline';
|
import Timeline from '@/components/timeline/Timeline';
|
||||||
@@ -15,6 +16,8 @@ import SafeLink from '@/components/utils/SafeLink';
|
|||||||
import useConfig from '@/core/useConfig';
|
import useConfig from '@/core/useConfig';
|
||||||
import useModalState from '@/hooks/useModalState';
|
import useModalState from '@/hooks/useModalState';
|
||||||
import { useTranslation } from '@/i18n/useTranslation';
|
import { useTranslation } from '@/i18n/useTranslation';
|
||||||
|
import { genericEvent } from '@/nostr/event';
|
||||||
|
import parseTextNote, { toResolved } from '@/nostr/parseTextNote';
|
||||||
import useCommands from '@/nostr/useCommands';
|
import useCommands from '@/nostr/useCommands';
|
||||||
import useFollowers from '@/nostr/useFollowers';
|
import useFollowers from '@/nostr/useFollowers';
|
||||||
import useFollowings, { fetchLatestFollowings } from '@/nostr/useFollowings';
|
import useFollowings, { fetchLatestFollowings } from '@/nostr/useFollowings';
|
||||||
@@ -55,7 +58,11 @@ const ProfileDisplay: Component<ProfileDisplayProps> = (props) => {
|
|||||||
const [modal, setModal] = createSignal<'Following' | null>(null);
|
const [modal, setModal] = createSignal<'Following' | null>(null);
|
||||||
const closeModal = () => setModal(null);
|
const closeModal = () => setModal(null);
|
||||||
|
|
||||||
const { profile, query: profileQuery } = useProfile(() => ({
|
const {
|
||||||
|
profile,
|
||||||
|
event: profileEvent,
|
||||||
|
query: profileQuery,
|
||||||
|
} = useProfile(() => ({
|
||||||
pubkey: props.pubkey,
|
pubkey: props.pubkey,
|
||||||
}));
|
}));
|
||||||
const { verification, query: verificationQuery } = useVerification(() =>
|
const { verification, query: verificationQuery } = useVerification(() =>
|
||||||
@@ -72,6 +79,16 @@ const ProfileDisplay: Component<ProfileDisplayProps> = (props) => {
|
|||||||
const isVerified = () => verification()?.pubkey === props.pubkey;
|
const isVerified = () => verification()?.pubkey === props.pubkey;
|
||||||
const isMuted = () => isPubkeyMuted(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 {
|
const {
|
||||||
followingPubkeys: myFollowingPubkeys,
|
followingPubkeys: myFollowingPubkeys,
|
||||||
invalidateFollowings: invalidateMyFollowings,
|
invalidateFollowings: invalidateMyFollowings,
|
||||||
@@ -368,10 +385,12 @@ const ProfileDisplay: Component<ProfileDisplayProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Show when={(profile()?.about ?? '').length > 0}>
|
<Show when={aboutParsed()} keyed>
|
||||||
<div class="max-h-40 shrink-0 overflow-y-auto whitespace-pre-wrap px-4 py-2 text-sm">
|
{(parsed) => (
|
||||||
{profile()?.about}
|
<div class="max-h-40 shrink-0 overflow-y-auto whitespace-pre-wrap px-4 py-2 text-sm">
|
||||||
</div>
|
<TextNoteContentDisplay parsed={parsed} embedding={false} initialHidden />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
<div class="flex border-t px-4 py-2">
|
<div class="flex border-t px-4 py-2">
|
||||||
<button class="flex flex-1 flex-col items-start" onClick={() => setModal('Following')}>
|
<button class="flex flex-1 flex-col items-start" onClick={() => setModal('Following')}>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import assert from 'assert';
|
import assert from 'assert';
|
||||||
|
|
||||||
import { Kind, type Event as NostrEvent } from 'nostr-tools';
|
import { Kind } from 'nostr-tools';
|
||||||
import { describe, it } from 'vitest';
|
import { describe, it } from 'vitest';
|
||||||
|
|
||||||
import TextNote, { MarkedEventTag, markedEventTags } from '@/nostr/event/TextNote';
|
import TextNote from '@/nostr/event/TextNote';
|
||||||
import { TagReference } from '@/nostr/parseTextNote';
|
import { MarkedEventTag, markedEventTags } from '@/nostr/event/TextNoteLike';
|
||||||
|
|
||||||
describe('markedEventTags', () => {
|
describe('markedEventTags', () => {
|
||||||
it('should return an empty array if the event has no tags', () => {
|
it('should return an empty array if the event has no tags', () => {
|
||||||
@@ -196,66 +196,4 @@ describe('TextNote', () => {
|
|||||||
assert.deepStrictEqual(textnote.replyingToEvent(), expected);
|
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 TextNoteLike from '@/nostr/event/TextNoteLike';
|
||||||
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;
|
|
||||||
|
|
||||||
|
export default class TextNote extends TextNoteLike {
|
||||||
constructor(rawEvent: NostrEvent) {
|
constructor(rawEvent: NostrEvent) {
|
||||||
if (rawEvent.kind !== (Kind.Text as number)) {
|
if (rawEvent.kind !== (Kind.Text as number)) {
|
||||||
throw new TypeError('kind should be 1');
|
throw new TypeError('kind should be 1');
|
||||||
}
|
}
|
||||||
super(rawEvent);
|
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 { 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', () => {
|
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 { nip19 } from 'nostr-tools';
|
||||||
import { DecodeResult } from 'nostr-tools/lib/nip19';
|
import { DecodeResult } from 'nostr-tools/lib/nip19';
|
||||||
|
|
||||||
|
import isValidId from '@/nostr/event/isValidId';
|
||||||
|
import TagsBase from '@/nostr/event/TagsBase';
|
||||||
|
|
||||||
const { decode } = nip19;
|
const { decode } = nip19;
|
||||||
|
|
||||||
export type PlainText = {
|
export type PlainText = {
|
||||||
@@ -13,12 +16,18 @@ export type UrlText = {
|
|||||||
content: string;
|
content: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// NIP-08
|
||||||
export type TagReference = {
|
export type TagReference = {
|
||||||
type: 'TagReference';
|
type: 'TagReference';
|
||||||
content: string;
|
content: string;
|
||||||
tagIndex: number;
|
tagIndex: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TagReferenceResolved = Omit<TagReference, 'type'> & {
|
||||||
|
type: 'TagReferenceResolved';
|
||||||
|
reference: MentionedUser | MentionedEvent | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
export type Bech32Entity = {
|
export type Bech32Entity = {
|
||||||
type: 'Bech32Entity';
|
type: 'Bech32Entity';
|
||||||
content: string;
|
content: string;
|
||||||
@@ -39,6 +48,11 @@ export type CustomEmoji = {
|
|||||||
shortcode: string;
|
shortcode: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CustomEmojiResolved = Omit<CustomEmoji, 'type'> & {
|
||||||
|
type: 'CustomEmojiResolved';
|
||||||
|
url: string | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
export type ParsedTextNoteNode =
|
export type ParsedTextNoteNode =
|
||||||
| PlainText
|
| PlainText
|
||||||
| UrlText
|
| UrlText
|
||||||
@@ -49,12 +63,22 @@ export type ParsedTextNoteNode =
|
|||||||
|
|
||||||
export type ParsedTextNote = 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 = {
|
export type MentionedEvent = {
|
||||||
type: 'MentionedEvent';
|
type: 'MentionedEvent';
|
||||||
content: string;
|
content: string;
|
||||||
tagIndex: number;
|
tagIndex: number;
|
||||||
eventId: string;
|
eventId: string;
|
||||||
marker: 'reply' | 'root' | 'mention' | undefined;
|
marker: Markers | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MentionedUser = {
|
export type MentionedUser = {
|
||||||
@@ -164,4 +188,55 @@ const parseTextNote = (textNoteContent: string) => {
|
|||||||
return result;
|
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;
|
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.
|
* Generate a URL of thumbnail for a given URL.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user