update thread widget

This commit is contained in:
reya 2023-10-19 14:45:41 +07:00
parent 0de72eb009
commit e1e54c1a98
14 changed files with 144 additions and 206 deletions

View File

@ -76,13 +76,14 @@
"react-markdown": "^8.0.7", "react-markdown": "^8.0.7",
"react-router-dom": "^6.17.0", "react-router-dom": "^6.17.0",
"react-string-replace": "^1.1.1", "react-string-replace": "^1.1.1",
"react-xarrows": "^2.0.2",
"reactflow": "^11.9.4", "reactflow": "^11.9.4",
"remark-gfm": "^3.0.1", "remark-gfm": "^3.0.1",
"sonner": "^1.0.3", "sonner": "^1.0.3",
"tailwind-scrollbar": "^3.0.5", "tailwind-scrollbar": "^3.0.5",
"tauri-controls": "^0.2.0", "tauri-controls": "^0.2.0",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"virtua": "^0.13.0", "virtua": "^0.14.0",
"zustand": "^4.4.3" "zustand": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {

View File

@ -179,6 +179,9 @@ dependencies:
react-string-replace: react-string-replace:
specifier: ^1.1.1 specifier: ^1.1.1
version: 1.1.1 version: 1.1.1
react-xarrows:
specifier: ^2.0.2
version: 2.0.2(react@18.2.0)
reactflow: reactflow:
specifier: ^11.9.4 specifier: ^11.9.4
version: 11.9.4(@types/react@18.2.29)(react-dom@18.2.0)(react@18.2.0) version: 11.9.4(@types/react@18.2.29)(react-dom@18.2.0)(react@18.2.0)
@ -198,8 +201,8 @@ dependencies:
specifier: ^6.3.7 specifier: ^6.3.7
version: 6.3.7 version: 6.3.7
virtua: virtua:
specifier: ^0.13.0 specifier: ^0.14.0
version: 0.13.0(react-dom@18.2.0)(react@18.2.0) version: 0.14.0(react-dom@18.2.0)(react@18.2.0)
zustand: zustand:
specifier: ^4.4.3 specifier: ^4.4.3
version: 4.4.3(@types/react@18.2.29)(react@18.2.0) version: 4.4.3(@types/react@18.2.29)(react@18.2.0)
@ -4688,7 +4691,6 @@ packages:
/lodash@4.17.21: /lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
dev: true
/log-update@5.0.1: /log-update@5.0.1:
resolution: {integrity: sha512-5UtUDQ/6edw4ofyljDNcOVJQ4c7OjDro4h3y8e1GQL5iYElYclVHJ3zeWchylvMaKnDbDilC8irOVyexnA/Slw==} resolution: {integrity: sha512-5UtUDQ/6edw4ofyljDNcOVJQ4c7OjDro4h3y8e1GQL5iYElYclVHJ3zeWchylvMaKnDbDilC8irOVyexnA/Slw==}
@ -5947,6 +5949,17 @@ packages:
tslib: 2.6.2 tslib: 2.6.2
dev: false dev: false
/react-xarrows@2.0.2(react@18.2.0):
resolution: {integrity: sha512-tDlAqaxHNmy0vegW/6NdhoWyXJq1LANX/WUAlHyzoHe9BwFVnJPPDghmDjYeVr7XWFmBrVTUrHsrW7GKYI6HtQ==}
peerDependencies:
react: '>=16.8.0'
dependencies:
'@types/prop-types': 15.7.9
lodash: 4.17.21
prop-types: 15.8.1
react: 18.2.0
dev: false
/react@18.2.0: /react@18.2.0:
resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -6728,8 +6741,8 @@ packages:
vfile-message: 3.1.4 vfile-message: 3.1.4
dev: false dev: false
/virtua@0.13.0(react-dom@18.2.0)(react@18.2.0): /virtua@0.14.0(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-NiM+3lhl/XMLWsT+Fc+rcMQrsAe7PDRvncu6CjP5UEgDtulIo05KAaugrJAr/ptBofP/iAnlZK/X0Bjd+UkjIQ==} resolution: {integrity: sha512-+g3fxgFuQCqw6PpU5qzTRKhbSUGOeMEap0VbPaIRB1RiK5MfLiGXIMwID1iX1DmvUC/SqsBsJfVvlUaPNGWSVQ==}
peerDependencies: peerDependencies:
react: '>=16.14.0' react: '>=16.14.0'
react-dom: '>=16.14.0' react-dom: '>=16.14.0'

View File

@ -15,7 +15,7 @@ import {
NoteStats, NoteStats,
UnknownNote, UnknownNote,
} from '@shared/notes'; } from '@shared/notes';
import { RepliesList } from '@shared/notes/replies/list'; import { ReplyList } from '@shared/notes/replies/list';
import { NoteSkeleton } from '@shared/notes/skeleton'; import { NoteSkeleton } from '@shared/notes/skeleton';
import { User } from '@shared/user'; import { User } from '@shared/user';
@ -117,7 +117,7 @@ export function ArticleNoteScreen() {
</div> </div>
<div ref={replyRef} className="px-3"> <div ref={replyRef} className="px-3">
<NoteReplyForm id={data.id} pubkey={db.account.pubkey} /> <NoteReplyForm id={data.id} pubkey={db.account.pubkey} />
<RepliesList id={data.id} /> <ReplyList id={data.id} />
</div> </div>
</> </>
)} )}

