Compare commits

...
28 Commits
Author SHA1 Message Date
reya 72da83d648 feat: add force quit command 2024-07-02 17:24:55 +07:00
reya 783a4538a4 Revert "chore: update ci"
This reverts commit 04706a6d7c.
2024-07-02 15:54:00 +07:00
reya 15e62cad11 fix: temporary include devtools in release build 2024-07-02 14:58:00 +07:00
reya c52b20ca80 chore: bump version 2024-07-02 14:36:16 +07:00
reya 04706a6d7c chore: update ci 2024-07-02 14:07:12 +07:00
reya 0755cbeb6c chore: bump version 2024-07-02 13:03:45 +07:00
雨宮蓮andGitHub 8eb01c8bbf Improve column management (#221)
* wip: redesign store

* feat: update trending column

* feat: add more functions
2024-07-02 12:51:50 +07:00
reya ed4f89ff66 feat: add option to toggle window transparent 2024-07-02 08:49:52 +07:00
reya d9fe647f8e feat: few improvements 2024-07-01 14:41:33 +07:00
reya 843c2d52e7 refactor: tray panel 2024-07-01 13:04:32 +07:00
reya 017a3676a4 feat: optimize spawn thread 2024-06-30 21:06:04 +07:00
reya fcb70c0e9a chore: update deps 2024-06-30 14:37:26 +07:00
雨宮蓮andGitHub 0fec21b9ce Some improments and Negentropy (#219)
* feat: adjust default window size

* feat: save window state

* feat: add window state plugin

* feat: add search

* feat: use negentropy for newsfeed

* feat: live feeds

* feat: add search user
2024-06-30 14:26:02 +07:00
XIAO YUandGitHub 968b1ada94 refactor: improve relay management code structure (#220) 2024-06-29 07:41:16 +07:00
reya 5c9b599b1e feat: use memo for some components 2024-06-26 17:49:36 +07:00
雨宮蓮andGitHub 717c3e17df Event Subscriptions (#218)
* feat: improve create column command

* refactor: thread

* feat: add window virtualized to event screen

* chore: update deps

* fix: window decoration

* feat: improve mention ntoe

* feat: add subscription to event screen
2024-06-26 14:51:50 +07:00
XIAO YUandGitHub a4540a0802 refactor: improve error handling in event.rs (#217) 2024-06-26 07:57:53 +07:00
reya 31bacc2646 chore: remove unused code 2024-06-24 11:09:32 +07:00
reya 6e5d0f0e76 chore: bump version 2024-06-24 10:06:12 +07:00
XIAO YUandGitHub f0712e5961 refactor: improve error handling (#216) 2024-06-24 07:19:36 +07:00
雨宮蓮andGitHub 3fbd66dece Add bitcoin connect (#215)
* feat: add bitcoin connect

* feat: improve zap screen
2024-06-21 14:56:10 +07:00
reya 1283432632 feat: use native feature instead of react 2024-06-21 10:24:09 +07:00
reya 59eaaec903 feat: support content warning 2024-06-21 08:57:49 +07:00
雨宮蓮andGitHub 4f0f210076 Add lazy carousel (#214)
* refactor: carousel

* feat: improve image carousel
2024-06-21 07:51:11 +07:00
reya e4a317f038 chore: bump version 2024-06-20 13:23:05 +07:00
reya 9779d020c7 feat: improve list virtualization 2024-06-20 13:22:28 +07:00
雨宮蓮andGitHub f8280ec8ee fix: get replies function (#213) 2024-06-19 21:02:33 +07:00
XIAO YUandGitHub 6c26f8967b chore: Refactor code for better performance and reliability (#212) 2024-06-19 14:36:59 +07:00
125 changed files with 9477 additions and 7565 deletions
+1 -1
View File
@@ -35,4 +35,4 @@ dist/
.DS_Store .DS_Store
*.pem *.pem
.vscode/ .vscode/
ndb/ .idea/
-5
View File
@@ -1,5 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
-5
View File
@@ -1,5 +0,0 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
</state>
</component>
-46
View File
@@ -1,46 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="CheckEmptyScriptTag" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="CheckValidXmlInScriptTagBody" enabled="false" level="ERROR" enabled_by_default="false" />
<inspection_tool class="GithubFunctionSignatureValidation" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="GrazieInspection" enabled="false" level="GRAMMAR_ERROR" enabled_by_default="false" />
<inspection_tool class="HtmlExtraClosingTag" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="HtmlMissingClosingTag" enabled="false" level="INFORMATION" enabled_by_default="false" />
<inspection_tool class="HtmlUnknownAnchorTarget" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="HtmlUnknownAttribute" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="HtmlUnknownBooleanAttribute" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="HtmlUnknownTag" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="HtmlUnknownTarget" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="HtmlWrongAttributeValue" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="HttpClientInappropriateProtocolUsageInspection" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
<inspection_tool class="HttpClientUnresolvedAuthId" enabled="false" level="ERROR" enabled_by_default="false" />
<inspection_tool class="HttpClientUnresolvedVariable" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="HttpRequestContentLengthIsIgnored" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="HttpRequestEnvironmentAuthConfigurationValidationInspection" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="HttpRequestPlaceholder" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="HttpRequestRequestSeparatorJsonBodyInspection" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
<inspection_tool class="HttpRequestRequestSeparatorXmlBodyInspection" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
<inspection_tool class="HttpRequestRequestSeparatorYamlBodyInspection" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
<inspection_tool class="HttpRequestWhitespaceInsideRequestTargetPath" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
<inspection_tool class="IncorrectHttpHeaderInspection" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="LanguageDetectionInspection" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="MandatoryParamsAbsent" enabled="false" level="ERROR" enabled_by_default="false" />
<inspection_tool class="MarkdownIncorrectTableFormatting" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
<inspection_tool class="MarkdownIncorrectlyNumberedListItem" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="MarkdownLinkDestinationWithSpaces" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="MarkdownNoTableBorders" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="MarkdownOutdatedTableOfContents" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="MarkdownUnresolvedFileReference" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="MarkdownUnresolvedHeaderReference" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="MarkdownUnresolvedLinkLabel" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="RequiredAttributes" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="SpellCheckingInspection" enabled="false" level="TYPO" enabled_by_default="false">
<option name="processCode" value="true" />
<option name="processLiterals" value="true" />
<option name="processComments" value="true" />
</inspection_tool>
<inspection_tool class="UndefinedAction" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="UndefinedParamsPresent" enabled="false" level="WARNING" enabled_by_default="false" />
</profile>
</component>
-17
View File
@@ -1,17 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="EMPTY_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src-tauri/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/.github" />
<excludeFolder url="file://$MODULE_DIR$/.turbo" />
<excludeFolder url="file://$MODULE_DIR$/apps" />
<excludeFolder url="file://$MODULE_DIR$/flatpak" />
<excludeFolder url="file://$MODULE_DIR$/node_modules" />
<excludeFolder url="file://$MODULE_DIR$/packages" />
<excludeFolder url="file://$MODULE_DIR$/src-tauri/target" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/lume.iml" filepath="$PROJECT_DIR$/.idea/lume.iml" />
</modules>
</component>
</project>
Generated
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+11 -11
View File
@@ -1,14 +1,14 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lume Desktop</title> <title>Lume Desktop</title>
</head> </head>
<body <body
class="relative h-screen w-screen cursor-default select-none overflow-hidden font-sans text-black antialiased dark:text-white" class="relative h-screen w-screen cursor-default select-none overflow-hidden font-sans text-black antialiased dark:text-white"
> >
<div id="root" class="h-full w-full"></div> <div id="root" class="h-full w-full"></div>
<script type="module" src="/src/app.tsx"></script> <script type="module" src="/src/app.tsx"></script>
</body> </body>
</html> </html>
+16 -20
View File
@@ -9,23 +9,21 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@getalby/bitcoin-connect-react": "^3.5.3",
"@lume/icons": "workspace:^", "@lume/icons": "workspace:^",
"@lume/system": "workspace:^", "@lume/system": "workspace:^",
"@lume/ui": "workspace:^", "@lume/ui": "workspace:^",
"@lume/utils": "workspace:^", "@lume/utils": "workspace:^",
"@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-checkbox": "^1.1.1",
"@radix-ui/react-collapsible": "^1.0.3", "@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-hover-card": "^1.0.7", "@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-tooltip": "^1.1.2",
"@radix-ui/react-switch": "^1.0.3", "@tanstack/query-persist-client-core": "^5.49.1",
"@radix-ui/react-tabs": "^1.0.4", "@tanstack/react-query": "^5.49.2",
"@radix-ui/react-tooltip": "^1.0.7", "@tanstack/react-router": "^1.43.3",
"@tanstack/query-persist-client-core": "^5.45.0",
"@tanstack/react-query": "^5.45.0",
"@tanstack/react-router": "^1.38.1",
"embla-carousel-react": "^8.1.5", "embla-carousel-react": "^8.1.5",
"i18next": "^23.11.5", "i18next": "^23.11.5",
"i18next-resources-to-backend": "^1.2.1", "i18next-resources-to-backend": "^1.2.1",
@@ -36,12 +34,10 @@
"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.52.0", "react-hook-form": "^7.52.0",
"react-hotkeys-hook": "^4.5.0",
"react-i18next": "^14.1.2", "react-i18next": "^14.1.2",
"react-string-replace": "^1.1.1", "react-string-replace": "^1.1.1",
"slate": "^0.103.0", "slate": "^0.103.0",
"slate-react": "^0.105.0", "slate-react": "^0.105.0",
"sonner": "^1.5.0",
"use-debounce": "^10.0.1", "use-debounce": "^10.0.1",
"virtua": "^0.31.0" "virtua": "^0.31.0"
}, },
@@ -49,16 +45,16 @@
"@lume/tailwindcss": "workspace:^", "@lume/tailwindcss": "workspace:^",
"@lume/tsconfig": "workspace:^", "@lume/tsconfig": "workspace:^",
"@lume/types": "workspace:^", "@lume/types": "workspace:^",
"@tanstack/router-devtools": "^1.38.1", "@tanstack/router-devtools": "^1.43.3",
"@tanstack/router-vite-plugin": "^1.38.0", "@tanstack/router-vite-plugin": "^1.43.1",
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@vitejs/plugin-react-swc": "^3.7.0", "@vitejs/plugin-react-swc": "^3.7.0",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"postcss": "^8.4.38", "postcss": "^8.4.39",
"tailwindcss": "^3.4.4", "tailwindcss": "^3.4.4",
"typescript": "^5.4.5", "typescript": "^5.5.2",
"vite": "^5.3.1", "vite": "^5.3.2",
"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"
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 17 KiB

-12
View File
@@ -2,18 +2,6 @@
@tailwind utilities; @tailwind utilities;
@tailwind components; @tailwind components;
*::-webkit-scrollbar {
@apply w-[5px];
}
*::-webkit-scrollbar-track {
@apply bg-transparent;
}
*::-webkit-scrollbar-thumb {
@apply rounded bg-black dark:bg-white;
}
@layer utilities { @layer utilities {
.content-break { .content-break {
word-break: break-word; word-break: break-word;
+6 -17
View File
@@ -1,30 +1,19 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createRouter } from "@tanstack/react-router"; import { RouterProvider, createRouter } from "@tanstack/react-router";
import React, { StrictMode } from "react";
import ReactDOM from "react-dom/client";
import { I18nextProvider } from "react-i18next";
import "./app.css";
import { type } from "@tauri-apps/plugin-os"; import { type } from "@tauri-apps/plugin-os";
import i18n from "./locale"; import { StrictMode } from "react";
import ReactDOM from "react-dom/client";
import { routeTree } from "./router.gen"; // auto generated file import { routeTree } from "./router.gen"; // auto generated file
import "./app.css";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
const os = await type(); const platform = type();
// Set up a Router instance
const router = createRouter({ const router = createRouter({
routeTree, routeTree,
context: { context: { queryClient, platform },
queryClient,
platform: os,
},
Wrap: ({ children }) => { Wrap: ({ children }) => {
return ( return (
<I18nextProvider i18n={i18n} defaultNS={"translation"}> <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</I18nextProvider>
); );
}, },
}); });
@@ -1,13 +1,13 @@
import { NostrQuery } from "@lume/system"; 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 { message } from "@tauri-apps/plugin-dialog";
import { import {
type Dispatch, type Dispatch,
type ReactNode, type ReactNode,
type SetStateAction, type SetStateAction,
useState, useState,
} from "react"; } from "react";
import { toast } from "sonner";
export function AvatarUploader({ export function AvatarUploader({
setPicture, setPicture,
@@ -27,7 +27,7 @@ export function AvatarUploader({
setPicture(image); setPicture(image);
} catch (e) { } catch (e) {
setLoading(false); setLoading(false);
toast.error(String(e)); await message(String(e), { title: "Lume", kind: "error" });
} }
}; };
-41
View File
@@ -1,41 +0,0 @@
import { User } from "@/components/user";
import { NostrAccount } from "@lume/system";
import { getBitcoinDisplayValues } from "@lume/utils";
import { useEffect, useMemo, useState } from "react";
export function Balance({ account }: { account: string }) {
const [balance, setBalance] = useState(0);
const value = useMemo(() => getBitcoinDisplayValues(balance), [balance]);
useEffect(() => {
async function getBalance() {
const val = await NostrAccount.getBalance();
setBalance(val);
}
getBalance();
}, []);
return (
<div
data-tauri-drag-region
className="flex h-16 items-center justify-end px-3"
>
<div className="flex items-center gap-2">
<div className="text-end">
<div className="text-sm leading-tight text-neutral-700 dark:text-neutral-300">
Your balance
</div>
<div className="font-medium leading-tight">
{value.bitcoinFormatted}
</div>
</div>
<User.Provider pubkey={account}>
<User.Root>
<User.Avatar className="size-9 rounded-full" />
</User.Root>
</User.Provider>
</div>
</div>
);
}
+77 -28
View File
@@ -1,17 +1,17 @@
import { CancelIcon, CheckIcon } from "@lume/icons"; import { CheckIcon, HorizontalDotsIcon } from "@lume/icons";
import type { LumeColumn } from "@lume/types"; import type { LumeColumn } from "@lume/types";
import { cn } from "@lume/utils";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
import { getCurrent } from "@tauri-apps/api/webviewWindow"; import { getCurrent } from "@tauri-apps/api/webviewWindow";
import { useCallback, useEffect, useRef, useState } from "react"; import { memo, useCallback, useEffect, useRef, useState } from "react";
type WindowEvent = { type WindowEvent = {
scroll: boolean; scroll: boolean;
resize: boolean; resize: boolean;
}; };
export function Column({ export const Column = memo(function Column({
column, column,
account, account,
}: { }: {
@@ -44,7 +44,7 @@ export function Column({
useEffect(() => { useEffect(() => {
if (!isCreated) return; if (!isCreated) return;
const unlisten = listen<WindowEvent>("child-webview", (data) => { const unlisten = listen<WindowEvent>("child_webview", (data) => {
if (data.payload.scroll) repositionWebview(); if (data.payload.scroll) repositionWebview();
if (data.payload.resize) repositionWebview().then(() => resizeWebview()); if (data.payload.resize) repositionWebview().then(() => resizeWebview());
}); });
@@ -60,15 +60,17 @@ export function Column({
const rect = container.current.getBoundingClientRect(); const rect = container.current.getBoundingClientRect();
const url = `${column.content}?account=${account}&label=${column.label}&name=${column.name}`; const url = `${column.content}?account=${account}&label=${column.label}&name=${column.name}`;
// create new webview const prop = {
invoke("create_column", {
label: webviewLabel, label: webviewLabel,
x: rect.x, x: rect.x,
y: rect.y, y: rect.y,
width: rect.width, width: rect.width,
height: rect.height, height: rect.height,
url, url,
}).then(() => { };
// create new webview
invoke("create_column", { column: prop }).then(() => {
console.log("created: ", webviewLabel); console.log("created: ", webviewLabel);
setIsCreated(true); setIsCreated(true);
}); });
@@ -82,23 +84,24 @@ export function Column({
}, [account]); }, [account]);
return ( return (
<div className="h-full w-[500px] shrink-0 p-2"> <div className="h-full w-[480px] shrink-0 p-2">
<div <div className="flex flex-col w-full h-full rounded-xl bg-black/5 dark:bg-white/10">
className={cn( <Header
"flex flex-col w-full h-full rounded-xl", label={column.label}
column.label !== "open" webview={webviewLabel}
? "bg-black/5 dark:bg-white/5 backdrop-blur-sm" name={column.name}
: "", />
)}
>
<Header label={column.label} name={column.name} />
<div ref={container} className="flex-1 w-full h-full" /> <div ref={container} className="flex-1 w-full h-full" />
</div> </div>
</div> </div>
); );
} });
function Header({ label, name }: { label: string; name: string }) { function Header({
label,
webview,
name,
}: { label: string; webview: string; name: string }) {
const [title, setTitle] = useState(name); const [title, setTitle] = useState(name);
const [isChanged, setIsChanged] = useState(false); const [isChanged, setIsChanged] = useState(false);
@@ -114,10 +117,56 @@ function Header({ label, name }: { label: string; name: string }) {
setIsChanged(false); setIsChanged(false);
}; };
const close = async () => { const showContextMenu = useCallback(async (e: React.MouseEvent) => {
const mainWindow = getCurrent(); e.preventDefault();
await mainWindow.emit("columns", { type: "remove", label });
}; const menuItems = await Promise.all([
MenuItem.new({
text: "Reload",
action: async () => {
await invoke("reload_column", { label: webview });
},
}),
MenuItem.new({
text: "Open in new window",
action: () => console.log("not implemented."),
}),
PredefinedMenuItem.new({ item: "Separator" }),
MenuItem.new({
text: "Move left",
action: async () => {
await getCurrent().emit("columns", {
type: "move",
label,
direction: "left",
});
},
}),
MenuItem.new({
text: "Move right",
action: async () => {
await getCurrent().emit("columns", {
type: "move",
label,
direction: "right",
});
},
}),
PredefinedMenuItem.new({ item: "Separator" }),
MenuItem.new({
text: "Close",
action: async () => {
await getCurrent().emit("columns", { type: "remove", label });
},
}),
]);
const menu = await Menu.new({
items: menuItems,
});
await menu.popup().catch((e) => console.error(e));
}, []);
useEffect(() => { useEffect(() => {
if (title.length !== name.length) setIsChanged(true); if (title.length !== name.length) setIsChanged(true);
@@ -126,7 +175,7 @@ function Header({ label, name }: { label: string; name: string }) {
return ( return (
<div className="flex items-center justify-between w-full px-1 h-9 shrink-0"> <div className="flex items-center justify-between w-full px-1 h-9 shrink-0">
<div className="size-7" /> <div className="size-7" />
<div className="flex items-center justify-center shrink-0 h-9"> <div className="flex items-center justify-center shrink-0 h-7">
<div className="relative flex items-center gap-2"> <div className="relative flex items-center gap-2">
<div <div
contentEditable contentEditable
@@ -149,10 +198,10 @@ function Header({ label, name }: { label: string; name: string }) {
</div> </div>
<button <button
type="button" type="button"
onClick={() => close()} onClick={(e) => showContextMenu(e)}
className="inline-flex items-center justify-center rounded-lg size-7 hover:bg-black/10 dark:hover:bg-white/10 text-neutral-600 dark:text-neutral-400 hover:text-neutral-800 dark:hover:text-neutral-200" className="inline-flex items-center justify-center rounded-lg size-7 hover:bg-black/10 dark:hover:bg-white/10"
> >
<CancelIcon className="size-4" /> <HorizontalDotsIcon className="size-5" />
</button> </button>
</div> </div>
); );
@@ -1,10 +1,10 @@
import { ThreadIcon } from "@lume/icons";
import { Note } from "@/components/note"; import { Note } from "@/components/note";
import { cn } from "@lume/utils"; import { ThreadIcon } from "@lume/icons";
import type { LumeEvent } from "@lume/system"; import type { LumeEvent } from "@lume/system";
import { useMemo } from "react"; import { cn } from "@lume/utils";
import { memo, useMemo } from "react";
export function Conversation({ export const Conversation = memo(function Conversation({
event, event,
className, className,
}: { }: {
@@ -17,7 +17,7 @@ export function Conversation({
<Note.Provider event={event}> <Note.Provider event={event}>
<Note.Root <Note.Root
className={cn( className={cn(
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl flex flex-col gap-3 shadow-primary dark:ring-1 ring-neutral-800/50", "bg-white dark:bg-black/20 rounded-xl flex flex-col gap-3 shadow-primary dark:ring-1 ring-neutral-800/50",
className, className,
)} )}
> >
@@ -44,4 +44,4 @@ export function Conversation({
</Note.Root> </Note.Root>
</Note.Provider> </Note.Provider>
); );
} });
@@ -17,7 +17,7 @@ export function NoteReply({ large = false }: { large?: boolean }) {
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
? "rounded-full bg-neutral-100 dark:bg-white/10 h-7 gap-1.5 w-24 text-sm font-medium hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20" ? "rounded-full h-7 gap-1.5 w-20 text-sm font-medium hover:bg-black/10 dark:hover:bg-white/10"
: "size-7", : "size-7",
)} )}
> >
@@ -4,8 +4,8 @@ import { Spinner } from "@lume/ui";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router"; import { useRouteContext } from "@tanstack/react-router";
import { Menu, MenuItem } from "@tauri-apps/api/menu"; import { Menu, MenuItem } from "@tauri-apps/api/menu";
import { message } from "@tauri-apps/plugin-dialog";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { toast } from "sonner";
import { useNoteContext } from "../provider"; import { useNoteContext } from "../provider";
export function NoteRepost({ large = false }: { large?: boolean }) { export function NoteRepost({ large = false }: { large?: boolean }) {
@@ -27,12 +27,12 @@ export function NoteRepost({ large = false }: { large?: boolean }) {
// update state // update state
setLoading(false); setLoading(false);
setIsRepost(true); setIsRepost(true);
// notify
toast.success("You've reposted this post successfully");
} catch { } catch {
setLoading(false); setLoading(false);
toast.error("Repost failed, try again later"); await message("Repost failed, try again later", {
title: "Lume",
kind: "info",
});
} }
}; };
@@ -64,9 +64,9 @@ export function NoteRepost({ large = false }: { large?: boolean }) {
type="button" type="button"
onClick={(e) => showContextMenu(e)} onClick={(e) => showContextMenu(e)}
className={cn( className={cn(
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200 rounded-full", "inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
large large
? "bg-neutral-100 dark:bg-white/10 h-7 gap-1.5 w-24 text-sm font-medium hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20" ? "rounded-full h-7 gap-1.5 w-24 text-sm font-medium hover:bg-black/10 dark:hover:bg-white/10"
: "size-7", : "size-7",
)} )}
> >
@@ -13,11 +13,11 @@ export function NoteZap({ large = false }: { large?: boolean }) {
return ( return (
<button <button
type="button" type="button"
onClick={() => LumeWindow.openZap(event.id, event.pubkey)} onClick={() => LumeWindow.openZap(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
? "rounded-full bg-neutral-100 dark:bg-white/10 h-7 gap-1.5 w-24 text-sm font-medium hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20" ? "rounded-full h-7 gap-1.5 w-20 text-sm font-medium hover:bg-black/10 dark:hover:bg-white/10"
: "size-7", : "size-7",
)} )}
> >
+38 -10
View File
@@ -1,7 +1,7 @@
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router"; import { useRouteContext } from "@tanstack/react-router";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { type ReactNode, useMemo } from "react"; import { type ReactNode, useMemo, useState } from "react";
import reactStringReplace from "react-string-replace"; import reactStringReplace from "react-string-replace";
import { Hashtag } from "./mentions/hashtag"; import { Hashtag } from "./mentions/hashtag";
import { MentionNote } from "./mentions/note"; import { MentionNote } from "./mentions/note";
@@ -23,6 +23,8 @@ export function NoteContent({
}) { }) {
const { settings } = useRouteContext({ strict: false }); const { settings } = useRouteContext({ strict: false });
const event = useNoteContext(); const event = useNoteContext();
const warning = useMemo(() => event.warning, [event]);
const content = useMemo(() => { const content = useMemo(() => {
try { try {
// Get parsed meta // Get parsed meta
@@ -85,28 +87,54 @@ export function NoteContent({
)); ));
return richContent; return richContent;
} catch (e) { } catch {
console.log("[parser]: ", e);
return event.content; return event.content;
} }
}, [event.content]); }, [event.content]);
const [blurred, setBlurred] = useState(() => warning?.length > 0);
return ( return (
<div className="flex flex-col gap-2"> <div className="relative flex flex-col gap-2">
{blurred ? (
<div className="absolute inset-0 z-10 flex items-center justify-center w-full h-full bg-black/80 backdrop-blur-lg">
<div className="flex flex-col items-center justify-center gap-2 text-center">
<p className="text-sm text-white/60">
The content is hidden because the author
<br />
marked it with a warning for a reason:
</p>
<p className="text-sm font-medium text-white">{warning}</p>
<button
type="button"
onClick={() => setBlurred(false)}
className="inline-flex items-center justify-center px-2 mt-4 text-sm font-medium border rounded-lg text-white/70 h-9 w-max bg-white/20 hover:bg-white/30 border-white/5"
>
View anyway
</button>
</div>
</div>
) : null}
<div <div
className={cn( className={cn(
"select-text text-pretty content-break overflow-hidden", "select-text text-pretty content-break overflow-hidden",
event.content.length > 620 ? "max-h-[250px] gradient-mask-b-0" : "", event.meta?.content.length > 500
? "max-h-[250px] gradient-mask-b-0"
: "",
className, className,
)} )}
> >
{content} {content}
</div> </div>
{settings.display_media && event.meta?.images.length ? ( {settings.display_media ? (
<Images urls={event.meta.images} /> <>
) : null} {event.meta?.images.length ? (
{settings.display_media && event.meta?.videos.length ? ( <Images urls={event.meta.images} />
<Videos urls={event.meta.videos} /> ) : null}
{event.meta?.videos.length ? (
<Videos urls={event.meta.videos} />
) : null}
</>
) : null} ) : null}
</div> </div>
); );
@@ -1,8 +1,6 @@
import { LumeWindow, useEvent } from "@lume/system";
import { LinkIcon } from "@lume/icons";
import { useTranslation } from "react-i18next";
import { cn } from "@lume/utils";
import { User } from "@/components/user"; import { User } from "@/components/user";
import { LinkIcon } from "@lume/icons";
import { LumeWindow, useEvent } from "@lume/system";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
export function MentionNote({ export function MentionNote({
@@ -12,65 +10,67 @@ export function MentionNote({
eventId: string; eventId: string;
openable?: boolean; openable?: boolean;
}) { }) {
const { t } = useTranslation();
const { isLoading, isError, data } = useEvent(eventId); const { isLoading, isError, data } = useEvent(eventId);
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center w-full h-20 mt-2 border rounded-xl border-black/10 dark:border-white/10"> <div className="py-2">
<Spinner className="size-5" /> <div className="pl-4 py-3 flex flex-col w-full border-l-2 border-black/5 dark:border-white/5">
<Spinner className="size-5" />
</div>
</div> </div>
); );
} }
if (isError || !data) { if (isError || !data) {
return ( return (
<div className="w-full p-3 mt-2 border rounded-xl border-black/10 dark:border-white/10"> <div className="py-2">
{t("note.error")} <div className="pl-4 py-3 flex flex-col w-full border-l-2 border-black/5 dark:border-white/5">
<p className="text-sm font-medium text-red-500">
Event not found with your current relay set
</p>
</div>
</div> </div>
); );
} }
return ( return (
<div className="flex flex-col w-full border rounded-lg cursor-default border-black/10 dark:border-white/10"> <div className="py-2">
<User.Provider pubkey={data.pubkey}> <div className="pl-4 py-3 flex flex-col w-full border-l-2 border-black/5 dark:border-white/5">
<User.Root className="flex items-center gap-2 px-3 h-11"> <User.Provider pubkey={data.pubkey}>
<User.Avatar className="object-cover rounded-full size-6 shrink-0" /> <User.Root className="flex items-center gap-2 h-8">
<div className="inline-flex items-center flex-1 gap-2"> <User.Avatar className="rounded-full size-6" />
<User.Name className="font-semibold text-neutral-900 dark:text-neutral-100" /> <div className="inline-flex items-center flex-1 gap-2">
<span className="text-neutral-600 dark:text-neutral-400">·</span> <User.Name className="font-semibold text-neutral-900 dark:text-neutral-100" />
<User.Time <span className="text-neutral-600 dark:text-neutral-400">·</span>
time={data.created_at} <User.Time
className="text-neutral-600 dark:text-neutral-400" time={data.created_at}
/> className="text-neutral-600 dark:text-neutral-400"
</div> />
</User.Root> </div>
</User.Provider> </User.Root>
<div </User.Provider>
className={cn( <div className="select-text text-pretty line-clamp-3 content-break leading-normal">
"px-3 select-text whitespace-normal text-pretty content-break leading-normal", {data.content}
data.content.length > 400 ? "max-h-[150px] gradient-mask-b-0" : "",
)}
>
{data.content}
</div>
{openable ? (
<div className="flex items-center justify-end px-2 h-11">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
LumeWindow.openEvent(data);
}}
className="z-10 inline-flex items-center justify-center gap-1 text-sm rounded-full h-7 w-28 bg-black/10 dark:bg-white/10 text-neutral-600 hover:text-blue-500 dark:text-neutral-400"
>
View post
<LinkIcon className="size-4" />
</button>
</div> </div>
) : ( {openable ? (
<div className="h-3" /> <div className="flex items-center justify-start mt-3">
)} <button
type="button"
onClick={(e) => {
e.stopPropagation();
LumeWindow.openEvent(data);
}}
className="inline-flex items-center gap-1 text-blue-500 text-sm"
>
View post
<LinkIcon className="size-3" />
</button>
</div>
) : (
<div className="h-3" />
)}
</div>
</div> </div>
); );
} }
@@ -35,7 +35,7 @@ export function ImagePreview({ url }: { url: string }) {
loading="lazy" loading="lazy"
decoding="async" decoding="async"
style={{ contentVisibility: "auto" }} style={{ contentVisibility: "auto" }}
className="max-h-[600px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15" className="max-h-[400px] max-w-[400px] h-auto w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
onClick={() => open(url)} onClick={() => open(url)}
onKeyDown={() => open(url)} onKeyDown={() => open(url)}
onError={({ currentTarget }) => { onError={({ currentTarget }) => {
@@ -1,23 +1,79 @@
import { Carousel, CarouselItem } from "@lume/ui"; import { ArrowLeftIcon, ArrowRightIcon } from "@lume/icons";
import { Spinner } from "@lume/ui";
import { cn } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router"; import { useRouteContext } from "@tanstack/react-router";
import { open } from "@tauri-apps/plugin-shell"; import { open } from "@tauri-apps/plugin-shell";
import { useMemo } from "react"; import useEmblaCarousel from "embla-carousel-react";
import { useCallback, useEffect, useMemo, useState } from "react";
export function Images({ urls }: { urls: string[] }) { export function Images({ urls }: { urls: string[] }) {
const { settings } = useRouteContext({ strict: false }); const { settings } = useRouteContext({ strict: false });
const [slidesInView, setSlidesInView] = useState([]);
const [emblaRef, emblaApi] = useEmblaCarousel({
dragFree: true,
align: "start",
watchSlides: false,
});
const imageUrls = useMemo(() => { const imageUrls = useMemo(() => {
if (settings.image_resize_service.length) { if (settings.image_resize_service.length) {
const newUrls = urls.map( let newUrls: string[];
(url) =>
`${settings.image_resize_service}?url=${url}&ll&af&default=1&n=-1`, if (urls.length === 1) {
); newUrls = urls.map(
(url) =>
`${settings.image_resize_service}?url=${url}&ll&af&default=1&n=-1`,
);
} else {
newUrls = urls.map(
(url) =>
`${settings.image_resize_service}?url=${url}&w=480&h=640&ll&af&default=1&n=-1`,
);
}
return newUrls; return newUrls;
} else { } else {
return urls; return urls;
} }
}, [settings.image_resize_service]); }, [settings.image_resize_service]);
const scrollPrev = useCallback(() => {
if (emblaApi) emblaApi.scrollPrev();
}, [emblaApi]);
const scrollNext = useCallback(() => {
if (emblaApi) emblaApi.scrollNext();
}, [emblaApi]);
const updateSlidesInView = useCallback((emblaApi) => {
setSlidesInView((slidesInView) => {
if (slidesInView.length === emblaApi.slideNodes().length) {
emblaApi.off("slidesInView", updateSlidesInView);
}
const inView = emblaApi
.slidesInView()
.filter((index) => !slidesInView.includes(index));
return slidesInView.concat(inView);
});
}, []);
useEffect(() => {
if (emblaApi && urls.length > 1) {
updateSlidesInView(emblaApi);
emblaApi.on("slidesInView", updateSlidesInView);
emblaApi.on("reInit", updateSlidesInView);
}
return () => {
emblaApi?.off("slidesInView", updateSlidesInView);
emblaApi?.off("reInit", updateSlidesInView);
};
}, [emblaApi, updateSlidesInView]);
if (urls.length === 1) { if (urls.length === 1) {
return ( return (
<div className="px-3 group"> <div className="px-3 group">
@@ -40,26 +96,81 @@ export function Images({ urls }: { urls: string[] }) {
} }
return ( return (
<Carousel <div className="relative px-3 overflow-hidden group">
items={imageUrls} <div ref={emblaRef} className="w-full h-[320px]">
renderItem={({ item, isSnapPoint }) => ( <div className="flex w-full gap-2 scrollbar-none">
<CarouselItem key={item} isSnapPoint={isSnapPoint}> {imageUrls.map((url, index) => (
<img <LazyImage
src={item} /* biome-ignore lint/suspicious/noArrayIndexKey: url can be duplicated */
alt={item} key={url + index}
loading="lazy" url={url}
decoding="async" inView={slidesInView.indexOf(index) > -1}
style={{ contentVisibility: "auto" }} />
className="object-cover w-full h-full rounded-lg outline outline-1 -outline-offset-1 outline-black/15" ))}
onClick={() => open(item)} </div>
onKeyDown={() => open(item)} </div>
onError={({ currentTarget }) => { <div className="absolute z-10 items-center justify-between hidden w-full px-5 transform -translate-x-1/2 -translate-y-1/2 group-hover:flex left-1/2 top-1/2">
currentTarget.onerror = null; <button
currentTarget.src = "/404.jpg"; type="button"
}} disabled={!emblaApi?.canScrollPrev}
/> className={cn(
</CarouselItem> "size-11 rounded-full bg-black/30 backdrop-blur-sm flex items-center justify-center text-white",
)} !emblaApi?.canScrollPrev ? "opacity-50" : "",
/> )}
onClick={() => scrollPrev()}
>
<ArrowLeftIcon className="size-6" />
</button>
<button
type="button"
disabled={!emblaApi?.canScrollNext}
className={cn(
"size-11 rounded-full bg-black/30 backdrop-blur-sm flex items-center justify-center text-white",
!emblaApi?.canScrollNext ? "opacity-50" : "",
)}
onClick={() => scrollNext()}
>
<ArrowRightIcon className="size-6" />
</button>
</div>
</div>
);
}
function LazyImage({ url, inView }: { url: string; inView: boolean }) {
const [hasLoaded, setHasLoaded] = useState(false);
const setLoaded = useCallback(() => {
if (inView) setHasLoaded(true);
}, [inView, setHasLoaded]);
return (
<div className="w-[240px] h-[320px] shrink-0 relative rounded-lg overflow-hidden">
{!hasLoaded ? (
<div className="flex items-center justify-center size-full bg-black/5 dark:bg-white/5">
<Spinner className="size-4" />
</div>
) : null}
<img
src={
inView
? url
: "data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs="
}
data-src={url}
alt={url}
loading="lazy"
decoding="async"
style={{ contentVisibility: "auto" }}
className="object-cover w-full h-full rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
onClick={() => open(url)}
onKeyDown={() => open(url)}
onLoad={setLoaded}
onError={({ currentTarget }) => {
currentTarget.onerror = null;
currentTarget.src = "/404.jpg";
}}
/>
</div>
); );
} }
@@ -20,10 +20,11 @@ export function VideoPreview({ url }: { url: string }) {
<div className="my-1"> <div className="my-1">
<video <video
className="max-h-[600px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15" className="max-h-[600px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
preload="metadata"
controls controls
muted muted
> >
<source src={url} type="video/mp4" /> <source src={`${url}#t=0.1`} type="video/mp4" />
Your browser does not support the video tag. Your browser does not support the video tag.
</video> </video>
</div> </div>
@@ -1,36 +1,18 @@
import { Carousel, CarouselItem } from "@lume/ui";
export function Videos({ urls }: { urls: string[] }) { export function Videos({ urls }: { urls: string[] }) {
if (urls.length === 1) { return (
return ( <div className="group px-3">
<div className="group px-3"> {urls.map((url) => (
<video <video
className="w-full h-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15" key={url}
className="max-h-[400px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
preload="metadata"
controls controls
muted muted
> >
<source src={urls[0]} type="video/mp4" /> <source src={`${urls[0]}#t=0.1`} type="video/mp4" />
Your browser does not support the video tag. Your browser does not support the video tag.
</video> </video>
</div> ))}
); </div>
}
return (
<Carousel
items={urls}
renderItem={({ item, isSnapPoint }) => (
<CarouselItem key={item} isSnapPoint={isSnapPoint}>
<video
className="w-full h-full object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
controls={false}
muted
>
<source src={item} type="video/mp4" />
Your browser does not support the video tag.
</video>
</CarouselItem>
)}
/>
); );
} }
+48 -48
View File
@@ -1,62 +1,62 @@
import { LumeWindow } from "@lume/system";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import * as HoverCard from "@radix-ui/react-hover-card"; import { Menu, MenuItem } from "@tauri-apps/api/menu";
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { useCallback } from "react";
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 event = useNoteContext(); const event = useNoteContext();
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
e.preventDefault();
const menuItems = await Promise.all([
MenuItem.new({
text: "View Profile",
action: () => LumeWindow.openProfile(event.pubkey),
}),
MenuItem.new({
text: "Copy Public Key",
action: async () => {
const pubkey = await event.pubkeyAsBech32();
await writeText(pubkey);
},
}),
]);
const menu = await Menu.new({
items: menuItems,
});
await menu.popup().catch((e) => console.error(e));
}, []);
return ( return (
<User.Provider pubkey={event.pubkey}> <User.Provider pubkey={event.pubkey}>
<HoverCard.Root> <User.Root className={cn("flex items-start justify-between", className)}>
<User.Root <div className="flex w-full gap-2">
className={cn("flex items-start justify-between", className)} <button
> type="button"
<div className="flex w-full gap-2"> onClick={(e) => showContextMenu(e)}
<HoverCard.Trigger className="shrink-0"> className="shrink-0"
<User.Avatar className="object-cover rounded-full size-8 outline outline-1 -outline-offset-1 outline-black/15" />
</HoverCard.Trigger>
<div className="flex items-center w-full gap-3">
<div className="flex items-center gap-1">
<User.Name className="font-semibold text-neutral-950 dark:text-neutral-50" />
<User.NIP05 />
</div>
<div className="text-neutral-600 dark:text-neutral-400">·</div>
<User.Time
time={event.created_at}
className="text-neutral-600 dark:text-neutral-400"
/>
</div>
</div>
</User.Root>
<HoverCard.Portal>
<HoverCard.Content
className="w-[300px] rounded-xl bg-black p-3 data-[side=bottom]:animate-slideUpAndFade data-[state=open]:transition-all dark:bg-white dark:shadow-none"
sideOffset={5}
side="right"
> >
<div className="flex flex-col gap-2"> <User.Avatar className="rounded-full size-8" />
<User.Avatar className="object-cover rounded-lg size-11" /> </button>
<div className="flex flex-col gap-2"> <div className="flex items-center w-full gap-3">
<div className="inline-flex items-center gap-1"> <div className="flex items-center gap-1">
<User.Name className="font-semibold leading-tight text-white dark:text-neutral-900" /> <User.Name className="font-semibold text-neutral-950 dark:text-neutral-50" />
<User.NIP05 /> <User.NIP05 />
</div>
<User.About className="text-sm text-white line-clamp-3 dark:text-neutral-900" />
<button
type="button"
onClick={() => LumeWindow.openProfile(event.pubkey)}
className="inline-flex items-center justify-center w-full mt-2 text-sm font-medium bg-white rounded-lg h-9 hover:bg-neutral-200 dark:bg-neutral-100 dark:text-neutral-900 dark:hover:bg-neutral-200"
>
View profile
</button>
</div>
</div> </div>
<HoverCard.Arrow className="fill-black dark:fill-white" /> <div className="text-neutral-600 dark:text-neutral-400">·</div>
</HoverCard.Content> <User.Time
</HoverCard.Portal> time={event.created_at}
</HoverCard.Root> className="text-neutral-600 dark:text-neutral-400"
/>
</div>
</div>
</User.Root>
</User.Provider> </User.Provider>
); );
} }
+6 -5
View File
@@ -1,9 +1,10 @@
import { QuoteIcon } from "@lume/icons";
import { Note } from "@/components/note"; import { Note } from "@/components/note";
import { cn } from "@lume/utils"; import { QuoteIcon } from "@lume/icons";
import type { LumeEvent } from "@lume/system"; import type { LumeEvent } from "@lume/system";
import { cn } from "@lume/utils";
import { memo } from "react";
export function Quote({ export const Quote = memo(function Quote({
event, event,
className, className,
}: { }: {
@@ -14,7 +15,7 @@ export function Quote({
<Note.Provider event={event}> <Note.Provider event={event}>
<Note.Root <Note.Root
className={cn( className={cn(
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl flex flex-col gap-3 shadow-primary dark:ring-1 ring-neutral-800/50", "bg-white dark:bg-black/20 rounded-xl flex flex-col gap-3 shadow-primary dark:ring-1 ring-neutral-800/50",
className, className,
)} )}
> >
@@ -40,4 +41,4 @@ export function Quote({
</Note.Root> </Note.Root>
</Note.Provider> </Note.Provider>
); );
} });
+7 -6
View File
@@ -1,11 +1,12 @@
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";
import { type LumeEvent, NostrQuery } from "@lume/system";
import { Spinner } from "@lume/ui";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { type LumeEvent, NostrQuery } from "@lume/system"; import { memo } from "react";
export function RepostNote({ export const RepostNote = memo(function RepostNote({
event, event,
className, className,
}: { }: {
@@ -32,7 +33,7 @@ export function RepostNote({
return ( return (
<Note.Root <Note.Root
className={cn( className={cn(
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50", "bg-white dark:bg-black/20 rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50",
className, className,
)} )}
> >
@@ -68,7 +69,7 @@ export function RepostNote({
<div className="text-sm font-medium text-neutral-800 dark:text-neutral-200"> <div className="text-sm font-medium text-neutral-800 dark:text-neutral-200">
Reposted by Reposted by
</div> </div>
<User.Avatar className="object-cover rounded-full size-6 shrink-0 ring-1 ring-neutral-200/50 dark:ring-neutral-800/50" /> <User.Avatar className="rounded-full size-6" />
</User.Root> </User.Root>
</User.Provider> </User.Provider>
</div> </div>
@@ -78,4 +79,4 @@ export function RepostNote({
)} )}
</Note.Root> </Note.Root>
); );
} });
+5 -4
View File
@@ -1,8 +1,9 @@
import { cn } from "@lume/utils";
import { Note } from "@/components/note"; import { Note } from "@/components/note";
import type { LumeEvent } from "@lume/system"; import type { LumeEvent } from "@lume/system";
import { cn } from "@lume/utils";
import { memo } from "react";
export function TextNote({ export const TextNote = memo(function TextNote({
event, event,
className, className,
}: { }: {
@@ -13,7 +14,7 @@ export function TextNote({
<Note.Provider event={event}> <Note.Provider event={event}>
<Note.Root <Note.Root
className={cn( className={cn(
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50", "bg-white dark:bg-black/20 rounded-xl shadow-primary dark:ring-1 dark:ring-white/5",
className, className,
)} )}
> >
@@ -31,4 +32,4 @@ export function TextNote({
</Note.Root> </Note.Root>
</Note.Provider> </Note.Provider>
); );
} });
+23 -12
View File
@@ -2,7 +2,6 @@ import { cn } from "@lume/utils";
import * as Avatar from "@radix-ui/react-avatar"; import * as Avatar from "@radix-ui/react-avatar";
import { useRouteContext } from "@tanstack/react-router"; import { useRouteContext } from "@tanstack/react-router";
import { minidenticon } from "minidenticons"; import { minidenticon } from "minidenticons";
import { nanoid } from "nanoid";
import { useMemo } from "react"; import { useMemo } from "react";
import { useUserContext } from "./provider"; import { useUserContext } from "./provider";
@@ -22,22 +21,29 @@ export function UserAvatar({ className }: { className?: string }) {
} }
}, [user.profile?.picture]); }, [user.profile?.picture]);
const fallbackAvatar = useMemo( const fallback = useMemo(
() => () =>
`data:image/svg+xml;utf8,${encodeURIComponent( `data:image/svg+xml;utf8,${encodeURIComponent(
minidenticon(user.pubkey || nanoid(), 90, 50), minidenticon(user.pubkey, 60, 50),
)}`, )}`,
[user.pubkey], [user.pubkey],
); );
if (settings && !settings.display_avatar) { if (settings && !settings.display_avatar) {
return ( return (
<Avatar.Root className="shrink-0"> <Avatar.Root
className={cn(
"shrink-0 block overflow-hidden bg-neutral-200 dark:bg-neutral-800",
className,
)}
>
<Avatar.Fallback delayMs={120}> <Avatar.Fallback delayMs={120}>
<img <img
src={fallbackAvatar} src={fallback}
alt={user.pubkey} alt={user.pubkey}
className={cn("bg-black dark:bg-white", className)} loading="lazy"
decoding="async"
className="size-full bg-black dark:bg-white outline-[.5px] outline-black/5 content-visibility-auto contain-intrinsic-size-[auto]"
/> />
</Avatar.Fallback> </Avatar.Fallback>
</Avatar.Root> </Avatar.Root>
@@ -45,19 +51,24 @@ export function UserAvatar({ className }: { className?: string }) {
} }
return ( return (
<Avatar.Root className="shrink-0"> <Avatar.Root
className={cn(
"shrink-0 block overflow-hidden bg-neutral-200 dark:bg-neutral-800",
className,
)}
>
<Avatar.Image <Avatar.Image
src={picture} src={picture}
alt={user.pubkey} alt={user.pubkey}
loading="eager" loading="lazy"
decoding="async" decoding="async"
className={cn("outline-[.5px] outline-black/5 object-cover", className)} className="w-full aspect-square object-cover outline-[.5px] outline-black/5 content-visibility-auto contain-intrinsic-size-[auto]"
/> />
<Avatar.Fallback delayMs={120}> <Avatar.Fallback>
<img <img
src={fallbackAvatar} src={fallback}
alt={user.pubkey} alt={user.pubkey}
className={cn("bg-black dark:bg-white", className)} className="size-full bg-black dark:bg-white outline-[.5px] outline-black/5 content-visibility-auto contain-intrinsic-size-[auto]"
/> />
</Avatar.Fallback> </Avatar.Fallback>
</Avatar.Root> </Avatar.Root>
+52 -30
View File
@@ -1,6 +1,6 @@
import { Column } from "@/components/column"; import { Column } from "@/components/column";
import { Toolbar } from "@/components/toolbar"; import { Toolbar } from "@/components/toolbar";
import { ArrowLeftIcon, ArrowRightIcon, PlusSquareIcon } from "@lume/icons"; import { ArrowLeftIcon, ArrowRightIcon, PlusIcon } from "@lume/icons";
import { NostrQuery } from "@lume/system"; import { NostrQuery } from "@lume/system";
import type { ColumnEvent, LumeColumn } from "@lume/types"; import type { ColumnEvent, LumeColumn } from "@lume/types";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
@@ -38,23 +38,23 @@ function Screen() {
}, [emblaApi]); }, [emblaApi]);
const emitScrollEvent = useCallback(() => { const emitScrollEvent = useCallback(() => {
getCurrent().emit("child-webview", { scroll: true }); getCurrent().emit("child_webview", { scroll: true });
}, []); }, []);
const emitResizeEvent = useCallback(() => { const emitResizeEvent = useCallback(() => {
getCurrent().emit("child-webview", { resize: true, direction: "x" }); getCurrent().emit("child_webview", { resize: true, direction: "x" });
}, []); }, []);
const openLumeStore = useDebouncedCallback(async () => { const openLumeStore = useCallback(async () => {
await getCurrent().emit("columns", { await getCurrent().emit("columns", {
type: "add", type: "add",
column: { column: {
label: "store", label: "store",
name: "Store", name: "Column Gallery",
content: "/store/official", content: "/store",
}, },
}); });
}, 150); }, []);
const add = useDebouncedCallback((column: LumeColumn) => { const add = useDebouncedCallback((column: LumeColumn) => {
column.label = `${column.label}-${nanoid()}`; // update col label column.label = `${column.label}-${nanoid()}`; // update col label
@@ -65,6 +65,23 @@ function Screen() {
setColumns((prev) => prev.filter((t) => t.label !== label)); setColumns((prev) => prev.filter((t) => t.label !== label));
}, 150); }, 150);
const move = useDebouncedCallback(
(label: string, direction: "left" | "right") => {
const newCols = [...columns];
const col = newCols.find((el) => el.label === label);
const colIndex = newCols.findIndex((el) => el.label === label);
newCols.splice(colIndex, 1);
if (direction === "left") newCols.splice(colIndex - 1, 0, col);
if (direction === "right") newCols.splice(colIndex + 1, 0, col);
setColumns(newCols);
},
150,
);
const updateName = useDebouncedCallback((label: string, title: string) => { const updateName = useDebouncedCallback((label: string, title: string) => {
const currentColIndex = columns.findIndex((col) => col.label === label); const currentColIndex = columns.findIndex((col) => col.label === label);
@@ -135,6 +152,8 @@ function Screen() {
if (data.payload.type === "reset") reset(); if (data.payload.type === "reset") reset();
if (data.payload.type === "add") add(data.payload.column); if (data.payload.type === "add") add(data.payload.column);
if (data.payload.type === "remove") remove(data.payload.label); if (data.payload.type === "remove") remove(data.payload.label);
if (data.payload.type === "move")
move(data.payload.label, data.payload.direction);
if (data.payload.type === "set_title") if (data.payload.type === "set_title")
updateName(data.payload.label, data.payload.title); updateName(data.payload.label, data.payload.title);
}); });
@@ -155,32 +174,35 @@ function Screen() {
account={account} account={account}
/> />
))} ))}
<div className="shrink-0 p-2 h-full w-[480px]">
<div className="size-full bg-black/5 dark:bg-white/5 rounded-xl flex items-center justify-center">
<button
type="button"
onClick={() => openLumeStore()}
className="inline-flex items-center justify-center gap-0.5 rounded-full text-sm font-medium h-8 w-max pl-1.5 pr-3 bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10"
>
<PlusIcon className="size-5" />
Add Column
</button>
</div>
</div>
</div> </div>
</div> </div>
<Toolbar> <Toolbar>
<div className="flex items-center h-8 gap-1 p-[2px] rounded-full bg-black/5 dark:bg-white/5"> <button
<button type="button"
type="button" onClick={() => scrollPrev()}
onClick={() => scrollPrev()} className="inline-flex items-center justify-center rounded-full size-8 hover:bg-black/5 dark:hover:bg-white/5"
className="inline-flex items-center justify-center rounded-full size-7 text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10" >
> <ArrowLeftIcon className="size-4" />
<ArrowLeftIcon className="size-4" /> </button>
</button> <button
<button type="button"
type="button" onClick={() => scrollNext()}
onClick={() => openLumeStore()} className="inline-flex items-center justify-center rounded-full size-8 hover:bg-black/5 dark:hover:bg-white/5"
className="inline-flex items-center justify-center rounded-full size-7 text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10" >
> <ArrowRightIcon className="size-4" />
<PlusSquareIcon className="size-4" /> </button>
</button>
<button
type="button"
onClick={() => scrollNext()}
className="inline-flex items-center justify-center rounded-full size-7 text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
>
<ArrowRightIcon className="size-4" />
</button>
</div>
</Toolbar> </Toolbar>
</div> </div>
); );
+175 -128
View File
@@ -1,186 +1,233 @@
import { User } from "@/components/user"; import { User } from "@/components/user";
import { import {
ChevronDownIcon,
ComposeFilledIcon, ComposeFilledIcon,
HorizontalDotsIcon,
PlusIcon, PlusIcon,
SearchIcon, SearchIcon,
} from "@lume/icons"; } from "@lume/icons";
import { LumeWindow, NostrAccount } from "@lume/system"; import { LumeWindow, NostrAccount, NostrQuery } from "@lume/system";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import * as Popover from "@radix-ui/react-popover";
import { Outlet, createFileRoute } from "@tanstack/react-router"; import { Outlet, createFileRoute } from "@tanstack/react-router";
import { Link } from "@tanstack/react-router"; import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
import { getCurrent } from "@tauri-apps/api/window"; import { getCurrent } from "@tauri-apps/api/window";
import { useEffect, useMemo, useState } from "react"; import { message } from "@tauri-apps/plugin-dialog";
import { toast } from "sonner"; import { memo, useCallback, useState } from "react";
export const Route = createFileRoute("/$account")({ export const Route = createFileRoute("/$account")({
beforeLoad: async () => { beforeLoad: async ({ params }) => {
const settings = await NostrQuery.getUserSettings();
const accounts = await NostrAccount.getAccounts(); const accounts = await NostrAccount.getAccounts();
return { accounts }; const otherAccounts = accounts.filter(
(account) => account !== params.account,
);
return { otherAccounts, settings };
}, },
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const { platform } = Route.useRouteContext(); const { settings, platform } = Route.useRouteContext();
const openLumeStore = async () => {
await getCurrent().emit("columns", {
type: "add",
column: {
label: "store",
name: "Column Gallery",
content: "/store",
},
});
};
return ( return (
<div className="flex flex-col w-screen h-screen"> <div className="flex flex-col w-screen h-screen">
<div <div
data-tauri-drag-region data-tauri-drag-region
className={cn( className="flex h-11 shrink-0 items-center justify-between px-3"
"flex h-11 shrink-0 items-center justify-between pr-2",
platform === "macos" ? "ml-2 pl-20" : "pl-4",
)}
> >
<div className="flex items-center gap-3"> <div
<Accounts /> data-tauri-drag-region
<Link className={cn(
to="/landing" "flex-1 flex items-center gap-2",
className="inline-flex items-center justify-center rounded-full size-8 shrink-0 bg-black/10 text-neutral-800 hover:bg-black/20 dark:bg-white/10 dark:text-neutral-200 dark:hover:bg-white/20" platform === "macos" ? "pl-[64px]" : "",
)}
>
<button
type="button"
onClick={() => openLumeStore()}
className="inline-flex items-center justify-center gap-0.5 rounded-full text-sm font-medium h-8 w-max pl-1.5 pr-3 bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10"
> >
<PlusIcon className="size-5" /> <PlusIcon className="size-5" />
</Link> Column
</button>
<div id="toolbar" />
</div> </div>
<div className="flex items-center gap-2"> <div data-tauri-drag-region className="hidden md:flex md:flex-1">
<Search />
</div>
<div
data-tauri-drag-region
className="flex-1 flex items-center justify-end gap-3"
>
<button <button
type="button" type="button"
onClick={() => LumeWindow.openEditor()} onClick={() => LumeWindow.openEditor()}
className="inline-flex items-center justify-center h-8 gap-1 px-3 text-sm font-medium text-white bg-blue-500 rounded-full w-max hover:bg-blue-600" className="inline-flex items-center justify-center h-8 gap-1 px-3 text-sm font-medium bg-black/5 dark:bg-white/5 rounded-full w-max hover:bg-blue-500 hover:text-white"
> >
<ComposeFilledIcon className="size-4" /> <ComposeFilledIcon className="size-4" />
New Post New Post
</button> </button>
<div id="toolbar" /> <Accounts />
</div> </div>
</div> </div>
<div className="flex-1"> <div
className={cn(
"flex-1",
settings.vibrancy
? ""
: "bg-white dark:bg-black border-t border-black/20 dark:border-white/20",
)}
>
<Outlet /> <Outlet />
</div> </div>
</div> </div>
); );
} }
function Accounts() { const Accounts = memo(function Accounts() {
const { accounts } = Route.useRouteContext(); const { otherAccounts } = Route.useRouteContext();
const { account } = Route.useParams(); const { account } = Route.useParams();
const [windowWidth, setWindowWidth] = useState<number>(null);
const navigate = Route.useNavigate(); const navigate = Route.useNavigate();
const sortedList = useMemo(() => {
const list = accounts;
for (const [i, item] of list.entries()) { const showContextMenu = useCallback(
if (item === account) { async (e: React.MouseEvent) => {
list.splice(i, 1); e.preventDefault();
list.unshift(item);
}
}
return list; const menuItems = await Promise.all([
}, [accounts]); MenuItem.new({
text: "New Post",
action: () => LumeWindow.openEditor(),
}),
PredefinedMenuItem.new({ item: "Separator" }),
MenuItem.new({
text: "View Profile",
action: () => LumeWindow.openProfile(account),
}),
MenuItem.new({
text: "Open Settings",
action: () => LumeWindow.openSettings(),
}),
]);
const changeAccount = async (npub: string) => { const menu = await Menu.new({
if (npub === account) { items: menuItems,
return await LumeWindow.openProfile(account);
}
// Change current account and update signer
const select = await NostrAccount.loadAccount(npub);
if (select) {
// Reset current columns
await getCurrent().emit("columns", { type: "reset" });
// Redirect to new account
return navigate({
to: "/$account/home",
params: { account: npub },
resetScroll: true,
replace: true,
}); });
} else {
toast.warning("Something wrong.");
}
};
const getWindowDimensions = () => { await menu.popup().catch((e) => console.error(e));
const { innerWidth: width, innerHeight: height } = window; },
return { [account],
width, );
height,
};
};
useEffect(() => { const changeAccount = useCallback(
function handleResize() { async (npub: string) => {
setWindowWidth(getWindowDimensions().width); // Change current account and update signer
} const select = await NostrAccount.loadAccount(npub);
if (!windowWidth) { if (select) {
setWindowWidth(getWindowDimensions().width); // Reset current columns
} await getCurrent().emit("columns", { type: "reset" });
window.addEventListener("resize", handleResize); // Redirect to new account
return navigate({
to: "/$account/home",
params: { account: npub },
resetScroll: true,
replace: true,
});
} else {
await message("Something wrong.", { title: "Accounts", kind: "error" });
}
},
[otherAccounts],
);
return () => { return (
window.removeEventListener("resize", handleResize); <div data-tauri-drag-region className="hidden md:flex items-center gap-3">
}; {otherAccounts.map((npub) => (
<button key={npub} type="button" onClick={(e) => changeAccount(npub)}>
<User.Provider pubkey={npub}>
<User.Root className="shrink-0 rounded-full transition-all ease-in-out duration-150 will-change-auto hover:ring-1 hover:ring-blue-500">
<User.Avatar className="rounded-full size-8" />
</User.Root>
</User.Provider>
</button>
))}
<button
type="button"
onClick={(e) => showContextMenu(e)}
className="inline-flex items-center gap-1.5"
>
<User.Provider pubkey={account}>
<User.Root className="shrink-0 rounded-full">
<User.Avatar className="rounded-full size-8" />
</User.Root>
</User.Provider>
<ChevronDownIcon className="size-3" />
</button>
</div>
);
});
const Search = memo(function Search() {
const [searchType, setSearchType] = useState<"notes" | "users">("notes");
const [query, setQuery] = useState("");
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
e.preventDefault();
const menuItems = await Promise.all([
MenuItem.new({
text: "Notes",
action: () => setSearchType("notes"),
}),
MenuItem.new({
text: "Users",
action: () => setSearchType("users"),
}),
]);
const menu = await Menu.new({
items: menuItems,
});
await menu.popup().catch((e) => console.error(e));
}, []); }, []);
return ( return (
<div data-tauri-drag-region className="flex items-center gap-3"> <div className="h-8 w-full px-3 text-sm rounded-full inline-flex items-center bg-black/5 dark:bg-white/5">
{sortedList <button
.slice(0, windowWidth > 500 ? account.length : 2) type="button"
.map((user) => ( onClick={(e) => showContextMenu(e)}
<button key={user} type="button" onClick={() => changeAccount(user)}> className="inline-flex items-center gap-1 capitalize text-sm font-medium pr-2 border-r border-black/10 dark:border-white/10 text-black/50 dark:text-white/50"
<User.Provider pubkey={user}> >
<User.Root {searchType}
className={cn( <ChevronDownIcon className="size-3" />
"shrink-0 rounded-full transition-all ease-in-out duration-150 will-change-auto", </button>
user === account <input
? "ring-1 ring-teal-500 ring-offset-2 ring-offset-neutral-200 dark:ring-offset-neutral-950" type="text"
: "", name="search"
)} placeholder="Search..."
> onChange={(e) => setQuery(e.target.value)}
<User.Avatar onKeyDown={(event) => {
className={cn( if (event.key === "Enter") {
"aspect-square h-auto rounded-full object-cover transition-all ease-in-out duration-150 will-change-auto", LumeWindow.openSearch(searchType, query);
user === account ? "w-7" : "w-8", }
)} }}
/> className="h-full w-full px-3 text-sm rounded-full border-none ring-0 focus:ring-0 focus:outline-none bg-transparent placeholder:text-black/50 dark:placeholder:text-white/50"
</User.Root> />
</User.Provider> <SearchIcon className="size-5" />
</button>
))}
{accounts.length >= 3 && windowWidth <= 700 ? (
<Popover.Root>
<Popover.Trigger className="inline-flex items-center justify-center rounded-full size-8 shrink-0 bg-black/10 text-neutral-800 hover:bg-black/20 dark:bg-white/10 dark:text-neutral-200 dark:hover:bg-white/20">
<HorizontalDotsIcon className="size-5" />
</Popover.Trigger>
<Popover.Portal>
<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="inline-flex items-center justify-center rounded-md size-9 hover:bg-white/10"
>
<User.Provider pubkey={user}>
<User.Root className="rounded-full ring-1 ring-white/10">
<User.Avatar className="object-cover h-auto rounded-full size-7 aspect-square" />
</User.Root>
</User.Provider>
</button>
))}
<Popover.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Popover.Content>
</Popover.Portal>
</Popover.Root>
) : null}
</div> </div>
); );
} });
+3 -27
View File
@@ -1,39 +1,15 @@
import { CancelCircleIcon, CheckCircleIcon, InfoCircleIcon } from "@lume/icons";
import type { Settings } from "@lume/system";
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 { OsType } from "@tauri-apps/plugin-os";
import { Toaster } from "sonner";
interface RouterContext { interface RouterContext {
// System
queryClient: QueryClient; queryClient: QueryClient;
// App info platform: OsType;
platform?: Platform;
locale?: string;
// Settings
settings?: Settings;
// Accounts
accounts?: string[];
} }
export const Route = createRootRouteWithContext<RouterContext>()({ export const Route = createRootRouteWithContext<RouterContext>()({
component: () => ( component: () => <Outlet />,
<>
<Toaster
position="bottom-right"
icons={{
success: <CheckCircleIcon className="size-5" />,
info: <InfoCircleIcon className="size-5" />,
error: <CancelCircleIcon className="size-5" />,
}}
closeButton
theme="system"
/>
<Outlet />
</>
),
pendingComponent: Pending, pendingComponent: Pending,
wrapInSuspense: true, wrapInSuspense: true,
}); });
@@ -5,9 +5,8 @@ import * as Checkbox from "@radix-ui/react-checkbox";
import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { writeText } from "@tauri-apps/plugin-clipboard-manager"; import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { message } from "@tauri-apps/plugin-dialog";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
export const Route = createFileRoute("/auth/$account/backup")({ export const Route = createFileRoute("/auth/$account/backup")({
component: Screen, component: Screen,
@@ -15,21 +14,22 @@ export const Route = createFileRoute("/auth/$account/backup")({
function Screen() { function Screen() {
const { account } = Route.useParams(); const { account } = Route.useParams();
const { t } = useTranslation(); const navigate = useNavigate();
const [key, setKey] = useState(null); const [key, setKey] = useState(null);
const [passphase, setPassphase] = useState(""); const [passphase, setPassphase] = useState("");
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [confirm, setConfirm] = useState({ c1: false, c2: false, c3: false }); const [confirm, setConfirm] = useState({ c1: false, c2: false });
const navigate = useNavigate();
const submit = async () => { const submit = async () => {
try { try {
if (key) { if (key) {
if (!confirm.c1 || !confirm.c2 || !confirm.c3) { if (!confirm.c1 || !confirm.c2) {
return toast.warning("You need to confirm before continue"); return await message("You need to confirm before continue", {
title: "Backup",
kind: "info",
});
} }
navigate({ to: "/", replace: true }); navigate({ to: "/", replace: true });
@@ -48,7 +48,10 @@ function Screen() {
}); });
} catch (e) { } catch (e) {
setLoading(false); setLoading(false);
toast.error(String(e)); await message(String(e), {
title: "Backup",
kind: "error",
});
} }
}; };
@@ -57,7 +60,10 @@ function Screen() {
await writeText(key); await writeText(key);
setCopied(true); setCopied(true);
} catch (e) { } catch (e) {
toast.error(e); await message(String(e), {
title: "Backup",
kind: "error",
});
} }
}; };
@@ -128,7 +134,7 @@ function Screen() {
className="text-sm leading-none text-neutral-800 dark:text-neutral-200" className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
htmlFor="confirm1" htmlFor="confirm1"
> >
{t("backup.confirm1")} I will make sure keep it safe and not sharing with anyone.
</label> </label>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -148,27 +154,7 @@ function Screen() {
className="text-sm leading-none text-neutral-800 dark:text-neutral-200" className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
htmlFor="confirm2" htmlFor="confirm2"
> >
{t("backup.confirm2")} I understand I cannot recover private key.
</label>
</div>
<div className="flex items-center gap-2">
<Checkbox.Root
checked={confirm.c3}
onCheckedChange={() =>
setConfirm((state) => ({ ...state, c3: !state.c3 }))
}
className="flex items-center justify-center rounded-md outline-none appearance-none size-6 bg-neutral-100 dark:bg-white/10 dark:hover:bg-white/20"
id="confirm3"
>
<Checkbox.Indicator className="text-blue-500">
<CheckIcon className="size-4" />
</Checkbox.Indicator>
</Checkbox.Root>
<label
className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
htmlFor="confirm3"
>
{t("backup.confirm3")}
</label> </label>
</div> </div>
</div> </div>
@@ -182,7 +168,7 @@ function Screen() {
disabled={loading} disabled={loading}
className="inline-flex items-center justify-center w-full font-semibold text-white bg-blue-500 rounded-lg h-11 shrink-0 hover:bg-blue-600 disabled:opacity-50" className="inline-flex items-center justify-center w-full font-semibold text-white bg-blue-500 rounded-lg h-11 shrink-0 hover:bg-blue-600 disabled:opacity-50"
> >
{loading ? <Spinner /> : t("global.continue")} {loading ? <Spinner /> : "Continue"}
</button> </button>
</div> </div>
</div> </div>
@@ -4,24 +4,21 @@ 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";
import { message } from "@tauri-apps/plugin-dialog";
import { useState } from "react"; import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
export const Route = createFileRoute("/auth/create-profile")({ export const Route = createFileRoute("/auth/create-profile")({
component: Screen,
loader: async () => { loader: async () => {
const account = await NostrAccount.createAccount(); const account = await NostrAccount.createAccount();
return account; return account;
}, },
component: Screen,
}); });
function Screen() { function Screen() {
const account = Route.useLoaderData(); const account = Route.useLoaderData();
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation();
const { register, handleSubmit } = useForm(); const { register, handleSubmit } = useForm();
const [picture, setPicture] = useState<string>(""); const [picture, setPicture] = useState<string>("");
@@ -53,7 +50,7 @@ function Screen() {
} }
} catch (e) { } catch (e) {
setLoading(false); setLoading(false);
toast.error(String(e)); await message(String(e), { title: "Create Profile", kind: "error" });
} }
}; };
@@ -87,7 +84,7 @@ function Screen() {
> >
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label htmlFor="display_name" className="font-medium"> <label htmlFor="display_name" className="font-medium">
{t("user.displayName")} * Display Name *
</label> </label>
<input <input
type={"text"} type={"text"}
@@ -99,7 +96,7 @@ function Screen() {
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label htmlFor="name" className="font-medium"> <label htmlFor="name" className="font-medium">
{t("user.name")} Name
</label> </label>
<input <input
type={"text"} type={"text"}
@@ -111,7 +108,7 @@ function Screen() {
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label htmlFor="about" className="font-medium"> <label htmlFor="about" className="font-medium">
{t("user.bio")} Bio
</label> </label>
<textarea <textarea
{...register("about")} {...register("about")}
@@ -122,7 +119,7 @@ function Screen() {
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label htmlFor="website" className="font-medium"> <label htmlFor="website" className="font-medium">
{t("user.website")} Website
</label> </label>
<input <input
type="url" type="url"
@@ -136,7 +133,7 @@ function Screen() {
type="submit" type="submit"
className="inline-flex items-center justify-center w-full mt-3 font-semibold text-white bg-blue-500 rounded-lg h-11 shrink-0 hover:bg-blue-600 disabled:opacity-50" className="inline-flex items-center justify-center w-full mt-3 font-semibold text-white bg-blue-500 rounded-lg h-11 shrink-0 hover:bg-blue-600 disabled:opacity-50"
> >
{loading ? <Spinner /> : t("global.continue")} {loading ? <Spinner /> : "Continue"}
</button> </button>
</form> </form>
</div> </div>
@@ -1,8 +1,8 @@
import { NostrAccount } from "@lume/system"; 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 { message } from "@tauri-apps/plugin-dialog";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner";
export const Route = createLazyFileRoute("/auth/import")({ export const Route = createLazyFileRoute("/auth/import")({
component: Screen, component: Screen,
@@ -16,10 +16,12 @@ function Screen() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const submit = async () => { const submit = async () => {
if (!key.startsWith("nsec1")) if (!key.startsWith("nsec1")) {
return toast.warning( return await message(
"You need to enter a valid private key starts with nsec or ncryptsec", "You need to enter a valid private key starts with nsec or ncryptsec",
{ title: "Import Key", kind: "info" },
); );
}
try { try {
setLoading(true); setLoading(true);
@@ -31,7 +33,7 @@ function Screen() {
} }
} catch (e) { } catch (e) {
setLoading(false); setLoading(false);
toast.error(e); await message(String(e), { title: "Import Key", kind: "error" });
} }
}; };
@@ -1,8 +1,8 @@
import { NostrAccount } from "@lume/system"; 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 { message } from "@tauri-apps/plugin-dialog";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner";
export const Route = createLazyFileRoute("/auth/remote")({ export const Route = createLazyFileRoute("/auth/remote")({
component: Screen, component: Screen,
@@ -15,10 +15,12 @@ function Screen() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const submit = async () => { const submit = async () => {
if (!uri.startsWith("bunker://")) if (!uri.startsWith("bunker://")) {
return toast.warning( return await message(
"You need to enter a valid Connect URI starts with bunker://", "You need to enter a valid Connect URI starts with bunker://",
{ title: "Nostr Connect", kind: "info" },
); );
}
try { try {
setLoading(true); setLoading(true);
@@ -30,7 +32,7 @@ function Screen() {
} }
} catch (e) { } catch (e) {
setLoading(false); setLoading(false);
toast.error(e); await message(String(e), { title: "Nostr Connect", kind: "error" });
} }
}; };
+35 -17
View File
@@ -3,9 +3,9 @@ import { NostrQuery } from "@lume/system";
import type { Relay } from "@lume/types"; import type { Relay } from "@lume/types";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { message } from "@tauri-apps/plugin-dialog";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner";
export const Route = createFileRoute("/bootstrap-relays")({ export const Route = createFileRoute("/bootstrap-relays")({
loader: async () => { loader: async () => {
@@ -28,11 +28,18 @@ function Screen() {
const onSubmit = async (data: { url: string; purpose: string }) => { const onSubmit = async (data: { url: string; purpose: string }) => {
try { try {
if (!data.url.startsWith("wss://") || !data.url.startsWith("ws://")) {
return await message("Relay must be starts with wss:// or ws://", {
title: "Bootstrap Relays",
kind: "info",
});
}
const relay: Relay = { url: data.url, purpose: data.purpose }; const relay: Relay = { url: data.url, purpose: data.purpose };
setRelays((prev) => [...prev, relay]); setRelays((prev) => [...prev, relay]);
reset(); reset();
} catch (e) { } catch (e) {
toast.error(String(e)); await message(String(e), { title: "Bootstrap Relays", kind: "error" });
} }
}; };
@@ -41,8 +48,7 @@ function Screen() {
setIsLoading(true); setIsLoading(true);
await NostrQuery.saveBootstrapRelays(relays); await NostrQuery.saveBootstrapRelays(relays);
} catch (e) { } catch (e) {
setIsLoading(false); await message(String(e), { title: "Bootstrap Relays", kind: "error" });
toast.error(String(e));
} }
}; };
@@ -51,12 +57,21 @@ function Screen() {
}, [bootstrapRelays]); }, [bootstrapRelays]);
return ( return (
<div className="flex flex-col items-center justify-center w-screen h-screen"> <div
<div className="w-full max-w-sm mx-auto lg:max-w-lg"> data-tauri-drag-region
<div className="text-center h-11"> className="relative flex flex-col items-center justify-between w-full h-full"
<h1 className="font-semibold">Customize Bootstrap Relays</h1> >
<div
data-tauri-drag-region
className="absolute top-0 left-0 h-14 w-full"
/>
<div className="flex items-end justify-center flex-1 w-full px-4 pb-10">
<div className="text-center">
<h2 className="text-2xl font-semibold">Customize Bootstrap Relays</h2>
</div> </div>
<div className="flex flex-col w-full px-2 bg-white rounded-xl shadow-primary backdrop-blur-lg dark:bg-white/20 dark:ring-1 ring-neutral-800/50"> </div>
<div className="flex flex-col items-center flex-1 w-full">
<div className="flex flex-col w-full max-w-sm mx-auto p-3 overflow-hidden bg-white divide-y divide-neutral-100 dark:divide-white/5 rounded-xl shadow-primary dark:bg-white/10 dark:ring-1 ring-white/15">
{relays.map((relay) => ( {relays.map((relay) => (
<div <div
key={relay.url} key={relay.url}
@@ -118,15 +133,18 @@ function Screen() {
</form> </form>
</div> </div>
</div> </div>
<button <div className="w-full max-w-sm mx-auto">
type="button" <button
onClick={() => save()} type="button"
disabled={isLoading} onClick={() => save()}
className="inline-flex items-center justify-center w-full h-10 mt-4 text-sm font-semibold text-white bg-blue-500 rounded-lg shrink-0 hover:bg-blue-600 disabled:opacity-50" disabled={isLoading}
> className="inline-flex items-center justify-center w-full h-9 mt-4 text-sm font-semibold text-white bg-blue-500 rounded-lg shrink-0 hover:bg-blue-600 disabled:opacity-50"
{isLoading ? <Spinner /> : "Save & Relaunch"} >
</button> {isLoading ? <Spinner /> : "Save & Relaunch"}
</button>
</div>
</div> </div>
<div className="flex-1" />
</div> </div>
); );
} }
+22 -22
View File
@@ -1,11 +1,11 @@
import { User } from "@/components/user";
import { CancelIcon, PlusIcon } from "@lume/icons"; import { CancelIcon, PlusIcon } from "@lume/icons";
import { NostrAccount, NostrQuery } from "@lume/system";
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 { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { message } from "@tauri-apps/plugin-dialog";
import { useState } from "react"; import { useState } from "react";
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 => {
@@ -65,25 +65,25 @@ function Screen() {
} }
} catch (e) { } catch (e) {
setIsLoading(false); setIsLoading(false);
toast.error(e); await message(String(e), { title: "Create Group", kind: "error" });
} }
}; };
return ( return (
<div className="w-full h-full flex flex-col items-center justify-center gap-4"> <div className="flex flex-col items-center justify-center w-full h-full gap-4">
<div className="text-center flex flex-col items-center justify-center"> <div className="flex flex-col items-center justify-center text-center">
<h1 className="text-2xl font-serif font-medium"> <h1 className="font-serif text-2xl font-medium">
Focus feeds for people you like Focus feeds for people you like
</h1> </h1>
<p className="leading-tight text-neutral-700 dark:text-neutral-300"> <p className="leading-tight text-neutral-700 dark:text-neutral-300">
Add some people for custom feeds. Add some people for custom feeds.
</p> </p>
</div> </div>
<div className="w-4/5 max-w-full flex flex-col gap-3"> <div className="flex flex-col w-4/5 max-w-full gap-3">
<div className="w-full h-9 shrink-0 flex items-center bg-black/5 dark:bg-white/5 rounded-lg"> <div className="flex items-center w-full rounded-lg h-9 shrink-0 bg-black/5 dark:bg-white/5">
<label <label
htmlFor="name" htmlFor="name"
className="w-16 border-r border-black/10 dark:border-white/10 shrink-0 text-center text-sm font-semibold" className="w-16 text-sm font-semibold text-center border-r border-black/10 dark:border-white/10 shrink-0"
> >
Name Name
</label> </label>
@@ -92,23 +92,23 @@ function Screen() {
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
placeholder="Enter a name for this group" placeholder="Enter a name for this group"
className="h-full bg-transparent border-none text-sm px-3 placeholder:text-neutral-600 focus:border-neutral-500 focus:ring-0 dark:placeholder:text-neutral-400" className="h-full px-3 text-sm bg-transparent border-none placeholder:text-neutral-600 focus:border-neutral-500 focus:ring-0 dark:placeholder:text-neutral-400"
/> />
</div> </div>
<div className="w-full flex flex-col items-center gap-3"> <div className="flex flex-col items-center w-full gap-3">
<div className="overflow-y-auto scrollbar-none p-2 w-full h-[450px] flex flex-col gap-3 bg-black/5 dark:bg-white/5 backdrop-blur-lg rounded-xl"> <div className="overflow-y-auto scrollbar-none p-2 w-full h-[450px] flex flex-col gap-3 bg-black/5 dark:bg-white/5 rounded-xl">
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
name="npub" name="npub"
value={npub} value={npub}
onChange={(e) => setNpub(e.target.value)} onChange={(e) => setNpub(e.target.value)}
placeholder="npub1..." 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" className="w-full px-3 text-sm border-none rounded-lg h-9 bg-black/10 dark:bg-white/10 placeholder:text-neutral-600 focus:border-neutral-500 focus:ring-0 dark:placeholder:text-neutral-400"
/> />
<button <button
type="button" type="button"
onClick={() => addUser()} onClick={() => addUser()}
className="inline-flex size-9 rounded-lg items-center justify-center bg-black/20 dark:bg-white/20 shrink-0 text-white hover:bg-blue-500" className="inline-flex items-center justify-center text-white rounded-lg size-9 bg-black/20 dark:bg-white/20 shrink-0 hover:bg-blue-500"
> >
<PlusIcon className="size-6" /> <PlusIcon className="size-6" />
</button> </button>
@@ -122,11 +122,11 @@ function Screen() {
key={item} key={item}
type="button" type="button"
onClick={() => toggleUser(item)} 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" className="inline-flex items-center justify-between px-3 py-2 bg-white rounded-lg dark:bg-black/20 shadow-primary dark:ring-1 ring-neutral-800/50"
> >
<User.Provider pubkey={item}> <User.Provider pubkey={item}>
<User.Root className="flex items-center gap-2.5"> <User.Root className="flex items-center gap-2.5">
<User.Avatar className="size-8 rounded-full object-cover" /> <User.Avatar className="rounded-full size-8" />
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<User.Name className="text-sm font-medium" /> <User.Name className="text-sm font-medium" />
</div> </div>
@@ -138,7 +138,7 @@ function Screen() {
</button> </button>
)) ))
) : ( ) : (
<div className="bg-black/5 dark:bg-white/5 text-sm flex items-center justify-center h-14 rounded-lg"> <div className="flex items-center justify-center text-sm rounded-lg bg-black/5 dark:bg-white/5 h-14">
Empty. Empty.
</div> </div>
)} )}
@@ -153,11 +153,11 @@ function Screen() {
key={item} key={item}
type="button" type="button"
onClick={() => toggleUser(item)} 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" className="inline-flex items-center justify-between px-3 py-2 bg-white rounded-lg dark:bg-black/20 shadow-primary dark:ring-1 ring-neutral-800/50"
> >
<User.Provider pubkey={item}> <User.Provider pubkey={item}>
<User.Root className="flex items-center gap-2.5"> <User.Root className="flex items-center gap-2.5">
<User.Avatar className="size-8 rounded-full object-cover" /> <User.Avatar className="rounded-full size-8" />
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<User.Name className="text-sm font-medium" /> <User.Name className="text-sm font-medium" />
</div> </div>
@@ -166,7 +166,7 @@ function Screen() {
</button> </button>
)) ))
) : ( ) : (
<div className="bg-black/5 dark:bg-white/5 text-sm flex items-center justify-center h-14 rounded-lg"> <div className="flex items-center justify-center text-sm rounded-lg bg-black/5 dark:bg-white/5 h-14">
<p> <p>
Find more user at{" "} Find more user at{" "}
<a <a
@@ -187,7 +187,7 @@ function Screen() {
type="button" type="button"
onClick={() => submit()} onClick={() => submit()}
disabled={isLoading || users.length < 1} disabled={isLoading || users.length < 1}
className="inline-flex items-center justify-center w-36 rounded-full h-9 bg-blue-500 text-white text-sm font-medium hover:bg-blue-600 disabled:opacity-50" className="inline-flex items-center justify-center text-sm font-medium text-white bg-blue-500 rounded-full w-36 h-9 hover:bg-blue-600 disabled:opacity-50"
> >
{isLoading ? <Spinner /> : "Confirm"} {isLoading ? <Spinner /> : "Confirm"}
</button> </button>
@@ -2,8 +2,8 @@ import { NostrAccount } from "@lume/system";
import type { ColumnRouteSearch } from "@lume/types"; import type { ColumnRouteSearch } from "@lume/types";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { message } from "@tauri-apps/plugin-dialog";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner";
export const Route = createFileRoute("/create-newsfeed/f2f")({ export const Route = createFileRoute("/create-newsfeed/f2f")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => { validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
@@ -24,8 +24,12 @@ function Screen() {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const submit = async () => { const submit = async () => {
if (!npub.startsWith("npub1")) if (!npub.startsWith("npub1")) {
return toast.warning("You must enter a valid npub."); return await message("You must enter a valid npub.", {
title: "Create Newsfeed",
kind: "info",
});
}
try { try {
setIsLoading(true); setIsLoading(true);
@@ -37,13 +41,16 @@ function Screen() {
} }
} catch (e) { } catch (e) {
setIsLoading(false); setIsLoading(false);
toast.error(String(e)); await message(String(e), {
title: "Create Newsfeed",
kind: "error",
});
} }
}; };
return ( return (
<div className="overflow-y-auto scrollbar-none p-2 shrink-0 h-[450px] bg-white dark:bg-white/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50"> <div className="overflow-y-auto scrollbar-none p-2 shrink-0 h-[450px] bg-white dark:bg-white/20 rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50">
<div className="h-full flex flex-col justify-between"> <div className="flex flex-col justify-between h-full">
<div className="flex-1 flex flex-col gap-1.5 justify-center px-5"> <div className="flex-1 flex flex-col gap-1.5 justify-center px-5">
<p className="font-semibold text-neutral-500"> <p className="font-semibold text-neutral-500">
You already have a friend on Nostr? You already have a friend on Nostr?
@@ -60,7 +67,7 @@ function Screen() {
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label htmlFor="npub" className="font-medium text-sm"> <label htmlFor="npub" className="text-sm font-medium">
NPUB NPUB
</label> </label>
<input <input
@@ -69,13 +76,13 @@ function Screen() {
value={npub} value={npub}
onChange={(e) => setNpub(e.target.value)} onChange={(e) => setNpub(e.target.value)}
spellCheck={false} 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" className="px-3 bg-transparent border rounded-lg h-11 border-neutral-200 dark:border-neutral-800 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:placeholder:text-neutral-400"
/> />
</div> </div>
<button <button
type="button" type="button"
onClick={() => submit()} 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" className="inline-flex items-center justify-center w-full text-sm font-medium text-white bg-blue-500 rounded-lg h-9 hover:bg-blue-600"
> >
{isLoading ? <Spinner /> : "Confirm"} {isLoading ? <Spinner /> : "Confirm"}
</button> </button>
@@ -1,11 +1,11 @@
import { createFileRoute } from "@tanstack/react-router";
import { Suspense, useState } from "react";
import { Await, defer } from "@tanstack/react-router";
import { User } from "@/components/user"; 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"; import { NostrAccount } from "@lume/system";
import type { ColumnRouteSearch } from "@lume/types";
import { Spinner } from "@lume/ui";
import { createFileRoute } from "@tanstack/react-router";
import { Await, defer } from "@tanstack/react-router";
import { message } from "@tauri-apps/plugin-dialog";
import { Suspense, useState } from "react";
export const Route = createFileRoute("/create-newsfeed/users")({ export const Route = createFileRoute("/create-newsfeed/users")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => { validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
@@ -59,16 +59,19 @@ function Screen() {
} }
} catch (e) { } catch (e) {
setIsLoading(false); setIsLoading(false);
toast.error(String(e)); await message(String(e), {
title: "Create Group",
kind: "error",
});
} }
}; };
return ( return (
<div className="w-full flex flex-col items-center gap-3"> <div className="flex flex-col items-center w-full 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="overflow-y-auto scrollbar-none p-2 w-full h-[450px] bg-black/5 dark:bg-white/5 rounded-xl">
<Suspense <Suspense
fallback={ fallback={
<div className="flex h-20 w-full flex-col items-center justify-center gap-1"> <div className="flex flex-col items-center justify-center w-full h-20 gap-1">
<button <button
type="button" type="button"
className="inline-flex items-center gap-2 text-sm font-medium" className="inline-flex items-center gap-2 text-sm font-medium"
@@ -85,27 +88,27 @@ function Screen() {
users.profiles.map((item: { pubkey: string }) => ( users.profiles.map((item: { pubkey: string }) => (
<div <div
key={item.pubkey} 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" className="w-full p-2 mb-2 overflow-hidden bg-white rounded-lg h-max dark:bg-black/20shadow-primary dark:ring-1 ring-neutral-800/50"
> >
<User.Provider pubkey={item.pubkey}> <User.Provider pubkey={item.pubkey}>
<User.Root> <User.Root>
<div className="flex h-full w-full flex-col gap-2"> <div className="flex flex-col w-full h-full gap-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<User.Avatar className="size-7 shrink-0 rounded-full object-cover" /> <User.Avatar className="rounded-full size-7" />
<User.Name className="text-sm leadning-tight max-w-[15rem] truncate font-semibold" /> <User.Name className="text-sm leadning-tight max-w-[15rem] truncate font-semibold" />
</div> </div>
<button <button
type="button" type="button"
onClick={() => toggleFollow(item.pubkey)} 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" className="inline-flex items-center justify-center w-20 text-sm font-medium rounded-lg h-7 bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
> >
{follows.includes(item.pubkey) {follows.includes(item.pubkey)
? "Unfollow" ? "Unfollow"
: "Follow"} : "Follow"}
</button> </button>
</div> </div>
<User.About className="line-clamp-3 max-w-none select-text text-neutral-800 dark:text-neutral-400" /> <User.About className="select-text line-clamp-3 max-w-none text-neutral-800 dark:text-neutral-400" />
</div> </div>
</User.Root> </User.Root>
</User.Provider> </User.Provider>
@@ -119,7 +122,7 @@ function Screen() {
type="button" type="button"
onClick={() => submit()} onClick={() => submit()}
disabled={isLoading || follows.length < 1} 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" className="inline-flex items-center justify-center text-sm font-medium text-white bg-blue-500 rounded-full w-36 h-9 hover:bg-blue-600 disabled:opacity-50"
> >
{isLoading ? <Spinner /> : "Confirm"} {isLoading ? <Spinner /> : "Confirm"}
</button> </button>
+7 -4
View File
@@ -4,8 +4,8 @@ import type { ColumnRouteSearch } from "@lume/types";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { TOPICS } from "@lume/utils"; import { TOPICS } from "@lume/utils";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { message } from "@tauri-apps/plugin-dialog";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner";
type Topic = { type Topic = {
title: string; title: string;
@@ -53,7 +53,10 @@ function Screen() {
} }
} catch (e) { } catch (e) {
setIsLoading(false); setIsLoading(false);
toast.error(String(e)); await message(String(e), {
title: "Create Topic",
kind: "error",
});
} }
}; };
@@ -72,14 +75,14 @@ function Screen() {
<span className="text-sm font-medium">Added: {topics.length}</span> <span className="text-sm font-medium">Added: {topics.length}</span>
</div> </div>
<div className="flex flex-col items-center w-full gap-3"> <div className="flex flex-col items-center w-full 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="overflow-y-auto scrollbar-none p-2 w-full h-[450px] bg-black/5 dark:bg-white/5 rounded-xl">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{TOPICS.map((topic) => ( {TOPICS.map((topic) => (
<button <button
key={topic.title} key={topic.title}
type="button" type="button"
onClick={() => toggleTopic(topic)} onClick={() => toggleTopic(topic)}
className="flex items-center justify-between px-3 bg-white border border-transparent rounded-lg h-11 dark:bg-black/20 backdrop-blur-lg hover:border-blue-500 shadow-primary dark:ring-1 ring-neutral-800/50" className="flex items-center justify-between px-3 bg-white border border-transparent rounded-lg h-11 dark:bg-black/20 hover:border-blue-500 shadow-primary dark:ring-1 ring-neutral-800/50"
> >
<div className="inline-flex items-center gap-1"> <div className="inline-flex items-center gap-1">
<div>{topic.icon}</div> <div>{topic.icon}</div>
@@ -4,9 +4,9 @@ import { Spinner } from "@lume/ui";
import { insertImage, isImagePath } from "@lume/utils"; import { insertImage, isImagePath } from "@lume/utils";
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 { message } from "@tauri-apps/plugin-dialog";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useSlateStatic } from "slate-react"; import { useSlateStatic } from "slate-react";
import { toast } from "sonner";
export function MediaButton() { export function MediaButton() {
const editor = useSlateStatic(); const editor = useSlateStatic();
@@ -24,7 +24,7 @@ export function MediaButton() {
setLoading(false); setLoading(false);
} catch (e) { } catch (e) {
setLoading(false); setLoading(false);
toast.error(`Upload failed, error: ${e}`); await message(String(e), { title: "Upload", kind: "error" });
} }
}; };
+7 -7
View File
@@ -1,7 +1,12 @@
import { Note } from "@/components/note";
import { MentionNote } from "@/components/note/mentions/note";
import { User } from "@/components/user";
import { ComposeFilledIcon } from "@lume/icons"; import { ComposeFilledIcon } from "@lume/icons";
import { LumeEvent, useEvent } from "@lume/system";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { cn, insertImage, insertNostrEvent, isImageUrl } from "@lume/utils"; import { cn, insertImage, insertNostrEvent, isImageUrl } from "@lume/utils";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { nip19 } from "nostr-tools";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { type Descendant, Node, Transforms, createEditor } from "slate"; import { type Descendant, Node, Transforms, createEditor } from "slate";
import { import {
@@ -14,13 +19,8 @@ import {
withReact, withReact,
} from "slate-react"; } from "slate-react";
import { MediaButton } from "./-components/media"; import { MediaButton } from "./-components/media";
import { LumeEvent, useEvent } from "@lume/system";
import { WarningButton } from "./-components/warning";
import { MentionNote } from "@/components/note/mentions/note";
import { PowButton } from "./-components/pow"; import { PowButton } from "./-components/pow";
import { User } from "@/components/user"; import { WarningButton } from "./-components/warning";
import { Note } from "@/components/note";
import { nip19 } from "nostr-tools";
type EditorSearch = { type EditorSearch = {
reply_to: string; reply_to: string;
@@ -250,7 +250,7 @@ function ChildNote({ id }: { id: string }) {
<Note.Root className="flex items-center gap-2"> <Note.Root className="flex items-center gap-2">
<User.Provider pubkey={data.pubkey}> <User.Provider pubkey={data.pubkey}>
<User.Root className="shrink-0"> <User.Root className="shrink-0">
<User.Avatar className="rounded-full size-8 shrink-0" /> <User.Avatar className="rounded-full size-8" />
</User.Root> </User.Root>
</User.Provider> </User.Provider>
<div className="content-break line-clamp-1">{data.content}</div> <div className="content-break line-clamp-1">{data.content}</div>
@@ -1,69 +0,0 @@
import { Note } from "@/components/note";
import { type LumeEvent, NostrQuery, useEvent } from "@lume/system";
import { Box, Container, Spinner } from "@lume/ui";
import { createFileRoute } from "@tanstack/react-router";
import { WindowVirtualizer } from "virtua";
import { ReplyList } from "./-components/replyList";
export const Route = createFileRoute("/events/$eventId")({
beforeLoad: async () => {
const settings = await NostrQuery.getUserSettings();
return { settings };
},
component: Screen,
});
function Screen() {
const { eventId } = Route.useParams();
const { isLoading, isError, data } = useEvent(eventId);
if (isLoading) {
return (
<div className="flex items-center justify-center w-full h-full">
<Spinner className="size-5" />
</div>
);
}
if (isError) {
<div className="flex items-center justify-center w-full h-full">
<p>Not found.</p>
</div>;
}
return (
<Container withDrag>
<Box className="scrollbar-none">
<WindowVirtualizer>
<MainNote data={data} />
{data ? (
<ReplyList eventId={eventId} />
) : (
<div className="flex items-center justify-center w-full h-full">
<Spinner className="size-5" />
</div>
)}
</WindowVirtualizer>
</Box>
</Container>
);
}
function MainNote({ data }: { data: LumeEvent }) {
return (
<Note.Provider event={data}>
<Note.Root>
<div className="flex items-center justify-between px-3 h-14">
<Note.User />
<Note.Menu />
</div>
<Note.ContentLarge className="px-3" />
<div className="flex items-center justify-end gap-2 px-3 mt-4 h-11">
<Note.Reply large />
<Note.Repost large />
<Note.Zap large />
</div>
</Note.Root>
</Note.Provider>
);
}
+143
View File
@@ -0,0 +1,143 @@
import { Note } from "@/components/note";
import { LumeEvent, NostrQuery } from "@lume/system";
import type { Meta } from "@lume/types";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { createFileRoute } from "@tanstack/react-router";
import { getCurrent } from "@tauri-apps/api/window";
import { useEffect, useRef, useState } from "react";
import { Virtualizer } from "virtua";
import NoteParent from "./-components/parent";
type Payload = {
raw: string;
parsed: Meta;
};
export const Route = createFileRoute("/events/$id")({
beforeLoad: async () => {
const settings = await NostrQuery.getUserSettings();
return { settings };
},
loader: async ({ params }) => {
const event = await NostrQuery.getEvent(params.id);
return event;
},
component: Screen,
});
function Screen() {
const ref = useRef<HTMLDivElement>(null);
return (
<div className="h-full flex flex-col">
<div
data-tauri-drag-region
className="shrink-0 h-8 w-full border-b border-black/5 dark:border-white/5"
/>
<ScrollArea.Root
type={"scroll"}
scrollHideDelay={300}
className="overflow-hidden size-full flex-1"
>
<ScrollArea.Viewport ref={ref} className="h-full p-3">
<RootEvent />
<Virtualizer scrollRef={ref}>
<ReplyList />
</Virtualizer>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
orientation="vertical"
>
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
</ScrollArea.Scrollbar>
<ScrollArea.Corner className="bg-transparent" />
</ScrollArea.Root>
</div>
);
}
function RootEvent() {
const event = Route.useLoaderData();
return (
<Note.Provider event={event}>
<Note.Root className="bg-white dark:bg-black/10 rounded-xl shadow-primary dark:ring-1 dark:ring-white/5">
<div className="flex items-center justify-between px-3 h-14">
<Note.User />
<Note.Menu />
</div>
<Note.ContentLarge className="px-3" />
<div className="flex items-center gap-2 px-3 mt-6 h-12 rounded-b-xl bg-neutral-50 dark:bg-white/5">
<Note.Reply large />
<Note.Repost large />
<Note.Zap large />
</div>
</Note.Root>
</Note.Provider>
);
}
function ReplyList() {
const event = Route.useLoaderData();
const [replies, setReplies] = useState<LumeEvent[]>([]);
useEffect(() => {
const unlistenEvent = getCurrent().listen<Payload>("new_reply", (data) => {
const event = LumeEvent.from(data.payload.raw, data.payload.parsed);
setReplies((prev) => [event, ...prev]);
});
const unlistenWindow = getCurrent().onCloseRequested(async () => {
await event.unlistenEventReply();
await getCurrent().destroy();
});
return () => {
unlistenEvent.then((f) => f());
unlistenWindow.then((f) => f());
};
}, []);
useEffect(() => {
let mounted = true;
async function getReplies() {
const data = await event.getEventReplies();
if (mounted) {
setReplies(data);
// Start listen for new reply
event.listenEventReply();
}
}
getReplies();
return () => {
mounted = false;
};
}, []);
return (
<div>
<div className="flex items-center text-sm font-semibold h-14 text-neutral-600 dark:text-white/30">
All replies
</div>
<div className="flex flex-col gap-3">
{!replies.length ? (
<div className="flex items-center justify-center w-full">
<div className="flex flex-col items-center justify-center gap-2 py-4">
<h3 className="text-3xl">👋</h3>
<p className="leading-none text-neutral-600 dark:text-neutral-400">
Be the first to Reply!
</p>
</div>
</div>
) : (
replies.map((event) => <NoteParent key={event.id} event={event} />)
)}
</div>
</div>
);
}
@@ -0,0 +1,41 @@
import { Note } from "@/components/note";
import type { LumeEvent } from "@lume/system";
import NoteParent from "./parent";
import { memo } from "react";
const NoteChild = memo(function NoteChild({ event }: { event: LumeEvent }) {
return (
<Note.Provider event={event}>
<Note.Root className="flex flex-col gap-6 mb-3">
<div>
<div className="flex items-center justify-between">
<Note.User />
<Note.Menu />
</div>
<div className="flex gap-2">
<div className="w-8 shrink-0" />
<div className="flex-1 flex flex-col gap-2">
<Note.ContentLarge />
<div className="flex items-center gap-1">
<Note.Reply />
<Note.Repost />
<Note.Zap />
</div>
</div>
</div>
</div>
{event.replies?.length ? (
<div className="flex flex-col gap-3 pl-4">
<div className="flex flex-col pl-6 border-l border-black/10 dark:border-white/10">
{event.replies?.map((childEvent) => (
<NoteParent key={childEvent.id} event={childEvent} />
))}
</div>
</div>
) : null}
</Note.Root>
</Note.Provider>
);
});
export default NoteChild;
@@ -0,0 +1,41 @@
import { Note } from "@/components/note";
import type { LumeEvent } from "@lume/system";
import NoteChild from "./child";
import { memo } from "react";
const NoteParent = memo(function NoteParent({ event }: { event: LumeEvent }) {
return (
<Note.Provider event={event}>
<Note.Root className="flex flex-col gap-6 mb-3">
<div>
<div className="flex items-center justify-between">
<Note.User />
<Note.Menu />
</div>
<div className="flex gap-2">
<div className="w-8 shrink-0" />
<div className="flex-1 flex flex-col gap-2">
<Note.ContentLarge />
<div className="flex items-center gap-1">
<Note.Reply />
<Note.Repost />
<Note.Zap />
</div>
</div>
</div>
</div>
{event.replies?.length ? (
<div className="flex flex-col gap-3 pl-4">
<div className="flex flex-col gap-3 pl-6 border-l border-black/10 dark:border-white/10">
{event.replies?.map((childEvent) => (
<NoteChild key={childEvent.id} event={childEvent} />
))}
</div>
</div>
) : null}
</Note.Root>
</Note.Provider>
);
});
export default NoteParent;
@@ -1,36 +0,0 @@
import type { EventWithReplies } from "@lume/types";
import { cn } from "@lume/utils";
import { SubReply } from "./subReply";
import { Note } from "@/components/note";
export function Reply({ event }: { event: EventWithReplies }) {
return (
<Note.Provider event={event}>
<Note.Root className="border-t border-neutral-100 dark:border-neutral-900">
<div className="px-3 h-14 flex items-center justify-between">
<Note.User />
<Note.Menu />
</div>
<Note.ContentLarge className="px-3" />
<div className="mt-3 flex items-center gap-4 px-3 h-14">
<Note.Reply />
<Note.Repost />
<Note.Zap />
</div>
<div
className={cn(
event.replies?.length > 0
? "py-2 pl-3 flex flex-col gap-3 divide-y divide-neutral-100 bg-neutral-50 dark:bg-white/5 border-l-2 border-blue-500 dark:divide-neutral-900"
: "",
)}
>
{event.replies?.length > 0
? event.replies?.map((childEvent) => (
<SubReply key={childEvent.id} event={childEvent} />
))
: null}
</div>
</Note.Root>
</Note.Provider>
);
}
@@ -1,50 +0,0 @@
import type { EventWithReplies } from "@lume/types";
import { Spinner } from "@lume/ui";
import { cn } from "@lume/utils";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Reply } from "./reply";
import { LumeEvent } from "@lume/system";
export function ReplyList({
eventId,
className,
}: {
eventId: string;
className?: string;
}) {
const [t] = useTranslation();
const [data, setData] = useState<null | EventWithReplies[]>(null);
useEffect(() => {
async function getReplies() {
const events = await LumeEvent.getReplies(eventId);
setData(events);
}
getReplies();
}, [eventId]);
return (
<div className={cn("flex flex-col", className)}>
<div className="h-11 flex px-3 items-center text-sm font-semibold text-neutral-700 dark:text-neutral-300 border-t border-neutral-100 dark:border-neutral-900">
Replies ({data?.length ?? 0})
</div>
{!data ? (
<div className="flex h-16 items-center justify-center p-3">
<Spinner className="size-5" />
</div>
) : data.length === 0 ? (
<div className="flex w-full items-center justify-center">
<div className="flex flex-col items-center justify-center gap-2 py-6">
<h3 className="text-3xl">👋</h3>
<p className="leading-none text-neutral-600 dark:text-neutral-400">
{t("note.reply.empty")}
</p>
</div>
</div>
) : (
data.map((event) => <Reply key={event.id} event={event} />)
)}
</div>
);
}
@@ -1,26 +0,0 @@
import type { NostrEvent } from "@lume/types";
import { Note } from "@/components/note";
export function SubReply({
event,
}: {
event: NostrEvent;
rootEventId?: string;
}) {
return (
<Note.Provider event={event}>
<Note.Root>
<div className="px-3 h-14 flex items-center justify-between">
<Note.User />
<Note.Menu />
</div>
<Note.ContentLarge className="px-3" />
<div className="mt-3 flex items-center gap-4 px-3">
<Note.Reply />
<Note.Repost />
<Note.Zap />
</div>
</Note.Root>
</Note.Provider>
);
}
+61 -43
View File
@@ -6,9 +6,10 @@ import { ArrowRightCircleIcon } from "@lume/icons";
import { type LumeEvent, NostrQuery } from "@lume/system"; import { type LumeEvent, NostrQuery } from "@lume/system";
import { type ColumnRouteSearch, Kind } from "@lume/types"; import { type ColumnRouteSearch, Kind } from "@lume/types";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { useInfiniteQuery } from "@tanstack/react-query"; import { useInfiniteQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { useCallback } from "react"; import { useCallback, useRef } from "react";
import { Virtualizer } from "virtua"; import { Virtualizer } from "virtua";
export const Route = createFileRoute("/global")({ export const Route = createFileRoute("/global")({
@@ -47,6 +48,8 @@ export function Screen() {
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}); });
const ref = useRef<HTMLDivElement>(null);
const renderItem = useCallback( const renderItem = useCallback(
(event: LumeEvent) => { (event: LumeEvent) => {
if (!event) return; if (!event) return;
@@ -70,48 +73,63 @@ export function Screen() {
); );
return ( return (
<div className="w-full h-full p-3 overflow-y-auto scrollbar-none"> <ScrollArea.Root
{isFetching && !isLoading && !isFetchingNextPage ? ( type={"scroll"}
<div className="flex items-center justify-center w-full mb-3 h-11 bg-black/10 dark:bg-white/10 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50"> scrollHideDelay={300}
<div className="flex items-center justify-center gap-2"> className="overflow-hidden size-full"
<Spinner className="size-5" /> >
<span className="text-sm font-medium">Fetching new notes...</span> <ScrollArea.Viewport ref={ref} className="h-full px-3 pb-3">
</div> <Virtualizer scrollRef={ref}>
</div> {isFetching && !isLoading && !isFetchingNextPage ? (
) : null} <div className="flex items-center justify-center w-full mb-3 h-12 bg-black/5 dark:bg-white/5 rounded-xl">
{isLoading ? ( <div className="flex items-center justify-center gap-2">
<div className="flex items-center justify-center w-full h-16 gap-2"> <Spinner className="size-5" />
<Spinner className="size-5" /> <span className="text-sm font-medium">
<span className="text-sm font-medium">Loading...</span> Getting new notes...
</div> </span>
) : !data.length ? ( </div>
<div className="flex items-center justify-center"> </div>
Yo. You're catching up on all the things happening around you. ) : null}
</div> {isLoading ? (
) : ( <div className="flex items-center justify-center w-full h-16 gap-2">
<Virtualizer overscan={3}>
{data.map((item) => renderItem(item))}
</Virtualizer>
)}
{data?.length && hasNextPage ? (
<div>
<button
type="button"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage || isLoading}
className="inline-flex items-center justify-center w-full gap-2 px-3 font-medium h-9 rounded-xl bg-black/5 hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
>
{isFetchingNextPage ? (
<Spinner className="size-5" /> <Spinner className="size-5" />
) : ( <span className="text-sm font-medium">Loading...</span>
<> </div>
<ArrowRightCircleIcon className="size-5" /> ) : !data.length ? (
Load more <div className="flex items-center justify-center">
</> Yo. You're catching up on all the things happening around you.
)} </div>
</button> ) : (
</div> data.map((item) => renderItem(item))
) : null} )}
</div> {data?.length && hasNextPage ? (
<div>
<button
type="button"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage || isLoading}
className="inline-flex items-center justify-center w-full gap-2 px-3 font-medium h-9 rounded-xl bg-black/5 hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
>
{isFetchingNextPage ? (
<Spinner className="size-5" />
) : (
<>
<ArrowRightCircleIcon className="size-5" />
Load more
</>
)}
</button>
</div>
) : null}
</Virtualizer>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
orientation="vertical"
>
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
</ScrollArea.Scrollbar>
<ScrollArea.Corner className="bg-transparent" />
</ScrollArea.Root>
); );
} }
+61 -43
View File
@@ -6,9 +6,10 @@ import { ArrowRightCircleIcon } from "@lume/icons";
import { type LumeEvent, NostrQuery } from "@lume/system"; import { type LumeEvent, NostrQuery } from "@lume/system";
import { type ColumnRouteSearch, Kind } from "@lume/types"; import { type ColumnRouteSearch, Kind } from "@lume/types";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { useInfiniteQuery } from "@tanstack/react-query"; import { useInfiniteQuery } from "@tanstack/react-query";
import { createFileRoute, redirect } from "@tanstack/react-router"; import { createFileRoute, redirect } from "@tanstack/react-router";
import { useCallback } from "react"; import { useCallback, useRef } from "react";
import { Virtualizer } from "virtua"; import { Virtualizer } from "virtua";
export const Route = createFileRoute("/group")({ export const Route = createFileRoute("/group")({
@@ -61,6 +62,8 @@ export function Screen() {
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}); });
const ref = useRef<HTMLDivElement>(null);
const renderItem = useCallback( const renderItem = useCallback(
(event: LumeEvent) => { (event: LumeEvent) => {
if (!event) return; if (!event) return;
@@ -84,48 +87,63 @@ export function Screen() {
); );
return ( return (
<div className="w-full h-full p-3 overflow-y-auto scrollbar-none"> <ScrollArea.Root
{isFetching && !isLoading && !isFetchingNextPage ? ( type={"scroll"}
<div className="flex items-center justify-center w-full mb-3 h-11 bg-black/10 dark:bg-white/10 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50"> scrollHideDelay={300}
<div className="flex items-center justify-center gap-2"> className="overflow-hidden size-full"
<Spinner className="size-5" /> >
<span className="text-sm font-medium">Fetching new notes...</span> <ScrollArea.Viewport ref={ref} className="h-full px-3 pb-3">
</div> <Virtualizer scrollRef={ref}>
</div> {isFetching && !isLoading && !isFetchingNextPage ? (
) : null} <div className="flex items-center justify-center w-full mb-3 h-12 bg-black/5 dark:bg-white/5 rounded-xl">
{isLoading ? ( <div className="flex items-center justify-center gap-2">
<div className="flex items-center justify-center w-full h-16 gap-2"> <Spinner className="size-5" />
<Spinner className="size-5" /> <span className="text-sm font-medium">
<span className="text-sm font-medium">Loading...</span> Getting new notes...
</div> </span>
) : !data.length ? ( </div>
<div className="flex items-center justify-center"> </div>
Yo. You're catching up on all the things happening around you. ) : null}
</div> {isLoading ? (
) : ( <div className="flex items-center justify-center w-full h-16 gap-2">
<Virtualizer overscan={3}>
{data.map((item) => renderItem(item))}
</Virtualizer>
)}
{data?.length && hasNextPage ? (
<div>
<button
type="button"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage || isLoading}
className="inline-flex items-center justify-center w-full gap-2 px-3 font-medium h-9 rounded-xl bg-black/5 hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
>
{isFetchingNextPage ? (
<Spinner className="size-5" /> <Spinner className="size-5" />
) : ( <span className="text-sm font-medium">Loading...</span>
<> </div>
<ArrowRightCircleIcon className="size-5" /> ) : !data.length ? (
Load more <div className="flex items-center justify-center">
</> Yo. You're catching up on all the things happening around you.
)} </div>
</button> ) : (
</div> data.map((item) => renderItem(item))
) : null} )}
</div> {data?.length && hasNextPage ? (
<div>
<button
type="button"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage || isLoading}
className="inline-flex items-center justify-center w-full gap-2 px-3 font-medium h-9 rounded-xl bg-black/5 hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
>
{isFetchingNextPage ? (
<Spinner className="size-5" />
) : (
<>
<ArrowRightCircleIcon className="size-5" />
Load more
</>
)}
</button>
</div>
) : null}
</Virtualizer>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
orientation="vertical"
>
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
</ScrollArea.Scrollbar>
<ScrollArea.Corner className="bg-transparent" />
</ScrollArea.Root>
); );
} }
+15 -8
View File
@@ -1,12 +1,12 @@
import { PlusIcon, RelayIcon } from "@lume/icons";
import { Spinner } from "@lume/ui";
import { User } from "@/components/user"; import { User } from "@/components/user";
import { PlusIcon, RelayIcon } from "@lume/icons";
import { NostrAccount } from "@lume/system";
import { Spinner } from "@lume/ui";
import { checkForAppUpdates, displayNpub } from "@lume/utils"; import { checkForAppUpdates, displayNpub } 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 { message } from "@tauri-apps/plugin-dialog";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner";
import { NostrAccount } from "@lume/system";
export const Route = createFileRoute("/")({ export const Route = createFileRoute("/")({
beforeLoad: async () => { beforeLoad: async () => {
@@ -51,7 +51,10 @@ function Screen() {
} }
} catch (e) { } catch (e) {
setLoading({ npub: "", status: false }); setLoading({ npub: "", status: false });
toast.error(String(e)); await message(String(e), {
title: "Account",
kind: "error",
});
} }
}; };
@@ -64,8 +67,12 @@ function Screen() {
return ( return (
<div <div
data-tauri-drag-region data-tauri-drag-region
className="flex flex-col items-center justify-between w-full h-full" className="relative flex flex-col items-center justify-between w-full h-full"
> >
<div
data-tauri-drag-region
className="absolute top-0 left-0 h-14 w-full"
/>
<div className="flex items-end justify-center flex-1 w-full px-4 pb-10"> <div className="flex items-end justify-center flex-1 w-full px-4 pb-10">
<div className="text-center"> <div className="text-center">
<h2 className="mb-1 text-lg text-neutral-700 dark:text-neutral-300"> <h2 className="mb-1 text-lg text-neutral-700 dark:text-neutral-300">
@@ -75,7 +82,7 @@ function Screen() {
</div> </div>
</div> </div>
<div className="flex flex-col items-center flex-1 w-full gap-3"> <div className="flex flex-col items-center flex-1 w-full gap-3">
<div className="flex flex-col w-full max-w-sm mx-auto overflow-hidden bg-white divide-y divide-neutral-100 dark:divide-white/5 rounded-xl shadow-primary backdrop-blur-lg dark:bg-white/10 dark:ring-1 ring-white/15"> <div className="flex flex-col w-full max-w-sm mx-auto overflow-hidden bg-white divide-y divide-neutral-100 dark:divide-white/5 rounded-xl shadow-primary dark:bg-white/10 dark:ring-1 ring-white/15">
{context.accounts.map((account) => ( {context.accounts.map((account) => (
<div <div
key={account} key={account}
@@ -85,7 +92,7 @@ function Screen() {
> >
<User.Provider pubkey={account}> <User.Provider pubkey={account}>
<User.Root className="flex items-center gap-2.5 p-3"> <User.Root className="flex items-center gap-2.5 p-3">
<User.Avatar className="object-cover rounded-full size-10 shrink-0" /> <User.Avatar className="rounded-full size-10" />
<div className="inline-flex flex-col items-start"> <div className="inline-flex flex-col items-start">
<User.Name className="max-w-[6rem] truncate font-medium leading-tight" /> <User.Name className="max-w-[6rem] truncate font-medium leading-tight" />
<span className="text-sm text-neutral-700 dark:text-neutral-300"> <span className="text-sm text-neutral-700 dark:text-neutral-300">
+1 -1
View File
@@ -12,7 +12,7 @@ function Screen() {
className="flex flex-col items-center justify-center w-screen h-screen" className="flex flex-col items-center justify-center w-screen h-screen"
> >
<div className="w-full max-w-xs mx-auto lg:max-w-md"> <div className="w-full max-w-xs mx-auto lg:max-w-md">
<div className="flex flex-col w-full gap-2 px-2 bg-white rounded-xl shadow-primary backdrop-blur-lg dark:bg-white/20 dark:ring-1 ring-neutral-800/50"> <div className="flex flex-col w-full gap-2 px-2 bg-white rounded-xl shadow-primary dark:bg-white/20 dark:ring-1 ring-neutral-800/50">
<div className="flex items-center h-20 border-b border-neutral-100 dark:border-white/5"> <div className="flex items-center h-20 border-b border-neutral-100 dark:border-white/5">
<Link <Link
to="/auth/create-profile" to="/auth/create-profile"
+143 -47
View File
@@ -2,15 +2,23 @@ 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 } from "@lume/icons"; import { ArrowRightCircleIcon, ArrowUpIcon } from "@lume/icons";
import { type LumeEvent, NostrAccount, NostrQuery } from "@lume/system"; import { LumeEvent, NostrAccount, NostrQuery } from "@lume/system";
import { type ColumnRouteSearch, Kind } from "@lume/types"; import { type ColumnRouteSearch, Kind, type Meta } from "@lume/types";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { useInfiniteQuery } from "@tanstack/react-query"; import * as ScrollArea from "@radix-ui/react-scroll-area";
import { type InfiniteData, useInfiniteQuery } from "@tanstack/react-query";
import { createFileRoute, redirect } from "@tanstack/react-router"; import { createFileRoute, redirect } from "@tanstack/react-router";
import { useCallback } from "react"; import { listen } from "@tauri-apps/api/event";
import { getCurrent } from "@tauri-apps/api/window";
import { useCallback, useEffect, useRef, useState } from "react";
import { Virtualizer } from "virtua"; import { Virtualizer } from "virtua";
type Payload = {
raw: string;
parsed: Meta;
};
export const Route = createFileRoute("/newsfeed")({ export const Route = createFileRoute("/newsfeed")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => { validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
return { return {
@@ -39,6 +47,7 @@ export const Route = createFileRoute("/newsfeed")({
}); });
export function Screen() { export function Screen() {
const { queryClient } = Route.useRouteContext();
const { label, account } = Route.useSearch(); const { label, account } = Route.useSearch();
const { const {
data, data,
@@ -56,9 +65,10 @@ export function Screen() {
}, },
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1, getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
select: (data) => data?.pages.flat(), select: (data) => data?.pages.flat(),
refetchOnWindowFocus: false,
}); });
const ref = useRef<HTMLDivElement>(null);
const renderItem = useCallback( const renderItem = useCallback(
(event: LumeEvent) => { (event: LumeEvent) => {
if (!event) return; if (!event) return;
@@ -81,49 +91,135 @@ export function Screen() {
[data], [data],
); );
useEffect(() => {
const unlisten = listen("synced", async () => {
await queryClient.invalidateQueries({ queryKey: [label, account] });
});
return () => {
unlisten.then((f) => f());
};
}, []);
return ( return (
<div className="w-full h-full p-3 overflow-y-auto scrollbar-none"> <ScrollArea.Root
{isFetching && !isLoading && !isFetchingNextPage ? ( type={"scroll"}
<div className="flex items-center justify-center w-full mb-3 h-11 bg-black/10 dark:bg-white/10 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50"> scrollHideDelay={300}
<div className="flex items-center justify-center gap-2"> className="overflow-hidden size-full"
<Spinner className="size-5" /> >
<span className="text-sm font-medium">Fetching new notes...</span> <ScrollArea.Viewport ref={ref} className="relative h-full px-3 pb-3">
</div> <Listerner />
</div> <Virtualizer scrollRef={ref}>
) : null} {isFetching && !isLoading && !isFetchingNextPage ? (
{isLoading ? ( <div className="flex items-center justify-center w-full mb-3 h-12 bg-black/5 dark:bg-white/5 rounded-xl">
<div className="flex items-center justify-center w-full h-16 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">Loading...</span> <span className="text-sm font-medium">
</div> Getting new notes...
) : !data.length ? ( </span>
<div className="flex items-center justify-center"> </div>
Yo. You're catching up on all the things happening around you. </div>
</div> ) : null}
) : ( {isLoading ? (
<Virtualizer overscan={3}> <div className="flex items-center justify-center w-full h-16 gap-2">
{data.map((item) => renderItem(item))}
</Virtualizer>
)}
{data?.length && hasNextPage ? (
<div>
<button
type="button"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage || isLoading}
className="inline-flex items-center justify-center w-full gap-2 px-3 font-medium h-9 rounded-xl bg-black/5 hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
>
{isFetchingNextPage ? (
<Spinner className="size-5" /> <Spinner className="size-5" />
) : ( <span className="text-sm font-medium">Loading...</span>
<> </div>
<ArrowRightCircleIcon className="size-5" /> ) : !data.length ? (
Load more <div className="flex items-center justify-center">
</> Yo. You're catching up on all the things happening around you.
)} </div>
</button> ) : (
</div> data.map((item) => renderItem(item))
) : null} )}
{data?.length && hasNextPage ? (
<div>
<button
type="button"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage || isLoading}
className="inline-flex items-center justify-center w-full gap-2 px-3 font-medium h-9 rounded-xl bg-black/5 hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
>
{isFetchingNextPage ? (
<Spinner className="size-5" />
) : (
<>
<ArrowRightCircleIcon className="size-5" />
Load more
</>
)}
</button>
</div>
) : null}
</Virtualizer>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
orientation="vertical"
>
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
</ScrollArea.Scrollbar>
<ScrollArea.Corner className="bg-transparent" />
</ScrollArea.Root>
);
}
function Listerner() {
const { queryClient } = Route.useRouteContext();
const { label, account } = Route.useSearch();
const [events, setEvents] = useState<LumeEvent[]>([]);
const pushNewEvents = async () => {
await queryClient.setQueryData(
[label, account],
(oldData: InfiniteData<LumeEvent[], number> | undefined) => {
const firstPage = oldData?.pages[0];
if (firstPage) {
return {
...oldData,
pages: [
{
...firstPage,
posts: [...events, ...firstPage],
},
...oldData.pages.slice(1),
],
};
}
},
);
await queryClient.invalidateQueries({ queryKey: [label, account] });
};
useEffect(() => {
const unlisten = getCurrent().listen<Payload>("new_event", (data) => {
const event = LumeEvent.from(data.payload.raw, data.payload.parsed);
setEvents((prev) => [event, ...prev]);
});
NostrQuery.listenLocalEvent().then(() => console.log("listen"));
return () => {
unlisten.then((f) => f());
NostrQuery.unlisten().then(() => console.log("unlisten"));
};
}, []);
if (!events?.length) return null;
return (
<div className="z-50 fixed top-0 left-0 w-full h-14 flex items-center justify-center px-3">
<button
type="button"
onClick={() => pushNewEvents()}
className="w-max h-8 pl-2 pr-3 inline-flex items-center justify-center gap-1.5 rounded-full shadow-lg text-sm font-medium text-white bg-black dark:text-black dark:bg-white"
>
<ArrowUpIcon className="size-4" />
{events.length} new notes
</button>
</div> </div>
); );
} }
-61
View File
@@ -1,61 +0,0 @@
import { ZapIcon } from "@lume/icons";
import { NostrAccount } from "@lume/system";
import { Container } from "@lume/ui";
import { createLazyFileRoute } from "@tanstack/react-router";
import { useState } from "react";
export const Route = createLazyFileRoute("/nwc")({
component: Screen,
});
function Screen() {
const [uri, setUri] = useState("");
const [isDone, setIsDone] = useState(false);
const save = async () => {
const nwc = await NostrAccount.setWallet(uri);
setIsDone(nwc);
};
return (
<Container withDrag>
<div className="h-full w-full flex-1 px-5">
{!isDone ? (
<>
<div className="flex flex-col gap-2">
<div className="inline-flex size-14 items-center justify-center rounded-xl bg-black text-white shadow-md">
<ZapIcon className="size-5" />
</div>
<div>
<h3 className="text-2xl font-light">
Connect <span className="font-semibold">bitcoin wallet</span>{" "}
to start zapping to your favorite content and creator.
</h3>
</div>
</div>
<div className="mt-10 flex flex-col gap-2">
<div className="flex flex-col gap-1.5">
<label>Paste a Nostr Wallet Connect connection string</label>
<textarea
value={uri}
onChange={(e) => setUri(e.target.value)}
placeholder="nostrconnect://"
className="h-24 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"
/>
</div>
<button
type="button"
onClick={save}
className="inline-flex h-11 w-full items-center justify-center gap-1.5 rounded-lg bg-blue-500 px-5 font-medium text-white hover:bg-blue-600"
>
Save & Connect
</button>
</div>
</>
) : (
<div>Done</div>
)}
</div>
</Container>
);
}
+4 -4
View File
@@ -22,7 +22,7 @@ function Screen() {
</p> </p>
</div> </div>
<div className="px-3 flex flex-col gap-3"> <div className="px-3 flex flex-col gap-3">
<div className="relative flex flex-col items-center justify-center rounded-xl bg-black/10 dark:bg-white/10 backdrop-blur-lg"> <div className="relative flex flex-col items-center justify-center rounded-xl bg-black/10 dark:bg-white/10">
<div className="absolute top-2 left-3 text-2xl font-semibold font-serif text-neutral-600 dark:text-neutral-400"> <div className="absolute top-2 left-3 text-2xl font-semibold font-serif text-neutral-600 dark:text-neutral-400">
01. 01.
</div> </div>
@@ -45,7 +45,7 @@ function Screen() {
</video> </video>
</div> </div>
</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="relative flex flex-col items-center justify-center rounded-xl bg-black/10 dark:bg-white/10">
<div className="absolute top-2 left-3 text-2xl font-semibold font-serif text-neutral-600 dark:text-neutral-400"> <div className="absolute top-2 left-3 text-2xl font-semibold font-serif text-neutral-600 dark:text-neutral-400">
02. 02.
</div> </div>
@@ -68,7 +68,7 @@ function Screen() {
</video> </video>
</div> </div>
</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="relative flex flex-col items-center justify-center rounded-xl bg-black/10 dark:bg-white/10">
<div className="absolute top-2 left-3 text-2xl font-semibold font-serif text-neutral-600 dark:text-neutral-400"> <div className="absolute top-2 left-3 text-2xl font-semibold font-serif text-neutral-600 dark:text-neutral-400">
03. 03.
</div> </div>
@@ -91,7 +91,7 @@ function Screen() {
</video> </video>
</div> </div>
</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="relative flex flex-col items-center justify-center rounded-xl bg-black/10 dark:bg-white/10">
<div className="absolute top-2 left-3 text-2xl font-semibold font-serif text-neutral-600 dark:text-neutral-400"> <div className="absolute top-2 left-3 text-2xl font-semibold font-serif text-neutral-600 dark:text-neutral-400">
04. 04.
</div> </div>
@@ -1,82 +1,74 @@
import { Note } from "@/components/note"; import { Note } from "@/components/note";
import { User } from "@/components/user"; import { User } from "@/components/user";
import { import { HorizontalDotsIcon, InfoIcon, RepostIcon } from "@lume/icons";
HorizontalDotsIcon,
InfoIcon,
RepostIcon,
SearchIcon,
} from "@lume/icons";
import { type LumeEvent, LumeWindow, NostrQuery, useEvent } from "@lume/system"; import { type LumeEvent, LumeWindow, NostrQuery, useEvent } from "@lume/system";
import { Kind } from "@lume/types"; import { Kind } from "@lume/types";
import { Spinner } from "@lume/ui";
import { import {
checkForAppUpdates, checkForAppUpdates,
decodeZapInvoice, decodeZapInvoice,
formatCreatedAt, formatCreatedAt,
} from "@lume/utils"; } from "@lume/utils";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import * as Tabs from "@radix-ui/react-tabs"; import * as Tabs from "@radix-ui/react-tabs";
import { useQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core";
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu"; import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
import { getCurrent } from "@tauri-apps/api/window"; import { getCurrent } from "@tauri-apps/api/window";
import { exit } from "@tauri-apps/plugin-process";
import { open } from "@tauri-apps/plugin-shell"; import { open } from "@tauri-apps/plugin-shell";
import { useCallback, useEffect, useMemo, useState } from "react"; import { type ReactNode, useCallback, useEffect, useRef } from "react";
import { Virtualizer } from "virtua";
interface EmitAccount { export const Route = createFileRoute("/panel/$account")({
account: string;
}
export const Route = createFileRoute("/panel")({
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const [account, setAccount] = useState<string>(null); const { account } = Route.useParams();
const [events, setEvents] = useState<LumeEvent[]>([]); const { queryClient } = Route.useRouteContext();
const { isLoading, data } = useQuery({
queryKey: ["notification", account],
queryFn: async () => {
const events = await NostrQuery.getNotifications();
return events;
},
select: (events) => {
const zaps = new Map<string, LumeEvent[]>();
const reactions = new Map<string, LumeEvent[]>();
const texts = events.filter((ev) => ev.kind === Kind.Text);
const zapEvents = events.filter((ev) => ev.kind === Kind.ZapReceipt);
const reactEvents = events.filter(
(ev) => ev.kind === Kind.Repost || ev.kind === Kind.Reaction,
);
const texts = useMemo( for (const event of reactEvents) {
() => events.filter((ev) => ev.kind === Kind.Text), const rootId = event.tags.filter((tag) => tag[0] === "e")[0]?.[1];
[events],
);
const zaps = useMemo(() => { if (rootId) {
const groups = new Map<string, LumeEvent[]>(); if (reactions.has(rootId)) {
const list = events.filter((ev) => ev.kind === Kind.ZapReceipt); reactions.get(rootId).push(event);
} else {
for (const event of list) { reactions.set(rootId, [event]);
const rootId = event.tags.filter((tag) => tag[0] === "e")[0]?.[1]; }
if (rootId) {
if (groups.has(rootId)) {
groups.get(rootId).push(event);
} else {
groups.set(rootId, [event]);
} }
} }
}
return groups; for (const event of zapEvents) {
}, [events]); const rootId = event.tags.filter((tag) => tag[0] === "e")[0]?.[1];
const reactions = useMemo(() => { if (rootId) {
const groups = new Map<string, LumeEvent[]>(); if (zaps.has(rootId)) {
const list = events.filter( zaps.get(rootId).push(event);
(ev) => ev.kind === Kind.Repost || ev.kind === Kind.Reaction, } else {
); zaps.set(rootId, [event]);
}
for (const event of list) {
const rootId = event.tags.filter((tag) => tag[0] === "e")[0]?.[1];
if (rootId) {
if (groups.has(rootId)) {
groups.get(rootId).push(event);
} else {
groups.set(rootId, [event]);
} }
} }
}
return groups; return { texts, zaps, reactions };
}, [events]); },
});
const showContextMenu = useCallback(async (e: React.MouseEvent) => { const showContextMenu = useCallback(async (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
@@ -90,10 +82,6 @@ function Screen() {
text: "New Post", text: "New Post",
action: () => LumeWindow.openEditor(), action: () => LumeWindow.openEditor(),
}), }),
MenuItem.new({
text: "Search",
action: () => LumeWindow.openSearch(),
}),
PredefinedMenuItem.new({ item: "Separator" }), PredefinedMenuItem.new({ item: "Separator" }),
MenuItem.new({ MenuItem.new({
text: "About Lume", text: "About Lume",
@@ -110,7 +98,7 @@ function Screen() {
PredefinedMenuItem.new({ item: "Separator" }), PredefinedMenuItem.new({ item: "Separator" }),
MenuItem.new({ MenuItem.new({
text: "Quit", text: "Quit",
action: async () => await exit(0), action: async () => await invoke("force_quit"),
}), }),
]); ]);
@@ -122,46 +110,30 @@ function Screen() {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (account?.length && account?.startsWith("npub1")) { const unlisten = getCurrent().listen("notification", async (data) => {
NostrQuery.getNotifications()
.then((data) => {
const sorted = data.sort((a, b) => b.created_at - a.created_at);
setEvents(sorted);
})
.catch((e) => console.log(e));
}
}, [account]);
useEffect(() => {
const unlistenLoad = getCurrent().listen<EmitAccount>(
"load-notification",
(data) => {
setAccount(data.payload.account);
},
);
const unlistenNewEvent = getCurrent().listen("notification", (data) => {
const event: LumeEvent = JSON.parse(data.payload as string); const event: LumeEvent = JSON.parse(data.payload as string);
setEvents((prev) => [event, ...prev]); await queryClient.setQueryData(
["notification", account],
(data: LumeEvent[]) => [event, ...data],
);
}); });
return () => { return () => {
unlistenLoad.then((f) => f()); unlisten.then((f) => f());
unlistenNewEvent.then((f) => f());
}; };
}, []); }, []);
if (!account) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center w-full h-full text-sm"> <div className="size-full flex items-center justify-center">
Please log in. <Spinner />
</div> </div>
); );
} }
return ( return (
<div className="flex flex-col w-full h-full"> <div className="flex flex-col size-full overflow-hidden">
<div className="flex items-center justify-between px-4 border-b h-11 shrink-0 border-black/5"> <div className="flex items-center justify-between px-4 border-b h-11 shrink-0 border-black/5 dark:border-white/5">
<div> <div>
<h1 className="text-sm font-semibold">Notifications</h1> <h1 className="text-sm font-semibold">Notifications</h1>
</div> </div>
@@ -171,13 +143,6 @@ function Screen() {
<User.Avatar className="rounded-full size-7" /> <User.Avatar className="rounded-full size-7" />
</User.Root> </User.Root>
</User.Provider> </User.Provider>
<button
type="button"
onClick={() => LumeWindow.openSearch()}
className="inline-flex items-center justify-center rounded-full size-7 bg-black/5 dark:bg-white/5"
>
<SearchIcon className="size-4" />
</button>
<button <button
type="button" type="button"
onClick={(e) => showContextMenu(e)} onClick={(e) => showContextMenu(e)}
@@ -187,41 +152,43 @@ function Screen() {
</button> </button>
</div> </div>
</div> </div>
<Tabs.Root <Tabs.Root defaultValue="replies" className="flex flex-col h-full">
defaultValue="replies" <Tabs.List className="h-8 shrink-0 flex items-center">
className="flex-1 overflow-x-hidden overflow-y-auto scrollbar-none"
>
<Tabs.List className="flex items-center">
<Tabs.Trigger <Tabs.Trigger
className="flex-1 inline-flex h-8 items-center justify-center gap-2 px-2 text-sm font-medium border-b border-black/10 data-[state=active]:border-black/30 dark:data-[state=active] data-[state=inactive]:opacity-50" className="flex-1 inline-flex h-8 items-center justify-center gap-2 px-2 text-sm font-medium border-b border-black/10 dark:border-white/10 data-[state=active]:border-black/30 dark:data-[state=active]:border-white/30 data-[state=inactive]:opacity-50"
value="replies" value="replies"
> >
Replies Replies
</Tabs.Trigger> </Tabs.Trigger>
<Tabs.Trigger <Tabs.Trigger
className="flex-1 inline-flex h-8 items-center justify-center gap-2 px-2 text-sm font-medium border-b border-black/10 data-[state=active]:border-black/30 dark:data-[state=active] data-[state=inactive]:opacity-50" className="flex-1 inline-flex h-8 items-center justify-center gap-2 px-2 text-sm font-medium border-b border-black/10 dark:border-white/10 data-[state=active]:border-black/30 dark:data-[state=active]:border-white/30 data-[state=inactive]:opacity-50"
value="reactions" value="reactions"
> >
Reactions Reactions
</Tabs.Trigger> </Tabs.Trigger>
<Tabs.Trigger <Tabs.Trigger
className="flex-1 inline-flex h-8 items-center justify-center gap-2 px-2 text-sm font-medium border-b border-black/10 data-[state=active]:border-black/30 dark:data-[state=active] data-[state=inactive]:opacity-50" className="flex-1 inline-flex h-8 items-center justify-center gap-2 px-2 text-sm font-medium border-b border-black/10 dark:border-white/10 data-[state=active]:border-black/30 dark:data-[state=active]:border-white/30 data-[state=inactive]:opacity-50"
value="zaps" value="zaps"
> >
Zaps Zaps
</Tabs.Trigger> </Tabs.Trigger>
</Tabs.List> </Tabs.List>
<div className="p-2"> <ScrollArea.Root
<Tabs.Content value="replies" className="flex flex-col gap-2"> type={"scroll"}
{texts.map((event) => ( scrollHideDelay={300}
<TextNote key={event.id} event={event} /> className="min-h-0 flex-1 overflow-x-hidden"
>
<Tab value="replies">
{data.texts.map((event, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
<TextNote key={event.id + index} event={event} />
))} ))}
</Tabs.Content> </Tab>
<Tabs.Content value="reactions" className="flex flex-col gap-2"> <Tab value="reactions">
{[...reactions.entries()].map(([root, events]) => ( {[...data.reactions.entries()].map(([root, events]) => (
<div <div
key={root} key={root}
className="flex flex-col gap-1 p-2 rounded-lg shrink-0 backdrop-blur-md bg-black/10 dark:bg-white/10" className="flex flex-col gap-1 p-2 mb-2 rounded-lg shrink-0 bg-black/10 dark:bg-white/10"
> >
<div className="flex flex-col flex-1 min-w-0 gap-2"> <div className="flex flex-col flex-1 min-w-0 gap-2">
<div className="flex items-center gap-2 pb-2 border-b border-black/5 dark:border-white/5"> <div className="flex items-center gap-2 pb-2 border-b border-black/5 dark:border-white/5">
@@ -230,7 +197,7 @@ function Screen() {
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
{events.map((event) => ( {events.map((event) => (
<User.Provider key={event.id} pubkey={event.pubkey}> <User.Provider key={event.id} pubkey={event.pubkey}>
<User.Root className="shrink-0 flex rounded-full h-8 bg-black/10 dark:bg-white/10 backdrop-blur-md p-[2px]"> <User.Root className="shrink-0 flex rounded-full h-8 bg-black/10 dark:bg-white/10 p-[2px]">
<User.Avatar className="flex-1 rounded-full size-7" /> <User.Avatar className="flex-1 rounded-full size-7" />
<div className="inline-flex items-center justify-center flex-1 text-xs truncate rounded-full size-7"> <div className="inline-flex items-center justify-center flex-1 text-xs truncate rounded-full size-7">
{event.kind === Kind.Reaction ? ( {event.kind === Kind.Reaction ? (
@@ -250,12 +217,12 @@ function Screen() {
</div> </div>
</div> </div>
))} ))}
</Tabs.Content> </Tab>
<Tabs.Content value="zaps" className="flex flex-col gap-2"> <Tab value="zaps">
{[...zaps.entries()].map(([root, events]) => ( {[...data.zaps.entries()].map(([root, events]) => (
<div <div
key={root} key={root}
className="flex flex-col gap-1 p-2 rounded-lg shrink-0 backdrop-blur-md bg-black/10 dark:bg-white/10" className="flex flex-col gap-1 p-2 mb-2 rounded-lg shrink-0 bg-black/10 dark:bg-white/10"
> >
<div className="flex flex-col flex-1 min-w-0 gap-2"> <div className="flex flex-col flex-1 min-w-0 gap-2">
<div className="flex items-center gap-2 pb-2 border-b border-black/5 dark:border-white/5"> <div className="flex items-center gap-2 pb-2 border-b border-black/5 dark:border-white/5">
@@ -267,8 +234,8 @@ function Screen() {
key={event.id} key={event.id}
pubkey={event.tags.find((tag) => tag[0] === "P")[1]} pubkey={event.tags.find((tag) => tag[0] === "P")[1]}
> >
<User.Root className="shrink-0 flex gap-1.5 rounded-full h-8 bg-black/10 dark:bg-white/10 backdrop-blur-md p-[2px]"> <User.Root className="shrink-0 flex gap-1.5 rounded-full h-8 bg-black/10 dark:bg-white/10 p-[2px]">
<User.Avatar className="flex-1 rounded-full size-7" /> <User.Avatar className="rounded-full size-7" />
<div className="flex-1 h-7 w-max pr-1.5 rounded-full inline-flex items-center justify-center text-sm truncate"> <div className="flex-1 h-7 w-max pr-1.5 rounded-full inline-flex items-center justify-center text-sm truncate">
{decodeZapInvoice(event.tags).bitcoinFormatted} {decodeZapInvoice(event.tags).bitcoinFormatted}
</div> </div>
@@ -279,13 +246,32 @@ function Screen() {
</div> </div>
</div> </div>
))} ))}
</Tabs.Content> </Tab>
</div> <ScrollArea.Scrollbar
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
orientation="vertical"
>
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
</ScrollArea.Scrollbar>
<ScrollArea.Corner className="bg-transparent" />
</ScrollArea.Root>
</Tabs.Root> </Tabs.Root>
</div> </div>
); );
} }
function Tab({ value, children }: { value: string; children: ReactNode[] }) {
const ref = useRef<HTMLDivElement>(null);
return (
<Tabs.Content value={value} className="size-full">
<ScrollArea.Viewport ref={ref} className="h-full px-2 pt-2">
<Virtualizer scrollRef={ref}>{children}</Virtualizer>
</ScrollArea.Viewport>
</Tabs.Content>
);
}
function RootNote({ id }: { id: string }) { function RootNote({ id }: { id: string }) {
const { isLoading, isError, data } = useEvent(id); const { isLoading, isError, data } = useEvent(id);
@@ -316,7 +302,7 @@ function RootNote({ id }: { id: string }) {
<Note.Root className="flex items-center gap-2"> <Note.Root className="flex items-center gap-2">
<User.Provider pubkey={data.pubkey}> <User.Provider pubkey={data.pubkey}>
<User.Root className="shrink-0"> <User.Root className="shrink-0">
<User.Avatar className="rounded-full size-8 shrink-0" /> <User.Avatar className="rounded-full size-8" />
</User.Root> </User.Root>
</User.Provider> </User.Provider>
<div className="line-clamp-1">{data.content}</div> <div className="line-clamp-1">{data.content}</div>
@@ -332,46 +318,40 @@ function TextNote({ event }: { event: LumeEvent }) {
.slice(0, 3); .slice(0, 3);
return ( return (
<button <Note.Provider event={event}>
type="button" <Note.Root className="flex flex-col p-2 mb-2 rounded-lg shrink-0 bg-black/10 dark:bg-white/10">
key={event.id} <User.Provider pubkey={event.pubkey}>
onClick={() => LumeWindow.openEvent(event)} <User.Root className="inline-flex items-center gap-2">
> <User.Avatar className="rounded-full size-9" />
<Note.Provider event={event}> <div className="flex flex-col flex-1">
<Note.Root className="flex flex-col p-2 rounded-lg shrink-0 backdrop-blur-md bg-black/10 dark:bg-white/10"> <div className="flex items-baseline justify-between w-full">
<User.Provider pubkey={event.pubkey}> <User.Name className="text-sm font-semibold leading-tight" />
<User.Root className="inline-flex items-center gap-2"> <span className="text-sm leading-tight text-black/50 dark:text-white/50">
<User.Avatar className="rounded-full size-9 shrink-0" /> {formatCreatedAt(event.created_at)}
<div className="flex flex-col flex-1"> </span>
<div className="flex items-baseline justify-between w-full"> </div>
<User.Name className="text-sm font-semibold leading-tight" /> <div className="inline-flex items-baseline gap-1 text-xs">
<span className="text-sm leading-tight text-black/50 dark:text-white/50"> <span className="leading-tight text-black/50 dark:text-white/50">
{formatCreatedAt(event.created_at)} Reply to:
</span> </span>
</div> <div className="inline-flex items-baseline gap-1">
<div className="inline-flex items-baseline gap-1 text-xs"> {[...new Set(pTags)].map((replyTo) => (
<span className="leading-tight text-black/50 dark:text-white/50"> <User.Provider key={replyTo} pubkey={replyTo}>
Reply to: <User.Root>
</span> <User.Name className="font-medium leading-tight" />
<div className="inline-flex items-baseline gap-1"> </User.Root>
{pTags.map((replyTo) => ( </User.Provider>
<User.Provider key={replyTo} pubkey={replyTo}> ))}
<User.Root>
<User.Name className="font-medium leading-tight" />
</User.Root>
</User.Provider>
))}
</div>
</div> </div>
</div> </div>
</User.Root> </div>
</User.Provider> </User.Root>
<div className="flex gap-2"> </User.Provider>
<div className="w-9 shrink-0" /> <div className="flex gap-2">
<div className="line-clamp-1 text-start">{event.content}</div> <div className="w-9 shrink-0" />
</div> <div className="line-clamp-1 text-start">{event.content}</div>
</Note.Root> </div>
</Note.Provider> </Note.Root>
</button> </Note.Provider>
); );
} }
+96
View File
@@ -0,0 +1,96 @@
import { Conversation } from "@/components/conversation";
import { Quote } from "@/components/quote";
import { RepostNote } from "@/components/repost";
import { TextNote } from "@/components/text";
import { LumeEvent, NostrQuery } from "@lume/system";
import { Kind, type NostrEvent } from "@lume/types";
import { Spinner } from "@lume/ui";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { useQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { fetch } from "@tauri-apps/plugin-http";
import { useCallback, useRef } from "react";
import { Virtualizer } from "virtua";
type Search = {
query: string;
};
export const Route = createFileRoute("/search/notes")({
validateSearch: (search: Record<string, string>): Search => {
return {
query: search.query,
};
},
beforeLoad: async () => {
const settings = await NostrQuery.getUserSettings();
return { settings };
},
component: Screen,
});
function Screen() {
const { query } = Route.useSearch();
const { isLoading, data } = useQuery({
queryKey: ["search", query],
queryFn: async () => {
try {
const res = await fetch(
`https://api.nostr.wine/search?query=${query}&kind=1&limit=50`,
);
const content = await res.json();
const events = content.data as NostrEvent[];
const lumeEvents = await Promise.all(
events.map(async (item): Promise<LumeEvent> => {
const event = await LumeEvent.build(item);
return event;
}),
);
return lumeEvents.sort((a, b) => b.created_at - a.created_at);
} catch (e) {
throw new Error(e);
}
},
refetchOnWindowFocus: false,
});
const ref = useRef<HTMLDivElement>(null);
const renderItem = useCallback(
(event: LumeEvent) => {
if (!event) return;
switch (event.kind) {
case Kind.Repost:
return <RepostNote key={event.id} event={event} className="mb-3" />;
default: {
if (event.isConversation) {
return (
<Conversation key={event.id} className="mb-3" event={event} />
);
}
if (event.isQuote) {
return <Quote key={event.id} event={event} className="mb-3" />;
}
return <TextNote key={event.id} event={event} className="mb-3" />;
}
}
},
[data],
);
return (
<ScrollArea.Viewport ref={ref} className="h-full p-3">
<Virtualizer scrollRef={ref}>
{isLoading ? (
<div className="flex items-center justify-center w-full h-11 gap-2">
<Spinner className="size-5" />
<span className="text-sm font-medium">Searching...</span>
</div>
) : (
data.map((item) => renderItem(item))
)}
</Virtualizer>
</ScrollArea.Viewport>
);
}
+32 -133
View File
@@ -1,145 +1,44 @@
import { SearchIcon } from "@lume/icons"; import * as ScrollArea from "@radix-ui/react-scroll-area";
import { type NostrEvent, Kind } from "@lume/types"; import { Outlet, createFileRoute } from "@tanstack/react-router";
import { Spinner } from "@lume/ui";
import { Note } from "@/components/note"; type Search = {
import { User } from "@/components/user"; query: string;
import { createFileRoute } from "@tanstack/react-router"; };
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { useDebounce } from "use-debounce";
import { LumeEvent, LumeWindow } from "@lume/system";
export const Route = createFileRoute("/search")({ export const Route = createFileRoute("/search")({
validateSearch: (search: Record<string, string>): Search => {
return {
query: search.query,
};
},
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const [loading, setLoading] = useState(false); const { query } = Route.useSearch();
const [events, setEvents] = useState<LumeEvent[]>([]);
const [search, setSearch] = useState("");
const [searchValue] = useDebounce(search, 500);
const searchEvents = async () => {
try {
setLoading(true);
const query = `https://api.nostr.wine/search?query=${searchValue}&kind=0,1`;
const res = await fetch(query);
const content = await res.json();
const events = content.data as NostrEvent[];
const lumeEvents = events.map((ev) => new LumeEvent(ev));
const sorted = lumeEvents.sort((a, b) => b.created_at - a.created_at);
setLoading(false);
setEvents(sorted);
} catch (e) {
setLoading(false);
toast.error(String(e));
}
};
useEffect(() => {
if (searchValue.length >= 3 && searchValue.length < 500) {
searchEvents();
}
}, [searchValue]);
return ( return (
<div data-tauri-drag-region className="flex flex-col w-full h-full"> <div className="flex flex-col h-full">
<div className="relative flex flex-col h-24 border-b shrink-0 border-black/5 dark:border-white/5"> <div
<div data-tauri-drag-region className="w-full h-4 shrink-0" /> data-tauri-drag-region
<input className="shrink-0 flex items-end gap-1 h-20 px-3 pb-3 w-full border-b border-black/10 dark:border-white/10"
value={search} >
onChange={(e) => setSearch(e.target.value)} Search result for: <span className="font-semibold">{query}</span>
onKeyDown={(e) => {
if (e.key === "Enter") searchEvents();
}}
placeholder="Search anything..."
className="w-full h-20 px-3 pt-10 text-lg bg-transparent border-none focus:outline-none focus:ring-0 placeholder:text-neutral-500 dark:placeholder:text-neutral-600"
/>
</div>
<div className="flex-1 p-3 overflow-y-auto scrollbar-none">
{loading ? (
<div className="flex items-center justify-center w-full h-full">
<Spinner />
</div>
) : events.length ? (
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-1.5">
<div className="text-sm font-medium text-neutral-700 dark:text-neutral-300 shrink-0">
Users
</div>
<div className="flex flex-col flex-1 gap-1">
{events
.filter((ev) => ev.kind === Kind.Metadata)
.map((event) => (
<SearchUser key={event.pubkey} event={event} />
))}
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="text-sm font-medium text-neutral-700 dark:text-neutral-300 shrink-0">
Notes
</div>
<div className="flex flex-col flex-1 gap-3">
{events
.filter((ev) => ev.kind === Kind.Text)
.map((event) => (
<SearchNote key={event.id} event={event} />
))}
</div>
</div>
</div>
) : null}
{!loading && !events.length ? (
<div className="flex flex-col items-center justify-center h-full gap-3">
<div className="inline-flex items-center justify-center rounded-full size-16 bg-black/10 dark:bg-white/10">
<SearchIcon className="size-6" />
</div>
Try searching for people, notes, or keywords
</div>
) : null}
</div> </div>
</div> <ScrollArea.Root
); type={"scroll"}
} scrollHideDelay={300}
className="overflow-hidden size-full flex-1"
function SearchUser({ event }: { event: LumeEvent }) { >
return ( <Outlet />
<button <ScrollArea.Scrollbar
key={event.id} className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
type="button" orientation="vertical"
onClick={() => LumeWindow.openProfile(event.pubkey)} >
className="col-span-1 p-2 rounded-lg hover:bg-black/10 dark:hover:bg-white/10" <ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
> </ScrollArea.Scrollbar>
<User.Provider pubkey={event.pubkey} embedProfile={event.content}> <ScrollArea.Corner className="bg-transparent" />
<User.Root className="flex items-center gap-2"> </ScrollArea.Root>
<User.Avatar className="rounded-full size-9 shrink-0" />
<div className="inline-flex items-center gap-1.5">
<User.Name className="font-semibold" />
<User.NIP05 />
</div>
</User.Root>
</User.Provider>
</button>
);
}
function SearchNote({ event }: { event: LumeEvent }) {
return (
<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.Root>
<div className="flex items-center justify-between px-3 h-14">
<Note.User />
<Note.Menu />
</div>
<Note.Content className="px-3" quote={false} mention={false} />
<div className="flex items-center gap-4 px-3 mt-3 h-14">
<Note.Open />
</div>
</Note.Root>
</Note.Provider>
</div> </div>
); );
} }
+101
View File
@@ -0,0 +1,101 @@
import { User } from "@/components/user";
import { LumeWindow, NostrQuery } from "@lume/system";
import type { NostrEvent } from "@lume/types";
import { Spinner } from "@lume/ui";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { useQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { fetch } from "@tauri-apps/plugin-http";
import { useRef } from "react";
import { Virtualizer } from "virtua";
type Search = {
query: string;
};
type UserItem = {
pubkey: string;
profile: string;
};
export const Route = createFileRoute("/search/users")({
validateSearch: (search: Record<string, string>): Search => {
return {
query: search.query,
};
},
beforeLoad: async () => {
const settings = await NostrQuery.getUserSettings();
return { settings };
},
component: Screen,
});
function Screen() {
const { query } = Route.useSearch();
const { isLoading, data } = useQuery({
queryKey: ["search", query],
queryFn: async () => {
try {
const res = await fetch(
`https://api.nostr.wine/search?query=${query}&kind=0&limit=100`,
);
const content = await res.json();
const events = content.data as NostrEvent[];
const users: UserItem[] = events.map((ev) => ({
pubkey: ev.pubkey,
profile: ev.content,
}));
return users;
} catch (e) {
throw new Error(e);
}
},
refetchOnWindowFocus: false,
});
const ref = useRef<HTMLDivElement>(null);
return (
<ScrollArea.Viewport ref={ref} className="h-full px-3 pt-3">
<Virtualizer scrollRef={ref}>
{isLoading ? (
<div className="flex items-center justify-center w-full h-11 gap-2">
<Spinner className="size-5" />
<span className="text-sm font-medium">Searching...</span>
</div>
) : (
data.map((item) => (
<div
key={item.pubkey}
className="w-full p-3 mb-2 overflow-hidden bg-white rounded-lg h-max dark:bg-black/20 shadow-primary dark:ring-1 ring-neutral-800/50"
>
<User.Provider pubkey={item.pubkey} embedProfile={item.profile}>
<User.Root className="flex flex-col w-full h-full gap-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<User.Avatar className="rounded-full size-7" />
<div className="inline-flex items-center gap-1">
<User.Name className="text-sm leadning-tight max-w-[15rem] truncate font-semibold" />
<User.NIP05 />
</div>
</div>
<button
type="button"
onClick={() => LumeWindow.openProfile(item.pubkey)}
className="inline-flex items-center justify-center w-16 text-sm font-medium rounded-md h-7 bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
>
View
</button>
</div>
<User.About className="select-text line-clamp-3 max-w-none text-neutral-800 dark:text-neutral-400" />
</User.Root>
</User.Provider>
</div>
))
)}
</Virtualizer>
</ScrollArea.Viewport>
);
}
+5 -16
View File
@@ -8,15 +8,12 @@ import {
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { Outlet, createFileRoute } from "@tanstack/react-router"; import { Outlet, createFileRoute } from "@tanstack/react-router";
import { useTranslation } from "react-i18next";
export const Route = createFileRoute("/settings")({ export const Route = createFileRoute("/settings")({
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const { t } = useTranslation();
return ( return (
<div className="flex flex-col w-full h-full"> <div className="flex flex-col w-full h-full">
<div <div
@@ -36,9 +33,7 @@ function Screen() {
)} )}
> >
<SettingsIcon className="size-5 shrink-0" /> <SettingsIcon className="size-5 shrink-0" />
<p className="text-sm font-medium"> <p className="text-sm font-medium">General</p>
{t("settings.general.title")}
</p>
</div> </div>
); );
}} }}
@@ -55,9 +50,7 @@ function Screen() {
)} )}
> >
<UserIcon className="size-5 shrink-0" /> <UserIcon className="size-5 shrink-0" />
<p className="text-sm font-medium"> <p className="text-sm font-medium">User</p>
{t("settings.user.title")}
</p>
</div> </div>
); );
}} }}
@@ -79,7 +72,7 @@ function Screen() {
); );
}} }}
</Link> </Link>
<Link to="/settings/zap"> <Link to="/settings/wallet">
{({ isActive }) => { {({ isActive }) => {
return ( return (
<div <div
@@ -91,9 +84,7 @@ function Screen() {
)} )}
> >
<ZapIcon className="size-5 shrink-0" /> <ZapIcon className="size-5 shrink-0" />
<p className="text-sm font-medium"> <p className="text-sm font-medium">Wallet</p>
{t("settings.zap.title")}
</p>
</div> </div>
); );
}} }}
@@ -110,9 +101,7 @@ function Screen() {
)} )}
> >
<SecureIcon className="size-5 shrink-0" /> <SecureIcon className="size-5 shrink-0" />
<p className="text-sm font-medium"> <p className="text-sm font-medium">Backup</p>
{t("settings.backup.title")}
</p>
</div> </div>
); );
}} }}
+4 -4
View File
@@ -1,11 +1,11 @@
import { User } from "@/components/user"; import { User } from "@/components/user";
import { NostrAccount } from "@lume/system"; import { NostrAccount } from "@lume/system";
import { displayNpub, displayNsec } from "@lume/utils"; import { displayNpub } 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";
import { writeText } from "@tauri-apps/plugin-clipboard-manager"; import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { message } from "@tauri-apps/plugin-dialog";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner";
interface Account { interface Account {
npub: string; npub: string;
@@ -43,7 +43,7 @@ function Account({ account }: { account: string }) {
await writeText(data); await writeText(data);
setCopied(true); setCopied(true);
} catch (e) { } catch (e) {
toast.error(e); await message(String(e), { title: "Backup", kind: "error" });
} }
}; };
@@ -51,7 +51,7 @@ function Account({ account }: { account: string }) {
<div className="flex items-center justify-between gap-2 py-3"> <div className="flex items-center justify-between gap-2 py-3">
<User.Provider pubkey={account}> <User.Provider pubkey={account}>
<User.Root className="flex items-center gap-2"> <User.Root className="flex items-center gap-2">
<User.Avatar className="object-cover rounded-full size-8" /> <User.Avatar className="rounded-full size-8" />
<div className="flex flex-col"> <div className="flex flex-col">
<User.Name className="text-sm leading-tight" /> <User.Name className="text-sm leading-tight" />
<span className="text-sm leading-tight text-black/50 dark:text-white/50"> <span className="text-sm leading-tight text-black/50 dark:text-white/50">
@@ -0,0 +1,39 @@
import { Button, init } from "@getalby/bitcoin-connect-react";
import { NostrAccount } from "@lume/system";
import { createFileRoute } from "@tanstack/react-router";
import { getCurrent } from "@tauri-apps/api/webviewWindow";
export const Route = createFileRoute("/settings/bitcoin-connect")({
beforeLoad: () => {
init({
appName: "Lume",
filters: ["nwc"],
showBalance: true,
});
},
component: Screen,
});
function Screen() {
const setNwcUri = async (uri: string) => {
const cmd = await NostrAccount.setWallet(uri);
if (cmd) getCurrent().close();
};
return (
<div className="flex items-center justify-center size-full">
<div className="flex flex-col items-center justify-center gap-3 text-center">
<div>
<p className="text-sm text-black/70 dark:text-white/70">
Click to the button below to connect with your Bitcoin wallet.
</p>
</div>
<Button
onConnected={(provider) =>
setNwcUri(provider.client.nostrWalletConnectUrl)
}
/>
</div>
</div>
);
}
+23 -1
View File
@@ -51,7 +51,7 @@ function Screen() {
return ( return (
<div className="w-full max-w-xl mx-auto"> <div className="w-full max-w-xl mx-auto">
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex items-center w-full h-12 px-3 text-sm rounded-xl bg-black/5 dark:bg-white/5"> <div className="flex items-center w-full px-3 text-sm rounded-lg h-11 bg-black/5 dark:bg-white/5">
* Setting changes require restarting the app to take effect. * Setting changes require restarting the app to take effect.
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@@ -130,6 +130,28 @@ function Screen() {
</select> </select>
</div> </div>
</div> </div>
<div className="flex items-start justify-between w-full gap-4 py-3">
<div className="flex-1">
<h3 className="font-medium">Vibrancy Effect</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Make the window transparent.
</p>
</div>
<div className="flex justify-end w-36 shrink-0">
<Switch.Root
checked={settings.vibrancy}
onClick={() =>
setSettings((prev) => ({
...prev,
vibrancy: !prev.vibrancy,
}))
}
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 items-start justify-between w-full gap-4 py-3"> <div className="flex items-start justify-between w-full gap-4 py-3">
<div className="flex-1"> <div className="flex-1">
<h3 className="font-medium">Zap Button</h3> <h3 className="font-medium">Zap Button</h3>
+18 -18
View File
@@ -1,9 +1,9 @@
import { CancelIcon, PlusIcon } from "@lume/icons"; import { CancelIcon, PlusIcon } from "@lume/icons";
import { NostrQuery } from "@lume/system"; import { NostrQuery } from "@lume/system";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { message } from "@tauri-apps/plugin-dialog";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner";
export const Route = createFileRoute("/settings/relay")({ export const Route = createFileRoute("/settings/relay")({
loader: async () => { loader: async () => {
@@ -33,7 +33,7 @@ function Screen() {
} }
} catch (e) { } catch (e) {
setIsLoading(false); setIsLoading(false);
toast.error(String(e)); await message(String(e), { title: "Relay", kind: "error" });
} }
}; };
@@ -42,22 +42,22 @@ function Screen() {
}, [relayList]); }, [relayList]);
return ( return (
<div className="mx-auto w-full max-w-xl"> <div className="w-full max-w-xl mx-auto">
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h2 className="font-semibold text-sm text-neutral-700 dark:text-neutral-300"> <h2 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
Connected Relays Connected Relays
</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 px-3 divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl">
{relays.map((relay) => ( {relays.map((relay) => (
<div <div
key={relay} key={relay}
className="flex justify-between items-center h-11" className="flex items-center justify-between h-11"
> >
<div className="inline-flex items-center gap-2 text-sm font-medium"> <div className="inline-flex items-center gap-2 text-sm font-medium">
<span className="relative flex size-2"> <span className="relative flex size-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-teal-400 opacity-75"></span> <span className="absolute inline-flex w-full h-full bg-teal-400 rounded-full opacity-75 animate-ping" />
<span className="relative inline-flex rounded-full size-2 bg-teal-500"></span> <span className="relative inline-flex bg-teal-500 rounded-full size-2" />
</span> </span>
{relay} {relay}
</div> </div>
@@ -65,7 +65,7 @@ function Screen() {
<button <button
type="button" type="button"
onClick={() => NostrQuery.removeRelay(relay)} 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 rounded-md size-7 hover:bg-black/10 dark:hover:bg-white/10"
> >
<CancelIcon className="size-4" /> <CancelIcon className="size-4" />
</button> </button>
@@ -75,7 +75,7 @@ function Screen() {
<div className="flex items-center h-14"> <div className="flex items-center h-14">
<form <form
onSubmit={handleSubmit(onSubmit)} onSubmit={handleSubmit(onSubmit)}
className="w-full flex items-center gap-2 mb-0" className="flex items-center w-full gap-2 mb-0"
> >
<input <input
{...register("url", { {...register("url", {
@@ -85,12 +85,12 @@ function Screen() {
name="url" name="url"
placeholder="wss://..." placeholder="wss://..."
spellCheck={false} spellCheck={false}
className="h-9 flex-1 rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring-0 dark:border-neutral-700 dark:placeholder:text-neutral-400" className="flex-1 px-3 bg-transparent rounded-lg h-9 border-neutral-300 placeholder:text-neutral-500 focus:border-blue-500 focus:ring-0 dark:border-neutral-700 dark:placeholder:text-neutral-400"
/> />
<button <button
type="submit" type="submit"
disabled={isLoading} disabled={isLoading}
className="shrink-0 inline-flex h-9 w-16 px-2 items-center justify-center rounded-lg bg-black/20 dark:bg-white/20 font-medium text-sm text-white hover:bg-blue-500 disabled:opacity-50" className="inline-flex items-center justify-center w-16 px-2 text-sm font-medium text-white rounded-lg shrink-0 h-9 bg-black/20 dark:bg-white/20 hover:bg-blue-500 disabled:opacity-50"
> >
<PlusIcon className="size-7" /> <PlusIcon className="size-7" />
</button> </button>
@@ -99,21 +99,21 @@ function Screen() {
</div> </div>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h2 className="font-semibold text-sm text-neutral-700 dark:text-neutral-300"> <h2 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
User Relays (NIP-65) User Relays (NIP-65)
</h2> </h2>
<div className="flex flex-col py-2 bg-black/5 dark:bg-white/5 rounded-xl px-3"> <div className="flex flex-col px-3 py-2 bg-black/5 dark:bg-white/5 rounded-xl">
<p className="text-sm text-yellow-500"> <p className="text-sm text-yellow-500">
Lume will automatically connect to the user's relay list, but the Lume will automatically connect to the user's relay list, but the
manager function (like adding, removing, changing relay purpose) manager function (like adding, removing, changing relay purpose)
is not yet available. is not yet available.
</p> </p>
</div> </div>
<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 px-3 divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl">
{relayList.read?.map((relay) => ( {relayList.read?.map((relay) => (
<div <div
key={relay} key={relay}
className="flex justify-between items-center h-11" className="flex items-center justify-between h-11"
> >
<div className="text-sm font-medium">{relay}</div> <div className="text-sm font-medium">{relay}</div>
<div className="text-xs font-semibold">READ</div> <div className="text-xs font-semibold">READ</div>
@@ -122,7 +122,7 @@ function Screen() {
{relayList.write?.map((relay) => ( {relayList.write?.map((relay) => (
<div <div
key={relay} key={relay}
className="flex justify-between items-center h-11" className="flex items-center justify-between h-11"
> >
<div className="text-sm font-medium">{relay}</div> <div className="text-sm font-medium">{relay}</div>
<div className="text-xs font-semibold">WRITE</div> <div className="text-xs font-semibold">WRITE</div>
@@ -131,7 +131,7 @@ function Screen() {
{relayList.both?.map((relay) => ( {relayList.both?.map((relay) => (
<div <div
key={relay} key={relay}
className="flex justify-between items-center h-11" className="flex items-center justify-between h-11"
> >
<div className="text-sm font-medium">{relay}</div> <div className="text-sm font-medium">{relay}</div>
<div className="text-xs font-semibold">READ + WRITE</div> <div className="text-xs font-semibold">READ + WRITE</div>
+21 -21
View File
@@ -5,9 +5,9 @@ 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";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { message } from "@tauri-apps/plugin-dialog";
import { useState } from "react"; import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner";
export const Route = createFileRoute("/settings/user")({ export const Route = createFileRoute("/settings/user")({
beforeLoad: async () => { beforeLoad: async () => {
@@ -34,31 +34,31 @@ function Screen() {
setLoading(false); setLoading(false);
} catch (e) { } catch (e) {
setLoading(false); setLoading(false);
toast.error(String(e)); await message(String(e), { title: "Profile", kind: "error" });
} }
}; };
return ( return (
<div className="flex w-full h-full"> <div className="flex w-full h-full">
<div className="flex-1 h-full flex items-center flex-col justify-center gap-3"> <div className="flex flex-col items-center justify-center flex-1 h-full gap-3">
<div className="relative size-24 rounded-full bg-gradient-to-tr from-orange-100 via-red-50 to-blue-200"> <div className="relative rounded-full size-24 bg-gradient-to-tr from-orange-100 via-red-50 to-blue-200">
{profile.picture ? ( {profile.picture ? (
<img <img
src={picture || profile.picture} src={picture || profile.picture}
alt="avatar" alt="avatar"
loading="lazy" loading="lazy"
decoding="async" decoding="async"
className="absolute inset-0 z-10 h-full w-full rounded-full object-cover" className="absolute inset-0 z-10 object-cover w-full h-full rounded-full"
/> />
) : null} ) : null}
<AvatarUploader <AvatarUploader
setPicture={setPicture} setPicture={setPicture}
className="absolute inset-0 z-20 flex h-full w-full items-center justify-center rounded-full bg-black/10 text-white hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20" className="absolute inset-0 z-20 flex items-center justify-center w-full h-full text-white rounded-full bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
> >
<PlusIcon className="size-8" /> <PlusIcon className="size-8" />
</AvatarUploader> </AvatarUploader>
</div> </div>
<div className="text-center flex flex-col items-center"> <div className="flex flex-col items-center text-center">
<div className="text-lg font-semibold">{profile.display_name}</div> <div className="text-lg font-semibold">{profile.display_name}</div>
<div className="text-neutral-800 dark:text-neutral-200"> <div className="text-neutral-800 dark:text-neutral-200">
{profile.nip05} {profile.nip05}
@@ -66,7 +66,7 @@ function Screen() {
<div className="mt-4"> <div className="mt-4">
<Link <Link
to="/settings/backup" to="/settings/backup"
className="px-5 h-9 border border-blue-300 text-sm font-medium hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800 rounded-full bg-blue-100 text-blue-500 inline-flex items-center justify-center" className="inline-flex items-center justify-center px-5 text-sm font-medium text-blue-500 bg-blue-100 border border-blue-300 rounded-full h-9 hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800"
> >
Backup Account Backup Account
</Link> </Link>
@@ -78,7 +78,7 @@ function Screen() {
onSubmit={handleSubmit(onSubmit)} onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-3 mb-0" className="flex flex-col gap-3 mb-0"
> >
<div className="flex w-full flex-col gap-1"> <div className="flex flex-col w-full gap-1">
<label <label
htmlFor="display_name" htmlFor="display_name"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300" className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
@@ -89,10 +89,10 @@ function Screen() {
name="display_name" name="display_name"
{...register("display_name")} {...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="w-full px-3 bg-transparent rounded-lg h-9 border-neutral-300 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"
/> />
</div> </div>
<div className="flex w-full flex-col gap-1"> <div className="flex flex-col w-full gap-1">
<label <label
htmlFor="name" htmlFor="name"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300" className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
@@ -103,10 +103,10 @@ function Screen() {
name="name" name="name"
{...register("name")} {...register("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="w-full px-3 bg-transparent rounded-lg h-9 border-neutral-300 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"
/> />
</div> </div>
<div className="flex w-full flex-col gap-1"> <div className="flex flex-col w-full gap-1">
<label <label
htmlFor="website" htmlFor="website"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300" className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
@@ -118,10 +118,10 @@ function Screen() {
type="url" type="url"
{...register("website")} {...register("website")}
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="w-full px-3 bg-transparent rounded-lg h-9 border-neutral-300 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"
/> />
</div> </div>
<div className="flex w-full flex-col gap-1"> <div className="flex flex-col w-full gap-1">
<label <label
htmlFor="banner" htmlFor="banner"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300" className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
@@ -133,10 +133,10 @@ function Screen() {
type="url" type="url"
{...register("banner")} {...register("banner")}
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="w-full px-3 bg-transparent rounded-lg h-9 border-neutral-300 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"
/> />
</div> </div>
<div className="flex w-full flex-col gap-1"> <div className="flex flex-col w-full gap-1">
<label <label
htmlFor="nip05" htmlFor="nip05"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300" className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
@@ -148,10 +148,10 @@ function Screen() {
type="email" type="email"
{...register("nip05")} {...register("nip05")}
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="w-full px-3 bg-transparent rounded-lg h-9 border-neutral-300 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"
/> />
</div> </div>
<div className="flex w-full flex-col gap-1"> <div className="flex flex-col w-full gap-1">
<label <label
htmlFor="lnaddress" htmlFor="lnaddress"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300" className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
@@ -162,13 +162,13 @@ function Screen() {
name="lnaddress" name="lnaddress"
type="email" type="email"
{...register("lud16")} {...register("lud16")}
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="w-full px-3 bg-transparent rounded-lg h-9 border-neutral-300 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"
/> />
</div> </div>
<div className="flex items-center justify-end"> <div className="flex items-center justify-end">
<button <button
type="submit" type="submit"
className="inline-flex h-9 w-32 px-2 items-center justify-center rounded-lg bg-blue-500 font-medium text-sm text-white hover:bg-blue-600 disabled:opacity-50" className="inline-flex items-center justify-center w-32 px-2 text-sm font-medium text-white bg-blue-500 rounded-lg h-9 hover:bg-blue-600 disabled:opacity-50"
> >
{loading ? <Spinner className="size-4" /> : "Update Profile"} {loading ? <Spinner className="size-4" /> : "Update Profile"}
</button> </button>
@@ -0,0 +1,59 @@
import { NostrAccount } from "@lume/system";
import { getBitcoinDisplayValues } from "@lume/utils";
import { createFileRoute, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/settings/wallet")({
beforeLoad: async () => {
const wallet = await NostrAccount.loadWallet();
if (!wallet) {
throw redirect({ to: "/settings/bitcoin-connect" });
}
const balance = getBitcoinDisplayValues(wallet);
return { balance };
},
component: Screen,
});
function Screen() {
const { balance } = Route.useRouteContext();
const disconnect = async () => {
window.localStorage.removeItem("bc:config");
await NostrAccount.removeWallet();
return redirect({ to: "/settings/bitcoin-connect" });
};
return (
<div className="w-full max-w-xl mx-auto">
<div className="flex flex-col w-full gap-3">
<div className="flex flex-col w-full px-3 bg-black/5 dark:bg-white/5 rounded-xl">
<div className="flex items-center justify-between w-full gap-4 py-3">
<div className="flex-1">
<h3 className="font-medium">Connection</h3>
</div>
<div className="flex justify-end w-36 shrink-0">
<button
type="button"
onClick={() => disconnect()}
className="h-8 w-max px-2.5 text-sm rounded-lg inline-flex items-center justify-center bg-black/10 dark:bg-white/10 hover:bg-black/20 dark:hover:bg-white/20"
>
Disconnect
</button>
</div>
</div>
</div>
<div className="flex flex-col w-full px-3 bg-black/5 dark:bg-white/5 rounded-xl">
<div className="flex items-center justify-between w-full gap-4 py-3">
<div className="flex-1">
<h3 className="font-medium">Current Balance</h3>
</div>
<div className="flex justify-end w-36 shrink-0">
{balance.bitcoinFormatted}
</div>
</div>
</div>
</div>
</div>
);
}
@@ -1,102 +0,0 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core";
import { useState } from "react";
import { toast } from "sonner";
export const Route = createLazyFileRoute("/settings/zap")({
component: Screen,
});
function Screen() {
return (
<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-6 py-3">
<Connection />
<DefaultAmount />
</div>
</div>
</div>
);
}
function Connection() {
const [uri, setUri] = useState("");
const connect = async () => {
try {
await invoke("set_nwc", { uri });
} catch (e) {
toast.error(String(e));
}
};
return (
<div className="flex items-start gap-6">
<div className="w-36 shrink-0 text-end font-medium text-sm">
Connection
</div>
<div className="flex-1">
<div className="flex w-full flex-col gap-1">
<label
htmlFor="nwc"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Nostr Wallet Connect
</label>
<div className="flex items-center gap-2">
<input
name="nwc"
type="text"
value={uri}
onChange={(e) => setUri(e.target.value)}
placeholder="nostrconnect://"
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
<button
type="button"
onClick={() => connect()}
className="inline-flex h-9 w-24 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-900 dark:hover:bg-neutral-700"
>
Connect
</button>
</div>
</div>
</div>
</div>
);
}
function DefaultAmount() {
return (
<div className="flex items-start gap-6">
<div className="w-36 shrink-0 text-end font-medium text-sm">
Default amount
</div>
<div className="flex-1">
<div className="flex w-full flex-col gap-1">
<label
htmlFor="amount"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Set default amount for quick zapping
</label>
<div className="flex items-center gap-2">
<input
name="amount"
type="number"
value={21}
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
<button
type="button"
className="inline-flex h-9 w-24 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-900 dark:hover:bg-neutral-700"
>
Update
</button>
</div>
</div>
</div>
</div>
);
}
@@ -1,21 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/store/community")({
component: Screen,
});
function Screen() {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 p-3">
<div className="size-24 bg-blue-100 flex flex-col items-center justify-end overflow-hidden dark:bg-blue-900 rounded-full">
<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>
<div className="text-center">
<h1 className="font-semibold text-lg">Coming Soon</h1>
<p className="text-sm text-neutral-700 dark:text-neutral-300 leading-tight">
Enhance your experience <br /> by adding column shared by community.
</p>
</div>
</div>
);
}
@@ -1,69 +0,0 @@
import type { LumeColumn } from "@lume/types";
import { createFileRoute } from "@tanstack/react-router";
import { resolveResource } from "@tauri-apps/api/path";
import { getCurrent } from "@tauri-apps/api/window";
import { readTextFile } from "@tauri-apps/plugin-fs";
export const Route = createFileRoute("/store/official")({
beforeLoad: async () => {
const resourcePath = await resolveResource(
"resources/official_columns.json",
);
const officialColumns: LumeColumn[] = JSON.parse(
await readTextFile(resourcePath),
);
return {
officialColumns,
};
},
component: Screen,
});
function Screen() {
const { officialColumns } = Route.useRouteContext();
const install = async (column: LumeColumn) => {
const mainWindow = getCurrent();
await mainWindow.emit("columns", { type: "add", column });
};
return (
<div className="flex flex-col gap-3 p-3">
{officialColumns.map((column) => (
<div
key={column.label}
className="relative h-[200px] w-full overflow-hidden rounded-xl bg-gradient-to-tr from-orange-100 to-blue-200 px-3 pt-3"
>
{column.cover ? (
<img
src={column.cover}
srcSet={column.coverRetina}
alt={column.name}
loading="lazy"
decoding="async"
className="absolute left-0 top-0 z-10 h-full w-full object-cover"
/>
) : null}
<div className="absolute bottom-0 left-0 z-20 h-16 w-full bg-black/40 px-3 backdrop-blur-xl">
<div className="flex h-full items-center justify-between">
<div>
<h1 className="font-semibold text-white">{column.name}</h1>
<p className="max-w-[24rem] truncate text-sm text-white/80">
{column.description}
</p>
</div>
<button
type="button"
onClick={() => install(column)}
className="inline-flex h-8 w-16 shrink-0 items-center justify-center rounded-full bg-white/20 text-sm font-medium text-white hover:bg-white hover:text-blue-500"
>
Add
</button>
</div>
</div>
</div>
))}
</div>
);
}
+91 -35
View File
@@ -1,48 +1,104 @@
import { GlobalIcon, LaurelIcon } from "@lume/icons"; import { CommunityIcon, LaurelIcon } from "@lume/icons";
import { cn } from "@lume/utils"; import type { LumeColumn } from "@lume/types";
import { Link } from "@tanstack/react-router"; import * as ScrollArea from "@radix-ui/react-scroll-area";
import { Outlet, createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { resolveResource } from "@tauri-apps/api/path";
import { getCurrent } from "@tauri-apps/api/window";
import { readTextFile } from "@tauri-apps/plugin-fs";
export const Route = createFileRoute("/store")({ export const Route = createFileRoute("/store")({
beforeLoad: async () => {
const path = "resources/official_columns.json";
const resourcePath = await resolveResource(path);
const fileContent = await readTextFile(resourcePath);
const officialColumns: LumeColumn[] = JSON.parse(fileContent);
return {
officialColumns,
};
},
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const { officialColumns } = Route.useRouteContext();
const install = async (column: LumeColumn) => {
const mainWindow = getCurrent();
await mainWindow.emit("columns", { type: "add", column });
};
return ( return (
<div className="flex flex-col h-full"> <div className="size-full">
<div className="px-3 mt-2 mb-1"> <ScrollArea.Root
<div className="inline-flex items-center w-full gap-1 p-1 rounded-lg shrink-0 bg-black/5 dark:bg-white/5"> type={"scroll"}
<Link to="/store/official" className="flex-1"> scrollHideDelay={300}
{({ isActive }) => ( className="flex-1 overflow-hidden size-full"
<div >
className={cn( <ScrollArea.Viewport className="h-full px-3 ">
"inline-flex h-8 w-full items-center justify-center gap-1.5 rounded-md text-sm font-medium leading-tight", <div className="flex flex-col gap-3 mb-10">
isActive ? "bg-neutral-50 dark:bg-white/10" : "opacity-50", <div className="inline-flex items-center gap-1.5 font-semibold leading-tight">
)} <div className="size-7 rounded-md inline-flex items-center justify-center bg-black/10 dark:bg-white/10">
>
<LaurelIcon className="size-4" /> <LaurelIcon className="size-4" />
Official
</div> </div>
)} Official
</Link> </div>
<Link to="/store/community" className="flex-1"> <div className="grid grid-cols-3 gap-4">
{({ isActive }) => ( {officialColumns.map((column) => (
<div <div
className={cn( key={column.label}
"inline-flex h-8 w-full items-center justify-center gap-1.5 rounded-md text-sm font-medium leading-tight", className="relative group flex flex-col w-full aspect-square overflow-hidden bg-white dark:bg-black/20 rounded-xl shadow-primary dark:ring-1 dark:ring-white/5"
isActive ? "bg-neutral-50 dark:bg-white/10" : "opacity-50", >
)} <div className="hidden group-hover:flex items-center justify-center absolute inset-0 size-full rounded-xl bg-white/20 dark:bg-black/20 backdrop-blur-md">
> <button
<GlobalIcon className="size-4" /> type="button"
Community onClick={() => install(column)}
className="w-16 h-8 inline-flex items-center justify-center rounded-full bg-black dark:bg-white text-white dark:text-black text-sm font-semibold"
>
Add
</button>
</div>
<div className="flex-1">
{column.cover ? (
<img
src={column.cover}
srcSet={column.coverRetina}
alt={column.name}
loading="lazy"
decoding="async"
className="size-full object-cover"
/>
) : null}
</div>
<div className="shrink-0 h-9 px-3 flex items-center">
<h3 className="text-sm font-semibold truncate w-full">
{column.name}
</h3>
</div>
</div>
))}
</div>
</div>
<div className="flex flex-col gap-3">
<div className="inline-flex items-center gap-1.5 font-semibold leading-tight">
<div className="size-7 rounded-md inline-flex items-center justify-center bg-black/10 dark:bg-white/10">
<CommunityIcon className="size-4" />
</div> </div>
)} Community
</Link> </div>
</div> <div className="w-full h-20 rounded-xl flex items-center justify-center text-sm font-medium bg-black/5 dark:bg-white/5">
</div> Coming Soon.
<div className="flex-1 overflow-y-auto scrollbar-none"> </div>
<Outlet /> </div>
</div> </ScrollArea.Viewport>
<ScrollArea.Scrollbar
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
orientation="vertical"
>
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
</ScrollArea.Scrollbar>
<ScrollArea.Corner className="bg-transparent" />
</ScrollArea.Root>
</div> </div>
); );
} }
+61 -43
View File
@@ -6,9 +6,10 @@ import { ArrowRightCircleIcon } from "@lume/icons";
import { type LumeEvent, NostrQuery } from "@lume/system"; import { type LumeEvent, NostrQuery } from "@lume/system";
import { type ColumnRouteSearch, Kind } from "@lume/types"; import { type ColumnRouteSearch, Kind } from "@lume/types";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { useInfiniteQuery } from "@tanstack/react-query"; import { useInfiniteQuery } from "@tanstack/react-query";
import { createFileRoute, redirect } from "@tanstack/react-router"; import { createFileRoute, redirect } from "@tanstack/react-router";
import { useCallback } from "react"; import { useCallback, useRef } from "react";
import { Virtualizer } from "virtua"; import { Virtualizer } from "virtua";
type Topic = { type Topic = {
@@ -71,6 +72,8 @@ export function Screen() {
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}); });
const ref = useRef<HTMLDivElement>(null);
const renderItem = useCallback( const renderItem = useCallback(
(event: LumeEvent) => { (event: LumeEvent) => {
if (!event) return; if (!event) return;
@@ -94,48 +97,63 @@ export function Screen() {
); );
return ( return (
<div className="w-full h-full p-3 overflow-y-auto scrollbar-none"> <ScrollArea.Root
{isFetching && !isLoading && !isFetchingNextPage ? ( type={"scroll"}
<div className="flex items-center justify-center w-full mb-3 h-11 bg-black/10 dark:bg-white/10 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50"> scrollHideDelay={300}
<div className="flex items-center justify-center gap-2"> className="overflow-hidden size-full"
<Spinner className="size-5" /> >
<span className="text-sm font-medium">Fetching new notes...</span> <ScrollArea.Viewport ref={ref} className="h-full px-3 pb-3">
</div> <Virtualizer scrollRef={ref}>
</div> {isFetching && !isLoading && !isFetchingNextPage ? (
) : null} <div className="flex items-center justify-center w-full mb-3 h-12 bg-black/5 dark:bg-white/5 rounded-xl">
{isLoading ? ( <div className="flex items-center justify-center gap-2">
<div className="flex items-center justify-center w-full h-16 gap-2"> <Spinner className="size-5" />
<Spinner className="size-5" /> <span className="text-sm font-medium">
<span className="text-sm font-medium">Loading...</span> Getting new notes...
</div> </span>
) : !data.length ? ( </div>
<div className="flex items-center justify-center"> </div>
Yo. You're catching up on all the things happening around you. ) : null}
</div> {isLoading ? (
) : ( <div className="flex items-center justify-center w-full h-16 gap-2">
<Virtualizer overscan={3}>
{data.map((item) => renderItem(item))}
</Virtualizer>
)}
{data?.length && hasNextPage ? (
<div>
<button
type="button"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage || isLoading}
className="inline-flex items-center justify-center w-full gap-2 px-3 font-medium h-9 rounded-xl bg-black/5 hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
>
{isFetchingNextPage ? (
<Spinner className="size-5" /> <Spinner className="size-5" />
) : ( <span className="text-sm font-medium">Loading...</span>
<> </div>
<ArrowRightCircleIcon className="size-5" /> ) : !data.length ? (
Load more <div className="flex items-center justify-center">
</> Yo. You're catching up on all the things happening around you.
)} </div>
</button> ) : (
</div> data.map((item) => renderItem(item))
) : null} )}
</div> {data?.length && hasNextPage ? (
<div>
<button
type="button"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage || isLoading}
className="inline-flex items-center justify-center w-full gap-2 px-3 font-medium h-9 rounded-xl bg-black/5 hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
>
{isFetchingNextPage ? (
<Spinner className="size-5" />
) : (
<>
<ArrowRightCircleIcon className="size-5" />
Load more
</>
)}
</button>
</div>
) : null}
</Virtualizer>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
orientation="vertical"
>
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
</ScrollArea.Scrollbar>
<ScrollArea.Corner className="bg-transparent" />
</ScrollArea.Root>
); );
} }
+61 -28
View File
@@ -1,10 +1,14 @@
import { Conversation } from "@/components/conversation";
import { Quote } from "@/components/quote";
import { RepostNote } from "@/components/repost";
import { TextNote } from "@/components/text"; import { TextNote } from "@/components/text";
import { LumeEvent } from "@lume/system"; import { LumeEvent } from "@lume/system";
import type { NostrEvent } from "@lume/types"; import { Kind, type NostrEvent } from "@lume/types";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import * as ScrollArea from "@radix-ui/react-scroll-area";
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";
import { Suspense } from "react"; import { Suspense, useCallback, useRef } from "react";
import { Virtualizer } from "virtua"; import { Virtualizer } from "virtua";
export const Route = createFileRoute("/trending/notes")({ export const Route = createFileRoute("/trending/notes")({
@@ -20,7 +24,9 @@ export const Route = createFileRoute("/trending/notes")({
const events: NostrEvent[] = res.notes.map( const events: NostrEvent[] = res.notes.map(
(item: { event: NostrEvent }) => item.event, (item: { event: NostrEvent }) => item.event,
); );
const lumeEvents = events.map((ev) => new LumeEvent(ev)); const lumeEvents = Promise.all(
events.map(async (ev) => await LumeEvent.build(ev)),
);
return lumeEvents; return lumeEvents;
}), }),
), ),
@@ -35,32 +41,59 @@ export const Route = createFileRoute("/trending/notes")({
export function Screen() { export function Screen() {
const { data } = Route.useLoaderData(); const { data } = Route.useLoaderData();
const ref = useRef<HTMLDivElement>(null);
const renderItem = useCallback((event: LumeEvent) => {
if (!event) return;
switch (event.kind) {
case Kind.Repost:
return <RepostNote key={event.id} event={event} className="mb-3" />;
default: {
if (event.isConversation) {
return <Conversation key={event.id} className="mb-3" event={event} />;
}
if (event.isQuote) {
return <Quote key={event.id} event={event} className="mb-3" />;
}
return <TextNote key={event.id} event={event} className="mb-3" />;
}
}
}, []);
return ( return (
<div className="w-full h-full"> <ScrollArea.Root
<Virtualizer overscan={3}> type={"scroll"}
<Suspense scrollHideDelay={300}
fallback={ className="overflow-hidden size-full"
<div className="flex flex-col items-center justify-center w-full h-20 gap-1"> >
<button <ScrollArea.Viewport ref={ref} className="h-full px-3 pb-3">
type="button" <Virtualizer scrollRef={ref}>
className="inline-flex items-center gap-2 text-sm font-medium" <Suspense
disabled fallback={
> <div className="flex flex-col items-center justify-center w-full h-20 gap-1">
<Spinner className="size-5" /> <button
Loading... type="button"
</button> className="inline-flex items-center gap-2 text-sm font-medium"
</div> disabled
} >
> <Spinner className="size-5" />
<Await promise={data}> Loading...
{(notes) => </button>
notes.map((event) => ( </div>
<TextNote key={event.id} event={event} className="mb-3" />
))
} }
</Await> >
</Suspense> <Await promise={data}>
</Virtualizer> {(notes) => notes.map((event) => renderItem(event))}
</div> </Await>
</Suspense>
</Virtualizer>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
orientation="vertical"
>
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
</ScrollArea.Scrollbar>
<ScrollArea.Corner className="bg-transparent" />
</ScrollArea.Root>
); );
} }
+7 -7
View File
@@ -25,14 +25,14 @@ function Screen() {
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="inline-flex items-center w-full gap-1 px-3 h-11 shrink-0"> <div className="shrink-0 h-11 flex items-center w-full gap-1 px-3">
<div className="inline-flex items-center w-full h-full gap-1"> <div className="flex w-full h-full gap-1">
<Link to="/trending/notes" search={search}> <Link to="/trending/notes" search={search}>
{({ isActive }) => ( {({ isActive }) => (
<div <div
className={cn( className={cn(
"inline-flex h-7 w-max items-center justify-center gap-2 rounded-full px-3 text-sm font-medium", "inline-flex h-8 w-max items-center justify-center gap-2 rounded-full px-3 text-sm font-medium",
isActive ? "bg-neutral-50 dark:bg-white/10" : "opacity-50", isActive ? "bg-black/10 dark:bg-white/10" : "opacity-50",
)} )}
> >
<ArticleIcon className="size-4" /> <ArticleIcon className="size-4" />
@@ -44,8 +44,8 @@ function Screen() {
{({ isActive }) => ( {({ isActive }) => (
<div <div
className={cn( className={cn(
"inline-flex h-7 w-max items-center justify-center gap-2 rounded-full px-3 text-sm font-medium", "inline-flex h-8 w-max items-center justify-center gap-2 rounded-full px-3 text-sm font-medium",
isActive ? "bg-neutral-50 dark:bg-white/10" : "opacity-50", isActive ? "bg-black/10 dark:bg-white/10" : "opacity-50",
)} )}
> >
<GroupFeedsIcon className="size-4" /> <GroupFeedsIcon className="size-4" />
@@ -55,7 +55,7 @@ function Screen() {
</Link> </Link>
</div> </div>
</div> </div>
<div className="flex-1 w-full h-full p-2 overflow-y-auto scrollbar-none"> <div className="flex-1 w-full h-full overflow-y-auto scrollbar-none">
<Outlet /> <Outlet />
</div> </div>
</div> </div>
+3 -3
View File
@@ -1,5 +1,5 @@
import { Spinner } from "@lume/ui";
import { User } from "@/components/user"; import { User } from "@/components/user";
import { Spinner } from "@lume/ui";
import { Await, defer } from "@tanstack/react-router"; import { Await, defer } from "@tanstack/react-router";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { Suspense } from "react"; import { Suspense } from "react";
@@ -45,14 +45,14 @@ export function Screen() {
users.profiles.map((item: { pubkey: string }) => ( 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 rounded-xl"
> >
<User.Provider pubkey={item.pubkey}> <User.Provider pubkey={item.pubkey}>
<User.Root> <User.Root>
<div className="flex h-full w-full flex-col gap-2"> <div className="flex h-full w-full flex-col gap-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<User.Avatar className="size-10 shrink-0 rounded-full object-cover" /> <User.Avatar className="size-10 rounded-full" />
<User.Name className="leadning-tight max-w-[15rem] truncate font-semibold" /> <User.Name className="leadning-tight max-w-[15rem] truncate font-semibold" />
</div> </div>
<User.Button className="inline-flex h-8 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" /> <User.Button className="inline-flex h-8 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" />
+12 -8
View File
@@ -1,17 +1,21 @@
import { Box, Container, Spinner } from "@lume/ui";
import { User } from "@/components/user";
import { createFileRoute, defer } from "@tanstack/react-router";
import { WindowVirtualizer } from "virtua";
import { Conversation } from "@/components/conversation"; import { 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 { Kind } from "@lume/types"; import { User } from "@/components/user";
import { Suspense, useCallback } from "react";
import { Await } from "@tanstack/react-router";
import { type LumeEvent, NostrQuery } from "@lume/system"; import { type LumeEvent, NostrQuery } from "@lume/system";
import { Kind } from "@lume/types";
import { Box, Container, Spinner } from "@lume/ui";
import { createFileRoute, defer } from "@tanstack/react-router";
import { Await } from "@tanstack/react-router";
import { Suspense, useCallback } from "react";
import { WindowVirtualizer } from "virtua";
export const Route = createFileRoute("/users/$pubkey")({ export const Route = createFileRoute("/users/$pubkey")({
beforeLoad: async () => {
const settings = await NostrQuery.getUserSettings();
return { settings };
},
loader: async ({ params }) => { loader: async ({ params }) => {
return { data: defer(NostrQuery.getUserEvents(params.pubkey)) }; return { data: defer(NostrQuery.getUserEvents(params.pubkey)) };
}, },
@@ -46,7 +50,7 @@ function Screen() {
return ( return (
<Container withDrag> <Container withDrag>
<Box className="px-0 scrollbar-none bg-black/5 dark:bg-white/5 backdrop-blur-sm"> <Box className="px-0 scrollbar-none bg-black/5 dark:bg-white/5">
<WindowVirtualizer> <WindowVirtualizer>
<User.Provider pubkey={pubkey}> <User.Provider pubkey={pubkey}>
<User.Root> <User.Root>
-124
View File
@@ -1,124 +0,0 @@
import { Balance } from "@/components/balance";
import { Box, Container } from "@lume/ui";
import { User } from "@/components/user";
import { createLazyFileRoute } from "@tanstack/react-router";
import { getCurrent } from "@tauri-apps/api/webviewWindow";
import { useState } from "react";
import CurrencyInput from "react-currency-input-field";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { LumeEvent } from "@lume/system";
const DEFAULT_VALUES = [69, 100, 200, 500];
export const Route = createLazyFileRoute("/zap/$id")({
component: Screen,
});
function Screen() {
const { t } = useTranslation();
const { id } = Route.useParams();
// @ts-ignore, magic !!!
const { pubkey, account } = Route.useSearch();
const [amount, setAmount] = useState(21);
const [message, setMessage] = useState("");
const [isCompleted, setIsCompleted] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const submit = async () => {
try {
// start loading
setIsLoading(true);
const val = await LumeEvent.zap(id, amount, message);
if (val) {
setIsCompleted(true);
const window = getCurrent();
// close current window
window.close();
}
} catch (e) {
setIsLoading(false);
toast.error(e);
}
};
return (
<Container>
<Balance account={account} />
<Box className="flex flex-col gap-3">
<div className="flex h-full flex-col justify-between py-5">
<div className="flex h-11 shrink-0 items-center justify-center gap-2">
{t("note.zap.modalTitle")}{" "}
<User.Provider pubkey={pubkey}>
<User.Root className="inline-flex items-center gap-2 rounded-full bg-neutral-100 p-1 dark:bg-neutral-900">
<User.Avatar className="size-6 rounded-full" />
<User.Name className="pr-2 text-sm font-medium" />
</User.Root>
</User.Provider>
</div>
<div className="flex flex-1 flex-col justify-between px-5">
<div className="relative flex flex-1 flex-col pb-8">
<div className="inline-flex h-full flex-1 items-center justify-center gap-1">
<CurrencyInput
placeholder="0"
defaultValue={21}
value={amount}
decimalsLimit={2}
min={0} // 0 sats
max={10000} // 1M sats
maxLength={10000} // 1M sats
onValueChange={(value) => setAmount(Number(value))}
className="w-full flex-1 border-none bg-transparent text-right text-4xl font-semibold placeholder:text-neutral-600 focus:outline-none focus:ring-0 dark:text-neutral-400"
/>
<span className="w-full flex-1 text-left text-4xl font-semibold text-neutral-500 dark:text-neutral-400">
sats
</span>
</div>
<div className="inline-flex items-center justify-center gap-2">
{DEFAULT_VALUES.map((value) => (
<button
key={value}
type="button"
onClick={() => setAmount(value)}
className="w-max rounded-full bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
{value} sats
</button>
))}
</div>
</div>
<div className="flex w-full flex-col gap-2">
<input
name="message"
value={message}
onChange={(e) => setMessage(e.target.value)}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
placeholder={t("note.zap.messagePlaceholder")}
className="h-11 w-full resize-none rounded-lg border-transparent bg-neutral-100 px-3 !outline-none placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-950 dark:text-neutral-400"
/>
<div className="flex flex-col gap-2">
<button
type="button"
onClick={() => submit()}
className="inline-flex h-9 w-full items-center justify-center rounded-lg border-t border-neutral-900 bg-neutral-950 pb-[2px] font-semibold text-neutral-50 hover:bg-neutral-900 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
{isCompleted
? t("note.zap.buttonFinish")
: isLoading
? t("note.zap.buttonLoading")
: t("note.zap.zap")}
</button>
</div>
</div>
</div>
</div>
</Box>
</Container>
);
}
+119
View File
@@ -0,0 +1,119 @@
import { User } from "@/components/user";
import { NostrQuery } from "@lume/system";
import { createFileRoute } from "@tanstack/react-router";
import { getCurrent } from "@tauri-apps/api/webviewWindow";
import { message } from "@tauri-apps/plugin-dialog";
import { useState } from "react";
import CurrencyInput from "react-currency-input-field";
const DEFAULT_VALUES = [21, 50, 100, 200];
export const Route = createFileRoute("/zap/$id")({
beforeLoad: async ({ params }) => {
const event = await NostrQuery.getEvent(params.id);
return { event };
},
component: Screen,
});
function Screen() {
const { event } = Route.useRouteContext();
const [amount, setAmount] = useState(21);
const [content, setContent] = useState("");
const [isCompleted, setIsCompleted] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const submit = async () => {
try {
// start loading
setIsLoading(true);
// Zap
const val = await event.zap(amount, content);
if (val) {
setIsCompleted(true);
// close current window
await getCurrent().close();
}
} catch (e) {
setIsLoading(false);
await message(String(e), {
title: "Zap",
kind: "error",
});
}
};
return (
<div data-tauri-drag-region className="flex flex-col pb-5 size-full">
<div
data-tauri-drag-region
className="flex items-center justify-center h-24 gap-2 shrink-0"
>
<p className="text-sm">Send zap to </p>
<User.Provider pubkey={event.pubkey}>
<User.Root className="inline-flex items-center gap-2 p-1 rounded-full bg-black/5 dark:bg-white/5">
<User.Avatar className="rounded-full size-6" />
<User.Name className="pr-2 text-sm font-medium" />
</User.Root>
</User.Provider>
</div>
<div className="flex flex-col justify-between h-full">
<div className="flex flex-col justify-between flex-1 px-5">
<div className="relative flex flex-col flex-1 pb-8">
<div className="inline-flex items-center justify-center flex-1 h-full gap-1">
<CurrencyInput
placeholder="0"
defaultValue={21}
value={amount}
decimalsLimit={2}
min={0} // 0 sats
max={10000} // 1M sats
maxLength={10000} // 1M sats
onValueChange={(value) => setAmount(Number(value))}
className="flex-1 w-full text-4xl font-semibold text-right bg-transparent border-none placeholder:text-neutral-600 focus:outline-none focus:ring-0 dark:text-neutral-400"
/>
<span className="flex-1 w-full text-4xl font-semibold text-left text-neutral-500 dark:text-neutral-400">
sats
</span>
</div>
<div className="inline-flex items-center justify-center gap-2">
{DEFAULT_VALUES.map((value) => (
<button
key={value}
type="button"
onClick={() => setAmount(value)}
className="w-max rounded-full bg-black/10 px-2.5 py-1 text-xs font-medium hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
>
{value} sats
</button>
))}
</div>
</div>
<div className="flex flex-col w-full gap-2">
<input
name="message"
value={content}
onChange={(e) => setContent(e.target.value)}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
placeholder="Enter message (optional)"
className="h-11 w-full resize-none rounded-xl border-transparent bg-black/5 px-3 !outline-none placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/5"
/>
<button
type="button"
onClick={() => submit()}
className="inline-flex items-center justify-center w-full h-10 font-medium rounded-xl bg-neutral-950 text-neutral-50 hover:bg-neutral-900 dark:bg-white/20 dark:hover:bg-white/30"
>
{isCompleted ? "Zapped" : isLoading ? "Processing..." : "Zap"}
</button>
</div>
</div>
</div>
</div>
);
}
+2 -2
View File
@@ -13,12 +13,12 @@
"@astrojs/check": "^0.5.10", "@astrojs/check": "^0.5.10",
"@astrojs/tailwind": "^5.1.0", "@astrojs/tailwind": "^5.1.0",
"@fontsource/alice": "^5.0.13", "@fontsource/alice": "^5.0.13",
"astro": "^4.10.2", "astro": "^4.11.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",
"tailwindcss": "^3.4.4", "tailwindcss": "^3.4.4",
"typescript": "^5.4.5" "typescript": "^5.5.2"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/typography": "^0.5.13" "@tailwindcss/typography": "^0.5.13"
+4 -4
View File
@@ -11,7 +11,7 @@
"tauri": "tauri" "tauri": "tauri"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^1.8.1", "@biomejs/biome": "^1.8.3",
"@tauri-apps/cli": "2.0.0-beta.20", "@tauri-apps/cli": "2.0.0-beta.20",
"turbo": "^1.13.4" "turbo": "^1.13.4"
}, },
@@ -25,11 +25,11 @@
"@tauri-apps/plugin-dialog": "2.0.0-beta.5", "@tauri-apps/plugin-dialog": "2.0.0-beta.5",
"@tauri-apps/plugin-fs": "2.0.0-beta.5", "@tauri-apps/plugin-fs": "2.0.0-beta.5",
"@tauri-apps/plugin-http": "2.0.0-beta.5", "@tauri-apps/plugin-http": "2.0.0-beta.5",
"@tauri-apps/plugin-notification": "2.0.0-beta.5", "@tauri-apps/plugin-os": "github:tauri-apps/tauri-plugin-os#v2",
"@tauri-apps/plugin-os": "2.0.0-beta.5",
"@tauri-apps/plugin-process": "2.0.0-beta.5", "@tauri-apps/plugin-process": "2.0.0-beta.5",
"@tauri-apps/plugin-shell": "2.0.0-beta.6", "@tauri-apps/plugin-shell": "2.0.0-beta.6",
"@tauri-apps/plugin-updater": "2.0.0-beta.5", "@tauri-apps/plugin-updater": "2.0.0-beta.5",
"@tauri-apps/plugin-upload": "2.0.0-beta.6" "@tauri-apps/plugin-upload": "2.0.0-beta.6",
"@tauri-apps/plugin-window-state": "2.0.0-beta.6"
} }
} }
+1 -1
View File
@@ -9,6 +9,6 @@
"devDependencies": { "devDependencies": {
"@lume/tsconfig": "workspace:*", "@lume/tsconfig": "workspace:*",
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"typescript": "^5.4.5" "typescript": "^5.5.2"
} }
} }
+3 -3
View File
@@ -5,8 +5,8 @@
"main": "./src/index.ts", "main": "./src/index.ts",
"dependencies": { "dependencies": {
"@lume/utils": "workspace:^", "@lume/utils": "workspace:^",
"@tanstack/query-persist-client-core": "^5.45.0", "@tanstack/query-persist-client-core": "^5.49.1",
"@tanstack/react-query": "^5.45.0", "@tanstack/react-query": "^5.49.2",
"nostr-tools": "^2.7.0", "nostr-tools": "^2.7.0",
"react": "^18.3.1" "react": "^18.3.1"
}, },
@@ -14,6 +14,6 @@
"@lume/tsconfig": "workspace:^", "@lume/tsconfig": "workspace:^",
"@lume/types": "workspace:^", "@lume/types": "workspace:^",
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"typescript": "^5.4.5" "typescript": "^5.5.2"
} }
} }
+48 -57
View File
@@ -1,9 +1,8 @@
import type { Metadata } from "@lume/types"; import type { Metadata } from "@lume/types";
import { type Result, commands } from "./commands"; import { type Result, commands } from "./commands";
import { Window } from "@tauri-apps/api/window";
export class NostrAccount { export const NostrAccount = {
static async getAccounts() { getAccounts: async () => {
const query = await commands.getAccounts(); const query = await commands.getAccounts();
if (query.status === "ok") { if (query.status === "ok") {
@@ -11,9 +10,8 @@ export class NostrAccount {
} else { } else {
return []; return [];
} }
} },
loadAccount: async (npub: string) => {
static async loadAccount(npub: string) {
const bunker: string = localStorage.getItem(`${npub}_bunker`); const bunker: string = localStorage.getItem(`${npub}_bunker`);
let query: Result<boolean, string>; let query: Result<boolean, string>;
@@ -24,16 +22,12 @@ export class NostrAccount {
} }
if (query.status === "ok") { if (query.status === "ok") {
const panel = Window.getByLabel("panel");
panel.emit("load-notification", { account: npub }); // trigger load notification
return query.data; return query.data;
} else { } else {
throw new Error(query.error); throw new Error(query.error);
} }
} },
createAccount: async () => {
static async createAccount() {
const query = await commands.createAccount(); const query = await commands.createAccount();
if (query.status === "ok") { if (query.status === "ok") {
@@ -41,9 +35,8 @@ export class NostrAccount {
} else { } else {
throw new Error(query.error); throw new Error(query.error);
} }
} },
createProfile: async (profile: Metadata) => {
static async createProfile(profile: Metadata) {
const query = await commands.createProfile( const query = await commands.createProfile(
profile.name || "", profile.name || "",
profile.display_name || "", profile.display_name || "",
@@ -60,9 +53,8 @@ export class NostrAccount {
} else { } else {
throw new Error(query.error); throw new Error(query.error);
} }
} },
saveAccount: async (nsec: string, password = "") => {
static async saveAccount(nsec: string, password = "") {
const query = await commands.saveAccount(nsec, password); const query = await commands.saveAccount(nsec, password);
if (query.status === "ok") { if (query.status === "ok") {
@@ -70,9 +62,8 @@ export class NostrAccount {
} else { } else {
throw new Error(query.error); throw new Error(query.error);
} }
} },
connectRemoteAccount: async (uri: string) => {
static async connectRemoteAccount(uri: string) {
const connect = await commands.connectRemoteAccount(uri); const connect = await commands.connectRemoteAccount(uri);
if (connect.status === "ok") { if (connect.status === "ok") {
@@ -87,9 +78,8 @@ export class NostrAccount {
} else { } else {
throw new Error(connect.error); throw new Error(connect.error);
} }
} },
setContactList: async (pubkeys: string[]) => {
static async setContactList(pubkeys: string[]) {
const query = await commands.setContactList(pubkeys); const query = await commands.setContactList(pubkeys);
if (query.status === "ok") { if (query.status === "ok") {
@@ -97,39 +87,44 @@ export class NostrAccount {
} else { } else {
throw new Error(query.error); throw new Error(query.error);
} }
} },
loadWallet: async () => {
const query = await commands.loadWallet();
static async setWallet(uri: string) { if (query.status === "ok") {
const query = await commands.setNwc(uri); return Number.parseInt(query.data);
} else {
throw new Error(query.error);
}
},
setWallet: async (uri: string) => {
const query = await commands.setWallet(uri);
if (query.status === "ok") { if (query.status === "ok") {
return query.data; return query.data;
} else { } else {
throw new Error(query.error); throw new Error(query.error);
} }
} },
removeWallet: async () => {
const query = await commands.removeWallet();
static async getProfile() { if (query.status === "ok") {
const query = await commands.getCurrentUserProfile(); return query.data;
} else {
throw new Error(query.error);
}
},
getProfile: async () => {
const query = await commands.getCurrentProfile();
if (query.status === "ok") { if (query.status === "ok") {
return JSON.parse(query.data) as Metadata; return JSON.parse(query.data) as Metadata;
} else { } else {
return null; return null;
} }
} },
getContactList: async () => {
static async getBalance() {
const query = await commands.getBalance();
if (query.status === "ok") {
return Number.parseInt(query.data);
} else {
return 0;
}
}
static async getContactList() {
const query = await commands.getContactList(); const query = await commands.getContactList();
if (query.status === "ok") { if (query.status === "ok") {
@@ -137,9 +132,8 @@ export class NostrAccount {
} else { } else {
return []; return [];
} }
} },
isContactListEmpty: async () => {
static async isContactListEmpty() {
const query = await commands.isContactListEmpty(); const query = await commands.isContactListEmpty();
if (query.status === "ok") { if (query.status === "ok") {
@@ -147,9 +141,8 @@ export class NostrAccount {
} else { } else {
return true; return true;
} }
} },
checkContact: async (pubkey: string) => {
static async checkContact(pubkey: string) {
const query = await commands.checkContact(pubkey); const query = await commands.checkContact(pubkey);
if (query.status === "ok") { if (query.status === "ok") {
@@ -157,9 +150,8 @@ export class NostrAccount {
} else { } else {
throw new Error(query.error); throw new Error(query.error);
} }
} },
toggleContact: async (pubkey: string, alias?: string) => {
static async toggleContact(pubkey: string, alias?: string) {
const query = await commands.toggleContact(pubkey, alias); const query = await commands.toggleContact(pubkey, alias);
if (query.status === "ok") { if (query.status === "ok") {
@@ -167,9 +159,8 @@ export class NostrAccount {
} else { } else {
throw new Error(query.error); throw new Error(query.error);
} }
} },
f2f: async (npub: string) => {
static async f2f(npub: string) {
const query = await commands.friendToFriend(npub); const query = await commands.friendToFriend(npub);
if (query.status === "ok") { if (query.status === "ok") {
@@ -177,5 +168,5 @@ export class NostrAccount {
} else { } else {
throw new Error(query.error); throw new Error(query.error);
} }
} },
} };
+55 -21
View File
@@ -4,7 +4,7 @@
/** user-defined commands **/ /** user-defined commands **/
export const commands = { export const commands = {
async getRelays() : Promise<Result<Relays, null>> { async getRelays() : Promise<Result<Relays, string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("get_relays") }; return { status: "ok", data: await TAURI_INVOKE("get_relays") };
} catch (e) { } catch (e) {
@@ -12,7 +12,7 @@ try {
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async connectRelay(relay: string) : Promise<Result<boolean, null>> { async connectRelay(relay: string) : Promise<Result<boolean, string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("connect_relay", { relay }) }; return { status: "ok", data: await TAURI_INVOKE("connect_relay", { relay }) };
} catch (e) { } catch (e) {
@@ -20,7 +20,7 @@ try {
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async removeRelay(relay: string) : Promise<Result<boolean, null>> { async removeRelay(relay: string) : Promise<Result<boolean, string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("remove_relay", { relay }) }; return { status: "ok", data: await TAURI_INVOKE("remove_relay", { relay }) };
} catch (e) { } catch (e) {
@@ -28,7 +28,7 @@ try {
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async getBootstrapRelays() : Promise<Result<string[], null>> { async getBootstrapRelays() : Promise<Result<string[], string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("get_bootstrap_relays") }; return { status: "ok", data: await TAURI_INVOKE("get_bootstrap_relays") };
} catch (e) { } catch (e) {
@@ -52,7 +52,7 @@ try {
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async createAccount() : Promise<Result<Account, null>> { async createAccount() : Promise<Result<Account, string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("create_account") }; return { status: "ok", data: await TAURI_INVOKE("create_account") };
} catch (e) { } catch (e) {
@@ -148,7 +148,7 @@ try {
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async checkContact(hex: string) : Promise<Result<boolean, null>> { async checkContact(hex: string) : Promise<Result<boolean, string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("check_contact", { hex }) }; return { status: "ok", data: await TAURI_INVOKE("check_contact", { hex }) };
} catch (e) { } catch (e) {
@@ -180,25 +180,25 @@ try {
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async setNwc(uri: string) : Promise<Result<boolean, string>> { async setWallet(uri: string) : Promise<Result<boolean, string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("set_nwc", { uri }) }; return { status: "ok", data: await TAURI_INVOKE("set_wallet", { uri }) };
} catch (e) { } catch (e) {
if(e instanceof Error) throw e; if(e instanceof Error) throw e;
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async loadNwc() : Promise<Result<boolean, string>> { async loadWallet() : Promise<Result<string, string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("load_nwc") }; return { status: "ok", data: await TAURI_INVOKE("load_wallet") };
} catch (e) { } catch (e) {
if(e instanceof Error) throw e; if(e instanceof Error) throw e;
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async getBalance() : Promise<Result<string, string>> { async removeWallet() : Promise<Result<null, null>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("get_balance") }; return { status: "ok", data: await TAURI_INVOKE("remove_wallet") };
} catch (e) { } catch (e) {
if(e instanceof Error) throw e; if(e instanceof Error) throw e;
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
@@ -292,6 +292,14 @@ try {
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async listenEventReply(id: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("listen_event_reply", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getEventsBy(publicKey: string, asOf: string | null) : Promise<Result<RichEvent[], string>> { async getEventsBy(publicKey: string, asOf: string | null) : Promise<Result<RichEvent[], string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("get_events_by", { publicKey, asOf }) }; return { status: "ok", data: await TAURI_INVOKE("get_events_by", { publicKey, asOf }) };
@@ -308,6 +316,14 @@ try {
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async listenLocalEvent(label: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("listen_local_event", { label }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getGroupEvents(publicKeys: string[], until: string | null) : Promise<Result<RichEvent[], string>> { async getGroupEvents(publicKeys: string[], until: string | null) : Promise<Result<RichEvent[], string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("get_group_events", { publicKeys, until }) }; return { status: "ok", data: await TAURI_INVOKE("get_group_events", { publicKeys, until }) };
@@ -372,18 +388,23 @@ try {
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async showInFolder(path: string) : Promise<void> { async unlisten(id: string) : Promise<Result<null, null>> {
await TAURI_INVOKE("show_in_folder", { path });
},
async createColumn(label: string, x: number, y: number, width: number, height: number, url: string) : Promise<Result<string, string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("create_column", { label, x, y, width, height, url }) }; return { status: "ok", data: await TAURI_INVOKE("unlisten", { id }) };
} catch (e) { } catch (e) {
if(e instanceof Error) throw e; if(e instanceof Error) throw e;
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async closeColumn(label: string) : Promise<Result<boolean, null>> { async createColumn(column: Column) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("create_column", { column }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async closeColumn(label: string) : Promise<Result<boolean, string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("close_column", { label }) }; return { status: "ok", data: await TAURI_INVOKE("close_column", { label }) };
} catch (e) { } catch (e) {
@@ -407,9 +428,17 @@ try {
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
} }
}, },
async openWindow(label: string, title: string, url: string, width: number, height: number) : Promise<Result<null, string>> { async reloadColumn(label: string) : Promise<Result<null, string>> {
try { try {
return { status: "ok", data: await TAURI_INVOKE("open_window", { label, title, url, width, height }) }; return { status: "ok", data: await TAURI_INVOKE("reload_column", { label }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async openWindow(window: Window) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("open_window", { window }) };
} catch (e) { } catch (e) {
if(e instanceof Error) throw e; if(e instanceof Error) throw e;
else return { status: "error", error: e as any }; else return { status: "error", error: e as any };
@@ -418,6 +447,9 @@ try {
async openMainWindow() : Promise<void> { async openMainWindow() : Promise<void> {
await TAURI_INVOKE("open_main_window"); await TAURI_INVOKE("open_main_window");
}, },
async forceQuit() : Promise<void> {
await TAURI_INVOKE("force_quit");
},
async setBadge(count: number) : Promise<void> { async setBadge(count: number) : Promise<void> {
await TAURI_INVOKE("set_badge", { count }); await TAURI_INVOKE("set_badge", { count });
} }
@@ -434,10 +466,12 @@ await TAURI_INVOKE("set_badge", { count });
/** user-defined types **/ /** user-defined types **/
export type Account = { npub: string; nsec: string } export type Account = { npub: string; nsec: string }
export type Column = { label: string; url: string; x: number; y: number; width: number; height: number }
export type Meta = { content: string; images: string[]; videos: string[]; events: string[]; mentions: string[]; hashtags: string[] } export type Meta = { content: string; images: string[]; videos: string[]; events: string[]; mentions: string[]; hashtags: string[] }
export type Relays = { connected: string[]; read: string[] | null; write: string[] | null; both: string[] | null } export type Relays = { connected: string[]; read: string[] | null; write: string[] | null; both: string[] | null }
export type RichEvent = { raw: string; parsed: Meta | null } export type RichEvent = { raw: string; parsed: Meta | null }
export type Settings = { proxy: string | null; image_resize_service: string | null; use_relay_hint: boolean; content_warning: boolean; display_avatar: boolean; display_zap_button: boolean; display_repost_button: boolean; display_media: boolean } export type Settings = { proxy: string | null; image_resize_service: string | null; use_relay_hint: boolean; content_warning: boolean; display_avatar: boolean; display_zap_button: boolean; display_repost_button: boolean; display_media: boolean; vibrancy: boolean }
export type Window = { label: string; title: string; url: string; width: number; height: number; maximizable: boolean; minimizable: boolean; hidden_title: boolean }
/** tauri-specta globals **/ /** tauri-specta globals **/
+128 -40
View File
@@ -1,10 +1,4 @@
import type { import type { EventTag, Kind, Meta, NostrEvent } from "@lume/types";
EventTag,
EventWithReplies,
Kind,
Meta,
NostrEvent,
} from "@lume/types";
import { type Result, commands } from "./commands"; import { type Result, commands } from "./commands";
export class LumeEvent { export class LumeEvent {
@@ -17,6 +11,7 @@ export class LumeEvent {
public sig: string; public sig: string;
public meta: Meta; public meta: Meta;
public relay?: string; public relay?: string;
public replies?: LumeEvent[];
#raw: NostrEvent; #raw: NostrEvent;
constructor(event: NostrEvent) { constructor(event: NostrEvent) {
@@ -24,11 +19,6 @@ export class LumeEvent {
Object.assign(this, event); Object.assign(this, event);
} }
get isWarning() {
const tag = this.tags.find((tag) => tag[0] === "content-warning");
return tag?.[1]; // return: reason;
}
get isQuote() { get isQuote() {
return this.tags.filter((tag) => tag[0] === "q").length > 0; return this.tags.filter((tag) => tag[0] === "q").length > 0;
} }
@@ -94,53 +84,128 @@ export class LumeEvent {
return { id, relayHint }; return { id, relayHint };
} }
public async getReplies(id: string) { get warning() {
const query = await commands.getReplies(id); const warningTag = this.tags.filter(
(tag) => tag[0] === "content-warning",
)?.[0];
if (warningTag) {
return warningTag[1];
} else {
const nsfwTag = this.tags.filter(
(tag) => tag[0] === "t" && tag[1] === "NSFW",
)?.[0];
if (nsfwTag) {
return "NSFW";
} else {
return null;
}
}
}
public async getEventReplies() {
const query = await commands.getReplies(this.id);
if (query.status === "ok") { if (query.status === "ok") {
const events = query.data.map((item) => { const events = query.data
const raw = JSON.parse(item.raw) as EventWithReplies; // Create Lume Events
.map((item) => LumeEvent.from(item.raw, item.parsed))
// Filter quote event
.filter(
(ev) =>
!ev.tags.filter((t) => t[0] === "q" || t[3] === "mention").length,
);
if (item.parsed) { if (events.length > 1) {
raw.meta = item.parsed; const removeQueues = new Set();
} else {
raw.meta = null;
}
return raw;
});
if (events.length > 0) {
const replies = new Set();
for (const event of events) { for (const event of events) {
const tags = event.tags.filter( const tags = event.tags.filter(
(el) => el[0] === "e" && el[1] !== id && el[3] !== "mention", (t) => t[0] === "e" && t[1] !== this.id,
); );
if (tags.length > 0) { if (tags.length === 1) {
for (const tag of tags) { const index = events.findIndex((ev) => ev.id === tags[0][1]);
const rootIndex = events.findIndex((el) => el.id === tag[1]);
if (rootIndex !== -1) { if (index !== -1) {
const rootEvent = events[rootIndex]; const rootEvent = events[index];
if (rootEvent?.replies) { if (rootEvent.replies?.length) {
rootEvent.replies.push(event); rootEvent.replies.push(event);
} else { } else {
rootEvent.replies = [event]; rootEvent.replies = [event];
}
// Add current event to queue
removeQueues.add(event.id);
continue;
}
}
for (const tag of tags) {
const id = tag[1];
const rootIndex = events.findIndex((ev) => ev.id === id);
if (rootIndex !== -1) {
const rootEvent = events[rootIndex];
if (rootEvent.replies?.length) {
const childIndex = rootEvent.replies.findIndex(
(ev) => ev.id === id,
);
if (childIndex !== -1) {
const childEvent = rootEvent.replies[rootIndex];
if (childEvent.replies?.length) {
childEvent.replies.push(event);
} else {
childEvent.replies = [event];
}
// Add current event to queue
removeQueues.add(event.id);
} }
} else {
replies.add(event.id); rootEvent.replies = [event];
// Add current event to queue
removeQueues.add(event.id);
} }
} }
break;
} }
} }
return events.filter((ev) => !replies.has(ev.id)); return events.filter((ev) => !removeQueues.has(ev.id));
} }
return events; return events;
} else {
console.error(query.error);
return [];
}
}
public async listenEventReply() {
const query = await commands.listenEventReply(this.id);
if (query.status === "ok") {
return query.data;
} else {
throw new Error(query.error);
}
}
public async unlistenEventReply() {
const query = await commands.unlisten(this.id);
if (query.status === "ok") {
return query.data;
} else {
throw new Error(query.error);
} }
} }
@@ -205,4 +270,27 @@ export class LumeEvent {
throw new Error(query.error); throw new Error(query.error);
} }
} }
static async build(event: NostrEvent) {
const query = await commands.getEventMeta(event.content);
if (query.status === "ok") {
event.meta = query.data;
return new LumeEvent(event);
} else {
return new LumeEvent(event);
}
}
static from(raw: string, parsed?: Meta) {
const nostrEvent: NostrEvent = JSON.parse(raw);
if (parsed) {
nostrEvent.meta = parsed;
} else {
nostrEvent.meta = null;
}
return new LumeEvent(nostrEvent);
}
} }
+84 -84
View File
@@ -1,5 +1,6 @@
import type { LumeColumn, Metadata, NostrEvent, Relay } from "@lume/types"; import type { LumeColumn, Metadata, NostrEvent, Relay } from "@lume/types";
import { resolveResource } from "@tauri-apps/api/path"; import { resolveResource } from "@tauri-apps/api/path";
import { getCurrent } from "@tauri-apps/api/window";
import { open } from "@tauri-apps/plugin-dialog"; import { open } from "@tauri-apps/plugin-dialog";
import { readFile, readTextFile } from "@tauri-apps/plugin-fs"; import { readFile, readTextFile } from "@tauri-apps/plugin-fs";
import { relaunch } from "@tauri-apps/plugin-process"; import { relaunch } from "@tauri-apps/plugin-process";
@@ -7,26 +8,26 @@ import { nip19 } from "nostr-tools";
import { type Result, type RichEvent, commands } from "./commands"; import { type Result, type RichEvent, commands } from "./commands";
import { LumeEvent } from "./event"; import { LumeEvent } from "./event";
export class NostrQuery { function toLumeEvents(richEvents: RichEvent[]) {
static #toLumeEvents(richEvents: RichEvent[]) { const events = richEvents.map((item) => {
const events = richEvents.map((item) => { const nostrEvent: NostrEvent = JSON.parse(item.raw);
const nostrEvent = JSON.parse(item.raw) as NostrEvent;
if (item.parsed) { if (item.parsed) {
nostrEvent.meta = item.parsed; nostrEvent.meta = item.parsed;
} else { } else {
nostrEvent.meta = null; nostrEvent.meta = null;
} }
const lumeEvent = new LumeEvent(nostrEvent); const lumeEvent = new LumeEvent(nostrEvent);
return lumeEvent; return lumeEvent;
}); });
return events; return events;
} }
static async upload(filePath?: string) { export const NostrQuery = {
upload: async (filePath?: string) => {
const allowExts = [ const allowExts = [
"png", "png",
"jpeg", "jpeg",
@@ -79,9 +80,8 @@ export class NostrQuery {
} catch (e) { } catch (e) {
throw new Error(String(e)); throw new Error(String(e));
} }
} },
getNotifications: async () => {
static async getNotifications() {
const query = await commands.getNotifications(); const query = await commands.getNotifications();
if (query.status === "ok") { if (query.status === "ok") {
@@ -93,9 +93,8 @@ export class NostrQuery {
console.error(query.error); console.error(query.error);
return []; return [];
} }
} },
getProfile: async (pubkey: string) => {
static async getProfile(pubkey: string) {
const normalize = pubkey.replace("nostr:", "").replace(/[^\w\s]/gi, ""); const normalize = pubkey.replace("nostr:", "").replace(/[^\w\s]/gi, "");
const query = await commands.getProfile(normalize); const query = await commands.getProfile(normalize);
@@ -105,9 +104,8 @@ export class NostrQuery {
} else { } else {
return null; return null;
} }
} },
getEvent: async (id: string, hint?: string) => {
static async getEvent(id: string, hint?: string) {
// Validate ID // Validate ID
const normalizeId: string = id const normalizeId: string = id
.replace("nostr:", "") .replace("nostr:", "")
@@ -149,9 +147,8 @@ export class NostrQuery {
console.log("[getEvent]: ", query.error); console.log("[getEvent]: ", query.error);
return null; return null;
} }
} },
getRepostEvent: async (event: LumeEvent) => {
static async getRepostEvent(event: LumeEvent) {
try { try {
const embed: NostrEvent = JSON.parse(event.content); const embed: NostrEvent = JSON.parse(event.content);
const query = await commands.getEventMeta(embed.content); const query = await commands.getEventMeta(embed.content);
@@ -180,70 +177,74 @@ export class NostrQuery {
return null; return null;
} }
} }
} },
getUserEvents: async (pubkey: string, asOf?: number) => {
static async getUserEvents(pubkey: string, asOf?: number) {
const until: string = asOf && asOf > 0 ? asOf.toString() : undefined; const until: string = asOf && asOf > 0 ? asOf.toString() : undefined;
const query = await commands.getEventsBy(pubkey, until); const query = await commands.getEventsBy(pubkey, until);
if (query.status === "ok") { if (query.status === "ok") {
const data = NostrQuery.#toLumeEvents(query.data); const data = toLumeEvents(query.data);
return data; return data;
} else { } else {
return []; return [];
} }
} },
getLocalEvents: async (asOf?: number) => {
static async getLocalEvents(asOf?: number) {
const until: string = asOf && asOf > 0 ? asOf.toString() : undefined; const until: string = asOf && asOf > 0 ? asOf.toString() : undefined;
const query = await commands.getLocalEvents(until); const query = await commands.getLocalEvents(until);
if (query.status === "ok") { if (query.status === "ok") {
const data = NostrQuery.#toLumeEvents(query.data); const data = toLumeEvents(query.data);
return data; return data;
} else { } else {
return []; return [];
} }
} },
listenLocalEvent: async () => {
const label = getCurrent().label;
const query = await commands.listenLocalEvent(label);
static async getGroupEvents(pubkeys: string[], asOf?: number) { if (query.status === "ok") {
return query.data;
} else {
throw new Error(query.error);
}
},
getGroupEvents: async (pubkeys: string[], asOf?: number) => {
const until: string = asOf && asOf > 0 ? asOf.toString() : undefined; const until: string = asOf && asOf > 0 ? asOf.toString() : undefined;
const query = await commands.getGroupEvents(pubkeys, until); const query = await commands.getGroupEvents(pubkeys, until);
if (query.status === "ok") { if (query.status === "ok") {
const data = NostrQuery.#toLumeEvents(query.data); const data = toLumeEvents(query.data);
return data; return data;
} else { } else {
return []; return [];
} }
} },
getGlobalEvents: async (asOf?: number) => {
static async getGlobalEvents(asOf?: number) {
const until: string = asOf && asOf > 0 ? asOf.toString() : undefined; const until: string = asOf && asOf > 0 ? asOf.toString() : undefined;
const query = await commands.getGlobalEvents(until); const query = await commands.getGlobalEvents(until);
if (query.status === "ok") { if (query.status === "ok") {
const data = NostrQuery.#toLumeEvents(query.data); const data = toLumeEvents(query.data);
return data; return data;
} else { } else {
return []; return [];
} }
} },
getHashtagEvents: async (hashtags: string[], asOf?: number) => {
static async getHashtagEvents(hashtags: string[], asOf?: number) {
const until: string = asOf && asOf > 0 ? asOf.toString() : undefined; const until: string = asOf && asOf > 0 ? asOf.toString() : undefined;
const nostrTags = hashtags.map((tag) => tag.replace("#", "")); const nostrTags = hashtags.map((tag) => tag.replace("#", ""));
const query = await commands.getHashtagEvents(nostrTags, until); const query = await commands.getHashtagEvents(nostrTags, until);
if (query.status === "ok") { if (query.status === "ok") {
const data = NostrQuery.#toLumeEvents(query.data); const data = toLumeEvents(query.data);
return data; return data;
} else { } else {
return []; return [];
} }
} },
verifyNip05: async (pubkey: string, nip05?: string) => {
static async verifyNip05(pubkey: string, nip05?: string) {
const query = await commands.verifyNip05(pubkey, nip05); const query = await commands.verifyNip05(pubkey, nip05);
if (query.status === "ok") { if (query.status === "ok") {
@@ -251,9 +252,8 @@ export class NostrQuery {
} else { } else {
return false; return false;
} }
} },
getNstore: async (key: string) => {
static async getNstore(key: string) {
const query = await commands.getNstore(key); const query = await commands.getNstore(key);
if (query.status === "ok") { if (query.status === "ok") {
@@ -262,9 +262,8 @@ export class NostrQuery {
} else { } else {
return null; return null;
} }
} },
setNstore: async (key: string, value: string) => {
static async setNstore(key: string, value: string) {
const query = await commands.setNstore(key, value); const query = await commands.setNstore(key, value);
if (query.status === "ok") { if (query.status === "ok") {
@@ -272,9 +271,8 @@ export class NostrQuery {
} else { } else {
throw new Error(query.error); throw new Error(query.error);
} }
} },
getUserSettings: async () => {
static async getUserSettings() {
const query = await commands.getSettings(); const query = await commands.getSettings();
if (query.status === "ok") { if (query.status === "ok") {
@@ -282,9 +280,8 @@ export class NostrQuery {
} else { } else {
return query.error; return query.error;
} }
} },
setUserSettings: async (newSettings: string) => {
static async setUserSettings(newSettings: string) {
const query = await commands.setNewSettings(newSettings); const query = await commands.setNewSettings(newSettings);
if (query.status === "ok") { if (query.status === "ok") {
@@ -292,9 +289,8 @@ export class NostrQuery {
} else { } else {
return query.error; return query.error;
} }
} },
getColumns: async () => {
static async getColumns() {
const key = "lume:columns"; const key = "lume:columns";
const systemPath = "resources/system_columns.json"; const systemPath = "resources/system_columns.json";
const resourcePath = await resolveResource(systemPath); const resourcePath = await resolveResource(systemPath);
@@ -319,9 +315,8 @@ export class NostrQuery {
} catch { } catch {
return systemColumns; return systemColumns;
} }
} },
setColumns: async (columns: LumeColumn[]) => {
static async setColumns(columns: LumeColumn[]) {
const key = "lume:columns"; const key = "lume:columns";
const content = JSON.stringify(columns); const content = JSON.stringify(columns);
const query = await commands.setNstore(key, content); const query = await commands.setNstore(key, content);
@@ -331,9 +326,8 @@ export class NostrQuery {
} else { } else {
throw new Error(query.error); throw new Error(query.error);
} }
} },
getRelays: async () => {
static async getRelays() {
const query = await commands.getRelays(); const query = await commands.getRelays();
if (query.status === "ok") { if (query.status === "ok") {
@@ -341,9 +335,8 @@ export class NostrQuery {
} else { } else {
throw new Error(query.error); throw new Error(query.error);
} }
} },
connectRelay: async (url: string) => {
static async connectRelay(url: string) {
const relayUrl = new URL(url); const relayUrl = new URL(url);
if (relayUrl.protocol === "wss:" || relayUrl.protocol === "ws:") { if (relayUrl.protocol === "wss:" || relayUrl.protocol === "ws:") {
@@ -355,9 +348,8 @@ export class NostrQuery {
throw new Error(query.error); throw new Error(query.error);
} }
} }
} },
removeRelay: async (url: string) => {
static async removeRelay(url: string) {
const relayUrl = new URL(url); const relayUrl = new URL(url);
if (relayUrl.protocol === "wss:" || relayUrl.protocol === "ws:") { if (relayUrl.protocol === "wss:" || relayUrl.protocol === "ws:") {
@@ -369,9 +361,8 @@ export class NostrQuery {
throw new Error(query.error); throw new Error(query.error);
} }
} }
} },
getBootstrapRelays: async () => {
static async getBootstrapRelays() {
const query = await commands.getBootstrapRelays(); const query = await commands.getBootstrapRelays();
if (query.status === "ok") { if (query.status === "ok") {
@@ -389,9 +380,8 @@ export class NostrQuery {
} else { } else {
return []; return [];
} }
} },
saveBootstrapRelays: async (relays: Relay[]) => {
static async saveBootstrapRelays(relays: Relay[]) {
const text = relays const text = relays
.map((relay) => Object.values(relay).join(",")) .map((relay) => Object.values(relay).join(","))
.join("\n"); .join("\n");
@@ -402,5 +392,15 @@ export class NostrQuery {
} else { } else {
throw new Error(query.error); throw new Error(query.error);
} }
} },
} unlisten: async (id?: string) => {
const label = id ? id : getCurrent().label;
const query = await commands.unlisten(label);
if (query.status === "ok") {
return query.data;
} else {
throw new Error(query.error);
}
},
};
+89 -90
View File
@@ -2,49 +2,58 @@ import type { NostrEvent } from "@lume/types";
import { commands } from "./commands"; import { commands } from "./commands";
import type { LumeEvent } from "./event"; import type { LumeEvent } from "./event";
export class LumeWindow { export const LumeWindow = {
static async openMainWindow() { openMainWindow: async () => {
const query = await commands.openMainWindow(); const query = await commands.openMainWindow();
return query; return query;
} },
openEvent: async (event: NostrEvent | LumeEvent) => {
static async openEvent(event: NostrEvent | LumeEvent) {
const eTags = event.tags.filter((tag) => tag[0] === "e" || tag[0] === "q"); const eTags = event.tags.filter((tag) => tag[0] === "e" || tag[0] === "q");
const root: string = const root: string =
eTags.find((el) => el[3] === "root")?.[1] ?? eTags[0]?.[1]; eTags.find((el) => el[3] === "root")?.[1] ?? eTags[0]?.[1];
const reply: string = const reply: string =
eTags.find((el) => el[3] === "reply")?.[1] ?? eTags[1]?.[1]; eTags.find((el) => el[3] === "reply")?.[1] ?? eTags[1]?.[1];
const label = `event-${event.id}`;
const url = `/events/${root ?? reply ?? event.id}`; const url = `/events/${root ?? reply ?? event.id}`;
const label = `event-${root ?? reply ?? event.id}`;
const query = await commands.openWindow(label, "Thread", url, 500, 800); const query = await commands.openWindow({
if (query.status === "ok") {
return query.data;
} else {
throw new Error(query.error);
}
}
static async openProfile(pubkey: string) {
const label = `user-${pubkey}`;
const query = await commands.openWindow(
label, label,
"Profile", url,
`/users/${pubkey}`, title: "Thread",
500, width: 500,
800, height: 800,
); maximizable: true,
minimizable: true,
hidden_title: false,
});
if (query.status === "ok") { if (query.status === "ok") {
return query.data; return query.data;
} else { } else {
throw new Error(query.error); throw new Error(query.error);
} }
} },
openProfile: async (pubkey: string) => {
const label = `user-${pubkey}`;
const query = await commands.openWindow({
label,
url: `/users/${pubkey}`,
title: "Profile",
width: 500,
height: 800,
maximizable: true,
minimizable: true,
hidden_title: true,
});
static async openEditor(reply_to?: string, quote?: string) { if (query.status === "ok") {
return query.data;
} else {
throw new Error(query.error);
}
},
openEditor: async (reply_to?: string, quote?: string) => {
let url: string; let url: string;
if (reply_to) { if (reply_to) {
@@ -60,93 +69,83 @@ export class LumeWindow {
} }
const label = `editor-${reply_to ? reply_to : 0}`; const label = `editor-${reply_to ? reply_to : 0}`;
const query = await commands.openWindow(label, "Editor", url, 560, 340); const query = await commands.openWindow({
label,
url,
title: "Editor",
width: 560,
height: 340,
maximizable: false,
minimizable: false,
hidden_title: true,
});
if (query.status === "ok") { if (query.status === "ok") {
return query.data; return query.data;
} else { } else {
throw new Error(query.error); throw new Error(query.error);
} }
} },
openZap: async (id: string) => {
const wallet = await commands.loadWallet();
static async openZap(id: string, pubkey: string) { if (wallet.status === "ok") {
const nwc = await commands.loadNwc(); await commands.openWindow({
label: `zap-${id}`,
if (nwc.status === "ok") { url: `/zap/${id}`,
const status = nwc.data; title: "Zap",
width: 360,
if (!status) { height: 460,
const label = "nwc"; maximizable: false,
await commands.openWindow( minimizable: false,
label, hidden_title: true,
"Nostr Wallet Connect", });
"/nwc",
400,
600,
);
} else {
const label = `zap-${id}`;
await commands.openWindow(
label,
"Zap",
`/zap/${id}?pubkey=${pubkey}`,
400,
500,
);
}
} else { } else {
throw new Error(nwc.error); await LumeWindow.openSettings("bitcoin-connect");
} }
} },
openSettings: async (path?: string) => {
static async openSettings() {
const label = "settings"; const label = "settings";
const query = await commands.openWindow( const query = await commands.openWindow({
label, label,
"Settings", url: path ? `/settings/${path}` : "/settings/general",
"/settings/general", title: "Settings",
800, width: 800,
500, height: 500,
); maximizable: false,
minimizable: false,
hidden_title: true,
});
if (query.status === "ok") { if (query.status === "ok") {
return query.data; return query.data;
} else { } else {
throw new Error(query.error); throw new Error(query.error);
} }
} },
openSearch: async (searchType: "notes" | "users", searchQuery: string) => {
const url = `/search/${searchType}?query=${searchQuery}`;
const label = `search-${searchQuery
.toLowerCase()
.replace(/[^\w ]+/g, "")
.replace(/ +/g, "_")
.replace(/_+/g, "_")}`;
static async openSearch() { const query = await commands.openWindow({
const label = "search";
const query = await commands.openWindow(
label, label,
"Search", url,
"/search", title: "Search",
400, width: 400,
600, height: 600,
); maximizable: false,
minimizable: false,
hidden_title: true,
});
if (query.status === "ok") { if (query.status === "ok") {
return query.data; return query.data;
} else { } else {
throw new Error(query.error); throw new Error(query.error);
} }
} },
};
static async openActivity(account: string) {
const label = "activity";
const query = await commands.openWindow(
label,
"Activity",
`/activity/${account}/texts`,
400,
600,
);
if (query.status === "ok") {
return query.data;
} else {
throw new Error(query.error);
}
}
}
+2 -1
View File
@@ -15,6 +15,7 @@
"@tailwindcss/typography": "^0.5.13", "@tailwindcss/typography": "^0.5.13",
"tailwind-gradient-mask-image": "^1.2.0", "tailwind-gradient-mask-image": "^1.2.0",
"tailwind-scrollbar": "^3.1.0", "tailwind-scrollbar": "^3.1.0",
"tailwindcss": "^3.4.4" "tailwindcss": "^3.4.4",
"tailwindcss-content-visibility": "^0.2.0"
} }
} }
+1 -1
View File
@@ -49,7 +49,7 @@ const config = {
require("@tailwindcss/forms"), require("@tailwindcss/forms"),
require("@tailwindcss/typography"), require("@tailwindcss/typography"),
require("tailwind-gradient-mask-image"), require("tailwind-gradient-mask-image"),
require("tailwind-scrollbar")({ nocompatible: true }), require("tailwindcss-content-visibility"),
], ],
}; };
+2 -1
View File
@@ -78,10 +78,11 @@ export interface LumeColumn {
} }
export interface ColumnEvent { export interface ColumnEvent {
type: "reset" | "add" | "remove" | "update" | "left" | "right" | "set_title"; type: "reset" | "add" | "remove" | "update" | "move" | "set_title";
label?: string; label?: string;
title?: string; title?: string;
column?: LumeColumn; column?: LumeColumn;
direction?: "left" | "right";
} }
export interface Relays { export interface Relays {
+1 -1
View File
@@ -9,6 +9,6 @@
"access": "public" "access": "public"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.4.5" "typescript": "^5.5.2"
} }
} }
+1 -1
View File
@@ -16,6 +16,6 @@
"@lume/types": "workspace:^", "@lume/types": "workspace:^",
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"tailwindcss": "^3.4.4", "tailwindcss": "^3.4.4",
"typescript": "^5.4.5" "typescript": "^5.5.2"
} }
} }
-86
View File
@@ -1,86 +0,0 @@
import { ArrowLeftIcon, ArrowRightIcon } from "@lume/icons";
import { cn } from "@lume/utils";
import { useSnapCarousel } from "react-snap-carousel";
interface CarouselProps<T> {
readonly items: T[];
readonly renderItem: (
props: CarouselRenderItemProps<T>,
) => React.ReactElement<CarouselItemProps>;
}
interface CarouselRenderItemProps<T> {
readonly item: T;
readonly isSnapPoint: boolean;
}
export const Carousel = <T,>({ items, renderItem }: CarouselProps<T>) => {
const {
scrollRef,
pages,
activePageIndex,
prev,
next,
goTo,
snapPointIndexes,
} = useSnapCarousel();
return (
<div className="relative group">
<ul
ref={scrollRef}
className="relative flex overflow-auto snap-x scrollbar-none"
>
{items.map((item, i) =>
renderItem({
item,
isSnapPoint: snapPointIndexes.has(i),
}),
)}
</ul>
<div
aria-hidden
className="absolute z-10 items-center justify-between hidden w-full px-5 transform -translate-x-1/2 -translate-y-1/2 group-hover:flex left-1/2 top-1/2"
>
<button
type="button"
className={cn(
"size-11 rounded-full bg-black/50 backdrop-blur-sm flex items-center justify-center text-white",
activePageIndex <= 0 ? "opacity-50" : "",
)}
onClick={() => prev()}
>
<ArrowLeftIcon className="size-6" />
</button>
<button
type="button"
className={cn(
"size-11 rounded-full bg-black/50 backdrop-blur-sm flex items-center justify-center text-white",
activePageIndex <= 0 ? "opacity-50" : "",
)}
onClick={() => next()}
>
<ArrowRightIcon className="size-6" />
</button>
</div>
<div className="absolute flex items-center justify-center w-12 h-6 text-sm font-medium text-white bg-black rounded-full top-3 right-3 mix-blend-multiply bg-opacity-20 backdrop-blur-sm">
{activePageIndex + 1} / {pages.length}
</div>
</div>
);
};
interface CarouselItemProps {
readonly isSnapPoint: boolean;
readonly children?: React.ReactNode;
}
export const CarouselItem = ({ isSnapPoint, children }: CarouselItemProps) => (
<li
className={cn(
"w-[240px] h-[320px] shrink-0 pl-3 last:pr-2",
isSnapPoint ? "" : "snap-start",
)}
>
{children}
</li>
);
-1
View File
@@ -1,4 +1,3 @@
export * from "./container"; export * from "./container";
export * from "./box"; export * from "./box";
export * from "./spinner"; export * from "./spinner";
export * from "./carousel";
-1
View File
@@ -1,5 +1,4 @@
export * from "./src/constants"; export * from "./src/constants";
export * from "./src/delay";
export * from "./src/formater"; export * from "./src/formater";
export * from "./src/editor"; export * from "./src/editor";
export * from "./src/cn"; export * from "./src/cn";
+1 -1
View File
@@ -24,6 +24,6 @@
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"tailwind-merge": "^2.3.0", "tailwind-merge": "^2.3.0",
"typescript": "^5.4.5" "typescript": "^5.5.2"
} }
} }
-1
View File
@@ -1 +0,0 @@
export const delay = (ms: number) => new Promise((res) => setTimeout(res, ms));
+2 -2
View File
@@ -81,7 +81,7 @@ export function displayLongHandle(str: string) {
const handle = split[0]; const handle = split[0];
const service = split[1]; const service = split[1];
return handle.substring(0, 16) + "..." + "@" + service; return `${handle.substring(0, 16)}...@${service}`;
} }
// convert number to K, M, B, T, etc. // convert number to K, M, B, T, etc.
@@ -127,7 +127,7 @@ export function getBitcoinDisplayValues(satoshis: number) {
.reverse() .reverse()
.forEach((c, index) => { .forEach((c, index) => {
if (index > 0 && index % 3 === 0) { if (index > 0 && index % 3 === 0) {
res = " " + res; res = ` ${res}`;
} }
res = c + res; res = c + res;
}); });

Some files were not shown because too many files have changed in this diff Show More