Notification Panel (#200)
* feat: add tauri nspanel * feat: add notification panel * feat: move notification service to backend * feat: add sync notification job * feat: enable panel to join all spaces including fullscreen (#203) * feat: fetch notification * feat: listen for new notification * feat: finish panel --------- Co-authored-by: Victor Aremu <me@victorare.mu>
This commit is contained in:
@@ -21,6 +21,7 @@
|
||||
"@radix-ui/react-hover-card": "^1.0.7",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-switch": "^1.0.3",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@tanstack/query-sync-storage-persister": "^5.40.0",
|
||||
"@tanstack/react-query": "^5.40.0",
|
||||
|
||||
@@ -2,7 +2,7 @@ export function Hashtag({ tag }: { tag: string }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="break-all cursor-default leading-normal group"
|
||||
className="break-all cursor-default leading-normal group text-start"
|
||||
>
|
||||
<span className="text-blue-500">#</span>
|
||||
<span className="underline-offset-1 underline decoration-2 decoration-blue-200 dark:decoration-blue-800 group-hover:decoration-blue-500">
|
||||
|
||||
@@ -23,7 +23,7 @@ export function UserAvatar({ className }: { className?: string }) {
|
||||
alt={user.pubkey}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
className={cn("outline-[.5px] outline-black/5", className)}
|
||||
className={cn("outline-[.5px] outline-black/5 object-cover", className)}
|
||||
/>
|
||||
<Avatar.Fallback delayMs={120}>
|
||||
<img
|
||||
|
||||
@@ -1,25 +1,17 @@
|
||||
import {
|
||||
BellIcon,
|
||||
ComposeFilledIcon,
|
||||
HorizontalDotsIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
} from "@lume/icons";
|
||||
import { type NostrEvent, Kind } from "@lume/types";
|
||||
import { User } from "@/components/user";
|
||||
import {
|
||||
cn,
|
||||
decodeZapInvoice,
|
||||
displayNpub,
|
||||
sendNativeNotification,
|
||||
} from "@lume/utils";
|
||||
import { cn } from "@lume/utils";
|
||||
import { Outlet, createFileRoute } from "@tanstack/react-router";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { getCurrent } from "@tauri-apps/api/window";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import { LumeWindow, NostrAccount, NostrQuery } from "@lume/system";
|
||||
import { LumeWindow, NostrAccount } from "@lume/system";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/$account")({
|
||||
@@ -60,7 +52,6 @@ function Screen() {
|
||||
<ComposeFilledIcon className="size-4" />
|
||||
New Post
|
||||
</button>
|
||||
<Bell />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => LumeWindow.openSearch()}
|
||||
@@ -195,64 +186,3 @@ function Accounts() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Bell() {
|
||||
const { account } = Route.useParams();
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const unlisten = getCurrent().listen<string>(
|
||||
"activity",
|
||||
async (payload) => {
|
||||
setCount((prevCount) => prevCount + 1);
|
||||
await invoke("set_badge", { count });
|
||||
|
||||
const event: NostrEvent = JSON.parse(payload.payload);
|
||||
const user = await NostrQuery.getProfile(event.pubkey);
|
||||
const userName =
|
||||
user.display_name || user.name || displayNpub(event.pubkey, 16);
|
||||
|
||||
switch (event.kind) {
|
||||
case Kind.Text: {
|
||||
sendNativeNotification("Mentioned you in a note", userName);
|
||||
break;
|
||||
}
|
||||
case Kind.Repost: {
|
||||
sendNativeNotification("Reposted your note", userName);
|
||||
break;
|
||||
}
|
||||
case Kind.ZapReceipt: {
|
||||
const amount = decodeZapInvoice(event.tags);
|
||||
sendNativeNotification(
|
||||
`Zapped ₿ ${amount.bitcoinFormatted}`,
|
||||
userName,
|
||||
);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
unlisten.then((f) => f());
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCount(0);
|
||||
LumeWindow.openActivity(account);
|
||||
}}
|
||||
className="relative inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
|
||||
>
|
||||
<BellIcon className="size-5" />
|
||||
{count > 0 ? (
|
||||
<span className="absolute right-0 top-0 block size-2 rounded-full bg-teal-500 ring-1 ring-black/5" />
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { Note } from "@/components/note";
|
||||
import { Await, createFileRoute, defer } from "@tanstack/react-router";
|
||||
import { Suspense } from "react";
|
||||
import { Virtualizer } from "virtua";
|
||||
import { NostrQuery } from "@lume/system";
|
||||
|
||||
export const Route = createFileRoute("/activity/$account/texts")({
|
||||
loader: async ({ params }) => {
|
||||
return { data: defer(NostrQuery.getUserActivities(params.account, "1")) };
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { data } = Route.useLoaderData();
|
||||
|
||||
return (
|
||||
<Virtualizer overscan={3}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 text-sm font-medium"
|
||||
disabled
|
||||
>
|
||||
<Spinner className="size-5" />
|
||||
Loading...
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Await promise={data}>
|
||||
{(events) =>
|
||||
events.map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className="flex flex-col gap-2 mb-3 bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50"
|
||||
>
|
||||
<Note.Provider event={event}>
|
||||
<Note.Root>
|
||||
<div className="px-3 h-14 flex items-center justify-between">
|
||||
<Note.User />
|
||||
<Note.Menu />
|
||||
</div>
|
||||
<Note.Activity className="px-3" />
|
||||
<Note.Content className="px-3" quote={false} clean />
|
||||
<div className="mt-3 flex items-center gap-4 h-14 px-3">
|
||||
<Note.Open />
|
||||
</div>
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</Await>
|
||||
</Suspense>
|
||||
</Virtualizer>
|
||||
);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { Box, Container } from "@lume/ui";
|
||||
import { cn } from "@lume/utils";
|
||||
import { Link, Outlet } from "@tanstack/react-router";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/activity/$account")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { account } = Route.useParams();
|
||||
|
||||
return (
|
||||
<Container withDrag>
|
||||
<Box className="scrollbar-none shadow-none bg-black/5 dark:bg-white/5 backdrop-blur-sm flex flex-col overflow-y-auto">
|
||||
<div className="h-14 shrink-0 flex w-full items-center gap-1 px-3">
|
||||
<div className="inline-flex h-full w-full items-center gap-1">
|
||||
<Link to="/activity/$account/texts" params={{ account }}>
|
||||
{({ isActive }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex h-7 w-max items-center justify-center gap-2 rounded-full px-3 text-sm font-medium",
|
||||
isActive ? "bg-neutral-50 dark:bg-white/10" : "opacity-50",
|
||||
)}
|
||||
>
|
||||
Notes
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
<Link to="/activity/$account/zaps" params={{ account }}>
|
||||
{({ isActive }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex h-7 w-max items-center justify-center gap-2 rounded-full px-3 text-sm font-medium",
|
||||
isActive ? "bg-neutral-50 dark:bg-white/10" : "opacity-50",
|
||||
)}
|
||||
>
|
||||
Zaps
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-2 flex-1 overflow-y-auto w-full h-full scrollbar-none">
|
||||
<Outlet />
|
||||
</div>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { User } from "@/components/user";
|
||||
import { NostrQuery } from "@lume/system";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { decodeZapInvoice } from "@lume/utils";
|
||||
import { Await, createFileRoute, defer } from "@tanstack/react-router";
|
||||
import { Suspense } from "react";
|
||||
import { Virtualizer } from "virtua";
|
||||
|
||||
export const Route = createFileRoute("/activity/$account/zaps")({
|
||||
loader: async ({ params }) => {
|
||||
return {
|
||||
data: defer(NostrQuery.getUserActivities(params.account, "9735")),
|
||||
};
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { data } = Route.useLoaderData();
|
||||
|
||||
return (
|
||||
<Virtualizer overscan={3}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 text-sm font-medium"
|
||||
disabled
|
||||
>
|
||||
<Spinner className="size-5" />
|
||||
Loading...
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Await promise={data}>
|
||||
{(events) =>
|
||||
events.map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className="flex flex-col gap-2 mb-3 bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50"
|
||||
>
|
||||
<User.Provider pubkey={event.pubkey}>
|
||||
<User.Root className="flex flex-col">
|
||||
<div className="text-lg h-20 font-medium leading-tight flex w-full items-center justify-center">
|
||||
₿ {decodeZapInvoice(event.tags).bitcoinFormatted}
|
||||
</div>
|
||||
<div className="h-11 border-t border-neutral-100 dark:border-neutral-900 flex items-center gap-1 px-2">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<User.Avatar className="size-7 rounded-full shrink-0" />
|
||||
<User.Name className="text-sm font-medium" />
|
||||
</div>
|
||||
<div className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
zapped you
|
||||
</div>
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</Await>
|
||||
</Suspense>
|
||||
</Virtualizer>
|
||||
);
|
||||
}
|
||||
308
apps/desktop2/src/routes/panel.tsx
Normal file
308
apps/desktop2/src/routes/panel.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
import { Note } from "@/components/note";
|
||||
import { User } from "@/components/user";
|
||||
import { LumeWindow, NostrQuery, useEvent } from "@lume/system";
|
||||
import { Kind, NostrEvent } from "@lume/types";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { getCurrent } from "@tauri-apps/api/window";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import * as Tabs from "@radix-ui/react-tabs";
|
||||
import { InfoIcon, RepostIcon, SettingsIcon } from "@lume/icons";
|
||||
import { decodeZapInvoice, formatCreatedAt } from "@lume/utils";
|
||||
|
||||
interface EmitAccount {
|
||||
account: string;
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/panel")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const [account, setAccount] = useState<string>(null);
|
||||
const [events, setEvents] = useState<NostrEvent[]>([]);
|
||||
|
||||
const texts = useMemo(
|
||||
() => events.filter((ev) => ev.kind === Kind.Text),
|
||||
[events],
|
||||
);
|
||||
|
||||
const zaps = useMemo(() => {
|
||||
const groups = new Map<string, NostrEvent[]>();
|
||||
const list = events.filter((ev) => ev.kind === Kind.ZapReceipt);
|
||||
|
||||
for (const event of list) {
|
||||
const rootId = event.tags.filter((tag) => tag[0] === "e")[0]?.[1];
|
||||
|
||||
if (rootId) {
|
||||
if (groups.has(rootId)) {
|
||||
groups.get(rootId).push(event);
|
||||
} else {
|
||||
groups.set(rootId, [event]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}, [events]);
|
||||
|
||||
const reactions = useMemo(() => {
|
||||
const groups = new Map<string, NostrEvent[]>();
|
||||
const list = events.filter(
|
||||
(ev) => ev.kind === Kind.Repost || ev.kind === Kind.Reaction,
|
||||
);
|
||||
|
||||
for (const event of list) {
|
||||
const rootId = event.tags.filter((tag) => tag[0] === "e")[0]?.[1];
|
||||
|
||||
if (rootId) {
|
||||
if (groups.has(rootId)) {
|
||||
groups.get(rootId).push(event);
|
||||
} else {
|
||||
groups.set(rootId, [event]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}, [events]);
|
||||
|
||||
useEffect(() => {
|
||||
if (account?.length && account?.startsWith("npub1")) {
|
||||
NostrQuery.getNotifications()
|
||||
.then((data) => {
|
||||
const sorted = data.sort((a, b) => b.created_at - a.created_at);
|
||||
setEvents(sorted);
|
||||
})
|
||||
.catch((e) => console.log(e));
|
||||
}
|
||||
}, [account]);
|
||||
|
||||
useEffect(() => {
|
||||
const unlistenLoad = getCurrent().listen<EmitAccount>(
|
||||
"load-notification",
|
||||
(data) => {
|
||||
setAccount(data.payload.account);
|
||||
},
|
||||
);
|
||||
|
||||
const unlistenNewEvent = getCurrent().listen("notification", (data) => {
|
||||
const event: NostrEvent = JSON.parse(data.payload as string);
|
||||
setEvents((prev) => [event, ...prev]);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlistenLoad.then((f) => f());
|
||||
unlistenNewEvent.then((f) => f());
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!account) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center text-sm">
|
||||
Please log in.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col">
|
||||
<div className="h-11 shrink-0 flex items-center justify-between border-b border-black/5 px-4">
|
||||
<div>
|
||||
<h1 className="text-sm font-semibold">Notifications</h1>
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<User.Provider pubkey={account}>
|
||||
<User.Root>
|
||||
<User.Avatar className="size-7 rounded-full" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => LumeWindow.openSettings()}
|
||||
className="size-7 inline-flex items-center justify-center bg-black/5 dark:bg-white/5 rounded-full"
|
||||
>
|
||||
<SettingsIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Tabs.Root
|
||||
defaultValue="replies"
|
||||
className="flex-1 overflow-y-auto overflow-x-hidden scrollbar-none"
|
||||
>
|
||||
<Tabs.List className="flex items-center">
|
||||
<Tabs.Trigger
|
||||
className="flex-1 inline-flex h-8 items-center justify-center gap-2 px-2 text-sm font-medium border-b border-black/10 data-[state=active]:border-black/30 dark:data-[state=active] data-[state=inactive]:opacity-50"
|
||||
value="replies"
|
||||
>
|
||||
Replies
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger
|
||||
className="flex-1 inline-flex h-8 items-center justify-center gap-2 px-2 text-sm font-medium border-b border-black/10 data-[state=active]:border-black/30 dark:data-[state=active] data-[state=inactive]:opacity-50"
|
||||
value="reactions"
|
||||
>
|
||||
Reactions
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger
|
||||
className="flex-1 inline-flex h-8 items-center justify-center gap-2 px-2 text-sm font-medium border-b border-black/10 data-[state=active]:border-black/30 dark:data-[state=active] data-[state=inactive]:opacity-50"
|
||||
value="zaps"
|
||||
>
|
||||
Zaps
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<div className="p-2">
|
||||
<Tabs.Content value="replies" className="flex flex-col gap-2">
|
||||
{texts.map((event) => (
|
||||
<TextNote event={event} />
|
||||
))}
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="reactions" className="flex flex-col gap-2">
|
||||
{[...reactions.entries()].map(([root, events]) => (
|
||||
<div
|
||||
key={root}
|
||||
className="shrink-0 flex flex-col gap-1 rounded-lg p-2 backdrop-blur-md bg-black/10 dark:bg-white/10"
|
||||
>
|
||||
<div className="min-w-0 flex-1 flex flex-col gap-2">
|
||||
<div className="pb-2 border-b border-black/5 dark:border-white/5 flex items-center gap-2">
|
||||
<RootNote id={root} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{events.map((event) => (
|
||||
<User.Provider pubkey={event.pubkey}>
|
||||
<User.Root className="shrink-0 flex rounded-full h-8 bg-black/10 dark:bg-white/10 backdrop-blur-md p-[2px]">
|
||||
<User.Avatar className="flex-1 size-7 rounded-full" />
|
||||
<div className="flex-1 size-7 rounded-full inline-flex items-center justify-center text-xs truncate">
|
||||
{event.kind === Kind.Reaction ? (
|
||||
event.content === "+" ? (
|
||||
"👍"
|
||||
) : (
|
||||
event.content
|
||||
)
|
||||
) : (
|
||||
<RepostIcon className="size-4 dark:text-teal-600 text-teal-400" />
|
||||
)}
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="zaps" className="flex flex-col gap-2">
|
||||
{[...zaps.entries()].map(([root, events]) => (
|
||||
<div
|
||||
key={root}
|
||||
className="shrink-0 flex flex-col gap-1 rounded-lg p-2 backdrop-blur-md bg-black/10 dark:bg-white/10"
|
||||
>
|
||||
<div className="min-w-0 flex-1 flex flex-col gap-2">
|
||||
<div className="pb-2 border-b border-black/5 dark:border-white/5 flex items-center gap-2">
|
||||
<RootNote id={root} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{events.map((event) => (
|
||||
<User.Provider
|
||||
pubkey={event.tags.find((tag) => tag[0] == "P")[1]}
|
||||
>
|
||||
<User.Root className="shrink-0 flex gap-1.5 rounded-full h-8 bg-black/10 dark:bg-white/10 backdrop-blur-md p-[2px]">
|
||||
<User.Avatar className="flex-1 size-7 rounded-full" />
|
||||
<div className="flex-1 h-7 w-max pr-1.5 rounded-full inline-flex items-center justify-center text-sm truncate">
|
||||
₿ {decodeZapInvoice(event.tags).bitcoinFormatted}
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Tabs.Content>
|
||||
</div>
|
||||
</Tabs.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RootNote({ id }: { id: string }) {
|
||||
const { isLoading, isError, data } = useEvent(id);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="pb-2 mb-2 flex items-center">
|
||||
<div className="size-8 shrink-0 rounded-full bg-black/20 dark:bg-white/20 animate-pulse" />
|
||||
<div className="animate-pulse rounded-md h-4 w-2/3 bg-black/20 dark:bg-white/20" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="size-8 shrink-0 rounded-full bg-red-500 text-white inline-flex items-center justify-center">
|
||||
<InfoIcon className="size-5" />
|
||||
</div>
|
||||
<p className="text-red-500 text-sm">
|
||||
Event not found with your current relay set
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Note.Provider event={data}>
|
||||
<Note.Root className="flex items-center gap-2">
|
||||
<User.Provider pubkey={data.pubkey}>
|
||||
<User.Root className="shrink-0">
|
||||
<User.Avatar className="size-8 shrink-0 rounded-full" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<div className="line-clamp-1">{data.content}</div>
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function TextNote({ event }: { event: NostrEvent }) {
|
||||
const pTags = event.tags
|
||||
.filter((tag) => tag[0] === "p")
|
||||
.map((tag) => tag[1])
|
||||
.slice(0, 3);
|
||||
|
||||
return (
|
||||
<Note.Provider event={event}>
|
||||
<Note.Root className="shrink-0 flex flex-col gap-1 rounded-lg p-2 backdrop-blur-md bg-black/10 dark:bg-white/10">
|
||||
<User.Provider pubkey={event.pubkey}>
|
||||
<User.Root className="inline-flex items-center gap-2">
|
||||
<User.Avatar className="size-9 shrink-0 rounded-full" />
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="w-full flex items-baseline justify-between">
|
||||
<User.Name className="leading-tight text-sm font-semibold" />
|
||||
<span className="leading-tight text-sm text-black/50 dark:text-white/50">
|
||||
{formatCreatedAt(event.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="inline-flex items-baseline gap-1 text-xs">
|
||||
<span className="leading-tight text-black/50 dark:text-white/50">
|
||||
Reply to:
|
||||
</span>
|
||||
<div className="inline-flex items-baseline gap-1">
|
||||
{pTags.map((replyTo) => (
|
||||
<User.Provider pubkey={replyTo}>
|
||||
<User.Root>
|
||||
<User.Name className="leading-tight font-medium" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<div className="flex gap-2">
|
||||
<div className="w-9 shrink-0" />
|
||||
<div className="line-clamp-1">{event.content}</div>
|
||||
</div>
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user