View File

@ -17,7 +17,7 @@ import {
TextNote, TextNote,
UnknownNote, UnknownNote,
} from '@shared/notes'; } from '@shared/notes';
import { RepliesList } from '@shared/notes/replies/list'; import { ReplyList } from '@shared/notes/replies/list';
import { User } from '@shared/user'; import { User } from '@shared/user';
import { useEvent } from '@utils/hooks/useEvent'; import { useEvent } from '@utils/hooks/useEvent';
@ -119,7 +119,7 @@ export function TextNoteScreen() {
)} )}
<div ref={replyRef} className="px-3"> <div ref={replyRef} className="px-3">
<NoteReplyForm id={id} pubkey={db.account.pubkey} /> <NoteReplyForm id={id} pubkey={db.account.pubkey} />
<RepliesList id={id} /> <ReplyList id={id} />
</div> </div>
</div> </div>
<div className="col-span-1" /> <div className="col-span-1" />

View File

@ -7,6 +7,7 @@ import { Link } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider'; import { useStorage } from '@libs/storage/provider';
import { AccountMoreActions } from '@shared/accounts/more'; import { AccountMoreActions } from '@shared/accounts/more';
import { NetworkStatusIndicator } from '@shared/networkStatusIndicator';
import { useActivities } from '@stores/activities'; import { useActivities } from '@stores/activities';
@ -78,7 +79,7 @@ export function ActiveAccount() {
/> />
</Avatar.Fallback> </Avatar.Fallback>
</Avatar.Root> </Avatar.Root>
<span className="absolute bottom-0 right-0 block h-2 w-2 rounded-full bg-teal-500 ring-2 ring-neutral-100 dark:ring-neutral-900" /> <NetworkStatusIndicator />
</Link> </Link>
<AccountMoreActions pubkey={db.account.pubkey} /> <AccountMoreActions pubkey={db.account.pubkey} />
</div> </div>

View File

@ -1,50 +0,0 @@
import { ReactNode } from 'react';
import { twMerge } from 'tailwind-merge';
export function Button({
preset,
children,
disabled = false,
onClick = undefined,
}: {
preset: 'small' | 'publish' | 'large' | 'large-alt';
children: ReactNode;
disabled?: boolean;
onClick?: () => void;
}) {
let preClass: string;
switch (preset) {
case 'small':
preClass =
'w-min h-9 px-4 bg-neutral-400 dark:bg-neutral-600 rounded-md text-sm font-medium text-white hover:bg-blue-600';
break;
case 'publish':
preClass =
'w-min h-9 px-4 bg-blue-500 rounded-md text-sm font-medium text-white hover:bg-blue-600';
break;
case 'large':
preClass =
'h-11 w-full bg-blue-500 rounded-lg font-medium text-white hover:bg-blue-600';
break;
case 'large-alt':
preClass =
'h-11 w-full bg-neutral-400 dark:bg-neutral-600 rounded-lg font-medium text-white hover:bg-white/20';
break;
default:
break;
}
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={twMerge(
'inline-flex transform items-center justify-center gap-1 leading-none focus:outline-none active:translate-y-1 disabled:pointer-events-none disabled:opacity-50',
preClass
)}
>
{children}
</button>
);
}

View File

@ -1,3 +1,5 @@
import { twMerge } from 'tailwind-merge';
import { useNetworkStatus } from '@utils/hooks/useNetworkStatus'; import { useNetworkStatus } from '@utils/hooks/useNetworkStatus';
export function NetworkStatusIndicator() { export function NetworkStatusIndicator() {
@ -5,9 +7,10 @@ export function NetworkStatusIndicator() {
return ( return (
<span <span
className={`absolute right-0 top-0 block h-2 w-2 -translate-y-1/2 translate-x-1/2 transform rounded-full ${ className={twMerge(
isOnline ? 'bg-green-400' : 'bg-red-400' 'absolute bottom-0 right-0 block h-2 w-2 rounded-full ring-2 ring-neutral-100 dark:ring-neutral-900',
} ring-2 ring-black`} isOnline ? 'bg-teal-500' : 'bg-red-500'
)}
/> />
); );
} }

