refactor(widget): migrate widget component to ark lib

This commit is contained in:
2023-12-18 13:39:03 +07:00
parent 344bdc0c66
commit 55298515af
36 changed files with 1153 additions and 1016 deletions

View File

@@ -22,6 +22,7 @@
"@getalby/sdk": "^2.7.0", "@getalby/sdk": "^2.7.0",
"@nostr-dev-kit/ndk": "^2.3.0", "@nostr-dev-kit/ndk": "^2.3.0",
"@nostr-fetch/adapter-ndk": "^0.14.1", "@nostr-fetch/adapter-ndk": "^0.14.1",
"@preact/signals-react": "^1.3.8",
"@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-alert-dialog": "^1.0.5", "@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-avatar": "^1.0.4",

25
pnpm-lock.yaml generated
View File

@@ -17,6 +17,9 @@ dependencies:
'@nostr-fetch/adapter-ndk': '@nostr-fetch/adapter-ndk':
specifier: ^0.14.1 specifier: ^0.14.1
version: 0.14.1(@nostr-dev-kit/ndk@2.3.0)(nostr-fetch@0.14.1) version: 0.14.1(@nostr-dev-kit/ndk@2.3.0)(nostr-fetch@0.14.1)
'@preact/signals-react':
specifier: ^1.3.8
version: 1.3.8(react@18.2.0)
'@radix-ui/react-accordion': '@radix-ui/react-accordion':
specifier: ^1.1.2 specifier: ^1.1.2
version: 1.1.2(@types/react-dom@18.2.18)(@types/react@18.2.45)(react-dom@18.2.0)(react@18.2.0) version: 1.1.2(@types/react-dom@18.2.18)(@types/react@18.2.45)(react-dom@18.2.0)(react@18.2.0)
@@ -876,6 +879,20 @@ packages:
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
dev: false dev: false
/@preact/signals-core@1.5.1:
resolution: {integrity: sha512-dE6f+WCX5ZUDwXzUIWNMhhglmuLpqJhuy3X3xHrhZYI0Hm2LyQwOu0l9mdPiWrVNsE+Q7txOnJPgtIqHCYoBVA==}
dev: false
/@preact/signals-react@1.3.8(react@18.2.0):
resolution: {integrity: sha512-i7mVZ/ZiD9WqNH79r+klpQsp8X+/dOd/5AtvDI0HNpgWuHyzyF9WXDViKl+1vXgB767n9VnH1W2azg+w1oyFMQ==}
peerDependencies:
react: ^16.14.0 || 17.x || 18.x
dependencies:
'@preact/signals-core': 1.5.1
react: 18.2.0
use-sync-external-store: 1.2.0(react@18.2.0)
dev: false
/@radix-ui/primitive@1.0.1: /@radix-ui/primitive@1.0.1:
resolution: {integrity: sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==} resolution: {integrity: sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==}
dependencies: dependencies:
@@ -5981,6 +5998,14 @@ packages:
tslib: 2.6.2 tslib: 2.6.2
dev: false dev: false
/use-sync-external-store@1.2.0(react@18.2.0):
resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
dependencies:
react: 18.2.0
dev: false
/utf-8-validate@5.0.10: /utf-8-validate@5.0.10:
resolution: {integrity: sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==} resolution: {integrity: sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==}
engines: {node: '>=6.14.2'} engines: {node: '>=6.14.2'}

View File

@@ -124,144 +124,138 @@ export default function App() {
}, },
], ],
}, },
],
},
{
path: 'auth',
element: <AuthLayout platform={ark.platform} />,
errorElement: <ErrorScreen />,
children: [
{ {
path: 'auth', path: 'welcome',
element: <AuthLayout />, async lazy() {
errorElement: <ErrorScreen />, const { WelcomeScreen } = await import('@app/auth/welcome');
children: [ return { Component: WelcomeScreen };
{ },
path: 'welcome',
async lazy() {
const { WelcomeScreen } = await import('@app/auth/welcome');
return { Component: WelcomeScreen };
},
},
{
path: 'create',
async lazy() {
const { CreateAccountScreen } = await import('@app/auth/create');
return { Component: CreateAccountScreen };
},
},
{
path: 'import',
async lazy() {
const { ImportAccountScreen } = await import('@app/auth/import');
return { Component: ImportAccountScreen };
},
},
{
path: 'onboarding',
async lazy() {
const { OnboardingScreen } = await import('@app/auth/onboarding');
return { Component: OnboardingScreen };
},
},
{
path: 'follow',
async lazy() {
const { FollowScreen } = await import('@app/auth/follow');
return { Component: FollowScreen };
},
},
{
path: 'finish',
async lazy() {
const { FinishScreen } = await import('@app/auth/finish');
return { Component: FinishScreen };
},
},
{
path: 'tutorials/note',
async lazy() {
const { TutorialNoteScreen } = await import('@app/auth/tutorials/note');
return { Component: TutorialNoteScreen };
},
},
{
path: 'tutorials/widget',
async lazy() {
const { TutorialWidgetScreen } = await import(
'@app/auth/tutorials/widget'
);
return { Component: TutorialWidgetScreen };
},
},
{
path: 'tutorials/posting',
async lazy() {
const { TutorialPostingScreen } = await import(
'@app/auth/tutorials/posting'
);
return { Component: TutorialPostingScreen };
},
},
{
path: 'tutorials/finish',
async lazy() {
const { TutorialFinishScreen } = await import(
'@app/auth/tutorials/finish'
);
return { Component: TutorialFinishScreen };
},
},
],
}, },
{ {
path: 'settings', path: 'create',
element: <SettingsLayout />, async lazy() {
errorElement: <ErrorScreen />, const { CreateAccountScreen } = await import('@app/auth/create');
children: [ return { Component: CreateAccountScreen };
{ },
index: true, },
async lazy() { {
const { UserSettingScreen } = await import('@app/settings'); path: 'import',
return { Component: UserSettingScreen }; async lazy() {
}, const { ImportAccountScreen } = await import('@app/auth/import');
}, return { Component: ImportAccountScreen };
{ },
path: 'edit-profile', },
async lazy() { {
const { EditProfileScreen } = await import('@app/settings/editProfile'); path: 'onboarding',
return { Component: EditProfileScreen }; async lazy() {
}, const { OnboardingScreen } = await import('@app/auth/onboarding');
}, return { Component: OnboardingScreen };
{ },
path: 'edit-contact', },
async lazy() { {
const { EditContactScreen } = await import('@app/settings/editContact'); path: 'follow',
return { Component: EditContactScreen }; async lazy() {
}, const { FollowScreen } = await import('@app/auth/follow');
}, return { Component: FollowScreen };
{ },
path: 'general', },
async lazy() { {
const { GeneralSettingScreen } = await import('@app/settings/general'); path: 'finish',
return { Component: GeneralSettingScreen }; async lazy() {
}, const { FinishScreen } = await import('@app/auth/finish');
}, return { Component: FinishScreen };
{ },
path: 'backup', },
async lazy() { {
const { BackupSettingScreen } = await import('@app/settings/backup'); path: 'tutorials/note',
return { Component: BackupSettingScreen }; async lazy() {
}, const { TutorialNoteScreen } = await import('@app/auth/tutorials/note');
}, return { Component: TutorialNoteScreen };
{ },
path: 'advanced', },
async lazy() { {
const { AdvancedSettingScreen } = await import('@app/settings/advanced'); path: 'tutorials/widget',
return { Component: AdvancedSettingScreen }; async lazy() {
}, const { TutorialWidgetScreen } = await import('@app/auth/tutorials/widget');
}, return { Component: TutorialWidgetScreen };
{ },
path: 'about', },
async lazy() { {
const { AboutScreen } = await import('@app/settings/about'); path: 'tutorials/posting',
return { Component: AboutScreen }; async lazy() {
}, const { TutorialPostingScreen } = await import('@app/auth/tutorials/posting');
}, return { Component: TutorialPostingScreen };
], },
},
{
path: 'tutorials/finish',
async lazy() {
const { TutorialFinishScreen } = await import('@app/auth/tutorials/finish');
return { Component: TutorialFinishScreen };
},
},
],
},
{
path: 'settings',
element: <SettingsLayout platform={ark.platform} />,
errorElement: <ErrorScreen />,
children: [
{
index: true,
async lazy() {
const { UserSettingScreen } = await import('@app/settings');
return { Component: UserSettingScreen };
},
},
{
path: 'edit-profile',
async lazy() {
const { EditProfileScreen } = await import('@app/settings/editProfile');
return { Component: EditProfileScreen };
},
},
{
path: 'edit-contact',
async lazy() {
const { EditContactScreen } = await import('@app/settings/editContact');
return { Component: EditContactScreen };
},
},
{
path: 'general',
async lazy() {
const { GeneralSettingScreen } = await import('@app/settings/general');
return { Component: GeneralSettingScreen };
},
},
{
path: 'backup',
async lazy() {
const { BackupSettingScreen } = await import('@app/settings/backup');
return { Component: BackupSettingScreen };
},
},
{
path: 'advanced',
async lazy() {
const { AdvancedSettingScreen } = await import('@app/settings/advanced');
return { Component: AdvancedSettingScreen };
},
},
{
path: 'about',
async lazy() {
const { AboutScreen } = await import('@app/settings/about');
return { Component: AboutScreen };
},
}, },
], ],
}, },

View File

