Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -9,7 +9,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lume/ark": "workspace:^",
|
"@lume/system": "workspace:^",
|
||||||
"@lume/icons": "workspace:^",
|
"@lume/icons": "workspace:^",
|
||||||
"@lume/ui": "workspace:^",
|
"@lume/ui": "workspace:^",
|
||||||
"@lume/utils": "workspace:^",
|
"@lume/utils": "workspace:^",
|
||||||
@@ -22,42 +22,42 @@
|
|||||||
"@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-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.36.0",
|
||||||
"@tanstack/react-query": "^5.32.0",
|
"@tanstack/react-query": "^5.36.0",
|
||||||
"@tanstack/react-query-persist-client": "^5.32.0",
|
"@tanstack/react-query-persist-client": "^5.36.0",
|
||||||
"@tanstack/react-router": "1.29.2",
|
"@tanstack/react-router": "1.32.5",
|
||||||
"i18next": "^23.11.3",
|
"i18next": "^23.11.4",
|
||||||
"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",
|
"nostr-tools": "^2.5.2",
|
||||||
"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.4",
|
||||||
"react-hotkeys-hook": "^4.5.0",
|
"react-hotkeys-hook": "^4.5.0",
|
||||||
"react-i18next": "^14.1.1",
|
"react-i18next": "^14.1.1",
|
||||||
"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.102.0",
|
||||||
"sonner": "^1.4.41",
|
"sonner": "^1.4.41",
|
||||||
"use-debounce": "^10.0.0",
|
"use-debounce": "^10.0.0",
|
||||||
"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.32.5",
|
||||||
"@tanstack/router-vite-plugin": "^1.30.0",
|
"@tanstack/router-vite-plugin": "^1.32.2",
|
||||||
"@types/react": "^18.3.1",
|
"@types/react": "^18.3.2",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@vitejs/plugin-react-swc": "^3.6.0",
|
"@vitejs/plugin-react-swc": "^3.6.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.11",
|
||||||
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
|
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 |
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ 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, useRef, useState } from "react";
|
||||||
|
|
||||||
export function Col({
|
export function Column({
|
||||||
column,
|
column,
|
||||||
account,
|
account,
|
||||||
isScroll,
|
isScroll,
|
||||||
@@ -17,68 +17,55 @@ 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 = `column-${account}_${column.label}`;
|
||||||
|
|
||||||
|
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 () => {
|
const rect = container.current.getBoundingClientRect();
|
||||||
if (webview && webview.length > 1) return;
|
const url = `${column.content}?account=${account}&label=${column.label}&name=${column.name}`;
|
||||||
|
|
||||||
const rect = container.current.getBoundingClientRect();
|
// create new webview
|
||||||
const windowLabel = `column-${column.label}`;
|
invoke("create_column", {
|
||||||
const url = `${column.content}?account=${account}&label=${column.label}&name=${column.name}`;
|
label: webviewLabel,
|
||||||
|
x: rect.x,
|
||||||
// create new webview
|
y: rect.y,
|
||||||
const label: string = await invoke("create_column", {
|
width: rect.width,
|
||||||
label: windowLabel,
|
height: rect.height,
|
||||||
x: rect.x,
|
url,
|
||||||
y: rect.y,
|
}).then(() => setIsCreated(true));
|
||||||
width: rect.width,
|
|
||||||
height: rect.height,
|
|
||||||
url,
|
|
||||||
});
|
|
||||||
|
|
||||||
setWebview(label);
|
|
||||||
})();
|
|
||||||
|
|
||||||
// close webview when unmounted
|
// close webview when unmounted
|
||||||
return () => {
|
return () => {
|
||||||
if (webview && webview.length > 1) {
|
invoke("close_column", { label: webviewLabel });
|
||||||
invoke("close_column", {
|
|
||||||
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 (
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
@@ -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,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 (
|
||||||
|
|||||||
@@ -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,47 +1,31 @@
|
|||||||
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 = 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));
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
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 { ark, storedColumns } = Route.useRouteContext();
|
|
||||||
|
|
||||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||||
const [columns, setColumns] = useState(storedColumns);
|
const [columns, setColumns] = useState([]);
|
||||||
const [isScroll, setIsScroll] = useState(false);
|
const [isScroll, setIsScroll] = useState(false);
|
||||||
const [isResize, setIsResize] = useState(false);
|
const [isResize, setIsResize] = useState(false);
|
||||||
|
|
||||||
@@ -114,9 +98,13 @@ function Screen() {
|
|||||||
150,
|
150,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setColumns(initialColumnList);
|
||||||
|
}, [initialColumnList]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// save state
|
// save state
|
||||||
ark.set_columns(columns);
|
NostrQuery.setColumns(columns);
|
||||||
}, [columns]);
|
}, [columns]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -144,13 +132,13 @@ 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={column.label}
|
||||||
column={column}
|
column={column}
|
||||||
account={account}
|
account={account}
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { BellIcon, ComposeFilledIcon, PlusIcon, SearchIcon } from "@lume/icons";
|
import {
|
||||||
import { Event, Kind } from "@lume/types";
|
BellIcon,
|
||||||
|
ComposeFilledIcon,
|
||||||
|
HorizontalDotsIcon,
|
||||||
|
PlusIcon,
|
||||||
|
SearchIcon,
|
||||||
|
} from "@lume/icons";
|
||||||
|
import { type NostrEvent, Kind } from "@lume/types";
|
||||||
import { User } from "@/components/user";
|
import { User } from "@/components/user";
|
||||||
import {
|
import {
|
||||||
cn,
|
cn,
|
||||||
@@ -10,21 +16,27 @@ import {
|
|||||||
import { Outlet, createFileRoute } from "@tanstack/react-router";
|
import { Outlet, createFileRoute } from "@tanstack/react-router";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
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, NostrQuery } from "@lume/system";
|
||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
type AccountSearch = {
|
||||||
|
accounts?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export const Route = createFileRoute("/$account")({
|
export const Route = createFileRoute("/$account")({
|
||||||
beforeLoad: async ({ context }) => {
|
validateSearch: (search: Record<string, unknown>): AccountSearch => {
|
||||||
const ark = context.ark;
|
return {
|
||||||
const accounts = await ark.get_all_accounts();
|
accounts: (search?.accounts as string[]) || [],
|
||||||
|
};
|
||||||
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,18 +49,17 @@ 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" />
|
||||||
@@ -57,7 +68,7 @@ function Screen() {
|
|||||||
<Bell />
|
<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,51 +84,116 @@ function Screen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Accounts() {
|
function Accounts() {
|
||||||
const navigate = Route.useNavigate();
|
const { accounts } = Route.useSearch();
|
||||||
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 } });
|
return navigate({ to: "/$account/home", params: { account: npub } });
|
||||||
|
} else {
|
||||||
|
toast.warning("Something wrong.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getWindowDimensions = () => {
|
||||||
|
const { innerWidth: width, innerHeight: height } = window;
|
||||||
|
return {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleResize() {
|
||||||
|
setWindowWidth(getWindowDimensions().width);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!windowWidth) setWindowWidth(getWindowDimensions().width);
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
|
||||||
|
return () => window.removeEventListener("resize", handleResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-tauri-drag-region className="flex items-center gap-3">
|
<div data-tauri-drag-region className="flex items-center gap-3">
|
||||||
{accounts.map((user) => (
|
{sortedList
|
||||||
<button key={user} type="button" onClick={() => changeAccount(user)}>
|
.slice(0, windowWidth > 500 ? account.length : 2)
|
||||||
<User.Provider pubkey={user}>
|
.map((user) => (
|
||||||
<User.Root
|
<button key={user} type="button" onClick={() => changeAccount(user)}>
|
||||||
className={cn(
|
<User.Provider pubkey={user}>
|
||||||
"rounded-full",
|
<User.Root
|
||||||
user === account
|
|
||||||
? "ring-1 ring-teal-500 ring-offset-2 ring-offset-neutral-200 dark:ring-offset-neutral-950"
|
|
||||||
: "",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<User.Avatar
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"aspect-square h-auto rounded-full object-cover",
|
"shrink-0 rounded-full transition-all ease-in-out duration-150 will-change-auto",
|
||||||
user === account ? "w-7" : "w-8",
|
user === account
|
||||||
|
? "ring-1 ring-teal-500 ring-offset-2 ring-offset-neutral-200 dark:ring-offset-neutral-950"
|
||||||
|
: "",
|
||||||
)}
|
)}
|
||||||
/>
|
>
|
||||||
</User.Root>
|
<User.Avatar
|
||||||
</User.Provider>
|
className={cn(
|
||||||
</button>
|
"aspect-square h-auto rounded-full object-cover transition-all ease-in-out duration-150 will-change-auto",
|
||||||
))}
|
user === account ? "w-7" : "w-8",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{accounts.length >= 3 && windowWidth <= 700 ? (
|
||||||
|
<Popover.Root>
|
||||||
|
<Popover.Trigger className="inline-flex size-8 shrink-0 items-center justify-center rounded-full bg-black/10 text-neutral-800 hover:bg-black/20 dark:bg-white/10 dark:text-neutral-200 dark:hover:bg-white/20">
|
||||||
|
<HorizontalDotsIcon className="size-5" />
|
||||||
|
</Popover.Trigger>
|
||||||
|
<Popover.Portal>
|
||||||
|
<Popover.Content className="flex h-11 select-none items-center justify-center rounded-md bg-neutral-950 p-1 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
|
||||||
|
{sortedList.slice(2).map((user) => (
|
||||||
|
<button
|
||||||
|
key={user}
|
||||||
|
type="button"
|
||||||
|
onClick={() => changeAccount(user)}
|
||||||
|
className="size-9 inline-flex items-center justify-center hover:bg-white/10 rounded-md"
|
||||||
|
>
|
||||||
|
<User.Provider pubkey={user}>
|
||||||
|
<User.Root className="rounded-full ring-1 ring-white/10">
|
||||||
|
<User.Avatar className="size-7 aspect-square h-auto rounded-full object-cover" />
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<Popover.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Portal>
|
||||||
|
</Popover.Root>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Bell() {
|
function Bell() {
|
||||||
const { ark } = Route.useRouteContext();
|
|
||||||
const { account } = Route.useParams();
|
const { account } = Route.useParams();
|
||||||
|
|
||||||
const [count, setCount] = useState(0);
|
const [count, setCount] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -127,8 +203,8 @@ function Bell() {
|
|||||||
setCount((prevCount) => prevCount + 1);
|
setCount((prevCount) => prevCount + 1);
|
||||||
await invoke("set_badge", { count });
|
await invoke("set_badge", { count });
|
||||||
|
|
||||||
const event: Event = JSON.parse(payload.payload);
|
const event: NostrEvent = JSON.parse(payload.payload);
|
||||||
const user = await ark.get_profile(event.pubkey);
|
const user = await NostrQuery.getProfile(event.pubkey);
|
||||||
const userName =
|
const userName =
|
||||||
user.display_name || user.name || displayNpub(event.pubkey, 16);
|
user.display_name || user.name || displayNpub(event.pubkey, 16);
|
||||||
|
|
||||||
@@ -165,7 +241,7 @@ function Bell() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCount(0);
|
setCount(0);
|
||||||
ark.open_activity(account);
|
LumeWindow.openActivity(account);
|
||||||
}}
|
}}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>()({
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import { Note } from "@/components/note";
|
|||||||
import { Await, createFileRoute, defer } from "@tanstack/react-router";
|
import { Await, createFileRoute, defer } from "@tanstack/react-router";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { Virtualizer } from "virtua";
|
import { Virtualizer } from "virtua";
|
||||||
|
import { NostrQuery } from "@lume/system";
|
||||||
|
|
||||||
export const Route = createFileRoute("/activity/$account/texts")({
|
export const Route = createFileRoute("/activity/$account/texts")({
|
||||||
loader: async ({ context, params }) => {
|
loader: async ({ params }) => {
|
||||||
const ark = context.ark;
|
return { data: defer(NostrQuery.getUserActivities(params.account, "1")) };
|
||||||
return { data: defer(ark.get_activities(params.account, "1")) };
|
|
||||||
},
|
},
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ function Screen() {
|
|||||||
const { account } = Route.useParams();
|
const { account } = Route.useParams();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container withDrag withNavigate={false}>
|
<Container withDrag>
|
||||||
<Box className="scrollbar-none shadow-none bg-black/5 dark:bg-white/5 backdrop-blur-sm flex flex-col overflow-y-auto">
|
<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="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">
|
<div className="inline-flex h-full w-full items-center gap-1">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { User } from "@/components/user";
|
import { User } from "@/components/user";
|
||||||
|
import { NostrQuery } from "@lume/system";
|
||||||
import { Spinner } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import { decodeZapInvoice } from "@lume/utils";
|
import { decodeZapInvoice } from "@lume/utils";
|
||||||
import { Await, createFileRoute, defer } from "@tanstack/react-router";
|
import { Await, createFileRoute, defer } from "@tanstack/react-router";
|
||||||
@@ -6,9 +7,10 @@ import { Suspense } from "react";
|
|||||||
import { Virtualizer } from "virtua";
|
import { Virtualizer } from "virtua";
|
||||||
|
|
||||||
export const Route = createFileRoute("/activity/$account/zaps")({
|
export const Route = createFileRoute("/activity/$account/zaps")({
|
||||||
loader: async ({ context, params }) => {
|
loader: async ({ params }) => {
|
||||||
const ark = context.ark;
|
return {
|
||||||
return { data: defer(ark.get_activities(params.account, "9735")) };
|
data: defer(NostrQuery.getUserActivities(params.account, "9735")),
|
||||||
|
};
|
||||||
},
|
},
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,43 +1,29 @@
|
|||||||
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 {
|
import { requestPermission } from "@tauri-apps/plugin-notification";
|
||||||
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 navigate = Route.useNavigate();
|
||||||
|
const { account } = Route.useParams();
|
||||||
const { account } = Route.useSearch();
|
const { settings } = Route.useRouteContext();
|
||||||
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 toggleNofitication = async () => {
|
||||||
@@ -82,7 +68,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({
|
||||||
@@ -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/new/$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,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/new/$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 npub = await NostrAccount.connectRemoteAccount(uri);
|
||||||
|
|
||||||
if (npub) {
|
if (npub) {
|
||||||
navigate({
|
navigate({
|
||||||
to: "/auth/settings",
|
to: "/auth/$account/settings",
|
||||||
search: { account: npub },
|
params: { account: npub },
|
||||||
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,11 +35,15 @@ 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 },
|
||||||
|
search: {
|
||||||
|
accounts: context.accounts,
|
||||||
|
},
|
||||||
replace: true,
|
replace: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -64,60 +62,45 @@ function Screen() {
|
|||||||
return (
|
return (
|
||||||
<div className="relative flex h-full w-full items-center justify-center">
|
<div 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,92 @@ 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
|
||||||
|
>
|
||||||
|
<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"
|
>
|
||||||
onClick={() => setUserType("new")}
|
<source
|
||||||
className={cn(
|
src="https://video.nostr.build/d33962520506d86acfb4b55a7b265821e10ae637f5ec830a173b7e6092b16ec8.mp4"
|
||||||
"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",
|
type="video/mp4"
|
||||||
userType === "new"
|
/>
|
||||||
? "bg-blue-500 text-white hover:bg-blue-600"
|
Your browser does not support the video tag.
|
||||||
: "",
|
</video>
|
||||||
)}
|
</div>
|
||||||
>
|
</div>
|
||||||
I'm completely new to Nostr.
|
<div className="relative flex flex-col items-center justify-center rounded-xl bg-black/10 dark:bg-white/10 backdrop-blur-lg">
|
||||||
<ArrowRightIcon className="size-4" />
|
<div className="absolute top-2 left-3 text-2xl font-semibold font-serif text-neutral-600 dark:text-neutral-400">
|
||||||
</button>
|
03.
|
||||||
<button
|
</div>
|
||||||
type="button"
|
<div className="h-16 flex items-center justify-center shrink-0 px-3 text-lg select-text">
|
||||||
onClick={() => setUserType("veteran")}
|
Open Lume Store.
|
||||||
className={cn(
|
</div>
|
||||||
"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",
|
<div className="flex-1 w-3/4 h-full pb-10">
|
||||||
userType === "veteran"
|
<video
|
||||||
? "bg-blue-500 text-white hover:bg-blue-600"
|
className="h-auto w-full aspect-square rounded-lg shadow-md transform"
|
||||||
: "",
|
controls
|
||||||
)}
|
muted
|
||||||
>
|
>
|
||||||
I've already been using another Nostr client.
|
<source
|
||||||
<ArrowRightIcon className="size-4" />
|
src="https://video.nostr.build/927abbfde2097e470ac751181b1db456b7e4b9149550408efff1a966a7ffb9a8.mp4"
|
||||||
</button>
|
type="video/mp4"
|
||||||
</div>
|
/>
|
||||||
|
Your browser does not support the video tag.
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative flex flex-col items-center justify-center rounded-xl bg-black/10 dark:bg-white/10 backdrop-blur-lg">
|
||||||
|
<div className="absolute top-2 left-3 text-2xl font-semibold font-serif text-neutral-600 dark:text-neutral-400">
|
||||||
|
04.
|
||||||
|
</div>
|
||||||
|
<div className="h-16 flex items-center justify-center shrink-0 px-3 text-lg select-text">
|
||||||
|
Use the Tray Menu.
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 w-3/4 h-full pb-10">
|
||||||
|
<video
|
||||||
|
className="h-auto w-full rounded-lg shadow-md transform"
|
||||||
|
controls
|
||||||
|
muted
|
||||||
|
>
|
||||||
|
<source
|
||||||
|
src="https://video.nostr.build/513de4824b6abaf7e9698c1dad2f68096574356848c0c200bc8cb8074df29410.mp4"
|
||||||
|
type="video/mp4"
|
||||||
|
/>
|
||||||
|
Your browser does not support the video tag.
|
||||||
|
</video>
|
||||||
</div>
|
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -7,6 +7,7 @@ import { useState } from "react";
|
|||||||
import CurrencyInput from "react-currency-input-field";
|
import CurrencyInput from "react-currency-input-field";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { LumeEvent } from "@lume/system";
|
||||||
|
|
||||||
const DEFAULT_VALUES = [69, 100, 200, 500];
|
const DEFAULT_VALUES = [69, 100, 200, 500];
|
||||||
|
|
||||||
@@ -16,7 +17,6 @@ export const Route = createLazyFileRoute("/zap/$id")({
|
|||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { ark } = Route.useRouteContext();
|
|
||||||
const { id } = Route.useParams();
|
const { id } = Route.useParams();
|
||||||
// @ts-ignore, magic !!!
|
// @ts-ignore, magic !!!
|
||||||
const { pubkey, account } = Route.useSearch();
|
const { pubkey, account } = Route.useSearch();
|
||||||
@@ -31,7 +31,7 @@ function Screen() {
|
|||||||
// start loading
|
// start loading
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
const val = await ark.zap_event(id, amount, message);
|
const val = await LumeEvent.zap(id, amount, message);
|
||||||
|
|
||||||
if (val) {
|
if (val) {
|
||||||
setIsCompleted(true);
|
setIsCompleted(true);
|
||||||
|
|||||||
@@ -12,8 +12,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/check": "^0.5.10",
|
"@astrojs/check": "^0.5.10",
|
||||||
"@astrojs/tailwind": "^5.1.0",
|
"@astrojs/tailwind": "^5.1.0",
|
||||||
"@fontsource/geist-mono": "^5.0.3",
|
"@fontsource/alice": "^5.0.13",
|
||||||
"astro": "^4.7.0",
|
"astro": "^4.8.3",
|
||||||
"astro-seo-meta": "^4.1.1",
|
"astro-seo-meta": "^4.1.1",
|
||||||
"astro-seo-schema": "^4.0.2",
|
"astro-seo-schema": "^4.0.2",
|
||||||
"schema-dts": "^1.1.2",
|
"schema-dts": "^1.1.2",
|
||||||
|
|||||||
|
After Width: | Height: | Size: 889 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
@@ -1,37 +0,0 @@
|
|||||||
<svg width="824" height="824" viewBox="0 0 824 824" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<g clip-path="url(#clip0_564_71)">
|
|
||||||
<rect width="824" height="824" rx="184" fill="white" style="fill:white;fill-opacity:1;"/>
|
|
||||||
<circle cx="267" cy="594" r="42" fill="black" style="fill:black;fill-opacity:1;"/>
|
|
||||||
<circle cx="267" cy="594" r="42" fill="url(#paint0_radial_564_71)" fill-opacity="0.5" style=""/>
|
|
||||||
<circle cx="267" cy="594" r="42" fill="url(#paint1_radial_564_71)" fill-opacity="0.3" style=""/>
|
|
||||||
<circle cx="557" cy="594" r="42" fill="black" style="fill:black;fill-opacity:1;"/>
|
|
||||||
<circle cx="557" cy="594" r="42" fill="url(#paint2_radial_564_71)" fill-opacity="0.5" style=""/>
|
|
||||||
<circle cx="557" cy="594" r="42" fill="url(#paint3_radial_564_71)" fill-opacity="0.3" style=""/>
|
|
||||||
<path d="M412 691C382.859 691 353.717 686.063 337.654 682.804C333.024 681.865 329.866 686.676 333.074 690.144C345.098 703.138 370.814 724 412 724C453.186 724 478.902 703.138 490.926 690.144C494.134 686.676 490.976 681.865 486.346 682.804C470.283 686.063 441.141 691 412 691Z" fill="url(#paint4_linear_564_71)" style=""/>
|
|
||||||
</g>
|
|
||||||
<defs>
|
|
||||||
<radialGradient id="paint0_radial_564_71" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(241.807 578.038) rotate(112.103) scale(88.2816 69.6512)">
|
|
||||||
<stop stop-color="white" style="stop-color:white;stop-opacity:1;"/>
|
|
||||||
<stop offset="1" stop-opacity="0" style="stop-color:none;stop-opacity:0;"/>
|
|
||||||
</radialGradient>
|
|
||||||
<radialGradient id="paint1_radial_564_71" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(288.309 621.165) rotate(126.504) scale(25.5816 15.4047)">
|
|
||||||
<stop stop-color="white" style="stop-color:white;stop-opacity:1;"/>
|
|
||||||
<stop offset="1" stop-opacity="0" style="stop-color:none;stop-opacity:0;"/>
|
|
||||||
</radialGradient>
|
|
||||||
<radialGradient id="paint2_radial_564_71" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(531.807 578.038) rotate(112.103) scale(88.2816 69.6512)">
|
|
||||||
<stop stop-color="white" style="stop-color:white;stop-opacity:1;"/>
|
|
||||||
<stop offset="1" stop-opacity="0" style="stop-color:none;stop-opacity:0;"/>
|
|
||||||
</radialGradient>
|
|
||||||
<radialGradient id="paint3_radial_564_71" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(578.309 621.165) rotate(126.504) scale(25.5816 15.4047)">
|
|
||||||
<stop stop-color="white" style="stop-color:white;stop-opacity:1;"/>
|
|
||||||
<stop offset="1" stop-opacity="0" style="stop-color:none;stop-opacity:0;"/>
|
|
||||||
</radialGradient>
|
|
||||||
<linearGradient id="paint4_linear_564_71" x1="293.565" y1="686.595" x2="316.497" y2="774.784" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#FF9F5A" style="stop-color:#FF9F5A;stop-color:color(display-p3 1.0000 0.6235 0.3529);stop-opacity:1;"/>
|
|
||||||
<stop offset="1" stop-color="#FF9F5A" style="stop-color:#FF9F5A;stop-color:color(display-p3 1.0000 0.6235 0.3529);stop-opacity:1;"/>
|
|
||||||
</linearGradient>
|
|
||||||
<clipPath id="clip0_564_71">
|
|
||||||
<rect width="824" height="824" fill="white" style="fill:white;fill-opacity:1;"/>
|
|
||||||
</clipPath>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 714 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 318 KiB |
@@ -3,153 +3,96 @@ import { Seo } from "astro-seo-meta";
|
|||||||
---
|
---
|
||||||
|
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width" />
|
<meta name="viewport" content="width=device-width" />
|
||||||
<meta name="generator" content={Astro.generator} />
|
<meta name="generator" content={Astro.generator} />
|
||||||
<title>Lume</title>
|
<title>Lume: The nostr client for desktop</title>
|
||||||
<Seo
|
<Seo
|
||||||
title="Lume"
|
title="Lume"
|
||||||
description="A multiple columns Nostr client for desktop."
|
description="A friendly and scalable Nostr desktop client."
|
||||||
keywords={[
|
keywords={[
|
||||||
"nostr",
|
"nostr",
|
||||||
"nostr client",
|
"nostr client",
|
||||||
"social network",
|
"social network",
|
||||||
"desktop app",
|
"desktop app",
|
||||||
"timeline",
|
"timeline",
|
||||||
"application",
|
"application",
|
||||||
"columns",
|
"columns",
|
||||||
]}
|
"tweetdeck",
|
||||||
themeColor="#fafafa"
|
]}
|
||||||
colorScheme="light"
|
themeColor="#fafafa"
|
||||||
facebook={{
|
colorScheme="light"
|
||||||
image: "/og-image.jpg",
|
facebook={{
|
||||||
url: "https://lume.nu",
|
image: "/og-image.jpg",
|
||||||
type: "website",
|
url: "https://lume.nu",
|
||||||
}}
|
type: "website",
|
||||||
twitter={{
|
}}
|
||||||
image: "/og-image.jpg",
|
twitter={{
|
||||||
card: "summary",
|
image: "/og-image.jpg",
|
||||||
}}
|
card: "summary",
|
||||||
/>
|
}}
|
||||||
</head>
|
/>
|
||||||
<body
|
</head>
|
||||||
class="w-full h-full antialiased font-mono bg-neutral-50 dark:bg-neutral-950 text-neutral-950 dark:text-neutral-50"
|
<body
|
||||||
>
|
class="w-full h-full antialiased bg-neutral-50 dark:bg-neutral-950 text-neutral-950 dark:text-neutral-50"
|
||||||
<div class="max-w-2xl mx-auto w-full py-16 md:px-0 px-2">
|
>
|
||||||
<div class="flex flex-col gap-16">
|
<div class="py-10 flex flex-col gap-10">
|
||||||
<div class="prose dark:prose-invert prose-neutral max-w-none">
|
<div class="mx-auto max-w-xl w-full flex flex-col gap-2">
|
||||||
<h3>About Lume</h3>
|
<div class="mb-5">
|
||||||
<p>
|
<img
|
||||||
Lume is a <b>Nostr client</b> for desktop include Linux, Windows and
|
src="/icon.png"
|
||||||
macOS. It is free and open source, you can look at source code <a
|
alt="App Icon"
|
||||||
href="https://github.com/lumehq/lume"
|
class="size-14 shadow-md shadow-neutral-500/50 rounded-xl object-cover transform-gpu -rotate-6 hover:animate-spin"
|
||||||
target="_blank">on Github</a
|
/>
|
||||||
>. Lume is actively improving the app and adding new features, you
|
</div>
|
||||||
can expect new update every month.
|
<h1 class="text-xl font-serif font-semibold">
|
||||||
</p>
|
A friendly and scalable Nostr desktop client.
|
||||||
<a href="#download">Download</a>
|
</h1>
|
||||||
<h3>What is nostr & how does it work?</h3>
|
<p class="text-sm font-medium text-neutral-700">
|
||||||
<p>
|
Lume is a <b>Nostr client</b> for desktop, including Linux, Windows, and
|
||||||
Nostr stands for Notes and Other Stuff Transmitted by Relays. It is
|
macOS. It is free and open-source; you can look at the source code on <a
|
||||||
an open, permission-less protocol that aims to provide
|
href="https://github.com/lumehq/lume">GitHub</a
|
||||||
censorship-resistance and interoperability. It can be used to create
|
>. Lume is actively improving the app and adding new features; you can
|
||||||
social networks or just about any other type of app (other stuff
|
expect a new update every month.
|
||||||
part of the acronym). It is not a single website or app, but the
|
</p>
|
||||||
glue that holds together many apps (clients) and <b>Lume</b> is one of
|
<p class="text-sm font-medium text-neutral-700">
|
||||||
it.
|
<b>Latest version</b>: 4.0.4
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<div
|
||||||
At its core, nostr consists of relays and events. A person does
|
class="w-full h-[120px] sm:h-[80px] flex flex-col sm:flex-row sm:items-center sm:justify-start justify-center gap-2"
|
||||||
something (event) and this event is sent to a relay. The relay
|
>
|
||||||
stores the event, then waits for another person to request it. The
|
<a
|
||||||
most common types of events are notes and reactions - the stuff
|
href="https://github.com/lumehq/lume/releases/latest"
|
||||||
social media is made of, but there are many other types of events.
|
class="inline-flex items-center justify-center w-44 h-11 rounded-full bg-black hover:ring-2 ring-blue-500 ring-offset-2 text-white font-medium text-sm"
|
||||||
It works very similar to how any other app would work with a
|
>Download for macOS</a
|
||||||
database, except in nostr there is no single database, rather a
|
>
|
||||||
large number of relays that store the events.
|
<span class="italic text-xs text-neutral-700"
|
||||||
</p>
|
>(Windows & Linux are coming later)</span
|
||||||
<h3>Lume is multiple columns experience</h3>
|
>
|
||||||
<p>
|
</div>
|
||||||
Lume is display your timeline as multiple column, each column is
|
<div class="text-sm italic text-neutral-600">
|
||||||
each different content and you can define your experience
|
* If you still need to use Lume on Windows and Linux, you can try v3 <a
|
||||||
</p>
|
href="https://github.com/lumehq/lume/releases/tag/v3.0.2"
|
||||||
<p>
|
class="text-blue-500">here</a
|
||||||
You can create a column to display newsfeed from specific people,
|
>
|
||||||
you can create a column to display all contents related to some
|
</div>
|
||||||
hashtags. It all up to you.
|
</div>
|
||||||
</p>
|
<div class="sm:max-w-3xl w-full mx-auto px-3 sm:px-0">
|
||||||
<img
|
<video
|
||||||
src="https://image.nostr.build/fd3e3cdeb4fb9f0f3de5c5e668a11dcae55f50cc9a78fc2b57b063240191a0f9.png"
|
class="aspect-video w-full h-auto rounded-xl"
|
||||||
alt="columns"
|
autoplay
|
||||||
loading="lazy"
|
muted
|
||||||
decoding="async"
|
controls
|
||||||
class="w-full h-auto rounded-lg"
|
>
|
||||||
/>
|
<source
|
||||||
<h3>"For You"</h3>
|
src="https://video.nostr.build/4cc4df88caeb861b62e3f73bddbb5e0b5cf63617472a97d22f427e273ee0e127.mp4"
|
||||||
<p>
|
type="video/mp4"
|
||||||
Unlike some social networks, they feed you by algorithm. In Lume,
|
/>
|
||||||
you totally control what to will see
|
Your browser does not support the video tag.
|
||||||
</p>
|
</video>
|
||||||
<img
|
</div>
|
||||||
src="https://image.nostr.build/5afd79de15929a4ac6f6e933791c942555baa4206fecee54fed61dde9fe167e1.png"
|
</div>
|
||||||
alt="for you"
|
</body>
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
class="w-full h-auto rounded-lg"
|
|
||||||
/>
|
|
||||||
<h3 id="download">Download and Explore</h3>
|
|
||||||
<p>
|
|
||||||
(Universal) macOS: <a
|
|
||||||
href="https://github.com/lumehq/lume/releases/download/v3.0.0/Lume_3.0.0_universal.dmg"
|
|
||||||
>Lume_3.0.0_universal.dmg
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
(x86-64) Windows 11: <a
|
|
||||||
href="https://github.com/lumehq/lume/releases/download/v3.0.0/Lume_3.0.0_x64-setup.exe"
|
|
||||||
>Lume_3.0.0_x64-setup.exe
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
(x86-64) Ubuntu: <a
|
|
||||||
href="https://github.com/lumehq/lume/releases/download/v3.0.0/lume_3.0.0_amd64.deb"
|
|
||||||
>lume_3.0.0_amd64.deb
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
(x86-64) Fedora: <a
|
|
||||||
href="https://github.com/lumehq/lume/releases/download/v3.0.0/lume-3.0.0-1.x86_64.rpm"
|
|
||||||
>lume-3.0.0-1.x86_64.rpm
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
(x86-64) Linux Flatpak: <a
|
|
||||||
href="https://github.com/lumehq/lume/releases/download/v3.0.0/lume_3.0.0_amd64.flatpak"
|
|
||||||
>lume_3.0.0_amd64.flatpak
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
(x86-64) Linux AppImage: <a
|
|
||||||
href="https://github.com/lumehq/lume/releases/download/v3.0.0/lume_3.0.0_amd64.AppImage"
|
|
||||||
>lume_3.0.0_amd64.AppImage
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Support for ARM, RISC-V and Loongarch architecture are coming soon.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="text-center">
|
|
||||||
<p class="text-sm text-neutral-500 dark:text-neutral-600">
|
|
||||||
Supported by <a
|
|
||||||
href="https://opensats.org"
|
|
||||||
target="_blank"
|
|
||||||
class="text-orange-500">Open Sats</a
|
|
||||||
> and Community
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export default {
|
|||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
mono: ["Geist Mono", ...defaultTheme.fontFamily.mono],
|
serif: ["Alice", ...defaultTheme.fontFamily.serif],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,17 +20,17 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "2.0.0-beta.7",
|
"@tauri-apps/api": "^2.0.0-beta.7",
|
||||||
"@tauri-apps/plugin-autostart": "2.0.0-beta.2",
|
"@tauri-apps/plugin-autostart": "2.0.0-beta.3",
|
||||||
"@tauri-apps/plugin-clipboard-manager": "2.1.0-beta.0",
|
"@tauri-apps/plugin-clipboard-manager": "2.1.0-beta.1",
|
||||||
"@tauri-apps/plugin-dialog": "2.0.0-beta.2",
|
"@tauri-apps/plugin-dialog": "2.0.0-beta.3",
|
||||||
"@tauri-apps/plugin-fs": "2.0.0-beta.2",
|
"@tauri-apps/plugin-fs": "2.0.0-beta.3",
|
||||||
"@tauri-apps/plugin-http": "2.0.0-beta.2",
|
"@tauri-apps/plugin-http": "2.0.0-beta.3",
|
||||||
"@tauri-apps/plugin-notification": "2.0.0-beta.2",
|
"@tauri-apps/plugin-notification": "2.0.0-beta.3",
|
||||||
"@tauri-apps/plugin-os": "2.0.0-beta.2",
|
"@tauri-apps/plugin-os": "2.0.0-beta.3",
|
||||||
"@tauri-apps/plugin-process": "2.0.0-beta.2",
|
"@tauri-apps/plugin-process": "2.0.0-beta.3",
|
||||||
"@tauri-apps/plugin-shell": "2.0.0-beta.2",
|
"@tauri-apps/plugin-shell": "2.0.0-beta.4",
|
||||||
"@tauri-apps/plugin-updater": "2.0.0-beta.2",
|
"@tauri-apps/plugin-updater": "2.0.0-beta.3",
|
||||||
"@tauri-apps/plugin-upload": "2.0.0-beta.3"
|
"@tauri-apps/plugin-upload": "2.0.0-beta.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,924 +0,0 @@
|
|||||||
import {
|
|
||||||
type Contact,
|
|
||||||
type Event,
|
|
||||||
type EventWithReplies,
|
|
||||||
type Interests,
|
|
||||||
type Keys,
|
|
||||||
Kind,
|
|
||||||
type LumeColumn,
|
|
||||||
type Metadata,
|
|
||||||
type Settings,
|
|
||||||
Relays,
|
|
||||||
} from "@lume/types";
|
|
||||||
import { generateContentTags } from "@lume/utils";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import type { WebviewWindow } from "@tauri-apps/api/webviewWindow";
|
|
||||||
import { open } from "@tauri-apps/plugin-dialog";
|
|
||||||
import { readFile } from "@tauri-apps/plugin-fs";
|
|
||||||
|
|
||||||
enum NSTORE_KEYS {
|
|
||||||
settings = "lume_user_settings",
|
|
||||||
interests = "lume_user_interests",
|
|
||||||
columns = "lume_user_columns",
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Ark {
|
|
||||||
public windows: WebviewWindow[];
|
|
||||||
public settings: Settings;
|
|
||||||
public accounts: string[];
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.windows = [];
|
|
||||||
this.settings = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async get_all_accounts() {
|
|
||||||
try {
|
|
||||||
const cmd: string[] = await invoke("get_accounts");
|
|
||||||
const accounts: string[] = cmd.map((item) => item.replace(".npub", ""));
|
|
||||||
|
|
||||||
if (!this.accounts) this.accounts = accounts;
|
|
||||||
|
|
||||||
return accounts;
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(String(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async load_selected_account(npub: string) {
|
|
||||||
try {
|
|
||||||
const cmd: boolean = await invoke("load_selected_account", {
|
|
||||||
npub,
|
|
||||||
});
|
|
||||||
return cmd;
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(String(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async nostr_connect(uri: string) {
|
|
||||||
try {
|
|
||||||
const remoteKey = uri.replace("bunker://", "").split("?")[0];
|
|
||||||
const npub: string = await invoke("to_npub", { hex: remoteKey });
|
|
||||||
|
|
||||||
if (npub) {
|
|
||||||
const connect: string = await invoke("nostr_connect", {
|
|
||||||
npub,
|
|
||||||
uri,
|
|
||||||
});
|
|
||||||
|
|
||||||
return connect;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(String(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async create_keys() {
|
|
||||||
try {
|
|
||||||
const cmd: Keys = await invoke("create_keys");
|
|
||||||
return cmd;
|
|
||||||
} catch (e) {
|
|
||||||
console.error(String(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async save_account(nsec: string, password = "") {
|
|
||||||
try {
|
|
||||||
const cmd: string = await invoke("save_key", {
|
|
||||||
nsec,
|
|
||||||
password,
|
|
||||||
});
|
|
||||||
|
|
||||||
return cmd;
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(String(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async event_to_bech32(id: string, relays: string[]) {
|
|
||||||
try {
|
|
||||||
const cmd: string = await invoke("event_to_bech32", {
|
|
||||||
id,
|
|
||||||
relays,
|
|
||||||
});
|
|
||||||
return cmd;
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(String(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async get_relays() {
|
|
||||||
try {
|
|
||||||
const cmd: Relays = await invoke("get_relays");
|
|
||||||
return cmd;
|
|
||||||
} catch (e) {
|
|
||||||
console.error(String(e));
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async add_relay(url: string) {
|
|
||||||
try {
|
|
||||||
const relayUrl = new URL(url);
|
|
||||||
|
|
||||||
if (relayUrl.protocol === "wss:" || relayUrl.protocol === "ws:") {
|
|
||||||
const cmd: boolean = await invoke("connect_relay", { relay: relayUrl });
|
|
||||||
return cmd;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(String(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async remove_relay(url: string) {
|
|
||||||
try {
|
|
||||||
const relayUrl = new URL(url);
|
|
||||||
|
|
||||||
if (relayUrl.protocol === "wss:" || relayUrl.protocol === "ws:") {
|
|
||||||
const cmd: boolean = await invoke("remove_relay", { relay: relayUrl });
|
|
||||||
return cmd;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(String(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async get_activities(account: string, kind: "1" | "6" | "9735" = "1") {
|
|
||||||
try {
|
|
||||||
const events: Event[] = await invoke("get_activities", { account, kind });
|
|
||||||
return events;
|
|
||||||
} catch (e) {
|
|
||||||
console.error(String(e));
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async get_event(id: string) {
|
|
||||||
try {
|
|
||||||
const eventId: string = id.replace("nostr:", "").replace(/[^\w\s]/gi, "");
|
|
||||||
const cmd: string = await invoke("get_event", { id: eventId });
|
|
||||||
const event: Event = JSON.parse(cmd);
|
|
||||||
return event;
|
|
||||||
} catch (e) {
|
|
||||||
console.error(id, String(e));
|
|
||||||
throw new Error(String(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async get_events_from(pubkey: string, limit: number, asOf?: number) {
|
|
||||||
try {
|
|
||||||
let until: string = undefined;
|
|
||||||
if (asOf && asOf > 0) until = asOf.toString();
|
|
||||||
|
|
||||||
const nostrEvents: Event[] = await invoke("get_events_from", {
|
|
||||||
publicKey: pubkey,
|
|
||||||
limit,
|
|
||||||
as_of: until,
|
|
||||||
});
|
|
||||||
|
|
||||||
return nostrEvents.sort((a, b) => b.created_at - a.created_at);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(String(e));
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async search(content: string, limit: number) {
|
|
||||||
try {
|
|
||||||
if (content.length < 1) return [];
|
|
||||||
|
|
||||||
const events: Event[] = await invoke("search", {
|
|
||||||
content: content.trim(),
|
|
||||||
limit,
|
|
||||||
});
|
|
||||||
|
|
||||||
return events;
|
|
||||||
} catch (e) {
|
|
||||||
console.info(String(e));
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async get_events(
|
|
||||||
limit: number,
|
|
||||||
asOf?: number,
|
|
||||||
contacts?: string[],
|
|
||||||
global?: boolean,
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
let until: string = undefined;
|
|
||||||
const isGlobal = global ?? false;
|
|
||||||
|
|
||||||
if (asOf && asOf > 0) until = asOf.toString();
|
|
||||||
|
|
||||||
const seenIds = new Set<string>();
|
|
||||||
const nostrEvents: Event[] = await invoke("get_events", {
|
|
||||||
limit,
|
|
||||||
until,
|
|
||||||
contacts,
|
|
||||||
global: isGlobal,
|
|
||||||
});
|
|
||||||
|
|
||||||
// remove duplicate event
|
|
||||||
for (const event of nostrEvents) {
|
|
||||||
if (event.kind === Kind.Repost) {
|
|
||||||
const repostId = event.tags.find((tag) => tag[0] === "e")?.[1];
|
|
||||||
seenIds.add(repostId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const eventIds = event.tags
|
|
||||||
.filter((el) => el[0] === "e")
|
|
||||||
?.map((item) => item[1]);
|
|
||||||
|
|
||||||
if (eventIds?.length) {
|
|
||||||
for (const id of eventIds) {
|
|
||||||
seenIds.add(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const events = nostrEvents
|
|
||||||
.filter((event) => !seenIds.has(event.id))
|
|
||||||
.sort((a, b) => b.created_at - a.created_at);
|
|
||||||
|
|
||||||
if (this.settings?.nsfw) {
|
|
||||||
return events.filter(
|
|
||||||
(event) =>
|
|
||||||
event.tags.filter((event) => event[0] === "content-warning")
|
|
||||||
.length > 0,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return events;
|
|
||||||
} catch (e) {
|
|
||||||
console.info(String(e));
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async get_events_from_interests(
|
|
||||||
hashtags: string[],
|
|
||||||
limit: number,
|
|
||||||
asOf?: number,
|
|
||||||
) {
|
|
||||||
let until: string = undefined;
|
|
||||||
if (asOf && asOf > 0) until = asOf.toString();
|
|
||||||
|
|
||||||
const seenIds = new Set<string>();
|
|
||||||
const dedupQueue = new Set<string>();
|
|
||||||
const nostrTags = hashtags.map((tag) => tag.replace("#", "").toLowerCase());
|
|
||||||
|
|
||||||
const nostrEvents: Event[] = await invoke("get_events_from_interests", {
|
|
||||||
hashtags: nostrTags,
|
|
||||||
limit,
|
|
||||||
until,
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const event of nostrEvents) {
|
|
||||||
const tags = event.tags
|
|
||||||
.filter((el) => el[0] === "e")
|
|
||||||
?.map((item) => item[1]);
|
|
||||||
|
|
||||||
if (tags.length) {
|
|
||||||
for (const tag of tags) {
|
|
||||||
if (seenIds.has(tag)) {
|
|
||||||
dedupQueue.add(event.id);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
seenIds.add(tag);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nostrEvents
|
|
||||||
.filter((event) => !dedupQueue.has(event.id))
|
|
||||||
.sort((a, b) => b.created_at - a.created_at);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async publish(
|
|
||||||
content: string,
|
|
||||||
reply_to?: string,
|
|
||||||
quote?: boolean,
|
|
||||||
nsfw?: boolean,
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const g = await generateContentTags(content);
|
|
||||||
|
|
||||||
const eventContent = g.content;
|
|
||||||
const eventTags = g.tags;
|
|
||||||
|
|
||||||
if (reply_to) {
|
|
||||||
const replyEvent = await this.get_event(reply_to);
|
|
||||||
const relayHint =
|
|
||||||
replyEvent.tags.find((ev) => ev[0] === "e")?.[0][2] ?? "";
|
|
||||||
|
|
||||||
if (quote) {
|
|
||||||
eventTags.push(["e", replyEvent.id, relayHint, "mention"]);
|
|
||||||
eventTags.push(["q", replyEvent.id]);
|
|
||||||
} else {
|
|
||||||
const rootEvent = replyEvent.tags.find((ev) => ev[3] === "root");
|
|
||||||
|
|
||||||
if (rootEvent) {
|
|
||||||
eventTags.push([
|
|
||||||
"e",
|
|
||||||
rootEvent[1],
|
|
||||||
rootEvent[2] || relayHint,
|
|
||||||
"root",
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
eventTags.push(["e", replyEvent.id, relayHint, "reply"]);
|
|
||||||
eventTags.push(["p", replyEvent.pubkey]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nsfw) {
|
|
||||||
eventTags.push(["L", "content-warning"]);
|
|
||||||
eventTags.push(["l", "reason", "content-warning"]);
|
|
||||||
eventTags.push(["content-warning", "nsfw"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const cmd: string = await invoke("publish", {
|
|
||||||
content: eventContent,
|
|
||||||
tags: eventTags,
|
|
||||||
});
|
|
||||||
|
|
||||||
return cmd;
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(String(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async reply_to(content: string, tags: string[]) {
|
|
||||||
try {
|
|
||||||
const cmd: string = await invoke("reply_to", { content, tags });
|
|
||||||
return cmd;
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(String(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async repost(id: string, author: string) {
|
|
||||||
try {
|
|
||||||
const cmd: string = await invoke("repost", { id, pubkey: author });
|
|
||||||
return cmd;
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(String(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async upvote(id: string, author: string) {
|
|
||||||
try {
|
|
||||||
const cmd: string = await invoke("upvote", { id, pubkey: author });
|
|
||||||
return cmd;
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(String(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async downvote(id: string, author: string) {
|
|
||||||
try {
|
|
||||||
const cmd: string = await invoke("downvote", { id, pubkey: author });
|
|
||||||
return cmd;
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(String(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async get_event_thread(id: string) {
|
|
||||||
try {
|
|
||||||
const events: EventWithReplies[] = await invoke("get_event_thread", {
|
|
||||||
id,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (events.length > 0) {
|
|
||||||
const replies = new Set();
|
|
||||||
for (const event of events) {
|
|
||||||
const tags = event.tags.filter(
|
|
||||||
(el) => el[0] === "e" && el[1] !== id && el[3] !== "mention",
|
|
||||||
);
|
|
||||||
if (tags.length > 0) {
|
|
||||||
for (const tag of tags) {
|
|
||||||
const rootIndex = events.findIndex((el) => el.id === tag[1]);
|
|
||||||
if (rootIndex !== -1) {
|
|
||||||
const rootEvent = events[rootIndex];
|
|
||||||
if (rootEvent?.replies) {
|
|
||||||
rootEvent.replies.push(event);
|
|
||||||
} else {
|
|
||||||
rootEvent.replies = [event];
|
|
||||||
}
|
|
||||||
replies.add(event.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const cleanEvents = events.filter((ev) => !replies.has(ev.id));
|
|
||||||
return cleanEvents;
|
|
||||||
}
|
|
||||||
|
|
||||||
return events;
|
|
||||||
} catch (e) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public parse_event_thread(tags: string[][]) {
|
|
||||||
let root: string = null;
|
|
||||||
let reply: string = null;
|
|
||||||
|
|
||||||
// Get all event references from tags, ignore mention
|
|
||||||
const events = tags.filter((el) => el[0] === "e" && el[3] !== "mention");
|
|
||||||
|
|
||||||
if (events.length === 1) {
|
|
||||||
root = events[0][1];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (events.length > 1) {
|
|
||||||
root = events.find((el) => el[3] === "root")?.[1] ?? events[0][1];
|
|
||||||
reply = events.find((el) => el[3] === "reply")?.[1] ?? events[1][1];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fix some rare case when root === reply
|
|
||||||
if (root && reply && root === reply) {
|
|
||||||
reply = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
root,
|
|
||||||
reply,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public async get_profile(pubkey: string) {
|
|
||||||
try {
|
|
||||||
const id = pubkey.replace("nostr:", "").replace(/[^\w\s]/gi, "");
|
|
||||||
const cmd: Metadata = await invoke("get_profile", { id });
|
|
||||||
|
|
||||||
return cmd;
|
|
||||||
} catch (e) {
|
|
||||||
console.error(pubkey, String(e));
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async get_current_user_profile() {
|
|
||||||
try {
|
|
||||||
const cmd: Metadata = await invoke("get_current_user_profile");
|
|
||||||
return cmd;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async create_profile(profile: Metadata) {
|
|
||||||
try {
|
|
||||||
const event: string = await invoke("create_profile", {
|
|
||||||
name: profile.name || "",
|
|
||||||
display_name: profile.display_name || "",
|
|
||||||
displayName: profile.display_name || "",
|
|
||||||
about: profile.about || "",
|
|
||||||
picture: profile.picture || "",
|
|
||||||
banner: profile.banner || "",
|
|
||||||
nip05: profile.nip05 || "",
|
|
||||||
lud16: profile.lud16 || "",
|
|
||||||
website: profile.website || "",
|
|
||||||
});
|
|
||||||
return event;
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(String(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async get_contact_list() {
|
|
||||||
try {
|
|
||||||
const cmd: string[] = await invoke("get_contact_list");
|
|
||||||
return cmd;
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async follow(id: string, alias?: string) {
|
|
||||||
try {
|
|
||||||
const cmd: string = await invoke("follow", { id, alias });
|
|
||||||
return cmd;
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(String(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async unfollow(id: string) {
|
|
||||||
try {
|
|
||||||
const cmd: string = await invoke("unfollow", { id });
|
|
||||||
return cmd;
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(String(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async user_to_bech32(key: string, relays: string[]) {
|
|
||||||
try {
|
|
||||||
const cmd: string = await invoke("user_to_bech32", {
|
|
||||||
key,
|
|
||||||
relays,
|
|
||||||
});
|
|
||||||
return cmd;
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(String(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async verify_nip05(pubkey: string, nip05: string) {
|
|
||||||
try {
|
|
||||||
const cmd: boolean = await invoke("verify_nip05", {
|
|
||||||
key: pubkey,
|
|
||||||
nip05,
|
|
||||||
});
|
|
||||||
return cmd;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async set_nwc(uri: string) {
|
|
||||||
try {
|
|
||||||
const cmd: boolean = await invoke("set_nwc", { uri });
|
|
||||||
return cmd;
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(String(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async load_nwc() {
|
|
||||||
try {
|
|
||||||
const cmd: boolean = await invoke("load_nwc");
|
|
||||||
return cmd;
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(String(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async get_balance() {
|
|
||||||
try {
|
|
||||||
const cmd: number = await invoke("get_balance");
|
|
||||||
return cmd;
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(String(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async zap_profile(id: string, amount: number, message: string) {
|
|
||||||
try {
|
|
||||||
const cmd: boolean = await invoke("zap_profile", { id, amount, message });
|
|
||||||
return cmd;
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(String(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async zap_event(id: string, amount: number, message: string) {
|
|
||||||
try {
|
|
||||||
const cmd: boolean = await invoke("zap_event", { id, amount, message });
|
|
||||||
return cmd;
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(String(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async upload(filePath?: string) {
|
|
||||||
const allowExts = [
|
|
||||||
"png",
|
|
||||||
"jpeg",
|
|
||||||
"jpg",
|
|
||||||
"gif",
|
|
||||||
"mp4",
|
|
||||||
"mp3",
|
|
||||||
"webm",
|
|
||||||
"mkv",
|
|
||||||
"avi",
|
|
||||||
"mov",
|
|
||||||
];
|
|
||||||
|
|
||||||
const selected =
|
|
||||||
filePath ||
|
|
||||||
(
|
|
||||||
await open({
|
|
||||||
multiple: false,
|
|
||||||
filters: [
|
|
||||||
{
|
|
||||||
name: "Media",
|
|
||||||
extensions: allowExts,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
).path;
|
|
||||||
|
|
||||||
// User cancelled action
|
|
||||||
if (!selected) return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const file = await readFile(selected);
|
|
||||||
const blob = new Blob([file]);
|
|
||||||
|
|
||||||
const data = new FormData();
|
|
||||||
data.append("fileToUpload", blob);
|
|
||||||
data.append("submit", "Upload Image");
|
|
||||||
|
|
||||||
const res = await fetch("https://nostr.build/api/v2/upload/files", {
|
|
||||||
method: "POST",
|
|
||||||
body: data,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) return null;
|
|
||||||
|
|
||||||
const json = await res.json();
|
|
||||||
const content = json.data[0];
|
|
||||||
|
|
||||||
return content.url as string;
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(String(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async get_columns() {
|
|
||||||
try {
|
|
||||||
const cmd: string = await invoke("get_nstore", {
|
|
||||||
key: NSTORE_KEYS.columns,
|
|
||||||
});
|
|
||||||
const columns: LumeColumn[] = cmd ? JSON.parse(cmd) : [];
|
|
||||||
return columns;
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async set_columns(columns: LumeColumn[]) {
|
|
||||||
try {
|
|
||||||
const cmd: string = await invoke("set_nstore", {
|
|
||||||
key: NSTORE_KEYS.columns,
|
|
||||||
content: JSON.stringify(columns),
|
|
||||||
});
|
|
||||||
return cmd;
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async get_settings() {
|
|
||||||
try {
|
|
||||||
if (this.settings) return this.settings;
|
|
||||||
|
|
||||||
const cmd: string = await invoke("get_nstore", {
|
|
||||||
key: NSTORE_KEYS.settings,
|
|
||||||
});
|
|
||||||
const settings: Settings = cmd ? JSON.parse(cmd) : null;
|
|
||||||
|
|
||||||
this.settings = settings;
|
|
||||||
|
|
||||||
return settings;
|
|
||||||
} catch {
|
|
||||||
const defaultSettings: Settings = {
|
|
||||||
autoUpdate: false,
|
|
||||||
enhancedPrivacy: false,
|
|
||||||
notification: false,
|
|
||||||
zap: false,
|
|
||||||
nsfw: false,
|
|
||||||
};
|
|
||||||
this.settings = defaultSettings;
|
|
||||||
return defaultSettings;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async set_settings(settings: Settings) {
|
|
||||||
try {
|
|
||||||
const cmd: string = await invoke("set_nstore", {
|
|
||||||
key: NSTORE_KEYS.settings,
|
|
||||||
content: JSON.stringify(settings),
|
|
||||||
});
|
|
||||||
return cmd;
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async get_interest() {
|
|
||||||
try {
|
|
||||||
const cmd: string = await invoke("get_nstore", {
|
|
||||||
key: NSTORE_KEYS.interests,
|
|
||||||
});
|
|
||||||
const interests: Interests = cmd ? JSON.parse(cmd) : null;
|
|
||||||
return interests;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async set_interest(
|
|
||||||
words: string[],
|
|
||||||
users: string[],
|
|
||||||
hashtags: string[],
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const interests: Interests = {
|
|
||||||
words: words ?? [],
|
|
||||||
users: users ?? [],
|
|
||||||
hashtags: hashtags ?? [],
|
|
||||||
};
|
|
||||||
const cmd: string = await invoke("set_nstore", {
|
|
||||||
key: NSTORE_KEYS.interests,
|
|
||||||
content: JSON.stringify(interests),
|
|
||||||
});
|
|
||||||
return cmd;
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(String(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async get_nstore(key: string) {
|
|
||||||
try {
|
|
||||||
const cmd: string = await invoke("get_nstore", {
|
|
||||||
key,
|
|
||||||
});
|
|
||||||
const parse: string | string[] = cmd ? JSON.parse(cmd) : null;
|
|
||||||
if (!parse.length) return null;
|
|
||||||
return parse;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async set_nstore(key: string, content: string) {
|
|
||||||
try {
|
|
||||||
const cmd: string = await invoke("set_nstore", {
|
|
||||||
key,
|
|
||||||
content,
|
|
||||||
});
|
|
||||||
return cmd;
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(String(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async open_event_id(id: string) {
|
|
||||||
try {
|
|
||||||
const label = `event-${id}`;
|
|
||||||
const url = `/events/${id}`;
|
|
||||||
|
|
||||||
await invoke("open_window", {
|
|
||||||
label,
|
|
||||||
title: "Thread",
|
|
||||||
url,
|
|
||||||
width: 500,
|
|
||||||
height: 800,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(String(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async open_event(event: Event) {
|
|
||||||
try {
|
|
||||||
let root: string = undefined;
|
|
||||||
let reply: string = undefined;
|
|
||||||
|
|
||||||
const eTags = event.tags.filter(
|
|
||||||
(tag) => tag[0] === "e" || tag[0] === "q",
|
|
||||||
);
|
|
||||||
|
|
||||||
root = eTags.find((el) => el[3] === "root")?.[1];
|
|
||||||
reply = eTags.find((el) => el[3] === "reply")?.[1];
|
|
||||||
|
|
||||||
if (!root) root = eTags[0]?.[1];
|
|
||||||
if (!reply) reply = eTags[1]?.[1];
|
|
||||||
|
|
||||||
const label = `event-${event.id}`;
|
|
||||||
const url = `/events/${root ?? reply ?? event.id}`;
|
|
||||||
|
|
||||||
await invoke("open_window", {
|
|
||||||
label,
|
|
||||||
title: "Thread",
|
|
||||||
url,
|
|
||||||
width: 500,
|
|
||||||
height: 800,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(String(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async open_profile(pubkey: string) {
|
|
||||||
try {
|
|
||||||
const label = `user-${pubkey}`;
|
|
||||||
await invoke("open_window", {
|
|
||||||
label,
|
|
||||||
title: "Profile",
|
|
||||||
url: `/users/${pubkey}`,
|
|
||||||
width: 500,
|
|
||||||
height: 800,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(String(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async open_editor(reply_to?: string, quote = false) {
|
|
||||||
try {
|
|
||||||
let url: string;
|
|
||||||
|
|
||||||
if (reply_to) {
|
|
||||||
url = `/editor?reply_to=${reply_to}"e=${quote}`;
|
|
||||||
} else {
|
|
||||||
url = "/editor";
|
|
||||||
}
|
|
||||||
|
|
||||||
const label = `editor-${reply_to ? reply_to : 0}`;
|
|
||||||
|
|
||||||
await invoke("open_window", {
|
|
||||||
label,
|
|
||||||
title: "Editor",
|
|
||||||
url,
|
|
||||||
width: 560,
|
|
||||||
height: 340,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(String(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async open_nwc() {
|
|
||||||
try {
|
|
||||||
const label = "nwc";
|
|
||||||
await invoke("open_window", {
|
|
||||||
label,
|
|
||||||
title: "Nostr Wallet Connect",
|
|
||||||
url: "/nwc",
|
|
||||||
width: 400,
|
|
||||||
height: 600,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(String(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async open_zap(id: string, pubkey: string, account: string) {
|
|
||||||
try {
|
|
||||||
const label = `zap-${id}`;
|
|
||||||
await invoke("open_window", {
|
|
||||||
label,
|
|
||||||
title: "Zap",
|
|
||||||
url: `/zap/${id}?pubkey=${pubkey}&account=${account}`,
|
|
||||||
width: 400,
|
|
||||||
height: 500,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(String(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async open_settings() {
|
|
||||||
try {
|
|
||||||
const label = "settings";
|
|
||||||
await invoke("open_window", {
|
|
||||||
label,
|
|
||||||
title: "Settings",
|
|
||||||
url: "/settings",
|
|
||||||
width: 800,
|
|
||||||
height: 500,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(String(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async open_search() {
|
|
||||||
try {
|
|
||||||
const label = "search";
|
|
||||||
await invoke("open_window", {
|
|
||||||
label,
|
|
||||||
title: "Search",
|
|
||||||
url: "/search",
|
|
||||||
width: 400,
|
|
||||||
height: 600,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(String(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async open_activity(account: string) {
|
|
||||||
try {
|
|
||||||
const label = "activity";
|
|
||||||
await invoke("open_window", {
|
|
||||||
label,
|
|
||||||
title: "Activity",
|
|
||||||
url: `/activity/${account}/texts`,
|
|
||||||
width: 400,
|
|
||||||
height: 600,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(String(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
|
|
||||||
export function usePreview(url: string) {
|
|
||||||
const { isLoading, isError, data } = useQuery({
|
|
||||||
queryKey: ["url", url],
|
|
||||||
queryFn: async () => {
|
|
||||||
try {
|
|
||||||
const cmd = await invoke("fetch_opg", { url });
|
|
||||||
console.log(cmd);
|
|
||||||
return cmd;
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(e);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
refetchOnMount: false,
|
|
||||||
refetchOnReconnect: false,
|
|
||||||
staleTime: Number.POSITIVE_INFINITY,
|
|
||||||
retry: 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { isLoading, isError, data };
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export * from "./ark";
|
|
||||||
export * from "./hooks/useEvent";
|
|
||||||
export * from "./hooks/useProfile";
|
|
||||||