View File

@ -1,14 +1,14 @@
import { useState } from 'react'; import { useState } from 'react';
import { Button } from '@shared/button'; import { useStorage } from '@libs/storage/provider';
import { User } from '@shared/user';
import { useNostr } from '@utils/hooks/useNostr'; import { useNostr } from '@utils/hooks/useNostr';
import { useProfile } from '@utils/hooks/useProfile';
import { displayNpub } from '@utils/shortenKey';
export function NoteReplyForm({ id, pubkey }: { id: string; pubkey: string }) { export function NoteReplyForm({ id }: { id: string }) {
const { publish } = useNostr(); const { publish } = useNostr();
const { status, user } = useProfile(pubkey); const { db } = useStorage();
const [value, setValue] = useState(''); const [value, setValue] = useState('');
@ -23,47 +23,23 @@ export function NoteReplyForm({ id, pubkey }: { id: string; pubkey: string }) {
}; };
return ( return (
<div className="mt-3 flex flex-col rounded-xl bg-neutral-200 dark:bg-neutral-800"> <div className="mt-3 flex gap-3">
<div className="relative w-full flex-1 overflow-hidden"> <User pubkey={db.account.pubkey} variant="miniavatar" />
<div className="relative flex flex-1 flex-col rounded-xl bg-neutral-100 dark:bg-neutral-900">
<textarea <textarea
value={value} value={value}
onChange={(e) => setValue(e.target.value)} onChange={(e) => setValue(e.target.value)}
placeholder="Reply to this thread..." placeholder="Reply to this thread..."
className=" relative h-24 w-full resize-none rounded-md bg-transparent px-3 py-3 text-base text-neutral-900 !outline-none placeholder:text-neutral-600 dark:text-neutral-100 dark:placeholder:text-neutral-400" className="relative h-24 w-full resize-none bg-transparent p-3 text-base text-neutral-900 !outline-none placeholder:text-neutral-600 dark:text-neutral-100 dark:placeholder:text-neutral-400"
spellCheck={false} spellCheck={false}
/> />
</div> <button
<div className="w-full border-t border-neutral-300 px-3 py-3 dark:border-neutral-700">
{status === 'loading' ? (
<div>Loading</div>
) : (
<div className="flex w-full items-center justify-between">
<div className="inline-flex items-center gap-2">
<div className="relative h-11 w-11 shrink-0 rounded">
<img
src={user?.picture || user?.image}
alt={pubkey}
className="h-11 w-11 rounded-lg bg-white object-cover"
/>
</div>
<div>
<p className="text-sm text-neutral-600 dark:text-neutral-400">Reply as</p>
<p className="font-semibold text-neutral-900 dark:text-neutral-100">
{user?.name || displayNpub(pubkey, 16)}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
onClick={() => submit()} onClick={() => submit()}
disabled={value.length === 0 ? true : false} disabled={value.length === 0 ? true : false}
preset="publish" className="mb-2 ml-auto mr-2 h-9 w-20 rounded-lg bg-blue-500 text-white hover:bg-blue-600 disabled:opacity-50"
> >
Reply Reply
</Button> </button>
</div>
</div>
)}
</div> </div>
</div> </div>
); );

View File

@ -1,12 +1,18 @@
import * as Collapsible from '@radix-ui/react-collapsible';
import { useState } from 'react';
import { twMerge } from 'tailwind-merge';
import { NavArrowDownIcon } from '@shared/icons';
import { MemoizedTextNote, NoteActions, SubReply } from '@shared/notes'; import { MemoizedTextNote, NoteActions, SubReply } from '@shared/notes';
import { User } from '@shared/user'; import { User } from '@shared/user';
import { NDKEventWithReplies } from '@utils/types'; import { NDKEventWithReplies } from '@utils/types';
export function Reply({ event, root }: { event: NDKEventWithReplies; root?: string }) { export function Reply({ event, root }: { event: NDKEventWithReplies; root?: string }) {
const [open, setOpen] = useState(false);
return ( return (
<div className="relative h-min w-full"> <div className="relative">
<div className="relative z-10">
<div className="relative flex flex-col"> <div className="relative flex flex-col">
<User pubkey={event.pubkey} time={event.created_at} eventId={event.id} /> <User pubkey={event.pubkey} time={event.created_at} eventId={event.id} />
<div className="-mt-4 flex items-start gap-3"> <div className="-mt-4 flex items-start gap-3">
@ -22,13 +28,26 @@ export function Reply({ event, root }: { event: NDKEventWithReplies; root?: stri
</div> </div>
</div> </div>
</div> </div>
<div className="pl-14"> <div className="pl-[48px]">
{event.replies ? ( <Collapsible.Root open={open} onOpenChange={setOpen}>
event.replies.map((sub) => <SubReply key={sub.id} event={sub} />) {event.replies?.length > 0 ? (
) : ( <div>
<div className="pb-3" /> <Collapsible.Trigger asChild>
)} <div className="inline-flex h-10 items-center gap-1 font-semibold text-blue-500">
<NavArrowDownIcon
className={twMerge('h-3 w-3', open ? 'rotate-180 transform' : '')}
/>
{event.replies?.length +
' ' +
(event.replies?.length === 1 ? 'reply' : 'replies')}
</div> </div>
</Collapsible.Trigger>
<Collapsible.Content>
{event.replies?.map((sub) => <SubReply key={sub.id} event={sub} />)}
</Collapsible.Content>
</div>
) : null}
</Collapsible.Root>
</div> </div>
</div> </div>
); );

View File

@ -1,11 +1,12 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { NoteSkeleton, Reply } from '@shared/notes'; import { LoaderIcon } from '@shared/icons';
import { Reply } from '@shared/notes';
import { useNostr } from '@utils/hooks/useNostr'; import { useNostr } from '@utils/hooks/useNostr';
import { NDKEventWithReplies } from '@utils/types'; import { NDKEventWithReplies } from '@utils/types';
export function RepliesList({ id }: { id: string }) { export function ReplyList({ id }: { id: string }) {
const { fetchAllReplies, sub } = useNostr(); const { fetchAllReplies, sub } = useNostr();
const [data, setData] = useState<null | NDKEventWithReplies[]>(null); const [data, setData] = useState<null | NDKEventWithReplies[]>(null);
@ -36,20 +37,16 @@ export function RepliesList({ id }: { id: string }) {
if (!data) { if (!data) {
return ( return (
<div className="mt-5 pb-10"> <div className="mt-3">
<div className="rounded-xl bg-neutral-100 px-3 py-3 dark:bg-neutral-900"> <div className="flex h-16 items-center justify-center rounded-xl bg-neutral-100 px-3 py-3 dark:bg-neutral-900">
<NoteSkeleton /> <LoaderIcon className="h-5 w-5 animate-spin" />
</div> </div>
</div> </div>
); );
} }
return ( return (
<div className="mt-5 pb-10"> <div className="mt-3 flex flex-col gap-5">
<h5 className="mb-2 text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{data?.length || 0} replies
</h5>
<div className="flex flex-col gap-2">
{data?.length === 0 ? ( {data?.length === 0 ? (
<div className="mt-2 flex w-full items-center justify-center rounded-xl bg-neutral-400 dark:bg-neutral-600"> <div className="mt-2 flex w-full items-center justify-center rounded-xl bg-neutral-400 dark:bg-neutral-600">
<div className="flex flex-col items-center justify-center gap-2 py-6"> <div className="flex flex-col items-center justify-center gap-2 py-6">
@ -63,6 +60,5 @@ export function RepliesList({ id }: { id: string }) {
data.map((event) => <Reply key={event.id} event={event} root={id} />) data.map((event) => <Reply key={event.id} event={event} root={id} />)
)} )}
</div> </div>
</div>
); );
} }

View File

@ -5,7 +5,7 @@ import { User } from '@shared/user';
export function SubReply({ event }: { event: NDKEvent }) { export function SubReply({ event }: { event: NDKEvent }) {
return ( return (
<div className="relative z-10 mb-3 mt-5 flex flex-col"> <div className="mb-3 flex flex-col">
<User pubkey={event.pubkey} time={event.created_at} eventId={event.id} /> <User pubkey={event.pubkey} time={event.created_at} eventId={event.id} />
<div className="-mt-4 flex items-start gap-3"> <div className="-mt-4 flex items-start gap-3">
<div className="w-10 shrink-0" /> <div className="w-10 shrink-0" />

View File

@ -32,6 +32,7 @@ export const User = memo(function User({
| 'chat' | 'chat'
| 'large' | 'large'
| 'thread' | 'thread'
| 'miniavatar'
| 'avatar' | 'avatar'
| 'stacked' | 'stacked'
| 'ministacked'; | 'ministacked';
@ -207,6 +208,28 @@ export const User = memo(function User({
); );
} }
if (variant === 'miniavatar') {
return (
<Avatar.Root>
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
style={{ contentVisibility: 'auto' }}
className="h-10 w-10 rounded-lg"
/>
<Avatar.Fallback delayMs={300}>
<img
src={svgURI}
alt={pubkey}
className="h-10 w-10 rounded-lg bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
);
}
if (variant === 'stacked') { if (variant === 'stacked') {
return ( return (
<Avatar.Root> <Avatar.Root>

View File

@ -1,8 +1,7 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk'; import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useStorage } from '@libs/storage/provider'; import { LoaderIcon } from '@shared/icons';
import { import {
MemoizedArticleNote, MemoizedArticleNote,
MemoizedFileNote, MemoizedFileNote,
@ -12,8 +11,7 @@ import {
NoteStats, NoteStats,
UnknownNote, UnknownNote,
} from '@shared/notes'; } from '@shared/notes';
import { RepliesList } from '@shared/notes/replies/list'; import { ReplyList } from '@shared/notes/replies/list';
import { NoteSkeleton } from '@shared/notes/skeleton';
import { TitleBar } from '@shared/titleBar'; import { TitleBar } from '@shared/titleBar';
import { User } from '@shared/user'; import { User } from '@shared/user';
import { WidgetWrapper } from '@shared/widgets'; import { WidgetWrapper } from '@shared/widgets';
@ -22,7 +20,6 @@ import { useEvent } from '@utils/hooks/useEvent';
import { Widget } from '@utils/types'; import { Widget } from '@utils/types';
export function LocalThreadWidget({ params }: { params: Widget }) { export function LocalThreadWidget({ params }: { params: Widget }) {
const { db } = useStorage();
const { status, data } = useEvent(params.content); const { status, data } = useEvent(params.content);
const renderKind = useCallback( const renderKind = useCallback(
@ -44,31 +41,22 @@ export function LocalThreadWidget({ params }: { params: Widget }) {
return ( return (
<WidgetWrapper> <WidgetWrapper>
<TitleBar id={params.id} title={params.title} /> <TitleBar id={params.id} title={params.title} />
<div className="h-full overflow-y-auto scrollbar-none"> <div className="h-full overflow-y-auto px-3 scrollbar-none">
{status === 'loading' ? ( {status === 'loading' ? (
<div className="px-3 py-1.5"> <div className="flex h-16 items-center justify-center rounded-xl bg-neutral-100 px-3 py-3 dark:bg-neutral-900">
<div className="rounded-xl bg-neutral-100 px-3 py-3 dark:bg-neutral-900"> <LoaderIcon className="h-5 w-5 animate-spin" />
<NoteSkeleton />
</div>
</div> </div>
) : ( ) : (
<div className="h-min w-full px-3">
<div className="rounded-xl bg-neutral-100 px-3 py-3 dark:bg-neutral-900"> <div className="rounded-xl bg-neutral-100 px-3 py-3 dark:bg-neutral-900">
<User pubkey={data.pubkey} time={data.created_at} variant="thread" /> <User pubkey={data.pubkey} time={data.created_at} variant="thread" />
<div className="mt-2">{renderKind(data)}</div> <div className="mt-2">{renderKind(data)}</div>
<NoteActions <NoteActions id={params.content} pubkey={data.pubkey} extraButtons={false} />
id={params.content}
pubkey={data.pubkey}
extraButtons={false}
/>
</div>
</div> </div>
)} )}
<div className="px-3">
<NoteStats id={params.content} /> <NoteStats id={params.content} />
<NoteReplyForm id={params.content} pubkey={db.account.pubkey} /> <hr className="my-4 h-px w-full border-none bg-neutral-100" />
<RepliesList id={params.content} /> <NoteReplyForm id={params.content} />
</div> <ReplyList id={params.content} />
</div> </div>
</WidgetWrapper> </WidgetWrapper>
); );

View File

@ -1,32 +0,0 @@
import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
interface SidebarState {
feeds: boolean;
chats: boolean;
communities: boolean;
integrations: boolean;
toggleFeeds: () => void;
toggleChats: () => void;
toggleCommunities: () => void;
toggleIntegrations: () => void;
}
export const useSidebar = create<SidebarState>()(
persist(
(set) => ({
feeds: true,
chats: false,
communities: true,
integrations: true,
toggleFeeds: () => set((state) => ({ feeds: !state.feeds })),
toggleChats: () => set((state) => ({ chats: !state.chats })),
toggleCommunities: () => set((state) => ({ communities: !state.communities })),
toggleIntegrations: () => set((state) => ({ integrations: !state.integrations })),
}),
{
name: 'sidebar',
storage: createJSONStorage(() => localStorage),
}
)
);