Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4a317f038 | ||
|
|
9779d020c7 | ||
|
|
f8280ec8ee | ||
|
|
6c26f8967b | ||
|
|
e1424b851c | ||
|
|
d14e609579 | ||
|
|
8c0627ff27 | ||
|
|
18c133d096 | ||
|
|
0061ecea78 | ||
|
|
d01cf8319d | ||
|
|
843895d876 | ||
|
|
7c99ed39e4 | ||
|
|
71be59b2e9 | ||
|
|
1c20512ecc | ||
|
|
90342c552f | ||
|
|
b396c8a695 | ||
|
|
6996e30889 | ||
|
|
7ba793fad8 | ||
|
|
f11f836518 | ||
|
|
04fe0fcec8 | ||
|
|
799835a629 | ||
|
|
4e7da4108b | ||
|
|
7c7b082b3a | ||
|
|
38d6c51921 | ||
|
|
1738cbdd97 | ||
|
|
2e885b76a1 | ||
|
|
f94680e487 | ||
|
|
c682a58842 | ||
|
|
921cf871ee | ||
|
|
d5b1593aca | ||
|
|
6676b4e2a4 | ||
|
|
5f30ddcfca | ||
|
|
41d0de539d | ||
|
|
e254ee3203 | ||
|
|
6d42360549 | ||
|
|
70c5143445 | ||
|
|
41b66b18f5 | ||
|
|
dda0720ed4 | ||
|
|
4b60b39119 | ||
|
|
d2e5122d5a | ||
|
|
32f3315344 | ||
|
|
5ca9444358 | ||
|
|
4dc13385a5 | ||
|
|
b90ad1421f | ||
|
|
bba324ea53 | ||
|
|
7449000f5f | ||
|
|
dc7762ca11 | ||
|
|
3a3f960dde | ||
|
|
12e066ff2e | ||
|
|
fe4f965ed5 | ||
|
|
5d3f2264e9 | ||
|
|
407fe40b67 | ||
|
|
1f38eba2cc | ||
|
|
9b5867f80c | ||
|
|
cac774a0c1 | ||
|
|
82689bf3c3 | ||
|
|
f60e438a64 | ||
|
|
ca06f2b6ed | ||
|
|
99d9c70826 | ||
|
|
60afbf090b | ||
|
|
10ca4e6ff4 | ||
|
|
b0f387d029 | ||
|
|
1a8f750640 | ||
|
|
25523229a2 | ||
|
|
47835ed857 | ||
|
|
d84647bc6b | ||
|
|
7724eccd72 | ||
|
|
8ea2335225 | ||
|
|
b60d4db0df | ||
|
|
f1e17ff3c4 | ||
|
|
32954f17b6 | ||
|
|
cf70b0f882 |
@@ -16,6 +16,8 @@ jobs:
|
|||||||
args: "--target aarch64-apple-darwin"
|
args: "--target aarch64-apple-darwin"
|
||||||
- platform: "macos-latest" # for Intel based macs.
|
- platform: "macos-latest" # for Intel based macs.
|
||||||
args: "--target x86_64-apple-darwin"
|
args: "--target x86_64-apple-darwin"
|
||||||
|
- platform: "macos-latest" # for Intel based macs.
|
||||||
|
args: "--target universal-apple-darwin"
|
||||||
#- platform: 'ubuntu-22.04'
|
#- platform: 'ubuntu-22.04'
|
||||||
# args: ''
|
# args: ''
|
||||||
#- platform: 'windows-latest'
|
#- platform: 'windows-latest'
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
|
<state>
|
||||||
|
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
|
||||||
|
</state>
|
||||||
|
</component>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?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>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?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>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -1,18 +1,14 @@
|
|||||||
_Note_: Lume is under rewrite to using Rust Nostr as back-end and more lightweight front-end. If you need stable version, you can download v3 and below.
|
|
||||||
|
|
||||||
Source code for v3 is stored here: https://github.com/lumehq/lume/tree/old
|
|
||||||
|
|
||||||
--
|
|
||||||
|
|
||||||
## Introduction
|
## Introduction
|
||||||
|
|
||||||
Lume is a Nostr client for desktop include Linux, Windows and macOS. It is free and open source, you can look at source code on Github. Lume is actively improving the app and adding new features, you can expect new update every month.
|
Lume is a Nostr client for desktop include Linux, Windows and macOS. It is free and open source, you can look at source code on Github. Lume is actively improving the app and adding new features, you can expect new update every month.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
Download Lume v3 (v3.0.1-stable) for your platform here: [https://github.com/lumehq/lume/releases](https://github.com/lumehq/lume/releases)
|
Download Lume v4 for your platform here: [https://github.com/lumehq/lume/releases](https://github.com/lumehq/lume/releases)
|
||||||
|
|
||||||
Supported platform: macOS, Windows and Linux
|
Supported platform: macOS. Windows and Linux are coming soon.
|
||||||
|
|
||||||
|
Windows and Linux are availabel on v3 and below.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
|
|||||||
@@ -9,52 +9,57 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lume/ark": "workspace:^",
|
|
||||||
"@lume/icons": "workspace:^",
|
"@lume/icons": "workspace:^",
|
||||||
|
"@lume/system": "workspace:^",
|
||||||
"@lume/ui": "workspace:^",
|
"@lume/ui": "workspace:^",
|
||||||
"@lume/utils": "workspace:^",
|
"@lume/utils": "workspace:^",
|
||||||
|
"@radix-ui/react-avatar": "^1.0.4",
|
||||||
"@radix-ui/react-checkbox": "^1.0.4",
|
"@radix-ui/react-checkbox": "^1.0.4",
|
||||||
"@radix-ui/react-collapsible": "^1.0.3",
|
"@radix-ui/react-collapsible": "^1.0.3",
|
||||||
"@radix-ui/react-dialog": "^1.0.5",
|
"@radix-ui/react-dialog": "^1.0.5",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||||
|
"@radix-ui/react-hover-card": "^1.0.7",
|
||||||
"@radix-ui/react-popover": "^1.0.7",
|
"@radix-ui/react-popover": "^1.0.7",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.1.0",
|
||||||
"@radix-ui/react-switch": "^1.0.3",
|
"@radix-ui/react-switch": "^1.0.3",
|
||||||
|
"@radix-ui/react-tabs": "^1.0.4",
|
||||||
"@radix-ui/react-tooltip": "^1.0.7",
|
"@radix-ui/react-tooltip": "^1.0.7",
|
||||||
"@tanstack/query-sync-storage-persister": "^5.32.0",
|
"@tanstack/query-persist-client-core": "^5.45.0",
|
||||||
"@tanstack/react-query": "^5.32.0",
|
"@tanstack/react-query": "^5.45.0",
|
||||||
"@tanstack/react-query-persist-client": "^5.32.0",
|
"@tanstack/react-router": "^1.38.1",
|
||||||
"@tanstack/react-router": "1.29.2",
|
"embla-carousel-react": "^8.1.5",
|
||||||
"i18next": "^23.11.3",
|
"i18next": "^23.11.5",
|
||||||
"i18next-resources-to-backend": "^1.2.1",
|
"i18next-resources-to-backend": "^1.2.1",
|
||||||
"minidenticons": "^4.2.1",
|
"minidenticons": "^4.2.1",
|
||||||
"nanoid": "^5.0.7",
|
"nanoid": "^5.0.7",
|
||||||
"nostr-tools": "^2.5.1",
|
"nostr-tools": "^2.7.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-currency-input-field": "^3.8.0",
|
"react-currency-input-field": "^3.8.0",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-hook-form": "^7.51.3",
|
"react-hook-form": "^7.52.0",
|
||||||
"react-hotkeys-hook": "^4.5.0",
|
"react-hotkeys-hook": "^4.5.0",
|
||||||
"react-i18next": "^14.1.1",
|
"react-i18next": "^14.1.2",
|
||||||
"slate": "^0.102.0",
|
"react-string-replace": "^1.1.1",
|
||||||
"slate-react": "^0.102.0",
|
"slate": "^0.103.0",
|
||||||
"sonner": "^1.4.41",
|
"slate-react": "^0.105.0",
|
||||||
"use-debounce": "^10.0.0",
|
"sonner": "^1.5.0",
|
||||||
"virtua": "^0.30.2"
|
"use-debounce": "^10.0.1",
|
||||||
|
"virtua": "^0.31.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@lume/tailwindcss": "workspace:^",
|
"@lume/tailwindcss": "workspace:^",
|
||||||
"@lume/tsconfig": "workspace:^",
|
"@lume/tsconfig": "workspace:^",
|
||||||
"@lume/types": "workspace:^",
|
"@lume/types": "workspace:^",
|
||||||
"@tanstack/router-devtools": "^1.31.3",
|
"@tanstack/router-devtools": "^1.38.1",
|
||||||
"@tanstack/router-vite-plugin": "^1.30.0",
|
"@tanstack/router-vite-plugin": "^1.38.0",
|
||||||
"@types/react": "^18.3.1",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@vitejs/plugin-react-swc": "^3.6.0",
|
"@vitejs/plugin-react-swc": "^3.7.0",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.19",
|
||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.38",
|
||||||
"tailwindcss": "^3.4.3",
|
"tailwindcss": "^3.4.4",
|
||||||
"typescript": "^5.4.5",
|
"typescript": "^5.4.5",
|
||||||
"vite": "^5.2.10",
|
"vite": "^5.3.1",
|
||||||
"vite-plugin-top-level-await": "^1.4.1",
|
"vite-plugin-top-level-await": "^1.4.1",
|
||||||
"vite-tsconfig-paths": "^4.3.2"
|
"vite-tsconfig-paths": "^4.3.2"
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 296 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
Before Width: | Height: | Size: 310 KiB |
|
Before Width: | Height: | Size: 951 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 201 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 257 KiB |
@@ -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,33 +1,31 @@
|
|||||||
import { Ark } from "@lume/ark";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { CancelCircleIcon, CheckCircleIcon, InfoCircleIcon } from "@lume/icons";
|
|
||||||
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
|
|
||||||
import { QueryClient } from "@tanstack/react-query";
|
|
||||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
|
||||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||||
import { platform } from "@tauri-apps/plugin-os";
|
|
||||||
import React, { StrictMode } from "react";
|
import React, { StrictMode } from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import { I18nextProvider } from "react-i18next";
|
import { I18nextProvider } from "react-i18next";
|
||||||
import { Toaster } from "sonner";
|
|
||||||
import "./app.css";
|
import "./app.css";
|
||||||
|
import { type } from "@tauri-apps/plugin-os";
|
||||||
import i18n from "./locale";
|
import i18n from "./locale";
|
||||||
import { routeTree } from "./router.gen"; // auto generated file
|
import { routeTree } from "./router.gen"; // auto generated file
|
||||||
|
|
||||||
const ark = new Ark();
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
const platformName = await platform();
|
const os = await type();
|
||||||
|
|
||||||
const persister = createSyncStoragePersister({
|
|
||||||
storage: window.localStorage,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set up a Router instance
|
// Set up a Router instance
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
routeTree,
|
routeTree,
|
||||||
context: {
|
context: {
|
||||||
ark,
|
|
||||||
queryClient,
|
queryClient,
|
||||||
platform: platformName,
|
platform: os,
|
||||||
|
},
|
||||||
|
Wrap: ({ children }) => {
|
||||||
|
return (
|
||||||
|
<I18nextProvider i18n={i18n} defaultNS={"translation"}>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{children}
|
||||||
|
</QueryClientProvider>
|
||||||
|
</I18nextProvider>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -48,25 +46,8 @@ const rootElement = document.getElementById("root")!;
|
|||||||
if (!rootElement.innerHTML) {
|
if (!rootElement.innerHTML) {
|
||||||
const root = ReactDOM.createRoot(rootElement);
|
const root = ReactDOM.createRoot(rootElement);
|
||||||
root.render(
|
root.render(
|
||||||
<I18nextProvider i18n={i18n} defaultNS={"translation"}>
|
<StrictMode>
|
||||||
<PersistQueryClientProvider
|
<App />
|
||||||
client={queryClient}
|
</StrictMode>,
|
||||||
persistOptions={{ persister }}
|
|
||||||
>
|
|
||||||
<StrictMode>
|
|
||||||
<Toaster
|
|
||||||
position="bottom-right"
|
|
||||||
icons={{
|
|
||||||
success: <CheckCircleIcon className="size-5" />,
|
|
||||||
info: <InfoCircleIcon className="size-5" />,
|
|
||||||
error: <CancelCircleIcon className="size-5" />,
|
|
||||||
}}
|
|
||||||
closeButton
|
|
||||||
theme="system"
|
|
||||||
/>
|
|
||||||
<App />
|
|
||||||
</StrictMode>
|
|
||||||
</PersistQueryClientProvider>
|
|
||||||
</I18nextProvider>,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import { NostrQuery } from "@lume/system";
|
||||||
import { Spinner } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
|
||||||
import {
|
import {
|
||||||
type Dispatch,
|
type Dispatch,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
@@ -18,21 +18,17 @@ export function AvatarUploader({
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const { ark } = useRouteContext({ strict: false });
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const uploadAvatar = async () => {
|
const uploadAvatar = async () => {
|
||||||
// start loading
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
try {
|
||||||
const image = await ark.upload();
|
setLoading(true);
|
||||||
|
const image = await NostrQuery.upload();
|
||||||
setPicture(image);
|
setPicture(image);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
setLoading(false);
|
||||||
toast.error(String(e));
|
toast.error(String(e));
|
||||||
}
|
}
|
||||||
|
|
||||||
// stop loading
|
|
||||||
setLoading(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
import { User } from "@lume/ui";
|
import { User } from "@/components/user";
|
||||||
|
import { NostrAccount } from "@lume/system";
|
||||||
import { getBitcoinDisplayValues } from "@lume/utils";
|
import { getBitcoinDisplayValues } from "@lume/utils";
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
export function Balance({ account }: { account: string }) {
|
export function Balance({ account }: { account: string }) {
|
||||||
const { ark } = useRouteContext({ strict: false });
|
|
||||||
const [balance, setBalance] = useState(0);
|
const [balance, setBalance] = useState(0);
|
||||||
const value = useMemo(() => getBitcoinDisplayValues(balance), [balance]);
|
const value = useMemo(() => getBitcoinDisplayValues(balance), [balance]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function getBalance() {
|
async function getBalance() {
|
||||||
const val = await ark.get_balance();
|
const val = await NostrAccount.getBalance();
|
||||||
setBalance(val);
|
setBalance(val);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,86 +2,87 @@ import { CancelIcon, CheckIcon } from "@lume/icons";
|
|||||||
import type { LumeColumn } from "@lume/types";
|
import type { LumeColumn } from "@lume/types";
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { getCurrent } from "@tauri-apps/api/webviewWindow";
|
import { getCurrent } from "@tauri-apps/api/webviewWindow";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
export function Col({
|
type WindowEvent = {
|
||||||
|
scroll: boolean;
|
||||||
|
resize: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Column({
|
||||||
column,
|
column,
|
||||||
account,
|
account,
|
||||||
isScroll,
|
|
||||||
isResize,
|
|
||||||
}: {
|
}: {
|
||||||
column: LumeColumn;
|
column: LumeColumn;
|
||||||
account: string;
|
account: string;
|
||||||
isScroll: boolean;
|
|
||||||
isResize: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
const container = useRef<HTMLDivElement>(null);
|
const container = useRef<HTMLDivElement>(null);
|
||||||
const [webview, setWebview] = useState<string | undefined>(undefined);
|
const webviewLabel = `column-${account}_${column.label}`;
|
||||||
|
|
||||||
const repositionWebview = async () => {
|
const [isCreated, setIsCreated] = useState(false);
|
||||||
if (webview && webview.length > 1) {
|
|
||||||
const newRect = container.current.getBoundingClientRect();
|
|
||||||
await invoke("reposition_column", {
|
|
||||||
label: webview,
|
|
||||||
x: newRect.x,
|
|
||||||
y: newRect.y,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resizeWebview = async () => {
|
const repositionWebview = useCallback(async () => {
|
||||||
if (webview && webview.length > 1) {
|
const newRect = container.current.getBoundingClientRect();
|
||||||
const newRect = container.current.getBoundingClientRect();
|
await invoke("reposition_column", {
|
||||||
await invoke("resize_column", {
|
label: webviewLabel,
|
||||||
label: webview,
|
x: newRect.x,
|
||||||
width: newRect.width,
|
y: newRect.y,
|
||||||
height: newRect.height,
|
});
|
||||||
});
|
}, []);
|
||||||
}
|
|
||||||
};
|
const resizeWebview = useCallback(async () => {
|
||||||
|
const newRect = container.current.getBoundingClientRect();
|
||||||
|
await invoke("resize_column", {
|
||||||
|
label: webviewLabel,
|
||||||
|
width: newRect.width,
|
||||||
|
height: newRect.height,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
resizeWebview();
|
if (!isCreated) return;
|
||||||
}, [isResize]);
|
|
||||||
|
const unlisten = listen<WindowEvent>("child-webview", (data) => {
|
||||||
|
if (data.payload.scroll) repositionWebview();
|
||||||
|
if (data.payload.resize) repositionWebview().then(() => resizeWebview());
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unlisten.then((f) => f());
|
||||||
|
};
|
||||||
|
}, [isCreated]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isScroll) repositionWebview();
|
if (!container?.current) return;
|
||||||
}, [isScroll]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const rect = container.current.getBoundingClientRect();
|
||||||
(async () => {
|
const url = `${column.content}?account=${account}&label=${column.label}&name=${column.name}`;
|
||||||
if (webview && webview.length > 1) return;
|
|
||||||
|
|
||||||
const rect = container.current.getBoundingClientRect();
|
// create new webview
|
||||||
const windowLabel = `column-${column.label}`;
|
invoke("create_column", {
|
||||||
const url = `${column.content}?account=${account}&label=${column.label}&name=${column.name}`;
|
label: webviewLabel,
|
||||||
|
x: rect.x,
|
||||||
// create new webview
|
y: rect.y,
|
||||||
const label: string = await invoke("create_column", {
|
width: rect.width,
|
||||||
label: windowLabel,
|
height: rect.height,
|
||||||
x: rect.x,
|
url,
|
||||||
y: rect.y,
|
}).then(() => {
|
||||||
width: rect.width,
|
console.log("created: ", webviewLabel);
|
||||||
height: rect.height,
|
setIsCreated(true);
|
||||||
url,
|
});
|
||||||
});
|
|
||||||
|
|
||||||
setWebview(label);
|
|
||||||
})();
|
|
||||||
|
|
||||||
// close webview when unmounted
|
// close webview when unmounted
|
||||||
return () => {
|
return () => {
|
||||||
if (webview && webview.length > 1) {
|
invoke("close_column", { label: webviewLabel }).then(() => {
|
||||||
invoke("close_column", {
|
console.log("closed: ", webviewLabel);
|
||||||
label: webview,
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, [webview]);
|
}, [account]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-[440px] shrink-0 p-2">
|
<div className="h-full w-[500px] shrink-0 p-2">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col w-full h-full rounded-xl",
|
"flex flex-col w-full h-full rounded-xl",
|
||||||
@@ -90,9 +91,7 @@ export function Col({
|
|||||||
: "",
|
: "",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{column.label !== "open" ? (
|
<Header label={column.label} name={column.name} />
|
||||||
<Header label={column.label} name={column.name} />
|
|
||||||
) : null}
|
|
||||||
<div ref={container} className="flex-1 w-full h-full" />
|
<div ref={container} className="flex-1 w-full h-full" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -125,10 +124,10 @@ function Header({ label, name }: { label: string; name: string }) {
|
|||||||
}, [title]);
|
}, [title]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-9 w-full flex items-center justify-between shrink-0 px-1">
|
<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="shrink-0 h-9 flex items-center justify-center">
|
<div className="flex items-center justify-center shrink-0 h-9">
|
||||||
<div className="relative flex gap-2 items-center">
|
<div className="relative flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
contentEditable
|
contentEditable
|
||||||
suppressContentEditableWarning={true}
|
suppressContentEditableWarning={true}
|
||||||
@@ -151,7 +150,7 @@ function Header({ label, name }: { label: string; name: string }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => close()}
|
onClick={() => close()}
|
||||||
className="size-7 inline-flex hover:bg-black/10 rounded-lg dark:hover:bg-white/10 items-center justify-center 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 text-neutral-600 dark:text-neutral-400 hover:text-neutral-800 dark:hover:text-neutral-200"
|
||||||
>
|
>
|
||||||
<CancelIcon className="size-4" />
|
<CancelIcon className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -1,18 +1,17 @@
|
|||||||
import { ThreadIcon } from "@lume/icons";
|
import { ThreadIcon } from "@lume/icons";
|
||||||
import type { Event } from "@lume/types";
|
import { Note } from "@/components/note";
|
||||||
import { Note } from "@lume/ui";
|
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
import type { LumeEvent } from "@lume/system";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
export function Conversation({
|
export function Conversation({
|
||||||
event,
|
event,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
event: Event;
|
event: LumeEvent;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const { ark } = useRouteContext({ strict: false });
|
const thread = useMemo(() => event.thread, [event]);
|
||||||
const thread = ark.parse_event_thread(event.tags);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Note.Provider event={event}>
|
<Note.Provider event={event}>
|
||||||
@@ -23,7 +22,7 @@ export function Conversation({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{thread?.root ? <Note.Child eventId={thread?.root} isRoot /> : null}
|
{thread?.root?.id ? <Note.Child event={thread?.root} isRoot /> : null}
|
||||||
<div className="flex items-center gap-2 px-3">
|
<div className="flex items-center gap-2 px-3">
|
||||||
<div className="inline-flex items-center gap-1.5 shrink-0 text-sm font-medium text-neutral-600 dark:text-neutral-400">
|
<div className="inline-flex items-center gap-1.5 shrink-0 text-sm font-medium text-neutral-600 dark:text-neutral-400">
|
||||||
<ThreadIcon className="size-4" />
|
<ThreadIcon className="size-4" />
|
||||||
@@ -31,15 +30,15 @@ export function Conversation({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1 h-px bg-neutral-100 dark:bg-white/5" />
|
<div className="flex-1 h-px bg-neutral-100 dark:bg-white/5" />
|
||||||
</div>
|
</div>
|
||||||
{thread?.reply ? <Note.Child eventId={thread?.reply} /> : null}
|
{thread?.reply?.id ? <Note.Child event={thread?.reply} /> : null}
|
||||||
<div>
|
<div>
|
||||||
<div className="px-3 h-14 flex items-center justify-between">
|
<div className="flex items-center justify-between px-3 h-14">
|
||||||
<Note.User />
|
<Note.User />
|
||||||
</div>
|
</div>
|
||||||
<Note.Content className="px-3" />
|
<Note.Content className="px-3" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center h-14 px-3">
|
<div className="flex items-center px-3 h-14">
|
||||||
<Note.Open />
|
<Note.Open />
|
||||||
</div>
|
</div>
|
||||||
</Note.Root>
|
</Note.Root>
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { VisitIcon } from "@lume/icons";
|
import { VisitIcon } from "@lume/icons";
|
||||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
|
||||||
import { useNoteContext } from "../provider";
|
import { useNoteContext } from "../provider";
|
||||||
|
import { LumeWindow } from "@lume/system";
|
||||||
|
|
||||||
export function NoteOpenThread() {
|
export function NoteOpenThread() {
|
||||||
const event = useNoteContext();
|
const event = useNoteContext();
|
||||||
const { ark } = useRouteContext({ strict: false });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip.Provider>
|
<Tooltip.Provider>
|
||||||
@@ -13,7 +12,7 @@ export function NoteOpenThread() {
|
|||||||
<Tooltip.Trigger asChild>
|
<Tooltip.Trigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => ark.open_event(event)}
|
onClick={() => LumeWindow.openEvent(event)}
|
||||||
className="group inline-flex h-7 w-14 bg-neutral-100 dark:bg-white/10 rounded-full items-center justify-center text-sm font-medium text-neutral-800 dark:text-neutral-200 hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20"
|
className="group inline-flex h-7 w-14 bg-neutral-100 dark:bg-white/10 rounded-full items-center justify-center text-sm font-medium text-neutral-800 dark:text-neutral-200 hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20"
|
||||||
>
|
>
|
||||||
<VisitIcon className="shrink-0 size-4" />
|
<VisitIcon className="shrink-0 size-4" />
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
import { ReplyIcon } from "@lume/icons";
|
import { ReplyIcon } from "@lume/icons";
|
||||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
|
||||||
import { useNoteContext } from "../provider";
|
import { useNoteContext } from "../provider";
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
|
import { LumeWindow } from "@lume/system";
|
||||||
|
|
||||||
export function NoteReply({ large = false }: { large?: boolean }) {
|
export function NoteReply({ large = false }: { large?: boolean }) {
|
||||||
const event = useNoteContext();
|
const event = useNoteContext();
|
||||||
const { ark } = useRouteContext({ strict: false });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip.Provider>
|
<Tooltip.Provider>
|
||||||
@@ -14,7 +13,7 @@ export function NoteReply({ large = false }: { large?: boolean }) {
|
|||||||
<Tooltip.Trigger asChild>
|
<Tooltip.Trigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => ark.open_editor(event.id)}
|
onClick={() => LumeWindow.openEditor(event.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
|
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
|
||||||
large
|
large
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { RepostIcon } from "@lume/icons";
|
||||||
|
import { LumeWindow } from "@lume/system";
|
||||||
|
import { Spinner } from "@lume/ui";
|
||||||
|
import { cn } from "@lume/utils";
|
||||||
|
import { useRouteContext } from "@tanstack/react-router";
|
||||||
|
import { Menu, MenuItem } from "@tauri-apps/api/menu";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useNoteContext } from "../provider";
|
||||||
|
|
||||||
|
export function NoteRepost({ large = false }: { large?: boolean }) {
|
||||||
|
const event = useNoteContext();
|
||||||
|
const { settings } = useRouteContext({ strict: false });
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [isRepost, setIsRepost] = useState(false);
|
||||||
|
|
||||||
|
const repost = async () => {
|
||||||
|
if (isRepost) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// repost
|
||||||
|
await event.repost();
|
||||||
|
|
||||||
|
// update state
|
||||||
|
setLoading(false);
|
||||||
|
setIsRepost(true);
|
||||||
|
|
||||||
|
// notify
|
||||||
|
toast.success("You've reposted this post successfully");
|
||||||
|
} catch {
|
||||||
|
setLoading(false);
|
||||||
|
toast.error("Repost failed, try again later");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const menuItems = await Promise.all([
|
||||||
|
MenuItem.new({
|
||||||
|
text: "Quote",
|
||||||
|
action: async () => repost(),
|
||||||
|
}),
|
||||||
|
MenuItem.new({
|
||||||
|
text: "Repost",
|
||||||
|
action: () => LumeWindow.openEditor(null, event.id),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const menu = await Menu.new({
|
||||||
|
items: menuItems,
|
||||||
|
});
|
||||||
|
|
||||||
|
await menu.popup().catch((e) => console.error(e));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!settings.display_repost_button) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => showContextMenu(e)}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200 rounded-full",
|
||||||
|
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"
|
||||||
|
: "size-7",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Spinner className="size-4" />
|
||||||
|
) : (
|
||||||
|
<RepostIcon className={cn("size-4", isRepost ? "text-blue-500" : "")} />
|
||||||
|
)}
|
||||||
|
{large ? "Repost" : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,33 +1,19 @@
|
|||||||
import { ZapIcon } from "@lume/icons";
|
import { ZapIcon } from "@lume/icons";
|
||||||
import { useRouteContext, useSearch } from "@tanstack/react-router";
|
import { LumeWindow } from "@lume/system";
|
||||||
import { toast } from "sonner";
|
|
||||||
import { useNoteContext } from "../provider";
|
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
|
import { useRouteContext } from "@tanstack/react-router";
|
||||||
|
import { useNoteContext } from "../provider";
|
||||||
|
|
||||||
export function NoteZap({ large = false }: { large?: boolean }) {
|
export function NoteZap({ large = false }: { large?: boolean }) {
|
||||||
const event = useNoteContext();
|
const event = useNoteContext();
|
||||||
const { ark, settings } = useRouteContext({ strict: false });
|
const { settings } = useRouteContext({ strict: false });
|
||||||
const { account } = useSearch({ strict: false });
|
|
||||||
|
|
||||||
const zap = async () => {
|
if (!settings.display_zap_button) return null;
|
||||||
try {
|
|
||||||
const nwc = await ark.load_nwc();
|
|
||||||
if (!nwc) {
|
|
||||||
ark.open_nwc();
|
|
||||||
} else {
|
|
||||||
ark.open_zap(event.id, event.pubkey, account);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(String(e));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!settings.zap) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => zap()}
|
onClick={() => LumeWindow.openZap(event.id, event.pubkey)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
|
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
|
||||||
large
|
large
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { useEvent } from "@lume/system";
|
||||||
|
import { cn } from "@lume/utils";
|
||||||
|
import { Note } from ".";
|
||||||
|
import { InfoIcon } from "@lume/icons";
|
||||||
|
import type { EventTag } from "@lume/types";
|
||||||
|
|
||||||
|
export function NoteChild({
|
||||||
|
event,
|
||||||
|
isRoot,
|
||||||
|
}: {
|
||||||
|
event: EventTag;
|
||||||
|
isRoot?: boolean;
|
||||||
|
}) {
|
||||||
|
const { isLoading, isError, data } = useEvent(event.id, event.relayHint);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-3 pt-3">
|
||||||
|
<div className="rounded-full size-8 shrink-0 bg-neutral-200 dark:bg-neutral-800 animate-pulse" />
|
||||||
|
<div className="w-2/3 h-4 rounded-md animate-pulse bg-neutral-200 dark:bg-neutral-800" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !data) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-3 pt-3">
|
||||||
|
<div className="inline-flex items-center justify-center text-white bg-red-500 rounded-full size-8 shrink-0">
|
||||||
|
<InfoIcon className="size-5" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-red-500">
|
||||||
|
Event not found with your current relay set
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Note.Provider event={data}>
|
||||||
|
<Note.Root className={cn(isRoot ? "mb-3" : "")}>
|
||||||
|
<div className="flex items-center justify-between px-3 h-14">
|
||||||
|
<Note.User />
|
||||||
|
</div>
|
||||||
|
<Note.Content className="px-3" />
|
||||||
|
</Note.Root>
|
||||||
|
</Note.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { cn } from "@lume/utils";
|
||||||
|
import { useRouteContext } from "@tanstack/react-router";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
import { type ReactNode, useMemo } from "react";
|
||||||
|
import reactStringReplace from "react-string-replace";
|
||||||
|
import { Hashtag } from "./mentions/hashtag";
|
||||||
|
import { MentionNote } from "./mentions/note";
|
||||||
|
import { MentionUser } from "./mentions/user";
|
||||||
|
import { Images } from "./preview/images";
|
||||||
|
import { Videos } from "./preview/videos";
|
||||||
|
import { useNoteContext } from "./provider";
|
||||||
|
|
||||||
|
export function NoteContent({
|
||||||
|
quote = true,
|
||||||
|
mention = true,
|
||||||
|
clean,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
quote?: boolean;
|
||||||
|
mention?: boolean;
|
||||||
|
clean?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const { settings } = useRouteContext({ strict: false });
|
||||||
|
const event = useNoteContext();
|
||||||
|
const content = useMemo(() => {
|
||||||
|
try {
|
||||||
|
// Get parsed meta
|
||||||
|
const { content, hashtags, events, mentions } = event.meta;
|
||||||
|
|
||||||
|
// Define rich content
|
||||||
|
let richContent: ReactNode[] | string = settings.display_media
|
||||||
|
? content
|
||||||
|
: event.content;
|
||||||
|
|
||||||
|
for (const hashtag of hashtags) {
|
||||||
|
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
|
||||||
|
richContent = reactStringReplace(richContent, regex, (_, index) => {
|
||||||
|
return <Hashtag key={hashtag + index} tag={hashtag} />;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
if (quote) {
|
||||||
|
richContent = reactStringReplace(richContent, event, (_, index) => (
|
||||||
|
<MentionNote key={event + index} eventId={event} />
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!quote && clean) {
|
||||||
|
richContent = reactStringReplace(richContent, event, () => null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const user of mentions) {
|
||||||
|
if (mention) {
|
||||||
|
richContent = reactStringReplace(richContent, user, (_, index) => (
|
||||||
|
<MentionUser key={user + index} pubkey={user} />
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mention && clean) {
|
||||||
|
richContent = reactStringReplace(richContent, user, () => null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
richContent = reactStringReplace(
|
||||||
|
richContent,
|
||||||
|
/(https?:\/\/\S+)/gi,
|
||||||
|
(match, index) => (
|
||||||
|
<a
|
||||||
|
key={match + index}
|
||||||
|
href={match}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline text-blue-500 hover:text-blue-600"
|
||||||
|
>
|
||||||
|
{match}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
richContent = reactStringReplace(richContent, /(\r\n|\r|\n)+/g, () => (
|
||||||
|
<div key={nanoid()} className="h-3" />
|
||||||
|
));
|
||||||
|
|
||||||
|
return richContent;
|
||||||
|
} catch (e) {
|
||||||
|
console.log("[parser]: ", e);
|
||||||
|
return event.content;
|
||||||
|
}
|
||||||
|
}, [event.content]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"select-text text-pretty content-break overflow-hidden",
|
||||||
|
event.meta?.content.length > 400
|
||||||
|
? "max-h-[250px] gradient-mask-b-0"
|
||||||
|
: "",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
{settings.display_media && event.meta?.images.length ? (
|
||||||
|
<Images urls={event.meta.images} />
|
||||||
|
) : null}
|
||||||
|
{settings.display_media && event.meta?.videos.length ? (
|
||||||
|
<Videos urls={event.meta.videos} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { cn } from "@lume/utils";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
import { type ReactNode, useMemo } from "react";
|
||||||
|
import reactStringReplace from "react-string-replace";
|
||||||
|
import { Hashtag } from "./mentions/hashtag";
|
||||||
|
import { MentionNote } from "./mentions/note";
|
||||||
|
import { MentionUser } from "./mentions/user";
|
||||||
|
import { ImagePreview } from "./preview/image";
|
||||||
|
import { VideoPreview } from "./preview/video";
|
||||||
|
import { useNoteContext } from "./provider";
|
||||||
|
|
||||||
|
export function NoteContentLarge({
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const event = useNoteContext();
|
||||||
|
const content = useMemo(() => {
|
||||||
|
try {
|
||||||
|
// Get parsed meta
|
||||||
|
const { images, videos, hashtags, events, mentions } = event.meta;
|
||||||
|
|
||||||
|
// Define rich content
|
||||||
|
let richContent: ReactNode[] | string = event.content;
|
||||||
|
|
||||||
|
for (const hashtag of hashtags) {
|
||||||
|
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
|
||||||
|
richContent = reactStringReplace(richContent, regex, () => (
|
||||||
|
<Hashtag key={nanoid()} tag={hashtag} />
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
richContent = reactStringReplace(richContent, event, (match, i) => (
|
||||||
|
<MentionNote key={match + i} eventId={event} />
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const mention of mentions) {
|
||||||
|
richContent = reactStringReplace(richContent, mention, (match, i) => (
|
||||||
|
<MentionUser key={match + i} pubkey={mention} />
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const image of images) {
|
||||||
|
richContent = reactStringReplace(richContent, image, (match, i) => (
|
||||||
|
<ImagePreview key={match + i} url={match} />
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const video of videos) {
|
||||||
|
richContent = reactStringReplace(richContent, video, (match, i) => (
|
||||||
|
<VideoPreview key={match + i} url={match} />
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
richContent = reactStringReplace(
|
||||||
|
richContent,
|
||||||
|
/(https?:\/\/\S+)/gi,
|
||||||
|
(match, i) => (
|
||||||
|
<a
|
||||||
|
key={match + i}
|
||||||
|
href={match}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-blue-500 line-clamp-1 hover:text-blue-600"
|
||||||
|
>
|
||||||
|
{match}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
richContent = reactStringReplace(richContent, /(\r\n|\r|\n)+/g, () => (
|
||||||
|
<div key={nanoid()} className="h-3" />
|
||||||
|
));
|
||||||
|
|
||||||
|
return richContent;
|
||||||
|
} catch (e) {
|
||||||
|
console.log("[parser]: ", e);
|
||||||
|
return event.content;
|
||||||
|
}
|
||||||
|
}, [event.content]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"select-text leading-normal text-pretty content-break",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { NoteActivity } from "./activity";
|
|
||||||
import { NoteOpenThread } from "./buttons/open";
|
import { NoteOpenThread } from "./buttons/open";
|
||||||
import { NoteReply } from "./buttons/reply";
|
import { NoteReply } from "./buttons/reply";
|
||||||
import { NoteRepost } from "./buttons/repost";
|
import { NoteRepost } from "./buttons/repost";
|
||||||
@@ -9,7 +8,6 @@ import { NoteContentLarge } from "./contentLarge";
|
|||||||
import { NoteMenu } from "./menu";
|
import { NoteMenu } from "./menu";
|
||||||
import { NoteProvider } from "./provider";
|
import { NoteProvider } from "./provider";
|
||||||
import { NoteRoot } from "./root";
|
import { NoteRoot } from "./root";
|
||||||
import { NoteThread } from "./thread";
|
|
||||||
import { NoteUser } from "./user";
|
import { NoteUser } from "./user";
|
||||||
|
|
||||||
export const Note = {
|
export const Note = {
|
||||||
@@ -24,6 +22,4 @@ export const Note = {
|
|||||||
Zap: NoteZap,
|
Zap: NoteZap,
|
||||||
Open: NoteOpenThread,
|
Open: NoteOpenThread,
|
||||||
Child: NoteChild,
|
Child: NoteChild,
|
||||||
Thread: NoteThread,
|
|
||||||
Activity: NoteActivity,
|
|
||||||
};
|
};
|
||||||
@@ -1,13 +1,10 @@
|
|||||||
export function Hashtag({ tag }: { tag: string }) {
|
export function Hashtag({ tag }: { tag: string }) {
|
||||||
return (
|
return (
|
||||||
<button
|
<span className="leading-normal break-all cursor-default group text-start">
|
||||||
type="button"
|
|
||||||
className="break-all cursor-default leading-normal group"
|
|
||||||
>
|
|
||||||
<span className="text-blue-500">#</span>
|
<span className="text-blue-500">#</span>
|
||||||
<span className="underline-offset-1 underline decoration-2 decoration-blue-200 dark:decoration-blue-800 group-hover:decoration-blue-500">
|
<span className="underline underline-offset-1 decoration-2 decoration-blue-200 dark:decoration-blue-800 group-hover:decoration-blue-500">
|
||||||
{tag.replace("#", "")}
|
{tag.replace("#", "")}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
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 { Spinner } from "@lume/ui";
|
||||||
|
|
||||||
|
export function MentionNote({
|
||||||
|
eventId,
|
||||||
|
openable = true,
|
||||||
|
}: {
|
||||||
|
eventId: string;
|
||||||
|
openable?: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { isLoading, isError, data } = useEvent(eventId);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center w-full h-20 mt-2 border rounded-xl border-black/10 dark:border-white/10">
|
||||||
|
<Spinner className="size-5" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !data) {
|
||||||
|
return (
|
||||||
|
<div className="w-full p-3 mt-2 border rounded-xl border-black/10 dark:border-white/10">
|
||||||
|
{t("note.error")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col w-full border rounded-lg cursor-default border-black/10 dark:border-white/10">
|
||||||
|
<User.Provider pubkey={data.pubkey}>
|
||||||
|
<User.Root className="flex items-center gap-2 px-3 h-11">
|
||||||
|
<User.Avatar className="object-cover rounded-full size-6 shrink-0" />
|
||||||
|
<div className="inline-flex items-center flex-1 gap-2">
|
||||||
|
<User.Name className="font-semibold text-neutral-900 dark:text-neutral-100" />
|
||||||
|
<span className="text-neutral-600 dark:text-neutral-400">·</span>
|
||||||
|
<User.Time
|
||||||
|
time={data.created_at}
|
||||||
|
className="text-neutral-600 dark:text-neutral-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"px-3 select-text whitespace-normal text-pretty content-break leading-normal",
|
||||||
|
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 className="h-3" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,24 +1,20 @@
|
|||||||
import { useProfile } from "@lume/ark";
|
import { LumeWindow, useProfile } from "@lume/system";
|
||||||
import { displayNpub } from "@lume/utils";
|
import { displayNpub } from "@lume/utils";
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
export function MentionUser({ pubkey }: { pubkey: string }) {
|
export function MentionUser({ pubkey }: { pubkey: string }) {
|
||||||
const { ark } = useRouteContext({ strict: false });
|
|
||||||
const { isLoading, isError, profile } = useProfile(pubkey);
|
const { isLoading, isError, profile } = useProfile(pubkey);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => ark.open_profile(pubkey)}
|
onClick={() => LumeWindow.openProfile(pubkey)}
|
||||||
className="break-words text-start text-blue-500 hover:text-blue-600"
|
className="break-words text-start text-blue-500 hover:text-blue-600"
|
||||||
>
|
>
|
||||||
{isLoading
|
{isLoading
|
||||||
? "@anon"
|
? "@anon"
|
||||||
: isError
|
: isError
|
||||||
? displayNpub(pubkey, 16)
|
? displayNpub(pubkey, 16)
|
||||||
: `@${
|
: `@${profile?.name || profile?.display_name || "anon"}`}
|
||||||
profile?.name || profile?.display_name || profile?.name || "anon"
|
|
||||||
}`}
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { HorizontalDotsIcon } from "@lume/icons";
|
||||||
|
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||||
|
import { useNoteContext } from "./provider";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
|
||||||
|
|
||||||
|
export function NoteMenu() {
|
||||||
|
const event = useNoteContext();
|
||||||
|
|
||||||
|
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const menuItems = await Promise.all([
|
||||||
|
MenuItem.new({
|
||||||
|
text: "Copy Sharable Link",
|
||||||
|
action: async () => {
|
||||||
|
const eventId = await event.idAsBech32();
|
||||||
|
await writeText(`https://njump.me/${eventId}`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
MenuItem.new({
|
||||||
|
text: "Copy Event ID",
|
||||||
|
action: async () => {
|
||||||
|
const eventId = await event.idAsBech32();
|
||||||
|
await writeText(eventId);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
MenuItem.new({
|
||||||
|
text: "Copy Public Key",
|
||||||
|
action: async () => {
|
||||||
|
const pubkey = await event.pubkeyAsBech32();
|
||||||
|
await writeText(pubkey);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
PredefinedMenuItem.new({ item: "Separator" }),
|
||||||
|
MenuItem.new({
|
||||||
|
text: "Copy Raw Event",
|
||||||
|
action: async () => {
|
||||||
|
event.meta = undefined;
|
||||||
|
const raw = JSON.stringify(event);
|
||||||
|
await writeText(raw);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const menu = await Menu.new({
|
||||||
|
items: menuItems,
|
||||||
|
});
|
||||||
|
|
||||||
|
await menu.popup().catch((e) => console.error(e));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => showContextMenu(e)}
|
||||||
|
className="inline-flex items-center justify-center group size-7 text-neutral-600 dark:text-neutral-400"
|
||||||
|
>
|
||||||
|
<HorizontalDotsIcon className="size-5" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { useRouteContext } from "@tanstack/react-router";
|
||||||
|
import { open } from "@tauri-apps/plugin-shell";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
export function ImagePreview({ url }: { url: string }) {
|
||||||
|
const { settings } = useRouteContext({ strict: false });
|
||||||
|
|
||||||
|
const imageUrl = useMemo(() => {
|
||||||
|
if (settings.image_resize_service.length) {
|
||||||
|
const newUrl = `${settings.image_resize_service}?url=${url}&ll&af&default=1&n=-1`;
|
||||||
|
return newUrl;
|
||||||
|
} else {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}, [settings.image_resize_service]);
|
||||||
|
|
||||||
|
if (!settings.display_media) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline text-blue-500 hover:text-blue-600"
|
||||||
|
>
|
||||||
|
{url}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="my-1">
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt={url}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
style={{ contentVisibility: "auto" }}
|
||||||
|
className="max-h-[600px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||||
|
onClick={() => open(url)}
|
||||||
|
onKeyDown={() => open(url)}
|
||||||
|
onError={({ currentTarget }) => {
|
||||||
|
currentTarget.onerror = null;
|
||||||
|
currentTarget.src = "/404.jpg";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { Carousel, CarouselItem } from "@lume/ui";
|
||||||
|
import { useRouteContext } from "@tanstack/react-router";
|
||||||
|
import { open } from "@tauri-apps/plugin-shell";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
export function Images({ urls }: { urls: string[] }) {
|
||||||
|
const { settings } = useRouteContext({ strict: false });
|
||||||
|
|
||||||
|
const imageUrls = useMemo(() => {
|
||||||
|
if (settings.image_resize_service.length) {
|
||||||
|
const newUrls = urls.map(
|
||||||
|
(url) =>
|
||||||
|
`${settings.image_resize_service}?url=${url}&ll&af&default=1&n=-1`,
|
||||||
|
);
|
||||||
|
return newUrls;
|
||||||
|
} else {
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
}, [settings.image_resize_service]);
|
||||||
|
|
||||||
|
if (urls.length === 1) {
|
||||||
|
return (
|
||||||
|
<div className="px-3 group">
|
||||||
|
<img
|
||||||
|
src={imageUrls[0]}
|
||||||
|
alt={urls[0]}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
style={{ contentVisibility: "auto" }}
|
||||||
|
className="max-h-[400px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||||
|
onClick={() => urls[0]}
|
||||||
|
onKeyDown={() => urls[0]}
|
||||||
|
onError={({ currentTarget }) => {
|
||||||
|
currentTarget.onerror = null;
|
||||||
|
currentTarget.src = "/404.jpg";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Carousel
|
||||||
|
items={imageUrls}
|
||||||
|
renderItem={({ item, index, isSnapPoint }) => (
|
||||||
|
<CarouselItem key={item + index} isSnapPoint={isSnapPoint}>
|
||||||
|
<img
|
||||||
|
src={item}
|
||||||
|
alt={item}
|
||||||
|
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(item)}
|
||||||
|
onKeyDown={() => open(item)}
|
||||||
|
onError={({ currentTarget }) => {
|
||||||
|
currentTarget.onerror = null;
|
||||||
|
currentTarget.src = "/404.jpg";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</CarouselItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { useRouteContext } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
export function VideoPreview({ url }: { url: string }) {
|
||||||
|
const { settings } = useRouteContext({ strict: false });
|
||||||
|
|
||||||
|
if (settings.display_media) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline text-blue-500 hover:text-blue-600"
|
||||||
|
>
|
||||||
|
{url}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="my-1">
|
||||||
|
<video
|
||||||
|
className="max-h-[600px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||||
|
controls
|
||||||
|
muted
|
||||||
|
>
|
||||||
|
<source src={url} type="video/mp4" />
|
||||||
|
Your browser does not support the video tag.
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Carousel, CarouselItem } from "../../carousel";
|
import { Carousel, CarouselItem } from "@lume/ui";
|
||||||
|
|
||||||
export function Videos({ urls }: { urls: string[] }) {
|
export function Videos({ urls }: { urls: string[] }) {
|
||||||
if (urls.length === 1) {
|
if (urls.length === 1) {
|
||||||
@@ -1,19 +1,20 @@
|
|||||||
import type { Event } from "@lume/types";
|
import type { LumeEvent } from "@lume/system";
|
||||||
import { type ReactNode, createContext, useContext } from "react";
|
import { type ReactNode, createContext, useContext } from "react";
|
||||||
|
|
||||||
const EventContext = createContext<Event>(null);
|
const NoteContext = createContext<LumeEvent>(null);
|
||||||
|
|
||||||
export function NoteProvider({
|
export function NoteProvider({
|
||||||
event,
|
event,
|
||||||
children,
|
children,
|
||||||
}: { event: Event; children: ReactNode }) {
|
}: {
|
||||||
return (
|
event: LumeEvent;
|
||||||
<EventContext.Provider value={event}>{children}</EventContext.Provider>
|
children: ReactNode;
|
||||||
);
|
}) {
|
||||||
|
return <NoteContext.Provider value={event}>{children}</NoteContext.Provider>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useNoteContext() {
|
export function useNoteContext() {
|
||||||
const context = useContext(EventContext);
|
const context = useContext(NoteContext);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error("Please import Note Provider to use useNoteContext() hook");
|
throw new Error("Please import Note Provider to use useNoteContext() hook");
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import * as HoverCard from "@radix-ui/react-hover-card";
|
import * as HoverCard from "@radix-ui/react-hover-card";
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
|
||||||
import { User } from "../user";
|
import { User } from "../user";
|
||||||
import { useNoteContext } from "./provider";
|
import { useNoteContext } from "./provider";
|
||||||
|
import { LumeWindow } from "@lume/system";
|
||||||
|
|
||||||
export function NoteUser({ className }: { className?: string }) {
|
export function NoteUser({ className }: { className?: string }) {
|
||||||
const { ark } = useRouteContext({ strict: false });
|
|
||||||
const event = useNoteContext();
|
const event = useNoteContext();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -16,9 +15,9 @@ export function NoteUser({ className }: { className?: string }) {
|
|||||||
>
|
>
|
||||||
<div className="flex w-full gap-2">
|
<div className="flex w-full gap-2">
|
||||||
<HoverCard.Trigger className="shrink-0">
|
<HoverCard.Trigger className="shrink-0">
|
||||||
<User.Avatar className="size-8 rounded-full object-cover outline outline-1 -outline-offset-1 outline-black/15" />
|
<User.Avatar className="object-cover rounded-full size-8 outline outline-1 -outline-offset-1 outline-black/15" />
|
||||||
</HoverCard.Trigger>
|
</HoverCard.Trigger>
|
||||||
<div className="flex w-full items-center gap-3">
|
<div className="flex items-center w-full gap-3">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<User.Name className="font-semibold text-neutral-950 dark:text-neutral-50" />
|
<User.Name className="font-semibold text-neutral-950 dark:text-neutral-50" />
|
||||||
<User.NIP05 />
|
<User.NIP05 />
|
||||||
@@ -38,16 +37,17 @@ export function NoteUser({ className }: { className?: string }) {
|
|||||||
side="right"
|
side="right"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<User.Avatar className="size-11 rounded-lg object-cover" />
|
<User.Avatar className="object-cover rounded-lg size-11" />
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="inline-flex items-center gap-1">
|
<div className="inline-flex items-center gap-1">
|
||||||
<User.Name className="font-semibold leading-tight text-white dark:text-neutral-900" />
|
<User.Name className="font-semibold leading-tight text-white dark:text-neutral-900" />
|
||||||
<User.NIP05 />
|
<User.NIP05 />
|
||||||
</div>
|
</div>
|
||||||
<User.About className="line-clamp-3 text-sm text-white dark:text-neutral-900" />
|
<User.About className="text-sm text-white line-clamp-3 dark:text-neutral-900" />
|
||||||
<button
|
<button
|
||||||
onClick={() => ark.open_profile(event.pubkey)}
|
type="button"
|
||||||
className="mt-2 inline-flex h-9 w-full items-center justify-center rounded-lg bg-white text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-100 dark:text-neutral-900 dark:hover:bg-neutral-200"
|
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
|
View profile
|
||||||
</button>
|
</button>
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import type { Event } from "@lume/types";
|
|
||||||
import { Note } from "@lume/ui";
|
|
||||||
import { cn } from "@lume/utils";
|
|
||||||
|
|
||||||
export function Notification({
|
|
||||||
event,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
event: Event;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Note.Provider event={event}>
|
|
||||||
<Note.Root
|
|
||||||
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",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div className="px-3 h-14 flex items-center justify-between">
|
|
||||||
<Note.User />
|
|
||||||
</div>
|
|
||||||
<Note.Content className="px-3" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center h-14 px-3">
|
|
||||||
<Note.Open />
|
|
||||||
</div>
|
|
||||||
</Note.Root>
|
|
||||||
</Note.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,19 +1,15 @@
|
|||||||
import { QuoteIcon } from "@lume/icons";
|
import { QuoteIcon } from "@lume/icons";
|
||||||
import type { Event } from "@lume/types";
|
import { Note } from "@/components/note";
|
||||||
import { Note } from "@lume/ui";
|
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
|
import type { LumeEvent } from "@lume/system";
|
||||||
|
|
||||||
export function Quote({
|
export function Quote({
|
||||||
event,
|
event,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
event: Event;
|
event: LumeEvent;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const quoteEventId = event.tags.find(
|
|
||||||
(tag) => tag[0] === "q" || tag[3] === "mention",
|
|
||||||
)?.[1];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Note.Provider event={event}>
|
<Note.Provider event={event}>
|
||||||
<Note.Root
|
<Note.Root
|
||||||
@@ -23,7 +19,7 @@ export function Quote({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<Note.Child eventId={quoteEventId} isRoot />
|
<Note.Child event={event.quote} isRoot />
|
||||||
<div className="flex items-center gap-2 px-3">
|
<div className="flex items-center gap-2 px-3">
|
||||||
<div className="inline-flex items-center gap-1.5 shrink-0 text-sm font-medium text-neutral-600 dark:text-neutral-400">
|
<div className="inline-flex items-center gap-1.5 shrink-0 text-sm font-medium text-neutral-600 dark:text-neutral-400">
|
||||||
<QuoteIcon className="size-4" />
|
<QuoteIcon className="size-4" />
|
||||||
@@ -32,13 +28,13 @@ export function Quote({
|
|||||||
<div className="flex-1 h-px bg-neutral-100 dark:bg-white/5" />
|
<div className="flex-1 h-px bg-neutral-100 dark:bg-white/5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="px-3 h-14 flex items-center justify-between">
|
<div className="flex items-center justify-between px-3 h-14">
|
||||||
<Note.User />
|
<Note.User />
|
||||||
</div>
|
</div>
|
||||||
<Note.Content className="px-3" quote={false} clean />
|
<Note.Content className="px-3" quote={false} clean />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center h-14 px-3">
|
<div className="flex items-center px-3 h-14">
|
||||||
<Note.Open />
|
<Note.Open />
|
||||||
</div>
|
</div>
|
||||||
</Note.Root>
|
</Note.Root>
|
||||||
|
|||||||
@@ -1,79 +1,77 @@
|
|||||||
import type { Event } from "@lume/types";
|
import { Spinner } from "@lume/ui";
|
||||||
import { Note, Spinner, User } from "@lume/ui";
|
import { Note } from "@/components/note";
|
||||||
|
import { User } from "@/components/user";
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
import { type LumeEvent, NostrQuery } from "@lume/system";
|
||||||
|
|
||||||
export function RepostNote({
|
export function RepostNote({
|
||||||
event,
|
event,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
event: Event;
|
event: LumeEvent;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const { ark } = useRouteContext({ strict: false });
|
const { isLoading, isError, data } = useQuery({
|
||||||
const {
|
queryKey: ["event", event.repostId],
|
||||||
isLoading,
|
|
||||||
isError,
|
|
||||||
data: repostEvent,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["repost", event.id],
|
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try {
|
try {
|
||||||
if (event.content.length > 50) {
|
const data = await NostrQuery.getRepostEvent(event);
|
||||||
const embed: Event = JSON.parse(event.content);
|
return data;
|
||||||
return embed;
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = event.tags.find((el) => el[0] === "e")?.[1];
|
|
||||||
const repostEvent = await ark.get_event(id);
|
|
||||||
|
|
||||||
return repostEvent;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(e);
|
throw new Error(e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnMount: false,
|
refetchOnMount: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
staleTime: Number.POSITIVE_INFINITY,
|
||||||
|
retry: 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Note.Root
|
<Note.Root
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl mb-3 shadow-primary dark:ring-1 ring-neutral-800/50",
|
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<User.Provider pubkey={event.pubkey}>
|
|
||||||
<User.Root className="flex items-center gap-2 px-3 py-3 border-b border-neutral-100 dark:border-neutral-800/50 rounded-t-xl">
|
|
||||||
<div className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
|
|
||||||
Reposted by
|
|
||||||
</div>
|
|
||||||
<User.Avatar className="size-6 shrink-0 rounded-full object-cover ring-1 ring-neutral-200/50 dark:ring-neutral-800/50" />
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex h-20 items-center justify-center gap-2">
|
<div className="flex items-center justify-center h-20 gap-2">
|
||||||
<Spinner />
|
<Spinner />
|
||||||
Loading event...
|
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||||
|
Loading event...
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : isError || !repostEvent ? (
|
) : isError || !data ? (
|
||||||
<div className="flex h-20 items-center justify-center">
|
<div className="flex items-center justify-center h-20">
|
||||||
Event not found within your current relay set
|
Event not found within your current relay set
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Note.Provider event={repostEvent}>
|
<Note.Provider event={data}>
|
||||||
<Note.Root>
|
<Note.Root>
|
||||||
<div className="px-3 h-14 flex items-center justify-between">
|
<div className="flex items-center justify-between px-3 h-14">
|
||||||
<Note.User />
|
<Note.User />
|
||||||
<Note.Menu />
|
<Note.Menu />
|
||||||
</div>
|
</div>
|
||||||
<Note.Content className="px-3" />
|
<Note.Content className="px-3" />
|
||||||
<div className="mt-3 flex items-center gap-4 h-14 px-3">
|
<div className="flex items-center justify-between px-3 mt-3 h-14">
|
||||||
<Note.Open />
|
<div className="inline-flex items-center gap-3">
|
||||||
<Note.Reply />
|
<Note.Open />
|
||||||
<Note.Repost />
|
<Note.Reply />
|
||||||
<Note.Zap />
|
<Note.Repost />
|
||||||
|
<Note.Zap />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<User.Provider pubkey={event.pubkey}>
|
||||||
|
<User.Root className="flex items-center gap-2">
|
||||||
|
<div className="text-sm font-medium text-neutral-800 dark:text-neutral-200">
|
||||||
|
Reposted by
|
||||||
|
</div>
|
||||||
|
<User.Avatar className="object-cover rounded-full size-6 shrink-0 ring-1 ring-neutral-200/50 dark:ring-neutral-800/50" />
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Note.Root>
|
</Note.Root>
|
||||||
</Note.Provider>
|
</Note.Provider>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import type { Event } from "@lume/types";
|
|
||||||
import { Note } from "@lume/ui";
|
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
|
import { Note } from "@/components/note";
|
||||||
|
import type { LumeEvent } from "@lume/system";
|
||||||
|
|
||||||
export function TextNote({
|
export function TextNote({
|
||||||
event,
|
event,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
event: Event;
|
event: LumeEvent;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -17,12 +17,12 @@ export function TextNote({
|
|||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="px-3 h-14 flex items-center justify-between">
|
<div className="flex items-center justify-between px-3 h-14">
|
||||||
<Note.User />
|
<Note.User />
|
||||||
<Note.Menu />
|
<Note.Menu />
|
||||||
</div>
|
</div>
|
||||||
<Note.Content className="px-3" />
|
<Note.Content className="px-3" />
|
||||||
<div className="mt-3 flex items-center gap-4 h-14 px-3">
|
<div className="flex items-center gap-4 px-3 mt-3 h-14">
|
||||||
<Note.Open />
|
<Note.Open />
|
||||||
<Note.Reply />
|
<Note.Reply />
|
||||||
<Note.Repost />
|
<Note.Repost />
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { cn } from "@lume/utils";
|
||||||
|
import * as Avatar from "@radix-ui/react-avatar";
|
||||||
|
import { useRouteContext } from "@tanstack/react-router";
|
||||||
|
import { minidenticon } from "minidenticons";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useUserContext } from "./provider";
|
||||||
|
|
||||||
|
export function UserAvatar({ className }: { className?: string }) {
|
||||||
|
const user = useUserContext();
|
||||||
|
const { settings } = useRouteContext({ strict: false });
|
||||||
|
|
||||||
|
const picture = useMemo(() => {
|
||||||
|
if (
|
||||||
|
settings?.image_resize_service?.length &&
|
||||||
|
user.profile?.picture?.length
|
||||||
|
) {
|
||||||
|
const url = `${settings.image_resize_service}?url=${user.profile?.picture}&w=100&h=100&default=1&n=-1`;
|
||||||
|
return url;
|
||||||
|
} else {
|
||||||
|
return user.profile?.picture;
|
||||||
|
}
|
||||||
|
}, [user.profile?.picture]);
|
||||||
|
|
||||||
|
const fallbackAvatar = useMemo(
|
||||||
|
() =>
|
||||||
|
`data:image/svg+xml;utf8,${encodeURIComponent(
|
||||||
|
minidenticon(user.pubkey || nanoid(), 90, 50),
|
||||||
|
)}`,
|
||||||
|
[user.pubkey],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (settings && !settings.display_avatar) {
|
||||||
|
return (
|
||||||
|
<Avatar.Root className="shrink-0">
|
||||||
|
<Avatar.Fallback delayMs={120}>
|
||||||
|
<img
|
||||||
|
src={fallbackAvatar}
|
||||||
|
alt={user.pubkey}
|
||||||
|
className={cn("bg-black dark:bg-white", className)}
|
||||||
|
/>
|
||||||
|
</Avatar.Fallback>
|
||||||
|
</Avatar.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Avatar.Root className="shrink-0">
|
||||||
|
<Avatar.Image
|
||||||
|
src={picture}
|
||||||
|
alt={user.pubkey}
|
||||||
|
loading="eager"
|
||||||
|
decoding="async"
|
||||||
|
className={cn("outline-[.5px] outline-black/5 object-cover", className)}
|
||||||
|
/>
|
||||||
|
<Avatar.Fallback delayMs={120}>
|
||||||
|
<img
|
||||||
|
src={fallbackAvatar}
|
||||||
|
alt={user.pubkey}
|
||||||
|
className={cn("bg-black dark:bg-white", className)}
|
||||||
|
/>
|
||||||
|
</Avatar.Fallback>
|
||||||
|
</Avatar.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ export function UserCover({ className }: { className?: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user && !user.profile.banner) {
|
if (user && !user.profile?.banner) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn("bg-gradient-to-b from-blue-400 to-teal-200", className)}
|
className={cn("bg-gradient-to-b from-blue-400 to-teal-200", className)}
|
||||||
@@ -25,7 +25,7 @@ export function UserCover({ className }: { className?: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src={user.profile.banner}
|
src={user?.profile?.banner}
|
||||||
alt="banner"
|
alt="banner"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { cn } from "@lume/utils";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Spinner } from "@lume/ui";
|
||||||
|
import { useUserContext } from "./provider";
|
||||||
|
import { NostrAccount } from "@lume/system";
|
||||||
|
|
||||||
|
export function UserFollowButton({
|
||||||
|
simple = false,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
simple?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const user = useUserContext();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [followed, setFollowed] = useState(false);
|
||||||
|
|
||||||
|
const toggleFollow = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const toggle = await NostrAccount.toggleContact(user.pubkey);
|
||||||
|
|
||||||
|
if (toggle) {
|
||||||
|
setFollowed((prev) => !prev);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
NostrAccount.checkContact(user.pubkey).then((status) => {
|
||||||
|
if (mounted) setFollowed(status);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => toggleFollow()}
|
||||||
|
className={cn("w-max", className)}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Spinner className="size-4" />
|
||||||
|
) : followed ? (
|
||||||
|
!simple ? (
|
||||||
|
"Unfollow"
|
||||||
|
) : null
|
||||||
|
) : (
|
||||||
|
"Follow"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,21 +2,34 @@ import { VerifiedIcon } from "@lume/icons";
|
|||||||
import { displayLongHandle, displayNpub } from "@lume/utils";
|
import { displayLongHandle, displayNpub } from "@lume/utils";
|
||||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
|
||||||
import { useUserContext } from "./provider";
|
import { useUserContext } from "./provider";
|
||||||
|
import { NostrQuery } from "@lume/system";
|
||||||
|
import { experimental_createPersister } from "@tanstack/query-persist-client-core";
|
||||||
|
|
||||||
export function UserNip05() {
|
export function UserNip05() {
|
||||||
const user = useUserContext();
|
const user = useUserContext();
|
||||||
|
|
||||||
const { ark } = useRouteContext({ strict: false });
|
|
||||||
const { isLoading, data: verified } = useQuery({
|
const { isLoading, data: verified } = useQuery({
|
||||||
queryKey: ["nip05", user?.pubkey],
|
queryKey: ["nip05", user?.pubkey],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!user.profile?.nip05) return false;
|
if (!user.profile?.nip05?.length) return false;
|
||||||
const verify = await ark.verify_nip05(user.pubkey, user.profile?.nip05);
|
|
||||||
|
const verify = await NostrQuery.verifyNip05(
|
||||||
|
user.pubkey,
|
||||||
|
user.profile?.nip05,
|
||||||
|
);
|
||||||
|
|
||||||
return verify;
|
return verify;
|
||||||
},
|
},
|
||||||
enabled: !!user.profile,
|
enabled: !!user.profile?.nip05,
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
staleTime: Number.POSITIVE_INFINITY,
|
||||||
|
retry: false,
|
||||||
|
persister: experimental_createPersister({
|
||||||
|
storage: localStorage,
|
||||||
|
maxAge: 1000 * 60 * 60 * 72, // 72 hours
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user.profile?.nip05?.length) return;
|
if (!user.profile?.nip05?.length) return;
|
||||||
@@ -26,7 +39,7 @@ export function UserNip05() {
|
|||||||
<Tooltip.Root delayDuration={150}>
|
<Tooltip.Root delayDuration={150}>
|
||||||
<Tooltip.Trigger>
|
<Tooltip.Trigger>
|
||||||
{!isLoading && verified ? (
|
{!isLoading && verified ? (
|
||||||
<VerifiedIcon className="size-4 text-teal-500" />
|
<VerifiedIcon className="text-teal-500 size-4" />
|
||||||
) : null}
|
) : null}
|
||||||
</Tooltip.Trigger>
|
</Tooltip.Trigger>
|
||||||
<Tooltip.Portal>
|
<Tooltip.Portal>
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useProfile } from "@lume/ark";
|
import { useProfile } from "@lume/system";
|
||||||
import type { Metadata } from "@lume/types";
|
import type { Metadata } from "@lume/types";
|
||||||
import { type ReactNode, createContext, useContext } from "react";
|
import { type ReactNode, createContext, useContext } from "react";
|
||||||
|
|
||||||
const UserContext = createContext<{
|
const UserContext = createContext<{
|
||||||
pubkey: string;
|
pubkey: string;
|
||||||
|
profile: Metadata;
|
||||||
isError: boolean;
|
isError: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
profile: Metadata;
|
|
||||||
}>(null);
|
}>(null);
|
||||||
|
|
||||||
export function UserProvider({
|
export function UserProvider({
|
||||||
@@ -21,7 +21,7 @@ export function UserProvider({
|
|||||||
const { isLoading, isError, profile } = useProfile(pubkey, embedProfile);
|
const { isLoading, isError, profile } = useProfile(pubkey, embedProfile);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserContext.Provider value={{ pubkey, isError, isLoading, profile }}>
|
<UserContext.Provider value={{ pubkey, profile, isError, isLoading }}>
|
||||||
{children}
|
{children}
|
||||||
</UserContext.Provider>
|
</UserContext.Provider>
|
||||||
);
|
);
|
||||||
@@ -1,100 +1,68 @@
|
|||||||
import { Col } from "@/components/col";
|
import { Column } from "@/components/column";
|
||||||
import { Toolbar } from "@/components/toolbar";
|
import { Toolbar } from "@/components/toolbar";
|
||||||
import { ArrowLeftIcon, ArrowRightIcon } from "@lume/icons";
|
import { ArrowLeftIcon, ArrowRightIcon, PlusSquareIcon } from "@lume/icons";
|
||||||
import type { EventColumns, LumeColumn } from "@lume/types";
|
import { NostrQuery } from "@lume/system";
|
||||||
|
import type { ColumnEvent, LumeColumn } from "@lume/types";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { resolveResource } from "@tauri-apps/api/path";
|
|
||||||
import { getCurrent } from "@tauri-apps/api/window";
|
import { getCurrent } from "@tauri-apps/api/window";
|
||||||
import { readTextFile } from "@tauri-apps/plugin-fs";
|
import useEmblaCarousel from "embla-carousel-react";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useDebouncedCallback } from "use-debounce";
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
import { VList, type VListHandle } from "virtua";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/$account/home")({
|
export const Route = createFileRoute("/$account/home")({
|
||||||
beforeLoad: async ({ context }) => {
|
loader: async () => {
|
||||||
try {
|
const columns = await NostrQuery.getColumns();
|
||||||
const ark = context.ark;
|
return columns;
|
||||||
const resourcePath = await resolveResource(
|
|
||||||
"resources/system_columns.json",
|
|
||||||
);
|
|
||||||
const systemColumns: LumeColumn[] = JSON.parse(
|
|
||||||
await readTextFile(resourcePath),
|
|
||||||
);
|
|
||||||
const userColumns = await ark.get_columns();
|
|
||||||
|
|
||||||
return {
|
|
||||||
storedColumns: !userColumns.length ? systemColumns : userColumns,
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
console.error(String(e));
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const vlistRef = useRef<VListHandle>(null);
|
|
||||||
|
|
||||||
const { account } = Route.useParams();
|
const { account } = Route.useParams();
|
||||||
const { ark, storedColumns } = Route.useRouteContext();
|
const initialColumnList = Route.useLoaderData();
|
||||||
|
|
||||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
const [columns, setColumns] = useState<LumeColumn[]>([]);
|
||||||
const [columns, setColumns] = useState(storedColumns);
|
const [emblaRef, emblaApi] = useEmblaCarousel({
|
||||||
const [isScroll, setIsScroll] = useState(false);
|
watchDrag: false,
|
||||||
const [isResize, setIsResize] = useState(false);
|
loop: false,
|
||||||
|
});
|
||||||
|
|
||||||
const goLeft = () => {
|
const scrollPrev = useCallback(() => {
|
||||||
const prevIndex = Math.max(selectedIndex - 1, 0);
|
if (emblaApi) emblaApi.scrollPrev(true);
|
||||||
setSelectedIndex(prevIndex);
|
}, [emblaApi]);
|
||||||
vlistRef.current.scrollToIndex(prevIndex, {
|
|
||||||
align: "center",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const goRight = () => {
|
const scrollNext = useCallback(() => {
|
||||||
const nextIndex = Math.min(selectedIndex + 1, columns.length - 1);
|
if (emblaApi) emblaApi.scrollNext(true);
|
||||||
setSelectedIndex(nextIndex);
|
}, [emblaApi]);
|
||||||
vlistRef.current.scrollToIndex(nextIndex, {
|
|
||||||
align: "center",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const add = useDebouncedCallback((column: LumeColumn) => {
|
const emitScrollEvent = useCallback(() => {
|
||||||
// update col label
|
getCurrent().emit("child-webview", { scroll: true });
|
||||||
column.label = `${column.label}-${nanoid()}`;
|
}, []);
|
||||||
|
|
||||||
// create new cols
|
const emitResizeEvent = useCallback(() => {
|
||||||
const cols = [...columns];
|
getCurrent().emit("child-webview", { resize: true, direction: "x" });
|
||||||
const openColIndex = cols.findIndex((col) => col.label === "open");
|
}, []);
|
||||||
const newCols = [
|
|
||||||
...cols.slice(0, openColIndex),
|
|
||||||
column,
|
|
||||||
...cols.slice(openColIndex),
|
|
||||||
];
|
|
||||||
|
|
||||||
setColumns(newCols);
|
const openLumeStore = useDebouncedCallback(async () => {
|
||||||
setSelectedIndex(newCols.length);
|
await getCurrent().emit("columns", {
|
||||||
setIsScroll(true);
|
type: "add",
|
||||||
|
column: {
|
||||||
// scroll to the newest column
|
label: "store",
|
||||||
vlistRef.current.scrollToIndex(newCols.length - 1, {
|
name: "Store",
|
||||||
align: "end",
|
content: "/store/official",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}, 150);
|
}, 150);
|
||||||
|
|
||||||
|
const add = useDebouncedCallback((column: LumeColumn) => {
|
||||||
|
column.label = `${column.label}-${nanoid()}`; // update col label
|
||||||
|
setColumns((prev) => [column, ...prev]);
|
||||||
|
}, 150);
|
||||||
|
|
||||||
const remove = useDebouncedCallback((label: string) => {
|
const remove = useDebouncedCallback((label: string) => {
|
||||||
const newCols = columns.filter((t) => t.label !== label);
|
setColumns((prev) => prev.filter((t) => t.label !== label));
|
||||||
|
|
||||||
setColumns(newCols);
|
|
||||||
setSelectedIndex(newCols.length);
|
|
||||||
setIsScroll(true);
|
|
||||||
|
|
||||||
// scroll to the first column
|
|
||||||
vlistRef.current.scrollToIndex(newCols.length - 1, {
|
|
||||||
align: "start",
|
|
||||||
});
|
|
||||||
}, 150);
|
}, 150);
|
||||||
|
|
||||||
const updateName = useDebouncedCallback((label: string, title: string) => {
|
const updateName = useDebouncedCallback((label: string, title: string) => {
|
||||||
@@ -109,71 +77,108 @@ function Screen() {
|
|||||||
setColumns(newCols);
|
setColumns(newCols);
|
||||||
}, 150);
|
}, 150);
|
||||||
|
|
||||||
const startResize = useDebouncedCallback(
|
const reset = useDebouncedCallback(() => setColumns([]), 150);
|
||||||
() => setIsResize((prev) => !prev),
|
|
||||||
150,
|
const handleKeyDown = useDebouncedCallback((event) => {
|
||||||
);
|
if (event.defaultPrevented) return;
|
||||||
|
|
||||||
|
switch (event.code) {
|
||||||
|
case "ArrowLeft":
|
||||||
|
if (emblaApi) emblaApi.scrollPrev(true);
|
||||||
|
break;
|
||||||
|
case "ArrowRight":
|
||||||
|
if (emblaApi) emblaApi.scrollNext(true);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
}, 150);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// save state
|
if (emblaApi) {
|
||||||
ark.set_columns(columns);
|
emblaApi.on("scroll", emitScrollEvent);
|
||||||
|
emblaApi.on("resize", emitResizeEvent);
|
||||||
|
emblaApi.on("slidesChanged", emitScrollEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
emblaApi?.off("scroll", emitScrollEvent);
|
||||||
|
emblaApi?.off("resize", emitResizeEvent);
|
||||||
|
emblaApi?.off("slidesChanged", emitScrollEvent);
|
||||||
|
};
|
||||||
|
}, [emblaApi, emitScrollEvent, emitResizeEvent]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (columns?.length) {
|
||||||
|
NostrQuery.setColumns(columns).then(() => console.log("saved"));
|
||||||
|
}
|
||||||
}, [columns]);
|
}, [columns]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unlistenColEvent = listen<EventColumns>("columns", (data) => {
|
setColumns(initialColumnList);
|
||||||
|
}, [initialColumnList]);
|
||||||
|
|
||||||
|
// Listen for keyboard event
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [handleKeyDown]);
|
||||||
|
|
||||||
|
// Listen for columns event
|
||||||
|
useEffect(() => {
|
||||||
|
const unlisten = listen<ColumnEvent>("columns", (data) => {
|
||||||
|
if (data.payload.type === "reset") reset();
|
||||||
if (data.payload.type === "add") add(data.payload.column);
|
if (data.payload.type === "add") add(data.payload.column);
|
||||||
if (data.payload.type === "remove") remove(data.payload.label);
|
if (data.payload.type === "remove") remove(data.payload.label);
|
||||||
if (data.payload.type === "set_title")
|
if (data.payload.type === "set_title")
|
||||||
updateName(data.payload.label, data.payload.title);
|
updateName(data.payload.label, data.payload.title);
|
||||||
});
|
});
|
||||||
|
|
||||||
const unlistenWindowResize = getCurrent().listen("tauri://resize", () => {
|
|
||||||
startResize();
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unlistenColEvent.then((f) => f());
|
unlisten.then((f) => f());
|
||||||
unlistenWindowResize.then((f) => f());
|
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full">
|
<div className="size-full">
|
||||||
<VList
|
<div ref={emblaRef} className="overflow-hidden size-full">
|
||||||
ref={vlistRef}
|
<div className="flex size-full">
|
||||||
horizontal
|
{columns?.map((column) => (
|
||||||
tabIndex={-1}
|
<Column
|
||||||
itemSize={440}
|
key={account + column.label}
|
||||||
overscan={3}
|
column={column}
|
||||||
onScroll={() => setIsScroll(true)}
|
account={account}
|
||||||
onScrollEnd={() => setIsScroll(false)}
|
/>
|
||||||
className="scrollbar-none h-full w-full overflow-x-auto focus:outline-none"
|
))}
|
||||||
>
|
</div>
|
||||||
{columns.map((column) => (
|
</div>
|
||||||
<Col
|
|
||||||
key={column.label}
|
|
||||||
column={column}
|
|
||||||
account={account}
|
|
||||||
isScroll={isScroll}
|
|
||||||
isResize={isResize}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</VList>
|
|
||||||
<Toolbar>
|
<Toolbar>
|
||||||
<div className="flex items-center gap-1">
|
<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={() => goLeft()}
|
onClick={() => scrollPrev()}
|
||||||
className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
|
className="inline-flex 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-5" />
|
<ArrowLeftIcon className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => goRight()}
|
onClick={() => openLumeStore()}
|
||||||
className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
|
className="inline-flex 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-5" />
|
<PlusSquareIcon className="size-4" />
|
||||||
|
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
|
|||||||
@@ -1,33 +1,32 @@
|
|||||||
import { BellIcon, ComposeFilledIcon, PlusIcon, SearchIcon } from "@lume/icons";
|
import { User } from "@/components/user";
|
||||||
import { Event, Kind } from "@lume/types";
|
|
||||||
import { User } from "@lume/ui";
|
|
||||||
import {
|
import {
|
||||||
cn,
|
ComposeFilledIcon,
|
||||||
decodeZapInvoice,
|
HorizontalDotsIcon,
|
||||||
displayNpub,
|
PlusIcon,
|
||||||
sendNativeNotification,
|
SearchIcon,
|
||||||
} from "@lume/utils";
|
} from "@lume/icons";
|
||||||
|
import { LumeWindow, NostrAccount } from "@lume/system";
|
||||||
|
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 { invoke } from "@tauri-apps/api/core";
|
import { Link } from "@tanstack/react-router";
|
||||||
import { getCurrent } from "@tauri-apps/api/window";
|
import { getCurrent } from "@tauri-apps/api/window";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export const Route = createFileRoute("/$account")({
|
export const Route = createFileRoute("/$account")({
|
||||||
beforeLoad: async ({ context }) => {
|
beforeLoad: async () => {
|
||||||
const ark = context.ark;
|
const accounts = await NostrAccount.getAccounts();
|
||||||
const accounts = await ark.get_all_accounts();
|
|
||||||
|
|
||||||
return { accounts };
|
return { accounts };
|
||||||
},
|
},
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const { ark, platform } = Route.useRouteContext();
|
const { platform } = Route.useRouteContext();
|
||||||
const navigate = Route.useNavigate();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-screen flex-col">
|
<div className="flex flex-col w-screen h-screen">
|
||||||
<div
|
<div
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -37,31 +36,22 @@ function Screen() {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Accounts />
|
<Accounts />
|
||||||
<button
|
<Link
|
||||||
type="button"
|
to="/landing"
|
||||||
onClick={() => navigate({ to: "/landing" })}
|
className="inline-flex 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"
|
||||||
className="inline-flex size-8 items-center justify-center rounded-full bg-black/10 text-neutral-800 hover:bg-black/20 dark:bg-white/10 dark:text-neutral-200 dark:hover:bg-white/20"
|
|
||||||
>
|
>
|
||||||
<PlusIcon className="size-5" />
|
<PlusIcon className="size-5" />
|
||||||
</button>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => ark.open_editor()}
|
onClick={() => LumeWindow.openEditor()}
|
||||||
className="inline-flex h-8 w-max items-center justify-center gap-1 rounded-full bg-blue-500 px-3 text-sm font-medium text-white hover:bg-blue-600"
|
className="inline-flex 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"
|
||||||
>
|
>
|
||||||
<ComposeFilledIcon className="size-4" />
|
<ComposeFilledIcon className="size-4" />
|
||||||
New Post
|
New Post
|
||||||
</button>
|
</button>
|
||||||
<Bell />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => ark.open_search()}
|
|
||||||
className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
|
|
||||||
>
|
|
||||||
<SearchIcon className="size-5" />
|
|
||||||
</button>
|
|
||||||
<div id="toolbar" />
|
<div id="toolbar" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,106 +63,124 @@ function Screen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Accounts() {
|
function Accounts() {
|
||||||
const navigate = Route.useNavigate();
|
const { accounts } = Route.useRouteContext();
|
||||||
const { ark, accounts } = Route.useRouteContext();
|
|
||||||
const { account } = Route.useParams();
|
const { account } = Route.useParams();
|
||||||
|
|
||||||
const changeAccount = async (npub: string) => {
|
const [windowWidth, setWindowWidth] = useState<number>(null);
|
||||||
if (npub === account) return;
|
|
||||||
|
|
||||||
const select = await ark.load_selected_account(npub);
|
const navigate = Route.useNavigate();
|
||||||
|
const sortedList = useMemo(() => {
|
||||||
|
const list = accounts;
|
||||||
|
|
||||||
|
for (const [i, item] of list.entries()) {
|
||||||
|
if (item === account) {
|
||||||
|
list.splice(i, 1);
|
||||||
|
list.unshift(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}, [accounts]);
|
||||||
|
|
||||||
|
const changeAccount = async (npub: string) => {
|
||||||
|
if (npub === account) {
|
||||||
|
return await LumeWindow.openProfile(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Change current account and update signer
|
||||||
|
const select = await NostrAccount.loadAccount(npub);
|
||||||
|
|
||||||
if (select) {
|
if (select) {
|
||||||
return navigate({ to: "/$account/home", params: { account: npub } });
|
// Reset current columns
|
||||||
|
await getCurrent().emit("columns", { type: "reset" });
|
||||||
|
|
||||||
|
// Redirect to new account
|
||||||
|
return navigate({
|
||||||
|
to: "/$account/home",
|
||||||
|
params: { account: npub },
|
||||||
|
resetScroll: true,
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.warning("Something wrong.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const getWindowDimensions = () => {
|
||||||
<div data-tauri-drag-region className="flex items-center gap-3">
|
const { innerWidth: width, innerHeight: height } = window;
|
||||||
{accounts.map((user) => (
|
return {
|
||||||
<button key={user} type="button" onClick={() => changeAccount(user)}>
|
width,
|
||||||
<User.Provider pubkey={user}>
|
height,
|
||||||
<User.Root
|
};
|
||||||
className={cn(
|
};
|
||||||
"rounded-full",
|
|
||||||
user === account
|
|
||||||
? "ring-1 ring-teal-500 ring-offset-2 ring-offset-neutral-200 dark:ring-offset-neutral-950"
|
|
||||||
: "",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<User.Avatar
|
|
||||||
className={cn(
|
|
||||||
"aspect-square h-auto rounded-full object-cover",
|
|
||||||
user === account ? "w-7" : "w-8",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Bell() {
|
|
||||||
const { ark } = Route.useRouteContext();
|
|
||||||
const { account } = Route.useParams();
|
|
||||||
|
|
||||||
const [count, setCount] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unlisten = getCurrent().listen<string>(
|
function handleResize() {
|
||||||
"activity",
|
setWindowWidth(getWindowDimensions().width);
|
||||||
async (payload) => {
|
}
|
||||||
setCount((prevCount) => prevCount + 1);
|
|
||||||
await invoke("set_badge", { count });
|
|
||||||
|
|
||||||
const event: Event = JSON.parse(payload.payload);
|
if (!windowWidth) {
|
||||||
const user = await ark.get_profile(event.pubkey);
|
setWindowWidth(getWindowDimensions().width);
|
||||||
const userName =
|
}
|
||||||
user.display_name || user.name || displayNpub(event.pubkey, 16);
|
|
||||||
|
|
||||||
switch (event.kind) {
|
window.addEventListener("resize", handleResize);
|
||||||
case Kind.Text: {
|
|
||||||
sendNativeNotification("Mentioned you in a note", userName);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case Kind.Repost: {
|
|
||||||
sendNativeNotification("Reposted your note", userName);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case Kind.ZapReceipt: {
|
|
||||||
const amount = decodeZapInvoice(event.tags);
|
|
||||||
sendNativeNotification(
|
|
||||||
`Zapped ₿ ${amount.bitcoinFormatted}`,
|
|
||||||
userName,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unlisten.then((f) => f());
|
window.removeEventListener("resize", handleResize);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div data-tauri-drag-region className="flex items-center gap-3">
|
||||||
type="button"
|
{sortedList
|
||||||
onClick={() => {
|
.slice(0, windowWidth > 500 ? account.length : 2)
|
||||||
setCount(0);
|
.map((user) => (
|
||||||
ark.open_activity(account);
|
<button key={user} type="button" onClick={() => changeAccount(user)}>
|
||||||
}}
|
<User.Provider pubkey={user}>
|
||||||
className="relative inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
|
<User.Root
|
||||||
>
|
className={cn(
|
||||||
<BellIcon className="size-5" />
|
"shrink-0 rounded-full transition-all ease-in-out duration-150 will-change-auto",
|
||||||
{count > 0 ? (
|
user === account
|
||||||
<span className="absolute right-0 top-0 block size-2 rounded-full bg-teal-500 ring-1 ring-black/5" />
|
? "ring-1 ring-teal-500 ring-offset-2 ring-offset-neutral-200 dark:ring-offset-neutral-950"
|
||||||
|
: "",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<User.Avatar
|
||||||
|
className={cn(
|
||||||
|
"aspect-square h-auto rounded-full object-cover transition-all ease-in-out duration-150 will-change-auto",
|
||||||
|
user === account ? "w-7" : "w-8",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{accounts.length >= 3 && windowWidth <= 700 ? (
|
||||||
|
<Popover.Root>
|
||||||
|
<Popover.Trigger className="inline-flex 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}
|
) : null}
|
||||||
</button>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +1,46 @@
|
|||||||
import type { Ark } from "@lume/ark";
|
import { CancelCircleIcon, CheckCircleIcon, InfoCircleIcon } from "@lume/icons";
|
||||||
import type { Interests, Metadata, Settings } from "@lume/types";
|
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 { Platform } from "@tauri-apps/plugin-os";
|
||||||
import type { Descendant } from "slate";
|
import { Toaster } from "sonner";
|
||||||
|
|
||||||
type EditorElement = {
|
|
||||||
type: string;
|
|
||||||
children: Descendant[];
|
|
||||||
eventId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface RouterContext {
|
interface RouterContext {
|
||||||
// System
|
// System
|
||||||
ark: Ark;
|
|
||||||
queryClient: QueryClient;
|
queryClient: QueryClient;
|
||||||
// App info
|
// App info
|
||||||
platform?: Platform;
|
platform?: Platform;
|
||||||
locale?: string;
|
locale?: string;
|
||||||
// Settings
|
// Settings
|
||||||
settings?: Settings;
|
settings?: Settings;
|
||||||
interests?: Interests;
|
// Accounts
|
||||||
// Profile
|
|
||||||
accounts?: string[];
|
accounts?: string[];
|
||||||
profile?: Metadata;
|
|
||||||
isNewUser?: boolean;
|
|
||||||
// Editor
|
|
||||||
initialValue?: EditorElement[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Route = createRootRouteWithContext<RouterContext>()({
|
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||||
component: () => <Outlet />,
|
component: () => (
|
||||||
|
<>
|
||||||
|
<Toaster
|
||||||
|
position="bottom-right"
|
||||||
|
icons={{
|
||||||
|
success: <CheckCircleIcon className="size-5" />,
|
||||||
|
info: <InfoCircleIcon className="size-5" />,
|
||||||
|
error: <CancelCircleIcon className="size-5" />,
|
||||||
|
}}
|
||||||
|
closeButton
|
||||||
|
theme="system"
|
||||||
|
/>
|
||||||
|
<Outlet />
|
||||||
|
</>
|
||||||
|
),
|
||||||
pendingComponent: Pending,
|
pendingComponent: Pending,
|
||||||
wrapInSuspense: true,
|
wrapInSuspense: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Pending() {
|
function Pending() {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-screen flex-col items-center justify-center">
|
<div className="flex flex-col items-center justify-center w-screen h-screen">
|
||||||
<Spinner className="size-5" />
|
<Spinner className="size-5" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/activity/$account/messages")({
|
|
||||||
component: () => <div>Hello /activity/$account/messages!</div>,
|
|
||||||
});
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { Note, Spinner } from "@lume/ui";
|
|
||||||
import { Await, createFileRoute, defer } from "@tanstack/react-router";
|
|
||||||
import { Suspense } from "react";
|
|
||||||
import { Virtualizer } from "virtua";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/activity/$account/texts")({
|
|
||||||
loader: async ({ context, params }) => {
|
|
||||||
const ark = context.ark;
|
|
||||||
return { data: defer(ark.get_activities(params.account, "1")) };
|
|
||||||
},
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
const { data } = Route.useLoaderData();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Virtualizer overscan={3}>
|
|
||||||
<Suspense
|
|
||||||
fallback={
|
|
||||||
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex items-center gap-2 text-sm font-medium"
|
|
||||||
disabled
|
|
||||||
>
|
|
||||||
<Spinner className="size-5" />
|
|
||||||
Loading...
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Await promise={data}>
|
|
||||||
{(events) =>
|
|
||||||
events.map((event) => (
|
|
||||||
<div
|
|
||||||
key={event.id}
|
|
||||||
className="flex flex-col gap-2 mb-3 bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50"
|
|
||||||
>
|
|
||||||
<Note.Provider event={event}>
|
|
||||||
<Note.Root>
|
|
||||||
<div className="px-3 h-14 flex items-center justify-between">
|
|
||||||
<Note.User />
|
|
||||||
<Note.Menu />
|
|
||||||
</div>
|
|
||||||
<Note.Activity className="px-3" />
|
|
||||||
<Note.Content className="px-3" quote={false} clean />
|
|
||||||
<div className="mt-3 flex items-center gap-4 h-14 px-3">
|
|
||||||
<Note.Open />
|
|
||||||
</div>
|
|
||||||
</Note.Root>
|
|
||||||
</Note.Provider>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</Await>
|
|
||||||
</Suspense>
|
|
||||||
</Virtualizer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import { Box, Container } from "@lume/ui";
|
|
||||||
import { cn } from "@lume/utils";
|
|
||||||
import { Link, Outlet } from "@tanstack/react-router";
|
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/activity/$account")({
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
const { account } = Route.useParams();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container withDrag withNavigate={false}>
|
|
||||||
<Box className="scrollbar-none shadow-none bg-black/5 dark:bg-white/5 backdrop-blur-sm flex flex-col overflow-y-auto">
|
|
||||||
<div className="h-14 shrink-0 flex w-full items-center gap-1 px-3">
|
|
||||||
<div className="inline-flex h-full w-full items-center gap-1">
|
|
||||||
<Link to="/activity/$account/texts" params={{ account }}>
|
|
||||||
{({ isActive }) => (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"inline-flex h-7 w-max items-center justify-center gap-2 rounded-full px-3 text-sm font-medium",
|
|
||||||
isActive ? "bg-neutral-50 dark:bg-white/10" : "opacity-50",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Notes
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
<Link to="/activity/$account/zaps" params={{ account }}>
|
|
||||||
{({ isActive }) => (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"inline-flex h-7 w-max items-center justify-center gap-2 rounded-full px-3 text-sm font-medium",
|
|
||||||
isActive ? "bg-neutral-50 dark:bg-white/10" : "opacity-50",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Zaps
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="px-2 flex-1 overflow-y-auto w-full h-full scrollbar-none">
|
|
||||||
<Outlet />
|
|
||||||
</div>
|
|
||||||
</Box>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import { Note, Spinner, User } from "@lume/ui";
|
|
||||||
import { decodeZapInvoice } from "@lume/utils";
|
|
||||||
import { Await, createFileRoute, defer } from "@tanstack/react-router";
|
|
||||||
import { Suspense } from "react";
|
|
||||||
import { Virtualizer } from "virtua";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/activity/$account/zaps")({
|
|
||||||
loader: async ({ context, params }) => {
|
|
||||||
const ark = context.ark;
|
|
||||||
return { data: defer(ark.get_activities(params.account, "9735")) };
|
|
||||||
},
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
const { data } = Route.useLoaderData();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Virtualizer overscan={3}>
|
|
||||||
<Suspense
|
|
||||||
fallback={
|
|
||||||
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex items-center gap-2 text-sm font-medium"
|
|
||||||
disabled
|
|
||||||
>
|
|
||||||
<Spinner className="size-5" />
|
|
||||||
Loading...
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Await promise={data}>
|
|
||||||
{(events) =>
|
|
||||||
events.map((event) => (
|
|
||||||
<div
|
|
||||||
key={event.id}
|
|
||||||
className="flex flex-col gap-2 mb-3 bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50"
|
|
||||||
>
|
|
||||||
<User.Provider pubkey={event.pubkey}>
|
|
||||||
<User.Root className="flex flex-col">
|
|
||||||
<div className="text-lg h-20 font-medium leading-tight flex w-full items-center justify-center">
|
|
||||||
₿ {decodeZapInvoice(event.tags).bitcoinFormatted}
|
|
||||||
</div>
|
|
||||||
<div className="h-11 border-t border-neutral-100 dark:border-neutral-900 flex items-center gap-1 px-2">
|
|
||||||
<div className="inline-flex items-center gap-2">
|
|
||||||
<User.Avatar className="size-7 rounded-full shrink-0" />
|
|
||||||
<User.Name className="text-sm font-medium" />
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-neutral-700 dark:text-neutral-300">
|
|
||||||
zapped you
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</Await>
|
|
||||||
</Suspense>
|
|
||||||
</Virtualizer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { CheckIcon } from "@lume/icons";
|
import { CheckIcon } from "@lume/icons";
|
||||||
import type { AppRouteSearch } from "@lume/types";
|
|
||||||
import { Spinner } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import { displayNsec } from "@lume/utils";
|
import { displayNsec } from "@lume/utils";
|
||||||
import * as Checkbox from "@radix-ui/react-checkbox";
|
import * as Checkbox from "@radix-ui/react-checkbox";
|
||||||
@@ -10,17 +9,12 @@ import { useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export const Route = createFileRoute("/auth/new/backup")({
|
export const Route = createFileRoute("/auth/$account/backup")({
|
||||||
validateSearch: (search: Record<string, string>): AppRouteSearch => {
|
|
||||||
return {
|
|
||||||
account: search.account,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const { account } = Route.useSearch();
|
const { account } = Route.useParams();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [key, setKey] = useState(null);
|
const [key, setKey] = useState(null);
|
||||||
@@ -38,10 +32,7 @@ function Screen() {
|
|||||||
return toast.warning("You need to confirm before continue");
|
return toast.warning("You need to confirm before continue");
|
||||||
}
|
}
|
||||||
|
|
||||||
return navigate({
|
navigate({ to: "/", replace: true });
|
||||||
to: "/auth/settings",
|
|
||||||
search: { account },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// start loading
|
// start loading
|
||||||
@@ -71,7 +62,7 @@ function Screen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
|
<div className="flex flex-col items-center justify-center w-full h-full gap-6 px-5 mx-auto xl:max-w-xl">
|
||||||
<div className="flex flex-col text-center">
|
<div className="flex flex-col text-center">
|
||||||
<h3 className="text-xl font-semibold">Backup your sign in keys</h3>
|
<h3 className="text-xl font-semibold">Backup your sign in keys</h3>
|
||||||
<p className="text-neutral-700 dark:text-neutral-300">
|
<p className="text-neutral-700 dark:text-neutral-300">
|
||||||
@@ -79,7 +70,7 @@ function Screen() {
|
|||||||
access to your account if you lose this key.
|
access to your account if you lose this key.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full flex-col gap-5">
|
<div className="flex flex-col w-full gap-5">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label htmlFor="passphase" className="font-medium">
|
<label htmlFor="passphase" className="font-medium">
|
||||||
Set a passphase to secure your key
|
Set a passphase to secure your key
|
||||||
@@ -90,7 +81,7 @@ function Screen() {
|
|||||||
type="password"
|
type="password"
|
||||||
value={passphase}
|
value={passphase}
|
||||||
onChange={(e) => setPassphase(e.target.value)}
|
onChange={(e) => setPassphase(e.target.value)}
|
||||||
className="w-full h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
className="w-full px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -106,12 +97,12 @@ function Screen() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={displayNsec(key, 36)}
|
value={displayNsec(key, 36)}
|
||||||
readOnly
|
readOnly
|
||||||
className="w-full h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
className="w-full px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => copyKey()}
|
onClick={() => copyKey()}
|
||||||
className="inline-flex h-11 w-24 items-center justify-center rounded-lg bg-neutral-200 hover:bg-neutral-300 dark:bg-white/20 dark:hover:bg-white/30"
|
className="inline-flex items-center justify-center w-24 rounded-lg h-11 bg-neutral-200 hover:bg-neutral-300 dark:bg-white/20 dark:hover:bg-white/30"
|
||||||
>
|
>
|
||||||
{copied ? "Copied" : "Copy"}
|
{copied ? "Copied" : "Copy"}
|
||||||
</button>
|
</button>
|
||||||
@@ -126,7 +117,7 @@ function Screen() {
|
|||||||
onCheckedChange={() =>
|
onCheckedChange={() =>
|
||||||
setConfirm((state) => ({ ...state, c1: !state.c1 }))
|
setConfirm((state) => ({ ...state, c1: !state.c1 }))
|
||||||
}
|
}
|
||||||
className="flex size-6 appearance-none items-center justify-center rounded-md bg-neutral-100 outline-none dark:bg-white/10 dark:hover:bg-white/20"
|
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="confirm1"
|
id="confirm1"
|
||||||
>
|
>
|
||||||
<Checkbox.Indicator className="text-blue-500">
|
<Checkbox.Indicator className="text-blue-500">
|
||||||
@@ -146,7 +137,7 @@ function Screen() {
|
|||||||
onCheckedChange={() =>
|
onCheckedChange={() =>
|
||||||
setConfirm((state) => ({ ...state, c2: !state.c2 }))
|
setConfirm((state) => ({ ...state, c2: !state.c2 }))
|
||||||
}
|
}
|
||||||
className="flex size-6 appearance-none items-center justify-center rounded-md bg-neutral-100 outline-none dark:bg-white/10 dark:hover:bg-white/20"
|
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="confirm2"
|
id="confirm2"
|
||||||
>
|
>
|
||||||
<Checkbox.Indicator className="text-blue-500">
|
<Checkbox.Indicator className="text-blue-500">
|
||||||
@@ -166,7 +157,7 @@ function Screen() {
|
|||||||
onCheckedChange={() =>
|
onCheckedChange={() =>
|
||||||
setConfirm((state) => ({ ...state, c3: !state.c3 }))
|
setConfirm((state) => ({ ...state, c3: !state.c3 }))
|
||||||
}
|
}
|
||||||
className="flex size-6 appearance-none items-center justify-center rounded-md bg-neutral-100 outline-none dark:bg-white/10 dark:hover:bg-white/20"
|
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"
|
id="confirm3"
|
||||||
>
|
>
|
||||||
<Checkbox.Indicator className="text-blue-500">
|
<Checkbox.Indicator className="text-blue-500">
|
||||||
@@ -189,7 +180,7 @@ function Screen() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => submit()}
|
onClick={() => submit()}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
|
className="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 /> : t("global.continue")}
|
||||||
</button>
|
</button>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { AvatarUploader } from "@/components/avatarUploader";
|
import { AvatarUploader } from "@/components/avatarUploader";
|
||||||
import { PlusIcon } from "@lume/icons";
|
import { PlusIcon } from "@lume/icons";
|
||||||
|
import { NostrAccount } from "@lume/system";
|
||||||
import type { Metadata } from "@lume/types";
|
import type { Metadata } from "@lume/types";
|
||||||
import { Spinner } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
@@ -8,19 +9,19 @@ import { useForm } from "react-hook-form";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export const Route = createFileRoute("/auth/new/profile")({
|
export const Route = createFileRoute("/auth/create-profile")({
|
||||||
component: Screen,
|
component: Screen,
|
||||||
loader: ({ context }) => {
|
loader: async () => {
|
||||||
return context.ark.create_keys();
|
const account = await NostrAccount.createAccount();
|
||||||
|
return account;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const keys = Route.useLoaderData();
|
const account = Route.useLoaderData();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { ark } = Route.useRouteContext();
|
|
||||||
const { register, handleSubmit } = useForm();
|
const { register, handleSubmit } = useForm();
|
||||||
|
|
||||||
const [picture, setPicture] = useState<string>("");
|
const [picture, setPicture] = useState<string>("");
|
||||||
@@ -35,17 +36,17 @@ function Screen() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Save account keys
|
// Save account keys
|
||||||
const save = await ark.save_account(keys.nsec);
|
const save = await NostrAccount.saveAccount(account.nsec);
|
||||||
|
|
||||||
// Then create profile
|
// Then create profile
|
||||||
if (save) {
|
if (save) {
|
||||||
const profile: Metadata = { ...data, picture };
|
const profile: Metadata = { ...data, picture };
|
||||||
const eventId = await ark.create_profile(profile);
|
const eventId = await NostrAccount.createProfile(profile);
|
||||||
|
|
||||||
if (eventId) {
|
if (eventId) {
|
||||||
navigate({
|
navigate({
|
||||||
to: "/auth/new/backup",
|
to: "/auth/$account/backup",
|
||||||
search: { account: keys.npub },
|
params: { account: account.npub },
|
||||||
replace: true,
|
replace: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -57,24 +58,24 @@ function Screen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
|
<div className="flex flex-col items-center justify-center w-full h-full gap-6 px-5 mx-auto xl:max-w-xl">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-xl font-semibold">Let's set up your profile.</h3>
|
<h3 className="text-xl font-semibold">Let's set up your profile.</h3>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<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">
|
||||||
{picture ? (
|
{picture ? (
|
||||||
<img
|
<img
|
||||||
src={picture}
|
src={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 dark:text-black 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 dark:text-black 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>
|
||||||
@@ -82,7 +83,7 @@ function Screen() {
|
|||||||
</div>
|
</div>
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
className="flex w-full flex-col gap-3"
|
className="flex flex-col w-full gap-3"
|
||||||
>
|
>
|
||||||
<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">
|
||||||
@@ -93,7 +94,7 @@ function Screen() {
|
|||||||
{...register("display_name", { required: true, minLength: 1 })}
|
{...register("display_name", { required: true, minLength: 1 })}
|
||||||
placeholder="e.g. Alice in Nostrland"
|
placeholder="e.g. Alice in Nostrland"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
className="px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
@@ -105,7 +106,7 @@ function Screen() {
|
|||||||
{...register("name")}
|
{...register("name")}
|
||||||
placeholder="e.g. alice"
|
placeholder="e.g. alice"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
className="px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
@@ -128,12 +129,12 @@ function Screen() {
|
|||||||
{...register("website")}
|
{...register("website")}
|
||||||
placeholder="e.g. https://alice.me"
|
placeholder="e.g. https://alice.me"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
className="px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-500 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="mt-3 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
|
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 /> : t("global.continue")}
|
||||||
</button>
|
</button>
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
|
import { NostrAccount } from "@lume/system";
|
||||||
import { Spinner } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export const Route = createLazyFileRoute("/auth/privkey")({
|
export const Route = createLazyFileRoute("/auth/import")({
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const { ark } = Route.useRouteContext();
|
|
||||||
const navigate = Route.useNavigate();
|
const navigate = Route.useNavigate();
|
||||||
|
|
||||||
const [key, setKey] = useState("");
|
const [key, setKey] = useState("");
|
||||||
@@ -24,14 +24,10 @@ function Screen() {
|
|||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const npub = await ark.save_account(key, password);
|
const npub = await NostrAccount.saveAccount(key, password);
|
||||||
|
|
||||||
if (npub) {
|
if (npub) {
|
||||||
navigate({
|
navigate({ to: "/", replace: true });
|
||||||
to: "/auth/settings",
|
|
||||||
search: { account: npub },
|
|
||||||
replace: true,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -40,11 +36,11 @@ function Screen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
|
<div className="flex flex-col items-center justify-center w-full h-full gap-6 px-5 mx-auto xl:max-w-xl">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-xl font-semibold">Continue with Private Key</h3>
|
<h3 className="text-xl font-semibold">Continue with Private Key</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full flex-col gap-3">
|
<div className="flex flex-col w-full gap-3">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label
|
<label
|
||||||
htmlFor="key"
|
htmlFor="key"
|
||||||
@@ -58,7 +54,7 @@ function Screen() {
|
|||||||
placeholder="nsec or ncryptsec..."
|
placeholder="nsec or ncryptsec..."
|
||||||
value={key}
|
value={key}
|
||||||
onChange={(e) => setKey(e.target.value)}
|
onChange={(e) => setKey(e.target.value)}
|
||||||
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
className="px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
@@ -73,14 +69,14 @@ function Screen() {
|
|||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
className="px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => submit()}
|
onClick={() => submit()}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="mt-3 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
|
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 /> : "Login"}
|
{loading ? <Spinner /> : "Login"}
|
||||||
</button>
|
</button>
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { NostrAccount } from "@lume/system";
|
||||||
import { Spinner } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -8,7 +9,6 @@ export const Route = createLazyFileRoute("/auth/remote")({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const { ark } = Route.useRouteContext();
|
|
||||||
const navigate = Route.useNavigate();
|
const navigate = Route.useNavigate();
|
||||||
|
|
||||||
const [uri, setUri] = useState("");
|
const [uri, setUri] = useState("");
|
||||||
@@ -23,14 +23,10 @@ function Screen() {
|
|||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const npub = await ark.nostr_connect(uri);
|
const remoteAccount = await NostrAccount.connectRemoteAccount(uri);
|
||||||
|
|
||||||
if (npub) {
|
if (remoteAccount?.length) {
|
||||||
navigate({
|
navigate({ to: "/", replace: true });
|
||||||
to: "/auth/settings",
|
|
||||||
search: { account: npub },
|
|
||||||
replace: true,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -39,11 +35,11 @@ function Screen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
|
<div className="flex flex-col items-center justify-center w-full h-full gap-6 px-5 mx-auto xl:max-w-xl">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-xl font-semibold">Continue with Nostr Connect</h3>
|
<h3 className="text-xl font-semibold">Continue with Nostr Connect</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full flex-col gap-3">
|
<div className="flex flex-col w-full gap-3">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label
|
<label
|
||||||
htmlFor="uri"
|
htmlFor="uri"
|
||||||
@@ -57,17 +53,24 @@ function Screen() {
|
|||||||
placeholder="bunker://..."
|
placeholder="bunker://..."
|
||||||
value={uri}
|
value={uri}
|
||||||
onChange={(e) => setUri(e.target.value)}
|
onChange={(e) => setUri(e.target.value)}
|
||||||
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
className="px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex flex-col items-center gap-1">
|
||||||
type="button"
|
<button
|
||||||
onClick={() => submit()}
|
type="button"
|
||||||
disabled={loading}
|
onClick={() => submit()}
|
||||||
className="mt-3 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
|
disabled={loading}
|
||||||
>
|
className="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 /> : "Login"}
|
>
|
||||||
</button>
|
{loading ? <Spinner /> : "Login"}
|
||||||
|
</button>
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-center text-neutral-600 dark:text-neutral-400">
|
||||||
|
Waiting confirmation...
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,218 +0,0 @@
|
|||||||
import { LaurelIcon } from "@lume/icons";
|
|
||||||
import type { AppRouteSearch, Settings } from "@lume/types";
|
|
||||||
import { Spinner } from "@lume/ui";
|
|
||||||
import * as Switch from "@radix-ui/react-switch";
|
|
||||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
|
||||||
import {
|
|
||||||
isPermissionGranted,
|
|
||||||
requestPermission,
|
|
||||||
} from "@tauri-apps/plugin-notification";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/auth/settings")({
|
|
||||||
validateSearch: (search: Record<string, string>): AppRouteSearch => {
|
|
||||||
return {
|
|
||||||
account: search.account,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
beforeLoad: async ({ context }) => {
|
|
||||||
const permissionGranted = await isPermissionGranted(); // get notification permission
|
|
||||||
const ark = context.ark;
|
|
||||||
const settings = await ark.get_settings();
|
|
||||||
|
|
||||||
return {
|
|
||||||
settings: { ...settings, notification: permissionGranted },
|
|
||||||
};
|
|
||||||
},
|
|
||||||
component: Screen,
|
|
||||||
pendingComponent: Pending,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const { account } = Route.useSearch();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { ark, settings } = Route.useRouteContext();
|
|
||||||
|
|
||||||
const [newSettings, setNewSettings] = useState<Settings>(settings);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const toggleNofitication = async () => {
|
|
||||||
await requestPermission();
|
|
||||||
setNewSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
notification: !newSettings.notification,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleAutoUpdate = () => {
|
|
||||||
setNewSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
autoUpdate: !newSettings.autoUpdate,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleEnhancedPrivacy = () => {
|
|
||||||
setNewSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
enhancedPrivacy: !newSettings.enhancedPrivacy,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleZap = () => {
|
|
||||||
setNewSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
zap: !newSettings.zap,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleNsfw = () => {
|
|
||||||
setNewSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
nsfw: !newSettings.nsfw,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
try {
|
|
||||||
// start loading
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
// publish settings
|
|
||||||
const eventId = await ark.set_settings(newSettings);
|
|
||||||
|
|
||||||
if (eventId) {
|
|
||||||
return navigate({
|
|
||||||
to: "/$account/home",
|
|
||||||
params: { account },
|
|
||||||
replace: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
setLoading(false);
|
|
||||||
toast.error(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
|
|
||||||
<div className="flex flex-col items-center gap-5 text-center">
|
|
||||||
<div className="flex size-20 items-center justify-center rounded-full bg-teal-100 dark:bg-teal-950 text-teal-500">
|
|
||||||
<LaurelIcon className="size-8" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-semibold">
|
|
||||||
{t("onboardingSettings.title")}
|
|
||||||
</h1>
|
|
||||||
<p className="leading-snug text-neutral-600 dark:text-neutral-400">
|
|
||||||
{t("onboardingSettings.subtitle")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-5">
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="font-semibold">Push Notification</h3>
|
|
||||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
|
||||||
Enabling push notifications will allow you to receive
|
|
||||||
notifications from Lume.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch.Root
|
|
||||||
checked={newSettings.notification}
|
|
||||||
onClick={() => toggleNofitication()}
|
|
||||||
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/20"
|
|
||||||
>
|
|
||||||
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
|
||||||
</Switch.Root>
|
|
||||||
</div>
|
|
||||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="font-semibold">Enhanced Privacy</h3>
|
|
||||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
|
||||||
Lume will display external resources like image, video or link
|
|
||||||
preview as plain text.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch.Root
|
|
||||||
checked={newSettings.enhancedPrivacy}
|
|
||||||
onClick={() => toggleEnhancedPrivacy()}
|
|
||||||
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/20"
|
|
||||||
>
|
|
||||||
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
|
||||||
</Switch.Root>
|
|
||||||
</div>
|
|
||||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="font-semibold">Auto Update</h3>
|
|
||||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
|
||||||
Automatically download and install new version.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch.Root
|
|
||||||
checked={newSettings.autoUpdate}
|
|
||||||
onClick={() => toggleAutoUpdate()}
|
|
||||||
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/20"
|
|
||||||
>
|
|
||||||
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
|
||||||
</Switch.Root>
|
|
||||||
</div>
|
|
||||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="font-semibold">Zap</h3>
|
|
||||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
|
||||||
Show the Zap button in each note and user's profile screen, use
|
|
||||||
for send Bitcoin tip to other users.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch.Root
|
|
||||||
checked={newSettings.zap}
|
|
||||||
onClick={() => toggleZap()}
|
|
||||||
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/20"
|
|
||||||
>
|
|
||||||
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
|
||||||
</Switch.Root>
|
|
||||||
</div>
|
|
||||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="font-semibold">Filter sensitive content</h3>
|
|
||||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
|
||||||
By default, Lume will display all content which have Content
|
|
||||||
Warning tag, it's may include NSFW content.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch.Root
|
|
||||||
checked={newSettings.nsfw}
|
|
||||||
onClick={() => toggleNsfw()}
|
|
||||||
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/20"
|
|
||||||
>
|
|
||||||
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
|
||||||
</Switch.Root>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => submit()}
|
|
||||||
disabled={loading}
|
|
||||||
className="mb-1 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{t("global.continue")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Pending() {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
|
||||||
<button type="button" className="size-5" disabled>
|
|
||||||
<Spinner className="size-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { CancelIcon, PlusIcon } from "@lume/icons";
|
||||||
|
import { NostrQuery } from "@lume/system";
|
||||||
|
import type { Relay } from "@lume/types";
|
||||||
|
import { Spinner } from "@lume/ui";
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/bootstrap-relays")({
|
||||||
|
loader: async () => {
|
||||||
|
const bootstrapRelays = await NostrQuery.getBootstrapRelays();
|
||||||
|
return bootstrapRelays;
|
||||||
|
},
|
||||||
|
component: Screen,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Screen() {
|
||||||
|
const bootstrapRelays = Route.useLoaderData();
|
||||||
|
const { register, reset, handleSubmit } = useForm();
|
||||||
|
|
||||||
|
const [relays, setRelays] = useState<Relay[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const removeRelay = (url: string) => {
|
||||||
|
setRelays((prev) => prev.filter((relay) => relay.url !== url));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (data: { url: string; purpose: string }) => {
|
||||||
|
try {
|
||||||
|
const relay: Relay = { url: data.url, purpose: data.purpose };
|
||||||
|
setRelays((prev) => [...prev, relay]);
|
||||||
|
reset();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
await NostrQuery.saveBootstrapRelays(relays);
|
||||||
|
} catch (e) {
|
||||||
|
setIsLoading(false);
|
||||||
|
toast.error(String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRelays(bootstrapRelays);
|
||||||
|
}, [bootstrapRelays]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center w-screen h-screen">
|
||||||
|
<div className="w-full max-w-sm mx-auto lg:max-w-lg">
|
||||||
|
<div className="text-center h-11">
|
||||||
|
<h1 className="font-semibold">Customize Bootstrap Relays</h1>
|
||||||
|
</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">
|
||||||
|
{relays.map((relay) => (
|
||||||
|
<div
|
||||||
|
key={relay.url}
|
||||||
|
className="flex items-center justify-between h-11"
|
||||||
|
>
|
||||||
|
<div className="inline-flex items-center gap-2 text-sm font-medium">
|
||||||
|
{relay.url}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{relay.purpose?.length ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center justify-center px-2 text-xs font-medium uppercase rounded-md h-7 w-max hover:bg-black/10 dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
{relay.purpose}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeRelay(relay.url)}
|
||||||
|
className="inline-flex items-center justify-center rounded-md size-7 text-neutral-700 dark:text-white/20 hover:bg-black/10 dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<CancelIcon className="size-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex items-center border-t h-14 border-neutral-100 dark:border-white/5">
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
className="flex items-center w-full gap-2 mb-0"
|
||||||
|
>
|
||||||
|
<div className="flex items-center flex-1 gap-2 border rounded-lg border-neutral-300 dark:border-white/20">
|
||||||
|
<input
|
||||||
|
{...register("url", {
|
||||||
|
required: true,
|
||||||
|
minLength: 1,
|
||||||
|
})}
|
||||||
|
name="url"
|
||||||
|
placeholder="wss://..."
|
||||||
|
spellCheck={false}
|
||||||
|
className="flex-1 px-3 bg-transparent border-none rounded-l-lg h-9 placeholder:text-neutral-500 dark:placeholder:text-neutral-400"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
{...register("purpose")}
|
||||||
|
className="flex-1 p-0 m-0 text-sm bg-transparent border-none outline-none h-9 ring-0 focus:outline-none focus:ring-0"
|
||||||
|
>
|
||||||
|
<option value="read">Read</option>
|
||||||
|
<option value="write">Write</option>
|
||||||
|
<option value="">Both</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="inline-flex items-center justify-center px-2 text-sm font-medium text-white rounded-lg shrink-0 h-9 w-14 bg-black/20 dark:bg-white/20 hover:bg-blue-500 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<PlusIcon className="size-7" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => save()}
|
||||||
|
disabled={isLoading}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{isLoading ? <Spinner /> : "Save & Relaunch"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { CheckCircleIcon } from "@lume/icons";
|
import { CancelIcon, PlusIcon } from "@lume/icons";
|
||||||
import type { ColumnRouteSearch } from "@lume/types";
|
import type { ColumnRouteSearch } from "@lume/types";
|
||||||
import { Spinner, User } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
import { User } from "@/components/user";
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { NostrAccount, NostrQuery } from "@lume/system";
|
||||||
|
|
||||||
export const Route = createFileRoute("/create-group")({
|
export const Route = createFileRoute("/create-group")({
|
||||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||||
@@ -13,113 +15,183 @@ export const Route = createFileRoute("/create-group")({
|
|||||||
name: search.name,
|
name: search.name,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
loader: async ({ context }) => {
|
loader: async () => {
|
||||||
const ark = context.ark;
|
const contacts = await NostrAccount.getContactList();
|
||||||
const contacts = await ark.get_contact_list();
|
|
||||||
return contacts;
|
return contacts;
|
||||||
},
|
},
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [npub, setNpub] = useState("");
|
||||||
|
const [users, setUsers] = useState<string[]>([
|
||||||
|
"npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445", // reya
|
||||||
|
]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const contacts = Route.useLoaderData();
|
const contacts = Route.useLoaderData();
|
||||||
const router = useRouter();
|
const search = Route.useSearch();
|
||||||
|
const navigate = Route.useNavigate();
|
||||||
const { ark } = Route.useRouteContext();
|
|
||||||
const { label, redirect } = Route.useSearch();
|
|
||||||
|
|
||||||
const [title, setTitle] = useState<string>("Just a new group");
|
|
||||||
const [users, setUsers] = useState<Array<string>>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [isDone, setIsDone] = useState(false);
|
|
||||||
|
|
||||||
const toggleUser = (pubkey: string) => {
|
const toggleUser = (pubkey: string) => {
|
||||||
const arr = users.includes(pubkey)
|
setUsers((prev) =>
|
||||||
? users.filter((i) => i !== pubkey)
|
prev.includes(pubkey)
|
||||||
: [...users, pubkey];
|
? prev.filter((i) => i !== pubkey)
|
||||||
setUsers(arr);
|
: [...prev, pubkey],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addUser = () => {
|
||||||
|
if (!npub.startsWith("npub1")) return;
|
||||||
|
if (users.includes(npub)) return;
|
||||||
|
|
||||||
|
setUsers((prev) => [...prev, npub]);
|
||||||
|
setNpub("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
try {
|
try {
|
||||||
if (isDone) return router.history.push(redirect);
|
setIsLoading(true);
|
||||||
|
|
||||||
// start loading
|
const key = `lume_group_${search.label}`;
|
||||||
setLoading(true);
|
const createGroup = await NostrQuery.setNstore(
|
||||||
|
key,
|
||||||
const groups = await ark.set_nstore(
|
|
||||||
`lume_group_${label}`,
|
|
||||||
JSON.stringify(users),
|
JSON.stringify(users),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (groups) {
|
if (createGroup) {
|
||||||
toast.success("Group has been created successfully.");
|
return navigate({ to: search.redirect, search: { ...search } });
|
||||||
// start loading
|
|
||||||
setIsDone(true);
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setLoading(false);
|
setIsLoading(false);
|
||||||
toast.error(e);
|
toast.error(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-y-auto scrollbar-none">
|
<div className="w-full h-full flex flex-col items-center justify-center gap-4">
|
||||||
<div className="flex flex-col gap-5 p-3">
|
<div className="text-center flex flex-col items-center justify-center">
|
||||||
<div className="flex flex-col gap-1">
|
<h1 className="text-2xl font-serif font-medium">
|
||||||
<label htmlFor="name" className="font-medium">
|
Focus feeds for people you like
|
||||||
|
</h1>
|
||||||
|
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
|
||||||
|
Add some people for custom feeds.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-4/5 max-w-full flex flex-col gap-3">
|
||||||
|
<div className="w-full h-9 shrink-0 flex items-center bg-black/5 dark:bg-white/5 rounded-lg">
|
||||||
|
<label
|
||||||
|
htmlFor="name"
|
||||||
|
className="w-16 border-r border-black/10 dark:border-white/10 shrink-0 text-center text-sm font-semibold"
|
||||||
|
>
|
||||||
Name
|
Name
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
name="name"
|
name="name"
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
placeholder="Nostrichs..."
|
placeholder="Enter a name for this group"
|
||||||
className="h-10 rounded-lg bg-transparent border border-neutral-300 dark:border-neutral-700 px-3 placeholder:text-neutral-600 focus:border-neutral-500 focus:ring-0 dark:placeholder:text-neutral-400"
|
className="h-full bg-transparent border-none text-sm px-3 placeholder:text-neutral-600 focus:border-neutral-500 focus:ring-0 dark:placeholder:text-neutral-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="w-full flex flex-col items-center gap-3">
|
||||||
<div className="inline-flex items-center justify-between">
|
<div className="overflow-y-auto scrollbar-none p-2 w-full h-[450px] flex flex-col gap-3 bg-black/5 dark:bg-white/5 backdrop-blur-lg rounded-xl">
|
||||||
<span className="font-medium">Pick user</span>
|
<div className="flex gap-2">
|
||||||
<span className="text-neutral-600 dark:text-neutral-400">{`${users.length} / ∞`}</span>
|
<input
|
||||||
</div>
|
name="npub"
|
||||||
<div className="flex flex-col gap-2">
|
value={npub}
|
||||||
{contacts.map((item: string) => (
|
onChange={(e) => setNpub(e.target.value)}
|
||||||
|
placeholder="npub1..."
|
||||||
|
className="h-9 w-full rounded-lg bg-black/10 dark:bg-white/10 border-none text-sm px-3 placeholder:text-neutral-600 focus:border-neutral-500 focus:ring-0 dark:placeholder:text-neutral-400"
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
key={item}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleUser(item)}
|
onClick={() => addUser()}
|
||||||
className="inline-flex items-center justify-between px-3 py-2 rounded-lg bg-black/10 dark:bg-white/10 hover:bg-black/20 dark:hover:bg-white/20"
|
className="inline-flex size-9 rounded-lg items-center justify-center bg-black/20 dark:bg-white/20 shrink-0 text-white hover:bg-blue-500"
|
||||||
>
|
>
|
||||||
<User.Provider pubkey={item}>
|
<PlusIcon className="size-6" />
|
||||||
<User.Root className="flex items-center gap-2.5">
|
|
||||||
<User.Avatar className="size-10 rounded-full object-cover" />
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<User.Name className="font-medium" />
|
|
||||||
<User.NIP05 />
|
|
||||||
</div>
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
{users.includes(item) ? (
|
|
||||||
<CheckCircleIcon className="size-5 text-teal-500" />
|
|
||||||
) : null}
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-sm font-semibold">Added</span>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{users.length ? (
|
||||||
|
users.map((item: string) => (
|
||||||
|
<button
|
||||||
|
key={item}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleUser(item)}
|
||||||
|
className="inline-flex items-center justify-between px-3 py-2 rounded-lg bg-white dark:bg-black/20 backdrop-blur-lg shadow-primary dark:ring-1 ring-neutral-800/50"
|
||||||
|
>
|
||||||
|
<User.Provider pubkey={item}>
|
||||||
|
<User.Root className="flex items-center gap-2.5">
|
||||||
|
<User.Avatar className="size-8 rounded-full object-cover" />
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<User.Name className="text-sm font-medium" />
|
||||||
|
</div>
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
|
<div>
|
||||||
|
<CancelIcon className="size-4" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="bg-black/5 dark:bg-white/5 text-sm flex items-center justify-center h-14 rounded-lg">
|
||||||
|
Empty.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-sm font-semibold">Contacts</span>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{contacts.length ? (
|
||||||
|
contacts.map((item: string) => (
|
||||||
|
<button
|
||||||
|
key={item}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleUser(item)}
|
||||||
|
className="inline-flex items-center justify-between px-3 py-2 rounded-lg bg-white dark:bg-black/20 backdrop-blur-lg shadow-primary dark:ring-1 ring-neutral-800/50"
|
||||||
|
>
|
||||||
|
<User.Provider pubkey={item}>
|
||||||
|
<User.Root className="flex items-center gap-2.5">
|
||||||
|
<User.Avatar className="size-8 rounded-full object-cover" />
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<User.Name className="text-sm font-medium" />
|
||||||
|
</div>
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="bg-black/5 dark:bg-white/5 text-sm flex items-center justify-center h-14 rounded-lg">
|
||||||
|
<p>
|
||||||
|
Find more user at{" "}
|
||||||
|
<a
|
||||||
|
href="https://www.nostr.directory/"
|
||||||
|
target="_blank"
|
||||||
|
className="text-blue-600 after:content-['_↗']"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
Nostr Directory
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="fixed z-10 flex items-center justify-center w-full bottom-6">
|
|
||||||
{users.length >= 1 ? (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => submit()}
|
onClick={() => submit()}
|
||||||
disabled={users.length < 1}
|
disabled={isLoading || users.length < 1}
|
||||||
className="inline-flex items-center justify-center px-4 font-medium text-white transform bg-blue-500 rounded-full active:translate-y-1 w-32 h-10 hover:bg-blue-600 focus:outline-none"
|
className="inline-flex items-center justify-center w-36 rounded-full h-9 bg-blue-500 text-white text-sm font-medium hover:bg-blue-600 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isDone ? "Back" : loading ? <Spinner /> : "Update"}
|
{isLoading ? <Spinner /> : "Confirm"}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { NostrAccount } from "@lume/system";
|
||||||
|
import type { ColumnRouteSearch } from "@lume/types";
|
||||||
|
import { Spinner } from "@lume/ui";
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/create-newsfeed/f2f")({
|
||||||
|
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||||
|
return {
|
||||||
|
account: search.account,
|
||||||
|
label: search.label,
|
||||||
|
name: search.name,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
component: Screen,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Screen() {
|
||||||
|
const navigate = Route.useNavigate();
|
||||||
|
const { redirect } = Route.useSearch();
|
||||||
|
|
||||||
|
const [npub, setNpub] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!npub.startsWith("npub1"))
|
||||||
|
return toast.warning("You must enter a valid npub.");
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const sync = await NostrAccount.f2f(npub);
|
||||||
|
|
||||||
|
if (sync) {
|
||||||
|
return navigate({ to: redirect });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setIsLoading(false);
|
||||||
|
toast.error(String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-y-auto scrollbar-none p-2 shrink-0 h-[450px] bg-white dark:bg-white/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50">
|
||||||
|
<div className="h-full flex flex-col justify-between">
|
||||||
|
<div className="flex-1 flex flex-col gap-1.5 justify-center px-5">
|
||||||
|
<p className="font-semibold text-neutral-500">
|
||||||
|
You already have a friend on Nostr?
|
||||||
|
</p>
|
||||||
|
<p>Instead of building the timeline by yourself.</p>
|
||||||
|
<p className="font-semibold text-neutral-500">
|
||||||
|
Just enter your friend's{" "}
|
||||||
|
<span className="text-blue-500">npub.</span>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
You will have the same experience as your friend. Of course, you
|
||||||
|
always can edit your network later.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor="npub" className="font-medium text-sm">
|
||||||
|
NPUB
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
name="npub"
|
||||||
|
placeholder="npub1..."
|
||||||
|
value={npub}
|
||||||
|
onChange={(e) => setNpub(e.target.value)}
|
||||||
|
spellCheck={false}
|
||||||
|
className="h-11 rounded-lg bg-transparent border border-neutral-200 dark:border-neutral-800 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:placeholder:text-neutral-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => submit()}
|
||||||
|
className="inline-flex items-center justify-center w-full rounded-lg h-9 bg-blue-500 text-white text-sm font-medium hover:bg-blue-600"
|
||||||
|
>
|
||||||
|
{isLoading ? <Spinner /> : "Confirm"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import type { ColumnRouteSearch } from "@lume/types";
|
||||||
|
import { cn } from "@lume/utils";
|
||||||
|
import { Link, Outlet } from "@tanstack/react-router";
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/create-newsfeed")({
|
||||||
|
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||||
|
return {
|
||||||
|
account: search.account,
|
||||||
|
label: search.label,
|
||||||
|
name: search.name,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
component: Screen,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Screen() {
|
||||||
|
const search = Route.useSearch();
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center w-full h-full gap-4">
|
||||||
|
<div className="flex flex-col items-center justify-center text-center">
|
||||||
|
<h1 className="font-serif text-2xl font-medium">
|
||||||
|
Build up your timeline.
|
||||||
|
</h1>
|
||||||
|
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
|
||||||
|
Follow some people to keep up to date with them.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col w-4/5 max-w-full gap-3">
|
||||||
|
<div className="w-full h-9 shrink-0 flex items-center justify-between bg-black/5 dark:bg-white/5 rounded-lg px-0.5">
|
||||||
|
<Link
|
||||||
|
to="/create-newsfeed/users"
|
||||||
|
search={search}
|
||||||
|
className="flex-1 h-8"
|
||||||
|
>
|
||||||
|
{({ isActive }) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"text-sm font-medium rounded-md h-full flex items-center justify-center",
|
||||||
|
isActive
|
||||||
|
? "bg-white dark:bg-white/20 shadow"
|
||||||
|
: "bg-transparent",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Users
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/create-newsfeed/f2f"
|
||||||
|
search={search}
|
||||||
|
className="flex-1 h-8"
|
||||||
|
>
|
||||||
|
{({ isActive }) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-md h-full flex items-center justify-center",
|
||||||
|
isActive ? "bg-white dark:bg-white/20" : "bg-transparent",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Friend to Friend
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { Suspense, useState } from "react";
|
||||||
|
import { Await, defer } from "@tanstack/react-router";
|
||||||
|
import { User } from "@/components/user";
|
||||||
|
import { Spinner } from "@lume/ui";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import type { ColumnRouteSearch } from "@lume/types";
|
||||||
|
import { NostrAccount } from "@lume/system";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/create-newsfeed/users")({
|
||||||
|
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||||
|
return {
|
||||||
|
account: search.account,
|
||||||
|
label: search.label,
|
||||||
|
name: search.name,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
loader: async ({ abortController }) => {
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
data: defer(
|
||||||
|
fetch("https://api.nostr.band/v0/trending/profiles", {
|
||||||
|
signal: abortController.signal,
|
||||||
|
}).then((res) => res.json()),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(String(e));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
component: Screen,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Screen() {
|
||||||
|
const { data } = Route.useLoaderData();
|
||||||
|
const { redirect } = Route.useSearch();
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [follows, setFollows] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const navigate = Route.useNavigate();
|
||||||
|
|
||||||
|
const toggleFollow = (pubkey: string) => {
|
||||||
|
setFollows((prev) =>
|
||||||
|
prev.includes(pubkey)
|
||||||
|
? prev.filter((i) => i !== pubkey)
|
||||||
|
: [...prev, pubkey],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const newContactList = await NostrAccount.setContactList(follows);
|
||||||
|
|
||||||
|
if (newContactList) {
|
||||||
|
return navigate({ to: redirect });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setIsLoading(false);
|
||||||
|
toast.error(String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full flex flex-col items-center gap-3">
|
||||||
|
<div className="overflow-y-auto scrollbar-none p-2 w-full h-[450px] bg-black/5 dark:bg-white/5 backdrop-blur-lg rounded-xl">
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-2 text-sm font-medium"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<Spinner className="size-5" />
|
||||||
|
Loading...
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Await promise={data}>
|
||||||
|
{(users) =>
|
||||||
|
users.profiles.map((item: { pubkey: string }) => (
|
||||||
|
<div
|
||||||
|
key={item.pubkey}
|
||||||
|
className="h-max w-full overflow-hidden mb-2 p-2 bg-white dark:bg-black/20 backdrop-blur-lg rounded-lg shadow-primary dark:ring-1 ring-neutral-800/50"
|
||||||
|
>
|
||||||
|
<User.Provider pubkey={item.pubkey}>
|
||||||
|
<User.Root>
|
||||||
|
<div className="flex h-full w-full flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<User.Avatar className="size-7 shrink-0 rounded-full object-cover" />
|
||||||
|
<User.Name className="text-sm leadning-tight max-w-[15rem] truncate font-semibold" />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleFollow(item.pubkey)}
|
||||||
|
className="inline-flex h-7 w-20 items-center justify-center rounded-lg bg-black/10 text-sm font-medium hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
|
||||||
|
>
|
||||||
|
{follows.includes(item.pubkey)
|
||||||
|
? "Unfollow"
|
||||||
|
: "Follow"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<User.About className="line-clamp-3 max-w-none select-text text-neutral-800 dark:text-neutral-400" />
|
||||||
|
</div>
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</Await>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => submit()}
|
||||||
|
disabled={isLoading || follows.length < 1}
|
||||||
|
className="inline-flex items-center justify-center w-36 rounded-full h-9 bg-blue-500 text-white text-sm font-medium hover:bg-blue-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? <Spinner /> : "Confirm"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { CheckCircleIcon } from "@lume/icons";
|
||||||
|
import { NostrQuery } from "@lume/system";
|
||||||
|
import type { ColumnRouteSearch } from "@lume/types";
|
||||||
|
import { Spinner } from "@lume/ui";
|
||||||
|
import { TOPICS } from "@lume/utils";
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
type Topic = {
|
||||||
|
title: string;
|
||||||
|
content: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/create-topic")({
|
||||||
|
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||||
|
return {
|
||||||
|
account: search.account,
|
||||||
|
label: search.label,
|
||||||
|
name: search.name,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
component: Screen,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Screen() {
|
||||||
|
const [topics, setTopics] = useState<Topic[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const search = Route.useSearch();
|
||||||
|
const navigate = Route.useNavigate();
|
||||||
|
|
||||||
|
const toggleTopic = (topic: Topic) => {
|
||||||
|
setTopics((prev) =>
|
||||||
|
prev.find((item) => item.title === topic.title)
|
||||||
|
? prev.filter((i) => i.title !== topic.title)
|
||||||
|
: [...prev, topic],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const key = `lume_topic_${search.label}`;
|
||||||
|
const createTopic = await NostrQuery.setNstore(
|
||||||
|
key,
|
||||||
|
JSON.stringify(topics),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (createTopic) {
|
||||||
|
return navigate({ to: search.redirect, search: { ...search } });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setIsLoading(false);
|
||||||
|
toast.error(String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center w-full h-full gap-4">
|
||||||
|
<div className="flex flex-col items-center justify-center text-center">
|
||||||
|
<h1 className="font-serif text-2xl font-medium">
|
||||||
|
What are your interests?
|
||||||
|
</h1>
|
||||||
|
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
|
||||||
|
Add some topics you want to focus on.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col w-4/5 max-w-full gap-3">
|
||||||
|
<div className="flex items-center justify-between w-full px-3 rounded-lg h-9 shrink-0 bg-black/5 dark:bg-white/5">
|
||||||
|
<span className="text-sm font-medium">Added: {topics.length}</span>
|
||||||
|
</div>
|
||||||
|
<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="flex flex-col gap-3">
|
||||||
|
{TOPICS.map((topic) => (
|
||||||
|
<button
|
||||||
|
key={topic.title}
|
||||||
|
type="button"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<div className="inline-flex items-center gap-1">
|
||||||
|
<div>{topic.icon}</div>
|
||||||
|
<div className="text-sm font-medium">
|
||||||
|
<span>{topic.title}</span>
|
||||||
|
<span className="ml-1 italic font-normal text-neutral-400 dark:text-neutral-600">
|
||||||
|
{topic.content.length} hashtags
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{topics.find((item) => item.title === topic.title) ? (
|
||||||
|
<CheckCircleIcon className="text-teal-500 size-4" />
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => submit()}
|
||||||
|
disabled={isLoading || topics.length < 1}
|
||||||
|
className="inline-flex items-center justify-center 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"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,25 +1,23 @@
|
|||||||
import { AddMediaIcon } from "@lume/icons";
|
import { AddMediaIcon } from "@lume/icons";
|
||||||
|
import { NostrQuery } from "@lume/system";
|
||||||
import { Spinner } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import { cn, insertImage, isImagePath } from "@lume/utils";
|
import { insertImage, isImagePath } from "@lume/utils";
|
||||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
|
||||||
import type { UnlistenFn } from "@tauri-apps/api/event";
|
import type { UnlistenFn } from "@tauri-apps/api/event";
|
||||||
import { getCurrent } from "@tauri-apps/api/window";
|
import { getCurrent } from "@tauri-apps/api/window";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useSlateStatic } from "slate-react";
|
import { useSlateStatic } from "slate-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export function MediaButton({ className }: { className?: string }) {
|
export function MediaButton() {
|
||||||
const editor = useSlateStatic();
|
const editor = useSlateStatic();
|
||||||
const { ark } = useRouteContext({ strict: false });
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const uploadToNostrBuild = async () => {
|
const upload = async () => {
|
||||||
try {
|
try {
|
||||||
// start loading
|
// start loading
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const image = await ark.upload();
|
const image = await NostrQuery.upload();
|
||||||
insertImage(editor, image);
|
insertImage(editor, image);
|
||||||
|
|
||||||
// reset loading
|
// reset loading
|
||||||
@@ -44,7 +42,7 @@ export function MediaButton({ className }: { className?: string }) {
|
|||||||
// upload all images
|
// upload all images
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (isImagePath(item)) {
|
if (isImagePath(item)) {
|
||||||
const image = await ark.upload(item);
|
const image = await NostrQuery.upload(item);
|
||||||
insertImage(editor, image);
|
insertImage(editor, image);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -62,29 +60,18 @@ export function MediaButton({ className }: { className?: string }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip.Provider>
|
<button
|
||||||
<Tooltip.Root delayDuration={150}>
|
type="button"
|
||||||
<Tooltip.Trigger asChild>
|
onClick={() => upload()}
|
||||||
<button
|
disabled={loading}
|
||||||
type="button"
|
className="inline-flex items-center h-8 gap-2 px-2.5 text-sm rounded-lg text-black/70 dark:text-white/70 w-max hover:bg-black/10 dark:hover:bg-white/10"
|
||||||
onClick={() => uploadToNostrBuild()}
|
>
|
||||||
disabled={loading}
|
{loading ? (
|
||||||
className={cn("inline-flex items-center justify-center", className)}
|
<Spinner className="size-4" />
|
||||||
>
|
) : (
|
||||||
{loading ? (
|
<AddMediaIcon className="size-4" />
|
||||||
<Spinner className="size-4" />
|
)}
|
||||||
) : (
|
Add media
|
||||||
<AddMediaIcon className="size-4" />
|
</button>
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</Tooltip.Trigger>
|
|
||||||
<Tooltip.Portal>
|
|
||||||
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 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">
|
|
||||||
Upload media
|
|
||||||
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
|
||||||
</Tooltip.Content>
|
|
||||||
</Tooltip.Portal>
|
|
||||||
</Tooltip.Root>
|
|
||||||
</Tooltip.Provider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
import { MentionIcon } from "@lume/icons";
|
|
||||||
import { cn, insertMention } from "@lume/utils";
|
|
||||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
|
||||||
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
|
||||||
import { User } from "@lume/ui";
|
|
||||||
import { useSlateStatic } from "slate-react";
|
|
||||||
import type { Contact } from "@lume/types";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
export function MentionButton({ className }: { className?: string }) {
|
|
||||||
const editor = useSlateStatic();
|
|
||||||
const { ark } = useRouteContext({ strict: false });
|
|
||||||
const [contacts, setContacts] = useState<string[]>([]);
|
|
||||||
|
|
||||||
const select = async (user: string) => {
|
|
||||||
try {
|
|
||||||
const metadata = await ark.get_profile(user);
|
|
||||||
const contact: Contact = { pubkey: user, profile: metadata };
|
|
||||||
|
|
||||||
insertMention(editor, contact);
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(String(e));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function getContacts() {
|
|
||||||
const data = await ark.get_contact_list();
|
|
||||||
setContacts(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
getContacts();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu.Root>
|
|
||||||
<Tooltip.Provider>
|
|
||||||
<Tooltip.Root delayDuration={150}>
|
|
||||||
<DropdownMenu.Trigger asChild>
|
|
||||||
<Tooltip.Trigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center justify-center",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<MentionIcon className="size-4" />
|
|
||||||
</button>
|
|
||||||
</Tooltip.Trigger>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<Tooltip.Portal>
|
|
||||||
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 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">
|
|
||||||
Mention
|
|
||||||
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
|
||||||
</Tooltip.Content>
|
|
||||||
</Tooltip.Portal>
|
|
||||||
</Tooltip.Root>
|
|
||||||
</Tooltip.Provider>
|
|
||||||
<DropdownMenu.Portal>
|
|
||||||
<DropdownMenu.Content className="flex w-[220px] h-[220px] scrollbar-none flex-col overflow-y-auto rounded-xl bg-black py-1 shadow-md shadow-neutral-500/20 focus:outline-none dark:bg-white">
|
|
||||||
{contacts.map((contact) => (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={contact}
|
|
||||||
onClick={() => select(contact)}
|
|
||||||
className="shrink-0 h-11 flex items-center hover:bg-white/10 px-2"
|
|
||||||
>
|
|
||||||
<User.Provider pubkey={contact}>
|
|
||||||
<User.Root className="flex items-center gap-2">
|
|
||||||
<User.Avatar className="shrink-0 size-8 rounded-full" />
|
|
||||||
<User.Name className="text-sm font-medium text-white dark:text-black" />
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
))}
|
|
||||||
<DropdownMenu.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Portal>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { NsfwIcon } from "@lume/icons";
|
|
||||||
import { cn } from "@lume/utils";
|
|
||||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
|
||||||
import type { Dispatch, SetStateAction } from "react";
|
|
||||||
|
|
||||||
export function NsfwToggle({
|
|
||||||
nsfw,
|
|
||||||
setNsfw,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
nsfw: boolean;
|
|
||||||
setNsfw: Dispatch<SetStateAction<boolean>>;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Tooltip.Provider>
|
|
||||||
<Tooltip.Root delayDuration={150}>
|
|
||||||
<Tooltip.Trigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setNsfw((prev) => !prev)}
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center justify-center",
|
|
||||||
className,
|
|
||||||
nsfw ? "bg-blue-500 text-white" : "",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<NsfwIcon className="size-4" />
|
|
||||||
</button>
|
|
||||||
</Tooltip.Trigger>
|
|
||||||
<Tooltip.Portal>
|
|
||||||
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 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">
|
|
||||||
Mark as sensitive content
|
|
||||||
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
|
||||||
</Tooltip.Content>
|
|
||||||
</Tooltip.Portal>
|
|
||||||
</Tooltip.Root>
|
|
||||||
</Tooltip.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,40 +1,21 @@
|
|||||||
import { NsfwIcon } from "@lume/icons";
|
import { PowIcon } from "@lume/icons";
|
||||||
import { cn } from "@lume/utils";
|
|
||||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
|
||||||
import type { Dispatch, SetStateAction } from "react";
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
|
|
||||||
export function PowToggle({
|
export function PowButton({
|
||||||
pow,
|
setDifficulty,
|
||||||
setPow,
|
|
||||||
className,
|
|
||||||
}: {
|
}: {
|
||||||
pow: boolean;
|
setDifficulty: Dispatch<SetStateAction<{ enable: boolean; num: number }>>;
|
||||||
setPow: Dispatch<SetStateAction<boolean>>;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Tooltip.Provider>
|
<button
|
||||||
<Tooltip.Root delayDuration={150}>
|
type="button"
|
||||||
<Tooltip.Trigger asChild>
|
onClick={() =>
|
||||||
<button
|
setDifficulty((prev) => ({ ...prev, enable: !prev.enable }))
|
||||||
type="button"
|
}
|
||||||
onClick={() => setPow((prev) => !prev)}
|
className="inline-flex items-center h-8 gap-2 px-2.5 text-sm rounded-lg text-black/70 dark:text-white/70 w-max hover:bg-black/10 dark:hover:bg-white/10"
|
||||||
className={cn(
|
>
|
||||||
"inline-flex items-center justify-center",
|
<PowIcon className="size-4" />
|
||||||
className,
|
PoW
|
||||||
pow ? "bg-blue-500 text-white" : "",
|
</button>
|
||||||
)}
|
|
||||||
>
|
|
||||||
<NsfwIcon className="size-4" />
|
|
||||||
</button>
|
|
||||||
</Tooltip.Trigger>
|
|
||||||
<Tooltip.Portal>
|
|
||||||
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 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">
|
|
||||||
Proof of Work
|
|
||||||
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
|
||||||
</Tooltip.Content>
|
|
||||||
</Tooltip.Portal>
|
|
||||||
</Tooltip.Root>
|
|
||||||
</Tooltip.Provider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { NsfwIcon } from "@lume/icons";
|
||||||
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
|
|
||||||
|
export function WarningButton({
|
||||||
|
setWarning,
|
||||||
|
}: {
|
||||||
|
setWarning: Dispatch<SetStateAction<{ enable: boolean; reason: string }>>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setWarning((prev) => ({ ...prev, enable: !prev.enable }))}
|
||||||
|
className="inline-flex items-center h-8 gap-2 px-2.5 text-sm rounded-lg text-black/70 dark:text-white/70 w-max hover:bg-black/10 dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<NsfwIcon className="size-4" />
|
||||||
|
Mark as sensitive
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,17 +1,8 @@
|
|||||||
import { ComposeFilledIcon, TrashIcon } from "@lume/icons";
|
import { ComposeFilledIcon } from "@lume/icons";
|
||||||
import { Spinner } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import { MentionNote } from "@lume/ui/src/note/mentions/note";
|
import { cn, insertImage, insertNostrEvent, isImageUrl } from "@lume/utils";
|
||||||
import {
|
|
||||||
cn,
|
|
||||||
insertImage,
|
|
||||||
insertNostrEvent,
|
|
||||||
isImageUrl,
|
|
||||||
sendNativeNotification,
|
|
||||||
} 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 { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { type Descendant, Node, Transforms, createEditor } from "slate";
|
import { type Descendant, Node, Transforms, createEditor } from "slate";
|
||||||
import {
|
import {
|
||||||
Editable,
|
Editable,
|
||||||
@@ -23,58 +14,70 @@ import {
|
|||||||
withReact,
|
withReact,
|
||||||
} from "slate-react";
|
} from "slate-react";
|
||||||
import { MediaButton } from "./-components/media";
|
import { MediaButton } from "./-components/media";
|
||||||
import { NsfwToggle } from "./-components/nsfw";
|
import { LumeEvent, useEvent } from "@lume/system";
|
||||||
import { MentionButton } from "./-components/mention";
|
import { WarningButton } from "./-components/warning";
|
||||||
|
import { MentionNote } from "@/components/note/mentions/note";
|
||||||
|
import { PowButton } from "./-components/pow";
|
||||||
|
import { User } from "@/components/user";
|
||||||
|
import { Note } from "@/components/note";
|
||||||
|
import { nip19 } from "nostr-tools";
|
||||||
|
|
||||||
type EditorSearch = {
|
type EditorSearch = {
|
||||||
reply_to: string;
|
reply_to: string;
|
||||||
quote: boolean;
|
quote: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EditorElement = {
|
||||||
|
type: string;
|
||||||
|
children: Descendant[];
|
||||||
|
eventId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Route = createFileRoute("/editor/")({
|
export const Route = createFileRoute("/editor/")({
|
||||||
validateSearch: (search: Record<string, string>): EditorSearch => {
|
validateSearch: (search: Record<string, string>): EditorSearch => {
|
||||||
return {
|
return {
|
||||||
reply_to: search.reply_to,
|
reply_to: search.reply_to,
|
||||||
quote: search.quote === "true" ?? false,
|
quote: search.quote,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
beforeLoad: async ({ search }) => {
|
beforeLoad: ({ search }) => {
|
||||||
return {
|
let initialValue: EditorElement[];
|
||||||
initialValue: search.quote
|
|
||||||
? [
|
if (search?.quote?.length) {
|
||||||
{
|
const eventId = nip19.noteEncode(search.quote);
|
||||||
type: "paragraph",
|
initialValue = [
|
||||||
children: [{ text: "" }],
|
{
|
||||||
},
|
type: "paragraph",
|
||||||
{
|
children: [{ text: "" }],
|
||||||
type: "event",
|
},
|
||||||
eventId: `nostr:${nip19.noteEncode(search.reply_to)}`,
|
{
|
||||||
children: [{ text: "" }],
|
type: "event",
|
||||||
},
|
eventId: `nostr:${eventId}`,
|
||||||
{
|
children: [{ text: "" }],
|
||||||
type: "paragraph",
|
},
|
||||||
children: [{ text: "" }],
|
];
|
||||||
},
|
} else {
|
||||||
]
|
initialValue = [
|
||||||
: [
|
{
|
||||||
{
|
type: "paragraph",
|
||||||
type: "paragraph",
|
children: [{ text: "" }],
|
||||||
children: [{ text: "" }],
|
},
|
||||||
},
|
];
|
||||||
],
|
}
|
||||||
};
|
|
||||||
|
return { initialValue };
|
||||||
},
|
},
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const { reply_to, quote } = Route.useSearch();
|
const { reply_to } = Route.useSearch();
|
||||||
const { ark, initialValue } = Route.useRouteContext();
|
const { initialValue } = Route.useRouteContext();
|
||||||
|
|
||||||
const [t] = useTranslation();
|
const [editorValue, setEditorValue] = useState<EditorElement[]>(null);
|
||||||
const [editorValue, setEditorValue] = useState(initialValue);
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [nsfw, setNsfw] = useState(false);
|
const [warning, setWarning] = useState({ enable: false, reason: "" });
|
||||||
|
const [difficulty, setDifficulty] = useState({ enable: false, num: 21 });
|
||||||
const [editor] = useState(() =>
|
const [editor] = useState(() =>
|
||||||
withMentions(withNostrEvent(withImages(withReact(createEditor())))),
|
withMentions(withNostrEvent(withImages(withReact(createEditor())))),
|
||||||
);
|
);
|
||||||
@@ -115,60 +118,42 @@ function Screen() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const content = serialize(editor.children);
|
const content = serialize(editor.children);
|
||||||
const eventId = await ark.publish(content, reply_to, quote);
|
const eventId = await LumeEvent.publish(
|
||||||
|
content,
|
||||||
|
warning.enable && warning.reason.length ? warning.reason : null,
|
||||||
|
difficulty.enable && difficulty.num > 0 ? difficulty.num : null,
|
||||||
|
reply_to,
|
||||||
|
);
|
||||||
|
|
||||||
if (eventId) {
|
if (eventId) {
|
||||||
await sendNativeNotification(
|
// stop loading
|
||||||
"Your note has been published successfully.",
|
setLoading(false);
|
||||||
"Lume",
|
// reset form
|
||||||
);
|
reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
// stop loading
|
|
||||||
setLoading(false);
|
|
||||||
|
|
||||||
// reset form
|
|
||||||
reset();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
await sendNativeNotification(String(e));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEditorValue(initialValue);
|
||||||
|
}, [initialValue]);
|
||||||
|
|
||||||
|
if (!editorValue) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full">
|
<div className="flex flex-col w-full h-full">
|
||||||
<Slate editor={editor} initialValue={editorValue}>
|
<Slate editor={editor} initialValue={editorValue}>
|
||||||
<div
|
<div data-tauri-drag-region className="h-9 shrink-0" />
|
||||||
data-tauri-drag-region
|
<div className="flex flex-col flex-1 overflow-y-auto">
|
||||||
className="flex h-14 w-full shrink-0 items-center justify-end gap-2 px-2 border-b border-black/10 dark:border-white/10"
|
{reply_to?.length ? (
|
||||||
>
|
<div className="flex items-center gap-3 px-2.5 pb-3 border-b border-black/5 dark:border-white/5">
|
||||||
<NsfwToggle
|
<div className="text-sm font-semibold shrink-0">Reply to:</div>
|
||||||
nsfw={nsfw}
|
<ChildNote id={reply_to} />
|
||||||
setNsfw={setNsfw}
|
|
||||||
className="size-8 rounded-full bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
|
|
||||||
/>
|
|
||||||
<MentionButton className="size-8 rounded-full bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20" />
|
|
||||||
<MediaButton className="size-8 rounded-full bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20" />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => publish()}
|
|
||||||
className="inline-flex h-8 w-max items-center justify-center gap-1 rounded-full bg-blue-500 px-3 text-sm font-medium text-white hover:bg-blue-600"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<Spinner className="size-4" />
|
|
||||||
) : (
|
|
||||||
<ComposeFilledIcon className="size-4" />
|
|
||||||
)}
|
|
||||||
{t("global.post")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex h-full w-full flex-1 flex-col">
|
|
||||||
{reply_to && !quote ? (
|
|
||||||
<div className="px-4 py-2">
|
|
||||||
<MentionNote eventId={reply_to} />
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="overflow-y-auto p-4">
|
<div className="px-4 py-4 overflow-y-auto">
|
||||||
<Editable
|
<Editable
|
||||||
key={JSON.stringify(editorValue)}
|
key={JSON.stringify(editorValue)}
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
@@ -177,17 +162,103 @@ function Screen() {
|
|||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
renderElement={(props) => <Element {...props} />}
|
renderElement={(props) => <Element {...props} />}
|
||||||
placeholder={
|
placeholder={
|
||||||
reply_to ? "Type your reply..." : t("editor.placeholder")
|
reply_to ? "Type your reply..." : "What're you up to?"
|
||||||
}
|
}
|
||||||
className="focus:outline-none"
|
className="focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{warning.enable ? (
|
||||||
|
<div className="flex items-center w-full px-4 border-t h-11 shrink-0 border-black/5 dark:border-white/5">
|
||||||
|
<span className="text-sm shrink-0 text-black/50 dark:text-white/50">
|
||||||
|
Reason:
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="NSFW..."
|
||||||
|
value={warning.reason}
|
||||||
|
onChange={(e) =>
|
||||||
|
setWarning((prev) => ({ ...prev, reason: e.target.value }))
|
||||||
|
}
|
||||||
|
className="flex-1 text-sm bg-transparent border-none focus:outline-none focus:ring-0 placeholder:text-black/50 dark:placeholder:text-white/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{difficulty.enable ? (
|
||||||
|
<div className="flex items-center w-full px-4 border-t h-11 shrink-0 border-black/5 dark:border-white/5">
|
||||||
|
<span className="text-sm shrink-0 text-black/50 dark:text-white/50">
|
||||||
|
Difficulty:
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
pattern="[0-9]"
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (!/[0-9]/.test(event.key)) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="21"
|
||||||
|
defaultValue={difficulty.num}
|
||||||
|
onChange={(e) =>
|
||||||
|
setWarning((prev) => ({ ...prev, num: Number(e.target.value) }))
|
||||||
|
}
|
||||||
|
className="flex-1 text-sm bg-transparent border-none focus:outline-none focus:ring-0 placeholder:text-black/50 dark:placeholder:text-white/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div
|
||||||
|
data-tauri-drag-region
|
||||||
|
className="flex items-center w-full h-16 gap-4 px-4 border-t divide-x divide-black/5 dark:divide-white/5 shrink-0 border-black/5 dark:border-white/5"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => publish()}
|
||||||
|
className="inline-flex items-center justify-center h-8 gap-1 px-2.5 text-sm font-medium rounded-lg bg-black/10 w-max hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Spinner className="size-4" />
|
||||||
|
) : (
|
||||||
|
<ComposeFilledIcon className="size-4" />
|
||||||
|
)}
|
||||||
|
Publish
|
||||||
|
</button>
|
||||||
|
<div className="inline-flex items-center flex-1 gap-2 pl-4">
|
||||||
|
<MediaButton />
|
||||||
|
<WarningButton setWarning={setWarning} />
|
||||||
|
<PowButton setDifficulty={setDifficulty} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Slate>
|
</Slate>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ChildNote({ id }: { id: string }) {
|
||||||
|
const { isLoading, isError, data } = useEvent(id);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <Spinner className="size-5" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !data) {
|
||||||
|
return <div>Event not found with your current relay set.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Note.Provider event={data}>
|
||||||
|
<Note.Root className="flex items-center gap-2">
|
||||||
|
<User.Provider pubkey={data.pubkey}>
|
||||||
|
<User.Root className="shrink-0">
|
||||||
|
<User.Avatar className="rounded-full size-8 shrink-0" />
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
|
<div className="content-break line-clamp-1">{data.content}</div>
|
||||||
|
</Note.Root>
|
||||||
|
</Note.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const withNostrEvent = (editor: ReactEditor) => {
|
const withNostrEvent = (editor: ReactEditor) => {
|
||||||
const { insertData, isVoid } = editor;
|
const { insertData, isVoid } = editor;
|
||||||
|
|
||||||
@@ -199,7 +270,7 @@ const withNostrEvent = (editor: ReactEditor) => {
|
|||||||
editor.insertData = (data) => {
|
editor.insertData = (data) => {
|
||||||
const text = data.getData("text/plain");
|
const text = data.getData("text/plain");
|
||||||
|
|
||||||
if (text.startsWith("nevent1") || text.startsWith("note1")) {
|
if (text.startsWith("nevent") || text.startsWith("note")) {
|
||||||
insertNostrEvent(editor, text);
|
insertNostrEvent(editor, text);
|
||||||
} else {
|
} else {
|
||||||
insertData(data);
|
insertData(data);
|
||||||
@@ -251,35 +322,25 @@ const withImages = (editor: ReactEditor) => {
|
|||||||
return editor;
|
return editor;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Image = ({ attributes, children, element }) => {
|
const Image = ({ attributes, element, children }) => {
|
||||||
const editor = useSlateStatic();
|
const editor = useSlateStatic();
|
||||||
const path = ReactEditor.findPath(editor as ReactEditor, element);
|
|
||||||
|
|
||||||
const selected = useSelected();
|
const selected = useSelected();
|
||||||
const focused = useFocused();
|
const focused = useFocused();
|
||||||
|
const path = ReactEditor.findPath(editor as ReactEditor, element);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div {...attributes}>
|
<div {...attributes}>
|
||||||
{children}
|
{children}
|
||||||
<div contentEditable={false} className="relative my-2">
|
<img
|
||||||
<img
|
src={element.url}
|
||||||
src={element.url}
|
alt={element.url}
|
||||||
alt={element.url}
|
className={cn(
|
||||||
className={cn(
|
"my-2 h-auto w-1/2 rounded-lg object-cover ring-2 outline outline-1 -outline-offset-1 outline-black/15",
|
||||||
"h-auto w-full rounded-lg border border-neutral-100 object-cover ring-2 dark:border-neutral-900",
|
selected && focused ? "ring-blue-500" : "ring-transparent",
|
||||||
selected && focused ? "ring-blue-500" : "ring-transparent",
|
)}
|
||||||
)}
|
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
||||||
contentEditable={false}
|
onKeyDown={() => Transforms.removeNodes(editor, { at: path })}
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
contentEditable={false}
|
|
||||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
|
||||||
className="absolute right-2 top-2 inline-flex size-8 items-center justify-center rounded-lg bg-red-500 text-white hover:bg-red-600"
|
|
||||||
>
|
|
||||||
<TrashIcon className="size-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -294,7 +355,7 @@ const Mention = ({ attributes, element }) => {
|
|||||||
type="button"
|
type="button"
|
||||||
contentEditable={false}
|
contentEditable={false}
|
||||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
||||||
className="inline-block align-baseline text-blue-500 hover:text-blue-600"
|
className="inline-block text-blue-500 align-baseline hover:text-blue-600"
|
||||||
>{`@${element.name}`}</span>
|
>{`@${element.name}`}</span>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -306,16 +367,13 @@ const Event = ({ attributes, element, children }) => {
|
|||||||
return (
|
return (
|
||||||
<div {...attributes}>
|
<div {...attributes}>
|
||||||
{children}
|
{children}
|
||||||
{/* biome-ignore lint/a11y/useKeyWithClickEvents: <explanation> */}
|
|
||||||
<div
|
<div
|
||||||
contentEditable={false}
|
contentEditable={false}
|
||||||
|
className="relative my-2 user-select-none"
|
||||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
||||||
className="user-select-none relative my-2"
|
onKeyDown={() => Transforms.removeNodes(editor, { at: path })}
|
||||||
>
|
>
|
||||||
<MentionNote
|
<MentionNote eventId={element.eventId} openable={false} />
|
||||||
eventId={element.eventId.replace("nostr:", "")}
|
|
||||||
openable={false}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,71 +1,82 @@
|
|||||||
import { useEvent } from "@lume/ark";
|
import { Note } from "@/components/note";
|
||||||
import type { Event } from "@lume/types";
|
import { type LumeEvent, NostrQuery } from "@lume/system";
|
||||||
import { Box, Container, Note, Spinner } from "@lume/ui";
|
import { Box, Container, Spinner } from "@lume/ui";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { WindowVirtualizer } from "virtua";
|
import { WindowVirtualizer } from "virtua";
|
||||||
import { ReplyList } from "./-components/replyList";
|
import { Reply } from "./-components/reply";
|
||||||
|
|
||||||
export const Route = createFileRoute("/events/$eventId")({
|
export const Route = createFileRoute("/events/$eventId")({
|
||||||
beforeLoad: async ({ context }) => {
|
beforeLoad: async () => {
|
||||||
const ark = context.ark;
|
const settings = await NostrQuery.getUserSettings();
|
||||||
const settings = await ark.get_settings();
|
|
||||||
|
|
||||||
return { settings };
|
return { settings };
|
||||||
},
|
},
|
||||||
|
loader: async ({ params }) => {
|
||||||
|
const event = await NostrQuery.getEvent(params.eventId);
|
||||||
|
return event;
|
||||||
|
},
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const { eventId } = Route.useParams();
|
const event = Route.useLoaderData();
|
||||||
const { isLoading, isError, data } = useEvent(eventId);
|
|
||||||
|
|
||||||
if (isLoading) {
|
const [reload, setReload] = useState(false);
|
||||||
return (
|
const [replies, setReplies] = useState<LumeEvent[]>(null);
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
|
||||||
<Spinner className="size-5" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isError) {
|
useEffect(() => {
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
let mounted = true;
|
||||||
<p>Not found.</p>
|
|
||||||
</div>;
|
if (event) {
|
||||||
}
|
event.getAllReplies().then((data) => {
|
||||||
|
if (mounted) setReplies(data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, [event]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container withDrag>
|
<Container withDrag>
|
||||||
<Box className="scrollbar-none">
|
<Box className="scrollbar-none">
|
||||||
<WindowVirtualizer>
|
<WindowVirtualizer>
|
||||||
<MainNote data={data} />
|
<Note.Provider event={event}>
|
||||||
{data ? (
|
<Note.Root>
|
||||||
<ReplyList eventId={eventId} />
|
<div className="flex items-center justify-between px-3 h-14">
|
||||||
) : (
|
<Note.User />
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
<Note.Menu />
|
||||||
<Spinner className="size-5" />
|
</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>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex items-center px-3 text-sm font-semibold border-t h-11 text-neutral-700 dark:text-neutral-300 border-neutral-100 dark:border-neutral-900">
|
||||||
|
Replies ({replies?.length ?? 0})
|
||||||
</div>
|
</div>
|
||||||
)}
|
{!replies ? (
|
||||||
|
<Spinner />
|
||||||
|
) : !replies.length ? (
|
||||||
|
<div className="flex items-center justify-center w-full">
|
||||||
|
<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">
|
||||||
|
Be the first to Reply!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
replies.map((event) => <Reply key={event.id} event={event} />)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</WindowVirtualizer>
|
</WindowVirtualizer>
|
||||||
</Box>
|
</Box>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MainNote({ data }: { data: Event }) {
|
|
||||||
return (
|
|
||||||
<Note.Provider event={data}>
|
|
||||||
<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-4 h-11 gap-2 flex items-center justify-end px-3">
|
|
||||||
<Note.Reply large />
|
|
||||||
<Note.Repost large />
|
|
||||||
<Note.Zap large />
|
|
||||||
</div>
|
|
||||||
</Note.Root>
|
|
||||||
</Note.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import type { EventWithReplies } from "@lume/types";
|
import { Note } from "@/components/note";
|
||||||
import { Note } from "@lume/ui";
|
import type { LumeEvent } from "@lume/system";
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { SubReply } from "./subReply";
|
import { SubReply } from "./subReply";
|
||||||
|
|
||||||
export function Reply({ event }: { event: EventWithReplies }) {
|
export function Reply({ event }: { event: LumeEvent }) {
|
||||||
return (
|
return (
|
||||||
<Note.Provider event={event}>
|
<Note.Provider event={event}>
|
||||||
<Note.Root className="border-t border-neutral-100 dark:border-neutral-900">
|
<Note.Root className="border-t border-neutral-100 dark:border-neutral-900">
|
||||||
<div className="px-3 h-14 flex items-center justify-between">
|
<div className="flex items-center justify-between px-3 h-14">
|
||||||
<Note.User />
|
<Note.User />
|
||||||
<Note.Menu />
|
<Note.Menu />
|
||||||
</div>
|
</div>
|
||||||
<Note.ContentLarge className="px-3" />
|
<Note.ContentLarge className="px-3" />
|
||||||
<div className="mt-3 flex items-center gap-4 px-3 h-14">
|
<div className="flex items-center gap-4 px-3 mt-3 h-14">
|
||||||
<Note.Reply />
|
<Note.Reply />
|
||||||
<Note.Repost />
|
<Note.Repost />
|
||||||
<Note.Zap />
|
<Note.Zap />
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import type { EventWithReplies } from "@lume/types";
|
import type { EventWithReplies } from "@lume/types";
|
||||||
import { Spinner } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Reply } from "./reply";
|
import { Reply } from "./reply";
|
||||||
|
import { LumeEvent } from "@lume/system";
|
||||||
|
|
||||||
export function ReplyList({
|
export function ReplyList({
|
||||||
eventId,
|
eventId,
|
||||||
@@ -13,13 +13,12 @@ export function ReplyList({
|
|||||||
eventId: string;
|
eventId: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const { ark } = useRouteContext({ strict: false });
|
|
||||||
const [t] = useTranslation();
|
const [t] = useTranslation();
|
||||||
const [data, setData] = useState<null | EventWithReplies[]>(null);
|
const [data, setData] = useState<null | EventWithReplies[]>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function getReplies() {
|
async function getReplies() {
|
||||||
const events = await ark.get_event_thread(eventId);
|
const events = await LumeEvent.getReplies(eventId);
|
||||||
setData(events);
|
setData(events);
|
||||||
}
|
}
|
||||||
getReplies();
|
getReplies();
|
||||||
|
|||||||