wip: new activity sidebar

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

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]);
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
editor={editor}
initialValue={editorValue}

View File

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

View File

@@ -5,7 +5,7 @@ export function HomeLayout() {
return (
<>
<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 />
</div>
</>

View File

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

View File

@@ -2,11 +2,8 @@ import { useProfile } from "@lume/ark";
import { RepostIcon } from "@lume/icons";
import { displayNpub, formatCreatedAt } from "@lume/utils";
import * as Avatar from "@radix-ui/react-avatar";
import * as HoverCard from "@radix-ui/react-hover-card";
import { minidenticon } from "minidenticons";
import { memo, useMemo } from "react";
import { Link } from "react-router-dom";
import { NIP05 } from "./nip05";
export const User = memo(function User({
pubkey,
@@ -21,6 +18,7 @@ export const User = memo(function User({
| "simple"
| "mention"
| "notify"
| "notify2"
| "repost"
| "chat"
| "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 (isLoading) {
return (
@@ -129,7 +176,7 @@ export const User = memo(function User({
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
loading="eager"
decoding="async"
className="h-8 w-8 rounded-md"
/>
@@ -525,105 +572,36 @@ export const User = memo(function User({
}
return (
<HoverCard.Root>
<div className="flex items-center gap-3 px-3">
<HoverCard.Trigger asChild>
<Avatar.Root className="h-9 w-9 shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
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
src={fallbackAvatar}
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>
</HoverCard.Trigger>
<div className="flex h-6 flex-1 items-start gap-2">
<div className="max-w-[15rem] truncate font-semibold text-neutral-950 dark:text-neutral-50">
{user?.name ||
user?.display_name ||
user?.displayName ||
fallbackName}
</div>
<div className="ml-auto inline-flex items-center gap-3">
<div className="text-neutral-500 dark:text-neutral-400">
{createdAt}
</div>
<div className="flex items-center gap-3 px-3">
<Avatar.Root className="h-9 w-9 shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
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
src={fallbackAvatar}
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>
<div className="flex h-6 flex-1 items-start gap-2">
<div className="max-w-[15rem] truncate font-semibold text-neutral-950 dark:text-neutral-50">
{user?.name ||
user?.display_name ||
user?.displayName ||
fallbackName}
</div>
<div className="ml-auto inline-flex items-center gap-3">
<div className="text-neutral-500 dark:text-neutral-400">
{createdAt}
</div>
</div>
</div>
<HoverCard.Portal>
<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>
</div>
);
});