Make Lume Faster (#208)
* chore: fix some lint issues * feat: refactor contact list * feat: refactor relay hint * feat: add missing commands * feat: use new cache layer for react query * feat: refactor column * feat: improve relay hint * fix: replace break with continue in parser * refactor: publish function * feat: add reply command * feat: improve editor * fix: quote * chore: update deps * refactor: note component * feat: improve repost * feat: improve cache * fix: backup screen * refactor: column manager
This commit is contained in:
@@ -1,6 +1,4 @@
|
||||
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||
import React, { StrictMode } from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
@@ -10,11 +8,8 @@ import i18n from "./locale";
|
||||
import { routeTree } from "./router.gen"; // auto generated file
|
||||
import { type } from "@tauri-apps/plugin-os";
|
||||
|
||||
const os = await type();
|
||||
const queryClient = new QueryClient();
|
||||
const persister = createSyncStoragePersister({
|
||||
storage: window.localStorage,
|
||||
});
|
||||
const os = await type();
|
||||
|
||||
// Set up a Router instance
|
||||
const router = createRouter({
|
||||
@@ -26,12 +21,9 @@ const router = createRouter({
|
||||
Wrap: ({ children }) => {
|
||||
return (
|
||||
<I18nextProvider i18n={i18n} defaultNS={"translation"}>
|
||||
<PersistQueryClientProvider
|
||||
client={queryClient}
|
||||
persistOptions={{ persister }}
|
||||
>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</PersistQueryClientProvider>
|
||||
</QueryClientProvider>
|
||||
</I18nextProvider>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -2,53 +2,57 @@ import { CancelIcon, CheckIcon } from "@lume/icons";
|
||||
import type { LumeColumn } from "@lume/types";
|
||||
import { cn } from "@lume/utils";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrent } from "@tauri-apps/api/webviewWindow";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
type WindowEvent = {
|
||||
scroll: boolean;
|
||||
resize: boolean;
|
||||
};
|
||||
|
||||
export function Column({
|
||||
column,
|
||||
account,
|
||||
isScroll,
|
||||
isResize,
|
||||
}: {
|
||||
column: LumeColumn;
|
||||
account: string;
|
||||
isScroll: boolean;
|
||||
isResize: boolean;
|
||||
}) {
|
||||
const container = useRef<HTMLDivElement>(null);
|
||||
const webviewLabel = useMemo(
|
||||
() => `column-${account}_${column.label}`,
|
||||
[account],
|
||||
);
|
||||
const webviewLabel = `column-${account}_${column.label}`;
|
||||
|
||||
const [isCreated, setIsCreated] = useState(false);
|
||||
|
||||
const repositionWebview = async () => {
|
||||
const repositionWebview = useCallback(async () => {
|
||||
const newRect = container.current.getBoundingClientRect();
|
||||
await invoke("reposition_column", {
|
||||
label: webviewLabel,
|
||||
x: newRect.x,
|
||||
y: newRect.y,
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
const resizeWebview = async () => {
|
||||
const resizeWebview = useCallback(async () => {
|
||||
const newRect = container.current.getBoundingClientRect();
|
||||
await invoke("resize_column", {
|
||||
label: webviewLabel,
|
||||
width: newRect.width,
|
||||
height: newRect.height,
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCreated) resizeWebview();
|
||||
}, [isResize]);
|
||||
if (!isCreated) return;
|
||||
|
||||
useEffect(() => {
|
||||
if (isScroll && isCreated) repositionWebview();
|
||||
}, [isScroll]);
|
||||
const unlisten = listen<WindowEvent>("window", (data) => {
|
||||
if (data.payload.scroll) repositionWebview();
|
||||
if (data.payload.resize) resizeWebview();
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlisten.then((f) => f());
|
||||
};
|
||||
}, [isCreated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!container?.current) return;
|
||||
@@ -78,7 +82,7 @@ export function Column({
|
||||
}, [account]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-[440px] shrink-0 p-2">
|
||||
<div className="h-full w-[500px] shrink-0 p-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col w-full h-full rounded-xl",
|
||||
@@ -87,9 +91,7 @@ export function Column({
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
{column.label !== "open" ? (
|
||||
<Header label={column.label} name={column.name} />
|
||||
) : null}
|
||||
<Header label={column.label} name={column.name} />
|
||||
<div ref={container} className="flex-1 w-full h-full" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -122,10 +124,10 @@ function Header({ label, name }: { label: string; name: string }) {
|
||||
}, [title]);
|
||||
|
||||
return (
|
||||
<div className="h-9 w-full flex items-center justify-between shrink-0 px-1">
|
||||
<div className="flex items-center justify-between w-full px-1 h-9 shrink-0">
|
||||
<div className="size-7" />
|
||||
<div className="shrink-0 h-9 flex items-center justify-center">
|
||||
<div className="relative flex gap-2 items-center">
|
||||
<div className="flex items-center justify-center shrink-0 h-9">
|
||||
<div className="relative flex items-center gap-2">
|
||||
<div
|
||||
contentEditable
|
||||
suppressContentEditableWarning={true}
|
||||
@@ -148,7 +150,7 @@ function Header({ label, name }: { label: string; name: string }) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => close()}
|
||||
className="size-7 inline-flex hover:bg-black/10 rounded-lg dark:hover:bg-white/10 items-center justify-center text-neutral-600 dark:text-neutral-400 hover:text-neutral-800 dark:hover:text-neutral-200"
|
||||
className="inline-flex items-center justify-center rounded-lg size-7 hover:bg-black/10 dark:hover:bg-white/10 text-neutral-600 dark:text-neutral-400 hover:text-neutral-800 dark:hover:text-neutral-200"
|
||||
>
|
||||
<CancelIcon className="size-4" />
|
||||
</button>
|
||||
|
||||
@@ -1,23 +1,17 @@
|
||||
import { ThreadIcon } from "@lume/icons";
|
||||
import type { NostrEvent } from "@lume/types";
|
||||
import { Note } from "@/components/note";
|
||||
import { cn } from "@lume/utils";
|
||||
import { LumeEvent } from "@lume/system";
|
||||
import type { LumeEvent } from "@lume/system";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export function Conversation({
|
||||
event,
|
||||
gossip,
|
||||
className,
|
||||
}: {
|
||||
event: NostrEvent;
|
||||
gossip?: boolean;
|
||||
event: LumeEvent;
|
||||
className?: string;
|
||||
}) {
|
||||
const thread = useMemo(
|
||||
() => LumeEvent.getEventThread(event.tags, gossip),
|
||||
[event],
|
||||
);
|
||||
const thread = useMemo(() => event.thread, [event]);
|
||||
|
||||
return (
|
||||
<Note.Provider event={event}>
|
||||
@@ -28,7 +22,7 @@ export function Conversation({
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
{thread?.root ? <Note.Child eventId={thread?.root} isRoot /> : null}
|
||||
{thread?.root?.id ? <Note.Child event={thread?.root} isRoot /> : null}
|
||||
<div className="flex items-center gap-2 px-3">
|
||||
<div className="inline-flex items-center gap-1.5 shrink-0 text-sm font-medium text-neutral-600 dark:text-neutral-400">
|
||||
<ThreadIcon className="size-4" />
|
||||
@@ -36,15 +30,15 @@ export function Conversation({
|
||||
</div>
|
||||
<div className="flex-1 h-px bg-neutral-100 dark:bg-white/5" />
|
||||
</div>
|
||||
{thread?.reply ? <Note.Child eventId={thread?.reply} /> : null}
|
||||
{thread?.reply?.id ? <Note.Child event={thread?.reply} /> : null}
|
||||
<div>
|
||||
<div className="px-3 h-14 flex items-center justify-between">
|
||||
<div className="flex items-center justify-between px-3 h-14">
|
||||
<Note.User />
|
||||
</div>
|
||||
<Note.Content className="px-3" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center h-14 px-3">
|
||||
<div className="flex items-center px-3 h-14">
|
||||
<Note.Open />
|
||||
</div>
|
||||
</Note.Root>
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { cn } from "@lume/utils";
|
||||
import { useNoteContext } from "./provider";
|
||||
import { User } from "../user";
|
||||
|
||||
export function NoteActivity({ className }: { className?: string }) {
|
||||
const event = useNoteContext();
|
||||
const mentions = event.mentions;
|
||||
|
||||
return (
|
||||
<div className={cn("-mt-3 mb-2", className)}>
|
||||
<div className="text-neutral-700 dark:text-neutral-300 inline-flex items-baseline gap-2 w-full overflow-hidden">
|
||||
<div className="shrink-0 text-sm font-medium">To:</div>
|
||||
{mentions.splice(0, 4).map((mention) => (
|
||||
<User.Provider key={mention} pubkey={mention}>
|
||||
<User.Root>
|
||||
<User.Name className="text-sm font-medium" prefix="@" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
))}
|
||||
{mentions.length > 4 ? "..." : ""}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -76,7 +76,7 @@ export function NoteRepost({ large = false }: { large?: boolean }) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => repost()}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||
className="inline-flex items-center gap-2 px-3 text-sm font-medium text-white rounded-lg h-9 hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||
>
|
||||
<RepostIcon className="size-4" />
|
||||
{t("note.buttons.repost")}
|
||||
@@ -85,8 +85,8 @@ export function NoteRepost({ large = false }: { large?: boolean }) {
|
||||
<DropdownMenu.Item asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => LumeWindow.openEditor(event.id, true)}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||
onClick={() => LumeWindow.openEditor(null, event.id)}
|
||||
className="inline-flex items-center gap-2 px-3 text-sm font-medium text-white rounded-lg h-9 hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||
>
|
||||
<QuoteIcon className="size-4" />
|
||||
{t("note.buttons.quote")}
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { ZapIcon } from "@lume/icons";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { useNoteContext } from "../provider";
|
||||
import { cn } from "@lume/utils";
|
||||
import { LumeWindow } from "@lume/system";
|
||||
|
||||
export function NoteZap({ large = false }: { large?: boolean }) {
|
||||
const event = useNoteContext();
|
||||
const { settings } = useRouteContext({ strict: false });
|
||||
|
||||
if (!settings.zap) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -2,32 +2,33 @@ import { useEvent } from "@lume/system";
|
||||
import { cn } from "@lume/utils";
|
||||
import { Note } from ".";
|
||||
import { InfoIcon } from "@lume/icons";
|
||||
import type { EventTag } from "@lume/types";
|
||||
|
||||
export function NoteChild({
|
||||
eventId,
|
||||
event,
|
||||
isRoot,
|
||||
}: {
|
||||
eventId: string;
|
||||
event: EventTag;
|
||||
isRoot?: boolean;
|
||||
}) {
|
||||
const { isLoading, isError, data } = useEvent(eventId);
|
||||
const { isLoading, isError, data } = useEvent(event.id, event.relayHint);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="pt-3 px-3 flex items-center gap-2">
|
||||
<div className="size-8 shrink-0 rounded-full bg-neutral-200 dark:bg-neutral-800 animate-pulse" />
|
||||
<div className="animate-pulse rounded-md h-4 w-2/3 bg-neutral-200 dark:bg-neutral-800" />
|
||||
<div className="flex items-center gap-2 px-3 pt-3">
|
||||
<div className="rounded-full size-8 shrink-0 bg-neutral-200 dark:bg-neutral-800 animate-pulse" />
|
||||
<div className="w-2/3 h-4 rounded-md animate-pulse bg-neutral-200 dark:bg-neutral-800" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<div className="pt-3 px-3 flex items-center gap-2">
|
||||
<div className="size-8 shrink-0 rounded-full bg-red-500 text-white inline-flex items-center justify-center">
|
||||
<div className="flex items-center gap-2 px-3 pt-3">
|
||||
<div className="inline-flex items-center justify-center text-white bg-red-500 rounded-full size-8 shrink-0">
|
||||
<InfoIcon className="size-5" />
|
||||
</div>
|
||||
<p className="text-red-500 text-sm">
|
||||
<p className="text-sm text-red-500">
|
||||
Event not found with your current relay set
|
||||
</p>
|
||||
</div>
|
||||
@@ -37,7 +38,7 @@ export function NoteChild({
|
||||
return (
|
||||
<Note.Provider event={data}>
|
||||
<Note.Root className={cn(isRoot ? "mb-3" : "")}>
|
||||
<div className="h-14 px-3 flex items-center justify-between">
|
||||
<div className="flex items-center justify-between px-3 h-14">
|
||||
<Note.User />
|
||||
</div>
|
||||
<Note.Content className="px-3" />
|
||||
|
||||
@@ -25,7 +25,7 @@ export function NoteContent({
|
||||
try {
|
||||
// Get parsed meta
|
||||
const { content, hashtags, events, mentions } = event.meta;
|
||||
|
||||
|
||||
// Define rich content
|
||||
let richContent: ReactNode[] | string = content;
|
||||
|
||||
@@ -69,7 +69,7 @@ export function NoteContent({
|
||||
href={match}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-500 line-clamp-1 hover:text-blue-600"
|
||||
className="inline text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
{match}
|
||||
</a>
|
||||
@@ -92,7 +92,7 @@ export function NoteContent({
|
||||
<div
|
||||
className={cn(
|
||||
"select-text text-pretty content-break overflow-hidden",
|
||||
event.content.length > 420 ? "max-h-[250px] gradient-mask-b-0" : "",
|
||||
event.content.length > 620 ? "max-h-[250px] gradient-mask-b-0" : "",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { NoteActivity } from "./activity";
|
||||
import { NoteOpenThread } from "./buttons/open";
|
||||
import { NoteReply } from "./buttons/reply";
|
||||
import { NoteRepost } from "./buttons/repost";
|
||||
@@ -23,5 +22,4 @@ export const Note = {
|
||||
Zap: NoteZap,
|
||||
Open: NoteOpenThread,
|
||||
Child: NoteChild,
|
||||
Activity: NoteActivity,
|
||||
};
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
export function Hashtag({ tag }: { tag: string }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="break-all cursor-default leading-normal group text-start"
|
||||
>
|
||||
<span className="leading-normal break-all cursor-default group text-start">
|
||||
<span className="text-blue-500">#</span>
|
||||
<span className="underline-offset-1 underline decoration-2 decoration-blue-200 dark:decoration-blue-800 group-hover:decoration-blue-500">
|
||||
<span className="underline underline-offset-1 decoration-2 decoration-blue-200 dark:decoration-blue-800 group-hover:decoration-blue-500">
|
||||
{tag.replace("#", "")}
|
||||
</span>
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export function MentionNote({
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="mt-2 w-full flex h-20 items-center justify-center rounded-xl border border-black/10 dark:border-white/10">
|
||||
<div className="flex items-center justify-center w-full h-20 mt-2 border rounded-xl border-black/10 dark:border-white/10">
|
||||
<Spinner className="size-5" />
|
||||
</div>
|
||||
);
|
||||
@@ -25,18 +25,18 @@ export function MentionNote({
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<div className="mt-2 w-full rounded-xl border border-black/10 p-3 dark:border-white/10">
|
||||
<div className="w-full p-3 mt-2 border rounded-xl border-black/10 dark:border-white/10">
|
||||
{t("note.error")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex w-full cursor-default flex-col rounded-xl border border-black/10 dark:border-white/10">
|
||||
<div className="flex flex-col w-full border rounded-lg cursor-default border-black/10 dark:border-white/10">
|
||||
<User.Provider pubkey={data.pubkey}>
|
||||
<User.Root className="flex h-12 items-center gap-2 px-3">
|
||||
<User.Avatar className="size-6 shrink-0 rounded-full object-cover" />
|
||||
<div className="inline-flex flex-1 items-center gap-2">
|
||||
<User.Root className="flex items-center gap-2 px-3 h-11">
|
||||
<User.Avatar className="object-cover rounded-full size-6 shrink-0" />
|
||||
<div className="inline-flex items-center 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
|
||||
@@ -49,20 +49,20 @@ export function MentionNote({
|
||||
<div
|
||||
className={cn(
|
||||
"px-3 select-text whitespace-normal text-pretty content-break leading-normal",
|
||||
data.content.length > 100 ? "max-h-[150px] gradient-mask-b-0" : "",
|
||||
data.content.length > 400 ? "max-h-[150px] gradient-mask-b-0" : "",
|
||||
)}
|
||||
>
|
||||
{data.content}
|
||||
</div>
|
||||
{openable ? (
|
||||
<div className="flex h-14 items-center justify-end px-3">
|
||||
<div className="flex items-center justify-end px-2 h-11">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
LumeWindow.openEvent(data);
|
||||
}}
|
||||
className="z-10 h-7 w-28 inline-flex items-center justify-center gap-1 text-sm bg-black/10 dark:bg-white/10 rounded-full text-neutral-600 hover:text-blue-500 dark:text-neutral-400"
|
||||
className="z-10 inline-flex items-center justify-center gap-1 text-sm rounded-full h-7 w-28 bg-black/10 dark:bg-white/10 text-neutral-600 hover:text-blue-500 dark:text-neutral-400"
|
||||
>
|
||||
View post
|
||||
<LinkIcon className="size-4" />
|
||||
|
||||
@@ -30,7 +30,7 @@ export function NoteMenu() {
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="group inline-flex size-7 items-center justify-center text-neutral-600 dark:text-neutral-400"
|
||||
className="inline-flex items-center justify-center group size-7 text-neutral-600 dark:text-neutral-400"
|
||||
>
|
||||
<HorizontalDotsIcon className="size-5" />
|
||||
</button>
|
||||
@@ -41,7 +41,7 @@ export function NoteMenu() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => LumeWindow.openEvent(event)}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||
className="inline-flex items-center gap-2 px-3 text-sm font-medium text-white rounded-lg h-9 hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||
>
|
||||
{t("note.menu.viewThread")}
|
||||
</button>
|
||||
@@ -50,7 +50,7 @@ export function NoteMenu() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyLink()}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||
className="inline-flex items-center gap-2 px-3 text-sm font-medium text-white rounded-lg h-9 hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||
>
|
||||
{t("note.menu.copyLink")}
|
||||
</button>
|
||||
@@ -59,7 +59,7 @@ export function NoteMenu() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyID()}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||
className="inline-flex items-center gap-2 px-3 text-sm font-medium text-white rounded-lg h-9 hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||
>
|
||||
{t("note.menu.copyNoteId")}
|
||||
</button>
|
||||
@@ -68,25 +68,26 @@ export function NoteMenu() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyNpub()}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||
className="inline-flex items-center gap-2 px-3 text-sm font-medium text-white rounded-lg h-9 hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||
>
|
||||
{t("note.menu.copyAuthorId")}
|
||||
</button>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => LumeWindow.openProfile(event.pubkey)}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||
className="inline-flex items-center gap-2 px-3 text-sm font-medium text-white rounded-lg h-9 hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||
>
|
||||
{t("note.menu.viewAuthor")}
|
||||
</button>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator className="my-1 h-px bg-neutral-900 dark:bg-neutral-100" />
|
||||
<DropdownMenu.Separator className="h-px my-1 bg-neutral-900 dark:bg-neutral-100" />
|
||||
<DropdownMenu.Item asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyRaw()}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||
className="inline-flex items-center gap-2 px-3 text-sm font-medium text-white rounded-lg h-9 hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||
>
|
||||
{t("note.menu.copyRaw")}
|
||||
</button>
|
||||
|
||||
@@ -25,7 +25,7 @@ export function ImagePreview({ url }: { url: string }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group relative my-1">
|
||||
<div className="relative my-1 group">
|
||||
<img
|
||||
src={url}
|
||||
alt={url}
|
||||
@@ -34,6 +34,7 @@ export function ImagePreview({ url }: { url: string }) {
|
||||
style={{ contentVisibility: "auto" }}
|
||||
className="max-h-[600px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||
onClick={() => open(url)}
|
||||
onKeyDown={() => open(url)}
|
||||
onError={({ currentTarget }) => {
|
||||
currentTarget.onerror = null;
|
||||
currentTarget.src = "/404.jpg";
|
||||
|
||||
@@ -27,7 +27,7 @@ export function Images({ urls }: { urls: string[] }) {
|
||||
|
||||
if (urls.length === 1) {
|
||||
return (
|
||||
<div className="group px-3">
|
||||
<div className="px-3 group">
|
||||
<img
|
||||
src={urls[0]}
|
||||
alt={urls[0]}
|
||||
@@ -36,6 +36,7 @@ export function Images({ urls }: { urls: string[] }) {
|
||||
style={{ contentVisibility: "auto" }}
|
||||
className="max-h-[400px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||
onClick={() => open(urls[0])}
|
||||
onKeyDown={() => open(urls[0])}
|
||||
onError={({ currentTarget }) => {
|
||||
currentTarget.onerror = null;
|
||||
currentTarget.src = "/404.jpg";
|
||||
@@ -56,8 +57,9 @@ export function Images({ urls }: { urls: string[] }) {
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ contentVisibility: "auto" }}
|
||||
className="w-full h-full object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||
className="object-cover w-full h-full rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||
onClick={() => open(item)}
|
||||
onKeyDown={() => open(item)}
|
||||
onError={({ currentTarget }) => {
|
||||
currentTarget.onerror = null;
|
||||
currentTarget.src = "/404.jpg";
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export function VideoPreview({ url }: { url: string }) {
|
||||
return (
|
||||
<div className="my-1 overflow-hidden rounded-xl">
|
||||
<div className="my-1">
|
||||
<video
|
||||
className="h-auto w-full bg-neutral-100 text-sm dark:bg-neutral-900"
|
||||
className="max-h-[600px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||
controls
|
||||
muted
|
||||
>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { LumeEvent } from "@lume/system";
|
||||
import type { NostrEvent } from "@lume/types";
|
||||
import type { LumeEvent } from "@lume/system";
|
||||
import { type ReactNode, createContext, useContext } from "react";
|
||||
|
||||
const NoteContext = createContext<LumeEvent>(null);
|
||||
@@ -8,14 +7,10 @@ export function NoteProvider({
|
||||
event,
|
||||
children,
|
||||
}: {
|
||||
event: NostrEvent;
|
||||
event: LumeEvent;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const lumeEvent = new LumeEvent(event);
|
||||
|
||||
return (
|
||||
<NoteContext.Provider value={lumeEvent}>{children}</NoteContext.Provider>
|
||||
);
|
||||
return <NoteContext.Provider value={event}>{children}</NoteContext.Provider>;
|
||||
}
|
||||
|
||||
export function useNoteContext() {
|
||||
|
||||
@@ -15,9 +15,9 @@ export function NoteUser({ className }: { className?: string }) {
|
||||
>
|
||||
<div className="flex w-full gap-2">
|
||||
<HoverCard.Trigger className="shrink-0">
|
||||
<User.Avatar className="size-8 rounded-full object-cover outline outline-1 -outline-offset-1 outline-black/15" />
|
||||
<User.Avatar className="object-cover rounded-full size-8 outline outline-1 -outline-offset-1 outline-black/15" />
|
||||
</HoverCard.Trigger>
|
||||
<div className="flex w-full items-center gap-3">
|
||||
<div className="flex items-center w-full gap-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<User.Name className="font-semibold text-neutral-950 dark:text-neutral-50" />
|
||||
<User.NIP05 />
|
||||
@@ -37,16 +37,17 @@ export function NoteUser({ className }: { className?: string }) {
|
||||
side="right"
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<User.Avatar className="size-11 rounded-lg object-cover" />
|
||||
<User.Avatar className="object-cover rounded-lg size-11" />
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<User.Name className="font-semibold leading-tight text-white dark:text-neutral-900" />
|
||||
<User.NIP05 />
|
||||
</div>
|
||||
<User.About className="line-clamp-3 text-sm text-white dark:text-neutral-900" />
|
||||
<User.About className="text-sm text-white line-clamp-3 dark:text-neutral-900" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => LumeWindow.openProfile(event.pubkey)}
|
||||
className="mt-2 inline-flex h-9 w-full items-center justify-center rounded-lg bg-white text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-100 dark:text-neutral-900 dark:hover:bg-neutral-200"
|
||||
className="inline-flex items-center justify-center w-full mt-2 text-sm font-medium bg-white rounded-lg h-9 hover:bg-neutral-200 dark:bg-neutral-100 dark:text-neutral-900 dark:hover:bg-neutral-200"
|
||||
>
|
||||
View profile
|
||||
</button>
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { NostrEvent } from "@lume/types";
|
||||
import { Note } from "@/components/note";
|
||||
import { cn } from "@lume/utils";
|
||||
|
||||
export function Notification({
|
||||
event,
|
||||
className,
|
||||
}: {
|
||||
event: NostrEvent;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Note.Provider event={event}>
|
||||
<Note.Root
|
||||
className={cn(
|
||||
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl flex flex-col gap-3 shadow-primary dark:ring-1 ring-neutral-800/50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<div className="px-3 h-14 flex items-center justify-between">
|
||||
<Note.User />
|
||||
</div>
|
||||
<Note.Content className="px-3" />
|
||||
</div>
|
||||
<div className="flex items-center h-14 px-3">
|
||||
<Note.Open />
|
||||
</div>
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,15 @@
|
||||
import { QuoteIcon } from "@lume/icons";
|
||||
import type { NostrEvent } from "@lume/types";
|
||||
import { Note } from "@/components/note";
|
||||
import { cn } from "@lume/utils";
|
||||
import type { LumeEvent } from "@lume/system";
|
||||
|
||||
export function Quote({
|
||||
event,
|
||||
className,
|
||||
}: {
|
||||
event: NostrEvent;
|
||||
event: LumeEvent;
|
||||
className?: string;
|
||||
}) {
|
||||
const quoteEventId = event.tags.find(
|
||||
(tag) => tag[0] === "q" || tag[3] === "mention",
|
||||
)?.[1];
|
||||
|
||||
return (
|
||||
<Note.Provider event={event}>
|
||||
<Note.Root
|
||||
@@ -23,7 +19,7 @@ export function Quote({
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Note.Child eventId={quoteEventId} isRoot />
|
||||
<Note.Child event={event.quote} isRoot />
|
||||
<div className="flex items-center gap-2 px-3">
|
||||
<div className="inline-flex items-center gap-1.5 shrink-0 text-sm font-medium text-neutral-600 dark:text-neutral-400">
|
||||
<QuoteIcon className="size-4" />
|
||||
@@ -32,13 +28,13 @@ export function Quote({
|
||||
<div className="flex-1 h-px bg-neutral-100 dark:bg-white/5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="px-3 h-14 flex items-center justify-between">
|
||||
<div className="flex items-center justify-between px-3 h-14">
|
||||
<Note.User />
|
||||
</div>
|
||||
<Note.Content className="px-3" quote={false} clean />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center h-14 px-3">
|
||||
<div className="flex items-center px-3 h-14">
|
||||
<Note.Open />
|
||||
</div>
|
||||
</Note.Root>
|
||||
|
||||
@@ -3,73 +3,75 @@ import { Note } from "@/components/note";
|
||||
import { User } from "@/components/user";
|
||||
import { cn } from "@lume/utils";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { NostrEvent } from "@lume/types";
|
||||
import { NostrQuery } from "@lume/system";
|
||||
import { type LumeEvent, NostrQuery } from "@lume/system";
|
||||
|
||||
export function RepostNote({
|
||||
event,
|
||||
className,
|
||||
}: {
|
||||
event: NostrEvent;
|
||||
event: LumeEvent;
|
||||
className?: string;
|
||||
}) {
|
||||
const {
|
||||
isLoading,
|
||||
isError,
|
||||
data: repostEvent,
|
||||
} = useQuery({
|
||||
queryKey: ["repost", event.id],
|
||||
const { isLoading, isError, data } = useQuery({
|
||||
queryKey: ["event", event.repostId],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const id = event.tags.find((el) => el[0] === "e")[1];
|
||||
const repostEvent = await NostrQuery.getEvent(id);
|
||||
|
||||
return repostEvent;
|
||||
const data = await NostrQuery.getRepostEvent(event);
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(e);
|
||||
}
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
retry: 2,
|
||||
});
|
||||
|
||||
return (
|
||||
<Note.Root
|
||||
className={cn(
|
||||
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl mb-3 shadow-primary dark:ring-1 ring-neutral-800/50",
|
||||
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<User.Provider pubkey={event.pubkey}>
|
||||
<User.Root className="flex items-center gap-2 px-3 py-3 border-b border-neutral-100 dark:border-neutral-800/50 rounded-t-xl">
|
||||
<div className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
|
||||
Reposted by
|
||||
</div>
|
||||
<User.Avatar className="object-cover rounded-full size-6 shrink-0 ring-1 ring-neutral-200/50 dark:ring-neutral-800/50" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-20 gap-2">
|
||||
<Spinner />
|
||||
Loading event...
|
||||
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
Loading event...
|
||||
</span>
|
||||
</div>
|
||||
) : isError || !repostEvent ? (
|
||||
) : isError || !data ? (
|
||||
<div className="flex items-center justify-center h-20">
|
||||
Event not found within your current relay set
|
||||
</div>
|
||||
) : (
|
||||
<Note.Provider event={repostEvent}>
|
||||
<Note.Provider event={data}>
|
||||
<Note.Root>
|
||||
<div className="flex items-center justify-between px-3 h-14">
|
||||
<Note.User />
|
||||
<Note.Menu />
|
||||
</div>
|
||||
<Note.Content className="px-3" />
|
||||
<div className="flex items-center gap-4 px-3 mt-3 h-14">
|
||||
<Note.Open />
|
||||
<Note.Reply />
|
||||
<Note.Repost />
|
||||
<Note.Zap />
|
||||
<div className="flex items-center justify-between px-3 mt-3 h-14">
|
||||
<div className="inline-flex items-center gap-3">
|
||||
<Note.Open />
|
||||
<Note.Reply />
|
||||
<Note.Repost />
|
||||
<Note.Zap />
|
||||
</div>
|
||||
<div>
|
||||
<User.Provider pubkey={event.pubkey}>
|
||||
<User.Root className="flex items-center gap-2">
|
||||
<div className="text-sm font-medium text-neutral-800 dark:text-neutral-200">
|
||||
Reposted by
|
||||
</div>
|
||||
<User.Avatar className="object-cover rounded-full size-6 shrink-0 ring-1 ring-neutral-200/50 dark:ring-neutral-800/50" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</div>
|
||||
</div>
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { NostrEvent } from "@lume/types";
|
||||
import { cn } from "@lume/utils";
|
||||
import { Note } from "@/components/note";
|
||||
import type { LumeEvent } from "@lume/system";
|
||||
|
||||
export function TextNote({
|
||||
event,
|
||||
className,
|
||||
}: {
|
||||
event: NostrEvent;
|
||||
event: LumeEvent;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
@@ -17,12 +17,12 @@ export function TextNote({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="px-3 h-14 flex items-center justify-between">
|
||||
<div className="flex items-center justify-between px-3 h-14">
|
||||
<Note.User />
|
||||
<Note.Menu />
|
||||
</div>
|
||||
<Note.Content className="px-3" />
|
||||
<div className="mt-3 flex items-center gap-4 h-14 px-3">
|
||||
<div className="flex items-center gap-4 px-3 mt-3 h-14">
|
||||
<Note.Open />
|
||||
<Note.Reply />
|
||||
<Note.Repost />
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { cn } from "@lume/utils";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { useUserContext } from "./provider";
|
||||
import { NostrAccount } from "@lume/system";
|
||||
@@ -14,34 +13,30 @@ export function UserFollowButton({
|
||||
}) {
|
||||
const user = useUserContext();
|
||||
|
||||
const [t] = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [followed, setFollowed] = useState(false);
|
||||
|
||||
const toggleFollow = async () => {
|
||||
setLoading(true);
|
||||
if (!followed) {
|
||||
const add = await NostrAccount.follow(user.pubkey, user.profile?.name);
|
||||
if (add) setFollowed(true);
|
||||
} else {
|
||||
const remove = await NostrAccount.unfollow(user.pubkey);
|
||||
if (remove) setFollowed(false);
|
||||
|
||||
const toggle = await NostrAccount.toggleContact(user.pubkey);
|
||||
|
||||
if (toggle) {
|
||||
setFollowed((prev) => !prev);
|
||||
setLoading(false);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
async function status() {
|
||||
setLoading(true);
|
||||
let mounted = true;
|
||||
|
||||
const contacts = await NostrAccount.getContactList();
|
||||
if (contacts?.includes(user.pubkey)) {
|
||||
setFollowed(true);
|
||||
}
|
||||
NostrAccount.checkContact(user.pubkey).then((status) => {
|
||||
if (mounted) setFollowed(status);
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
status();
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -55,10 +50,10 @@ export function UserFollowButton({
|
||||
<Spinner className="size-4" />
|
||||
) : followed ? (
|
||||
!simple ? (
|
||||
t("user.unfollow")
|
||||
"Unfollow"
|
||||
) : null
|
||||
) : (
|
||||
t("user.follow")
|
||||
"Follow"
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -4,19 +4,32 @@ import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useUserContext } from "./provider";
|
||||
import { NostrQuery } from "@lume/system";
|
||||
import { experimental_createPersister } from "@tanstack/query-persist-client-core";
|
||||
|
||||
export function UserNip05() {
|
||||
const user = useUserContext();
|
||||
const { isLoading, data: verified } = useQuery({
|
||||
queryKey: ["nip05", user?.pubkey],
|
||||
queryFn: async () => {
|
||||
if (!user.profile?.nip05?.length) return false;
|
||||
|
||||
const verify = await NostrQuery.verifyNip05(
|
||||
user.pubkey,
|
||||
user.profile?.nip05,
|
||||
);
|
||||
|
||||
return verify;
|
||||
},
|
||||
enabled: !!user.profile?.nip05,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
retry: false,
|
||||
persister: experimental_createPersister({
|
||||
storage: localStorage,
|
||||
maxAge: 1000 * 60 * 60 * 72, // 72 hours
|
||||
}),
|
||||
});
|
||||
|
||||
if (!user.profile?.nip05?.length) return;
|
||||
@@ -26,7 +39,7 @@ export function UserNip05() {
|
||||
<Tooltip.Root delayDuration={150}>
|
||||
<Tooltip.Trigger>
|
||||
{!isLoading && verified ? (
|
||||
<VerifiedIcon className="size-4 text-teal-500" />
|
||||
<VerifiedIcon className="text-teal-500 size-4" />
|
||||
) : null}
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
|
||||
@@ -1,91 +1,68 @@
|
||||
import { Column } from "@/components/column";
|
||||
import { Toolbar } from "@/components/toolbar";
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "@lume/icons";
|
||||
import { ArrowLeftIcon, ArrowRightIcon, PlusSquareIcon } from "@lume/icons";
|
||||
import { NostrQuery } from "@lume/system";
|
||||
import type { EventColumns, LumeColumn } from "@lume/types";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrent } from "@tauri-apps/api/window";
|
||||
import { nanoid } from "nanoid";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import { VList, type VListHandle } from "virtua";
|
||||
import useEmblaCarousel from "embla-carousel-react";
|
||||
|
||||
export const Route = createFileRoute("/$account/home")({
|
||||
loader: async () => {
|
||||
const columns = await NostrQuery.getColumns();
|
||||
return columns;
|
||||
},
|
||||
gcTime: 0,
|
||||
shouldReload: false,
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { account } = Route.useParams();
|
||||
const initialColumnList = Route.useLoaderData();
|
||||
const vlistRef = useRef<VListHandle>(null);
|
||||
|
||||
const [columns, setColumns] = useState<LumeColumn[]>([]);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const [isScroll, setIsScroll] = useState(false);
|
||||
const [isResize, setIsResize] = useState(false);
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({
|
||||
watchDrag: false,
|
||||
loop: true,
|
||||
});
|
||||
|
||||
const reset = () => {
|
||||
setColumns(null);
|
||||
setSelectedIndex(-1);
|
||||
};
|
||||
const scrollPrev = useCallback(() => {
|
||||
if (emblaApi) emblaApi.scrollPrev(true);
|
||||
}, [emblaApi]);
|
||||
|
||||
const goLeft = () => {
|
||||
const prevIndex = Math.max(selectedIndex - 1, 0);
|
||||
setSelectedIndex(prevIndex);
|
||||
vlistRef.current.scrollToIndex(prevIndex, {
|
||||
align: "center",
|
||||
});
|
||||
};
|
||||
const scrollNext = useCallback(() => {
|
||||
if (emblaApi) emblaApi.scrollNext(true);
|
||||
}, [emblaApi]);
|
||||
|
||||
const goRight = () => {
|
||||
const nextIndex = Math.min(selectedIndex + 1, columns.length - 1);
|
||||
setSelectedIndex(nextIndex);
|
||||
vlistRef.current.scrollToIndex(nextIndex, {
|
||||
align: "center",
|
||||
});
|
||||
};
|
||||
const emitScrollEvent = useCallback(() => {
|
||||
getCurrent().emit("window", { scroll: true });
|
||||
}, []);
|
||||
|
||||
const add = useDebouncedCallback((column: LumeColumn) => {
|
||||
// update col label
|
||||
column.label = `${column.label}-${nanoid()}`;
|
||||
const emitResizeEvent = useCallback(() => {
|
||||
getCurrent().emit("window", { resize: true });
|
||||
}, []);
|
||||
|
||||
// create new cols
|
||||
const cols = [...columns];
|
||||
const openColIndex = cols.findIndex((col) => col.label === "open");
|
||||
const newCols = [
|
||||
...cols.slice(0, openColIndex),
|
||||
column,
|
||||
...cols.slice(openColIndex),
|
||||
];
|
||||
|
||||
setColumns(newCols);
|
||||
setSelectedIndex(newCols.length);
|
||||
setIsScroll(true);
|
||||
|
||||
// scroll to the newest column
|
||||
vlistRef.current.scrollToIndex(newCols.length - 1, {
|
||||
align: "center",
|
||||
const openLumeStore = useDebouncedCallback(async () => {
|
||||
await getCurrent().emit("columns", {
|
||||
type: "add",
|
||||
column: {
|
||||
label: "store",
|
||||
name: "Store",
|
||||
content: "/store/official",
|
||||
},
|
||||
});
|
||||
}, 150);
|
||||
|
||||
const add = useDebouncedCallback((column: LumeColumn) => {
|
||||
column.label = `${column.label}-${nanoid()}`; // update col label
|
||||
setColumns((prev) => [...prev, column]);
|
||||
}, 150);
|
||||
|
||||
const remove = useDebouncedCallback((label: string) => {
|
||||
const newCols = columns.filter((t) => t.label !== label);
|
||||
|
||||
setColumns(newCols);
|
||||
setSelectedIndex(newCols.length);
|
||||
setIsScroll(true);
|
||||
|
||||
// scroll to the first column
|
||||
vlistRef.current.scrollToIndex(newCols.length - 1, {
|
||||
align: "start",
|
||||
});
|
||||
setColumns((prev) => prev.filter((t) => t.label !== label));
|
||||
}, 150);
|
||||
|
||||
const updateName = useDebouncedCallback((label: string, title: string) => {
|
||||
@@ -100,24 +77,46 @@ function Screen() {
|
||||
setColumns(newCols);
|
||||
}, 150);
|
||||
|
||||
const startResize = useDebouncedCallback(
|
||||
() => setIsResize((prev) => !prev),
|
||||
150,
|
||||
);
|
||||
const reset = useDebouncedCallback(() => setColumns([]), 150);
|
||||
|
||||
const handleKeyDown = useDebouncedCallback((event) => {
|
||||
if (event.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.code) {
|
||||
case "ArrowLeft":
|
||||
if (emblaApi) emblaApi.scrollPrev(true);
|
||||
break;
|
||||
case "ArrowRight":
|
||||
if (emblaApi) emblaApi.scrollNext(true);
|
||||
break;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
}, 150);
|
||||
|
||||
useEffect(() => {
|
||||
setColumns(initialColumnList);
|
||||
}, [initialColumnList]);
|
||||
if (emblaApi) {
|
||||
emblaApi.on("scroll", emitScrollEvent);
|
||||
emblaApi.on("resize", emitResizeEvent);
|
||||
emblaApi.on("slidesChanged", emitScrollEvent);
|
||||
}
|
||||
}, [emblaApi, emitScrollEvent, emitResizeEvent]);
|
||||
|
||||
useEffect(() => {
|
||||
// save current state
|
||||
if (columns?.length) {
|
||||
NostrQuery.setColumns(columns).then(() => console.log("saved"));
|
||||
}
|
||||
}, [columns]);
|
||||
|
||||
useEffect(() => {
|
||||
const unlistenColEvent = listen<EventColumns>("columns", (data) => {
|
||||
setColumns(initialColumnList);
|
||||
}, [initialColumnList]);
|
||||
|
||||
useEffect(() => {
|
||||
// Listen for columns event
|
||||
const unlisten = listen<EventColumns>("columns", (data) => {
|
||||
if (data.payload.type === "reset") reset();
|
||||
if (data.payload.type === "add") add(data.payload.column);
|
||||
if (data.payload.type === "remove") remove(data.payload.label);
|
||||
@@ -125,53 +124,50 @@ function Screen() {
|
||||
updateName(data.payload.label, data.payload.title);
|
||||
});
|
||||
|
||||
const unlistenWindowResize = getCurrent().listen("tauri://resize", () => {
|
||||
startResize();
|
||||
});
|
||||
// Listen for keyboard event
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
unlistenColEvent.then((f) => f());
|
||||
unlistenWindowResize.then((f) => f());
|
||||
unlisten.then((f) => f());
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<VList
|
||||
ref={vlistRef}
|
||||
horizontal
|
||||
tabIndex={-1}
|
||||
itemSize={440}
|
||||
overscan={5}
|
||||
onScroll={() => setIsScroll(true)}
|
||||
onScrollEnd={() => setIsScroll(false)}
|
||||
className="scrollbar-none h-full w-full overflow-x-auto focus:outline-none"
|
||||
>
|
||||
{columns?.map((column) => (
|
||||
<Column
|
||||
key={account + column.label}
|
||||
column={column}
|
||||
account={account}
|
||||
isScroll={isScroll}
|
||||
isResize={isResize}
|
||||
/>
|
||||
))}
|
||||
</VList>
|
||||
<div className="size-full">
|
||||
<div ref={emblaRef} className="overflow-hidden size-full">
|
||||
<div className="flex size-full">
|
||||
{columns?.map((column) => (
|
||||
<Column
|
||||
key={account + column.label}
|
||||
column={column}
|
||||
account={account}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Toolbar>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center h-8 gap-1 p-[2px] rounded-full bg-black/5 dark:bg-white/5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => goLeft()}
|
||||
className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
|
||||
onClick={() => scrollPrev()}
|
||||
className="inline-flex items-center justify-center rounded-full size-7 text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
|
||||
>
|
||||
<ArrowLeftIcon className="size-5" />
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => goRight()}
|
||||
className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
|
||||
onClick={() => openLumeStore()}
|
||||
className="inline-flex items-center justify-center rounded-full size-7 text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
|
||||
>
|
||||
<ArrowRightIcon className="size-5" />
|
||||
<PlusSquareIcon className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scrollNext()}
|
||||
className="inline-flex items-center justify-center rounded-full size-7 text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
|
||||
>
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</Toolbar>
|
||||
|
||||
@@ -26,7 +26,7 @@ function Screen() {
|
||||
const { platform } = Route.useRouteContext();
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen flex-col">
|
||||
<div className="flex flex-col w-screen h-screen">
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className={cn(
|
||||
@@ -38,27 +38,28 @@ function Screen() {
|
||||
<Accounts />
|
||||
<Link
|
||||
to="/landing"
|
||||
className="inline-flex size-8 shrink-0 items-center justify-center rounded-full bg-black/10 text-neutral-800 hover:bg-black/20 dark:bg-white/10 dark:text-neutral-200 dark:hover:bg-white/20"
|
||||
className="inline-flex items-center justify-center rounded-full size-8 shrink-0 bg-black/10 text-neutral-800 hover:bg-black/20 dark:bg-white/10 dark:text-neutral-200 dark:hover:bg-white/20"
|
||||
>
|
||||
<PlusIcon className="size-5" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => LumeWindow.openSearch()}
|
||||
className="inline-flex items-center justify-center rounded-full size-8 text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
|
||||
>
|
||||
<SearchIcon className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => LumeWindow.openEditor()}
|
||||
className="inline-flex h-8 w-max items-center justify-center gap-1 rounded-full bg-blue-500 px-3 text-sm font-medium text-white hover:bg-blue-600"
|
||||
className="inline-flex items-center justify-center h-8 gap-1 px-3 text-sm font-medium text-white bg-blue-500 rounded-full w-max hover:bg-blue-600"
|
||||
>
|
||||
<ComposeFilledIcon className="size-4" />
|
||||
New Post
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => LumeWindow.openSearch()}
|
||||
className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
|
||||
>
|
||||
<SearchIcon className="size-5" />
|
||||
</button>
|
||||
|
||||
<div id="toolbar" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -159,7 +160,7 @@ function Accounts() {
|
||||
))}
|
||||
{accounts.length >= 3 && windowWidth <= 700 ? (
|
||||
<Popover.Root>
|
||||
<Popover.Trigger className="inline-flex size-8 shrink-0 items-center justify-center rounded-full bg-black/10 text-neutral-800 hover:bg-black/20 dark:bg-white/10 dark:text-neutral-200 dark:hover:bg-white/20">
|
||||
<Popover.Trigger className="inline-flex items-center justify-center rounded-full size-8 shrink-0 bg-black/10 text-neutral-800 hover:bg-black/20 dark:bg-white/10 dark:text-neutral-200 dark:hover:bg-white/20">
|
||||
<HorizontalDotsIcon className="size-5" />
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
@@ -169,11 +170,11 @@ function Accounts() {
|
||||
key={user}
|
||||
type="button"
|
||||
onClick={() => changeAccount(user)}
|
||||
className="size-9 inline-flex items-center justify-center hover:bg-white/10 rounded-md"
|
||||
className="inline-flex items-center justify-center rounded-md size-9 hover:bg-white/10"
|
||||
>
|
||||
<User.Provider pubkey={user}>
|
||||
<User.Root className="rounded-full ring-1 ring-white/10">
|
||||
<User.Avatar className="size-7 aspect-square h-auto rounded-full object-cover" />
|
||||
<User.Avatar className="object-cover h-auto rounded-full size-7 aspect-square" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</button>
|
||||
|
||||
@@ -33,13 +33,6 @@ function Screen() {
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleZap = () => {
|
||||
setNewSettings((prev) => ({
|
||||
...prev,
|
||||
zap: !newSettings.zap,
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleNsfw = () => {
|
||||
setNewSettings((prev) => ({
|
||||
...prev,
|
||||
@@ -69,9 +62,9 @@ function Screen() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-6 px-5 mx-auto xl:max-w-xl">
|
||||
<div className="flex flex-col items-center gap-5 text-center">
|
||||
<div className="flex size-20 items-center justify-center rounded-full bg-teal-100 dark:bg-teal-950 text-teal-500">
|
||||
<div className="flex items-center justify-center text-teal-500 bg-teal-100 rounded-full size-20 dark:bg-teal-950">
|
||||
<LaurelIcon className="size-8" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -85,7 +78,7 @@ function Screen() {
|
||||
</div>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
|
||||
<div className="flex items-start justify-between w-full gap-4 px-5 py-4 rounded-lg bg-neutral-100 dark:bg-white/10">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold">Enhanced Privacy</h3>
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
@@ -101,23 +94,7 @@ function Screen() {
|
||||
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||
</Switch.Root>
|
||||
</div>
|
||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold">Zap</h3>
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
Show the Zap button in each note and user's profile screen, use
|
||||
for send Bitcoin tip to other users.
|
||||
</p>
|
||||
</div>
|
||||
<Switch.Root
|
||||
checked={newSettings.zap}
|
||||
onClick={() => toggleZap()}
|
||||
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/20"
|
||||
>
|
||||
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||
</Switch.Root>
|
||||
</div>
|
||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
|
||||
<div className="flex items-start justify-between w-full gap-4 px-5 py-4 rounded-lg bg-neutral-100 dark:bg-white/10">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold">Filter sensitive content</h3>
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
@@ -138,7 +115,7 @@ function Screen() {
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
disabled={loading}
|
||||
className="mb-1 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
|
||||
className="inline-flex items-center justify-center w-full mb-1 font-semibold text-white bg-blue-500 rounded-lg h-11 shrink-0 hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{loading ? <Spinner /> : t("global.continue")}
|
||||
</button>
|
||||
@@ -149,7 +126,7 @@ function Screen() {
|
||||
|
||||
function Pending() {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<button type="button" className="size-5" disabled>
|
||||
<Spinner className="size-5" />
|
||||
</button>
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { AddMediaIcon } from "@lume/icons";
|
||||
import { NostrQuery } from "@lume/system";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { cn, insertImage, isImagePath } from "@lume/utils";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import { insertImage, isImagePath } from "@lume/utils";
|
||||
import type { UnlistenFn } from "@tauri-apps/api/event";
|
||||
import { getCurrent } from "@tauri-apps/api/window";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSlateStatic } from "slate-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function MediaButton({ className }: { className?: string }) {
|
||||
export function MediaButton() {
|
||||
const editor = useSlateStatic();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -61,29 +60,18 @@ export function MediaButton({ className }: { className?: string }) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root delayDuration={150}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => upload()}
|
||||
disabled={loading}
|
||||
className={cn("inline-flex items-center justify-center", className)}
|
||||
>
|
||||
{loading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<AddMediaIcon className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content className="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] 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 dark:bg-neutral-50 dark:text-neutral-950">
|
||||
Upload media
|
||||
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => upload()}
|
||||
disabled={loading}
|
||||
className="inline-flex items-center h-8 gap-2 px-2.5 text-sm rounded-lg text-black/70 dark:text-white/70 w-max hover:bg-black/10 dark:hover:bg-white/10"
|
||||
>
|
||||
{loading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<AddMediaIcon className="size-4" />
|
||||
)}
|
||||
Add media
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ export function MentionButton({ className }: { className?: string }) {
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content className="flex w-[220px] h-[220px] scrollbar-none flex-col overflow-y-auto rounded-xl bg-black py-1 shadow-md shadow-neutral-500/20 focus:outline-none dark:bg-white">
|
||||
{contacts.length < 1 ? (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<p className="text-sm text-white">Contact List is empty.</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -69,11 +69,11 @@ export function MentionButton({ className }: { className?: string }) {
|
||||
<DropdownMenu.Item
|
||||
key={contact}
|
||||
onClick={() => select(contact)}
|
||||
className="shrink-0 h-11 flex items-center hover:bg-white/10 px-2"
|
||||
className="flex items-center px-2 shrink-0 h-11 hover:bg-white/10"
|
||||
>
|
||||
<User.Provider pubkey={contact}>
|
||||
<User.Root className="flex items-center gap-2">
|
||||
<User.Avatar className="shrink-0 size-8 rounded-full" />
|
||||
<User.Avatar className="rounded-full shrink-0 size-8" />
|
||||
<User.Name className="text-sm font-medium text-white dark:text-black" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
|
||||
@@ -1,40 +1,21 @@
|
||||
import { NsfwIcon } from "@lume/icons";
|
||||
import { cn } from "@lume/utils";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import { PowIcon } from "@lume/icons";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
|
||||
export function PowToggle({
|
||||
pow,
|
||||
setPow,
|
||||
className,
|
||||
export function PowButton({
|
||||
setDifficulty,
|
||||
}: {
|
||||
pow: boolean;
|
||||
setPow: Dispatch<SetStateAction<boolean>>;
|
||||
className?: string;
|
||||
setDifficulty: Dispatch<SetStateAction<{ enable: boolean; num: number }>>;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root delayDuration={150}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPow((prev) => !prev)}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center",
|
||||
className,
|
||||
pow ? "bg-blue-500 text-white" : "",
|
||||
)}
|
||||
>
|
||||
<NsfwIcon className="size-4" />
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content className="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] 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 dark:bg-neutral-50 dark:text-neutral-950">
|
||||
Proof of Work
|
||||
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setDifficulty((prev) => ({ ...prev, enable: !prev.enable }))
|
||||
}
|
||||
className="inline-flex items-center h-8 gap-2 px-2.5 text-sm rounded-lg text-black/70 dark:text-white/70 w-max hover:bg-black/10 dark:hover:bg-white/10"
|
||||
>
|
||||
<PowIcon className="size-4" />
|
||||
PoW
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,40 +1,19 @@
|
||||
import { NsfwIcon } from "@lume/icons";
|
||||
import { cn } from "@lume/utils";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
|
||||
export function WarningToggle({
|
||||
warning,
|
||||
export function WarningButton({
|
||||
setWarning,
|
||||
className,
|
||||
}: {
|
||||
warning: boolean;
|
||||
setWarning: Dispatch<SetStateAction<boolean>>;
|
||||
className?: string;
|
||||
setWarning: Dispatch<SetStateAction<{ enable: boolean; reason: string }>>;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root delayDuration={150}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWarning((prev) => !prev)}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center",
|
||||
className,
|
||||
warning ? "bg-blue-500 text-white" : "",
|
||||
)}
|
||||
>
|
||||
<NsfwIcon className="size-4" />
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content className="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] 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 dark:bg-neutral-50 dark:text-neutral-950">
|
||||
Mark as sensitive content
|
||||
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWarning((prev) => ({ ...prev, enable: !prev.enable }))}
|
||||
className="inline-flex items-center h-8 gap-2 px-2.5 text-sm rounded-lg text-black/70 dark:text-white/70 w-max hover:bg-black/10 dark:hover:bg-white/10"
|
||||
>
|
||||
<NsfwIcon className="size-4" />
|
||||
Mark as sensitive
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
import { ComposeFilledIcon } from "@lume/icons";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import {
|
||||
cn,
|
||||
insertImage,
|
||||
insertNostrEvent,
|
||||
isImageUrl,
|
||||
sendNativeNotification,
|
||||
} from "@lume/utils";
|
||||
import { cn, insertImage, insertNostrEvent, isImageUrl } from "@lume/utils";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEffect, useState } from "react";
|
||||
import { type Descendant, Node, Transforms, createEditor } from "slate";
|
||||
import {
|
||||
Editable,
|
||||
@@ -21,14 +14,17 @@ import {
|
||||
withReact,
|
||||
} from "slate-react";
|
||||
import { MediaButton } from "./-components/media";
|
||||
import { MentionButton } from "./-components/mention";
|
||||
import { LumeEvent } from "@lume/system";
|
||||
import { WarningToggle } from "./-components/warning";
|
||||
import { LumeEvent, useEvent } from "@lume/system";
|
||||
import { WarningButton } from "./-components/warning";
|
||||
import { MentionNote } from "@/components/note/mentions/note";
|
||||
import { PowButton } from "./-components/pow";
|
||||
import { User } from "@/components/user";
|
||||
import { Note } from "@/components/note";
|
||||
import { nip19 } from "nostr-tools";
|
||||
|
||||
type EditorSearch = {
|
||||
reply_to: string;
|
||||
quote: boolean;
|
||||
quote: string;
|
||||
};
|
||||
|
||||
type EditorElement = {
|
||||
@@ -41,26 +37,47 @@ export const Route = createFileRoute("/editor/")({
|
||||
validateSearch: (search: Record<string, string>): EditorSearch => {
|
||||
return {
|
||||
reply_to: search.reply_to,
|
||||
quote: search.quote === "true" || false,
|
||||
quote: search.quote,
|
||||
};
|
||||
},
|
||||
beforeLoad: ({ search }) => {
|
||||
let initialValue: EditorElement[];
|
||||
|
||||
if (search?.quote?.length) {
|
||||
const eventId = nip19.noteEncode(search.quote);
|
||||
initialValue = [
|
||||
{
|
||||
type: "paragraph",
|
||||
children: [{ text: "" }],
|
||||
},
|
||||
{
|
||||
type: "event",
|
||||
eventId: `nostr:${eventId}`,
|
||||
children: [{ text: "" }],
|
||||
},
|
||||
];
|
||||
} else {
|
||||
initialValue = [
|
||||
{
|
||||
type: "paragraph",
|
||||
children: [{ text: "" }],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return { initialValue };
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
const initialValue: EditorElement[] = [
|
||||
{
|
||||
type: "paragraph",
|
||||
children: [{ text: "" }],
|
||||
},
|
||||
];
|
||||
|
||||
function Screen() {
|
||||
const search = Route.useSearch();
|
||||
const { reply_to } = Route.useSearch();
|
||||
const { initialValue } = Route.useRouteContext();
|
||||
|
||||
const [t] = useTranslation();
|
||||
const [editorValue, setEditorValue] = useState(initialValue);
|
||||
const [editorValue, setEditorValue] = useState<EditorElement[]>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [warning, setWarning] = useState(false);
|
||||
const [warning, setWarning] = useState({ enable: false, reason: "" });
|
||||
const [difficulty, setDifficulty] = useState({ enable: false, num: 21 });
|
||||
const [editor] = useState(() =>
|
||||
withMentions(withNostrEvent(withImages(withReact(createEditor())))),
|
||||
);
|
||||
@@ -103,63 +120,40 @@ function Screen() {
|
||||
const content = serialize(editor.children);
|
||||
const eventId = await LumeEvent.publish(
|
||||
content,
|
||||
search.reply_to,
|
||||
search.quote,
|
||||
warning,
|
||||
warning.enable && warning.reason.length ? warning.reason : null,
|
||||
difficulty.enable && difficulty.num > 0 ? difficulty.num : null,
|
||||
reply_to,
|
||||
);
|
||||
|
||||
if (eventId) {
|
||||
await sendNativeNotification(
|
||||
"Your note has been published successfully.",
|
||||
"Lume",
|
||||
);
|
||||
// stop loading
|
||||
setLoading(false);
|
||||
// reset form
|
||||
reset();
|
||||
}
|
||||
|
||||
// stop loading
|
||||
setLoading(false);
|
||||
|
||||
// reset form
|
||||
reset();
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
await sendNativeNotification(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setEditorValue(initialValue);
|
||||
}, [initialValue]);
|
||||
|
||||
if (!editorValue) return null;
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col">
|
||||
<div className="flex flex-col w-full h-full">
|
||||
<Slate editor={editor} initialValue={editorValue}>
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="shrink-0 flex h-14 w-full items-center justify-end gap-2 px-2 border-b border-black/10 dark:border-white/10"
|
||||
>
|
||||
<WarningToggle
|
||||
warning={warning}
|
||||
setWarning={setWarning}
|
||||
className="size-8 rounded-full bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
|
||||
/>
|
||||
<MentionButton className="size-8 rounded-full bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20" />
|
||||
<MediaButton className="size-8 rounded-full bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => publish()}
|
||||
className="inline-flex h-8 w-max items-center justify-center gap-1 rounded-full bg-blue-500 px-3 text-sm font-medium text-white hover:bg-blue-600"
|
||||
>
|
||||
{loading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<ComposeFilledIcon className="size-4" />
|
||||
)}
|
||||
{t("global.post")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto flex flex-col">
|
||||
{search.reply_to ? (
|
||||
<div className="px-4 py-2">
|
||||
<MentionNote eventId={search.reply_to} />
|
||||
<div data-tauri-drag-region className="h-9 shrink-0" />
|
||||
<div className="flex flex-col flex-1 overflow-y-auto">
|
||||
{reply_to?.length ? (
|
||||
<div className="flex items-center gap-3 px-2.5 pb-3 border-b border-black/5 dark:border-white/5">
|
||||
<div className="text-sm font-semibold shrink-0">Reply to:</div>
|
||||
<ChildNote id={reply_to} />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="overflow-y-auto scrollbar-none p-4">
|
||||
<div className="px-4 py-4 overflow-y-auto">
|
||||
<Editable
|
||||
key={JSON.stringify(editorValue)}
|
||||
autoFocus={true}
|
||||
@@ -168,17 +162,103 @@ function Screen() {
|
||||
spellCheck={false}
|
||||
renderElement={(props) => <Element {...props} />}
|
||||
placeholder={
|
||||
search.reply_to ? "Type your reply..." : t("editor.placeholder")
|
||||
reply_to ? "Type your reply..." : "What're you up to?"
|
||||
}
|
||||
className="focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{warning.enable ? (
|
||||
<div className="flex items-center w-full px-4 border-t h-11 shrink-0 border-black/5 dark:border-white/5">
|
||||
<span className="text-sm shrink-0 text-black/50 dark:text-white/50">
|
||||
Reason:
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="NSFW..."
|
||||
value={warning.reason}
|
||||
onChange={(e) =>
|
||||
setWarning((prev) => ({ ...prev, reason: e.target.value }))
|
||||
}
|
||||
className="flex-1 text-sm bg-transparent border-none focus:outline-none focus:ring-0 placeholder:text-black/50 dark:placeholder:text-white/50"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{difficulty.enable ? (
|
||||
<div className="flex items-center w-full px-4 border-t h-11 shrink-0 border-black/5 dark:border-white/5">
|
||||
<span className="text-sm shrink-0 text-black/50 dark:text-white/50">
|
||||
Difficulty:
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]"
|
||||
onKeyDown={(event) => {
|
||||
if (!/[0-9]/.test(event.key)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
placeholder="21"
|
||||
defaultValue={difficulty.num}
|
||||
onChange={(e) =>
|
||||
setWarning((prev) => ({ ...prev, num: Number(e.target.value) }))
|
||||
}
|
||||
className="flex-1 text-sm bg-transparent border-none focus:outline-none focus:ring-0 placeholder:text-black/50 dark:placeholder:text-white/50"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="flex items-center w-full h-16 gap-4 px-4 border-t divide-x divide-black/5 dark:divide-white/5 shrink-0 border-black/5 dark:border-white/5"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => publish()}
|
||||
className="inline-flex items-center justify-center h-8 gap-1 px-2.5 text-sm font-medium rounded-lg bg-black/10 w-max hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
|
||||
>
|
||||
{loading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<ComposeFilledIcon className="size-4" />
|
||||
)}
|
||||
Publish
|
||||
</button>
|
||||
<div className="inline-flex items-center flex-1 gap-2 pl-4">
|
||||
<MediaButton />
|
||||
<WarningButton setWarning={setWarning} />
|
||||
<PowButton setDifficulty={setDifficulty} />
|
||||
</div>
|
||||
</div>
|
||||
</Slate>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChildNote({ id }: { id: string }) {
|
||||
const { isLoading, isError, data } = useEvent(id);
|
||||
|
||||
if (isLoading) {
|
||||
return <Spinner className="size-5" />;
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return <div>Event not found with your current relay set.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Note.Provider event={data}>
|
||||
<Note.Root className="flex items-center gap-2">
|
||||
<User.Provider pubkey={data.pubkey}>
|
||||
<User.Root className="shrink-0">
|
||||
<User.Avatar className="rounded-full size-8 shrink-0" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<div className="content-break line-clamp-1">{data.content}</div>
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const withNostrEvent = (editor: ReactEditor) => {
|
||||
const { insertData, isVoid } = editor;
|
||||
|
||||
@@ -190,7 +270,7 @@ const withNostrEvent = (editor: ReactEditor) => {
|
||||
editor.insertData = (data) => {
|
||||
const text = data.getData("text/plain");
|
||||
|
||||
if (text.startsWith("nevent1") || text.startsWith("note1")) {
|
||||
if (text.startsWith("nevent") || text.startsWith("note")) {
|
||||
insertNostrEvent(editor, text);
|
||||
} else {
|
||||
insertData(data);
|
||||
@@ -259,6 +339,7 @@ const Image = ({ attributes, element, children }) => {
|
||||
selected && focused ? "ring-blue-500" : "ring-transparent",
|
||||
)}
|
||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
||||
onKeyDown={() => Transforms.removeNodes(editor, { at: path })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -274,7 +355,7 @@ const Mention = ({ attributes, element }) => {
|
||||
type="button"
|
||||
contentEditable={false}
|
||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
||||
className="inline-block align-baseline text-blue-500 hover:text-blue-600"
|
||||
className="inline-block text-blue-500 align-baseline hover:text-blue-600"
|
||||
>{`@${element.name}`}</span>
|
||||
);
|
||||
};
|
||||
@@ -286,16 +367,13 @@ const Event = ({ attributes, element, children }) => {
|
||||
return (
|
||||
<div {...attributes}>
|
||||
{children}
|
||||
{/* biome-ignore lint/a11y/useKeyWithClickEvents: <explanation> */}
|
||||
<div
|
||||
contentEditable={false}
|
||||
className="relative my-2 user-select-none"
|
||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
||||
className="user-select-none relative my-2"
|
||||
onKeyDown={() => Transforms.removeNodes(editor, { at: path })}
|
||||
>
|
||||
<MentionNote
|
||||
eventId={element.eventId.replace("nostr:", "")}
|
||||
openable={false}
|
||||
/>
|
||||
<MentionNote eventId={element.eventId} openable={false} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,12 +2,13 @@ import { Conversation } from "@/components/conversation";
|
||||
import { Quote } from "@/components/quote";
|
||||
import { RepostNote } from "@/components/repost";
|
||||
import { TextNote } from "@/components/text";
|
||||
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
|
||||
import { ArrowRightCircleIcon } from "@lume/icons";
|
||||
import { NostrQuery } from "@lume/system";
|
||||
import { type ColumnRouteSearch, type NostrEvent, Kind } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useCallback } from "react";
|
||||
import { Virtualizer } from "virtua";
|
||||
|
||||
export const Route = createFileRoute("/global")({
|
||||
@@ -18,10 +19,6 @@ export const Route = createFileRoute("/global")({
|
||||
name: search.name,
|
||||
};
|
||||
},
|
||||
beforeLoad: async () => {
|
||||
const settings = await NostrQuery.getSettings();
|
||||
return { settings };
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
@@ -46,34 +43,39 @@ export function Screen() {
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const renderItem = (event: NostrEvent) => {
|
||||
if (!event) return;
|
||||
switch (event.kind) {
|
||||
case Kind.Repost:
|
||||
return <RepostNote key={event.id} event={event} />;
|
||||
default: {
|
||||
const isConversation =
|
||||
event.tags.filter((tag) => tag[0] === "e" && tag[3] !== "mention")
|
||||
.length > 0;
|
||||
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0;
|
||||
const renderItem = useCallback(
|
||||
(event: NostrEvent) => {
|
||||
if (!event) return;
|
||||
switch (event.kind) {
|
||||
case Kind.Repost:
|
||||
return <RepostNote key={event.id} event={event} />;
|
||||
default: {
|
||||
const isConversation =
|
||||
event.tags.filter((tag) => tag[0] === "e" && tag[3] !== "mention")
|
||||
.length > 0;
|
||||
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0;
|
||||
|
||||
if (isConversation) {
|
||||
return <Conversation key={event.id} event={event} className="mb-3" />;
|
||||
if (isConversation) {
|
||||
return (
|
||||
<Conversation key={event.id} event={event} className="mb-3" />
|
||||
);
|
||||
}
|
||||
|
||||
if (isQuote) {
|
||||
return <Quote key={event.id} event={event} className="mb-3" />;
|
||||
}
|
||||
|
||||
return <TextNote key={event.id} event={event} className="mb-3" />;
|
||||
}
|
||||
|
||||
if (isQuote) {
|
||||
return <Quote key={event.id} event={event} className="mb-3" />;
|
||||
}
|
||||
|
||||
return <TextNote key={event.id} event={event} className="mb-3" />;
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
[data],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none">
|
||||
<div className="w-full h-full p-2 overflow-y-auto scrollbar-none">
|
||||
{isFetching && !isLoading && !isFetchingNextPage ? (
|
||||
<div className="w-full h-11 flex items-center justify-center">
|
||||
<div className="flex items-center justify-center w-full h-11">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Spinner className="size-5" />
|
||||
<span className="text-sm font-medium">Fetching new notes...</span>
|
||||
@@ -81,7 +83,7 @@ export function Screen() {
|
||||
</div>
|
||||
) : null}
|
||||
{isLoading ? (
|
||||
<div className="flex h-16 w-full items-center justify-center gap-2">
|
||||
<div className="flex items-center justify-center w-full h-16 gap-2">
|
||||
<Spinner className="size-5" />
|
||||
<span className="text-sm font-medium">Loading...</span>
|
||||
</div>
|
||||
@@ -98,7 +100,7 @@ export function Screen() {
|
||||
type="button"
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={isFetchingNextPage || isLoading}
|
||||
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-black/5 px-3 font-medium hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
|
||||
className="inline-flex items-center justify-center w-full h-12 gap-2 px-3 font-medium rounded-xl bg-black/5 hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<Spinner className="size-5" />
|
||||
@@ -117,31 +119,12 @@ export function Screen() {
|
||||
|
||||
function Empty() {
|
||||
return (
|
||||
<div className="flex flex-col py-10 gap-10">
|
||||
<div className="text-center flex flex-col items-center justify-center">
|
||||
<div className="size-24 bg-blue-100 flex flex-col items-center justify-end overflow-hidden dark:bg-blue-900 rounded-full mb-8">
|
||||
<div className="w-12 h-16 bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900 rounded-t-lg" />
|
||||
<div className="flex flex-col gap-10 py-10">
|
||||
<div className="flex flex-col items-center justify-center text-center">
|
||||
<div className="flex flex-col items-center justify-end mb-8 overflow-hidden bg-blue-100 rounded-full size-24 dark:bg-blue-900">
|
||||
<div className="w-12 h-16 rounded-t-lg bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900" />
|
||||
</div>
|
||||
<p className="text-lg font-medium">Your newsfeed is empty</p>
|
||||
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
|
||||
Here are few suggestions to get started.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col px-3 gap-2">
|
||||
<Link
|
||||
to="/trending/notes"
|
||||
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
|
||||
>
|
||||
<ArrowRightIcon className="size-5" />
|
||||
Show trending notes
|
||||
</Link>
|
||||
<Link
|
||||
to="/trending/users"
|
||||
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
|
||||
>
|
||||
<ArrowRightIcon className="size-5" />
|
||||
Discover trending users
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,12 +2,13 @@ import { Conversation } from "@/components/conversation";
|
||||
import { Quote } from "@/components/quote";
|
||||
import { RepostNote } from "@/components/repost";
|
||||
import { TextNote } from "@/components/text";
|
||||
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
|
||||
import { NostrAccount, NostrQuery } from "@lume/system";
|
||||
import { ArrowRightCircleIcon } from "@lume/icons";
|
||||
import { NostrQuery } from "@lume/system";
|
||||
import { type ColumnRouteSearch, type NostrEvent, Kind } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { Link, createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { useCallback } from "react";
|
||||
import { Virtualizer } from "virtua";
|
||||
|
||||
export const Route = createFileRoute("/group")({
|
||||
@@ -21,7 +22,6 @@ export const Route = createFileRoute("/group")({
|
||||
beforeLoad: async ({ search }) => {
|
||||
const key = `lume_group_${search.label}`;
|
||||
const groups = (await NostrQuery.getNstore(key)) as string[];
|
||||
const settings = await NostrQuery.getSettings();
|
||||
|
||||
if (!groups?.length) {
|
||||
throw redirect({
|
||||
@@ -33,10 +33,7 @@ export const Route = createFileRoute("/group")({
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
groups,
|
||||
settings,
|
||||
};
|
||||
return { groups };
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
@@ -55,7 +52,7 @@ export function Screen() {
|
||||
queryKey: [label, account],
|
||||
initialPageParam: 0,
|
||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||
const events = await NostrQuery.getLocalEvents(groups, pageParam);
|
||||
const events = await NostrQuery.getGroupEvents(groups, pageParam);
|
||||
return events;
|
||||
},
|
||||
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
|
||||
@@ -64,34 +61,39 @@ export function Screen() {
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const renderItem = (event: NostrEvent) => {
|
||||
if (!event) return;
|
||||
switch (event.kind) {
|
||||
case Kind.Repost:
|
||||
return <RepostNote key={event.id} event={event} />;
|
||||
default: {
|
||||
const isConversation =
|
||||
event.tags.filter((tag) => tag[0] === "e" && tag[3] !== "mention")
|
||||
.length > 0;
|
||||
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0;
|
||||
const renderItem = useCallback(
|
||||
(event: NostrEvent) => {
|
||||
if (!event) return;
|
||||
switch (event.kind) {
|
||||
case Kind.Repost:
|
||||
return <RepostNote key={event.id} event={event} />;
|
||||
default: {
|
||||
const isConversation =
|
||||
event.tags.filter((tag) => tag[0] === "e" && tag[3] !== "mention")
|
||||
.length > 0;
|
||||
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0;
|
||||
|
||||
if (isConversation) {
|
||||
return <Conversation key={event.id} event={event} className="mb-3" />;
|
||||
if (isConversation) {
|
||||
return (
|
||||
<Conversation key={event.id} event={event} className="mb-3" />
|
||||
);
|
||||
}
|
||||
|
||||
if (isQuote) {
|
||||
return <Quote key={event.id} event={event} className="mb-3" />;
|
||||
}
|
||||
|
||||
return <TextNote key={event.id} event={event} className="mb-3" />;
|
||||
}
|
||||
|
||||
if (isQuote) {
|
||||
return <Quote key={event.id} event={event} className="mb-3" />;
|
||||
}
|
||||
|
||||
return <TextNote key={event.id} event={event} className="mb-3" />;
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
[data],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none">
|
||||
<div className="w-full h-full p-2 overflow-y-auto scrollbar-none">
|
||||
{isFetching && !isLoading && !isFetchingNextPage ? (
|
||||
<div className="mb-3 w-full h-11 flex items-center justify-center bg-black/10 dark:bg-white/10 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50">
|
||||
<div className="flex items-center justify-center w-full mb-3 h-11 bg-black/10 dark:bg-white/10 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Spinner className="size-5" />
|
||||
<span className="text-sm font-medium">Fetching new notes...</span>
|
||||
@@ -99,7 +101,7 @@ export function Screen() {
|
||||
</div>
|
||||
) : null}
|
||||
{isLoading ? (
|
||||
<div className="flex h-16 w-full items-center justify-center gap-2">
|
||||
<div className="flex items-center justify-center w-full h-16 gap-2">
|
||||
<Spinner className="size-5" />
|
||||
<span className="text-sm font-medium">Loading...</span>
|
||||
</div>
|
||||
@@ -116,7 +118,7 @@ export function Screen() {
|
||||
type="button"
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={isFetchingNextPage || isLoading}
|
||||
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-neutral-100 px-3 font-medium hover:bg-neutral-50 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
|
||||
className="inline-flex items-center justify-center w-full h-12 gap-2 px-3 font-medium rounded-xl bg-neutral-100 hover:bg-neutral-50 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<Spinner className="size-5" />
|
||||
@@ -135,31 +137,12 @@ export function Screen() {
|
||||
|
||||
function Empty() {
|
||||
return (
|
||||
<div className="flex flex-col py-10 gap-10">
|
||||
<div className="text-center flex flex-col items-center justify-center">
|
||||
<div className="size-24 bg-blue-100 flex flex-col items-center justify-end overflow-hidden dark:bg-blue-900 rounded-full mb-8">
|
||||
<div className="w-12 h-16 bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900 rounded-t-lg" />
|
||||
<div className="flex flex-col gap-10 py-10">
|
||||
<div className="flex flex-col items-center justify-center text-center">
|
||||
<div className="flex flex-col items-center justify-end mb-8 overflow-hidden bg-blue-100 rounded-full size-24 dark:bg-blue-900">
|
||||
<div className="w-12 h-16 rounded-t-lg bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900" />
|
||||
</div>
|
||||
<p className="text-lg font-medium">Your newsfeed is empty</p>
|
||||
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
|
||||
Here are few suggestions to get started.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col px-3 gap-2">
|
||||
<Link
|
||||
to="/trending/notes"
|
||||
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
|
||||
>
|
||||
<ArrowRightIcon className="size-5" />
|
||||
Show trending notes
|
||||
</Link>
|
||||
<Link
|
||||
to="/trending/users"
|
||||
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
|
||||
>
|
||||
<ArrowRightIcon className="size-5" />
|
||||
Discover trending users
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,12 +3,12 @@ import { Quote } from "@/components/quote";
|
||||
import { RepostNote } from "@/components/repost";
|
||||
import { TextNote } from "@/components/text";
|
||||
import { ArrowRightCircleIcon } from "@lume/icons";
|
||||
import { NostrAccount, NostrQuery } from "@lume/system";
|
||||
import { type ColumnRouteSearch, type NostrEvent, Kind } from "@lume/types";
|
||||
import { type LumeEvent, NostrAccount, NostrQuery } from "@lume/system";
|
||||
import { type ColumnRouteSearch, Kind } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { redirect } from "@tanstack/react-router";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { useCallback } from "react";
|
||||
import { Virtualizer } from "virtua";
|
||||
|
||||
export const Route = createFileRoute("/newsfeed")({
|
||||
@@ -20,10 +20,8 @@ export const Route = createFileRoute("/newsfeed")({
|
||||
};
|
||||
},
|
||||
beforeLoad: async ({ search }) => {
|
||||
const settings = await NostrQuery.getSettings();
|
||||
const contacts = await NostrAccount.getContactList();
|
||||
|
||||
if (!contacts.length) {
|
||||
const isContactListEmpty = await NostrAccount.isContactListEmpty();
|
||||
if (isContactListEmpty) {
|
||||
throw redirect({
|
||||
to: "/create-newsfeed/users",
|
||||
search: {
|
||||
@@ -32,15 +30,12 @@ export const Route = createFileRoute("/newsfeed")({
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { settings, contacts };
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
export function Screen() {
|
||||
const { label, account } = Route.useSearch();
|
||||
const { contacts, settings } = Route.useRouteContext();
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
@@ -52,7 +47,7 @@ export function Screen() {
|
||||
queryKey: [label, account],
|
||||
initialPageParam: 0,
|
||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||
const events = await NostrQuery.getLocalEvents(contacts, pageParam);
|
||||
const events = await NostrQuery.getLocalEvents(pageParam);
|
||||
return events;
|
||||
},
|
||||
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
|
||||
@@ -60,41 +55,32 @@ export function Screen() {
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const renderItem = (event: NostrEvent) => {
|
||||
if (!event) return;
|
||||
switch (event.kind) {
|
||||
case Kind.Repost:
|
||||
return <RepostNote key={event.id} event={event} />;
|
||||
default: {
|
||||
const isConversation =
|
||||
event.tags.filter((tag) => tag[0] === "e" && tag[3] !== "mention")
|
||||
.length > 0;
|
||||
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0;
|
||||
|
||||
if (isConversation) {
|
||||
return (
|
||||
<Conversation
|
||||
key={event.id}
|
||||
className="mb-3"
|
||||
event={event}
|
||||
gossip={settings?.gossip}
|
||||
/>
|
||||
);
|
||||
const renderItem = useCallback(
|
||||
(event: LumeEvent) => {
|
||||
if (!event) return;
|
||||
switch (event.kind) {
|
||||
case Kind.Repost:
|
||||
return <RepostNote key={event.id} event={event} className="mb-3" />;
|
||||
default: {
|
||||
if (event.isConversation) {
|
||||
return (
|
||||
<Conversation key={event.id} className="mb-3" event={event} />
|
||||
);
|
||||
}
|
||||
if (event.isQuote) {
|
||||
return <Quote key={event.id} event={event} className="mb-3" />;
|
||||
}
|
||||
return <TextNote key={event.id} event={event} className="mb-3" />;
|
||||
}
|
||||
|
||||
if (isQuote) {
|
||||
return <Quote key={event.id} event={event} className="mb-3" />;
|
||||
}
|
||||
|
||||
return <TextNote key={event.id} event={event} className="mb-3" />;
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
[data],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none">
|
||||
<div className="w-full h-full p-3 overflow-y-auto scrollbar-none">
|
||||
{isFetching && !isLoading && !isFetchingNextPage ? (
|
||||
<div className="mb-3 w-full h-11 flex items-center justify-center bg-black/10 dark:bg-white/10 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50">
|
||||
<div className="flex items-center justify-center w-full mb-3 h-11 bg-black/10 dark:bg-white/10 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Spinner className="size-5" />
|
||||
<span className="text-sm font-medium">Fetching new notes...</span>
|
||||
@@ -102,7 +88,7 @@ export function Screen() {
|
||||
</div>
|
||||
) : null}
|
||||
{isLoading ? (
|
||||
<div className="flex h-16 w-full items-center justify-center gap-2">
|
||||
<div className="flex items-center justify-center w-full h-16 gap-2">
|
||||
<Spinner className="size-5" />
|
||||
<span className="text-sm font-medium">Loading...</span>
|
||||
</div>
|
||||
@@ -121,7 +107,7 @@ export function Screen() {
|
||||
type="button"
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={isFetchingNextPage || isLoading}
|
||||
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-black/5 px-3 font-medium hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
|
||||
className="inline-flex items-center justify-center w-full gap-2 px-3 font-medium h-9 rounded-xl bg-black/5 hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<Spinner className="size-5" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Note } from "@/components/note";
|
||||
import { User } from "@/components/user";
|
||||
import { LumeWindow, NostrQuery, useEvent } from "@lume/system";
|
||||
import { Kind, NostrEvent } from "@lume/types";
|
||||
import { type LumeEvent, LumeWindow, NostrQuery, useEvent } from "@lume/system";
|
||||
import { Kind } from "@lume/types";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { getCurrent } from "@tauri-apps/api/window";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
@@ -19,7 +19,7 @@ export const Route = createFileRoute("/panel")({
|
||||
|
||||
function Screen() {
|
||||
const [account, setAccount] = useState<string>(null);
|
||||
const [events, setEvents] = useState<NostrEvent[]>([]);
|
||||
const [events, setEvents] = useState<LumeEvent[]>([]);
|
||||
|
||||
const texts = useMemo(
|
||||
() => events.filter((ev) => ev.kind === Kind.Text),
|
||||
@@ -27,7 +27,7 @@ function Screen() {
|
||||
);
|
||||
|
||||
const zaps = useMemo(() => {
|
||||
const groups = new Map<string, NostrEvent[]>();
|
||||
const groups = new Map<string, LumeEvent[]>();
|
||||
const list = events.filter((ev) => ev.kind === Kind.ZapReceipt);
|
||||
|
||||
for (const event of list) {
|
||||
@@ -46,7 +46,7 @@ function Screen() {
|
||||
}, [events]);
|
||||
|
||||
const reactions = useMemo(() => {
|
||||
const groups = new Map<string, NostrEvent[]>();
|
||||
const groups = new Map<string, LumeEvent[]>();
|
||||
const list = events.filter(
|
||||
(ev) => ev.kind === Kind.Repost || ev.kind === Kind.Reaction,
|
||||
);
|
||||
@@ -86,7 +86,7 @@ function Screen() {
|
||||
);
|
||||
|
||||
const unlistenNewEvent = getCurrent().listen("notification", (data) => {
|
||||
const event: NostrEvent = JSON.parse(data.payload as string);
|
||||
const event: LumeEvent = JSON.parse(data.payload as string);
|
||||
setEvents((prev) => [event, ...prev]);
|
||||
});
|
||||
|
||||
@@ -98,28 +98,28 @@ function Screen() {
|
||||
|
||||
if (!account) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center text-sm">
|
||||
<div className="flex items-center justify-center w-full h-full text-sm">
|
||||
Please log in.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col">
|
||||
<div className="h-11 shrink-0 flex items-center justify-between border-b border-black/5 px-4">
|
||||
<div className="flex flex-col w-full h-full">
|
||||
<div className="flex items-center justify-between px-4 border-b h-11 shrink-0 border-black/5">
|
||||
<div>
|
||||
<h1 className="text-sm font-semibold">Notifications</h1>
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<User.Provider pubkey={account}>
|
||||
<User.Root>
|
||||
<User.Avatar className="size-7 rounded-full" />
|
||||
<User.Avatar className="rounded-full size-7" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => LumeWindow.openSettings()}
|
||||
className="size-7 inline-flex items-center justify-center bg-black/5 dark:bg-white/5 rounded-full"
|
||||
className="inline-flex items-center justify-center rounded-full size-7 bg-black/5 dark:bg-white/5"
|
||||
>
|
||||
<SettingsIcon className="size-4" />
|
||||
</button>
|
||||
@@ -127,7 +127,7 @@ function Screen() {
|
||||
</div>
|
||||
<Tabs.Root
|
||||
defaultValue="replies"
|
||||
className="flex-1 overflow-y-auto overflow-x-hidden scrollbar-none"
|
||||
className="flex-1 overflow-x-hidden overflow-y-auto scrollbar-none"
|
||||
>
|
||||
<Tabs.List className="flex items-center">
|
||||
<Tabs.Trigger
|
||||
@@ -152,25 +152,25 @@ function Screen() {
|
||||
<div className="p-2">
|
||||
<Tabs.Content value="replies" className="flex flex-col gap-2">
|
||||
{texts.map((event) => (
|
||||
<TextNote event={event} />
|
||||
<TextNote key={event.id} event={event} />
|
||||
))}
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="reactions" className="flex flex-col gap-2">
|
||||
{[...reactions.entries()].map(([root, events]) => (
|
||||
<div
|
||||
key={root}
|
||||
className="shrink-0 flex flex-col gap-1 rounded-lg p-2 backdrop-blur-md bg-black/10 dark:bg-white/10"
|
||||
className="flex flex-col gap-1 p-2 rounded-lg shrink-0 backdrop-blur-md bg-black/10 dark:bg-white/10"
|
||||
>
|
||||
<div className="min-w-0 flex-1 flex flex-col gap-2">
|
||||
<div className="pb-2 border-b border-black/5 dark:border-white/5 flex items-center gap-2">
|
||||
<div className="flex flex-col flex-1 min-w-0 gap-2">
|
||||
<div className="flex items-center gap-2 pb-2 border-b border-black/5 dark:border-white/5">
|
||||
<RootNote id={root} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{events.map((event) => (
|
||||
<User.Provider pubkey={event.pubkey}>
|
||||
<User.Provider key={event.id} pubkey={event.pubkey}>
|
||||
<User.Root className="shrink-0 flex rounded-full h-8 bg-black/10 dark:bg-white/10 backdrop-blur-md p-[2px]">
|
||||
<User.Avatar className="flex-1 size-7 rounded-full" />
|
||||
<div className="flex-1 size-7 rounded-full inline-flex items-center justify-center text-xs truncate">
|
||||
<User.Avatar className="flex-1 rounded-full size-7" />
|
||||
<div className="inline-flex items-center justify-center flex-1 text-xs truncate rounded-full size-7">
|
||||
{event.kind === Kind.Reaction ? (
|
||||
event.content === "+" ? (
|
||||
"👍"
|
||||
@@ -178,7 +178,7 @@ function Screen() {
|
||||
event.content
|
||||
)
|
||||
) : (
|
||||
<RepostIcon className="size-4 dark:text-teal-600 text-teal-400" />
|
||||
<RepostIcon className="text-teal-400 size-4 dark:text-teal-600" />
|
||||
)}
|
||||
</div>
|
||||
</User.Root>
|
||||
@@ -193,19 +193,20 @@ function Screen() {
|
||||
{[...zaps.entries()].map(([root, events]) => (
|
||||
<div
|
||||
key={root}
|
||||
className="shrink-0 flex flex-col gap-1 rounded-lg p-2 backdrop-blur-md bg-black/10 dark:bg-white/10"
|
||||
className="flex flex-col gap-1 p-2 rounded-lg shrink-0 backdrop-blur-md bg-black/10 dark:bg-white/10"
|
||||
>
|
||||
<div className="min-w-0 flex-1 flex flex-col gap-2">
|
||||
<div className="pb-2 border-b border-black/5 dark:border-white/5 flex items-center gap-2">
|
||||
<div className="flex flex-col flex-1 min-w-0 gap-2">
|
||||
<div className="flex items-center gap-2 pb-2 border-b border-black/5 dark:border-white/5">
|
||||
<RootNote id={root} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{events.map((event) => (
|
||||
<User.Provider
|
||||
pubkey={event.tags.find((tag) => tag[0] == "P")[1]}
|
||||
key={event.id}
|
||||
pubkey={event.tags.find((tag) => tag[0] === "P")[1]}
|
||||
>
|
||||
<User.Root className="shrink-0 flex gap-1.5 rounded-full h-8 bg-black/10 dark:bg-white/10 backdrop-blur-md p-[2px]">
|
||||
<User.Avatar className="flex-1 size-7 rounded-full" />
|
||||
<User.Avatar className="flex-1 rounded-full size-7" />
|
||||
<div className="flex-1 h-7 w-max pr-1.5 rounded-full inline-flex items-center justify-center text-sm truncate">
|
||||
₿ {decodeZapInvoice(event.tags).bitcoinFormatted}
|
||||
</div>
|
||||
@@ -228,9 +229,9 @@ function RootNote({ id }: { id: string }) {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="pb-2 mb-2 flex items-center">
|
||||
<div className="size-8 shrink-0 rounded-full bg-black/20 dark:bg-white/20 animate-pulse" />
|
||||
<div className="animate-pulse rounded-md h-4 w-2/3 bg-black/20 dark:bg-white/20" />
|
||||
<div className="flex items-center pb-2 mb-2">
|
||||
<div className="rounded-full size-8 shrink-0 bg-black/20 dark:bg-white/20 animate-pulse" />
|
||||
<div className="w-2/3 h-4 rounded-md animate-pulse bg-black/20 dark:bg-white/20" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -238,10 +239,10 @@ function RootNote({ id }: { id: string }) {
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="size-8 shrink-0 rounded-full bg-red-500 text-white inline-flex items-center justify-center">
|
||||
<div className="inline-flex items-center justify-center text-white bg-red-500 rounded-full size-8 shrink-0">
|
||||
<InfoIcon className="size-5" />
|
||||
</div>
|
||||
<p className="text-red-500 text-sm">
|
||||
<p className="text-sm text-red-500">
|
||||
Event not found with your current relay set
|
||||
</p>
|
||||
</div>
|
||||
@@ -253,7 +254,7 @@ function RootNote({ id }: { id: string }) {
|
||||
<Note.Root className="flex items-center gap-2">
|
||||
<User.Provider pubkey={data.pubkey}>
|
||||
<User.Root className="shrink-0">
|
||||
<User.Avatar className="size-8 shrink-0 rounded-full" />
|
||||
<User.Avatar className="rounded-full size-8 shrink-0" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<div className="line-clamp-1">{data.content}</div>
|
||||
@@ -262,7 +263,7 @@ function RootNote({ id }: { id: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function TextNote({ event }: { event: NostrEvent }) {
|
||||
function TextNote({ event }: { event: LumeEvent }) {
|
||||
const pTags = event.tags
|
||||
.filter((tag) => tag[0] === "p")
|
||||
.map((tag) => tag[1])
|
||||
@@ -275,14 +276,14 @@ function TextNote({ event }: { event: NostrEvent }) {
|
||||
onClick={() => LumeWindow.openEvent(event)}
|
||||
>
|
||||
<Note.Provider event={event}>
|
||||
<Note.Root className="shrink-0 flex flex-col gap-1 rounded-lg p-2 backdrop-blur-md bg-black/10 dark:bg-white/10">
|
||||
<Note.Root className="flex flex-col gap-1 p-2 rounded-lg shrink-0 backdrop-blur-md bg-black/10 dark:bg-white/10">
|
||||
<User.Provider pubkey={event.pubkey}>
|
||||
<User.Root className="inline-flex items-center gap-2">
|
||||
<User.Avatar className="size-9 shrink-0 rounded-full" />
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="w-full flex items-baseline justify-between">
|
||||
<User.Name className="leading-tight text-sm font-semibold" />
|
||||
<span className="leading-tight text-sm text-black/50 dark:text-white/50">
|
||||
<User.Avatar className="rounded-full size-9 shrink-0" />
|
||||
<div className="flex flex-col flex-1">
|
||||
<div className="flex items-baseline justify-between w-full">
|
||||
<User.Name className="text-sm font-semibold leading-tight" />
|
||||
<span className="text-sm leading-tight text-black/50 dark:text-white/50">
|
||||
{formatCreatedAt(event.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -292,9 +293,9 @@ function TextNote({ event }: { event: NostrEvent }) {
|
||||
</span>
|
||||
<div className="inline-flex items-baseline gap-1">
|
||||
{pTags.map((replyTo) => (
|
||||
<User.Provider pubkey={replyTo}>
|
||||
<User.Provider key={replyTo} pubkey={replyTo}>
|
||||
<User.Root>
|
||||
<User.Name className="leading-tight font-medium" />
|
||||
<User.Name className="font-medium leading-tight" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { User } from "@/components/user";
|
||||
import { NostrAccount } from "@lume/system";
|
||||
import { displayNsec } from "@lume/utils";
|
||||
import { displayNpub, displayNsec } from "@lume/utils";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||
@@ -13,50 +13,34 @@ interface Account {
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/settings/backup")({
|
||||
component: Screen,
|
||||
loader: async () => {
|
||||
const npubs = await NostrAccount.getAccounts();
|
||||
const accounts: Account[] = [];
|
||||
|
||||
for (const npub of npubs) {
|
||||
const nsec: string = await invoke("get_stored_nsec", { npub });
|
||||
accounts.push({ npub, nsec });
|
||||
}
|
||||
|
||||
return accounts;
|
||||
beforeLoad: async () => {
|
||||
const accounts = await NostrAccount.getAccounts();
|
||||
return { accounts };
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const accounts = Route.useLoaderData();
|
||||
const { accounts } = Route.useRouteContext();
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-xl">
|
||||
<div className="w-full max-w-xl mx-auto">
|
||||
<div className="flex flex-col gap-3 divide-y divide-neutral-300 dark:divide-neutral-700">
|
||||
{accounts.map((account) => (
|
||||
<List key={account.npub} account={account} />
|
||||
<Account key={account} account={account} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function List({ account }: { account: Account }) {
|
||||
const [key, setKey] = useState(account.nsec);
|
||||
function Account({ account }: { account: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [passphase, setPassphase] = useState("");
|
||||
|
||||
const encrypt = async () => {
|
||||
const encrypted: string = await invoke("get_encrypted_key", {
|
||||
npub: account.npub,
|
||||
password: passphase,
|
||||
});
|
||||
setKey(encrypted);
|
||||
};
|
||||
|
||||
const copyKey = async () => {
|
||||
try {
|
||||
await writeText(key);
|
||||
const data: string = await invoke("get_private_key", { npub: account });
|
||||
await writeText(data);
|
||||
setCopied(true);
|
||||
} catch (e) {
|
||||
toast.error(e);
|
||||
@@ -64,65 +48,26 @@ function List({ account }: { account: Account }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-2 py-3">
|
||||
<User.Provider pubkey={account.npub}>
|
||||
<div className="flex items-center justify-between gap-2 py-3">
|
||||
<User.Provider pubkey={account}>
|
||||
<User.Root className="flex items-center gap-2">
|
||||
<User.Avatar className="size-8 rounded-full object-cover" />
|
||||
<User.Avatar className="object-cover rounded-full size-8" />
|
||||
<div className="flex flex-col">
|
||||
<User.Name className="text-sm leading-tight" />
|
||||
<User.NIP05 />
|
||||
<span className="text-sm leading-tight text-black/50 dark:text-white/50">
|
||||
{displayNpub(account, 16)}
|
||||
</span>
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<label
|
||||
htmlFor="nsec"
|
||||
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
||||
>
|
||||
Private Key
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
readOnly
|
||||
name="nsec"
|
||||
type="text"
|
||||
value={displayNsec(key, 36)}
|
||||
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyKey()}
|
||||
className="inline-flex h-9 w-24 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-900 dark:hover:bg-neutral-700"
|
||||
>
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<label
|
||||
htmlFor="passphase"
|
||||
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
||||
>
|
||||
Set a passphase to secure your key
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
name="passphase"
|
||||
type="password"
|
||||
value={passphase}
|
||||
onChange={(e) => setPassphase(e.target.value)}
|
||||
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => encrypt()}
|
||||
className="inline-flex h-9 w-24 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-900 dark:hover:bg-neutral-700"
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyKey()}
|
||||
className="inline-flex items-center justify-center h-8 text-sm font-medium rounded-md w-36 bg-neutral-200 hover:bg-neutral-300 dark:bg-white/10 dark:hover:bg-white/20"
|
||||
>
|
||||
{copied ? "Copied" : "Copy Private Key"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -48,13 +48,6 @@ function Screen() {
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleZap = () => {
|
||||
setNewSettings((prev) => ({
|
||||
...prev,
|
||||
zap: !newSettings.zap,
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleNsfw = () => {
|
||||
setNewSettings((prev) => ({
|
||||
...prev,
|
||||
@@ -84,14 +77,14 @@ function Screen() {
|
||||
}, [newSettings]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-xl">
|
||||
<div className="w-full max-w-xl mx-auto">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="font-semibold text-sm text-neutral-700 dark:text-neutral-300">
|
||||
<h2 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
|
||||
General
|
||||
</h2>
|
||||
<div className="flex flex-col divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl px-3">
|
||||
<div className="flex w-full items-start justify-between gap-4 py-3">
|
||||
<div className="flex flex-col px-3 divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl">
|
||||
<div className="flex items-start justify-between w-full gap-4 py-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium">Notification</h3>
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
@@ -99,7 +92,7 @@ function Screen() {
|
||||
notifications from Lume directly.
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-36 flex justify-end shrink-0">
|
||||
<div className="flex justify-end w-36 shrink-0">
|
||||
<Switch.Root
|
||||
checked={newSettings.notification}
|
||||
onClick={() => toggleNofitication()}
|
||||
@@ -109,7 +102,7 @@ function Screen() {
|
||||
</Switch.Root>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full items-start justify-between gap-4 py-3">
|
||||
<div className="flex items-start justify-between w-full gap-4 py-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium">Relay Hint</h3>
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
@@ -117,7 +110,7 @@ function Screen() {
|
||||
Relay Hint when fetching a new event.
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-36 flex justify-end shrink-0">
|
||||
<div className="flex justify-end w-36 shrink-0">
|
||||
<Switch.Root
|
||||
checked={newSettings.gossip}
|
||||
onClick={() => toggleGossip()}
|
||||
@@ -127,7 +120,7 @@ function Screen() {
|
||||
</Switch.Root>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full items-start justify-between gap-4 py-3">
|
||||
<div className="flex items-start justify-between w-full gap-4 py-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium">Enhanced Privacy</h3>
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
@@ -135,7 +128,7 @@ function Screen() {
|
||||
previews in plain text.
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-36 flex justify-end shrink-0">
|
||||
<div className="flex justify-end w-36 shrink-0">
|
||||
<Switch.Root
|
||||
checked={newSettings.enhancedPrivacy}
|
||||
onClick={() => toggleEnhancedPrivacy()}
|
||||
@@ -145,14 +138,14 @@ function Screen() {
|
||||
</Switch.Root>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full items-start justify-between gap-4 py-3">
|
||||
<div className="flex items-start justify-between w-full gap-4 py-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium">Auto Update</h3>
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
Automatically download and install new version.
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-36 flex justify-end shrink-0">
|
||||
<div className="flex justify-end w-36 shrink-0">
|
||||
<Switch.Root
|
||||
checked={newSettings.autoUpdate}
|
||||
onClick={() => toggleAutoUpdate()}
|
||||
@@ -162,7 +155,7 @@ function Screen() {
|
||||
</Switch.Root>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full items-start justify-between gap-4 py-3">
|
||||
<div className="flex items-start justify-between w-full gap-4 py-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold">Filter sensitive content</h3>
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
@@ -170,7 +163,7 @@ function Screen() {
|
||||
Warning tag, it's may include NSFW content.
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-36 flex justify-end shrink-0">
|
||||
<div className="flex justify-end w-36 shrink-0">
|
||||
<Switch.Root
|
||||
checked={newSettings.nsfw}
|
||||
onClick={() => toggleNsfw()}
|
||||
@@ -183,39 +176,21 @@ function Screen() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="font-semibold text-sm text-neutral-700 dark:text-neutral-300">
|
||||
<h2 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
|
||||
Interface
|
||||
</h2>
|
||||
<div className="flex flex-col divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl px-3">
|
||||
<div className="flex w-full items-start justify-between gap-4 py-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold">Zap</h3>
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
Show the Zap button in each note and user's profile screen,
|
||||
use for send bitcoin tip to other users.
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-36 flex justify-end shrink-0">
|
||||
<Switch.Root
|
||||
checked={newSettings.zap}
|
||||
onClick={() => toggleZap()}
|
||||
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
|
||||
>
|
||||
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||
</Switch.Root>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full items-start justify-between gap-4 py-3">
|
||||
<div className="flex flex-col px-3 divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl">
|
||||
<div className="flex items-start justify-between w-full gap-4 py-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold">Appearance</h3>
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
* Require restarting the app to take effect.
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-36 flex justify-end shrink-0">
|
||||
<div className="flex justify-end w-36 shrink-0">
|
||||
<select
|
||||
name="theme"
|
||||
className="bg-transparent shadow-none outline-none rounded-lg border-1 border-black/10 dark:border-white/10 py-1 w-24"
|
||||
className="w-24 py-1 bg-transparent rounded-lg shadow-none outline-none border-1 border-black/10 dark:border-white/10"
|
||||
defaultValue={settings.theme}
|
||||
onChange={(e) => changeTheme(e.target.value)}
|
||||
>
|
||||
|
||||
@@ -2,17 +2,18 @@ import { Conversation } from "@/components/conversation";
|
||||
import { Quote } from "@/components/quote";
|
||||
import { RepostNote } from "@/components/repost";
|
||||
import { TextNote } from "@/components/text";
|
||||
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
|
||||
import { ArrowRightCircleIcon } from "@lume/icons";
|
||||
import { NostrQuery } from "@lume/system";
|
||||
import {
|
||||
type ColumnRouteSearch,
|
||||
type NostrEvent,
|
||||
type Topic,
|
||||
Kind,
|
||||
Topic,
|
||||
} from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { Link, createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { useCallback } from "react";
|
||||
import { Virtualizer } from "virtua";
|
||||
|
||||
export const Route = createFileRoute("/topic")({
|
||||
@@ -26,7 +27,6 @@ export const Route = createFileRoute("/topic")({
|
||||
beforeLoad: async ({ search }) => {
|
||||
const key = `lume_topic_${search.label}`;
|
||||
const topics = (await NostrQuery.getNstore(key)) as unknown as Topic[];
|
||||
const settings = await NostrQuery.getSettings();
|
||||
|
||||
if (!topics?.length) {
|
||||
throw redirect({
|
||||
@@ -38,16 +38,13 @@ export const Route = createFileRoute("/topic")({
|
||||
});
|
||||
}
|
||||
|
||||
let hashtags: string[] = [];
|
||||
const hashtags: string[] = [];
|
||||
|
||||
for (const topic of topics) {
|
||||
hashtags.push(...topic.content);
|
||||
}
|
||||
|
||||
return {
|
||||
hashtags,
|
||||
settings,
|
||||
};
|
||||
return { hashtags };
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
@@ -74,34 +71,39 @@ export function Screen() {
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const renderItem = (event: NostrEvent) => {
|
||||
if (!event) return;
|
||||
switch (event.kind) {
|
||||
case Kind.Repost:
|
||||
return <RepostNote key={event.id} event={event} />;
|
||||
default: {
|
||||
const isConversation =
|
||||
event.tags.filter((tag) => tag[0] === "e" && tag[3] !== "mention")
|
||||
.length > 0;
|
||||
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0;
|
||||
const renderItem = useCallback(
|
||||
(event: NostrEvent) => {
|
||||
if (!event) return;
|
||||
switch (event.kind) {
|
||||
case Kind.Repost:
|
||||
return <RepostNote key={event.id} event={event} />;
|
||||
default: {
|
||||
const isConversation =
|
||||
event.tags.filter((tag) => tag[0] === "e" && tag[3] !== "mention")
|
||||
.length > 0;
|
||||
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0;
|
||||
|
||||
if (isConversation) {
|
||||
return <Conversation key={event.id} event={event} className="mb-3" />;
|
||||
if (isConversation) {
|
||||
return (
|
||||
<Conversation key={event.id} event={event} className="mb-3" />
|
||||
);
|
||||
}
|
||||
|
||||
if (isQuote) {
|
||||
return <Quote key={event.id} event={event} className="mb-3" />;
|
||||
}
|
||||
|
||||
return <TextNote key={event.id} event={event} className="mb-3" />;
|
||||
}
|
||||
|
||||
if (isQuote) {
|
||||
return <Quote key={event.id} event={event} className="mb-3" />;
|
||||
}
|
||||
|
||||
return <TextNote key={event.id} event={event} className="mb-3" />;
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
[data],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none">
|
||||
<div className="w-full h-full p-2 overflow-y-auto scrollbar-none">
|
||||
{isFetching && !isLoading && !isFetchingNextPage ? (
|
||||
<div className="mb-3 w-full h-11 flex items-center justify-center bg-black/10 dark:bg-white/10 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50">
|
||||
<div className="flex items-center justify-center w-full mb-3 h-11 bg-black/10 dark:bg-white/10 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Spinner className="size-5" />
|
||||
<span className="text-sm font-medium">Fetching new notes...</span>
|
||||
@@ -109,7 +111,7 @@ export function Screen() {
|
||||
</div>
|
||||
) : null}
|
||||
{isLoading ? (
|
||||
<div className="flex h-16 w-full items-center justify-center gap-2">
|
||||
<div className="flex items-center justify-center w-full h-16 gap-2">
|
||||
<Spinner className="size-5" />
|
||||
<span className="text-sm font-medium">Loading...</span>
|
||||
</div>
|
||||
@@ -126,7 +128,7 @@ export function Screen() {
|
||||
type="button"
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={isFetchingNextPage || isLoading}
|
||||
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-neutral-100 px-3 font-medium hover:bg-neutral-50 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
|
||||
className="inline-flex items-center justify-center w-full h-12 gap-2 px-3 font-medium rounded-xl bg-neutral-100 hover:bg-neutral-50 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<Spinner className="size-5" />
|
||||
@@ -145,31 +147,12 @@ export function Screen() {
|
||||
|
||||
function Empty() {
|
||||
return (
|
||||
<div className="flex flex-col py-10 gap-10">
|
||||
<div className="text-center flex flex-col items-center justify-center">
|
||||
<div className="size-24 bg-blue-100 flex flex-col items-center justify-end overflow-hidden dark:bg-blue-900 rounded-full mb-8">
|
||||
<div className="w-12 h-16 bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900 rounded-t-lg" />
|
||||
<div className="flex flex-col gap-10 py-10">
|
||||
<div className="flex flex-col items-center justify-center text-center">
|
||||
<div className="flex flex-col items-center justify-end mb-8 overflow-hidden bg-blue-100 rounded-full size-24 dark:bg-blue-900">
|
||||
<div className="w-12 h-16 rounded-t-lg bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900" />
|
||||
</div>
|
||||
<p className="text-lg font-medium">Your newsfeed is empty</p>
|
||||
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
|
||||
Here are few suggestions to get started.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col px-3 gap-2">
|
||||
<Link
|
||||
to="/trending/notes"
|
||||
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
|
||||
>
|
||||
<ArrowRightIcon className="size-5" />
|
||||
Show trending notes
|
||||
</Link>
|
||||
<Link
|
||||
to="/trending/users"
|
||||
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
|
||||
>
|
||||
<ArrowRightIcon className="size-5" />
|
||||
Discover trending users
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user