feat: show thread

This commit is contained in:
Shusui MOYATANI
2023-03-26 11:29:38 +09:00
parent 904c5a547c
commit 3abd0dd94e
12 changed files with 237 additions and 61 deletions

View File

@@ -1,5 +1,7 @@
import type { Component, JSX } from 'solid-js';
import { Show, type JSX, type Component } from 'solid-js';
import { useHandleCommand } from '@/hooks/useCommandBus';
import { ColumnContext, useColumnState } from '@/components/ColumnContext';
import ColumnContentDisplay from '@/components/ColumnContentDisplay';
export type ColumnProps = {
name: string;
@@ -12,6 +14,8 @@ export type ColumnProps = {
const Column: Component<ColumnProps> = (props) => {
let columnDivRef: HTMLDivElement | undefined;
const columnState = useColumnState();
const width = () => props.width ?? 'medium';
useHandleCommand(() => ({
@@ -33,22 +37,38 @@ const Column: Component<ColumnProps> = (props) => {
}));
return (
<div
ref={columnDivRef}
class="flex w-[80vw] shrink-0 snap-center snap-always flex-col border-r sm:snap-align-none"
classList={{
'sm:w-[500px]': width() === 'widest',
'sm:w-[350px]': width() === 'wide',
'sm:w-[310px]': width() === 'medium',
'sm:w-[270px]': width() === 'narrow',
}}
>
<div class="flex h-8 shrink-0 items-center border-b bg-white px-2">
{/* <span class="column-icon">🏠</span> */}
<span class="column-name">{props.name}</span>
<ColumnContext.Provider value={columnState}>
<div
ref={columnDivRef}
class="relative flex w-[80vw] shrink-0 snap-center snap-always flex-col border-r sm:snap-align-none"
classList={{
'sm:w-[500px]': width() === 'widest',
'sm:w-[350px]': width() === 'wide',
'sm:w-[310px]': width() === 'medium',
'sm:w-[270px]': width() === 'narrow',
}}
>
<div class="flex h-8 shrink-0 items-center border-b bg-white px-2">
{/* <span class="column-icon">🏠</span> */}
<span class="column-name">{props.name}</span>
</div>
<ul class="flex flex-col overflow-y-scroll scroll-smooth">{props.children}</ul>
<Show when={columnState.columnState.content} keyed>
{(columnContent) => (
<div class="absolute h-full w-full bg-white">
<div class="flex h-8 shrink-0 items-center border-b bg-white px-2">
<button class="w-full text-left" onClick={() => columnState?.clearColumnContext()}>
</button>
</div>
<ul class="flex h-full flex-col overflow-y-scroll scroll-smooth">
<ColumnContentDisplay columnContent={columnContent} />
</ul>
</div>
)}
</Show>
</div>
<ul class="flex flex-col overflow-y-scroll scroll-smooth">{props.children}</ul>
</div>
</ColumnContext.Provider>
);
};

View File

@@ -0,0 +1,33 @@
import { Switch, Match, type Component } from 'solid-js';
import useConfig from '@/nostr/useConfig';
import { type ColumnContent } from '@/components/ColumnContext';
import Timeline from '@/components/Timeline';
import useSubscription from '@/nostr/useSubscription';
const RepliesDisplay: Component<{ eventId: string }> = (props) => {
const { config } = useConfig();
const { events } = useSubscription(() => ({
relayUrls: config().relayUrls,
filters: [
{ kinds: [1], ids: [props.eventId], limit: 25 },
{ kinds: [1], '#e': [props.eventId], limit: 25 },
],
}));
return <Timeline events={[...events()].reverse()} />;
};
const ColumnContentDisplay: Component<{ columnContent: ColumnContent }> = (props) => {
return (
<Switch>
<Match when={props.columnContent.type === 'Replies' && props.columnContent} keyed>
{(replies) => <RepliesDisplay eventId={replies.eventId} />}
</Match>
</Switch>
);
};
export default ColumnContentDisplay;

View File

@@ -0,0 +1,31 @@
import { createContext, useContext, type JSX } from 'solid-js';
import { createStore } from 'solid-js/store';
export type ColumnContent = {
type: 'Replies';
eventId: string;
};
export type ColumnState = {
content?: ColumnContent;
};
export type UseColumnState = {
columnState: ColumnState;
setColumnContent: (content: ColumnContent) => void;
clearColumnContext: () => void;
};
export const ColumnContext = createContext<UseColumnState>();
export const useColumnContext = () => useContext(ColumnContext);
export const useColumnState = (): UseColumnState => {
const [columnState, setColumnState] = createStore<ColumnState>({});
return {
columnState,
setColumnContent: (content: ColumnContent) => setColumnState('content', content),
clearColumnContext: () => setColumnState('content', undefined),
};
};

View File

@@ -186,7 +186,7 @@ const ConfigUI = (props: ConfigProps) => {
<div class="relative">
<div class="flex flex-col gap-1">
<h2 class="flex-1 text-center font-bold"></h2>
<button class="absolute top-1 right-0 h-4 w-4" onClick={() => props.onClose?.()}>
<button class="absolute top-1 right-0 z-0 h-4 w-4" onClick={() => props.onClose?.()}>
<XMark />
</button>
</div>

View File

@@ -17,7 +17,7 @@ const Modal: Component<ModalProps> = (props) => {
return (
<div
ref={containerRef}
class="absolute top-0 left-0 flex h-screen w-screen cursor-default place-content-center place-items-center bg-black/30"
class="absolute top-0 left-0 z-10 flex h-screen w-screen cursor-default place-content-center place-items-center bg-black/30"
onClick={handleClickContainer}
>
{props.children}

View File

@@ -29,11 +29,11 @@ export type ProfileDisplayProps = {
};
const FollowersCount: Component<{ pubkey: string }> = (props) => {
const { followersPubkeys } = useFollowers(() => ({
const { count } = useFollowers(() => ({
pubkey: props.pubkey,
}));
return <span>{followersPubkeys().length}</span>;
return <>{count()}</>;
};
const ProfileDisplay: Component<ProfileDisplayProps> = (props) => {
@@ -66,9 +66,11 @@ const ProfileDisplay: Component<ProfileDisplayProps> = (props) => {
);
const following = () => myFollowingPubkeys().includes(props.pubkey);
const { followingPubkeys: userFollowingPubkeys } = useFollowings(() => ({
pubkey: props.pubkey,
}));
const { followingPubkeys: userFollowingPubkeys, query: userFollowingQuery } = useFollowings(
() => ({
pubkey: props.pubkey,
}),
);
const followed = () => {
const p = pubkey();
return p != null && userFollowingPubkeys().includes(p);
@@ -86,6 +88,7 @@ const ProfileDisplay: Component<ProfileDisplayProps> = (props) => {
until: epoch(),
},
],
continuous: false,
}));
return (
@@ -210,14 +213,28 @@ const ProfileDisplay: Component<ProfileDisplayProps> = (props) => {
<div class="flex border-t px-4 py-2">
<div class="flex flex-1 flex-col items-start">
<div class="text-sm"></div>
<div class="text-xl">{userFollowingPubkeys().length}</div>
<div class="text-xl">
<Show
when={userFollowingQuery.isFetched}
fallback={<span class="text-sm"></span>}
>
{userFollowingPubkeys().length}
</Show>
</div>
</div>
<div class="flex flex-1 flex-col items-start">
<div class="text-sm"></div>
<div class="text-xl">
<Show
when={showFollowers()}
fallback={<button onClick={() => setShowFollowers(true)}></button>}
fallback={
<button
class="text-sm hover:text-stone-800 hover:underline"
onClick={() => setShowFollowers(true)}
>
</button>
}
keyed
>
<FollowersCount pubkey={props.pubkey} />

View File

@@ -8,11 +8,6 @@ import ArrowPathRoundedSquare from 'heroicons/24/outline/arrow-path-rounded-squa
import ChatBubbleLeft from 'heroicons/24/outline/chat-bubble-left.svg';
import EllipsisHorizontal from 'heroicons/24/outline/ellipsis-horizontal.svg';
import GeneralUserMentionDisplay from '@/components/textNote/GeneralUserMentionDisplay';
import ContentWarningDisplay from '@/components/textNote/ContentWarningDisplay';
import TextNoteContentDisplay from '@/components/textNote/TextNoteContentDisplay';
import NotePostForm from '@/components/NotePostForm';
import eventWrapper from '@/core/event';
import useProfile from '@/nostr/useProfile';
@@ -23,12 +18,19 @@ import useReactions from '@/nostr/useReactions';
import useDeprecatedReposts from '@/nostr/useDeprecatedReposts';
import useFormatDate from '@/hooks/useFormatDate';
import useModalState from '@/hooks/useModalState';
import UserNameDisplay from '@/components/UserDisplayName';
import TextNoteDisplayById from '@/components/textNote/TextNoteDisplayById';
import { useColumnContext } from '@/components/ColumnContext';
import GeneralUserMentionDisplay from '@/components/textNote/GeneralUserMentionDisplay';
import ContentWarningDisplay from '@/components/textNote/ContentWarningDisplay';
import TextNoteContentDisplay from '@/components/textNote/TextNoteContentDisplay';
import NotePostForm from '@/components/NotePostForm';
import ensureNonNull from '@/utils/ensureNonNull';
import npubEncodeFallback from '@/utils/npubEncodeFallback';
import useModalState from '@/hooks/useModalState';
import UserNameDisplay from '../UserDisplayName';
import TextNoteDisplayById from './TextNoteDisplayById';
import useSubscription from '@/nostr/useSubscription';
export type TextNoteDisplayProps = {
event: NostrEvent;
@@ -43,6 +45,7 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
const formatDate = useFormatDate();
const pubkey = usePubkey();
const { showProfile } = useModalState();
const columnContext = useColumnContext();
const [showReplyForm, setShowReplyForm] = createSignal(false);
const closeReplyForm = () => setShowReplyForm(false);
@@ -60,11 +63,11 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
}));
const { reactions, isReactedBy, invalidateReactions } = useReactions(() => ({
eventId: props.event.id, // TODO いつかなおす
eventId: props.event.id,
}));
const { reposts, isRepostedBy, invalidateDeprecatedReposts } = useDeprecatedReposts(() => ({
eventId: props.event.id, // TODO いつかなおす
eventId: props.event.id,
}));
const commands = useCommands();
@@ -118,7 +121,9 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
const createdAt = () => formatDate(event().createdAtAsDate());
const handleRepost: JSX.EventHandler<HTMLButtonElement, MouseEvent> = () => {
const handleRepost: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (ev) => {
ev.stopPropagation();
if (isRepostedByMe()) {
// TODO remove reaction
return;
@@ -134,7 +139,9 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
});
};
const handleReaction: JSX.EventHandler<HTMLButtonElement, MouseEvent> = () => {
const handleReaction: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (ev) => {
ev.stopPropagation();
if (isReactedByMe()) {
// TODO remove reaction
return;
@@ -158,11 +165,22 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
});
return (
<div class="nostr-textnote flex flex-col">
<div
class="nostr-textnote flex flex-col"
onClick={() => {
columnContext?.setColumnContent({
type: 'Replies',
eventId: event().rootEvent()?.id ?? props.event.id,
});
}}
>
<div class="flex w-full gap-1">
<button
class="author-icon h-10 w-10 shrink-0 overflow-hidden"
onClick={() => showProfile(event().pubkey)}
onClick={(ev) => {
ev.stopPropagation();
showProfile(event().pubkey);
}}
>
<Show when={author()?.picture}>
{/* TODO 画像は脆弱性回避のためにimgじゃない方法で読み込みたい */}
@@ -173,7 +191,10 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
<div class="flex justify-between gap-1 text-xs">
<button
class="author flex min-w-0 truncate hover:text-blue-500"
onClick={() => showProfile(event().pubkey)}
onClick={() => {
ev.stopPropagation();
showProfile(event().pubkey);
}}
>
{/* TODO link to author */}
<Show when={(author()?.display_name?.length ?? 0) > 0}>
@@ -211,7 +232,10 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
{(replyToPubkey: string) => (
<button
class="pr-1 text-blue-500 hover:underline"
onClick={() => showProfile(replyToPubkey)}
onClick={(ev) => {
ev.stopPropagation();
showProfile(replyToPubkey);
}}
>
<GeneralUserMentionDisplay pubkey={replyToPubkey} />
</button>
@@ -229,7 +253,10 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
<Show when={overflow()}>
<button
class="text-xs text-stone-600 hover:text-stone-800"
onClick={() => setShowOverflow((current) => !current)}
onClick={(ev) => {
ev.stopPropagation();
setShowOverflow((current) => !current);
}}
>
<Show when={!showOverflow()} fallback="隠す">
@@ -240,7 +267,10 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
<div class="actions flex w-48 items-center justify-between gap-8 pt-1">
<button
class="h-4 w-4 shrink-0 text-zinc-400"
onClick={() => setShowReplyForm((current) => !current)}
onClick={() => {
stopPropagation();
setShowReplyForm((current) => !current);
}}
>
<ChatBubbleLeft />
</button>
@@ -285,7 +315,10 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
<div>
<button
class="h-4 w-4 text-zinc-400"
onClick={() => setShowMenu((current) => !current)}
onClick={(ev) => {
ev.stopPropagation();
setShowMenu((current) => !current);
}}
>
<EllipsisHorizontal />
</button>

View File

@@ -8,7 +8,7 @@ export type Task<TaskArgs, TaskResult> = {
};
export type UseBatchProps<TaskArgs, TaskResult> = {
executor: (task: Task<TaskArgs, TaskResult>[]) => void;
executor: (tasks: Task<TaskArgs, TaskResult>[]) => void;
interval?: number;
batchSize?: number;
};

View File

@@ -9,11 +9,14 @@ import {
import { type Event as NostrEvent, type Filter, Kind } from 'nostr-tools';
import { createQuery, useQueryClient, type CreateQueryResult } from '@tanstack/solid-query';
import timeout from '@/utils/timeout';
import useBatch, { type Task } from '@/nostr/useBatch';
import eventWrapper from '@/core/event';
import useConfig from './useConfig';
import usePool from './usePool';
import useBatch, { type Task } from '@/nostr/useBatch';
import useStats from '@/nostr/useStats';
import useConfig from '@/nostr/useConfig';
import usePool from '@/nostr/usePool';
import timeout from '@/utils/timeout';
type TaskArg =
| { type: 'Profile'; pubkey: string }
@@ -63,7 +66,7 @@ export type UseTextNoteProps = {
};
export type UseTextNote = {
event: Accessor<NostrEvent | null>;
event: () => NostrEvent | null;
query: CreateQueryResult<NostrEvent | null>;
};
@@ -73,8 +76,8 @@ export type UseReactionsProps = {
};
export type UseReactions = {
reactions: Accessor<NostrEvent[]>;
reactionsGroupedByContent: Accessor<Map<string, NostrEvent[]>>;
reactions: () => NostrEvent[];
reactionsGroupedByContent: () => Map<string, NostrEvent[]>;
isReactedBy: (pubkey: string) => boolean;
invalidateReactions: () => Promise<void>;
query: CreateQueryResult<NostrEvent[]>;
@@ -86,7 +89,7 @@ export type UseDeprecatedRepostsProps = {
};
export type UseDeprecatedReposts = {
reposts: Accessor<NostrEvent[]>;
reposts: () => NostrEvent[];
isRepostedBy: (pubkey: string) => boolean;
invalidateDeprecatedReposts: () => Promise<void>;
query: CreateQueryResult<NostrEvent[]>;
@@ -104,18 +107,22 @@ type Following = {
};
export type UseFollowings = {
followings: Accessor<Following[]>;
followingPubkeys: Accessor<string[]>;
followings: () => Following[];
followingPubkeys: () => string[];
query: CreateQueryResult<NostrEvent | null>;
};
let count = 0;
setInterval(() => console.log('batchSub', count), 1000);
const { setActiveBatchSubscriptions } = useStats();
setInterval(() => {
setActiveBatchSubscriptions(count);
}, 1000);
const { exec } = useBatch<TaskArg, TaskRes>(() => ({
interval: 2000,
batchSize: 100,
batchSize: 150,
executor: (tasks) => {
const profileTasks = new Map<string, Task<TaskArg, TaskRes>[]>();
const textNoteTasks = new Map<string, Task<TaskArg, TaskRes>[]>();
@@ -201,7 +208,7 @@ const { exec } = useBatch<TaskArg, TaskRes>(() => ({
const { config } = useConfig();
const pool = usePool();
const sub = pool().sub(config().relayUrls, filters);
const sub = pool().sub(config().relayUrls, filters, {});
count += 1;
@@ -300,7 +307,6 @@ export const useProfile = (propsProvider: () => UseProfileProps | null): UseProf
export const useTextNote = (propsProvider: () => UseTextNoteProps | null): UseTextNote => {
const props = createMemo(propsProvider);
const queryClient = useQueryClient();
const query = createQuery(
() => ['useTextNote', props()] as const,
@@ -317,8 +323,11 @@ export const useTextNote = (propsProvider: () => UseTextNoteProps | null): UseTe
},
{
// Text notes never change, so they can be stored for a long time.
// However, events tend to be unreferenced as time passes.
staleTime: 4 * 60 * 60 * 1000, // 4 hour
cacheTime: 4 * 60 * 60 * 1000, // 4 hour
refetchOnWindowFocus: false,
refetchOnMount: false,
},
);
@@ -449,6 +458,7 @@ export const useFollowings = (propsProvider: () => UseFollowingsProps | null): U
staleTime: 5 * 60 * 1000, // 5 min
cacheTime: 24 * 60 * 60 * 1000, // 24 hour
refetchOnWindowFocus: false,
refetchInterval: 5 * 60 * 1000, // 5 min
},
);

View File

@@ -22,5 +22,7 @@ export default function useFollowers(propsProvider: () => UseFollowersProps) {
const followersPubkeys = () => uniq(events()?.map((ev) => ev.pubkey));
return { followersPubkeys };
const count = () => followersPubkeys().length;
return { followersPubkeys, count };
}

26
src/nostr/useStats.ts Normal file
View File

@@ -0,0 +1,26 @@
import { createStore } from 'solid-js/store';
export type Stats = {
activeSubscriptions: number;
activeBatchSubscriptions: number;
};
const [stats, setStats] = createStore<Stats>({
activeSubscriptions: 0,
activeBatchSubscriptions: 0,
});
const useStats = () => {
const setActiveSubscriptions = (count: number) => setStats('activeSubscriptions', count);
const setActiveBatchSubscriptions = (count: number) =>
setStats('activeBatchSubscriptions', count);
return {
stats,
setActiveSubscriptions,
setActiveBatchSubscriptions,
};
};
export default useStats;

View File

@@ -2,6 +2,7 @@ import { createSignal, createEffect, onCleanup } from 'solid-js';
import type { Event as NostrEvent, Filter, SubscriptionOptions } from 'nostr-tools';
import uniqBy from 'lodash/uniqBy';
import usePool from '@/nostr/usePool';
import useStats from './useStats';
export type UseSubscriptionProps = {
relayUrls: string[];
@@ -27,7 +28,10 @@ const sortEvents = (events: NostrEvent[]) =>
let count = 0;
setInterval(() => console.log('sub', count), 1000);
const { setActiveSubscriptions } = useStats();
setInterval(() => {
setActiveSubscriptions(count);
}, 1000);
const useSubscription = (propsProvider: () => UseSubscriptionProps | null) => {
const pool = usePool();