This commit is contained in:
Shusui MOYATANI
2023-03-17 09:38:28 +09:00
parent 158d0e3a20
commit c34143065b
23 changed files with 421 additions and 246 deletions

View File

@@ -6,24 +6,10 @@ type ConfigProps = {
}; };
const RelayConfig = () => { const RelayConfig = () => {
const { config, setConfig } = useConfig(); const { config, setConfig, addRelay, removeRelay } = useConfig();
const [relayUrlInput, setRelayUrlInput] = createSignal<string>(''); const [relayUrlInput, setRelayUrlInput] = createSignal<string>('');
const addRelay = (relayUrl: string) => {
setConfig((current) => ({
...current,
relayUrls: [...current.relayUrls, relayUrl],
}));
};
const removeRelay = (relayUrl: string) => {
setConfig((current) => ({
...current,
relayUrls: current.relayUrls.filter((e) => e !== relayUrl),
}));
};
const handleClickAddRelay: JSX.EventHandler<HTMLFormElement, Event> = (ev) => { const handleClickAddRelay: JSX.EventHandler<HTMLFormElement, Event> = (ev) => {
ev.preventDefault(); ev.preventDefault();
const relayUrl = ev.currentTarget?.relayUrl?.value as string | undefined; const relayUrl = ev.currentTarget?.relayUrl?.value as string | undefined;

View File

@@ -1,25 +1,75 @@
import { createSignal, createMemo, onMount, type Component, type JSX } from 'solid-js'; import { createSignal, createMemo, onMount, For, type Component, type JSX, Show } from 'solid-js';
import { Event as NostrEvent } from 'nostr-tools';
import PaperAirplane from 'heroicons/24/solid/paper-airplane.svg'; import PaperAirplane from 'heroicons/24/solid/paper-airplane.svg';
import XMark from 'heroicons/24/outline/x-mark.svg';
import UserNameDisplay from '@/components/UserDisplayName';
import eventWrapper from '@/core/event';
import useConfig from '@/nostr/useConfig';
import useCommands from '@/nostr/useCommands';
import usePubkey from '@/nostr/usePubkey';
type NotePostFormProps = { type NotePostFormProps = {
ref?: HTMLTextAreaElement | undefined; replyTo?: NostrEvent;
onPost: (textNote: { content: string }) => void; mode?: 'normal' | 'reply';
onClose: () => void; onClose: () => void;
}; };
const NotePostForm: Component<NotePostFormProps> = (props) => { const placeholder = (mode: NotePostFormProps['mode']) => {
const [text, setText] = createSignal<string>(''); switch (mode) {
case 'normal':
return 'いまどうしてる?';
case 'reply':
return '返信を投稿';
default:
return 'いまどうしてる?';
}
};
const NotePostForm: Component<NotePostFormProps> = (props) => {
let textAreaRef: HTMLTextAreaElement | undefined;
const { config } = useConfig();
const getPubkey = usePubkey();
const commands = useCommands();
const [text, setText] = createSignal<string>('');
const clearText = () => setText(''); const clearText = () => setText('');
const replyTo = () => props.replyTo && eventWrapper(props.replyTo);
const mode = () => props.mode ?? 'normal';
const notifyPubkeys = createMemo(() => replyTo()?.mentionedPubkeys() ?? []);
const handleChangeText: JSX.EventHandler<HTMLTextAreaElement, Event> = (ev) => { const handleChangeText: JSX.EventHandler<HTMLTextAreaElement, Event> = (ev) => {
setText(ev.currentTarget.value); setText(ev.currentTarget.value);
}; };
// TODO 投稿完了したかどうかの検知をしたい
const submit = () => { const submit = () => {
props.onPost({ content: text() }); const pubkey = getPubkey();
clearText(); if (pubkey == null) {
console.error('pubkey is not available');
return;
}
commands
.publishTextNote({
relayUrls: config().relayUrls,
pubkey,
content: text(),
notifyPubkeys: notifyPubkeys(),
rootEventId: replyTo()?.rootEvent()?.id ?? replyTo()?.id,
replyEventId: replyTo()?.id,
})
.then(() => {
console.log('succeeded to post');
clearText();
props?.onClose();
})
.catch((err) => {
console.error('error', err);
});
}; };
const handleSubmit: JSX.EventHandler<HTMLFormElement, Event> = (ev) => { const handleSubmit: JSX.EventHandler<HTMLFormElement, Event> = (ev) => {
@@ -37,23 +87,55 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
const submitDisabled = createMemo(() => text().trim().length === 0); const submitDisabled = createMemo(() => text().trim().length === 0);
onMount(() => {
setTimeout(() => {
textAreaRef?.focus();
}, 50);
});
return ( return (
<div class="p-1"> <div class="p-1">
<Show when={notifyPubkeys().length > 0}>
<div>
<For each={notifyPubkeys()}>
{(pubkey) => (
<>
<UserNameDisplay pubkey={pubkey} />{' '}
</>
)}
</For>
</div>
</Show>
<form class="flex flex-col gap-1" onSubmit={handleSubmit}> <form class="flex flex-col gap-1" onSubmit={handleSubmit}>
<textarea <textarea
ref={props.ref} ref={textAreaRef}
name="text" name="text"
class="rounded border-none" class="rounded border-none"
rows={4} rows={4}
placeholder="いまどうしてる?" placeholder={placeholder(mode())}
onInput={handleChangeText} onInput={handleChangeText}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
value={text()} value={text()}
/> />
<div class="grid justify-end"> <div class="flex items-end justify-end">
<Show when={mode() === 'reply'}>
<div class="flex-1">
<button class="h-5 w-5 text-stone-500" onClick={() => props.onClose()}>
<XMark />
</button>
</div>
</Show>
<button <button
class="h-8 w-8 rounded bg-primary p-2 font-bold text-white" class="rounded bg-primary p-2 font-bold text-white"
classList={{ 'bg-primary-disabled': submitDisabled(), 'bg-primary': !submitDisabled() }} classList={{
'bg-primary-disabled': submitDisabled(),
'bg-primary': !submitDisabled(),
'h-8': mode() === 'normal',
'w-8': mode() === 'normal',
'h-7': mode() === 'reply',
'w-7': mode() === 'reply',
}}
type="submit" type="submit"
disabled={submitDisabled()} disabled={submitDisabled()}
> >

View File

@@ -1,73 +0,0 @@
import { createSignal, createMemo, type Component, type JSX } from 'solid-js';
import type { Event as NostrEvent } from 'nostr-tools';
import PaperAirplane from 'heroicons/24/solid/paper-airplane.svg';
type ReplyPostFormProps = {
replyTo: NostrEvent;
onPost: (textNote: { content: string }) => void;
onClose: () => void;
};
const ReplyPostForm: Component<ReplyPostFormProps> = (props: ReplyPostFormProps) => {
const [text, setText] = createSignal<string>('');
const clearText = () => setText('');
const handleChangeText: JSX.EventHandler<HTMLTextAreaElement, Event> = (ev) => {
setText(ev.currentTarget.value);
};
// TODO 投稿完了したかどうかの検知をしたい
const submit = () => {
props.onPost({ content: text() });
clearText();
};
const handleSubmit: JSX.EventHandler<HTMLFormElement, Event> = (ev) => {
ev.preventDefault();
submit();
};
const handleKeyDown: JSX.EventHandler<HTMLTextAreaElement, KeyboardEvent> = (ev) => {
if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey)) {
submit();
}
};
const submitDisabled = createMemo(() => text().trim().length === 0);
return (
<div class="p-1">
<div>
{'Replying to '}
{props.replyTo.pubkey}
</div>
<form class="grid w-full gap-1" onSubmit={handleSubmit}>
<textarea
name="text"
class="rounded border-none"
rows={4}
placeholder="返信を投稿"
onInput={handleChangeText}
onKeyDown={handleKeyDown}
value={text()}
/>
<div class="flex justify-between">
{/* TODO あとでちゃんとアイコンにする */}
<button onClick={() => props.onClose()}>X</button>
<button
class="h-7 w-7 rounded bg-primary p-2 font-bold text-white"
classList={{ 'bg-primary-disabled': submitDisabled(), 'bg-primary': !submitDisabled() }}
type="submit"
disabled={submitDisabled()}
>
<PaperAirplane />
</button>
</div>
</form>
</div>
);
};
export default ReplyPostForm;

View File

@@ -6,56 +6,18 @@ import Cog6Tooth from 'heroicons/24/outline/cog-6-tooth.svg';
import NotePostForm from '@/components/NotePostForm'; import NotePostForm from '@/components/NotePostForm';
import Config from '@/components/Config'; import Config from '@/components/Config';
import useConfig from '@/nostr/useConfig';
import useCommands from '@/nostr/useCommands';
import usePubkey from '@/nostr/usePubkey';
import { useHandleCommand } from '@/hooks/useCommandBus'; import { useHandleCommand } from '@/hooks/useCommandBus';
const SideBar: Component = () => { const SideBar: Component = () => {
let formTextAreaRef: HTMLTextAreaElement | undefined;
const { config } = useConfig();
const getPubkey = usePubkey();
const commands = useCommands();
const [formOpened, setFormOpened] = createSignal(false); const [formOpened, setFormOpened] = createSignal(false);
const [configOpened, setConfigOpened] = createSignal(false); const [configOpened, setConfigOpened] = createSignal(false);
const openForm = () => { const openForm = () => setFormOpened(true);
setFormOpened(true); const closeForm = () => setFormOpened(false);
setTimeout(() => {
formTextAreaRef?.focus?.();
}, 100);
};
const closeForm = () => {
setFormOpened(false);
formTextAreaRef?.blur?.();
};
const handlePost = ({ content }: { content: string }) => {
const pubkey = getPubkey();
if (pubkey == null) {
console.error('pubkey is not available');
return;
}
commands
.publishTextNote({
relayUrls: config().relayUrls,
pubkey,
content,
})
.then(() => {
console.log('succeeded to post');
})
.catch((err) => {
console.error('error', err);
});
};
useHandleCommand(() => ({ useHandleCommand(() => ({
commandType: 'openPostForm', commandType: 'openPostForm',
handler: (cmd) => { handler: (cmd) => openForm(),
openForm();
},
})); }));
return ( return (
@@ -87,7 +49,7 @@ const SideBar: Component = () => {
</div> </div>
</div> </div>
<Show when={formOpened()}> <Show when={formOpened()}>
<NotePostForm ref={formTextAreaRef} onPost={handlePost} onClose={closeForm} /> <NotePostForm onClose={closeForm} />
</Show> </Show>
<Show when={configOpened()}> <Show when={configOpened()}>
<Config onClose={() => setConfigOpened(false)} /> <Config onClose={() => setConfigOpened(false)} />

View File

@@ -1,4 +1,5 @@
import { Component, Switch, Match } from 'solid-js'; import { Component, Switch, Match } from 'solid-js';
import { npubEncode } from 'nostr-tools/nip19';
import useConfig from '@/nostr/useConfig'; import useConfig from '@/nostr/useConfig';
import useProfile from '@/nostr/useProfile'; import useProfile from '@/nostr/useProfile';
@@ -15,7 +16,7 @@ const UserNameDisplay: Component<UserNameDisplayProps> = (props) => {
})); }));
return ( return (
<Switch fallback={`@${props.pubkey}`}> <Switch fallback={npubEncode(props.pubkey)}>
<Match when={(profile()?.display_name?.length ?? 0) > 0}>{profile()?.display_name}</Match> <Match when={(profile()?.display_name?.length ?? 0) > 0}>{profile()?.display_name}</Match>
<Match when={(profile()?.name?.length ?? 0) > 0}>@{profile()?.name}</Match> <Match when={(profile()?.name?.length ?? 0) > 0}>@{profile()?.name}</Match>
</Switch> </Switch>

View File

@@ -9,6 +9,7 @@ import UserDisplayName from '@/components/UserDisplayName';
import useConfig from '@/nostr/useConfig'; import useConfig from '@/nostr/useConfig';
import useProfile from '@/nostr/useProfile'; import useProfile from '@/nostr/useProfile';
import useEvent from '@/nostr/useEvent'; import useEvent from '@/nostr/useEvent';
import { npubEncode } from 'nostr-tools/nip19';
type ReactionProps = { type ReactionProps = {
event: NostrEvent; event: NostrEvent;
@@ -63,10 +64,11 @@ const Reaction: Component<ReactionProps> = (props) => {
</div> </div>
<div class="notification-event py-1"> <div class="notification-event py-1">
<Show <Show
when={reactedEvent() != null} when={reactedEvent()}
fallback={<div class="truncate">loading {eventId()}</div>} fallback={<div class="truncate">loading {eventId()}</div>}
keyed
> >
<TextNoteDisplay event={reactedEvent()} /> {(event) => <TextNoteDisplay event={event} />}
</Show> </Show>
</div> </div>
</ColumnItem> </ColumnItem>

View File

@@ -1,7 +1,8 @@
import type { MentionedUser } from '@/core/parseTextNote'; import { Show } from 'solid-js';
import { npubEncode } from 'nostr-tools/nip19';
import useProfile from '@/nostr/useProfile'; import useProfile from '@/nostr/useProfile';
import useConfig from '@/nostr/useConfig'; import useConfig from '@/nostr/useConfig';
import { Show } from 'solid-js';
export type GeneralUserMentionDisplayProps = { export type GeneralUserMentionDisplayProps = {
pubkey: string; pubkey: string;
@@ -15,7 +16,7 @@ const GeneralUserMentionDisplay = (props: GeneralUserMentionDisplayProps) => {
})); }));
return ( return (
<Show when={(profile()?.name?.length ?? 0) > 0} fallback={`@${props.pubkey}`}> <Show when={(profile()?.name?.length ?? 0) > 0} fallback={`@${npubEncode(props.pubkey)}`}>
@{profile()?.name ?? props.pubkey} @{profile()?.name ?? props.pubkey}
</Show> </Show>
); );

View File

@@ -1,29 +1,13 @@
import { Component, createSignal, Show } from 'solid-js'; import { Component, createEffect, createSignal, Show } from 'solid-js';
import { ContentWarning } from '@/core/event'; import { fixUrl } from '@/utils/imageUrl';
type ImageDisplayProps = { type ImageDisplayProps = {
url: string; url: string;
contentWarning: ContentWarning; initialHidden: boolean;
};
const fixUrl = (url: URL): string => {
const result = new URL(url);
if (url.host === 'i.imgur.com') {
const match = url.pathname.match(/^\/([a-zA-Z0-9]+)\.(jpg|jpeg|png|gif)/);
if (match != null) {
const imageId = match[1];
result.pathname = `${imageId}l.webp`;
}
} else if (url.host === 'i.gyazo.com') {
result.host = 'thumb.gyazo.com';
result.pathname = `/thumb/640_w${url.pathname}`;
}
return result.toString();
}; };
const ImageDisplay: Component<ImageDisplayProps> = (props) => { const ImageDisplay: Component<ImageDisplayProps> = (props) => {
const [hidden, setHidden] = createSignal(props.contentWarning.contentWarning); const [hidden, setHidden] = createSignal(props.initialHidden);
const url = () => new URL(props.url);
return ( return (
<Show <Show
@@ -40,7 +24,7 @@ const ImageDisplay: Component<ImageDisplayProps> = (props) => {
<a class="my-2 block" href={props.url} target="_blank" rel="noopener noreferrer"> <a class="my-2 block" href={props.url} target="_blank" rel="noopener noreferrer">
<img <img
class="inline-block max-h-64 max-w-full rounded object-contain shadow hover:shadow-md" class="inline-block max-h-64 max-w-full rounded object-contain shadow hover:shadow-md"
src={fixUrl(url())} src={fixUrl(new URL(props.url)).toString()}
alt={props.url} alt={props.url}
/> />
</a> </a>

View File

@@ -1,4 +1,4 @@
import { For, Switch, Match } from 'solid-js'; import { For } from 'solid-js';
import parseTextNote, { type ParsedTextNoteNode } from '@/core/parseTextNote'; import parseTextNote, { type ParsedTextNoteNode } from '@/core/parseTextNote';
import type { Event as NostrEvent } from 'nostr-tools'; import type { Event as NostrEvent } from 'nostr-tools';
import PlainTextDisplay from '@/components/textNote/PlainTextDisplay'; import PlainTextDisplay from '@/components/textNote/PlainTextDisplay';
@@ -6,6 +6,7 @@ import MentionedUserDisplay from '@/components/textNote/MentionedUserDisplay';
import MentionedEventDisplay from '@/components/textNote/MentionedEventDisplay'; import MentionedEventDisplay from '@/components/textNote/MentionedEventDisplay';
import ImageDisplay from '@/components/textNote/ImageDisplay'; import ImageDisplay from '@/components/textNote/ImageDisplay';
import eventWrapper from '@/core/event'; import eventWrapper from '@/core/event';
import { isImageUrl } from '@/utils/imageUrl';
import EventLink from '../EventLink'; import EventLink from '../EventLink';
import TextNoteDisplayById from './TextNoteDisplayById'; import TextNoteDisplayById from './TextNoteDisplayById';
@@ -45,8 +46,13 @@ const TextNoteContentDisplay = (props: TextNoteContentDisplayProps) => {
return <span class="text-blue-500 underline">{item.content}</span>; return <span class="text-blue-500 underline">{item.content}</span>;
} }
if (item.type === 'URL') { if (item.type === 'URL') {
if (item.content.match(/\.(jpeg|jpg|png|gif|webp)$/i) && props.embedding) { if (isImageUrl(new URL(item.content))) {
return <ImageDisplay url={item.content} contentWarning={event().contentWarning()} />; return (
<ImageDisplay
url={item.content}
initialHidden={event().contentWarning().contentWarning || !props.embedding}
/>
);
} }
return ( return (
<a <a

View File

@@ -1,4 +1,13 @@
import { Show, For, createSignal, createMemo, type JSX, type Component } from 'solid-js'; import {
Show,
For,
createSignal,
createMemo,
type JSX,
type Component,
Match,
Switch,
} from 'solid-js';
import type { Event as NostrEvent } from 'nostr-tools'; import type { Event as NostrEvent } from 'nostr-tools';
import HeartOutlined from 'heroicons/24/outline/heart.svg'; import HeartOutlined from 'heroicons/24/outline/heart.svg';
@@ -9,8 +18,9 @@ import EllipsisHorizontal from 'heroicons/24/outline/ellipsis-horizontal.svg';
import ColumnItem from '@/components/ColumnItem'; import ColumnItem from '@/components/ColumnItem';
import GeneralUserMentionDisplay from '@/components/textNote/GeneralUserMentionDisplay'; import GeneralUserMentionDisplay from '@/components/textNote/GeneralUserMentionDisplay';
import ContentWarningDisplay from '@/components/textNote/ContentWarningDisplay';
import TextNoteContentDisplay from '@/components/textNote/TextNoteContentDisplay'; import TextNoteContentDisplay from '@/components/textNote/TextNoteContentDisplay';
import ReplyPostForm from '@/components/ReplyPostForm'; import NotePostForm from '@/components/NotePostForm';
import eventWrapper from '@/core/event'; import eventWrapper from '@/core/event';
@@ -24,7 +34,9 @@ import useDeprecatedReposts from '@/nostr/useDeprecatedReposts';
import useFormatDate from '@/hooks/useFormatDate'; import useFormatDate from '@/hooks/useFormatDate';
import ensureNonNull from '@/utils/ensureNonNull'; import ensureNonNull from '@/utils/ensureNonNull';
import ContentWarningDisplay from './ContentWarningDisplay'; import { npubEncode } from 'nostr-tools/nip19';
import UserNameDisplay from '../UserDisplayName';
import TextNoteDisplayById from './TextNoteDisplayById';
export type TextNoteDisplayProps = { export type TextNoteDisplayProps = {
event: NostrEvent; event: NostrEvent;
@@ -32,15 +44,15 @@ export type TextNoteDisplayProps = {
actions?: boolean; actions?: boolean;
}; };
const ContentWarning = (props) => {};
const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => { const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
const { config } = useConfig(); const { config } = useConfig();
const formatDate = useFormatDate(); const formatDate = useFormatDate();
const commands = useCommands(); const commands = useCommands();
const pubkey = usePubkey(); const pubkey = usePubkey();
const [showReplyForm, setShowReplyForm] = createSignal(false); const [showReplyForm, setShowReplyForm] = createSignal(false);
const [showContentWarning, setShowContentWarning] = createSignal(false); const [postingRepost, setPostingRepost] = createSignal(false);
const [postingReaction, setPostingReaction] = createSignal(false);
const event = createMemo(() => eventWrapper(props.event)); const event = createMemo(() => eventWrapper(props.event));
@@ -54,41 +66,41 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
const { reactions, isReactedBy, invalidateReactions } = useReactions(() => ({ const { reactions, isReactedBy, invalidateReactions } = useReactions(() => ({
relayUrls: config().relayUrls, relayUrls: config().relayUrls,
eventId: props.event.id, eventId: props.event.id as string, // TODO いつかなおす
})); }));
const { reposts, isRepostedBy, invalidateDeprecatedReposts } = useDeprecatedReposts(() => ({ const { reposts, isRepostedBy, invalidateDeprecatedReposts } = useDeprecatedReposts(() => ({
relayUrls: config().relayUrls, relayUrls: config().relayUrls,
eventId: props.event.id, eventId: props.event.id as string, // TODO いつかなおす
})); }));
const isReactedByMe = createMemo(() => isReactedBy(pubkey())); const isReactedByMe = createMemo(() => isReactedBy(pubkey()));
const isRepostedByMe = createMemo(() => isRepostedBy(pubkey())); const isRepostedByMe = createMemo(() => isRepostedBy(pubkey()));
const createdAt = () => formatDate(event().createdAtAsDate()); const showReplyEvent = (): string | undefined => {
const replyingToEvent = event().replyingToEvent();
const handleReplyPost = ({ content }: { content: string }) => { if (
commands embedding() &&
.publishTextNote({ replyingToEvent != null &&
relayUrls: config().relayUrls, !event().containsEventMentionIndex(replyingToEvent.index)
pubkey: pubkey(), ) {
content, return replyingToEvent.id;
notifyPubkeys: [event().pubkey, ...event().mentionedPubkeys()], }
rootEventId: event().rootEvent()?.id ?? props.event.id, return undefined;
replyEventId: props.event.id,
})
.then(() => {
setShowReplyForm(false);
});
}; };
const createdAt = () => formatDate(event().createdAtAsDate());
const handleRepost: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (ev) => { const handleRepost: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (ev) => {
if (isRepostedByMe()) { if (isRepostedByMe()) {
// TODO remove reaction // TODO remove reaction
return; return;
} }
ev.preventDefault(); if (postingRepost()) {
return;
}
setPostingRepost(true);
ensureNonNull([pubkey(), props.event.id] as const)(([pubkeyNonNull, eventIdNonNull]) => { ensureNonNull([pubkey(), props.event.id] as const)(([pubkeyNonNull, eventIdNonNull]) => {
commands commands
.publishDeprecatedRepost({ .publishDeprecatedRepost({
@@ -98,7 +110,8 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
notifyPubkey: props.event.pubkey, notifyPubkey: props.event.pubkey,
}) })
.then(() => invalidateDeprecatedReposts()) .then(() => invalidateDeprecatedReposts())
.catch((err) => console.error('failed to repost: ', err)); .catch((err) => console.error('failed to repost: ', err))
.finally(() => setPostingRepost(false));
}); });
}; };
@@ -107,8 +120,11 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
// TODO remove reaction // TODO remove reaction
return; return;
} }
ev.preventDefault(); if (postingReaction()) {
return;
}
setPostingReaction(true);
ensureNonNull([pubkey(), props.event.id] as const)(([pubkeyNonNull, eventIdNonNull]) => { ensureNonNull([pubkey(), props.event.id] as const)(([pubkeyNonNull, eventIdNonNull]) => {
commands commands
.publishReaction({ .publishReaction({
@@ -119,7 +135,8 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
notifyPubkey: props.event.pubkey, notifyPubkey: props.event.pubkey,
}) })
.then(() => invalidateReactions()) .then(() => invalidateReactions())
.catch((err) => console.error('failed to publish reaction: ', err)); .catch((err) => console.error('failed to publish reaction: ', err))
.finally(() => setPostingReaction(false));
}); });
}; };
@@ -145,23 +162,31 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
<div class="author-name truncate pr-1 font-bold">{author()?.display_name}</div> <div class="author-name truncate pr-1 font-bold">{author()?.display_name}</div>
</Show> </Show>
<div class="author-username truncate text-zinc-600"> <div class="author-username truncate text-zinc-600">
<Show when={author()?.name} fallback={props.event.pubkey}> <Show when={author()?.name != null} fallback={`@${npubEncode(props.event.pubkey)}`}>
@{author()?.name} @{author()?.name}
</Show> </Show>
{/* TODO <Match when={author()?.nip05 != null}>@{author()?.nip05}</Match> */}
</div> </div>
</div> </div>
<div class="created-at shrink-0">{createdAt()}</div> <div class="created-at shrink-0">{createdAt()}</div>
</div> </div>
<Show when={event().mentionedPubkeys().length > 0}> <Show when={showReplyEvent()} keyed>
{(id) => (
<div class="rounded border p-1">
<TextNoteDisplayById eventId={id} actions={false} embedding={false} />
</div>
)}
</Show>
<Show when={event().mentionedPubkeysWithoutAuthor().length > 0}>
<div class="text-xs"> <div class="text-xs">
{'Replying to '} <For each={event().mentionedPubkeysWithoutAuthor()}>
<For each={event().mentionedPubkeys()}>
{(replyToPubkey: string) => ( {(replyToPubkey: string) => (
<span class="pr-1 text-blue-500 underline"> <span class="pr-1 text-blue-500 underline">
<GeneralUserMentionDisplay pubkey={replyToPubkey} /> <GeneralUserMentionDisplay pubkey={replyToPubkey} />
</span> </span>
)} )}
</For> </For>
{'への返信'}
</div> </div>
</Show> </Show>
<ContentWarningDisplay contentWarning={event().contentWarning()}> <ContentWarningDisplay contentWarning={event().contentWarning()}>
@@ -184,7 +209,7 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
'text-green-400': isRepostedByMe(), 'text-green-400': isRepostedByMe(),
}} }}
> >
<button class="h-4 w-4" onClick={handleRepost}> <button class="h-4 w-4" onClick={handleRepost} disabled={postingRepost()}>
<ArrowPathRoundedSquare /> <ArrowPathRoundedSquare />
</button> </button>
<Show when={reposts().length > 0}> <Show when={reposts().length > 0}>
@@ -198,7 +223,7 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
'text-rose-400': isReactedByMe(), 'text-rose-400': isReactedByMe(),
}} }}
> >
<button class="h-4 w-4" onClick={handleReaction}> <button class="h-4 w-4" onClick={handleReaction} disabled={postingReaction()}>
<Show when={isReactedByMe()} fallback={<HeartOutlined />}> <Show when={isReactedByMe()} fallback={<HeartOutlined />}>
<HeartSolid /> <HeartSolid />
</Show> </Show>
@@ -215,11 +240,7 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
</div> </div>
</div> </div>
<Show when={showReplyForm()}> <Show when={showReplyForm()}>
<ReplyPostForm <NotePostForm mode="reply" replyTo={props.event} onClose={() => setShowReplyForm(false)} />
replyTo={props.event}
onPost={handleReplyPost}
onClose={() => setShowReplyForm(false)}
/>
</Show> </Show>
</div> </div>
); );

View File

@@ -6,6 +6,7 @@ export type EventMarker = 'reply' | 'root' | 'mention';
export type TaggedEvent = { export type TaggedEvent = {
id: string; id: string;
relayUrl?: string; relayUrl?: string;
index: number;
marker: EventMarker; marker: EventMarker;
}; };
@@ -28,8 +29,8 @@ const eventWrapper = (event: NostrEvent) => {
get createdAt(): number { get createdAt(): number {
return event.created_at; return event.created_at;
}, },
get content(): Date { get content(): string {
return new Date(event.created_at * 1000); return event.content;
}, },
createdAtAsDate(): Date { createdAtAsDate(): Date {
return new Date(event.created_at * 1000); return new Date(event.created_at * 1000);
@@ -44,7 +45,9 @@ const eventWrapper = (event: NostrEvent) => {
return Array.from(pubkeys); return Array.from(pubkeys);
}, },
taggedEvents(): TaggedEvent[] { taggedEvents(): TaggedEvent[] {
const events = event.tags.filter(([tagName]) => tagName === 'e'); const events = event.tags
.map((tag, originalIndex) => [tag, originalIndex] as const)
.filter(([[tagName]]) => tagName === 'e');
// NIP-10: Positional "e" tags (DEPRECATED) // NIP-10: Positional "e" tags (DEPRECATED)
const positionToMarker = (index: number): EventMarker => { const positionToMarker = (index: number): EventMarker => {
@@ -61,10 +64,11 @@ const eventWrapper = (event: NostrEvent) => {
return 'mention'; return 'mention';
}; };
return events.map(([, eventId, relayUrl, marker], index) => ({ return events.map(([[, eventId, relayUrl, marker], originalIndex], eTagIndex) => ({
id: eventId, id: eventId,
relayUrl, relayUrl,
marker: (marker as EventMarker | undefined) ?? positionToMarker(index), marker: (marker as EventMarker | undefined) ?? positionToMarker(eTagIndex),
index: originalIndex,
})); }));
}, },
replyingToEvent(): TaggedEvent | undefined { replyingToEvent(): TaggedEvent | undefined {
@@ -79,6 +83,9 @@ const eventWrapper = (event: NostrEvent) => {
mentionedPubkeys(): string[] { mentionedPubkeys(): string[] {
return uniq(event.tags.filter(([tagName]) => tagName === 'p').map((e) => e[1])); return uniq(event.tags.filter(([tagName]) => tagName === 'p').map((e) => e[1]));
}, },
mentionedPubkeysWithoutAuthor(): string[] {
return this.mentionedPubkeys().filter((pubkey) => pubkey !== event.pubkey);
},
contentWarning(): ContentWarning { contentWarning(): ContentWarning {
const tag = event.tags.find(([tagName]) => tagName === 'content-warning'); const tag = event.tags.find(([tagName]) => tagName === 'content-warning');
if (tag == null) return { contentWarning: false }; if (tag == null) return { contentWarning: false };
@@ -86,6 +93,15 @@ const eventWrapper = (event: NostrEvent) => {
const reason = (tag[1]?.length ?? 0) > 0 ? tag[1] : undefined; const reason = (tag[1]?.length ?? 0) > 0 ? tag[1] : undefined;
return { contentWarning: true, reason }; return { contentWarning: true, reason };
}, },
containsEventMention(eventId: string): boolean {
const tagIndex = event.tags.findIndex(([tagName, id]) => tagName === 'e' && id === eventId);
if (tagIndex < 0) return false;
return this.containsEventMentionIndex(tagIndex);
},
containsEventMentionIndex(index: number): boolean {
if (index < 0 || index >= event.tags.length) return false;
return event.content.includes(`#[${index}]`);
},
}; };
}; };

View File

@@ -55,7 +55,7 @@ export type ParsedTextNote = ParsedTextNoteNode[];
export const parseTextNote = (event: NostrEvent): ParsedTextNote => { export const parseTextNote = (event: NostrEvent): ParsedTextNote => {
const matches = [ const matches = [
...event.content.matchAll(/(?:#\[(?<idx>\d+)\])/g), ...event.content.matchAll(/(?:#\[(?<idx>\d+)\])/g),
...event.content.matchAll(/#(?<hashtag>[^[-`:-@!-/{-~\d\s][^[-`:-@!-/{-~\s]+)/g), ...event.content.matchAll(/#(?<hashtag>[^[-^`:-@!-/{-~\d\s][^[-^`:-@!-/{-~\s]+)/g),
...event.content.matchAll( ...event.content.matchAll(
/(?<nip19>(npub|note|nprofile|nevent|nrelay|naddr)1[ac-hj-np-z02-9]+)/gi, /(?<nip19>(npub|note|nprofile|nevent|nrelay|naddr)1[ac-hj-np-z02-9]+)/gi,
), ),

11
src/hooks/useFileInput.ts Normal file
View File

@@ -0,0 +1,11 @@
import { createSignal, type JSX } from 'solid-js';
const useFileInput = () => {
const [file, setFile] = createSignal<File | undefined>();
const handleChange: JSX.EventHandler<HTMLInputElement, Event> = (ev) => {
setFile(ev.currentTarget.files?.[0]);
};
return { file, handleChange };
};

View File

@@ -1,20 +1,24 @@
import { createMemo } from 'solid-js';
import useConfig from '@/nostr/useConfig'; import useConfig from '@/nostr/useConfig';
import useDatePulser from '@/hooks/useDatePulser'; import useDatePulser from '@/hooks/useDatePulser';
import { formatRelative, formatAbsolute } from '@/utils/formatDate'; import { formatRelative, formatAbsoluteLong, formatAbsoluteShort } from '@/utils/formatDate';
const useFormatDate = () => { const useFormatDate = () => {
const { config } = useConfig(); const { config } = useConfig();
const currentDate = useDatePulser(); const currentDate = useDatePulser();
return (date: Date) => { return (date: Date) => {
if (config().dateFormat === 'absolute') { switch (config().dateFormat) {
return formatAbsolute(date); case 'absolute-long':
return formatAbsoluteLong(date, currentDate());
case 'absolute-short':
return formatAbsoluteShort(date, currentDate());
case 'relative':
return formatRelative(date, currentDate());
default:
return formatRelative(date, currentDate());
} }
return formatRelative(date, currentDate());
}; };
}; };

View File

@@ -1,4 +1,4 @@
import { createSignal, createMemo, type Signal, type Accessor } from 'solid-js'; import { createSignal, createMemo, createRoot, type Signal, type Accessor } from 'solid-js';
import { type Event as NostrEvent, type Filter } from 'nostr-tools'; import { type Event as NostrEvent, type Filter } from 'nostr-tools';
import useConfig from '@/nostr/useConfig'; import useConfig from '@/nostr/useConfig';
@@ -50,9 +50,11 @@ const useBatchedEvents = <TaskArgs>(propsProvider: () => UseBatchedEventsProps<T
const getSignalForKey = (key: string | number): Signal<BatchedEvents> => { const getSignalForKey = (key: string | number): Signal<BatchedEvents> => {
const eventsSignal = const eventsSignal =
keyEventSignalsMap.get(key) ?? keyEventSignalsMap.get(key) ??
createSignal<BatchedEvents>({ createRoot((dispose) => {
events: [], return createSignal<BatchedEvents>({
completed: false, events: [],
completed: false,
});
}); });
keyEventSignalsMap.set(key, eventsSignal); keyEventSignalsMap.set(key, eventsSignal);
return eventsSignal; return eventsSignal;

View File

@@ -6,12 +6,14 @@ import {
type Config = { type Config = {
relayUrls: string[]; relayUrls: string[];
dateFormat: 'relative' | 'absolute'; dateFormat: 'relative' | 'absolute-long' | 'absolute-short';
}; };
type UseConfig = { type UseConfig = {
config: Accessor<Config>; config: Accessor<Config>;
setConfig: Setter<Config>; setConfig: Setter<Config>;
addRelay: (url: string) => void;
removeRelay: (url: string) => void;
}; };
const InitialConfig: Config = { const InitialConfig: Config = {
@@ -37,7 +39,21 @@ const storage = createStorageWithSerializer(() => window.localStorage, serialize
const [config, setConfig] = createSignalWithStorage('RabbitConfig', InitialConfig, storage); const [config, setConfig] = createSignalWithStorage('RabbitConfig', InitialConfig, storage);
const useConfig = (): UseConfig => { const useConfig = (): UseConfig => {
return { config, setConfig }; const addRelay = (relayUrl: string) => {
setConfig((current) => ({
...current,
relayUrls: [...current.relayUrls, relayUrl],
}));
};
const removeRelay = (relayUrl: string) => {
setConfig((current) => ({
...current,
relayUrls: current.relayUrls.filter((e) => e !== relayUrl),
}));
};
return { config, setConfig, addRelay, removeRelay };
}; };
export default useConfig; export default useConfig;

View File

@@ -8,6 +8,7 @@ export type UseSubscriptionProps = {
options?: SubscriptionOptions; options?: SubscriptionOptions;
// subscribe not only stored events but also new events published after the subscription // subscribe not only stored events but also new events published after the subscription
// default is true // default is true
clientEventFilter?: (event: NostrEvent) => boolean;
continuous?: boolean; continuous?: boolean;
onEvent?: (event: NostrEvent) => void; onEvent?: (event: NostrEvent) => void;
onEOSE?: () => void; onEOSE?: () => void;
@@ -36,6 +37,9 @@ const useSubscription = (propsProvider: () => UseSubscriptionProps | null) => {
if (onEvent != null) { if (onEvent != null) {
onEvent(event); onEvent(event);
} }
if (props.clientEventFilter != null && !props.clientEventFilter(event)) {
return;
}
if (!eose) { if (!eose) {
pushed = true; pushed = true;
storedEvents.push(event); storedEvents.push(event);

7
src/nostr/useThread.ts Normal file
View File

@@ -0,0 +1,7 @@
const filter = {
kinds: [1],
'#e': [
rootEventId,
currentEventId,
],
};

View File

@@ -62,7 +62,7 @@ const Hello: Component = () => {
</Match> </Match>
<Match when={signerStatus() === 'unavailable'}> <Match when={signerStatus() === 'unavailable'}>
<div class="pb-1 text-lg font-bold"></div> <div class="pb-1 text-lg font-bold"></div>
<p> <p class="pt-2">
NIP-07 NIP-07
<br /> <br />
<a <a
@@ -73,7 +73,19 @@ const Hello: Component = () => {
> >
</a> </a>
</p>
<p class="pt-2">
Nostrを利用する方は
<a
class="text-blue-500 underline"
target="_blank"
rel="noopener noreferrer"
href="https://scrapbox.io/nostr/%E3%81%AF%E3%81%98%E3%82%81%E3%81%A6%E3%81%AENostr%E3%80%90%E3%81%AF%E3%81%98%E3%82%81%E3%81%A6%E3%81%AE%E6%96%B9%E3%81%AF%E3%81%93%E3%81%A1%E3%82%89%E3%80%91"
>
Nostr
</a>
</p> </p>
</Match> </Match>
<Match when={signerStatus() === 'available'}> <Match when={signerStatus() === 'available'}>

19
src/utils/file.ts Normal file
View File

@@ -0,0 +1,19 @@
const readAs =
<T>(fn: (reader: FileReader, file: File) => void) =>
(file: File): Promise<T | undefined> =>
new Promise<T | undefined>((resolve) => {
const reader = new FileReader();
reader.addEventListener('load', () => {
// user must specify correct type
resolve(reader.result as T);
});
fn(reader, file);
});
export const dataUrl = readAs<string>((reader, file) => {
reader.readAsDataURL(file);
});
export const arrayBuffer = readAs<ArrayBuffer>((reader, file) => {
reader.readAsArrayBuffer(file);
});

View File

@@ -1,4 +1,4 @@
type ParsedDate = export type RelativeDate =
| { kind: 'now' } | { kind: 'now' }
| { kind: 'seconds'; value: number } | { kind: 'seconds'; value: number }
| { kind: 'minutes'; value: number } | { kind: 'minutes'; value: number }
@@ -6,9 +6,15 @@ type ParsedDate =
| { kind: 'days'; value: number } | { kind: 'days'; value: number }
| { kind: 'abs'; value: Date }; | { kind: 'abs'; value: Date };
export type DateFormatter = (parsedDate: ParsedDate) => string; export type AbsoluteDate =
| { kind: 'today'; value: Date }
| { kind: 'yesterday'; value: Date }
| { kind: 'abs'; value: Date };
const defaultDateFormatter = (parsedDate: ParsedDate): string => { export type RelativeDateFormatter = (parsedDate: RelativeDate) => string;
export type AbsoluteDateFormatter = (parsedDate: AbsoluteDate) => string;
const defaultRelativeDateFormatter = (parsedDate: RelativeDate): string => {
switch (parsedDate.kind) { switch (parsedDate.kind) {
case 'now': case 'now':
return 'now'; return 'now';
@@ -21,16 +27,41 @@ const defaultDateFormatter = (parsedDate: ParsedDate): string => {
case 'days': case 'days':
return `${parsedDate.value}d`; return `${parsedDate.value}d`;
case 'abs': case 'abs':
return parsedDate.value.toLocaleDateString();
default: default:
return ''; return parsedDate.value.toLocaleDateString();
}
};
const formatTime = (date: Date): string =>
`${date.getHours()}:${date.getMinutes().toString().padStart(2, '0')}`;
const defaultAbsoluteDateLongFormatter = (parsedDate: AbsoluteDate): string => {
switch (parsedDate.kind) {
case 'today':
return parsedDate.value.toLocaleTimeString();
case 'yesterday':
case 'abs':
default:
return parsedDate.value.toLocaleDateString();
}
};
const defaultAbsoluteDateShortFormatter = (parsedDate: AbsoluteDate): string => {
switch (parsedDate.kind) {
case 'today':
return formatTime(parsedDate.value);
case 'yesterday':
return `昨日 ${formatTime(parsedDate.value)}`;
case 'abs':
default:
return parsedDate.value.toLocaleString();
} }
}; };
const calcDiffSec = (date: Date, currentDate: Date): number => const calcDiffSec = (date: Date, currentDate: Date): number =>
(Number(currentDate) - Number(date)) / 1000; (Number(currentDate) - Number(date)) / 1000;
const parseDateDiff = (date: Date, currentDate: Date): ParsedDate => { const parseDateDiff = (date: Date, currentDate: Date): RelativeDate => {
const diffSec = calcDiffSec(date, currentDate); const diffSec = calcDiffSec(date, currentDate);
if (diffSec < 10) { if (diffSec < 10) {
@@ -53,19 +84,35 @@ const parseDateDiff = (date: Date, currentDate: Date): ParsedDate => {
return { kind: 'abs', value: date }; return { kind: 'abs', value: date };
}; };
export const formatAbsolute = (date: Date, currentDate: Date = new Date()): string => { const isSameDate = (lhs: Date, rhs: Date): boolean =>
if ( lhs.getFullYear() === rhs.getFullYear() &&
date.getFullYear() === currentDate.getFullYear() && lhs.getMonth() === rhs.getMonth() &&
date.getMonth() === currentDate.getMonth() && lhs.getDate() === rhs.getDate();
date.getDate() === currentDate.getDate()
) { const yesterdayOf = (date: Date): Date => new Date(+date - 24 * 60 * 60 * 1000);
return date.toLocaleTimeString();
const formatAbsolute = (
date: Date,
currentDate: Date,
formatter: AbsoluteDateFormatter,
): string => {
if (isSameDate(date, currentDate)) {
return formatter({ kind: 'today', value: date });
} }
return date.toLocaleString(); if (isSameDate(date, yesterdayOf(currentDate))) {
return formatter({ kind: 'yesterday', value: date });
}
return formatter({ kind: 'abs', value: date });
}; };
export const formatAbsoluteLong = (date: Date, currentDate: Date = new Date()) =>
formatAbsolute(date, currentDate, defaultAbsoluteDateLongFormatter);
export const formatAbsoluteShort = (date: Date, currentDate: Date = new Date()) =>
formatAbsolute(date, currentDate, defaultAbsoluteDateShortFormatter);
export const formatRelative = ( export const formatRelative = (
date: Date, date: Date,
currentDate: Date = new Date(), currentDate: Date = new Date(),
formatter: DateFormatter = defaultDateFormatter, formatter: RelativeDateFormatter = defaultRelativeDateFormatter,
): string => formatter(parseDateDiff(date, currentDate)); ): string => formatter(parseDateDiff(date, currentDate));

32
src/utils/imageUpload.ts Normal file
View File

@@ -0,0 +1,32 @@
const toHexString = (buff: ArrayBuffer): string => {
const arr = new Array(buff.byteLength);
const view = new Int8Array(buff);
for (let i = 0; i < view.byteLength; i += 1) {
arr[i] = view[i].toString(16).padStart(2, '0');
}
return arr.join();
};
const upload = async (blob: Blob): Promise<object> => {
const data = await blob.arrayBuffer();
const digestBuffer = await window.crypto.subtle.digest('SHA-256', data);
const digest = toHexString(digestBuffer);
const res = await fetch('https://void.cat/upload', {
method: 'POST',
headers: {
'V-Content-Type': blob.type,
'V-Full-Digest': digest,
},
mode: 'cors',
body: data,
});
if (!res.ok) throw new Error('failed to post image: status code was not 2xx');
return res.json();
};
export default upload;

33
src/utils/imageUrl.ts Normal file
View File

@@ -0,0 +1,33 @@
const domains = ['i.imgur.com', 'imgur.com', 'i.gyazo.com'];
export const isImageUrl = (url: URL): boolean => {
if (url.pathname.match(/\.(jpeg|jpg|png|gif|webp)$/i)) return true;
if (domains.includes(url.host)) return true;
return false;
};
export const fixUrl = (url: URL): URL => {
// Imgur
if (url.host === 'i.imgur.com' || url.host === 'imgur.com') {
const match = url.pathname.match(/^\/([a-zA-Z0-9]+)\.(jpg|jpeg|png|gif)/);
if (match != null) {
const result = new URL(url);
const imageId = match[1];
result.host = 'i.imgur.com';
result.pathname = `${imageId}l.webp`;
return result;
}
return url;
}
// Gyazo
if (url.host === 'i.gyazo.com') {
const result = new URL(url);
result.host = 'thumb.gyazo.com';
result.pathname = `/thumb/640_w${url.pathname}`;
return result;
}
return url;
};