Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6996e30889 | ||
|
|
7ba793fad8 | ||
|
|
f11f836518 | ||
|
|
04fe0fcec8 | ||
|
|
799835a629 | ||
|
|
4e7da4108b | ||
|
|
7c7b082b3a | ||
|
|
38d6c51921 | ||
|
|
1738cbdd97 | ||
|
|
2e885b76a1 | ||
|
|
f94680e487 | ||
|
|
c682a58842 | ||
|
|
921cf871ee | ||
|
|
d5b1593aca | ||
|
|
6676b4e2a4 | ||
|
|
5f30ddcfca | ||
|
|
41d0de539d | ||
|
|
e254ee3203 | ||
|
|
6d42360549 | ||
|
|
70c5143445 | ||
|
|
41b66b18f5 | ||
|
|
dda0720ed4 | ||
|
|
4b60b39119 | ||
|
|
d2e5122d5a | ||
|
|
32f3315344 | ||
|
|
5ca9444358 | ||
|
|
4dc13385a5 | ||
|
|
b90ad1421f | ||
|
|
bba324ea53 | ||
|
|
7449000f5f | ||
|
|
dc7762ca11 | ||
|
|
3a3f960dde | ||
|
|
12e066ff2e | ||
|
|
fe4f965ed5 | ||
|
|
5d3f2264e9 | ||
|
|
407fe40b67 | ||
|
|
1f38eba2cc | ||
|
|
9b5867f80c | ||
|
|
cac774a0c1 | ||
|
|
82689bf3c3 | ||
|
|
f60e438a64 | ||
|
|
ca06f2b6ed | ||
|
|
99d9c70826 | ||
|
|
60afbf090b | ||
|
|
10ca4e6ff4 | ||
|
|
b0f387d029 | ||
|
|
1a8f750640 | ||
|
|
25523229a2 | ||
|
|
47835ed857 | ||
|
|
d84647bc6b | ||
|
|
7724eccd72 | ||
|
|
8ea2335225 | ||
|
|
b60d4db0df |
@@ -16,6 +16,8 @@ jobs:
|
|||||||
args: "--target aarch64-apple-darwin"
|
args: "--target aarch64-apple-darwin"
|
||||||
- platform: "macos-latest" # for Intel based macs.
|
- platform: "macos-latest" # for Intel based macs.
|
||||||
args: "--target x86_64-apple-darwin"
|
args: "--target x86_64-apple-darwin"
|
||||||
|
- platform: "macos-latest" # for Intel based macs.
|
||||||
|
args: "--target universal-apple-darwin"
|
||||||
#- platform: 'ubuntu-22.04'
|
#- platform: 'ubuntu-22.04'
|
||||||
# args: ''
|
# args: ''
|
||||||
#- platform: 'windows-latest'
|
#- platform: 'windows-latest'
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lume/ark": "workspace:^",
|
|
||||||
"@lume/icons": "workspace:^",
|
"@lume/icons": "workspace:^",
|
||||||
|
"@lume/system": "workspace:^",
|
||||||
"@lume/ui": "workspace:^",
|
"@lume/ui": "workspace:^",
|
||||||
"@lume/utils": "workspace:^",
|
"@lume/utils": "workspace:^",
|
||||||
"@radix-ui/react-avatar": "^1.0.4",
|
"@radix-ui/react-avatar": "^1.0.4",
|
||||||
@@ -21,43 +21,43 @@
|
|||||||
"@radix-ui/react-hover-card": "^1.0.7",
|
"@radix-ui/react-hover-card": "^1.0.7",
|
||||||
"@radix-ui/react-popover": "^1.0.7",
|
"@radix-ui/react-popover": "^1.0.7",
|
||||||
"@radix-ui/react-switch": "^1.0.3",
|
"@radix-ui/react-switch": "^1.0.3",
|
||||||
|
"@radix-ui/react-tabs": "^1.0.4",
|
||||||
"@radix-ui/react-tooltip": "^1.0.7",
|
"@radix-ui/react-tooltip": "^1.0.7",
|
||||||
"@tanstack/query-sync-storage-persister": "^5.32.0",
|
"@tanstack/query-sync-storage-persister": "^5.40.0",
|
||||||
"@tanstack/react-query": "^5.32.0",
|
"@tanstack/react-query": "^5.40.0",
|
||||||
"@tanstack/react-query-persist-client": "^5.32.0",
|
"@tanstack/react-query-persist-client": "^5.40.0",
|
||||||
"@tanstack/react-router": "1.29.2",
|
"@tanstack/react-router": "^1.34.9",
|
||||||
"i18next": "^23.11.3",
|
"i18next": "^23.11.5",
|
||||||
"i18next-resources-to-backend": "^1.2.1",
|
"i18next-resources-to-backend": "^1.2.1",
|
||||||
"minidenticons": "^4.2.1",
|
"minidenticons": "^4.2.1",
|
||||||
"nanoid": "^5.0.7",
|
"nanoid": "^5.0.7",
|
||||||
"nostr-tools": "^2.5.1",
|
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-currency-input-field": "^3.8.0",
|
"react-currency-input-field": "^3.8.0",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-hook-form": "^7.51.3",
|
"react-hook-form": "^7.51.5",
|
||||||
"react-hotkeys-hook": "^4.5.0",
|
"react-hotkeys-hook": "^4.5.0",
|
||||||
"react-i18next": "^14.1.1",
|
"react-i18next": "^14.1.2",
|
||||||
"react-string-replace": "^1.1.1",
|
"react-string-replace": "^1.1.1",
|
||||||
"slate": "^0.102.0",
|
"slate": "^0.103.0",
|
||||||
"slate-react": "^0.102.0",
|
"slate-react": "^0.104.0",
|
||||||
"sonner": "^1.4.41",
|
"sonner": "^1.4.41",
|
||||||
"use-debounce": "^10.0.0",
|
"use-debounce": "^10.0.1",
|
||||||
"virtua": "^0.30.2"
|
"virtua": "^0.31.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@lume/tailwindcss": "workspace:^",
|
"@lume/tailwindcss": "workspace:^",
|
||||||
"@lume/tsconfig": "workspace:^",
|
"@lume/tsconfig": "workspace:^",
|
||||||
"@lume/types": "workspace:^",
|
"@lume/types": "workspace:^",
|
||||||
"@tanstack/router-devtools": "^1.31.3",
|
"@tanstack/router-devtools": "^1.34.9",
|
||||||
"@tanstack/router-vite-plugin": "^1.30.0",
|
"@tanstack/router-vite-plugin": "^1.34.8",
|
||||||
"@types/react": "^18.3.1",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@vitejs/plugin-react-swc": "^3.6.0",
|
"@vitejs/plugin-react-swc": "^3.7.0",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.19",
|
||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.38",
|
||||||
"tailwindcss": "^3.4.3",
|
"tailwindcss": "^3.4.3",
|
||||||
"typescript": "^5.4.5",
|
"typescript": "^5.4.5",
|
||||||
"vite": "^5.2.10",
|
"vite": "^5.2.12",
|
||||||
"vite-plugin-top-level-await": "^1.4.1",
|
"vite-plugin-top-level-await": "^1.4.1",
|
||||||
"vite-tsconfig-paths": "^4.3.2"
|
"vite-tsconfig-paths": "^4.3.2"
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 26 KiB |
|
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: 72 KiB |
|
Before Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 296 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
Before Width: | Height: | Size: 310 KiB |
|
Before Width: | Height: | Size: 951 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 |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 201 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 257 KiB |
@@ -1,20 +1,17 @@
|
|||||||
import { Ark } from "@lume/ark";
|
|
||||||
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
|
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
|
||||||
import { QueryClient } from "@tanstack/react-query";
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||||
import { platform } from "@tauri-apps/plugin-os";
|
|
||||||
import React, { StrictMode } from "react";
|
import React, { StrictMode } from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import { I18nextProvider } from "react-i18next";
|
import { I18nextProvider } from "react-i18next";
|
||||||
import "./app.css";
|
import "./app.css";
|
||||||
import i18n from "./locale";
|
import i18n from "./locale";
|
||||||
import { routeTree } from "./router.gen"; // auto generated file
|
import { routeTree } from "./router.gen"; // auto generated file
|
||||||
|
import { type } from "@tauri-apps/plugin-os";
|
||||||
|
|
||||||
const ark = new Ark();
|
const os = await type();
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
const platformName = await platform();
|
|
||||||
|
|
||||||
const persister = createSyncStoragePersister({
|
const persister = createSyncStoragePersister({
|
||||||
storage: window.localStorage,
|
storage: window.localStorage,
|
||||||
});
|
});
|
||||||
@@ -23,9 +20,8 @@ const persister = createSyncStoragePersister({
|
|||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
routeTree,
|
routeTree,
|
||||||
context: {
|
context: {
|
||||||
ark,
|
|
||||||
queryClient,
|
queryClient,
|
||||||
platform: platformName,
|
platform: os,
|
||||||
},
|
},
|
||||||
Wrap: ({ children }) => {
|
Wrap: ({ children }) => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import { NostrQuery } from "@lume/system";
|
||||||
import { Spinner } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
|
||||||
import {
|
import {
|
||||||
type Dispatch,
|
type Dispatch,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
@@ -18,21 +18,17 @@ export function AvatarUploader({
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const { ark } = useRouteContext({ strict: false });
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const uploadAvatar = async () => {
|
const uploadAvatar = async () => {
|
||||||
// start loading
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
try {
|
||||||
const image = await ark.upload();
|
setLoading(true);
|
||||||
|
const image = await NostrQuery.upload();
|
||||||
setPicture(image);
|
setPicture(image);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
setLoading(false);
|
||||||
toast.error(String(e));
|
toast.error(String(e));
|
||||||
}
|
}
|
||||||
|
|
||||||
// stop loading
|
|
||||||
setLoading(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
import { User } from "./user";
|
import { User } from "@/components/user";
|
||||||
|
import { NostrAccount } from "@lume/system";
|
||||||
import { getBitcoinDisplayValues } from "@lume/utils";
|
import { getBitcoinDisplayValues } from "@lume/utils";
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
export function Balance({ account }: { account: string }) {
|
export function Balance({ account }: { account: string }) {
|
||||||
const { ark } = useRouteContext({ strict: false });
|
|
||||||
const [balance, setBalance] = useState(0);
|
const [balance, setBalance] = useState(0);
|
||||||
const value = useMemo(() => getBitcoinDisplayValues(balance), [balance]);
|
const value = useMemo(() => getBitcoinDisplayValues(balance), [balance]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function getBalance() {
|
async function getBalance() {
|
||||||
const val = await ark.get_balance();
|
const val = await NostrAccount.getBalance();
|
||||||
setBalance(val);
|
setBalance(val);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import type { LumeColumn } from "@lume/types";
|
|||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { getCurrent } from "@tauri-apps/api/webviewWindow";
|
import { getCurrent } from "@tauri-apps/api/webviewWindow";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
export function Col({
|
export function Column({
|
||||||
column,
|
column,
|
||||||
account,
|
account,
|
||||||
isScroll,
|
isScroll,
|
||||||
@@ -17,68 +17,65 @@ export function Col({
|
|||||||
isResize: boolean;
|
isResize: boolean;
|
||||||
}) {
|
}) {
|
||||||
const container = useRef<HTMLDivElement>(null);
|
const container = useRef<HTMLDivElement>(null);
|
||||||
const [webview, setWebview] = useState<string | undefined>(undefined);
|
const webviewLabel = useMemo(
|
||||||
|
() => `column-${account}_${column.label}`,
|
||||||
|
[account],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [isCreated, setIsCreated] = useState(false);
|
||||||
|
|
||||||
const repositionWebview = async () => {
|
const repositionWebview = async () => {
|
||||||
if (webview && webview.length > 1) {
|
const newRect = container.current.getBoundingClientRect();
|
||||||
const newRect = container.current.getBoundingClientRect();
|
await invoke("reposition_column", {
|
||||||
await invoke("reposition_column", {
|
label: webviewLabel,
|
||||||
label: webview,
|
x: newRect.x,
|
||||||
x: newRect.x,
|
y: newRect.y,
|
||||||
y: newRect.y,
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const resizeWebview = async () => {
|
const resizeWebview = async () => {
|
||||||
if (webview && webview.length > 1) {
|
const newRect = container.current.getBoundingClientRect();
|
||||||
const newRect = container.current.getBoundingClientRect();
|
await invoke("resize_column", {
|
||||||
await invoke("resize_column", {
|
label: webviewLabel,
|
||||||
label: webview,
|
width: newRect.width,
|
||||||
width: newRect.width,
|
height: newRect.height,
|
||||||
height: newRect.height,
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
resizeWebview();
|
if (isCreated) resizeWebview();
|
||||||
}, [isResize]);
|
}, [isResize]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isScroll) repositionWebview();
|
if (isScroll && isCreated) repositionWebview();
|
||||||
}, [isScroll]);
|
}, [isScroll]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
if (!container?.current) return;
|
||||||
if (webview && webview.length > 1) return;
|
|
||||||
|
|
||||||
const rect = container.current.getBoundingClientRect();
|
const rect = container.current.getBoundingClientRect();
|
||||||
const windowLabel = `column-${column.label}`;
|
const url = `${column.content}?account=${account}&label=${column.label}&name=${column.name}`;
|
||||||
const url = `${column.content}?account=${account}&label=${column.label}&name=${column.name}`;
|
|
||||||
|
|
||||||
// create new webview
|
// create new webview
|
||||||
const label: string = await invoke("create_column", {
|
invoke("create_column", {
|
||||||
label: windowLabel,
|
label: webviewLabel,
|
||||||
x: rect.x,
|
x: rect.x,
|
||||||
y: rect.y,
|
y: rect.y,
|
||||||
width: rect.width,
|
width: rect.width,
|
||||||
height: rect.height,
|
height: rect.height,
|
||||||
url,
|
url,
|
||||||
});
|
}).then(() => {
|
||||||
|
console.log("created: ", webviewLabel);
|
||||||
setWebview(label);
|
setIsCreated(true);
|
||||||
})();
|
});
|
||||||
|
|
||||||
// close webview when unmounted
|
// close webview when unmounted
|
||||||
return () => {
|
return () => {
|
||||||
if (webview && webview.length > 1) {
|
invoke("close_column", { label: webviewLabel }).then(() => {
|
||||||
invoke("close_column", {
|
console.log("closed: ", webviewLabel);
|
||||||
label: webview,
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, [webview]);
|
}, [account]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-[440px] shrink-0 p-2">
|
<div className="h-full w-[440px] shrink-0 p-2">
|
||||||
@@ -1,18 +1,23 @@
|
|||||||
import { ThreadIcon } from "@lume/icons";
|
import { ThreadIcon } from "@lume/icons";
|
||||||
import type { Event } from "@lume/types";
|
import type { NostrEvent } from "@lume/types";
|
||||||
import { Note } from "./note";
|
import { Note } from "@/components/note";
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
import { LumeEvent } from "@lume/system";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
export function Conversation({
|
export function Conversation({
|
||||||
event,
|
event,
|
||||||
|
gossip,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
event: Event;
|
event: NostrEvent;
|
||||||
|
gossip?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const { ark } = useRouteContext({ strict: false });
|
const thread = useMemo(
|
||||||
const thread = ark.parse_event_thread(event.tags);
|
() => LumeEvent.getEventThread(event.tags, gossip),
|
||||||
|
[event],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Note.Provider event={event}>
|
<Note.Provider event={event}>
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ import { User } from "../user";
|
|||||||
|
|
||||||
export function NoteActivity({ className }: { className?: string }) {
|
export function NoteActivity({ className }: { className?: string }) {
|
||||||
const event = useNoteContext();
|
const event = useNoteContext();
|
||||||
const mentions = event.tags
|
const mentions = event.mentions;
|
||||||
.filter((tag) => tag[0] === "p")
|
|
||||||
.map((tag) => tag[1]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("-mt-3 mb-2", className)}>
|
<div className={cn("-mt-3 mb-2", className)}>
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { VisitIcon } from "@lume/icons";
|
import { VisitIcon } from "@lume/icons";
|
||||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
|
||||||
import { useNoteContext } from "../provider";
|
import { useNoteContext } from "../provider";
|
||||||
|
import { LumeWindow } from "@lume/system";
|
||||||
|
|
||||||
export function NoteOpenThread() {
|
export function NoteOpenThread() {
|
||||||
const event = useNoteContext();
|
const event = useNoteContext();
|
||||||
const { ark } = useRouteContext({ strict: false });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip.Provider>
|
<Tooltip.Provider>
|
||||||
@@ -13,7 +12,7 @@ export function NoteOpenThread() {
|
|||||||
<Tooltip.Trigger asChild>
|
<Tooltip.Trigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => ark.open_event(event)}
|
onClick={() => LumeWindow.openEvent(event)}
|
||||||
className="group inline-flex h-7 w-14 bg-neutral-100 dark:bg-white/10 rounded-full items-center justify-center text-sm font-medium text-neutral-800 dark:text-neutral-200 hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20"
|
className="group inline-flex h-7 w-14 bg-neutral-100 dark:bg-white/10 rounded-full items-center justify-center text-sm font-medium text-neutral-800 dark:text-neutral-200 hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20"
|
||||||
>
|
>
|
||||||
<VisitIcon className="shrink-0 size-4" />
|
<VisitIcon className="shrink-0 size-4" />
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { ReplyIcon } from "@lume/icons";
|
import { ReplyIcon } from "@lume/icons";
|
||||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
|
||||||
import { useNoteContext } from "../provider";
|
import { useNoteContext } from "../provider";
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
|
import { LumeWindow } from "@lume/system";
|
||||||
|
|
||||||
export function NoteReply({ large = false }: { large?: boolean }) {
|
export function NoteReply({ large = false }: { large?: boolean }) {
|
||||||
const event = useNoteContext();
|
const event = useNoteContext();
|
||||||
const { ark } = useRouteContext({ strict: false });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip.Provider>
|
<Tooltip.Provider>
|
||||||
@@ -14,7 +13,7 @@ export function NoteReply({ large = false }: { large?: boolean }) {
|
|||||||
<Tooltip.Trigger asChild>
|
<Tooltip.Trigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => ark.open_editor(event.id)}
|
onClick={() => LumeWindow.openEditor(event.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
|
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
|
||||||
large
|
large
|
||||||
|
|||||||
@@ -2,15 +2,14 @@ import { QuoteIcon, RepostIcon } from "@lume/icons";
|
|||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
|
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
|
||||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Spinner } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import { useNoteContext } from "../provider";
|
import { useNoteContext } from "../provider";
|
||||||
|
import { LumeWindow } from "@lume/system";
|
||||||
|
|
||||||
export function NoteRepost({ large = false }: { large?: boolean }) {
|
export function NoteRepost({ large = false }: { large?: boolean }) {
|
||||||
const { ark } = useRouteContext({ strict: false });
|
|
||||||
const event = useNoteContext();
|
const event = useNoteContext();
|
||||||
|
|
||||||
const [t] = useTranslation();
|
const [t] = useTranslation();
|
||||||
@@ -23,7 +22,7 @@ export function NoteRepost({ large = false }: { large?: boolean }) {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
// repost
|
// repost
|
||||||
await ark.repost(event.id, event.pubkey);
|
await event.repost();
|
||||||
|
|
||||||
// update state
|
// update state
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -86,7 +85,7 @@ export function NoteRepost({ large = false }: { large?: boolean }) {
|
|||||||
<DropdownMenu.Item asChild>
|
<DropdownMenu.Item asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => ark.open_editor(event.id, true)}
|
onClick={() => LumeWindow.openEditor(event.id, true)}
|
||||||
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||||
>
|
>
|
||||||
<QuoteIcon className="size-4" />
|
<QuoteIcon className="size-4" />
|
||||||
|
|||||||
@@ -1,33 +1,19 @@
|
|||||||
import { ZapIcon } from "@lume/icons";
|
import { ZapIcon } from "@lume/icons";
|
||||||
import { useRouteContext, useSearch } from "@tanstack/react-router";
|
import { useRouteContext } from "@tanstack/react-router";
|
||||||
import { toast } from "sonner";
|
|
||||||
import { useNoteContext } from "../provider";
|
import { useNoteContext } from "../provider";
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
|
import { LumeWindow } from "@lume/system";
|
||||||
|
|
||||||
export function NoteZap({ large = false }: { large?: boolean }) {
|
export function NoteZap({ large = false }: { large?: boolean }) {
|
||||||
const event = useNoteContext();
|
const event = useNoteContext();
|
||||||
const { ark, settings } = useRouteContext({ strict: false });
|
const { settings } = useRouteContext({ strict: false });
|
||||||
const { account } = useSearch({ strict: false });
|
|
||||||
|
|
||||||
const zap = async () => {
|
|
||||||
try {
|
|
||||||
const nwc = await ark.load_nwc();
|
|
||||||
if (!nwc) {
|
|
||||||
ark.open_nwc();
|
|
||||||
} else {
|
|
||||||
ark.open_zap(event.id, event.pubkey, account);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(String(e));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!settings.zap) return null;
|
if (!settings.zap) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => zap()}
|
onClick={() => LumeWindow.openZap(event.id, event.pubkey)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
|
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
|
||||||
large
|
large
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEvent } from "@lume/ark";
|
import { useEvent } from "@lume/system";
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { Note } from ".";
|
import { Note } from ".";
|
||||||
|
import { InfoIcon } from "@lume/icons";
|
||||||
|
|
||||||
export function NoteChild({
|
export function NoteChild({
|
||||||
eventId,
|
eventId,
|
||||||
@@ -12,11 +13,25 @@ export function NoteChild({
|
|||||||
const { isLoading, isError, data } = useEvent(eventId);
|
const { isLoading, isError, data } = useEvent(eventId);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <div>Loading...</div>;
|
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) {
|
if (isError || !data) {
|
||||||
return <div>Error</div>;
|
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 (
|
return (
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export function NoteContent({
|
|||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"select-text text-[15px] text-balance break-words overflow-hidden",
|
"select-text text-[15px] text-pretty content-break overflow-hidden",
|
||||||
event.content.length > 500 ? "max-h-[300px] gradient-mask-b-0" : "",
|
event.content.length > 500 ? "max-h-[300px] gradient-mask-b-0" : "",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ export function NoteContentLarge({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("select-text", className)}>
|
<div className={cn("select-text", className)}>
|
||||||
<div className="text-[15px] text-balance content-break leading-normal">
|
<div className="text-[15px] text-pretty content-break leading-normal">
|
||||||
{content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ export function Hashtag({ tag }: { tag: string }) {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="break-all cursor-default leading-normal group"
|
className="break-all cursor-default leading-normal group text-start"
|
||||||
>
|
>
|
||||||
<span className="text-blue-500">#</span>
|
<span className="text-blue-500">#</span>
|
||||||
<span className="underline-offset-1 underline decoration-2 decoration-blue-200 dark:decoration-blue-800 group-hover:decoration-blue-500">
|
<span className="underline-offset-1 underline decoration-2 decoration-blue-200 dark:decoration-blue-800 group-hover:decoration-blue-500">
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useEvent } from "@lume/ark";
|
import { LumeWindow, useEvent } from "@lume/system";
|
||||||
import { LinkIcon } from "@lume/icons";
|
import { LinkIcon } from "@lume/icons";
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { User } from "@/components/user";
|
import { User } from "@/components/user";
|
||||||
|
import { Spinner } from "@lume/ui";
|
||||||
|
|
||||||
export function MentionNote({
|
export function MentionNote({
|
||||||
eventId,
|
eventId,
|
||||||
@@ -12,21 +12,20 @@ export function MentionNote({
|
|||||||
eventId: string;
|
eventId: string;
|
||||||
openable?: boolean;
|
openable?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { ark } = useRouteContext({ strict: false });
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isLoading, isError, data } = useEvent(eventId);
|
const { isLoading, isError, data } = useEvent(eventId);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full cursor-default items-center justify-between rounded-xl border border-black/10 p-3 dark:border-white/10">
|
<div className="mt-2 w-full flex h-20 items-center justify-center rounded-xl border border-black/10 dark:border-white/10">
|
||||||
<p>Loading...</p>
|
<Spinner className="size-5" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isError || !data) {
|
if (isError || !data) {
|
||||||
return (
|
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")}
|
{t("note.error")}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -49,7 +48,7 @@ export function MentionNote({
|
|||||||
</User.Provider>
|
</User.Provider>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-3 select-text content-break whitespace-normal text-balance leading-normal",
|
"px-3 select-text whitespace-normal text-pretty content-break leading-normal",
|
||||||
data.content.length > 100 ? "max-h-[150px] gradient-mask-b-0" : "",
|
data.content.length > 100 ? "max-h-[150px] gradient-mask-b-0" : "",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -61,9 +60,9 @@ export function MentionNote({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
ark.open_event_id(data.id);
|
LumeWindow.openEvent(data);
|
||||||
}}
|
}}
|
||||||
className="z-10 h-7 w-28 inline-flex items-center justify-center gap-1 text-sm bg-neutral-100 dark:bg-white/10 rounded-full text-neutral-600 hover:text-blue-500 dark:text-neutral-400"
|
className="z-10 h-7 w-28 inline-flex items-center justify-center gap-1 text-sm bg-black/10 dark:bg-white/10 rounded-full text-neutral-600 hover:text-blue-500 dark:text-neutral-400"
|
||||||
>
|
>
|
||||||
View post
|
View post
|
||||||
<LinkIcon className="size-4" />
|
<LinkIcon className="size-4" />
|
||||||
|
|||||||
@@ -1,24 +1,20 @@
|
|||||||
import { useProfile } from "@lume/ark";
|
import { LumeWindow, useProfile } from "@lume/system";
|
||||||
import { displayNpub } from "@lume/utils";
|
import { displayNpub } from "@lume/utils";
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
export function MentionUser({ pubkey }: { pubkey: string }) {
|
export function MentionUser({ pubkey }: { pubkey: string }) {
|
||||||
const { ark } = useRouteContext({ strict: false });
|
|
||||||
const { isLoading, isError, profile } = useProfile(pubkey);
|
const { isLoading, isError, profile } = useProfile(pubkey);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => ark.open_profile(pubkey)}
|
onClick={() => LumeWindow.openProfile(pubkey)}
|
||||||
className="break-words text-start text-blue-500 hover:text-blue-600"
|
className="break-words text-start text-blue-500 hover:text-blue-600"
|
||||||
>
|
>
|
||||||
{isLoading
|
{isLoading
|
||||||
? "@anon"
|
? "@anon"
|
||||||
: isError
|
: isError
|
||||||
? displayNpub(pubkey, 16)
|
? displayNpub(pubkey, 16)
|
||||||
: `@${
|
: `@${profile?.name || profile?.display_name || "anon"}`}
|
||||||
profile?.name || profile?.display_name || profile?.name || "anon"
|
|
||||||
}`}
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,28 @@
|
|||||||
import { HorizontalDotsIcon } from "@lume/icons";
|
import { HorizontalDotsIcon } from "@lume/icons";
|
||||||
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
|
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
|
||||||
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
|
||||||
import { useNoteContext } from "./provider";
|
import { useNoteContext } from "./provider";
|
||||||
|
import { LumeWindow } from "@lume/system";
|
||||||
|
|
||||||
export function NoteMenu() {
|
export function NoteMenu() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const event = useNoteContext();
|
const event = useNoteContext();
|
||||||
|
|
||||||
const { ark } = useRouteContext({ strict: false });
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const copyID = async () => {
|
const copyID = async () => {
|
||||||
await writeText(await ark.event_to_bech32(event.id, [""]));
|
await writeText(await event.idAsBech32());
|
||||||
toast.success("Copied");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const copyRaw = async () => {
|
const copyRaw = async () => {
|
||||||
await writeText(JSON.stringify(event));
|
await writeText(JSON.stringify(event));
|
||||||
toast.success("Copied");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const copyNpub = async () => {
|
const copyNpub = async () => {
|
||||||
await writeText(await ark.user_to_bech32(event.pubkey, [""]));
|
await writeText(await event.pubkeyAsBech32());
|
||||||
toast.success("Copied");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const copyLink = async () => {
|
const copyLink = async () => {
|
||||||
await writeText(
|
await writeText(`https://njump.me/${await event.idAsBech32()}`);
|
||||||
`https://njump.me/${await ark.event_to_bech32(event.id, [""])}`,
|
|
||||||
);
|
|
||||||
toast.success("Copied");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -49,7 +40,7 @@ export function NoteMenu() {
|
|||||||
<DropdownMenu.Item asChild>
|
<DropdownMenu.Item asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => ark.open_event(event)}
|
onClick={() => LumeWindow.openEvent(event)}
|
||||||
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||||
>
|
>
|
||||||
{t("note.menu.viewThread")}
|
{t("note.menu.viewThread")}
|
||||||
@@ -84,7 +75,7 @@ export function NoteMenu() {
|
|||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
<DropdownMenu.Item asChild>
|
<DropdownMenu.Item asChild>
|
||||||
<button
|
<button
|
||||||
onClick={() => ark.open_profile(event.pubkey)}
|
onClick={() => LumeWindow.openProfile(event.pubkey)}
|
||||||
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||||
>
|
>
|
||||||
{t("note.menu.viewAuthor")}
|
{t("note.menu.viewAuthor")}
|
||||||
|
|||||||
@@ -1,61 +1,44 @@
|
|||||||
import { CheckCircleIcon, DownloadIcon } from "@lume/icons";
|
|
||||||
import { downloadDir } from "@tauri-apps/api/path";
|
|
||||||
import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
|
import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||||
import { download } from "@tauri-apps/plugin-upload";
|
|
||||||
import { type SyntheticEvent, useState } from "react";
|
|
||||||
|
|
||||||
export function ImagePreview({ url }: { url: string }) {
|
export function ImagePreview({ url }: { url: string }) {
|
||||||
const [downloaded, setDownloaded] = useState(false);
|
const open = async (url: string) => {
|
||||||
|
const name = new URL(url).pathname
|
||||||
|
.split("/")
|
||||||
|
.pop()
|
||||||
|
.replace(/[^a-zA-Z ]/g, "");
|
||||||
|
const label = `viewer-${name}`;
|
||||||
|
const window = WebviewWindow.getByLabel(label);
|
||||||
|
|
||||||
const downloadImage = async (e: { stopPropagation: () => void }) => {
|
if (!window) {
|
||||||
try {
|
const newWindow = new WebviewWindow(label, {
|
||||||
e.stopPropagation();
|
url,
|
||||||
|
title: "Image Viewer",
|
||||||
|
width: 800,
|
||||||
|
height: 800,
|
||||||
|
titleBarStyle: "overlay",
|
||||||
|
});
|
||||||
|
|
||||||
const downloadDirPath = await downloadDir();
|
return newWindow;
|
||||||
const filename = url.substring(url.lastIndexOf("/") + 1);
|
|
||||||
await download(url, `${downloadDirPath}/${filename}`);
|
|
||||||
|
|
||||||
setDownloaded(true);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const open = async () => {
|
return await window.setFocus();
|
||||||
const name = new URL(url).pathname.split("/").pop();
|
|
||||||
return new WebviewWindow("image-viewer", {
|
|
||||||
url,
|
|
||||||
title: name,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const fallback = (event: SyntheticEvent<HTMLImageElement, Event>) => {
|
|
||||||
event.currentTarget.src = "/fallback-image.jpg";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// biome-ignore lint/a11y/useKeyWithClickEvents: <explanation>
|
<div className="group relative my-1">
|
||||||
<div onClick={() => open()} className="group relative my-1">
|
|
||||||
<img
|
<img
|
||||||
src={url}
|
src={url}
|
||||||
alt={url}
|
alt={url}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
style={{ contentVisibility: "auto" }}
|
style={{ contentVisibility: "auto" }}
|
||||||
onError={fallback}
|
|
||||||
className="max-h-[600px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
className="max-h-[600px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||||
|
onClick={() => open(url)}
|
||||||
|
onError={({ currentTarget }) => {
|
||||||
|
currentTarget.onerror = null;
|
||||||
|
currentTarget.src = "/404.jpg";
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => downloadImage(e)}
|
|
||||||
className="absolute right-2 top-2 z-20 hidden size-8 items-center justify-center rounded-md bg-white/10 text-white/70 backdrop-blur-2xl hover:bg-blue-500 hover:text-white group-hover:inline-flex"
|
|
||||||
>
|
|
||||||
{downloaded ? (
|
|
||||||
<CheckCircleIcon className="size-4" />
|
|
||||||
) : (
|
|
||||||
<DownloadIcon className="size-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ export function Images({ urls }: { urls: string[] }) {
|
|||||||
style={{ contentVisibility: "auto" }}
|
style={{ contentVisibility: "auto" }}
|
||||||
className="max-h-[400px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
className="max-h-[400px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||||
onClick={() => open(urls[0])}
|
onClick={() => open(urls[0])}
|
||||||
|
onError={({ currentTarget }) => {
|
||||||
|
currentTarget.onerror = null;
|
||||||
|
currentTarget.src = "/404.jpg";
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -54,6 +58,10 @@ export function Images({ urls }: { urls: string[] }) {
|
|||||||
style={{ contentVisibility: "auto" }}
|
style={{ contentVisibility: "auto" }}
|
||||||
className="w-full h-full object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
className="w-full h-full object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||||
onClick={() => open(item)}
|
onClick={() => open(item)}
|
||||||
|
onError={({ currentTarget }) => {
|
||||||
|
currentTarget.onerror = null;
|
||||||
|
currentTarget.src = "/404.jpg";
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</CarouselItem>
|
</CarouselItem>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,19 +1,25 @@
|
|||||||
import type { Event } from "@lume/types";
|
import { LumeEvent } from "@lume/system";
|
||||||
|
import type { NostrEvent } from "@lume/types";
|
||||||
import { type ReactNode, createContext, useContext } from "react";
|
import { type ReactNode, createContext, useContext } from "react";
|
||||||
|
|
||||||
const EventContext = createContext<Event>(null);
|
const NoteContext = createContext<LumeEvent>(null);
|
||||||
|
|
||||||
export function NoteProvider({
|
export function NoteProvider({
|
||||||
event,
|
event,
|
||||||
children,
|
children,
|
||||||
}: { event: Event; children: ReactNode }) {
|
}: {
|
||||||
|
event: NostrEvent;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const lumeEvent = new LumeEvent(event);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EventContext.Provider value={event}>{children}</EventContext.Provider>
|
<NoteContext.Provider value={lumeEvent}>{children}</NoteContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useNoteContext() {
|
export function useNoteContext() {
|
||||||
const context = useContext(EventContext);
|
const context = useContext(NoteContext);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error("Please import Note Provider to use useNoteContext() hook");
|
throw new Error("Please import Note Provider to use useNoteContext() hook");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import * as HoverCard from "@radix-ui/react-hover-card";
|
import * as HoverCard from "@radix-ui/react-hover-card";
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
|
||||||
import { User } from "../user";
|
import { User } from "../user";
|
||||||
import { useNoteContext } from "./provider";
|
import { useNoteContext } from "./provider";
|
||||||
|
import { LumeWindow } from "@lume/system";
|
||||||
|
|
||||||
export function NoteUser({ className }: { className?: string }) {
|
export function NoteUser({ className }: { className?: string }) {
|
||||||
const { ark } = useRouteContext({ strict: false });
|
|
||||||
const event = useNoteContext();
|
const event = useNoteContext();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -46,7 +45,7 @@ export function NoteUser({ className }: { className?: string }) {
|
|||||||
</div>
|
</div>
|
||||||
<User.About className="line-clamp-3 text-sm text-white dark:text-neutral-900" />
|
<User.About className="line-clamp-3 text-sm text-white dark:text-neutral-900" />
|
||||||
<button
|
<button
|
||||||
onClick={() => ark.open_profile(event.pubkey)}
|
onClick={() => LumeWindow.openProfile(event.pubkey)}
|
||||||
className="mt-2 inline-flex h-9 w-full items-center justify-center rounded-lg bg-white text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-100 dark:text-neutral-900 dark:hover:bg-neutral-200"
|
className="mt-2 inline-flex h-9 w-full items-center justify-center rounded-lg bg-white text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-100 dark:text-neutral-900 dark:hover:bg-neutral-200"
|
||||||
>
|
>
|
||||||
View profile
|
View profile
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import type { Event } from "@lume/types";
|
import type { NostrEvent } from "@lume/types";
|
||||||
import { Note } from "./note";
|
import { Note } from "@/components/note";
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
|
|
||||||
export function Notification({
|
export function Notification({
|
||||||
event,
|
event,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
event: Event;
|
event: NostrEvent;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { QuoteIcon } from "@lume/icons";
|
import { QuoteIcon } from "@lume/icons";
|
||||||
import type { Event } from "@lume/types";
|
import type { NostrEvent } from "@lume/types";
|
||||||
import { Note } from "./note";
|
import { Note } from "@/components/note";
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
|
|
||||||
export function Quote({
|
export function Quote({
|
||||||
event,
|
event,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
event: Event;
|
event: NostrEvent;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const quoteEventId = event.tags.find(
|
const quoteEventId = event.tags.find(
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
import type { Event } from "@lume/types";
|
|
||||||
import { Spinner } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import { Note } from "./note";
|
import { Note } from "@/components/note";
|
||||||
import { User } from "./user";
|
import { User } from "@/components/user";
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
import { NostrEvent } from "@lume/types";
|
||||||
|
import { NostrQuery } from "@lume/system";
|
||||||
|
|
||||||
export function RepostNote({
|
export function RepostNote({
|
||||||
event,
|
event,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
event: Event;
|
event: NostrEvent;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const { ark } = useRouteContext({ strict: false });
|
|
||||||
const {
|
const {
|
||||||
isLoading,
|
isLoading,
|
||||||
isError,
|
isError,
|
||||||
@@ -23,12 +22,12 @@ export function RepostNote({
|
|||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try {
|
try {
|
||||||
if (event.content.length > 50) {
|
if (event.content.length > 50) {
|
||||||
const embed: Event = JSON.parse(event.content);
|
const embed: NostrEvent = JSON.parse(event.content);
|
||||||
return embed;
|
return embed;
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = event.tags.find((el) => el[0] === "e")?.[1];
|
const id = event.tags.find((el) => el[0] === "e")?.[1];
|
||||||
const repostEvent = await ark.get_event(id);
|
const repostEvent = await NostrQuery.getEvent(id);
|
||||||
|
|
||||||
return repostEvent;
|
return repostEvent;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import type { Event } from "@lume/types";
|
import type { NostrEvent } from "@lume/types";
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { Note } from "./note";
|
import { Note } from "@/components/note";
|
||||||
|
|
||||||
export function TextNote({
|
export function TextNote({
|
||||||
event,
|
event,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
event: Event;
|
event: NostrEvent;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export function UserAvatar({ className }: { className?: string }) {
|
|||||||
alt={user.pubkey}
|
alt={user.pubkey}
|
||||||
loading="eager"
|
loading="eager"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
className={cn("outline-[.5px] outline-black/5", className)}
|
className={cn("outline-[.5px] outline-black/5 object-cover", className)}
|
||||||
/>
|
/>
|
||||||
<Avatar.Fallback delayMs={120}>
|
<Avatar.Fallback delayMs={120}>
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Spinner } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import { useUserContext } from "./provider";
|
import { useUserContext } from "./provider";
|
||||||
|
import { NostrAccount } from "@lume/system";
|
||||||
|
|
||||||
export function UserFollowButton({
|
export function UserFollowButton({
|
||||||
simple = false,
|
simple = false,
|
||||||
@@ -12,7 +12,6 @@ export function UserFollowButton({
|
|||||||
simple?: boolean;
|
simple?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const { ark } = useRouteContext({ strict: false });
|
|
||||||
const user = useUserContext();
|
const user = useUserContext();
|
||||||
|
|
||||||
const [t] = useTranslation();
|
const [t] = useTranslation();
|
||||||
@@ -22,10 +21,10 @@ export function UserFollowButton({
|
|||||||
const toggleFollow = async () => {
|
const toggleFollow = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
if (!followed) {
|
if (!followed) {
|
||||||
const add = await ark.follow(user.pubkey);
|
const add = await NostrAccount.follow(user.pubkey, user.profile?.name);
|
||||||
if (add) setFollowed(true);
|
if (add) setFollowed(true);
|
||||||
} else {
|
} else {
|
||||||
const remove = await ark.unfollow(user.pubkey);
|
const remove = await NostrAccount.unfollow(user.pubkey);
|
||||||
if (remove) setFollowed(false);
|
if (remove) setFollowed(false);
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -35,7 +34,7 @@ export function UserFollowButton({
|
|||||||
async function status() {
|
async function status() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const contacts = await ark.get_contact_list();
|
const contacts = await NostrAccount.getContactList();
|
||||||
if (contacts?.includes(user.pubkey)) {
|
if (contacts?.includes(user.pubkey)) {
|
||||||
setFollowed(true);
|
setFollowed(true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,21 +2,21 @@ import { VerifiedIcon } from "@lume/icons";
|
|||||||
import { displayLongHandle, displayNpub } from "@lume/utils";
|
import { displayLongHandle, displayNpub } from "@lume/utils";
|
||||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
|
||||||
import { useUserContext } from "./provider";
|
import { useUserContext } from "./provider";
|
||||||
|
import { NostrQuery } from "@lume/system";
|
||||||
|
|
||||||
export function UserNip05() {
|
export function UserNip05() {
|
||||||
const user = useUserContext();
|
const user = useUserContext();
|
||||||
|
|
||||||
const { ark } = useRouteContext({ strict: false });
|
|
||||||
const { isLoading, data: verified } = useQuery({
|
const { isLoading, data: verified } = useQuery({
|
||||||
queryKey: ["nip05", user?.pubkey],
|
queryKey: ["nip05", user?.pubkey],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!user.profile?.nip05) return false;
|
const verify = await NostrQuery.verifyNip05(
|
||||||
const verify = await ark.verify_nip05(user.pubkey, user.profile?.nip05);
|
user.pubkey,
|
||||||
|
user.profile?.nip05,
|
||||||
|
);
|
||||||
return verify;
|
return verify;
|
||||||
},
|
},
|
||||||
enabled: !!user.profile,
|
enabled: !!user.profile?.nip05,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user.profile?.nip05?.length) return;
|
if (!user.profile?.nip05?.length) return;
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useProfile } from "@lume/ark";
|
import { useProfile } from "@lume/system";
|
||||||
import type { Metadata } from "@lume/types";
|
import type { Metadata } from "@lume/types";
|
||||||
import { type ReactNode, createContext, useContext } from "react";
|
import { type ReactNode, createContext, useContext } from "react";
|
||||||
|
|
||||||
const UserContext = createContext<{
|
const UserContext = createContext<{
|
||||||
pubkey: string;
|
pubkey: string;
|
||||||
|
profile: Metadata;
|
||||||
isError: boolean;
|
isError: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
profile: Metadata;
|
|
||||||
}>(null);
|
}>(null);
|
||||||
|
|
||||||
export function UserProvider({
|
export function UserProvider({
|
||||||
@@ -21,7 +21,7 @@ export function UserProvider({
|
|||||||
const { isLoading, isError, profile } = useProfile(pubkey, embedProfile);
|
const { isLoading, isError, profile } = useProfile(pubkey, embedProfile);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserContext.Provider value={{ pubkey, isError, isLoading, profile }}>
|
<UserContext.Provider value={{ pubkey, profile, isError, isLoading }}>
|
||||||
{children}
|
{children}
|
||||||
</UserContext.Provider>
|
</UserContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,50 +1,41 @@
|
|||||||
import { Col } from "@/components/col";
|
import { Column } from "@/components/column";
|
||||||
import { Toolbar } from "@/components/toolbar";
|
import { Toolbar } from "@/components/toolbar";
|
||||||
import { ArrowLeftIcon, ArrowRightIcon } from "@lume/icons";
|
import { ArrowLeftIcon, ArrowRightIcon } from "@lume/icons";
|
||||||
|
import { NostrQuery } from "@lume/system";
|
||||||
import type { EventColumns, LumeColumn } from "@lume/types";
|
import type { EventColumns, LumeColumn } from "@lume/types";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { resolveResource } from "@tauri-apps/api/path";
|
|
||||||
import { getCurrent } from "@tauri-apps/api/window";
|
import { getCurrent } from "@tauri-apps/api/window";
|
||||||
import { readTextFile } from "@tauri-apps/plugin-fs";
|
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useDebouncedCallback } from "use-debounce";
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
import { VList, type VListHandle } from "virtua";
|
import { VList, type VListHandle } from "virtua";
|
||||||
|
|
||||||
export const Route = createFileRoute("/$account/home")({
|
export const Route = createFileRoute("/$account/home")({
|
||||||
beforeLoad: async ({ context }) => {
|
loader: async () => {
|
||||||
try {
|
const columns = await NostrQuery.getColumns();
|
||||||
const ark = context.ark;
|
return columns;
|
||||||
const resourcePath = await resolveResource(
|
|
||||||
"resources/system_columns.json",
|
|
||||||
);
|
|
||||||
const systemColumns: LumeColumn[] = JSON.parse(
|
|
||||||
await readTextFile(resourcePath),
|
|
||||||
);
|
|
||||||
const userColumns = await ark.get_columns();
|
|
||||||
|
|
||||||
return {
|
|
||||||
storedColumns: !userColumns.length ? systemColumns : userColumns,
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
console.error(String(e));
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
gcTime: 0,
|
||||||
|
shouldReload: false,
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
|
const { account } = Route.useParams();
|
||||||
|
const initialColumnList = Route.useLoaderData();
|
||||||
const vlistRef = useRef<VListHandle>(null);
|
const vlistRef = useRef<VListHandle>(null);
|
||||||
|
|
||||||
const { account } = Route.useParams();
|
const [columns, setColumns] = useState<LumeColumn[]>([]);
|
||||||
const { ark, storedColumns } = Route.useRouteContext();
|
|
||||||
|
|
||||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||||
const [columns, setColumns] = useState(storedColumns);
|
|
||||||
const [isScroll, setIsScroll] = useState(false);
|
const [isScroll, setIsScroll] = useState(false);
|
||||||
const [isResize, setIsResize] = useState(false);
|
const [isResize, setIsResize] = useState(false);
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setColumns(null);
|
||||||
|
setSelectedIndex(-1);
|
||||||
|
};
|
||||||
|
|
||||||
const goLeft = () => {
|
const goLeft = () => {
|
||||||
const prevIndex = Math.max(selectedIndex - 1, 0);
|
const prevIndex = Math.max(selectedIndex - 1, 0);
|
||||||
setSelectedIndex(prevIndex);
|
setSelectedIndex(prevIndex);
|
||||||
@@ -80,7 +71,7 @@ function Screen() {
|
|||||||
|
|
||||||
// scroll to the newest column
|
// scroll to the newest column
|
||||||
vlistRef.current.scrollToIndex(newCols.length - 1, {
|
vlistRef.current.scrollToIndex(newCols.length - 1, {
|
||||||
align: "end",
|
align: "center",
|
||||||
});
|
});
|
||||||
}, 150);
|
}, 150);
|
||||||
|
|
||||||
@@ -115,12 +106,19 @@ function Screen() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// save state
|
setColumns(initialColumnList);
|
||||||
ark.set_columns(columns);
|
}, [initialColumnList]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// save current state
|
||||||
|
if (columns?.length) {
|
||||||
|
NostrQuery.setColumns(columns).then(() => console.log("saved"));
|
||||||
|
}
|
||||||
}, [columns]);
|
}, [columns]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unlistenColEvent = listen<EventColumns>("columns", (data) => {
|
const unlistenColEvent = listen<EventColumns>("columns", (data) => {
|
||||||
|
if (data.payload.type === "reset") reset();
|
||||||
if (data.payload.type === "add") add(data.payload.column);
|
if (data.payload.type === "add") add(data.payload.column);
|
||||||
if (data.payload.type === "remove") remove(data.payload.label);
|
if (data.payload.type === "remove") remove(data.payload.label);
|
||||||
if (data.payload.type === "set_title")
|
if (data.payload.type === "set_title")
|
||||||
@@ -144,14 +142,14 @@ function Screen() {
|
|||||||
horizontal
|
horizontal
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
itemSize={440}
|
itemSize={440}
|
||||||
overscan={3}
|
overscan={5}
|
||||||
onScroll={() => setIsScroll(true)}
|
onScroll={() => setIsScroll(true)}
|
||||||
onScrollEnd={() => setIsScroll(false)}
|
onScrollEnd={() => setIsScroll(false)}
|
||||||
className="scrollbar-none h-full w-full overflow-x-auto focus:outline-none"
|
className="scrollbar-none h-full w-full overflow-x-auto focus:outline-none"
|
||||||
>
|
>
|
||||||
{columns.map((column) => (
|
{columns?.map((column) => (
|
||||||
<Col
|
<Column
|
||||||
key={column.label}
|
key={account + column.label}
|
||||||
column={column}
|
column={column}
|
||||||
account={account}
|
account={account}
|
||||||
isScroll={isScroll}
|
isScroll={isScroll}
|
||||||
|
|||||||
@@ -1,30 +1,29 @@
|
|||||||
import { BellIcon, ComposeFilledIcon, PlusIcon, SearchIcon } from "@lume/icons";
|
|
||||||
import { Event, Kind } from "@lume/types";
|
|
||||||
import { User } from "@/components/user";
|
|
||||||
import {
|
import {
|
||||||
cn,
|
ComposeFilledIcon,
|
||||||
decodeZapInvoice,
|
HorizontalDotsIcon,
|
||||||
displayNpub,
|
PlusIcon,
|
||||||
sendNativeNotification,
|
SearchIcon,
|
||||||
} from "@lume/utils";
|
} from "@lume/icons";
|
||||||
|
import { User } from "@/components/user";
|
||||||
|
import { cn } from "@lume/utils";
|
||||||
import { Outlet, createFileRoute } from "@tanstack/react-router";
|
import { Outlet, createFileRoute } from "@tanstack/react-router";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { getCurrent } from "@tauri-apps/api/window";
|
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";
|
||||||
|
import { LumeWindow, NostrAccount } from "@lume/system";
|
||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
|
|
||||||
export const Route = createFileRoute("/$account")({
|
export const Route = createFileRoute("/$account")({
|
||||||
beforeLoad: async ({ context }) => {
|
beforeLoad: async () => {
|
||||||
const ark = context.ark;
|
const accounts = await NostrAccount.getAccounts();
|
||||||
const accounts = await ark.get_all_accounts();
|
|
||||||
|
|
||||||
return { accounts };
|
return { accounts };
|
||||||
},
|
},
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const { ark, platform } = Route.useRouteContext();
|
const { platform } = Route.useRouteContext();
|
||||||
const navigate = Route.useNavigate();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-screen flex-col">
|
<div className="flex h-screen w-screen flex-col">
|
||||||
@@ -37,27 +36,25 @@ function Screen() {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Accounts />
|
<Accounts />
|
||||||
<button
|
<Link
|
||||||
type="button"
|
to="/landing"
|
||||||
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"
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
<PlusIcon className="size-5" />
|
<PlusIcon className="size-5" />
|
||||||
</button>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => ark.open_editor()}
|
onClick={() => LumeWindow.openEditor()}
|
||||||
className="inline-flex h-8 w-max items-center justify-center gap-1 rounded-full bg-blue-500 px-3 text-sm font-medium text-white hover:bg-blue-600"
|
className="inline-flex h-8 w-max items-center justify-center gap-1 rounded-full bg-blue-500 px-3 text-sm font-medium text-white hover:bg-blue-600"
|
||||||
>
|
>
|
||||||
<ComposeFilledIcon className="size-4" />
|
<ComposeFilledIcon className="size-4" />
|
||||||
New Post
|
New Post
|
||||||
</button>
|
</button>
|
||||||
<Bell />
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => ark.open_search()}
|
onClick={() => LumeWindow.openSearch()}
|
||||||
className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
|
className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
|
||||||
>
|
>
|
||||||
<SearchIcon className="size-5" />
|
<SearchIcon className="size-5" />
|
||||||
@@ -73,106 +70,119 @@ function Screen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Accounts() {
|
function Accounts() {
|
||||||
const navigate = Route.useNavigate();
|
const { accounts } = Route.useRouteContext();
|
||||||
const { ark, accounts } = Route.useRouteContext();
|
|
||||||
const { account } = Route.useParams();
|
const { account } = Route.useParams();
|
||||||
|
|
||||||
const changeAccount = async (npub: string) => {
|
const [windowWidth, setWindowWidth] = useState<number>(null);
|
||||||
if (npub === account) return;
|
|
||||||
|
|
||||||
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 LumeWindow.openProfile(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Change current account and update signer
|
||||||
|
const select = await NostrAccount.loadAccount(npub);
|
||||||
|
|
||||||
if (select) {
|
if (select) {
|
||||||
return navigate({ to: "/$account/home", params: { account: npub } });
|
// Reset current columns
|
||||||
|
await getCurrent().emit("columns", { type: "reset" });
|
||||||
|
|
||||||
|
// Redirect to new account
|
||||||
|
return navigate({
|
||||||
|
to: "/$account/home",
|
||||||
|
params: { account: npub },
|
||||||
|
resetScroll: true,
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.warning("Something wrong.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const getWindowDimensions = () => {
|
||||||
<div data-tauri-drag-region className="flex items-center gap-3">
|
const { innerWidth: width, innerHeight: height } = window;
|
||||||
{accounts.map((user) => (
|
return {
|
||||||
<button key={user} type="button" onClick={() => changeAccount(user)}>
|
width,
|
||||||
<User.Provider pubkey={user}>
|
height,
|
||||||
<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
|
|
||||||
className={cn(
|
|
||||||
"aspect-square h-auto rounded-full object-cover",
|
|
||||||
user === account ? "w-7" : "w-8",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Bell() {
|
|
||||||
const { ark } = Route.useRouteContext();
|
|
||||||
const { account } = Route.useParams();
|
|
||||||
|
|
||||||
const [count, setCount] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unlisten = getCurrent().listen<string>(
|
function handleResize() {
|
||||||
"activity",
|
setWindowWidth(getWindowDimensions().width);
|
||||||
async (payload) => {
|
}
|
||||||
setCount((prevCount) => prevCount + 1);
|
|
||||||
await invoke("set_badge", { count });
|
|
||||||
|
|
||||||
const event: Event = JSON.parse(payload.payload);
|
if (!windowWidth) setWindowWidth(getWindowDimensions().width);
|
||||||
const user = await ark.get_profile(event.pubkey);
|
window.addEventListener("resize", handleResize);
|
||||||
const userName =
|
|
||||||
user.display_name || user.name || displayNpub(event.pubkey, 16);
|
|
||||||
|
|
||||||
switch (event.kind) {
|
return () => window.removeEventListener("resize", handleResize);
|
||||||
case Kind.Text: {
|
|
||||||
sendNativeNotification("Mentioned you in a note", userName);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case Kind.Repost: {
|
|
||||||
sendNativeNotification("Reposted your note", userName);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case Kind.ZapReceipt: {
|
|
||||||
const amount = decodeZapInvoice(event.tags);
|
|
||||||
sendNativeNotification(
|
|
||||||
`Zapped ₿ ${amount.bitcoinFormatted}`,
|
|
||||||
userName,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unlisten.then((f) => f());
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div data-tauri-drag-region className="flex items-center gap-3">
|
||||||
type="button"
|
{sortedList
|
||||||
onClick={() => {
|
.slice(0, windowWidth > 500 ? account.length : 2)
|
||||||
setCount(0);
|
.map((user) => (
|
||||||
ark.open_activity(account);
|
<button key={user} type="button" onClick={() => changeAccount(user)}>
|
||||||
}}
|
<User.Provider pubkey={user}>
|
||||||
className="relative inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
|
<User.Root
|
||||||
>
|
className={cn(
|
||||||
<BellIcon className="size-5" />
|
"shrink-0 rounded-full transition-all ease-in-out duration-150 will-change-auto",
|
||||||
{count > 0 ? (
|
user === account
|
||||||
<span className="absolute right-0 top-0 block size-2 rounded-full bg-teal-500 ring-1 ring-black/5" />
|
? "ring-1 ring-teal-500 ring-offset-2 ring-offset-neutral-200 dark:ring-offset-neutral-950"
|
||||||
|
: "",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<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}
|
) : null}
|
||||||
</button>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,21 @@
|
|||||||
import type { Ark } from "@lume/ark";
|
|
||||||
import { CheckCircleIcon, InfoCircleIcon, CancelCircleIcon } from "@lume/icons";
|
import { CheckCircleIcon, InfoCircleIcon, CancelCircleIcon } from "@lume/icons";
|
||||||
import type { Interests, Metadata, Settings } from "@lume/types";
|
import type { Settings } from "@lume/types";
|
||||||
import { Spinner } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import type { QueryClient } from "@tanstack/react-query";
|
import type { QueryClient } from "@tanstack/react-query";
|
||||||
import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
|
import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
|
||||||
import type { Platform } from "@tauri-apps/plugin-os";
|
import type { Platform } from "@tauri-apps/plugin-os";
|
||||||
import type { Descendant } from "slate";
|
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
|
|
||||||
type EditorElement = {
|
|
||||||
type: string;
|
|
||||||
children: Descendant[];
|
|
||||||
eventId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface RouterContext {
|
interface RouterContext {
|
||||||
// System
|
// System
|
||||||
ark: Ark;
|
|
||||||
queryClient: QueryClient;
|
queryClient: QueryClient;
|
||||||
// App info
|
// App info
|
||||||
platform?: Platform;
|
platform?: Platform;
|
||||||
locale?: string;
|
locale?: string;
|
||||||
// Settings
|
// Settings
|
||||||
settings?: Settings;
|
settings?: Settings;
|
||||||
interests?: Interests;
|
// Accounts
|
||||||
// Profile
|
|
||||||
accounts?: string[];
|
accounts?: string[];
|
||||||
profile?: Metadata;
|
|
||||||
isNewUser?: boolean;
|
|
||||||
// Editor
|
|
||||||
initialValue?: EditorElement[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Route = createRootRouteWithContext<RouterContext>()({
|
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
import { Spinner } from "@lume/ui";
|
|
||||||
import { Note } from "@/components/note";
|
|
||||||
import { Await, createFileRoute, defer } from "@tanstack/react-router";
|
|
||||||
import { Suspense } from "react";
|
|
||||||
import { Virtualizer } from "virtua";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/activity/$account/texts")({
|
|
||||||
loader: async ({ context, params }) => {
|
|
||||||
const ark = context.ark;
|
|
||||||
return { data: defer(ark.get_activities(params.account, "1")) };
|
|
||||||
},
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
const { data } = Route.useLoaderData();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Virtualizer overscan={3}>
|
|
||||||
<Suspense
|
|
||||||
fallback={
|
|
||||||
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex items-center gap-2 text-sm font-medium"
|
|
||||||
disabled
|
|
||||||
>
|
|
||||||
<Spinner className="size-5" />
|
|
||||||
Loading...
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Await promise={data}>
|
|
||||||
{(events) =>
|
|
||||||
events.map((event) => (
|
|
||||||
<div
|
|
||||||
key={event.id}
|
|
||||||
className="flex flex-col gap-2 mb-3 bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50"
|
|
||||||
>
|
|
||||||
<Note.Provider event={event}>
|
|
||||||
<Note.Root>
|
|
||||||
<div className="px-3 h-14 flex items-center justify-between">
|
|
||||||
<Note.User />
|
|
||||||
<Note.Menu />
|
|
||||||
</div>
|
|
||||||
<Note.Activity className="px-3" />
|
|
||||||
<Note.Content className="px-3" quote={false} clean />
|
|
||||||
<div className="mt-3 flex items-center gap-4 h-14 px-3">
|
|
||||||
<Note.Open />
|
|
||||||
</div>
|
|
||||||
</Note.Root>
|
|
||||||
</Note.Provider>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</Await>
|
|
||||||
</Suspense>
|
|
||||||
</Virtualizer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import { Box, Container } from "@lume/ui";
|
|
||||||
import { cn } from "@lume/utils";
|
|
||||||
import { Link, Outlet } from "@tanstack/react-router";
|
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/activity/$account")({
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
const { account } = Route.useParams();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container withDrag withNavigate={false}>
|
|
||||||
<Box className="scrollbar-none shadow-none bg-black/5 dark:bg-white/5 backdrop-blur-sm flex flex-col overflow-y-auto">
|
|
||||||
<div className="h-14 shrink-0 flex w-full items-center gap-1 px-3">
|
|
||||||
<div className="inline-flex h-full w-full items-center gap-1">
|
|
||||||
<Link to="/activity/$account/texts" params={{ account }}>
|
|
||||||
{({ isActive }) => (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"inline-flex h-7 w-max items-center justify-center gap-2 rounded-full px-3 text-sm font-medium",
|
|
||||||
isActive ? "bg-neutral-50 dark:bg-white/10" : "opacity-50",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Notes
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
<Link to="/activity/$account/zaps" params={{ account }}>
|
|
||||||
{({ isActive }) => (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"inline-flex h-7 w-max items-center justify-center gap-2 rounded-full px-3 text-sm font-medium",
|
|
||||||
isActive ? "bg-neutral-50 dark:bg-white/10" : "opacity-50",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Zaps
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="px-2 flex-1 overflow-y-auto w-full h-full scrollbar-none">
|
|
||||||
<Outlet />
|
|
||||||
</div>
|
|
||||||
</Box>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
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";
|
|
||||||
import { Virtualizer } from "virtua";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/activity/$account/zaps")({
|
|
||||||
loader: async ({ context, params }) => {
|
|
||||||
const ark = context.ark;
|
|
||||||
return { data: defer(ark.get_activities(params.account, "9735")) };
|
|
||||||
},
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
const { data } = Route.useLoaderData();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Virtualizer overscan={3}>
|
|
||||||
<Suspense
|
|
||||||
fallback={
|
|
||||||
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex items-center gap-2 text-sm font-medium"
|
|
||||||
disabled
|
|
||||||
>
|
|
||||||
<Spinner className="size-5" />
|
|
||||||
Loading...
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Await promise={data}>
|
|
||||||
{(events) =>
|
|
||||||
events.map((event) => (
|
|
||||||
<div
|
|
||||||
key={event.id}
|
|
||||||
className="flex flex-col gap-2 mb-3 bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50"
|
|
||||||
>
|
|
||||||
<User.Provider pubkey={event.pubkey}>
|
|
||||||
<User.Root className="flex flex-col">
|
|
||||||
<div className="text-lg h-20 font-medium leading-tight flex w-full items-center justify-center">
|
|
||||||
₿ {decodeZapInvoice(event.tags).bitcoinFormatted}
|
|
||||||
</div>
|
|
||||||
<div className="h-11 border-t border-neutral-100 dark:border-neutral-900 flex items-center gap-1 px-2">
|
|
||||||
<div className="inline-flex items-center gap-2">
|
|
||||||
<User.Avatar className="size-7 rounded-full shrink-0" />
|
|
||||||
<User.Name className="text-sm font-medium" />
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-neutral-700 dark:text-neutral-300">
|
|
||||||
zapped you
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</Await>
|
|
||||||
</Suspense>
|
|
||||||
</Virtualizer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { CheckIcon } from "@lume/icons";
|
import { CheckIcon } from "@lume/icons";
|
||||||
import type { AppRouteSearch } from "@lume/types";
|
|
||||||
import { Spinner } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import { displayNsec } from "@lume/utils";
|
import { displayNsec } from "@lume/utils";
|
||||||
import * as Checkbox from "@radix-ui/react-checkbox";
|
import * as Checkbox from "@radix-ui/react-checkbox";
|
||||||
@@ -10,17 +9,12 @@ import { useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export const Route = createFileRoute("/auth/new/backup")({
|
export const Route = createFileRoute("/auth/$account/backup")({
|
||||||
validateSearch: (search: Record<string, string>): AppRouteSearch => {
|
|
||||||
return {
|
|
||||||
account: search.account,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const { account } = Route.useSearch();
|
const { account } = Route.useParams();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [key, setKey] = useState(null);
|
const [key, setKey] = useState(null);
|
||||||
@@ -39,8 +33,8 @@ function Screen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return navigate({
|
return navigate({
|
||||||
to: "/auth/settings",
|
to: "/auth/$account/settings",
|
||||||
search: { account },
|
params: { account },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,59 +1,30 @@
|
|||||||
import { LaurelIcon } from "@lume/icons";
|
import { LaurelIcon } from "@lume/icons";
|
||||||
import type { AppRouteSearch, Settings } from "@lume/types";
|
import { NostrQuery } from "@lume/system";
|
||||||
import { Spinner } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import * as Switch from "@radix-ui/react-switch";
|
import * as Switch from "@radix-ui/react-switch";
|
||||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import {
|
|
||||||
isPermissionGranted,
|
|
||||||
requestPermission,
|
|
||||||
} from "@tauri-apps/plugin-notification";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export const Route = createFileRoute("/auth/settings")({
|
export const Route = createFileRoute("/auth/$account/settings")({
|
||||||
validateSearch: (search: Record<string, string>): AppRouteSearch => {
|
beforeLoad: async () => {
|
||||||
return {
|
const settings = await NostrQuery.getSettings();
|
||||||
account: search.account,
|
return { settings };
|
||||||
};
|
|
||||||
},
|
|
||||||
beforeLoad: async ({ context }) => {
|
|
||||||
const permissionGranted = await isPermissionGranted(); // get notification permission
|
|
||||||
const ark = context.ark;
|
|
||||||
const settings = await ark.get_settings();
|
|
||||||
|
|
||||||
return {
|
|
||||||
settings: { ...settings, notification: permissionGranted },
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
component: Screen,
|
component: Screen,
|
||||||
pendingComponent: Pending,
|
pendingComponent: Pending,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const navigate = useNavigate();
|
const { settings } = Route.useRouteContext();
|
||||||
|
const { account } = Route.useParams();
|
||||||
const { account } = Route.useSearch();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { ark, settings } = Route.useRouteContext();
|
|
||||||
|
|
||||||
const [newSettings, setNewSettings] = useState<Settings>(settings);
|
const [newSettings, setNewSettings] = useState(settings);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const toggleNofitication = async () => {
|
const navigate = Route.useNavigate();
|
||||||
await requestPermission();
|
|
||||||
setNewSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
notification: !newSettings.notification,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleAutoUpdate = () => {
|
|
||||||
setNewSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
autoUpdate: !newSettings.autoUpdate,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleEnhancedPrivacy = () => {
|
const toggleEnhancedPrivacy = () => {
|
||||||
setNewSettings((prev) => ({
|
setNewSettings((prev) => ({
|
||||||
@@ -82,7 +53,7 @@ function Screen() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
// publish settings
|
// publish settings
|
||||||
const eventId = await ark.set_settings(newSettings);
|
const eventId = await NostrQuery.setSettings(newSettings);
|
||||||
|
|
||||||
if (eventId) {
|
if (eventId) {
|
||||||
return navigate({
|
return navigate({
|
||||||
@@ -114,22 +85,6 @@ function Screen() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="font-semibold">Push Notification</h3>
|
|
||||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
|
||||||
Enabling push notifications will allow you to receive
|
|
||||||
notifications from Lume.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch.Root
|
|
||||||
checked={newSettings.notification}
|
|
||||||
onClick={() => toggleNofitication()}
|
|
||||||
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/20"
|
|
||||||
>
|
|
||||||
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
|
||||||
</Switch.Root>
|
|
||||||
</div>
|
|
||||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
|
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="font-semibold">Enhanced Privacy</h3>
|
<h3 className="font-semibold">Enhanced Privacy</h3>
|
||||||
@@ -146,21 +101,6 @@ function Screen() {
|
|||||||
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||||
</Switch.Root>
|
</Switch.Root>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="font-semibold">Auto Update</h3>
|
|
||||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
|
||||||
Automatically download and install new version.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch.Root
|
|
||||||
checked={newSettings.autoUpdate}
|
|
||||||
onClick={() => toggleAutoUpdate()}
|
|
||||||
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/20"
|
|
||||||
>
|
|
||||||
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
|
||||||
</Switch.Root>
|
|
||||||
</div>
|
|
||||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
|
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="font-semibold">Zap</h3>
|
<h3 className="font-semibold">Zap</h3>
|
||||||
@@ -200,7 +140,7 @@ function Screen() {
|
|||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="mb-1 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
|
className="mb-1 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{t("global.continue")}
|
{loading ? <Spinner /> : t("global.continue")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { AvatarUploader } from "@/components/avatarUploader";
|
import { AvatarUploader } from "@/components/avatarUploader";
|
||||||
import { PlusIcon } from "@lume/icons";
|
import { PlusIcon } from "@lume/icons";
|
||||||
|
import { NostrAccount } from "@lume/system";
|
||||||
import type { Metadata } from "@lume/types";
|
import type { Metadata } from "@lume/types";
|
||||||
import { Spinner } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
@@ -10,17 +11,17 @@ import { toast } from "sonner";
|
|||||||
|
|
||||||
export const Route = createFileRoute("/auth/new/profile")({
|
export const Route = createFileRoute("/auth/new/profile")({
|
||||||
component: Screen,
|
component: Screen,
|
||||||
loader: ({ context }) => {
|
loader: async () => {
|
||||||
return context.ark.create_keys();
|
const account = await NostrAccount.createAccount();
|
||||||
|
return account;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const keys = Route.useLoaderData();
|
const account = Route.useLoaderData();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { ark } = Route.useRouteContext();
|
|
||||||
const { register, handleSubmit } = useForm();
|
const { register, handleSubmit } = useForm();
|
||||||
|
|
||||||
const [picture, setPicture] = useState<string>("");
|
const [picture, setPicture] = useState<string>("");
|
||||||
@@ -35,17 +36,17 @@ function Screen() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Save account keys
|
// Save account keys
|
||||||
const save = await ark.save_account(keys.nsec);
|
const save = await NostrAccount.saveAccount(account.nsec);
|
||||||
|
|
||||||
// Then create profile
|
// Then create profile
|
||||||
if (save) {
|
if (save) {
|
||||||
const profile: Metadata = { ...data, picture };
|
const profile: Metadata = { ...data, picture };
|
||||||
const eventId = await ark.create_profile(profile);
|
const eventId = await NostrAccount.createProfile(profile);
|
||||||
|
|
||||||
if (eventId) {
|
if (eventId) {
|
||||||
navigate({
|
navigate({
|
||||||
to: "/auth/new/backup",
|
to: "/auth/$account/backup",
|
||||||
search: { account: keys.npub },
|
params: { account: account.npub },
|
||||||
replace: true,
|
replace: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { NostrAccount } from "@lume/system";
|
||||||
import { Spinner } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -8,7 +9,6 @@ export const Route = createLazyFileRoute("/auth/privkey")({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const { ark } = Route.useRouteContext();
|
|
||||||
const navigate = Route.useNavigate();
|
const navigate = Route.useNavigate();
|
||||||
|
|
||||||
const [key, setKey] = useState("");
|
const [key, setKey] = useState("");
|
||||||
@@ -24,12 +24,12 @@ function Screen() {
|
|||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const npub = await ark.save_account(key, password);
|
const npub = await NostrAccount.saveAccount(key, password);
|
||||||
|
|
||||||
if (npub) {
|
if (npub) {
|
||||||
navigate({
|
navigate({
|
||||||
to: "/auth/settings",
|
to: "/auth/$account/settings",
|
||||||
search: { account: npub },
|
params: { account: npub },
|
||||||
replace: true,
|
replace: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { NostrAccount } from "@lume/system";
|
||||||
import { Spinner } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -8,7 +9,6 @@ export const Route = createLazyFileRoute("/auth/remote")({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const { ark } = Route.useRouteContext();
|
|
||||||
const navigate = Route.useNavigate();
|
const navigate = Route.useNavigate();
|
||||||
|
|
||||||
const [uri, setUri] = useState("");
|
const [uri, setUri] = useState("");
|
||||||
@@ -23,12 +23,12 @@ function Screen() {
|
|||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const npub = await ark.nostr_connect(uri);
|
const remoteAccount = await NostrAccount.connectRemoteAccount(uri);
|
||||||
|
|
||||||
if (npub) {
|
if (remoteAccount?.length) {
|
||||||
navigate({
|
return navigate({
|
||||||
to: "/auth/settings",
|
to: "/auth/$account/settings",
|
||||||
search: { account: npub },
|
params: { account: remoteAccount },
|
||||||
replace: true,
|
replace: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -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"
|
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>
|
</div>
|
||||||
<button
|
<div className="flex flex-col gap-1 items-center">
|
||||||
type="button"
|
<button
|
||||||
onClick={() => submit()}
|
type="button"
|
||||||
disabled={loading}
|
onClick={() => submit()}
|
||||||
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"
|
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 ? <Spinner /> : "Login"}
|
||||||
|
</button>
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-neutral-600 dark:text-neutral-400 text-sm text-center">
|
||||||
|
Waiting confirmation...
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { CheckCircleIcon } from "@lume/icons";
|
import { CancelIcon, PlusIcon } from "@lume/icons";
|
||||||
import type { ColumnRouteSearch } from "@lume/types";
|
import type { ColumnRouteSearch } from "@lume/types";
|
||||||
import { Spinner } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import { User } from "@/components/user";
|
import { User } from "@/components/user";
|
||||||
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { NostrAccount, NostrQuery } from "@lume/system";
|
||||||
|
|
||||||
export const Route = createFileRoute("/create-group")({
|
export const Route = createFileRoute("/create-group")({
|
||||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||||
@@ -14,113 +15,183 @@ export const Route = createFileRoute("/create-group")({
|
|||||||
name: search.name,
|
name: search.name,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
loader: async ({ context }) => {
|
loader: async () => {
|
||||||
const ark = context.ark;
|
const contacts = await NostrAccount.getContactList();
|
||||||
const contacts = await ark.get_contact_list();
|
|
||||||
return contacts;
|
return contacts;
|
||||||
},
|
},
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [npub, setNpub] = useState("");
|
||||||
|
const [users, setUsers] = useState<string[]>([
|
||||||
|
"npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445", // reya
|
||||||
|
]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const contacts = Route.useLoaderData();
|
const contacts = Route.useLoaderData();
|
||||||
const router = useRouter();
|
const search = Route.useSearch();
|
||||||
|
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 toggleUser = (pubkey: string) => {
|
const toggleUser = (pubkey: string) => {
|
||||||
const arr = users.includes(pubkey)
|
setUsers((prev) =>
|
||||||
? users.filter((i) => i !== pubkey)
|
prev.includes(pubkey)
|
||||||
: [...users, pubkey];
|
? prev.filter((i) => i !== pubkey)
|
||||||
setUsers(arr);
|
: [...prev, pubkey],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addUser = () => {
|
||||||
|
if (!npub.startsWith("npub1")) return;
|
||||||
|
if (users.includes(npub)) return;
|
||||||
|
|
||||||
|
setUsers((prev) => [...prev, npub]);
|
||||||
|
setNpub("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
try {
|
try {
|
||||||
if (isDone) return router.history.push(redirect);
|
setIsLoading(true);
|
||||||
|
|
||||||
// start loading
|
const key = `lume_group_${search.label}`;
|
||||||
setLoading(true);
|
const createGroup = await NostrQuery.setNstore(
|
||||||
|
key,
|
||||||
const groups = await ark.set_nstore(
|
|
||||||
`lume_group_${label}`,
|
|
||||||
JSON.stringify(users),
|
JSON.stringify(users),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (groups) {
|
if (createGroup) {
|
||||||
toast.success("Group has been created successfully.");
|
return navigate({ to: search.redirect, search: { ...search } });
|
||||||
// start loading
|
|
||||||
setIsDone(true);
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setLoading(false);
|
setIsLoading(false);
|
||||||
toast.error(e);
|
toast.error(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-y-auto scrollbar-none">
|
<div className="w-full h-full flex flex-col items-center justify-center gap-4">
|
||||||
<div className="flex flex-col gap-5 p-3">
|
<div className="text-center flex flex-col items-center justify-center">
|
||||||
<div className="flex flex-col gap-1">
|
<h1 className="text-2xl font-serif font-medium">
|
||||||
<label htmlFor="name" className="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
|
Name
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
name="name"
|
name="name"
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
placeholder="Nostrichs..."
|
placeholder="Enter a name for this group"
|
||||||
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"
|
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>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="w-full flex flex-col items-center gap-3">
|
||||||
<div className="inline-flex items-center justify-between">
|
<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">
|
||||||
<span className="font-medium">Pick user</span>
|
<div className="flex gap-2">
|
||||||
<span className="text-neutral-600 dark:text-neutral-400">{`${users.length} / ∞`}</span>
|
<input
|
||||||
</div>
|
name="npub"
|
||||||
<div className="flex flex-col gap-2">
|
value={npub}
|
||||||
{contacts.map((item: string) => (
|
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
|
<button
|
||||||
key={item}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleUser(item)}
|
onClick={() => addUser()}
|
||||||
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"
|
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}>
|
<PlusIcon className="size-6" />
|
||||||
<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}
|
|
||||||
</button>
|
</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>
|
|
||||||
<div className="fixed z-10 flex items-center justify-center w-full bottom-6">
|
|
||||||
{users.length >= 1 ? (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => submit()}
|
onClick={() => submit()}
|
||||||
disabled={users.length < 1}
|
disabled={isLoading || 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"
|
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>
|
</button>
|
||||||
) : null}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { NostrAccount } from "@lume/system";
|
||||||
|
import type { ColumnRouteSearch } from "@lume/types";
|
||||||
|
import { Spinner } from "@lume/ui";
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
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 = await NostrAccount.f2f(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";
|
||||||
|
import { NostrAccount } from "@lume/system";
|
||||||
|
|
||||||
|
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 { 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 NostrAccount.setContactList(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,106 @@
|
|||||||
|
import { CheckCircleIcon } from "@lume/icons";
|
||||||
|
import { NostrQuery } from "@lume/system";
|
||||||
|
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 [topics, setTopics] = useState<Topic[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const search = Route.useSearch();
|
||||||
|
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_${search.label}`;
|
||||||
|
const createTopic = await NostrQuery.setNstore(
|
||||||
|
key,
|
||||||
|
JSON.stringify(topics),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (createTopic) {
|
||||||
|
return navigate({ to: search.redirect, search: { ...search } });
|
||||||
|
}
|
||||||
|
} 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { AddMediaIcon } from "@lume/icons";
|
import { AddMediaIcon } from "@lume/icons";
|
||||||
|
import { NostrQuery } from "@lume/system";
|
||||||
import { Spinner } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import { cn, insertImage, isImagePath } from "@lume/utils";
|
import { cn, insertImage, isImagePath } from "@lume/utils";
|
||||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
|
||||||
import type { UnlistenFn } from "@tauri-apps/api/event";
|
import type { UnlistenFn } from "@tauri-apps/api/event";
|
||||||
import { getCurrent } from "@tauri-apps/api/window";
|
import { getCurrent } from "@tauri-apps/api/window";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
@@ -11,15 +11,14 @@ import { toast } from "sonner";
|
|||||||
|
|
||||||
export function MediaButton({ className }: { className?: string }) {
|
export function MediaButton({ className }: { className?: string }) {
|
||||||
const editor = useSlateStatic();
|
const editor = useSlateStatic();
|
||||||
const { ark } = useRouteContext({ strict: false });
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const uploadToNostrBuild = async () => {
|
const upload = async () => {
|
||||||
try {
|
try {
|
||||||
// start loading
|
// start loading
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const image = await ark.upload();
|
const image = await NostrQuery.upload();
|
||||||
insertImage(editor, image);
|
insertImage(editor, image);
|
||||||
|
|
||||||
// reset loading
|
// reset loading
|
||||||
@@ -44,7 +43,7 @@ export function MediaButton({ className }: { className?: string }) {
|
|||||||
// upload all images
|
// upload all images
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (isImagePath(item)) {
|
if (isImagePath(item)) {
|
||||||
const image = await ark.upload(item);
|
const image = await NostrQuery.upload(item);
|
||||||
insertImage(editor, image);
|
insertImage(editor, image);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,7 +66,7 @@ export function MediaButton({ className }: { className?: string }) {
|
|||||||
<Tooltip.Trigger asChild>
|
<Tooltip.Trigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => uploadToNostrBuild()}
|
onClick={() => upload()}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className={cn("inline-flex items-center justify-center", className)}
|
className={cn("inline-flex items-center justify-center", className)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,20 +3,19 @@ import { cn, insertMention } from "@lume/utils";
|
|||||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||||
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
|
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
|
||||||
import { useSlateStatic } from "slate-react";
|
import { useSlateStatic } from "slate-react";
|
||||||
import type { Contact } from "@lume/types";
|
import type { Contact } from "@lume/types";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { User } from "@/components/user";
|
import { User } from "@/components/user";
|
||||||
|
import { NostrAccount, NostrQuery } from "@lume/system";
|
||||||
|
|
||||||
export function MentionButton({ className }: { className?: string }) {
|
export function MentionButton({ className }: { className?: string }) {
|
||||||
const editor = useSlateStatic();
|
const editor = useSlateStatic();
|
||||||
const { ark } = useRouteContext({ strict: false });
|
|
||||||
const [contacts, setContacts] = useState<string[]>([]);
|
const [contacts, setContacts] = useState<string[]>([]);
|
||||||
|
|
||||||
const select = async (user: string) => {
|
const select = async (user: string) => {
|
||||||
try {
|
try {
|
||||||
const metadata = await ark.get_profile(user);
|
const metadata = await NostrQuery.getProfile(user);
|
||||||
const contact: Contact = { pubkey: user, profile: metadata };
|
const contact: Contact = { pubkey: user, profile: metadata };
|
||||||
|
|
||||||
insertMention(editor, contact);
|
insertMention(editor, contact);
|
||||||
@@ -27,7 +26,7 @@ export function MentionButton({ className }: { className?: string }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function getContacts() {
|
async function getContacts() {
|
||||||
const data = await ark.get_contact_list();
|
const data = await NostrAccount.getContactList();
|
||||||
setContacts(data);
|
setContacts(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,20 +60,26 @@ export function MentionButton({ className }: { className?: string }) {
|
|||||||
</Tooltip.Provider>
|
</Tooltip.Provider>
|
||||||
<DropdownMenu.Portal>
|
<DropdownMenu.Portal>
|
||||||
<DropdownMenu.Content className="flex w-[220px] h-[220px] scrollbar-none flex-col overflow-y-auto rounded-xl bg-black py-1 shadow-md shadow-neutral-500/20 focus:outline-none dark:bg-white">
|
<DropdownMenu.Content className="flex w-[220px] h-[220px] scrollbar-none flex-col overflow-y-auto rounded-xl bg-black py-1 shadow-md shadow-neutral-500/20 focus:outline-none dark:bg-white">
|
||||||
{contacts.map((contact) => (
|
{contacts.length < 1 ? (
|
||||||
<DropdownMenu.Item
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
key={contact}
|
<p className="text-sm text-white">Contact List is empty.</p>
|
||||||
onClick={() => select(contact)}
|
</div>
|
||||||
className="shrink-0 h-11 flex items-center hover:bg-white/10 px-2"
|
) : (
|
||||||
>
|
contacts.map((contact) => (
|
||||||
<User.Provider pubkey={contact}>
|
<DropdownMenu.Item
|
||||||
<User.Root className="flex items-center gap-2">
|
key={contact}
|
||||||
<User.Avatar className="shrink-0 size-8 rounded-full" />
|
onClick={() => select(contact)}
|
||||||
<User.Name className="text-sm font-medium text-white dark:text-black" />
|
className="shrink-0 h-11 flex items-center hover:bg-white/10 px-2"
|
||||||
</User.Root>
|
>
|
||||||
</User.Provider>
|
<User.Provider pubkey={contact}>
|
||||||
</DropdownMenu.Item>
|
<User.Root className="flex items-center gap-2">
|
||||||
))}
|
<User.Avatar className="shrink-0 size-8 rounded-full" />
|
||||||
|
<User.Name className="text-sm font-medium text-white dark:text-black" />
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
))
|
||||||
|
)}
|
||||||
<DropdownMenu.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
<DropdownMenu.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Portal>
|
</DropdownMenu.Portal>
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ import { cn } from "@lume/utils";
|
|||||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||||
import type { Dispatch, SetStateAction } from "react";
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
|
|
||||||
export function NsfwToggle({
|
export function WarningToggle({
|
||||||
nsfw,
|
warning,
|
||||||
setNsfw,
|
setWarning,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
nsfw: boolean;
|
warning: boolean;
|
||||||
setNsfw: Dispatch<SetStateAction<boolean>>;
|
setWarning: Dispatch<SetStateAction<boolean>>;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -18,11 +18,11 @@ export function NsfwToggle({
|
|||||||
<Tooltip.Trigger asChild>
|
<Tooltip.Trigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setNsfw((prev) => !prev)}
|
onClick={() => setWarning((prev) => !prev)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center justify-center",
|
"inline-flex items-center justify-center",
|
||||||
className,
|
className,
|
||||||
nsfw ? "bg-blue-500 text-white" : "",
|
warning ? "bg-blue-500 text-white" : "",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<NsfwIcon className="size-4" />
|
<NsfwIcon className="size-4" />
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ComposeFilledIcon, TrashIcon } from "@lume/icons";
|
import { ComposeFilledIcon } from "@lume/icons";
|
||||||
import { Spinner } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import {
|
import {
|
||||||
cn,
|
cn,
|
||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
sendNativeNotification,
|
sendNativeNotification,
|
||||||
} from "@lume/utils";
|
} from "@lume/utils";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { nip19 } from "nostr-tools";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { type Descendant, Node, Transforms, createEditor } from "slate";
|
import { type Descendant, Node, Transforms, createEditor } from "slate";
|
||||||
@@ -22,8 +21,9 @@ import {
|
|||||||
withReact,
|
withReact,
|
||||||
} from "slate-react";
|
} from "slate-react";
|
||||||
import { MediaButton } from "./-components/media";
|
import { MediaButton } from "./-components/media";
|
||||||
import { NsfwToggle } from "./-components/nsfw";
|
|
||||||
import { MentionButton } from "./-components/mention";
|
import { MentionButton } from "./-components/mention";
|
||||||
|
import { LumeEvent } from "@lume/system";
|
||||||
|
import { WarningToggle } from "./-components/warning";
|
||||||
import { MentionNote } from "@/components/note/mentions/note";
|
import { MentionNote } from "@/components/note/mentions/note";
|
||||||
|
|
||||||
type EditorSearch = {
|
type EditorSearch = {
|
||||||
@@ -31,6 +31,12 @@ type EditorSearch = {
|
|||||||
quote: boolean;
|
quote: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type EditorElement = {
|
||||||
|
type: string;
|
||||||
|
children: Descendant[];
|
||||||
|
eventId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export const Route = createFileRoute("/editor/")({
|
export const Route = createFileRoute("/editor/")({
|
||||||
validateSearch: (search: Record<string, string>): EditorSearch => {
|
validateSearch: (search: Record<string, string>): EditorSearch => {
|
||||||
return {
|
return {
|
||||||
@@ -38,43 +44,23 @@ export const Route = createFileRoute("/editor/")({
|
|||||||
quote: search.quote === "true" || false,
|
quote: search.quote === "true" || false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
beforeLoad: async ({ search }) => {
|
|
||||||
return {
|
|
||||||
initialValue: search.quote
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
type: "paragraph",
|
|
||||||
children: [{ text: "" }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "event",
|
|
||||||
eventId: `nostr:${nip19.noteEncode(search.reply_to)}`,
|
|
||||||
children: [{ text: "" }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "paragraph",
|
|
||||||
children: [{ text: "" }],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
type: "paragraph",
|
|
||||||
children: [{ text: "" }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const initialValue: EditorElement[] = [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
children: [{ text: "" }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const { reply_to, quote } = Route.useSearch();
|
const search = Route.useSearch();
|
||||||
const { ark, initialValue } = Route.useRouteContext();
|
|
||||||
|
|
||||||
const [t] = useTranslation();
|
const [t] = useTranslation();
|
||||||
const [editorValue, setEditorValue] = useState(initialValue);
|
const [editorValue, setEditorValue] = useState(initialValue);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [nsfw, setNsfw] = useState(false);
|
const [warning, setWarning] = useState(false);
|
||||||
const [editor] = useState(() =>
|
const [editor] = useState(() =>
|
||||||
withMentions(withNostrEvent(withImages(withReact(createEditor())))),
|
withMentions(withNostrEvent(withImages(withReact(createEditor())))),
|
||||||
);
|
);
|
||||||
@@ -115,7 +101,12 @@ function Screen() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const content = serialize(editor.children);
|
const content = serialize(editor.children);
|
||||||
const eventId = await ark.publish(content, reply_to, quote);
|
const eventId = await LumeEvent.publish(
|
||||||
|
content,
|
||||||
|
search.reply_to,
|
||||||
|
search.quote,
|
||||||
|
warning,
|
||||||
|
);
|
||||||
|
|
||||||
if (eventId) {
|
if (eventId) {
|
||||||
await sendNativeNotification(
|
await sendNativeNotification(
|
||||||
@@ -136,15 +127,15 @@ function Screen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full">
|
<div className="w-full h-full flex flex-col">
|
||||||
<Slate editor={editor} initialValue={editorValue}>
|
<Slate editor={editor} initialValue={editorValue}>
|
||||||
<div
|
<div
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
className="flex h-14 w-full shrink-0 items-center justify-end gap-2 px-2 border-b border-black/10 dark:border-white/10"
|
className="shrink-0 flex h-14 w-full items-center justify-end gap-2 px-2 border-b border-black/10 dark:border-white/10"
|
||||||
>
|
>
|
||||||
<NsfwToggle
|
<WarningToggle
|
||||||
nsfw={nsfw}
|
warning={warning}
|
||||||
setNsfw={setNsfw}
|
setWarning={setWarning}
|
||||||
className="size-8 rounded-full bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
|
className="size-8 rounded-full bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
|
||||||
/>
|
/>
|
||||||
<MentionButton className="size-8 rounded-full bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20" />
|
<MentionButton className="size-8 rounded-full bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20" />
|
||||||
@@ -162,13 +153,13 @@ function Screen() {
|
|||||||
{t("global.post")}
|
{t("global.post")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex h-full w-full flex-1 flex-col">
|
<div className="flex-1 overflow-y-auto flex flex-col">
|
||||||
{reply_to && !quote ? (
|
{search.reply_to ? (
|
||||||
<div className="px-4 py-2">
|
<div className="px-4 py-2">
|
||||||
<MentionNote eventId={reply_to} />
|
<MentionNote eventId={search.reply_to} />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="overflow-y-auto p-4">
|
<div className="overflow-y-auto scrollbar-none p-4">
|
||||||
<Editable
|
<Editable
|
||||||
key={JSON.stringify(editorValue)}
|
key={JSON.stringify(editorValue)}
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
@@ -177,7 +168,7 @@ function Screen() {
|
|||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
renderElement={(props) => <Element {...props} />}
|
renderElement={(props) => <Element {...props} />}
|
||||||
placeholder={
|
placeholder={
|
||||||
reply_to ? "Type your reply..." : t("editor.placeholder")
|
search.reply_to ? "Type your reply..." : t("editor.placeholder")
|
||||||
}
|
}
|
||||||
className="focus:outline-none"
|
className="focus:outline-none"
|
||||||
/>
|
/>
|
||||||
@@ -251,35 +242,24 @@ const withImages = (editor: ReactEditor) => {
|
|||||||
return editor;
|
return editor;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Image = ({ attributes, children, element }) => {
|
const Image = ({ attributes, element, children }) => {
|
||||||
const editor = useSlateStatic();
|
const editor = useSlateStatic();
|
||||||
const path = ReactEditor.findPath(editor as ReactEditor, element);
|
|
||||||
|
|
||||||
const selected = useSelected();
|
const selected = useSelected();
|
||||||
const focused = useFocused();
|
const focused = useFocused();
|
||||||
|
const path = ReactEditor.findPath(editor as ReactEditor, element);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div {...attributes}>
|
<div {...attributes}>
|
||||||
{children}
|
{children}
|
||||||
<div contentEditable={false} className="relative my-2">
|
<img
|
||||||
<img
|
src={element.url}
|
||||||
src={element.url}
|
alt={element.url}
|
||||||
alt={element.url}
|
className={cn(
|
||||||
className={cn(
|
"my-2 h-auto w-1/2 rounded-lg object-cover ring-2 outline outline-1 -outline-offset-1 outline-black/15",
|
||||||
"h-auto w-full rounded-lg border border-neutral-100 object-cover ring-2 dark:border-neutral-900",
|
selected && focused ? "ring-blue-500" : "ring-transparent",
|
||||||
selected && focused ? "ring-blue-500" : "ring-transparent",
|
)}
|
||||||
)}
|
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
||||||
contentEditable={false}
|
/>
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
contentEditable={false}
|
|
||||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
|
||||||
className="absolute right-2 top-2 inline-flex size-8 items-center justify-center rounded-lg bg-red-500 text-white hover:bg-red-600"
|
|
||||||
>
|
|
||||||
<TrashIcon className="size-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEvent } from "@lume/ark";
|
import { NostrQuery, useEvent } from "@lume/system";
|
||||||
import type { Event } from "@lume/types";
|
import type { NostrEvent } from "@lume/types";
|
||||||
import { Box, Container, Spinner } from "@lume/ui";
|
import { Box, Container, Spinner } from "@lume/ui";
|
||||||
import { Note } from "@/components/note";
|
import { Note } from "@/components/note";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
@@ -7,10 +7,8 @@ import { WindowVirtualizer } from "virtua";
|
|||||||
import { ReplyList } from "./-components/replyList";
|
import { ReplyList } from "./-components/replyList";
|
||||||
|
|
||||||
export const Route = createFileRoute("/events/$eventId")({
|
export const Route = createFileRoute("/events/$eventId")({
|
||||||
beforeLoad: async ({ context }) => {
|
beforeLoad: async () => {
|
||||||
const ark = context.ark;
|
const settings = await NostrQuery.getSettings();
|
||||||
const settings = await ark.get_settings();
|
|
||||||
|
|
||||||
return { settings };
|
return { settings };
|
||||||
},
|
},
|
||||||
component: Screen,
|
component: Screen,
|
||||||
@@ -52,7 +50,7 @@ function Screen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MainNote({ data }: { data: Event }) {
|
function MainNote({ data }: { data: NostrEvent }) {
|
||||||
return (
|
return (
|
||||||
<Note.Provider event={data}>
|
<Note.Provider event={data}>
|
||||||
<Note.Root>
|
<Note.Root>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import type { EventWithReplies } from "@lume/types";
|
import type { EventWithReplies } from "@lume/types";
|
||||||
import { Spinner } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Reply } from "./reply";
|
import { Reply } from "./reply";
|
||||||
|
import { LumeEvent } from "@lume/system";
|
||||||
|
|
||||||
export function ReplyList({
|
export function ReplyList({
|
||||||
eventId,
|
eventId,
|
||||||
@@ -13,13 +13,12 @@ export function ReplyList({
|
|||||||
eventId: string;
|
eventId: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const { ark } = useRouteContext({ strict: false });
|
|
||||||
const [t] = useTranslation();
|
const [t] = useTranslation();
|
||||||
const [data, setData] = useState<null | EventWithReplies[]>(null);
|
const [data, setData] = useState<null | EventWithReplies[]>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function getReplies() {
|
async function getReplies() {
|
||||||
const events = await ark.get_event_thread(eventId);
|
const events = await LumeEvent.getReplies(eventId);
|
||||||
setData(events);
|
setData(events);
|
||||||
}
|
}
|
||||||
getReplies();
|
getReplies();
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import type { Event } from "@lume/types";
|
import type { NostrEvent } from "@lume/types";
|
||||||
import { Note } from "@/components/note";
|
import { Note } from "@/components/note";
|
||||||
|
|
||||||
export function SubReply({ event }: { event: Event; rootEventId?: string }) {
|
export function SubReply({
|
||||||
|
event,
|
||||||
|
}: {
|
||||||
|
event: NostrEvent;
|
||||||
|
rootEventId?: string;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<Note.Provider event={event}>
|
<Note.Provider event={event}>
|
||||||
<Note.Root>
|
<Note.Root>
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { Quote } from "@/components/quote";
|
|||||||
import { RepostNote } from "@/components/repost";
|
import { RepostNote } from "@/components/repost";
|
||||||
import { TextNote } from "@/components/text";
|
import { TextNote } from "@/components/text";
|
||||||
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
|
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
|
||||||
import { type ColumnRouteSearch, type Event, Kind } from "@lume/types";
|
import { NostrQuery } from "@lume/system";
|
||||||
|
import { type ColumnRouteSearch, type NostrEvent, Kind } from "@lume/types";
|
||||||
import { Spinner } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||||
@@ -17,18 +18,15 @@ export const Route = createFileRoute("/global")({
|
|||||||
name: search.name,
|
name: search.name,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
beforeLoad: async ({ context }) => {
|
beforeLoad: async () => {
|
||||||
const ark = context.ark;
|
const settings = await NostrQuery.getSettings();
|
||||||
const settings = await ark.get_settings();
|
|
||||||
|
|
||||||
return { settings };
|
return { settings };
|
||||||
},
|
},
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
export function Screen() {
|
export function Screen() {
|
||||||
const { account } = Route.useSearch();
|
const { label, account } = Route.useSearch();
|
||||||
const { ark } = Route.useRouteContext();
|
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -37,21 +35,18 @@ export function Screen() {
|
|||||||
hasNextPage,
|
hasNextPage,
|
||||||
fetchNextPage,
|
fetchNextPage,
|
||||||
} = useInfiniteQuery({
|
} = useInfiniteQuery({
|
||||||
queryKey: ["global", account],
|
queryKey: [label, account],
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||||
const events = await ark.get_events(20, pageParam, undefined, true);
|
const events = await NostrQuery.getGlobalEvents(pageParam);
|
||||||
return events;
|
return events;
|
||||||
},
|
},
|
||||||
getNextPageParam: (lastPage) => {
|
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
|
||||||
const lastEvent = lastPage?.at(-1);
|
|
||||||
return lastEvent ? lastEvent.created_at - 1 : null;
|
|
||||||
},
|
|
||||||
select: (data) => data?.pages.flatMap((page) => page),
|
select: (data) => data?.pages.flatMap((page) => page),
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const renderItem = (event: Event) => {
|
const renderItem = (event: NostrEvent) => {
|
||||||
if (!event) return;
|
if (!event) return;
|
||||||
switch (event.kind) {
|
switch (event.kind) {
|
||||||
case Kind.Repost:
|
case Kind.Repost:
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
import { Conversation } from "@/components/conversation";
|
||||||
|
import { Quote } from "@/components/quote";
|
||||||
import { RepostNote } from "@/components/repost";
|
import { RepostNote } from "@/components/repost";
|
||||||
import { TextNote } from "@/components/text";
|
import { TextNote } from "@/components/text";
|
||||||
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
|
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
|
||||||
import { type ColumnRouteSearch, type Event, Kind } from "@lume/types";
|
import { NostrAccount, NostrQuery } from "@lume/system";
|
||||||
|
import { type ColumnRouteSearch, type NostrEvent, Kind } from "@lume/types";
|
||||||
import { Spinner } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { Link, createFileRoute, redirect } from "@tanstack/react-router";
|
import { Link, createFileRoute, redirect } from "@tanstack/react-router";
|
||||||
@@ -15,14 +18,12 @@ export const Route = createFileRoute("/group")({
|
|||||||
name: search.name,
|
name: search.name,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
beforeLoad: async ({ search, context }) => {
|
beforeLoad: async ({ search }) => {
|
||||||
const ark = context.ark;
|
const key = `lume_group_${search.label}`;
|
||||||
const groups = (await ark.get_nstore(
|
const groups = (await NostrQuery.getNstore(key)) as string[];
|
||||||
`lume_group_${search.label}`,
|
const settings = await NostrQuery.getSettings();
|
||||||
)) as string[];
|
|
||||||
const settings = await ark.get_settings();
|
|
||||||
|
|
||||||
if (!groups) {
|
if (!groups?.length) {
|
||||||
throw redirect({
|
throw redirect({
|
||||||
to: "/create-group",
|
to: "/create-group",
|
||||||
search: {
|
search: {
|
||||||
@@ -41,8 +42,8 @@ export const Route = createFileRoute("/group")({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export function Screen() {
|
export function Screen() {
|
||||||
const { name, account } = Route.useSearch();
|
const { label, account } = Route.useSearch();
|
||||||
const { ark, groups } = Route.useRouteContext();
|
const { groups } = Route.useRouteContext();
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -51,35 +52,46 @@ export function Screen() {
|
|||||||
hasNextPage,
|
hasNextPage,
|
||||||
fetchNextPage,
|
fetchNextPage,
|
||||||
} = useInfiniteQuery({
|
} = useInfiniteQuery({
|
||||||
queryKey: [name, account],
|
queryKey: [label, account],
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||||
const events = await ark.get_events(20, pageParam, groups);
|
const events = await NostrQuery.getLocalEvents(groups, pageParam);
|
||||||
return events;
|
return events;
|
||||||
},
|
},
|
||||||
getNextPageParam: (lastPage) => {
|
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
|
||||||
const lastEvent = lastPage?.at(-1);
|
|
||||||
return lastEvent ? lastEvent.created_at - 1 : null;
|
|
||||||
},
|
|
||||||
select: (data) =>
|
select: (data) =>
|
||||||
data?.pages.flatMap((page) => page.filter((ev) => ev.kind === Kind.Text)),
|
data?.pages.flatMap((page) => page.filter((ev) => ev.kind === Kind.Text)),
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const renderItem = (event: Event) => {
|
const renderItem = (event: NostrEvent) => {
|
||||||
if (!event) return;
|
if (!event) return;
|
||||||
switch (event.kind) {
|
switch (event.kind) {
|
||||||
case Kind.Repost:
|
case Kind.Repost:
|
||||||
return <RepostNote key={event.id} event={event} />;
|
return <RepostNote key={event.id} event={event} />;
|
||||||
default:
|
default: {
|
||||||
return <TextNote key={event.id} event={event} />;
|
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 (
|
return (
|
||||||
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none">
|
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none">
|
||||||
{isFetching && !isLoading && !isFetchingNextPage ? (
|
{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">
|
<div className="flex items-center justify-center gap-2">
|
||||||
<Spinner className="size-5" />
|
<Spinner className="size-5" />
|
||||||
<span className="text-sm font-medium">Fetching new notes...</span>
|
<span className="text-sm font-medium">Fetching new notes...</span>
|
||||||
|
|||||||
@@ -4,28 +4,22 @@ import { User } from "@/components/user";
|
|||||||
import { checkForAppUpdates } from "@lume/utils";
|
import { checkForAppUpdates } from "@lume/utils";
|
||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { NostrAccount } from "@lume/system";
|
||||||
|
|
||||||
export const Route = createFileRoute("/")({
|
export const Route = createFileRoute("/")({
|
||||||
beforeLoad: async ({ context }) => {
|
beforeLoad: async () => {
|
||||||
// check for app updates
|
await checkForAppUpdates(true); // check for app updates
|
||||||
await checkForAppUpdates(true);
|
const accounts = await NostrAccount.getAccounts();
|
||||||
|
|
||||||
const ark = context.ark;
|
if (accounts.length < 1) {
|
||||||
const accounts = await ark.get_all_accounts();
|
|
||||||
|
|
||||||
if (!accounts.length) {
|
|
||||||
throw redirect({
|
throw redirect({
|
||||||
to: "/landing",
|
to: "/landing",
|
||||||
replace: true,
|
replace: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run notification service
|
|
||||||
await invoke("run_notification", { accounts });
|
|
||||||
|
|
||||||
return { accounts };
|
return { accounts };
|
||||||
},
|
},
|
||||||
component: Screen,
|
component: Screen,
|
||||||
@@ -33,7 +27,7 @@ export const Route = createFileRoute("/")({
|
|||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const navigate = Route.useNavigate();
|
const navigate = Route.useNavigate();
|
||||||
const { ark, accounts } = Route.useRouteContext();
|
const context = Route.useRouteContext();
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
@@ -41,8 +35,9 @@ function Screen() {
|
|||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const loadAccount = await ark.load_selected_account(npub);
|
const status = await NostrAccount.loadAccount(npub);
|
||||||
if (loadAccount) {
|
|
||||||
|
if (status) {
|
||||||
return navigate({
|
return navigate({
|
||||||
to: "/$account/home",
|
to: "/$account/home",
|
||||||
params: { account: npub },
|
params: { account: npub },
|
||||||
@@ -62,62 +57,50 @@ function Screen() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-full w-full items-center justify-center">
|
<div
|
||||||
|
data-tauri-drag-region
|
||||||
|
className="relative flex h-full w-full items-center justify-center"
|
||||||
|
>
|
||||||
<div className="relative z-20 flex flex-col items-center gap-16">
|
<div className="relative z-20 flex flex-col items-center gap-16">
|
||||||
<div className="text-center text-white">
|
<div className="text-center">
|
||||||
<h2 className="mb-1 text-2xl">{currentDate}</h2>
|
<h2 className="text-xl text-neutral-700 dark:text-neutral-300">
|
||||||
|
{currentDate}
|
||||||
|
</h2>
|
||||||
<h2 className="text-2xl font-semibold">Welcome back!</h2>
|
<h2 className="text-2xl font-semibold">Welcome back!</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-center gap-6">
|
<div className="flex flex-wrap px-3 items-center justify-center gap-6">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="inline-flex size-6 items-center justify-center">
|
<div className="inline-flex size-6 items-center justify-center">
|
||||||
<Spinner className="size-6 text-white" />
|
<Spinner className="size-6" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{accounts.map((account) => (
|
{context.accounts.map((account) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
key={account}
|
key={account}
|
||||||
|
type="button"
|
||||||
onClick={() => select(account)}
|
onClick={() => select(account)}
|
||||||
>
|
>
|
||||||
<User.Provider pubkey={account}>
|
<User.Provider pubkey={account}>
|
||||||
<User.Root className="flex h-36 w-32 flex-col items-center justify-center gap-4 rounded-2xl p-2 hover:bg-white/10 dark:hover:bg-black/10">
|
<User.Root className="flex h-36 w-32 flex-col items-center justify-center gap-3 rounded-2xl p-2 hover:bg-black/10 dark:hover:bg-white/10">
|
||||||
<User.Avatar className="size-20 rounded-full object-cover" />
|
<User.Avatar className="size-20 rounded-full object-cover" />
|
||||||
<User.Name className="max-w-[5rem] truncate text-lg font-medium leading-tight text-white" />
|
<User.Name className="max-w-[6rem] truncate font-medium leading-tight" />
|
||||||
</User.Root>
|
</User.Root>
|
||||||
</User.Provider>
|
</User.Provider>
|
||||||
</button>
|
</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 h-36 w-32 flex-col items-center justify-center gap-3 rounded-2xl p-2 hover:bg-black/10 dark:hover:bg-white/10">
|
||||||
<div className="flex size-20 items-center justify-center rounded-full bg-white/20 dark:bg-black/20">
|
<div className="flex size-20 items-center justify-center rounded-full bg-black/5 dark:bg-white/5">
|
||||||
<PlusIcon className="size-5" />
|
<PlusIcon className="size-8" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-medium leading-tight">Add</p>
|
<p className="font-medium leading-tight">Add</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute z-10 h-full w-full bg-white/10 backdrop-blur-lg dark:bg-black/10" />
|
|
||||||
<div className="absolute inset-0 h-full w-full">
|
|
||||||
<img
|
|
||||||
src="/lock-screen.jpg"
|
|
||||||
srcSet="/lock-screen@2x.jpg 2x"
|
|
||||||
alt="Lock Screen Background"
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
<a
|
|
||||||
href="https://njump.me/nprofile1qqs9tuz9jpn57djg7nxunhyvuvk69g5zqaxdpvpqt9hwqv7395u9rpg6zq5uw"
|
|
||||||
target="_blank"
|
|
||||||
className="absolute bottom-3 right-3 z-50 rounded-md bg-white/20 px-2 py-1 text-xs font-medium text-white dark:bg-black/20"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
Design by NoGood
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { KeyIcon, RemoteIcon } from "@lume/icons";
|
||||||
|
import { Link, createLazyFileRoute } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
export const Route = createLazyFileRoute("/landing")({
|
||||||
|
component: Screen,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Screen() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-tauri-drag-region
|
||||||
|
className="flex flex-col justify-center items-center h-screen w-screen"
|
||||||
|
>
|
||||||
|
<div className="mx-auto max-w-xs lg:max-w-md w-full">
|
||||||
|
<div className="flex w-full flex-col gap-2 bg-white rounded-xl shadow-primary backdrop-blur-lg dark:bg-white/20 dark:ring-1 ring-neutral-800/50 px-2">
|
||||||
|
<div className="h-20 flex items-center border-b border-neutral-100 dark:border-white/5">
|
||||||
|
<Link
|
||||||
|
to="/auth/new/profile"
|
||||||
|
className="h-14 w-full flex items-center justify-center gap-2 hover:bg-neutral-100 dark:hover:bg-white/10 rounded-lg px-2"
|
||||||
|
>
|
||||||
|
<div className="size-9 shrink-0 rounded-full inline-flex items-center justify-center">
|
||||||
|
<img
|
||||||
|
src="/icon.jpeg"
|
||||||
|
alt="App Icon"
|
||||||
|
className="size-9 object-cover rounded-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 inline-flex flex-col">
|
||||||
|
<span className="leading-tight font-semibold">
|
||||||
|
Create new account
|
||||||
|
</span>
|
||||||
|
<span className="leading-tight text-sm text-neutral-500">
|
||||||
|
Use everywhere
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1 pb-2.5">
|
||||||
|
<Link
|
||||||
|
to="/auth/privkey"
|
||||||
|
className="inline-flex h-11 w-full items-center gap-2 rounded-lg px-2 hover:bg-neutral-100 dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<div className="size-9 inline-flex items-center justify-center">
|
||||||
|
<KeyIcon className="size-5 text-neutral-600 dark:text-neutral-400" />
|
||||||
|
</div>
|
||||||
|
Login with Private Key
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/auth/remote"
|
||||||
|
className="inline-flex h-11 w-full items-center gap-2 rounded-lg px-2 hover:bg-neutral-100 dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<div className="size-9 inline-flex items-center justify-center">
|
||||||
|
<RemoteIcon className="size-5 text-neutral-600 dark:text-neutral-400" />
|
||||||
|
</div>
|
||||||
|
Nostr Connect
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import { KeyIcon, RemoteIcon } from "@lume/icons";
|
|
||||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/landing/")({
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative flex h-screen w-screen bg-black">
|
|
||||||
<div
|
|
||||||
data-tauri-drag-region
|
|
||||||
className="absolute left-0 top-0 z-50 h-16 w-full"
|
|
||||||
/>
|
|
||||||
<div className="z-20 flex h-full w-full flex-col items-center justify-between">
|
|
||||||
<div />
|
|
||||||
<div className="mx-auto flex w-full max-w-4xl flex-col items-center gap-10">
|
|
||||||
<div className="flex flex-col items-center text-center">
|
|
||||||
<img
|
|
||||||
src="/heading-en.png"
|
|
||||||
srcSet="/heading-en@2x.png 2x"
|
|
||||||
alt="lume"
|
|
||||||
className="xl:w-2/3"
|
|
||||||
/>
|
|
||||||
<p className="mt-4 whitespace-pre-line text-lg font-medium leading-snug text-white/70">
|
|
||||||
{t("welcome.title")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="mx-auto flex w-full max-w-sm flex-col gap-4">
|
|
||||||
<Link
|
|
||||||
to="/auth/new/profile"
|
|
||||||
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-white font-medium text-blue-500 backdrop-blur-lg hover:bg-white/90"
|
|
||||||
>
|
|
||||||
{t("welcome.signup")}
|
|
||||||
</Link>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="h-px flex-1 bg-white/20" />
|
|
||||||
<div className="text-white/70">{t("login.or")}</div>
|
|
||||||
<div className="h-px flex-1 bg-white/20" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Link
|
|
||||||
to="/auth/remote"
|
|
||||||
className="group inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-white/20 px-3 font-medium text-white backdrop-blur-md hover:bg-white/40"
|
|
||||||
>
|
|
||||||
<RemoteIcon className="size-5 text-neutral-600 dark:text-neutral-400 group-hover:text-neutral-400 dark:group-hover:text-neutral-600" />
|
|
||||||
Nostr Connect
|
|
||||||
<div className="size-5" />
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
to="/auth/privkey"
|
|
||||||
className="group inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-white/20 px-3 font-medium text-white backdrop-blur-md hover:bg-white/40"
|
|
||||||
>
|
|
||||||
<KeyIcon className="size-5 text-neutral-600 dark:text-neutral-400 group-hover:text-neutral-400 dark:group-hover:text-neutral-600" />
|
|
||||||
Private Key
|
|
||||||
<div className="size-5" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex h-11 items-center justify-center" />
|
|
||||||
</div>
|
|
||||||
<div className="absolute z-10 h-full w-full bg-black/5 backdrop-blur-sm" />
|
|
||||||
<div className="absolute inset-0 h-full w-full">
|
|
||||||
<img
|
|
||||||
src="/lock-screen.jpg"
|
|
||||||
srcSet="/lock-screen@2x.jpg 2x"
|
|
||||||
alt="Lock Screen Background"
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
<a
|
|
||||||
href="https://njump.me/nprofile1qqs9tuz9jpn57djg7nxunhyvuvk69g5zqaxdpvpqt9hwqv7395u9rpg6zq5uw"
|
|
||||||
target="_blank"
|
|
||||||
className="absolute bottom-3 right-3 z-50 rounded-md bg-white/20 px-2 py-1 text-xs font-medium text-white backdrop-blur-md dark:bg-black/20"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
Design by NoGood
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,11 +2,12 @@ import { Conversation } from "@/components/conversation";
|
|||||||
import { Quote } from "@/components/quote";
|
import { Quote } from "@/components/quote";
|
||||||
import { RepostNote } from "@/components/repost";
|
import { RepostNote } from "@/components/repost";
|
||||||
import { TextNote } from "@/components/text";
|
import { TextNote } from "@/components/text";
|
||||||
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
|
import { ArrowRightCircleIcon } from "@lume/icons";
|
||||||
import { type ColumnRouteSearch, type Event, Kind } from "@lume/types";
|
import { NostrAccount, NostrQuery } from "@lume/system";
|
||||||
|
import { type ColumnRouteSearch, type NostrEvent, Kind } from "@lume/types";
|
||||||
import { Spinner } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { Link } from "@tanstack/react-router";
|
import { redirect } from "@tanstack/react-router";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { Virtualizer } from "virtua";
|
import { Virtualizer } from "virtua";
|
||||||
|
|
||||||
@@ -18,18 +19,28 @@ export const Route = createFileRoute("/newsfeed")({
|
|||||||
name: search.name,
|
name: search.name,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
beforeLoad: async ({ context }) => {
|
beforeLoad: async ({ search }) => {
|
||||||
const ark = context.ark;
|
const settings = await NostrQuery.getSettings();
|
||||||
const settings = await ark.get_settings();
|
const contacts = await NostrAccount.getContactList();
|
||||||
|
|
||||||
return { settings };
|
if (!contacts.length) {
|
||||||
|
throw redirect({
|
||||||
|
to: "/create-newsfeed/users",
|
||||||
|
search: {
|
||||||
|
...search,
|
||||||
|
redirect: "/newsfeed",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { settings, contacts };
|
||||||
},
|
},
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
export function Screen() {
|
export function Screen() {
|
||||||
const { label, account } = Route.useSearch();
|
const { label, account } = Route.useSearch();
|
||||||
const { ark } = Route.useRouteContext();
|
const { contacts, settings } = Route.useRouteContext();
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -41,18 +52,15 @@ export function Screen() {
|
|||||||
queryKey: [label, account],
|
queryKey: [label, account],
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||||
const events = await ark.get_events(20, pageParam);
|
const events = await NostrQuery.getLocalEvents(contacts, pageParam);
|
||||||
return events;
|
return events;
|
||||||
},
|
},
|
||||||
getNextPageParam: (lastPage) => {
|
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
|
||||||
const lastEvent = lastPage?.at(-1);
|
|
||||||
return lastEvent ? lastEvent.created_at - 1 : null;
|
|
||||||
},
|
|
||||||
select: (data) => data?.pages.flatMap((page) => page),
|
select: (data) => data?.pages.flatMap((page) => page),
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const renderItem = (event: Event) => {
|
const renderItem = (event: NostrEvent) => {
|
||||||
if (!event) return;
|
if (!event) return;
|
||||||
switch (event.kind) {
|
switch (event.kind) {
|
||||||
case Kind.Repost:
|
case Kind.Repost:
|
||||||
@@ -64,7 +72,14 @@ export function Screen() {
|
|||||||
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0;
|
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0;
|
||||||
|
|
||||||
if (isConversation) {
|
if (isConversation) {
|
||||||
return <Conversation key={event.id} event={event} className="mb-3" />;
|
return (
|
||||||
|
<Conversation
|
||||||
|
key={event.id}
|
||||||
|
className="mb-3"
|
||||||
|
event={event}
|
||||||
|
gossip={settings?.gossip}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isQuote) {
|
if (isQuote) {
|
||||||
@@ -79,7 +94,7 @@ export function Screen() {
|
|||||||
return (
|
return (
|
||||||
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none">
|
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none">
|
||||||
{isFetching && !isLoading && !isFetchingNextPage ? (
|
{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">
|
<div className="flex items-center justify-center gap-2">
|
||||||
<Spinner className="size-5" />
|
<Spinner className="size-5" />
|
||||||
<span className="text-sm font-medium">Fetching new notes...</span>
|
<span className="text-sm font-medium">Fetching new notes...</span>
|
||||||
@@ -92,7 +107,9 @@ export function Screen() {
|
|||||||
<span className="text-sm font-medium">Loading...</span>
|
<span className="text-sm font-medium">Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
) : !data.length ? (
|
) : !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}>
|
<Virtualizer overscan={3}>
|
||||||
{data.map((item) => renderItem(item))}
|
{data.map((item) => renderItem(item))}
|
||||||
@@ -120,35 +137,3 @@ export function Screen() {
|
|||||||
</div>
|
</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,4 +1,5 @@
|
|||||||
import { ZapIcon } from "@lume/icons";
|
import { ZapIcon } from "@lume/icons";
|
||||||
|
import { NostrAccount } from "@lume/system";
|
||||||
import { Container } from "@lume/ui";
|
import { Container } from "@lume/ui";
|
||||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -8,18 +9,16 @@ export const Route = createLazyFileRoute("/nwc")({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const { ark } = Route.useRouteContext();
|
|
||||||
|
|
||||||
const [uri, setUri] = useState("");
|
const [uri, setUri] = useState("");
|
||||||
const [isDone, setIsDone] = useState(false);
|
const [isDone, setIsDone] = useState(false);
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
const nwc = await ark.set_nwc(uri);
|
const nwc = await NostrAccount.setWallet(uri);
|
||||||
setIsDone(nwc);
|
setIsDone(nwc);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container withDrag withNavigate={false}>
|
<Container withDrag>
|
||||||
<div className="h-full w-full flex-1 px-5">
|
<div className="h-full w-full flex-1 px-5">
|
||||||
{!isDone ? (
|
{!isDone ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,14 +1,5 @@
|
|||||||
import { ArrowRightIcon, CancelIcon } from "@lume/icons";
|
import type { ColumnRouteSearch } from "@lume/types";
|
||||||
import type { ColumnRouteSearch, LumeColumn } from "@lume/types";
|
|
||||||
import { Spinner } from "@lume/ui";
|
|
||||||
import { User } from "@/components/user";
|
|
||||||
import { cn } from "@lume/utils";
|
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
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")({
|
export const Route = createFileRoute("/onboarding")({
|
||||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||||
@@ -22,54 +13,6 @@ export const Route = createFileRoute("/onboarding")({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
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 (
|
return (
|
||||||
<div className="h-full flex flex-col py-6 gap-6 overflow-y-auto scrollbar-none">
|
<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">
|
<div className="text-center flex flex-col items-center justify-center">
|
||||||
@@ -78,369 +21,100 @@ function Screen() {
|
|||||||
Here are a few suggestions to help you get started.
|
Here are a few suggestions to help you get started.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-3">
|
<div className="px-3 flex flex-col gap-3">
|
||||||
<div className="mb-6 w-full h-44">
|
<div className="relative flex flex-col items-center justify-center rounded-xl bg-black/10 dark:bg-white/10 backdrop-blur-lg">
|
||||||
<img
|
<div className="absolute top-2 left-3 text-2xl font-semibold font-serif text-neutral-600 dark:text-neutral-400">
|
||||||
src="/lock-screen.jpg"
|
01.
|
||||||
srcSet="/lock-screen@2x.jpg 2x"
|
</div>
|
||||||
alt="background"
|
<div className="h-16 flex items-center justify-center shrink-0 px-3 text-lg select-text">
|
||||||
className="h-full w-full object-cover rounded-xl outline outline-1 -outline-offset-1 outline-black/15"
|
Navigate between columns.
|
||||||
/>
|
</div>
|
||||||
|
<div className="flex-1 w-3/4 h-full pb-10">
|
||||||
|
<video
|
||||||
|
className="h-auto w-full aspect-square rounded-lg shadow-md transform"
|
||||||
|
controls
|
||||||
|
muted
|
||||||
|
preload="none"
|
||||||
|
poster="/poster_1.jpeg"
|
||||||
|
>
|
||||||
|
<source
|
||||||
|
src="https://video.nostr.build/692f71e2be47ecfc29edcbdaa198cc5979bfb9c900f05d78682895dd546d8d4f.mp4"
|
||||||
|
type="video/mp4"
|
||||||
|
/>
|
||||||
|
Your browser does not support the video tag.
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-6">
|
<div className="relative flex flex-col items-center justify-center rounded-xl bg-black/10 dark:bg-white/10 backdrop-blur-lg">
|
||||||
<div className="flex items-start gap-2 text-[13px]">
|
<div className="absolute top-2 left-3 text-2xl font-semibold font-serif text-neutral-600 dark:text-neutral-400">
|
||||||
<Mide />
|
02.
|
||||||
<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>
|
</div>
|
||||||
<div className="flex items-start flex-row-reverse gap-2 text-[13px]">
|
<div className="h-16 flex items-center justify-center shrink-0 px-3 text-lg select-text">
|
||||||
<CurrentUser />
|
Switch between accounts.
|
||||||
<div className="flex flex-col gap-0.5">
|
</div>
|
||||||
<h3 className="font-semibold text-end">You</h3>
|
<div className="flex-1 w-3/4 h-full pb-10">
|
||||||
<div className="p-2 bg-black/5 dark:bg-white/5 rounded-lg">
|
<video
|
||||||
How can I get started?
|
className="h-auto w-full aspect-square rounded-lg shadow-md transform"
|
||||||
</div>
|
controls
|
||||||
<button
|
muted
|
||||||
type="button"
|
preload="none"
|
||||||
onClick={() => setUserType("new")}
|
poster="/poster_2.jpeg"
|
||||||
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",
|
<source
|
||||||
userType === "new"
|
src="https://video.nostr.build/d33962520506d86acfb4b55a7b265821e10ae637f5ec830a173b7e6092b16ec8.mp4"
|
||||||
? "bg-blue-500 text-white hover:bg-blue-600"
|
type="video/mp4"
|
||||||
: "",
|
/>
|
||||||
)}
|
Your browser does not support the video tag.
|
||||||
>
|
</video>
|
||||||
I'm completely new to Nostr.
|
</div>
|
||||||
<ArrowRightIcon className="size-4" />
|
</div>
|
||||||
</button>
|
<div className="relative flex flex-col items-center justify-center rounded-xl bg-black/10 dark:bg-white/10 backdrop-blur-lg">
|
||||||
<button
|
<div className="absolute top-2 left-3 text-2xl font-semibold font-serif text-neutral-600 dark:text-neutral-400">
|
||||||
type="button"
|
03.
|
||||||
onClick={() => setUserType("veteran")}
|
</div>
|
||||||
className={cn(
|
<div className="h-16 flex items-center justify-center shrink-0 px-3 text-lg select-text">
|
||||||
"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",
|
Open Lume Store.
|
||||||
userType === "veteran"
|
</div>
|
||||||
? "bg-blue-500 text-white hover:bg-blue-600"
|
<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
|
||||||
I've already been using another Nostr client.
|
muted
|
||||||
<ArrowRightIcon className="size-4" />
|
preload="none"
|
||||||
</button>
|
poster="/poster_3.jpeg"
|
||||||
</div>
|
>
|
||||||
|
<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
|
||||||
|
preload="none"
|
||||||
|
poster="/poster_4.jpeg"
|
||||||
|
>
|
||||||
|
<source
|
||||||
|
src="https://video.nostr.build/513de4824b6abaf7e9698c1dad2f68096574356848c0c200bc8cb8074df29410.mp4"
|
||||||
|
type="video/mp4"
|
||||||
|
/>
|
||||||
|
Your browser does not support the video tag.
|
||||||
|
</video>
|
||||||
</div>
|
</div>
|
||||||
{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>
|
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,314 @@
|
|||||||
|
import { Note } from "@/components/note";
|
||||||
|
import { User } from "@/components/user";
|
||||||
|
import { LumeWindow, NostrQuery, useEvent } from "@lume/system";
|
||||||
|
import { Kind, NostrEvent } from "@lume/types";
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { getCurrent } from "@tauri-apps/api/window";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import * as Tabs from "@radix-ui/react-tabs";
|
||||||
|
import { InfoIcon, RepostIcon, SettingsIcon } from "@lume/icons";
|
||||||
|
import { decodeZapInvoice, formatCreatedAt } from "@lume/utils";
|
||||||
|
|
||||||
|
interface EmitAccount {
|
||||||
|
account: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/panel")({
|
||||||
|
component: Screen,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Screen() {
|
||||||
|
const [account, setAccount] = useState<string>(null);
|
||||||
|
const [events, setEvents] = useState<NostrEvent[]>([]);
|
||||||
|
|
||||||
|
const texts = useMemo(
|
||||||
|
() => events.filter((ev) => ev.kind === Kind.Text),
|
||||||
|
[events],
|
||||||
|
);
|
||||||
|
|
||||||
|
const zaps = useMemo(() => {
|
||||||
|
const groups = new Map<string, NostrEvent[]>();
|
||||||
|
const list = events.filter((ev) => ev.kind === Kind.ZapReceipt);
|
||||||
|
|
||||||
|
for (const event of list) {
|
||||||
|
const rootId = event.tags.filter((tag) => tag[0] === "e")[0]?.[1];
|
||||||
|
|
||||||
|
if (rootId) {
|
||||||
|
if (groups.has(rootId)) {
|
||||||
|
groups.get(rootId).push(event);
|
||||||
|
} else {
|
||||||
|
groups.set(rootId, [event]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}, [events]);
|
||||||
|
|
||||||
|
const reactions = useMemo(() => {
|
||||||
|
const groups = new Map<string, NostrEvent[]>();
|
||||||
|
const list = events.filter(
|
||||||
|
(ev) => ev.kind === Kind.Repost || ev.kind === Kind.Reaction,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const event of list) {
|
||||||
|
const rootId = event.tags.filter((tag) => tag[0] === "e")[0]?.[1];
|
||||||
|
|
||||||
|
if (rootId) {
|
||||||
|
if (groups.has(rootId)) {
|
||||||
|
groups.get(rootId).push(event);
|
||||||
|
} else {
|
||||||
|
groups.set(rootId, [event]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}, [events]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (account?.length && account?.startsWith("npub1")) {
|
||||||
|
NostrQuery.getNotifications()
|
||||||
|
.then((data) => {
|
||||||
|
const sorted = data.sort((a, b) => b.created_at - a.created_at);
|
||||||
|
setEvents(sorted);
|
||||||
|
})
|
||||||
|
.catch((e) => console.log(e));
|
||||||
|
}
|
||||||
|
}, [account]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unlistenLoad = getCurrent().listen<EmitAccount>(
|
||||||
|
"load-notification",
|
||||||
|
(data) => {
|
||||||
|
setAccount(data.payload.account);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const unlistenNewEvent = getCurrent().listen("notification", (data) => {
|
||||||
|
const event: NostrEvent = JSON.parse(data.payload as string);
|
||||||
|
setEvents((prev) => [event, ...prev]);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unlistenLoad.then((f) => f());
|
||||||
|
unlistenNewEvent.then((f) => f());
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-sm">
|
||||||
|
Please log in.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full flex flex-col">
|
||||||
|
<div className="h-11 shrink-0 flex items-center justify-between border-b border-black/5 px-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-sm font-semibold">Notifications</h1>
|
||||||
|
</div>
|
||||||
|
<div className="inline-flex items-center gap-2">
|
||||||
|
<User.Provider pubkey={account}>
|
||||||
|
<User.Root>
|
||||||
|
<User.Avatar className="size-7 rounded-full" />
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => LumeWindow.openSettings()}
|
||||||
|
className="size-7 inline-flex items-center justify-center bg-black/5 dark:bg-white/5 rounded-full"
|
||||||
|
>
|
||||||
|
<SettingsIcon className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Tabs.Root
|
||||||
|
defaultValue="replies"
|
||||||
|
className="flex-1 overflow-y-auto overflow-x-hidden scrollbar-none"
|
||||||
|
>
|
||||||
|
<Tabs.List className="flex items-center">
|
||||||
|
<Tabs.Trigger
|
||||||
|
className="flex-1 inline-flex h-8 items-center justify-center gap-2 px-2 text-sm font-medium border-b border-black/10 data-[state=active]:border-black/30 dark:data-[state=active] data-[state=inactive]:opacity-50"
|
||||||
|
value="replies"
|
||||||
|
>
|
||||||
|
Replies
|
||||||
|
</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger
|
||||||
|
className="flex-1 inline-flex h-8 items-center justify-center gap-2 px-2 text-sm font-medium border-b border-black/10 data-[state=active]:border-black/30 dark:data-[state=active] data-[state=inactive]:opacity-50"
|
||||||
|
value="reactions"
|
||||||
|
>
|
||||||
|
Reactions
|
||||||
|
</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger
|
||||||
|
className="flex-1 inline-flex h-8 items-center justify-center gap-2 px-2 text-sm font-medium border-b border-black/10 data-[state=active]:border-black/30 dark:data-[state=active] data-[state=inactive]:opacity-50"
|
||||||
|
value="zaps"
|
||||||
|
>
|
||||||
|
Zaps
|
||||||
|
</Tabs.Trigger>
|
||||||
|
</Tabs.List>
|
||||||
|
<div className="p-2">
|
||||||
|
<Tabs.Content value="replies" className="flex flex-col gap-2">
|
||||||
|
{texts.map((event) => (
|
||||||
|
<TextNote event={event} />
|
||||||
|
))}
|
||||||
|
</Tabs.Content>
|
||||||
|
<Tabs.Content value="reactions" className="flex flex-col gap-2">
|
||||||
|
{[...reactions.entries()].map(([root, events]) => (
|
||||||
|
<div
|
||||||
|
key={root}
|
||||||
|
className="shrink-0 flex flex-col gap-1 rounded-lg p-2 backdrop-blur-md bg-black/10 dark:bg-white/10"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1 flex flex-col gap-2">
|
||||||
|
<div className="pb-2 border-b border-black/5 dark:border-white/5 flex items-center gap-2">
|
||||||
|
<RootNote id={root} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
{events.map((event) => (
|
||||||
|
<User.Provider pubkey={event.pubkey}>
|
||||||
|
<User.Root className="shrink-0 flex rounded-full h-8 bg-black/10 dark:bg-white/10 backdrop-blur-md p-[2px]">
|
||||||
|
<User.Avatar className="flex-1 size-7 rounded-full" />
|
||||||
|
<div className="flex-1 size-7 rounded-full inline-flex items-center justify-center text-xs truncate">
|
||||||
|
{event.kind === Kind.Reaction ? (
|
||||||
|
event.content === "+" ? (
|
||||||
|
"👍"
|
||||||
|
) : (
|
||||||
|
event.content
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<RepostIcon className="size-4 dark:text-teal-600 text-teal-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Tabs.Content>
|
||||||
|
<Tabs.Content value="zaps" className="flex flex-col gap-2">
|
||||||
|
{[...zaps.entries()].map(([root, events]) => (
|
||||||
|
<div
|
||||||
|
key={root}
|
||||||
|
className="shrink-0 flex flex-col gap-1 rounded-lg p-2 backdrop-blur-md bg-black/10 dark:bg-white/10"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1 flex flex-col gap-2">
|
||||||
|
<div className="pb-2 border-b border-black/5 dark:border-white/5 flex items-center gap-2">
|
||||||
|
<RootNote id={root} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
{events.map((event) => (
|
||||||
|
<User.Provider
|
||||||
|
pubkey={event.tags.find((tag) => tag[0] == "P")[1]}
|
||||||
|
>
|
||||||
|
<User.Root className="shrink-0 flex gap-1.5 rounded-full h-8 bg-black/10 dark:bg-white/10 backdrop-blur-md p-[2px]">
|
||||||
|
<User.Avatar className="flex-1 size-7 rounded-full" />
|
||||||
|
<div className="flex-1 h-7 w-max pr-1.5 rounded-full inline-flex items-center justify-center text-sm truncate">
|
||||||
|
₿ {decodeZapInvoice(event.tags).bitcoinFormatted}
|
||||||
|
</div>
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Tabs.Content>
|
||||||
|
</div>
|
||||||
|
</Tabs.Root>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RootNote({ id }: { id: string }) {
|
||||||
|
const { isLoading, isError, data } = useEvent(id);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="pb-2 mb-2 flex items-center">
|
||||||
|
<div className="size-8 shrink-0 rounded-full bg-black/20 dark:bg-white/20 animate-pulse" />
|
||||||
|
<div className="animate-pulse rounded-md h-4 w-2/3 bg-black/20 dark:bg-white/20" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !data) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="size-8 shrink-0 rounded-full bg-red-500 text-white inline-flex items-center justify-center">
|
||||||
|
<InfoIcon className="size-5" />
|
||||||
|
</div>
|
||||||
|
<p className="text-red-500 text-sm">
|
||||||
|
Event not found with your current relay set
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Note.Provider event={data}>
|
||||||
|
<Note.Root className="flex items-center gap-2">
|
||||||
|
<User.Provider pubkey={data.pubkey}>
|
||||||
|
<User.Root className="shrink-0">
|
||||||
|
<User.Avatar className="size-8 shrink-0 rounded-full" />
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
|
<div className="line-clamp-1">{data.content}</div>
|
||||||
|
</Note.Root>
|
||||||
|
</Note.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextNote({ event }: { event: NostrEvent }) {
|
||||||
|
const pTags = event.tags
|
||||||
|
.filter((tag) => tag[0] === "p")
|
||||||
|
.map((tag) => tag[1])
|
||||||
|
.slice(0, 3);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={event.id}
|
||||||
|
onClick={() => LumeWindow.openEvent(event)}
|
||||||
|
>
|
||||||
|
<Note.Provider event={event}>
|
||||||
|
<Note.Root className="shrink-0 flex flex-col gap-1 rounded-lg p-2 backdrop-blur-md bg-black/10 dark:bg-white/10">
|
||||||
|
<User.Provider pubkey={event.pubkey}>
|
||||||
|
<User.Root className="inline-flex items-center gap-2">
|
||||||
|
<User.Avatar className="size-9 shrink-0 rounded-full" />
|
||||||
|
<div className="flex-1 flex flex-col">
|
||||||
|
<div className="w-full flex items-baseline justify-between">
|
||||||
|
<User.Name className="leading-tight text-sm font-semibold" />
|
||||||
|
<span className="leading-tight text-sm text-black/50 dark:text-white/50">
|
||||||
|
{formatCreatedAt(event.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="inline-flex items-baseline gap-1 text-xs">
|
||||||
|
<span className="leading-tight text-black/50 dark:text-white/50">
|
||||||
|
Reply to:
|
||||||
|
</span>
|
||||||
|
<div className="inline-flex items-baseline gap-1">
|
||||||
|
{pTags.map((replyTo) => (
|
||||||
|
<User.Provider pubkey={replyTo}>
|
||||||
|
<User.Root>
|
||||||
|
<User.Name className="leading-tight font-medium" />
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="w-9 shrink-0" />
|
||||||
|
<div className="line-clamp-1">{event.content}</div>
|
||||||
|
</div>
|
||||||
|
</Note.Root>
|
||||||
|
</Note.Provider>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { SearchIcon } from "@lume/icons";
|
import { SearchIcon } from "@lume/icons";
|
||||||
import { type Event, Kind } from "@lume/types";
|
import { type NostrEvent, Kind } from "@lume/types";
|
||||||
import { Spinner } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import { Note } from "@/components/note";
|
import { Note } from "@/components/note";
|
||||||
import { User } from "@/components/user";
|
import { User } from "@/components/user";
|
||||||
@@ -7,6 +7,7 @@ import { createFileRoute } from "@tanstack/react-router";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useDebounce } from "use-debounce";
|
import { useDebounce } from "use-debounce";
|
||||||
|
import { LumeWindow } from "@lume/system";
|
||||||
|
|
||||||
export const Route = createFileRoute("/search")({
|
export const Route = createFileRoute("/search")({
|
||||||
component: Screen,
|
component: Screen,
|
||||||
@@ -14,7 +15,7 @@ export const Route = createFileRoute("/search")({
|
|||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [events, setEvents] = useState<Event[]>([]);
|
const [events, setEvents] = useState<NostrEvent[]>([]);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [searchValue] = useDebounce(search, 500);
|
const [searchValue] = useDebounce(search, 500);
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ function Screen() {
|
|||||||
const query = `https://api.nostr.wine/search?query=${searchValue}&kind=0,1`;
|
const query = `https://api.nostr.wine/search?query=${searchValue}&kind=0,1`;
|
||||||
const res = await fetch(query);
|
const res = await fetch(query);
|
||||||
const content = await res.json();
|
const content = await res.json();
|
||||||
const events = content.data as Event[];
|
const events = content.data as NostrEvent[];
|
||||||
const sorted = events.sort((a, b) => b.created_at - a.created_at);
|
const sorted = events.sort((a, b) => b.created_at - a.created_at);
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -102,14 +103,12 @@ function Screen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SearchUser({ event }: { event: Event }) {
|
function SearchUser({ event }: { event: NostrEvent }) {
|
||||||
const { ark } = Route.useRouteContext();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={event.id}
|
key={event.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => ark.open_profile(event.pubkey)}
|
onClick={() => LumeWindow.openProfile(event.pubkey)}
|
||||||
className="col-span-1 p-2 hover:bg-black/10 dark:hover:bg-white/10 rounded-lg"
|
className="col-span-1 p-2 hover:bg-black/10 dark:hover:bg-white/10 rounded-lg"
|
||||||
>
|
>
|
||||||
<User.Provider pubkey={event.pubkey} embedProfile={event.content}>
|
<User.Provider pubkey={event.pubkey} embedProfile={event.content}>
|
||||||
@@ -125,7 +124,7 @@ function SearchUser({ event }: { event: Event }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SearchNote({ event }: { event: Event }) {
|
function SearchNote({ event }: { event: NostrEvent }) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50">
|
<div className="bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50">
|
||||||
<Note.Provider event={event}>
|
<Note.Provider event={event}>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { User } from "@/components/user";
|
import { User } from "@/components/user";
|
||||||
import type { Account } from "@lume/types";
|
import { NostrAccount } from "@lume/system";
|
||||||
import { displayNsec } from "@lume/utils";
|
import { displayNsec } from "@lume/utils";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
@@ -7,19 +7,20 @@ import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface Account {
|
||||||
|
npub: string;
|
||||||
|
nsec: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const Route = createFileRoute("/settings/backup")({
|
export const Route = createFileRoute("/settings/backup")({
|
||||||
component: Screen,
|
component: Screen,
|
||||||
loader: async ({ context }) => {
|
loader: async () => {
|
||||||
const ark = context.ark;
|
const npubs = await NostrAccount.getAccounts();
|
||||||
const npubs = await ark.get_all_accounts();
|
|
||||||
|
|
||||||
const accounts: Account[] = [];
|
const accounts: Account[] = [];
|
||||||
|
|
||||||
for (const account of npubs) {
|
for (const npub of npubs) {
|
||||||
const nsec: string = await invoke("get_stored_nsec", {
|
const nsec: string = await invoke("get_stored_nsec", { npub });
|
||||||
npub: account.npub,
|
accounts.push({ npub, nsec });
|
||||||
});
|
|
||||||
accounts.push({ ...account, nsec });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return accounts;
|
return accounts;
|
||||||
@@ -33,14 +34,14 @@ function Screen() {
|
|||||||
<div className="mx-auto w-full max-w-xl">
|
<div className="mx-auto w-full max-w-xl">
|
||||||
<div className="flex flex-col gap-3 divide-y divide-neutral-300 dark:divide-neutral-700">
|
<div className="flex flex-col gap-3 divide-y divide-neutral-300 dark:divide-neutral-700">
|
||||||
{accounts.map((account) => (
|
{accounts.map((account) => (
|
||||||
<NostrAccount key={account.npub} account={account} />
|
<List key={account.npub} account={account} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function NostrAccount({ account }: { account: Account }) {
|
function List({ account }: { account: Account }) {
|
||||||
const [key, setKey] = useState(account.nsec);
|
const [key, setKey] = useState(account.nsec);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [passphase, setPassphase] = useState("");
|
const [passphase, setPassphase] = useState("");
|
||||||
|
|||||||
@@ -1,28 +1,22 @@
|
|||||||
|
import { NostrQuery } from "@lume/system";
|
||||||
import type { Settings } from "@lume/types";
|
import type { Settings } from "@lume/types";
|
||||||
import * as Switch from "@radix-ui/react-switch";
|
import * as Switch from "@radix-ui/react-switch";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import {
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
isPermissionGranted,
|
import { requestPermission } from "@tauri-apps/plugin-notification";
|
||||||
requestPermission,
|
|
||||||
} from "@tauri-apps/plugin-notification";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useDebouncedCallback } from "use-debounce";
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
|
|
||||||
export const Route = createFileRoute("/settings/general")({
|
export const Route = createFileRoute("/settings/general")({
|
||||||
beforeLoad: async ({ context }) => {
|
beforeLoad: async () => {
|
||||||
const permissionGranted = await isPermissionGranted(); // get notification permission
|
const settings = await NostrQuery.getSettings();
|
||||||
const ark = context.ark;
|
return { settings };
|
||||||
const settings = await ark.get_settings();
|
|
||||||
|
|
||||||
return {
|
|
||||||
settings: { ...settings, notification: permissionGranted },
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const { ark, settings } = Route.useRouteContext();
|
const { settings } = Route.useRouteContext();
|
||||||
const [newSettings, setNewSettings] = useState<Settings>(settings);
|
const [newSettings, setNewSettings] = useState<Settings>(settings);
|
||||||
|
|
||||||
const toggleNofitication = async () => {
|
const toggleNofitication = async () => {
|
||||||
@@ -33,6 +27,13 @@ function Screen() {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleGossip = async () => {
|
||||||
|
setNewSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
gossip: !newSettings.gossip,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
const toggleAutoUpdate = () => {
|
const toggleAutoUpdate = () => {
|
||||||
setNewSettings((prev) => ({
|
setNewSettings((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -61,8 +62,21 @@ function Screen() {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const changeTheme = (theme: string) => {
|
||||||
|
if (theme === "auto" || theme === "light" || theme === "dark") {
|
||||||
|
invoke("plugin:theme|set_theme", {
|
||||||
|
theme: theme,
|
||||||
|
}).then(() =>
|
||||||
|
setNewSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
theme,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const updateSettings = useDebouncedCallback(() => {
|
const updateSettings = useDebouncedCallback(() => {
|
||||||
ark.set_settings(newSettings);
|
NostrQuery.setSettings(newSettings);
|
||||||
}, 200);
|
}, 200);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -95,6 +109,24 @@ function Screen() {
|
|||||||
</Switch.Root>
|
</Switch.Root>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex w-full items-start justify-between gap-4 py-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-medium">Relay Hint</h3>
|
||||||
|
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
|
Automatically connect to the necessary relay suggested by
|
||||||
|
Relay Hint when fetching a new event.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-36 flex justify-end shrink-0">
|
||||||
|
<Switch.Root
|
||||||
|
checked={newSettings.gossip}
|
||||||
|
onClick={() => toggleGossip()}
|
||||||
|
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
|
||||||
|
>
|
||||||
|
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||||
|
</Switch.Root>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex w-full items-start justify-between gap-4 py-3">
|
<div className="flex w-full items-start justify-between gap-4 py-3">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="font-medium">Enhanced Privacy</h3>
|
<h3 className="font-medium">Enhanced Privacy</h3>
|
||||||
@@ -155,24 +187,42 @@ function Screen() {
|
|||||||
Interface
|
Interface
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex flex-col divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl px-3">
|
<div className="flex flex-col divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl px-3">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex w-full items-start justify-between gap-4 py-3">
|
||||||
<div className="flex w-full items-start justify-between gap-4 py-3">
|
<div className="flex-1">
|
||||||
<div className="flex-1">
|
<h3 className="font-semibold">Zap</h3>
|
||||||
<h3 className="font-semibold">Zap</h3>
|
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
Show the Zap button in each note and user's profile screen,
|
||||||
Show the Zap button in each note and user's profile screen,
|
use for send bitcoin tip to other users.
|
||||||
use for send bitcoin tip to other users.
|
</p>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
<div className="w-36 flex justify-end shrink-0">
|
||||||
<div className="w-36 flex justify-end shrink-0">
|
<Switch.Root
|
||||||
<Switch.Root
|
checked={newSettings.zap}
|
||||||
checked={newSettings.zap}
|
onClick={() => toggleZap()}
|
||||||
onClick={() => toggleZap()}
|
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
|
||||||
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
|
>
|
||||||
>
|
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||||
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
</Switch.Root>
|
||||||
</Switch.Root>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex w-full items-start justify-between gap-4 py-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold">Appearance</h3>
|
||||||
|
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
|
* Require restarting the app to take effect.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-36 flex justify-end shrink-0">
|
||||||
|
<select
|
||||||
|
name="theme"
|
||||||
|
className="bg-transparent shadow-none outline-none rounded-lg border-1 border-black/10 dark:border-white/10 py-1 w-24"
|
||||||
|
defaultValue={settings.theme}
|
||||||
|
onChange={(e) => changeTheme(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="auto">Auto</option>
|
||||||
|
<option value="light">Light</option>
|
||||||
|
<option value="dark">Dark</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { CancelIcon, PlusIcon } from "@lume/icons";
|
import { CancelIcon, PlusIcon } from "@lume/icons";
|
||||||
|
import { NostrQuery } from "@lume/system";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export const Route = createFileRoute("/settings/relay")({
|
export const Route = createFileRoute("/settings/relay")({
|
||||||
loader: async ({ context }) => {
|
loader: async () => {
|
||||||
const ark = context.ark;
|
const relays = await NostrQuery.getRelays();
|
||||||
const relays = await ark.get_relays();
|
|
||||||
|
|
||||||
return relays;
|
return relays;
|
||||||
},
|
},
|
||||||
component: Screen,
|
component: Screen,
|
||||||
@@ -18,12 +17,11 @@ function Screen() {
|
|||||||
const relayList = Route.useLoaderData();
|
const relayList = Route.useLoaderData();
|
||||||
const [relays, setRelays] = useState(relayList.connected);
|
const [relays, setRelays] = useState(relayList.connected);
|
||||||
|
|
||||||
const { ark } = Route.useRouteContext();
|
|
||||||
const { register, reset, handleSubmit } = useForm();
|
const { register, reset, handleSubmit } = useForm();
|
||||||
|
|
||||||
const onSubmit = async (data: { url: string }) => {
|
const onSubmit = async (data: { url: string }) => {
|
||||||
try {
|
try {
|
||||||
const add = await ark.add_relay(data.url);
|
const add = await NostrQuery.connectRelay(data.url);
|
||||||
if (add) {
|
if (add) {
|
||||||
setRelays((prev) => [...prev, data.url]);
|
setRelays((prev) => [...prev, data.url]);
|
||||||
reset();
|
reset();
|
||||||
@@ -56,6 +54,7 @@ function Screen() {
|
|||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
onClick={() => NostrQuery.removeRelay(relay)}
|
||||||
className="inline-flex items-center justify-center size-7 rounded-md hover:bg-black/10 dark:hover:bg-white/10"
|
className="inline-flex items-center justify-center size-7 rounded-md hover:bg-black/10 dark:hover:bg-white/10"
|
||||||
>
|
>
|
||||||
<CancelIcon className="size-4" />
|
<CancelIcon className="size-4" />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { AvatarUploader } from "@/components/avatarUploader";
|
import { AvatarUploader } from "@/components/avatarUploader";
|
||||||
import { PlusIcon } from "@lume/icons";
|
import { PlusIcon } from "@lume/icons";
|
||||||
|
import { NostrAccount } from "@lume/system";
|
||||||
import type { Metadata } from "@lume/types";
|
import type { Metadata } from "@lume/types";
|
||||||
import { Spinner } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
@@ -9,17 +10,16 @@ import { useForm } from "react-hook-form";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export const Route = createFileRoute("/settings/user")({
|
export const Route = createFileRoute("/settings/user")({
|
||||||
beforeLoad: async ({ context }) => {
|
beforeLoad: async () => {
|
||||||
const ark = context.ark;
|
const profile = await NostrAccount.getProfile();
|
||||||
const profile = await ark.get_current_user_profile();
|
|
||||||
return { profile };
|
return { profile };
|
||||||
},
|
},
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const { ark, profile } = Route.useRouteContext();
|
const { profile } = Route.useRouteContext();
|
||||||
const { register, handleSubmit } = useForm();
|
const { register, handleSubmit } = useForm({ defaultValues: profile });
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [picture, setPicture] = useState<string>("");
|
const [picture, setPicture] = useState<string>("");
|
||||||
@@ -28,8 +28,8 @@ function Screen() {
|
|||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const profile = { ...data, picture };
|
const newProfile: Metadata = { ...profile, ...data, picture };
|
||||||
await ark.create_profile(profile);
|
await NostrAccount.createProfile(newProfile);
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -87,7 +87,7 @@ function Screen() {
|
|||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
name="display_name"
|
name="display_name"
|
||||||
{...register("display_name", { required: true, minLength: 1 })}
|
{...register("display_name")}
|
||||||
spellCheck={false}
|
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"
|
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,21 @@
|
|||||||
|
import { Conversation } from "@/components/conversation";
|
||||||
|
import { Quote } from "@/components/quote";
|
||||||
import { RepostNote } from "@/components/repost";
|
import { RepostNote } from "@/components/repost";
|
||||||
import { TextNote } from "@/components/text";
|
import { TextNote } from "@/components/text";
|
||||||
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
|
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
|
||||||
import { type ColumnRouteSearch, type Event, Kind } from "@lume/types";
|
import { NostrQuery } from "@lume/system";
|
||||||
|
import {
|
||||||
|
type ColumnRouteSearch,
|
||||||
|
type NostrEvent,
|
||||||
|
Kind,
|
||||||
|
Topic,
|
||||||
|
} from "@lume/types";
|
||||||
import { Spinner } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { Link, createFileRoute, redirect } from "@tanstack/react-router";
|
import { Link, createFileRoute, redirect } from "@tanstack/react-router";
|
||||||
import { Virtualizer } from "virtua";
|
import { Virtualizer } from "virtua";
|
||||||
|
|
||||||
export const Route = createFileRoute("/foryou")({
|
export const Route = createFileRoute("/topic")({
|
||||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||||
return {
|
return {
|
||||||
account: search.account,
|
account: search.account,
|
||||||
@@ -15,23 +23,29 @@ export const Route = createFileRoute("/foryou")({
|
|||||||
name: search.name,
|
name: search.name,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
beforeLoad: async ({ search, context }) => {
|
beforeLoad: async ({ search }) => {
|
||||||
const ark = context.ark;
|
const key = `lume_topic_${search.label}`;
|
||||||
const interests = await ark.get_interest();
|
const topics = (await NostrQuery.getNstore(key)) as unknown as Topic[];
|
||||||
const settings = await ark.get_settings();
|
const settings = await NostrQuery.getSettings();
|
||||||
|
|
||||||
if (!interests) {
|
if (!topics?.length) {
|
||||||
throw redirect({
|
throw redirect({
|
||||||
to: "/interests",
|
to: "/create-topic",
|
||||||
search: {
|
search: {
|
||||||
...search,
|
...search,
|
||||||
redirect: "/foryou",
|
redirect: "/topic",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let hashtags: string[] = [];
|
||||||
|
|
||||||
|
for (const topic of topics) {
|
||||||
|
hashtags.push(...topic.content);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
interests,
|
hashtags,
|
||||||
settings,
|
settings,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -39,8 +53,8 @@ export const Route = createFileRoute("/foryou")({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export function Screen() {
|
export function Screen() {
|
||||||
const { name, account } = Route.useSearch();
|
const { label, account } = Route.useSearch();
|
||||||
const { ark, interests } = Route.useRouteContext();
|
const { hashtags } = Route.useRouteContext();
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -49,38 +63,45 @@ export function Screen() {
|
|||||||
hasNextPage,
|
hasNextPage,
|
||||||
fetchNextPage,
|
fetchNextPage,
|
||||||
} = useInfiniteQuery({
|
} = useInfiniteQuery({
|
||||||
queryKey: [name, account],
|
queryKey: [label, account],
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||||
const events = await ark.get_events_from_interests(
|
const events = NostrQuery.getHashtagEvents(hashtags, pageParam);
|
||||||
interests.hashtags,
|
|
||||||
20,
|
|
||||||
pageParam,
|
|
||||||
);
|
|
||||||
return events;
|
return events;
|
||||||
},
|
},
|
||||||
getNextPageParam: (lastPage) => {
|
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
|
||||||
const lastEvent = lastPage?.at(-1);
|
|
||||||
return lastEvent ? lastEvent.created_at - 1 : null;
|
|
||||||
},
|
|
||||||
select: (data) => data?.pages.flatMap((page) => page),
|
select: (data) => data?.pages.flatMap((page) => page),
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const renderItem = (event: Event) => {
|
const renderItem = (event: NostrEvent) => {
|
||||||
if (!event) return;
|
if (!event) return;
|
||||||
switch (event.kind) {
|
switch (event.kind) {
|
||||||
case Kind.Repost:
|
case Kind.Repost:
|
||||||
return <RepostNote key={event.id} event={event} />;
|
return <RepostNote key={event.id} event={event} />;
|
||||||
default:
|
default: {
|
||||||
return <TextNote key={event.id} event={event} />;
|
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 (
|
return (
|
||||||
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none">
|
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none">
|
||||||
{isFetching && !isLoading && !isFetchingNextPage ? (
|
{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">
|
<div className="flex items-center justify-center gap-2">
|
||||||
<Spinner className="size-5" />
|
<Spinner className="size-5" />
|
||||||
<span className="text-sm font-medium">Fetching new notes...</span>
|
<span className="text-sm font-medium">Fetching new notes...</span>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { TextNote } from "@/components/text";
|
import { TextNote } from "@/components/text";
|
||||||
import { type Event } from "@lume/types";
|
import type { NostrEvent } from "@lume/types";
|
||||||
import { Spinner } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import { Await, createFileRoute } from "@tanstack/react-router";
|
import { Await, createFileRoute } from "@tanstack/react-router";
|
||||||
import { defer } from "@tanstack/react-router";
|
import { defer } from "@tanstack/react-router";
|
||||||
@@ -15,7 +15,7 @@ export const Route = createFileRoute("/trending/notes")({
|
|||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
})
|
})
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
.then((res) => res.notes.map((item) => item.event) as Event[]),
|
.then((res) => res.notes.map((item) => item.event) as NostrEvent[]),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ArticleIcon, GroupFeedsIcon } from "@lume/icons";
|
import { ArticleIcon, GroupFeedsIcon } from "@lume/icons";
|
||||||
|
import { NostrQuery } from "@lume/system";
|
||||||
import type { ColumnRouteSearch } from "@lume/types";
|
import type { ColumnRouteSearch } from "@lume/types";
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { Link, Outlet } from "@tanstack/react-router";
|
import { Link, Outlet } from "@tanstack/react-router";
|
||||||
@@ -12,10 +13,8 @@ export const Route = createFileRoute("/trending")({
|
|||||||
name: search.name,
|
name: search.name,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
beforeLoad: async ({ context }) => {
|
beforeLoad: async () => {
|
||||||
const ark = context.ark;
|
const settings = await NostrQuery.getSettings();
|
||||||
const settings = await ark.get_settings();
|
|
||||||
|
|
||||||
return { settings };
|
return { settings };
|
||||||
},
|
},
|
||||||
component: Screen,
|
component: Screen,
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function Screen() {
|
|||||||
>
|
>
|
||||||
<Await promise={data}>
|
<Await promise={data}>
|
||||||
{(users) =>
|
{(users) =>
|
||||||
users.profiles.map((item) => (
|
users.profiles.map((item: { pubkey: string }) => (
|
||||||
<div
|
<div
|
||||||
key={item.pubkey}
|
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"
|
className="h-max w-full overflow-hidden mb-3 p-2 bg-black/5 dark:bg-white/5 backdrop-blur-lg rounded-xl"
|
||||||
|
|||||||
@@ -6,20 +6,18 @@ import { Conversation } from "@/components/conversation";
|
|||||||
import { Quote } from "@/components/quote";
|
import { Quote } from "@/components/quote";
|
||||||
import { RepostNote } from "@/components/repost";
|
import { RepostNote } from "@/components/repost";
|
||||||
import { TextNote } from "@/components/text";
|
import { TextNote } from "@/components/text";
|
||||||
import { Event, Kind } from "@lume/types";
|
import { type NostrEvent, Kind } from "@lume/types";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { Await } from "@tanstack/react-router";
|
import { Await } from "@tanstack/react-router";
|
||||||
|
import { NostrQuery } from "@lume/system";
|
||||||
|
|
||||||
export const Route = createFileRoute("/users/$pubkey")({
|
export const Route = createFileRoute("/users/$pubkey")({
|
||||||
beforeLoad: async ({ context }) => {
|
beforeLoad: async () => {
|
||||||
const ark = context.ark;
|
const settings = await NostrQuery.getSettings();
|
||||||
const settings = await ark.get_settings();
|
|
||||||
|
|
||||||
return { settings };
|
return { settings };
|
||||||
},
|
},
|
||||||
loader: async ({ params, context }) => {
|
loader: async ({ params }) => {
|
||||||
const ark = context.ark;
|
return { data: defer(NostrQuery.getUserEvents(params.pubkey)) };
|
||||||
return { data: defer(ark.get_events_from(params.pubkey, 50)) };
|
|
||||||
},
|
},
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
@@ -28,7 +26,7 @@ function Screen() {
|
|||||||
const { pubkey } = Route.useParams();
|
const { pubkey } = Route.useParams();
|
||||||
const { data } = Route.useLoaderData();
|
const { data } = Route.useLoaderData();
|
||||||
|
|
||||||
const renderItem = (event: Event) => {
|
const renderItem = (event: NostrEvent) => {
|
||||||
if (!event) return;
|
if (!event) return;
|
||||||
switch (event.kind) {
|
switch (event.kind) {
|
||||||
case Kind.Repost:
|
case Kind.Repost:
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
import { RepostNote } from "@/components/repost";
|
|
||||||
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";
|
|
||||||
|
|
||||||
export function EventList({ id }: { id: string }) {
|
|
||||||
const { ark } = useRouteContext({ strict: false });
|
|
||||||
const { data, hasNextPage, isLoading, isFetchingNextPage, fetchNextPage } =
|
|
||||||
useInfiniteQuery({
|
|
||||||
queryKey: ["events", id],
|
|
||||||
initialPageParam: 0,
|
|
||||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
|
||||||
const events = await ark.get_events_from(id, FETCH_LIMIT, pageParam);
|
|
||||||
return events;
|
|
||||||
},
|
|
||||||
getNextPageParam: (lastPage) => {
|
|
||||||
const lastEvent = lastPage?.at(-1);
|
|
||||||
return lastEvent ? lastEvent.created_at - 1 : null;
|
|
||||||
},
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const renderItem = (event: Event) => {
|
|
||||||
if (!event) return;
|
|
||||||
switch (event.kind) {
|
|
||||||
case Kind.Repost:
|
|
||||||
return <RepostNote key={event.id} event={event} />;
|
|
||||||
default:
|
|
||||||
return <TextNote key={event.id} event={event} />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
|
|
||||||
<Spinner className="size-5" />
|
|
||||||
</div>
|
|
||||||
) : !data.length ? (
|
|
||||||
<div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-5 dark:bg-neutral-950">
|
|
||||||
<InfoIcon className="size-6" />
|
|
||||||
<p>Empty newsfeed.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
data.map((item) => renderItem(item))
|
|
||||||
)}
|
|
||||||
<div className="flex h-20 items-center justify-center">
|
|
||||||
{hasNextPage ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => fetchNextPage()}
|
|
||||||
disabled={!hasNextPage || isFetchingNextPage}
|
|
||||||
className="inline-flex h-12 w-36 items-center justify-center gap-2 rounded-full bg-neutral-100 px-3 font-medium hover:bg-neutral-200 focus:outline-none dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
|
||||||
>
|
|
||||||
{isFetchingNextPage ? (
|
|
||||||
<Spinner className="size-5" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ArrowRightCircleIcon className="size-5" />
|
|
||||||
Load more
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||