mirror of
https://github.com/aljazceru/rabbit.git
synced 2025-12-17 14:04:21 +01:00
update
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
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();
|
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()}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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)} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}]`);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
11
src/hooks/useFileInput.ts
Normal 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 };
|
||||||
|
};
|
||||||
@@ -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());
|
return formatRelative(date, currentDate());
|
||||||
|
default:
|
||||||
|
return formatRelative(date, currentDate());
|
||||||
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,10 +50,12 @@ 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) => {
|
||||||
|
return createSignal<BatchedEvents>({
|
||||||
events: [],
|
events: [],
|
||||||
completed: false,
|
completed: false,
|
||||||
});
|
});
|
||||||
|
});
|
||||||
keyEventSignalsMap.set(key, eventsSignal);
|
keyEventSignalsMap.set(key, eventsSignal);
|
||||||
return eventsSignal;
|
return eventsSignal;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
7
src/nostr/useThread.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
const filter = {
|
||||||
|
kinds: [1],
|
||||||
|
'#e': [
|
||||||
|
rootEventId,
|
||||||
|
currentEventId,
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -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
19
src/utils/file.ts
Normal 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);
|
||||||
|
});
|
||||||
@@ -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
32
src/utils/imageUpload.ts
Normal 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
33
src/utils/imageUrl.ts
Normal 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;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user