@@ -1,5 +1,6 @@
import { useSignal } from '@preact/signals-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useCallback, useRef, useState } from 'react'; import { useRef } from 'react';
import { VList, VListHandle } from 'virtua'; import { VList, VListHandle } from 'virtua';
import { useArk } from '@libs/ark'; import { useArk } from '@libs/ark';
import { LoaderIcon } from '@shared/icons'; import { LoaderIcon } from '@shared/icons';
@@ -22,27 +23,27 @@ import { WIDGET_KIND } from '@utils/constants';
import { Widget } from '@utils/types'; import { Widget } from '@utils/types';
export function HomeScreen() { export function HomeScreen() {
const ref = useRef<VListHandle>(null);
const [selectedIndex, setSelectedIndex] = useState(-1);
const ark = useArk(); const ark = useArk();
const { status, data } = useQuery({ const ref = useRef<VListHandle>(null);
const index = useSignal(-1);
const { isLoading, data } = useQuery({
queryKey: ['widgets'], queryKey: ['widgets'],
queryFn: async () => { queryFn: async () => {
const dbWidgets = await ark.getWidgets(); const dbWidgets = await ark.getWidgets();
const defaultWidgets = [ const defaultWidgets = [
{
id: '9999',
title: 'Newsfeed',
content: '',
kind: WIDGET_KIND.newsfeed,
},
{ {
id: '9998', id: '9998',
title: 'Notification', title: 'Notification',
content: '', content: '',
kind: WIDGET_KIND.notification, kind: WIDGET_KIND.notification,
}, },
{
id: '9999',
title: 'Newsfeed',
content: '',
kind: WIDGET_KIND.newsfeed,
},
]; ];
return [...defaultWidgets, ...dbWidgets]; return [...defaultWidgets, ...dbWidgets];
@@ -53,7 +54,7 @@ export function HomeScreen() {
staleTime: Infinity, staleTime: Infinity,
}); });
const renderItem = useCallback((widget: Widget) => { const renderItem = (widget: Widget) => {
switch (widget.kind) { switch (widget.kind) {
case WIDGET_KIND.notification: case WIDGET_KIND.notification:
return <NotificationWidget key={widget.id} />; return <NotificationWidget key={widget.id} />;
@@ -80,13 +81,13 @@ export function HomeScreen() {
case WIDGET_KIND.list: case WIDGET_KIND.list:
return <WidgetList key={widget.id} widget={widget} />; return <WidgetList key={widget.id} widget={widget} />;
default: default:
return null; return <NewsfeedWidget key={widget.id} />;
} }
}, []); };
if (status === 'pending') { if (isLoading) {
return ( return (
<div className="flex h-full w-full items-center justify-center bg-white dark:bg-black"> <div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-5 w-5 animate-spin" /> <LoaderIcon className="h-5 w-5 animate-spin" />
</div> </div>
); );
@@ -106,8 +107,8 @@ export function HomeScreen() {
case 'ArrowUp': case 'ArrowUp':
case 'ArrowLeft': { case 'ArrowLeft': {
e.preventDefault(); e.preventDefault();
const prevIndex = Math.max(selectedIndex - 1, 0); const prevIndex = Math.max(index.peek() - 1, 0);
setSelectedIndex(prevIndex); index.value = prevIndex;
ref.current.scrollToIndex(prevIndex, { ref.current.scrollToIndex(prevIndex, {
align: 'center', align: 'center',
smooth: true, smooth: true,
@@ -117,8 +118,8 @@ export function HomeScreen() {
case 'ArrowDown': case 'ArrowDown':
case 'ArrowRight': { case 'ArrowRight': {
e.preventDefault(); e.preventDefault();
const nextIndex = Math.min(selectedIndex + 1, data.length - 1); const nextIndex = Math.min(index.peek() + 1, data.length - 1);
setSelectedIndex(nextIndex); index.value = nextIndex;
ref.current.scrollToIndex(nextIndex, { ref.current.scrollToIndex(nextIndex, {
align: 'center', align: 'center',
smooth: true, smooth: true,

View File

@@ -0,0 +1,5 @@
import { ReactNode } from 'react';
export function WidgetContent({ children }: { children: ReactNode }) {
return <div className="h-full w-full">{children}</div>;
}

View File

@@ -0,0 +1,112 @@
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { useQueryClient } from '@tanstack/react-query';
import { ReactNode } from 'react';
import {
ArrowLeftIcon,
ArrowRightIcon,
HomeIcon,
HorizontalDotsIcon,
RefreshIcon,
TrashIcon,
} from '@shared/icons';
import { useWidget } from '@utils/hooks/useWidget';
export function WidgetHeader({
id,
title,
queryKey,
icon,
}: {
id: string;
title: string;
queryKey?: string;
icon?: ReactNode;
}) {
const queryClient = useQueryClient();
const { removeWidget } = useWidget();
const refresh = async () => {
if (queryKey) await queryClient.refetchQueries({ queryKey: [queryKey] });
};
const moveLeft = async () => {
removeWidget.mutate(id);
};
const moveRight = async () => {
removeWidget.mutate(id);
};
const deleteWidget = async () => {
removeWidget.mutate(id);
};
return (
<div className="flex h-11 w-full shrink-0 items-center justify-between border-b border-neutral-100 px-3 dark:border-neutral-900">
<div className="inline-flex items-center gap-4">
<div className="h-5 w-1 rounded-full bg-blue-500" />
<div className="inline-flex items-center gap-2">
{icon ? icon : <HomeIcon className="h-5 w-5" />}
<div className="text-sm font-medium">{title}</div>
</div>
</div>
<div>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
type="button"
className="inline-flex h-6 w-6 items-center justify-center"
>
<HorizontalDotsIcon className="h-4 w-4" />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content className="flex w-[220px] flex-col overflow-hidden rounded-xl border border-neutral-100 bg-white p-2 shadow-lg shadow-neutral-200/50 focus:outline-none dark:border-neutral-900 dark:bg-neutral-950 dark:shadow-neutral-900/50">
<DropdownMenu.Item asChild>
<button
type="button"
onClick={refresh}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-neutral-700 hover:bg-blue-100 hover:text-blue-500 focus:outline-none dark:text-neutral-300 dark:hover:bg-neutral-900 dark:hover:text-neutral-50"
>
<RefreshIcon className="h-5 w-5" />
Refresh
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={moveLeft}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-neutral-700 hover:bg-blue-100 hover:text-blue-500 focus:outline-none dark:text-neutral-300 dark:hover:bg-neutral-900 dark:hover:text-neutral-50"
>
<ArrowLeftIcon className="h-5 w-5" />
Move left
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={moveRight}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-neutral-700 hover:bg-blue-100 hover:text-blue-500 focus:outline-none dark:text-neutral-300 dark:hover:bg-neutral-900 dark:hover:text-neutral-50"
>
<ArrowRightIcon className="h-5 w-5" />
Move right
</button>
</DropdownMenu.Item>
<DropdownMenu.Separator className="my-1 h-px bg-neutral-100 dark:bg-neutral-900" />
<DropdownMenu.Item asChild>
<button
type="button"
onClick={deleteWidget}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-red-500 hover:bg-red-500 hover:text-red-50 focus:outline-none"
>
<TrashIcon className="h-5 w-5" />
Delete
</button>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</div>
</div>
);
}

View File

@@ -0,0 +1,9 @@
import { WidgetContent } from './content';
import { WidgetHeader } from './header';
import { WidgetRoot } from './root';
export const Widget = {
Root: WidgetRoot,
Header: WidgetHeader,
Content: WidgetContent,
};

View File

@@ -1,22 +1,23 @@
import { useSignal } from '@preact/signals-react';
import { Resizable } from 're-resizable'; import { Resizable } from 're-resizable';
import { ReactNode, useState } from 'react'; import { ReactNode } from 'react';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
export function WidgetWrapper({ export function WidgetRoot({
children, children,
className, className,
}: { }: {
children: ReactNode; children: ReactNode;
className?: string; className?: string;
}) { }) {
const [width, setWidth] = useState(420); const width = useSignal(420);
return ( return (
<Resizable <Resizable
size={{ width: width, height: '100%' }} size={{ width: width.value, height: '100%' }}
onResizeStart={(e) => e.preventDefault()} onResizeStart={(e) => e.preventDefault()}
onResizeStop={(_e, _direction, _ref, d) => { onResizeStop={(_e, _direction, _ref, d) => {
setWidth((prevWidth) => prevWidth + d.width); width.value = width.peek() + d.width;
}} }}
minWidth={420} minWidth={420}
maxWidth={600} maxWidth={600}

View File

@@ -1,2 +1,6 @@
export * from './ark'; export * from './ark';
export * from './provider'; export * from './provider';
export * from './components/widget';
export * from './components/widget/content';
export * from './components/widget/header';
export * from './components/widget/root';

View File

@@ -38,7 +38,7 @@ const ArkProvider = ({ children }: PropsWithChildren<object>) => {
// start depot // start depot
if (_ark.settings.depot) { if (_ark.settings.depot) {
await ark.launchDepot(); await _ark.launchDepot();
await delay(2000); await delay(2000);
} }

View File

@@ -0,0 +1,18 @@
export function AnnouncementIcon(props: JSX.IntrinsicElements['svg']) {
return (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="24"
height="24"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
>
<path d="M16.36 3.014A27.429 27.429 0 0 1 8.143 8.04l-4.67 1.825a5.126 5.126 0 0 0 1.7 6.34l1.631-.25m9.556-12.94c-.875.234-.824 3.262.114 6.764.938 3.501 2.408 6.15 3.283 5.915M16.36 3.014c.875-.234 2.345 2.414 3.284 5.915.938 3.502.989 6.53.113 6.765m0 0a27.428 27.428 0 0 0-8.595-.382m0 0L13.295 22H8.92l-2.116-6.044m4.358-.644c-.345.04-.69.085-1.034.138l-3.324.506" />
</svg>
);
}

View File

@@ -1,15 +1,18 @@
import { SVGProps } from 'react'; export function ArrowLeftIcon(props: JSX.IntrinsicElements['svg']) {
export function ArrowLeftIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
return ( return (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}> <svg
<path {...props}
d="M10 18.25L3.75 12M3.75 12L10 5.75M3.75 12H20.25" xmlns="http://www.w3.org/2000/svg"
stroke="currentColor" viewBox="0 0 24 24"
strokeWidth={1.5} width="24"
strokeLinecap="round" height="24"
strokeLinejoin="round" fill="none"
/> stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
>
<path d="M8.83 6a30.23 30.23 0 0 0-5.62 5.406A.949.949 0 0 0 3 12m5.83 6a30.233 30.233 0 0 1-5.62-5.406A.949.949 0 0 1 3 12m0 0h18" />
</svg> </svg>
); );
} }

View File

@@ -1,15 +1,18 @@
import { SVGProps } from 'react'; export function ArrowRightIcon(props: JSX.IntrinsicElements['svg']) {
export function ArrowRightIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
return ( return (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}> <svg
<path {...props}
d="M14 5.75L20.25 12M20.25 12L14 18.25M20.25 12H3.75" xmlns="http://www.w3.org/2000/svg"
stroke="currentColor" viewBox="0 0 24 24"
strokeWidth={1.5} width="24"
strokeLinecap="round" height="24"
strokeLinejoin="round" fill="none"
/> stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
>
<path d="M15.17 6a30.23 30.23 0 0 1 5.62 5.406c.14.174.21.384.21.594m-5.83 6a30.232 30.232 0 0 0 5.62-5.406A.949.949 0 0 0 21 12m0 0H3" />
</svg> </svg>
); );
} }

View File

@@ -1,21 +1,18 @@
import { SVGProps } from 'react'; export function HomeIcon(props: JSX.IntrinsicElements['svg']) {
export function HomeIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
return ( return (
<svg <svg
{...props}
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="24" width="24"
height="24" height="24"
fill="none" fill="none"
viewBox="0 0 24 24" stroke="currentColor"
{...props} strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
> >
<path <path d="M9 17h6M7.606 5.65l-2.6 2.456c-.74.698-1.11 1.047-1.374 1.46a4 4 0 0 0-.513 1.191C3 11.233 3 11.742 3 12.76V14.6c0 2.24 0 3.36.436 4.216a4 4 0 0 0 1.748 1.748C6.04 21 7.16 21 9.4 21h5.2c2.24 0 3.36 0 4.216-.436a4 4 0 0 0 1.748-1.748C21 17.96 21 16.84 21 14.6v-1.841c0-1.017 0-1.526-.119-2.002a4 4 0 0 0-.513-1.19c-.265-.414-.634-.763-1.374-1.461l-2.6-2.456c-1.546-1.46-2.32-2.19-3.201-2.466a4 4 0 0 0-2.386 0c-.882.275-1.655 1.006-3.201 2.466Z" />
fill="currentColor"
fillRule="evenodd"
d="M10.108 1.999a3 3 0 013.784 0l6 4.875A3 3 0 0121 9.202V18a3 3 0 01-3 3H6a3 3 0 01-3-3V9.202a3 3 0 011.108-2.328l6-4.875zM8 15a1 1 0 100 2h8a1 1 0 100-2H8z"
clipRule="evenodd"
></path>
</svg> </svg>
); );
} }

View File

@@ -84,3 +84,4 @@ export * from './info';
export * from './light'; export * from './light';
export * from './dark'; export * from './dark';
export * from './system'; export * from './system';
export * from './announcement';

View File

@@ -1,14 +1,18 @@
import { SVGProps } from 'react'; export function RefreshIcon(props: JSX.IntrinsicElements['svg']) {
export function RefreshIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
return ( return (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}> <svg
<path {...props}
fillRule="evenodd" xmlns="http://www.w3.org/2000/svg"
clipRule="evenodd" viewBox="0 0 24 24"
d="M11.979 4.5C7.83687 4.5 4.479 7.85786 4.479 12C4.479 16.1421 7.83687 19.5 11.979 19.5C15.2434 19.5 18.0225 17.4141 19.0524 14.5001C19.1905 14.1095 19.619 13.9048 20.0095 14.0429C20.4 14.1809 20.6047 14.6094 20.4667 14.9999C19.2315 18.4945 15.8988 21 11.979 21C7.00844 21 2.979 16.9706 2.979 12C2.979 7.02944 7.00844 3 11.979 3C13.709 3 15.1419 3.42256 16.4191 4.20651C17.1663 4.6651 17.8487 5.24046 18.5 5.90708V4C18.5 3.58579 18.8358 3.25 19.25 3.25C19.6642 3.25 20 3.58579 20 4V8C20 8.41421 19.6642 8.75 19.25 8.75H15.25C14.8358 8.75 14.5 8.41421 14.5 8C14.5 7.58579 14.8358 7.25 15.25 7.25H17.7068C17.0285 6.51595 16.3546 5.92693 15.6345 5.4849C14.6015 4.85088 13.4417 4.5 11.979 4.5Z" width="24"
fill="currentColor" height="24"
/> fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
>
<path d="M17.5 2.474c.51 1.192.861 2.444 1.049 3.726a.479.479 0 0 1-.298.515l-.181.07M6.5 21.527A15 15 0 0 1 5.451 17.8a.48.48 0 0 1 .298-.515l.181-.07M14.5 7.67a15 15 0 0 0 3.57-.884m0 0a8 8 0 0 0-13.912 6.797m15.75-2.79A8 8 0 0 1 5.93 17.215m3.571-.885a15.002 15.002 0 0 0-3.57.884" />
</svg> </svg>
); );
} }

View File

@@ -1,22 +1,18 @@
import { SVGProps } from 'react'; export function TimelineIcon(props: JSX.IntrinsicElements['svg']) {
export function TimeLineIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
return ( return (
<svg <svg
{...props}
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="24" width="24"
height="24" height="24"
fill="none" fill="none"
viewBox="0 0 24 24" stroke="currentColor"
{...props} strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
> >
<path <path d="M15 21v-5a3 3 0 1 0-6 0v5M7.606 5.65l-2.6 2.456c-.74.698-1.11 1.047-1.374 1.46a4 4 0 0 0-.513 1.191C3 11.233 3 11.742 3 12.76V14.6c0 2.24 0 3.36.436 4.216a4 4 0 0 0 1.748 1.748C6.04 21 7.16 21 9.4 21h5.2c2.24 0 3.36 0 4.216-.436a4 4 0 0 0 1.748-1.748C21 17.96 21 16.84 21 14.6v-1.841c0-1.017 0-1.526-.119-2.002a4 4 0 0 0-.513-1.19c-.265-.414-.634-.763-1.374-1.461l-2.6-2.456c-1.546-1.46-2.32-2.19-3.201-2.466a4 4 0 0 0-2.386 0c-.882.275-1.655 1.006-3.201 2.466Z" />
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="1.5"
d="M22.25 15C17.215 15 15 17.215 15 22.25 15 17.215 12.785 15 7.75 15 12.785 15 15 12.785 15 7.75c0 5.035 2.215 7.25 7.25 7.25zM11.25 6.5c-3.299 0-4.75 1.451-4.75 4.75 0-3.299-1.451-4.75-4.75-4.75 3.299 0 4.75-1.451 4.75-4.75 0 3.299 1.451 4.75 4.75 4.75z"
clipRule="evenodd"
></path>
</svg> </svg>
); );
} }

View File

@@ -1,19 +1,18 @@
import { SVGProps } from 'react'; export function TrashIcon(props: JSX.IntrinsicElements['svg']) {
export function TrashIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
return ( return (
<svg <svg
width={24}
height={24}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props} {...props}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="24"
height="24"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
> >
<path <path d="m16 6-1.106-2.211a3.236 3.236 0 0 0-5.788 0L8 6M4 6h16m-10 5v5m4-5v5M6 6h12v9c0 1.864 0 2.796-.305 3.53a4 4 0 0 1-2.164 2.165C14.796 21 13.864 21 12 21s-2.796 0-3.53-.305a4 4 0 0 1-2.166-2.164C6 17.796 6 16.864 6 15V6Z" />
d="M5.75 21.25L5.00156 21.2983C5.02702 21.6929 5.35453 22 5.75 22V21.25ZM18.25 21.25V22C18.6455 22 18.973 21.6929 18.9984 21.2983L18.25 21.25ZM2.75 5C2.33579 5 2 5.33579 2 5.75C2 6.16421 2.33579 6.5 2.75 6.5V5ZM21.25 6.5C21.6642 6.5 22 6.16421 22 5.75C22 5.33579 21.6642 5 21.25 5V6.5ZM10.5 10.75C10.5 10.3358 10.1642 10 9.75 10C9.33579 10 9 10.3358 9 10.75H10.5ZM9 16.25C9 16.6642 9.33579 17 9.75 17C10.1642 17 10.5 16.6642 10.5 16.25H9ZM15 10.75C15 10.3358 14.6642 10 14.25 10C13.8358 10 13.5 10.3358 13.5 10.75H15ZM13.5 16.25C13.5 16.6642 13.8358 17 14.25 17C14.6642 17 15 16.6642 15 16.25H13.5ZM15.1477 5.93694C15.2509 6.33808 15.6598 6.57957 16.0609 6.47633C16.4621 6.37308 16.7036 5.9642 16.6003 5.56306L15.1477 5.93694ZM4.00156 5.79829L5.00156 21.2983L6.49844 21.2017L5.49844 5.70171L4.00156 5.79829ZM5.75 22H18.25V20.5H5.75V22ZM18.9984 21.2983L19.9984 5.79829L18.5016 5.70171L17.5016 21.2017L18.9984 21.2983ZM19.25 5H4.75V6.5H19.25V5ZM2.75 6.5H4.75V5H2.75V6.5ZM19.25 6.5H21.25V5H19.25V6.5ZM9 10.75V16.25H10.5V10.75H9ZM13.5 10.75V16.25H15V10.75H13.5ZM12 3.5C13.5134 3.5 14.7868 4.53504 15.1477 5.93694L16.6003 5.56306C16.0731 3.51451 14.2144 2 12 2V3.5ZM8.85237 5.93694C9.21319 4.53504 10.4867 3.5 12 3.5V2C9.78568 2 7.92697 3.51451 7.39971 5.56306L8.85237 5.93694Z"
fill="currentColor"
/>
</svg> </svg>
); );
} }

View File

@@ -1,10 +1,18 @@
import { Outlet } from 'react-router-dom'; import { type Platform } from '@tauri-apps/plugin-os';
import { Outlet, ScrollRestoration } from 'react-router-dom';
import { WindowTitleBar } from '@shared/titlebar';
export function AuthLayout() { export function AuthLayout({ platform }: { platform: Platform }) {
return ( return (
<div className="h-full w-full px-2.5 pb-2.5 pt-1"> <div className="flex h-screen w-screen flex-col bg-neutral-50 dark:bg-neutral-950">
<div className="flex h-full min-h-0 w-full rounded-lg bg-white p-3 shadow-[rgba(50,_50,_105,_0.15)_0px_2px_5px_0px,_rgba(0,_0,_0,_0.05)_0px_1px_1px_0px] dark:bg-black dark:shadow-[inset_0_0_0.5px_1px_hsla(0,0%,100%,0.075),0_0_0_1px_hsla(0,0%,0%,0.05),0_0.3px_0.4px_hsla(0,0%,0%,0.02),0_0.9px_1.5px_hsla(0,0%,0%,0.045),0_3.5px_6px_hsla(0,0%,0%,0.09)]"> {platform !== 'macos' ? (
<WindowTitleBar platform={platform} />
) : (
<div data-tauri-drag-region className="h-9 shrink-0" />
)}
<div className="h-full w-full">
<Outlet /> <Outlet />
<ScrollRestoration />
</div> </div>
</div> </div>
); );

View File

@@ -1,3 +1,4 @@
import { Platform } from '@tauri-apps/plugin-os';
import { NavLink, Outlet, useNavigate } from 'react-router-dom'; import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import { import {
@@ -8,98 +9,106 @@ import {
SettingsIcon, SettingsIcon,
UserIcon, UserIcon,
} from '@shared/icons'; } from '@shared/icons';
import { WindowTitleBar } from '@shared/titlebar';
export function SettingsLayout() { export function SettingsLayout({ platform }: { platform: Platform }) {
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
<div className="flex h-full min-h-0 w-full flex-col gap-8 overflow-y-auto"> <div className="flex h-screen w-screen flex-col bg-neutral-50 dark:bg-neutral-950">
<div className="flex h-20 w-full items-center justify-between border-b border-neutral-200 px-2 pb-2 dark:border-neutral-900"> {platform !== 'macos' ? (
<div> <WindowTitleBar platform={platform} />
<button ) : (
type="button" <div data-tauri-drag-region className="h-9 shrink-0" />
onClick={() => navigate(-1)} )}
className="inline-flex h-12 w-12 items-center justify-center rounded-xl" <div className="flex h-full min-h-0 w-full flex-col gap-8 overflow-y-auto">
> <div className="flex h-20 w-full items-center justify-between border-b border-neutral-200 px-2 pb-2 dark:border-neutral-900">
<ArrowLeftIcon className="h-5 w-5" /> <div>
</button> <button
type="button"
onClick={() => navigate(-1)}
className="inline-flex h-12 w-12 items-center justify-center rounded-xl"
>
<ArrowLeftIcon className="h-5 w-5" />
</button>
</div>
<div className="flex items-center gap-0.5">
<NavLink
to="/settings/"
end
className={({ isActive }) =>
twMerge(
'flex w-20 shrink-0 flex-col items-center justify-center rounded-lg px-2 py-2 text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-900',
isActive
? 'bg-neutral-50 text-blue-500 hover:bg-neutral-100 dark:bg-neutral-950 dark:hover:bg-neutral-900'
: ''
)
}
>
<UserIcon className="h-6 w-6" />
<p className="text-sm font-medium">User</p>
</NavLink>
<NavLink
to="/settings/general"
className={({ isActive }) =>
twMerge(
'flex w-20 shrink-0 flex-col items-center justify-center rounded-lg px-2 py-2 text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-900',
isActive
? 'bg-neutral-50 text-blue-500 hover:bg-neutral-100 dark:bg-neutral-950 dark:hover:bg-neutral-900'
: ''
)
}
>
<SettingsIcon className="h-6 w-6" />
<p className="text-sm font-medium">General</p>
</NavLink>
<NavLink
to="/settings/backup"
className={({ isActive }) =>
twMerge(
'flex w-20 shrink-0 flex-col items-center justify-center rounded-lg px-2 py-2 text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-900',
isActive
? 'bg-neutral-50 text-blue-500 hover:bg-neutral-100 dark:bg-neutral-950 dark:hover:bg-neutral-900'
: ''
)
}
>
<SecureIcon className="h-6 w-6" />
<p className="text-sm font-medium">Backup</p>
</NavLink>
<NavLink
to="/settings/advanced"
className={({ isActive }) =>
twMerge(
'flex w-20 shrink-0 flex-col items-center justify-center rounded-lg px-2 py-2 text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-900',
isActive
? 'bg-neutral-50 text-blue-500 hover:bg-neutral-100 dark:bg-neutral-950 dark:hover:bg-neutral-900'
: ''
)
}
>
<AdvancedSettingsIcon className="h-6 w-6" />
<p className="text-sm font-medium">Advanced</p>
</NavLink>
<NavLink
to="/settings/about"
className={({ isActive }) =>
twMerge(
'flex w-20 shrink-0 flex-col items-center justify-center rounded-lg px-2 py-2 text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-900',
isActive
? 'bg-neutral-50 text-blue-500 hover:bg-neutral-100 dark:bg-neutral-950 dark:hover:bg-neutral-900'
: ''
)
}
>
<InfoIcon className="h-6 w-6" />
<p className="text-sm font-medium">About</p>
</NavLink>
</div>
<div />
</div> </div>
<div className="flex items-center gap-0.5"> <Outlet />
<NavLink
to="/settings/"
end
className={({ isActive }) =>
twMerge(
'flex w-20 shrink-0 flex-col items-center justify-center rounded-lg px-2 py-2 text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-900',
isActive
? 'bg-neutral-50 text-blue-500 hover:bg-neutral-100 dark:bg-neutral-950 dark:hover:bg-neutral-900'
: ''
)
}
>
<UserIcon className="h-6 w-6" />
<p className="text-sm font-medium">User</p>
</NavLink>
<NavLink
to="/settings/general"
className={({ isActive }) =>
twMerge(
'flex w-20 shrink-0 flex-col items-center justify-center rounded-lg px-2 py-2 text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-900',
isActive
? 'bg-neutral-50 text-blue-500 hover:bg-neutral-100 dark:bg-neutral-950 dark:hover:bg-neutral-900'
: ''
)
}
>
<SettingsIcon className="h-6 w-6" />
<p className="text-sm font-medium">General</p>
</NavLink>
<NavLink
to="/settings/backup"
className={({ isActive }) =>
twMerge(
'flex w-20 shrink-0 flex-col items-center justify-center rounded-lg px-2 py-2 text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-900',
isActive
? 'bg-neutral-50 text-blue-500 hover:bg-neutral-100 dark:bg-neutral-950 dark:hover:bg-neutral-900'
: ''
)
}
>
<SecureIcon className="h-6 w-6" />
<p className="text-sm font-medium">Backup</p>
</NavLink>
<NavLink
to="/settings/advanced"
className={({ isActive }) =>
twMerge(
'flex w-20 shrink-0 flex-col items-center justify-center rounded-lg px-2 py-2 text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-900',
isActive
? 'bg-neutral-50 text-blue-500 hover:bg-neutral-100 dark:bg-neutral-950 dark:hover:bg-neutral-900'
: ''
)
}
>
<AdvancedSettingsIcon className="h-6 w-6" />
<p className="text-sm font-medium">Advanced</p>
</NavLink>
<NavLink
to="/settings/about"
className={({ isActive }) =>
twMerge(
'flex w-20 shrink-0 flex-col items-center justify-center rounded-lg px-2 py-2 text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-900',
isActive
? 'bg-neutral-50 text-blue-500 hover:bg-neutral-100 dark:bg-neutral-950 dark:hover:bg-neutral-900'
: ''
)
}
>
<InfoIcon className="h-6 w-6" />
<p className="text-sm font-medium">About</p>
</NavLink>
</div>
<div />
</div> </div>
<Outlet />
</div> </div>
); );
} }

View File

@@ -3,18 +3,18 @@ import { useInfiniteQuery } from '@tanstack/react-query';
import { FetchFilter } from 'nostr-fetch'; import { FetchFilter } from 'nostr-fetch';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { VList } from 'virtua'; import { VList } from 'virtua';
import { useArk } from '@libs/ark'; import { Widget, useArk } from '@libs/ark';
import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons'; import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
import { MemoizedArticleNote } from '@shared/notes'; import { MemoizedArticleNote } from '@shared/notes';
import { TitleBar, WidgetWrapper } from '@shared/widgets';
import { FETCH_LIMIT } from '@utils/constants'; import { FETCH_LIMIT } from '@utils/constants';
import { Widget } from '@utils/types'; import { WidgetProps } from '@utils/types';
export function ArticleWidget({ widget }: { widget: Widget }) { export function ArticleWidget({ props }: { props: WidgetProps }) {
const ark = useArk(); const ark = useArk();
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } = const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({ useInfiniteQuery({
queryKey: ['article', widget.id], queryKey: ['article', props.id],
initialPageParam: 0, initialPageParam: 0,
queryFn: async ({ queryFn: async ({
signal, signal,
@@ -24,7 +24,7 @@ export function ArticleWidget({ widget }: { widget: Widget }) {
pageParam: number; pageParam: number;
}) => { }) => {
let filter: FetchFilter; let filter: FetchFilter;
const content = JSON.parse(widget.content); const content = JSON.parse(props.content);
if (content.global) { if (content.global) {
filter = { filter = {
@@ -62,50 +62,52 @@ export function ArticleWidget({ widget }: { widget: Widget }) {
); );
return ( return (
<WidgetWrapper> <Widget.Root>
<TitleBar id={widget.id} title={widget.title} /> <Widget.Header id={props.id} title={props.title} />
<VList className="flex-1"> <Widget.Content>
{status === 'pending' ? ( <VList className="flex-1">
<div className="flex h-full w-full items-center justify-center"> {status === 'pending' ? (
<LoaderIcon className="h-5 w-5 animate-spin" /> <div className="flex h-full w-full items-center justify-center">
</div> <LoaderIcon className="h-5 w-5 animate-spin" />
) : allEvents.length === 0 ? ( </div>
<div className="flex h-full w-full flex-col items-center justify-center px-3"> ) : allEvents.length === 0 ? (
<div className="flex flex-col items-center gap-4"> <div className="flex h-full w-full flex-col items-center justify-center px-3">
<img src="/ghost.png" alt="empty feeds" className="h-16 w-16" /> <div className="flex flex-col items-center gap-4">
<div className="text-center"> <img src="/ghost.png" alt="empty feeds" className="h-16 w-16" />
<h3 className="font-semibold leading-tight text-neutral-900 dark:text-neutral-100"> <div className="text-center">
Oops, it looks like there are no articles. <h3 className="font-semibold leading-tight text-neutral-900 dark:text-neutral-100">
</h3> Oops, it looks like there are no articles.
<p className="text-neutral-500 dark:text-neutral-400"> </h3>
You can close this widget <p className="text-neutral-500 dark:text-neutral-400">
</p> You can close this widget
</p>
</div>
</div> </div>
</div> </div>
) : (
allEvents.map((item) => <MemoizedArticleNote key={item.id} event={item} />)
)}
<div className="flex h-16 items-center justify-center px-3 pb-3">
{hasNextPage ? (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
>
{isFetchingNextPage ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="h-5 w-5" />
Load more
</>
)}
</button>
) : null}
</div> </div>
) : ( </VList>
allEvents.map((item) => <MemoizedArticleNote key={item.id} event={item} />) </Widget.Content>
)} </Widget.Root>
<div className="flex h-16 items-center justify-center px-3 pb-3">
{hasNextPage ? (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
>
{isFetchingNextPage ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="h-5 w-5" />
Load more
</>
)}
</button>
) : null}
</div>
</VList>
</WidgetWrapper>
); );
} }

View File

@@ -2,18 +2,18 @@ import { useInfiniteQuery } from '@tanstack/react-query';
import { FetchFilter } from 'nostr-fetch'; import { FetchFilter } from 'nostr-fetch';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { VList } from 'virtua'; import { VList } from 'virtua';
import { useArk } from '@libs/ark'; import { Widget, useArk } from '@libs/ark';
import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons'; import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
import { MemoizedFileNote } from '@shared/notes'; import { MemoizedFileNote } from '@shared/notes';
import { TitleBar, WidgetWrapper } from '@shared/widgets';
import { FETCH_LIMIT } from '@utils/constants'; import { FETCH_LIMIT } from '@utils/constants';
import { Widget } from '@utils/types'; import { WidgetProps } from '@utils/types';
export function FileWidget({ widget }: { widget: Widget }) { export function FileWidget({ props }: { props: WidgetProps }) {
const ark = useArk(); const ark = useArk();
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } = const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({ useInfiniteQuery({
queryKey: ['media', widget.id], queryKey: ['media', props.id],
initialPageParam: 0, initialPageParam: 0,
queryFn: async ({ queryFn: async ({
signal, signal,
@@ -23,7 +23,7 @@ export function FileWidget({ widget }: { widget: Widget }) {
pageParam: number; pageParam: number;
}) => { }) => {
let filter: FetchFilter; let filter: FetchFilter;
const content = JSON.parse(widget.content); const content = JSON.parse(props.content);
if (content.global) { if (content.global) {
filter = { filter = {
@@ -61,50 +61,52 @@ export function FileWidget({ widget }: { widget: Widget }) {
); );
return ( return (
<WidgetWrapper> <Widget.Root>
<TitleBar id={widget.id} title={widget.title} /> <Widget.Header id={props.id} title={props.title} />
<VList className="flex-1"> <Widget.Content>
{status === 'pending' ? ( <VList className="flex-1">
<div className="flex h-full w-full items-center justify-center"> {status === 'pending' ? (
<LoaderIcon className="h-5 w-5 animate-spin" /> <div className="flex h-full w-full items-center justify-center">
</div> <LoaderIcon className="h-5 w-5 animate-spin" />
) : allEvents.length === 0 ? ( </div>
<div className="flex h-full w-full flex-col items-center justify-center px-3"> ) : allEvents.length === 0 ? (
<div className="flex flex-col items-center gap-4"> <div className="flex h-full w-full flex-col items-center justify-center px-3">
<img src="/ghost.png" alt="empty feeds" className="h-16 w-16" /> <div className="flex flex-col items-center gap-4">
<div className="text-center"> <img src="/ghost.png" alt="empty feeds" className="h-16 w-16" />
<h3 className="font-semibold leading-tight text-neutral-900 dark:text-neutral-100"> <div className="text-center">
Oops, it looks like there are no files. <h3 className="font-semibold leading-tight text-neutral-900 dark:text-neutral-100">
</h3> Oops, it looks like there are no files.
<p className="text-neutral-500 dark:text-neutral-400"> </h3>
You can close this widget <p className="text-neutral-500 dark:text-neutral-400">
</p> You can close this widget
</p>
</div>
</div> </div>
</div> </div>
) : (
allEvents.map((item) => <MemoizedFileNote key={item.id} event={item} />)
)}
<div className="flex h-16 items-center justify-center px-3 pb-3">
{hasNextPage ? (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
>
{isFetchingNextPage ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="h-5 w-5" />
Load more
</>
)}
</button>
) : null}
</div> </div>
) : ( </VList>
allEvents.map((item) => <MemoizedFileNote key={item.id} event={item} />) </Widget.Content>
)} </Widget.Root>
<div className="flex h-16 items-center justify-center px-3 pb-3">
{hasNextPage ? (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
>
{isFetchingNextPage ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="h-5 w-5" />
Load more
</>
)}
</button>
) : null}
</div>
</VList>
</WidgetWrapper>
); );
} }

View File

@@ -1,8 +1,8 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk'; import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { useInfiniteQuery } from '@tanstack/react-query'; import { useInfiniteQuery } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react'; import { useMemo } from 'react';
import { VList } from 'virtua'; import { VList } from 'virtua';
import { useArk } from '@libs/ark'; import { Widget, useArk } from '@libs/ark';
import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons'; import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
import { import {
MemoizedRepost, MemoizedRepost,
@@ -10,15 +10,14 @@ import {
NoteSkeleton, NoteSkeleton,
UnknownNote, UnknownNote,
} from '@shared/notes'; } from '@shared/notes';
import { TitleBar, WidgetWrapper } from '@shared/widgets';
import { FETCH_LIMIT } from '@utils/constants'; import { FETCH_LIMIT } from '@utils/constants';
import { Widget } from '@utils/types'; import { WidgetProps } from '@utils/types';
export function GroupWidget({ widget }: { widget: Widget }) { export function GroupWidget({ props }: { props: WidgetProps }) {
const ark = useArk(); const ark = useArk();
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } = const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({ useInfiniteQuery({
queryKey: ['groupfeeds', widget.id], queryKey: ['groupfeeds', props.id],
initialPageParam: 0, initialPageParam: 0,
queryFn: async ({ queryFn: async ({
signal, signal,
@@ -27,7 +26,7 @@ export function GroupWidget({ widget }: { widget: Widget }) {
signal: AbortSignal; signal: AbortSignal;
pageParam: number; pageParam: number;
}) => { }) => {
const authors = JSON.parse(widget.content); const authors = JSON.parse(props.content);
const events = await ark.getInfiniteEvents({ const events = await ark.getInfiniteEvents({
filter: { filter: {
kinds: [NDKKind.Text, NDKKind.Repost], kinds: [NDKKind.Text, NDKKind.Repost],
@@ -55,53 +54,52 @@ export function GroupWidget({ widget }: { widget: Widget }) {
[data] [data]
); );
const renderItem = useCallback( const renderItem = (event: NDKEvent) => {
(event: NDKEvent) => { switch (event.kind) {
switch (event.kind) { case NDKKind.Text:
case NDKKind.Text: return <MemoizedTextNote key={event.id} event={event} />;
return <MemoizedTextNote key={event.id} event={event} />; case NDKKind.Repost:
case NDKKind.Repost: return <MemoizedRepost key={event.id} event={event} />;
return <MemoizedRepost key={event.id} event={event} />; default:
default: return <UnknownNote key={event.id} event={event} />;
return <UnknownNote key={event.id} event={event} />; }
} };
},
[data]
);
return ( return (
<WidgetWrapper> <Widget.Root>
<TitleBar id={widget.id} title={widget.title} /> <Widget.Header id={props.id} title={props.title} />
<VList className="flex-1"> <Widget.Content>
{status === 'pending' ? ( <VList className="flex-1">
<div className="px-3 py-1.5"> {status === 'pending' ? (
<div className="rounded-xl bg-neutral-100 px-3 py-3 dark:bg-neutral-900"> <div className="px-3 py-1.5">
<NoteSkeleton /> <div className="rounded-xl bg-neutral-100 px-3 py-3 dark:bg-neutral-900">
<NoteSkeleton />
</div>
</div> </div>
) : (
allEvents.map((item) => renderItem(item))
)}
<div className="flex h-16 items-center justify-center px-3 pb-3">
{hasNextPage ? (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
>
{isFetchingNextPage ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="h-5 w-5" />
Load more
</>
)}
</button>
) : null}
</div> </div>
) : ( </VList>
allEvents.map((item) => renderItem(item)) </Widget.Content>
)} </Widget.Root>
<div className="flex h-16 items-center justify-center px-3 pb-3">
{hasNextPage ? (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
>
{isFetchingNextPage ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="h-5 w-5" />
Load more
</>
)}
</button>
) : null}
</div>
</VList>
</WidgetWrapper>
); );
} }

View File

@@ -1,19 +1,18 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk'; import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { useInfiniteQuery } from '@tanstack/react-query'; import { useInfiniteQuery } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react'; import { useMemo } from 'react';
import { VList } from 'virtua'; import { VList } from 'virtua';
import { useArk } from '@libs/ark'; import { Widget, useArk } from '@libs/ark';
import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons'; import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
import { MemoizedRepost, MemoizedTextNote, UnknownNote } from '@shared/notes'; import { MemoizedRepost, MemoizedTextNote, UnknownNote } from '@shared/notes';
import { TitleBar, WidgetWrapper } from '@shared/widgets';
import { FETCH_LIMIT } from '@utils/constants'; import { FETCH_LIMIT } from '@utils/constants';
import { Widget } from '@utils/types'; import { WidgetProps } from '@utils/types';
export function HashtagWidget({ widget }: { widget: Widget }) { export function HashtagWidget({ props }: { props: WidgetProps }) {
const ark = useArk(); const ark = useArk();
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } = const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({ useInfiniteQuery({
queryKey: ['hashtag', widget.id], queryKey: ['hashtag', props.id],
initialPageParam: 0, initialPageParam: 0,
queryFn: async ({ queryFn: async ({
signal, signal,
@@ -25,7 +24,7 @@ export function HashtagWidget({ widget }: { widget: Widget }) {
const events = await ark.getInfiniteEvents({ const events = await ark.getInfiniteEvents({
filter: { filter: {
kinds: [NDKKind.Text, NDKKind.Repost], kinds: [NDKKind.Text, NDKKind.Repost],
'#t': [widget.content], '#t': [props.content],
}, },
limit: FETCH_LIMIT, limit: FETCH_LIMIT,
pageParam, pageParam,
@@ -50,65 +49,64 @@ export function HashtagWidget({ widget }: { widget: Widget }) {
); );
// render event match event kind // render event match event kind
const renderItem = useCallback( const renderItem = (event: NDKEvent) => {
(event: NDKEvent) => { switch (event.kind) {
switch (event.kind) { case NDKKind.Text:
case NDKKind.Text: return <MemoizedTextNote key={event.id} event={event} />;
return <MemoizedTextNote key={event.id} event={event} />; case NDKKind.Repost:
case NDKKind.Repost: return <MemoizedRepost key={event.id} event={event} />;
return <MemoizedRepost key={event.id} event={event} />; default:
default: return <UnknownNote key={event.id} event={event} />;
return <UnknownNote key={event.id} event={event} />; }
} };
},
[data]
);
return ( return (
<WidgetWrapper> <Widget.Root>
<TitleBar id={widget.id} title={widget.title} /> <Widget.Header id={props.id} title={props.title} />
<VList className="flex-1"> <Widget.Content>
{status === 'pending' ? ( <VList className="flex-1">
<div className="flex h-full w-full items-center justify-center"> {status === 'pending' ? (
<LoaderIcon className="h-5 w-5 animate-spin" /> <div className="flex h-full w-full items-center justify-center">
</div> <LoaderIcon className="h-5 w-5 animate-spin" />
) : allEvents.length === 0 ? ( </div>
<div className="flex h-full w-full flex-col items-center justify-center px-3"> ) : allEvents.length === 0 ? (
<div className="flex flex-col items-center gap-4"> <div className="flex h-full w-full flex-col items-center justify-center px-3">
<img src="/ghost.png" alt="empty feeds" className="h-16 w-16" /> <div className="flex flex-col items-center gap-4">
<div className="text-center"> <img src="/ghost.png" alt="empty feeds" className="h-16 w-16" />
<h3 className="font-semibold leading-tight text-neutral-900 dark:text-neutral-100"> <div className="text-center">
Oops, it looks like there are no events related to {widget.content}. <h3 className="font-semibold leading-tight text-neutral-900 dark:text-neutral-100">
</h3> Oops, it looks like there are no events related to {props.content}.
<p className="text-neutral-500 dark:text-neutral-400"> </h3>
You can close this widget <p className="text-neutral-500 dark:text-neutral-400">
</p> You can close this widget
</p>
</div>
</div> </div>
</div> </div>
) : (
allEvents.map((item) => renderItem(item))
)}
<div className="flex h-16 items-center justify-center px-3 pb-3">
{hasNextPage ? (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
>
{isFetchingNextPage ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="h-5 w-5" />
Load more
</>
)}
</button>
) : null}
</div> </div>
) : ( </VList>
allEvents.map((item) => renderItem(item)) </Widget.Content>
)} </Widget.Root>
<div className="flex h-16 items-center justify-center px-3 pb-3">
{hasNextPage ? (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
>
{isFetchingNextPage ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="h-5 w-5" />
Load more
</>
)}
</button>
) : null}
</div>
</VList>
</WidgetWrapper>
); );
} }

View File

@@ -7,10 +7,8 @@ export * from './file';
export * from './hashtag'; export * from './hashtag';
export * from './thread'; export * from './thread';
export * from './group'; export * from './group';
export * from './titleBar';
export * from './nostrBand/trendingAccounts'; export * from './nostrBand/trendingAccounts';
export * from './nostrBand/trendingNotes'; export * from './nostrBand/trendingNotes';
export * from './other/wrapper';
export * from './other/liveUpdater'; export * from './other/liveUpdater';
export * from './other/toggleWidgetList'; export * from './other/toggleWidgetList';
export * from './other/widgetList'; export * from './other/widgetList';

View File

@@ -2,15 +2,14 @@ import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { useInfiniteQuery } from '@tanstack/react-query'; import { useInfiniteQuery } from '@tanstack/react-query';
import { useMemo, useRef } from 'react'; import { useMemo, useRef } from 'react';
import { VList, VListHandle } from 'virtua'; import { VList, VListHandle } from 'virtua';
import { useArk } from '@libs/ark'; import { Widget, useArk } from '@libs/ark';
import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons'; import { ArrowRightCircleIcon, LoaderIcon, TimelineIcon } from '@shared/icons';
import { import {
MemoizedRepost, MemoizedRepost,
MemoizedTextNote, MemoizedTextNote,
NoteSkeleton, NoteSkeleton,
UnknownNote, UnknownNote,
} from '@shared/notes'; } from '@shared/notes';
import { LiveUpdater, TitleBar, WidgetWrapper } from '@shared/widgets';
import { FETCH_LIMIT } from '@utils/constants'; import { FETCH_LIMIT } from '@utils/constants';
export function NewsfeedWidget() { export function NewsfeedWidget() {
@@ -67,39 +66,44 @@ export function NewsfeedWidget() {
}; };
return ( return (
<WidgetWrapper> <Widget.Root>
<TitleBar id="9999" isLive /> <Widget.Header
<LiveUpdater status={status} /> id="9999"
<VList ref={ref} overscan={2} className="flex-1"> title="Timeline"
{status === 'pending' ? ( icon={<TimelineIcon className="h-5 w-5" />}
<div className="px-3 py-1.5"> />
<div className="rounded-xl bg-neutral-100 px-3 py-3 dark:bg-neutral-900"> <Widget.Content>
<NoteSkeleton /> <VList ref={ref} overscan={2} className="flex-1">
{status === 'pending' ? (
<div className="px-3 py-1.5">
<div className="rounded-xl bg-neutral-100 px-3 py-3 dark:bg-neutral-900">
<NoteSkeleton />
</div>
</div> </div>
) : (
allEvents.map((item) => renderItem(item))
)}
<div className="flex h-16 items-center justify-center px-3 py-3">
{hasNextPage ? (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
>
{isFetchingNextPage ? (
<LoaderIcon className="h-5 w-5 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="h-5 w-5" />
Load more
</>
)}
</button>
) : null}
</div> </div>
) : ( </VList>
allEvents.map((item) => renderItem(item)) </Widget.Content>
)} </Widget.Root>
<div className="flex h-16 items-center justify-center px-3 pb-3">
{hasNextPage ? (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
>
{isFetchingNextPage ? (
<LoaderIcon className="h-5 w-5 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="h-5 w-5" />
Load more
</>
)}
</button>
) : null}
</div>
</VList>
</WidgetWrapper>
); );
} }

View File

@@ -1,19 +1,15 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { VList } from 'virtua'; import { VList } from 'virtua';
import { Widget } from '@libs/ark';
import { LoaderIcon } from '@shared/icons'; import { LoaderIcon } from '@shared/icons';
import { import { NostrBandUserProfile, type Profile } from '@shared/widgets';
NostrBandUserProfile, import { WidgetProps } from '@utils/types';
type Profile,
TitleBar,
WidgetWrapper,
} from '@shared/widgets';
import { Widget } from '@utils/types';
interface Response { interface Response {
profiles: Array<{ pubkey: string }>; profiles: Array<{ pubkey: string }>;
} }
export function TrendingAccountsWidget({ widget }: { widget: Widget }) { export function TrendingAccountsWidget({ props }: { props: WidgetProps }) {
const { status, data } = useQuery({ const { status, data } = useQuery({
queryKey: ['trending-users'], queryKey: ['trending-users'],
queryFn: async () => { queryFn: async () => {
@@ -32,38 +28,40 @@ export function TrendingAccountsWidget({ widget }: { widget: Widget }) {
}); });
return ( return (
<WidgetWrapper> <Widget.Root>
<TitleBar id={widget.id} title="Trending Accounts" /> <Widget.Header id={props.id} title="Trending Accounts" />
<div className="flex-1"> <Widget.Content>
{status === 'pending' ? ( <div className="flex-1">
<div className="flex h-full w-full items-center justify-center "> {status === 'pending' ? (
<div className="inline-flex flex-col items-center justify-center gap-2"> <div className="flex h-full w-full items-center justify-center ">
<LoaderIcon className="h-5 w-5 animate-spin text-black dark:text-white" /> <div className="inline-flex flex-col items-center justify-center gap-2">
<p className="text-sm font-medium text-neutral-500 dark:text-neutral-300"> <LoaderIcon className="h-5 w-5 animate-spin text-black dark:text-white" />
Loading trending accounts... <p className="text-sm font-medium text-neutral-500 dark:text-neutral-300">
</p> Loading trending accounts...
</div> </p>
</div>
) : status === 'error' ? (
<div className="flex h-full w-full flex-col items-center justify-center px-3">
<div className="flex flex-col items-center gap-4">
<img src="/ghost.png" alt="empty feeds" className="h-16 w-16" />
<div className="text-center">
<h3 className="font-semibold leading-tight text-neutral-500 dark:text-neutral-300">
Sorry, an unexpected error has occurred.
</h3>
</div> </div>
</div> </div>
</div> ) : status === 'error' ? (
) : ( <div className="flex h-full w-full flex-col items-center justify-center px-3">
<VList className="h-full"> <div className="flex flex-col items-center gap-4">
{data.map((item: Profile) => ( <img src="/ghost.png" alt="empty feeds" className="h-16 w-16" />
<NostrBandUserProfile key={item.pubkey} data={item} /> <div className="text-center">
))} <h3 className="font-semibold leading-tight text-neutral-500 dark:text-neutral-300">
<div className="h-16" /> Sorry, an unexpected error has occurred.
</VList> </h3>
)} </div>
</div> </div>
</WidgetWrapper> </div>
) : (
<VList className="h-full">
{data.map((item: Profile) => (
<NostrBandUserProfile key={item.pubkey} data={item} />
))}
<div className="h-16" />
</VList>
)}
</div>
</Widget.Content>
</Widget.Root>
); );
} }

View File

@@ -1,16 +1,16 @@
import { NDKEvent } from '@nostr-dev-kit/ndk'; import { NDKEvent } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { VList } from 'virtua'; import { VList } from 'virtua';
import { Widget } from '@libs/ark';
import { LoaderIcon } from '@shared/icons'; import { LoaderIcon } from '@shared/icons';
import { MemoizedTextNote } from '@shared/notes'; import { MemoizedTextNote } from '@shared/notes';
import { TitleBar, WidgetWrapper } from '@shared/widgets'; import { WidgetProps } from '@utils/types';
import { Widget } from '@utils/types';
interface Response { interface Response {
notes: Array<{ event: NDKEvent }>; notes: Array<{ event: NDKEvent }>;
} }
export function TrendingNotesWidget({ widget }: { widget: Widget }) { export function TrendingNotesWidget({ props }: { props: WidgetProps }) {
const { status, data } = useQuery({ const { status, data } = useQuery({
queryKey: ['trending-posts'], queryKey: ['trending-posts'],
queryFn: async () => { queryFn: async () => {
@@ -29,33 +29,37 @@ export function TrendingNotesWidget({ widget }: { widget: Widget }) {
}); });
return ( return (
<WidgetWrapper> <Widget.Root>
<TitleBar id={widget.id} title="Trending Notes" /> <Widget.Header id={props.id} title="Trending Notes" />
<VList className="flex-1"> <Widget.Content>
{status === 'pending' ? ( <VList className="flex-1">
<div className="flex h-full w-full items-center justify-center "> {status === 'pending' ? (
<div className="inline-flex flex-col items-center justify-center gap-2"> <div className="flex h-full w-full items-center justify-center ">
<LoaderIcon className="h-5 w-5 animate-spin text-black dark:text-white" /> <div className="inline-flex flex-col items-center justify-center gap-2">
<p className="text-sm font-medium text-neutral-500 dark:text-neutral-300"> <LoaderIcon className="h-5 w-5 animate-spin text-black dark:text-white" />
Loading trending posts... <p className="text-sm font-medium text-neutral-500 dark:text-neutral-300">
</p> Loading trending posts...
</div> </p>
</div>
) : status === 'error' ? (
<div className="flex h-full w-full flex-col items-center justify-center px-3">
<div className="flex flex-col items-center gap-4">
<img src="/ghost.png" alt="empty feeds" className="h-16 w-16" />
<div className="text-center">
<h3 className="font-semibold leading-tight text-neutral-500 dark:text-neutral-300">
Sorry, an unexpected error has occurred.
</h3>
</div> </div>
</div> </div>
</div> ) : status === 'error' ? (
) : ( <div className="flex h-full w-full flex-col items-center justify-center px-3">
data.map((item) => <MemoizedTextNote key={item.event.id} event={item.event} />) <div className="flex flex-col items-center gap-4">
)} <img src="/ghost.png" alt="empty feeds" className="h-16 w-16" />
</VList> <div className="text-center">
</WidgetWrapper> <h3 className="font-semibold leading-tight text-neutral-500 dark:text-neutral-300">
Sorry, an unexpected error has occurred.
</h3>
</div>
</div>
</div>
) : (
data.map((item) => (
<MemoizedTextNote key={item.event.id} event={item.event} />
))
)}
</VList>
</Widget.Content>
</Widget.Root>
); );
} }

View File

@@ -1,18 +1,17 @@
import { NDKEvent, NDKKind, NDKSubscription } from '@nostr-dev-kit/ndk'; import { NDKEvent, NDKKind, NDKSubscription } from '@nostr-dev-kit/ndk';
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'; import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
import { useCallback, useEffect, useMemo } from 'react'; import { useEffect, useMemo } from 'react';
import { VList } from 'virtua'; import { VList } from 'virtua';
import { useArk } from '@libs/ark'; import { Widget, useArk } from '@libs/ark';
import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons'; import { AnnouncementIcon, ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
import { MemoizedNotifyNote, NoteSkeleton } from '@shared/notes'; import { MemoizedNotifyNote, NoteSkeleton } from '@shared/notes';
import { TitleBar, WidgetWrapper } from '@shared/widgets';
import { FETCH_LIMIT } from '@utils/constants'; import { FETCH_LIMIT } from '@utils/constants';
import { sendNativeNotification } from '@utils/notification'; import { sendNativeNotification } from '@utils/notification';
export function NotificationWidget() { export function NotificationWidget() {
const ark = useArk();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const ark = useArk();
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } = const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({ useInfiniteQuery({
queryKey: ['notification'], queryKey: ['notification'],
@@ -52,10 +51,10 @@ export function NotificationWidget() {
[data] [data]
); );
const renderEvent = useCallback((event: NDKEvent) => { const renderEvent = (event: NDKEvent) => {
if (event.pubkey === ark.account.pubkey) return null; if (event.pubkey === ark.account.pubkey) return null;
return <MemoizedNotifyNote key={event.id} event={event} />; return <MemoizedNotifyNote key={event.id} event={event} />;
}, []); };
useEffect(() => { useEffect(() => {
let sub: NDKSubscription = undefined; let sub: NDKSubscription = undefined;
@@ -124,45 +123,51 @@ export function NotificationWidget() {
}, [status]); }, [status]);
return ( return (
<WidgetWrapper> <Widget.Root>
<TitleBar id="9998" title="Notification" isLive /> <Widget.Header
<VList className="flex-1" overscan={2}> id="9998"
{status === 'pending' ? ( title="Notification"
<div className="px-3 py-1.5"> icon={<AnnouncementIcon className="h-5 w-5" />}
<div className="rounded-xl bg-neutral-100 px-3 py-3 dark:bg-neutral-900"> />
<NoteSkeleton /> <Widget.Content>
<VList className="flex-1" overscan={2}>
{status === 'pending' ? (
<div className="px-3 py-1.5">
<div className="rounded-xl bg-neutral-100 px-3 py-3 dark:bg-neutral-900">
<NoteSkeleton />
</div>
</div> </div>
) : allEvents.length < 1 ? (
<div className="flex h-[400px] w-full flex-col items-center justify-center">
<p className="mb-2 text-4xl">🎉</p>
<p className="text-center font-medium text-neutral-900 dark:text-neutral-100">
Hmm! Nothing new yet.
</p>
</div>
) : (
allEvents.map((event) => renderEvent(event))
)}
<div className="flex h-16 items-center justify-center px-3 pb-3">
{hasNextPage ? (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
>
{isFetchingNextPage ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="h-5 w-5" />
Load more
</>
)}
</button>
) : null}
</div> </div>
) : allEvents.length < 1 ? ( </VList>
<div className="flex h-[400px] w-full flex-col items-center justify-center"> </Widget.Content>
<p className="mb-2 text-4xl">🎉</p> </Widget.Root>
<p className="text-center font-medium text-neutral-900 dark:text-neutral-100">
Hmm! Nothing new yet.
</p>
</div>
) : (
allEvents.map((event) => renderEvent(event))
)}
<div className="flex h-16 items-center justify-center px-3 pb-3">
{hasNextPage ? (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
>
{isFetchingNextPage ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="h-5 w-5" />
Load more
</>
)}
</button>
) : null}
</div>
</VList>
</WidgetWrapper>
); );
} }

View File

@@ -1,5 +1,5 @@
import { Widget } from '@libs/ark';
import { PlusIcon } from '@shared/icons'; import { PlusIcon } from '@shared/icons';
import { WidgetWrapper } from '@shared/widgets';
import { WIDGET_KIND } from '@utils/constants'; import { WIDGET_KIND } from '@utils/constants';
import { useWidget } from '@utils/hooks/useWidget'; import { useWidget } from '@utils/hooks/useWidget';
@@ -7,18 +7,20 @@ export function ToggleWidgetList() {
const { addWidget } = useWidget(); const { addWidget } = useWidget();
return ( return (
<WidgetWrapper> <Widget.Root>
<div className="relative flex h-full w-full flex-col items-center justify-center"> <Widget.Content>
<button <div className="relative flex h-full w-full flex-col items-center justify-center">
type="button" <button
onClick={() => type="button"
addWidget.mutate({ kind: WIDGET_KIND.list, title: '', content: '' }) onClick={() =>
} addWidget.mutate({ kind: WIDGET_KIND.list, title: '', content: '' })
className="inline-flex h-14 w-14 items-center justify-center rounded-full bg-neutral-100 text-neutral-900 hover:bg-neutral-200 dark:bg-neutral-900 dark:text-neutral-100 dark:hover:bg-neutral-800" }
> className="inline-flex h-14 w-14 items-center justify-center rounded-full bg-neutral-100 text-neutral-900 hover:bg-neutral-200 dark:bg-neutral-900 dark:text-neutral-100 dark:hover:bg-neutral-800"
<PlusIcon className="h-5 w-5" /> >
</button> <PlusIcon className="h-5 w-5" />
</div> </button>
</WidgetWrapper> </div>
</Widget.Content>
</Widget.Root>
); );
} }

View File

@@ -1,121 +1,124 @@
import { Widget } from '@libs/ark';
import { ArticleIcon, MediaIcon, PlusIcon } from '@shared/icons'; import { ArticleIcon, MediaIcon, PlusIcon } from '@shared/icons';
import { AddGroupFeeds, AddHashtagFeeds, TitleBar, WidgetWrapper } from '@shared/widgets'; import { AddGroupFeeds, AddHashtagFeeds } from '@shared/widgets';
import { TOPICS, WIDGET_KIND } from '@utils/constants'; import { TOPICS, WIDGET_KIND } from '@utils/constants';
import { useWidget } from '@utils/hooks/useWidget'; import { useWidget } from '@utils/hooks/useWidget';
import { Widget } from '@utils/types'; import { WidgetProps } from '@utils/types';
export function WidgetList({ widget }: { widget: Widget }) { export function WidgetList({ props }: { props: WidgetProps }) {
const { replaceWidget } = useWidget(); const { replaceWidget } = useWidget();
return ( return (
<WidgetWrapper> <Widget.Root>
<TitleBar id={widget.id} title="Add widgets" /> <Widget.Header id={props.id} title="Add widgets" />
<div className="flex-1 overflow-y-auto pb-10 scrollbar-none"> <Widget.Content>
<div className="flex flex-col gap-6 px-3"> <div className="flex-1 overflow-y-auto pb-10 scrollbar-none">
<div className="rounded-xl bg-neutral-100 p-3 dark:bg-neutral-900"> <div className="flex flex-col gap-6 px-3">
<h3 className="mb-2.5 text-sm font-semibold uppercase text-neutral-700 dark:text-neutral-300"> <div className="rounded-xl bg-neutral-100 p-3 dark:bg-neutral-900">
Topics <h3 className="mb-2.5 text-sm font-semibold uppercase text-neutral-700 dark:text-neutral-300">
</h3> Topics
<div className="flex flex-col gap-3"> </h3>
{TOPICS.sort((a, b) => a.title.localeCompare(b.title)).map( <div className="flex flex-col gap-3">
(topic, index) => ( {TOPICS.sort((a, b) => a.title.localeCompare(b.title)).map(
<div (topic, index) => (
key={index} <div
className="inline-flex h-14 w-full items-center justify-between rounded-lg bg-neutral-50 px-3 ring-1 ring-transparent hover:ring-neutral-200 dark:bg-neutral-950 dark:hover:ring-neutral-800" key={index}
> className="inline-flex h-14 w-full items-center justify-between rounded-lg bg-neutral-50 px-3 ring-1 ring-transparent hover:ring-neutral-200 dark:bg-neutral-950 dark:hover:ring-neutral-800"
<div className="inline-flex items-center gap-2.5">
<div className="h-9 w-9 shrink-0 rounded-md">
<img
src={`/${topic.title.toLowerCase()}.jpg`}
alt={topic.title}
className="h-9 w-9 rounded-md"
/>
</div>
<p className="font-medium">{topic.title}</p>
</div>
<button
type="button"
onClick={() =>
replaceWidget.mutate({
currentId: widget.id,
widget: {
kind: WIDGET_KIND.topic,
title: topic.title,
content: JSON.stringify(topic.content),
},
})
}
className="inline-flex h-6 items-center gap-1 rounded-md bg-neutral-100 pl-1.5 pr-2.5 text-sm font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-900"
> >
<PlusIcon className="h-3 w-3" /> <div className="inline-flex items-center gap-2.5">
Add <div className="h-9 w-9 shrink-0 rounded-md">
</button> <img
</div> src={`/${topic.title.toLowerCase()}.jpg`}
) alt={topic.title}
)} className="h-9 w-9 rounded-md"
</div> />
</div> </div>
<div className="rounded-xl bg-neutral-100 p-3 dark:bg-neutral-900"> <p className="font-medium">{topic.title}</p>
<h3 className="mb-2.5 text-sm font-semibold uppercase text-neutral-700 dark:text-neutral-300"> </div>
Newsfeed <button
</h3> type="button"
<div className="flex flex-col gap-3"> onClick={() =>
<AddGroupFeeds currentWidgetId={widget.id} /> replaceWidget.mutate({
<AddHashtagFeeds currentWidgetId={widget.id} /> currentId: props.id,
<div className="inline-flex h-14 w-full items-center justify-between rounded-lg bg-neutral-50 px-3 hover:shadow-md hover:shadow-neutral-200/50 dark:bg-neutral-950 dark:hover:shadow-neutral-800/50"> widget: {
<div className="inline-flex items-center gap-2.5"> kind: WIDGET_KIND.topic,
<div className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-neutral-100 dark:bg-neutral-900"> title: topic.title,
<ArticleIcon className="h-4 w-4" /> content: JSON.stringify(topic.content),
</div> },
<p className="font-medium">Articles</p> })
</div> }
<button className="inline-flex h-6 items-center gap-1 rounded-md bg-neutral-100 pl-1.5 pr-2.5 text-sm font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-900"
type="button" >
onClick={() => <PlusIcon className="h-3 w-3" />
replaceWidget.mutate({ Add
currentId: widget.id, </button>
widget: { </div>
kind: WIDGET_KIND.article, )
title: 'Articles', )}
content: JSON.stringify({ global: true }),
},
})
}
className="inline-flex h-6 items-center gap-1 rounded-md bg-neutral-100 pl-1.5 pr-2.5 text-sm font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-900"
>
<PlusIcon className="h-3 w-3" />
Add
</button>
</div> </div>
<div className="inline-flex h-14 w-full items-center justify-between rounded-lg bg-neutral-50 px-3 hover:shadow-md hover:shadow-neutral-200/50 dark:bg-neutral-950 dark:hover:shadow-neutral-800/50"> </div>
<div className="inline-flex items-center gap-2.5"> <div className="rounded-xl bg-neutral-100 p-3 dark:bg-neutral-900">
<div className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-neutral-100 dark:bg-neutral-900"> <h3 className="mb-2.5 text-sm font-semibold uppercase text-neutral-700 dark:text-neutral-300">
<MediaIcon className="h-4 w-4" /> Newsfeed
</h3>
<div className="flex flex-col gap-3">
<AddGroupFeeds currentWidgetId={props.id} />
<AddHashtagFeeds currentWidgetId={props.id} />
<div className="inline-flex h-14 w-full items-center justify-between rounded-lg bg-neutral-50 px-3 hover:shadow-md hover:shadow-neutral-200/50 dark:bg-neutral-950 dark:hover:shadow-neutral-800/50">
<div className="inline-flex items-center gap-2.5">
<div className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-neutral-100 dark:bg-neutral-900">
<ArticleIcon className="h-4 w-4" />
</div>
<p className="font-medium">Articles</p>
</div> </div>
<p className="font-medium">Media</p> <button
type="button"
onClick={() =>
replaceWidget.mutate({
currentId: props.id,
widget: {
kind: WIDGET_KIND.article,
title: 'Articles',
content: JSON.stringify({ global: true }),
},
})
}
className="inline-flex h-6 items-center gap-1 rounded-md bg-neutral-100 pl-1.5 pr-2.5 text-sm font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-900"
>
<PlusIcon className="h-3 w-3" />
Add
</button>
</div>
<div className="inline-flex h-14 w-full items-center justify-between rounded-lg bg-neutral-50 px-3 hover:shadow-md hover:shadow-neutral-200/50 dark:bg-neutral-950 dark:hover:shadow-neutral-800/50">
<div className="inline-flex items-center gap-2.5">
<div className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-neutral-100 dark:bg-neutral-900">
<MediaIcon className="h-4 w-4" />
</div>
<p className="font-medium">Media</p>
</div>
<button
type="button"
onClick={() =>
replaceWidget.mutate({
currentId: props.id,
widget: {
kind: WIDGET_KIND.file,
title: 'Media',
content: JSON.stringify({ global: true }),
},
})
}
className="inline-flex h-6 items-center gap-1 rounded-md bg-neutral-100 pl-1.5 pr-2.5 text-sm font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-900"
>
<PlusIcon className="h-3 w-3" />
Add
</button>
</div> </div>
<button
type="button"
onClick={() =>
replaceWidget.mutate({
currentId: widget.id,
widget: {
kind: WIDGET_KIND.file,
title: 'Media',
content: JSON.stringify({ global: true }),
},
})
}
className="inline-flex h-6 items-center gap-1 rounded-md bg-neutral-100 pl-1.5 pr-2.5 text-sm font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-900"
>
<PlusIcon className="h-3 w-3" />
Add
</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </Widget.Content>
</WidgetWrapper> </Widget.Root>
); );
} }

View File

@@ -1,7 +1,6 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk'; import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { useCallback } from 'react';
import { WVList } from 'virtua'; import { WVList } from 'virtua';
import { useArk } from '@libs/ark'; import { Widget, useArk } from '@libs/ark';
import { LoaderIcon } from '@shared/icons'; import { LoaderIcon } from '@shared/icons';
import { import {
ChildNote, ChildNote,
@@ -13,71 +12,69 @@ import {
} from '@shared/notes'; } from '@shared/notes';
import { ReplyList } from '@shared/notes/replies/list'; import { ReplyList } from '@shared/notes/replies/list';
import { User } from '@shared/user'; import { User } from '@shared/user';
import { TitleBar, WidgetWrapper } from '@shared/widgets';
import { useEvent } from '@utils/hooks/useEvent'; import { useEvent } from '@utils/hooks/useEvent';
import { Widget } from '@utils/types'; import { type WidgetProps } from '@utils/types';
export function ThreadWidget({ widget }: { widget: Widget }) { export function ThreadWidget({ props }: { props: WidgetProps }) {
const { isFetching, isError, data } = useEvent(widget.content);
const ark = useArk(); const ark = useArk();
const { isFetching, isError, data } = useEvent(props.content);
const renderKind = useCallback( const renderKind = (event: NDKEvent) => {
(event: NDKEvent) => { const thread = ark.getEventThread({ tags: event.tags });
const thread = ark.getEventThread({ tags: event.tags }); switch (event.kind) {
switch (event.kind) { case NDKKind.Text:
case NDKKind.Text: return (
return ( <>
<> {thread ? (
{thread ? ( <div className="mb-2 w-full px-3">
<div className="mb-2 w-full px-3"> <div className="flex h-min w-full flex-col gap-3 rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
<div className="flex h-min w-full flex-col gap-3 rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900"> {thread.rootEventId ? (
{thread.rootEventId ? ( <ChildNote id={thread.rootEventId} isRoot />
<ChildNote id={thread.rootEventId} isRoot /> ) : null}
) : null} {thread.replyEventId ? <ChildNote id={thread.replyEventId} /> : null}
{thread.replyEventId ? <ChildNote id={thread.replyEventId} /> : null}
</div>
</div> </div>
) : null} </div>
<MemoizedTextKind content={event.content} /> ) : null}
</> <MemoizedTextKind content={event.content} />
); </>
case NDKKind.Article: );
return <MemoizedArticleKind id={event.id} tags={event.tags} />; case NDKKind.Article:
case 1063: return <MemoizedArticleKind id={event.id} tags={event.tags} />;
return <MemoizedFileKind tags={event.tags} />; case 1063:
default: return <MemoizedFileKind tags={event.tags} />;
return null; default:
} return null;
}, }
[data] };
);
return ( return (
<WidgetWrapper> <Widget.Root>
<TitleBar id={widget.id} title={widget.title} /> <Widget.Header id={props.id} title={props.title} />
<WVList className="flex-1 overflow-y-auto px-3 pb-5"> <Widget.Content>
{isFetching ? ( <WVList className="flex-1 overflow-y-auto px-3 pb-5">
<div className="flex h-16 items-center justify-center rounded-xl bg-neutral-50 px-3 py-3 dark:bg-neutral-950"> {isFetching ? (
<LoaderIcon className="h-5 w-5 animate-spin" /> <div className="flex h-16 items-center justify-center rounded-xl bg-neutral-50 px-3 py-3 dark:bg-neutral-950">
</div> <LoaderIcon className="h-5 w-5 animate-spin" />
) : (
<>
<div className="flex flex-col rounded-xl bg-neutral-50 dark:bg-neutral-950">
{isError ? (
<div>Failed to fetch event</div>
) : (
<>
<User pubkey={data.pubkey} time={data.created_at} variant="thread" />
{renderKind(data)}
<NoteActions event={data} />
</>
)}
</div> </div>
<NoteReplyForm rootEvent={data} /> ) : (
<ReplyList eventId={data.id} /> <>
</> <div className="flex flex-col rounded-xl bg-neutral-50 dark:bg-neutral-950">
)} {isError ? (
</WVList> <div>Failed to fetch event</div>
</WidgetWrapper> ) : (
<>
<User pubkey={data.pubkey} time={data.created_at} variant="thread" />
{renderKind(data)}
<NoteActions event={data} />
</>
)}
</div>
<NoteReplyForm rootEvent={data} />
<ReplyList eventId={data.id} />
</>
)}
</WVList>
</Widget.Content>
</Widget.Root>
); );
} }

View File

@@ -1,64 +0,0 @@
import { useArk } from '@libs/ark';
import { CancelIcon } from '@shared/icons';
import { User } from '@shared/user';
import { useWidget } from '@utils/hooks/useWidget';
export function TitleBar({
id,
title,
isLive,
}: {
id?: string;
title?: string;
isLive?: boolean;
}) {
const ark = useArk();
const { removeWidget } = useWidget();
return (
<div className="grid h-11 w-full shrink-0 grid-cols-3 items-center px-3">
<div className="col-span-1 flex justify-start">
{isLive ? (
<div className="flex items-center gap-1.5">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-teal-400 opacity-75"></span>
<span className="relative inline-flex h-2 w-2 rounded-full bg-teal-500"></span>
</span>
<p className="text-xs font-medium text-teal-500">Live</p>
</div>
) : null}
</div>
<div className="col-span-1 flex justify-center">
{id === '9999' ? (
<div className="isolate flex -space-x-2">
{ark.account.contacts
?.slice(0, 8)
.map((item) => <User key={item} pubkey={item} variant="ministacked" />)}
{ark.account.contacts?.length > 8 ? (
<div className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-neutral-300 text-neutral-900 ring-1 ring-white dark:bg-neutral-700 dark:text-neutral-100 dark:ring-black">
<span className="text-[8px] font-medium">
+{ark.account.contacts?.length - 8}
</span>
</div>
) : null}
</div>
) : (
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">
{title}
</h3>
)}
</div>
<div className="col-span-1 flex justify-end">
{id !== '9999' && id !== '9998' ? (
<button
type="button"
onClick={() => removeWidget.mutate(id)}
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded text-neutral-900 backdrop-blur-xl hover:bg-neutral-100 dark:text-neutral-100 dark:hover:bg-neutral-900"
>
<CancelIcon className="h-3 w-3" />
</button>
) : null}
</div>
</div>
);
}

View File

@@ -1,8 +1,8 @@
import { NDKEvent, NDKFilter, NDKKind } from '@nostr-dev-kit/ndk'; import { NDKEvent, NDKFilter, NDKKind } from '@nostr-dev-kit/ndk';
import { useInfiniteQuery } from '@tanstack/react-query'; import { useInfiniteQuery } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react'; import { useMemo } from 'react';
import { VList } from 'virtua'; import { VList } from 'virtua';
import { useArk } from '@libs/ark'; import { Widget, useArk } from '@libs/ark';
import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons'; import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
import { import {
MemoizedRepost, MemoizedRepost,
@@ -10,15 +10,14 @@ import {
NoteSkeleton, NoteSkeleton,
UnknownNote, UnknownNote,
} from '@shared/notes'; } from '@shared/notes';
import { TitleBar, WidgetWrapper } from '@shared/widgets';
import { FETCH_LIMIT } from '@utils/constants'; import { FETCH_LIMIT } from '@utils/constants';
import { Widget } from '@utils/types'; import { type WidgetProps } from '@utils/types';
export function TopicWidget({ widget }: { widget: Widget }) { export function TopicWidget({ props }: { props: WidgetProps }) {
const ark = useArk(); const ark = useArk();
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } = const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({ useInfiniteQuery({
queryKey: ['topic', widget.id], queryKey: ['topic', props.id],
initialPageParam: 0, initialPageParam: 0,
queryFn: async ({ queryFn: async ({
signal, signal,
@@ -27,7 +26,7 @@ export function TopicWidget({ widget }: { widget: Widget }) {
signal: AbortSignal; signal: AbortSignal;
pageParam: number; pageParam: number;
}) => { }) => {
const hashtags: string[] = JSON.parse(widget.content as string); const hashtags: string[] = JSON.parse(props.content as string);
const filter: NDKFilter = { const filter: NDKFilter = {
kinds: [NDKKind.Text, NDKKind.Repost], kinds: [NDKKind.Text, NDKKind.Repost],
'#t': hashtags.map((tag) => tag.replace('#', '')), '#t': hashtags.map((tag) => tag.replace('#', '')),
@@ -55,53 +54,52 @@ export function TopicWidget({ widget }: { widget: Widget }) {
[data] [data]
); );
const renderItem = useCallback( const renderItem = (event: NDKEvent) => {
(event: NDKEvent) => { switch (event.kind) {
switch (event.kind) { case NDKKind.Text:
case NDKKind.Text: return <MemoizedTextNote key={event.id} event={event} />;
return <MemoizedTextNote key={event.id} event={event} />; case NDKKind.Repost:
case NDKKind.Repost: return <MemoizedRepost key={event.id} event={event} />;
return <MemoizedRepost key={event.id} event={event} />; default:
default: return <UnknownNote key={event.id} event={event} />;
return <UnknownNote key={event.id} event={event} />; }
} };
},
[data]
);
return ( return (
<WidgetWrapper> <Widget.Root>
<TitleBar id={widget.id} title={widget.title} /> <Widget.Header id={props.id} title={props.title} />
<VList className="flex-1" overscan={2}> <Widget.Content>
{status === 'pending' ? ( <VList className="flex-1" overscan={2}>
<div className="px-3 py-1.5"> {status === 'pending' ? (
<div className="rounded-xl bg-neutral-100 px-3 py-3 dark:bg-neutral-900"> <div className="px-3 py-1.5">
<NoteSkeleton /> <div className="rounded-xl bg-neutral-100 px-3 py-3 dark:bg-neutral-900">
<NoteSkeleton />
</div>
</div> </div>
) : (
allEvents.map((item) => renderItem(item))
)}
<div className="flex h-16 items-center justify-center px-3 pb-3">
{hasNextPage ? (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
>
{isFetchingNextPage ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="h-5 w-5" />
Load more
</>
)}
</button>
) : null}
</div> </div>
) : ( </VList>
allEvents.map((item) => renderItem(item)) </Widget.Content>
)} </Widget.Root>
<div className="flex h-16 items-center justify-center px-3 pb-3">
{hasNextPage ? (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
>
{isFetchingNextPage ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="h-5 w-5" />
Load more
</>
)}
</button>
) : null}
</div>
</VList>
</WidgetWrapper>
); );
} }

View File

@@ -2,7 +2,7 @@ import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { useInfiniteQuery } from '@tanstack/react-query'; import { useInfiniteQuery } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { WVList } from 'virtua'; import { WVList } from 'virtua';
import { useArk } from '@libs/ark'; import { Widget, useArk } from '@libs/ark';
import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons'; import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
import { import {
MemoizedRepost, MemoizedRepost,
@@ -10,15 +10,15 @@ import {
NoteSkeleton, NoteSkeleton,
UnknownNote, UnknownNote,
} from '@shared/notes'; } from '@shared/notes';
import { TitleBar, UserProfile, WidgetWrapper } from '@shared/widgets'; import { UserProfile } from '@shared/widgets';
import { FETCH_LIMIT } from '@utils/constants'; import { FETCH_LIMIT } from '@utils/constants';
import { Widget } from '@utils/types'; import { type WidgetProps } from '@utils/types';
export function UserWidget({ widget }: { widget: Widget }) { export function UserWidget({ props }: { props: WidgetProps }) {
const ark = useArk(); const ark = useArk();
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } = const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({ useInfiniteQuery({
queryKey: ['user-posts', widget.content], queryKey: ['user-posts', props.content],
initialPageParam: 0, initialPageParam: 0,
queryFn: async ({ queryFn: async ({
signal, signal,
@@ -30,7 +30,7 @@ export function UserWidget({ widget }: { widget: Widget }) {
const events = await ark.getInfiniteEvents({ const events = await ark.getInfiniteEvents({
filter: { filter: {
kinds: [NDKKind.Text, NDKKind.Repost], kinds: [NDKKind.Text, NDKKind.Repost],
authors: [widget.content], authors: [props.content],
}, },
limit: FETCH_LIMIT, limit: FETCH_LIMIT,
pageParam, pageParam,
@@ -68,48 +68,50 @@ export function UserWidget({ widget }: { widget: Widget }) {
); );
return ( return (
<WidgetWrapper> <Widget.Root>
<TitleBar id={widget.id} title={widget.title} /> <Widget.Header id={props.id} title={props.title} />
<WVList className="flex-1 overflow-y-auto"> <Widget.Content>
<div className="px-3 pt-1.5"> <WVList className="flex-1 overflow-y-auto">
<UserProfile pubkey={widget.content} /> <div className="px-3 pt-1.5">
</div> <UserProfile pubkey={props.content} />
<div> </div>
<h3 className="mb-3 mt-4 px-3 text-lg font-semibold text-neutral-900 dark:text-neutral-100"> <div>
Latest posts <h3 className="mb-3 mt-4 px-3 text-lg font-semibold text-neutral-900 dark:text-neutral-100">
</h3> Latest posts
<div className="flex h-full w-full flex-col justify-between gap-1.5 pb-10"> </h3>
{status === 'pending' ? ( <div className="flex h-full w-full flex-col justify-between gap-1.5 pb-10">
<div className="px-3 py-1.5"> {status === 'pending' ? (
<div className="rounded-xl bg-neutral-100 px-3 py-3 dark:bg-neutral-900"> <div className="px-3 py-1.5">
<NoteSkeleton /> <div className="rounded-xl bg-neutral-100 px-3 py-3 dark:bg-neutral-900">
<NoteSkeleton />
</div>
</div> </div>
) : (
allEvents.map((item) => renderItem(item))
)}
<div className="flex h-16 items-center justify-center px-3 pb-3">
{hasNextPage ? (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
>
{isFetchingNextPage ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="h-5 w-5" />
Load more
</>
)}
</button>
) : null}
</div> </div>
) : (
allEvents.map((item) => renderItem(item))
)}
<div className="flex h-16 items-center justify-center px-3 pb-3">
{hasNextPage ? (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
>
{isFetchingNextPage ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="h-5 w-5" />
Load more
</>
)}
</button>
) : null}
</div> </div>
</div> </div>
</div> </WVList>
</WVList> </Widget.Content>
</WidgetWrapper> </Widget.Root>
); );
} }

View File

@@ -30,7 +30,7 @@ export interface WidgetGroupItem {
icon?: string; icon?: string;
} }
export interface Widget { export interface WidgetProps {
id?: string; id?: string;
account_id?: number; account_id?: number;
kind: number; kind: number;