wip: tired...

This commit is contained in:
2024-02-14 15:51:06 +07:00
parent 60fd09000b
commit 6171b9bed1
87 changed files with 2380 additions and 3667 deletions

View File

@@ -6,8 +6,7 @@ import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { minidenticon } from "minidenticons";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Logout } from "./logout";
import { Link } from "@tanstack/react-router";
import { LogoutDialog } from "./logoutDialog";
export function ActiveAccount() {
const ark = useArk();
@@ -26,58 +25,55 @@ export function ActiveAccount() {
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<div className="relative">
<Avatar.Root>
<Avatar.Image
src={user?.picture}
alt={ark.account.npub}
loading="lazy"
decoding="async"
style={{ contentVisibility: "auto" }}
className="aspect-square h-auto w-full rounded-xl object-cover"
/>
<Avatar.Fallback delayMs={150}>
<img
src={svgURI}
alt={ark.account.npub}
className="aspect-square h-auto w-full rounded-xl bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<span
className={cn(
"absolute bottom-0 right-0 block size-3 rounded-full ring-2 ring-neutral-200 dark:ring-neutral-800",
isOnline ? "bg-teal-500" : "bg-red-500",
)}
<Avatar.Root
className={cn(
"rounded-full ring-1 ring-offset-2 ring-offset-neutral-200 dark:ring-offset-neutral-950",
isOnline ? "ring-teal-500" : "ring-red-500",
)}
>
<Avatar.Image
src={user?.picture}
alt={ark.account.npub}
loading="lazy"
decoding="async"
style={{ contentVisibility: "auto" }}
className="aspect-square h-auto w-7 rounded-lg object-cover"
/>
</div>
<Avatar.Fallback delayMs={150}>
<img
src={svgURI}
alt={ark.account.npub}
className="aspect-square h-auto w-7 rounded-full bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
side="right"
sideOffset={5}
className="relative top-5 flex w-[200px] flex-col overflow-hidden rounded-2xl bg-white/50 p-2 ring-1 ring-black/10 backdrop-blur-2xl focus:outline-none dark:bg-black/50 dark:ring-white/10"
side="left"
sideOffset={10}
className="relative top-2 flex w-[200px] flex-col overflow-hidden rounded-2xl bg-white/50 p-2 ring-1 ring-black/10 backdrop-blur-2xl focus:outline-none dark:bg-black/50 dark:ring-white/10"
>
<DropdownMenu.Item asChild>
<Link
to="/settings/profile"
<a
href="/settings/profile"
className="inline-flex h-9 items-center gap-3 rounded-lg px-3 text-sm font-medium text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
>
<UserIcon className="size-4" />
{t("user.editProfile")}
</Link>
</a>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<Link
to="/settings/"
<a
href="/settings/"
className="inline-flex h-9 items-center gap-3 rounded-lg px-3 text-sm font-medium text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
>
<SettingsIcon className="size-4" />
{t("user.settings")}
</Link>
</a>
</DropdownMenu.Item>
<DropdownMenu.Separator className="my-1 h-px bg-black/10 dark:bg-white/10" />
<Logout />
<LogoutDialog />
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>

View File

@@ -6,7 +6,7 @@ import { useNavigate } from "@tanstack/react-router";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
export function Logout() {
export function LogoutDialog() {
const ark = useArk();
const queryClient = useQueryClient();
const navigate = useNavigate();

View File

@@ -0,0 +1,6 @@
import { ReactNode } from "react";
import { Routes } from "react-router-dom";
export function ColumnContent({ children }: { children: ReactNode }) {
return <Routes>{children}</Routes>;
}

View File

@@ -0,0 +1,89 @@
import {
ChevronDownIcon,
MoveLeftIcon,
MoveRightIcon,
RefreshIcon,
TrashIcon,
} from "@lume/icons";
import { useColumn } from "@lume/storage";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useColumnContext } from "./provider";
export function ColumnHeader({
queryKey,
}: {
queryKey?: string[];
}) {
const { t } = useTranslation();
const { move, remove } = useColumn();
const column = useColumnContext();
const queryClient = useQueryClient();
const refresh = async () => {
if (queryKey) await queryClient.refetchQueries({ queryKey });
};
return (
<DropdownMenu.Root>
<div className="flex items-center justify-center gap-2 px-3 w-full border-b h-11 shrink-0 border-neutral-100 dark:border-neutral-900">
<DropdownMenu.Trigger asChild>
<div className="inline-flex items-center gap-1.5">
<div className="text-[13px] font-medium">{column.title}</div>
<ChevronDownIcon className="size-5" />
</div>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
sideOffset={5}
className="flex w-[200px] p-2 flex-col overflow-hidden rounded-2xl bg-white/50 dark:bg-black/50 ring-1 ring-black/10 dark:ring-white/10 backdrop-blur-2xl focus:outline-none"
>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={refresh}
className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
>
<RefreshIcon className="size-4" />
{t("global.refresh")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => move(column.id, "left")}
className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
>
<MoveLeftIcon className="size-4" />
{t("global.moveLeft")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => move(column.id, "right")}
className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
>
<MoveRightIcon className="size-4" />
{t("global.moveRight")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Separator className="h-px my-1 bg-black/10 dark:bg-white/10" />
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => remove(column.id)}
className="inline-flex items-center gap-3 px-3 text-sm font-medium text-red-500 rounded-lg h-9 hover:bg-red-500 hover:text-red-50 focus:outline-none"
>
<TrashIcon className="size-4" />
{t("global.delete")}
</button>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</div>
</DropdownMenu.Root>
);
}

View File

@@ -0,0 +1,15 @@
import { Route } from "react-router-dom";
import { ColumnContent } from "./content";
import { ColumnHeader } from "./header";
import { ColumnLiveWidget } from "./live";
import { ColumnProvider } from "./provider";
import { ColumnRoot } from "./root";
export const Column = {
Provider: ColumnProvider,
Root: ColumnRoot,
Live: ColumnLiveWidget,
Header: ColumnHeader,
Content: ColumnContent,
Route: Route,
};

View File

@@ -0,0 +1,46 @@
import { ArrowUpIcon } from "@lume/icons";
import { useEffect, useState } from "react";
export function ColumnLiveWidget({
filter,
onClick,
}: {
filter: NDKFilter;
onClick: (event: NDKEvent[]) => void;
}) {
const ark = useArk();
const [events, setEvents] = useState<NDKEvent[]>([]);
const update = async () => {
onClick(events);
// reset
setEvents([]);
};
useEffect(() => {
const sub = ark.subscribe({
filter,
closeOnEose: false,
cb: (event: NDKEvent) => setEvents((prev) => [...prev, event]),
});
return () => {
if (sub) sub.stop();
};
}, []);
if (!events.length) return null;
return (
<div className="absolute left-0 z-40 flex items-center justify-center w-full top-12 h-11">
<button
type="button"
onClick={update}
className="inline-flex items-center justify-center h-9 gap-1 pl-4 pr-3 text-sm font-semibold rounded-full w-max bg-neutral-950 dark:bg-neutral-50 hover:bg-neutral-900 dark:hover:bg-neutral-100 text-neutral-50 dark:text-neutral-950"
>
{events.length} {events.length === 1 ? "new note" : "new notes"}
<ArrowUpIcon className="size-4" />
</button>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { LumeColumn } from "@lume/types";
import { ReactNode, createContext, useContext } from "react";
const ColumnContext = createContext<LumeColumn>(null);
export function ColumnProvider({
column,
children,
}: { column: LumeColumn; children: ReactNode }) {
return (
<ColumnContext.Provider value={column}>{children}</ColumnContext.Provider>
);
}
export function useColumnContext() {
const context = useContext(ColumnContext);
if (!context) {
throw new Error(
"Please import Column Provider to use useColumnContext() hook",
);
}
return context;
}

View File

@@ -0,0 +1,37 @@
import { cn } from "@lume/utils";
import { Resizable } from "re-resizable";
import { ReactNode, useState } from "react";
import { MemoryRouter, UNSAFE_LocationContext } from "react-router-dom";
export function ColumnRoot({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
const [width, setWidth] = useState(420);
return (
<UNSAFE_LocationContext.Provider value={null}>
<Resizable
size={{ width, height: "100%" }}
onResizeStart={(e) => e.preventDefault()}
onResizeStop={(_e, _direction, _ref, d) => {
setWidth((prevWidth) => prevWidth + d.width);
}}
minWidth={420}
maxWidth={600}
className={cn(
"relative flex flex-col border-r-2 border-neutral-50 hover:border-neutral-100 dark:border-neutral-950 dark:hover:border-neutral-900",
className,
)}
enable={{ right: true }}
>
<MemoryRouter future={{ v7_startTransition: true }}>
{children}
</MemoryRouter>
</Resizable>
</UNSAFE_LocationContext.Provider>
);
}

View File

@@ -1,263 +1,262 @@
import { MentionNote, User, useArk, useColumnContext } from "@lume/ark";
import { useArk } from "@lume/ark";
import { LoaderIcon, TrashIcon } from "@lume/icons";
import { useStorage } from "@lume/storage";
import { NDKCacheUserProfile } from "@lume/types";
import { COL_TYPES, cn, editorValueAtom } from "@lume/utils";
import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk";
import { cn, editorValueAtom } from "@lume/utils";
import { invoke } from "@tauri-apps/api/core";
import { useAtom } from "jotai";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Descendant,
Editor,
Node,
Range,
Transforms,
createEditor,
Descendant,
Editor,
Node,
Range,
Transforms,
createEditor,
} from "slate";
import {
Editable,
ReactEditor,
Slate,
useFocused,
useSelected,
useSlateStatic,
withReact,
Editable,
ReactEditor,
Slate,
useFocused,
useSelected,
useSlateStatic,
withReact,
} from "slate-react";
import { toast } from "sonner";
import { EditorAddMedia } from "./addMedia";
import {
Portal,
insertImage,
insertMention,
insertNostrEvent,
isImageUrl,
Portal,
insertImage,
insertMention,
insertNostrEvent,
isImageUrl,
} from "./utils";
import { MentionNote } from "../note/mentions/note";
const withNostrEvent = (editor: ReactEditor) => {
const { insertData, isVoid } = editor;
const { insertData, isVoid } = editor;
editor.isVoid = (element) => {
// @ts-expect-error, wtf
return element.type === "event" ? true : isVoid(element);
};
editor.isVoid = (element) => {
// @ts-expect-error, wtf
return element.type === "event" ? true : isVoid(element);
};
editor.insertData = (data) => {
const text = data.getData("text/plain");
editor.insertData = (data) => {
const text = data.getData("text/plain");
if (text.startsWith("nevent1") || text.startsWith("note1")) {
insertNostrEvent(editor, text);
} else {
insertData(data);
}
};
if (text.startsWith("nevent1") || text.startsWith("note1")) {
insertNostrEvent(editor, text);
} else {
insertData(data);
}
};
return editor;
return editor;
};
const withMentions = (editor: ReactEditor) => {
const { isInline, isVoid, markableVoid } = editor;
const { isInline, isVoid, markableVoid } = editor;
editor.isInline = (element) => {
// @ts-expect-error, wtf
return element.type === "mention" ? true : isInline(element);
};
editor.isInline = (element) => {
// @ts-expect-error, wtf
return element.type === "mention" ? true : isInline(element);
};
editor.isVoid = (element) => {
// @ts-expect-error, wtf
return element.type === "mention" ? true : isVoid(element);
};
editor.isVoid = (element) => {
// @ts-expect-error, wtf
return element.type === "mention" ? true : isVoid(element);
};
editor.markableVoid = (element) => {
// @ts-expect-error, wtf
return element.type === "mention" || markableVoid(element);
};
editor.markableVoid = (element) => {
// @ts-expect-error, wtf
return element.type === "mention" || markableVoid(element);
};
return editor;
return editor;
};
const withImages = (editor: ReactEditor) => {
const { insertData, isVoid } = editor;
const { insertData, isVoid } = editor;
editor.isVoid = (element) => {
// @ts-expect-error, wtf
return element.type === "image" ? true : isVoid(element);
};
editor.isVoid = (element) => {
// @ts-expect-error, wtf
return element.type === "image" ? true : isVoid(element);
};
editor.insertData = (data) => {
const text = data.getData("text/plain");
editor.insertData = (data) => {
const text = data.getData("text/plain");
if (isImageUrl(text)) {
insertImage(editor, text);
} else {
insertData(data);
}
};
if (isImageUrl(text)) {
insertImage(editor, text);
} else {
insertData(data);
}
};
return editor;
return editor;
};
const Image = ({ attributes, children, element }) => {
const editor = useSlateStatic();
const path = ReactEditor.findPath(editor as ReactEditor, element);
const editor = useSlateStatic();
const path = ReactEditor.findPath(editor as ReactEditor, element);
const selected = useSelected();
const focused = useFocused();
const selected = useSelected();
const focused = useFocused();
return (
<div {...attributes}>
{children}
<div contentEditable={false} className="relative my-2">
<img
src={element.url}
alt={element.url}
className={cn(
"object-cover w-full h-auto border rounded-lg border-neutral-100 dark:border-neutral-900 ring-2",
selected && focused ? "ring-blue-500" : "ring-transparent",
)}
contentEditable={false}
/>
<button
type="button"
contentEditable={false}
onClick={() => Transforms.removeNodes(editor, { at: path })}
className="absolute inline-flex items-center justify-center text-white bg-red-500 rounded-lg top-2 right-2 size-8 hover:bg-red-600"
>
<TrashIcon className="size-4" />
</button>
</div>
</div>
);
return (
<div {...attributes}>
{children}
<div contentEditable={false} className="relative my-2">
<img
src={element.url}
alt={element.url}
className={cn(
"h-auto w-full rounded-lg border border-neutral-100 object-cover ring-2 dark:border-neutral-900",
selected && focused ? "ring-blue-500" : "ring-transparent",
)}
contentEditable={false}
/>
<button
type="button"
contentEditable={false}
onClick={() => Transforms.removeNodes(editor, { at: path })}
className="absolute right-2 top-2 inline-flex size-8 items-center justify-center rounded-lg bg-red-500 text-white hover:bg-red-600"
>
<TrashIcon className="size-4" />
</button>
</div>
</div>
);
};
const Mention = ({ attributes, element }) => {
const editor = useSlateStatic();
const path = ReactEditor.findPath(editor as ReactEditor, element);
const editor = useSlateStatic();
const path = ReactEditor.findPath(editor as ReactEditor, element);
return (
<span
{...attributes}
type="button"
contentEditable={false}
onClick={() => Transforms.removeNodes(editor, { at: path })}
className="inline-block text-blue-500 align-baseline hover:text-blue-600"
>{`@${element.name}`}</span>
);
return (
<span
{...attributes}
type="button"
contentEditable={false}
onClick={() => Transforms.removeNodes(editor, { at: path })}
className="inline-block align-baseline text-blue-500 hover:text-blue-600"
>{`@${element.name}`}</span>
);
};
const Event = ({ attributes, element, children }) => {
const editor = useSlateStatic();
const path = ReactEditor.findPath(editor as ReactEditor, element);
const editor = useSlateStatic();
const path = ReactEditor.findPath(editor as ReactEditor, element);
return (
<div {...attributes}>
{children}
{/* biome-ignore lint/a11y/useKeyWithClickEvents: <explanation> */}
<div
contentEditable={false}
onClick={() => Transforms.removeNodes(editor, { at: path })}
className="relative user-select-none my-2"
>
<MentionNote
eventId={element.eventId.replace("nostr:", "")}
openable={false}
/>
</div>
</div>
);
return (
<div {...attributes}>
{children}
{/* biome-ignore lint/a11y/useKeyWithClickEvents: <explanation> */}
<div
contentEditable={false}
onClick={() => Transforms.removeNodes(editor, { at: path })}
className="user-select-none relative my-2"
>
<MentionNote
eventId={element.eventId.replace("nostr:", "")}
openable={false}
/>
</div>
</div>
);
};
const Element = (props) => {
const { attributes, children, element } = props;
const { attributes, children, element } = props;
switch (element.type) {
case "image":
return <Image {...props} />;
case "mention":
return <Mention {...props} />;
case "event":
return <Event {...props} />;
default:
return (
<p {...attributes} className="text-lg">
{children}
</p>
);
}
switch (element.type) {
case "image":
return <Image {...props} />;
case "mention":
return <Mention {...props} />;
case "event":
return <Event {...props} />;
default:
return (
<p {...attributes} className="text-lg">
{children}
</p>
);
}
};
export function EditorForm() {
const ref = useRef<HTMLDivElement | null>();
const ref = useRef<HTMLDivElement | null>();
const [editorValue, setEditorValue] = useAtom(editorValueAtom);
const [contacts, setContacts] = useState<NDKCacheUserProfile[]>([]);
const [target, setTarget] = useState<Range | undefined>();
const [index, setIndex] = useState(0);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(false);
const [editor] = useState(() =>
withMentions(withNostrEvent(withImages(withReact(createEditor())))),
);
const [editorValue, setEditorValue] = useAtom(editorValueAtom);
const [contacts, setContacts] = useState<NDKCacheUserProfile[]>([]);
const [target, setTarget] = useState<Range | undefined>();
const [index, setIndex] = useState(0);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(false);
const [editor] = useState(() =>
withMentions(withNostrEvent(withImages(withReact(createEditor())))),
);
const { t } = useTranslation();
const { t } = useTranslation();
const filters = contacts
?.filter((c) => c?.name?.toLowerCase().startsWith(search.toLowerCase()))
?.slice(0, 10);
const filters = contacts
?.filter((c) => c?.name?.toLowerCase().startsWith(search.toLowerCase()))
?.slice(0, 10);
const reset = () => {
// @ts-expect-error, backlog
editor.children = [{ type: "paragraph", children: [{ text: "" }] }];
setEditorValue([{ type: "paragraph", children: [{ text: "" }] }]);
};
const reset = () => {
// @ts-expect-error, backlog
editor.children = [{ type: "paragraph", children: [{ text: "" }] }];
setEditorValue([{ type: "paragraph", children: [{ text: "" }] }]);
};
const serialize = (nodes: Descendant[]) => {
return nodes
.map((n) => {
// @ts-expect-error, backlog
if (n.type === "image") return n.url;
// @ts-expect-error, backlog
if (n.type === "event") return n.eventId;
const serialize = (nodes: Descendant[]) => {
return nodes
.map((n) => {
// @ts-expect-error, backlog
if (n.type === "image") return n.url;
// @ts-expect-error, backlog
if (n.type === "event") return n.eventId;
// @ts-expect-error, backlog
if (n.children.length) {
// @ts-expect-error, backlog
return n.children
.map((n) => {
if (n.type === "mention") return n.npub;
return Node.string(n).trim();
})
.join(" ");
}
// @ts-expect-error, backlog
if (n.children.length) {
// @ts-expect-error, backlog
return n.children
.map((n) => {
if (n.type === "mention") return n.npub;
return Node.string(n).trim();
})
.join(" ");
}
return Node.string(n);
})
.join("\n");
};
return Node.string(n);
})
.join("\n");
};
const submit = async () => {
try {
setLoading(true);
const submit = async () => {
try {
setLoading(true);
const content = serialize(editor.children);
const publish = await invoke("publish", { content });
const content = serialize(editor.children);
const publish = await invoke("publish", { content });
if (publish) {
console.log(publish);
toast.success(t("editor.successMessage"));
if (publish) {
console.log(publish);
toast.success(t("editor.successMessage"));
return reset();
}
return reset();
}
setLoading(false);
} catch (e) {
setLoading(false);
toast.error(String(e));
}
};
setLoading(false);
} catch (e) {
setLoading(false);
toast.error(String(e));
}
};
/*
/*
useEffect(() => {
async function loadContacts() {
const res = await storage.getAllCacheUsers();
@@ -268,113 +267,113 @@ export function EditorForm() {
}, []);
*/
useEffect(() => {
if (target && filters.length > 0) {
const el = ref.current;
const domRange = ReactEditor.toDOMRange(editor, target);
const rect = domRange.getBoundingClientRect();
el.style.top = `${rect.top + window.pageYOffset + 24}px`;
el.style.left = `${rect.left + window.pageXOffset}px`;
}
}, [filters.length, editor, index, search, target]);
useEffect(() => {
if (target && filters.length > 0) {
const el = ref.current;
const domRange = ReactEditor.toDOMRange(editor, target);
const rect = domRange.getBoundingClientRect();
el.style.top = `${rect.top + window.pageYOffset + 24}px`;
el.style.left = `${rect.left + window.pageXOffset}px`;
}
}, [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-none dark:ring-1 dark:ring-white/10">
<Slate
editor={editor}
initialValue={editorValue}
onChange={() => {
const { selection } = editor;
return (
<div className="flex h-full w-full flex-col justify-between overflow-hidden rounded-xl 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}
onChange={() => {
const { selection } = editor;
if (selection && Range.isCollapsed(selection)) {
const [start] = Range.edges(selection);
const wordBefore = Editor.before(editor, start, { unit: "word" });
const before = wordBefore && Editor.before(editor, wordBefore);
const beforeRange = before && Editor.range(editor, before, start);
const beforeText =
beforeRange && Editor.string(editor, beforeRange);
const beforeMatch = beforeText?.match(/^@(\w+)$/);
const after = Editor.after(editor, start);
const afterRange = Editor.range(editor, start, after);
const afterText = Editor.string(editor, afterRange);
const afterMatch = afterText.match(/^(\s|$)/);
if (selection && Range.isCollapsed(selection)) {
const [start] = Range.edges(selection);
const wordBefore = Editor.before(editor, start, { unit: "word" });
const before = wordBefore && Editor.before(editor, wordBefore);
const beforeRange = before && Editor.range(editor, before, start);
const beforeText =
beforeRange && Editor.string(editor, beforeRange);
const beforeMatch = beforeText?.match(/^@(\w+)$/);
const after = Editor.after(editor, start);
const afterRange = Editor.range(editor, start, after);
const afterText = Editor.string(editor, afterRange);
const afterMatch = afterText.match(/^(\s|$)/);
if (beforeMatch && afterMatch) {
setTarget(beforeRange);
setSearch(beforeMatch[1]);
setIndex(0);
return;
}
}
if (beforeMatch && afterMatch) {
setTarget(beforeRange);
setSearch(beforeMatch[1]);
setIndex(0);
return;
}
}
setTarget(null);
}}
>
<div className="flex items-center justify-between h-16 pl-7 pr-3 border-b shrink-0 border-neutral-100 dark:border-neutral-900 bg-neutral-50 dark:bg-neutral-950">
<div>
<h3 className="font-medium">{t("editor.title")}</h3>
</div>
<div className="flex items-center">
<div className="inline-flex items-center gap-2">
<EditorAddMedia />
</div>
<div className="w-px h-6 mx-3 bg-neutral-200 dark:bg-neutral-800" />
<button
type="button"
onClick={submit}
className="inline-flex items-center justify-center w-20 pb-[2px] font-semibold border-t rounded-lg border-neutral-900 dark:border-neutral-800 h-9 bg-neutral-950 text-neutral-50 dark:bg-neutral-900 hover:bg-neutral-900 dark:hover:bg-neutral-800"
>
{loading ? (
<LoaderIcon className="size-4 animate-spin" />
) : (
t("global.post")
)}
</button>
</div>
</div>
<div className="py-6 h-full overflow-y-auto px-7">
<Editable
key={JSON.stringify(editorValue)}
autoFocus={true}
autoCapitalize="none"
autoCorrect="none"
spellCheck={false}
renderElement={(props) => <Element {...props} />}
placeholder={t("editor.placeholder")}
className="focus:outline-none"
/>
{target && filters.length > 0 && (
<Portal>
<div
ref={ref}
className="top-[-9999px] left-[-9999px] absolute z-10 w-[250px] p-2 bg-white border border-neutral-50 dark:border-neutral-900 dark:bg-neutral-950 rounded-xl shadow-lg"
>
{filters.map((contact, i) => (
<button
key={contact.npub}
type="button"
onClick={() => {
Transforms.select(editor, target);
insertMention(editor, contact);
setTarget(null);
}}
className="p-2 flex flex-col w-full rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-900"
>
<User.Provider pubkey={contact.npub}>
<User.Root className="w-full flex items-center gap-2.5">
<User.Avatar className="size-8 rounded-lg object-cover shrink-0" />
<div className="flex w-full flex-col items-start">
<User.Name className="max-w-[8rem] truncate text-sm font-medium" />
</div>
</User.Root>
</User.Provider>
</button>
))}
</div>
</Portal>
)}
</div>
</Slate>
</div>
);
setTarget(null);
}}
>
<div className="flex h-16 shrink-0 items-center justify-between border-b border-neutral-100 bg-neutral-50 pl-7 pr-3 dark:border-neutral-900 dark:bg-neutral-950">
<div>
<h3 className="font-medium">{t("editor.title")}</h3>
</div>
<div className="flex items-center">
<div className="inline-flex items-center gap-2">
<EditorAddMedia />
</div>
<div className="mx-3 h-6 w-px bg-neutral-200 dark:bg-neutral-800" />
<button
type="button"
onClick={submit}
className="inline-flex h-9 w-20 items-center justify-center rounded-lg border-t border-neutral-900 bg-neutral-950 pb-[2px] font-semibold text-neutral-50 hover:bg-neutral-900 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
{loading ? (
<LoaderIcon className="size-4 animate-spin" />
) : (
t("global.post")
)}
</button>
</div>
</div>
<div className="h-full overflow-y-auto px-7 py-6">
<Editable
key={JSON.stringify(editorValue)}
autoFocus={true}
autoCapitalize="none"
autoCorrect="none"
spellCheck={false}
renderElement={(props) => <Element {...props} />}
placeholder={t("editor.placeholder")}
className="focus:outline-none"
/>
{target && filters.length > 0 && (
<Portal>
<div
ref={ref}
className="absolute left-[-9999px] top-[-9999px] z-10 w-[250px] rounded-xl border border-neutral-50 bg-white p-2 shadow-lg dark:border-neutral-900 dark:bg-neutral-950"
>
{filters.map((contact, i) => (
<button
key={contact.npub}
type="button"
onClick={() => {
Transforms.select(editor, target);
insertMention(editor, contact);
setTarget(null);
}}
className="flex w-full flex-col rounded-lg p-2 hover:bg-neutral-100 dark:hover:bg-neutral-900"
>
<User.Provider pubkey={contact.npub}>
<User.Root className="flex w-full items-center gap-2.5">
<User.Avatar className="size-8 shrink-0 rounded-lg object-cover" />
<div className="flex w-full flex-col items-start">
<User.Name className="max-w-[8rem] truncate text-sm font-medium" />
</div>
</User.Root>
</User.Provider>
</button>
))}
</div>
</Portal>
)}
</div>
</Slate>
</div>
);
}

View File

@@ -1,4 +1,3 @@
import { MentionNote, User, useArk } from "@lume/ark";
import { LoaderIcon, TrashIcon } from "@lume/icons";
import { useStorage } from "@lume/storage";
import { NDKCacheUserProfile } from "@lume/types";
@@ -8,391 +7,397 @@ import { Portal } from "@radix-ui/react-dropdown-menu";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Descendant,
Editor,
Node,
Range,
Transforms,
createEditor,
Descendant,
Editor,
Node,
Range,
Transforms,
createEditor,
} from "slate";
import {
Editable,
ReactEditor,
Slate,
useFocused,
useSelected,
useSlateStatic,
withReact,
Editable,
ReactEditor,
Slate,
useFocused,
useSelected,
useSlateStatic,
withReact,
} from "slate-react";
import { toast } from "sonner";
import { EditorAddMedia } from "./addMedia";
import {
insertImage,
insertMention,
insertNostrEvent,
isImageUrl,
insertImage,
insertMention,
insertNostrEvent,
isImageUrl,
} from "./utils";
import { MentionNote } from "../note/mentions/note";
import { useArk } from "@lume/ark";
import { User } from "../user";
const withNostrEvent = (editor: ReactEditor) => {
const { insertData, isVoid } = editor;
const { insertData, isVoid } = editor;
editor.isVoid = (element) => {
// @ts-expect-error, wtf
return element.type === "event" ? true : isVoid(element);
};
editor.isVoid = (element) => {
// @ts-expect-error, wtf
return element.type === "event" ? true : isVoid(element);
};
editor.insertData = (data) => {
const text = data.getData("text/plain");
editor.insertData = (data) => {
const text = data.getData("text/plain");
if (text.startsWith("nevent1") || text.startsWith("note1")) {
insertNostrEvent(editor, text);
} else {
insertData(data);
}
};
if (text.startsWith("nevent1") || text.startsWith("note1")) {
insertNostrEvent(editor, text);
} else {
insertData(data);
}
};
return editor;
return editor;
};
const withMentions = (editor: ReactEditor) => {
const { isInline, isVoid, markableVoid } = editor;
const { isInline, isVoid, markableVoid } = editor;
editor.isInline = (element) => {
// @ts-expect-error, wtf
return element.type === "mention" ? true : isInline(element);
};
editor.isInline = (element) => {
// @ts-expect-error, wtf
return element.type === "mention" ? true : isInline(element);
};
editor.isVoid = (element) => {
// @ts-expect-error, wtf
return element.type === "mention" ? true : isVoid(element);
};
editor.isVoid = (element) => {
// @ts-expect-error, wtf
return element.type === "mention" ? true : isVoid(element);
};
editor.markableVoid = (element) => {
// @ts-expect-error, wtf
return element.type === "mention" || markableVoid(element);
};
editor.markableVoid = (element) => {
// @ts-expect-error, wtf
return element.type === "mention" || markableVoid(element);
};
return editor;
return editor;
};
const withImages = (editor: ReactEditor) => {
const { insertData, isVoid } = editor;
const { insertData, isVoid } = editor;
editor.isVoid = (element) => {
// @ts-expect-error, wtf
return element.type === "image" ? true : isVoid(element);
};
editor.isVoid = (element) => {
// @ts-expect-error, wtf
return element.type === "image" ? true : isVoid(element);
};
editor.insertData = (data) => {
const text = data.getData("text/plain");
editor.insertData = (data) => {
const text = data.getData("text/plain");
if (isImageUrl(text)) {
insertImage(editor, text);
} else {
insertData(data);
}
};
if (isImageUrl(text)) {
insertImage(editor, text);
} else {
insertData(data);
}
};
return editor;
return editor;
};
const Image = ({ attributes, children, element }) => {
const editor = useSlateStatic();
const path = ReactEditor.findPath(editor as ReactEditor, element);
const editor = useSlateStatic();
const path = ReactEditor.findPath(editor as ReactEditor, element);
const selected = useSelected();
const focused = useFocused();
const selected = useSelected();
const focused = useFocused();
return (
<div {...attributes}>
{children}
<div contentEditable={false} className="relative">
<img
src={element.url}
alt={element.url}
className={cn(
"object-cover w-full h-auto border rounded-lg border-neutral-100 dark:border-neutral-900 ring-2",
selected && focused ? "ring-blue-500" : "ring-transparent",
)}
contentEditable={false}
/>
<button
type="button"
contentEditable={false}
onClick={() => Transforms.removeNodes(editor, { at: path })}
className="absolute inline-flex items-center justify-center text-white bg-red-500 rounded-lg top-2 right-2 size-8 hover:bg-red-600"
>
<TrashIcon className="size-4" />
</button>
</div>
</div>
);
return (
<div {...attributes}>
{children}
<div contentEditable={false} className="relative">
<img
src={element.url}
alt={element.url}
className={cn(
"h-auto w-full rounded-lg border border-neutral-100 object-cover ring-2 dark:border-neutral-900",
selected && focused ? "ring-blue-500" : "ring-transparent",
)}
contentEditable={false}
/>
<button
type="button"
contentEditable={false}
onClick={() => Transforms.removeNodes(editor, { at: path })}
className="absolute right-2 top-2 inline-flex size-8 items-center justify-center rounded-lg bg-red-500 text-white hover:bg-red-600"
>
<TrashIcon className="size-4" />
</button>
</div>
</div>
);
};
const Mention = ({ attributes, element }) => {
const editor = useSlateStatic();
const path = ReactEditor.findPath(editor as ReactEditor, element);
const editor = useSlateStatic();
const path = ReactEditor.findPath(editor as ReactEditor, element);
return (
<span
{...attributes}
type="button"
contentEditable={false}
onClick={() => Transforms.removeNodes(editor, { at: path })}
className="inline-block text-blue-500 align-baseline hover:text-blue-600"
>{`@${element.name}`}</span>
);
return (
<span
{...attributes}
type="button"
contentEditable={false}
onClick={() => Transforms.removeNodes(editor, { at: path })}
className="inline-block align-baseline text-blue-500 hover:text-blue-600"
>{`@${element.name}`}</span>
);
};
const Event = ({ attributes, element, children }) => {
const editor = useSlateStatic();
const path = ReactEditor.findPath(editor as ReactEditor, element);
const editor = useSlateStatic();
const path = ReactEditor.findPath(editor as ReactEditor, element);
return (
<div {...attributes}>
{children}
{/* biome-ignore lint/a11y/useKeyWithClickEvents: <explanation> */}
<div
contentEditable={false}
onClick={() => Transforms.removeNodes(editor, { at: path })}
className="relative user-select-none"
>
<MentionNote
eventId={element.eventId.replace("nostr:", "")}
openable={false}
/>
</div>
</div>
);
return (
<div {...attributes}>
{children}
{/* biome-ignore lint/a11y/useKeyWithClickEvents: <explanation> */}
<div
contentEditable={false}
onClick={() => Transforms.removeNodes(editor, { at: path })}
className="user-select-none relative"
>
<MentionNote
eventId={element.eventId.replace("nostr:", "")}
openable={false}
/>
</div>
</div>
);
};
const Element = (props) => {
const { attributes, children, element } = props;
const { attributes, children, element } = props;
switch (element.type) {
case "image":
return <Image {...props} />;
case "mention":
return <Mention {...props} />;
case "event":
return <Event {...props} />;
default:
return (
<p {...attributes} className="text-lg">
{children}
</p>
);
}
switch (element.type) {
case "image":
return <Image {...props} />;
case "mention":
return <Mention {...props} />;
case "event":
return <Event {...props} />;
default:
return (
<p {...attributes} className="text-lg">
{children}
</p>
);
}
};
export function ReplyForm({
eventId,
className,
}: { eventId: string; className?: string }) {
const ark = useArk();
const storage = useStorage();
const ref = useRef<HTMLDivElement | null>();
eventId,
className,
}: {
eventId: string;
className?: string;
}) {
const ark = useArk();
const storage = useStorage();
const ref = useRef<HTMLDivElement | null>();
const [editorValue, setEditorValue] = useState([
{
type: "paragraph",
children: [{ text: "" }],
},
]);
const [contacts, setContacts] = useState<NDKCacheUserProfile[]>([]);
const [target, setTarget] = useState<Range | undefined>();
const [index, setIndex] = useState(0);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(false);
const [editor] = useState(() =>
withMentions(withNostrEvent(withImages(withReact(createEditor())))),
);
const [editorValue, setEditorValue] = useState([
{
type: "paragraph",
children: [{ text: "" }],
},
]);
const [contacts, setContacts] = useState<NDKCacheUserProfile[]>([]);
const [target, setTarget] = useState<Range | undefined>();
const [index, setIndex] = useState(0);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(false);
const [editor] = useState(() =>
withMentions(withNostrEvent(withImages(withReact(createEditor())))),
);
const { t } = useTranslation();
const { t } = useTranslation();
const filters = contacts
?.filter((c) => c?.name?.toLowerCase().startsWith(search.toLowerCase()))
?.slice(0, 10);
const filters = contacts
?.filter((c) => c?.name?.toLowerCase().startsWith(search.toLowerCase()))
?.slice(0, 10);
const reset = () => {
// @ts-expect-error, backlog
editor.children = [{ type: "paragraph", children: [{ text: "" }] }];
setEditorValue([{ type: "paragraph", children: [{ text: "" }] }]);
};
const reset = () => {
// @ts-expect-error, backlog
editor.children = [{ type: "paragraph", children: [{ text: "" }] }];
setEditorValue([{ type: "paragraph", children: [{ text: "" }] }]);
};
const serialize = (nodes: Descendant[]) => {
return nodes
.map((n) => {
// @ts-expect-error, backlog
if (n.type === "image") return n.url;
// @ts-expect-error, backlog
if (n.type === "event") return n.eventId;
const serialize = (nodes: Descendant[]) => {
return nodes
.map((n) => {
// @ts-expect-error, backlog
if (n.type === "image") return n.url;
// @ts-expect-error, backlog
if (n.type === "event") return n.eventId;
// @ts-expect-error, backlog
if (n.children.length) {
// @ts-expect-error, backlog
return n.children
.map((n) => {
if (n.type === "mention") return n.npub;
return Node.string(n).trim();
})
.join(" ");
}
// @ts-expect-error, backlog
if (n.children.length) {
// @ts-expect-error, backlog
return n.children
.map((n) => {
if (n.type === "mention") return n.npub;
return Node.string(n).trim();
})
.join(" ");
}
return Node.string(n);
})
.join("\n");
};
return Node.string(n);
})
.join("\n");
};
const submit = async () => {
try {
setLoading(true);
const submit = async () => {
try {
setLoading(true);
const event = new NDKEvent(ark.ndk);
event.kind = NDKKind.Text;
event.content = serialize(editor.children);
const event = new NDKEvent(ark.ndk);
event.kind = NDKKind.Text;
event.content = serialize(editor.children);
const rootEvent = await ark.getEventById(eventId);
event.tag(rootEvent, "root");
const rootEvent = await ark.getEventById(eventId);
event.tag(rootEvent, "root");
const publish = await event.publish();
const publish = await event.publish();
if (publish) {
toast.success(
`Event has been published successfully to ${publish.size} relays.`,
);
if (publish) {
toast.success(
`Event has been published successfully to ${publish.size} relays.`,
);
setLoading(false);
setLoading(false);
return reset();
}
} catch (e) {
setLoading(false);
toast.error(String(e));
}
};
return reset();
}
} catch (e) {
setLoading(false);
toast.error(String(e));
}
};
useEffect(() => {
async function loadContacts() {
const res = await storage.getAllCacheUsers();
if (res) setContacts(res);
}
useEffect(() => {
async function loadContacts() {
const res = await storage.getAllCacheUsers();
if (res) setContacts(res);
}
loadContacts();
}, []);
loadContacts();
}, []);
useEffect(() => {
if (target && filters.length > 0) {
const el = ref.current;
const domRange = ReactEditor.toDOMRange(editor, target);
const rect = domRange.getBoundingClientRect();
el.style.top = `${rect.top + window.pageYOffset + 24}px`;
el.style.left = `${rect.left + window.pageXOffset}px`;
}
}, [filters.length, editor, index, search, target]);
useEffect(() => {
if (target && filters.length > 0) {
const el = ref.current;
const domRange = ReactEditor.toDOMRange(editor, target);
const rect = domRange.getBoundingClientRect();
el.style.top = `${rect.top + window.pageYOffset + 24}px`;
el.style.left = `${rect.left + window.pageXOffset}px`;
}
}, [filters.length, editor, index, search, target]);
return (
<div className={cn("flex gap-3", className)}>
<User.Provider pubkey={ark.account.pubkey}>
<User.Root>
<User.Avatar className="size-9 shrink-0 rounded-lg object-cover" />
</User.Root>
</User.Provider>
<div className="flex-1">
<Slate
editor={editor}
initialValue={editorValue}
onChange={() => {
const { selection } = editor;
return (
<div className={cn("flex gap-3", className)}>
<User.Provider pubkey={ark.account.pubkey}>
<User.Root>
<User.Avatar className="size-9 shrink-0 rounded-lg object-cover" />
</User.Root>
</User.Provider>
<div className="flex-1">
<Slate
editor={editor}
initialValue={editorValue}
onChange={() => {
const { selection } = editor;
if (selection && Range.isCollapsed(selection)) {
const [start] = Range.edges(selection);
const wordBefore = Editor.before(editor, start, { unit: "word" });
const before = wordBefore && Editor.before(editor, wordBefore);
const beforeRange = before && Editor.range(editor, before, start);
const beforeText =
beforeRange && Editor.string(editor, beforeRange);
const beforeMatch = beforeText?.match(/^@(\w+)$/);
const after = Editor.after(editor, start);
const afterRange = Editor.range(editor, start, after);
const afterText = Editor.string(editor, afterRange);
const afterMatch = afterText.match(/^(\s|$)/);
if (selection && Range.isCollapsed(selection)) {
const [start] = Range.edges(selection);
const wordBefore = Editor.before(editor, start, { unit: "word" });
const before = wordBefore && Editor.before(editor, wordBefore);
const beforeRange = before && Editor.range(editor, before, start);
const beforeText =
beforeRange && Editor.string(editor, beforeRange);
const beforeMatch = beforeText?.match(/^@(\w+)$/);
const after = Editor.after(editor, start);
const afterRange = Editor.range(editor, start, after);
const afterText = Editor.string(editor, afterRange);
const afterMatch = afterText.match(/^(\s|$)/);
if (beforeMatch && afterMatch) {
setTarget(beforeRange);
setSearch(beforeMatch[1]);
setIndex(0);
return;
}
}
if (beforeMatch && afterMatch) {
setTarget(beforeRange);
setSearch(beforeMatch[1]);
setIndex(0);
return;
}
}
setTarget(null);
}}
>
<div className="overflow-y-auto p-3 bg-neutral-100 dark:bg-neutral-900 rounded-xl">
<Editable
key={JSON.stringify(editorValue)}
autoFocus={false}
autoCapitalize="none"
autoCorrect="none"
spellCheck={false}
renderElement={(props) => <Element {...props} />}
placeholder={t("editor.replyPlaceholder")}
className="focus:outline-none h-28"
/>
{target && filters.length > 0 && (
<Portal>
<div
ref={ref}
className="top-[-9999px] left-[-9999px] absolute z-10 w-[250px] p-1 bg-white border border-neutral-50 dark:border-neutral-900 dark:bg-neutral-950 rounded-lg shadow-lg"
>
{filters.map((contact, i) => (
// biome-ignore lint/a11y/useKeyWithClickEvents: <explanation>
<div
key={contact.npub}
onClick={() => {
Transforms.select(editor, target);
insertMention(editor, contact);
setTarget(null);
}}
className="px-2 py-2 rounded-md hover:bg-neutral-100 dark:hover:bg-neutral-900"
>
<User.Provider pubkey={contact.npub}>
<User.Root className="flex items-center gap-2.5">
<User.Avatar className="size-10 rounded-lg object-cover shrink-0" />
<div className="flex w-full flex-col items-start">
<User.Name className="max-w-[15rem] truncate font-semibold" />
</div>
</User.Root>
</User.Provider>
</div>
))}
</div>
</Portal>
)}
</div>
<div className="mt-3 flex items-center justify-between shrink-0">
<div />
<div className="flex items-center">
<div className="inline-flex items-center gap-2">
<EditorAddMedia />
</div>
<div className="w-px h-6 mx-3 bg-neutral-200 dark:bg-neutral-800" />
<button
type="button"
onClick={submit}
className="inline-flex items-center justify-center w-20 pb-[2px] font-semibold border-t rounded-lg border-neutral-900 dark:border-neutral-800 h-9 bg-neutral-950 text-neutral-50 dark:bg-neutral-900 hover:bg-neutral-900 dark:hover:bg-neutral-800"
>
{loading ? (
<LoaderIcon className="size-4 animate-spin" />
) : (
t("global.post")
)}
</button>
</div>
</div>
</Slate>
</div>
</div>
);
setTarget(null);
}}
>
<div className="overflow-y-auto rounded-xl bg-neutral-100 p-3 dark:bg-neutral-900">
<Editable
key={JSON.stringify(editorValue)}
autoFocus={false}
autoCapitalize="none"
autoCorrect="none"
spellCheck={false}
renderElement={(props) => <Element {...props} />}
placeholder={t("editor.replyPlaceholder")}
className="h-28 focus:outline-none"
/>
{target && filters.length > 0 && (
<Portal>
<div
ref={ref}
className="absolute left-[-9999px] top-[-9999px] z-10 w-[250px] rounded-lg border border-neutral-50 bg-white p-1 shadow-lg dark:border-neutral-900 dark:bg-neutral-950"
>
{filters.map((contact, i) => (
// biome-ignore lint/a11y/useKeyWithClickEvents: <explanation>
<div
key={contact.npub}
onClick={() => {
Transforms.select(editor, target);
insertMention(editor, contact);
setTarget(null);
}}
className="rounded-md px-2 py-2 hover:bg-neutral-100 dark:hover:bg-neutral-900"
>
<User.Provider pubkey={contact.npub}>
<User.Root className="flex items-center gap-2.5">
<User.Avatar className="size-10 shrink-0 rounded-lg object-cover" />
<div className="flex w-full flex-col items-start">
<User.Name className="max-w-[15rem] truncate font-semibold" />
</div>
</User.Root>
</User.Provider>
</div>
))}
</div>
</Portal>
)}
</div>
<div className="mt-3 flex shrink-0 items-center justify-between">
<div />
<div className="flex items-center">
<div className="inline-flex items-center gap-2">
<EditorAddMedia />
</div>
<div className="mx-3 h-6 w-px bg-neutral-200 dark:bg-neutral-800" />
<button
type="button"
onClick={submit}
className="inline-flex h-9 w-20 items-center justify-center rounded-lg border-t border-neutral-900 bg-neutral-950 pb-[2px] font-semibold text-neutral-50 hover:bg-neutral-900 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
{loading ? (
<LoaderIcon className="size-4 animate-spin" />
) : (
t("global.post")
)}
</button>
</div>
</div>
</Slate>
</div>
</div>
);
}

View File

@@ -1,16 +1,14 @@
export * from "./account/active";
export * from "./account/logout";
export * from "./navigation";
export * from "./titlebar";
export * from "./layouts/app";
export * from "./layouts/auth";
export * from "./layouts/home";
export * from "./layouts/settings";
export * from "./mentions";
export * from "./replyList";
export * from "./emptyFeed";
// New
export * from "./user";
export * from "./note";
export * from "./column";
// Deprecated
export * from "./routes/event";
export * from "./routes/user";
export * from "./routes/suggest";
export * from "./mentions";
export * from "./replyList";
export * from "./emptyFeed";
export * from "./translateRegisterModal";
export * from "./user";
export * from "./account/active";

View File

@@ -1,20 +0,0 @@
import { Outlet } from "react-router-dom";
import { Editor } from "../editor/column";
import { Navigation } from "../navigation";
import { SearchDialog } from "../search/dialog";
export function AppLayout() {
return (
<div className="flex h-screen w-screen flex-col bg-gradient-to-tl from-neutral-50 to-neutral-200 dark:from-neutral-950 dark:to-neutral-800">
<div data-tauri-drag-region className="h-9 shrink-0" />
<div className="flex w-full h-full min-h-0">
<Navigation />
<Editor />
<SearchDialog />
<div className="flex-1 h-full px-1 pb-1">
<Outlet />
</div>
</div>
</div>
);
}

View File

@@ -1,31 +0,0 @@
import { ArrowLeftIcon } from "@lume/icons";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
export function AuthLayout() {
const location = useLocation();
const navigate = useNavigate();
const canGoBack = location.pathname.length > 6;
return (
<div className="flex flex-col w-screen h-screen bg-black text-neutral-50">
<div data-tauri-drag-region className="h-9 shrink-0" />
<div className="relative w-full h-full">
<div className="absolute top-8 z-10 flex items-center justify-between w-full px-9">
{canGoBack ? (
<button
type="button"
onClick={() => navigate(-1)}
className="inline-flex items-center justify-center rounded-lg size-10 group"
>
<ArrowLeftIcon className="size-6 text-neutral-700 group-hover:text-neutral-500" />
</button>
) : (
<div />
)}
</div>
<Outlet />
</div>
</div>
);
}

View File

@@ -1,13 +0,0 @@
import { Outlet } from "react-router-dom";
import { OnboardingModal } from "../onboarding/modal";
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-none dark:ring-1 dark:ring-white/10">
<Outlet />
</div>
</>
);
}

View File

@@ -1,115 +0,0 @@
import {
AdvancedSettingsIcon,
InfoIcon,
SecureIcon,
SettingsIcon,
UserIcon,
ZapIcon,
} from "@lume/icons";
import { cn } from "@lume/utils";
import { useTranslation } from "react-i18next";
import { NavLink, Outlet } from "react-router-dom";
export function SettingsLayout() {
const { t } = useTranslation();
return (
<div className="flex h-full min-h-0 w-full flex-col rounded-xl overflow-y-auto">
<div className="flex h-24 shrink-0 w-full items-center justify-center px-2 bg-white/50 backdrop-blur-xl dark:bg-black/50">
<div className="flex items-center gap-2">
<NavLink
end
to="/settings/"
className={({ isActive }) =>
cn(
"flex w-20 shrink-0 flex-col gap-1 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-black/10 dark:bg-white/10 text-blue-500 hover:bg-black/20 dark:hover:bg-white/20"
: "",
)
}
>
<SettingsIcon className="size-6" />
<p className="text-sm font-medium">{t("settings.general.title")}</p>
</NavLink>
<NavLink
to="/settings/profile"
end
className={({ isActive }) =>
cn(
"flex w-20 shrink-0 flex-col gap-1 items-center justify-center rounded-lg px-2 py-2 text-neutral-700 hover:bg-black/10 dark:text-neutral-300 dark:hover:bg-white/10",
isActive
? "bg-black/10 dark:bg-white/10 text-blue-500 hover:bg-black/20 dark:hover:bg-white/20"
: "",
)
}
>
<UserIcon className="size-6" />
<p className="text-sm font-medium">{t("settings.user.title")}</p>
</NavLink>
<NavLink
to="/settings/nwc"
className={({ isActive }) =>
cn(
"flex w-20 shrink-0 flex-col gap-1 items-center justify-center rounded-lg px-2 py-2 text-neutral-700 hover:bg-black/10 dark:text-neutral-300 dark:hover:bg-white/10",
isActive
? "bg-black/10 dark:bg-white/10 text-blue-500 hover:bg-black/20 dark:hover:bg-white/20"
: "",
)
}
>
<ZapIcon className="size-6" />
<p className="text-sm font-medium">{t("settings.zap.title")}</p>
</NavLink>
<NavLink
to="/settings/backup"
className={({ isActive }) =>
cn(
"flex w-20 shrink-0 flex-col gap-1 items-center justify-center rounded-lg px-2 py-2 text-neutral-700 hover:bg-black/10 dark:text-neutral-300 dark:hover:bg-white/10",
isActive
? "bg-black/10 dark:bg-white/10 text-blue-500 hover:bg-black/20 dark:hover:bg-white/20"
: "",
)
}
>
<SecureIcon className="size-6" />
<p className="text-sm font-medium">{t("settings.backup.title")}</p>
</NavLink>
<NavLink
to="/settings/advanced"
className={({ isActive }) =>
cn(
"flex w-20 shrink-0 flex-col gap-1 items-center justify-center rounded-lg px-2 py-2 text-neutral-700 hover:bg-black/10 dark:text-neutral-300 dark:hover:bg-white/10",
isActive
? "bg-black/10 dark:bg-white/10 text-blue-500 hover:bg-black/20 dark:hover:bg-white/20"
: "",
)
}
>
<AdvancedSettingsIcon className="size-6" />
<p className="text-sm font-medium">
{t("settings.advanced.title")}
</p>
</NavLink>
<NavLink
to="/settings/about"
className={({ isActive }) =>
cn(
"flex w-20 shrink-0 flex-col gap-1 items-center justify-center rounded-lg px-2 py-2 text-neutral-700 hover:bg-black/10 dark:text-neutral-300 dark:hover:bg-white/10",
isActive
? "bg-black/10 dark:bg-white/10 text-blue-500 hover:bg-black/20 dark:hover:bg-white/20"
: "",
)
}
>
<InfoIcon className="size-6" />
<p className="text-sm font-medium">{t("settings.about.title")}</p>
</NavLink>
</div>
</div>
<div className="flex-1 py-6 min-h-0 overflow-y-auto 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>
</div>
);
}

View File

@@ -1,181 +0,0 @@
import {
ArrowUpSquareIcon,
BellFilledIcon,
BellIcon,
HomeFilledIcon,
HomeIcon,
PlusIcon,
SearchFilledIcon,
SearchIcon,
SettingsFilledIcon,
SettingsIcon,
} from "@lume/icons";
import { cn, editorAtom, searchAtom } from "@lume/utils";
import { Link } from "@tanstack/react-router";
import { confirm } from "@tauri-apps/plugin-dialog";
import { relaunch } from "@tauri-apps/plugin-process";
import { Update, check } from "@tauri-apps/plugin-updater";
import { useAtom } from "jotai";
import { useEffect, useState } from "react";
import { useHotkeys } from "react-hotkeys-hook";
import { ActiveAccount } from "./account/active";
import { UnreadActivity } from "./unread";
export function Navigation() {
const [isEditorOpen, setIsEditorOpen] = useAtom(editorAtom);
const [search, setSearch] = useAtom(searchAtom);
const [update, setUpdate] = useState<Update>(null);
// shortcut for editor
useHotkeys("meta+n", () => setIsEditorOpen((state) => !state), []);
const installNewUpdate = async () => {
if (!update) return;
const yes = await confirm(update.body, {
title: `v${update.version} is available`,
type: "info",
});
if (yes) {
await update.downloadAndInstall();
await relaunch();
}
};
useEffect(() => {
async function checkNewUpdate() {
const newVersion = await check();
setUpdate(newVersion);
}
checkNewUpdate();
}, []);
return (
<div
data-tauri-drag-region
className="flex h-full w-20 shrink-0 flex-col justify-between px-4 py-3"
>
<div className="flex flex-1 flex-col">
<div className="flex flex-col gap-3">
<ActiveAccount />
<button
type="button"
onClick={() => setIsEditorOpen((state) => !state)}
className={cn(
"inline-flex aspect-square h-auto w-full items-center justify-center rounded-xl",
isEditorOpen
? "bg-blue-500 text-white"
: "bg-neutral-300 hover:bg-neutral-400 dark:bg-neutral-700 hover:dark:bg-neutral-600",
)}
>
<PlusIcon className="size-5" />
</button>
</div>
<div className="mx-auto my-5 h-px w-2/3 bg-black/10 dark:bg-white/10" />
<div className="flex flex-col gap-2">
<Link
to="/app/space"
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-neutral-300 hover:bg-neutral-400 dark:bg-neutral-700 hover:dark:bg-neutral-600"
: "text-neutral-600 dark:text-neutral-400",
)}
>
{isActive ? (
<HomeFilledIcon className="size-6" />
) : (
<HomeIcon className="size-6" />
)}
</div>
)}
</Link>
<Link
to="/app/activity"
className="inline-flex flex-col items-center justify-center"
>
{({ isActive }) => (
<div
className={cn(
"relative inline-flex aspect-square h-auto w-full items-center justify-center rounded-xl",
isActive
? "bg-neutral-300 hover:bg-neutral-400 dark:bg-neutral-700 hover:dark:bg-neutral-600"
: "text-neutral-600 dark:text-neutral-400",
)}
>
{isActive ? (
<BellFilledIcon className="size-6" />
) : (
<BellIcon className="size-6" />
)}
<UnreadActivity />
</div>
)}
</Link>
</div>
</div>
<div className="flex flex-col gap-2">
{update ? (
<button
type="button"
onClick={installNewUpdate}
className="relative inline-flex flex-col items-center justify-center"
>
<span className="inline-flex items-center rounded-full bg-teal-500/60 px-2 py-1 text-xs font-semibold text-teal-50 ring-1 ring-inset ring-teal-500/80 dark:bg-teal-500/10 dark:text-teal-400 dark:ring-teal-500/20">
Update
</span>
<div className="inline-flex aspect-square h-auto w-full items-center justify-center rounded-xl text-black/50 dark:text-neutral-400">
<ArrowUpSquareIcon className="size-6" />
</div>
</button>
) : null}
<button
type="button"
onClick={() => setSearch((open) => !open)}
className="inline-flex flex-col items-center justify-center"
>
<div
className={cn(
"inline-flex aspect-square h-auto w-full items-center justify-center rounded-xl",
search
? "bg-neutral-300 hover:bg-neutral-400 dark:bg-neutral-700 hover:dark:bg-neutral-600"
: "text-neutral-600 dark:text-neutral-400",
)}
>
{search ? (
<SearchFilledIcon className="size-6" />
) : (
<SearchIcon className="size-6" />
)}
</div>
</button>
<Link
to="/settings"
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-neutral-300 hover:bg-neutral-400 dark:bg-neutral-700 hover:dark:bg-neutral-600"
: "text-neutral-600 dark:text-neutral-400",
)}
>
{isActive ? (
<SettingsFilledIcon className="size-6" />
) : (
<SettingsIcon className="size-6" />
)}
</div>
)}
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,53 @@
import { useArk } from "@lume/ark";
import { useQuery } from "@tanstack/react-query";
export function AppHandler({ tag }: { tag: string[] }) {
const ark = useArk();
const { isLoading, isError, data } = useQuery({
queryKey: ["app-handler", tag[1]],
queryFn: async () => {
const ref = tag[1].split(":");
const event = await ark.getEventByFilter({
filter: {
kinds: [Number(ref[0])],
authors: [ref[1]],
"#d": [ref[2]],
},
});
if (!event) return null;
const app = NDKAppHandlerEvent.from(event);
return await app.fetchProfile();
},
refetchOnWindowFocus: false,
});
if (isLoading) {
<div>Loading...</div>;
}
if (isError || !data) {
return <div>Error</div>;
}
return (
<div className="flex items-center gap-2 rounded-md bg-neutral-200 p-2 hover:ring-1 hover:ring-blue-500 dark:bg-neutral-800">
<img
src={data?.picture || data?.image}
alt={data.pubkey}
decoding="async"
className="h-9 w-9 shrink-0 rounded-lg bg-white object-cover ring-1 ring-neutral-200/50 dark:ring-neutral-800/50"
/>
<div className="flex flex-col">
<div className="max-w-[15rem] truncate font-semibold text-neutral-950 dark:text-neutral-50">
{data.name}
</div>
<div className="line-clamp-1 text-sm text-neutral-600 dark:text-neutral-400">
{data.about}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,31 @@
import { PinIcon } from "@lume/icons";
import * as Tooltip from "@radix-ui/react-tooltip";
import { useTranslation } from "react-i18next";
import { useNoteContext } from "../provider";
export function NotePin() {
const event = useNoteContext();
const { t } = useTranslation();
return (
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button
type="button"
className="inline-flex items-center justify-center gap-2 pl-2 pr-3 text-sm font-medium rounded-full h-7 w-max bg-neutral-100 hover:bg-neutral-200 dark:hover:bg-neutral-800 dark:bg-neutral-900"
>
<PinIcon className="size-4" />
{t("note.buttons.pin")}
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none text-neutral-50 dark:text-neutral-950 items-center justify-center rounded-md bg-neutral-950 dark:bg-neutral-50 px-3.5 text-sm will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade">
{t("note.buttons.pinTooltip")}
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}

View File

@@ -0,0 +1,137 @@
import { ReactionIcon } from "@lume/icons";
import * as HoverCard from "@radix-ui/react-hover-card";
import { useState } from "react";
import { useNoteContext } from "../provider";
const REACTIONS = [
{
content: "👏",
img: "/clapping_hands.png",
},
{
content: "🤪",
img: "/face_with_tongue.png",
},
{
content: "😮",
img: "/face_with_open_mouth.png",
},
{
content: "😢",
img: "/crying_face.png",
},
{
content: "🤡",
img: "/clown_face.png",
},
];
export function NoteReaction() {
const event = useNoteContext();
const [open, setOpen] = useState(false);
const [reaction, setReaction] = useState<string | null>(null);
const getReactionImage = (content: string) => {
const reaction: { img: string } = REACTIONS.find(
(el) => el.content === content,
);
return reaction.img;
};
const react = async (content: string) => {
try {
setReaction(content);
// react
await event.react(content);
setOpen(false);
} catch (e) {
console.error(e);
}
};
return (
<HoverCard.Root open={open} onOpenChange={setOpen}>
<HoverCard.Trigger asChild>
<button
type="button"
className="inline-flex items-center justify-center group h-7 w-7 text-neutral-600 dark:text-neutral-400"
>
{reaction ? (
<img
src={getReactionImage(reaction)}
alt={reaction}
className="size-6"
/>
) : (
<ReactionIcon className="size-5 group-hover:text-blue-500" />
)}
</button>
</HoverCard.Trigger>
<HoverCard.Portal>
<HoverCard.Content
className="select-none rounded-lg bg-neutral-950 dark:bg-neutral-50 px-1 py-1 text-sm will-change-[transform,opacity] data-[state=open]:data-[side=bottom]:animate-slideUpAndFade data-[state=open]:data-[side=left]:animate-slideRightAndFade data-[state=open]:data-[side=right]:animate-slideLeftAndFade data-[state=open]:data-[side=top]:animate-slideDownAndFade"
sideOffset={0}
side="top"
>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => react("👏")}
className="inline-flex items-center justify-center w-8 h-8 rounded-md backdrop-blur-xl hover:bg-white/10 dark:hover:bg-black/10"
>
<img
src="/clapping_hands.png"
alt="Clapping Hands"
className="size-6"
/>
</button>
<button
type="button"
onClick={() => react("🤪")}
className="inline-flex items-center justify-center w-8 h-8 rounded-md backdrop-blur-xl hover:bg-white/10 dark:hover:bg-black/10"
>
<img
src="/face_with_tongue.png"
alt="Face with Tongue"
className="size-6"
/>
</button>
<button
type="button"
onClick={() => react("😮")}
className="inline-flex items-center justify-center w-8 h-8 rounded-md backdrop-blur-xl hover:bg-white/10 dark:hover:bg-black/10"
>
<img
src="/face_with_open_mouth.png"
alt="Face with Open Mouth"
className="size-6"
/>
</button>
<button
type="button"
onClick={() => react("😢")}
className="inline-flex items-center justify-center w-8 h-8 rounded-md backdrop-blur-xl hover:bg-white/10 dark:hover:bg-black/10"
>
<img
src="/crying_face.png"
alt="Crying Face"
className="size-6"
/>
</button>
<button
type="button"
onClick={() => react("🤡")}
className="inline-flex items-center justify-center w-8 h-8 rounded-md backdrop-blur-xl hover:bg-white/10 dark:hover:bg-black/10"
>
<img src="/clown_face.png" alt="Clown Face" className="size-6" />
</button>
</div>
<HoverCard.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</HoverCard.Content>
</HoverCard.Portal>
</HoverCard.Root>
);
}

View File

@@ -0,0 +1,34 @@
import { ReplyIcon } from "@lume/icons";
import * as Tooltip from "@radix-ui/react-tooltip";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useNoteContext } from "../provider";
export function NoteReply() {
const event = useNoteContext();
const navigate = useNavigate();
const { t } = useTranslation();
return (
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button
type="button"
onClick={() => navigate(`/events/${event.id}`)}
className="inline-flex items-center justify-center group h-7 w-7 text-neutral-600 dark:text-neutral-400"
>
<ReplyIcon className="size-5 group-hover:text-blue-500" />
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none text-neutral-50 dark:text-neutral-950 items-center justify-center rounded-md bg-neutral-950 dark:bg-neutral-50 px-3.5 text-sm will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade">
{t("note.menu.viewThread")}
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}

View File

@@ -0,0 +1,117 @@
import { LoaderIcon, ReplyIcon, RepostIcon } from "@lume/icons";
import { cn, editorAtom, editorValueAtom } from "@lume/utils";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import * as Tooltip from "@radix-ui/react-tooltip";
import { useSetAtom } from "jotai";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useNoteContext } from "../provider";
export function NoteRepost() {
const event = useNoteContext();
const setEditorValue = useSetAtom(editorValueAtom);
const setIsEditorOpen = useSetAtom(editorAtom);
const [t] = useTranslation();
const [loading, setLoading] = useState(false);
const [isRepost, setIsRepost] = useState(false);
const [open, setOpen] = useState(false);
const repost = async () => {
try {
setLoading(true);
// repost
await event.repost(true);
// update state
setLoading(false);
setIsRepost(true);
// notify
toast.success("You've reposted this post successfully");
} catch (e) {
setLoading(false);
toast.error("Repost failed, try again later");
}
};
const quote = () => {
setEditorValue([
{
type: "paragraph",
children: [{ text: "" }],
},
{
type: "event",
// @ts-expect-error, useless
eventId: `nostr:${nip19.noteEncode(event.id)}`,
children: [{ text: "" }],
},
{
type: "paragraph",
children: [{ text: "" }],
},
]);
setIsEditorOpen(true);
};
return (
<DropdownMenu.Root open={open} onOpenChange={setOpen}>
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<DropdownMenu.Trigger asChild>
<Tooltip.Trigger asChild>
<button
type="button"
className="inline-flex items-center justify-center group h-7 w-7 text-neutral-600 dark:text-neutral-400"
>
{loading ? (
<LoaderIcon className="size-4 animate-spin" />
) : (
<RepostIcon
className={cn(
"size-5 group-hover:text-blue-600",
isRepost ? "text-blue-500" : "",
)}
/>
)}
</button>
</Tooltip.Trigger>
</DropdownMenu.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none text-neutral-50 dark:text-neutral-950 items-center justify-center rounded-md bg-neutral-950 dark:bg-neutral-50 px-3.5 text-sm will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade">
{t("note.buttons.repost")}
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
<DropdownMenu.Portal>
<DropdownMenu.Content className="flex w-[200px] p-2 flex-col overflow-hidden rounded-2xl bg-white/50 dark:bg-black/50 ring-1 ring-black/10 dark:ring-white/10 backdrop-blur-2xl focus:outline-none">
<DropdownMenu.Item asChild>
<button
type="button"
onClick={repost}
className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
>
<RepostIcon className="size-4" />
{t("note.buttons.repost")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={quote}
className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
>
<ReplyIcon className="size-4" />
{t("note.buttons.quote")}
</button>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
}

View File

@@ -0,0 +1,260 @@
import { webln } from "@getalby/sdk";
import { type SendPaymentResponse } from "@getalby/sdk/dist/types";
import { CancelIcon, LoaderIcon, ZapIcon } from "@lume/icons";
import { useStorage } from "@lume/storage";
import { cn, compactNumber, displayNpub } from "@lume/utils";
import * as Dialog from "@radix-ui/react-dialog";
import * as Tooltip from "@radix-ui/react-tooltip";
import { QRCodeSVG } from "qrcode.react";
import { useState } from "react";
import CurrencyInput from "react-currency-input-field";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useNoteContext } from "../provider";
import { useProfile } from "@lume/ark";
export function NoteZap() {
const storage = useStorage();
const event = useNoteContext();
const [amount, setAmount] = useState<string>("21");
const [zapMessage, setZapMessage] = useState<string>("");
const [isOpen, setIsOpen] = useState(false);
const [isCompleted, setIsCompleted] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [invoice, setInvoice] = useState<string>(null);
const { t } = useTranslation();
const { user } = useProfile(event.pubkey);
const createZapRequest = async (instant?: boolean) => {
if (instant && !storage.nwc) return;
let nwc: webln.NostrWebLNProvider = undefined;
try {
// start loading
setIsLoading(true);
const zapAmount = parseInt(amount) * 1000;
const res = await event.zap(zapAmount, zapMessage);
if (!storage.nwc) return setInvoice(res);
// user connect nwc
nwc = new webln.NostrWebLNProvider({
nostrWalletConnectUrl: storage.nwc,
});
await nwc.enable();
// send payment via nwc
const send: SendPaymentResponse = await nwc.sendPayment(res);
if (send) {
toast.success(
`You've zapped ${compactNumber.format(send.amount)} sats to ${
user?.name || user?.displayName || "anon"
}`,
);
// reset after 1.5 secs
if (!instant) {
const timeout = setTimeout(() => setIsCompleted(false), 1500);
clearTimeout(timeout);
}
}
// eose
nwc.close();
// update state
setIsCompleted(true);
setIsLoading(false);
} catch (e) {
nwc?.close();
setIsLoading(false);
toast.error(String(e));
}
};
if (storage.settings.instantZap) {
return (
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button
type="button"
onClick={() => createZapRequest(true)}
className="group inline-flex size-7 items-center justify-center text-neutral-600 dark:text-neutral-400"
>
{isLoading ? (
<LoaderIcon className="size-4 animate-spin" />
) : (
<ZapIcon
className={cn(
"size-5 group-hover:text-blue-500",
isCompleted ? "text-blue-500" : "",
)}
/>
)}
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] dark:bg-neutral-50 dark:text-neutral-950">
{t("note.zap.tooltip")}
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}
return (
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<Dialog.Trigger asChild>
<Tooltip.Trigger asChild>
<button
type="button"
className="group inline-flex size-7 items-center justify-center text-neutral-600 dark:text-neutral-400"
>
<ZapIcon className="size-5 group-hover:text-blue-500" />
</button>
</Tooltip.Trigger>
</Dialog.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] dark:bg-neutral-50 dark:text-neutral-950">
{t("note.zap.tooltip")}
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/20 backdrop-blur-sm dark:bg-white/20" />
<Dialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Dialog.Close className="absolute right-5 top-5 z-50">
<div className="flex flex-col gap-1.5">
<div className="inline-flex size-10 items-center justify-center rounded-lg bg-white dark:bg-black">
<CancelIcon className="size-5" />
</div>
<span className="text-sm font-medium">Esc</span>
</div>
</Dialog.Close>
<div className="relative h-min w-full max-w-xl rounded-xl bg-white dark:bg-black">
<div className="inline-flex w-full shrink-0 items-center justify-center px-5 py-3">
<div className="w-6" />
<Dialog.Title className="text-center font-semibold">
{t("note.zap.modalTitle")}{" "}
{user?.name ||
user?.displayName ||
displayNpub(event.pubkey, 16)}
</Dialog.Title>
</div>
{!invoice ? (
<div className="overflow-y-auto overflow-x-hidden px-5 pb-5">
<div className="relative flex h-36 flex-col">
<div className="inline-flex h-full flex-1 items-center justify-center gap-1">
<CurrencyInput
placeholder="0"
defaultValue={"21"}
value={amount}
decimalsLimit={2}
min={0} // 0 sats
max={10000} // 1M sats
maxLength={10000} // 1M sats
onValueChange={(value) => setAmount(value)}
className="w-full flex-1 border-none bg-transparent text-right text-4xl font-semibold placeholder:text-neutral-600 focus:outline-none focus:ring-0 dark:text-neutral-400"
/>
<span className="w-full flex-1 text-left text-4xl font-semibold text-neutral-500 dark:text-neutral-400">
sats
</span>
</div>
<div className="inline-flex items-center justify-center gap-2">
<button
type="button"
onClick={() => setAmount("69")}
className="w-max rounded-full bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
69 sats
</button>
<button
type="button"
onClick={() => setAmount("100")}
className="w-max rounded-full bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
100 sats
</button>
<button
type="button"
onClick={() => setAmount("200")}
className="w-max rounded-full bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
200 sats
</button>
<button
type="button"
onClick={() => setAmount("500")}
className="w-max rounded-full bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
500 sats
</button>
<button
type="button"
onClick={() => setAmount("1000")}
className="w-max rounded-full bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
1K sats
</button>
</div>
</div>
<div className="mt-4 flex w-full flex-col gap-2">
<input
name="zapMessage"
value={zapMessage}
onChange={(e) => setZapMessage(e.target.value)}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
placeholder={t("note.zap.messagePlaceholder")}
className="w-full resize-none rounded-lg border-transparent bg-neutral-100 px-3 py-3 !outline-none placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-950 dark:text-neutral-400"
/>
<div className="flex flex-col gap-2">
<button
type="button"
onClick={() => createZapRequest()}
className="inline-flex h-9 w-full items-center justify-center rounded-lg border-t border-neutral-900 bg-neutral-950 pb-[2px] font-semibold text-neutral-50 hover:bg-neutral-900 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
{isCompleted
? t("note.zap.buttonFinish")
: isLoading
? t("note.zap.buttonLoading")
: t("note.zap.zap")}
</button>
</div>
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center gap-4 px-5 pb-5">
<div className="rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
<QRCodeSVG value={invoice} size={256} />
</div>
<div className="flex flex-col items-center gap-1">
<h3 className="text-lg font-medium">
{t("note.zap.invoiceButton")}
</h3>
<span className="text-center text-sm text-neutral-600 dark:text-neutral-400">
{t("note.zap.invoiceFooter")}
</span>
</div>
</div>
)}
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -0,0 +1,125 @@
import { NOSTR_MENTIONS } from "@lume/utils";
import { nanoid } from "nanoid";
import { ReactNode, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import reactStringReplace from "react-string-replace";
import { User } from "../user";
import { Hashtag } from "./mentions/hashtag";
import { MentionUser } from "./mentions/user";
import { useEvent } from "@lume/ark";
export function NoteChild({
eventId,
isRoot,
}: {
eventId: string;
isRoot?: boolean;
}) {
const { t } = useTranslation();
const { isLoading, isError, data } = useEvent(eventId);
const richContent = useMemo(() => {
if (!data) return "";
let parsedContent: string | ReactNode[] = data.content.replace(
/\n+/g,
"\n",
);
const text = parsedContent as string;
const words = text.split(/( |\n)/);
const hashtags = words.filter((word) => word.startsWith("#"));
const mentions = words.filter((word) =>
NOSTR_MENTIONS.some((el) => word.startsWith(el)),
);
try {
if (hashtags.length) {
for (const hashtag of hashtags) {
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
parsedContent = reactStringReplace(parsedContent, regex, () => {
return <Hashtag key={nanoid()} tag={hashtag} />;
});
}
}
if (mentions.length) {
for (const mention of mentions) {
parsedContent = reactStringReplace(
parsedContent,
mention,
(match, i) => <MentionUser key={match + i} pubkey={mention} />,
);
}
}
parsedContent = reactStringReplace(
parsedContent,
/(https?:\/\/\S+)/g,
(match, i) => {
const url = new URL(match);
return (
<Link
key={match + i}
to={url.toString()}
target="_blank"
rel="noreferrer"
className="break-p font-normal text-blue-500 hover:text-blue-600"
>
{url.toString()}
</Link>
);
},
);
return parsedContent;
} catch (e) {
console.log(e);
return parsedContent;
}
}, [data]);
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 || !data) {
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">
{t("note.error")}
</div>
</div>
);
}
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="absolute right-0 top-[18px] h-3 w-3 -translate-y-1/2 translate-x-1/2 rotate-45 transform bg-neutral-200 dark:bg-neutral-800" />
<div className="break-p mt-6 line-clamp-3 select-text leading-normal text-neutral-900 dark:text-neutral-100">
{richContent}
</div>
</div>
<User.Provider pubkey={data.pubkey}>
<User.Root>
<User.Avatar className="size-10 shrink-0 rounded-lg object-cover" />
<div className="absolute left-2 top-2 inline-flex items-center gap-1.5 font-semibold leading-tight">
<User.Name className="max-w-[10rem] truncate" />
<div className="font-normal text-neutral-700 dark:text-neutral-300">
{isRoot ? t("note.posted") : t("note.replied")}:
</div>
</div>
</User.Root>
</User.Provider>
</div>
);
}

View File

@@ -0,0 +1,256 @@
import { useStorage } from "@lume/storage";
import { Kind } from "@lume/types";
import {
AUDIOS,
IMAGES,
NOSTR_EVENTS,
NOSTR_MENTIONS,
VIDEOS,
canPreview,
cn,
regionNames,
} from "@lume/utils";
import { fetch } from "@tauri-apps/plugin-http";
import getUrls from "get-urls";
import { nanoid } from "nanoid";
import { ReactNode, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import reactStringReplace from "react-string-replace";
import { toast } from "sonner";
import { stripHtml } from "string-strip-html";
import { Hashtag } from "./mentions/hashtag";
import { MentionNote } from "./mentions/note";
import { MentionUser } from "./mentions/user";
import { NIP89 } from "./nip89";
import { ImagePreview } from "./preview/image";
import { LinkPreview } from "./preview/link";
import { VideoPreview } from "./preview/video";
import { useNoteContext } from "./provider";
export function NoteContent({
className,
}: {
className?: string;
}) {
const storage = useStorage();
const event = useNoteContext();
const [content, setContent] = useState(event.content);
const [translate, setTranslate] = useState({
translatable: false,
translated: false,
});
const richContent = useMemo(() => {
if (event.kind !== Kind.Text) return content;
let parsedContent: string | ReactNode[] = stripHtml(
content.replace(/\n{2,}\s*/g, "\n"),
).result;
let linkPreview: string = undefined;
let images: string[] = [];
let videos: string[] = [];
let audios: string[] = [];
let events: string[] = [];
const text = parsedContent;
const words = text.split(/( |\n)/);
const urls = [...getUrls(text)];
if (storage.settings.media && !storage.settings.lowPower) {
images = urls.filter((word) =>
IMAGES.some((el) => {
const url = new URL(word);
const extension = url.pathname.split(".")[1];
if (extension === el) return true;
return false;
}),
);
videos = urls.filter((word) =>
VIDEOS.some((el) => {
const url = new URL(word);
const extension = url.pathname.split(".")[1];
if (extension === el) return true;
return false;
}),
);
audios = urls.filter((word) =>
AUDIOS.some((el) => {
const url = new URL(word);
const extension = url.pathname.split(".")[1];
if (extension === el) return true;
return false;
}),
);
}
events = words.filter((word) =>
NOSTR_EVENTS.some((el) => word.startsWith(el)),
);
const hashtags = words.filter((word) => word.startsWith("#"));
const mentions = words.filter((word) =>
NOSTR_MENTIONS.some((el) => word.startsWith(el)),
);
try {
if (images.length) {
for (const image of images) {
parsedContent = reactStringReplace(
parsedContent,
image,
(match, i) => <ImagePreview key={match + i} url={match} />,
);
}
}
if (videos.length) {
for (const video of videos) {
parsedContent = reactStringReplace(
parsedContent,
video,
(match, i) => <VideoPreview key={match + i} url={match} />,
);
}
}
if (audios.length) {
for (const audio of audios) {
parsedContent = reactStringReplace(
parsedContent,
audio,
(match, i) => <VideoPreview key={match + i} url={match} />,
);
}
}
if (hashtags.length) {
for (const hashtag of hashtags) {
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
parsedContent = reactStringReplace(parsedContent, regex, () => {
if (storage.settings.hashtag)
return <Hashtag key={nanoid()} tag={hashtag} />;
return null;
});
}
}
if (events.length) {
for (const event of events) {
parsedContent = reactStringReplace(
parsedContent,
event,
(match, i) => <MentionNote key={match + i} eventId={event} />,
);
}
}
if (mentions.length) {
for (const mention of mentions) {
parsedContent = reactStringReplace(
parsedContent,
mention,
(match, i) => <MentionUser key={match + i} pubkey={mention} />,
);
}
}
parsedContent = reactStringReplace(
parsedContent,
/(https?:\/\/\S+)/g,
(match, i) => {
const url = new URL(match);
if (!linkPreview && canPreview(match)) {
linkPreview = match;
return <LinkPreview key={match + i} url={url.toString()} />;
}
return (
<Link
key={match + i}
to={url.toString()}
target="_blank"
rel="noreferrer"
className="break-p truncate inline-block w-full font-normal text-blue-500 hover:text-blue-600"
>
{url.toString()}
</Link>
);
},
);
parsedContent = reactStringReplace(parsedContent, "\n", () => {
return <div key={nanoid()} className="h-3" />;
});
if (typeof parsedContent[0] === "string") {
parsedContent[0] = parsedContent[0].trimStart();
}
return parsedContent;
} catch (e) {
console.warn(event.id, `[parser] parse failed: ${e}`);
return parsedContent;
}
}, [content]);
const translateContent = async () => {
try {
if (!translate.translatable) return;
const res = await fetch("https://translate.nostr.wine/translate", {
method: "POST",
body: JSON.stringify({
q: event.content,
target: storage.locale.slice(0, 2),
api_key: storage.settings.translateApiKey,
}),
headers: { "Content-Type": "application/json" },
});
if (!res.ok)
toast.error(
"Cannot connect to translate service, please try again later.",
);
const data = await res.json();
setContent(data.translatedText);
setTranslate((state) => ({ ...state, translated: true }));
} catch (e) {
console.error("translate api: ", String(e));
}
};
if (event.kind !== Kind.Text) {
return <NIP89 className={className} />;
}
return (
<div className={cn(className)}>
<div className="break-p select-text text-balance leading-normal whitespace-pre-line">
{richContent}
</div>
{storage.settings.translation && translate.translatable ? (
translate.translated ? (
<button
type="button"
onClick={() => setContent(event.content)}
className="mt-3 text-sm text-blue-500 hover:text-blue-600 border-none shadow-none focus:outline-none"
>
Show original content
</button>
) : (
<button
type="button"
onClick={translateContent}
className="mt-3 text-sm text-blue-500 hover:text-blue-600 border-none shadow-none focus:outline-none"
>
Translate to {regionNames.of(storage.locale)}
</button>
)
) : null}
</div>
);
}

View File

@@ -0,0 +1,27 @@
import { NotePin } from "./buttons/pin";
import { NoteReaction } from "./buttons/reaction";
import { NoteReply } from "./buttons/reply";
import { NoteRepost } from "./buttons/repost";
import { NoteZap } from "./buttons/zap";
import { NoteChild } from "./child";
import { NoteContent } from "./content";
import { NoteMenu } from "./menu";
import { NoteProvider } from "./provider";
import { NoteRoot } from "./root";
import { NoteThread } from "./thread";
import { NoteUser } from "./user";
export const Note = {
Provider: NoteProvider,
Root: NoteRoot,
User: NoteUser,
Menu: NoteMenu,
Reply: NoteReply,
Repost: NoteRepost,
Reaction: NoteReaction,
Content: NoteContent,
Zap: NoteZap,
Pin: NotePin,
Child: NoteChild,
Thread: NoteThread,
};

View File

@@ -0,0 +1,10 @@
export function Hashtag({ tag }: { tag: string }) {
return (
<button
type="button"
className="text-blue-500 break-all cursor-default hover:text-blue-600"
>
{tag}
</button>
);
}

View File

@@ -0,0 +1,10 @@
import { QRCodeSVG } from 'qrcode.react';
import { memo } from 'react';
export const Invoice = memo(function Invoice({ invoice }: { invoice: string }) {
return (
<div className="mt-2 flex items-center rounded-lg bg-neutral-200 p-2 dark:bg-neutral-800">
<QRCodeSVG value={invoice} includeMargin={true} className="rounded-lg" />
</div>
);
});

View File

@@ -0,0 +1,147 @@
import { PinIcon } from "@lume/icons";
import { NOSTR_MENTIONS } from "@lume/utils";
import { ReactNode, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import reactStringReplace from "react-string-replace";
import { User } from "../../user";
import { Hashtag } from "./hashtag";
import { MentionUser } from "./user";
import { useEvent } from "@lume/ark";
export function MentionNote({
eventId,
openable = true,
}: {
eventId: string;
openable?: boolean;
}) {
const { t } = useTranslation();
const { isLoading, isError, data } = useEvent(eventId);
const richContent = useMemo(() => {
if (!data) return "";
let parsedContent: string | ReactNode[] = data.content.replace(
/\n+/g,
"\n",
);
const text = parsedContent as string;
const words = text.split(/( |\n)/);
const hashtags = words.filter((word) => word.startsWith("#"));
const mentions = words.filter((word) =>
NOSTR_MENTIONS.some((el) => word.startsWith(el)),
);
try {
if (hashtags.length) {
for (const hashtag of hashtags) {
parsedContent = reactStringReplace(
parsedContent,
hashtag,
(match, i) => {
return <Hashtag key={match + i} tag={hashtag} />;
},
);
}
}
if (mentions.length) {
for (const mention of mentions) {
parsedContent = reactStringReplace(
parsedContent,
mention,
(match, i) => <MentionUser key={match + i} pubkey={mention} />,
);
}
}
parsedContent = reactStringReplace(
parsedContent,
/(https?:\/\/\S+)/g,
(match, i) => {
const url = new URL(match);
return (
<Link
key={match + i}
to={url.toString()}
target="_blank"
rel="noreferrer"
className="break-p inline-block w-full truncate font-normal text-blue-500 hover:text-blue-600"
>
{url.toString()}
</Link>
);
},
);
return parsedContent;
} catch (e) {
console.log(e);
return parsedContent;
}
}, [data]);
if (isLoading) {
return (
<div
contentEditable={false}
className="my-1 flex w-full cursor-default items-center justify-between rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900"
>
<p>Loading...</p>
</div>
);
}
if (isError || !data) {
return (
<div
contentEditable={false}
className="my-1 w-full cursor-default rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900"
>
{t("note.error")}
</div>
);
}
return (
<div className="my-1 flex w-full cursor-default flex-col rounded-lg border border-black/5 bg-neutral-100 dark:border-white/5 dark:bg-neutral-900">
<User.Provider pubkey={data.pubkey}>
<User.Root className="flex h-10 items-center gap-2 px-3">
<User.Avatar className="size-6 shrink-0 rounded-md object-cover" />
<div className="inline-flex flex-1 gap-2">
<User.Name className="font-semibold text-neutral-900 dark:text-neutral-100" />
<span className="text-neutral-600 dark:text-neutral-400">·</span>
<User.Time
time={data.created_at}
className="text-neutral-600 dark:text-neutral-400"
/>
</div>
</User.Root>
</User.Provider>
<div className="line-clamp-4 select-text whitespace-pre-line text-balance px-3 leading-normal">
{richContent}
</div>
{openable ? (
<div className="flex h-10 items-center justify-between px-3">
<Link
to={`/events/${data.id}`}
className="text-sm text-blue-500 hover:text-blue-600"
>
{t("note.showMore")}
</Link>
<button
type="button"
className="inline-flex size-6 items-center justify-center rounded-md bg-neutral-200 text-neutral-600 hover:bg-neutral-300 dark:bg-neutral-800 dark:text-neutral-400 dark:hover:bg-neutral-700"
>
<PinIcon className="size-4" />
</button>
</div>
) : (
<div className="h-3" />
)}
</div>
);
}

View File

@@ -0,0 +1,39 @@
import { useProfile } from "@lume/ark";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
export function MentionUser({ pubkey }: { pubkey: string }) {
const { isLoading, isError, user } = useProfile(pubkey);
const { t } = useTranslation();
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger className="break-words text-start text-blue-500 hover:text-blue-600">
{isLoading
? "@anon"
: isError
? pubkey
: `@${user?.name || user?.display_name || user?.name || "anon"}`}
</DropdownMenu.Trigger>
<DropdownMenu.Content className="flex w-[200px] flex-col overflow-hidden rounded-2xl bg-white/50 p-2 ring-1 ring-black/10 backdrop-blur-2xl focus:outline-none dark:bg-black/50 dark:ring-white/10">
<DropdownMenu.Item asChild>
<Link
to={`/users/${pubkey}`}
className="inline-flex h-9 items-center gap-3 rounded-lg px-3 text-sm font-medium text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
>
{t("note.buttons.viewProfile")}
</Link>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
className="inline-flex h-9 items-center gap-3 rounded-lg px-3 text-sm font-medium text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
>
{t("note.buttons.pin")}
</button>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
);
}

View File

@@ -0,0 +1,112 @@
import { HorizontalDotsIcon } from "@lume/icons";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { useNoteContext } from "./provider";
import { useArk } from "@lume/ark";
export function NoteMenu() {
const ark = useArk();
const event = useNoteContext();
const navigate = useNavigate();
const { t } = useTranslation();
const copyID = async () => {
await writeText(await ark.event_to_bech32(event.id, [""]));
};
const copyRaw = async () => {
await writeText(JSON.stringify(event));
};
const copyNpub = async () => {
await writeText(await ark.user_to_bech32(event.pubkey, [""]));
};
const copyLink = async () => {
await writeText(
`https://njump.me/${await ark.event_to_bech32(event.id, [""])}`,
);
};
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
type="button"
className="inline-flex size-6 items-center justify-center"
>
<HorizontalDotsIcon className="size-4 hover:text-blue-500 dark:text-neutral-200" />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content className="flex w-[200px] flex-col overflow-hidden rounded-2xl bg-white/50 p-2 ring-1 ring-black/10 backdrop-blur-2xl focus:outline-none dark:bg-black/50 dark:ring-white/10">
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => copyLink()}
className="inline-flex h-9 items-center gap-3 rounded-lg px-3 text-sm font-medium text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
>
{t("note.menu.viewThread")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => navigate(`/events/${event.id}`)}
className="inline-flex h-9 items-center gap-3 rounded-lg px-3 text-sm font-medium text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
>
{t("note.menu.copyLink")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => copyID()}
className="inline-flex h-9 items-center gap-3 rounded-lg px-3 text-sm font-medium text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
>
{t("note.menu.copyNoteId")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => copyNpub()}
className="inline-flex h-9 items-center gap-3 rounded-lg px-3 text-sm font-medium text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
>
{t("note.menu.copyAuthorId")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<Link
to={`/users/${event.pubkey}`}
className="inline-flex h-9 items-center gap-3 rounded-lg px-3 text-sm font-medium text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
>
{t("note.menu.viewAuthor")}
</Link>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
className="inline-flex h-9 items-center gap-3 rounded-lg px-3 text-sm font-medium text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
>
{t("note.menu.pinAuthor")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Separator className="my-1 h-px bg-black/10 dark:bg-white/10" />
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => copyRaw()}
className="inline-flex h-9 items-center gap-3 rounded-lg px-3 text-sm font-medium text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
>
{t("note.menu.copyRaw")}
</button>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
}

View File

@@ -0,0 +1,55 @@
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { AppHandler } from "./appHandler";
import { useNoteContext } from "./provider";
import { useArk } from "@lume/ark";
export function NIP89({ className }: { className?: string }) {
const ark = useArk();
const event = useNoteContext();
const { t } = useTranslation();
const { isLoading, isError, data } = useQuery({
queryKey: ["app-recommend", event.id],
queryFn: () => {
return ark.getAppRecommend({
unknownKind: event.kind.toString(),
author: event.pubkey,
});
},
refetchOnWindowFocus: false,
refetchOnMount: false,
staleTime: Infinity,
});
if (isLoading) {
<div>Loading...</div>;
}
if (isError || !data) {
return <div>Error</div>;
}
return (
<div className={className}>
<div className="flex flex-col rounded-lg bg-neutral-100 dark:bg-neutral-900">
<div className="inline-flex h-10 shrink-0 items-center justify-between border-b border-neutral-200 px-3 dark:border-neutral-800">
<p className="text-sm font-medium text-amber-400">
{t("nip89.unsupported")}
</p>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
{event.kind}
</p>
</div>
<div className="flex flex-1 flex-col gap-2 px-3 py-3">
<span className="text-sm font-medium uppercase text-neutral-600 dark:text-neutral-400">
{t("nip89.openWith")}
</span>
{data.map((item) => (
<AppHandler key={item[1]} tag={item} />
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,61 @@
import { CheckCircleIcon, DownloadIcon } from "@lume/icons";
import { downloadDir } from "@tauri-apps/api/path";
import { WebviewWindow } from "@tauri-apps/api/webview";
import { download } from "@tauri-apps/plugin-upload";
import { SyntheticEvent, useState } from "react";
export function ImagePreview({ url }: { url: string }) {
const [downloaded, setDownloaded] = useState(false);
const downloadImage = async (e: { stopPropagation: () => void }) => {
try {
e.stopPropagation();
const downloadDirPath = await downloadDir();
const filename = url.substring(url.lastIndexOf("/") + 1);
await download(url, `${downloadDirPath}/${filename}`);
setDownloaded(true);
} catch (e) {
console.error(e);
}
};
const open = async () => {
const name = new URL(url).pathname.split("/").pop();
return new WebviewWindow("image-viewer", {
url,
title: name,
});
};
const fallback = (event: SyntheticEvent<HTMLImageElement, Event>) => {
event.currentTarget.src = "/fallback-image.jpg";
};
return (
// biome-ignore lint/a11y/useKeyWithClickEvents: <explanation>
<div onClick={open} className="relative mt-1 mb-2.5 group">
<img
src={url}
alt={url}
loading="lazy"
decoding="async"
style={{ contentVisibility: "auto" }}
onError={fallback}
className="object-cover w-full h-auto border rounded-xl border-neutral-200/50 dark:border-neutral-800/50"
/>
<button
type="button"
onClick={(e) => downloadImage(e)}
className="absolute z-10 items-center justify-center hidden size-10 bg-white/10 text-black/70 backdrop-blur-xl rounded-lg right-2 top-2 group-hover:inline-flex hover:bg-blue-500 hover:text-white"
>
{downloaded ? (
<CheckCircleIcon className="size-5" />
) : (
<DownloadIcon className="size-5" />
)}
</button>
</div>
);
}

View File

@@ -0,0 +1,87 @@
import { useOpenGraph } from "@lume/utils";
function isImage(url: string) {
return /^https?:\/\/.+\.(jpg|jpeg|png|webp|avif)$/.test(url);
}
export function LinkPreview({ url }: { url: string }) {
const domain = new URL(url);
const { isLoading, isError, data } = useOpenGraph(url);
if (isLoading) {
return (
<div className="mb-2.5 mt-1 flex w-full flex-col overflow-hidden rounded-xl border border-black/5 bg-neutral-100 dark:border-white/5 dark:bg-neutral-900">
<div className="h-48 w-full shrink-0 animate-pulse bg-neutral-300 dark:bg-neutral-700" />
<div className="flex flex-col gap-2 px-3 py-3">
<div className="h-3 w-2/3 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
<div className="h-3 w-3/4 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
<span className="mt-2.5 text-sm leading-none text-neutral-600 dark:text-neutral-400">
{domain.hostname}
</span>
</div>
</div>
);
}
if (!data.title && !data.image && !data.description) {
return (
<a
href={url}
target="_blank"
rel="noreferrer"
className="text-blue-500 hover:text-blue-600"
>
{url}
</a>
);
}
if (isError) {
return (
<a
href={url}
target="_blank"
rel="noreferrer"
className="text-blue-500 hover:text-blue-600"
>
{url}
</a>
);
}
return (
<a
href={url}
target="_blank"
rel="noreferrer"
className="mb-2.5 mt-1 flex w-full flex-col overflow-hidden rounded-xl border border-black/5 bg-neutral-100 dark:border-white/5 dark:bg-neutral-900"
>
{isImage(data.image) ? (
<img
src={data.image}
alt={url}
loading="lazy"
decoding="async"
className="h-48 w-full shrink-0 rounded-t-lg bg-white object-cover"
/>
) : null}
<div className="flex flex-col items-start p-3">
<div className="flex flex-col items-start text-left">
{data.title ? (
<div className="break-p text-base font-semibold text-neutral-900 dark:text-neutral-100">
{data.title}
</div>
) : null}
{data.description ? (
<div className="break-p mb-2 line-clamp-3 text-balance text-sm text-neutral-700 dark:text-neutral-400">
{data.description}
</div>
) : null}
</div>
<div className="break-all text-sm font-semibold text-blue-500">
{domain.hostname}
</div>
</div>
</a>
);
}

View File

@@ -0,0 +1,30 @@
import {
MediaControlBar,
MediaController,
MediaMuteButton,
MediaPlayButton,
MediaTimeDisplay,
MediaTimeRange,
} from "media-chrome/dist/react";
export function VideoPreview({ url }: { url: string }) {
return (
<div className="mt-1 mb-2.5 w-full rounded-xl overflow-hidden">
<MediaController>
<video
slot="media"
src={url}
preload="auto"
muted
className="w-full h-auto"
/>
<MediaControlBar>
<MediaPlayButton />
<MediaTimeRange />
<MediaTimeDisplay showDuration />
<MediaMuteButton />
</MediaControlBar>
</MediaController>
</div>
);
}

View File

@@ -0,0 +1,20 @@
import { Event } from "@lume/types";
import { Note } from "..";
export function ChildReply({ event }: { event: Event; rootEventId?: string }) {
return (
<Note.Provider event={event}>
<Note.Root className="py-2">
<div className="flex items-center justify-between h-14">
<Note.User className="flex-1 pr-2" />
<Note.Menu />
</div>
<Note.Content />
<div className="flex items-center justify-end gap-4 mt-2">
<Note.Repost />
<Note.Zap />
</div>
</Note.Root>
</Note.Provider>
);
}

View File

@@ -0,0 +1,69 @@
import { NavArrowDownIcon } from "@lume/icons";
import { EventWithReplies } from "@lume/types";
import { cn } from "@lume/utils";
import * as Collapsible from "@radix-ui/react-collapsible";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Note } from "..";
import { ChildReply } from "./childReply";
export function Reply({
event,
}: {
event: EventWithReplies;
}) {
const [t] = useTranslation();
const [open, setOpen] = useState(false);
return (
<Collapsible.Root open={open} onOpenChange={setOpen}>
<Note.Provider event={event}>
<Note.Root className="pt-2">
<div className="flex items-center justify-between h-14">
<Note.User className="flex-1 pr-2" />
<Note.Menu />
</div>
<Note.Content />
<div className="flex items-center justify-between h-14">
{event.replies?.length > 0 ? (
<Collapsible.Trigger asChild>
<div className="inline-flex items-center gap-1 font-semibold text-sm text-neutral-600 dark:text-neutral-400 h-14">
<NavArrowDownIcon
className={cn("size-5", open ? "rotate-180 transform" : "")}
/>
{`${event.replies?.length} ${
event.replies?.length === 1
? t("note.reply.single")
: t("note.reply.plural")
}`}
</div>
</Collapsible.Trigger>
) : (
<div />
)}
<div className="inline-flex items-center gap-4">
<Note.Reply />
<Note.Repost />
<Note.Zap />
</div>
</div>
<div
className={cn(
open
? "pb-3 border-t border-neutral-100 dark:border-neutral-900"
: "",
)}
>
{event.replies?.length > 0 ? (
<Collapsible.Content className="divide-y divide-neutral-100 dark:divide-neutral-900 pl-6">
{event.replies?.map((childEvent) => (
<ChildReply key={childEvent.id} event={childEvent} />
))}
</Collapsible.Content>
) : null}
</div>
</Note.Root>
</Note.Provider>
</Collapsible.Root>
);
}

View File

@@ -0,0 +1,113 @@
import { RepostIcon } from "@lume/icons";
import { Event } from "@lume/types";
import { cn } from "@lume/utils";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Note } from "..";
import { User } from "../../user";
import { useArk } from "@lume/ark";
export function RepostNote({
event,
className,
}: {
event: Event;
className?: string;
}) {
const ark = useArk();
const { t } = useTranslation();
const {
isLoading,
isError,
data: repostEvent,
} = useQuery({
queryKey: ["repost", event.id],
queryFn: async () => {
try {
if (event.content.length > 50) {
const embed = JSON.parse(event.content) as Event;
return embed;
}
const id = event.tags.find((el) => el[0] === "e")[1];
return await ark.get_event(id);
} catch {
throw new Error("Failed to get repost event");
}
},
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
});
if (isLoading) {
return <div className="w-full px-3 pb-3">Loading...</div>;
}
if (isError || !repostEvent) {
return (
<Note.Root className={className}>
<User.Provider pubkey={event.pubkey}>
<User.Root className="flex h-14 gap-2 px-3">
<div className="inline-flex w-10 shrink-0 items-center justify-center">
<RepostIcon className="h-5 w-5 text-blue-500" />
</div>
<div className="inline-flex items-center gap-2">
<User.Avatar className="size-6 shrink-0 rounded object-cover" />
<div className="inline-flex items-baseline gap-1">
<User.Name className="font-medium text-neutral-900 dark:text-neutral-100" />
<span className="text-blue-500">{t("note.reposted")}</span>
</div>
</div>
</User.Root>
</User.Provider>
<div className="mb-3 select-text px-3">
<div className="flex flex-col items-start justify-start rounded-lg bg-red-100 px-3 py-3 dark:bg-red-900">
<p className="text-red-500">Failed to get event</p>
</div>
</div>
</Note.Root>
);
}
return (
<Note.Root
className={cn(
"flex flex-col rounded-xl bg-neutral-50 dark:bg-neutral-950",
className,
)}
>
<User.Provider pubkey={event.pubkey}>
<User.Root className="flex h-14 gap-2 px-3">
<div className="inline-flex w-10 shrink-0 items-center justify-center">
<RepostIcon className="h-5 w-5 text-blue-500" />
</div>
<div className="inline-flex items-center gap-2">
<User.Avatar className="size-6 shrink-0 rounded object-cover" />
<div className="inline-flex items-baseline gap-1">
<User.Name className="font-medium text-neutral-900 dark:text-neutral-100" />
<span className="text-blue-500">{t("note.reposted")}</span>
</div>
</div>
</User.Root>
</User.Provider>
<Note.Provider event={repostEvent}>
<div className="relative flex flex-col gap-2 px-3">
<div className="flex items-center justify-between">
<Note.User className="flex-1 pr-2" />
<Note.Menu />
</div>
<Note.Content />
<div className="flex h-14 items-center justify-between">
<Note.Pin />
<div className="inline-flex items-center gap-4">
<Note.Reply />
<Note.Repost />
<Note.Zap />
</div>
</div>
</div>
</Note.Provider>
</Note.Root>
);
}

View File

@@ -0,0 +1,24 @@
import { Note } from '..';
export function NoteSkeleton() {
return (
<Note.Root>
<div className="flex h-min flex-col p-3">
<div className="flex items-start gap-2">
<div className="relative h-10 w-10 shrink-0 animate-pulse overflow-hidden rounded-lg bg-neutral-400 dark:bg-neutral-600" />
<div className="h-6 w-full">
<div className="h-4 w-24 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
</div>
</div>
<div className="-mt-4 flex gap-3">
<div className="w-10 shrink-0" />
<div className="flex w-full flex-col gap-1">
<div className="h-3 w-2/3 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
<div className="h-3 w-2/3 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
<div className="h-3 w-1/2 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
</div>
</div>
</div>
</Note.Root>
);
}

View File

@@ -0,0 +1,34 @@
import { Event } from "@lume/types";
import { cn } from "@lume/utils";
import { Note } from "..";
export function TextNote({
event,
className,
}: { event: Event; className?: string }) {
return (
<Note.Provider event={event}>
<Note.Root
className={cn(
"flex flex-col rounded-xl bg-neutral-50 dark:bg-neutral-950",
className,
)}
>
<div className="flex items-center justify-between px-3 h-14">
<Note.User className="flex-1 pr-2" />
<Note.Menu />
</div>
<Note.Thread className="mb-2" />
<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-4">
<Note.Reply />
<Note.Repost />
<Note.Zap />
</div>
</div>
</Note.Root>
</Note.Provider>
);
}

View File

@@ -0,0 +1,43 @@
import { useEvent } from "@lume/ark";
import { Note } from "..";
import { User } from "../../user";
export function ThreadNote({ eventId }: { eventId: string }) {
const { isLoading, data } = useEvent(eventId);
if (isLoading) {
return <div>Loading...</div>;
}
return (
<Note.Provider event={data}>
<Note.Root className="flex flex-col rounded-xl bg-neutral-50 dark:bg-neutral-950">
<div className="flex h-16 items-center justify-between px-3">
<User.Provider pubkey={data.pubkey}>
<User.Root className="flex h-16 flex-1 items-center gap-3">
<User.Avatar className="size-10 shrink-0 rounded-lg object-cover ring-1 ring-neutral-200/50 dark:ring-neutral-800/50" />
<div className="flex flex-1 flex-col">
<User.Name className="font-semibold text-neutral-900 dark:text-neutral-100" />
<div className="inline-flex items-center gap-2 text-sm text-neutral-600 dark:text-neutral-400">
<User.Time time={data.created_at} />
<span>·</span>
<User.NIP05 />
</div>
</div>
</User.Root>
</User.Provider>
<Note.Menu />
</div>
<Note.Thread className="mb-2" />
<Note.Content className="min-w-0 px-3" />
<div className="flex h-14 items-center justify-between px-3">
<Note.Pin />
<div className="inline-flex items-center gap-4">
<Note.Repost />
<Note.Zap />
</div>
</div>
</Note.Root>
</Note.Provider>
);
}

View File

@@ -0,0 +1,21 @@
import { Event } from "@lume/types";
import { ReactNode, createContext, useContext } from "react";
const EventContext = createContext<Event>(null);
export function NoteProvider({
event,
children,
}: { event: Event; children: ReactNode }) {
return (
<EventContext.Provider value={event}>{children}</EventContext.Provider>
);
}
export function useNoteContext() {
const context = useContext(EventContext);
if (!context) {
throw new Error("Please import Note Provider to use useNoteContext() hook");
}
return context;
}

View File

@@ -0,0 +1,19 @@
import { cn } from "@lume/utils";
import { ReactNode } from "react";
export function NoteRoot({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<div
className={cn("h-min w-full overflow-hidden", className)}
contentEditable={false}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,47 @@
import { PinIcon } from "@lume/icons";
import { cn } from "@lume/utils";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { Note } from ".";
import { useNoteContext } from "./provider";
import { useArk } from "@lume/ark";
export function NoteThread({ className }: { className?: string }) {
const ark = useArk();
const event = useNoteContext();
const thread = ark.parse_event_thread({
content: event.content,
tags: event.tags,
});
const { t } = useTranslation();
if (!thread) return null;
return (
<div className={cn("w-full px-3", className)}>
<div className="flex h-min w-full flex-col gap-3 rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
{thread.rootEventId ? (
<Note.Child eventId={thread.rootEventId} isRoot />
) : null}
{thread.replyEventId ? (
<Note.Child eventId={thread.replyEventId} />
) : null}
<div className="inline-flex items-center justify-between">
<Link
to={`/events/${thread?.rootEventId || thread?.replyEventId}`}
className="self-start text-blue-500 hover:text-blue-600"
>
{t("note.showThread")}
</Link>
<button
type="button"
className="inline-flex size-6 items-center justify-center rounded-md bg-neutral-200 text-neutral-600 hover:bg-neutral-300 dark:bg-neutral-800 dark:text-neutral-400 dark:hover:bg-neutral-700"
>
<PinIcon className="size-4" />
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,51 @@
import { cn } from "@lume/utils";
import * as HoverCard from "@radix-ui/react-hover-card";
import { User } from "../user";
import { useNoteContext } from "./provider";
export function NoteUser({ className }: { className?: string }) {
const event = useNoteContext();
return (
<User.Provider pubkey={event.pubkey}>
<HoverCard.Root>
<User.Root className={cn("flex items-center gap-3", className)}>
<HoverCard.Trigger>
<User.Avatar className="size-9 shrink-0 rounded-lg object-cover ring-1 ring-neutral-200/50 dark:ring-neutral-800/50" />
</HoverCard.Trigger>
<div className="flex h-6 flex-1 items-start justify-between gap-2">
<User.Name className="font-semibold text-neutral-950 dark:text-neutral-50" />
<User.Time
time={event.created_at}
className="text-neutral-500 dark:text-neutral-400"
/>
</div>
</User.Root>
<HoverCard.Portal>
<HoverCard.Content
className="data-[side=bottom]:animate-slideUpAndFade w-[300px] rounded-xl bg-white p-5 shadow-lg shadow-neutral-500/20 data-[state=open]:transition-all dark:border dark:border-neutral-800 dark:bg-neutral-900 dark:shadow-none"
sideOffset={5}
>
<div className="flex flex-col gap-2">
<User.Avatar className="size-11 rounded-lg object-cover ring-1 ring-neutral-200/50 dark:ring-neutral-800/50" />
<div className="flex flex-col gap-2">
<div>
<User.Name className="font-semibold leading-tight" />
<User.NIP05 className="text-neutral-600 dark:text-neutral-400" />
</div>
<User.About className="line-clamp-3" />
<a
href={`/users/${event.pubkey}`}
className="mt-3 inline-flex h-8 w-full items-center justify-center rounded-lg bg-neutral-100 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
View profile
</a>
</div>
</div>
<HoverCard.Arrow className="fill-white dark:fill-neutral-800" />
</HoverCard.Content>
</HoverCard.Portal>
</HoverCard.Root>
</User.Provider>
);
}

View File

@@ -1,83 +1,86 @@
import { Reply, useArk } from "@lume/ark";
import { useArk } from "@lume/ark";
import { LoaderIcon } from "@lume/icons";
import { NDKEventWithReplies } from "@lume/types";
import { cn } from "@lume/utils";
import { NDKKind, type NDKSubscription } from "@nostr-dev-kit/ndk";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ReplyForm } from "./editor/replyForm";
import { Reply } from "./note/primitives/reply";
export function ReplyList({
eventId,
className,
}: { eventId: string; className?: string }) {
const ark = useArk();
eventId,
className,
}: {
eventId: string;
className?: string;
}) {
const ark = useArk();
const [t] = useTranslation();
const [data, setData] = useState<null | NDKEventWithReplies[]>(null);
const [t] = useTranslation();
const [data, setData] = useState<null | NDKEventWithReplies[]>(null);
useEffect(() => {
let sub: NDKSubscription = undefined;
let isCancelled = false;
useEffect(() => {
let sub: NDKSubscription = undefined;
let isCancelled = false;
async function fetchRepliesAndSub() {
const id = ark.getCleanEventId(eventId);
const events = await ark.getThreads(id);
async function fetchRepliesAndSub() {
const id = ark.getCleanEventId(eventId);
const events = await ark.getThreads(id);
if (!isCancelled) {
setData(events);
}
if (!isCancelled) {
setData(events);
}
if (!sub) {
sub = ark.subscribe({
filter: {
"#e": [id],
kinds: [NDKKind.Text],
since: Math.floor(Date.now() / 1000),
},
closeOnEose: false,
cb: (event: NDKEventWithReplies) =>
setData((prev) => [event, ...prev]),
});
}
}
if (!sub) {
sub = ark.subscribe({
filter: {
"#e": [id],
kinds: [NDKKind.Text],
since: Math.floor(Date.now() / 1000),
},
closeOnEose: false,
cb: (event: NDKEventWithReplies) =>
setData((prev) => [event, ...prev]),
});
}
}
// subscribe for new replies
fetchRepliesAndSub();
// subscribe for new replies
fetchRepliesAndSub();
return () => {
isCancelled = true;
if (sub) sub.stop();
};
}, [eventId]);
return () => {
isCancelled = true;
if (sub) sub.stop();
};
}, [eventId]);
return (
<div
className={cn(
"flex flex-col divide-y divide-neutral-100 dark:divide-neutral-900",
className,
)}
>
<ReplyForm
eventId={eventId}
className="py-4 border-t border-neutral-100 dark:border-neutral-900"
/>
{!data ? (
<div className="mt-4 flex h-16 items-center justify-center p-3">
<LoaderIcon className="h-5 w-5 animate-spin" />
</div>
) : data.length === 0 ? (
<div className="mt-4 flex w-full items-center justify-center">
<div className="flex flex-col items-center justify-center gap-2 py-6">
<h3 className="text-3xl">👋</h3>
<p className="leading-none text-neutral-600 dark:text-neutral-400">
{t("note.reply.empty")}
</p>
</div>
</div>
) : (
data.map((event) => <Reply key={event.id} event={event} />)
)}
</div>
);
return (
<div
className={cn(
"flex flex-col divide-y divide-neutral-100 dark:divide-neutral-900",
className,
)}
>
<ReplyForm
eventId={eventId}
className="border-t border-neutral-100 py-4 dark:border-neutral-900"
/>
{!data ? (
<div className="mt-4 flex h-16 items-center justify-center p-3">
<LoaderIcon className="h-5 w-5 animate-spin" />
</div>
) : data.length === 0 ? (
<div className="mt-4 flex w-full items-center justify-center">
<div className="flex flex-col items-center justify-center gap-2 py-6">
<h3 className="text-3xl">👋</h3>
<p className="leading-none text-neutral-600 dark:text-neutral-400">
{t("note.reply.empty")}
</p>
</div>
</div>
) : (
data.map((event) => <Reply key={event.id} event={event} />)
)}
</div>
);
}

View File

@@ -1,37 +1,37 @@
import { ThreadNote } from "@lume/ark";
import { ArrowLeftIcon, ArrowRightIcon } from "@lume/icons";
import { useNavigate, useParams } from "react-router-dom";
import { WindowVirtualizer } from "virtua";
import { ReplyList } from "../replyList";
import { ThreadNote } from "../note/primitives/thread";
export function EventRoute() {
const { id } = useParams();
const navigate = useNavigate();
const { id } = useParams();
const navigate = useNavigate();
return (
<div className="pb-5 overflow-y-auto">
<WindowVirtualizer>
<div className="relative z-50 h-11 bg-neutral-50 dark:bg-neutral-950 border-b flex items-center justify-start gap-2 px-3 border-neutral-100 dark:border-neutral-900 mb-3">
<button
type="button"
className="size-9 hover:bg-neutral-100 hover:text-blue-500 dark:hover:bg-neutral-900 rounded-lg inline-flex items-center justify-center"
onClick={() => navigate(-1)}
>
<ArrowLeftIcon className="size-5" />
</button>
<button
type="button"
className="size-9 hover:bg-neutral-100 hover:text-blue-500 dark:hover:bg-neutral-900 rounded-lg inline-flex items-center justify-center"
onClick={() => navigate(1)}
>
<ArrowRightIcon className="size-5" />
</button>
</div>
<div className="px-3">
<ThreadNote eventId={id} />
<ReplyList eventId={id} className="mt-3" />
</div>
</WindowVirtualizer>
</div>
);
return (
<div className="overflow-y-auto pb-5">
<WindowVirtualizer>
<div className="relative z-50 mb-3 flex h-11 items-center justify-start gap-2 border-b border-neutral-100 bg-neutral-50 px-3 dark:border-neutral-900 dark:bg-neutral-950">
<button
type="button"
className="inline-flex size-9 items-center justify-center rounded-lg hover:bg-neutral-100 hover:text-blue-500 dark:hover:bg-neutral-900"
onClick={() => navigate(-1)}
>
<ArrowLeftIcon className="size-5" />
</button>
<button
type="button"
className="inline-flex size-9 items-center justify-center rounded-lg hover:bg-neutral-100 hover:text-blue-500 dark:hover:bg-neutral-900"
onClick={() => navigate(1)}
>
<ArrowRightIcon className="size-5" />
</button>
</div>
<div className="px-3">
<ThreadNote eventId={id} />
<ReplyList eventId={id} className="mt-3" />
</div>
</WindowVirtualizer>
</div>
);
}

View File

@@ -1,127 +1,127 @@
import { User } from "@lume/ark";
import { ArrowLeftIcon, ArrowRightIcon, LoaderIcon } from "@lume/icons";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { WindowVirtualizer } from "virtua";
import { User } from "../user";
const POPULAR_USERS = [
"npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6",
"npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m",
"npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s",
"npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z",
"npub1az9xj85cmxv8e9j9y80lvqp97crsqdu2fpu3srwthd99qfu9qsgstam8y8",
"npub1a2cww4kn9wqte4ry70vyfwqyqvpswksna27rtxd8vty6c74era8sdcw83a",
"npub168ghgug469n4r2tuyw05dmqhqv5jcwm7nxytn67afmz8qkc4a4zqsu2dlc",
"npub133vj8ycevdle0cq8mtgddq0xtn34kxkwxvak983dx0u5vhqnycyqj6tcza",
"npub18ams6ewn5aj2n3wt2qawzglx9mr4nzksxhvrdc4gzrecw7n5tvjqctp424",
"npub1r0rs5q2gk0e3dk3nlc7gnu378ec6cnlenqp8a3cjhyzu6f8k5sgs4sq9ac",
"npub1prya33fnqerq0fljwjtp77ehtu7jlsjt5ydhwveuwmqdsdm6k8esk42xcv",
"npub19mduaf5569jx9xz555jcx3v06mvktvtpu0zgk47n4lcpjsz43zzqhj6vzk",
"npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6",
"npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m",
"npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s",
"npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z",
"npub1az9xj85cmxv8e9j9y80lvqp97crsqdu2fpu3srwthd99qfu9qsgstam8y8",
"npub1a2cww4kn9wqte4ry70vyfwqyqvpswksna27rtxd8vty6c74era8sdcw83a",
"npub168ghgug469n4r2tuyw05dmqhqv5jcwm7nxytn67afmz8qkc4a4zqsu2dlc",
"npub133vj8ycevdle0cq8mtgddq0xtn34kxkwxvak983dx0u5vhqnycyqj6tcza",
"npub18ams6ewn5aj2n3wt2qawzglx9mr4nzksxhvrdc4gzrecw7n5tvjqctp424",
"npub1r0rs5q2gk0e3dk3nlc7gnu378ec6cnlenqp8a3cjhyzu6f8k5sgs4sq9ac",
"npub1prya33fnqerq0fljwjtp77ehtu7jlsjt5ydhwveuwmqdsdm6k8esk42xcv",
"npub19mduaf5569jx9xz555jcx3v06mvktvtpu0zgk47n4lcpjsz43zzqhj6vzk",
];
const LUME_USERS = [
"npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445",
"npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445",
];
export function SuggestRoute({ queryKey }: { queryKey: string }) {
const queryClient = useQueryClient();
const navigate = useNavigate();
const queryClient = useQueryClient();
const navigate = useNavigate();
const { t } = useTranslation();
const { isLoading, isError, data } = useQuery({
queryKey: ["trending-users"],
queryFn: async ({ signal }: { signal: AbortSignal }) => {
const res = await fetch("https://api.nostr.band/v0/trending/profiles", {
signal,
});
if (!res.ok) {
throw new Error("Failed to fetch trending users from nostr.band API.");
}
return res.json();
},
});
const { t } = useTranslation();
const { isLoading, isError, data } = useQuery({
queryKey: ["trending-users"],
queryFn: async ({ signal }: { signal: AbortSignal }) => {
const res = await fetch("https://api.nostr.band/v0/trending/profiles", {
signal,
});
if (!res.ok) {
throw new Error("Failed to fetch trending users from nostr.band API.");
}
return res.json();
},
});
const submit = async () => {
try {
await queryClient.refetchQueries({ queryKey: [queryKey] });
return navigate("/", { replace: true });
} catch (e) {
toast.error(String(e));
}
};
const submit = async () => {
try {
await queryClient.refetchQueries({ queryKey: [queryKey] });
return navigate("/", { replace: true });
} catch (e) {
toast.error(String(e));
}
};
return (
<div className="pb-5 overflow-y-auto">
<WindowVirtualizer>
<div className="h-11 bg-neutral-50 dark:bg-neutral-950 border-b flex items-center justify-start gap-2 px-3 border-neutral-100 dark:border-neutral-900 mb-3">
<button
type="button"
className="size-9 hover:bg-neutral-100 hover:text-blue-500 dark:hover:bg-neutral-900 rounded-lg inline-flex items-center justify-center"
onClick={() => navigate(-1)}
>
<ArrowLeftIcon className="size-5" />
</button>
<button
type="button"
className="size-9 hover:bg-neutral-100 hover:text-blue-500 dark:hover:bg-neutral-900 rounded-lg inline-flex items-center justify-center"
onClick={() => navigate(1)}
>
<ArrowRightIcon className="size-5" />
</button>
</div>
<div className="relative px-3">
<div className="flex items-center h-16">
<h3 className="font-semibold text-xl">{t("suggestion.title")}</h3>
</div>
<div className="flex flex-col divide-y divide-neutral-100 dark:divide-neutral-900">
{isLoading ? (
<div className="flex h-44 w-full items-center justify-center">
<LoaderIcon className="size-4 animate-spin" />
</div>
) : isError ? (
<div className="flex h-44 w-full items-center justify-center">
{t("suggestion.error")}
</div>
) : (
data?.profiles.map((item: { pubkey: string }) => (
<div
key={item.pubkey}
className="py-5 h-max w-full overflow-hidden"
>
<User.Provider pubkey={item.pubkey}>
<User.Root>
<div className="flex h-full w-full flex-col gap-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<User.Avatar className="size-10 shrink-0 rounded-lg" />
<User.Name className="max-w-[15rem] truncate font-semibold leadning-tight" />
</div>
<User.Button
target={item.pubkey}
className="w-20 h-8 text-sm font-medium bg-neutral-100 dark:bg-neutral-900 hover:bg-neutral-200 dark:hover:bg-neutral-800 rounded-lg inline-flex items-center justify-center"
/>
</div>
<User.About className="mt-1 line-clamp-3 text-neutral-800 dark:text-neutral-400 max-w-none select-text" />
</div>
</User.Root>
</User.Provider>
</div>
))
)}
</div>
<div className="sticky z-10 flex items-center justify-center w-full bottom-0">
<button
type="button"
onClick={submit}
className="inline-flex items-center justify-center gap-2 px-6 font-medium shadow-xl dark:shadow-none shadow-neutral-500/50 text-white transform bg-blue-500 rounded-full active:translate-y-1 w-44 h-11 hover:bg-blue-600 focus:outline-none disabled:cursor-not-allowed"
>
{t("suggestion.button")}
</button>
</div>
</div>
</WindowVirtualizer>
</div>
);
return (
<div className="overflow-y-auto pb-5">
<WindowVirtualizer>
<div className="mb-3 flex h-11 items-center justify-start gap-2 border-b border-neutral-100 bg-neutral-50 px-3 dark:border-neutral-900 dark:bg-neutral-950">
<button
type="button"
className="inline-flex size-9 items-center justify-center rounded-lg hover:bg-neutral-100 hover:text-blue-500 dark:hover:bg-neutral-900"
onClick={() => navigate(-1)}
>
<ArrowLeftIcon className="size-5" />
</button>
<button
type="button"
className="inline-flex size-9 items-center justify-center rounded-lg hover:bg-neutral-100 hover:text-blue-500 dark:hover:bg-neutral-900"
onClick={() => navigate(1)}
>
<ArrowRightIcon className="size-5" />
</button>
</div>
<div className="relative px-3">
<div className="flex h-16 items-center">
<h3 className="text-xl font-semibold">{t("suggestion.title")}</h3>
</div>
<div className="flex flex-col divide-y divide-neutral-100 dark:divide-neutral-900">
{isLoading ? (
<div className="flex h-44 w-full items-center justify-center">
<LoaderIcon className="size-4 animate-spin" />
</div>
) : isError ? (
<div className="flex h-44 w-full items-center justify-center">
{t("suggestion.error")}
</div>
) : (
data?.profiles.map((item: { pubkey: string }) => (
<div
key={item.pubkey}
className="h-max w-full overflow-hidden py-5"
>
<User.Provider pubkey={item.pubkey}>
<User.Root>
<div className="flex h-full w-full flex-col gap-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<User.Avatar className="size-10 shrink-0 rounded-lg" />
<User.Name className="leadning-tight max-w-[15rem] truncate font-semibold" />
</div>
<User.Button
target={item.pubkey}
className="inline-flex h-8 w-20 items-center justify-center rounded-lg bg-neutral-100 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
/>
</div>
<User.About className="mt-1 line-clamp-3 max-w-none select-text text-neutral-800 dark:text-neutral-400" />
</div>
</User.Root>
</User.Provider>
</div>
))
)}
</div>
<div className="sticky bottom-0 z-10 flex w-full items-center justify-center">
<button
type="button"
onClick={submit}
className="inline-flex h-11 w-44 transform items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white shadow-xl shadow-neutral-500/50 hover:bg-blue-600 focus:outline-none active:translate-y-1 disabled:cursor-not-allowed dark:shadow-none"
>
{t("suggestion.button")}
</button>
</div>
</div>
</WindowVirtualizer>
</div>
);
}

View File

@@ -1,9 +1,9 @@
import { RepostNote, TextNote, User, useArk } from "@lume/ark";
import { useArk } from "@lume/ark";
import {
ArrowLeftIcon,
ArrowRightCircleIcon,
ArrowRightIcon,
LoaderIcon,
ArrowLeftIcon,
ArrowRightCircleIcon,
ArrowRightIcon,
LoaderIcon,
} from "@lume/icons";
import { FETCH_LIMIT } from "@lume/utils";
import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk";
@@ -12,136 +12,137 @@ import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom";
import { WindowVirtualizer } from "virtua";
import { User } from "../user";
export function UserRoute() {
const ark = useArk();
const navigate = useNavigate();
const ark = useArk();
const navigate = useNavigate();
const { id } = useParams();
const { t } = useTranslation();
const { data, hasNextPage, isLoading, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: ["user-posts", id],
initialPageParam: 0,
queryFn: async ({
signal,
pageParam,
}: {
signal: AbortSignal;
pageParam: number;
}) => {
const events = await ark.getInfiniteEvents({
filter: {
kinds: [NDKKind.Text, NDKKind.Repost],
authors: [id],
},
limit: FETCH_LIMIT,
pageParam,
signal,
});
const { id } = useParams();
const { t } = useTranslation();
const { data, hasNextPage, isLoading, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: ["user-posts", id],
initialPageParam: 0,
queryFn: async ({
signal,
pageParam,
}: {
signal: AbortSignal;
pageParam: number;
}) => {
const events = await ark.getInfiniteEvents({
filter: {
kinds: [NDKKind.Text, NDKKind.Repost],
authors: [id],
},
limit: FETCH_LIMIT,
pageParam,
signal,
});
return events;
},
getNextPageParam: (lastPage) => {
const lastEvent = lastPage.at(-1);
if (!lastEvent) return;
return lastEvent.created_at - 1;
},
refetchOnWindowFocus: false,
});
return events;
},
getNextPageParam: (lastPage) => {
const lastEvent = lastPage.at(-1);
if (!lastEvent) return;
return lastEvent.created_at - 1;
},
refetchOnWindowFocus: false,
});
const allEvents = useMemo(
() => (data ? data.pages.flatMap((page) => page) : []),
[data],
);
const allEvents = useMemo(
() => (data ? data.pages.flatMap((page) => page) : []),
[data],
);
const renderItem = (event: NDKEvent) => {
switch (event.kind) {
case NDKKind.Text:
return <TextNote key={event.id} event={event} className="mt-3" />;
case NDKKind.Repost:
return <RepostNote key={event.id} event={event} className="mt-3" />;
default:
return <TextNote key={event.id} event={event} className="mt-3" />;
}
};
const renderItem = (event: NDKEvent) => {
switch (event.kind) {
case NDKKind.Text:
return <TextNote key={event.id} event={event} className="mt-3" />;
case NDKKind.Repost:
return <RepostNote key={event.id} event={event} className="mt-3" />;
default:
return <TextNote key={event.id} event={event} className="mt-3" />;
}
};
return (
<div className="pb-5 overflow-y-auto">
<WindowVirtualizer>
<div className="h-11 bg-neutral-50 dark:bg-neutral-950 border-b flex items-center justify-start gap-2 px-3 border-neutral-100 dark:border-neutral-900 mb-3">
<button
type="button"
className="size-9 hover:bg-neutral-100 hover:text-blue-500 dark:hover:bg-neutral-900 rounded-lg inline-flex items-center justify-center"
onClick={() => navigate(-1)}
>
<ArrowLeftIcon className="size-5" />
</button>
<button
type="button"
className="size-9 hover:bg-neutral-100 hover:text-blue-500 dark:hover:bg-neutral-900 rounded-lg inline-flex items-center justify-center"
onClick={() => navigate(1)}
>
<ArrowRightIcon className="size-5" />
</button>
</div>
<div className="px-3">
<User.Provider pubkey={id}>
<User.Root className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<User.Avatar className="h-12 w-12 shrink-0 rounded-lg object-cover" />
<User.Button
target={id}
className="inline-flex items-center justify-center w-24 text-sm font-semibold border-t rounded-lg border-neutral-900 dark:border-neutral-800 h-9 bg-neutral-950 text-neutral-50 dark:bg-neutral-900 hover:bg-neutral-900 dark:hover:bg-neutral-800"
/>
</div>
<div className="flex flex-1 flex-col gap-1.5">
<div className="flex flex-col">
<User.Name className="text-lg font-semibold" />
<User.NIP05
pubkey={id}
className="max-w-[15rem] truncate text-sm text-neutral-600 dark:text-neutral-400"
/>
</div>
<User.About className="text-neutral-900 dark:text-neutral-100" />
</div>
</User.Root>
</User.Provider>
<div className="pt-2 mt-2 border-t border-neutral-100 dark:border-neutral-900">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{t("user.latestPosts")}
</h3>
<div className="flex h-full w-full flex-col justify-between gap-1.5 pb-10">
{isLoading ? (
<div className="flex items-center justify-center">
<LoaderIcon className="h-4 w-4 animate-spin" />
</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 items-center justify-center w-full h-12 gap-2 font-medium bg-neutral-100 hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800 rounded-xl focus:outline-none"
>
{isFetchingNextPage ? (
<LoaderIcon className="size-5 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="size-5" />
{t("global.loadMore")}
</>
)}
</button>
) : null}
</div>
</div>
</div>
</div>
</WindowVirtualizer>
</div>
);
return (
<div className="overflow-y-auto pb-5">
<WindowVirtualizer>
<div className="mb-3 flex h-11 items-center justify-start gap-2 border-b border-neutral-100 bg-neutral-50 px-3 dark:border-neutral-900 dark:bg-neutral-950">
<button
type="button"
className="inline-flex size-9 items-center justify-center rounded-lg hover:bg-neutral-100 hover:text-blue-500 dark:hover:bg-neutral-900"
onClick={() => navigate(-1)}
>
<ArrowLeftIcon className="size-5" />
</button>
<button
type="button"
className="inline-flex size-9 items-center justify-center rounded-lg hover:bg-neutral-100 hover:text-blue-500 dark:hover:bg-neutral-900"
onClick={() => navigate(1)}
>
<ArrowRightIcon className="size-5" />
</button>
</div>
<div className="px-3">
<User.Provider pubkey={id}>
<User.Root className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<User.Avatar className="h-12 w-12 shrink-0 rounded-lg object-cover" />
<User.Button
target={id}
className="inline-flex h-9 w-24 items-center justify-center rounded-lg border-t border-neutral-900 bg-neutral-950 text-sm font-semibold text-neutral-50 hover:bg-neutral-900 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800"
/>
</div>
<div className="flex flex-1 flex-col gap-1.5">
<div className="flex flex-col">
<User.Name className="text-lg font-semibold" />
<User.NIP05
pubkey={id}
className="max-w-[15rem] truncate text-sm text-neutral-600 dark:text-neutral-400"
/>
</div>
<User.About className="text-neutral-900 dark:text-neutral-100" />
</div>
</User.Root>
</User.Provider>
<div className="mt-2 border-t border-neutral-100 pt-2 dark:border-neutral-900">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{t("user.latestPosts")}
</h3>
<div className="flex h-full w-full flex-col justify-between gap-1.5 pb-10">
{isLoading ? (
<div className="flex items-center justify-center">
<LoaderIcon className="h-4 w-4 animate-spin" />
</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-12 w-full items-center justify-center gap-2 rounded-xl bg-neutral-100 font-medium hover:bg-neutral-200 focus:outline-none dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
{isFetchingNextPage ? (
<LoaderIcon className="size-5 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="size-5" />
{t("global.loadMore")}
</>
)}
</button>
) : null}
</div>
</div>
</div>
</div>
</WindowVirtualizer>
</div>
);
}

View File

@@ -1,21 +0,0 @@
import { cn } from "@lume/utils";
import type { ButtonHTMLAttributes } from "react";
export function WindowButton({
className,
children,
...props
}: ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button
type="button"
className={cn(
"inline-flex cursor-default items-center justify-center",
className,
)}
{...props}
>
{children}
</button>
);
}

View File

@@ -1,140 +0,0 @@
import type { SVGProps } from 'react';
export const WindowIcons = {
minimizeWin: (props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) => (
<svg
width="10"
height="1"
viewBox="0 0 10 1"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M0.498047 1.00098C0.429688 1.00098 0.364583 0.987956 0.302734 0.961914C0.244141 0.935872 0.192057 0.900065 0.146484 0.854492C0.100911 0.808919 0.0651042 0.756836 0.0390625 0.698242C0.0130208 0.636393 0 0.571289 0 0.50293C0 0.43457 0.0130208 0.371094 0.0390625 0.3125C0.0651042 0.250651 0.100911 0.19694 0.146484 0.151367C0.192057 0.102539 0.244141 0.0651042 0.302734 0.0390625C0.364583 0.0130208 0.429688 0 0.498047 0H9.50195C9.57031 0 9.63379 0.0130208 9.69238 0.0390625C9.75423 0.0651042 9.80794 0.102539 9.85352 0.151367C9.89909 0.19694 9.9349 0.250651 9.96094 0.3125C9.98698 0.371094 10 0.43457 10 0.50293C10 0.571289 9.98698 0.636393 9.96094 0.698242C9.9349 0.756836 9.89909 0.808919 9.85352 0.854492C9.80794 0.900065 9.75423 0.935872 9.69238 0.961914C9.63379 0.987956 9.57031 1.00098 9.50195 1.00098H0.498047Z"
fill="currentColor"
fillOpacity="0.8956"
/>
</svg>
),
maximizeWin: (props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) => (
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M1.47461 10.001C1.2793 10.001 1.09212 9.96191 0.913086 9.88379C0.734049 9.80241 0.576172 9.69499 0.439453 9.56152C0.30599 9.4248 0.198568 9.26693 0.117188 9.08789C0.0390625 8.90885 0 8.72168 0 8.52637V1.47559C0 1.28027 0.0390625 1.0931 0.117188 0.914062C0.198568 0.735026 0.30599 0.578776 0.439453 0.445312C0.576172 0.308594 0.734049 0.201172 0.913086 0.123047C1.09212 0.0416667 1.2793 0.000976562 1.47461 0.000976562H8.52539C8.7207 0.000976562 8.90788 0.0416667 9.08691 0.123047C9.26595 0.201172 9.4222 0.308594 9.55566 0.445312C9.69238 0.578776 9.7998 0.735026 9.87793 0.914062C9.95931 1.0931 10 1.28027 10 1.47559V8.52637C10 8.72168 9.95931 8.90885 9.87793 9.08789C9.7998 9.26693 9.69238 9.4248 9.55566 9.56152C9.4222 9.69499 9.26595 9.80241 9.08691 9.88379C8.90788 9.96191 8.7207 10.001 8.52539 10.001H1.47461ZM8.50098 9C8.56934 9 8.63281 8.98698 8.69141 8.96094C8.75326 8.9349 8.80697 8.89909 8.85254 8.85352C8.89811 8.80794 8.93392 8.75586 8.95996 8.69727C8.986 8.63542 8.99902 8.57031 8.99902 8.50195V1.5C8.99902 1.43164 8.986 1.36816 8.95996 1.30957C8.93392 1.24772 8.89811 1.19401 8.85254 1.14844C8.80697 1.10286 8.75326 1.06706 8.69141 1.04102C8.63281 1.01497 8.56934 1.00195 8.50098 1.00195H1.49902C1.43066 1.00195 1.36556 1.01497 1.30371 1.04102C1.24512 1.06706 1.19303 1.10286 1.14746 1.14844C1.10189 1.19401 1.06608 1.24772 1.04004 1.30957C1.014 1.36816 1.00098 1.43164 1.00098 1.5V8.50195C1.00098 8.57031 1.014 8.63542 1.04004 8.69727C1.06608 8.75586 1.10189 8.80794 1.14746 8.85352C1.19303 8.89909 1.24512 8.9349 1.30371 8.96094C1.36556 8.98698 1.43066 9 1.49902 9H8.50098Z"
fill="currentColor"
fillOpacity="0.8956"
/>
</svg>
),
maximizeRestoreWin: (props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) => (
<svg
width="10"
height="11"
viewBox="0 0 10 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M8.99902 2.98096C8.99902 2.71077 8.94531 2.45687 8.83789 2.21924C8.73047 1.97835 8.58398 1.77002 8.39844 1.59424C8.21615 1.4152 8.00293 1.27523 7.75879 1.17432C7.5179 1.07015 7.264 1.01807 6.99707 1.01807H2.08496C2.13704 0.868327 2.21029 0.731608 2.30469 0.60791C2.39909 0.484212 2.50814 0.378418 2.63184 0.290527C2.75553 0.202637 2.89062 0.135905 3.03711 0.090332C3.18685 0.0415039 3.34147 0.0170898 3.50098 0.0170898H6.99707C7.41048 0.0170898 7.79948 0.0968424 8.16406 0.256348C8.52865 0.412598 8.84603 0.625814 9.11621 0.895996C9.38965 1.16618 9.60449 1.48356 9.76074 1.84814C9.92025 2.21273 10 2.60173 10 3.01514V6.51611C10 6.67562 9.97559 6.83024 9.92676 6.97998C9.88118 7.12646 9.81445 7.26156 9.72656 7.38525C9.63867 7.50895 9.53288 7.618 9.40918 7.7124C9.28548 7.8068 9.14876 7.88005 8.99902 7.93213V2.98096ZM1.47461 10.0171C1.2793 10.0171 1.09212 9.97803 0.913086 9.8999C0.734049 9.81852 0.576172 9.7111 0.439453 9.57764C0.30599 9.44092 0.198568 9.28304 0.117188 9.104C0.0390625 8.92497 0 8.73779 0 8.54248V3.49365C0 3.29508 0.0390625 3.10791 0.117188 2.93213C0.198568 2.75309 0.30599 2.59684 0.439453 2.46338C0.576172 2.32666 0.732422 2.21924 0.908203 2.14111C1.08724 2.05973 1.27604 2.01904 1.47461 2.01904H6.52344C6.72201 2.01904 6.91081 2.05973 7.08984 2.14111C7.26888 2.21924 7.42513 2.32503 7.55859 2.4585C7.69206 2.59196 7.79785 2.74821 7.87598 2.92725C7.95736 3.10628 7.99805 3.29508 7.99805 3.49365V8.54248C7.99805 8.74105 7.95736 8.92985 7.87598 9.10889C7.79785 9.28467 7.69043 9.44092 7.55371 9.57764C7.42025 9.7111 7.264 9.81852 7.08496 9.8999C6.90918 9.97803 6.72201 10.0171 6.52344 10.0171H1.47461ZM6.49902 9.01611C6.56738 9.01611 6.63086 9.00309 6.68945 8.97705C6.7513 8.95101 6.80501 8.9152 6.85059 8.86963C6.89941 8.82406 6.93685 8.77197 6.96289 8.71338C6.98893 8.65153 7.00195 8.58643 7.00195 8.51807V3.51807C7.00195 3.44971 6.98893 3.3846 6.96289 3.32275C6.93685 3.2609 6.90104 3.20719 6.85547 3.16162C6.8099 3.11605 6.75618 3.08024 6.69434 3.0542C6.63249 3.02816 6.56738 3.01514 6.49902 3.01514H1.49902C1.43066 3.01514 1.36556 3.02816 1.30371 3.0542C1.24512 3.08024 1.19303 3.11768 1.14746 3.1665C1.10189 3.21208 1.06608 3.26579 1.04004 3.32764C1.014 3.38623 1.00098 3.44971 1.00098 3.51807V8.51807C1.00098 8.58643 1.014 8.65153 1.04004 8.71338C1.06608 8.77197 1.10189 8.82406 1.14746 8.86963C1.19303 8.9152 1.24512 8.95101 1.30371 8.97705C1.36556 9.00309 1.43066 9.01611 1.49902 9.01611H6.49902Z"
fill="currentColor"
fillOpacity="0.8956"
/>
</svg>
),
closeWin: (props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) => (
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M5 5.70898L0.854492 9.85449C0.756836 9.95215 0.639648 10.001 0.50293 10.001C0.359701 10.001 0.239258 9.95378 0.141602 9.85938C0.0472005 9.76172 0 9.64128 0 9.49805C0 9.36133 0.0488281 9.24414 0.146484 9.14648L4.29199 5.00098L0.146484 0.855469C0.0488281 0.757812 0 0.638997 0 0.499023C0 0.430664 0.0130208 0.36556 0.0390625 0.303711C0.0651042 0.241862 0.100911 0.189779 0.146484 0.147461C0.192057 0.101888 0.245768 0.0660807 0.307617 0.0400391C0.369466 0.0139974 0.43457 0.000976562 0.50293 0.000976562C0.639648 0.000976562 0.756836 0.0498047 0.854492 0.147461L5 4.29297L9.14551 0.147461C9.24316 0.0498047 9.36198 0.000976562 9.50195 0.000976562C9.57031 0.000976562 9.63379 0.0139974 9.69238 0.0400391C9.75423 0.0660807 9.80794 0.101888 9.85352 0.147461C9.89909 0.193034 9.9349 0.246745 9.96094 0.308594C9.98698 0.367188 10 0.430664 10 0.499023C10 0.638997 9.95117 0.757812 9.85352 0.855469L5.70801 5.00098L9.85352 9.14648C9.95117 9.24414 10 9.36133 10 9.49805C10 9.56641 9.98698 9.63151 9.96094 9.69336C9.9349 9.75521 9.89909 9.80892 9.85352 9.85449C9.8112 9.90007 9.75911 9.93587 9.69727 9.96191C9.63542 9.98796 9.57031 10.001 9.50195 10.001C9.36198 10.001 9.24316 9.95215 9.14551 9.85449L5 5.70898Z"
fill="currentColor"
fillOpacity="0.8956"
/>
</svg>
),
closeMac: (props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) => (
<svg
width="6"
height="6"
viewBox="0 0 16 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M15.7522 4.44381L11.1543 9.04165L15.7494 13.6368C16.0898 13.9771 16.078 14.5407 15.724 14.8947L13.8907 16.728C13.5358 17.0829 12.9731 17.0938 12.6328 16.7534L8.03766 12.1583L3.44437 16.7507C3.10402 17.091 2.54132 17.0801 2.18645 16.7253L0.273257 14.8121C-0.0807018 14.4572 -0.0925004 13.8945 0.247845 13.5542L4.84024 8.96087L0.32499 4.44653C-0.0153555 4.10619 -0.00355681 3.54258 0.350402 3.18862L2.18373 1.35529C2.53859 1.00042 3.1013 0.989533 3.44164 1.32988L7.95689 5.84422L12.5556 1.24638C12.8951 0.906035 13.4587 0.917833 13.8126 1.27179L15.7267 3.18589C16.0807 3.53985 16.0925 4.10346 15.7522 4.44381Z"
fill="currentColor"
/>
</svg>
),
minMac: (props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) => (
<svg
width="8"
height="8"
viewBox="0 0 17 6"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g clipPath="url(#clip0_20_2051)">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M1.47211 1.18042H15.4197C15.8052 1.18042 16.1179 1.50551 16.1179 1.90769V3.73242C16.1179 4.13387 15.8052 4.80006 15.4197 4.80006H1.47211C1.08665 4.80006 0.773926 4.47497 0.773926 4.07278V1.90769C0.773926 1.50551 1.08665 1.18042 1.47211 1.18042Z"
fill="currentColor"
/>
</g>
</svg>
),
fullMac: (props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) => (
<svg
width="6"
height="6"
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g clipPath="url(#clip0_20_2057)">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3.53068 0.433838L15.0933 12.0409C15.0933 12.0409 15.0658 5.35028 15.0658 4.01784C15.0658 1.32095 14.1813 0.433838 11.5378 0.433838C10.6462 0.433838 3.53068 0.433838 3.53068 0.433838ZM12.4409 15.5378L0.87735 3.93073C0.87735 3.93073 0.905794 10.6214 0.905794 11.9538C0.905794 14.6507 1.79024 15.5378 4.43291 15.5378C5.32535 15.5378 12.4409 15.5378 12.4409 15.5378Z"
fill="currentColor"
/>
</g>
</svg>
),
plusMac: (props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) => (
<svg
width="8"
height="8"
viewBox="0 0 17 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g clipPath="url(#clip0_20_2053)">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M15.5308 9.80147H10.3199V15.0095C10.3199 15.3949 9.9941 15.7076 9.59265 15.7076H7.51555C7.11337 15.7076 6.78828 15.3949 6.78828 15.0095V9.80147H1.58319C1.19774 9.80147 0.88501 9.47638 0.88501 9.07419V6.90619C0.88501 6.50401 1.19774 6.17892 1.58319 6.17892H6.78828V1.06183C6.78828 0.676375 7.11337 0.363647 7.51555 0.363647H9.59265C9.9941 0.363647 10.3199 0.676375 10.3199 1.06183V6.17892H15.5308C15.9163 6.17892 16.229 6.50401 16.229 6.90619V9.07419C16.229 9.47638 15.9163 9.80147 15.5308 9.80147Z"
fill="currentColor"
/>
</g>
</svg>
),
};

View File

@@ -1,115 +0,0 @@
import { type Window, getCurrent } from "@tauri-apps/api/window";
import { type } from "@tauri-apps/plugin-os";
import React, { createContext, useCallback, useEffect, useState } from "react";
interface AppWindowContextType {
appWindow: Window | null;
isWindowMaximized: boolean;
minimizeWindow: () => Promise<void>;
maximizeWindow: () => Promise<void>;
fullscreenWindow: () => Promise<void>;
closeWindow: () => Promise<void>;
}
export const AppWindowContext = createContext<AppWindowContextType>({
appWindow: null,
isWindowMaximized: false,
minimizeWindow: () => Promise.resolve(),
maximizeWindow: () => Promise.resolve(),
fullscreenWindow: () => Promise.resolve(),
closeWindow: () => Promise.resolve(),
});
interface AppWindowProviderProps {
children: React.ReactNode;
}
export const AppWindowProvider: React.FC<AppWindowProviderProps> = ({
children,
}) => {
const [appWindow, setAppWindow] = useState<Window | null>(null);
const [isWindowMaximized, setIsWindowMaximized] = useState(false);
useEffect(() => {
const window = getCurrent();
setAppWindow(window);
}, []);
const updateIsWindowMaximized = useCallback(async () => {
if (appWindow) {
const _isWindowMaximized = await appWindow.isMaximized();
setIsWindowMaximized(_isWindowMaximized);
}
}, [appWindow]);
useEffect(() => {
let unlisten: () => void = () => {};
async function getOsType() {
const osname = await type();
if (osname !== "macos") {
updateIsWindowMaximized();
const listen = async () => {
if (appWindow) {
unlisten = await appWindow.onResized(() => {
updateIsWindowMaximized();
});
}
};
listen();
}
}
getOsType();
// Cleanup the listener when the component unmounts
return () => unlisten?.();
}, [appWindow, updateIsWindowMaximized]);
const minimizeWindow = async () => {
if (appWindow) {
await appWindow.minimize();
}
};
const maximizeWindow = async () => {
if (appWindow) {
await appWindow.toggleMaximize();
}
};
const fullscreenWindow = async () => {
if (appWindow) {
const fullscreen = await appWindow.isFullscreen();
if (fullscreen) {
await appWindow.setFullscreen(false);
} else {
await appWindow.setFullscreen(true);
}
}
};
const closeWindow = async () => {
if (appWindow) {
await appWindow.close();
}
};
return (
<AppWindowContext.Provider
value={{
appWindow,
isWindowMaximized,
minimizeWindow,
maximizeWindow,
fullscreenWindow,
closeWindow,
}}
>
{children}
</AppWindowContext.Provider>
);
};

View File

@@ -1,40 +0,0 @@
import { cn } from "@lume/utils";
import { HTMLProps, useContext } from "react";
import { WindowButton } from "../components/button";
import { WindowIcons } from "../components/icons";
import { AppWindowContext } from "../context";
export function Gnome({ className, ...props }: HTMLProps<HTMLDivElement>) {
const { isWindowMaximized, minimizeWindow, maximizeWindow, closeWindow } =
useContext(AppWindowContext);
return (
<div
className={cn("mr-[10px] h-auto items-center space-x-[13px]", className)}
{...props}
>
<WindowButton
onClick={minimizeWindow}
className="m-0 aspect-square h-6 w-6 cursor-default rounded-full bg-[#dadada] p-0 text-[#3d3d3d] hover:bg-[#d1d1d1] active:bg-[#bfbfbf] dark:bg-[#373737] dark:text-white dark:hover:bg-[#424242] dark:active:bg-[#565656]"
>
<WindowIcons.minimizeWin className="h-[9px] w-[9px]" />
</WindowButton>
<WindowButton
onClick={maximizeWindow}
className="m-0 aspect-square h-6 w-6 cursor-default rounded-full bg-[#dadada] p-0 text-[#3d3d3d] hover:bg-[#d1d1d1] active:bg-[#bfbfbf] dark:bg-[#373737] dark:text-white dark:hover:bg-[#424242] dark:active:bg-[#565656]"
>
{!isWindowMaximized ? (
<WindowIcons.maximizeWin className="h-2 w-2" />
) : (
<WindowIcons.maximizeRestoreWin className="h-[9px] w-[9px]" />
)}
</WindowButton>
<WindowButton
onClick={closeWindow}
className="m-0 aspect-square h-6 w-6 cursor-default rounded-full bg-[#dadada] p-0 text-[#3d3d3d] hover:bg-[#d1d1d1] active:bg-[#bfbfbf] dark:bg-[#373737] dark:text-white dark:hover:bg-[#424242] dark:active:bg-[#565656]"
>
<WindowIcons.closeWin className="h-2 w-2" />
</WindowButton>
</div>
);
}

View File

@@ -1,79 +0,0 @@
import { cn } from "@lume/utils";
import { HTMLProps, useContext, useEffect, useState } from "react";
import { WindowButton } from "../components/button";
import { WindowIcons } from "../components/icons";
import { AppWindowContext } from "../context";
export function MacOS({ className, ...props }: HTMLProps<HTMLDivElement>) {
const { minimizeWindow, maximizeWindow, fullscreenWindow, closeWindow } =
useContext(AppWindowContext);
const [isAltKeyPressed, setIsAltKeyPressed] = useState(false);
const [isHovering, setIsHovering] = useState(false);
const last = isAltKeyPressed ? (
<WindowIcons.plusMac />
) : (
<WindowIcons.fullMac />
);
const key = "Alt";
const handleMouseEnter = () => {
setIsHovering(true);
};
const handleMouseLeave = () => {
setIsHovering(false);
};
const handleAltKeyDown = (e: KeyboardEvent) => {
if (e.key === key) {
setIsAltKeyPressed(true);
}
};
const handleAltKeyUp = (e: KeyboardEvent) => {
if (e.key === key) {
setIsAltKeyPressed(false);
}
};
useEffect(() => {
// Attach event listeners when the component mounts
window.addEventListener("keydown", handleAltKeyDown);
window.addEventListener("keyup", handleAltKeyUp);
}, []);
return (
<div
className={cn(
"space-x-2 px-3 text-black active:text-black dark:text-black",
className,
)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
{...props}
>
<WindowButton
onClick={closeWindow}
className="aspect-square h-3 w-3 cursor-default content-center items-center justify-center self-center rounded-full border border-black/[.12] bg-[#ff544d] text-center text-black/60 hover:bg-[#ff544d] active:bg-[#bf403a] active:text-black/60 dark:border-none"
>
{isHovering && <WindowIcons.closeMac />}
</WindowButton>
<WindowButton
onClick={minimizeWindow}
className="aspect-square h-3 w-3 cursor-default content-center items-center justify-center self-center rounded-full border border-black/[.12] bg-[#ffbd2e] text-center text-black/60 hover:bg-[#ffbd2e] active:bg-[#bf9122] active:text-black/60 dark:border-none"
>
{isHovering && <WindowIcons.minMac />}
</WindowButton>
<WindowButton
// onKeyDown={handleAltKeyDown}
// onKeyUp={handleAltKeyUp}
onClick={isAltKeyPressed ? maximizeWindow : fullscreenWindow}
className="aspect-square h-3 w-3 cursor-default content-center items-center justify-center self-center rounded-full border border-black/[.12] bg-[#28c93f] text-center text-black/60 hover:bg-[#28c93f] active:bg-[#1e9930] active:text-black/60 dark:border-none"
>
{isHovering && last}
</WindowButton>
</div>
);
}

View File

@@ -1,41 +0,0 @@
import { cn } from "@lume/utils";
import { HTMLProps, useContext } from "react";
import { WindowButton } from "../components/button";
import { WindowIcons } from "../components/icons";
import { AppWindowContext } from "../context";
export function Windows({ className, ...props }: HTMLProps<HTMLDivElement>) {
const { isWindowMaximized, minimizeWindow, maximizeWindow, closeWindow } =
useContext(AppWindowContext);
return (
<div className={cn("h-8", className)} {...props}>
<WindowButton
onClick={minimizeWindow}
className="max-h-8 w-[46px] cursor-default rounded-none bg-transparent text-black/90 hover:bg-black/[.05] active:bg-black/[.03] dark:text-white dark:hover:bg-white/[.06] dark:active:bg-white/[.04]"
>
<WindowIcons.minimizeWin />
</WindowButton>
<WindowButton
onClick={maximizeWindow}
className={cn(
"max-h-8 w-[46px] cursor-default rounded-none bg-transparent",
"text-black/90 hover:bg-black/[.05] active:bg-black/[.03] dark:text-white dark:hover:bg-white/[.06] dark:active:bg-white/[.04]",
// !isMaximizable && "text-white/[.36]",
)}
>
{!isWindowMaximized ? (
<WindowIcons.maximizeWin />
) : (
<WindowIcons.maximizeRestoreWin />
)}
</WindowButton>
<WindowButton
onClick={closeWindow}
className="max-h-8 w-[46px] cursor-default rounded-none bg-transparent text-black/90 hover:bg-[#c42b1c] hover:text-white active:bg-[#c42b1c]/90 dark:text-white"
>
<WindowIcons.closeWin />
</WindowButton>
</div>
);
}

View File

@@ -1,7 +0,0 @@
export * from './context';
export * from './components/button';
export * from './components/icons';
export * from './controls/gnome';
export * from './controls/windows';
export * from './controls/macos';
export * from './titleBar';

View File

@@ -1,31 +0,0 @@
import { Platform } from "@tauri-apps/plugin-os";
import { AppWindowProvider } from "./context";
import { Gnome } from "./controls/gnome";
import { MacOS } from "./controls/macos";
import { Windows } from "./controls/windows";
export function WindowTitleBar({ platform }: { platform: Platform }) {
const ControlsComponent = () => {
switch (platform) {
case "windows":
return <Windows className="ml-auto flex" />;
case "macos":
return <MacOS className="ml-0 flex" />;
case "linux":
return <Gnome className="ml-auto flex" />;
default:
return <Windows className="ml-auto flex" />;
}
};
return (
<AppWindowProvider>
<div
data-tauri-drag-region
className="bg-background flex select-none flex-row overflow-hidden"
>
<ControlsComponent />
</div>
</AppWindowProvider>
);
}

View File

@@ -1,604 +0,0 @@
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 { minidenticon } from "minidenticons";
import { memo, useMemo } from "react";
export const User = memo(function User({
pubkey,
time,
variant = "default",
subtext,
}: {
pubkey: string;
time?: number;
variant?:
| "default"
| "simple"
| "mention"
| "notify"
| "notify2"
| "repost"
| "chat"
| "large"
| "thread"
| "miniavatar"
| "avatar"
| "stacked"
| "ministacked"
| "childnote";
subtext?: string;
}) {
const { isLoading, user } = useProfile(pubkey);
const createdAt = useMemo(
() => formatCreatedAt(time, variant === "chat"),
[time, variant],
);
const fallbackName = useMemo(() => displayNpub(pubkey, 16), [pubkey]);
const fallbackAvatar = useMemo(
() =>
`data:image/svg+xml;utf8,${encodeURIComponent(
minidenticon(pubkey, 90, 50),
)}`,
[pubkey],
);
if (variant === "mention") {
if (isLoading) {
return (
<div className="flex items-center gap-2">
<Avatar.Root className="shrink-0">
<Avatar.Image
src={fallbackAvatar}
alt={pubkey}
className="w-6 h-6 bg-black rounded-md dark:bg-white"
/>
</Avatar.Root>
<div className="flex items-baseline flex-1 gap-2">
<h5 className="max-w-[10rem] truncate font-semibold text-neutral-900 dark:text-neutral-100">
{fallbackName}
</h5>
<span className="text-neutral-600 dark:text-neutral-400">·</span>
<span className="text-neutral-600 dark:text-neutral-400">
{createdAt}
</span>
</div>
</div>
);
}
return (
<div className="flex items-center h-6 gap-2">
<Avatar.Root className="shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="w-6 h-6 rounded-md"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="w-6 h-6 bg-black rounded-md dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="flex items-baseline flex-1 gap-2">
<h5 className="max-w-[10rem] truncate font-semibold text-neutral-900 dark:text-neutral-100">
{user?.name ||
user?.display_name ||
user?.displayName ||
fallbackName}
</h5>
<span className="text-neutral-600 dark:text-neutral-400">·</span>
<span className="text-neutral-600 dark:text-neutral-400">
{createdAt}
</span>
</div>
</div>
);
}
if (variant === "notify2") {
if (isLoading) {
return (
<div className="flex items-center gap-2">
<Avatar.Root className="w-8 h-8 shrink-0">
<Avatar.Image
src={fallbackAvatar}
alt={pubkey}
className="w-8 h-8 bg-black rounded-md 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="w-8 h-8 shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="eager"
decoding="async"
className="w-8 h-8 rounded-md"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="w-8 h-8 bg-black rounded-md 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 (
<div className="flex items-center gap-2">
<Avatar.Root className="w-8 h-8 shrink-0">
<Avatar.Image
src={fallbackAvatar}
alt={pubkey}
className="w-8 h-8 bg-black rounded-md 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="w-8 h-8 shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="eager"
decoding="async"
className="w-8 h-8 rounded-md"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="w-8 h-8 bg-black rounded-md dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<h5 className="max-w-[10rem] truncate font-semibold text-neutral-900 dark:text-neutral-100">
{user?.name || user?.display_name || fallbackName}
</h5>
</div>
);
}
if (variant === "large") {
if (isLoading) {
return (
<div className="flex items-center gap-2.5">
<div className="rounded-lg h-14 w-14 shrink-0 animate-pulse bg-neutral-300 dark:bg-neutral-700" />
<div className="flex flex-col gap-1.5">
<div className="h-3.5 w-36 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
<div className="w-24 h-4 rounded animate-pulse bg-neutral-300 dark:bg-neutral-700" />
</div>
</div>
);
}
return (
<div>
<div className="h-20 bg-gray-200 rounded-t-lg dark:bg-gray-800">
{user?.banner ? (
<img
src={user.banner}
alt="banner"
className="object-cover w-full h-full"
/>
) : null}
</div>
<div className="flex h-full w-full flex-col gap-2.5 px-3 -mt-6">
<Avatar.Root className="shrink-0">
<Avatar.Image
src={user?.picture}
alt={pubkey}
decoding="async"
className="object-cover rounded-lg size-11"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="bg-black rounded-lg size-11 dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="flex flex-col items-start text-start">
<p className="max-w-[15rem] truncate text-lg font-semibold leadning-tight">
{user?.name || user?.display_name}
</p>
<p className="whitespace-pre-line select-text break-p text-neutral-700 dark:text-neutral-600 max-w-none">
{user?.about || "No bio"}
</p>
</div>
</div>
</div>
);
}
if (variant === "simple") {
if (isLoading) {
return (
<div className="flex items-center gap-2.5">
<div className="w-10 h-10 rounded-lg shrink-0 animate-pulse bg-neutral-300 dark:bg-neutral-700" />
<div className="flex flex-col items-start w-full gap-1">
<div className="h-4 rounded w-36 animate-pulse bg-neutral-300 dark:bg-neutral-700" />
<div className="w-24 h-4 rounded animate-pulse bg-neutral-300 dark:bg-neutral-700" />
</div>
</div>
);
}
return (
<div className="flex items-center gap-2.5">
<Avatar.Root className="w-10 h-10 shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="object-cover w-10 h-10 rounded-lg"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="w-10 h-10 bg-black rounded-lg dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="flex flex-col items-start w-full">
<h3 className="max-w-[15rem] truncate text-base font-semibold text-neutral-900 dark:text-neutral-100">
{user?.name || user?.display_name || user?.displayName}
</h3>
<p className="max-w-[10rem] truncate text-sm text-neutral-900 dark:text-neutral-100/70">
{user?.nip05 || user?.username || fallbackName}
</p>
</div>
</div>
);
}
if (variant === "avatar") {
if (isLoading) {
return (
<div className="w-12 h-12 rounded-lg animate-pulse bg-neutral-300 dark:bg-neutral-700" />
);
}
return (
<Avatar.Root>
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="w-12 h-12 rounded-lg"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="w-12 h-12 bg-black rounded-lg dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
);
}
if (variant === "miniavatar") {
if (isLoading) {
return (
<div className="w-10 h-10 rounded-lg shrink-0 animate-pulse bg-neutral-300 dark:bg-neutral-700" />
);
}
return (
<Avatar.Root className="w-10 h-10 shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="w-10 h-10 rounded-lg"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="w-10 h-10 bg-black rounded-lg dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
);
}
if (variant === "childnote") {
if (isLoading) {
return (
<>
<Avatar.Root className="w-10 h-10 shrink-0">
<Avatar.Image
src={fallbackAvatar}
alt={pubkey}
className="object-cover w-10 h-10 bg-black rounded-lg dark:bg-white"
/>
</Avatar.Root>
<div className="absolute left-2 top-2 inline-flex items-center gap-1.5 font-semibold leading-tight">
<div className="w-full max-w-[10rem] truncate">{fallbackName} </div>
<div className="font-normal text-neutral-700 dark:text-neutral-300">
{subtext}:
</div>
</div>
</>
);
}
return (
<>
<Avatar.Root className="w-10 h-10 shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="object-cover w-10 h-10 rounded-lg"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="w-10 h-10 bg-black rounded-lg dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="absolute left-2 top-2 inline-flex items-center gap-1.5 font-semibold leading-tight">
<div className="w-full max-w-[10rem] truncate">
{user?.display_name ||
user?.name ||
user?.displayName ||
fallbackName}{" "}
</div>
<div className="font-normal text-neutral-700 dark:text-neutral-300">
{subtext}:
</div>
</div>
</>
);
}
if (variant === "stacked") {
if (isLoading) {
return (
<div className="inline-block w-8 h-8 rounded-full animate-pulse bg-neutral-300 ring-1 ring-neutral-200 dark:bg-neutral-700 dark:ring-neutral-800" />
);
}
return (
<Avatar.Root>
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="inline-block w-8 h-8 rounded-full ring-1 ring-neutral-200 dark:ring-neutral-800"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="inline-block w-8 h-8 bg-black rounded-full ring-1 ring-neutral-200 dark:bg-white dark:ring-neutral-800"
/>
</Avatar.Fallback>
</Avatar.Root>
);
}
if (variant === "ministacked") {
if (isLoading) {
return (
<div className="inline-block w-6 h-6 rounded-full animate-pulse bg-neutral-300 ring-1 ring-white dark:ring-black" />
);
}
return (
<Avatar.Root>
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="inline-block w-6 h-6 rounded-full ring-1 ring-white dark:ring-black"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="inline-block w-6 h-6 bg-black rounded-full ring-1 ring-white dark:bg-white dark:ring-black"
/>
</Avatar.Fallback>
</Avatar.Root>
);
}
if (variant === "repost") {
if (isLoading) {
return (
<div className="flex gap-3">
<div className="inline-flex items-center justify-center w-10 h-10">
<RepostIcon className="w-5 h-5 text-blue-500" />
</div>
<div className="inline-flex items-center gap-2">
<div className="w-6 h-6 rounded animate-pulse bg-neutral-300 dark:bg-neutral-700" />
<div className="w-24 h-4 rounded animate-pulse bg-neutral-300 dark:bg-neutral-700" />
</div>
</div>
);
}
return (
<div className="flex gap-2 px-3">
<div className="inline-flex items-center justify-center w-10">
<RepostIcon className="w-5 h-5 text-blue-500" />
</div>
<div className="inline-flex items-center gap-2">
<Avatar.Root className="shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="object-cover w-6 h-6 rounded"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="w-6 h-6 bg-black rounded dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="inline-flex items-baseline gap-1">
<h5 className="max-w-[10rem] truncate font-medium text-neutral-900 dark:text-neutral-100/80">
{user?.name ||
user?.display_name ||
user?.displayName ||
fallbackName}
</h5>
<span className="text-blue-500">reposted</span>
</div>
</div>
</div>
);
}
if (variant === "thread") {
if (isLoading) {
return (
<div className="flex items-center h-16 gap-3 px-3">
<div className="w-10 h-10 rounded-lg shrink-0 animate-pulse bg-neutral-300 dark:bg-neutral-700" />
<div className="flex flex-col flex-1 gap-1">
<div className="h-4 rounded w-36 animate-pulse bg-neutral-300 dark:bg-neutral-700" />
<div className="w-24 h-3 rounded animate-pulse bg-neutral-300 dark:bg-neutral-700" />
</div>
</div>
);
}
return (
<div className="flex items-center h-16 gap-3 px-3">
<Avatar.Root className="w-10 h-10 shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="object-cover w-10 h-10 rounded-lg ring-1 ring-neutral-200/50 dark:ring-neutral-800/50"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="w-10 h-10 bg-black rounded-lg ring-1 ring-neutral-200/50 dark:bg-white dark:ring-neutral-800/50"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="flex flex-col flex-1">
<h5 className="max-w-[15rem] truncate font-semibold text-neutral-900 dark:text-neutral-100">
{user?.name || user?.display_name || user?.displayName || "Anon"}
</h5>
<div className="inline-flex items-center gap-2 text-sm text-neutral-600 dark:text-neutral-400">
<span>{createdAt}</span>
<span>·</span>
<span>{fallbackName}</span>
</div>
</div>
</div>
);
}
if (isLoading) {
return (
<div className="flex items-center gap-3 px-3">
<Avatar.Root className="h-9 w-9 shrink-0">
<Avatar.Image
src={fallbackAvatar}
alt={pubkey}
className="bg-black rounded-lg h-9 w-9 ring-1 ring-neutral-200/50 dark:bg-white dark:ring-neutral-800/50"
/>
</Avatar.Root>
<div className="flex-1 h-6">
<div className="max-w-[15rem] truncate font-semibold text-neutral-950 dark:text-neutral-50">
{fallbackName}
</div>
</div>
</div>
);
}
return (
<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="object-cover bg-white rounded-lg h-9 w-9 ring-1 ring-neutral-200/50 dark:ring-neutral-800/50"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="bg-black rounded-lg h-9 w-9 ring-1 ring-neutral-200/50 dark:bg-white dark:ring-neutral-800/50"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="flex items-start flex-1 h-6 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="inline-flex items-center gap-3 ml-auto">
<div className="text-neutral-500 dark:text-neutral-400">
{createdAt}
</div>
</div>
</div>
</div>
);
});

View File

@@ -0,0 +1,37 @@
import { cn } from "@lume/utils";
import { useUserContext } from "./provider";
export function UserAbout({ className }: { className?: string }) {
const user = useUserContext();
if (!user.profile) {
return (
<div className="flex flex-col gap-1">
<div
className={cn(
"h-4 w-20 bg-black/20 dark:bg-white/20 rounded animate-pulse",
className,
)}
/>
<div
className={cn(
"h-4 w-full bg-black/20 dark:bg-white/20 rounded animate-pulse",
className,
)}
/>
<div
className={cn(
"h-4 w-24 bg-black/20 dark:bg-white/20 rounded animate-pulse",
className,
)}
/>
</div>
);
}
return (
<div className={cn("select-text break-p", className)}>
{user.profile.about?.trim() || "No bio"}
</div>
);
}

View File

@@ -0,0 +1,65 @@
import { useStorage } from "@lume/storage";
import { cn } from "@lume/utils";
import * as Avatar from "@radix-ui/react-avatar";
import { minidenticon } from "minidenticons";
import { nanoid } from "nanoid";
import { useMemo } from "react";
import { useUserContext } from "./provider";
export function UserAvatar({ className }: { className?: string }) {
const user = useUserContext();
const storage = useStorage();
const fallbackAvatar = useMemo(
() =>
`data:image/svg+xml;utf8,${encodeURIComponent(
minidenticon(user?.pubkey || nanoid(), 90, 50),
)}`,
[user],
);
if (!user.profile) {
return (
<div className="shrink-0">
<div
className={cn(
"bg-black/20 dark:bg-white/20 rounded animate-pulse",
className,
)}
/>
</div>
);
}
return (
<Avatar.Root className="shrink-0">
{storage.settings.lowPower ? (
<Avatar.Image
src={fallbackAvatar}
alt={user.pubkey}
loading="eager"
decoding="async"
className={cn(
"bg-black dark:bg-white ring-1 ring-black/5 dark:ring-white/5",
className,
)}
/>
) : (
<Avatar.Image
src={user.profile.picture}
alt={user.pubkey}
loading="eager"
decoding="async"
className={cn("ring-1 ring-black/5 dark:ring-white/5", className)}
/>
)}
<Avatar.Fallback delayMs={120}>
<img
src={fallbackAvatar}
alt={user.pubkey}
className={cn("bg-black dark:bg-white", className)}
/>
</Avatar.Fallback>
</Avatar.Root>
);
}

View File

@@ -0,0 +1,36 @@
import { cn } from "@lume/utils";
import { useUserContext } from "./provider";
export function UserCover({ className }: { className?: string }) {
const user = useUserContext();
if (!user) {
return (
<div
className={cn(
"animate-pulse bg-neutral-300 dark:bg-neutral-700",
className,
)}
/>
);
}
if (user && !user.profile.banner) {
return (
<div
className={cn("bg-gradient-to-b from-blue-400 to-teal-200", className)}
/>
);
}
return (
<img
src={user.profile.banner}
alt="banner"
loading="lazy"
decoding="async"
style={{ contentVisibility: "auto" }}
className={cn("object-cover", className)}
/>
);
}

View File

@@ -0,0 +1,62 @@
import { useArk } from "@lume/ark";
import { LoaderIcon } from "@lume/icons";
import { cn } from "@lume/utils";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
export function UserFollowButton({
target,
className,
}: {
target: string;
className?: string;
}) {
const ark = useArk();
const [t] = useTranslation();
const [loading, setLoading] = useState(false);
const [followed, setFollowed] = useState(false);
const toggleFollow = async () => {
setLoading(true);
if (!followed) {
const add = await ark.createContact(target);
if (add) setFollowed(true);
} else {
const remove = await ark.deleteContact(target);
if (remove) setFollowed(false);
}
setLoading(false);
};
useEffect(() => {
async function status() {
setLoading(true);
const contacts = await ark.getUserContacts();
if (contacts?.includes(target)) {
setFollowed(true);
}
setLoading(false);
}
status();
}, []);
return (
<button
type="button"
disabled={loading}
onClick={toggleFollow}
className={cn("w-max", className)}
>
{loading ? (
<LoaderIcon className="size-4 animate-spin" />
) : followed ? (
t("user.unfollow")
) : (
t("user.follow")
)}
</button>
);
}

View File

@@ -0,0 +1,21 @@
import { UserAbout } from "./about";
import { UserAvatar } from "./avatar";
import { UserCover } from "./cover";
import { UserFollowButton } from "./followButton";
import { UserName } from "./name";
import { UserNip05 } from "./nip05";
import { UserProvider } from "./provider";
import { UserRoot } from "./root";
import { UserTime } from "./time";
export const User = {
Provider: UserProvider,
Root: UserRoot,
Avatar: UserAvatar,
Cover: UserCover,
Name: UserName,
NIP05: UserNip05,
Time: UserTime,
About: UserAbout,
Button: UserFollowButton,
};

View File

@@ -0,0 +1,23 @@
import { cn } from "@lume/utils";
import { useUserContext } from "./provider";
export function UserName({ className }: { className?: string }) {
const user = useUserContext();
if (!user.profile) {
return (
<div
className={cn(
"h-4 w-20 self-center bg-black/20 dark:bg-white/20 rounded animate-pulse",
className,
)}
/>
);
}
return (
<div className={cn("max-w-[12rem] truncate", className)}>
{user.profile.display_name || user.profile.name || "Anon"}
</div>
);
}

View File

@@ -0,0 +1,44 @@
import { VerifiedIcon } from "@lume/icons";
import { cn, displayNpub } from "@lume/utils";
import { useQuery } from "@tanstack/react-query";
import { useUserContext } from "./provider";
export function UserNip05({ className }: { className?: string }) {
const user = useUserContext();
const { isLoading, data: verified } = useQuery({
queryKey: ["nip05", user?.profile.nip05],
queryFn: async () => {
if (!user) return false;
if (!user.profile.nip05) return false;
return false;
},
enabled: !!user,
});
if (!user.profile) {
return (
<div
className={cn(
"h-4 w-20 bg-black/20 dark:bg-white/20 rounded animate-pulse",
className,
)}
/>
);
}
return (
<div className="inline-flex items-center gap-1">
<p className={cn("text-sm", className)}>
{!user?.profile.nip05
? displayNpub(user.pubkey, 16)
: user?.profile.nip05?.startsWith("_@")
? user?.profile.nip05?.replace("_@", "")
: user?.profile.nip05}
</p>
{!isLoading && verified ? (
<VerifiedIcon className="text-teal-500 size-4" />
) : null}
</div>
);
}

View File

@@ -0,0 +1,45 @@
import { Metadata } from "@lume/types";
import { useQuery } from "@tanstack/react-query";
import { invoke } from "@tauri-apps/api/core";
import { ReactNode, createContext, useContext } from "react";
const UserContext = createContext<{ pubkey: string; profile: Metadata }>(null);
export function UserProvider({
pubkey,
children,
embed,
}: {
pubkey: string;
children: ReactNode;
embed?: string;
}) {
const { data: profile } = useQuery({
queryKey: ["user", pubkey],
queryFn: async () => {
if (embed) return JSON.parse(embed) as Metadata;
try {
const profile: Metadata = await invoke("get_profile", { id: pubkey });
return profile;
} catch (e) {
throw new Error(e);
}
},
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
staleTime: Infinity,
retry: 2,
});
return (
<UserContext.Provider value={{ pubkey, profile }}>
{children}
</UserContext.Provider>
);
}
export function useUserContext() {
const context = useContext(UserContext);
return context;
}

View File

@@ -0,0 +1,12 @@
import { cn } from "@lume/utils";
import { ReactNode } from "react";
export function UserRoot({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return <div className={cn(className)}>{children}</div>;
}

View File

@@ -0,0 +1,11 @@
import { cn, formatCreatedAt } from "@lume/utils";
import { useMemo } from "react";
export function UserTime({
time,
className,
}: { time: number; className?: string }) {
const createdAt = useMemo(() => formatCreatedAt(time), [time]);
return <div className={cn("", className)}>{createdAt}</div>;
}