feat: parse profile, add relay column

This commit is contained in:
Shusui MOYATANI
2023-11-29 01:03:29 +09:00
parent 658363bf5a
commit 85505f477e
11 changed files with 689 additions and 796 deletions

View File

@@ -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>

View File

@@ -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>
);
};

View File

@@ -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;

View File

@@ -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}
/>

View File

@@ -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;

View File

@@ -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')}>

View File

@@ -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);
});
});
});

View File

@@ -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)),
);
}
}

View File

@@ -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);
});
});

View File

@@ -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;

View File

@@ -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.
*/