Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d3f2264e9 | ||
|
|
407fe40b67 | ||
|
|
1f38eba2cc | ||
|
|
9b5867f80c | ||
|
|
cac774a0c1 | ||
|
|
82689bf3c3 | ||
|
|
f60e438a64 | ||
|
|
ca06f2b6ed | ||
|
|
99d9c70826 | ||
|
|
60afbf090b | ||
|
|
10ca4e6ff4 | ||
|
|
b0f387d029 | ||
|
|
1a8f750640 | ||
|
|
25523229a2 | ||
|
|
47835ed857 | ||
|
|
d84647bc6b | ||
|
|
7724eccd72 | ||
|
|
8ea2335225 | ||
|
|
b60d4db0df | ||
|
|
f1e17ff3c4 | ||
|
|
32954f17b6 | ||
|
|
cf70b0f882 |
@@ -1,18 +1,14 @@
|
||||
_Note_: Lume is under rewrite to using Rust Nostr as back-end and more lightweight front-end. If you need stable version, you can download v3 and below.
|
||||
|
||||
Source code for v3 is stored here: https://github.com/lumehq/lume/tree/old
|
||||
|
||||
--
|
||||
|
||||
## Introduction
|
||||
|
||||
Lume is a Nostr client for desktop include Linux, Windows and macOS. It is free and open source, you can look at source code on Github. Lume is actively improving the app and adding new features, you can expect new update every month.
|
||||
|
||||
## Usage
|
||||
|
||||
Download Lume v3 (v3.0.1-stable) for your platform here: [https://github.com/lumehq/lume/releases](https://github.com/lumehq/lume/releases)
|
||||
Download Lume v4 for your platform here: [https://github.com/lumehq/lume/releases](https://github.com/lumehq/lume/releases)
|
||||
|
||||
Supported platform: macOS, Windows and Linux
|
||||
Supported platform: macOS. Windows and Linux are coming soon.
|
||||
|
||||
Windows and Linux are availabel on v3 and below.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
|
||||
@@ -13,48 +13,51 @@
|
||||
"@lume/icons": "workspace:^",
|
||||
"@lume/ui": "workspace:^",
|
||||
"@lume/utils": "workspace:^",
|
||||
"@radix-ui/react-avatar": "^1.0.4",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-collapsible": "^1.0.3",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-hover-card": "^1.0.7",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-switch": "^1.0.3",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@tanstack/query-sync-storage-persister": "^5.32.0",
|
||||
"@tanstack/react-query": "^5.32.0",
|
||||
"@tanstack/react-query-persist-client": "^5.32.0",
|
||||
"@tanstack/react-router": "1.29.2",
|
||||
"i18next": "^23.11.3",
|
||||
"@tanstack/query-sync-storage-persister": "^5.36.0",
|
||||
"@tanstack/react-query": "^5.36.0",
|
||||
"@tanstack/react-query-persist-client": "^5.36.0",
|
||||
"@tanstack/react-router": "1.32.5",
|
||||
"i18next": "^23.11.4",
|
||||
"i18next-resources-to-backend": "^1.2.1",
|
||||
"minidenticons": "^4.2.1",
|
||||
"nanoid": "^5.0.7",
|
||||
"nostr-tools": "^2.5.1",
|
||||
"nostr-tools": "^2.5.2",
|
||||
"react": "^18.3.1",
|
||||
"react-currency-input-field": "^3.8.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.51.3",
|
||||
"react-hook-form": "^7.51.4",
|
||||
"react-hotkeys-hook": "^4.5.0",
|
||||
"react-i18next": "^14.1.1",
|
||||
"slate": "^0.102.0",
|
||||
"react-string-replace": "^1.1.1",
|
||||
"slate": "^0.103.0",
|
||||
"slate-react": "^0.102.0",
|
||||
"sonner": "^1.4.41",
|
||||
"use-debounce": "^10.0.0",
|
||||
"virtua": "^0.30.2"
|
||||
"virtua": "^0.31.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lume/tailwindcss": "workspace:^",
|
||||
"@lume/tsconfig": "workspace:^",
|
||||
"@lume/types": "workspace:^",
|
||||
"@tanstack/router-devtools": "^1.31.3",
|
||||
"@tanstack/router-vite-plugin": "^1.30.0",
|
||||
"@types/react": "^18.3.1",
|
||||
"@tanstack/router-devtools": "^1.32.5",
|
||||
"@tanstack/router-vite-plugin": "^1.32.2",
|
||||
"@types/react": "^18.3.2",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react-swc": "^3.6.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.2.10",
|
||||
"vite": "^5.2.11",
|
||||
"vite-plugin-top-level-await": "^1.4.1",
|
||||
"vite-tsconfig-paths": "^4.3.2"
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 26 KiB |
@@ -1,5 +1,4 @@
|
||||
import { Ark } from "@lume/ark";
|
||||
import { CancelCircleIcon, CheckCircleIcon, InfoCircleIcon } from "@lume/icons";
|
||||
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||
@@ -8,7 +7,6 @@ import { platform } from "@tauri-apps/plugin-os";
|
||||
import React, { StrictMode } from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import { Toaster } from "sonner";
|
||||
import "./app.css";
|
||||
import i18n from "./locale";
|
||||
import { routeTree } from "./router.gen"; // auto generated file
|
||||
@@ -29,6 +27,18 @@ const router = createRouter({
|
||||
queryClient,
|
||||
platform: platformName,
|
||||
},
|
||||
Wrap: ({ children }) => {
|
||||
return (
|
||||
<I18nextProvider i18n={i18n} defaultNS={"translation"}>
|
||||
<PersistQueryClientProvider
|
||||
client={queryClient}
|
||||
persistOptions={{ persister }}
|
||||
>
|
||||
{children}
|
||||
</PersistQueryClientProvider>
|
||||
</I18nextProvider>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// Register things for typesafety
|
||||
@@ -48,25 +58,8 @@ const rootElement = document.getElementById("root")!;
|
||||
if (!rootElement.innerHTML) {
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
root.render(
|
||||
<I18nextProvider i18n={i18n} defaultNS={"translation"}>
|
||||
<PersistQueryClientProvider
|
||||
client={queryClient}
|
||||
persistOptions={{ persister }}
|
||||
>
|
||||
<StrictMode>
|
||||
<Toaster
|
||||
position="bottom-right"
|
||||
icons={{
|
||||
success: <CheckCircleIcon className="size-5" />,
|
||||
info: <InfoCircleIcon className="size-5" />,
|
||||
error: <CancelCircleIcon className="size-5" />,
|
||||
}}
|
||||
closeButton
|
||||
theme="system"
|
||||
/>
|
||||
<App />
|
||||
</StrictMode>
|
||||
</PersistQueryClientProvider>
|
||||
</I18nextProvider>,
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { User } from "@lume/ui";
|
||||
import { User } from "@/components/user";
|
||||
import { getBitcoinDisplayValues } from "@lume/utils";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
@@ -5,7 +5,7 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
import { getCurrent } from "@tauri-apps/api/webviewWindow";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export function Col({
|
||||
export function Column({
|
||||
column,
|
||||
account,
|
||||
isScroll,
|
||||
@@ -17,68 +17,55 @@ export function Col({
|
||||
isResize: boolean;
|
||||
}) {
|
||||
const container = useRef<HTMLDivElement>(null);
|
||||
const [webview, setWebview] = useState<string | undefined>(undefined);
|
||||
const webviewLabel = `column-${account}_${column.label}`;
|
||||
|
||||
const [isCreated, setIsCreated] = useState(false);
|
||||
|
||||
const repositionWebview = async () => {
|
||||
if (webview && webview.length > 1) {
|
||||
const newRect = container.current.getBoundingClientRect();
|
||||
await invoke("reposition_column", {
|
||||
label: webview,
|
||||
x: newRect.x,
|
||||
y: newRect.y,
|
||||
});
|
||||
}
|
||||
const newRect = container.current.getBoundingClientRect();
|
||||
await invoke("reposition_column", {
|
||||
label: webviewLabel,
|
||||
x: newRect.x,
|
||||
y: newRect.y,
|
||||
});
|
||||
};
|
||||
|
||||
const resizeWebview = async () => {
|
||||
if (webview && webview.length > 1) {
|
||||
const newRect = container.current.getBoundingClientRect();
|
||||
await invoke("resize_column", {
|
||||
label: webview,
|
||||
width: newRect.width,
|
||||
height: newRect.height,
|
||||
});
|
||||
}
|
||||
const newRect = container.current.getBoundingClientRect();
|
||||
await invoke("resize_column", {
|
||||
label: webviewLabel,
|
||||
width: newRect.width,
|
||||
height: newRect.height,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
resizeWebview();
|
||||
if (isCreated) resizeWebview();
|
||||
}, [isResize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isScroll) repositionWebview();
|
||||
if (isScroll && isCreated) repositionWebview();
|
||||
}, [isScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (webview && webview.length > 1) return;
|
||||
const rect = container.current.getBoundingClientRect();
|
||||
const url = `${column.content}?account=${account}&label=${column.label}&name=${column.name}`;
|
||||
|
||||
const rect = container.current.getBoundingClientRect();
|
||||
const windowLabel = `column-${column.label}`;
|
||||
const url = `${column.content}?account=${account}&label=${column.label}&name=${column.name}`;
|
||||
|
||||
// create new webview
|
||||
const label: string = await invoke("create_column", {
|
||||
label: windowLabel,
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
url,
|
||||
});
|
||||
|
||||
setWebview(label);
|
||||
})();
|
||||
// create new webview
|
||||
invoke("create_column", {
|
||||
label: webviewLabel,
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
url,
|
||||
}).then(() => setIsCreated(true));
|
||||
|
||||
// close webview when unmounted
|
||||
return () => {
|
||||
if (webview && webview.length > 1) {
|
||||
invoke("close_column", {
|
||||
label: webview,
|
||||
});
|
||||
}
|
||||
invoke("close_column", { label: webviewLabel });
|
||||
};
|
||||
}, [webview]);
|
||||
}, [account]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-[440px] shrink-0 p-2">
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ThreadIcon } from "@lume/icons";
|
||||
import type { Event } from "@lume/types";
|
||||
import { Note } from "@lume/ui";
|
||||
import { Note } from "@/components/note";
|
||||
import { cn } from "@lume/utils";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
|
||||
@@ -12,7 +12,7 @@ export function Conversation({
|
||||
className?: string;
|
||||
}) {
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
const thread = ark.parse_event_thread(event.tags);
|
||||
const thread = ark.get_thread(event.tags);
|
||||
|
||||
return (
|
||||
<Note.Provider event={event}>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useRouteContext } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Spinner } from "../../spinner";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { useNoteContext } from "../provider";
|
||||
|
||||
export function NoteRepost({ large = false }: { large?: boolean }) {
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useEvent } from "@lume/ark";
|
||||
import { cn } from "@lume/utils";
|
||||
import { Note } from ".";
|
||||
import { InfoIcon } from "@lume/icons";
|
||||
|
||||
export function NoteChild({
|
||||
eventId,
|
||||
isRoot,
|
||||
}: {
|
||||
eventId: string;
|
||||
isRoot?: boolean;
|
||||
}) {
|
||||
const { isLoading, isError, data } = useEvent(eventId);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
<InfoIcon className="size-5" />
|
||||
</div>
|
||||
<p className="text-red-500 text-sm">
|
||||
Event not found with your current relay set
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Note.Provider event={data}>
|
||||
<Note.Root className={cn(isRoot ? "mb-3" : "")}>
|
||||
<div className="h-14 px-3 flex items-center justify-between">
|
||||
<Note.User />
|
||||
</div>
|
||||
<Note.Content className="px-3" />
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import { NoteContentLarge } from "./contentLarge";
|
||||
import { NoteMenu } from "./menu";
|
||||
import { NoteProvider } from "./provider";
|
||||
import { NoteRoot } from "./root";
|
||||
import { NoteThread } from "./thread";
|
||||
import { NoteUser } from "./user";
|
||||
|
||||
export const Note = {
|
||||
@@ -24,6 +23,5 @@ export const Note = {
|
||||
Zap: NoteZap,
|
||||
Open: NoteOpenThread,
|
||||
Child: NoteChild,
|
||||
Thread: NoteThread,
|
||||
Activity: NoteActivity,
|
||||
};
|
||||
@@ -2,8 +2,9 @@ import { useEvent } from "@lume/ark";
|
||||
import { LinkIcon } from "@lume/icons";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { User } from "../../user";
|
||||
import { cn } from "@lume/utils";
|
||||
import { User } from "@/components/user";
|
||||
import { Spinner } from "@lume/ui";
|
||||
|
||||
export function MentionNote({
|
||||
eventId,
|
||||
@@ -18,15 +19,15 @@ export function MentionNote({
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex w-full cursor-default items-center justify-between rounded-xl border border-black/10 p-3 dark:border-white/10">
|
||||
<p>Loading...</p>
|
||||
<div className="mt-2 w-full flex h-20 items-center justify-center rounded-xl border border-black/10 dark:border-white/10">
|
||||
<Spinner className="size-5" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<div className="w-full cursor-default rounded-xl border border-black/10 p-3 dark:border-white/10">
|
||||
<div className="mt-2 w-full rounded-xl border border-black/10 p-3 dark:border-white/10">
|
||||
{t("note.error")}
|
||||
</div>
|
||||
);
|
||||
@@ -1,5 +1,5 @@
|
||||
import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||
import { Carousel, CarouselItem } from "../../carousel";
|
||||
import { Carousel, CarouselItem } from "@lume/ui";
|
||||
|
||||
export function Images({ urls }: { urls: string[] }) {
|
||||
const open = async (url: string) => {
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Carousel, CarouselItem } from "../../carousel";
|
||||
import { Carousel, CarouselItem } from "@lume/ui";
|
||||
|
||||
export function Videos({ urls }: { urls: string[] }) {
|
||||
if (urls.length === 1) {
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Event } from "@lume/types";
|
||||
import { Note } from "@lume/ui";
|
||||
import { Note } from "@/components/note";
|
||||
import { cn } from "@lume/utils";
|
||||
|
||||
export function Notification({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { QuoteIcon } from "@lume/icons";
|
||||
import type { Event } from "@lume/types";
|
||||
import { Note } from "@lume/ui";
|
||||
import { Note } from "@/components/note";
|
||||
import { cn } from "@lume/utils";
|
||||
|
||||
export function Quote({
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Event } from "@lume/types";
|
||||
import { Note, Spinner, User } from "@lume/ui";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { Note } from "@/components/note";
|
||||
import { User } from "@/components/user";
|
||||
import { cn } from "@lume/utils";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Event } from "@lume/types";
|
||||
import { Note } from "@lume/ui";
|
||||
import { cn } from "@lume/utils";
|
||||
import { Note } from "@/components/note";
|
||||
|
||||
export function TextNote({
|
||||
event,
|
||||
|
||||
@@ -2,13 +2,16 @@ import { cn } from "@lume/utils";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Spinner } from "../spinner";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { useUserContext } from "./provider";
|
||||
|
||||
export function UserFollowButton({
|
||||
simple = false,
|
||||
className,
|
||||
}: { simple?: boolean; className?: string }) {
|
||||
}: {
|
||||
simple?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
const user = useUserContext();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Col } from "@/components/col";
|
||||
import { Column } from "@/components/column";
|
||||
import { Toolbar } from "@/components/toolbar";
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "@lume/icons";
|
||||
import type { EventColumns, LumeColumn } from "@lume/types";
|
||||
@@ -13,20 +13,19 @@ import { useDebouncedCallback } from "use-debounce";
|
||||
import { VList, type VListHandle } from "virtua";
|
||||
|
||||
export const Route = createFileRoute("/$account/home")({
|
||||
beforeLoad: async ({ context }) => {
|
||||
loader: async ({ context }) => {
|
||||
try {
|
||||
const ark = context.ark;
|
||||
const resourcePath = await resolveResource(
|
||||
"resources/system_columns.json",
|
||||
);
|
||||
const systemColumns: LumeColumn[] = JSON.parse(
|
||||
await readTextFile(resourcePath),
|
||||
);
|
||||
const userColumns = await ark.get_columns();
|
||||
const userColumns = await context.ark.get_columns();
|
||||
if (userColumns.length > 0) {
|
||||
return userColumns;
|
||||
} else {
|
||||
const systemPath = "resources/system_columns.json";
|
||||
const resourcePath = await resolveResource(systemPath);
|
||||
const resourceFile = await readTextFile(resourcePath);
|
||||
const systemColumns: LumeColumn[] = JSON.parse(resourceFile);
|
||||
|
||||
return {
|
||||
storedColumns: !userColumns.length ? systemColumns : userColumns,
|
||||
};
|
||||
return systemColumns;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(String(e));
|
||||
}
|
||||
@@ -35,13 +34,14 @@ export const Route = createFileRoute("/$account/home")({
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const userSavedColumns = Route.useLoaderData();
|
||||
const vlistRef = useRef<VListHandle>(null);
|
||||
|
||||
const { account } = Route.useParams();
|
||||
const { ark, storedColumns } = Route.useRouteContext();
|
||||
const { ark } = Route.useRouteContext();
|
||||
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const [columns, setColumns] = useState(storedColumns);
|
||||
const [columns, setColumns] = useState([]);
|
||||
const [isScroll, setIsScroll] = useState(false);
|
||||
const [isResize, setIsResize] = useState(false);
|
||||
|
||||
@@ -114,6 +114,10 @@ function Screen() {
|
||||
150,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setColumns(userSavedColumns);
|
||||
}, [userSavedColumns]);
|
||||
|
||||
useEffect(() => {
|
||||
// save state
|
||||
ark.set_columns(columns);
|
||||
@@ -148,9 +152,10 @@ function Screen() {
|
||||
onScroll={() => setIsScroll(true)}
|
||||
onScrollEnd={() => setIsScroll(false)}
|
||||
className="scrollbar-none h-full w-full overflow-x-auto focus:outline-none"
|
||||
cache={null}
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<Col
|
||||
<Column
|
||||
key={column.label}
|
||||
column={column}
|
||||
account={account}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { BellIcon, ComposeFilledIcon, PlusIcon, SearchIcon } from "@lume/icons";
|
||||
import { Event, Kind } from "@lume/types";
|
||||
import { User } from "@lume/ui";
|
||||
import {
|
||||
BellIcon,
|
||||
ComposeFilledIcon,
|
||||
HorizontalDotsIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
} from "@lume/icons";
|
||||
import { type Event, Kind } from "@lume/types";
|
||||
import { User } from "@/components/user";
|
||||
import {
|
||||
cn,
|
||||
decodeZapInvoice,
|
||||
@@ -10,12 +16,14 @@ import {
|
||||
import { Outlet, createFileRoute } from "@tanstack/react-router";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { getCurrent } from "@tauri-apps/api/window";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
|
||||
export const Route = createFileRoute("/$account")({
|
||||
beforeLoad: async ({ context }) => {
|
||||
const ark = context.ark;
|
||||
const accounts = await ark.get_all_accounts();
|
||||
const accounts = await ark.get_accounts();
|
||||
|
||||
return { accounts };
|
||||
},
|
||||
@@ -39,8 +47,8 @@ function Screen() {
|
||||
<Accounts />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate({ to: "/landing" })}
|
||||
className="inline-flex size-8 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"
|
||||
onClick={() => navigate({ 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"
|
||||
>
|
||||
<PlusIcon className="size-5" />
|
||||
</button>
|
||||
@@ -73,43 +81,110 @@ function Screen() {
|
||||
}
|
||||
|
||||
function Accounts() {
|
||||
const navigate = Route.useNavigate();
|
||||
const { ark, accounts } = Route.useRouteContext();
|
||||
const { account } = Route.useParams();
|
||||
|
||||
const changeAccount = async (npub: string) => {
|
||||
if (npub === account) return;
|
||||
const [windowWidth, setWindowWidth] = useState<number>(null);
|
||||
|
||||
const select = await ark.load_selected_account(npub);
|
||||
const navigate = Route.useNavigate();
|
||||
const sortedList = useMemo(() => {
|
||||
const list = accounts;
|
||||
|
||||
for (const [i, item] of list.entries()) {
|
||||
if (item === account) {
|
||||
list.splice(i, 1);
|
||||
list.unshift(item);
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}, [accounts]);
|
||||
|
||||
const changeAccount = async (npub: string) => {
|
||||
if (npub === account) {
|
||||
return await ark.open_profile(account);
|
||||
}
|
||||
|
||||
// change current account and update signer
|
||||
const select = await ark.load_account(npub);
|
||||
|
||||
if (select) {
|
||||
return navigate({ to: "/$account/home", params: { account: npub } });
|
||||
} else {
|
||||
toast.warning("Something wrong.");
|
||||
}
|
||||
};
|
||||
|
||||
const getWindowDimensions = () => {
|
||||
const { innerWidth: width, innerHeight: height } = window;
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
function handleResize() {
|
||||
setWindowWidth(getWindowDimensions().width);
|
||||
}
|
||||
|
||||
if (!windowWidth) setWindowWidth(getWindowDimensions().width);
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div data-tauri-drag-region className="flex items-center gap-3">
|
||||
{accounts.map((user) => (
|
||||
<button key={user} type="button" onClick={() => changeAccount(user)}>
|
||||
<User.Provider pubkey={user}>
|
||||
<User.Root
|
||||
className={cn(
|
||||
"rounded-full",
|
||||
user === account
|
||||
? "ring-1 ring-teal-500 ring-offset-2 ring-offset-neutral-200 dark:ring-offset-neutral-950"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
<User.Avatar
|
||||
{sortedList
|
||||
.slice(0, windowWidth > 500 ? account.length : 2)
|
||||
.map((user) => (
|
||||
<button key={user} type="button" onClick={() => changeAccount(user)}>
|
||||
<User.Provider pubkey={user}>
|
||||
<User.Root
|
||||
className={cn(
|
||||
"aspect-square h-auto rounded-full object-cover",
|
||||
user === account ? "w-7" : "w-8",
|
||||
"shrink-0 rounded-full transition-all ease-in-out duration-150 will-change-auto",
|
||||
user === account
|
||||
? "ring-1 ring-teal-500 ring-offset-2 ring-offset-neutral-200 dark:ring-offset-neutral-950"
|
||||
: "",
|
||||
)}
|
||||
/>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</button>
|
||||
))}
|
||||
>
|
||||
<User.Avatar
|
||||
className={cn(
|
||||
"aspect-square h-auto rounded-full object-cover transition-all ease-in-out duration-150 will-change-auto",
|
||||
user === account ? "w-7" : "w-8",
|
||||
)}
|
||||
/>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</button>
|
||||
))}
|
||||
{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">
|
||||
<HorizontalDotsIcon className="size-5" />
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content className="flex h-11 select-none items-center justify-center rounded-md bg-neutral-950 p-1 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">
|
||||
{sortedList.slice(2).map((user) => (
|
||||
<button
|
||||
key={user}
|
||||
type="button"
|
||||
onClick={() => changeAccount(user)}
|
||||
className="size-9 inline-flex items-center justify-center hover:bg-white/10 rounded-md"
|
||||
>
|
||||
<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.Root>
|
||||
</User.Provider>
|
||||
</button>
|
||||
))}
|
||||
<Popover.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { Ark } from "@lume/ark";
|
||||
import { CheckCircleIcon, InfoCircleIcon, CancelCircleIcon } from "@lume/icons";
|
||||
import type { Interests, Metadata, Settings } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
|
||||
import type { Platform } from "@tauri-apps/plugin-os";
|
||||
import type { Descendant } from "slate";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
type EditorElement = {
|
||||
type: string;
|
||||
@@ -31,7 +33,21 @@ interface RouterContext {
|
||||
}
|
||||
|
||||
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||
component: () => <Outlet />,
|
||||
component: () => (
|
||||
<>
|
||||
<Toaster
|
||||
position="bottom-right"
|
||||
icons={{
|
||||
success: <CheckCircleIcon className="size-5" />,
|
||||
info: <InfoCircleIcon className="size-5" />,
|
||||
error: <CancelCircleIcon className="size-5" />,
|
||||
}}
|
||||
closeButton
|
||||
theme="system"
|
||||
/>
|
||||
<Outlet />
|
||||
</>
|
||||
),
|
||||
pendingComponent: Pending,
|
||||
wrapInSuspense: true,
|
||||
});
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/activity/$account/messages")({
|
||||
component: () => <div>Hello /activity/$account/messages!</div>,
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Note, Spinner } from "@lume/ui";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { Note } from "@/components/note";
|
||||
import { Await, createFileRoute, defer } from "@tanstack/react-router";
|
||||
import { Suspense } from "react";
|
||||
import { Virtualizer } from "virtua";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Note, Spinner, User } from "@lume/ui";
|
||||
import { User } from "@/components/user";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { decodeZapInvoice } from "@lume/utils";
|
||||
import { Await, createFileRoute, defer } from "@tanstack/react-router";
|
||||
import { Suspense } from "react";
|
||||
|
||||
@@ -60,14 +60,21 @@ function Screen() {
|
||||
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
disabled={loading}
|
||||
className="mt-3 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"
|
||||
>
|
||||
{loading ? <Spinner /> : "Login"}
|
||||
</button>
|
||||
<div className="flex flex-col gap-1 items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
disabled={loading}
|
||||
className="mt-3 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"
|
||||
>
|
||||
{loading ? <Spinner /> : "Login"}
|
||||
</button>
|
||||
{loading ? (
|
||||
<p className="text-neutral-600 dark:text-neutral-400 text-sm text-center">
|
||||
Waiting confirmation...
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { CheckCircleIcon } from "@lume/icons";
|
||||
import { CancelIcon, CheckCircleIcon, PlusIcon } from "@lume/icons";
|
||||
import type { ColumnRouteSearch } from "@lume/types";
|
||||
import { Spinner, User } from "@lume/ui";
|
||||
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { User } from "@/components/user";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -23,103 +24,173 @@ export const Route = createFileRoute("/create-group")({
|
||||
|
||||
function Screen() {
|
||||
const contacts = Route.useLoaderData();
|
||||
const router = useRouter();
|
||||
const navigate = Route.useNavigate();
|
||||
|
||||
const { ark } = Route.useRouteContext();
|
||||
const { label, redirect } = Route.useSearch();
|
||||
|
||||
const [title, setTitle] = useState<string>("Just a new group");
|
||||
const [users, setUsers] = useState<Array<string>>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isDone, setIsDone] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
const [npub, setNpub] = useState("");
|
||||
const [users, setUsers] = useState<string[]>([
|
||||
"npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445", // reya
|
||||
]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const toggleUser = (pubkey: string) => {
|
||||
const arr = users.includes(pubkey)
|
||||
? users.filter((i) => i !== pubkey)
|
||||
: [...users, pubkey];
|
||||
setUsers(arr);
|
||||
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 = async () => {
|
||||
try {
|
||||
if (isDone) return router.history.push(redirect);
|
||||
setIsLoading(true);
|
||||
|
||||
// start loading
|
||||
setLoading(true);
|
||||
const key = `lume_group_${label}`;
|
||||
const createGroup = await ark.set_nstore(key, JSON.stringify(users));
|
||||
|
||||
const groups = await ark.set_nstore(
|
||||
`lume_group_${label}`,
|
||||
JSON.stringify(users),
|
||||
);
|
||||
|
||||
if (groups) {
|
||||
toast.success("Group has been created successfully.");
|
||||
// start loading
|
||||
setIsDone(true);
|
||||
setLoading(false);
|
||||
if (createGroup) {
|
||||
return navigate({ to: redirect });
|
||||
}
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
setIsLoading(false);
|
||||
toast.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto scrollbar-none">
|
||||
<div className="flex flex-col gap-5 p-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="name" className="font-medium">
|
||||
<div className="w-full h-full flex flex-col items-center justify-center gap-4">
|
||||
<div className="text-center flex flex-col items-center justify-center">
|
||||
<h1 className="text-2xl font-serif font-medium">
|
||||
Focus feeds for people you like
|
||||
</h1>
|
||||
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
|
||||
Add some people for custom feeds.
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-4/5 max-w-full flex flex-col gap-3">
|
||||
<div className="w-full h-9 shrink-0 flex items-center bg-black/5 dark:bg-white/5 rounded-lg">
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="w-16 border-r border-black/10 dark:border-white/10 shrink-0 text-center text-sm font-semibold"
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
name="name"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Nostrichs..."
|
||||
className="h-10 rounded-lg bg-transparent border border-neutral-300 dark:border-neutral-700 px-3 placeholder:text-neutral-600 focus:border-neutral-500 focus:ring-0 dark:placeholder:text-neutral-400"
|
||||
placeholder="Enter a name for this group"
|
||||
className="h-full bg-transparent border-none text-sm px-3 placeholder:text-neutral-600 focus:border-neutral-500 focus:ring-0 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="inline-flex items-center justify-between">
|
||||
<span className="font-medium">Pick user</span>
|
||||
<span className="text-neutral-600 dark:text-neutral-400">{`${users.length} / ∞`}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{contacts.map((item: string) => (
|
||||
<div className="w-full flex flex-col items-center 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 backdrop-blur-lg rounded-xl">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
name="npub"
|
||||
value={npub}
|
||||
onChange={(e) => setNpub(e.target.value)}
|
||||
placeholder="npub1..."
|
||||
className="h-9 w-full rounded-lg bg-black/10 dark:bg-white/10 border-none text-sm px-3 placeholder:text-neutral-600 focus:border-neutral-500 focus:ring-0 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
onClick={() => toggleUser(item)}
|
||||
className="inline-flex items-center justify-between px-3 py-2 rounded-lg bg-black/10 dark:bg-white/10 hover:bg-black/20 dark:hover:bg-white/20"
|
||||
onClick={() => addUser()}
|
||||
className="inline-flex size-9 rounded-lg items-center justify-center bg-black/20 dark:bg-white/20 shrink-0 text-white hover:bg-blue-500"
|
||||
>
|
||||
<User.Provider pubkey={item}>
|
||||
<User.Root className="flex items-center gap-2.5">
|
||||
<User.Avatar className="size-10 rounded-full object-cover" />
|
||||
<div className="flex items-center gap-1">
|
||||
<User.Name className="font-medium" />
|
||||
<User.NIP05 />
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
{users.includes(item) ? (
|
||||
<CheckCircleIcon className="size-5 text-teal-500" />
|
||||
) : null}
|
||||
<PlusIcon className="size-6" />
|
||||
</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 rounded-lg bg-white dark:bg-black/20 backdrop-blur-lg 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="size-8 rounded-full object-cover" />
|
||||
<div className="flex items-center gap-1">
|
||||
<User.Name className="text-sm font-medium" />
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<div>
|
||||
<CancelIcon className="size-4" />
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="bg-black/5 dark:bg-white/5 text-sm flex items-center justify-center h-14 rounded-lg">
|
||||
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 rounded-lg bg-white dark:bg-black/20 backdrop-blur-lg 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="size-8 rounded-full object-cover" />
|
||||
<div className="flex items-center gap-1">
|
||||
<User.Name className="text-sm font-medium" />
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="bg-black/5 dark:bg-white/5 text-sm flex items-center justify-center h-14 rounded-lg">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div className="fixed z-10 flex items-center justify-center w-full bottom-6">
|
||||
{users.length >= 1 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
disabled={users.length < 1}
|
||||
className="inline-flex items-center justify-center px-4 font-medium text-white transform bg-blue-500 rounded-full active:translate-y-1 w-32 h-10 hover:bg-blue-600 focus:outline-none"
|
||||
disabled={isLoading || users.length < 1}
|
||||
className="inline-flex items-center justify-center w-36 rounded-full h-9 bg-blue-500 text-white text-sm font-medium hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{isDone ? "Back" : loading ? <Spinner /> : "Update"}
|
||||
{isLoading ? <Spinner /> : "Confirm"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { ColumnRouteSearch } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/create-newsfeed/f2f")({
|
||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||
return {
|
||||
account: search.account,
|
||||
label: search.label,
|
||||
name: search.name,
|
||||
};
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const navigate = Route.useNavigate();
|
||||
const { redirect } = Route.useSearch();
|
||||
|
||||
const [npub, setNpub] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const submit = async () => {
|
||||
if (!npub.startsWith("npub1"))
|
||||
return toast.warning("You must enter a valid npub.");
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const sync: boolean = await invoke("friend_to_friend", { npub });
|
||||
|
||||
if (sync) {
|
||||
return navigate({ to: redirect });
|
||||
}
|
||||
} catch (e) {
|
||||
setIsLoading(false);
|
||||
toast.error(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-y-auto scrollbar-none p-2 shrink-0 h-[450px] bg-white dark:bg-white/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50">
|
||||
<div className="h-full flex flex-col justify-between">
|
||||
<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="font-medium text-sm">
|
||||
NPUB
|
||||
</label>
|
||||
<input
|
||||
name="npub"
|
||||
placeholder="npub1..."
|
||||
value={npub}
|
||||
onChange={(e) => setNpub(e.target.value)}
|
||||
spellCheck={false}
|
||||
className="h-11 rounded-lg bg-transparent border border-neutral-200 dark:border-neutral-800 px-3 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 rounded-lg h-9 bg-blue-500 text-white text-sm font-medium hover:bg-blue-600"
|
||||
>
|
||||
{isLoading ? <Spinner /> : "Confirm"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { cn } from "@lume/utils";
|
||||
import { Link, Outlet } from "@tanstack/react-router";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/create-newsfeed")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center gap-4">
|
||||
<div className="text-center flex flex-col items-center justify-center">
|
||||
<h1 className="text-2xl font-serif 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="w-4/5 max-w-full flex flex-col 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="/create-newsfeed/users" 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="/create-newsfeed/f2f" 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",
|
||||
)}
|
||||
>
|
||||
Friend to Friend
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Suspense, useState } from "react";
|
||||
import { Await, defer } from "@tanstack/react-router";
|
||||
import { User } from "@/components/user";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { toast } from "sonner";
|
||||
import type { ColumnRouteSearch } from "@lume/types";
|
||||
|
||||
export const Route = createFileRoute("/create-newsfeed/users")({
|
||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||
return {
|
||||
account: search.account,
|
||||
label: search.label,
|
||||
name: search.name,
|
||||
};
|
||||
},
|
||||
loader: async ({ abortController }) => {
|
||||
try {
|
||||
return {
|
||||
data: defer(
|
||||
fetch("https://api.nostr.band/v0/trending/profiles", {
|
||||
signal: abortController.signal,
|
||||
}).then((res) => res.json()),
|
||||
),
|
||||
};
|
||||
} catch (e) {
|
||||
throw new Error(String(e));
|
||||
}
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { ark } = Route.useRouteContext();
|
||||
const { data } = Route.useLoaderData();
|
||||
const { redirect } = Route.useSearch();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [follows, setFollows] = useState<string[]>([]);
|
||||
|
||||
const navigate = Route.useNavigate();
|
||||
|
||||
const toggleFollow = (pubkey: string) => {
|
||||
setFollows((prev) =>
|
||||
prev.includes(pubkey)
|
||||
? prev.filter((i) => i !== pubkey)
|
||||
: [...prev, pubkey],
|
||||
);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const newContactList = await ark.set_contact_list(follows);
|
||||
|
||||
if (newContactList) {
|
||||
return navigate({ to: redirect });
|
||||
}
|
||||
} catch (e) {
|
||||
setIsLoading(false);
|
||||
toast.error(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col items-center gap-3">
|
||||
<div className="overflow-y-auto scrollbar-none p-2 w-full h-[450px] bg-black/5 dark:bg-white/5 backdrop-blur-lg rounded-xl">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 text-sm font-medium"
|
||||
disabled
|
||||
>
|
||||
<Spinner className="size-5" />
|
||||
Loading...
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Await promise={data}>
|
||||
{(users) =>
|
||||
users.profiles.map((item: { pubkey: string }) => (
|
||||
<div
|
||||
key={item.pubkey}
|
||||
className="h-max w-full overflow-hidden mb-2 p-2 bg-white dark:bg-black/20 backdrop-blur-lg rounded-lg shadow-primary dark:ring-1 ring-neutral-800/50"
|
||||
>
|
||||
<User.Provider pubkey={item.pubkey}>
|
||||
<User.Root>
|
||||
<div className="flex h-full w-full flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<User.Avatar className="size-7 shrink-0 rounded-full object-cover" />
|
||||
<User.Name className="text-sm leadning-tight max-w-[15rem] truncate font-semibold" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleFollow(item.pubkey)}
|
||||
className="inline-flex h-7 w-20 items-center justify-center rounded-lg bg-black/10 text-sm font-medium hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
|
||||
>
|
||||
{follows.includes(item.pubkey)
|
||||
? "Unfollow"
|
||||
: "Follow"}
|
||||
</button>
|
||||
</div>
|
||||
<User.About className="line-clamp-3 max-w-none select-text text-neutral-800 dark:text-neutral-400" />
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</Await>
|
||||
</Suspense>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
disabled={isLoading || follows.length < 1}
|
||||
className="inline-flex items-center justify-center w-36 rounded-full h-9 bg-blue-500 text-white text-sm font-medium hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? <Spinner /> : "Confirm"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { CheckCircleIcon } from "@lume/icons";
|
||||
import type { ColumnRouteSearch, Topic } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { TOPICS } from "@lume/utils";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/create-topic")({
|
||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||
return {
|
||||
account: search.account,
|
||||
label: search.label,
|
||||
name: search.name,
|
||||
};
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { label, redirect } = Route.useSearch();
|
||||
const { ark } = Route.useRouteContext();
|
||||
|
||||
const [topics, setTopics] = useState<Topic[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const navigate = Route.useNavigate();
|
||||
|
||||
const toggleTopic = (topic: Topic) => {
|
||||
setTopics((prev) =>
|
||||
prev.find((item) => item.title === topic.title)
|
||||
? prev.filter((i) => i.title !== topic.title)
|
||||
: [...prev, topic],
|
||||
);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const key = `lume_topic_${label}`;
|
||||
const createTopic = await ark.set_nstore(key, JSON.stringify(topics));
|
||||
|
||||
if (createTopic) {
|
||||
return navigate({ to: redirect });
|
||||
}
|
||||
} catch (e) {
|
||||
setIsLoading(false);
|
||||
toast.error(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center gap-4">
|
||||
<div className="text-center flex flex-col items-center justify-center">
|
||||
<h1 className="text-2xl font-serif font-medium">
|
||||
What are your interests?
|
||||
</h1>
|
||||
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
|
||||
Add some topics you want to focus on.
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-4/5 max-w-full flex flex-col 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-3">
|
||||
<span className="text-sm font-medium">Added: {topics.length}</span>
|
||||
</div>
|
||||
<div className="w-full flex flex-col items-center gap-3">
|
||||
<div className="overflow-y-auto scrollbar-none p-2 w-full h-[450px] bg-black/5 dark:bg-white/5 backdrop-blur-lg rounded-xl">
|
||||
<div className="flex flex-col gap-3">
|
||||
{TOPICS.map((topic) => (
|
||||
<button
|
||||
key={topic.title}
|
||||
onClick={() => toggleTopic(topic)}
|
||||
className="h-11 px-3 flex items-center justify-between bg-white dark:bg-black/20 backdrop-blur-lg border border-transparent hover:border-blue-500 rounded-lg shadow-primary dark:ring-1 ring-neutral-800/50"
|
||||
>
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<div>{topic.icon}</div>
|
||||
<div className="text-sm font-medium">
|
||||
<span>{topic.title}</span>
|
||||
<span className="ml-1 italic text-neutral-400 dark:text-neutral-600 font-normal">
|
||||
{topic.content.length} hashtags
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{topics.find((item) => item.title === topic.title) ? (
|
||||
<CheckCircleIcon className="text-teal-500 size-4" />
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
disabled={isLoading || topics.length < 1}
|
||||
className="inline-flex items-center justify-center w-36 rounded-full h-9 bg-blue-500 text-white text-sm font-medium hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? <Spinner /> : "Confirm"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,10 +4,10 @@ import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { User } from "@lume/ui";
|
||||
import { useSlateStatic } from "slate-react";
|
||||
import type { Contact } from "@lume/types";
|
||||
import { toast } from "sonner";
|
||||
import { User } from "@/components/user";
|
||||
|
||||
export function MentionButton({ className }: { className?: string }) {
|
||||
const editor = useSlateStatic();
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ComposeFilledIcon, TrashIcon } from "@lume/icons";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { MentionNote } from "@lume/ui/src/note/mentions/note";
|
||||
import {
|
||||
cn,
|
||||
insertImage,
|
||||
@@ -25,6 +24,8 @@ import {
|
||||
import { MediaButton } from "./-components/media";
|
||||
import { NsfwToggle } from "./-components/nsfw";
|
||||
import { MentionButton } from "./-components/mention";
|
||||
import { MentionNote } from "@/components/note/mentions/note";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type EditorSearch = {
|
||||
reply_to: string;
|
||||
@@ -35,7 +36,7 @@ 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 === "true" || false,
|
||||
};
|
||||
},
|
||||
beforeLoad: async ({ search }) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEvent } from "@lume/ark";
|
||||
import type { Event } from "@lume/types";
|
||||
import { Box, Container, Note, Spinner } from "@lume/ui";
|
||||
import { Box, Container, Spinner } from "@lume/ui";
|
||||
import { Note } from "@/components/note";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { WindowVirtualizer } from "virtua";
|
||||
import { ReplyList } from "./-components/replyList";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { EventWithReplies } from "@lume/types";
|
||||
import { Note } from "@lume/ui";
|
||||
import { cn } from "@lume/utils";
|
||||
import { SubReply } from "./subReply";
|
||||
import { Note } from "@/components/note";
|
||||
|
||||
export function Reply({ event }: { event: EventWithReplies }) {
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Event } from "@lume/types";
|
||||
import { Note } from "@lume/ui";
|
||||
import { Note } from "@/components/note";
|
||||
|
||||
export function SubReply({ event }: { event: Event; rootEventId?: string }) {
|
||||
return (
|
||||
|
||||
@@ -27,7 +27,7 @@ export const Route = createFileRoute("/global")({
|
||||
});
|
||||
|
||||
export function Screen() {
|
||||
const { account } = Route.useSearch();
|
||||
const { label, account } = Route.useSearch();
|
||||
const { ark } = Route.useRouteContext();
|
||||
const {
|
||||
data,
|
||||
@@ -37,16 +37,13 @@ export function Screen() {
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: ["global", account],
|
||||
queryKey: [label, account],
|
||||
initialPageParam: 0,
|
||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||
const events = await ark.get_events(20, pageParam, undefined, true);
|
||||
const events = await ark.get_global_events(20, pageParam);
|
||||
return events;
|
||||
},
|
||||
getNextPageParam: (lastPage) => {
|
||||
const lastEvent = lastPage?.at(-1);
|
||||
return lastEvent ? lastEvent.created_at - 1 : null;
|
||||
},
|
||||
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
|
||||
select: (data) => data?.pages.flatMap((page) => page),
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
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";
|
||||
@@ -17,12 +19,11 @@ export const Route = createFileRoute("/group")({
|
||||
},
|
||||
beforeLoad: async ({ search, context }) => {
|
||||
const ark = context.ark;
|
||||
const groups = (await ark.get_nstore(
|
||||
`lume_group_${search.label}`,
|
||||
)) as string[];
|
||||
const key = `lume_group_${search.label}`;
|
||||
const groups = (await ark.get_nstore(key)) as string[];
|
||||
const settings = await ark.get_settings();
|
||||
|
||||
if (!groups) {
|
||||
if (!groups?.length) {
|
||||
throw redirect({
|
||||
to: "/create-group",
|
||||
search: {
|
||||
@@ -41,7 +42,7 @@ export const Route = createFileRoute("/group")({
|
||||
});
|
||||
|
||||
export function Screen() {
|
||||
const { name, account } = Route.useSearch();
|
||||
const { label, account } = Route.useSearch();
|
||||
const { ark, groups } = Route.useRouteContext();
|
||||
const {
|
||||
data,
|
||||
@@ -51,16 +52,13 @@ export function Screen() {
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: [name, account],
|
||||
queryKey: [label, account],
|
||||
initialPageParam: 0,
|
||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||
const events = await ark.get_events(20, pageParam, groups);
|
||||
const events = await ark.get_local_events(groups, 20, pageParam);
|
||||
return events;
|
||||
},
|
||||
getNextPageParam: (lastPage) => {
|
||||
const lastEvent = lastPage?.at(-1);
|
||||
return lastEvent ? lastEvent.created_at - 1 : null;
|
||||
},
|
||||
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
|
||||
select: (data) =>
|
||||
data?.pages.flatMap((page) => page.filter((ev) => ev.kind === Kind.Text)),
|
||||
refetchOnWindowFocus: false,
|
||||
@@ -71,15 +69,29 @@ export function Screen() {
|
||||
switch (event.kind) {
|
||||
case Kind.Repost:
|
||||
return <RepostNote key={event.id} event={event} />;
|
||||
default:
|
||||
return <TextNote 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 (isQuote) {
|
||||
return <Quote key={event.id} event={event} className="mb-3" />;
|
||||
}
|
||||
|
||||
return <TextNote key={event.id} event={event} className="mb-3" />;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none">
|
||||
{isFetching && !isLoading && !isFetchingNextPage ? (
|
||||
<div className="w-full h-11 flex items-center justify-center">
|
||||
<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 gap-2">
|
||||
<Spinner className="size-5" />
|
||||
<span className="text-sm font-medium">Fetching new notes...</span>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PlusIcon } from "@lume/icons";
|
||||
import { Spinner, User } from "@lume/ui";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { User } from "@/components/user";
|
||||
import { checkForAppUpdates } from "@lume/utils";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
@@ -13,11 +14,11 @@ export const Route = createFileRoute("/")({
|
||||
await checkForAppUpdates(true);
|
||||
|
||||
const ark = context.ark;
|
||||
const accounts = await ark.get_all_accounts();
|
||||
const accounts = await ark.get_accounts();
|
||||
|
||||
if (!accounts.length) {
|
||||
throw redirect({
|
||||
to: "/landing",
|
||||
to: "/landing/",
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
@@ -40,7 +41,7 @@ function Screen() {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const loadAccount = await ark.load_selected_account(npub);
|
||||
const loadAccount = await ark.load_account(npub);
|
||||
if (loadAccount) {
|
||||
return navigate({
|
||||
to: "/$account/home",
|
||||
@@ -67,7 +68,7 @@ function Screen() {
|
||||
<h2 className="mb-1 text-2xl">{currentDate}</h2>
|
||||
<h2 className="text-2xl font-semibold">Welcome back!</h2>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-6">
|
||||
<div className="flex flex-wrap px-3 items-center justify-center gap-6">
|
||||
{loading ? (
|
||||
<div className="inline-flex size-6 items-center justify-center">
|
||||
<Spinner className="size-6 text-white" />
|
||||
@@ -88,10 +89,10 @@ function Screen() {
|
||||
</User.Provider>
|
||||
</button>
|
||||
))}
|
||||
<Link to="/landing">
|
||||
<Link to="/landing/">
|
||||
<div className="flex h-36 w-32 flex-col items-center justify-center gap-4 rounded-2xl p-2 text-white hover:bg-white/10 dark:hover:bg-black/10">
|
||||
<div className="flex size-20 items-center justify-center rounded-full bg-white/20 dark:bg-black/20">
|
||||
<PlusIcon className="size-5" />
|
||||
<PlusIcon className="size-8" />
|
||||
</div>
|
||||
<p className="text-lg font-medium leading-tight">Add</p>
|
||||
</div>
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import type { ColumnRouteSearch } from "@lume/types";
|
||||
import { TOPICS, cn } from "@lume/utils";
|
||||
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/interests")({
|
||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||
return {
|
||||
account: search.account,
|
||||
label: search.label,
|
||||
name: search.name,
|
||||
};
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { t } = useTranslation();
|
||||
const { label, name, redirect } = Route.useSearch();
|
||||
const { ark } = Route.useRouteContext();
|
||||
|
||||
const [hashtags, setHashtags] = useState<string[]>([]);
|
||||
const [isDone, setIsDone] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const toggleHashtag = (item: string) => {
|
||||
const arr = hashtags.includes(item)
|
||||
? hashtags.filter((i) => i !== item)
|
||||
: [...hashtags, item];
|
||||
setHashtags(arr);
|
||||
};
|
||||
|
||||
const toggleAll = (item: string[]) => {
|
||||
const sets = new Set([...hashtags, ...item]);
|
||||
setHashtags([...sets]);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
if (isDone) {
|
||||
return router.history.push(redirect);
|
||||
}
|
||||
|
||||
const eventId = await ark.set_interest(undefined, undefined, hashtags);
|
||||
if (eventId) {
|
||||
setIsDone(true);
|
||||
toast.success("Interest has been updated successfully.");
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col px-2">
|
||||
<div className="shrink-0 flex h-16 items-center justify-between">
|
||||
<div className="flex flex-1 flex-col">
|
||||
<h3 className="font-semibold">Interests</h3>
|
||||
<p className="text-sm leading-tight text-neutral-700 dark:text-neutral-300">
|
||||
Pick things you'd like to see.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
className="inline-flex h-8 w-20 items-center justify-center rounded-full bg-blue-500 px-2 text-sm font-medium text-white hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{isDone ? t("global.back") : t("global.update")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-3 pb-2 scrollbar-none overflow-y-auto">
|
||||
{TOPICS.map((topic) => (
|
||||
<div
|
||||
key={topic.title}
|
||||
className="flex flex-col gap-4 bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50"
|
||||
>
|
||||
<div className="px-3 flex w-full items-center justify-between h-14 shrink-0 border-b border-neutral-100 dark:border-neutral-900">
|
||||
<div className="inline-flex items-center gap-2.5">
|
||||
<img
|
||||
src={topic.icon}
|
||||
alt={topic.title}
|
||||
className="size-8 rounded-lg object-cover"
|
||||
/>
|
||||
<h3 className="text-lg font-semibold">{topic.title}</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleAll(topic.content)}
|
||||
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
||||
>
|
||||
{t("interests.followAll")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-3 pb-3 flex flex-wrap items-center gap-3">
|
||||
{topic.content.map((hashtag) => (
|
||||
<button
|
||||
key={hashtag}
|
||||
type="button"
|
||||
onClick={() => toggleHashtag(hashtag)}
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full border border-transparent bg-neutral-100 px-2 py-1 text-sm font-medium dark:bg-neutral-900",
|
||||
hashtags.includes(hashtag)
|
||||
? "border-blue-500 text-blue-500"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
{hashtag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
|
||||
import { type ColumnRouteSearch, type Event, Kind } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Link, redirect } from "@tanstack/react-router";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Virtualizer } from "virtua";
|
||||
|
||||
@@ -18,18 +18,29 @@ export const Route = createFileRoute("/newsfeed")({
|
||||
name: search.name,
|
||||
};
|
||||
},
|
||||
beforeLoad: async ({ context }) => {
|
||||
beforeLoad: async ({ search, context }) => {
|
||||
const ark = context.ark;
|
||||
const settings = await ark.get_settings();
|
||||
const contacts = await ark.get_contact_list();
|
||||
|
||||
return { settings };
|
||||
if (!contacts.length) {
|
||||
throw redirect({
|
||||
to: "/create-newsfeed/users",
|
||||
search: {
|
||||
...search,
|
||||
redirect: "/newsfeed",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { settings, contacts };
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
export function Screen() {
|
||||
const { label, account } = Route.useSearch();
|
||||
const { ark } = Route.useRouteContext();
|
||||
const { ark, contacts } = Route.useRouteContext();
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
@@ -41,13 +52,10 @@ export function Screen() {
|
||||
queryKey: [label, account],
|
||||
initialPageParam: 0,
|
||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||
const events = await ark.get_events(20, pageParam);
|
||||
const events = await ark.get_local_events(contacts, 20, pageParam);
|
||||
return events;
|
||||
},
|
||||
getNextPageParam: (lastPage) => {
|
||||
const lastEvent = lastPage?.at(-1);
|
||||
return lastEvent ? lastEvent.created_at - 1 : null;
|
||||
},
|
||||
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
|
||||
select: (data) => data?.pages.flatMap((page) => page),
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
@@ -79,7 +87,7 @@ export function Screen() {
|
||||
return (
|
||||
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none">
|
||||
{isFetching && !isLoading && !isFetchingNextPage ? (
|
||||
<div className="w-full h-11 flex items-center justify-center">
|
||||
<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 gap-2">
|
||||
<Spinner className="size-5" />
|
||||
<span className="text-sm font-medium">Fetching new notes...</span>
|
||||
@@ -92,7 +100,9 @@ export function Screen() {
|
||||
<span className="text-sm font-medium">Loading...</span>
|
||||
</div>
|
||||
) : !data.length ? (
|
||||
<Empty />
|
||||
<div className="flex items-center justify-center">
|
||||
Yo. You're catching up on all the things happening around you.
|
||||
</div>
|
||||
) : (
|
||||
<Virtualizer overscan={3}>
|
||||
{data.map((item) => renderItem(item))}
|
||||
@@ -120,35 +130,3 @@ export function Screen() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
import { ArrowRightIcon, CancelIcon } from "@lume/icons";
|
||||
import type { ColumnRouteSearch, LumeColumn } from "@lume/types";
|
||||
import { Spinner, User } from "@lume/ui";
|
||||
import { cn } from "@lume/utils";
|
||||
import type { ColumnRouteSearch } from "@lume/types";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { getCurrent } from "@tauri-apps/api/window";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/onboarding")({
|
||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||
@@ -21,54 +13,6 @@ export const Route = createFileRoute("/onboarding")({
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { label } = Route.useSearch();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { isValid, isSubmitting },
|
||||
} = useForm();
|
||||
|
||||
const [userType, setUserType] = useState<"new" | "veteran">(null);
|
||||
|
||||
const install = async (column: LumeColumn) => {
|
||||
const mainWindow = getCurrent();
|
||||
await mainWindow.emit("columns", { type: "add", column });
|
||||
};
|
||||
|
||||
const close = async () => {
|
||||
const mainWindow = getCurrent();
|
||||
await mainWindow.emit("columns", { type: "remove", label });
|
||||
};
|
||||
|
||||
const friendToFriend = async (data: { npub: string }) => {
|
||||
if (!data.npub.startsWith("npub1"))
|
||||
return toast.warning(
|
||||
"NPUB is invalid. NPUB must be starts with npub1...",
|
||||
);
|
||||
|
||||
try {
|
||||
const connect: boolean = await invoke("friend_to_friend", {
|
||||
npub: data.npub,
|
||||
});
|
||||
|
||||
if (connect) {
|
||||
const column = {
|
||||
label: "newsfeed",
|
||||
name: "Newsfeed",
|
||||
content: "/newsfeed",
|
||||
};
|
||||
const mainWindow = getCurrent();
|
||||
await mainWindow.emit("columns", { type: "add", column });
|
||||
|
||||
// reset form
|
||||
reset();
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
@@ -77,369 +21,92 @@ function Screen() {
|
||||
Here are a few suggestions to help you get started.
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-3">
|
||||
<div className="mb-6 w-full h-44">
|
||||
<img
|
||||
src="/lock-screen.jpg"
|
||||
srcSet="/lock-screen@2x.jpg 2x"
|
||||
alt="background"
|
||||
className="h-full w-full object-cover rounded-xl outline outline-1 -outline-offset-1 outline-black/15"
|
||||
/>
|
||||
<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 backdrop-blur-lg">
|
||||
<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
|
||||
>
|
||||
<source
|
||||
src="https://video.nostr.build/692f71e2be47ecfc29edcbdaa198cc5979bfb9c900f05d78682895dd546d8d4f.mp4"
|
||||
type="video/mp4"
|
||||
/>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-start gap-2 text-[13px]">
|
||||
<Mide />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h3 className="font-semibold">Mide</h3>
|
||||
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||
👋 Yo! I'm Mide, and I'll be your friendly guide to Nostr and
|
||||
beyond. Looking forward to our adventure together!
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex flex-col items-center justify-center rounded-xl bg-black/10 dark:bg-white/10 backdrop-blur-lg">
|
||||
<div className="absolute top-2 left-3 text-2xl font-semibold font-serif text-neutral-600 dark:text-neutral-400">
|
||||
02.
|
||||
</div>
|
||||
<div className="flex items-start flex-row-reverse gap-2 text-[13px]">
|
||||
<CurrentUser />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h3 className="font-semibold text-end">You</h3>
|
||||
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||
How can I get started?
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUserType("new")}
|
||||
className={cn(
|
||||
"mt-1 px-3 py-2 shadow-primary flex items-center justify-between gap-6 bg-white hover:bg-blue-500 hover:text-white dark:bg-white/10 rounded-lg",
|
||||
userType === "new"
|
||||
? "bg-blue-500 text-white hover:bg-blue-600"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
I'm completely new to Nostr.
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUserType("veteran")}
|
||||
className={cn(
|
||||
"mt-1 px-3 py-2 shadow-primary flex items-center justify-between gap-6 bg-white hover:bg-blue-500 hover:text-white dark:bg-white/10 rounded-lg",
|
||||
userType === "veteran"
|
||||
? "bg-blue-500 text-white hover:bg-blue-600"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
I've already been using another Nostr client.
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</button>
|
||||
</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
|
||||
>
|
||||
<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 backdrop-blur-lg">
|
||||
<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
|
||||
>
|
||||
<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 backdrop-blur-lg">
|
||||
<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
|
||||
>
|
||||
<source
|
||||
src="https://video.nostr.build/513de4824b6abaf7e9698c1dad2f68096574356848c0c200bc8cb8074df29410.mp4"
|
||||
type="video/mp4"
|
||||
/>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
{userType === "veteran" ? (
|
||||
<div className="flex items-start gap-2 text-[13px]">
|
||||
<Mide />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h3 className="font-semibold">Mide</h3>
|
||||
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||
So, I'm excited to give you a quick intro to Lume and all the
|
||||
awesome features it has to offer. Let's dive in!
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{userType === "veteran" ? (
|
||||
<div className="flex items-start flex-row-reverse gap-2 text-[13px]">
|
||||
<CurrentUser />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h3 className="font-semibold text-end">You</h3>
|
||||
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||
Thanks! But I already know about Lume.
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
install({
|
||||
label: "newsfeed",
|
||||
name: "Newsfeed",
|
||||
content: "/newsfeed",
|
||||
})
|
||||
}
|
||||
className="mt-1 px-3 py-2 shadow-primary flex items-center justify-between gap-6 bg-white hover:bg-blue-500 hover:text-white dark:bg-white/10 rounded-lg"
|
||||
>
|
||||
Skip! Show my newsfeed
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{userType === "veteran" ? (
|
||||
<div className="flex items-start gap-2 text-[13px]">
|
||||
<Mide />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h3 className="font-semibold">Mide</h3>
|
||||
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||
First off, Lume is a social media client for Nostr. It's a
|
||||
place where you can follow friends, dive into chats, and post
|
||||
what's on your mind.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{userType === "veteran" ? (
|
||||
<div className="flex items-start gap-2 text-[13px]">
|
||||
<Mide />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h3 className="font-semibold">Mide</h3>
|
||||
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||
That's not all! What makes Lume unique is the column system.
|
||||
You can enhance your experience by adding new columns from the
|
||||
Lume Store.
|
||||
</div>
|
||||
<div className="mt-1 p-2 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||
If you're confused about the term "Column," you can imagine it
|
||||
as mini-apps, with each column providing its own experience.
|
||||
</div>
|
||||
<div className="mt-1 p-2 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||
Here is a quick guide for how to add a new column:
|
||||
</div>
|
||||
<div className="mt-1 rounded-lg">
|
||||
<video
|
||||
className="h-auto w-full rounded-lg object-cover aspect-video outline outline-1 -outline-offset-1 outline-black/15"
|
||||
controls
|
||||
muted
|
||||
>
|
||||
<source
|
||||
src="https://samplelib.com/lib/preview/mp4/sample-5s.mp4"
|
||||
type="video/mp4"
|
||||
/>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{userType === "veteran" ? (
|
||||
<div className="flex items-start flex-row-reverse gap-2 text-[13px]">
|
||||
<CurrentUser />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h3 className="font-semibold text-end">You</h3>
|
||||
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||
Can you introduce me to the UI? I am still confused.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{userType === "veteran" ? (
|
||||
<div className="flex items-start gap-2 text-[13px]">
|
||||
<Mide />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h3 className="font-semibold">Mide</h3>
|
||||
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||
Of course, here is a quick introduction video for Lume.
|
||||
</div>
|
||||
<div className="mt-1 rounded-lg">
|
||||
<video
|
||||
className="h-auto w-full rounded-lg object-cover aspect-video outline outline-1 -outline-offset-1 outline-black/15"
|
||||
controls
|
||||
muted
|
||||
>
|
||||
<source
|
||||
src="https://samplelib.com/lib/preview/mp4/sample-5s.mp4"
|
||||
type="video/mp4"
|
||||
/>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{userType === "new" ? (
|
||||
<div className="flex items-start gap-2 text-[13px]">
|
||||
<Mide />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h3 className="font-semibold">Mide</h3>
|
||||
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||
Diving into new social media platforms like Nostr can be a bit
|
||||
overwhelming, but don't worry! Here are some handy tips to
|
||||
help you navigate and discover what interests you.
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
install({
|
||||
label: "foryou",
|
||||
name: "For you",
|
||||
content: "/foryou",
|
||||
})
|
||||
}
|
||||
className="mt-1 px-3 py-2 shadow-primary flex items-center justify-between bg-white hover:bg-blue-500 hover:text-white dark:bg-white/10 rounded-lg"
|
||||
>
|
||||
Add some topics that you're interested in.
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
install({
|
||||
label: "trending_users",
|
||||
name: "Trending",
|
||||
content: "/trending/users",
|
||||
})
|
||||
}
|
||||
className="mt-1 px-3 py-2 shadow-primary flex items-center justify-between bg-white hover:bg-blue-500 hover:text-white dark:bg-white/10 rounded-lg"
|
||||
>
|
||||
Follow some users.
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{userType === "new" ? (
|
||||
<div className="flex items-start flex-row-reverse gap-2 text-[13px]">
|
||||
<CurrentUser />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h3 className="font-semibold text-end">You</h3>
|
||||
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||
My girlfriend introduced Nostr to me, and I have her NPUB. Can
|
||||
I get the same experiences as her?
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{userType === "new" ? (
|
||||
<div className="flex items-start gap-2 text-[13px]">
|
||||
<Mide />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h3 className="font-semibold">Mide</h3>
|
||||
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||
Absolutely! Since your girlfriend shared her NPUB with you,
|
||||
you can dive into Nostr and explore it just like she does.
|
||||
It's a great way to share experiences and discover what Nostr
|
||||
has to offer together!
|
||||
</div>
|
||||
<form
|
||||
onSubmit={handleSubmit(friendToFriend)}
|
||||
className="mt-1 flex flex-col items-end bg-white dark:bg-white/10 rounded-lg shadow-primary"
|
||||
>
|
||||
<input
|
||||
{...register("npub", { required: true })}
|
||||
name="npub"
|
||||
placeholder="Enter npub here..."
|
||||
className="w-full h-14 px-3 rounded-t-lg bg-transparent border-b border-x-0 border-t-0 border-neutral-100 dark:border-white/5 focus:border-neutral-200 dark:focus:border-white/20 focus:outline-none focus:ring-0 placeholder:text-neutral-600 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
<div className="h-10 flex items-center px-1">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isValid || isSubmitting}
|
||||
className="px-2 h-8 w-20 inline-flex items-center justify-center bg-blue-500 text-white rounded-md text-sm font-medium hover:bg-blue-600"
|
||||
>
|
||||
{isSubmitting ? <Spinner className="size-4" /> : "Submit"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{userType ? (
|
||||
<>
|
||||
<div className="flex items-start flex-row-reverse gap-2 text-[13px]">
|
||||
<CurrentUser />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h3 className="font-semibold text-end">You</h3>
|
||||
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||
Thank you. I can use Lume and explore Nostr by myself from
|
||||
now on.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 text-[13px]">
|
||||
<Mide />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h3 className="font-semibold">Mide</h3>
|
||||
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||
I really hope you enjoy your time on Nostr! If you're keen
|
||||
to dive deeper, here are some helpful resources to get you
|
||||
started:
|
||||
</div>
|
||||
<a
|
||||
href="https://nostr.org"
|
||||
target="_blank"
|
||||
className="mt-1 px-3 py-2 shadow-primary flex items-center justify-between bg-white hover:bg-blue-500 hover:text-white dark:bg-white/10 rounded-lg"
|
||||
rel="noreferrer"
|
||||
>
|
||||
[Website] nostr.org
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</a>
|
||||
<a
|
||||
href="https://www.youtube.com/watch?v=5W-jtbbh3eA"
|
||||
target="_blank"
|
||||
className="mt-1 px-3 py-2 shadow-primary flex items-center justify-between bg-white hover:bg-blue-500 hover:text-white dark:bg-white/10 rounded-lg"
|
||||
rel="noreferrer"
|
||||
>
|
||||
[Video] What is Nostr?
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/nostr-protocol/nostr"
|
||||
target="_blank"
|
||||
className="mt-1 px-3 py-2 shadow-primary flex items-center justify-between bg-white hover:bg-blue-500 hover:text-white dark:bg-white/10 rounded-lg"
|
||||
rel="noreferrer"
|
||||
>
|
||||
[Develop] Github
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</a>
|
||||
<a
|
||||
href="https://www.nostrapps.com/"
|
||||
target="_blank"
|
||||
className="mt-1 px-3 py-2 shadow-primary flex items-center justify-between bg-white hover:bg-blue-500 hover:text-white dark:bg-white/10 rounded-lg"
|
||||
rel="noreferrer"
|
||||
>
|
||||
[Ecosystem] nostrapps.com
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 text-[13px]">
|
||||
<Mide />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h3 className="font-semibold">Mide</h3>
|
||||
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
|
||||
If you want to close this onboarding board, you can click
|
||||
the button below.
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => close()}
|
||||
className="mt-1 px-3 py-2 shadow-primary flex items-center justify-between bg-white hover:bg-blue-500 hover:text-white dark:bg-white/10 rounded-lg"
|
||||
>
|
||||
Close
|
||||
<CancelIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Mide() {
|
||||
return (
|
||||
<img
|
||||
src="/ai.jpg"
|
||||
alt="Ai-chan"
|
||||
className="shrink-0 size-10 rounded-full outline outline-1 -outline-offset-1 outline-black/15"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CurrentUser() {
|
||||
const { account } = Route.useSearch();
|
||||
|
||||
return (
|
||||
<User.Provider pubkey={account}>
|
||||
<User.Root className="shrink-0">
|
||||
<User.Avatar className="size-10 rounded-full outline outline-1 -outline-offset-1 outline-black/15" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { SearchIcon } from "@lume/icons";
|
||||
import { type Event, Kind } from "@lume/types";
|
||||
import { Note, Spinner, User } from "@lume/ui";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { Note } from "@/components/note";
|
||||
import { User } from "@/components/user";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { User } from "@/components/user";
|
||||
import type { Account } from "@lume/types";
|
||||
import { User } from "@lume/ui";
|
||||
import { displayNsec } from "@lume/utils";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
@@ -11,7 +11,7 @@ export const Route = createFileRoute("/settings/backup")({
|
||||
component: Screen,
|
||||
loader: async ({ context }) => {
|
||||
const ark = context.ark;
|
||||
const npubs = await ark.get_all_accounts();
|
||||
const npubs = await ark.get_accounts();
|
||||
|
||||
const accounts: Account[] = [];
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ export const Route = createFileRoute("/settings/user")({
|
||||
|
||||
function Screen() {
|
||||
const { ark, profile } = Route.useRouteContext();
|
||||
const { register, handleSubmit } = useForm();
|
||||
const { register, handleSubmit } = useForm({ defaultValues: profile });
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [picture, setPicture] = useState<string>("");
|
||||
@@ -28,8 +28,8 @@ function Screen() {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const profile = { ...data, picture };
|
||||
await ark.create_profile(profile);
|
||||
const newProfile: Metadata = { ...profile, ...data, picture };
|
||||
await ark.create_profile(newProfile);
|
||||
|
||||
setLoading(false);
|
||||
} catch (e) {
|
||||
@@ -87,7 +87,7 @@ function Screen() {
|
||||
</label>
|
||||
<input
|
||||
name="display_name"
|
||||
{...register("display_name", { required: true, minLength: 1 })}
|
||||
{...register("display_name")}
|
||||
spellCheck={false}
|
||||
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"
|
||||
/>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
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 { type ColumnRouteSearch, type Event, Kind } from "@lume/types";
|
||||
import { type ColumnRouteSearch, type Event, 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 { Virtualizer } from "virtua";
|
||||
|
||||
export const Route = createFileRoute("/foryou")({
|
||||
export const Route = createFileRoute("/topic")({
|
||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||
return {
|
||||
account: search.account,
|
||||
@@ -17,21 +19,28 @@ export const Route = createFileRoute("/foryou")({
|
||||
},
|
||||
beforeLoad: async ({ search, context }) => {
|
||||
const ark = context.ark;
|
||||
const interests = await ark.get_interest();
|
||||
const key = `lume_topic_${search.label}`;
|
||||
const topics = (await ark.get_nstore(key)) as unknown as Topic[];
|
||||
const settings = await ark.get_settings();
|
||||
|
||||
if (!interests) {
|
||||
if (!topics?.length) {
|
||||
throw redirect({
|
||||
to: "/interests",
|
||||
to: "/create-topic",
|
||||
search: {
|
||||
...search,
|
||||
redirect: "/foryou",
|
||||
redirect: "/topic",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let hashtags: string[] = [];
|
||||
|
||||
for (const topic of topics) {
|
||||
hashtags.push(...topic.content);
|
||||
}
|
||||
|
||||
return {
|
||||
interests,
|
||||
hashtags,
|
||||
settings,
|
||||
};
|
||||
},
|
||||
@@ -39,8 +48,8 @@ export const Route = createFileRoute("/foryou")({
|
||||
});
|
||||
|
||||
export function Screen() {
|
||||
const { name, account } = Route.useSearch();
|
||||
const { ark, interests } = Route.useRouteContext();
|
||||
const { label, account } = Route.useSearch();
|
||||
const { ark, hashtags } = Route.useRouteContext();
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
@@ -49,20 +58,13 @@ export function Screen() {
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: [name, account],
|
||||
queryKey: [label, account],
|
||||
initialPageParam: 0,
|
||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||
const events = await ark.get_events_from_interests(
|
||||
interests.hashtags,
|
||||
20,
|
||||
pageParam,
|
||||
);
|
||||
const events = ark.get_hashtag_events(hashtags, 20, pageParam);
|
||||
return events;
|
||||
},
|
||||
getNextPageParam: (lastPage) => {
|
||||
const lastEvent = lastPage?.at(-1);
|
||||
return lastEvent ? lastEvent.created_at - 1 : null;
|
||||
},
|
||||
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
|
||||
select: (data) => data?.pages.flatMap((page) => page),
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
@@ -72,15 +74,29 @@ export function Screen() {
|
||||
switch (event.kind) {
|
||||
case Kind.Repost:
|
||||
return <RepostNote key={event.id} event={event} />;
|
||||
default:
|
||||
return <TextNote 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 (isQuote) {
|
||||
return <Quote key={event.id} event={event} className="mb-3" />;
|
||||
}
|
||||
|
||||
return <TextNote key={event.id} event={event} className="mb-3" />;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none">
|
||||
{isFetching && !isLoading && !isFetchingNextPage ? (
|
||||
<div className="w-full h-11 flex items-center justify-center">
|
||||
<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 gap-2">
|
||||
<Spinner className="size-5" />
|
||||
<span className="text-sm font-medium">Fetching new notes...</span>
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TextNote } from "@/components/text";
|
||||
import { type Event } from "@lume/types";
|
||||
import type { Event } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { Await, createFileRoute } from "@tanstack/react-router";
|
||||
import { defer } from "@tanstack/react-router";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Spinner, User } from "@lume/ui";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { User } from "@/components/user";
|
||||
import { Await, defer } from "@tanstack/react-router";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Suspense } from "react";
|
||||
@@ -41,7 +42,7 @@ export function Screen() {
|
||||
>
|
||||
<Await promise={data}>
|
||||
{(users) =>
|
||||
users.profiles.map((item) => (
|
||||
users.profiles.map((item: { pubkey: string }) => (
|
||||
<div
|
||||
key={item.pubkey}
|
||||
className="h-max w-full overflow-hidden mb-3 p-2 bg-black/5 dark:bg-white/5 backdrop-blur-lg rounded-xl"
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Box, Container, Spinner, User } from "@lume/ui";
|
||||
import { Box, Container, Spinner } from "@lume/ui";
|
||||
import { User } from "@/components/user";
|
||||
import { createFileRoute, defer } from "@tanstack/react-router";
|
||||
import { WindowVirtualizer } from "virtua";
|
||||
import { Conversation } from "@/components/conversation";
|
||||
import { Quote } from "@/components/quote";
|
||||
import { RepostNote } from "@/components/repost";
|
||||
import { TextNote } from "@/components/text";
|
||||
import { Event, Kind } from "@lume/types";
|
||||
import { type Event, Kind } from "@lume/types";
|
||||
import { Suspense } from "react";
|
||||
import { Await } from "@tanstack/react-router";
|
||||
|
||||
@@ -18,7 +19,7 @@ export const Route = createFileRoute("/users/$pubkey")({
|
||||
},
|
||||
loader: async ({ params, context }) => {
|
||||
const ark = context.ark;
|
||||
return { data: defer(ark.get_events_from(params.pubkey, 50)) };
|
||||
return { data: defer(ark.get_events_by(params.pubkey, 50)) };
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import { TextNote } from "@/components/text";
|
||||
import { ArrowRightCircleIcon, InfoIcon } from "@lume/icons";
|
||||
import { type Event, Kind } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { FETCH_LIMIT } from "@lume/utils";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
|
||||
@@ -14,7 +13,7 @@ export function EventList({ id }: { id: string }) {
|
||||
queryKey: ["events", id],
|
||||
initialPageParam: 0,
|
||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||
const events = await ark.get_events_from(id, FETCH_LIMIT, pageParam);
|
||||
const events = await ark.get_events_by(id, 20, pageParam);
|
||||
return events;
|
||||
},
|
||||
getNextPageParam: (lastPage) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Balance } from "@/components/balance";
|
||||
import { Box, Container, User } from "@lume/ui";
|
||||
import { Box, Container } from "@lume/ui";
|
||||
import { User } from "@/components/user";
|
||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||
import { getCurrent } from "@tauri-apps/api/webviewWindow";
|
||||
import { useState } from "react";
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"@astrojs/check": "^0.5.10",
|
||||
"@astrojs/tailwind": "^5.1.0",
|
||||
"@fontsource/geist-mono": "^5.0.3",
|
||||
"astro": "^4.7.0",
|
||||
"astro": "^4.8.3",
|
||||
"astro-seo-meta": "^4.1.1",
|
||||
"astro-seo-schema": "^4.0.2",
|
||||
"schema-dts": "^1.1.2",
|
||||
|
||||
@@ -20,17 +20,17 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "2.0.0-beta.7",
|
||||
"@tauri-apps/plugin-autostart": "2.0.0-beta.2",
|
||||
"@tauri-apps/plugin-clipboard-manager": "2.1.0-beta.0",
|
||||
"@tauri-apps/plugin-dialog": "2.0.0-beta.2",
|
||||
"@tauri-apps/plugin-fs": "2.0.0-beta.2",
|
||||
"@tauri-apps/plugin-http": "2.0.0-beta.2",
|
||||
"@tauri-apps/plugin-notification": "2.0.0-beta.2",
|
||||
"@tauri-apps/plugin-os": "2.0.0-beta.2",
|
||||
"@tauri-apps/plugin-process": "2.0.0-beta.2",
|
||||
"@tauri-apps/plugin-shell": "2.0.0-beta.2",
|
||||
"@tauri-apps/plugin-updater": "2.0.0-beta.2",
|
||||
"@tauri-apps/plugin-upload": "2.0.0-beta.3"
|
||||
"@tauri-apps/api": "^2.0.0-beta.7",
|
||||
"@tauri-apps/plugin-autostart": "2.0.0-beta.3",
|
||||
"@tauri-apps/plugin-clipboard-manager": "2.1.0-beta.1",
|
||||
"@tauri-apps/plugin-dialog": "2.0.0-beta.3",
|
||||
"@tauri-apps/plugin-fs": "2.0.0-beta.3",
|
||||
"@tauri-apps/plugin-http": "2.0.0-beta.3",
|
||||
"@tauri-apps/plugin-notification": "2.0.0-beta.3",
|
||||
"@tauri-apps/plugin-os": "2.0.0-beta.3",
|
||||
"@tauri-apps/plugin-process": "2.0.0-beta.3",
|
||||
"@tauri-apps/plugin-shell": "2.0.0-beta.4",
|
||||
"@tauri-apps/plugin-updater": "2.0.0-beta.3",
|
||||
"@tauri-apps/plugin-upload": "2.0.0-beta.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
"main": "./src/index.ts",
|
||||
"dependencies": {
|
||||
"@lume/utils": "workspace:^",
|
||||
"@tanstack/react-query": "^5.32.0",
|
||||
"@tanstack/react-query": "^5.36.0",
|
||||
"react": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lume/tsconfig": "workspace:^",
|
||||
"@lume/types": "workspace:^",
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react": "^18.3.2",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import {
|
||||
type Contact,
|
||||
type Event,
|
||||
type EventWithReplies,
|
||||
type Interests,
|
||||
type Keys,
|
||||
Kind,
|
||||
type LumeColumn,
|
||||
type Metadata,
|
||||
type Settings,
|
||||
@@ -18,7 +16,6 @@ import { readFile } from "@tauri-apps/plugin-fs";
|
||||
|
||||
enum NSTORE_KEYS {
|
||||
settings = "lume_user_settings",
|
||||
interests = "lume_user_interests",
|
||||
columns = "lume_user_columns",
|
||||
}
|
||||
|
||||
@@ -32,22 +29,26 @@ export class Ark {
|
||||
this.settings = undefined;
|
||||
}
|
||||
|
||||
public async get_all_accounts() {
|
||||
public async get_accounts() {
|
||||
try {
|
||||
const cmd: string[] = await invoke("get_accounts");
|
||||
const accounts: string[] = cmd.map((item) => item.replace(".npub", ""));
|
||||
const cmd: string = await invoke("get_accounts");
|
||||
const parse = cmd.split(/\s+/).filter((v) => v.startsWith("npub1"));
|
||||
const accounts = [...new Set(parse)];
|
||||
|
||||
if (!this.accounts) this.accounts = accounts;
|
||||
if (!this.accounts) {
|
||||
this.accounts = accounts;
|
||||
}
|
||||
|
||||
return accounts;
|
||||
} catch (e) {
|
||||
throw new Error(String(e));
|
||||
console.info(String(e));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async load_selected_account(npub: string) {
|
||||
public async load_account(npub: string) {
|
||||
try {
|
||||
const cmd: boolean = await invoke("load_selected_account", {
|
||||
const cmd: boolean = await invoke("load_account", {
|
||||
npub,
|
||||
});
|
||||
return cmd;
|
||||
@@ -76,7 +77,7 @@ export class Ark {
|
||||
|
||||
public async create_keys() {
|
||||
try {
|
||||
const cmd: Keys = await invoke("create_keys");
|
||||
const cmd: Keys = await invoke("create_account");
|
||||
return cmd;
|
||||
} catch (e) {
|
||||
console.error(String(e));
|
||||
@@ -85,7 +86,7 @@ export class Ark {
|
||||
|
||||
public async save_account(nsec: string, password = "") {
|
||||
try {
|
||||
const cmd: string = await invoke("save_key", {
|
||||
const cmd: string = await invoke("save_account", {
|
||||
nsec,
|
||||
password,
|
||||
});
|
||||
@@ -166,24 +167,6 @@ export class Ark {
|
||||
}
|
||||
}
|
||||
|
||||
public async get_events_from(pubkey: string, limit: number, asOf?: number) {
|
||||
try {
|
||||
let until: string = undefined;
|
||||
if (asOf && asOf > 0) until = asOf.toString();
|
||||
|
||||
const nostrEvents: Event[] = await invoke("get_events_from", {
|
||||
publicKey: pubkey,
|
||||
limit,
|
||||
as_of: until,
|
||||
});
|
||||
|
||||
return nostrEvents.sort((a, b) => b.created_at - a.created_at);
|
||||
} catch (e) {
|
||||
console.error(String(e));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async search(content: string, limit: number) {
|
||||
try {
|
||||
if (content.length < 1) return [];
|
||||
@@ -200,100 +183,127 @@ export class Ark {
|
||||
}
|
||||
}
|
||||
|
||||
public async get_events(
|
||||
private dedup_events(nostrEvents: Event[]) {
|
||||
const seens = new Set<string>();
|
||||
const events = nostrEvents.filter((event) => {
|
||||
const eTags = event.tags.filter((el) => el[0] === "e");
|
||||
const ids = eTags.map((item) => item[1]);
|
||||
const isDup = ids.some((id) => seens.has(id));
|
||||
|
||||
// Add found ids to seen list
|
||||
for (const id of ids) {
|
||||
seens.add(id);
|
||||
}
|
||||
|
||||
// Filter NSFW event
|
||||
if (this.settings?.nsfw) {
|
||||
const wTags = event.tags.filter((t) => t[0] === "content-warning");
|
||||
const isLewd = wTags.length > 0;
|
||||
|
||||
return !isDup && !isLewd;
|
||||
}
|
||||
|
||||
// Filter duplicate event
|
||||
return !isDup;
|
||||
});
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
public async get_local_events(
|
||||
pubkeys: string[],
|
||||
limit: number,
|
||||
asOf?: number,
|
||||
contacts?: string[],
|
||||
global?: boolean,
|
||||
) {
|
||||
try {
|
||||
let until: string = undefined;
|
||||
const isGlobal = global ?? false;
|
||||
|
||||
if (asOf && asOf > 0) until = asOf.toString();
|
||||
|
||||
const seenIds = new Set<string>();
|
||||
const nostrEvents: Event[] = await invoke("get_events", {
|
||||
const until: string = asOf && asOf > 0 ? asOf.toString() : undefined;
|
||||
const nostrEvents: Event[] = await invoke("get_local_events", {
|
||||
pubkeys,
|
||||
limit,
|
||||
until,
|
||||
contacts,
|
||||
global: isGlobal,
|
||||
});
|
||||
|
||||
// remove duplicate event
|
||||
for (const event of nostrEvents) {
|
||||
if (event.kind === Kind.Repost) {
|
||||
const repostId = event.tags.find((tag) => tag[0] === "e")?.[1];
|
||||
seenIds.add(repostId);
|
||||
}
|
||||
|
||||
const eventIds = event.tags
|
||||
.filter((el) => el[0] === "e")
|
||||
?.map((item) => item[1]);
|
||||
|
||||
if (eventIds?.length) {
|
||||
for (const id of eventIds) {
|
||||
seenIds.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const events = nostrEvents
|
||||
.filter((event) => !seenIds.has(event.id))
|
||||
.sort((a, b) => b.created_at - a.created_at);
|
||||
|
||||
if (this.settings?.nsfw) {
|
||||
return events.filter(
|
||||
(event) =>
|
||||
event.tags.filter((event) => event[0] === "content-warning")
|
||||
.length > 0,
|
||||
);
|
||||
}
|
||||
const events = this.dedup_events(nostrEvents);
|
||||
|
||||
return events;
|
||||
} catch (e) {
|
||||
console.info(String(e));
|
||||
console.error("[get_local_events] failed", String(e));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async get_events_from_interests(
|
||||
public async get_global_events(limit: number, asOf?: number) {
|
||||
try {
|
||||
const until: string = asOf && asOf > 0 ? asOf.toString() : undefined;
|
||||
const nostrEvents: Event[] = await invoke("get_global_events", {
|
||||
limit,
|
||||
until,
|
||||
});
|
||||
const events = this.dedup_events(nostrEvents);
|
||||
|
||||
return events;
|
||||
} catch (e) {
|
||||
console.error("[get_global_events] failed", String(e));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async get_hashtag_events(
|
||||
hashtags: string[],
|
||||
limit: number,
|
||||
asOf?: number,
|
||||
) {
|
||||
let until: string = undefined;
|
||||
if (asOf && asOf > 0) until = asOf.toString();
|
||||
try {
|
||||
const until: string = asOf && asOf > 0 ? asOf.toString() : undefined;
|
||||
const nostrTags = hashtags.map((tag) => tag.replace("#", ""));
|
||||
const nostrEvents: Event[] = await invoke("get_hashtag_events", {
|
||||
hashtags: nostrTags,
|
||||
limit,
|
||||
until,
|
||||
});
|
||||
const events = this.dedup_events(nostrEvents);
|
||||
|
||||
const seenIds = new Set<string>();
|
||||
const dedupQueue = new Set<string>();
|
||||
const nostrTags = hashtags.map((tag) => tag.replace("#", "").toLowerCase());
|
||||
|
||||
const nostrEvents: Event[] = await invoke("get_events_from_interests", {
|
||||
hashtags: nostrTags,
|
||||
limit,
|
||||
until,
|
||||
});
|
||||
|
||||
for (const event of nostrEvents) {
|
||||
const tags = event.tags
|
||||
.filter((el) => el[0] === "e")
|
||||
?.map((item) => item[1]);
|
||||
|
||||
if (tags.length) {
|
||||
for (const tag of tags) {
|
||||
if (seenIds.has(tag)) {
|
||||
dedupQueue.add(event.id);
|
||||
break;
|
||||
}
|
||||
seenIds.add(tag);
|
||||
}
|
||||
}
|
||||
return events;
|
||||
} catch (e) {
|
||||
console.error("[get_hashtag_events] failed", String(e));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return nostrEvents
|
||||
.filter((event) => !dedupQueue.has(event.id))
|
||||
.sort((a, b) => b.created_at - a.created_at);
|
||||
public async get_group_events(
|
||||
contacts: string[],
|
||||
limit: number,
|
||||
asOf?: number,
|
||||
) {
|
||||
try {
|
||||
const until: string = asOf && asOf > 0 ? asOf.toString() : undefined;
|
||||
const nostrEvents: Event[] = await invoke("get_group_events", {
|
||||
list: contacts,
|
||||
limit,
|
||||
until,
|
||||
});
|
||||
const events = this.dedup_events(nostrEvents);
|
||||
|
||||
return events;
|
||||
} catch (e) {
|
||||
console.error("[get_group_events] failed", String(e));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async get_events_by(pubkey: string, limit: number, asOf?: number) {
|
||||
try {
|
||||
const until: string = asOf && asOf > 0 ? asOf.toString() : undefined;
|
||||
const nostrEvents: Event[] = await invoke("get_events_by", {
|
||||
publicKey: pubkey,
|
||||
limit,
|
||||
as_of: until,
|
||||
});
|
||||
|
||||
return nostrEvents.sort((a, b) => b.created_at - a.created_at);
|
||||
} catch (e) {
|
||||
console.error("[get_events_by] failed", String(e));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async publish(
|
||||
@@ -368,27 +378,9 @@ export class Ark {
|
||||
}
|
||||
}
|
||||
|
||||
public async upvote(id: string, author: string) {
|
||||
try {
|
||||
const cmd: string = await invoke("upvote", { id, pubkey: author });
|
||||
return cmd;
|
||||
} catch (e) {
|
||||
throw new Error(String(e));
|
||||
}
|
||||
}
|
||||
|
||||
public async downvote(id: string, author: string) {
|
||||
try {
|
||||
const cmd: string = await invoke("downvote", { id, pubkey: author });
|
||||
return cmd;
|
||||
} catch (e) {
|
||||
throw new Error(String(e));
|
||||
}
|
||||
}
|
||||
|
||||
public async get_event_thread(id: string) {
|
||||
try {
|
||||
const events: EventWithReplies[] = await invoke("get_event_thread", {
|
||||
const events: EventWithReplies[] = await invoke("get_thread", {
|
||||
id,
|
||||
});
|
||||
|
||||
@@ -423,13 +415,23 @@ export class Ark {
|
||||
}
|
||||
}
|
||||
|
||||
public parse_event_thread(tags: string[][]) {
|
||||
public get_thread(tags: string[][], gossip: boolean = false) {
|
||||
let root: string = null;
|
||||
let reply: string = null;
|
||||
|
||||
// Get all event references from tags, ignore mention
|
||||
const events = tags.filter((el) => el[0] === "e" && el[3] !== "mention");
|
||||
|
||||
if (gossip) {
|
||||
const relays = tags.filter((el) => el[0] === "e" && el[2]?.length);
|
||||
|
||||
if (relays.length >= 1) {
|
||||
for (const relay of relays) {
|
||||
if (relay[2]?.length) this.add_relay(relay[2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (events.length === 1) {
|
||||
root = events[0][1];
|
||||
}
|
||||
@@ -490,6 +492,15 @@ export class Ark {
|
||||
}
|
||||
}
|
||||
|
||||
public async set_contact_list(pubkeys: string[]) {
|
||||
try {
|
||||
const cmd: boolean = await invoke("set_contact_list", { pubkeys });
|
||||
return cmd;
|
||||
} catch (e) {
|
||||
throw new Error(String(e));
|
||||
}
|
||||
}
|
||||
|
||||
public async get_contact_list() {
|
||||
try {
|
||||
const cmd: string[] = await invoke("get_contact_list");
|
||||
@@ -703,39 +714,6 @@ export class Ark {
|
||||
}
|
||||
}
|
||||
|
||||
public async get_interest() {
|
||||
try {
|
||||
const cmd: string = await invoke("get_nstore", {
|
||||
key: NSTORE_KEYS.interests,
|
||||
});
|
||||
const interests: Interests = cmd ? JSON.parse(cmd) : null;
|
||||
return interests;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async set_interest(
|
||||
words: string[],
|
||||
users: string[],
|
||||
hashtags: string[],
|
||||
) {
|
||||
try {
|
||||
const interests: Interests = {
|
||||
words: words ?? [],
|
||||
users: users ?? [],
|
||||
hashtags: hashtags ?? [],
|
||||
};
|
||||
const cmd: string = await invoke("set_nstore", {
|
||||
key: NSTORE_KEYS.interests,
|
||||
content: JSON.stringify(interests),
|
||||
});
|
||||
return cmd;
|
||||
} catch (e) {
|
||||
throw new Error(String(e));
|
||||
}
|
||||
}
|
||||
|
||||
public async get_nstore(key: string) {
|
||||
try {
|
||||
const cmd: string = await invoke("get_nstore", {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lume/tsconfig": "workspace:*",
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react": "^18.3.2",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,12 @@ export interface Account {
|
||||
interests?: Interests;
|
||||
}
|
||||
|
||||
export interface Topic {
|
||||
icon: string;
|
||||
title: string;
|
||||
content: string[];
|
||||
}
|
||||
|
||||
export interface Interests {
|
||||
hashtags: string[];
|
||||
users: string[];
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"main": "./src/index.ts",
|
||||
"dependencies": {
|
||||
"@getalby/sdk": "^3.5.0",
|
||||
"@getalby/sdk": "^3.5.1",
|
||||
"@lume/ark": "workspace:^",
|
||||
"@lume/icons": "workspace:^",
|
||||
"@lume/utils": "workspace:^",
|
||||
@@ -17,11 +17,11 @@
|
||||
"@radix-ui/react-hover-card": "^1.0.7",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@tanstack/react-query": "^5.32.0",
|
||||
"@tanstack/react-router": "^1.31.3",
|
||||
"framer-motion": "^11.1.7",
|
||||
"@tanstack/react-query": "^5.36.0",
|
||||
"@tanstack/react-router": "^1.32.5",
|
||||
"framer-motion": "^11.2.0",
|
||||
"get-urls": "^12.1.0",
|
||||
"media-chrome": "^3.2.1",
|
||||
"media-chrome": "^3.2.2",
|
||||
"minidenticons": "^4.2.1",
|
||||
"nanoid": "^5.0.7",
|
||||
"qrcode.react": "^3.1.0",
|
||||
@@ -29,24 +29,24 @@
|
||||
"react": "^18.3.1",
|
||||
"react-currency-input-field": "^3.8.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.51.3",
|
||||
"react-hook-form": "^7.51.4",
|
||||
"react-hotkeys-hook": "^4.5.0",
|
||||
"react-i18next": "^14.1.1",
|
||||
"react-snap-carousel": "^0.4.0",
|
||||
"react-string-replace": "^1.1.1",
|
||||
"slate": "^0.102.0",
|
||||
"slate": "^0.103.0",
|
||||
"slate-react": "^0.102.0",
|
||||
"sonner": "^1.4.41",
|
||||
"string-strip-html": "^13.4.8",
|
||||
"uqr": "^0.1.2",
|
||||
"use-debounce": "^10.0.0",
|
||||
"virtua": "^0.30.2"
|
||||
"virtua": "^0.31.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lume/tailwindcss": "workspace:^",
|
||||
"@lume/tsconfig": "workspace:^",
|
||||
"@lume/types": "workspace:^",
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react": "^18.3.2",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "@lume/icons";
|
||||
import { cn } from "@lume/utils";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function Container({
|
||||
children,
|
||||
withDrag = false,
|
||||
withNavigate = true,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
withDrag?: boolean;
|
||||
withNavigate?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
@@ -23,27 +20,8 @@ export function Container({
|
||||
{withDrag ? (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="bg-transparent flex h-11 w-full shrink-0 items-center justify-end pr-2"
|
||||
>
|
||||
{withNavigate ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.history.back()}
|
||||
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"
|
||||
>
|
||||
<ArrowLeftIcon className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.history.forward()}
|
||||
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"
|
||||
>
|
||||
<ArrowRightIcon className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
className="bg-transparent flex h-11 w-full shrink-0"
|
||||
/>
|
||||
) : null}
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
export * from "./user";
|
||||
export * from "./note";
|
||||
export * from "./note/mentions/note";
|
||||
|
||||
// UI
|
||||
export * from "./container";
|
||||
export * from "./box";
|
||||
export * from "./spinner";
|
||||
export * from "./carousel";
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { useEvent } from "@lume/ark";
|
||||
import { cn } from "@lume/utils";
|
||||
import { Note } from ".";
|
||||
|
||||
export function NoteChild({
|
||||
eventId,
|
||||
isRoot,
|
||||
}: {
|
||||
eventId: string;
|
||||
isRoot?: boolean;
|
||||
}) {
|
||||
const { isLoading, isError, data } = useEvent(eventId);
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return <div>Error</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Note.Provider event={data}>
|
||||
<Note.Root className={cn(isRoot ? "mb-3" : "")}>
|
||||
<div className="h-14 px-3 flex items-center justify-between">
|
||||
<Note.User />
|
||||
</div>
|
||||
<Note.Content className="px-3" />
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { memo } from "react";
|
||||
|
||||
export const Invoice = memo(function Invoice({ invoice }: { invoice: string }) {
|
||||
return (
|
||||
<div className="mt-2 flex items-center rounded-lg bg-neutral-200 p-2 dark:bg-neutral-800">
|
||||
<QRCodeSVG value={invoice} includeMargin={true} className="rounded-lg" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { Event } from "@lume/types";
|
||||
import { Note } from "..";
|
||||
|
||||
export function ChildReply({ event }: { event: Event; rootEventId?: string }) {
|
||||
return (
|
||||
<Note.Provider event={event}>
|
||||
<Note.Root className="py-2">
|
||||
<div className="flex items-center justify-between h-14">
|
||||
<Note.User className="flex-1 pr-2" />
|
||||
<Note.Menu />
|
||||
</div>
|
||||
<Note.Content />
|
||||
<div className="flex items-center justify-end gap-4 mt-2">
|
||||
<Note.Repost />
|
||||
<Note.Zap />
|
||||
</div>
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { NavArrowDownIcon } from "@lume/icons";
|
||||
import type { EventWithReplies } from "@lume/types";
|
||||
import { cn } from "@lume/utils";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Note } from "..";
|
||||
import { ChildReply } from "./childReply";
|
||||
|
||||
export function Reply({
|
||||
event,
|
||||
}: {
|
||||
event: EventWithReplies;
|
||||
}) {
|
||||
const [t] = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Collapsible.Root open={open} onOpenChange={setOpen}>
|
||||
<Note.Provider event={event}>
|
||||
<Note.Root className="pt-2">
|
||||
<div className="flex items-center justify-between h-14">
|
||||
<Note.User className="flex-1 pr-2" />
|
||||
<Note.Menu />
|
||||
</div>
|
||||
<Note.Content />
|
||||
<div className="flex items-center justify-between h-14">
|
||||
{event.replies?.length > 0 ? (
|
||||
<Collapsible.Trigger asChild>
|
||||
<div className="inline-flex items-center gap-1 font-semibold text-sm text-neutral-600 dark:text-neutral-400 h-14">
|
||||
<NavArrowDownIcon
|
||||
className={cn("size-5", open ? "rotate-180 transform" : "")}
|
||||
/>
|
||||
{`${event.replies?.length} ${
|
||||
event.replies?.length === 1
|
||||
? t("note.reply.single")
|
||||
: t("note.reply.plural")
|
||||
}`}
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<div className="inline-flex items-center gap-4">
|
||||
<Note.Reply />
|
||||
<Note.Repost />
|
||||
<Note.Zap />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
open
|
||||
? "pb-3 border-t border-neutral-100 dark:border-neutral-900"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
{event.replies?.length > 0 ? (
|
||||
<Collapsible.Content className="divide-y divide-neutral-100 dark:divide-neutral-900 pl-6">
|
||||
{event.replies?.map((childEvent) => (
|
||||
<ChildReply key={childEvent.id} event={childEvent} />
|
||||
))}
|
||||
</Collapsible.Content>
|
||||
) : null}
|
||||
</div>
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
</Collapsible.Root>
|
||||
);
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import { RepostIcon } from "@lume/icons";
|
||||
import type { Event } from "@lume/types";
|
||||
import { cn } from "@lume/utils";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Note } from "..";
|
||||
import { User } from "../../user";
|
||||
|
||||
export function RepostNote({
|
||||
event,
|
||||
className,
|
||||
}: {
|
||||
event: Event;
|
||||
className?: string;
|
||||
}) {
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
isLoading,
|
||||
isError,
|
||||
data: repostEvent,
|
||||
} = useQuery({
|
||||
queryKey: ["repost", event.id],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
if (event.content.length > 50) {
|
||||
const embed = JSON.parse(event.content) as Event;
|
||||
return embed;
|
||||
}
|
||||
const id = event.tags.find((el) => el[0] === "e")[1];
|
||||
return await ark.get_event(id);
|
||||
} catch {
|
||||
throw new Error("Failed to get repost event");
|
||||
}
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="w-full px-3 pb-3">Loading...</div>;
|
||||
}
|
||||
|
||||
if (isError || !repostEvent) {
|
||||
return (
|
||||
<Note.Root className={className}>
|
||||
<User.Provider pubkey={event.pubkey}>
|
||||
<User.Root className="flex h-14 gap-2 px-3">
|
||||
<div className="inline-flex w-10 shrink-0 items-center justify-center">
|
||||
<RepostIcon className="h-5 w-5 text-blue-500" />
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<User.Avatar className="size-6 shrink-0 rounded object-cover" />
|
||||
<div className="inline-flex items-baseline gap-1">
|
||||
<User.Name className="font-medium text-neutral-900 dark:text-neutral-100" />
|
||||
<span className="text-blue-500">{t("note.reposted")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<div className="mb-3 select-text px-3">
|
||||
<div className="flex flex-col items-start justify-start rounded-lg bg-red-100 px-3 py-3 dark:bg-red-900">
|
||||
<p className="text-red-500">Failed to get event</p>
|
||||
</div>
|
||||
</div>
|
||||
</Note.Root>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Note.Root
|
||||
className={cn(
|
||||
"mb-3 flex flex-col gap-2 border-b border-neutral-100 pb-3 dark:border-neutral-900",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<User.Provider pubkey={event.pubkey}>
|
||||
<User.Root className="flex gap-3">
|
||||
<div className="inline-flex w-10 shrink-0 items-center justify-center">
|
||||
<RepostIcon className="h-5 w-5 text-blue-500" />
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<User.Avatar className="size-6 shrink-0 rounded-full object-cover" />
|
||||
<div className="inline-flex items-baseline gap-1">
|
||||
<User.Name className="font-medium text-neutral-900 dark:text-neutral-100" />
|
||||
<span className="text-blue-500">{t("note.reposted")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<Note.Provider event={repostEvent}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<Note.User className="flex-1 pr-2" />
|
||||
<Note.Menu />
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="size-10 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<Note.Content />
|
||||
<div className="mt-5 flex items-center justify-between">
|
||||
<Note.Reaction />
|
||||
<div className="inline-flex items-center gap-4">
|
||||
<Note.Reply />
|
||||
<Note.Repost />
|
||||
<Note.Zap />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Note.Provider>
|
||||
</Note.Root>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Note } from "..";
|
||||
|
||||
export function NoteSkeleton() {
|
||||
return (
|
||||
<Note.Root>
|
||||
<div className="flex h-min flex-col p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="relative h-10 w-10 shrink-0 animate-pulse overflow-hidden rounded-lg bg-neutral-400 dark:bg-neutral-600" />
|
||||
<div className="h-6 w-full">
|
||||
<div className="h-4 w-24 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="-mt-4 flex gap-3">
|
||||
<div className="w-10 shrink-0" />
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<div className="h-3 w-2/3 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
|
||||
<div className="h-3 w-2/3 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
|
||||
<div className="h-3 w-1/2 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Note.Root>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import type { Event } from "@lume/types";
|
||||
import { cn } from "@lume/utils";
|
||||
import { Note } from "..";
|
||||
|
||||
export function TextNote({
|
||||
event,
|
||||
className,
|
||||
}: {
|
||||
event: Event;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Note.Provider event={event}>
|
||||
<Note.Root
|
||||
className={cn(
|
||||
"mb-3 flex flex-col gap-2 border-b border-neutral-100 pb-3 dark:border-neutral-900",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<Note.User className="flex-1 pr-2" />
|
||||
<Note.Menu />
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="size-11 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<Note.Thread className="mb-2" />
|
||||
<Note.Content />
|
||||
<div className="mt-5 flex items-center justify-between">
|
||||
<Note.Reaction />
|
||||
<div className="inline-flex items-center gap-4">
|
||||
<Note.Reply />
|
||||
<Note.Repost />
|
||||
<Note.Zap />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { useEvent } from "@lume/ark";
|
||||
import { Note } from "..";
|
||||
import { User } from "../../user";
|
||||
|
||||
export function ThreadNote({ eventId }: { eventId: string }) {
|
||||
const { isLoading, data } = useEvent(eventId);
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Note.Provider event={data}>
|
||||
<Note.Root className="flex flex-col">
|
||||
<div className="flex h-16 items-center justify-between">
|
||||
<User.Provider pubkey={data.pubkey}>
|
||||
<User.Root className="flex h-16 flex-1 items-center gap-3">
|
||||
<User.Avatar className="size-11 shrink-0 rounded-full object-cover ring-1 ring-neutral-200/50 dark:ring-neutral-800/50" />
|
||||
<div className="flex flex-1 flex-col">
|
||||
<User.Name className="font-semibold text-neutral-900 dark:text-neutral-100" />
|
||||
<div className="inline-flex items-center gap-2 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
<User.Time time={data.created_at} />
|
||||
<span>·</span>
|
||||
<User.NIP05 />
|
||||
</div>
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<Note.Menu />
|
||||
</div>
|
||||
<Note.Thread className="mb-2" />
|
||||
<Note.Content className="min-w-0" />
|
||||
<div className="flex h-14 items-center justify-between">
|
||||
<Note.Reaction />
|
||||
<div className="inline-flex items-center gap-4">
|
||||
<Note.Repost />
|
||||
<Note.Zap />
|
||||
</div>
|
||||
</div>
|
||||
</Note.Root>
|
||||
</Note.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { LinkIcon } from "@lume/icons";
|
||||
import { cn } from "@lume/utils";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Note } from ".";
|
||||
import { useNoteContext } from "./provider";
|
||||
|
||||
export function NoteThread({ className }: { className?: string }) {
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
const event = useNoteContext();
|
||||
const thread = ark.parse_event_thread({
|
||||
content: event.content,
|
||||
tags: event.tags,
|
||||
});
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!thread) return null;
|
||||
|
||||
return (
|
||||
<div className={cn("w-full", className)}>
|
||||
<div className="flex h-min w-full flex-col gap-3 rounded-2xl border border-black/10 p-3 dark:border-white/10">
|
||||
{thread.rootEventId ? (
|
||||
<Note.Child eventId={thread.rootEventId} isRoot />
|
||||
) : null}
|
||||
{thread.replyEventId ? (
|
||||
<Note.Child eventId={thread.replyEventId} />
|
||||
) : null}
|
||||
<div className="inline-flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
ark.open_thread(thread.rootEventId || thread.replyEventId)
|
||||
}
|
||||
className="inline-flex items-center gap-1 text-sm text-neutral-600 hover:text-blue-500 dark:text-neutral-400"
|
||||
>
|
||||
{t("note.showThread")}
|
||||
<LinkIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||