wip: new activity sidebar

This commit is contained in:
reya 2024-01-11 21:00:42 +07:00
parent a8cd34d998
commit 2c8571ecc7
21 changed files with 537 additions and 203 deletions

View File

@ -392,8 +392,12 @@ export class Ark {
const seenIds = new Set<string>(); const seenIds = new Set<string>();
const dedupQueue = new Set<string>(); const dedupQueue = new Set<string>();
const relayUrls = [...this.ndk.pool.relays.values()].map(
(item) => item.url,
);
const events = await this.#fetcher.fetchLatestEvents( const events = await this.#fetcher.fetchLatestEvents(
this.#storage.account.relayList, relayUrls,
filter, filter,
limit, limit,
{ {

View File

@ -28,6 +28,8 @@ export function RepostNote({
} }
}, },
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
}); });
if (isLoading) { if (isLoading) {
@ -72,7 +74,6 @@ export function RepostNote({
<Note.Repost /> <Note.Repost />
<Note.Zap /> <Note.Zap />
</div> </div>
N
</div> </div>
</div> </div>
</Note.Provider> </Note.Provider>

View File

@ -41,7 +41,7 @@ export function NoteUser({
<Avatar.Image <Avatar.Image
src={fallbackAvatar} src={fallbackAvatar}
alt={event.pubkey} alt={event.pubkey}
className="h-6 w-6 rounded-md bg-black dark:bg-white" className="h-6 w-6 shrink-0 object-cover rounded-md bg-black dark:bg-white"
/> />
</Avatar.Root> </Avatar.Root>
<div className="flex flex-1 items-baseline gap-2"> <div className="flex flex-1 items-baseline gap-2">
@ -65,7 +65,7 @@ export function NoteUser({
alt={event.pubkey} alt={event.pubkey}
loading="eager" loading="eager"
decoding="async" decoding="async"
className="h-6 w-6 rounded-md" className="h-6 w-6 shrink-0 object-cover rounded-md"
/> />
<Avatar.Fallback delayMs={300}> <Avatar.Fallback delayMs={300}>
<img <img

View File

@ -1,14 +1,22 @@
import { LoaderIcon } from "@lume/icons"; import { LoaderIcon } from "@lume/icons";
import { NDKCacheAdapterTauri } from "@lume/ndk-cache-tauri"; import { NDKCacheAdapterTauri } from "@lume/ndk-cache-tauri";
import { LumeStorage } from "@lume/storage"; import { LumeStorage } from "@lume/storage";
import { QUOTES, delay, sendNativeNotification } from "@lume/utils"; import {
FETCH_LIMIT,
QUOTES,
delay,
sendNativeNotification,
} from "@lume/utils";
import NDK, { import NDK, {
NDKEvent,
NDKKind,
NDKNip46Signer, NDKNip46Signer,
NDKPrivateKeySigner, NDKPrivateKeySigner,
NDKRelay, NDKRelay,
NDKRelayAuthPolicies, NDKRelayAuthPolicies,
} from "@nostr-dev-kit/ndk"; } from "@nostr-dev-kit/ndk";
import { ndkAdapter } from "@nostr-fetch/adapter-ndk"; import { ndkAdapter } from "@nostr-fetch/adapter-ndk";
import { useQueryClient } from "@tanstack/react-query";
import { fetch } from "@tauri-apps/plugin-http"; import { fetch } from "@tauri-apps/plugin-http";
import { platform } from "@tauri-apps/plugin-os"; import { platform } from "@tauri-apps/plugin-os";
import { relaunch } from "@tauri-apps/plugin-process"; import { relaunch } from "@tauri-apps/plugin-process";
@ -35,6 +43,8 @@ const LumeContext = createContext<Context>({
}); });
const LumeProvider = ({ children }: PropsWithChildren<object>) => { const LumeProvider = ({ children }: PropsWithChildren<object>) => {
const queryClient = useQueryClient();
const [context, setContext] = useState<Context>(undefined); const [context, setContext] = useState<Context>(undefined);
const [isNewVersion, setIsNewVersion] = useState(false); const [isNewVersion, setIsNewVersion] = useState(false);
@ -176,14 +186,41 @@ const LumeProvider = ({ children }: PropsWithChildren<object>) => {
const contacts = await user.follows(); const contacts = await user.follows();
storage.account.contacts = [...contacts].map((user) => user.pubkey); storage.account.contacts = [...contacts].map((user) => user.pubkey);
const relays = await user.relayList(); // subscribe for new activity
const sub = ndk.subscribe(
{
kinds: [NDKKind.Text, NDKKind.Repost, NDKKind.Zap],
"#p": [storage.account.pubkey],
since: Math.floor(Date.now() / 1000),
},
{ closeOnEose: false, groupable: false },
);
if (!relays) storage.account.relayList = ndk.explicitRelayUrls; sub.addListener("event", async (event: NDKEvent) => {
const profile = await ark.getUserProfile(event.pubkey);
storage.account.relayList = [ switch (event.kind) {
...relays.readRelayUrls, case NDKKind.Text:
...relays.bothRelayUrls, return await sendNativeNotification(
]; `${
profile.displayName || profile.name || "anon"
} has replied to your note`,
);
case NDKKind.Repost:
return await sendNativeNotification(
`${
profile.displayName || profile.name || "anon"
} has reposted to your note`,
);
case NDKKind.Zap:
return await sendNativeNotification(
`${
profile.displayName || profile.name || "anon"
} has zapped to your note`,
);
default:
break;
}
});
} }
// init nostr fetcher // init nostr fetcher

View File

@ -1,8 +1,8 @@
{ {
"extends": "@lume/tsconfig/base.json", "extends": "@lume/tsconfig/base.json",
"compilerOptions": { "compilerOptions": {
"outDir": "dist" "outDir": "dist"
}, },
"include": ["src"], "include": ["src"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]
} }

View File

@ -106,3 +106,4 @@ export * from "./src/check";
export * from "./src/popperFilled"; export * from "./src/popperFilled";
export * from "./src/composeFilled"; export * from "./src/composeFilled";
export * from "./src/settingsFilled"; export * from "./src/settingsFilled";
export * from "./src/bellFilled";

View File

@ -1,20 +1,24 @@
import { SVGProps } from 'react'; import { SVGProps } from "react";
export function BellIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) { export function BellIcon(
return ( props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
<svg ) {
width={24} return (
height={24} <svg
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"
fill="none" width="24"
xmlns="http://www.w3.org/2000/svg" height="24"
{...props} fill="none"
> viewBox="0 0 24 24"
<path {...props}
d="M16 18.25C15.3267 20.0159 13.7891 21.25 12 21.25C10.2109 21.25 8.67327 20.0159 8 18.25M20.5 18.25L18.9554 8.67345C18.4048 5.2596 15.458 2.75 12 2.75C8.54203 2.75 5.59523 5.2596 5.04461 8.67345L3.5 18.25H20.5Z" >
stroke="currentColor" <path
strokeWidth={1.5} stroke="currentColor"
/> strokeLinecap="round"
</svg> strokeLinejoin="round"
); strokeWidth="2"
d="M9.159 17.724a59.522 59.522 0 01-3.733-.297 1.587 1.587 0 01-1.33-2.08c.161-.485.324-.963.367-1.478l.355-4.26a7.207 7.207 0 0114.365 0l.355 4.262c.043.515.206.993.367 1.479a1.587 1.587 0 01-1.33 2.077 59.5 59.5 0 01-3.732.297m-5.684 0c1.893.09 3.79.09 5.684 0m-5.684 0v.434a2.842 2.842 0 105.684 0v-.434"
/>
</svg>
);
} }

View File

@ -0,0 +1,23 @@
import { SVGProps } from "react";
export function BellFilledIcon(
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
{...props}
>
<path
fill="currentColor"
fillRule="evenodd"
d="M3.822 9.526a8.207 8.207 0 0116.358 0l.355 4.262c.03.374.15.735.32 1.246a2.587 2.587 0 01-2.17 3.387c-.957.106-1.916.19-2.876.25a3.843 3.843 0 01-7.616 0c-.96-.06-1.92-.143-2.877-.25a2.588 2.588 0 01-2.17-3.39c.17-.51.29-.872.32-1.245l.356-4.26zm6.44 9.24a1.843 1.843 0 003.478 0l-.294.008a60.587 60.587 0 01-3.184-.008z"
clipRule="evenodd"
/>
</svg>
);
}

View File

@ -1,21 +1,24 @@
import { SVGProps } from 'react'; import { SVGProps } from "react";
export function ChatsIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) { export function ChatsIcon(
return ( props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
<svg ) {
xmlns="http://www.w3.org/2000/svg" return (
width="24" <svg
height="24" xmlns="http://www.w3.org/2000/svg"
fill="none" width="25"
viewBox="0 0 24 24" height="24"
{...props} fill="none"
> viewBox="0 0 25 24"
<path {...props}
fill="currentColor" >
fillRule="evenodd" <path
d="M19.002 3a3 3 0 013 3v6a3 3 0 01-3 3h-1v1a3 3 0 01-3 3h-4.24l-4.274 2.374a1 1 0 01-1.486-.874V19a3 3 0 01-3-3v-6a3 3 0 013-3h1V6a3 3 0 013-3h10zm-11 4h7a3 3 0 013 3v3h1a1 1 0 001-1V6a1 1 0 00-1-1h-10a1 1 0 00-1 1v1z" stroke="currentColor"
clipRule="evenodd" strokeLinecap="round"
></path> strokeLinejoin="round"
</svg> strokeWidth="2"
); d="M17.55 18.718l1.74.124c.762.055 1.143.082 1.428-.053a1.2 1.2 0 00.57-.57c.136-.286.109-.667.054-1.429l-.124-1.74c-.02-.28-.03-.42-.032-.562-.004-.258-.007-.122.009-.38.009-.142.088-.811.248-2.15A8 8 0 006.884 6.5m7.416 9.1a5.4 5.4 0 10-10.733.856c.096.6.144.9.152.992.012.139.011.107.01.247 0 .093-.008.204-.023.427l-.1 1.386c-.036.515-.055.772.037.964a.81.81 0 00.385.385c.192.091.45.073.964.036l1.385-.099c.224-.015.335-.024.428-.024.14 0 .108-.001.247.011.093.008.393.056.992.152a5.387 5.387 0 005.06-1.942A5.377 5.377 0 0014.3 15.6z"
/>
</svg>
);
} }

View File

@ -0,0 +1,33 @@
import { activityAtom } from "@lume/utils";
import { AnimatePresence, motion } from "framer-motion";
import { useAtomValue } from "jotai";
import { ActivityContent } from "./content";
export function Activity() {
const isActivityOpen = useAtomValue(activityAtom);
return (
<AnimatePresence initial={false} mode="wait">
{isActivityOpen ? (
<motion.div
key={isActivityOpen ? "activity-open" : "activity-close"}
layout
initial={{ scale: 0.9, opacity: 0, translateX: -20 }}
animate={{
scale: [0.95, 1],
opacity: [0.5, 1],
translateX: [-10, 0],
}}
exit={{
scale: [0.95, 0.9],
opacity: [0.5, 0],
translateX: [-10, -20],
}}
className="h-full w-[350px] px-1 pb-1 shrink-0"
>
<ActivityContent />
</motion.div>
) : null}
</AnimatePresence>
);
}

View File

@ -0,0 +1,101 @@
import { useArk, useStorage } from "@lume/ark";
import { LoaderIcon } from "@lume/icons";
import { FETCH_LIMIT } from "@lume/utils";
import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useCallback, useMemo } from "react";
import { VList } from "virtua";
import { ReplyActivity } from "./reply";
import { RepostActivity } from "./repost";
import { ZapActivity } from "./zap";
export function ActivityContent() {
const ark = useArk();
const storage = useStorage();
const { isLoading, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: ["activity"],
initialPageParam: 0,
queryFn: async ({
signal,
pageParam,
}: {
signal: AbortSignal;
pageParam: number;
}) => {
const events = await ark.getInfiniteEvents({
filter: {
kinds: [NDKKind.Zap],
"#p": [storage.account.pubkey],
},
limit: 100,
pageParam,
signal,
});
return events;
},
getNextPageParam: (lastPage) => {
const lastEvent = lastPage.at(-1);
if (!lastEvent) return;
return lastEvent.created_at - 1;
},
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
});
const allEvents = useMemo(
() => (data ? data.pages.flatMap((page) => page) : []),
[data],
);
const renderEvent = useCallback((event: NDKEvent) => {
if (event.pubkey === storage.account.pubkey) return null;
switch (event.kind) {
case NDKKind.Text:
return <ReplyActivity key={event.id} event={event} />;
case NDKKind.Repost:
return <RepostActivity key={event.id} event={event} />;
case NDKKind.Zap:
return <ZapActivity key={event.id} event={event} />;
default:
return <ReplyActivity key={event.id} event={event} />;
}
}, []);
return (
<div className="w-full h-full flex flex-col justify-between rounded-xl overflow-hidden bg-white 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-none dark:ring-1 dark:ring-white/10">
<div className="h-full w-full min-h-0">
{isLoading ? (
<div className="w-[350px] h-full flex items-center justify-center">
<LoaderIcon className="size-5 animate-spin" />
</div>
) : allEvents.length < 1 ? (
<div className="w-full h-full flex flex-col items-center justify-center">
<p className="mb-2 text-2xl">🎉</p>
<p className="text-center font-medium">Yo! Nothing new yet.</p>
</div>
) : (
renderEvent(allEvents[0])
)}
</div>
<div className="h-16 shrink-0 px-3 flex items-center gap-3 bg-neutral-100 dark:bg-neutral-900">
<button
type="button"
className="h-11 flex-1 inline-flex items-center justify-center rounded-xl font-medium bg-neutral-200 dark:bg-neutral-800 hover:bg-neutral-300 dark:hover:bg-neutral-700"
>
Previous
</button>
<button
type="button"
className="h-11 flex-1 inline-flex items-center justify-center rounded-xl font-medium bg-blue-500 text-white hover:bg-blue-600"
>
Next
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,48 @@
import { Note, useArk } from "@lume/ark";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { ActivityRootNote } from "./rootNote";
export function ReplyActivity({ event }: { event: NDKEvent }) {
const ark = useArk();
const thread = ark.getEventThread({ tags: event.tags });
return (
<div className="h-full pb-3 flex flex-col justify-between">
<div className="h-14 border-b border-neutral-100 dark:border-neutral-900 flex flex-col items-center justify-center px-3">
<h3 className="text-center font-semibold leading-tight">
Conversation
</h3>
<p className="text-sm text-blue-500 font-medium leading-tight">
@ Someone has replied to your note
</p>
</div>
<div className="px-3">
{thread ? (
<div className="flex flex-col gap-3 mb-1">
<ActivityRootNote eventId={thread.rootEventId} />
<ActivityRootNote eventId={thread.replyEventId} />
</div>
) : null}
<div className="mt-3 flex flex-col gap-3">
<div className="flex items-center gap-3">
<p className="text-teal-500 font-medium">New reply</p>
<div className="flex-1 h-px bg-teal-300" />
<div className="w-4 shrink-0 h-px bg-teal-300" />
</div>
<Note.Provider event={event}>
<Note.Root>
<div className="flex items-center justify-between px-3 h-14">
<Note.User className="flex-1 pr-1" />
</div>
<Note.Content className="min-w-0 px-3" />
<div className="flex items-center justify-between px-3 h-14">
<Note.Pin />
<div className="inline-flex items-center gap-10" />
</div>
</Note.Root>
</Note.Provider>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,33 @@
import { formatCreatedAt } from "@lume/utils";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { User } from "../user";
import { ActivityRootNote } from "./rootNote";
export function RepostActivity({ event }: { event: NDKEvent }) {
const repostId = event.tags.find((el) => el[0] === "e")[1];
const createdAt = formatCreatedAt(event.created_at);
return (
<div className="h-full pb-3 flex flex-col justify-between">
<div className="h-14 border-b border-neutral-100 dark:border-neutral-900 flex flex-col items-center justify-center px-3">
<h3 className="text-center font-semibold leading-tight">Boost</h3>
<p className="text-sm text-blue-500 font-medium leading-tight">
@ Someone has reposted to your note
</p>
</div>
<div className="px-3">
<div className="flex flex-col gap-3">
<User pubkey={event.pubkey} variant="notify2" />
<div className="flex items-center gap-3">
<p className="text-teal-500 font-medium">Reposted</p>
<div className="flex-1 h-px bg-teal-300" />
<div className="w-4 shrink-0 h-px bg-teal-300" />
</div>
</div>
<div className="mt-3 flex flex-col gap-3">
<ActivityRootNote eventId={repostId} />
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,40 @@
import { Note, useEvent } from "@lume/ark";
export function ActivityRootNote({ eventId }: { eventId: string }) {
const { isLoading, isError, data } = useEvent(eventId);
if (isLoading) {
return (
<div className="relative flex gap-3">
<div className="relative flex-1 rounded-md bg-neutral-200 px-2 py-2 dark:bg-neutral-800">
<div className="h-4 w-full animate-pulse bg-neutral-300 dark:bg-neutral-700" />
</div>
</div>
);
}
if (isError) {
return (
<div className="relative flex gap-3">
<div className="relative flex-1 rounded-md bg-neutral-200 px-2 py-2 dark:bg-neutral-800">
Failed to fetch event
</div>
</div>
);
}
return (
<Note.Provider event={data}>
<Note.Root>
<div className="flex items-center justify-between px-3 h-14">
<Note.User className="flex-1 pr-1" />
</div>
<Note.Content className="min-w-0 px-3" />
<div className="flex items-center justify-between px-3 h-14">
<Note.Pin />
<div className="inline-flex items-center gap-10" />
</div>
</Note.Root>
</Note.Provider>
);
}

View File

@ -0,0 +1,37 @@
import { compactNumber, formatCreatedAt } from "@lume/utils";
import { NDKEvent, zapInvoiceFromEvent } from "@nostr-dev-kit/ndk";
import { User } from "../user";
import { ActivityRootNote } from "./rootNote";
export function ZapActivity({ event }: { event: NDKEvent }) {
const zapEventId = event.tags.find((tag) => tag[0] === "e")[1];
const invoice = zapInvoiceFromEvent(event);
return (
<div className="h-full pb-3 flex flex-col justify-between">
<div className="h-14 border-b border-neutral-100 dark:border-neutral-900 flex flex-col items-center justify-center px-3">
<h3 className="text-center font-semibold leading-tight">Zap</h3>
<p className="text-sm text-blue-500 font-medium leading-tight">
@ Someone love your note
</p>
</div>
<div className="px-3">
<div className="flex flex-col gap-3">
<User
pubkey={event.pubkey}
variant="notify2"
subtext={`${compactNumber.format(invoice.amount)} sats`}
/>
<div className="flex items-center gap-3">
<p className="text-teal-500 font-medium">Zapped</p>
<div className="flex-1 h-px bg-teal-300" />
<div className="w-4 shrink-0 h-px bg-teal-300" />
</div>
</div>
<div className="mt-3 flex flex-col gap-3">
<ActivityRootNote eventId={zapEventId} />
</div>
</div>
</div>
);
}

View File

@ -288,7 +288,7 @@ export function EditorForm() {
}, [filters.length, editor, index, search, target]); }, [filters.length, editor, index, search, target]);
return ( return (
<div className="w-full h-full flex flex-col justify-between rounded-xl overflow-hidden bg-white 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)]"> <div className="w-full h-full flex flex-col justify-between rounded-xl overflow-hidden bg-white 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-none dark:ring-1 dark:ring-white/10">
<Slate <Slate
editor={editor} editor={editor}
initialValue={editorValue} initialValue={editorValue}

View File

@ -1,6 +1,7 @@
import { type Platform } from "@tauri-apps/plugin-os"; import { type Platform } from "@tauri-apps/plugin-os";
import { Outlet } from "react-router-dom"; import { Outlet } from "react-router-dom";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { Activity } from "../activity/column";
import { Editor } from "../editor/column"; import { Editor } from "../editor/column";
import { Navigation } from "../navigation"; import { Navigation } from "../navigation";
import { WindowTitleBar } from "../titlebar"; import { WindowTitleBar } from "../titlebar";
@ -21,6 +22,7 @@ export function AppLayout({ platform }: { platform: Platform }) {
<div className="flex w-full h-full min-h-0"> <div className="flex w-full h-full min-h-0">
<Navigation /> <Navigation />
<Editor /> <Editor />
<Activity />
<div className="flex-1 h-full px-1 pb-1"> <div className="flex-1 h-full px-1 pb-1">
<Outlet /> <Outlet />
</div> </div>

View File

@ -5,7 +5,7 @@ export function HomeLayout() {
return ( return (
<> <>
<OnboardingModal /> <OnboardingModal />
<div className="h-full w-full rounded-xl overflow-hidden bg-white 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)]"> <div className="h-full w-full rounded-xl overflow-hidden bg-white 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-none dark:ring-1 dark:ring-white/10">
<Outlet /> <Outlet />
</div> </div>
</> </>

View File

@ -1,18 +1,19 @@
import { import {
BellFilledIcon,
BellIcon,
ComposeFilledIcon, ComposeFilledIcon,
ComposeIcon, ComposeIcon,
DepotFilledIcon, DepotFilledIcon,
DepotIcon, DepotIcon,
HomeFilledIcon, HomeFilledIcon,
HomeIcon, HomeIcon,
NwcFilledIcon,
NwcIcon, NwcIcon,
RelayFilledIcon, RelayFilledIcon,
RelayIcon, RelayIcon,
SettingsFilledIcon, SettingsFilledIcon,
SettingsIcon, SettingsIcon,
} from "@lume/icons"; } from "@lume/icons";
import { cn, editorAtom } from "@lume/utils"; import { activityAtom, cn, editorAtom } from "@lume/utils";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useHotkeys } from "react-hotkeys-hook"; import { useHotkeys } from "react-hotkeys-hook";
import { NavLink } from "react-router-dom"; import { NavLink } from "react-router-dom";
@ -20,6 +21,8 @@ import { ActiveAccount } from "./account/active";
export function Navigation() { export function Navigation() {
const [isEditorOpen, setIsEditorOpen] = useAtom(editorAtom); const [isEditorOpen, setIsEditorOpen] = useAtom(editorAtom);
const [isActvityOpen, setIsActvityOpen] = useAtom(activityAtom);
useHotkeys("meta+n", () => setIsEditorOpen((state) => !state), []); useHotkeys("meta+n", () => setIsEditorOpen((state) => !state), []);
return ( return (
@ -29,7 +32,10 @@ export function Navigation() {
<ActiveAccount /> <ActiveAccount />
<button <button
type="button" type="button"
onClick={() => setIsEditorOpen((prev) => !prev)} onClick={() => {
setIsEditorOpen((state) => !state);
setIsActvityOpen(false);
}}
className="flex items-center justify-center h-auto w-full text-black aspect-square rounded-xl bg-black/5 hover:bg-blue-500 hover:text-white dark:bg-white/5 dark:text-white dark:hover:bg-blue-500" className="flex items-center justify-center h-auto w-full text-black aspect-square rounded-xl bg-black/5 hover:bg-blue-500 hover:text-white dark:bg-white/5 dark:text-white dark:hover:bg-blue-500"
> >
{isEditorOpen ? ( {isEditorOpen ? (
@ -63,50 +69,30 @@ export function Navigation() {
</div> </div>
)} )}
</NavLink> </NavLink>
<NavLink <button
to="/relays" type="button"
preventScrollReset={true} onClick={() => {
setIsActvityOpen((state) => !state);
setIsEditorOpen(false);
}}
className="inline-flex flex-col items-center justify-center" className="inline-flex flex-col items-center justify-center"
> >
{({ isActive }) => ( <div
<div className={cn(
className={cn( "inline-flex aspect-square h-auto w-full items-center justify-center rounded-xl",
"inline-flex aspect-square h-auto w-full items-center justify-center rounded-xl", isActvityOpen
isActive ? "bg-black/10 text-black dark:bg-white/10 dark:text-white"
? "bg-black/10 text-black dark:bg-white/10 dark:text-white" : "text-black/50 dark:text-neutral-400",
: "text-black/50 dark:text-neutral-400", )}
)} >
> {isActvityOpen ? (
{isActive ? ( <BellFilledIcon className="size-6" />
<RelayFilledIcon className="size-6" /> ) : (
) : ( <BellIcon className="size-6" />
<RelayIcon className="size-6" /> )}
)} </div>
</div> </button>
)}
</NavLink>
<NavLink
to="/depot"
preventScrollReset={true}
className="inline-flex flex-col items-center justify-center"
>
{({ isActive }) => (
<div
className={cn(
"inline-flex aspect-square h-auto w-full items-center justify-center rounded-xl",
isActive
? "bg-black/10 text-black dark:bg-white/10 dark:text-white"
: "text-black/50 dark:text-neutral-400",
)}
>
{isActive ? (
<DepotFilledIcon className="text-black size-6 dark:text-white" />
) : (
<DepotIcon className="size-6" />
)}
</div>
)}
</NavLink>
<NavLink <NavLink
to="/nwc" to="/nwc"
preventScrollReset={true} preventScrollReset={true}

View File

@ -2,11 +2,8 @@ import { useProfile } from "@lume/ark";
import { RepostIcon } from "@lume/icons"; import { RepostIcon } from "@lume/icons";
import { displayNpub, formatCreatedAt } from "@lume/utils"; import { displayNpub, formatCreatedAt } from "@lume/utils";
import * as Avatar from "@radix-ui/react-avatar"; import * as Avatar from "@radix-ui/react-avatar";
import * as HoverCard from "@radix-ui/react-hover-card";
import { minidenticon } from "minidenticons"; import { minidenticon } from "minidenticons";
import { memo, useMemo } from "react"; import { memo, useMemo } from "react";
import { Link } from "react-router-dom";
import { NIP05 } from "./nip05";
export const User = memo(function User({ export const User = memo(function User({
pubkey, pubkey,
@ -21,6 +18,7 @@ export const User = memo(function User({
| "simple" | "simple"
| "mention" | "mention"
| "notify" | "notify"
| "notify2"
| "repost" | "repost"
| "chat" | "chat"
| "large" | "large"
@ -105,6 +103,55 @@ export const User = memo(function User({
); );
} }
if (variant === "notify2") {
if (isLoading) {
return (
<div className="flex items-center gap-2">
<Avatar.Root className="h-8 w-8 shrink-0">
<Avatar.Image
src={fallbackAvatar}
alt={pubkey}
className="h-8 w-8 rounded-md bg-black dark:bg-white"
/>
</Avatar.Root>
<h5 className="max-w-[10rem] truncate font-semibold text-neutral-900 dark:text-neutral-100">
{fallbackName}
</h5>
</div>
);
}
return (
<div className="flex items-center gap-2">
<Avatar.Root className="h-8 w-8 shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="eager"
decoding="async"
className="h-8 w-8 rounded-md"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="h-8 w-8 rounded-md bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="inline-flex items-center gap-1">
<h5 className="max-w-[8rem] truncate font-semibold text-neutral-900 dark:text-neutral-100">
{user?.name ||
user?.display_name ||
user?.displayName ||
fallbackName}
</h5>
<p>{subtext}</p>
</div>
</div>
);
}
if (variant === "notify") { if (variant === "notify") {
if (isLoading) { if (isLoading) {
return ( return (
@ -129,7 +176,7 @@ export const User = memo(function User({
<Avatar.Image <Avatar.Image
src={user?.picture || user?.image} src={user?.picture || user?.image}
alt={pubkey} alt={pubkey}
loading="lazy" loading="eager"
decoding="async" decoding="async"
className="h-8 w-8 rounded-md" className="h-8 w-8 rounded-md"
/> />
@ -525,105 +572,36 @@ export const User = memo(function User({
} }
return ( return (
<HoverCard.Root> <div className="flex items-center gap-3 px-3">
<div className="flex items-center gap-3 px-3"> <Avatar.Root className="h-9 w-9 shrink-0">
<HoverCard.Trigger asChild> <Avatar.Image
<Avatar.Root className="h-9 w-9 shrink-0"> src={user?.picture || user?.image}
<Avatar.Image alt={pubkey}
src={user?.picture || user?.image} loading="lazy"
alt={pubkey} decoding="async"
loading="lazy" className="h-9 w-9 rounded-lg bg-white object-cover ring-1 ring-neutral-200/50 dark:ring-neutral-800/50"
decoding="async" />
className="h-9 w-9 rounded-lg bg-white object-cover ring-1 ring-neutral-200/50 dark:ring-neutral-800/50" <Avatar.Fallback delayMs={300}>
/> <img
<Avatar.Fallback delayMs={300}> src={fallbackAvatar}
<img alt={pubkey}
src={fallbackAvatar} className="h-9 w-9 rounded-lg bg-black ring-1 ring-neutral-200/50 dark:bg-white dark:ring-neutral-800/50"
alt={pubkey} />
className="h-9 w-9 rounded-lg bg-black ring-1 ring-neutral-200/50 dark:bg-white dark:ring-neutral-800/50" </Avatar.Fallback>
/> </Avatar.Root>
</Avatar.Fallback> <div className="flex h-6 flex-1 items-start gap-2">
</Avatar.Root> <div className="max-w-[15rem] truncate font-semibold text-neutral-950 dark:text-neutral-50">
</HoverCard.Trigger> {user?.name ||
<div className="flex h-6 flex-1 items-start gap-2"> user?.display_name ||
<div className="max-w-[15rem] truncate font-semibold text-neutral-950 dark:text-neutral-50"> user?.displayName ||
{user?.name || fallbackName}
user?.display_name || </div>
user?.displayName || <div className="ml-auto inline-flex items-center gap-3">
fallbackName} <div className="text-neutral-500 dark:text-neutral-400">
</div> {createdAt}
<div className="ml-auto inline-flex items-center gap-3">
<div className="text-neutral-500 dark:text-neutral-400">
{createdAt}
</div>
</div> </div>
</div> </div>
</div> </div>
<HoverCard.Portal> </div>
<HoverCard.Content
className="ml-4 w-[300px] overflow-hidden rounded-xl border border-neutral-200 bg-neutral-100 shadow-lg data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade data-[side=right]:animate-slideLeftAndFade data-[side=top]:animate-slideDownAndFade data-[state=open]:transition-all focus:outline-none dark:border-neutral-800 dark:bg-neutral-900"
sideOffset={5}
>
<div className="flex gap-2.5 border-b border-neutral-200 px-3 py-3 dark:border-neutral-800">
<Avatar.Root className="shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="h-10 w-10 rounded-lg object-cover"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="h-10 w-10 rounded-lg bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="flex flex-1 flex-col gap-2">
<div className="inline-flex flex-col">
<h5 className="text-sm font-semibold">
{user?.name ||
user?.display_name ||
user?.displayName ||
user?.username}
</h5>
{user?.nip05 ? (
<NIP05
pubkey={pubkey}
nip05={user.nip05}
className="max-w-[15rem] truncate text-sm text-neutral-500 dark:text-neutral-300"
/>
) : (
<span className="max-w-[15rem] truncate text-sm text-neutral-500 dark:text-neutral-300">
{fallbackName}
</span>
)}
</div>
<div>
<p className="line-clamp-3 break-all text-sm leading-tight text-neutral-900 dark:text-neutral-100">
{user?.about}
</p>
</div>
</div>
</div>
<div className="flex items-center gap-2 px-3 py-3">
<Link
to={`/users/${pubkey}`}
className="inline-flex h-9 flex-1 items-center justify-center rounded-lg bg-neutral-200 text-sm font-semibold hover:bg-blue-500 hover:text-white dark:bg-neutral-800"
>
View profile
</Link>
<Link
to={`/chats/${pubkey}`}
className="inline-flex h-9 flex-1 items-center justify-center rounded-lg bg-neutral-200 text-sm font-semibold hover:bg-blue-500 hover:text-white dark:bg-neutral-800"
>
Message
</Link>
</div>
</HoverCard.Content>
</HoverCard.Portal>
</HoverCard.Root>
); );
}); });

View File

@ -9,3 +9,6 @@ export const editorValueAtom = atom([
]); ]);
export const onboardingAtom = atom(false); export const onboardingAtom = atom(false);
export const activityAtom = atom(false);
export const activityUnreadAtom = atom(0);