This commit is contained in:
Shusui MOYATANI
2023-03-04 12:59:45 +09:00
parent 7cc2a304dc
commit 4be810523d
13 changed files with 221 additions and 99 deletions

26
package-lock.json generated
View File

@@ -1,11 +1,11 @@
{ {
"name": "nostiger", "name": "rabbit",
"version": "0.0.0", "version": "0.0.0",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "nostiger", "name": "rabbit",
"version": "0.0.0", "version": "0.0.0",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
@@ -16,7 +16,9 @@
"@tanstack/react-query-persist-client": "^4.24.10", "@tanstack/react-query-persist-client": "^4.24.10",
"@tanstack/solid-query": "^4.24.10", "@tanstack/solid-query": "^4.24.10",
"@thisbeyond/solid-dnd": "^0.7.3", "@thisbeyond/solid-dnd": "^0.7.3",
"@types/lodash": "^4.14.191",
"heroicons": "^2.0.15", "heroicons": "^2.0.15",
"lodash": "^4.17.21",
"nostr-tools": "^1.3.2", "nostr-tools": "^1.3.2",
"solid-js": "^1.6.9", "solid-js": "^1.6.9",
"tailwindcss": "^3.2.4", "tailwindcss": "^3.2.4",
@@ -1492,6 +1494,11 @@
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
"dev": true "dev": true
}, },
"node_modules/@types/lodash": {
"version": "4.14.191",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.191.tgz",
"integrity": "sha512-BdZ5BCCvho3EIXw6wUCXHe7rS53AIDPLE+JzwgT+OsJk53oBfbSmZZ7CX4VaRoN78N+TJpFi9QPlfIVNmJYWxQ=="
},
"node_modules/@types/mocha": { "node_modules/@types/mocha": {
"version": "10.0.1", "version": "10.0.1",
"resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.1.tgz", "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.1.tgz",
@@ -5281,6 +5288,11 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"node_modules/lodash.flattendeep": { "node_modules/lodash.flattendeep": {
"version": "4.4.0", "version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz",
@@ -9532,6 +9544,11 @@
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
"dev": true "dev": true
}, },
"@types/lodash": {
"version": "4.14.191",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.191.tgz",
"integrity": "sha512-BdZ5BCCvho3EIXw6wUCXHe7rS53AIDPLE+JzwgT+OsJk53oBfbSmZZ7CX4VaRoN78N+TJpFi9QPlfIVNmJYWxQ=="
},
"@types/mocha": { "@types/mocha": {
"version": "10.0.1", "version": "10.0.1",
"resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.1.tgz", "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.1.tgz",
@@ -12263,6 +12280,11 @@
"p-locate": "^5.0.0" "p-locate": "^5.0.0"
} }
}, },
"lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"lodash.flattendeep": { "lodash.flattendeep": {
"version": "4.4.0", "version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz",

View File

@@ -54,7 +54,9 @@
"@tanstack/react-query-persist-client": "^4.24.10", "@tanstack/react-query-persist-client": "^4.24.10",
"@tanstack/solid-query": "^4.24.10", "@tanstack/solid-query": "^4.24.10",
"@thisbeyond/solid-dnd": "^0.7.3", "@thisbeyond/solid-dnd": "^0.7.3",
"@types/lodash": "^4.14.191",
"heroicons": "^2.0.15", "heroicons": "^2.0.15",
"lodash": "^4.17.21",
"nostr-tools": "^1.3.2", "nostr-tools": "^1.3.2",
"solid-js": "^1.6.9", "solid-js": "^1.6.9",
"tailwindcss": "^3.2.4", "tailwindcss": "^3.2.4",

View File

