Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72da83d648 | ||
|
|
783a4538a4 | ||
|
|
15e62cad11 | ||
|
|
c52b20ca80 | ||
|
|
04706a6d7c | ||
|
|
0755cbeb6c | ||
|
|
8eb01c8bbf | ||
|
|
ed4f89ff66 | ||
|
|
d9fe647f8e | ||
|
|
843c2d52e7 | ||
|
|
017a3676a4 | ||
|
|
fcb70c0e9a | ||
|
|
0fec21b9ce | ||
|
|
968b1ada94 | ||
|
|
5c9b599b1e | ||
|
|
717c3e17df | ||
|
|
a4540a0802 | ||
|
|
31bacc2646 | ||
|
|
6e5d0f0e76 | ||
|
|
f0712e5961 | ||
|
|
3fbd66dece | ||
|
|
1283432632 | ||
|
|
59eaaec903 | ||
|
|
4f0f210076 | ||
|
|
e4a317f038 | ||
|
|
9779d020c7 | ||
|
|
f8280ec8ee | ||
|
|
6c26f8967b |
+1
-1
@@ -35,4 +35,4 @@ dist/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
*.pem
|
||||||
.vscode/
|
.vscode/
|
||||||
ndb/
|
.idea/
|
||||||
|
|||||||
Generated
-5
@@ -1,5 +0,0 @@
|
|||||||
# Default ignored files
|
|
||||||
/shelf/
|
|
||||||
/workspace.xml
|
|
||||||
# Editor-based HTTP Client requests
|
|
||||||
/httpRequests/
|
|
||||||
Generated
-5
@@ -1,5 +0,0 @@
|
|||||||
<component name="ProjectCodeStyleConfiguration">
|
|
||||||
<state>
|
|
||||||
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
|
|
||||||
</state>
|
|
||||||
</component>
|
|
||||||
-46
@@ -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>
|
|
||||||
Generated
-17
@@ -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>
|
|
||||||
Generated
-8
@@ -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
@@ -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
@@ -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
@@ -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 |
@@ -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;
|
||||||
|
|||||||
@@ -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" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -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" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
|||||||
+135
-155
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Vendored
+2
-1
@@ -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 {
|
||||||
|
|||||||
@@ -9,6 +9,6 @@
|
|||||||
"access": "public"
|
"access": "public"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.4.5"
|
"typescript": "^5.5.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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,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";
|
||||||
|
|||||||
@@ -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 +0,0 @@
|
|||||||
export const delay = (ms: number) => new Promise((res) => setTimeout(res, ms));
|
|
||||||
@@ -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
Reference in New Issue
Block a user