Release v4.1 (#229)
* refactor: remove custom icon packs * fix: command not work on windows * fix: make open_window command async * feat: improve commands * feat: improve * refactor: column * feat: improve thread column * feat: improve * feat: add stories column * feat: improve * feat: add search column * feat: add reset password * feat: add subscription * refactor: settings * chore: improve commands * fix: crash on production * feat: use tauri store plugin for cache * feat: new icon * chore: update icon for windows * chore: improve some columns * chore: polish code
This commit is contained in:
185
src/routes/columns/_layout/create-group.lazy.tsx
Normal file
185
src/routes/columns/_layout/create-group.lazy.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import { commands } from "@/commands.gen";
|
||||
import { Spinner } from "@/components";
|
||||
import { User } from "@/components/user";
|
||||
import { Plus, X } from "@phosphor-icons/react";
|
||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||
import { message } from "@tauri-apps/plugin-dialog";
|
||||
import { useState, useTransition } from "react";
|
||||
|
||||
export const Route = createLazyFileRoute("/columns/_layout/create-group")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
const REYA_NPUB =
|
||||
"npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445";
|
||||
|
||||
function Screen() {
|
||||
const contacts = Route.useLoaderData();
|
||||
const search = Route.useSearch();
|
||||
const navigate = Route.useNavigate();
|
||||
const { queryClient } = Route.useRouteContext();
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const [npub, setNpub] = useState("");
|
||||
const [users, setUsers] = useState<string[]>([REYA_NPUB]);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const toggleUser = (pubkey: string) => {
|
||||
setUsers((prev) =>
|
||||
prev.includes(pubkey)
|
||||
? prev.filter((i) => i !== pubkey)
|
||||
: [...prev, pubkey],
|
||||
);
|
||||
};
|
||||
|
||||
const addUser = () => {
|
||||
if (!npub.startsWith("npub1")) return;
|
||||
if (users.includes(npub)) return;
|
||||
|
||||
setUsers((prev) => [...prev, npub]);
|
||||
setNpub("");
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
startTransition(async () => {
|
||||
const key = `lume_v4:group:${search.label}`;
|
||||
const res = await commands.setLumeStore(key, JSON.stringify(users));
|
||||
|
||||
if (res.status === "ok") {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: [search.label, search.account],
|
||||
});
|
||||
navigate({ to: search.redirect, search: { ...search, name: title } });
|
||||
} else {
|
||||
await message(res.error, {
|
||||
title: "Create Group",
|
||||
kind: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-4">
|
||||
<div className="flex flex-col items-center justify-center text-center">
|
||||
<h1 className="font-serif text-2xl font-medium">Create a group</h1>
|
||||
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
|
||||
For the people that you want to keep up.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col w-4/5 max-w-full gap-3">
|
||||
<div className="flex items-center w-full rounded-lg h-9 shrink-0 bg-black/5 dark:bg-white/5">
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="w-16 text-sm font-semibold text-center border-r border-black/10 dark:border-white/10 shrink-0"
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
name="name"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Enter a name for this group"
|
||||
className="h-full px-3 text-sm bg-transparent border-none placeholder:text-neutral-600 focus:border-neutral-500 focus:ring-0 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col items-center w-full gap-3">
|
||||
<div className="overflow-y-auto scrollbar-none p-2 w-full h-[450px] flex flex-col gap-3 bg-black/5 dark:bg-white/5 rounded-xl">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
name="npub"
|
||||
value={npub}
|
||||
onChange={(e) => setNpub(e.target.value)}
|
||||
placeholder="npub1..."
|
||||
className="w-full px-3 text-sm border-none rounded-lg h-9 bg-black/10 dark:bg-white/10 placeholder:text-neutral-600 focus:border-neutral-500 focus:ring-0 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addUser()}
|
||||
className="inline-flex items-center justify-center text-white rounded-lg size-9 bg-black/20 dark:bg-white/20 shrink-0 hover:bg-blue-500"
|
||||
>
|
||||
<Plus className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-semibold">Added</span>
|
||||
<div className="flex flex-col gap-2">
|
||||
{users.length ? (
|
||||
users.map((item: string) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
onClick={() => toggleUser(item)}
|
||||
className="inline-flex items-center justify-between px-3 py-2 bg-white rounded-lg dark:bg-black/20 shadow-primary dark:ring-1 ring-neutral-800/50"
|
||||
>
|
||||
<User.Provider pubkey={item}>
|
||||
<User.Root className="flex items-center gap-2.5">
|
||||
<User.Avatar className="rounded-full size-8" />
|
||||
<div className="flex items-center gap-1">
|
||||
<User.Name className="text-sm font-medium" />
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="flex items-center justify-center text-sm rounded-lg bg-black/5 dark:bg-white/5 h-14">
|
||||
Empty.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-semibold">Contacts</span>
|
||||
<div className="flex flex-col gap-2">
|
||||
{contacts.length ? (
|
||||
contacts.map((item: string) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
onClick={() => toggleUser(item)}
|
||||
className="inline-flex items-center justify-between px-3 py-2 bg-white rounded-lg dark:bg-black/20 shadow-primary dark:ring-1 ring-neutral-800/50"
|
||||
>
|
||||
<User.Provider pubkey={item}>
|
||||
<User.Root className="flex items-center gap-2.5">
|
||||
<User.Avatar className="rounded-full size-8" />
|
||||
<div className="flex items-center gap-1">
|
||||
<User.Name className="text-sm font-medium" />
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="flex items-center justify-center text-sm rounded-lg bg-black/5 dark:bg-white/5 h-14">
|
||||
<p>
|
||||
Find more user at{" "}
|
||||
<a
|
||||
href="https://www.nostr.directory/"
|
||||
target="_blank"
|
||||
className="text-blue-600 after:content-['_↗']"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Nostr Directory
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
disabled={isPending || users.length < 1}
|
||||
className="inline-flex items-center justify-center text-sm font-medium text-white bg-blue-500 rounded-full w-36 h-9 hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{isPending ? <Spinner /> : "Confirm"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
src/routes/columns/_layout/create-group.tsx
Normal file
9
src/routes/columns/_layout/create-group.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { NostrAccount } from "@/system";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/columns/_layout/create-group")({
|
||||
loader: async () => {
|
||||
const contacts = await NostrAccount.getContactList();
|
||||
return contacts;
|
||||
},
|
||||
});
|
||||
87
src/routes/columns/_layout/create-newsfeed.f2f.tsx
Normal file
87
src/routes/columns/_layout/create-newsfeed.f2f.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { commands } from "@/commands.gen";
|
||||
import { Spinner } from "@/components";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { message } from "@tauri-apps/plugin-dialog";
|
||||
import { useState, useTransition } from "react";
|
||||
|
||||
export const Route = createFileRoute("/columns/_layout/create-newsfeed/f2f")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { queryClient } = Route.useRouteContext();
|
||||
const { redirect, label, account } = Route.useSearch();
|
||||
|
||||
const [npub, setNpub] = useState("");
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const navigate = Route.useNavigate();
|
||||
|
||||
const submit = async () => {
|
||||
startTransition(async () => {
|
||||
if (!npub.startsWith("npub1")) {
|
||||
await message("You must enter a valid npub.", {
|
||||
title: "Create Newsfeed",
|
||||
kind: "info",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await commands.copyFriend(npub);
|
||||
|
||||
if (res.status === "ok") {
|
||||
await queryClient.invalidateQueries({ queryKey: [label, account] });
|
||||
navigate({ to: redirect });
|
||||
} else {
|
||||
await message(res.error, {
|
||||
title: "Create Newsfeed",
|
||||
kind: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-y-auto scrollbar-none p-2 shrink-0 h-[450px] bg-white dark:bg-white/20 rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50">
|
||||
<div className="flex flex-col justify-between h-full">
|
||||
<div className="flex-1 flex flex-col gap-1.5 justify-center px-5">
|
||||
<p className="font-semibold text-neutral-500">
|
||||
You already have a friend on Nostr?
|
||||
</p>
|
||||
<p>Instead of building the timeline by yourself.</p>
|
||||
<p className="font-semibold text-neutral-500">
|
||||
Just enter your friend's{" "}
|
||||
<span className="text-blue-500">npub.</span>
|
||||
</p>
|
||||
<p>
|
||||
You will have the same experience as your friend. Of course, you
|
||||
always can edit your network later.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="npub" className="text-sm font-medium">
|
||||
NPUB
|
||||
</label>
|
||||
<input
|
||||
name="npub"
|
||||
placeholder="npub1..."
|
||||
value={npub}
|
||||
onChange={(e) => setNpub(e.target.value)}
|
||||
spellCheck={false}
|
||||
className="px-3 bg-transparent border rounded-lg h-11 border-neutral-200 dark:border-neutral-800 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
className="inline-flex items-center justify-center w-full text-sm font-medium text-white bg-blue-500 rounded-lg h-9 hover:bg-blue-600"
|
||||
>
|
||||
{isPending ? <Spinner /> : "Confirm"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
src/routes/columns/_layout/create-newsfeed.tsx
Normal file
71
src/routes/columns/_layout/create-newsfeed.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { cn } from "@/commons";
|
||||
import type { ColumnRouteSearch } from "@/types";
|
||||
import { Link, Outlet } from "@tanstack/react-router";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/columns/_layout/create-newsfeed")({
|
||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||
return {
|
||||
account: search.account,
|
||||
label: search.label,
|
||||
name: search.name,
|
||||
};
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const search = Route.useSearch();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-4">
|
||||
<div className="flex flex-col items-center justify-center text-center">
|
||||
<h1 className="font-serif text-2xl font-medium">
|
||||
Build up your timeline.
|
||||
</h1>
|
||||
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
|
||||
Follow some people to keep up to date with them.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col w-4/5 max-w-full gap-3">
|
||||
<div className="w-full h-9 shrink-0 flex items-center justify-between bg-black/5 dark:bg-white/5 rounded-lg px-0.5">
|
||||
<Link
|
||||
to="/columns/create-newsfeed/users"
|
||||
search={search}
|
||||
className="flex-1 h-8"
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"text-sm font-medium rounded-md h-full flex items-center justify-center",
|
||||
isActive
|
||||
? "bg-white dark:bg-white/20 shadow"
|
||||
: "bg-transparent",
|
||||
)}
|
||||
>
|
||||
Users
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
<Link
|
||||
to="/columns/create-newsfeed/f2f"
|
||||
search={search}
|
||||
className="flex-1 h-8"
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md h-full flex items-center justify-center",
|
||||
isActive ? "bg-white dark:bg-white/20" : "bg-transparent",
|
||||
)}
|
||||
>
|
||||
CopyFriend
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
141
src/routes/columns/_layout/create-newsfeed.users.tsx
Normal file
141
src/routes/columns/_layout/create-newsfeed.users.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { commands } from "@/commands.gen";
|
||||
import { Spinner } from "@/components";
|
||||
import { User } from "@/components/user";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { message } from "@tauri-apps/plugin-dialog";
|
||||
import { fetch } from "@tauri-apps/plugin-http";
|
||||
import { useRef, useState, useTransition } from "react";
|
||||
import { Virtualizer } from "virtua";
|
||||
|
||||
export const Route = createFileRoute("/columns/_layout/create-newsfeed/users")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
interface Trending {
|
||||
profiles: Array<{ pubkey: string }>;
|
||||
}
|
||||
|
||||
function Screen() {
|
||||
const { redirect, label, account } = Route.useSearch();
|
||||
const { queryClient } = Route.useRouteContext();
|
||||
const { isLoading, isError, data } = useQuery({
|
||||
queryKey: ["trending-users"],
|
||||
queryFn: async ({ signal }) => {
|
||||
const res = await fetch("https://api.nostr.band/v0/trending/profiles", {
|
||||
signal,
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error("Error.");
|
||||
}
|
||||
|
||||
const data: Trending = await res.json();
|
||||
const users = data.profiles.map((item) => item.pubkey);
|
||||
|
||||
return users;
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const navigate = Route.useNavigate();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [follows, setFollows] = useState<string[]>([]);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const toggleFollow = (pubkey: string) => {
|
||||
setFollows((prev) =>
|
||||
prev.includes(pubkey)
|
||||
? prev.filter((i) => i !== pubkey)
|
||||
: [...prev, pubkey],
|
||||
);
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
startTransition(async () => {
|
||||
const res = await commands.setContactList(follows);
|
||||
|
||||
if (res.status === "ok") {
|
||||
await queryClient.invalidateQueries({ queryKey: [label, account] });
|
||||
navigate({ to: redirect });
|
||||
} else {
|
||||
await message(res.error, { kind: "error" });
|
||||
return;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center w-full gap-3">
|
||||
<ScrollArea.Root
|
||||
type={"scroll"}
|
||||
scrollHideDelay={300}
|
||||
className="w-full h-[408px] bg-black/5 dark:bg-white/5 rounded-xl"
|
||||
>
|
||||
<ScrollArea.Viewport ref={ref} className="relative h-full p-2">
|
||||
<Virtualizer scrollRef={ref}>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col items-center justify-center w-full h-20 gap-1">
|
||||
<div className="inline-flex items-center gap-2 text-sm font-medium">
|
||||
<Spinner className="size-5" />
|
||||
Loading...
|
||||
</div>
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="flex flex-col items-center justify-center w-full h-20 gap-1">
|
||||
<div className="inline-flex items-center gap-2 text-sm font-medium">
|
||||
Error.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
data?.map((item) => (
|
||||
<div
|
||||
key={item}
|
||||
className="w-full p-2 mb-2 overflow-hidden bg-white rounded-lg h-max dark:bg-black/20shadow-primary dark:ring-1 ring-neutral-800/50"
|
||||
>
|
||||
<User.Provider pubkey={item}>
|
||||
<User.Root>
|
||||
<div className="flex flex-col w-full h-full gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<User.Avatar className="rounded-full size-7" />
|
||||
<User.Name className="text-sm leadning-tight max-w-[15rem] truncate font-semibold" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleFollow(item)}
|
||||
className="inline-flex items-center justify-center w-20 text-sm font-medium rounded-lg h-7 bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
|
||||
>
|
||||
{follows.includes(item) ? "Unfollow" : "Follow"}
|
||||
</button>
|
||||
</div>
|
||||
<User.About className="select-text line-clamp-3 text-neutral-800 dark:text-neutral-400" />
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</Virtualizer>
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
|
||||
orientation="vertical"
|
||||
>
|
||||
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
|
||||
</ScrollArea.Scrollbar>
|
||||
<ScrollArea.Corner className="bg-transparent" />
|
||||
</ScrollArea.Root>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
disabled={isPending || follows.length < 1}
|
||||
className="inline-flex items-center justify-center text-sm font-medium text-white bg-blue-500 rounded-full w-36 h-9 hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{isPending ? <Spinner /> : "Confirm"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
250
src/routes/columns/_layout/events.$id.lazy.tsx
Normal file
250
src/routes/columns/_layout/events.$id.lazy.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import { events, commands } from "@/commands.gen";
|
||||
import { Note, ReplyNote, Spinner } from "@/components";
|
||||
import { LumeEvent, useEvent } from "@/system";
|
||||
import type { EventPayload } from "@/types";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Virtualizer } from "virtua";
|
||||
|
||||
export const Route = createLazyFileRoute("/columns/_layout/events/$id")({
|
||||
component: Screen,
|
||||
pendingComponent: Pending,
|
||||
});
|
||||
|
||||
function Pending() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-screen h-screen">
|
||||
<Spinner className="size-5" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Screen() {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<ScrollArea.Root
|
||||
type={"scroll"}
|
||||
scrollHideDelay={300}
|
||||
className="overflow-hidden size-full flex-1"
|
||||
>
|
||||
<ScrollArea.Viewport ref={ref} className="h-full pt-1 px-3 pb-3">
|
||||
<Virtualizer scrollRef={ref}>
|
||||
<RootEvent />
|
||||
<ReplyList />
|
||||
</Virtualizer>
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
|
||||
orientation="vertical"
|
||||
>
|
||||
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
|
||||
</ScrollArea.Scrollbar>
|
||||
<ScrollArea.Corner className="bg-transparent" />
|
||||
</ScrollArea.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function RootEvent() {
|
||||
const { id } = Route.useParams();
|
||||
const { data: event, error, isLoading, isError } = useEvent(id);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-white flex items-center justify-center h-32 dark:bg-black/10 rounded-xl shadow-primary dark:ring-1 dark:ring-white/5">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Spinner />
|
||||
Loading...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="bg-white flex items-center justify-center h-32 dark:bg-black/10 rounded-xl shadow-primary dark:ring-1 dark:ring-white/5">
|
||||
<div className="flex items-center gap-2 text-sm text-red-500">
|
||||
{error.message}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Note.Provider event={event}>
|
||||
<Note.Root className="bg-white dark:bg-white/10 rounded-xl shadow-primary dark:shadow-none">
|
||||
<div className="flex items-center justify-between px-3 h-14">
|
||||
<Note.User />
|
||||
<Note.Menu />
|
||||
</div>
|
||||
<Note.ContentLarge className="px-3" />
|
||||
<div className="flex items-center gap-2 px-3 mt-6 h-12 rounded-b-xl bg-neutral-50 dark:bg-white/5">
|
||||
<Note.Reply large />
|
||||
<Note.Repost large />
|
||||
<Note.Zap large />
|
||||
</div>
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function ReplyList() {
|
||||
const { label } = Route.useSearch();
|
||||
const { id } = Route.useParams();
|
||||
const { queryClient } = Route.useRouteContext();
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["reply", id],
|
||||
queryFn: async () => {
|
||||
const res = await commands.getReplies(id);
|
||||
|
||||
if (res.status === "ok") {
|
||||
const events = res.data
|
||||
// Create Lume Events
|
||||
.map((item) => LumeEvent.from(item.raw, item.parsed))
|
||||
// Filter quote
|
||||
.filter(
|
||||
(ev) =>
|
||||
!ev.tags.filter((t) => t[0] === "q" || t[3] === "mention").length,
|
||||
);
|
||||
|
||||
return events;
|
||||
} else {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
},
|
||||
select: (events) => {
|
||||
const removeQueues = new Set();
|
||||
|
||||
for (const event of events) {
|
||||
const tags = event.tags.filter((t) => t[0] === "e" && t[1] !== id);
|
||||
|
||||
if (tags.length === 1) {
|
||||
const index = events.findIndex((ev) => ev.id === tags[0][1]);
|
||||
|
||||
if (index !== -1) {
|
||||
const rootEvent = events[index];
|
||||
|
||||
if (rootEvent.replies?.length) {
|
||||
rootEvent.replies.push(event);
|
||||
} else {
|
||||
rootEvent.replies = [event];
|
||||
}
|
||||
|
||||
// Add current event to queue
|
||||
removeQueues.add(event.id);
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
for (const tag of tags) {
|
||||
const id = tag[1];
|
||||
const rootIndex = events.findIndex((ev) => ev.id === id);
|
||||
|
||||
if (rootIndex !== -1) {
|
||||
const rootEvent = events[rootIndex];
|
||||
|
||||
if (rootEvent.replies?.length) {
|
||||
const childIndex = rootEvent.replies.findIndex(
|
||||
(ev) => ev.id === id,
|
||||
);
|
||||
|
||||
if (childIndex !== -1) {
|
||||
const childEvent = rootEvent.replies[rootIndex];
|
||||
|
||||
if (childEvent.replies?.length) {
|
||||
childEvent.replies.push(event);
|
||||
} else {
|
||||
childEvent.replies = [event];
|
||||
}
|
||||
|
||||
// Add current event to queue
|
||||
removeQueues.add(event.id);
|
||||
}
|
||||
} else {
|
||||
rootEvent.replies = [event];
|
||||
// Add current event to queue
|
||||
removeQueues.add(event.id);
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return events.filter((ev) => !removeQueues.has(ev.id));
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
events.subscription
|
||||
.emit({ label, kind: "Subscribe", event_id: id, local_only: undefined })
|
||||
.then(() => console.log("Subscribe: ", label));
|
||||
|
||||
return () => {
|
||||
events.subscription
|
||||
.emit({
|
||||
label,
|
||||
kind: "Unsubscribe",
|
||||
event_id: id,
|
||||
local_only: undefined,
|
||||
})
|
||||
.then(() => console.log("Unsubscribe: ", label));
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const unlisten = getCurrentWindow().listen<EventPayload>(
|
||||
"event",
|
||||
async (data) => {
|
||||
const event = LumeEvent.from(data.payload.raw, data.payload.parsed);
|
||||
await queryClient.setQueryData(
|
||||
["reply", id],
|
||||
(prevEvents: LumeEvent[]) => {
|
||||
if (!prevEvents) return [event];
|
||||
return [event, ...prevEvents];
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
unlisten.then((f) => f());
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center text-sm font-semibold h-14 text-neutral-600 dark:text-white/30">
|
||||
All replies
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center w-full mb-3 h-12 bg-black/5 dark:bg-white/5 rounded-xl">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Spinner className="size-5" />
|
||||
<span className="text-sm font-medium">Getting replies...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{!data.length ? (
|
||||
<div className="flex items-center justify-center w-full">
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-4">
|
||||
<h3 className="text-3xl">👋</h3>
|
||||
<p className="leading-none text-neutral-600 dark:text-neutral-400">
|
||||
Be the first to Reply!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
data.map((event) => <ReplyNote key={event.id} event={event} />)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
src/routes/columns/_layout/gallery.lazy.tsx
Normal file
75
src/routes/columns/_layout/gallery.lazy.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { LumeColumn } from "@/types";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import Avatar from "boring-avatars";
|
||||
|
||||
export const Route = createLazyFileRoute("/columns/_layout/gallery")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { columns } = Route.useRouteContext();
|
||||
|
||||
const install = async (column: LumeColumn) => {
|
||||
const mainWindow = getCurrentWindow();
|
||||
await mainWindow.emit("columns", { type: "add", column });
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea.Root
|
||||
type={"scroll"}
|
||||
scrollHideDelay={300}
|
||||
className="overflow-hidden size-full"
|
||||
>
|
||||
<ScrollArea.Viewport className="relative h-full px-3">
|
||||
{columns.map((column) => (
|
||||
<div
|
||||
key={column.label}
|
||||
className="mb-3 group flex px-2 items-center justify-between h-16 rounded-xl bg-white dark:bg-white/20 shadow-sm shadow-neutral-500/10"
|
||||
>
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<div className="size-11 bg-neutral-200 rounded-lg overflow-hidden">
|
||||
<Avatar
|
||||
name={column.name}
|
||||
size={44}
|
||||
square={true}
|
||||
variant="pixel"
|
||||
colors={[
|
||||
"#84cc16",
|
||||
"#22c55e",
|
||||
"#0ea5e9",
|
||||
"#3b82f6",
|
||||
"#6366f1",
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<div className="mb-px leading-tight font-semibold">
|
||||
{column.name}
|
||||
</div>
|
||||
<div className="leading-tight text-neutral-500 dark:text-neutral-400">
|
||||
{column.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => install(column)}
|
||||
className="text-xs uppercase font-semibold w-max h-7 pl-2.5 pr-2 hidden group-hover:inline-flex items-center justify-center rounded-full bg-neutral-200 hover:bg-blue-500 hover:text-white dark:bg-black/10"
|
||||
>
|
||||
Install
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
|
||||
orientation="vertical"
|
||||
>
|
||||
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
|
||||
</ScrollArea.Scrollbar>
|
||||
<ScrollArea.Corner className="bg-transparent" />
|
||||
</ScrollArea.Root>
|
||||
);
|
||||
}
|
||||
19
src/routes/columns/_layout/gallery.tsx
Normal file
19
src/routes/columns/_layout/gallery.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { LumeColumn } from "@/types";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { resolveResource } from "@tauri-apps/api/path";
|
||||
import { readTextFile } from "@tauri-apps/plugin-fs";
|
||||
|
||||
export const Route = createFileRoute("/columns/_layout/gallery")({
|
||||
beforeLoad: async () => {
|
||||
const systemPath = "resources/columns.json";
|
||||
const resourcePath = await resolveResource(systemPath);
|
||||
const resourceFile = await readTextFile(resourcePath);
|
||||
|
||||
const systemColumns: LumeColumn[] = JSON.parse(resourceFile);
|
||||
const columns = systemColumns.filter((col) => !col.default);
|
||||
|
||||
return {
|
||||
columns,
|
||||
};
|
||||
},
|
||||
});
|
||||
135
src/routes/columns/_layout/global.tsx
Normal file
135
src/routes/columns/_layout/global.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { Spinner } from "@/components";
|
||||
import { Conversation } from "@/components/conversation";
|
||||
import { Quote } from "@/components/quote";
|
||||
import { RepostNote } from "@/components/repost";
|
||||
import { TextNote } from "@/components/text";
|
||||
import { type LumeEvent, NostrQuery } from "@/system";
|
||||
import { type ColumnRouteSearch, Kind } from "@/types";
|
||||
import { ArrowCircleRight } from "@phosphor-icons/react";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { Virtualizer } from "virtua";
|
||||
|
||||
export const Route = createFileRoute("/columns/_layout/global")({
|
||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||
return {
|
||||
account: search.account,
|
||||
label: search.label,
|
||||
name: search.name,
|
||||
};
|
||||
},
|
||||
beforeLoad: async () => {
|
||||
const settings = await NostrQuery.getUserSettings();
|
||||
return { settings };
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
export function Screen() {
|
||||
const { label, account } = Route.useSearch();
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: [label, account],
|
||||
initialPageParam: 0,
|
||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||
const events = await NostrQuery.getGlobalEvents(pageParam);
|
||||
return events;
|
||||
},
|
||||
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
|
||||
select: (data) => data?.pages.flat(),
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
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" />;
|
||||
}
|
||||
}
|
||||
},
|
||||
[data],
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollArea.Root
|
||||
type={"scroll"}
|
||||
scrollHideDelay={300}
|
||||
className="overflow-hidden size-full"
|
||||
>
|
||||
<ScrollArea.Viewport ref={ref} className="h-full px-3 pb-3">
|
||||
<Virtualizer scrollRef={ref}>
|
||||
{isFetching && !isLoading && !isFetchingNextPage ? (
|
||||
<div className="flex items-center justify-center w-full mb-3 h-12 bg-black/5 dark:bg-white/5 rounded-xl">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Spinner className="size-5" />
|
||||
<span className="text-sm font-medium">
|
||||
Getting new notes...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{isLoading ? (
|
||||
<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>
|
||||
) : !data.length ? (
|
||||
<div className="flex items-center justify-center">
|
||||
Yo. You're catching up on all the things happening around you.
|
||||
</div>
|
||||
) : (
|
||||
data.map((item) => renderItem(item))
|
||||
)}
|
||||
{data?.length && hasNextPage ? (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={isFetchingNextPage || isLoading}
|
||||
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" />
|
||||
) : (
|
||||
<>
|
||||
<ArrowCircleRight className="size-5" />
|
||||
Load more
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</Virtualizer>
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
|
||||
orientation="vertical"
|
||||
>
|
||||
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
|
||||
</ScrollArea.Scrollbar>
|
||||
<ScrollArea.Corner className="bg-transparent" />
|
||||
</ScrollArea.Root>
|
||||
);
|
||||
}
|
||||
123
src/routes/columns/_layout/group.lazy.tsx
Normal file
123
src/routes/columns/_layout/group.lazy.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { commands } from "@/commands.gen";
|
||||
import { toLumeEvents } from "@/commons";
|
||||
import { Quote, RepostNote, Spinner, TextNote } from "@/components";
|
||||
import type { LumeEvent } from "@/system";
|
||||
import { Kind } from "@/types";
|
||||
import { ArrowCircleRight } from "@phosphor-icons/react";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { Virtualizer } from "virtua";
|
||||
|
||||
export const Route = createLazyFileRoute("/columns/_layout/group")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
export function Screen() {
|
||||
const { label, account } = Route.useSearch();
|
||||
const { groups } = Route.useRouteContext();
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: [label, account],
|
||||
initialPageParam: 0,
|
||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||
const until = pageParam > 0 ? pageParam.toString() : undefined;
|
||||
const res = await commands.getGroupEvents(groups, until);
|
||||
|
||||
if (res.status === "error") {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
return toLumeEvents(res.data);
|
||||
},
|
||||
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
|
||||
select: (data) => data?.pages.flat(),
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
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.isQuote) {
|
||||
return <Quote key={event.id} event={event} className="mb-3" />;
|
||||
} else {
|
||||
return <TextNote key={event.id} event={event} className="mb-3" />;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[data],
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollArea.Root
|
||||
type={"scroll"}
|
||||
scrollHideDelay={300}
|
||||
className="overflow-hidden size-full"
|
||||
>
|
||||
<ScrollArea.Viewport ref={ref} className="h-full px-3 pb-3">
|
||||
<Virtualizer scrollRef={ref}>
|
||||
{isFetching && !isLoading && !isFetchingNextPage ? (
|
||||
<div className="z-50 fixed top-0 left-0 w-full h-14 flex items-center justify-center px-3">
|
||||
<div className="w-max h-8 pl-2 pr-3 inline-flex items-center justify-center gap-1.5 rounded-full shadow-lg text-sm font-medium text-white bg-black dark:text-black dark:bg-white">
|
||||
<Spinner className="size-4" />
|
||||
Getting new notes...
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{isLoading ? (
|
||||
<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>
|
||||
) : !data.length ? (
|
||||
<div className="mb-3 flex items-center justify-center h-20 text-sm rounded-xl bg-black/5 dark:bg-white/5">
|
||||
🎉 Yo. You're catching up on all latest notes.
|
||||
</div>
|
||||
) : (
|
||||
data.map((item) => renderItem(item))
|
||||
)}
|
||||
{hasNextPage ? (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={isFetchingNextPage || isLoading}
|
||||
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" />
|
||||
) : (
|
||||
<>
|
||||
<ArrowCircleRight className="size-5" />
|
||||
Load more
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</Virtualizer>
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
|
||||
orientation="vertical"
|
||||
>
|
||||
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
|
||||
</ScrollArea.Scrollbar>
|
||||
<ScrollArea.Corner className="bg-transparent" />
|
||||
</ScrollArea.Root>
|
||||
);
|
||||
}
|
||||
25
src/routes/columns/_layout/group.tsx
Normal file
25
src/routes/columns/_layout/group.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { commands } from "@/commands.gen";
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/columns/_layout/group")({
|
||||
beforeLoad: async ({ search }) => {
|
||||
const key = `lume_v4:group:${search.label}`;
|
||||
const res = await commands.getLumeStore(key);
|
||||
|
||||
if (res.status === "ok") {
|
||||
const groups: string[] = JSON.parse(res.data);
|
||||
|
||||
if (groups.length) {
|
||||
return { groups };
|
||||
}
|
||||
}
|
||||
|
||||
throw redirect({
|
||||
to: "/columns/create-group",
|
||||
search: {
|
||||
...search,
|
||||
redirect: "/columns/group",
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
124
src/routes/columns/_layout/hashtags.$content.lazy.tsx
Normal file
124
src/routes/columns/_layout/hashtags.$content.lazy.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { commands } from "@/commands.gen";
|
||||
import { toLumeEvents } from "@/commons";
|
||||
import { Quote, RepostNote, Spinner, TextNote } from "@/components";
|
||||
import type { LumeEvent } from "@/system";
|
||||
import { Kind } from "@/types";
|
||||
import { ArrowCircleRight } from "@phosphor-icons/react";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { Virtualizer } from "virtua";
|
||||
|
||||
export const Route = createLazyFileRoute("/columns/_layout/hashtags/$content")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
export function Screen() {
|
||||
const { label, account } = Route.useSearch();
|
||||
const { content } = Route.useParams();
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: [label, account],
|
||||
initialPageParam: 0,
|
||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||
const hashtags = content.split("_");
|
||||
const until = pageParam > 0 ? pageParam.toString() : undefined;
|
||||
const res = await commands.getHashtagEvents(hashtags, until);
|
||||
|
||||
if (res.status === "error") {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
return toLumeEvents(res.data);
|
||||
},
|
||||
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
|
||||
select: (data) => data?.pages.flat(),
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
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.isQuote) {
|
||||
return <Quote key={event.id} event={event} className="mb-3" />;
|
||||
} else {
|
||||
return <TextNote key={event.id} event={event} className="mb-3" />;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[data],
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollArea.Root
|
||||
type={"scroll"}
|
||||
scrollHideDelay={300}
|
||||
className="overflow-hidden size-full"
|
||||
>
|
||||
<ScrollArea.Viewport ref={ref} className="h-full px-3 pb-3">
|
||||
<Virtualizer scrollRef={ref}>
|
||||
{isFetching && !isLoading && !isFetchingNextPage ? (
|
||||
<div className="z-50 fixed top-0 left-0 w-full h-14 flex items-center justify-center px-3">
|
||||
<div className="w-max h-8 pl-2 pr-3 inline-flex items-center justify-center gap-1.5 rounded-full shadow-lg text-sm font-medium text-white bg-black dark:text-black dark:bg-white">
|
||||
<Spinner className="size-4" />
|
||||
Getting new notes...
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{isLoading ? (
|
||||
<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>
|
||||
) : !data.length ? (
|
||||
<div className="mb-3 flex items-center justify-center h-20 text-sm rounded-xl bg-black/5 dark:bg-white/5">
|
||||
🎉 Yo. You're catching up on all latest notes.
|
||||
</div>
|
||||
) : (
|
||||
data.map((item) => renderItem(item))
|
||||
)}
|
||||
{hasNextPage ? (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={isFetchingNextPage || isLoading}
|
||||
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" />
|
||||
) : (
|
||||
<>
|
||||
<ArrowCircleRight className="size-5" />
|
||||
Load more
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</Virtualizer>
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
|
||||
orientation="vertical"
|
||||
>
|
||||
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
|
||||
</ScrollArea.Scrollbar>
|
||||
<ScrollArea.Corner className="bg-transparent" />
|
||||
</ScrollArea.Root>
|
||||
);
|
||||
}
|
||||
244
src/routes/columns/_layout/newsfeed.lazy.tsx
Normal file
244
src/routes/columns/_layout/newsfeed.lazy.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import { events, commands } from "@/commands.gen";
|
||||
import { toLumeEvents } from "@/commons";
|
||||
import { Quote, RepostNote, Spinner, TextNote } from "@/components";
|
||||
import { LumeEvent } from "@/system";
|
||||
import { Kind, type Meta } from "@/types";
|
||||
import { ArrowCircleRight, ArrowUp } from "@phosphor-icons/react";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { type InfiniteData, useInfiniteQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Navigate,
|
||||
createLazyFileRoute,
|
||||
useLocation,
|
||||
} from "@tanstack/react-router";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useTransition,
|
||||
} from "react";
|
||||
import { Virtualizer } from "virtua";
|
||||
|
||||
type Payload = {
|
||||
raw: string;
|
||||
parsed: Meta;
|
||||
};
|
||||
|
||||
export const Route = createLazyFileRoute("/columns/_layout/newsfeed")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
export function Screen() {
|
||||
const { queryClient } = Route.useRouteContext();
|
||||
const { label, account } = Route.useSearch();
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isError,
|
||||
isFetching,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: [label, account],
|
||||
initialPageParam: 0,
|
||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||
const until = pageParam > 0 ? pageParam.toString() : undefined;
|
||||
const res = await commands.getEventsFromContacts(until);
|
||||
|
||||
if (res.status === "error") {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
return toLumeEvents(res.data);
|
||||
},
|
||||
getNextPageParam: (lastPage) => lastPage?.at?.(-1)?.created_at - 1,
|
||||
select: (data) => data?.pages.flat(),
|
||||
});
|
||||
|
||||
const location = useLocation();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
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.isQuote) {
|
||||
return <Quote key={event.id} event={event} className="mb-3" />;
|
||||
} else {
|
||||
return <TextNote key={event.id} event={event} className="mb-3" />;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[data],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const unlisten = listen("newsfeed_synchronized", async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: [label, account] });
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlisten.then((f) => f());
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Navigate
|
||||
to="/columns/create-newsfeed/users"
|
||||
search={{ label, account, redirect: location.href }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea.Root
|
||||
type={"scroll"}
|
||||
scrollHideDelay={300}
|
||||
className="overflow-hidden size-full"
|
||||
>
|
||||
<ScrollArea.Viewport ref={ref} className="relative h-full px-3 pb-3">
|
||||
<Listerner />
|
||||
<Virtualizer scrollRef={ref}>
|
||||
{isFetching && !isLoading && !isFetchingNextPage ? (
|
||||
<div className="z-50 fixed top-0 left-0 w-full h-14 flex items-center justify-center px-3">
|
||||
<div className="w-max h-8 pl-2 pr-3 inline-flex items-center justify-center gap-1.5 rounded-full shadow-lg text-sm font-medium text-white bg-black dark:text-black dark:bg-white">
|
||||
<Spinner className="size-4" />
|
||||
Getting new notes...
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{isLoading ? (
|
||||
<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>
|
||||
) : !data.length ? (
|
||||
<div className="mb-3 flex items-center justify-center h-20 text-sm rounded-xl bg-black/5 dark:bg-white/5">
|
||||
🎉 Yo. You're catching up on all latest notes.
|
||||
</div>
|
||||
) : (
|
||||
data.map((item) => renderItem(item))
|
||||
)}
|
||||
{hasNextPage ? (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={isFetchingNextPage || isLoading}
|
||||
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" />
|
||||
) : (
|
||||
<>
|
||||
<ArrowCircleRight className="size-5" />
|
||||
Load more
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</Virtualizer>
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
|
||||
orientation="vertical"
|
||||
>
|
||||
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
|
||||
</ScrollArea.Scrollbar>
|
||||
<ScrollArea.Corner className="bg-transparent" />
|
||||
</ScrollArea.Root>
|
||||
);
|
||||
}
|
||||
|
||||
const Listerner = memo(function Listerner() {
|
||||
const { queryClient } = Route.useRouteContext();
|
||||
const { label, account } = Route.useSearch();
|
||||
|
||||
const [lumeEvents, setLumeEvents] = useState<LumeEvent[]>([]);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const queryStatus = queryClient.getQueryState([label, account]);
|
||||
|
||||
const pushNewEvents = () => {
|
||||
startTransition(() => {
|
||||
queryClient.setQueryData(
|
||||
[label, account],
|
||||
(oldData: InfiniteData<LumeEvent[], number> | undefined) => {
|
||||
if (oldData) {
|
||||
const firstPage = oldData.pages[0];
|
||||
const newPage = [...lumeEvents, ...firstPage];
|
||||
|
||||
return {
|
||||
...oldData,
|
||||
pages: [newPage, ...oldData.pages.slice(1)],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Reset array
|
||||
setLumeEvents([]);
|
||||
|
||||
return;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
events.subscription
|
||||
.emit({ label, kind: "Subscribe", event_id: undefined })
|
||||
.then(() => console.log("Subscribe: ", label));
|
||||
|
||||
return () => {
|
||||
events.subscription
|
||||
.emit({
|
||||
label,
|
||||
kind: "Unsubscribe",
|
||||
event_id: undefined,
|
||||
})
|
||||
.then(() => console.log("Unsubscribe: ", label));
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const unlisten = getCurrentWindow().listen<Payload>("event", (data) => {
|
||||
const event = LumeEvent.from(data.payload.raw, data.payload.parsed);
|
||||
setLumeEvents((prev) => [event, ...prev]);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlisten.then((f) => f());
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (lumeEvents.length && queryStatus.fetchStatus !== "fetching") {
|
||||
return (
|
||||
<div className="z-50 fixed top-0 left-0 w-full h-14 flex items-center justify-center px-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => pushNewEvents()}
|
||||
className="w-max h-8 pl-2 pr-3 inline-flex items-center justify-center gap-1.5 rounded-full shadow-lg text-sm font-medium text-white bg-black dark:text-black dark:bg-white"
|
||||
>
|
||||
{isPending ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<ArrowUp className="size-4" />
|
||||
)}
|
||||
{lumeEvents.length} new notes
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
296
src/routes/columns/_layout/notification.lazy.tsx
Normal file
296
src/routes/columns/_layout/notification.lazy.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
import { decodeZapInvoice, formatCreatedAt } from "@/commons";
|
||||
import { Spinner } from "@/components";
|
||||
import { Note } from "@/components/note";
|
||||
import { User } from "@/components/user";
|
||||
import { type LumeEvent, NostrQuery, useEvent } from "@/system";
|
||||
import { Kind } from "@/types";
|
||||
import { Info, Repeat } from "@phosphor-icons/react";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import * as Tabs from "@radix-ui/react-tabs";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { type ReactNode, useEffect, useRef } from "react";
|
||||
import { Virtualizer } from "virtua";
|
||||
|
||||
export const Route = createLazyFileRoute("/columns/_layout/notification")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { account } = Route.useSearch();
|
||||
const { queryClient } = Route.useRouteContext();
|
||||
const { isLoading, data } = useQuery({
|
||||
queryKey: ["notification", account],
|
||||
queryFn: async () => {
|
||||
const events = await NostrQuery.getNotifications();
|
||||
return events;
|
||||
},
|
||||
select: (events) => {
|
||||
const zaps = new Map<string, LumeEvent[]>();
|
||||
const reactions = new Map<string, LumeEvent[]>();
|
||||
const hex = nip19.decode(account).data;
|
||||
|
||||
const texts = events.filter(
|
||||
(ev) => ev.kind === Kind.Text && ev.pubkey !== hex,
|
||||
);
|
||||
const zapEvents = events.filter((ev) => ev.kind === Kind.ZapReceipt);
|
||||
const reactEvents = events.filter(
|
||||
(ev) => ev.kind === Kind.Repost || ev.kind === Kind.Reaction,
|
||||
);
|
||||
|
||||
for (const event of reactEvents) {
|
||||
const rootId = event.tags.filter((tag) => tag[0] === "e")[0]?.[1];
|
||||
|
||||
if (rootId) {
|
||||
if (reactions.has(rootId)) {
|
||||
reactions.get(rootId).push(event);
|
||||
} else {
|
||||
reactions.set(rootId, [event]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const event of zapEvents) {
|
||||
const rootId = event.tags.filter((tag) => tag[0] === "e")[0]?.[1];
|
||||
|
||||
if (rootId) {
|
||||
if (zaps.has(rootId)) {
|
||||
zaps.get(rootId).push(event);
|
||||
} else {
|
||||
zaps.set(rootId, [event]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { texts, zaps, reactions };
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const unlisten = getCurrentWindow().listen("event", async (data) => {
|
||||
const event: LumeEvent = JSON.parse(data.payload as string);
|
||||
await queryClient.setQueryData(
|
||||
["notification", account],
|
||||
(data: LumeEvent[]) => [event, ...data],
|
||||
);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlisten.then((f) => f());
|
||||
};
|
||||
}, [account]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="size-full flex items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs.Root defaultValue="replies" className="flex flex-col h-full">
|
||||
<Tabs.List className="h-8 shrink-0 flex items-center">
|
||||
<Tabs.Trigger
|
||||
className="flex-1 inline-flex h-8 items-center justify-center gap-2 px-3 text-sm font-medium border-b border-black/10 dark:border-white/10 data-[state=active]:border-black/30 dark:data-[state=active]:border-white/30 data-[state=inactive]:opacity-50"
|
||||
value="replies"
|
||||
>
|
||||
Replies
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger
|
||||
className="flex-1 inline-flex h-8 items-center justify-center gap-2 px-3 text-sm font-medium border-b border-black/10 dark:border-white/10 data-[state=active]:border-black/30 dark:data-[state=active]:border-white/30 data-[state=inactive]:opacity-50"
|
||||
value="reactions"
|
||||
>
|
||||
Reactions
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger
|
||||
className="flex-1 inline-flex h-8 items-center justify-center gap-2 px-3 text-sm font-medium border-b border-black/10 dark:border-white/10 data-[state=active]:border-black/30 dark:data-[state=active]:border-white/30 data-[state=inactive]:opacity-50"
|
||||
value="zaps"
|
||||
>
|
||||
Zaps
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<ScrollArea.Root
|
||||
type={"scroll"}
|
||||
scrollHideDelay={300}
|
||||
className="min-h-0 flex-1 overflow-x-hidden"
|
||||
>
|
||||
<Tab value="replies">
|
||||
{data.texts.map((event, index) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
|
||||
<TextNote key={event.id + index} event={event} />
|
||||
))}
|
||||
</Tab>
|
||||
<Tab value="reactions">
|
||||
{[...data.reactions.entries()].map(([root, events]) => (
|
||||
<div
|
||||
key={root}
|
||||
className="flex flex-col gap-1 p-3 mb-3 bg-white dark:bg-black/20 rounded-xl shadow-primary dark:ring-1 dark:ring-white/5"
|
||||
>
|
||||
<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 key={event.id} pubkey={event.pubkey}>
|
||||
<User.Root className="shrink-0 flex rounded-full h-7 bg-black/10 dark:bg-white/10 p-[2px]">
|
||||
<User.Avatar className="flex-1 rounded-full size-6" />
|
||||
<div className="inline-flex items-center justify-center flex-1 text-xs truncate rounded-full size-7">
|
||||
{event.kind === Kind.Reaction ? (
|
||||
event.content === "+" ? (
|
||||
"👍"
|
||||
) : (
|
||||
event.content
|
||||
)
|
||||
) : (
|
||||
<Repeat className="text-teal-400 size-4 dark:text-teal-600" />
|
||||
)}
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Tab>
|
||||
<Tab value="zaps">
|
||||
{[...data.zaps.entries()].map(([root, events]) => (
|
||||
<div
|
||||
key={root}
|
||||
className="flex flex-col gap-1 p-3 mb-3 bg-white dark:bg-black/20 rounded-xl shadow-primary dark:ring-1 dark:ring-white/5"
|
||||
>
|
||||
<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
|
||||
key={event.id}
|
||||
pubkey={event.tags.find((tag) => tag[0] === "P")[1]}
|
||||
>
|
||||
<User.Root className="shrink-0 flex gap-1.5 rounded-full h-7 bg-black/10 dark:bg-white/10 p-[2px]">
|
||||
<User.Avatar className="rounded-full size-6" />
|
||||
<div className="flex-1 h-6 w-max pr-1.5 rounded-full inline-flex items-center justify-center text-xs font-semibold truncate">
|
||||
₿ {decodeZapInvoice(event.tags).bitcoinFormatted}
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Tab>
|
||||
<ScrollArea.Scrollbar
|
||||
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
|
||||
orientation="vertical"
|
||||
>
|
||||
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
|
||||
</ScrollArea.Scrollbar>
|
||||
<ScrollArea.Corner className="bg-transparent" />
|
||||
</ScrollArea.Root>
|
||||
</Tabs.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function Tab({ value, children }: { value: string; children: ReactNode[] }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<Tabs.Content value={value} className="size-full">
|
||||
<ScrollArea.Viewport ref={ref} className="h-full p-3">
|
||||
<Virtualizer scrollRef={ref}>{children}</Virtualizer>
|
||||
</ScrollArea.Viewport>
|
||||
</Tabs.Content>
|
||||
);
|
||||
}
|
||||
|
||||
function RootNote({ id }: { id: string }) {
|
||||
const { isLoading, isError, data } = useEvent(id);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="inline-flex items-center justify-center text-white bg-red-500 rounded-full size-8 shrink-0">
|
||||
<Info className="size-5" />
|
||||
</div>
|
||||
<p className="text-sm text-red-500">
|
||||
Event not found with your current relay set
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Note.Provider event={data}>
|
||||
<Note.Root className="flex items-center gap-2">
|
||||
<User.Provider pubkey={data.pubkey}>
|
||||
<User.Root className="shrink-0">
|
||||
<User.Avatar className="rounded-full size-8" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<div className="line-clamp-1">{data.content}</div>
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function TextNote({ event }: { event: LumeEvent }) {
|
||||
const pTags = event.tags
|
||||
.filter((tag) => tag[0] === "p")
|
||||
.map((tag) => tag[1])
|
||||
.slice(0, 3);
|
||||
|
||||
return (
|
||||
<Note.Provider event={event}>
|
||||
<Note.Root className="flex flex-col p-3 mb-3 bg-white dark:bg-black/20 rounded-xl shadow-primary dark:ring-1 dark:ring-white/5">
|
||||
<User.Provider pubkey={event.pubkey}>
|
||||
<User.Root className="inline-flex items-center gap-2">
|
||||
<User.Avatar className="rounded-full size-9" />
|
||||
<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>
|
||||
<div className="inline-flex items-baseline gap-1 text-xs">
|
||||
<span className="leading-tight text-black/50 dark:text-white/50">
|
||||
Reply to:
|
||||
</span>
|
||||
<div className="inline-flex items-baseline gap-1">
|
||||
{[...new Set(pTags)].map((replyTo) => (
|
||||
<User.Provider key={replyTo} pubkey={replyTo}>
|
||||
<User.Root>
|
||||
<User.Name className="font-medium leading-tight" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<div className="flex gap-2">
|
||||
<div className="w-9 shrink-0" />
|
||||
<div className="line-clamp-1 text-start">{event.content}</div>
|
||||
</div>
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
);
|
||||
}
|
||||
112
src/routes/columns/_layout/onboarding.lazy.tsx
Normal file
112
src/routes/columns/_layout/onboarding.lazy.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createLazyFileRoute("/columns/_layout/onboarding")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
return (
|
||||
<div className="h-full flex flex-col py-6 gap-6 overflow-y-auto scrollbar-none">
|
||||
<div className="text-center flex flex-col items-center justify-center">
|
||||
<h1 className="text-2xl font-serif font-medium">Welcome to Lume</h1>
|
||||
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
|
||||
Here are a few suggestions to help you get started.
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-3 flex flex-col gap-3">
|
||||
<div className="relative flex flex-col items-center justify-center rounded-xl bg-black/10 dark:bg-white/10">
|
||||
<div className="absolute top-2 left-3 text-2xl font-semibold font-serif text-neutral-600 dark:text-neutral-400">
|
||||
01.
|
||||
</div>
|
||||
<div className="h-16 flex items-center justify-center shrink-0 px-3 text-lg select-text">
|
||||
Navigate between columns.
|
||||
</div>
|
||||
<div className="flex-1 w-3/4 h-full pb-10">
|
||||
<video
|
||||
className="h-auto w-full aspect-square rounded-lg shadow-md transform"
|
||||
controls
|
||||
muted
|
||||
preload="none"
|
||||
poster="/poster_1.jpeg"
|
||||
>
|
||||
<source
|
||||
src="https://video.nostr.build/692f71e2be47ecfc29edcbdaa198cc5979bfb9c900f05d78682895dd546d8d4f.mp4"
|
||||
type="video/mp4"
|
||||
/>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex flex-col items-center justify-center rounded-xl bg-black/10 dark:bg-white/10">
|
||||
<div className="absolute top-2 left-3 text-2xl font-semibold font-serif text-neutral-600 dark:text-neutral-400">
|
||||
02.
|
||||
</div>
|
||||
<div className="h-16 flex items-center justify-center shrink-0 px-3 text-lg select-text">
|
||||
Switch between accounts.
|
||||
</div>
|
||||
<div className="flex-1 w-3/4 h-full pb-10">
|
||||
<video
|
||||
className="h-auto w-full aspect-square rounded-lg shadow-md transform"
|
||||
controls
|
||||
muted
|
||||
preload="none"
|
||||
poster="/poster_2.jpeg"
|
||||
>
|
||||
<source
|
||||
src="https://video.nostr.build/d33962520506d86acfb4b55a7b265821e10ae637f5ec830a173b7e6092b16ec8.mp4"
|
||||
type="video/mp4"
|
||||
/>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex flex-col items-center justify-center rounded-xl bg-black/10 dark:bg-white/10">
|
||||
<div className="absolute top-2 left-3 text-2xl font-semibold font-serif text-neutral-600 dark:text-neutral-400">
|
||||
03.
|
||||
</div>
|
||||
<div className="h-16 flex items-center justify-center shrink-0 px-3 text-lg select-text">
|
||||
Open Lume Store.
|
||||
</div>
|
||||
<div className="flex-1 w-3/4 h-full pb-10">
|
||||
<video
|
||||
className="h-auto w-full aspect-square rounded-lg shadow-md transform"
|
||||
controls
|
||||
muted
|
||||
preload="none"
|
||||
poster="/poster_3.jpeg"
|
||||
>
|
||||
<source
|
||||
src="https://video.nostr.build/927abbfde2097e470ac751181b1db456b7e4b9149550408efff1a966a7ffb9a8.mp4"
|
||||
type="video/mp4"
|
||||
/>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex flex-col items-center justify-center rounded-xl bg-black/10 dark:bg-white/10">
|
||||
<div className="absolute top-2 left-3 text-2xl font-semibold font-serif text-neutral-600 dark:text-neutral-400">
|
||||
04.
|
||||
</div>
|
||||
<div className="h-16 flex items-center justify-center shrink-0 px-3 text-lg select-text">
|
||||
Use the Tray Menu.
|
||||
</div>
|
||||
<div className="flex-1 w-3/4 h-full pb-10">
|
||||
<video
|
||||
className="h-auto w-full rounded-lg shadow-md transform"
|
||||
controls
|
||||
muted
|
||||
preload="none"
|
||||
poster="/poster_4.jpeg"
|
||||
>
|
||||
<source
|
||||
src="https://video.nostr.build/513de4824b6abaf7e9698c1dad2f68096574356848c0c200bc8cb8074df29410.mp4"
|
||||
type="video/mp4"
|
||||
/>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
src/routes/columns/_layout/replies.$id.lazy.tsx
Normal file
55
src/routes/columns/_layout/replies.$id.lazy.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { ReplyNote } from "@/components";
|
||||
import { ArrowLeft } from "@phosphor-icons/react";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import {
|
||||
createLazyFileRoute,
|
||||
useRouter,
|
||||
useRouterState,
|
||||
} from "@tanstack/react-router";
|
||||
import { useRef } from "react";
|
||||
import { Virtualizer } from "virtua";
|
||||
|
||||
export const Route = createLazyFileRoute("/columns/_layout/replies/$id")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const router = useRouter();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { events } = useRouterState({ select: (s) => s.location.state });
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="h-10 shrink-0 border-b border-black/5 dark:border-white/5 flex items-center justify-between px-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.history.back()}
|
||||
className="inline-flex items-center justify-center gap-1.5 h-7 w-max px-1 text-sm font-semibold hover:bg-black/10 dark:hover:bg-white/10 rounded-md"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
<ScrollArea.Root
|
||||
type={"scroll"}
|
||||
scrollHideDelay={300}
|
||||
className="overflow-hidden size-full flex-1"
|
||||
>
|
||||
<ScrollArea.Viewport ref={ref} className="h-full p-3">
|
||||
<Virtualizer scrollRef={ref}>
|
||||
{events.map((event) => (
|
||||
<ReplyNote key={event.id} event={event} />
|
||||
))}
|
||||
</Virtualizer>
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
|
||||
orientation="vertical"
|
||||
>
|
||||
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
|
||||
</ScrollArea.Scrollbar>
|
||||
<ScrollArea.Corner className="bg-transparent" />
|
||||
</ScrollArea.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
142
src/routes/columns/_layout/search.lazy.tsx
Normal file
142
src/routes/columns/_layout/search.lazy.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { commands } from "@/commands.gen";
|
||||
import { toLumeEvents } from "@/commons";
|
||||
import { Spinner, TextNote, User } from "@/components";
|
||||
import { type LumeEvent, LumeWindow } from "@/system";
|
||||
import { Kind } from "@/types";
|
||||
import { MagnifyingGlass } from "@phosphor-icons/react";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||
import { message } from "@tauri-apps/plugin-dialog";
|
||||
import { useCallback, useRef, useState, useTransition } from "react";
|
||||
import { Virtualizer } from "virtua";
|
||||
|
||||
export const Route = createLazyFileRoute("/columns/_layout/search")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [events, setEvents] = useState<LumeEvent[]>([]);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const renderItem = useCallback(
|
||||
(event: LumeEvent) => {
|
||||
if (!event) return;
|
||||
|
||||
switch (event.kind) {
|
||||
case Kind.Text:
|
||||
return <TextNote key={event.id} event={event} className="mb-3" />;
|
||||
case Kind.Metadata:
|
||||
return (
|
||||
<div
|
||||
key={event.id}
|
||||
className="p-3 mb-3 bg-white dark:bg-black/20 rounded-xl shadow-primary dark:ring-1 dark:ring-white/5"
|
||||
>
|
||||
<User.Provider pubkey={event.pubkey}>
|
||||
<User.Root className="flex flex-col w-full h-full gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<User.Avatar className="rounded-full size-7" />
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<User.Name className="text-sm leadning-tight max-w-[15rem] truncate font-semibold" />
|
||||
<User.NIP05 />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => LumeWindow.openProfile(event.pubkey)}
|
||||
className="text-blue-500 text-sm font-medium h-7 inline-flex items-center justify-center"
|
||||
>
|
||||
View profile
|
||||
</button>
|
||||
<User.Button className="inline-flex items-center justify-center w-20 text-sm font-medium rounded-md h-7 bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20" />
|
||||
</div>
|
||||
</div>
|
||||
<User.About className="select-text line-clamp-3 max-w-none text-neutral-800 dark:text-neutral-400" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return <TextNote key={event.id} event={event} className="mb-3" />;
|
||||
}
|
||||
},
|
||||
[events],
|
||||
);
|
||||
|
||||
const search = () => {
|
||||
startTransition(async () => {
|
||||
if (!query.length) return;
|
||||
|
||||
const res = await commands.search(query, null);
|
||||
|
||||
if (res.status === "ok") {
|
||||
const data = toLumeEvents(res.data);
|
||||
setEvents(data);
|
||||
} else {
|
||||
await message(res.error, { title: "Search", kind: "error" });
|
||||
return;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 size-full overflow-hidden">
|
||||
<div className="h-9 shrink-0 px-3 flex items-center gap-2">
|
||||
<input
|
||||
name="search"
|
||||
placeholder="Search nostr ..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") search();
|
||||
}}
|
||||
className="h-9 px-5 flex-1 rounded-full border-none bg-black/5 dark:bg-white/10 placeholder:text-neutral-500 dark:placeholder:text-neutral-400 focus:bg-black/10 dark:focus:bg-white/10 focus:outline-none focus:ring-0"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!query.length || isPending}
|
||||
className="size-9 shrink-0 inline-flex items-center justify-center rounded-full bg-black/5 dark:bg-white/10"
|
||||
>
|
||||
{isPending ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<MagnifyingGlass className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<ScrollArea.Root
|
||||
type={"scroll"}
|
||||
scrollHideDelay={300}
|
||||
className="overflow-hidden size-full flex-1"
|
||||
>
|
||||
<ScrollArea.Viewport ref={ref} className="relative h-full px-3">
|
||||
<Virtualizer scrollRef={ref}>
|
||||
{isPending ? (
|
||||
<div className="w-full h-[200px] flex gap-2 items-center justify-center">
|
||||
<Spinner />
|
||||
Searching...
|
||||
</div>
|
||||
) : !events.length ? (
|
||||
<div className="w-full h-[200px] flex gap-2 items-center justify-center">
|
||||
Type somethings to search.
|
||||
</div>
|
||||
) : (
|
||||
events.map((event) => renderItem(event))
|
||||
)}
|
||||
</Virtualizer>
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
|
||||
orientation="vertical"
|
||||
>
|
||||
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
|
||||
</ScrollArea.Scrollbar>
|
||||
<ScrollArea.Corner className="bg-transparent" />
|
||||
</ScrollArea.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
150
src/routes/columns/_layout/stories.lazy.tsx
Normal file
150
src/routes/columns/_layout/stories.lazy.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { commands } from "@/commands.gen";
|
||||
import { replyTime, toLumeEvents } from "@/commons";
|
||||
import { Note, Spinner, User } from "@/components";
|
||||
import { type LumeEvent, LumeWindow } from "@/system";
|
||||
import { ColumnsPlusLeft } from "@phosphor-icons/react";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||
import { memo, useRef } from "react";
|
||||
import { Virtualizer } from "virtua";
|
||||
|
||||
export const Route = createLazyFileRoute("/columns/_layout/stories")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { contacts } = Route.useRouteContext();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<ScrollArea.Root
|
||||
type={"scroll"}
|
||||
scrollHideDelay={300}
|
||||
className="overflow-hidden size-full"
|
||||
>
|
||||
<ScrollArea.Viewport ref={ref} className="relative h-full px-3 pb-3">
|
||||
<Virtualizer scrollRef={ref} overscan={0}>
|
||||
{contacts.map((contact) => (
|
||||
<StoryItem key={contact} contact={contact} />
|
||||
))}
|
||||
</Virtualizer>
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
|
||||
orientation="vertical"
|
||||
>
|
||||
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
|
||||
</ScrollArea.Scrollbar>
|
||||
<ScrollArea.Corner className="bg-transparent" />
|
||||
</ScrollArea.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function StoryItem({ contact }: { contact: string }) {
|
||||
const {
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
data: events,
|
||||
} = useQuery({
|
||||
queryKey: ["stories", contact],
|
||||
queryFn: async () => {
|
||||
const res = await commands.getEventsBy(contact, 10);
|
||||
|
||||
if (res.status === "ok") {
|
||||
const data = toLumeEvents(res.data);
|
||||
return data;
|
||||
} else {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<div className="mb-3 flex flex-col w-full h-[300px] bg-white dark:bg-black/20 rounded-xl shadow-primary dark:ring-1 dark:ring-white/5">
|
||||
<div className="h-12 shrink-0 px-2 flex items-center justify-between border-b border-neutral-100 dark:border-white/5">
|
||||
<User.Provider pubkey={contact}>
|
||||
<User.Root className="inline-flex items-center gap-2">
|
||||
<User.Avatar className="size-8 rounded-full" />
|
||||
<User.Name className="text-sm font-medium" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => LumeWindow.openProfile(contact)}
|
||||
className="size-7 inline-flex items-center justify-center rounded-lg text-neutral-500 hover:bg-neutral-100 dark:hover:bg-white/20"
|
||||
>
|
||||
<ColumnsPlusLeft className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea.Root
|
||||
type={"scroll"}
|
||||
scrollHideDelay={300}
|
||||
className="flex-1 min-h-0 overflow-hidden size-full"
|
||||
>
|
||||
<ScrollArea.Viewport ref={ref} className="relative h-full px-2 pt-2">
|
||||
<Virtualizer scrollRef={ref} overscan={0}>
|
||||
{isLoading ? (
|
||||
<div className="w-full h-[calc(300px-48px)] flex items-center justify-center text-sm">
|
||||
<Spinner className="size-4" />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="w-full h-[calc(300px-48px)] flex items-center justify-center text-sm">
|
||||
{error.message}
|
||||
</div>
|
||||
) : !events.length ? (
|
||||
<div className="w-full h-[calc(300px-48px)] flex items-center justify-center text-sm">
|
||||
This user didn't have any new notes.
|
||||
</div>
|
||||
) : (
|
||||
events.map((event) => <StoryEvent key={event.id} event={event} />)
|
||||
)}
|
||||
</Virtualizer>
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
|
||||
orientation="vertical"
|
||||
>
|
||||
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
|
||||
</ScrollArea.Scrollbar>
|
||||
<ScrollArea.Corner className="bg-transparent" />
|
||||
</ScrollArea.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const StoryEvent = memo(function StoryEvent({ event }: { event: LumeEvent }) {
|
||||
return (
|
||||
<Note.Provider event={event}>
|
||||
<User.Provider pubkey={event.pubkey}>
|
||||
<Note.Root className="group flex flex-col gap-1 mb-3">
|
||||
<div>
|
||||
<User.Name
|
||||
className="shrink-0 inline font-medium text-blue-500"
|
||||
suffix=":"
|
||||
/>
|
||||
<div className="pl-2 inline select-text text-balance content-break overflow-hidden">
|
||||
{event.content}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-between">
|
||||
<span className="text-sm text-neutral-500">
|
||||
{replyTime(event.created_at)}
|
||||
</span>
|
||||
<div className="invisible group-hover:visible flex items-center justify-end gap-3">
|
||||
<Note.Reply />
|
||||
<Note.Repost />
|
||||
<Note.Zap />
|
||||
</div>
|
||||
</div>
|
||||
</Note.Root>
|
||||
</User.Provider>
|
||||
</Note.Provider>
|
||||
);
|
||||
});
|
||||
15
src/routes/columns/_layout/stories.tsx
Normal file
15
src/routes/columns/_layout/stories.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { commands } from "@/commands.gen";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/columns/_layout/stories")({
|
||||
beforeLoad: async () => {
|
||||
const res = await commands.getContactList();
|
||||
|
||||
if (res.status === "ok") {
|
||||
const contacts = res.data;
|
||||
return { contacts };
|
||||
} else {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
},
|
||||
});
|
||||
94
src/routes/columns/_layout/trending.lazy.tsx
Normal file
94
src/routes/columns/_layout/trending.lazy.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { Quote, RepostNote, Spinner, TextNote } from "@/components";
|
||||
import { LumeEvent } from "@/system";
|
||||
import { Kind, type NostrEvent } from "@/types";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { Virtualizer } from "virtua";
|
||||
|
||||
export const Route = createLazyFileRoute("/columns/_layout/trending")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
export function Screen() {
|
||||
const { isLoading, isError, data } = useQuery({
|
||||
queryKey: ["trending-notes"],
|
||||
queryFn: async ({ signal }) => {
|
||||
const res = await fetch("https://api.nostr.band/v0/trending/notes", {
|
||||
signal,
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error("Error.");
|
||||
}
|
||||
|
||||
const data: { notes: Array<{ event: NostrEvent }> } = await res.json();
|
||||
const events: NostrEvent[] = data.notes.map(
|
||||
(item: { event: NostrEvent }) => item.event,
|
||||
);
|
||||
const lumeEvents = Promise.all(
|
||||
events.map(async (ev) => await LumeEvent.build(ev)),
|
||||
);
|
||||
|
||||
return lumeEvents;
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
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.isQuote) {
|
||||
return <Quote key={event.id} event={event} className="mb-3" />;
|
||||
} else {
|
||||
return <TextNote key={event.id} event={event} className="mb-3" />;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[data],
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollArea.Root
|
||||
type={"scroll"}
|
||||
scrollHideDelay={300}
|
||||
className="overflow-hidden size-full"
|
||||
>
|
||||
<ScrollArea.Viewport ref={ref} className="h-full px-3">
|
||||
<Virtualizer scrollRef={ref} overscan={1}>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col items-center justify-center w-full h-20 gap-1">
|
||||
<div className="inline-flex items-center gap-2 text-sm font-medium">
|
||||
<Spinner className="size-5" />
|
||||
Loading...
|
||||
</div>
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="flex flex-col items-center justify-center w-full h-20 gap-1">
|
||||
<div className="inline-flex items-center gap-2 text-sm font-medium">
|
||||
Error.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
data.map((item) => renderItem(item))
|
||||
)}
|
||||
</Virtualizer>
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
|
||||
orientation="vertical"
|
||||
>
|
||||
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
|
||||
</ScrollArea.Scrollbar>
|
||||
<ScrollArea.Corner className="bg-transparent" />
|
||||
</ScrollArea.Root>
|
||||
);
|
||||
}
|
||||
103
src/routes/columns/_layout/users.$id.lazy.tsx
Normal file
103
src/routes/columns/_layout/users.$id.lazy.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { commands } from "@/commands.gen";
|
||||
import { toLumeEvents } from "@/commons";
|
||||
import { Spinner } from "@/components";
|
||||
import { Quote } from "@/components/quote";
|
||||
import { RepostNote } from "@/components/repost";
|
||||
import { TextNote } from "@/components/text";
|
||||
import { User } from "@/components/user";
|
||||
import type { LumeEvent } from "@/system";
|
||||
import { Kind } from "@/types";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { Virtualizer } from "virtua";
|
||||
|
||||
export const Route = createLazyFileRoute("/columns/_layout/users/$id")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { id } = Route.useParams();
|
||||
const { isLoading, data: events } = useQuery({
|
||||
queryKey: ["stories", id],
|
||||
queryFn: async () => {
|
||||
const res = await commands.getEventsBy(id, 20);
|
||||
|
||||
if (res.status === "ok") {
|
||||
const data = toLumeEvents(res.data);
|
||||
return data;
|
||||
} else {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
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.isQuote) {
|
||||
return <Quote key={event.id} event={event} className="mb-3" />;
|
||||
}
|
||||
return <TextNote key={event.id} event={event} className="mb-3" />;
|
||||
}
|
||||
}
|
||||
},
|
||||
[events],
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollArea.Root
|
||||
type={"scroll"}
|
||||
scrollHideDelay={300}
|
||||
className="overflow-hidden size-full"
|
||||
>
|
||||
<ScrollArea.Viewport ref={ref} className="relative h-full px-3 pb-3">
|
||||
<Virtualizer scrollRef={ref} overscan={0}>
|
||||
<User.Provider pubkey={id}>
|
||||
<User.Root className="relative">
|
||||
<User.Cover className="object-cover w-full h-44 rounded-t-lg gradient-mask-b-0" />
|
||||
<User.Button className="z-10 absolute top-4 right-4 inline-flex items-center justify-center w-20 text-xs font-medium text-white shadow-md bg-black hover:bg-black/80 rounded-full h-7" />
|
||||
<div className="z-10 relative flex flex-col items-center gap-1.5 -mt-16">
|
||||
<User.Avatar className="rounded-full size-14" />
|
||||
<div className="flex items-center gap-1">
|
||||
<User.Name className="text-lg font-semibold leading-tight" />
|
||||
<User.NIP05 />
|
||||
</div>
|
||||
<User.About className="text-center" />
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<div className="mt-5">
|
||||
{isLoading ? (
|
||||
<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>
|
||||
) : !events.length ? (
|
||||
<div className="flex items-center justify-center">
|
||||
Yo. You're catching up on all the things happening around you.
|
||||
</div>
|
||||
) : (
|
||||
events.map((item) => renderItem(item))
|
||||
)}
|
||||
</div>
|
||||
</Virtualizer>
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
|
||||
orientation="vertical"
|
||||
>
|
||||
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
|
||||
</ScrollArea.Scrollbar>
|
||||
<ScrollArea.Corner className="bg-transparent" />
|
||||
</ScrollArea.Root>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user