@@ -46,16 +46,33 @@ const useCommands = () => {
relayUrls, relayUrls,
pubkey, pubkey,
content, content,
notifyPubkeys,
rootEventId,
mentionEventIds,
replyEventId,
}: { }: {
relayUrls: string[]; relayUrls: string[];
pubkey: string; pubkey: string;
content: string; content: string;
notifyPubkeys?: string[];
rootEventId?: string;
mentionEventIds?: string[];
replyEventId?: string;
}): Promise<Promise<void>[]> { }): Promise<Promise<void>[]> {
const pTags = notifyPubkeys?.map((p) => ['p', p]) ?? [];
const eTags = [];
if (rootEventId != null) eTags.push(['e', rootEventId, '', 'root']);
if (mentionEventIds != null)
mentionEventIds.forEach((id) => eTags.push(['e', id, '', 'mention']));
if (replyEventId != null) eTags.push(['e', replyEventId, '', 'reply']);
const tags = [...pTags, ...eTags];
const preSignedEvent: NostrEvent = { const preSignedEvent: NostrEvent = {
kind: 1, kind: 1,
pubkey, pubkey,
created_at: currentDate(), created_at: currentDate(),
tags: [], tags,
content, content,
}; };
return publishEvent(relayUrls, preSignedEvent); return publishEvent(relayUrls, preSignedEvent);

View File

@@ -31,6 +31,7 @@ const { exec } = useBatchedEvents<UseDeprecatedRepostsProps>(() => ({
const useDeprecatedReposts = ( const useDeprecatedReposts = (
propsProvider: () => UseDeprecatedRepostsProps, propsProvider: () => UseDeprecatedRepostsProps,
): UseDeprecatedReposts => { ): UseDeprecatedReposts => {
const queryClient = useQueryClient();
const props = createMemo(propsProvider); const props = createMemo(propsProvider);
const queryKey = createMemo(() => ['useDeprecatedReposts', props()] as const); const queryKey = createMemo(() => ['useDeprecatedReposts', props()] as const);
@@ -55,10 +56,8 @@ const useDeprecatedReposts = (
const isRepostedBy = (pubkey: string): boolean => const isRepostedBy = (pubkey: string): boolean =>
reposts().findIndex((event) => event.pubkey === pubkey) !== -1; reposts().findIndex((event) => event.pubkey === pubkey) !== -1;
const invalidateDeprecatedReposts = (): Promise<void> => { const invalidateDeprecatedReposts = (): Promise<void> =>
const queryClient = useQueryClient(); queryClient.invalidateQueries(queryKey());
return queryClient.invalidateQueries(queryKey());
};
return { reposts, isRepostedBy, invalidateDeprecatedReposts, query }; return { reposts, isRepostedBy, invalidateDeprecatedReposts, query };
}; };

View File

@@ -30,6 +30,7 @@ const { exec } = useBatchedEvents<UseReactionsProps>(() => ({
})); }));
const useReactions = (propsProvider: () => UseReactionsProps): UseReactions => { const useReactions = (propsProvider: () => UseReactionsProps): UseReactions => {
const queryClient = useQueryClient();
const props = createMemo(propsProvider); const props = createMemo(propsProvider);
const queryKey = createMemo(() => ['useReactions', props()] as const); const queryKey = createMemo(() => ['useReactions', props()] as const);
@@ -61,10 +62,7 @@ const useReactions = (propsProvider: () => UseReactionsProps): UseReactions => {
const isReactedBy = (pubkey: string): boolean => const isReactedBy = (pubkey: string): boolean =>
reactions().findIndex((event) => event.pubkey === pubkey) !== -1; reactions().findIndex((event) => event.pubkey === pubkey) !== -1;
const invalidateReactions = (): Promise<void> => { const invalidateReactions = (): Promise<void> => queryClient.invalidateQueries(queryKey());
const queryClient = useQueryClient();
return queryClient.invalidateQueries(queryKey());
};
return { reactions, reactionsGroupedByContent, isReactedBy, invalidateReactions, query }; return { reactions, reactionsGroupedByContent, isReactedBy, invalidateReactions, query };
}; };

View File

@@ -5,9 +5,7 @@ type ColumnItemProps = {
}; };
const ColumnItem: Component<ColumnItemProps> = (props) => { const ColumnItem: Component<ColumnItemProps> = (props) => {
return ( return <div class="overflow-hidden border-b p-1">{props.children}</div>;
<div class="flex w-full flex-row gap-1 overflow-hidden border-b p-1">{props.children}</div>
);
}; };
export default ColumnItem; export default ColumnItem;

View File

@@ -7,7 +7,7 @@ import useConfig from '@/clients/useConfig';
import useEvent from '@/clients/useEvent'; import useEvent from '@/clients/useEvent';
import useProfile from '@/clients/useProfile'; import useProfile from '@/clients/useProfile';
import UserNameDisplay from '@/components/UserNameDisplay'; import UserDisplayName from '@/components/UserDisplayName';
import TextNote from '@/components/TextNote'; import TextNote from '@/components/TextNote';
export type DeprecatedRepostProps = { export type DeprecatedRepostProps = {
@@ -32,7 +32,7 @@ const DeprecatedRepost: Component<DeprecatedRepostProps> = (props) => {
<ArrowPathRoundedSquare /> <ArrowPathRoundedSquare />
</div> </div>
<div class="truncate break-all"> <div class="truncate break-all">
<UserNameDisplay pubkey={props.event.pubkey} /> <UserDisplayName pubkey={props.event.pubkey} />
{' Reposted'} {' Reposted'}
</div> </div>
</div> </div>

View File

@@ -25,7 +25,7 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
return ( return (
<div class="p-1"> <div class="p-1">
<form class="grid w-64 gap-1" onSubmit={handleSubmit}> <form class="flex flex-col gap-1" onSubmit={handleSubmit}>
<textarea <textarea
name="text" name="text"
class="rounded border-none" class="rounded border-none"

View File

@@ -1,6 +1,62 @@
import { type Component } from 'solid-js'; import { createSignal, createMemo, type Component, type JSX } from 'solid-js';
import type { Event as NostrEvent } from 'nostr-tools/event';
const ReplyPostForm = () => { 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);
};
const handleSubmit: JSX.EventHandler<HTMLFormElement, Event> = (ev) => {
ev.preventDefault();
// TODO 投稿完了したかどうかの検知をしたい
props.onPost({ content: text() });
clearText();
};
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}
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; export default ReplyPostForm;

View File

@@ -12,7 +12,7 @@ const SideBar: Component<SideBarProps> = (props) => {
return ( return (
<div class="flex shrink-0 flex-row border-r bg-sidebar-bg"> <div class="flex shrink-0 flex-row border-r bg-sidebar-bg">
<div class="flex w-14 flex-auto flex-col items-center gap-3 border-r py-5"> <div class="flex w-14 flex-auto flex-col items-center gap-3 border-r border-rose-200 py-5">
<button <button
class={`h-9 w-9 rounded-full border border-primary bg-primary p-2 text-2xl font-bold text-white`} class={`h-9 w-9 rounded-full border border-primary bg-primary p-2 text-2xl font-bold text-white`}
onClick={() => setFormOpened((current) => !current)} onClick={() => setFormOpened((current) => !current)}

View File

@@ -1,5 +1,6 @@
import { Show, For, createMemo, type JSX, type Component } from 'solid-js'; import { Show, For, createSignal, createMemo, type JSX, type Component } from 'solid-js';
import type { Event as NostrEvent } from 'nostr-tools/event'; import type { Event as NostrEvent } from 'nostr-tools/event';
import uniq from 'lodash/uniq';
import HeartOutlined from 'heroicons/24/outline/heart.svg'; import HeartOutlined from 'heroicons/24/outline/heart.svg';
import HeartSolid from 'heroicons/24/solid/heart.svg'; import HeartSolid from 'heroicons/24/solid/heart.svg';
@@ -18,6 +19,7 @@ import { formatRelative } from '@/utils/formatDate';
import ColumnItem from '@/components/ColumnItem'; import ColumnItem from '@/components/ColumnItem';
import GeneralUserMentionDisplay from '@/components/textNote/GeneralUserMentionDisplay'; import GeneralUserMentionDisplay from '@/components/textNote/GeneralUserMentionDisplay';
import TextNoteContentDisplay from '@/components/textNote/TextNoteContentDisplay'; import TextNoteContentDisplay from '@/components/textNote/TextNoteContentDisplay';
import ReplyPostForm from '@/components/ReplyPostForm';
export type TextNoteProps = { export type TextNoteProps = {
event: NostrEvent; event: NostrEvent;
@@ -28,6 +30,7 @@ const TextNote: Component<TextNoteProps> = (props) => {
const [config] = useConfig(); const [config] = useConfig();
const commands = useCommands(); const commands = useCommands();
const pubkey = usePubkey(); const pubkey = usePubkey();
const [showReplyForm, setShowReplyForm] = createSignal(false);
const { profile: author } = useProfile(() => ({ const { profile: author } = useProfile(() => ({
relayUrls: config().relayUrls, relayUrls: config().relayUrls,
@@ -48,11 +51,24 @@ const TextNote: Component<TextNoteProps> = (props) => {
const isRepostedByMe = createMemo(() => isRepostedBy(pubkey())); const isRepostedByMe = createMemo(() => isRepostedBy(pubkey()));
const replyingToPubKeys = createMemo(() => const replyingToPubKeys = createMemo(() =>
props.event.tags.filter((tag) => tag[0] === 'p').map((e) => e[1]), uniq(props.event.tags.filter((tag) => tag[0] === 'p').map((e) => e[1])),
); );
// TODO 日付をいい感じにフォーマットする関数を作る
const createdAt = () => formatRelative(new Date(props.event.created_at * 1000), currentDate()); const createdAt = () => formatRelative(new Date(props.event.created_at * 1000), currentDate());
const handleReplyPost = ({ content }: { content: string }) => {
commands
.publishTextNote({
relayUrls: config().relayUrls,
pubkey: pubkey(),
content,
notifyPubkeys: [props.event.pubkey, ...replyingToPubKeys()],
replyEventId: props.event.id,
})
.then(() => {
setShowReplyForm(false);
});
};
const handleRepost: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (ev) => { const handleRepost: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (ev) => {
ev.preventDefault(); ev.preventDefault();
@@ -87,85 +103,99 @@ const TextNote: Component<TextNoteProps> = (props) => {
return ( return (
<div class="textnote"> <div class="textnote">
<ColumnItem> <ColumnItem>
<div class="author-icon h-10 w-10 shrink-0"> <div class="flex flex-col">
<Show when={author()?.picture}> <div class="flex w-full gap-1">
{/* TODO 画像は脆弱性回避のためにimgじゃない方法で読み込みたい */} <div class="author-icon h-10 w-10 shrink-0">
<img <Show when={author()?.picture}>
src={author()?.picture} {/* TODO 画像は脆弱性回避のためにimgじゃない方法で読み込みたい */}
alt="icon" <img
// TODO autofit src={author()?.picture}
class="h-10 w-10 rounded" alt="icon"
/> // TODO autofit
</Show> class="h-10 w-10 rounded"
</div> />
<div class="min-w-0 flex-auto">
<div class="flex justify-between gap-1 text-xs">
<div class="author flex min-w-0 truncate">
{/* TODO link to author */}
<Show when={(author()?.display_name?.length ?? 0) > 0}>
<div class="author-name truncate pr-1 font-bold">{author()?.display_name}</div>
</Show> </Show>
<div class="author-username truncate text-zinc-600"> </div>
<Show when={author()?.name} fallback={props.event.pubkey}> <div class="min-w-0 flex-auto">
@{author()?.name} <div class="flex justify-between gap-1 text-xs">
</Show> <div class="author flex min-w-0 truncate">
{/* TODO link to author */}
<Show when={(author()?.display_name?.length ?? 0) > 0}>
<div class="author-name truncate pr-1 font-bold">{author()?.display_name}</div>
</Show>
<div class="author-username truncate text-zinc-600">
<Show when={author()?.name} fallback={props.event.pubkey}>
@{author()?.name}
</Show>
</div>
</div>
<div class="created-at shrink-0">{createdAt()}</div>
</div>
<Show when={replyingToPubKeys().length > 0}>
<div class="text-xs">
{'Replying to '}
<For each={replyingToPubKeys()}>
{(replyToPubkey: string) => (
<span class="pr-1 text-blue-500 underline">
<GeneralUserMentionDisplay pubkey={replyToPubkey} />
</span>
)}
</For>
</div>
</Show>
<div class="content whitespace-pre-wrap break-all">
<TextNoteContentDisplay event={props.event} embedding={true} />
</div>
<div class="flex w-48 items-center justify-between gap-8 pt-1">
<button
class="h-4 w-4 text-zinc-400"
onClick={() => setShowReplyForm((current) => !current)}
>
<ChatBubbleLeft />
</button>
<div
class="flex items-center gap-1"
classList={{
'text-zinc-400': !isRepostedByMe(),
'text-green-400': isRepostedByMe(),
}}
>
<button class="h-4 w-4" onClick={handleReaction}>
<ArrowPathRoundedSquare />
</button>
<Show when={reposts().length > 0}>
<div class="text-sm text-zinc-400">{reposts().length}</div>
</Show>
</div>
<div
class="flex items-center gap-1"
classList={{
'text-zinc-400': !isReactedByMe(),
'text-rose-400': isReactedByMe(),
}}
>
<button class="h-4 w-4" onClick={handleReaction}>
<Show when={isReactedByMe()} fallback={<HeartOutlined />}>
<HeartSolid />
</Show>
</button>
<Show when={reactions().length > 0}>
<div class="text-sm text-zinc-400">{reactions().length}</div>
</Show>
</div>
<button class="h-4 w-4 text-zinc-400">
<EllipsisHorizontal />
</button>
</div> </div>
</div> </div>
<div class="created-at shrink-0">{createdAt()}</div>
</div> </div>
<Show when={replyingToPubKeys().length > 0}> <Show when={showReplyForm()}>
<div class="text-xs"> <ReplyPostForm
{'Replying to '} replyTo={props.event}
<For each={replyingToPubKeys()}> onPost={handleReplyPost}
{(replyToPubkey: string) => ( onClose={() => setShowReplyForm(false)}
<span class="pr-1 text-blue-500 underline"> />
<GeneralUserMentionDisplay pubkey={replyToPubkey} />
</span>
)}
</For>
</div>
</Show> </Show>
<div class="content whitespace-pre-wrap break-all">
<TextNoteContentDisplay event={props.event} embedding={true} />
</div>
<div class="flex w-48 items-center justify-between gap-8 pt-1">
<button class="h-4 w-4 text-zinc-400">
<ChatBubbleLeft />
</button>
<div
class="flex items-center gap-1"
classList={{
'text-zinc-400': !isRepostedByMe(),
'text-green-400': isRepostedByMe(),
}}
>
<button class="h-4 w-4" onClick={handleReaction}>
<ArrowPathRoundedSquare />
</button>
<Show when={reposts().length > 0}>
<div class="text-sm text-zinc-400">{reposts().length}</div>
</Show>
</div>
<div
class="flex items-center gap-1"
classList={{
'text-zinc-400': !isReactedByMe(),
'text-rose-400': isReactedByMe(),
}}
>
<button class="h-4 w-4" onClick={handleReaction}>
<Show when={isReactedByMe()} fallback={<HeartOutlined />}>
<HeartSolid />
</Show>
</button>
<Show when={reactions().length > 0}>
<div class="text-sm text-zinc-400">{reactions().length}</div>
</Show>
</div>
<button class="h-4 w-4 text-zinc-400">
<EllipsisHorizontal />
</button>
</div>
</div> </div>
</ColumnItem> </ColumnItem>
</div> </div>

View File

@@ -2,7 +2,7 @@ import { Switch, Match, type Component, Show } from 'solid-js';
import { type Event as NostrEvent } from 'nostr-tools/event'; import { type Event as NostrEvent } from 'nostr-tools/event';
import HeartSolid from 'heroicons/24/solid/heart.svg'; import HeartSolid from 'heroicons/24/solid/heart.svg';
import UserNameDisplay from '@/components/UserNameDisplay'; import UserDisplayName from '@/components/UserDisplayName';
import TextNote from '@/components/TextNote'; import TextNote from '@/components/TextNote';
import useConfig from '@/clients/useConfig'; import useConfig from '@/clients/useConfig';
@@ -54,7 +54,7 @@ const Reaction: Component<ReactionProps> = (props) => {
</div> </div>
<div> <div>
<span class="truncate whitespace-pre-wrap break-all font-bold"> <span class="truncate whitespace-pre-wrap break-all font-bold">
<UserNameDisplay pubkey={props.event.pubkey} /> <UserDisplayName pubkey={props.event.pubkey} />
</span> </span>
{' reacted'} {' reacted'}
</div> </div>