Compare commits

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

* feat: update trending column

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

* feat: save window state

* feat: add window state plugin

* feat: add search

* feat: use negentropy for newsfeed

* feat: live feeds

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

* refactor: thread

* feat: add window virtualized to event screen

* chore: update deps

* fix: window decoration

* feat: improve mention ntoe

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

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

* feat: improve image carousel
2024-06-21 07:51:11 +07:00
reya e4a317f038 chore: bump version 2024-06-20 13:23:05 +07:00
reya 9779d020c7 feat: improve list virtualization 2024-06-20 13:22:28 +07:00
雨宮蓮andGitHub f8280ec8ee fix: get replies function (#213) 2024-06-19 21:02:33 +07:00
XIAO YUandGitHub 6c26f8967b chore: Refactor code for better performance and reliability (#212) 2024-06-19 14:36:59 +07:00
reya e1424b851c fix: routes 2024-06-19 14:22:45 +07:00
reya d14e609579 chore: bump version 2024-06-19 14:14:34 +07:00
reya 8c0627ff27 fix: crash on startup 2024-06-19 14:14:03 +07:00
雨宮蓮andGitHub 18c133d096 Settings Manager (#211)
* refactor: landing screen

* fix: code debt

* feat: add settings screen

* chore: clean up

* feat: settings

* feat: small updates
2024-06-19 14:00:58 +07:00
reya 0061ecea78 feat: use native context menu in tray panel 2024-06-18 09:07:58 +07:00
reya d01cf8319d feat: native context menu 2024-06-17 15:31:59 +07:00
雨宮蓮andGitHub 843895d876 Make Lume Faster (#208)
* chore: fix some lint issues

* feat: refactor contact list

* feat: refactor relay hint

* feat: add missing commands

* feat: use new cache layer for react query

* feat: refactor column

* feat: improve relay hint

* fix: replace break with continue in parser

* refactor: publish function

* feat: add reply command

* feat: improve editor

* fix: quote

* chore: update deps

* refactor: note component

* feat: improve repost

* feat: improve cache

* fix: backup screen

* refactor: column manager
2024-06-17 13:52:06 +07:00
XIAO YUandGitHub 7c99ed39e4 chore: handle unwrap err (#210) 2024-06-16 08:11:48 +07:00
71be59b2e9 Move the event parser and dedup functions to Rust (#206)
* feat: improve js parser

* feat: move parser and dedup to rust

* fix: parser

* fix: get event function

* feat: improve parser performance (#207)

* feat: improve parser performance

* feat: add test for video parsing

* feat: finish new parser

---------

Co-authored-by: XIAO YU <xyzmhx@gmail.com>
2024-06-12 08:27:53 +07:00
reya 1c20512ecc chore: update deps 2024-06-10 13:15:57 +07:00
雨宮蓮andGitHub 90342c552f Customize Bootstrap Relays (#205)
* feat: add bootstrap relays file

* feat: add save bootstrap relays command

* feat: add customize bootstrap relays screen
2024-06-10 10:48:39 +07:00
reya b396c8a695 feat: upgrade to rust-nostr 0.32 2024-06-08 08:00:02 +07:00
reya 6996e30889 chore: update github ci 2024-06-07 11:26:20 +07:00
reya 7ba793fad8 chore: bump version 2024-06-07 10:03:04 +07:00
reya f11f836518 chore: update tray icon 2024-06-07 09:56:57 +07:00
reya 04fe0fcec8 feat: respect the relay hint 2024-06-07 09:07:33 +07:00
799835a629 Notification Panel (#200)
* feat: add tauri nspanel

* feat: add notification panel

* feat: move notification service to backend

* feat: add sync notification job

* feat: enable panel to join all spaces including fullscreen (#203)

* feat: fetch notification

* feat: listen for new notification

* feat: finish panel

---------

Co-authored-by: Victor Aremu <me@victorare.mu>
2024-06-06 14:32:30 +07:00
XIAO YUandGitHub 4e7da4108b feat: Add get user following function (#202)
* feat: Add get user following function

* refactor: Refactor get_following function to use state and string public key

* feat: Update get_following function to use timeout duration

* feat: Fix connect_remote_account function to return remote_npub without conversion

* feat: Refactor get_following function to handle public key parsing errors

* Refactor get_followers function to handle public key parsing errors and use timeout duration
2024-06-05 13:24:32 +07:00
reya 7c7b082b3a fix: memory leak in image component 2024-06-03 07:32:34 +07:00
reya 38d6c51921 feat: use nostrdb for unix and rocksdb for windows 2024-06-02 08:16:59 +07:00
reya 1738cbdd97 chore: upgrade tauri 2024-06-01 14:54:35 +07:00
reya 2e885b76a1 feat: improve text wrap 2024-06-01 08:27:22 +07:00
reya f94680e487 fix: column overlapped after change account 2024-05-31 15:25:47 +07:00
XIAO YUandGitHub c682a58842 chore: Remove unused modules and update metadata.rs (#199) 2024-05-31 12:53:35 +07:00
reya 921cf871ee chore: bump version 2024-05-31 08:54:53 +07:00
reya d5b1593aca feat: improve nostr connect flow 2024-05-31 08:54:17 +07:00
reya 6676b4e2a4 Revert "chore: Update dependencies and add thiserror crate (#196)"
This reverts commit e254ee3203.
2024-05-30 15:40:44 +07:00
reya 5f30ddcfca Merge branch 'main' of github.com:lumehq/lume 2024-05-30 15:23:11 +07:00
reya 41d0de539d feat: revamp nostr connect flow 2024-05-30 15:21:33 +07:00
XIAO YUandGitHub e254ee3203 chore: Update dependencies and add thiserror crate (#196) 2024-05-30 07:12:46 +07:00
reya 6d42360549 chore: bump version 2024-05-29 14:47:22 +07:00
reya 70c5143445 feat: redirect to index after add account 2024-05-29 14:41:33 +07:00
reya 41b66b18f5 feat: lazy load onboarding videos 2024-05-29 14:11:30 +07:00
雨宮蓮andGitHub dda0720ed4 chore: update deps (#195) 2024-05-29 12:54:58 +07:00
reya 4b60b39119 chore: bump version 2024-05-29 10:34:32 +07:00
reya d2e5122d5a feat: improve splash screen and notification service 2024-05-29 10:33:38 +07:00
reya 32f3315344 fix: editor crash on open 2024-05-28 14:43:34 +07:00
reya 5ca9444358 feat: add theme switcher 2024-05-26 16:11:27 +07:00
reya 4dc13385a5 feat: add option for relay hint 2024-05-26 08:23:29 +07:00
XIAO YUandGitHub b90ad1421f chore: update keys.rs and relay.rs (#193) 2024-05-26 07:28:16 +07:00
雨宮蓮andGitHub bba324ea53 refactor: use specta for commands (#192)
* feat: add tauri-specta

* refactor: system library

* chore: format
2024-05-25 15:21:40 +07:00
reya 7449000f5f chore: bump version 2024-05-23 08:01:04 +07:00
reya dc7762ca11 fix: column lose state when navigate back 2024-05-23 08:00:33 +07:00
XIAO YUandGitHub 3a3f960dde refactor: use borrowed references for adding and connecting relays (#191) 2024-05-23 07:08:39 +07:00
reya 12e066ff2e fix: web 2024-05-22 15:36:41 +07:00
reya fe4f965ed5 chore: update website 2024-05-22 15:35:41 +07:00
203 changed files with 24846 additions and 20087 deletions
+2
View File
@@ -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'
+1 -1
View File
@@ -35,4 +35,4 @@ dist/
.DS_Store .DS_Store
*.pem *.pem
.vscode/ .vscode/
ndb/ .idea/
+11 -11
View File
@@ -1,14 +1,14 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lume Desktop</title> <title>Lume Desktop</title>
</head> </head>
<body <body
class="relative h-screen w-screen cursor-default select-none overflow-hidden font-sans text-black antialiased dark:text-white" class="relative h-screen w-screen cursor-default select-none overflow-hidden font-sans text-black antialiased dark:text-white"
> >
<div id="root" class="h-full w-full"></div> <div id="root" class="h-full w-full"></div>
<script type="module" src="/src/app.tsx"></script> <script type="module" src="/src/app.tsx"></script>
</body> </body>
</html> </html>
+27 -30
View File
@@ -9,55 +9,52 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@lume/ark": "workspace:^", "@getalby/bitcoin-connect-react": "^3.5.3",
"@lume/icons": "workspace:^", "@lume/icons": "workspace:^",
"@lume/system": "workspace:^",
"@lume/ui": "workspace:^", "@lume/ui": "workspace:^",
"@lume/utils": "workspace:^", "@lume/utils": "workspace:^",
"@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-checkbox": "^1.1.1",
"@radix-ui/react-collapsible": "^1.0.3", "@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-hover-card": "^1.0.7", "@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-tooltip": "^1.1.2",
"@radix-ui/react-switch": "^1.0.3", "@tanstack/query-persist-client-core": "^5.49.1",
"@radix-ui/react-tooltip": "^1.0.7", "@tanstack/react-query": "^5.49.2",
"@tanstack/query-sync-storage-persister": "^5.36.0", "@tanstack/react-router": "^1.43.3",
"@tanstack/react-query": "^5.36.0", "embla-carousel-react": "^8.1.5",
"@tanstack/react-query-persist-client": "^5.36.0", "i18next": "^23.11.5",
"@tanstack/react-router": "1.32.5",
"i18next": "^23.11.4",
"i18next-resources-to-backend": "^1.2.1", "i18next-resources-to-backend": "^1.2.1",
"minidenticons": "^4.2.1", "minidenticons": "^4.2.1",
"nanoid": "^5.0.7", "nanoid": "^5.0.7",
"nostr-tools": "^2.5.2", "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.4", "react-hook-form": "^7.52.0",
"react-hotkeys-hook": "^4.5.0", "react-i18next": "^14.1.2",
"react-i18next": "^14.1.1",
"react-string-replace": "^1.1.1", "react-string-replace": "^1.1.1",
"slate": "^0.103.0", "slate": "^0.103.0",
"slate-react": "^0.102.0", "slate-react": "^0.105.0",
"sonner": "^1.4.41", "use-debounce": "^10.0.1",
"use-debounce": "^10.0.0",
"virtua": "^0.31.0" "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.32.5", "@tanstack/router-devtools": "^1.43.3",
"@tanstack/router-vite-plugin": "^1.32.2", "@tanstack/router-vite-plugin": "^1.43.1",
"@types/react": "^18.3.2", "@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.39",
"tailwindcss": "^3.4.3", "tailwindcss": "^3.4.4",
"typescript": "^5.4.5", "typescript": "^5.5.2",
"vite": "^5.2.11", "vite": "^5.3.2",
"vite-plugin-top-level-await": "^1.4.1", "vite-plugin-top-level-await": "^1.4.1",
"vite-tsconfig-paths": "^4.3.2" "vite-tsconfig-paths": "^4.3.2"
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 951 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 KiB

-12
View File
@@ -2,18 +2,6 @@
@tailwind utilities; @tailwind utilities;
@tailwind components; @tailwind components;
*::-webkit-scrollbar {
@apply w-[5px];
}
*::-webkit-scrollbar-track {
@apply bg-transparent;
}
*::-webkit-scrollbar-thumb {
@apply rounded bg-black dark:bg-white;
}
@layer utilities { @layer utilities {
.content-break { .content-break {
word-break: break-word; word-break: break-word;
+7 -30
View File
@@ -1,42 +1,19 @@
import { Ark } from "@lume/ark"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
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 { type } from "@tauri-apps/plugin-os";
import React, { StrictMode } from "react"; import { StrictMode } from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { I18nextProvider } from "react-i18next";
import "./app.css";
import i18n from "./locale";
import { routeTree } from "./router.gen"; // auto generated file import { routeTree } from "./router.gen"; // auto generated file
import "./app.css";
const ark = new Ark();
const queryClient = new QueryClient(); const queryClient = new QueryClient();
const platformName = await platform(); const platform = type();
const persister = createSyncStoragePersister({
storage: window.localStorage,
});
// Set up a Router instance
const router = createRouter({ const router = createRouter({
routeTree, routeTree,
context: { context: { queryClient, platform },
ark,
queryClient,
platform: platformName,
},
Wrap: ({ children }) => { Wrap: ({ children }) => {
return ( return (
<I18nextProvider i18n={i18n} defaultNS={"translation"}> <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
<PersistQueryClientProvider
client={queryClient}
persistOptions={{ persister }}
>
{children}
</PersistQueryClientProvider>
</I18nextProvider>
); );
}, },
}); });
@@ -1,13 +1,13 @@
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 { message } from "@tauri-apps/plugin-dialog";
import { import {
type Dispatch, type Dispatch,
type ReactNode, type ReactNode,
type SetStateAction, type SetStateAction,
useState, useState,
} from "react"; } from "react";
import { toast } from "sonner";
export function AvatarUploader({ export function AvatarUploader({
setPicture, setPicture,
@@ -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) {
toast.error(String(e)); setLoading(false);
await message(String(e), { title: "Lume", kind: "error" });
} }
// stop loading
setLoading(false);
}; };
return ( return (
-42
View File
@@ -1,42 +0,0 @@
import { User } from "@/components/user";
import { getBitcoinDisplayValues } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router";
import { useEffect, useMemo, useState } from "react";
export function Balance({ account }: { account: string }) {
const { ark } = useRouteContext({ strict: false });
const [balance, setBalance] = useState(0);
const value = useMemo(() => getBitcoinDisplayValues(balance), [balance]);
useEffect(() => {
async function getBalance() {
const val = await ark.get_balance();
setBalance(val);
}
getBalance();
}, []);
return (
<div
data-tauri-drag-region
className="flex h-16 items-center justify-end px-3"
>
<div className="flex items-center gap-2">
<div className="text-end">
<div className="text-sm leading-tight text-neutral-700 dark:text-neutral-300">
Your balance
</div>
<div className="font-medium leading-tight">
{value.bitcoinFormatted}
</div>
</div>
<User.Provider pubkey={account}>
<User.Root>
<User.Avatar className="size-9 rounded-full" />
</User.Root>
</User.Provider>
</div>
</div>
);
}
+106 -45
View File
@@ -1,92 +1,107 @@
import { CancelIcon, CheckIcon } from "@lume/icons"; import { CheckIcon, HorizontalDotsIcon } from "@lume/icons";
import type { LumeColumn } from "@lume/types"; import type { LumeColumn } from "@lume/types";
import { cn } from "@lume/utils";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
import { getCurrent } from "@tauri-apps/api/webviewWindow"; import { getCurrent } from "@tauri-apps/api/webviewWindow";
import { useEffect, useRef, useState } from "react"; import { memo, useCallback, useEffect, useRef, useState } from "react";
export function Column({ type WindowEvent = {
scroll: boolean;
resize: boolean;
};
export const Column = memo(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 webviewLabel = `column-${account}_${column.label}`; const webviewLabel = `column-${account}_${column.label}`;
const [isCreated, setIsCreated] = useState(false); const [isCreated, setIsCreated] = useState(false);
const repositionWebview = async () => { const repositionWebview = useCallback(async () => {
const newRect = container.current.getBoundingClientRect(); const newRect = container.current.getBoundingClientRect();
await invoke("reposition_column", { await invoke("reposition_column", {
label: webviewLabel, label: webviewLabel,
x: newRect.x, x: newRect.x,
y: newRect.y, y: newRect.y,
}); });
}; }, []);
const resizeWebview = async () => { const resizeWebview = useCallback(async () => {
const newRect = container.current.getBoundingClientRect(); const newRect = container.current.getBoundingClientRect();
await invoke("resize_column", { await invoke("resize_column", {
label: webviewLabel, label: webviewLabel,
width: newRect.width, width: newRect.width,
height: newRect.height, height: newRect.height,
}); });
}; }, []);
useEffect(() => { useEffect(() => {
if (isCreated) 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 && isCreated) repositionWebview(); if (!container?.current) return;
}, [isScroll]);
useEffect(() => {
const rect = container.current.getBoundingClientRect(); const rect = container.current.getBoundingClientRect();
const url = `${column.content}?account=${account}&label=${column.label}&name=${column.name}`; const url = `${column.content}?account=${account}&label=${column.label}&name=${column.name}`;
// create new webview const prop = {
invoke("create_column", {
label: webviewLabel, label: webviewLabel,
x: rect.x, x: rect.x,
y: rect.y, y: rect.y,
width: rect.width, width: rect.width,
height: rect.height, height: rect.height,
url, url,
}).then(() => setIsCreated(true)); };
// create new webview
invoke("create_column", { column: prop }).then(() => {
console.log("created: ", webviewLabel);
setIsCreated(true);
});
// close webview when unmounted // close webview when unmounted
return () => { return () => {
invoke("close_column", { label: webviewLabel }); invoke("close_column", { label: webviewLabel }).then(() => {
console.log("closed: ", webviewLabel);
});
}; };
}, [account]); }, [account]);
return ( return (
<div className="h-full w-[440px] shrink-0 p-2"> <div className="h-full w-[480px] shrink-0 p-2">
<div <div className="flex flex-col w-full h-full rounded-xl bg-black/5 dark:bg-white/10">
className={cn( <Header
"flex flex-col w-full h-full rounded-xl", label={column.label}
column.label !== "open" webview={webviewLabel}
? "bg-black/5 dark:bg-white/5 backdrop-blur-sm" name={column.name}
: "", />
)}
>
{column.label !== "open" ? (
<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>
); );
} });
function Header({ label, name }: { label: string; name: string }) { function Header({
label,
webview,
name,
}: { label: string; webview: string; name: string }) {
const [title, setTitle] = useState(name); const [title, setTitle] = useState(name);
const [isChanged, setIsChanged] = useState(false); const [isChanged, setIsChanged] = useState(false);
@@ -102,20 +117,66 @@ function Header({ label, name }: { label: string; name: string }) {
setIsChanged(false); setIsChanged(false);
}; };
const close = async () => { const showContextMenu = useCallback(async (e: React.MouseEvent) => {
const mainWindow = getCurrent(); e.preventDefault();
await mainWindow.emit("columns", { type: "remove", label });
}; const menuItems = await Promise.all([
MenuItem.new({
text: "Reload",
action: async () => {
await invoke("reload_column", { label: webview });
},
}),
MenuItem.new({
text: "Open in new window",
action: () => console.log("not implemented."),
}),
PredefinedMenuItem.new({ item: "Separator" }),
MenuItem.new({
text: "Move left",
action: async () => {
await getCurrent().emit("columns", {
type: "move",
label,
direction: "left",
});
},
}),
MenuItem.new({
text: "Move right",
action: async () => {
await getCurrent().emit("columns", {
type: "move",
label,
direction: "right",
});
},
}),
PredefinedMenuItem.new({ item: "Separator" }),
MenuItem.new({
text: "Close",
action: async () => {
await getCurrent().emit("columns", { type: "remove", label });
},
}),
]);
const menu = await Menu.new({
items: menuItems,
});
await menu.popup().catch((e) => console.error(e));
}, []);
useEffect(() => { useEffect(() => {
if (title.length !== name.length) setIsChanged(true); if (title.length !== name.length) setIsChanged(true);
}, [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-7">
<div className="relative flex gap-2 items-center"> <div className="relative flex items-center gap-2">
<div <div
contentEditable contentEditable
suppressContentEditableWarning={true} suppressContentEditableWarning={true}
@@ -137,10 +198,10 @@ function Header({ label, name }: { label: string; name: string }) {
</div> </div>
<button <button
type="button" type="button"
onClick={() => close()} onClick={(e) => showContextMenu(e)}
className="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"
> >
<CancelIcon className="size-4" /> <HorizontalDotsIcon className="size-5" />
</button> </button>
</div> </div>
); );
+12 -13
View File
@@ -1,29 +1,28 @@
import { ThreadIcon } from "@lume/icons";
import type { Event } from "@lume/types";
import { Note } from "@/components/note"; import { Note } from "@/components/note";
import { ThreadIcon } from "@lume/icons";
import type { LumeEvent } from "@lume/system";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router"; import { memo, useMemo } from "react";
export function Conversation({ export const Conversation = memo(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.get_thread(event.tags);
return ( return (
<Note.Provider event={event}> <Note.Provider event={event}>
<Note.Root <Note.Root
className={cn( className={cn(
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl flex flex-col gap-3 shadow-primary dark:ring-1 ring-neutral-800/50", "bg-white dark:bg-black/20 rounded-xl flex flex-col gap-3 shadow-primary dark:ring-1 ring-neutral-800/50",
className, className,
)} )}
> >
<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,18 +30,18 @@ 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>
</Note.Provider> </Note.Provider>
); );
} });
@@ -1,26 +0,0 @@
import { cn } from "@lume/utils";
import { useNoteContext } from "./provider";
import { User } from "../user";
export function NoteActivity({ className }: { className?: string }) {
const event = useNoteContext();
const mentions = event.tags
.filter((tag) => tag[0] === "p")
.map((tag) => tag[1]);
return (
<div className={cn("-mt-3 mb-2", className)}>
<div className="text-neutral-700 dark:text-neutral-300 inline-flex items-baseline gap-2 w-full overflow-hidden">
<div className="shrink-0 text-sm font-medium">To:</div>
{mentions.splice(0, 4).map((mention) => (
<User.Provider key={mention} pubkey={mention}>
<User.Root>
<User.Name className="text-sm font-medium" prefix="@" />
</User.Root>
</User.Provider>
))}
{mentions.length > 4 ? "..." : ""}
</div>
</div>
);
}
@@ -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,11 +13,11 @@ 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
? "rounded-full bg-neutral-100 dark:bg-white/10 h-7 gap-1.5 w-24 text-sm font-medium hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20" ? "rounded-full h-7 gap-1.5 w-20 text-sm font-medium hover:bg-black/10 dark:hover:bg-white/10"
: "size-7", : "size-7",
)} )}
> >
@@ -1,101 +1,81 @@
import { QuoteIcon, RepostIcon } from "@lume/icons"; import { RepostIcon } from "@lume/icons";
import { cn } from "@lume/utils"; import { LumeWindow } from "@lume/system";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import * as Tooltip from "@radix-ui/react-tooltip";
import { useRouteContext } from "@tanstack/react-router";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Spinner } from "@lume/ui"; 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 { message } from "@tauri-apps/plugin-dialog";
import { useCallback, useState } from "react";
import { useNoteContext } from "../provider"; import { useNoteContext } from "../provider";
export function NoteRepost({ large = false }: { large?: boolean }) { export function NoteRepost({ large = false }: { large?: boolean }) {
const { ark } = useRouteContext({ strict: false });
const event = useNoteContext(); const event = useNoteContext();
const { settings } = useRouteContext({ strict: false });
const [t] = useTranslation();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [isRepost, setIsRepost] = useState(false); const [isRepost, setIsRepost] = useState(false);
const repost = async () => { const repost = async () => {
if (isRepost) return;
try { try {
if (isRepost) return;
setLoading(true); setLoading(true);
// repost // repost
await ark.repost(event.id, event.pubkey); await event.repost();
// update state // update state
setLoading(false); setLoading(false);
setIsRepost(true); setIsRepost(true);
} catch {
// notify
toast.success("You've reposted this post successfully");
} catch (e) {
setLoading(false); setLoading(false);
toast.error("Repost failed, try again later"); await message("Repost failed, try again later", {
title: "Lume",
kind: "info",
});
} }
}; };
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 ( return (
<DropdownMenu.Root> <button
<Tooltip.Provider> type="button"
<Tooltip.Root delayDuration={150}> onClick={(e) => showContextMenu(e)}
<DropdownMenu.Trigger asChild> className={cn(
<Tooltip.Trigger asChild> "inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
<button large
type="button" ? "rounded-full h-7 gap-1.5 w-24 text-sm font-medium hover:bg-black/10 dark:hover:bg-white/10"
className={cn( : "size-7",
"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" {loading ? (
: "size-7", <Spinner className="size-4" />
)} ) : (
> <RepostIcon className={cn("size-4", isRepost ? "text-blue-500" : "")} />
{loading ? ( )}
<Spinner className="size-4" /> {large ? "Repost" : null}
) : ( </button>
<RepostIcon
className={cn("size-4", isRepost ? "text-blue-500" : "")}
/>
)}
{large ? "Repost" : null}
</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">
{t("note.buttons.repost")}
<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-[200px] flex-col overflow-hidden rounded-xl bg-black p-1 shadow-md shadow-neutral-500/20 focus:outline-none dark:bg-white">
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => repost()}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
>
<RepostIcon className="size-4" />
{t("note.buttons.repost")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => ark.open_editor(event.id, true)}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
>
<QuoteIcon className="size-4" />
{t("note.buttons.quote")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Arrow className="fill-black dark:fill-white" />
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
); );
} }
@@ -1,37 +1,23 @@
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)}
className={cn( className={cn(
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200", "inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
large large
? "rounded-full bg-neutral-100 dark:bg-white/10 h-7 gap-1.5 w-24 text-sm font-medium hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20" ? "rounded-full h-7 gap-1.5 w-20 text-sm font-medium hover:bg-black/10 dark:hover:bg-white/10"
: "size-7", : "size-7",
)} )}
> >
+12 -11
View File
@@ -1,33 +1,34 @@
import { useEvent } from "@lume/ark"; import { useEvent } from "@lume/system";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { Note } from "."; import { Note } from ".";
import { InfoIcon } from "@lume/icons"; import { InfoIcon } from "@lume/icons";
import type { EventTag } from "@lume/types";
export function NoteChild({ export function NoteChild({
eventId, event,
isRoot, isRoot,
}: { }: {
eventId: string; event: EventTag;
isRoot?: boolean; isRoot?: boolean;
}) { }) {
const { isLoading, isError, data } = useEvent(eventId); const { isLoading, isError, data } = useEvent(event.id, event.relayHint);
if (isLoading) { if (isLoading) {
return ( return (
<div className="pt-3 px-3 flex items-center gap-2"> <div className="flex items-center gap-2 px-3 pt-3">
<div className="size-8 shrink-0 rounded-full bg-neutral-200 dark:bg-neutral-800 animate-pulse" /> <div className="rounded-full size-8 shrink-0 bg-neutral-200 dark:bg-neutral-800 animate-pulse" />
<div className="animate-pulse rounded-md h-4 w-2/3 bg-neutral-200 dark:bg-neutral-800" /> <div className="w-2/3 h-4 rounded-md animate-pulse bg-neutral-200 dark:bg-neutral-800" />
</div> </div>
); );
} }
if (isError || !data) { if (isError || !data) {
return ( return (
<div className="pt-3 px-3 flex items-center gap-2"> <div className="flex items-center gap-2 px-3 pt-3">
<div className="size-8 shrink-0 rounded-full bg-red-500 text-white inline-flex items-center justify-center"> <div className="inline-flex items-center justify-center text-white bg-red-500 rounded-full size-8 shrink-0">
<InfoIcon className="size-5" /> <InfoIcon className="size-5" />
</div> </div>
<p className="text-red-500 text-sm"> <p className="text-sm text-red-500">
Event not found with your current relay set Event not found with your current relay set
</p> </p>
</div> </div>
@@ -37,7 +38,7 @@ export function NoteChild({
return ( return (
<Note.Provider event={data}> <Note.Provider event={data}>
<Note.Root className={cn(isRoot ? "mb-3" : "")}> <Note.Root className={cn(isRoot ? "mb-3" : "")}>
<div className="h-14 px-3 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" />
+80 -55
View File
@@ -1,5 +1,7 @@
import { NOSTR_EVENTS, NOSTR_MENTIONS, cn, parser } from "@lume/utils"; import { cn } from "@lume/utils";
import { type ReactNode, useMemo } from "react"; import { useRouteContext } from "@tanstack/react-router";
import { nanoid } from "nanoid";
import { type ReactNode, useMemo, useState } from "react";
import reactStringReplace from "react-string-replace"; import reactStringReplace from "react-string-replace";
import { Hashtag } from "./mentions/hashtag"; import { Hashtag } from "./mentions/hashtag";
import { MentionNote } from "./mentions/note"; import { MentionNote } from "./mentions/note";
@@ -7,7 +9,6 @@ import { MentionUser } from "./mentions/user";
import { Images } from "./preview/images"; import { Images } from "./preview/images";
import { Videos } from "./preview/videos"; import { Videos } from "./preview/videos";
import { useNoteContext } from "./provider"; import { useNoteContext } from "./provider";
import { nanoid } from "nanoid";
export function NoteContent({ export function NoteContent({
quote = true, quote = true,
@@ -20,55 +21,48 @@ export function NoteContent({
clean?: boolean; clean?: boolean;
className?: string; className?: string;
}) { }) {
const { settings } = useRouteContext({ strict: false });
const event = useNoteContext(); const event = useNoteContext();
const data = useMemo(() => {
const { content, images, videos } = parser(event.content);
const words = content.split(/( |\n)/);
const hashtags = words.filter((word) => word.startsWith("#"));
const events = words.filter((word) =>
NOSTR_EVENTS.some((el) => word.startsWith(el)),
);
const mentions = words.filter((word) =>
NOSTR_MENTIONS.some((el) => word.startsWith(el)),
);
let richContent: ReactNode[] | string = content;
const warning = useMemo(() => event.warning, [event]);
const content = useMemo(() => {
try { try {
if (hashtags.length) { // Get parsed meta
for (const hashtag of hashtags) { const { content, hashtags, events, mentions } = event.meta;
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
richContent = reactStringReplace(richContent, regex, (_, index) => { // Define rich content
return <Hashtag key={hashtag + index} tag={hashtag} />; 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);
} }
} }
if (events.length) { for (const user of mentions) {
for (const event of events) { if (mention) {
if (quote) { richContent = reactStringReplace(richContent, user, (_, index) => (
richContent = reactStringReplace(richContent, event, (_, index) => ( <MentionUser key={user + index} pubkey={user} />
<MentionNote key={event + index} eventId={event} /> ));
));
}
if (!quote && clean) {
richContent = reactStringReplace(richContent, event, () => null);
}
} }
}
if (mentions.length) { if (!mention && clean) {
for (const user of mentions) { richContent = reactStringReplace(richContent, user, () => null);
if (mention) {
richContent = reactStringReplace(richContent, user, (_, index) => (
<MentionUser key={user + index} pubkey={user} />
));
}
if (!mention && clean) {
richContent = reactStringReplace(richContent, user, () => null);
}
} }
} }
@@ -81,7 +75,7 @@ export function NoteContent({
href={match} href={match}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="line-clamp-1 text-blue-500 hover:text-blue-600" className="inline text-blue-500 hover:text-blue-600"
> >
{match} {match}
</a> </a>
@@ -92,25 +86,56 @@ export function NoteContent({
<div key={nanoid()} className="h-3" /> <div key={nanoid()} className="h-3" />
)); ));
return { content: richContent, images, videos }; return richContent;
} catch (e) { } catch {
return { content, images, videos }; return event.content;
} }
}, []); }, [event.content]);
const [blurred, setBlurred] = useState(() => warning?.length > 0);
return ( return (
<div className="flex flex-col gap-2"> <div className="relative flex flex-col gap-2">
{blurred ? (
<div className="absolute inset-0 z-10 flex items-center justify-center w-full h-full bg-black/80 backdrop-blur-lg">
<div className="flex flex-col items-center justify-center gap-2 text-center">
<p className="text-sm text-white/60">
The content is hidden because the author
<br />
marked it with a warning for a reason:
</p>
<p className="text-sm font-medium text-white">{warning}</p>
<button
type="button"
onClick={() => setBlurred(false)}
className="inline-flex items-center justify-center px-2 mt-4 text-sm font-medium border rounded-lg text-white/70 h-9 w-max bg-white/20 hover:bg-white/30 border-white/5"
>
View anyway
</button>
</div>
</div>
) : null}
<div <div
className={cn( className={cn(
"select-text text-[15px] text-balance break-words overflow-hidden", "select-text text-pretty content-break overflow-hidden",
event.content.length > 500 ? "max-h-[300px] gradient-mask-b-0" : "", event.meta?.content.length > 500
? "max-h-[250px] gradient-mask-b-0"
: "",
className, className,
)} )}
> >
{data.content} {content}
</div> </div>
{data.images.length ? <Images urls={data.images} /> : null} {settings.display_media ? (
{data.videos.length ? <Videos urls={data.videos} /> : null} <>
{event.meta?.images.length ? (
<Images urls={event.meta.images} />
) : null}
{event.meta?.videos.length ? (
<Videos urls={event.meta.videos} />
) : null}
</>
) : null}
</div> </div>
); );
} }
@@ -1,13 +1,4 @@
import type { Settings } from "@lume/types"; import { cn } from "@lume/utils";
import {
AUDIOS,
IMAGES,
NOSTR_EVENTS,
NOSTR_MENTIONS,
VIDEOS,
cn,
} from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { type ReactNode, useMemo } from "react"; import { type ReactNode, useMemo } from "react";
import reactStringReplace from "react-string-replace"; import reactStringReplace from "react-string-replace";
@@ -19,135 +10,85 @@ import { VideoPreview } from "./preview/video";
import { useNoteContext } from "./provider"; import { useNoteContext } from "./provider";
export function NoteContentLarge({ export function NoteContentLarge({
compact = true,
className, className,
}: { }: {
compact?: boolean;
className?: string; className?: string;
}) { }) {
const { settings }: { settings: Settings } = useRouteContext({
strict: false,
});
const event = useNoteContext(); const event = useNoteContext();
const content = useMemo(() => { const content = useMemo(() => {
const text = event.content.trim();
const words = text.split(/( |\n)/);
// @ts-ignore, kaboom !!!
let parsedContent: ReactNode[] = compact
? text.replace(/\n\s*\n/g, "\n")
: text;
const hashtags = words.filter((word) => word.startsWith("#"));
const events = words.filter((word) =>
NOSTR_EVENTS.some((el) => word.startsWith(el)),
);
const mentions = words.filter((word) =>
NOSTR_MENTIONS.some((el) => word.startsWith(el)),
);
try { try {
if (hashtags.length) { // Get parsed meta
for (const hashtag of hashtags) { const { images, videos, hashtags, events, mentions } = event.meta;
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
parsedContent = reactStringReplace(parsedContent, regex, () => {
return <Hashtag key={nanoid()} tag={hashtag} />;
});
}
}
if (events.length) { // Define rich content
for (const event of events) { let richContent: ReactNode[] | string = event.content;
parsedContent = reactStringReplace(
parsedContent,
event,
(match, i) => <MentionNote key={match + i} eventId={event} />,
);
}
}
if (mentions.length) { for (const hashtag of hashtags) {
for (const mention of mentions) { const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
parsedContent = reactStringReplace( richContent = reactStringReplace(richContent, regex, () => (
parsedContent, <Hashtag key={nanoid()} tag={hashtag} />
mention,
(match, i) => <MentionUser key={match + i} pubkey={mention} />,
);
}
}
parsedContent = reactStringReplace(
parsedContent,
/(https?:\/\/\S+)/gi,
(match, i) => {
try {
const url = new URL(match);
const ext = url.pathname.split(".")[1];
if (!settings.enhancedPrivacy) {
if (IMAGES.includes(ext)) {
return <ImagePreview key={match + i} url={url.toString()} />;
}
if (VIDEOS.includes(ext)) {
return <VideoPreview key={match + i} url={url.toString()} />;
}
if (AUDIOS.includes(ext)) {
return <VideoPreview key={match + i} url={url.toString()} />;
}
}
return (
<a
key={match + i}
href={match}
target="_blank"
rel="noreferrer"
className="content-break w-full font-normal text-blue-500 hover:text-blue-600"
>
{match}
</a>
);
} catch {
return (
<a
key={match + i}
href={match}
target="_blank"
rel="noreferrer"
className="content-break w-full font-normal text-blue-500 hover:text-blue-600"
>
{match}
</a>
);
}
},
);
if (compact) {
parsedContent = reactStringReplace(parsedContent, /\n*\n/g, () => (
<div key={nanoid()} className="h-1.5" />
)); ));
} }
parsedContent = reactStringReplace( for (const event of events) {
parsedContent, richContent = reactStringReplace(richContent, event, (match, i) => (
/[\r]?\n[\r]?\n/g, <MentionNote key={match + i} eventId={event} />
(_, index) => <br key={event.id + "_br_" + index} />, ));
}
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>
),
); );
return parsedContent; richContent = reactStringReplace(richContent, /(\r\n|\r|\n)+/g, () => (
<div key={nanoid()} className="h-3" />
));
return richContent;
} catch (e) { } catch (e) {
return text; console.log("[parser]: ", e);
return event.content;
} }
}, []); }, [event.content]);
return ( return (
<div className={cn("select-text", className)}> <div
<div className="text-[15px] text-balance content-break leading-normal"> className={cn(
{content} "select-text leading-normal text-pretty content-break",
</div> className,
)}
>
{content}
</div> </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";
@@ -23,5 +22,4 @@ export const Note = {
Zap: NoteZap, Zap: NoteZap,
Open: NoteOpenThread, Open: NoteOpenThread,
Child: NoteChild, Child: NoteChild,
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>
); );
} }
@@ -1,9 +1,6 @@
import { useEvent } from "@lume/ark";
import { LinkIcon } from "@lume/icons";
import { useRouteContext } from "@tanstack/react-router";
import { useTranslation } from "react-i18next";
import { cn } from "@lume/utils";
import { User } from "@/components/user"; import { User } from "@/components/user";
import { LinkIcon } from "@lume/icons";
import { LumeWindow, useEvent } from "@lume/system";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
export function MentionNote({ export function MentionNote({
@@ -13,66 +10,67 @@ export function MentionNote({
eventId: string; eventId: string;
openable?: boolean; openable?: boolean;
}) { }) {
const { ark } = useRouteContext({ strict: false });
const { t } = useTranslation();
const { isLoading, isError, data } = useEvent(eventId); const { isLoading, isError, data } = useEvent(eventId);
if (isLoading) { if (isLoading) {
return ( return (
<div className="mt-2 w-full flex h-20 items-center justify-center rounded-xl border border-black/10 dark:border-white/10"> <div className="py-2">
<Spinner className="size-5" /> <div className="pl-4 py-3 flex flex-col w-full border-l-2 border-black/5 dark:border-white/5">
<Spinner className="size-5" />
</div>
</div> </div>
); );
} }
if (isError || !data) { if (isError || !data) {
return ( return (
<div className="mt-2 w-full rounded-xl border border-black/10 p-3 dark:border-white/10"> <div className="py-2">
{t("note.error")} <div className="pl-4 py-3 flex flex-col w-full border-l-2 border-black/5 dark:border-white/5">
<p className="text-sm font-medium text-red-500">
Event not found with your current relay set
</p>
</div>
</div> </div>
); );
} }
return ( return (
<div className="mt-2 flex w-full cursor-default flex-col rounded-xl border border-black/10 dark:border-white/10"> <div className="py-2">
<User.Provider pubkey={data.pubkey}> <div className="pl-4 py-3 flex flex-col w-full border-l-2 border-black/5 dark:border-white/5">
<User.Root className="flex h-12 items-center gap-2 px-3"> <User.Provider pubkey={data.pubkey}>
<User.Avatar className="size-6 shrink-0 rounded-full object-cover" /> <User.Root className="flex items-center gap-2 h-8">
<div className="inline-flex flex-1 items-center gap-2"> <User.Avatar className="rounded-full size-6" />
<User.Name className="font-semibold text-neutral-900 dark:text-neutral-100" /> <div className="inline-flex items-center flex-1 gap-2">
<span className="text-neutral-600 dark:text-neutral-400">·</span> <User.Name className="font-semibold text-neutral-900 dark:text-neutral-100" />
<User.Time <span className="text-neutral-600 dark:text-neutral-400">·</span>
time={data.created_at} <User.Time
className="text-neutral-600 dark:text-neutral-400" time={data.created_at}
/> className="text-neutral-600 dark:text-neutral-400"
</div> />
</User.Root> </div>
</User.Provider> </User.Root>
<div </User.Provider>
className={cn( <div className="select-text text-pretty line-clamp-3 content-break leading-normal">
"px-3 select-text content-break whitespace-normal text-balance leading-normal", {data.content}
data.content.length > 100 ? "max-h-[150px] gradient-mask-b-0" : "",
)}
>
{data.content}
</div>
{openable ? (
<div className="flex h-14 items-center justify-end px-3">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
ark.open_event_id(data.id);
}}
className="z-10 h-7 w-28 inline-flex items-center justify-center gap-1 text-sm bg-neutral-100 dark:bg-white/10 rounded-full text-neutral-600 hover:text-blue-500 dark:text-neutral-400"
>
View post
<LinkIcon className="size-4" />
</button>
</div> </div>
) : ( {openable ? (
<div className="h-3" /> <div className="flex items-center justify-start mt-3">
)} <button
type="button"
onClick={(e) => {
e.stopPropagation();
LumeWindow.openEvent(data);
}}
className="inline-flex items-center gap-1 text-blue-500 text-sm"
>
View post
<LinkIcon className="size-3" />
</button>
</div>
) : (
<div className="h-3" />
)}
</div>
</div> </div>
); );
} }
@@ -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>
); );
} }
+48 -94
View File
@@ -1,108 +1,62 @@
import { HorizontalDotsIcon } from "@lume/icons"; import { HorizontalDotsIcon } from "@lume/icons";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { useRouteContext } from "@tanstack/react-router";
import { writeText } from "@tauri-apps/plugin-clipboard-manager"; import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useNoteContext } from "./provider"; import { useNoteContext } from "./provider";
import { useCallback } from "react";
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
export function NoteMenu() { export function NoteMenu() {
const event = useNoteContext(); const event = useNoteContext();
const { ark } = useRouteContext({ strict: false }); const showContextMenu = useCallback(async (e: React.MouseEvent) => {
const { t } = useTranslation(); e.preventDefault();
const copyID = async () => { const menuItems = await Promise.all([
await writeText(await ark.event_to_bech32(event.id, [""])); MenuItem.new({
toast.success("Copied"); 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 copyRaw = async () => { const menu = await Menu.new({
await writeText(JSON.stringify(event)); items: menuItems,
toast.success("Copied"); });
};
const copyNpub = async () => { await menu.popup().catch((e) => console.error(e));
await writeText(await ark.user_to_bech32(event.pubkey, [""])); }, []);
toast.success("Copied");
};
const copyLink = async () => {
await writeText(
`https://njump.me/${await ark.event_to_bech32(event.id, [""])}`,
);
toast.success("Copied");
};
return ( return (
<DropdownMenu.Root> <button
<DropdownMenu.Trigger asChild> type="button"
<button onClick={(e) => showContextMenu(e)}
type="button" className="inline-flex items-center justify-center group size-7 text-neutral-600 dark:text-neutral-400"
className="group inline-flex size-7 items-center justify-center text-neutral-600 dark:text-neutral-400" >
> <HorizontalDotsIcon className="size-5" />
<HorizontalDotsIcon className="size-5" /> </button>
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content className="flex w-[200px] flex-col overflow-hidden rounded-xl bg-black p-1 shadow-md shadow-neutral-500/20 focus:outline-none dark:bg-white">
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => ark.open_event(event)}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
>
{t("note.menu.viewThread")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => copyLink()}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
>
{t("note.menu.copyLink")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => copyID()}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
>
{t("note.menu.copyNoteId")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => copyNpub()}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
>
{t("note.menu.copyAuthorId")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
onClick={() => ark.open_profile(event.pubkey)}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
>
{t("note.menu.viewAuthor")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Separator className="my-1 h-px bg-neutral-900 dark:bg-neutral-100" />
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => copyRaw()}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
>
{t("note.menu.copyRaw")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Arrow className="fill-black dark:fill-white" />
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
); );
} }
@@ -1,61 +1,48 @@
import { CheckCircleIcon, DownloadIcon } from "@lume/icons"; import { useRouteContext } from "@tanstack/react-router";
import { downloadDir } from "@tauri-apps/api/path"; import { open } from "@tauri-apps/plugin-shell";
import { WebviewWindow } from "@tauri-apps/api/webviewWindow"; import { useMemo } from "react";
import { download } from "@tauri-apps/plugin-upload";
import { type SyntheticEvent, useState } from "react";
export function ImagePreview({ url }: { url: string }) { export function ImagePreview({ url }: { url: string }) {
const [downloaded, setDownloaded] = useState(false); const { settings } = useRouteContext({ strict: false });
const downloadImage = async (e: { stopPropagation: () => void }) => { const imageUrl = useMemo(() => {
try { if (settings.image_resize_service.length) {
e.stopPropagation(); const newUrl = `${settings.image_resize_service}?url=${url}&ll&af&default=1&n=-1`;
return newUrl;
const downloadDirPath = await downloadDir(); } else {
const filename = url.substring(url.lastIndexOf("/") + 1); return url;
await download(url, `${downloadDirPath}/${filename}`);
setDownloaded(true);
} catch (e) {
console.error(e);
} }
}; }, [settings.image_resize_service]);
const open = async () => { if (!settings.display_media) {
const name = new URL(url).pathname.split("/").pop(); return (
return new WebviewWindow("image-viewer", { <a
url, href={url}
title: name, target="_blank"
}); rel="noreferrer"
}; className="inline text-blue-500 hover:text-blue-600"
>
const fallback = (event: SyntheticEvent<HTMLImageElement, Event>) => { {url}
event.currentTarget.src = "/fallback-image.jpg"; </a>
}; );
}
return ( return (
// biome-ignore lint/a11y/useKeyWithClickEvents: <explanation> <div className="my-1">
<div onClick={() => open()} className="group relative my-1">
<img <img
src={url} src={imageUrl}
alt={url} alt={url}
loading="lazy" loading="lazy"
decoding="async" decoding="async"
style={{ contentVisibility: "auto" }} style={{ contentVisibility: "auto" }}
onError={fallback} className="max-h-[400px] max-w-[400px] h-auto w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
className="max-h-[600px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15" onClick={() => open(url)}
onKeyDown={() => open(url)}
onError={({ currentTarget }) => {
currentTarget.onerror = null;
currentTarget.src = "/404.jpg";
}}
/> />
<button
type="button"
onClick={(e) => downloadImage(e)}
className="absolute right-2 top-2 z-20 hidden size-8 items-center justify-center rounded-md bg-white/10 text-white/70 backdrop-blur-2xl hover:bg-blue-500 hover:text-white group-hover:inline-flex"
>
{downloaded ? (
<CheckCircleIcon className="size-4" />
) : (
<DownloadIcon className="size-4" />
)}
</button>
</div> </div>
); );
} }
@@ -1,62 +1,176 @@
import { WebviewWindow } from "@tauri-apps/api/webviewWindow"; import { ArrowLeftIcon, ArrowRightIcon } from "@lume/icons";
import { Carousel, CarouselItem } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { cn } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router";
import { open } from "@tauri-apps/plugin-shell";
import useEmblaCarousel from "embla-carousel-react";
import { useCallback, useEffect, useMemo, useState } from "react";
export function Images({ urls }: { urls: string[] }) { export function Images({ urls }: { urls: string[] }) {
const open = async (url: string) => { const { settings } = useRouteContext({ strict: false });
const name = new URL(url).pathname
.split("/")
.pop()
.replace(/[^a-zA-Z ]/g, "");
const label = `viewer-${name}`;
const window = WebviewWindow.getByLabel(label);
if (!window) { const [slidesInView, setSlidesInView] = useState([]);
const newWindow = new WebviewWindow(label, { const [emblaRef, emblaApi] = useEmblaCarousel({
url, dragFree: true,
title: "Image Viewer", align: "start",
width: 800, watchSlides: false,
height: 800, });
titleBarStyle: "overlay",
});
return newWindow; const imageUrls = useMemo(() => {
if (settings.image_resize_service.length) {
let newUrls: string[];
if (urls.length === 1) {
newUrls = urls.map(
(url) =>
`${settings.image_resize_service}?url=${url}&ll&af&default=1&n=-1`,
);
} else {
newUrls = urls.map(
(url) =>
`${settings.image_resize_service}?url=${url}&w=480&h=640&ll&af&default=1&n=-1`,
);
}
return newUrls;
} else {
return urls;
}
}, [settings.image_resize_service]);
const scrollPrev = useCallback(() => {
if (emblaApi) emblaApi.scrollPrev();
}, [emblaApi]);
const scrollNext = useCallback(() => {
if (emblaApi) emblaApi.scrollNext();
}, [emblaApi]);
const updateSlidesInView = useCallback((emblaApi) => {
setSlidesInView((slidesInView) => {
if (slidesInView.length === emblaApi.slideNodes().length) {
emblaApi.off("slidesInView", updateSlidesInView);
}
const inView = emblaApi
.slidesInView()
.filter((index) => !slidesInView.includes(index));
return slidesInView.concat(inView);
});
}, []);
useEffect(() => {
if (emblaApi && urls.length > 1) {
updateSlidesInView(emblaApi);
emblaApi.on("slidesInView", updateSlidesInView);
emblaApi.on("reInit", updateSlidesInView);
} }
return await window.setFocus(); return () => {
}; emblaApi?.off("slidesInView", updateSlidesInView);
emblaApi?.off("reInit", updateSlidesInView);
};
}, [emblaApi, updateSlidesInView]);
if (urls.length === 1) { if (urls.length === 1) {
return ( return (
<div className="group px-3"> <div className="px-3 group">
<img <img
src={urls[0]} src={imageUrls[0]}
alt={urls[0]} alt={urls[0]}
loading="lazy" loading="lazy"
decoding="async" decoding="async"
style={{ contentVisibility: "auto" }} style={{ contentVisibility: "auto" }}
className="max-h-[400px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15" className="max-h-[400px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
onClick={() => open(urls[0])} onClick={() => urls[0]}
onKeyDown={() => urls[0]}
onError={({ currentTarget }) => {
currentTarget.onerror = null;
currentTarget.src = "/404.jpg";
}}
/> />
</div> </div>
); );
} }
return ( return (
<Carousel <div className="relative px-3 overflow-hidden group">
items={urls} <div ref={emblaRef} className="w-full h-[320px]">
renderItem={({ item, isSnapPoint }) => ( <div className="flex w-full gap-2 scrollbar-none">
<CarouselItem key={item} isSnapPoint={isSnapPoint}> {imageUrls.map((url, index) => (
<img <LazyImage
src={item} /* biome-ignore lint/suspicious/noArrayIndexKey: url can be duplicated */
alt={item} key={url + index}
loading="lazy" url={url}
decoding="async" inView={slidesInView.indexOf(index) > -1}
style={{ contentVisibility: "auto" }} />
className="w-full h-full object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15" ))}
onClick={() => open(item)} </div>
/> </div>
</CarouselItem> <div className="absolute z-10 items-center justify-between hidden w-full px-5 transform -translate-x-1/2 -translate-y-1/2 group-hover:flex left-1/2 top-1/2">
)} <button
/> type="button"
disabled={!emblaApi?.canScrollPrev}
className={cn(
"size-11 rounded-full bg-black/30 backdrop-blur-sm flex items-center justify-center text-white",
!emblaApi?.canScrollPrev ? "opacity-50" : "",
)}
onClick={() => scrollPrev()}
>
<ArrowLeftIcon className="size-6" />
</button>
<button
type="button"
disabled={!emblaApi?.canScrollNext}
className={cn(
"size-11 rounded-full bg-black/30 backdrop-blur-sm flex items-center justify-center text-white",
!emblaApi?.canScrollNext ? "opacity-50" : "",
)}
onClick={() => scrollNext()}
>
<ArrowRightIcon className="size-6" />
</button>
</div>
</div>
);
}
function LazyImage({ url, inView }: { url: string; inView: boolean }) {
const [hasLoaded, setHasLoaded] = useState(false);
const setLoaded = useCallback(() => {
if (inView) setHasLoaded(true);
}, [inView, setHasLoaded]);
return (
<div className="w-[240px] h-[320px] shrink-0 relative rounded-lg overflow-hidden">
{!hasLoaded ? (
<div className="flex items-center justify-center size-full bg-black/5 dark:bg-white/5">
<Spinner className="size-4" />
</div>
) : null}
<img
src={
inView
? url
: "data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs="
}
data-src={url}
alt={url}
loading="lazy"
decoding="async"
style={{ contentVisibility: "auto" }}
className="object-cover w-full h-full rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
onClick={() => open(url)}
onKeyDown={() => open(url)}
onLoad={setLoaded}
onError={({ currentTarget }) => {
currentTarget.onerror = null;
currentTarget.src = "/404.jpg";
}}
/>
</div>
); );
} }
@@ -1,87 +0,0 @@
import { useOpenGraph } from "@lume/utils";
function isImage(url: string) {
return /^https?:\/\/.+\.(jpg|jpeg|png|webp|avif)$/.test(url);
}
export function LinkPreview({ url }: { url: string }) {
const domain = new URL(url);
const { isLoading, isError, data } = useOpenGraph(url);
if (isLoading) {
return (
<div className="my-1.5 flex w-full flex-col overflow-hidden rounded-2xl border border-black/10 p-3 dark:border-white/10">
<div className="h-48 w-full shrink-0 animate-pulse bg-neutral-300 dark:bg-neutral-700" />
<div className="flex flex-col gap-2 px-3 py-3">
<div className="h-3 w-2/3 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
<div className="h-3 w-3/4 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
<span className="mt-2.5 text-sm leading-none text-neutral-600 dark:text-neutral-400">
{domain.hostname}
</span>
</div>
</div>
);
}
if (!data.title && !data.image && !data.description) {
return (
<a
href={url}
target="_blank"
rel="noreferrer"
className="inline-block text-blue-500 hover:text-blue-600"
>
{url}
</a>
);
}
if (isError) {
return (
<a
href={url}
target="_blank"
rel="noreferrer"
className="inline-block text-blue-500 hover:text-blue-600"
>
{url}
</a>
);
}
return (
<a
href={url}
target="_blank"
rel="noreferrer"
className="my-1 flex w-full flex-col overflow-hidden rounded-2xl border border-black/10 dark:border-white/10"
>
{isImage(data.image) ? (
<img
src={data.image}
alt={url}
loading="lazy"
decoding="async"
className="h-48 w-full shrink-0 rounded-t-lg bg-white object-cover"
/>
) : null}
<div className="flex flex-col items-start p-3">
<div className="flex flex-col items-start text-left">
{data.title ? (
<div className="content-break line-clamp-1 text-base font-semibold text-neutral-900 dark:text-neutral-100">
{data.title}
</div>
) : null}
{data.description ? (
<div className="content-break mb-2 line-clamp-3 text-balance text-sm text-neutral-700 dark:text-neutral-400">
{data.description}
</div>
) : null}
</div>
<div className="break-all text-sm font-semibold text-blue-500">
{domain.hostname}
</div>
</div>
</a>
);
}
@@ -1,12 +1,30 @@
import { useRouteContext } from "@tanstack/react-router";
export function VideoPreview({ url }: { url: string }) { 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 ( return (
<div className="my-1 overflow-hidden rounded-xl"> <div className="my-1">
<video <video
className="h-auto w-full bg-neutral-100 text-sm dark:bg-neutral-900" className="max-h-[600px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
preload="metadata"
controls controls
muted muted
> >
<source src={url} type="video/mp4" /> <source src={`${url}#t=0.1`} type="video/mp4" />
Your browser does not support the video tag. Your browser does not support the video tag.
</video> </video>
</div> </div>
@@ -1,36 +1,18 @@
import { Carousel, CarouselItem } from "@lume/ui";
export function Videos({ urls }: { urls: string[] }) { export function Videos({ urls }: { urls: string[] }) {
if (urls.length === 1) { return (
return ( <div className="group px-3">
<div className="group px-3"> {urls.map((url) => (
<video <video
className="w-full h-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15" key={url}
className="max-h-[400px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
preload="metadata"
controls controls
muted muted
> >
<source src={urls[0]} type="video/mp4" /> <source src={`${urls[0]}#t=0.1`} type="video/mp4" />
Your browser does not support the video tag. Your browser does not support the video tag.
</video> </video>
</div> ))}
); </div>
}
return (
<Carousel
items={urls}
renderItem={({ item, isSnapPoint }) => (
<CarouselItem key={item} isSnapPoint={isSnapPoint}>
<video
className="w-full h-full object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
controls={false}
muted
>
<source src={item} type="video/mp4" />
Your browser does not support the video tag.
</video>
</CarouselItem>
)}
/>
); );
} }
@@ -1,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");
} }
+48 -48
View File
@@ -1,62 +1,62 @@
import { LumeWindow } from "@lume/system";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import * as HoverCard from "@radix-ui/react-hover-card"; import { Menu, MenuItem } from "@tauri-apps/api/menu";
import { useRouteContext } from "@tanstack/react-router"; import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { useCallback } from "react";
import { User } from "../user"; import { User } from "../user";
import { useNoteContext } from "./provider"; import { useNoteContext } from "./provider";
export function NoteUser({ className }: { className?: string }) { export function NoteUser({ className }: { className?: string }) {
const { ark } = useRouteContext({ strict: false });
const event = useNoteContext(); const event = useNoteContext();
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
e.preventDefault();
const menuItems = await Promise.all([
MenuItem.new({
text: "View Profile",
action: () => LumeWindow.openProfile(event.pubkey),
}),
MenuItem.new({
text: "Copy Public Key",
action: async () => {
const pubkey = await event.pubkeyAsBech32();
await writeText(pubkey);
},
}),
]);
const menu = await Menu.new({
items: menuItems,
});
await menu.popup().catch((e) => console.error(e));
}, []);
return ( return (
<User.Provider pubkey={event.pubkey}> <User.Provider pubkey={event.pubkey}>
<HoverCard.Root> <User.Root className={cn("flex items-start justify-between", className)}>
<User.Root <div className="flex w-full gap-2">
className={cn("flex items-start justify-between", className)} <button
> type="button"
<div className="flex w-full gap-2"> onClick={(e) => showContextMenu(e)}
<HoverCard.Trigger className="shrink-0"> className="shrink-0"
<User.Avatar className="size-8 rounded-full object-cover outline outline-1 -outline-offset-1 outline-black/15" />
</HoverCard.Trigger>
<div className="flex w-full items-center gap-3">
<div className="flex items-center gap-1">
<User.Name className="font-semibold text-neutral-950 dark:text-neutral-50" />
<User.NIP05 />
</div>
<div className="text-neutral-600 dark:text-neutral-400">·</div>
<User.Time
time={event.created_at}
className="text-neutral-600 dark:text-neutral-400"
/>
</div>
</div>
</User.Root>
<HoverCard.Portal>
<HoverCard.Content
className="w-[300px] rounded-xl bg-black p-3 data-[side=bottom]:animate-slideUpAndFade data-[state=open]:transition-all dark:bg-white dark:shadow-none"
sideOffset={5}
side="right"
> >
<div className="flex flex-col gap-2"> <User.Avatar className="rounded-full size-8" />
<User.Avatar className="size-11 rounded-lg object-cover" /> </button>
<div className="flex flex-col gap-2"> <div className="flex items-center w-full gap-3">
<div className="inline-flex items-center gap-1"> <div className="flex items-center gap-1">
<User.Name className="font-semibold leading-tight text-white dark:text-neutral-900" /> <User.Name className="font-semibold text-neutral-950 dark:text-neutral-50" />
<User.NIP05 /> <User.NIP05 />
</div>
<User.About className="line-clamp-3 text-sm text-white dark:text-neutral-900" />
<button
onClick={() => ark.open_profile(event.pubkey)}
className="mt-2 inline-flex h-9 w-full items-center justify-center rounded-lg bg-white text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-100 dark:text-neutral-900 dark:hover:bg-neutral-200"
>
View profile
</button>
</div>
</div> </div>
<HoverCard.Arrow className="fill-black dark:fill-white" /> <div className="text-neutral-600 dark:text-neutral-400">·</div>
</HoverCard.Content> <User.Time
</HoverCard.Portal> time={event.created_at}
</HoverCard.Root> className="text-neutral-600 dark:text-neutral-400"
/>
</div>
</div>
</User.Root>
</User.Provider> </User.Provider>
); );
} }
@@ -1,32 +0,0 @@
import type { Event } from "@lume/types";
import { Note } from "@/components/note";
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>
);
}
+10 -13
View File
@@ -1,29 +1,26 @@
import { QuoteIcon } from "@lume/icons";
import type { Event } from "@lume/types";
import { Note } from "@/components/note"; import { Note } from "@/components/note";
import { QuoteIcon } from "@lume/icons";
import type { LumeEvent } from "@lume/system";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { memo } from "react";
export function Quote({ export const Quote = memo(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
className={cn( className={cn(
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl flex flex-col gap-3 shadow-primary dark:ring-1 ring-neutral-800/50", "bg-white dark:bg-black/20 rounded-xl flex flex-col gap-3 shadow-primary dark:ring-1 ring-neutral-800/50",
className, className,
)} )}
> >
<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,16 +29,16 @@ 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>
</Note.Provider> </Note.Provider>
); );
} });
+39 -42
View File
@@ -1,85 +1,82 @@
import type { Event } from "@lume/types";
import { Spinner } from "@lume/ui";
import { Note } from "@/components/note"; import { Note } from "@/components/note";
import { User } from "@/components/user"; import { User } from "@/components/user";
import { type LumeEvent, NostrQuery } from "@lume/system";
import { Spinner } from "@lume/ui";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useRouteContext } from "@tanstack/react-router"; import { memo } from "react";
export function RepostNote({ export const RepostNote = memo(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 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="rounded-full size-6" />
</User.Root>
</User.Provider>
</div>
</div> </div>
</Note.Root> </Note.Root>
</Note.Provider> </Note.Provider>
)} )}
</Note.Root> </Note.Root>
); );
} });
+9 -8
View File
@@ -1,28 +1,29 @@
import type { Event } from "@lume/types";
import { cn } from "@lume/utils";
import { Note } from "@/components/note"; import { Note } from "@/components/note";
import type { LumeEvent } from "@lume/system";
import { cn } from "@lume/utils";
import { memo } from "react";
export function TextNote({ export const TextNote = memo(function TextNote({
event, event,
className, className,
}: { }: {
event: Event; event: LumeEvent;
className?: string; className?: string;
}) { }) {
return ( return (
<Note.Provider event={event}> <Note.Provider event={event}>
<Note.Root <Note.Root
className={cn( className={cn(
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50", "bg-white dark:bg-black/20 rounded-xl shadow-primary dark:ring-1 dark:ring-white/5",
className, className,
)} )}
> >
<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 />
@@ -31,4 +32,4 @@ export function TextNote({
</Note.Root> </Note.Root>
</Note.Provider> </Note.Provider>
); );
} });
+50 -11
View File
@@ -1,35 +1,74 @@
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import * as Avatar from "@radix-ui/react-avatar"; import * as Avatar from "@radix-ui/react-avatar";
import { useRouteContext } from "@tanstack/react-router";
import { minidenticon } from "minidenticons"; import { minidenticon } from "minidenticons";
import { nanoid } from "nanoid";
import { useMemo } from "react"; import { useMemo } from "react";
import { useUserContext } from "./provider"; import { useUserContext } from "./provider";
export function UserAvatar({ className }: { className?: string }) { export function UserAvatar({ className }: { className?: string }) {
const user = useUserContext(); const user = useUserContext();
const { settings } = useRouteContext({ strict: false });
const fallbackAvatar = useMemo( 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 fallback = useMemo(
() => () =>
`data:image/svg+xml;utf8,${encodeURIComponent( `data:image/svg+xml;utf8,${encodeURIComponent(
minidenticon(user.pubkey || nanoid(), 90, 50), minidenticon(user.pubkey, 60, 50),
)}`, )}`,
[user], [user.pubkey],
); );
if (settings && !settings.display_avatar) {
return (
<Avatar.Root
className={cn(
"shrink-0 block overflow-hidden bg-neutral-200 dark:bg-neutral-800",
className,
)}
>
<Avatar.Fallback delayMs={120}>
<img
src={fallback}
alt={user.pubkey}
loading="lazy"
decoding="async"
className="size-full bg-black dark:bg-white outline-[.5px] outline-black/5 content-visibility-auto contain-intrinsic-size-[auto]"
/>
</Avatar.Fallback>
</Avatar.Root>
);
}
return ( return (
<Avatar.Root className="shrink-0"> <Avatar.Root
className={cn(
"shrink-0 block overflow-hidden bg-neutral-200 dark:bg-neutral-800",
className,
)}
>
<Avatar.Image <Avatar.Image
src={user.profile?.picture} src={picture}
alt={user.pubkey} alt={user.pubkey}
loading="eager" loading="lazy"
decoding="async" decoding="async"
className={cn("outline-[.5px] outline-black/5", className)} className="w-full aspect-square object-cover outline-[.5px] outline-black/5 content-visibility-auto contain-intrinsic-size-[auto]"
/> />
<Avatar.Fallback delayMs={120}> <Avatar.Fallback>
<img <img
src={fallbackAvatar} src={fallback}
alt={user.pubkey} alt={user.pubkey}
className={cn("bg-black dark:bg-white", className)} className="size-full bg-black dark:bg-white outline-[.5px] outline-black/5 content-visibility-auto contain-intrinsic-size-[auto]"
/> />
</Avatar.Fallback> </Avatar.Fallback>
</Avatar.Root> </Avatar.Root>
+2 -2
View File
@@ -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"
@@ -1,9 +1,8 @@
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 { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { useUserContext } from "./provider"; import { useUserContext } from "./provider";
import { NostrAccount } from "@lume/system";
export function UserFollowButton({ export function UserFollowButton({
simple = false, simple = false,
@@ -12,37 +11,32 @@ export function UserFollowButton({
simple?: boolean; simple?: boolean;
className?: string; className?: string;
}) { }) {
const { ark } = useRouteContext({ strict: false });
const user = useUserContext(); const user = useUserContext();
const [t] = useTranslation();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [followed, setFollowed] = useState(false); const [followed, setFollowed] = useState(false);
const toggleFollow = async () => { const toggleFollow = async () => {
setLoading(true); setLoading(true);
if (!followed) {
const add = await ark.follow(user.pubkey); const toggle = await NostrAccount.toggleContact(user.pubkey);
if (add) setFollowed(true);
} else { if (toggle) {
const remove = await ark.unfollow(user.pubkey); setFollowed((prev) => !prev);
if (remove) setFollowed(false); setLoading(false);
} }
setLoading(false);
}; };
useEffect(() => { useEffect(() => {
async function status() { let mounted = true;
setLoading(true);
const contacts = await ark.get_contact_list(); NostrAccount.checkContact(user.pubkey).then((status) => {
if (contacts?.includes(user.pubkey)) { if (mounted) setFollowed(status);
setFollowed(true); });
}
setLoading(false); return () => {
} mounted = false;
status(); };
}, []); }, []);
return ( return (
@@ -56,10 +50,10 @@ export function UserFollowButton({
<Spinner className="size-4" /> <Spinner className="size-4" />
) : followed ? ( ) : followed ? (
!simple ? ( !simple ? (
t("user.unfollow") "Unfollow"
) : null ) : null
) : ( ) : (
t("user.follow") "Follow"
)} )}
</button> </button>
); );
+20 -7
View File
@@ -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>
); );
+149 -127
View File
@@ -1,102 +1,87 @@
import { Column } from "@/components/column"; import { Column } from "@/components/column";
import { Toolbar } from "@/components/toolbar"; import { Toolbar } from "@/components/toolbar";
import { ArrowLeftIcon, ArrowRightIcon } from "@lume/icons"; import { ArrowLeftIcon, ArrowRightIcon, PlusIcon } 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")({
loader: async ({ context }) => { loader: async () => {
try { const columns = await NostrQuery.getColumns();
const userColumns = await context.ark.get_columns(); return columns;
if (userColumns.length > 0) {
return userColumns;
} else {
const systemPath = "resources/system_columns.json";
const resourcePath = await resolveResource(systemPath);
const resourceFile = await readTextFile(resourcePath);
const systemColumns: LumeColumn[] = JSON.parse(resourceFile);
return systemColumns;
}
} catch (e) {
console.error(String(e));
}
}, },
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const userSavedColumns = Route.useLoaderData();
const vlistRef = useRef<VListHandle>(null);
const { account } = Route.useParams(); const { account } = Route.useParams();
const { ark } = Route.useRouteContext(); const initialColumnList = Route.useLoaderData();
const [selectedIndex, setSelectedIndex] = useState(-1); const [columns, setColumns] = useState<LumeColumn[]>([]);
const [columns, setColumns] = useState([]); 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 scrollNext = useCallback(() => {
if (emblaApi) emblaApi.scrollNext(true);
}, [emblaApi]);
const emitScrollEvent = useCallback(() => {
getCurrent().emit("child_webview", { scroll: true });
}, []);
const emitResizeEvent = useCallback(() => {
getCurrent().emit("child_webview", { resize: true, direction: "x" });
}, []);
const openLumeStore = useCallback(async () => {
await getCurrent().emit("columns", {
type: "add",
column: {
label: "store",
name: "Column Gallery",
content: "/store",
},
}); });
}; }, []);
const goRight = () => {
const nextIndex = Math.min(selectedIndex + 1, columns.length - 1);
setSelectedIndex(nextIndex);
vlistRef.current.scrollToIndex(nextIndex, {
align: "center",
});
};
const add = useDebouncedCallback((column: LumeColumn) => { const add = useDebouncedCallback((column: LumeColumn) => {
// update col label column.label = `${column.label}-${nanoid()}`; // update col label
column.label = `${column.label}-${nanoid()}`; setColumns((prev) => [column, ...prev]);
// create new cols
const cols = [...columns];
const openColIndex = cols.findIndex((col) => col.label === "open");
const newCols = [
...cols.slice(0, openColIndex),
column,
...cols.slice(openColIndex),
];
setColumns(newCols);
setSelectedIndex(newCols.length);
setIsScroll(true);
// scroll to the newest column
vlistRef.current.scrollToIndex(newCols.length - 1, {
align: "end",
});
}, 150); }, 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 move = useDebouncedCallback(
(label: string, direction: "left" | "right") => {
const newCols = [...columns];
const col = newCols.find((el) => el.label === label);
const colIndex = newCols.findIndex((el) => el.label === label);
newCols.splice(colIndex, 1);
if (direction === "left") newCols.splice(colIndex - 1, 0, col);
if (direction === "right") newCols.splice(colIndex + 1, 0, col);
setColumns(newCols);
},
150,
);
const updateName = useDebouncedCallback((label: string, title: string) => { const updateName = useDebouncedCallback((label: string, title: string) => {
const currentColIndex = columns.findIndex((col) => col.label === label); const currentColIndex = columns.findIndex((col) => col.label === label);
@@ -109,78 +94,115 @@ 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(() => {
setColumns(userSavedColumns); if (emblaApi) {
}, [userSavedColumns]); 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(() => { useEffect(() => {
// save state if (columns?.length) {
ark.set_columns(columns); 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 === "move")
move(data.payload.label, data.payload.direction);
if (data.payload.type === "set_title") if (data.payload.type === "set_title")
updateName(data.payload.label, data.payload.title); updateName(data.payload.label, data.payload.title);
}); });
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" ))}
cache={null} <div className="shrink-0 p-2 h-full w-[480px]">
> <div className="size-full bg-black/5 dark:bg-white/5 rounded-xl flex items-center justify-center">
{columns.map((column) => ( <button
<Column type="button"
key={column.label} onClick={() => openLumeStore()}
column={column} className="inline-flex items-center justify-center gap-0.5 rounded-full text-sm font-medium h-8 w-max pl-1.5 pr-3 bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10"
account={account} >
isScroll={isScroll} <PlusIcon className="size-5" />
isResize={isResize} Add Column
/> </button>
))} </div>
</VList> </div>
<Toolbar>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => goLeft()}
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"
>
<ArrowLeftIcon className="size-5" />
</button>
<button
type="button"
onClick={() => goRight()}
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"
>
<ArrowRightIcon className="size-5" />
</button>
</div> </div>
</div>
<Toolbar>
<button
type="button"
onClick={() => scrollPrev()}
className="inline-flex items-center justify-center rounded-full size-8 hover:bg-black/5 dark:hover:bg-white/5"
>
<ArrowLeftIcon className="size-4" />
</button>
<button
type="button"
onClick={() => scrollNext()}
className="inline-flex items-center justify-center rounded-full size-8 hover:bg-black/5 dark:hover:bg-white/5"
>
<ArrowRightIcon className="size-4" />
</button>
</Toolbar> </Toolbar>
</div> </div>
); );
+175 -195
View File
@@ -1,253 +1,233 @@
import { User } from "@/components/user";
import { import {
BellIcon, ChevronDownIcon,
ComposeFilledIcon, ComposeFilledIcon,
HorizontalDotsIcon,
PlusIcon, PlusIcon,
SearchIcon, SearchIcon,
} from "@lume/icons"; } from "@lume/icons";
import { type Event, Kind } from "@lume/types"; import { LumeWindow, NostrAccount, NostrQuery } from "@lume/system";
import { User } from "@/components/user"; import { cn } from "@lume/utils";
import {
cn,
decodeZapInvoice,
displayNpub,
sendNativeNotification,
} from "@lume/utils";
import { Outlet, createFileRoute } from "@tanstack/react-router"; import { Outlet, createFileRoute } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core"; import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
import { getCurrent } from "@tauri-apps/api/window"; import { getCurrent } from "@tauri-apps/api/window";
import { useEffect, useMemo, useState } from "react"; import { message } from "@tauri-apps/plugin-dialog";
import { toast } from "sonner"; import { memo, useCallback, useState } from "react";
import * as Popover from "@radix-ui/react-popover";
export const Route = createFileRoute("/$account")({ export const Route = createFileRoute("/$account")({
beforeLoad: async ({ context }) => { beforeLoad: async ({ params }) => {
const ark = context.ark; const settings = await NostrQuery.getUserSettings();
const accounts = await ark.get_accounts(); const accounts = await NostrAccount.getAccounts();
const otherAccounts = accounts.filter(
(account) => account !== params.account,
);
return { accounts }; return { otherAccounts, settings };
}, },
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const { ark, platform } = Route.useRouteContext(); const { settings, platform } = Route.useRouteContext();
const navigate = Route.useNavigate();
const openLumeStore = async () => {
await getCurrent().emit("columns", {
type: "add",
column: {
label: "store",
name: "Column Gallery",
content: "/store",
},
});
};
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="flex h-11 shrink-0 items-center justify-between px-3"
"flex h-11 shrink-0 items-center justify-between pr-2",
platform === "macos" ? "ml-2 pl-20" : "pl-4",
)}
> >
<div className="flex items-center gap-3"> <div
<Accounts /> data-tauri-drag-region
className={cn(
"flex-1 flex items-center gap-2",
platform === "macos" ? "pl-[64px]" : "",
)}
>
<button <button
type="button" type="button"
onClick={() => navigate({ to: "/landing/" })} onClick={() => openLumeStore()}
className="inline-flex size-8 shrink-0 items-center justify-center rounded-full bg-black/10 text-neutral-800 hover:bg-black/20 dark:bg-white/10 dark:text-neutral-200 dark:hover:bg-white/20" className="inline-flex items-center justify-center gap-0.5 rounded-full text-sm font-medium h-8 w-max pl-1.5 pr-3 bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10"
> >
<PlusIcon className="size-5" /> <PlusIcon className="size-5" />
Column
</button> </button>
<div id="toolbar" />
</div> </div>
<div className="flex items-center gap-2"> <div data-tauri-drag-region className="hidden md:flex md:flex-1">
<Search />
</div>
<div
data-tauri-drag-region
className="flex-1 flex items-center justify-end gap-3"
>
<button <button
type="button" type="button"
onClick={() => 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 bg-black/5 dark:bg-white/5 rounded-full w-max hover:bg-blue-500 hover:text-white"
> >
<ComposeFilledIcon className="size-4" /> <ComposeFilledIcon className="size-4" />
New Post New Post
</button> </button>
<Bell /> <Accounts />
<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> </div>
</div> </div>
<div className="flex-1"> <div
className={cn(
"flex-1",
settings.vibrancy
? ""
: "bg-white dark:bg-black border-t border-black/20 dark:border-white/20",
)}
>
<Outlet /> <Outlet />
</div> </div>
</div> </div>
); );
} }
function Accounts() { const Accounts = memo(function Accounts() {
const { ark, accounts } = Route.useRouteContext(); const { otherAccounts } = Route.useRouteContext();
const { account } = Route.useParams(); const { account } = Route.useParams();
const [windowWidth, setWindowWidth] = useState<number>(null);
const navigate = Route.useNavigate(); const navigate = Route.useNavigate();
const sortedList = useMemo(() => {
const list = accounts;
for (const [i, item] of list.entries()) { const showContextMenu = useCallback(
if (item === account) { async (e: React.MouseEvent) => {
list.splice(i, 1); e.preventDefault();
list.unshift(item);
const menuItems = await Promise.all([
MenuItem.new({
text: "New Post",
action: () => LumeWindow.openEditor(),
}),
PredefinedMenuItem.new({ item: "Separator" }),
MenuItem.new({
text: "View Profile",
action: () => LumeWindow.openProfile(account),
}),
MenuItem.new({
text: "Open Settings",
action: () => LumeWindow.openSettings(),
}),
]);
const menu = await Menu.new({
items: menuItems,
});
await menu.popup().catch((e) => console.error(e));
},
[account],
);
const changeAccount = useCallback(
async (npub: string) => {
// Change current account and update signer
const select = await NostrAccount.loadAccount(npub);
if (select) {
// Reset current columns
await getCurrent().emit("columns", { type: "reset" });
// Redirect to new account
return navigate({
to: "/$account/home",
params: { account: npub },
resetScroll: true,
replace: true,
});
} else {
await message("Something wrong.", { title: "Accounts", kind: "error" });
} }
} },
[otherAccounts],
return list; );
}, [accounts]);
const changeAccount = async (npub: string) => {
if (npub === account) {
return await ark.open_profile(account);
}
// change current account and update signer
const select = await ark.load_account(npub);
if (select) {
return navigate({ to: "/$account/home", params: { account: npub } });
} else {
toast.warning("Something wrong.");
}
};
const getWindowDimensions = () => {
const { innerWidth: width, innerHeight: height } = window;
return {
width,
height,
};
};
useEffect(() => {
function handleResize() {
setWindowWidth(getWindowDimensions().width);
}
if (!windowWidth) setWindowWidth(getWindowDimensions().width);
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
return ( return (
<div data-tauri-drag-region className="flex items-center gap-3"> <div data-tauri-drag-region className="hidden md:flex items-center gap-3">
{sortedList {otherAccounts.map((npub) => (
.slice(0, windowWidth > 500 ? account.length : 2) <button key={npub} type="button" onClick={(e) => changeAccount(npub)}>
.map((user) => ( <User.Provider pubkey={npub}>
<button key={user} type="button" onClick={() => changeAccount(user)}> <User.Root className="shrink-0 rounded-full transition-all ease-in-out duration-150 will-change-auto hover:ring-1 hover:ring-blue-500">
<User.Provider pubkey={user}> <User.Avatar className="rounded-full size-8" />
<User.Root </User.Root>
className={cn( </User.Provider>
"shrink-0 rounded-full transition-all ease-in-out duration-150 will-change-auto", </button>
user === account ))}
? "ring-1 ring-teal-500 ring-offset-2 ring-offset-neutral-200 dark:ring-offset-neutral-950" <button
: "", type="button"
)} onClick={(e) => showContextMenu(e)}
> className="inline-flex items-center gap-1.5"
<User.Avatar >
className={cn( <User.Provider pubkey={account}>
"aspect-square h-auto rounded-full object-cover transition-all ease-in-out duration-150 will-change-auto", <User.Root className="shrink-0 rounded-full">
user === account ? "w-7" : "w-8", <User.Avatar className="rounded-full size-8" />
)} </User.Root>
/> </User.Provider>
</User.Root> <ChevronDownIcon className="size-3" />
</User.Provider> </button>
</button>
))}
{accounts.length >= 3 && windowWidth <= 700 ? (
<Popover.Root>
<Popover.Trigger className="inline-flex size-8 shrink-0 items-center justify-center rounded-full bg-black/10 text-neutral-800 hover:bg-black/20 dark:bg-white/10 dark:text-neutral-200 dark:hover:bg-white/20">
<HorizontalDotsIcon className="size-5" />
</Popover.Trigger>
<Popover.Portal>
<Popover.Content className="flex h-11 select-none items-center justify-center rounded-md bg-neutral-950 p-1 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
{sortedList.slice(2).map((user) => (
<button
key={user}
type="button"
onClick={() => changeAccount(user)}
className="size-9 inline-flex items-center justify-center hover:bg-white/10 rounded-md"
>
<User.Provider pubkey={user}>
<User.Root className="rounded-full ring-1 ring-white/10">
<User.Avatar className="size-7 aspect-square h-auto rounded-full object-cover" />
</User.Root>
</User.Provider>
</button>
))}
<Popover.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Popover.Content>
</Popover.Portal>
</Popover.Root>
) : null}
</div> </div>
); );
} });
function Bell() { const Search = memo(function Search() {
const { ark } = Route.useRouteContext(); const [searchType, setSearchType] = useState<"notes" | "users">("notes");
const { account } = Route.useParams(); const [query, setQuery] = useState("");
const [count, setCount] = useState(0); const showContextMenu = useCallback(async (e: React.MouseEvent) => {
e.preventDefault();
useEffect(() => { const menuItems = await Promise.all([
const unlisten = getCurrent().listen<string>( MenuItem.new({
"activity", text: "Notes",
async (payload) => { action: () => setSearchType("notes"),
setCount((prevCount) => prevCount + 1); }),
await invoke("set_badge", { count }); MenuItem.new({
text: "Users",
action: () => setSearchType("users"),
}),
]);
const event: Event = JSON.parse(payload.payload); const menu = await Menu.new({
const user = await ark.get_profile(event.pubkey); items: menuItems,
const userName = });
user.display_name || user.name || displayNpub(event.pubkey, 16);
switch (event.kind) { await menu.popup().catch((e) => console.error(e));
case Kind.Text: {
sendNativeNotification("Mentioned you in a note", userName);
break;
}
case Kind.Repost: {
sendNativeNotification("Reposted your note", userName);
break;
}
case Kind.ZapReceipt: {
const amount = decodeZapInvoice(event.tags);
sendNativeNotification(
`Zapped ₿ ${amount.bitcoinFormatted}`,
userName,
);
break;
}
default:
break;
}
},
);
return () => {
unlisten.then((f) => f());
};
}, []); }, []);
return ( return (
<button <div className="h-8 w-full px-3 text-sm rounded-full inline-flex items-center bg-black/5 dark:bg-white/5">
type="button" <button
onClick={() => { type="button"
setCount(0); onClick={(e) => showContextMenu(e)}
ark.open_activity(account); className="inline-flex items-center gap-1 capitalize text-sm font-medium pr-2 border-r border-black/10 dark:border-white/10 text-black/50 dark:text-white/50"
}} >
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" {searchType}
> <ChevronDownIcon className="size-3" />
<BellIcon className="size-5" /> </button>
{count > 0 ? ( <input
<span className="absolute right-0 top-0 block size-2 rounded-full bg-teal-500 ring-1 ring-black/5" /> type="text"
) : null} name="search"
</button> placeholder="Search..."
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
LumeWindow.openSearch(searchType, query);
}
}}
className="h-full w-full px-3 text-sm rounded-full border-none ring-0 focus:ring-0 focus:outline-none bg-transparent placeholder:text-black/50 dark:placeholder:text-white/50"
/>
<SearchIcon className="size-5" />
</div>
); );
} });
+4 -42
View File
@@ -1,60 +1,22 @@
import type { Ark } from "@lume/ark";
import { CheckCircleIcon, InfoCircleIcon, CancelCircleIcon } from "@lume/icons";
import type { Interests, Metadata, Settings } from "@lume/types";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import type { QueryClient } from "@tanstack/react-query"; import type { QueryClient } from "@tanstack/react-query";
import { Outlet, createRootRouteWithContext } from "@tanstack/react-router"; import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
import type { Platform } from "@tauri-apps/plugin-os"; import type { OsType } 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
ark: Ark;
queryClient: QueryClient; queryClient: QueryClient;
// App info platform: OsType;
platform?: Platform;
locale?: string;
// Settings
settings?: Settings;
interests?: Interests;
// Profile
accounts?: string[];
profile?: Metadata;
isNewUser?: boolean;
// Editor
initialValue?: EditorElement[];
} }
export const Route = createRootRouteWithContext<RouterContext>()({ export const Route = createRootRouteWithContext<RouterContext>()({
component: () => ( component: () => <Outlet />,
<>
<Toaster
position="bottom-right"
icons={{
success: <CheckCircleIcon className="size-5" />,
info: <InfoCircleIcon className="size-5" />,
error: <CancelCircleIcon className="size-5" />,
}}
closeButton
theme="system"
/>
<Outlet />
</>
),
pendingComponent: Pending, pendingComponent: Pending,
wrapInSuspense: true, wrapInSuspense: true,
}); });
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,61 +0,0 @@
import { Spinner } from "@lume/ui";
import { Note } from "@/components/note";
import { Await, createFileRoute, defer } from "@tanstack/react-router";
import { Suspense } from "react";
import { Virtualizer } from "virtua";
export const Route = createFileRoute("/activity/$account/texts")({
loader: async ({ context, params }) => {
const ark = context.ark;
return { data: defer(ark.get_activities(params.account, "1")) };
},
component: Screen,
});
function Screen() {
const { data } = Route.useLoaderData();
return (
<Virtualizer overscan={3}>
<Suspense
fallback={
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
<button
type="button"
className="inline-flex items-center gap-2 text-sm font-medium"
disabled
>
<Spinner className="size-5" />
Loading...
</button>
</div>
}
>
<Await promise={data}>
{(events) =>
events.map((event) => (
<div
key={event.id}
className="flex flex-col gap-2 mb-3 bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50"
>
<Note.Provider event={event}>
<Note.Root>
<div className="px-3 h-14 flex items-center justify-between">
<Note.User />
<Note.Menu />
</div>
<Note.Activity className="px-3" />
<Note.Content className="px-3" quote={false} clean />
<div className="mt-3 flex items-center gap-4 h-14 px-3">
<Note.Open />
</div>
</Note.Root>
</Note.Provider>
</div>
))
}
</Await>
</Suspense>
</Virtualizer>
);
}
@@ -1,50 +0,0 @@
import { Box, Container } from "@lume/ui";
import { cn } from "@lume/utils";
import { Link, Outlet } from "@tanstack/react-router";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/activity/$account")({
component: Screen,
});
function Screen() {
const { account } = Route.useParams();
return (
<Container withDrag withNavigate={false}>
<Box className="scrollbar-none shadow-none bg-black/5 dark:bg-white/5 backdrop-blur-sm flex flex-col overflow-y-auto">
<div className="h-14 shrink-0 flex w-full items-center gap-1 px-3">
<div className="inline-flex h-full w-full items-center gap-1">
<Link to="/activity/$account/texts" params={{ account }}>
{({ isActive }) => (
<div
className={cn(
"inline-flex h-7 w-max items-center justify-center gap-2 rounded-full px-3 text-sm font-medium",
isActive ? "bg-neutral-50 dark:bg-white/10" : "opacity-50",
)}
>
Notes
</div>
)}
</Link>
<Link to="/activity/$account/zaps" params={{ account }}>
{({ isActive }) => (
<div
className={cn(
"inline-flex h-7 w-max items-center justify-center gap-2 rounded-full px-3 text-sm font-medium",
isActive ? "bg-neutral-50 dark:bg-white/10" : "opacity-50",
)}
>
Zaps
</div>
)}
</Link>
</div>
</div>
<div className="px-2 flex-1 overflow-y-auto w-full h-full scrollbar-none">
<Outlet />
</div>
</Box>
</Container>
);
}
@@ -1,65 +0,0 @@
import { User } from "@/components/user";
import { Spinner } from "@lume/ui";
import { decodeZapInvoice } from "@lume/utils";
import { Await, createFileRoute, defer } from "@tanstack/react-router";
import { Suspense } from "react";
import { Virtualizer } from "virtua";
export const Route = createFileRoute("/activity/$account/zaps")({
loader: async ({ context, params }) => {
const ark = context.ark;
return { data: defer(ark.get_activities(params.account, "9735")) };
},
component: Screen,
});
function Screen() {
const { data } = Route.useLoaderData();
return (
<Virtualizer overscan={3}>
<Suspense
fallback={
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
<button
type="button"
className="inline-flex items-center gap-2 text-sm font-medium"
disabled
>
<Spinner className="size-5" />
Loading...
</button>
</div>
}
>
<Await promise={data}>
{(events) =>
events.map((event) => (
<div
key={event.id}
className="flex flex-col gap-2 mb-3 bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50"
>
<User.Provider pubkey={event.pubkey}>
<User.Root className="flex flex-col">
<div className="text-lg h-20 font-medium leading-tight flex w-full items-center justify-center">
{decodeZapInvoice(event.tags).bitcoinFormatted}
</div>
<div className="h-11 border-t border-neutral-100 dark:border-neutral-900 flex items-center gap-1 px-2">
<div className="inline-flex items-center gap-2">
<User.Avatar className="size-7 rounded-full shrink-0" />
<User.Name className="text-sm font-medium" />
</div>
<div className="text-sm text-neutral-700 dark:text-neutral-300">
zapped you
</div>
</div>
</User.Root>
</User.Provider>
</div>
))
}
</Await>
</Suspense>
</Virtualizer>
);
}
@@ -1,47 +1,38 @@
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";
import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { writeText } from "@tauri-apps/plugin-clipboard-manager"; import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { message } from "@tauri-apps/plugin-dialog";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
export const Route = createFileRoute("/auth/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 navigate = useNavigate();
const [key, setKey] = useState(null); const [key, setKey] = useState(null);
const [passphase, setPassphase] = useState(""); const [passphase, setPassphase] = useState("");
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [confirm, setConfirm] = useState({ c1: false, c2: false, c3: false }); const [confirm, setConfirm] = useState({ c1: false, c2: false });
const navigate = useNavigate();
const submit = async () => { const submit = async () => {
try { try {
if (key) { if (key) {
if (!confirm.c1 || !confirm.c2 || !confirm.c3) { if (!confirm.c1 || !confirm.c2) {
return toast.warning("You need to confirm before continue"); return await message("You need to confirm before continue", {
title: "Backup",
kind: "info",
});
} }
return navigate({ navigate({ to: "/", replace: true });
to: "/auth/settings",
search: { account },
});
} }
// start loading // start loading
@@ -57,7 +48,10 @@ function Screen() {
}); });
} catch (e) { } catch (e) {
setLoading(false); setLoading(false);
toast.error(String(e)); await message(String(e), {
title: "Backup",
kind: "error",
});
} }
}; };
@@ -66,12 +60,15 @@ function Screen() {
await writeText(key); await writeText(key);
setCopied(true); setCopied(true);
} catch (e) { } catch (e) {
toast.error(e); await message(String(e), {
title: "Backup",
kind: "error",
});
} }
}; };
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 +76,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 +87,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 +103,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 +123,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">
@@ -137,7 +134,7 @@ function Screen() {
className="text-sm leading-none text-neutral-800 dark:text-neutral-200" className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
htmlFor="confirm1" htmlFor="confirm1"
> >
{t("backup.confirm1")} I will make sure keep it safe and not sharing with anyone.
</label> </label>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -146,7 +143,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">
@@ -157,27 +154,7 @@ function Screen() {
className="text-sm leading-none text-neutral-800 dark:text-neutral-200" className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
htmlFor="confirm2" htmlFor="confirm2"
> >
{t("backup.confirm2")} I understand I cannot recover private key.
</label>
</div>
<div className="flex items-center gap-2">
<Checkbox.Root
checked={confirm.c3}
onCheckedChange={() =>
setConfirm((state) => ({ ...state, c3: !state.c3 }))
}
className="flex size-6 appearance-none items-center justify-center rounded-md bg-neutral-100 outline-none dark:bg-white/10 dark:hover:bg-white/20"
id="confirm3"
>
<Checkbox.Indicator className="text-blue-500">
<CheckIcon className="size-4" />
</Checkbox.Indicator>
</Checkbox.Root>
<label
className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
htmlFor="confirm3"
>
{t("backup.confirm3")}
</label> </label>
</div> </div>
</div> </div>
@@ -189,9 +166,9 @@ 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 /> : "Continue"}
</button> </button>
</div> </div>
</div> </div>
@@ -1,26 +1,24 @@
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";
import { message } from "@tauri-apps/plugin-dialog";
import { useState } from "react"; import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
export const Route = createFileRoute("/auth/new/profile")({ export const Route = createFileRoute("/auth/create-profile")({
component: Screen, loader: async () => {
loader: ({ context }) => { const account = await NostrAccount.createAccount();
return context.ark.create_keys(); return account;
}, },
component: Screen,
}); });
function Screen() { function Screen() {
const keys = Route.useLoaderData(); const account = Route.useLoaderData();
const navigate = useNavigate(); const navigate = useNavigate();
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,46 +33,46 @@ 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,
}); });
} }
} }
} catch (e) { } catch (e) {
setLoading(false); setLoading(false);
toast.error(String(e)); await message(String(e), { title: "Create Profile", kind: "error" });
} }
}; };
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,35 +80,35 @@ 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">
{t("user.displayName")} * Display Name *
</label> </label>
<input <input
type={"text"} type={"text"}
{...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">
<label htmlFor="name" className="font-medium"> <label htmlFor="name" className="font-medium">
{t("user.name")} Name
</label> </label>
<input <input
type={"text"} type={"text"}
{...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">
<label htmlFor="about" className="font-medium"> <label htmlFor="about" className="font-medium">
{t("user.bio")} Bio
</label> </label>
<textarea <textarea
{...register("about")} {...register("about")}
@@ -121,21 +119,21 @@ function Screen() {
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label htmlFor="website" className="font-medium"> <label htmlFor="website" className="font-medium">
{t("user.website")} Website
</label> </label>
<input <input
type="url" type="url"
{...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 /> : "Continue"}
</button> </button>
</form> </form>
</div> </div>
@@ -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 { message } from "@tauri-apps/plugin-dialog";
import { useState } from "react"; import { useState } from "react";
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("");
@@ -16,35 +16,33 @@ function Screen() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const submit = async () => { const submit = async () => {
if (!key.startsWith("nsec1")) if (!key.startsWith("nsec1")) {
return toast.warning( return await message(
"You need to enter a valid private key starts with nsec or ncryptsec", "You need to enter a valid private key starts with nsec or ncryptsec",
{ title: "Import Key", kind: "info" },
); );
}
try { try {
setLoading(true); setLoading(true);
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);
toast.error(e); await message(String(e), { title: "Import Key", kind: "error" });
} }
}; };
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 +56,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 +71,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>
+16 -18
View File
@@ -1,49 +1,47 @@
import { NostrAccount } from "@lume/system";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { createLazyFileRoute } from "@tanstack/react-router"; import { createLazyFileRoute } from "@tanstack/react-router";
import { message } from "@tauri-apps/plugin-dialog";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner";
export const Route = createLazyFileRoute("/auth/remote")({ export const Route = createLazyFileRoute("/auth/remote")({
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const { ark } = Route.useRouteContext();
const navigate = Route.useNavigate(); const navigate = Route.useNavigate();
const [uri, setUri] = useState(""); const [uri, setUri] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const submit = async () => { const submit = async () => {
if (!uri.startsWith("bunker://")) if (!uri.startsWith("bunker://")) {
return toast.warning( return await message(
"You need to enter a valid Connect URI starts with bunker://", "You need to enter a valid Connect URI starts with bunker://",
{ title: "Nostr Connect", kind: "info" },
); );
}
try { try {
setLoading(true); setLoading(true);
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);
toast.error(e); await message(String(e), { title: "Nostr Connect", kind: "error" });
} }
}; };
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,20 +55,20 @@ 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>
<div className="flex flex-col gap-1 items-center"> <div className="flex flex-col items-center gap-1">
<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>
{loading ? ( {loading ? (
<p className="text-neutral-600 dark:text-neutral-400 text-sm text-center"> <p className="text-sm text-center text-neutral-600 dark:text-neutral-400">
Waiting confirmation... Waiting confirmation...
</p> </p>
) : null} ) : null}
-218
View File
@@ -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,150 @@
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 { message } from "@tauri-apps/plugin-dialog";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
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 {
if (!data.url.startsWith("wss://") || !data.url.startsWith("ws://")) {
return await message("Relay must be starts with wss:// or ws://", {
title: "Bootstrap Relays",
kind: "info",
});
}
const relay: Relay = { url: data.url, purpose: data.purpose };
setRelays((prev) => [...prev, relay]);
reset();
} catch (e) {
await message(String(e), { title: "Bootstrap Relays", kind: "error" });
}
};
const save = async () => {
try {
setIsLoading(true);
await NostrQuery.saveBootstrapRelays(relays);
} catch (e) {
await message(String(e), { title: "Bootstrap Relays", kind: "error" });
}
};
useEffect(() => {
setRelays(bootstrapRelays);
}, [bootstrapRelays]);
return (
<div
data-tauri-drag-region
className="relative flex flex-col items-center justify-between w-full h-full"
>
<div
data-tauri-drag-region
className="absolute top-0 left-0 h-14 w-full"
/>
<div className="flex items-end justify-center flex-1 w-full px-4 pb-10">
<div className="text-center">
<h2 className="text-2xl font-semibold">Customize Bootstrap Relays</h2>
</div>
</div>
<div className="flex flex-col items-center flex-1 w-full">
<div className="flex flex-col w-full max-w-sm mx-auto p-3 overflow-hidden bg-white divide-y divide-neutral-100 dark:divide-white/5 rounded-xl shadow-primary dark:bg-white/10 dark:ring-1 ring-white/15">
{relays.map((relay) => (
<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>
<div className="w-full max-w-sm mx-auto">
<button
type="button"
onClick={() => save()}
disabled={isLoading}
className="inline-flex items-center justify-center w-full h-9 mt-4 text-sm font-semibold text-white bg-blue-500 rounded-lg shrink-0 hover:bg-blue-600 disabled:opacity-50"
>
{isLoading ? <Spinner /> : "Save & Relaunch"}
</button>
</div>
</div>
<div className="flex-1" />
</div>
);
}
+35 -34
View File
@@ -1,10 +1,11 @@
import { CancelIcon, CheckCircleIcon, PlusIcon } from "@lume/icons"; import { User } from "@/components/user";
import { CancelIcon, PlusIcon } from "@lume/icons";
import { NostrAccount, NostrQuery } from "@lume/system";
import type { ColumnRouteSearch } from "@lume/types"; import type { ColumnRouteSearch } from "@lume/types";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { User } from "@/components/user";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { message } from "@tauri-apps/plugin-dialog";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner";
export const Route = createFileRoute("/create-group")({ export const Route = createFileRoute("/create-group")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => { validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
@@ -14,21 +15,14 @@ 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 contacts = Route.useLoaderData();
const navigate = Route.useNavigate();
const { ark } = Route.useRouteContext();
const { label, redirect } = Route.useSearch();
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [npub, setNpub] = useState(""); const [npub, setNpub] = useState("");
const [users, setUsers] = useState<string[]>([ const [users, setUsers] = useState<string[]>([
@@ -36,6 +30,10 @@ function Screen() {
]); ]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const contacts = Route.useLoaderData();
const search = Route.useSearch();
const navigate = Route.useNavigate();
const toggleUser = (pubkey: string) => { const toggleUser = (pubkey: string) => {
setUsers((prev) => setUsers((prev) =>
prev.includes(pubkey) prev.includes(pubkey)
@@ -56,33 +54,36 @@ function Screen() {
try { try {
setIsLoading(true); setIsLoading(true);
const key = `lume_group_${label}`; const key = `lume_group_${search.label}`;
const createGroup = await ark.set_nstore(key, JSON.stringify(users)); const createGroup = await NostrQuery.setNstore(
key,
JSON.stringify(users),
);
if (createGroup) { if (createGroup) {
return navigate({ to: redirect }); return navigate({ to: search.redirect, search: { ...search } });
} }
} catch (e) { } catch (e) {
setIsLoading(false); setIsLoading(false);
toast.error(e); await message(String(e), { title: "Create Group", kind: "error" });
} }
}; };
return ( return (
<div className="w-full h-full flex flex-col items-center justify-center gap-4"> <div className="flex flex-col items-center justify-center w-full h-full gap-4">
<div className="text-center flex flex-col items-center justify-center"> <div className="flex flex-col items-center justify-center text-center">
<h1 className="text-2xl font-serif font-medium"> <h1 className="font-serif text-2xl font-medium">
Focus feeds for people you like Focus feeds for people you like
</h1> </h1>
<p className="leading-tight text-neutral-700 dark:text-neutral-300"> <p className="leading-tight text-neutral-700 dark:text-neutral-300">
Add some people for custom feeds. Add some people for custom feeds.
</p> </p>
</div> </div>
<div className="w-4/5 max-w-full flex flex-col gap-3"> <div className="flex flex-col w-4/5 max-w-full gap-3">
<div className="w-full h-9 shrink-0 flex items-center bg-black/5 dark:bg-white/5 rounded-lg"> <div className="flex items-center w-full rounded-lg h-9 shrink-0 bg-black/5 dark:bg-white/5">
<label <label
htmlFor="name" htmlFor="name"
className="w-16 border-r border-black/10 dark:border-white/10 shrink-0 text-center text-sm font-semibold" className="w-16 text-sm font-semibold text-center border-r border-black/10 dark:border-white/10 shrink-0"
> >
Name Name
</label> </label>
@@ -91,23 +92,23 @@ function Screen() {
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
placeholder="Enter a name for this group" placeholder="Enter a name for this group"
className="h-full bg-transparent border-none text-sm px-3 placeholder:text-neutral-600 focus:border-neutral-500 focus:ring-0 dark:placeholder:text-neutral-400" className="h-full px-3 text-sm bg-transparent border-none placeholder:text-neutral-600 focus:border-neutral-500 focus:ring-0 dark:placeholder:text-neutral-400"
/> />
</div> </div>
<div className="w-full flex flex-col items-center gap-3"> <div className="flex flex-col items-center w-full gap-3">
<div className="overflow-y-auto scrollbar-none p-2 w-full h-[450px] flex flex-col gap-3 bg-black/5 dark:bg-white/5 backdrop-blur-lg rounded-xl"> <div className="overflow-y-auto scrollbar-none p-2 w-full h-[450px] flex flex-col gap-3 bg-black/5 dark:bg-white/5 rounded-xl">
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
name="npub" name="npub"
value={npub} value={npub}
onChange={(e) => setNpub(e.target.value)} onChange={(e) => setNpub(e.target.value)}
placeholder="npub1..." placeholder="npub1..."
className="h-9 w-full rounded-lg bg-black/10 dark:bg-white/10 border-none text-sm px-3 placeholder:text-neutral-600 focus:border-neutral-500 focus:ring-0 dark:placeholder:text-neutral-400" className="w-full px-3 text-sm border-none rounded-lg h-9 bg-black/10 dark:bg-white/10 placeholder:text-neutral-600 focus:border-neutral-500 focus:ring-0 dark:placeholder:text-neutral-400"
/> />
<button <button
type="button" type="button"
onClick={() => addUser()} onClick={() => addUser()}
className="inline-flex size-9 rounded-lg items-center justify-center bg-black/20 dark:bg-white/20 shrink-0 text-white hover:bg-blue-500" className="inline-flex items-center justify-center text-white rounded-lg size-9 bg-black/20 dark:bg-white/20 shrink-0 hover:bg-blue-500"
> >
<PlusIcon className="size-6" /> <PlusIcon className="size-6" />
</button> </button>
@@ -121,11 +122,11 @@ function Screen() {
key={item} key={item}
type="button" type="button"
onClick={() => toggleUser(item)} onClick={() => toggleUser(item)}
className="inline-flex items-center justify-between px-3 py-2 rounded-lg bg-white dark:bg-black/20 backdrop-blur-lg shadow-primary dark:ring-1 ring-neutral-800/50" className="inline-flex items-center justify-between px-3 py-2 bg-white rounded-lg dark:bg-black/20 shadow-primary dark:ring-1 ring-neutral-800/50"
> >
<User.Provider pubkey={item}> <User.Provider pubkey={item}>
<User.Root className="flex items-center gap-2.5"> <User.Root className="flex items-center gap-2.5">
<User.Avatar className="size-8 rounded-full object-cover" /> <User.Avatar className="rounded-full size-8" />
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<User.Name className="text-sm font-medium" /> <User.Name className="text-sm font-medium" />
</div> </div>
@@ -137,7 +138,7 @@ function Screen() {
</button> </button>
)) ))
) : ( ) : (
<div className="bg-black/5 dark:bg-white/5 text-sm flex items-center justify-center h-14 rounded-lg"> <div className="flex items-center justify-center text-sm rounded-lg bg-black/5 dark:bg-white/5 h-14">
Empty. Empty.
</div> </div>
)} )}
@@ -152,11 +153,11 @@ function Screen() {
key={item} key={item}
type="button" type="button"
onClick={() => toggleUser(item)} onClick={() => toggleUser(item)}
className="inline-flex items-center justify-between px-3 py-2 rounded-lg bg-white dark:bg-black/20 backdrop-blur-lg shadow-primary dark:ring-1 ring-neutral-800/50" className="inline-flex items-center justify-between px-3 py-2 bg-white rounded-lg dark:bg-black/20 shadow-primary dark:ring-1 ring-neutral-800/50"
> >
<User.Provider pubkey={item}> <User.Provider pubkey={item}>
<User.Root className="flex items-center gap-2.5"> <User.Root className="flex items-center gap-2.5">
<User.Avatar className="size-8 rounded-full object-cover" /> <User.Avatar className="rounded-full size-8" />
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<User.Name className="text-sm font-medium" /> <User.Name className="text-sm font-medium" />
</div> </div>
@@ -165,7 +166,7 @@ function Screen() {
</button> </button>
)) ))
) : ( ) : (
<div className="bg-black/5 dark:bg-white/5 text-sm flex items-center justify-center h-14 rounded-lg"> <div className="flex items-center justify-center text-sm rounded-lg bg-black/5 dark:bg-white/5 h-14">
<p> <p>
Find more user at{" "} Find more user at{" "}
<a <a
@@ -186,7 +187,7 @@ function Screen() {
type="button" type="button"
onClick={() => submit()} onClick={() => submit()}
disabled={isLoading || users.length < 1} disabled={isLoading || users.length < 1}
className="inline-flex items-center justify-center w-36 rounded-full h-9 bg-blue-500 text-white text-sm font-medium hover:bg-blue-600 disabled:opacity-50" className="inline-flex items-center justify-center text-sm font-medium text-white bg-blue-500 rounded-full w-36 h-9 hover:bg-blue-600 disabled:opacity-50"
> >
{isLoading ? <Spinner /> : "Confirm"} {isLoading ? <Spinner /> : "Confirm"}
</button> </button>
@@ -1,9 +1,9 @@
import { NostrAccount } from "@lume/system";
import type { ColumnRouteSearch } from "@lume/types"; import type { ColumnRouteSearch } from "@lume/types";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core"; import { message } from "@tauri-apps/plugin-dialog";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner";
export const Route = createFileRoute("/create-newsfeed/f2f")({ export const Route = createFileRoute("/create-newsfeed/f2f")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => { validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
@@ -24,26 +24,33 @@ function Screen() {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const submit = async () => { const submit = async () => {
if (!npub.startsWith("npub1")) if (!npub.startsWith("npub1")) {
return toast.warning("You must enter a valid npub."); return await message("You must enter a valid npub.", {
title: "Create Newsfeed",
kind: "info",
});
}
try { try {
setIsLoading(true); setIsLoading(true);
const sync: boolean = await invoke("friend_to_friend", { npub }); const sync = await NostrAccount.f2f(npub);
if (sync) { if (sync) {
return navigate({ to: redirect }); return navigate({ to: redirect });
} }
} catch (e) { } catch (e) {
setIsLoading(false); setIsLoading(false);
toast.error(String(e)); await message(String(e), {
title: "Create Newsfeed",
kind: "error",
});
} }
}; };
return ( return (
<div className="overflow-y-auto scrollbar-none p-2 shrink-0 h-[450px] bg-white dark:bg-white/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50"> <div className="overflow-y-auto scrollbar-none p-2 shrink-0 h-[450px] bg-white dark:bg-white/20 rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50">
<div className="h-full flex flex-col justify-between"> <div className="flex flex-col justify-between h-full">
<div className="flex-1 flex flex-col gap-1.5 justify-center px-5"> <div className="flex-1 flex flex-col gap-1.5 justify-center px-5">
<p className="font-semibold text-neutral-500"> <p className="font-semibold text-neutral-500">
You already have a friend on Nostr? You already have a friend on Nostr?
@@ -60,7 +67,7 @@ function Screen() {
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label htmlFor="npub" className="font-medium text-sm"> <label htmlFor="npub" className="text-sm font-medium">
NPUB NPUB
</label> </label>
<input <input
@@ -69,13 +76,13 @@ function Screen() {
value={npub} value={npub}
onChange={(e) => setNpub(e.target.value)} onChange={(e) => setNpub(e.target.value)}
spellCheck={false} spellCheck={false}
className="h-11 rounded-lg bg-transparent border border-neutral-200 dark:border-neutral-800 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:placeholder:text-neutral-400" className="px-3 bg-transparent border rounded-lg h-11 border-neutral-200 dark:border-neutral-800 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:placeholder:text-neutral-400"
/> />
</div> </div>
<button <button
type="button" type="button"
onClick={() => submit()} onClick={() => submit()}
className="inline-flex items-center justify-center w-full rounded-lg h-9 bg-blue-500 text-white text-sm font-medium hover:bg-blue-600" className="inline-flex items-center justify-center w-full text-sm font-medium text-white bg-blue-500 rounded-lg h-9 hover:bg-blue-600"
> >
{isLoading ? <Spinner /> : "Confirm"} {isLoading ? <Spinner /> : "Confirm"}
</button> </button>
+23 -6
View File
@@ -1,25 +1,38 @@
import type { ColumnRouteSearch } from "@lume/types";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { Link, Outlet } from "@tanstack/react-router"; import { Link, Outlet } from "@tanstack/react-router";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/create-newsfeed")({ export const Route = createFileRoute("/create-newsfeed")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
return {
account: search.account,
label: search.label,
name: search.name,
};
},
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const search = Route.useSearch();
return ( return (
<div className="w-full h-full flex flex-col items-center justify-center gap-4"> <div className="flex flex-col items-center justify-center w-full h-full gap-4">
<div className="text-center flex flex-col items-center justify-center"> <div className="flex flex-col items-center justify-center text-center">
<h1 className="text-2xl font-serif font-medium"> <h1 className="font-serif text-2xl font-medium">
Build up your timeline. Build up your timeline.
</h1> </h1>
<p className="leading-tight text-neutral-700 dark:text-neutral-300"> <p className="leading-tight text-neutral-700 dark:text-neutral-300">
Follow some people to keep up to date with them. Follow some people to keep up to date with them.
</p> </p>
</div> </div>
<div className="w-4/5 max-w-full flex flex-col gap-3"> <div className="flex flex-col w-4/5 max-w-full gap-3">
<div className="w-full h-9 shrink-0 flex items-center justify-between bg-black/5 dark:bg-white/5 rounded-lg px-0.5"> <div className="w-full h-9 shrink-0 flex items-center justify-between bg-black/5 dark:bg-white/5 rounded-lg px-0.5">
<Link to="/create-newsfeed/users" className="flex-1 h-8"> <Link
to="/create-newsfeed/users"
search={search}
className="flex-1 h-8"
>
{({ isActive }) => ( {({ isActive }) => (
<div <div
className={cn( className={cn(
@@ -33,7 +46,11 @@ function Screen() {
</div> </div>
)} )}
</Link> </Link>
<Link to="/create-newsfeed/f2f" className="flex-1 h-8"> <Link
to="/create-newsfeed/f2f"
search={search}
className="flex-1 h-8"
>
{({ isActive }) => ( {({ isActive }) => (
<div <div
className={cn( className={cn(
@@ -1,10 +1,11 @@
import { createFileRoute } from "@tanstack/react-router";
import { Suspense, useState } from "react";
import { Await, defer } from "@tanstack/react-router";
import { User } from "@/components/user"; import { User } from "@/components/user";
import { Spinner } from "@lume/ui"; import { NostrAccount } from "@lume/system";
import { toast } from "sonner";
import type { ColumnRouteSearch } from "@lume/types"; import type { ColumnRouteSearch } from "@lume/types";
import { Spinner } from "@lume/ui";
import { createFileRoute } from "@tanstack/react-router";
import { Await, defer } from "@tanstack/react-router";
import { message } from "@tauri-apps/plugin-dialog";
import { Suspense, useState } from "react";
export const Route = createFileRoute("/create-newsfeed/users")({ export const Route = createFileRoute("/create-newsfeed/users")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => { validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
@@ -31,7 +32,6 @@ export const Route = createFileRoute("/create-newsfeed/users")({
}); });
function Screen() { function Screen() {
const { ark } = Route.useRouteContext();
const { data } = Route.useLoaderData(); const { data } = Route.useLoaderData();
const { redirect } = Route.useSearch(); const { redirect } = Route.useSearch();
@@ -52,23 +52,26 @@ function Screen() {
try { try {
setIsLoading(true); setIsLoading(true);
const newContactList = await ark.set_contact_list(follows); const newContactList = await NostrAccount.setContactList(follows);
if (newContactList) { if (newContactList) {
return navigate({ to: redirect }); return navigate({ to: redirect });
} }
} catch (e) { } catch (e) {
setIsLoading(false); setIsLoading(false);
toast.error(String(e)); await message(String(e), {
title: "Create Group",
kind: "error",
});
} }
}; };
return ( return (
<div className="w-full flex flex-col items-center gap-3"> <div className="flex flex-col items-center w-full gap-3">
<div className="overflow-y-auto scrollbar-none p-2 w-full h-[450px] bg-black/5 dark:bg-white/5 backdrop-blur-lg rounded-xl"> <div className="overflow-y-auto scrollbar-none p-2 w-full h-[450px] bg-black/5 dark:bg-white/5 rounded-xl">
<Suspense <Suspense
fallback={ fallback={
<div className="flex h-20 w-full flex-col items-center justify-center gap-1"> <div className="flex flex-col items-center justify-center w-full h-20 gap-1">
<button <button
type="button" type="button"
className="inline-flex items-center gap-2 text-sm font-medium" className="inline-flex items-center gap-2 text-sm font-medium"
@@ -85,27 +88,27 @@ function Screen() {
users.profiles.map((item: { pubkey: string }) => ( users.profiles.map((item: { pubkey: string }) => (
<div <div
key={item.pubkey} key={item.pubkey}
className="h-max w-full overflow-hidden mb-2 p-2 bg-white dark:bg-black/20 backdrop-blur-lg rounded-lg shadow-primary dark:ring-1 ring-neutral-800/50" className="w-full p-2 mb-2 overflow-hidden bg-white rounded-lg h-max dark:bg-black/20shadow-primary dark:ring-1 ring-neutral-800/50"
> >
<User.Provider pubkey={item.pubkey}> <User.Provider pubkey={item.pubkey}>
<User.Root> <User.Root>
<div className="flex h-full w-full flex-col gap-2"> <div className="flex flex-col w-full h-full gap-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<User.Avatar className="size-7 shrink-0 rounded-full object-cover" /> <User.Avatar className="rounded-full size-7" />
<User.Name className="text-sm leadning-tight max-w-[15rem] truncate font-semibold" /> <User.Name className="text-sm leadning-tight max-w-[15rem] truncate font-semibold" />
</div> </div>
<button <button
type="button" type="button"
onClick={() => toggleFollow(item.pubkey)} onClick={() => toggleFollow(item.pubkey)}
className="inline-flex h-7 w-20 items-center justify-center rounded-lg bg-black/10 text-sm font-medium hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20" className="inline-flex items-center justify-center w-20 text-sm font-medium rounded-lg h-7 bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
> >
{follows.includes(item.pubkey) {follows.includes(item.pubkey)
? "Unfollow" ? "Unfollow"
: "Follow"} : "Follow"}
</button> </button>
</div> </div>
<User.About className="line-clamp-3 max-w-none select-text text-neutral-800 dark:text-neutral-400" /> <User.About className="select-text line-clamp-3 max-w-none text-neutral-800 dark:text-neutral-400" />
</div> </div>
</User.Root> </User.Root>
</User.Provider> </User.Provider>
@@ -119,7 +122,7 @@ function Screen() {
type="button" type="button"
onClick={() => submit()} onClick={() => submit()}
disabled={isLoading || follows.length < 1} disabled={isLoading || follows.length < 1}
className="inline-flex items-center justify-center w-36 rounded-full h-9 bg-blue-500 text-white text-sm font-medium hover:bg-blue-600 disabled:opacity-50" className="inline-flex items-center justify-center text-sm font-medium text-white bg-blue-500 rounded-full w-36 h-9 hover:bg-blue-600 disabled:opacity-50"
> >
{isLoading ? <Spinner /> : "Confirm"} {isLoading ? <Spinner /> : "Confirm"}
</button> </button>
+30 -19
View File
@@ -1,10 +1,16 @@
import { CheckCircleIcon } from "@lume/icons"; import { CheckCircleIcon } from "@lume/icons";
import type { ColumnRouteSearch, Topic } from "@lume/types"; import { NostrQuery } from "@lume/system";
import type { ColumnRouteSearch } from "@lume/types";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { TOPICS } from "@lume/utils"; import { TOPICS } from "@lume/utils";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { message } from "@tauri-apps/plugin-dialog";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner";
type Topic = {
title: string;
content: string[];
};
export const Route = createFileRoute("/create-topic")({ export const Route = createFileRoute("/create-topic")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => { validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
@@ -18,12 +24,10 @@ export const Route = createFileRoute("/create-topic")({
}); });
function Screen() { function Screen() {
const { label, redirect } = Route.useSearch();
const { ark } = Route.useRouteContext();
const [topics, setTopics] = useState<Topic[]>([]); const [topics, setTopics] = useState<Topic[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const search = Route.useSearch();
const navigate = Route.useNavigate(); const navigate = Route.useNavigate();
const toggleTopic = (topic: Topic) => { const toggleTopic = (topic: Topic) => {
@@ -38,46 +42,53 @@ function Screen() {
try { try {
setIsLoading(true); setIsLoading(true);
const key = `lume_topic_${label}`; const key = `lume_topic_${search.label}`;
const createTopic = await ark.set_nstore(key, JSON.stringify(topics)); const createTopic = await NostrQuery.setNstore(
key,
JSON.stringify(topics),
);
if (createTopic) { if (createTopic) {
return navigate({ to: redirect }); return navigate({ to: search.redirect, search: { ...search } });
} }
} catch (e) { } catch (e) {
setIsLoading(false); setIsLoading(false);
toast.error(String(e)); await message(String(e), {
title: "Create Topic",
kind: "error",
});
} }
}; };
return ( return (
<div className="w-full h-full flex flex-col items-center justify-center gap-4"> <div className="flex flex-col items-center justify-center w-full h-full gap-4">
<div className="text-center flex flex-col items-center justify-center"> <div className="flex flex-col items-center justify-center text-center">
<h1 className="text-2xl font-serif font-medium"> <h1 className="font-serif text-2xl font-medium">
What are your interests? What are your interests?
</h1> </h1>
<p className="leading-tight text-neutral-700 dark:text-neutral-300"> <p className="leading-tight text-neutral-700 dark:text-neutral-300">
Add some topics you want to focus on. Add some topics you want to focus on.
</p> </p>
</div> </div>
<div className="w-4/5 max-w-full flex flex-col gap-3"> <div className="flex flex-col w-4/5 max-w-full gap-3">
<div className="w-full h-9 shrink-0 flex items-center justify-between bg-black/5 dark:bg-white/5 rounded-lg px-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> <span className="text-sm font-medium">Added: {topics.length}</span>
</div> </div>
<div className="w-full flex flex-col items-center gap-3"> <div className="flex flex-col items-center w-full gap-3">
<div className="overflow-y-auto scrollbar-none p-2 w-full h-[450px] bg-black/5 dark:bg-white/5 backdrop-blur-lg rounded-xl"> <div className="overflow-y-auto scrollbar-none p-2 w-full h-[450px] bg-black/5 dark:bg-white/5 rounded-xl">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{TOPICS.map((topic) => ( {TOPICS.map((topic) => (
<button <button
key={topic.title} key={topic.title}
type="button"
onClick={() => toggleTopic(topic)} onClick={() => toggleTopic(topic)}
className="h-11 px-3 flex items-center justify-between bg-white dark:bg-black/20 backdrop-blur-lg border border-transparent hover:border-blue-500 rounded-lg shadow-primary dark:ring-1 ring-neutral-800/50" className="flex items-center justify-between px-3 bg-white border border-transparent rounded-lg h-11 dark:bg-black/20 hover:border-blue-500 shadow-primary dark:ring-1 ring-neutral-800/50"
> >
<div className="inline-flex items-center gap-1"> <div className="inline-flex items-center gap-1">
<div>{topic.icon}</div> <div>{topic.icon}</div>
<div className="text-sm font-medium"> <div className="text-sm font-medium">
<span>{topic.title}</span> <span>{topic.title}</span>
<span className="ml-1 italic text-neutral-400 dark:text-neutral-600 font-normal"> <span className="ml-1 italic font-normal text-neutral-400 dark:text-neutral-600">
{topic.content.length} hashtags {topic.content.length} hashtags
</span> </span>
</div> </div>
@@ -93,7 +104,7 @@ function Screen() {
type="button" type="button"
onClick={() => submit()} onClick={() => submit()}
disabled={isLoading || topics.length < 1} disabled={isLoading || topics.length < 1}
className="inline-flex items-center justify-center w-36 rounded-full h-9 bg-blue-500 text-white text-sm font-medium hover:bg-blue-600 disabled:opacity-50" className="inline-flex items-center justify-center text-sm font-medium text-white bg-blue-500 rounded-full w-36 h-9 hover:bg-blue-600 disabled:opacity-50"
> >
{isLoading ? <Spinner /> : "Confirm"} {isLoading ? <Spinner /> : "Confirm"}
</button> </button>
@@ -1,32 +1,30 @@
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 { message } from "@tauri-apps/plugin-dialog";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useSlateStatic } from "slate-react"; import { useSlateStatic } from "slate-react";
import { toast } from "sonner";
export function MediaButton({ 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
setLoading(false); setLoading(false);
} catch (e) { } catch (e) {
setLoading(false); setLoading(false);
toast.error(`Upload failed, error: ${e}`); await message(String(e), { title: "Upload", kind: "error" });
} }
}; };
@@ -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 { useSlateStatic } from "slate-react";
import type { Contact } from "@lume/types";
import { toast } from "sonner";
import { User } from "@/components/user";
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>
);
}
+176 -119
View File
@@ -1,16 +1,13 @@
import { ComposeFilledIcon, TrashIcon } from "@lume/icons"; import { Note } from "@/components/note";
import { MentionNote } from "@/components/note/mentions/note";
import { User } from "@/components/user";
import { ComposeFilledIcon } from "@lume/icons";
import { LumeEvent, useEvent } from "@lume/system";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { import { cn, insertImage, insertNostrEvent, isImageUrl } from "@lume/utils";
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 { nip19 } from "nostr-tools";
import { useState } from "react"; import { useEffect, 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,
@@ -22,60 +19,65 @@ 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 { PowButton } from "./-components/pow";
import { MentionButton } from "./-components/mention"; import { WarningButton } from "./-components/warning";
import { MentionNote } from "@/components/note/mentions/note";
import { toast } from "sonner";
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())))),
); );
@@ -116,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}
@@ -178,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" />
</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;
@@ -200,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);
@@ -252,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>
); );
}; };
@@ -295,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>
); );
}; };
@@ -307,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,72 +0,0 @@
import { useEvent } from "@lume/ark";
import type { Event } from "@lume/types";
import { Box, Container, Spinner } from "@lume/ui";
import { Note } from "@/components/note";
import { createFileRoute } from "@tanstack/react-router";
import { WindowVirtualizer } from "virtua";
import { ReplyList } from "./-components/replyList";
export const Route = createFileRoute("/events/$eventId")({
beforeLoad: async ({ context }) => {
const ark = context.ark;
const settings = await ark.get_settings();
return { settings };
},
component: Screen,
});
function Screen() {
const { eventId } = Route.useParams();
const { isLoading, isError, data } = useEvent(eventId);
if (isLoading) {
return (
<div className="flex h-full w-full items-center justify-center">
<Spinner className="size-5" />
</div>
);
}
if (isError) {
<div className="flex h-full w-full items-center justify-center">
<p>Not found.</p>
</div>;
}
return (
<Container withDrag>
<Box className="scrollbar-none">
<WindowVirtualizer>
<MainNote data={data} />
{data ? (
<ReplyList eventId={eventId} />
) : (
<div className="flex h-full w-full items-center justify-center">
<Spinner className="size-5" />
</div>
)}
</WindowVirtualizer>
</Box>
</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>
);
}
+143
View File
@@ -0,0 +1,143 @@
import { Note } from "@/components/note";
import { LumeEvent, NostrQuery } from "@lume/system";
import type { Meta } from "@lume/types";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { createFileRoute } from "@tanstack/react-router";
import { getCurrent } from "@tauri-apps/api/window";
import { useEffect, useRef, useState } from "react";
import { Virtualizer } from "virtua";
import NoteParent from "./-components/parent";
type Payload = {
raw: string;
parsed: Meta;
};
export const Route = createFileRoute("/events/$id")({
beforeLoad: async () => {
const settings = await NostrQuery.getUserSettings();
return { settings };
},
loader: async ({ params }) => {
const event = await NostrQuery.getEvent(params.id);
return event;
},
component: Screen,
});
function Screen() {
const ref = useRef<HTMLDivElement>(null);
return (
<div className="h-full flex flex-col">
<div
data-tauri-drag-region
className="shrink-0 h-8 w-full border-b border-black/5 dark:border-white/5"
/>
<ScrollArea.Root
type={"scroll"}
scrollHideDelay={300}
className="overflow-hidden size-full flex-1"
>
<ScrollArea.Viewport ref={ref} className="h-full p-3">
<RootEvent />
<Virtualizer scrollRef={ref}>
<ReplyList />
</Virtualizer>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
orientation="vertical"
>
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
</ScrollArea.Scrollbar>
<ScrollArea.Corner className="bg-transparent" />
</ScrollArea.Root>
</div>
);
}
function RootEvent() {
const event = Route.useLoaderData();
return (
<Note.Provider event={event}>
<Note.Root className="bg-white dark:bg-black/10 rounded-xl shadow-primary dark:ring-1 dark:ring-white/5">
<div className="flex items-center justify-between px-3 h-14">
<Note.User />
<Note.Menu />
</div>
<Note.ContentLarge className="px-3" />
<div className="flex items-center gap-2 px-3 mt-6 h-12 rounded-b-xl bg-neutral-50 dark:bg-white/5">
<Note.Reply large />
<Note.Repost large />
<Note.Zap large />
</div>
</Note.Root>
</Note.Provider>
);
}
function ReplyList() {
const event = Route.useLoaderData();
const [replies, setReplies] = useState<LumeEvent[]>([]);
useEffect(() => {
const unlistenEvent = getCurrent().listen<Payload>("new_reply", (data) => {
const event = LumeEvent.from(data.payload.raw, data.payload.parsed);
setReplies((prev) => [event, ...prev]);
});
const unlistenWindow = getCurrent().onCloseRequested(async () => {
await event.unlistenEventReply();
await getCurrent().destroy();
});
return () => {
unlistenEvent.then((f) => f());
unlistenWindow.then((f) => f());
};
}, []);
useEffect(() => {
let mounted = true;
async function getReplies() {
const data = await event.getEventReplies();
if (mounted) {
setReplies(data);
// Start listen for new reply
event.listenEventReply();
}
}
getReplies();
return () => {
mounted = false;
};
}, []);
return (
<div>
<div className="flex items-center text-sm font-semibold h-14 text-neutral-600 dark:text-white/30">
All replies
</div>
<div className="flex flex-col gap-3">
{!replies.length ? (
<div className="flex items-center justify-center w-full">
<div className="flex flex-col items-center justify-center gap-2 py-4">
<h3 className="text-3xl">👋</h3>
<p className="leading-none text-neutral-600 dark:text-neutral-400">
Be the first to Reply!
</p>
</div>
</div>
) : (
replies.map((event) => <NoteParent key={event.id} event={event} />)
)}
</div>
</div>
);
}
@@ -0,0 +1,41 @@
import { Note } from "@/components/note";
import type { LumeEvent } from "@lume/system";
import NoteParent from "./parent";
import { memo } from "react";
const NoteChild = memo(function NoteChild({ event }: { event: LumeEvent }) {
return (
<Note.Provider event={event}>
<Note.Root className="flex flex-col gap-6 mb-3">
<div>
<div className="flex items-center justify-between">
<Note.User />
<Note.Menu />
</div>
<div className="flex gap-2">
<div className="w-8 shrink-0" />
<div className="flex-1 flex flex-col gap-2">
<Note.ContentLarge />
<div className="flex items-center gap-1">
<Note.Reply />
<Note.Repost />
<Note.Zap />
</div>
</div>
</div>
</div>
{event.replies?.length ? (
<div className="flex flex-col gap-3 pl-4">
<div className="flex flex-col pl-6 border-l border-black/10 dark:border-white/10">
{event.replies?.map((childEvent) => (
<NoteParent key={childEvent.id} event={childEvent} />
))}
</div>
</div>
) : null}
</Note.Root>
</Note.Provider>
);
});
export default NoteChild;
@@ -0,0 +1,41 @@
import { Note } from "@/components/note";
import type { LumeEvent } from "@lume/system";
import NoteChild from "./child";
import { memo } from "react";
const NoteParent = memo(function NoteParent({ event }: { event: LumeEvent }) {
return (
<Note.Provider event={event}>
<Note.Root className="flex flex-col gap-6 mb-3">
<div>
<div className="flex items-center justify-between">
<Note.User />
<Note.Menu />
</div>
<div className="flex gap-2">
<div className="w-8 shrink-0" />
<div className="flex-1 flex flex-col gap-2">
<Note.ContentLarge />
<div className="flex items-center gap-1">
<Note.Reply />
<Note.Repost />
<Note.Zap />
</div>
</div>
</div>
</div>
{event.replies?.length ? (
<div className="flex flex-col gap-3 pl-4">
<div className="flex flex-col gap-3 pl-6 border-l border-black/10 dark:border-white/10">
{event.replies?.map((childEvent) => (
<NoteChild key={childEvent.id} event={childEvent} />
))}
</div>
</div>
) : null}
</Note.Root>
</Note.Provider>
);
});
export default NoteParent;
@@ -1,36 +0,0 @@
import type { EventWithReplies } from "@lume/types";
import { cn } from "@lume/utils";
import { SubReply } from "./subReply";
import { Note } from "@/components/note";
export function Reply({ event }: { event: EventWithReplies }) {
return (
<Note.Provider event={event}>
<Note.Root className="border-t border-neutral-100 dark:border-neutral-900">
<div className="px-3 h-14 flex items-center justify-between">
<Note.User />
<Note.Menu />
</div>
<Note.ContentLarge className="px-3" />
<div className="mt-3 flex items-center gap-4 px-3 h-14">
<Note.Reply />
<Note.Repost />
<Note.Zap />
</div>
<div
className={cn(
event.replies?.length > 0
? "py-2 pl-3 flex flex-col gap-3 divide-y divide-neutral-100 bg-neutral-50 dark:bg-white/5 border-l-2 border-blue-500 dark:divide-neutral-900"
: "",
)}
>
{event.replies?.length > 0
? event.replies?.map((childEvent) => (
<SubReply key={childEvent.id} event={childEvent} />
))
: null}
</div>
</Note.Root>
</Note.Provider>
);
}
@@ -1,51 +0,0 @@
import type { EventWithReplies } from "@lume/types";
import { Spinner } from "@lume/ui";
import { cn } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Reply } from "./reply";
export function ReplyList({
eventId,
className,
}: {
eventId: string;
className?: string;
}) {
const { ark } = useRouteContext({ strict: false });
const [t] = useTranslation();
const [data, setData] = useState<null | EventWithReplies[]>(null);
useEffect(() => {
async function getReplies() {
const events = await ark.get_event_thread(eventId);
setData(events);
}
getReplies();
}, [eventId]);
return (
<div className={cn("flex flex-col", className)}>
<div className="h-11 flex px-3 items-center text-sm font-semibold text-neutral-700 dark:text-neutral-300 border-t border-neutral-100 dark:border-neutral-900">
Replies ({data?.length ?? 0})
</div>
{!data ? (
<div className="flex h-16 items-center justify-center p-3">
<Spinner className="size-5" />
</div>
) : data.length === 0 ? (
<div className="flex w-full items-center justify-center">
<div className="flex flex-col items-center justify-center gap-2 py-6">
<h3 className="text-3xl">👋</h3>
<p className="leading-none text-neutral-600 dark:text-neutral-400">
{t("note.reply.empty")}
</p>
</div>
</div>
) : (
data.map((event) => <Reply key={event.id} event={event} />)
)}
</div>
);
}
@@ -1,21 +0,0 @@
import type { Event } from "@lume/types";
import { Note } from "@/components/note";
export function SubReply({ event }: { event: Event; rootEventId?: string }) {
return (
<Note.Provider event={event}>
<Note.Root>
<div className="px-3 h-14 flex items-center justify-between">
<Note.User />
<Note.Menu />
</div>
<Note.ContentLarge className="px-3" />
<div className="mt-3 flex items-center gap-4 px-3">
<Note.Reply />
<Note.Repost />
<Note.Zap />
</div>
</Note.Root>
</Note.Provider>
);
}
+87 -102
View File
@@ -2,11 +2,14 @@ import { Conversation } from "@/components/conversation";
import { Quote } from "@/components/quote"; import { Quote } from "@/components/quote";
import { RepostNote } from "@/components/repost"; import { RepostNote } from "@/components/repost";
import { TextNote } from "@/components/text"; import { TextNote } from "@/components/text";
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons"; import { ArrowRightCircleIcon } from "@lume/icons";
import { type ColumnRouteSearch, type Event, Kind } from "@lume/types"; import { type LumeEvent, NostrQuery } from "@lume/system";
import { type ColumnRouteSearch, Kind } from "@lume/types";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { useInfiniteQuery } from "@tanstack/react-query"; import { useInfiniteQuery } from "@tanstack/react-query";
import { Link, createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { useCallback, useRef } from "react";
import { Virtualizer } from "virtua"; import { Virtualizer } from "virtua";
export const Route = createFileRoute("/global")({ export const Route = createFileRoute("/global")({
@@ -17,10 +20,8 @@ export const Route = createFileRoute("/global")({
name: search.name, name: search.name,
}; };
}, },
beforeLoad: async ({ context }) => { beforeLoad: async () => {
const ark = context.ark; const settings = await NostrQuery.getUserSettings();
const settings = await ark.get_settings();
return { settings }; return { settings };
}, },
component: Screen, component: Screen,
@@ -28,7 +29,6 @@ export const Route = createFileRoute("/global")({
export function Screen() { export function Screen() {
const { label, account } = Route.useSearch(); const { label, account } = Route.useSearch();
const { ark } = Route.useRouteContext();
const { const {
data, data,
isLoading, isLoading,
@@ -40,111 +40,96 @@ export function Screen() {
queryKey: [label, account], queryKey: [label, account],
initialPageParam: 0, initialPageParam: 0,
queryFn: async ({ pageParam }: { pageParam: number }) => { queryFn: async ({ pageParam }: { pageParam: number }) => {
const events = await ark.get_global_events(20, pageParam); const events = await NostrQuery.getGlobalEvents(pageParam);
return events; return events;
}, },
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1, getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
select: (data) => data?.pages.flatMap((page) => page), select: (data) => data?.pages.flat(),
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}); });
const renderItem = (event: Event) => { const ref = useRef<HTMLDivElement>(null);
if (!event) return;
switch (event.kind) {
case Kind.Repost:
return <RepostNote key={event.id} event={event} />;
default: {
const isConversation =
event.tags.filter((tag) => tag[0] === "e" && tag[3] !== "mention")
.length > 0;
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0;
if (isConversation) { const renderItem = useCallback(
return <Conversation key={event.id} event={event} className="mb-3" />; (event: LumeEvent) => {
if (!event) return;
switch (event.kind) {
case Kind.Repost:
return <RepostNote key={event.id} event={event} className="mb-3" />;
default: {
if (event.isConversation) {
return (
<Conversation key={event.id} className="mb-3" event={event} />
);
}
if (event.isQuote) {
return <Quote key={event.id} event={event} className="mb-3" />;
}
return <TextNote key={event.id} event={event} className="mb-3" />;
} }
if (isQuote) {
return <Quote key={event.id} event={event} className="mb-3" />;
}
return <TextNote key={event.id} event={event} className="mb-3" />;
} }
} },
}; [data],
);
return ( return (
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none"> <ScrollArea.Root
{isFetching && !isLoading && !isFetchingNextPage ? ( type={"scroll"}
<div className="w-full h-11 flex items-center justify-center"> scrollHideDelay={300}
<div className="flex items-center justify-center gap-2"> className="overflow-hidden size-full"
<Spinner className="size-5" /> >
<span className="text-sm font-medium">Fetching new notes...</span> <ScrollArea.Viewport ref={ref} className="h-full px-3 pb-3">
</div> <Virtualizer scrollRef={ref}>
</div> {isFetching && !isLoading && !isFetchingNextPage ? (
) : null} <div className="flex items-center justify-center w-full mb-3 h-12 bg-black/5 dark:bg-white/5 rounded-xl">
{isLoading ? ( <div className="flex items-center justify-center gap-2">
<div className="flex h-16 w-full items-center justify-center gap-2"> <Spinner className="size-5" />
<Spinner className="size-5" /> <span className="text-sm font-medium">
<span className="text-sm font-medium">Loading...</span> Getting new notes...
</div> </span>
) : !data.length ? ( </div>
<Empty /> </div>
) : ( ) : null}
<Virtualizer overscan={3}> {isLoading ? (
{data.map((item) => renderItem(item))} <div className="flex items-center justify-center w-full h-16 gap-2">
</Virtualizer>
)}
{data?.length && hasNextPage ? (
<div>
<button
type="button"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage || isLoading}
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-black/5 px-3 font-medium hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
>
{isFetchingNextPage ? (
<Spinner className="size-5" /> <Spinner className="size-5" />
) : ( <span className="text-sm font-medium">Loading...</span>
<> </div>
<ArrowRightCircleIcon className="size-5" /> ) : !data.length ? (
Load more <div className="flex items-center justify-center">
</> Yo. You're catching up on all the things happening around you.
)} </div>
</button> ) : (
</div> data.map((item) => renderItem(item))
) : null} )}
</div> {data?.length && hasNextPage ? (
); <div>
} <button
type="button"
function Empty() { onClick={() => fetchNextPage()}
return ( disabled={isFetchingNextPage || isLoading}
<div className="flex flex-col py-10 gap-10"> className="inline-flex items-center justify-center w-full gap-2 px-3 font-medium h-9 rounded-xl bg-black/5 hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
<div className="text-center flex flex-col items-center justify-center"> >
<div className="size-24 bg-blue-100 flex flex-col items-center justify-end overflow-hidden dark:bg-blue-900 rounded-full mb-8"> {isFetchingNextPage ? (
<div className="w-12 h-16 bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900 rounded-t-lg" /> <Spinner className="size-5" />
</div> ) : (
<p className="text-lg font-medium">Your newsfeed is empty</p> <>
<p className="leading-tight text-neutral-700 dark:text-neutral-300"> <ArrowRightCircleIcon className="size-5" />
Here are few suggestions to get started. Load more
</p> </>
</div> )}
<div className="flex flex-col px-3 gap-2"> </button>
<Link </div>
to="/trending/notes" ) : null}
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3" </Virtualizer>
> </ScrollArea.Viewport>
<ArrowRightIcon className="size-5" /> <ScrollArea.Scrollbar
Show trending notes className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
</Link> orientation="vertical"
<Link >
to="/trending/users" <ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3" </ScrollArea.Scrollbar>
> <ScrollArea.Corner className="bg-transparent" />
<ArrowRightIcon className="size-5" /> </ScrollArea.Root>
Discover trending users
</Link>
</div>
</div>
); );
} }
+91 -108
View File
@@ -2,11 +2,14 @@ import { Conversation } from "@/components/conversation";
import { Quote } from "@/components/quote"; import { Quote } from "@/components/quote";
import { RepostNote } from "@/components/repost"; import { RepostNote } from "@/components/repost";
import { TextNote } from "@/components/text"; import { TextNote } from "@/components/text";
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons"; import { ArrowRightCircleIcon } from "@lume/icons";
import { type ColumnRouteSearch, type Event, Kind } from "@lume/types"; import { type LumeEvent, NostrQuery } from "@lume/system";
import { type ColumnRouteSearch, Kind } from "@lume/types";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { useInfiniteQuery } from "@tanstack/react-query"; import { useInfiniteQuery } from "@tanstack/react-query";
import { Link, createFileRoute, redirect } from "@tanstack/react-router"; import { createFileRoute, redirect } from "@tanstack/react-router";
import { useCallback, useRef } from "react";
import { Virtualizer } from "virtua"; import { Virtualizer } from "virtua";
export const Route = createFileRoute("/group")({ export const Route = createFileRoute("/group")({
@@ -17,11 +20,10 @@ export const Route = createFileRoute("/group")({
name: search.name, name: search.name,
}; };
}, },
beforeLoad: async ({ search, context }) => { beforeLoad: async ({ search }) => {
const ark = context.ark; const key = `lume:group:${search.label}`;
const key = `lume_group_${search.label}`; const groups: string[] = await NostrQuery.getNstore(key);
const groups = (await ark.get_nstore(key)) as string[]; const settings = await NostrQuery.getUserSettings();
const settings = await ark.get_settings();
if (!groups?.length) { if (!groups?.length) {
throw redirect({ throw redirect({
@@ -33,17 +35,14 @@ export const Route = createFileRoute("/group")({
}); });
} }
return { return { groups, settings };
groups,
settings,
};
}, },
component: Screen, component: Screen,
}); });
export function Screen() { export function Screen() {
const { label, account } = Route.useSearch(); const { label, account } = Route.useSearch();
const { ark, groups } = Route.useRouteContext(); const { groups } = Route.useRouteContext();
const { const {
data, data,
isLoading, isLoading,
@@ -55,112 +54,96 @@ export function Screen() {
queryKey: [label, account], queryKey: [label, account],
initialPageParam: 0, initialPageParam: 0,
queryFn: async ({ pageParam }: { pageParam: number }) => { queryFn: async ({ pageParam }: { pageParam: number }) => {
const events = await ark.get_local_events(groups, 20, pageParam); const events = await NostrQuery.getGroupEvents(groups, pageParam);
return events; return events;
}, },
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1, getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
select: (data) => select: (data) => data?.pages.flat(),
data?.pages.flatMap((page) => page.filter((ev) => ev.kind === Kind.Text)),
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}); });
const renderItem = (event: Event) => { const ref = useRef<HTMLDivElement>(null);
if (!event) return;
switch (event.kind) {
case Kind.Repost:
return <RepostNote key={event.id} event={event} />;
default: {
const isConversation =
event.tags.filter((tag) => tag[0] === "e" && tag[3] !== "mention")
.length > 0;
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0;
if (isConversation) { const renderItem = useCallback(
return <Conversation key={event.id} event={event} className="mb-3" />; (event: LumeEvent) => {
if (!event) return;
switch (event.kind) {
case Kind.Repost:
return <RepostNote key={event.id} event={event} className="mb-3" />;
default: {
if (event.isConversation) {
return (
<Conversation key={event.id} className="mb-3" event={event} />
);
}
if (event.isQuote) {
return <Quote key={event.id} event={event} className="mb-3" />;
}
return <TextNote key={event.id} event={event} className="mb-3" />;
} }
if (isQuote) {
return <Quote key={event.id} event={event} className="mb-3" />;
}
return <TextNote key={event.id} event={event} className="mb-3" />;
} }
} },
}; [data],
);
return ( return (
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none"> <ScrollArea.Root
{isFetching && !isLoading && !isFetchingNextPage ? ( type={"scroll"}
<div className="mb-3 w-full h-11 flex items-center justify-center bg-black/10 dark:bg-white/10 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50"> scrollHideDelay={300}
<div className="flex items-center justify-center gap-2"> className="overflow-hidden size-full"
<Spinner className="size-5" /> >
<span className="text-sm font-medium">Fetching new notes...</span> <ScrollArea.Viewport ref={ref} className="h-full px-3 pb-3">
</div> <Virtualizer scrollRef={ref}>
</div> {isFetching && !isLoading && !isFetchingNextPage ? (
) : null} <div className="flex items-center justify-center w-full mb-3 h-12 bg-black/5 dark:bg-white/5 rounded-xl">
{isLoading ? ( <div className="flex items-center justify-center gap-2">
<div className="flex h-16 w-full items-center justify-center gap-2"> <Spinner className="size-5" />
<Spinner className="size-5" /> <span className="text-sm font-medium">
<span className="text-sm font-medium">Loading...</span> Getting new notes...
</div> </span>
) : !data.length ? ( </div>
<Empty /> </div>
) : ( ) : null}
<Virtualizer overscan={3}> {isLoading ? (
{data.map((item) => renderItem(item))} <div className="flex items-center justify-center w-full h-16 gap-2">
</Virtualizer>
)}
{data?.length && hasNextPage ? (
<div>
<button
type="button"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage || isLoading}
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-neutral-100 px-3 font-medium hover:bg-neutral-50 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
>
{isFetchingNextPage ? (
<Spinner className="size-5" /> <Spinner className="size-5" />
) : ( <span className="text-sm font-medium">Loading...</span>
<> </div>
<ArrowRightCircleIcon className="size-5" /> ) : !data.length ? (
Load more <div className="flex items-center justify-center">
</> Yo. You're catching up on all the things happening around you.
)} </div>
</button> ) : (
</div> data.map((item) => renderItem(item))
) : null} )}
</div> {data?.length && hasNextPage ? (
); <div>
} <button
type="button"
function Empty() { onClick={() => fetchNextPage()}
return ( disabled={isFetchingNextPage || isLoading}
<div className="flex flex-col py-10 gap-10"> className="inline-flex items-center justify-center w-full gap-2 px-3 font-medium h-9 rounded-xl bg-black/5 hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
<div className="text-center flex flex-col items-center justify-center"> >
<div className="size-24 bg-blue-100 flex flex-col items-center justify-end overflow-hidden dark:bg-blue-900 rounded-full mb-8"> {isFetchingNextPage ? (
<div className="w-12 h-16 bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900 rounded-t-lg" /> <Spinner className="size-5" />
</div> ) : (
<p className="text-lg font-medium">Your newsfeed is empty</p> <>
<p className="leading-tight text-neutral-700 dark:text-neutral-300"> <ArrowRightCircleIcon className="size-5" />
Here are few suggestions to get started. Load more
</p> </>
</div> )}
<div className="flex flex-col px-3 gap-2"> </button>
<Link </div>
to="/trending/notes" ) : null}
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3" </Virtualizer>
> </ScrollArea.Viewport>
<ArrowRightIcon className="size-5" /> <ScrollArea.Scrollbar
Show trending notes className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
</Link> orientation="vertical"
<Link >
to="/trending/users" <ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3" </ScrollArea.Scrollbar>
> <ScrollArea.Corner className="bg-transparent" />
<ArrowRightIcon className="size-5" /> </ScrollArea.Root>
Discover trending users
</Link>
</div>
</div>
); );
} }
+89 -72
View File
@@ -1,31 +1,30 @@
import { PlusIcon } from "@lume/icons";
import { Spinner } from "@lume/ui";
import { User } from "@/components/user"; import { User } from "@/components/user";
import { checkForAppUpdates } from "@lume/utils"; import { PlusIcon, RelayIcon } from "@lume/icons";
import { NostrAccount } from "@lume/system";
import { Spinner } from "@lume/ui";
import { checkForAppUpdates, displayNpub } from "@lume/utils";
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { createFileRoute, redirect } from "@tanstack/react-router"; import { createFileRoute, redirect } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core"; import { message } from "@tauri-apps/plugin-dialog";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner";
export const Route = createFileRoute("/")({ export const Route = createFileRoute("/")({
beforeLoad: async ({ context }) => { beforeLoad: async () => {
// check for app updates // Check for app updates
// TODO: move this function to rust
await checkForAppUpdates(true); await checkForAppUpdates(true);
const ark = context.ark; // Get all accounts
const accounts = await ark.get_accounts(); // TODO: use emit & listen
const accounts = await NostrAccount.getAccounts();
if (!accounts.length) { if (accounts.length < 1) {
throw redirect({ throw redirect({
to: "/landing/", to: "/landing",
replace: true, replace: true,
}); });
} }
// Run notification service
await invoke("run_notification", { accounts });
return { accounts }; return { accounts };
}, },
component: Screen, component: Screen,
@@ -33,16 +32,17 @@ export const Route = createFileRoute("/")({
function Screen() { function Screen() {
const navigate = Route.useNavigate(); const navigate = Route.useNavigate();
const { ark, accounts } = Route.useRouteContext(); const context = Route.useRouteContext();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState({ npub: "", status: false });
const select = async (npub: string) => { const select = async (npub: string) => {
try { try {
setLoading(true); setLoading({ npub, status: true });
const loadAccount = await ark.load_account(npub); const status = await NostrAccount.loadAccount(npub);
if (loadAccount) {
if (status) {
return navigate({ return navigate({
to: "/$account/home", to: "/$account/home",
params: { account: npub }, params: { account: npub },
@@ -50,8 +50,11 @@ function Screen() {
}); });
} }
} catch (e) { } catch (e) {
setLoading(false); setLoading({ npub: "", status: false });
toast.error(String(e)); await message(String(e), {
title: "Account",
kind: "error",
});
} }
}; };
@@ -62,62 +65,76 @@ function Screen() {
}); });
return ( return (
<div className="relative flex h-full w-full items-center justify-center"> <div
<div className="relative z-20 flex flex-col items-center gap-16"> data-tauri-drag-region
<div className="text-center text-white"> className="relative flex flex-col items-center justify-between w-full h-full"
<h2 className="mb-1 text-2xl">{currentDate}</h2> >
<div
data-tauri-drag-region
className="absolute top-0 left-0 h-14 w-full"
/>
<div className="flex items-end justify-center flex-1 w-full px-4 pb-10">
<div className="text-center">
<h2 className="mb-1 text-lg text-neutral-700 dark:text-neutral-300">
{currentDate}
</h2>
<h2 className="text-2xl font-semibold">Welcome back!</h2> <h2 className="text-2xl font-semibold">Welcome back!</h2>
</div> </div>
<div className="flex flex-wrap px-3 items-center justify-center gap-6"> </div>
{loading ? ( <div className="flex flex-col items-center flex-1 w-full gap-3">
<div className="inline-flex size-6 items-center justify-center"> <div className="flex flex-col w-full max-w-sm mx-auto overflow-hidden bg-white divide-y divide-neutral-100 dark:divide-white/5 rounded-xl shadow-primary dark:bg-white/10 dark:ring-1 ring-white/15">
<Spinner className="size-6 text-white" /> {context.accounts.map((account) => (
</div> <div
) : ( key={account}
<> onClick={() => select(account)}
{accounts.map((account) => ( onKeyDown={() => select(account)}
<button className="flex items-center justify-between hover:bg-black/5 dark:hover:bg-white/5"
type="button" >
key={account} <User.Provider pubkey={account}>
onClick={() => select(account)} <User.Root className="flex items-center gap-2.5 p-3">
> <User.Avatar className="rounded-full size-10" />
<User.Provider pubkey={account}> <div className="inline-flex flex-col items-start">
<User.Root className="flex h-36 w-32 flex-col items-center justify-center gap-4 rounded-2xl p-2 hover:bg-white/10 dark:hover:bg-black/10"> <User.Name className="max-w-[6rem] truncate font-medium leading-tight" />
<User.Avatar className="size-20 rounded-full object-cover" /> <span className="text-sm text-neutral-700 dark:text-neutral-300">
<User.Name className="max-w-[5rem] truncate text-lg font-medium leading-tight text-white" /> {displayNpub(account, 16)}
</User.Root> </span>
</User.Provider>
</button>
))}
<Link to="/landing/">
<div className="flex h-36 w-32 flex-col items-center justify-center gap-4 rounded-2xl p-2 text-white hover:bg-white/10 dark:hover:bg-black/10">
<div className="flex size-20 items-center justify-center rounded-full bg-white/20 dark:bg-black/20">
<PlusIcon className="size-8" />
</div> </div>
<p className="text-lg font-medium leading-tight">Add</p> </User.Root>
</div> </User.Provider>
</Link> <div className="inline-flex items-center justify-center size-10">
</> {loading.npub === account ? (
)} loading.status ? (
<Spinner />
) : null
) : null}
</div>
</div>
))}
<Link
to="/landing"
className="flex items-center justify-between hover:bg-black/5 dark:hover:bg-white/5"
>
<div className="flex items-center gap-2.5 p-3">
<div className="inline-flex items-center justify-center rounded-full size-10 bg-neutral-200 dark:bg-white/10">
<PlusIcon className="size-5" />
</div>
<span className="max-w-[6rem] truncate text-sm font-medium leading-tight">
Add account
</span>
</div>
</Link>
</div>
<div className="w-full max-w-sm mx-auto">
<Link
to="/bootstrap-relays"
className="inline-flex items-center justify-center w-full h-8 gap-2 px-2 text-xs font-medium rounded-lg bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 text-neutral-700 dark:text-white/40"
>
<RelayIcon className="size-4" />
Custom Bootstrap Relays
</Link>
</div> </div>
</div> </div>
<div className="absolute z-10 h-full w-full bg-white/10 backdrop-blur-lg dark:bg-black/10" /> <div className="flex-1" />
<div className="absolute inset-0 h-full w-full">
<img
src="/lock-screen.jpg"
srcSet="/lock-screen@2x.jpg 2x"
alt="Lock Screen Background"
className="h-full w-full object-cover"
/>
<a
href="https://njump.me/nprofile1qqs9tuz9jpn57djg7nxunhyvuvk69g5zqaxdpvpqt9hwqv7395u9rpg6zq5uw"
target="_blank"
className="absolute bottom-3 right-3 z-50 rounded-md bg-white/20 px-2 py-1 text-xs font-medium text-white dark:bg-black/20"
rel="noreferrer"
>
Design by NoGood
</a>
</div>
</div> </div>
); );
} }
+62
View File
@@ -0,0 +1,62 @@
import { KeyIcon, RemoteIcon } from "@lume/icons";
import { Link, createLazyFileRoute } from "@tanstack/react-router";
export const Route = createLazyFileRoute("/landing")({
component: Screen,
});
function Screen() {
return (
<div
data-tauri-drag-region
className="flex flex-col items-center justify-center w-screen h-screen"
>
<div className="w-full max-w-xs mx-auto lg:max-w-md">
<div className="flex flex-col w-full gap-2 px-2 bg-white rounded-xl shadow-primary dark:bg-white/20 dark:ring-1 ring-neutral-800/50">
<div className="flex items-center h-20 border-b border-neutral-100 dark:border-white/5">
<Link
to="/auth/create-profile"
className="flex items-center justify-center w-full gap-2 px-2 rounded-lg h-14 hover:bg-neutral-100 dark:hover:bg-white/10"
>
<div className="inline-flex items-center justify-center rounded-full size-9 shrink-0">
<img
src="/icon.jpeg"
alt="App Icon"
className="object-cover rounded-full size-9"
/>
</div>
<div className="inline-flex flex-col flex-1">
<span className="font-semibold leading-tight">
Create new account
</span>
<span className="text-sm leading-tight text-neutral-500">
Use everywhere
</span>
</div>
</Link>
</div>
<div className="flex flex-col gap-1 pb-2.5">
<Link
to="/auth/import"
className="inline-flex items-center w-full gap-2 px-2 rounded-lg h-11 hover:bg-neutral-100 dark:hover:bg-white/10"
>
<div className="inline-flex items-center justify-center size-9">
<KeyIcon className="size-5 text-neutral-600 dark:text-neutral-400" />
</div>
Login with Private Key
</Link>
<Link
to="/auth/remote"
className="inline-flex items-center w-full gap-2 px-2 rounded-lg h-11 hover:bg-neutral-100 dark:hover:bg-white/10"
>
<div className="inline-flex items-center justify-center size-9">
<RemoteIcon className="size-5 text-neutral-600 dark:text-neutral-400" />
</div>
Nostr Connect
</Link>
</div>
</div>
</div>
</div>
);
}
@@ -1,85 +0,0 @@
import { KeyIcon, RemoteIcon } from "@lume/icons";
import { Link, createFileRoute } from "@tanstack/react-router";
import { useTranslation } from "react-i18next";
export const Route = createFileRoute("/landing/")({
component: Screen,
});
function Screen() {
const { t } = useTranslation();
return (
<div className="relative flex h-screen w-screen bg-black">
<div
data-tauri-drag-region
className="absolute left-0 top-0 z-50 h-16 w-full"
/>
<div className="z-20 flex h-full w-full flex-col items-center justify-between">
<div />
<div className="mx-auto flex w-full max-w-4xl flex-col items-center gap-10">
<div className="flex flex-col items-center text-center">
<img
src="/heading-en.png"
srcSet="/heading-en@2x.png 2x"
alt="lume"
className="xl:w-2/3"
/>
<p className="mt-4 whitespace-pre-line text-lg font-medium leading-snug text-white/70">
{t("welcome.title")}
</p>
</div>
<div className="mx-auto flex w-full max-w-sm flex-col gap-4">
<Link
to="/auth/new/profile"
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-white font-medium text-blue-500 backdrop-blur-lg hover:bg-white/90"
>
{t("welcome.signup")}
</Link>
<div className="flex items-center gap-2">
<div className="h-px flex-1 bg-white/20" />
<div className="text-white/70">{t("login.or")}</div>
<div className="h-px flex-1 bg-white/20" />
</div>
<div className="flex flex-col gap-2">
<Link
to="/auth/remote"
className="group inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-white/20 px-3 font-medium text-white backdrop-blur-md hover:bg-white/40"
>
<RemoteIcon className="size-5 text-neutral-600 dark:text-neutral-400 group-hover:text-neutral-400 dark:group-hover:text-neutral-600" />
Nostr Connect
<div className="size-5" />
</Link>
<Link
to="/auth/privkey"
className="group inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-white/20 px-3 font-medium text-white backdrop-blur-md hover:bg-white/40"
>
<KeyIcon className="size-5 text-neutral-600 dark:text-neutral-400 group-hover:text-neutral-400 dark:group-hover:text-neutral-600" />
Private Key
<div className="size-5" />
</Link>
</div>
</div>
</div>
<div className="flex h-11 items-center justify-center" />
</div>
<div className="absolute z-10 h-full w-full bg-black/5 backdrop-blur-sm" />
<div className="absolute inset-0 h-full w-full">
<img
src="/lock-screen.jpg"
srcSet="/lock-screen@2x.jpg 2x"
alt="Lock Screen Background"
className="h-full w-full object-cover"
/>
<a
href="https://njump.me/nprofile1qqs9tuz9jpn57djg7nxunhyvuvk69g5zqaxdpvpqt9hwqv7395u9rpg6zq5uw"
target="_blank"
className="absolute bottom-3 right-3 z-50 rounded-md bg-white/20 px-2 py-1 text-xs font-medium text-white backdrop-blur-md dark:bg-black/20"
rel="noreferrer"
>
Design by NoGood
</a>
</div>
</div>
);
}
+169 -76
View File
@@ -2,14 +2,23 @@ import { Conversation } from "@/components/conversation";
import { Quote } from "@/components/quote"; import { Quote } from "@/components/quote";
import { RepostNote } from "@/components/repost"; import { RepostNote } from "@/components/repost";
import { TextNote } from "@/components/text"; import { TextNote } from "@/components/text";
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons"; import { ArrowRightCircleIcon, ArrowUpIcon } from "@lume/icons";
import { type ColumnRouteSearch, type Event, Kind } from "@lume/types"; import { LumeEvent, NostrAccount, NostrQuery } from "@lume/system";
import { type ColumnRouteSearch, Kind, type Meta } from "@lume/types";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { useInfiniteQuery } from "@tanstack/react-query"; import * as ScrollArea from "@radix-ui/react-scroll-area";
import { Link, redirect } from "@tanstack/react-router"; import { type InfiniteData, useInfiniteQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute, redirect } from "@tanstack/react-router";
import { listen } from "@tauri-apps/api/event";
import { getCurrent } from "@tauri-apps/api/window";
import { useCallback, useEffect, useRef, useState } from "react";
import { Virtualizer } from "virtua"; import { Virtualizer } from "virtua";
type Payload = {
raw: string;
parsed: Meta;
};
export const Route = createFileRoute("/newsfeed")({ export const Route = createFileRoute("/newsfeed")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => { validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
return { return {
@@ -18,12 +27,11 @@ export const Route = createFileRoute("/newsfeed")({
name: search.name, name: search.name,
}; };
}, },
beforeLoad: async ({ search, context }) => { beforeLoad: async ({ search }) => {
const ark = context.ark; const isContactListEmpty = await NostrAccount.isContactListEmpty();
const settings = await ark.get_settings(); const settings = await NostrQuery.getUserSettings();
const contacts = await ark.get_contact_list();
if (!contacts.length) { if (isContactListEmpty) {
throw redirect({ throw redirect({
to: "/create-newsfeed/users", to: "/create-newsfeed/users",
search: { search: {
@@ -33,14 +41,14 @@ export const Route = createFileRoute("/newsfeed")({
}); });
} }
return { settings, contacts }; return { settings };
}, },
component: Screen, component: Screen,
}); });
export function Screen() { export function Screen() {
const { queryClient } = Route.useRouteContext();
const { label, account } = Route.useSearch(); const { label, account } = Route.useSearch();
const { ark, contacts } = Route.useRouteContext();
const { const {
data, data,
isLoading, isLoading,
@@ -52,81 +60,166 @@ export function Screen() {
queryKey: [label, account], queryKey: [label, account],
initialPageParam: 0, initialPageParam: 0,
queryFn: async ({ pageParam }: { pageParam: number }) => { queryFn: async ({ pageParam }: { pageParam: number }) => {
const events = await ark.get_local_events(contacts, 20, pageParam); const events = await NostrQuery.getLocalEvents(pageParam);
return events; return events;
}, },
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1, getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
select: (data) => data?.pages.flatMap((page) => page), select: (data) => data?.pages.flat(),
refetchOnWindowFocus: false,
}); });
const renderItem = (event: Event) => { const ref = useRef<HTMLDivElement>(null);
if (!event) return;
switch (event.kind) {
case Kind.Repost:
return <RepostNote key={event.id} event={event} />;
default: {
const isConversation =
event.tags.filter((tag) => tag[0] === "e" && tag[3] !== "mention")
.length > 0;
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0;
if (isConversation) { const renderItem = useCallback(
return <Conversation key={event.id} event={event} className="mb-3" />; (event: LumeEvent) => {
if (!event) return;
switch (event.kind) {
case Kind.Repost:
return <RepostNote key={event.id} event={event} className="mb-3" />;
default: {
if (event.isConversation) {
return (
<Conversation key={event.id} className="mb-3" event={event} />
);
}
if (event.isQuote) {
return <Quote key={event.id} event={event} className="mb-3" />;
}
return <TextNote key={event.id} event={event} className="mb-3" />;
} }
if (isQuote) {
return <Quote key={event.id} event={event} className="mb-3" />;
}
return <TextNote key={event.id} event={event} className="mb-3" />;
} }
} },
}; [data],
);
useEffect(() => {
const unlisten = listen("synced", async () => {
await queryClient.invalidateQueries({ queryKey: [label, account] });
});
return () => {
unlisten.then((f) => f());
};
}, []);
return ( return (
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none"> <ScrollArea.Root
{isFetching && !isLoading && !isFetchingNextPage ? ( type={"scroll"}
<div className="mb-3 w-full h-11 flex items-center justify-center bg-black/10 dark:bg-white/10 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50"> scrollHideDelay={300}
<div className="flex items-center justify-center gap-2"> className="overflow-hidden size-full"
<Spinner className="size-5" /> >
<span className="text-sm font-medium">Fetching new notes...</span> <ScrollArea.Viewport ref={ref} className="relative h-full px-3 pb-3">
</div> <Listerner />
</div> <Virtualizer scrollRef={ref}>
) : null} {isFetching && !isLoading && !isFetchingNextPage ? (
{isLoading ? ( <div className="flex items-center justify-center w-full mb-3 h-12 bg-black/5 dark:bg-white/5 rounded-xl">
<div className="flex h-16 w-full items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
<Spinner className="size-5" /> <Spinner className="size-5" />
<span className="text-sm font-medium">Loading...</span> <span className="text-sm font-medium">
</div> Getting new notes...
) : !data.length ? ( </span>
<div className="flex items-center justify-center"> </div>
Yo. You're catching up on all the things happening around you. </div>
</div> ) : null}
) : ( {isLoading ? (
<Virtualizer overscan={3}> <div className="flex items-center justify-center w-full h-16 gap-2">
{data.map((item) => renderItem(item))}
</Virtualizer>
)}
{data?.length && hasNextPage ? (
<div>
<button
type="button"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage || isLoading}
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-black/5 px-3 font-medium hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
>
{isFetchingNextPage ? (
<Spinner className="size-5" /> <Spinner className="size-5" />
) : ( <span className="text-sm font-medium">Loading...</span>
<> </div>
<ArrowRightCircleIcon className="size-5" /> ) : !data.length ? (
Load more <div className="flex items-center justify-center">
</> Yo. You're catching up on all the things happening around you.
)} </div>
</button> ) : (
</div> data.map((item) => renderItem(item))
) : null} )}
{data?.length && hasNextPage ? (
<div>
<button
type="button"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage || isLoading}
className="inline-flex items-center justify-center w-full gap-2 px-3 font-medium h-9 rounded-xl bg-black/5 hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
>
{isFetchingNextPage ? (
<Spinner className="size-5" />
) : (
<>
<ArrowRightCircleIcon className="size-5" />
Load more
</>
)}
</button>
</div>
) : null}
</Virtualizer>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
orientation="vertical"
>
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
</ScrollArea.Scrollbar>
<ScrollArea.Corner className="bg-transparent" />
</ScrollArea.Root>
);
}
function Listerner() {
const { queryClient } = Route.useRouteContext();
const { label, account } = Route.useSearch();
const [events, setEvents] = useState<LumeEvent[]>([]);
const pushNewEvents = async () => {
await queryClient.setQueryData(
[label, account],
(oldData: InfiniteData<LumeEvent[], number> | undefined) => {
const firstPage = oldData?.pages[0];
if (firstPage) {
return {
...oldData,
pages: [
{
...firstPage,
posts: [...events, ...firstPage],
},
...oldData.pages.slice(1),
],
};
}
},
);
await queryClient.invalidateQueries({ queryKey: [label, account] });
};
useEffect(() => {
const unlisten = getCurrent().listen<Payload>("new_event", (data) => {
const event = LumeEvent.from(data.payload.raw, data.payload.parsed);
setEvents((prev) => [event, ...prev]);
});
NostrQuery.listenLocalEvent().then(() => console.log("listen"));
return () => {
unlisten.then((f) => f());
NostrQuery.unlisten().then(() => console.log("unlisten"));
};
}, []);
if (!events?.length) return null;
return (
<div className="z-50 fixed top-0 left-0 w-full h-14 flex items-center justify-center px-3">
<button
type="button"
onClick={() => pushNewEvents()}
className="w-max h-8 pl-2 pr-3 inline-flex items-center justify-center gap-1.5 rounded-full shadow-lg text-sm font-medium text-white bg-black dark:text-black dark:bg-white"
>
<ArrowUpIcon className="size-4" />
{events.length} new notes
</button>
</div> </div>
); );
} }
-62
View File
@@ -1,62 +0,0 @@
import { ZapIcon } from "@lume/icons";
import { Container } from "@lume/ui";
import { createLazyFileRoute } from "@tanstack/react-router";
import { useState } from "react";
export const Route = createLazyFileRoute("/nwc")({
component: Screen,
});
function Screen() {
const { ark } = Route.useRouteContext();
const [uri, setUri] = useState("");
const [isDone, setIsDone] = useState(false);
const save = async () => {
const nwc = await ark.set_nwc(uri);
setIsDone(nwc);
};
return (
<Container withDrag withNavigate={false}>
<div className="h-full w-full flex-1 px-5">
{!isDone ? (
<>
<div className="flex flex-col gap-2">
<div className="inline-flex size-14 items-center justify-center rounded-xl bg-black text-white shadow-md">
<ZapIcon className="size-5" />
</div>
<div>
<h3 className="text-2xl font-light">
Connect <span className="font-semibold">bitcoin wallet</span>{" "}
to start zapping to your favorite content and creator.
</h3>
</div>
</div>
<div className="mt-10 flex flex-col gap-2">
<div className="flex flex-col gap-1.5">
<label>Paste a Nostr Wallet Connect connection string</label>
<textarea
value={uri}
onChange={(e) => setUri(e.target.value)}
placeholder="nostrconnect://"
className="h-24 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<button
type="button"
onClick={save}
className="inline-flex h-11 w-full items-center justify-center gap-1.5 rounded-lg bg-blue-500 px-5 font-medium text-white hover:bg-blue-600"
>
Save & Connect
</button>
</div>
</>
) : (
<div>Done</div>
)}
</div>
</Container>
);
}
+12 -4
View File
@@ -22,7 +22,7 @@ function Screen() {
</p> </p>
</div> </div>
<div className="px-3 flex flex-col gap-3"> <div className="px-3 flex flex-col gap-3">
<div className="relative flex flex-col items-center justify-center rounded-xl bg-black/10 dark:bg-white/10 backdrop-blur-lg"> <div className="relative flex flex-col items-center justify-center rounded-xl bg-black/10 dark:bg-white/10">
<div className="absolute top-2 left-3 text-2xl font-semibold font-serif text-neutral-600 dark:text-neutral-400"> <div className="absolute top-2 left-3 text-2xl font-semibold font-serif text-neutral-600 dark:text-neutral-400">
01. 01.
</div> </div>
@@ -34,6 +34,8 @@ function Screen() {
className="h-auto w-full aspect-square rounded-lg shadow-md transform" className="h-auto w-full aspect-square rounded-lg shadow-md transform"
controls controls
muted muted
preload="none"
poster="/poster_1.jpeg"
> >
<source <source
src="https://video.nostr.build/692f71e2be47ecfc29edcbdaa198cc5979bfb9c900f05d78682895dd546d8d4f.mp4" src="https://video.nostr.build/692f71e2be47ecfc29edcbdaa198cc5979bfb9c900f05d78682895dd546d8d4f.mp4"
@@ -43,7 +45,7 @@ function Screen() {
</video> </video>
</div> </div>
</div> </div>
<div className="relative flex flex-col items-center justify-center rounded-xl bg-black/10 dark:bg-white/10 backdrop-blur-lg"> <div className="relative flex flex-col items-center justify-center rounded-xl bg-black/10 dark:bg-white/10">
<div className="absolute top-2 left-3 text-2xl font-semibold font-serif text-neutral-600 dark:text-neutral-400"> <div className="absolute top-2 left-3 text-2xl font-semibold font-serif text-neutral-600 dark:text-neutral-400">
02. 02.
</div> </div>
@@ -55,6 +57,8 @@ function Screen() {
className="h-auto w-full aspect-square rounded-lg shadow-md transform" className="h-auto w-full aspect-square rounded-lg shadow-md transform"
controls controls
muted muted
preload="none"
poster="/poster_2.jpeg"
> >
<source <source
src="https://video.nostr.build/d33962520506d86acfb4b55a7b265821e10ae637f5ec830a173b7e6092b16ec8.mp4" src="https://video.nostr.build/d33962520506d86acfb4b55a7b265821e10ae637f5ec830a173b7e6092b16ec8.mp4"
@@ -64,7 +68,7 @@ function Screen() {
</video> </video>
</div> </div>
</div> </div>
<div className="relative flex flex-col items-center justify-center rounded-xl bg-black/10 dark:bg-white/10 backdrop-blur-lg"> <div className="relative flex flex-col items-center justify-center rounded-xl bg-black/10 dark:bg-white/10">
<div className="absolute top-2 left-3 text-2xl font-semibold font-serif text-neutral-600 dark:text-neutral-400"> <div className="absolute top-2 left-3 text-2xl font-semibold font-serif text-neutral-600 dark:text-neutral-400">
03. 03.
</div> </div>
@@ -76,6 +80,8 @@ function Screen() {
className="h-auto w-full aspect-square rounded-lg shadow-md transform" className="h-auto w-full aspect-square rounded-lg shadow-md transform"
controls controls
muted muted
preload="none"
poster="/poster_3.jpeg"
> >
<source <source
src="https://video.nostr.build/927abbfde2097e470ac751181b1db456b7e4b9149550408efff1a966a7ffb9a8.mp4" src="https://video.nostr.build/927abbfde2097e470ac751181b1db456b7e4b9149550408efff1a966a7ffb9a8.mp4"
@@ -85,7 +91,7 @@ function Screen() {
</video> </video>
</div> </div>
</div> </div>
<div className="relative flex flex-col items-center justify-center rounded-xl bg-black/10 dark:bg-white/10 backdrop-blur-lg"> <div className="relative flex flex-col items-center justify-center rounded-xl bg-black/10 dark:bg-white/10">
<div className="absolute top-2 left-3 text-2xl font-semibold font-serif text-neutral-600 dark:text-neutral-400"> <div className="absolute top-2 left-3 text-2xl font-semibold font-serif text-neutral-600 dark:text-neutral-400">
04. 04.
</div> </div>
@@ -97,6 +103,8 @@ function Screen() {
className="h-auto w-full rounded-lg shadow-md transform" className="h-auto w-full rounded-lg shadow-md transform"
controls controls
muted muted
preload="none"
poster="/poster_4.jpeg"
> >
<source <source
src="https://video.nostr.build/513de4824b6abaf7e9698c1dad2f68096574356848c0c200bc8cb8074df29410.mp4" src="https://video.nostr.build/513de4824b6abaf7e9698c1dad2f68096574356848c0c200bc8cb8074df29410.mp4"
-48
View File
@@ -1,48 +0,0 @@
import { PlusIcon } from "@lume/icons";
import type { LumeColumn } from "@lume/types";
import { createLazyRoute } from "@tanstack/react-router";
import { getCurrent } from "@tauri-apps/api/window";
export const Route = createLazyRoute("/open")({
component: Screen,
});
function Screen() {
const install = async (column: LumeColumn) => {
const mainWindow = getCurrent();
await mainWindow.emit("columns", { type: "add", column });
};
return (
<div className="relative flex h-full w-full items-center justify-center">
<div className="group absolute left-0 top-0 z-10 h-full w-12">
<button
type="button"
onClick={() =>
install({
label: "store",
name: "Store",
content: "/store/official",
})
}
className="flex h-full w-full items-center justify-center rounded-xl bg-transparent transition-colors duration-100 ease-in-out group-hover:bg-black/5 dark:group-hover:bg-white/5"
>
<PlusIcon className="size-6 scale-0 transform transition-transform duration-150 ease-in-out will-change-transform group-hover:scale-100" />
</button>
</div>
<button
type="button"
onClick={() =>
install({
label: "store",
name: "Store",
content: "/store/official",
})
}
className="inline-flex size-14 items-center justify-center rounded-full bg-black/10 backdrop-blur-lg hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
>
<PlusIcon className="size-8" />
</button>
</div>
);
}
+357
View File
@@ -0,0 +1,357 @@
import { Note } from "@/components/note";
import { User } from "@/components/user";
import { HorizontalDotsIcon, InfoIcon, RepostIcon } from "@lume/icons";
import { type LumeEvent, LumeWindow, NostrQuery, useEvent } from "@lume/system";
import { Kind } from "@lume/types";
import { Spinner } from "@lume/ui";
import {
checkForAppUpdates,
decodeZapInvoice,
formatCreatedAt,
} from "@lume/utils";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import * as Tabs from "@radix-ui/react-tabs";
import { useQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core";
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
import { getCurrent } from "@tauri-apps/api/window";
import { open } from "@tauri-apps/plugin-shell";
import { type ReactNode, useCallback, useEffect, useRef } from "react";
import { Virtualizer } from "virtua";
export const Route = createFileRoute("/panel/$account")({
component: Screen,
});
function Screen() {
const { account } = Route.useParams();
const { queryClient } = Route.useRouteContext();
const { isLoading, data } = useQuery({
queryKey: ["notification", account],
queryFn: async () => {
const events = await NostrQuery.getNotifications();
return events;
},
select: (events) => {
const zaps = new Map<string, LumeEvent[]>();
const reactions = new Map<string, LumeEvent[]>();
const texts = events.filter((ev) => ev.kind === Kind.Text);
const zapEvents = events.filter((ev) => ev.kind === Kind.ZapReceipt);
const reactEvents = events.filter(
(ev) => ev.kind === Kind.Repost || ev.kind === Kind.Reaction,
);
for (const event of reactEvents) {
const rootId = event.tags.filter((tag) => tag[0] === "e")[0]?.[1];
if (rootId) {
if (reactions.has(rootId)) {
reactions.get(rootId).push(event);
} else {
reactions.set(rootId, [event]);
}
}
}
for (const event of zapEvents) {
const rootId = event.tags.filter((tag) => tag[0] === "e")[0]?.[1];
if (rootId) {
if (zaps.has(rootId)) {
zaps.get(rootId).push(event);
} else {
zaps.set(rootId, [event]);
}
}
}
return { texts, zaps, reactions };
},
});
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
e.preventDefault();
const menuItems = await Promise.all([
MenuItem.new({
text: "Open Lume",
action: () => LumeWindow.openMainWindow(),
}),
MenuItem.new({
text: "New Post",
action: () => LumeWindow.openEditor(),
}),
PredefinedMenuItem.new({ item: "Separator" }),
MenuItem.new({
text: "About Lume",
action: async () => await open("https://lume.nu"),
}),
MenuItem.new({
text: "Check for Updates",
action: async () => await checkForAppUpdates(false),
}),
MenuItem.new({
text: "Settings",
action: () => LumeWindow.openSettings(),
}),
PredefinedMenuItem.new({ item: "Separator" }),
MenuItem.new({
text: "Quit",
action: async () => await invoke("force_quit"),
}),
]);
const menu = await Menu.new({
items: menuItems,
});
await menu.popup().catch((e) => console.error(e));
}, []);
useEffect(() => {
const unlisten = getCurrent().listen("notification", async (data) => {
const event: LumeEvent = JSON.parse(data.payload as string);
await queryClient.setQueryData(
["notification", account],
(data: LumeEvent[]) => [event, ...data],
);
});
return () => {
unlisten.then((f) => f());
};
}, []);
if (isLoading) {
return (
<div className="size-full flex items-center justify-center">
<Spinner />
</div>
);
}
return (
<div className="flex flex-col size-full overflow-hidden">
<div className="flex items-center justify-between px-4 border-b h-11 shrink-0 border-black/5 dark:border-white/5">
<div>
<h1 className="text-sm font-semibold">Notifications</h1>
</div>
<div className="inline-flex items-center gap-2">
<User.Provider pubkey={account}>
<User.Root>
<User.Avatar className="rounded-full size-7" />
</User.Root>
</User.Provider>
<button
type="button"
onClick={(e) => showContextMenu(e)}
className="inline-flex items-center justify-center rounded-full size-7 bg-black/5 dark:bg-white/5"
>
<HorizontalDotsIcon className="size-4" />
</button>
</div>
</div>
<Tabs.Root defaultValue="replies" className="flex flex-col h-full">
<Tabs.List className="h-8 shrink-0 flex items-center">
<Tabs.Trigger
className="flex-1 inline-flex h-8 items-center justify-center gap-2 px-2 text-sm font-medium border-b border-black/10 dark:border-white/10 data-[state=active]:border-black/30 dark:data-[state=active]:border-white/30 data-[state=inactive]:opacity-50"
value="replies"
>
Replies
</Tabs.Trigger>
<Tabs.Trigger
className="flex-1 inline-flex h-8 items-center justify-center gap-2 px-2 text-sm font-medium border-b border-black/10 dark:border-white/10 data-[state=active]:border-black/30 dark:data-[state=active]:border-white/30 data-[state=inactive]:opacity-50"
value="reactions"
>
Reactions
</Tabs.Trigger>
<Tabs.Trigger
className="flex-1 inline-flex h-8 items-center justify-center gap-2 px-2 text-sm font-medium border-b border-black/10 dark:border-white/10 data-[state=active]:border-black/30 dark:data-[state=active]:border-white/30 data-[state=inactive]:opacity-50"
value="zaps"
>
Zaps
</Tabs.Trigger>
</Tabs.List>
<ScrollArea.Root
type={"scroll"}
scrollHideDelay={300}
className="min-h-0 flex-1 overflow-x-hidden"
>
<Tab value="replies">
{data.texts.map((event, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
<TextNote key={event.id + index} event={event} />
))}
</Tab>
<Tab value="reactions">
{[...data.reactions.entries()].map(([root, events]) => (
<div
key={root}
className="flex flex-col gap-1 p-2 mb-2 rounded-lg shrink-0 bg-black/10 dark:bg-white/10"
>
<div className="flex flex-col flex-1 min-w-0 gap-2">
<div className="flex items-center gap-2 pb-2 border-b border-black/5 dark:border-white/5">
<RootNote id={root} />
</div>
<div className="flex flex-wrap items-center gap-3">
{events.map((event) => (
<User.Provider key={event.id} pubkey={event.pubkey}>
<User.Root className="shrink-0 flex rounded-full h-8 bg-black/10 dark:bg-white/10 p-[2px]">
<User.Avatar className="flex-1 rounded-full size-7" />
<div className="inline-flex items-center justify-center flex-1 text-xs truncate rounded-full size-7">
{event.kind === Kind.Reaction ? (
event.content === "+" ? (
"👍"
) : (
event.content
)
) : (
<RepostIcon className="text-teal-400 size-4 dark:text-teal-600" />
)}
</div>
</User.Root>
</User.Provider>
))}
</div>
</div>
</div>
))}
</Tab>
<Tab value="zaps">
{[...data.zaps.entries()].map(([root, events]) => (
<div
key={root}
className="flex flex-col gap-1 p-2 mb-2 rounded-lg shrink-0 bg-black/10 dark:bg-white/10"
>
<div className="flex flex-col flex-1 min-w-0 gap-2">
<div className="flex items-center gap-2 pb-2 border-b border-black/5 dark:border-white/5">
<RootNote id={root} />
</div>
<div className="flex flex-wrap items-center gap-3">
{events.map((event) => (
<User.Provider
key={event.id}
pubkey={event.tags.find((tag) => tag[0] === "P")[1]}
>
<User.Root className="shrink-0 flex gap-1.5 rounded-full h-8 bg-black/10 dark:bg-white/10 p-[2px]">
<User.Avatar className="rounded-full size-7" />
<div className="flex-1 h-7 w-max pr-1.5 rounded-full inline-flex items-center justify-center text-sm truncate">
{decodeZapInvoice(event.tags).bitcoinFormatted}
</div>
</User.Root>
</User.Provider>
))}
</div>
</div>
</div>
))}
</Tab>
<ScrollArea.Scrollbar
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
orientation="vertical"
>
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
</ScrollArea.Scrollbar>
<ScrollArea.Corner className="bg-transparent" />
</ScrollArea.Root>
</Tabs.Root>
</div>
);
}
function Tab({ value, children }: { value: string; children: ReactNode[] }) {
const ref = useRef<HTMLDivElement>(null);
return (
<Tabs.Content value={value} className="size-full">
<ScrollArea.Viewport ref={ref} className="h-full px-2 pt-2">
<Virtualizer scrollRef={ref}>{children}</Virtualizer>
</ScrollArea.Viewport>
</Tabs.Content>
);
}
function RootNote({ id }: { id: string }) {
const { isLoading, isError, data } = useEvent(id);
if (isLoading) {
return (
<div className="flex items-center pb-2 mb-2">
<div className="rounded-full size-8 shrink-0 bg-black/20 dark:bg-white/20 animate-pulse" />
<div className="w-2/3 h-4 rounded-md animate-pulse bg-black/20 dark:bg-white/20" />
</div>
);
}
if (isError || !data) {
return (
<div className="flex items-center gap-2">
<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="flex items-center gap-2">
<User.Provider pubkey={data.pubkey}>
<User.Root className="shrink-0">
<User.Avatar className="rounded-full size-8" />
</User.Root>
</User.Provider>
<div className="line-clamp-1">{data.content}</div>
</Note.Root>
</Note.Provider>
);
}
function TextNote({ event }: { event: LumeEvent }) {
const pTags = event.tags
.filter((tag) => tag[0] === "p")
.map((tag) => tag[1])
.slice(0, 3);
return (
<Note.Provider event={event}>
<Note.Root className="flex flex-col p-2 mb-2 rounded-lg shrink-0 bg-black/10 dark:bg-white/10">
<User.Provider pubkey={event.pubkey}>
<User.Root className="inline-flex items-center gap-2">
<User.Avatar className="rounded-full size-9" />
<div className="flex flex-col flex-1">
<div className="flex items-baseline justify-between w-full">
<User.Name className="text-sm font-semibold leading-tight" />
<span className="text-sm leading-tight text-black/50 dark:text-white/50">
{formatCreatedAt(event.created_at)}
</span>
</div>
<div className="inline-flex items-baseline gap-1 text-xs">
<span className="leading-tight text-black/50 dark:text-white/50">
Reply to:
</span>
<div className="inline-flex items-baseline gap-1">
{[...new Set(pTags)].map((replyTo) => (
<User.Provider key={replyTo} pubkey={replyTo}>
<User.Root>
<User.Name className="font-medium leading-tight" />
</User.Root>
</User.Provider>
))}
</div>
</div>
</div>
</User.Root>
</User.Provider>
<div className="flex gap-2">
<div className="w-9 shrink-0" />
<div className="line-clamp-1 text-start">{event.content}</div>
</div>
</Note.Root>
</Note.Provider>
);
}
+96
View File
@@ -0,0 +1,96 @@
import { Conversation } from "@/components/conversation";
import { Quote } from "@/components/quote";
import { RepostNote } from "@/components/repost";
import { TextNote } from "@/components/text";
import { LumeEvent, NostrQuery } from "@lume/system";
import { Kind, type NostrEvent } from "@lume/types";
import { Spinner } from "@lume/ui";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { useQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { fetch } from "@tauri-apps/plugin-http";
import { useCallback, useRef } from "react";
import { Virtualizer } from "virtua";
type Search = {
query: string;
};
export const Route = createFileRoute("/search/notes")({
validateSearch: (search: Record<string, string>): Search => {
return {
query: search.query,
};
},
beforeLoad: async () => {
const settings = await NostrQuery.getUserSettings();
return { settings };
},
component: Screen,
});
function Screen() {
const { query } = Route.useSearch();
const { isLoading, data } = useQuery({
queryKey: ["search", query],
queryFn: async () => {
try {
const res = await fetch(
`https://api.nostr.wine/search?query=${query}&kind=1&limit=50`,
);
const content = await res.json();
const events = content.data as NostrEvent[];
const lumeEvents = await Promise.all(
events.map(async (item): Promise<LumeEvent> => {
const event = await LumeEvent.build(item);
return event;
}),
);
return lumeEvents.sort((a, b) => b.created_at - a.created_at);
} catch (e) {
throw new Error(e);
}
},
refetchOnWindowFocus: false,
});
const ref = useRef<HTMLDivElement>(null);
const renderItem = useCallback(
(event: LumeEvent) => {
if (!event) return;
switch (event.kind) {
case Kind.Repost:
return <RepostNote key={event.id} event={event} className="mb-3" />;
default: {
if (event.isConversation) {
return (
<Conversation key={event.id} className="mb-3" event={event} />
);
}
if (event.isQuote) {
return <Quote key={event.id} event={event} className="mb-3" />;
}
return <TextNote key={event.id} event={event} className="mb-3" />;
}
}
},
[data],
);
return (
<ScrollArea.Viewport ref={ref} className="h-full p-3">
<Virtualizer scrollRef={ref}>
{isLoading ? (
<div className="flex items-center justify-center w-full h-11 gap-2">
<Spinner className="size-5" />
<span className="text-sm font-medium">Searching...</span>
</div>
) : (
data.map((item) => renderItem(item))
)}
</Virtualizer>
</ScrollArea.Viewport>
);
}
+32 -133
View File
@@ -1,145 +1,44 @@
import { SearchIcon } from "@lume/icons"; import * as ScrollArea from "@radix-ui/react-scroll-area";
import { type Event, Kind } from "@lume/types"; import { Outlet, createFileRoute } from "@tanstack/react-router";
import { Spinner } from "@lume/ui";
import { Note } from "@/components/note"; type Search = {
import { User } from "@/components/user"; query: string;
import { createFileRoute } from "@tanstack/react-router"; };
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { useDebounce } from "use-debounce";
export const Route = createFileRoute("/search")({ export const Route = createFileRoute("/search")({
validateSearch: (search: Record<string, string>): Search => {
return {
query: search.query,
};
},
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const [loading, setLoading] = useState(false); const { query } = Route.useSearch();
const [events, setEvents] = useState<Event[]>([]);
const [search, setSearch] = useState("");
const [searchValue] = useDebounce(search, 500);
const searchEvents = async () => {
try {
setLoading(true);
const query = `https://api.nostr.wine/search?query=${searchValue}&kind=0,1`;
const res = await fetch(query);
const content = await res.json();
const events = content.data as Event[];
const sorted = events.sort((a, b) => b.created_at - a.created_at);
setLoading(false);
setEvents(sorted);
} catch (e) {
setLoading(false);
toast.error(String(e));
}
};
useEffect(() => {
if (searchValue.length >= 3 && searchValue.length < 500) {
searchEvents();
}
}, [searchValue]);
return ( return (
<div data-tauri-drag-region className="flex flex-col w-full h-full"> <div className="flex flex-col h-full">
<div className="relative h-24 shrink-0 flex flex-col border-b border-black/5 dark:border-white/5"> <div
<div data-tauri-drag-region className="w-full h-4 shrink-0" /> data-tauri-drag-region
<input className="shrink-0 flex items-end gap-1 h-20 px-3 pb-3 w-full border-b border-black/10 dark:border-white/10"
value={search} >
onChange={(e) => setSearch(e.target.value)} Search result for: <span className="font-semibold">{query}</span>
onKeyDown={(e) => {
if (e.key === "Enter") searchEvents();
}}
placeholder="Search anything..."
className="w-full h-20 pt-10 px-3 text-lg bg-transparent border-none focus:outline-none focus:ring-0 placeholder:text-neutral-500 dark:placeholder:text-neutral-600"
/>
</div>
<div className="flex-1 p-3 overflow-y-auto scrollbar-none">
{loading ? (
<div className="w-full h-full flex items-center justify-center">
<Spinner />
</div>
) : events.length ? (
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-1.5">
<div className="text-sm font-medium text-neutral-700 dark:text-neutral-300 shrink-0">
Users
</div>
<div className="flex-1 flex flex-col gap-1">
{events
.filter((ev) => ev.kind === Kind.Metadata)
.map((event, index) => (
<SearchUser key={event.pubkey + index} event={event} />
))}
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="text-sm font-medium text-neutral-700 dark:text-neutral-300 shrink-0">
Notes
</div>
<div className="flex-1 flex flex-col gap-3">
{events
.filter((ev) => ev.kind === Kind.Text)
.map((event) => (
<SearchNote key={event.id} event={event} />
))}
</div>
</div>
</div>
) : null}
{!loading && !events.length ? (
<div className="h-full flex items-center justify-center flex-col gap-3">
<div className="size-16 bg-black/10 dark:bg-white/10 rounded-full inline-flex items-center justify-center">
<SearchIcon className="size-6" />
</div>
Try searching for people, notes, or keywords
</div>
) : null}
</div> </div>
</div> <ScrollArea.Root
); type={"scroll"}
} scrollHideDelay={300}
className="overflow-hidden size-full flex-1"
function SearchUser({ event }: { event: Event }) { >
const { ark } = Route.useRouteContext(); <Outlet />
<ScrollArea.Scrollbar
return ( className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
<button orientation="vertical"
key={event.id} >
type="button" <ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
onClick={() => ark.open_profile(event.pubkey)} </ScrollArea.Scrollbar>
className="col-span-1 p-2 hover:bg-black/10 dark:hover:bg-white/10 rounded-lg" <ScrollArea.Corner className="bg-transparent" />
> </ScrollArea.Root>
<User.Provider pubkey={event.pubkey} embedProfile={event.content}>
<User.Root className="flex items-center gap-2">
<User.Avatar className="size-9 rounded-full shrink-0" />
<div className="inline-flex items-center gap-1.5">
<User.Name className="font-semibold" />
<User.NIP05 />
</div>
</User.Root>
</User.Provider>
</button>
);
}
function SearchNote({ event }: { event: Event }) {
return (
<div className="bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50">
<Note.Provider event={event}>
<Note.Root>
<div className="px-3 h-14 flex items-center justify-between">
<Note.User />
<Note.Menu />
</div>
<Note.Content className="px-3" quote={false} mention={false} />
<div className="mt-3 flex items-center gap-4 h-14 px-3">
<Note.Open />
</div>
</Note.Root>
</Note.Provider>
</div> </div>
); );
} }
+101
View File
@@ -0,0 +1,101 @@
import { User } from "@/components/user";
import { LumeWindow, NostrQuery } from "@lume/system";
import type { NostrEvent } from "@lume/types";
import { Spinner } from "@lume/ui";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { useQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { fetch } from "@tauri-apps/plugin-http";
import { useRef } from "react";
import { Virtualizer } from "virtua";
type Search = {
query: string;
};
type UserItem = {
pubkey: string;
profile: string;
};
export const Route = createFileRoute("/search/users")({
validateSearch: (search: Record<string, string>): Search => {
return {
query: search.query,
};
},
beforeLoad: async () => {
const settings = await NostrQuery.getUserSettings();
return { settings };
},
component: Screen,
});
function Screen() {
const { query } = Route.useSearch();
const { isLoading, data } = useQuery({
queryKey: ["search", query],
queryFn: async () => {
try {
const res = await fetch(
`https://api.nostr.wine/search?query=${query}&kind=0&limit=100`,
);
const content = await res.json();
const events = content.data as NostrEvent[];
const users: UserItem[] = events.map((ev) => ({
pubkey: ev.pubkey,
profile: ev.content,
}));
return users;
} catch (e) {
throw new Error(e);
}
},
refetchOnWindowFocus: false,
});
const ref = useRef<HTMLDivElement>(null);
return (
<ScrollArea.Viewport ref={ref} className="h-full px-3 pt-3">
<Virtualizer scrollRef={ref}>
{isLoading ? (
<div className="flex items-center justify-center w-full h-11 gap-2">
<Spinner className="size-5" />
<span className="text-sm font-medium">Searching...</span>
</div>
) : (
data.map((item) => (
<div
key={item.pubkey}
className="w-full p-3 mb-2 overflow-hidden bg-white rounded-lg h-max dark:bg-black/20 shadow-primary dark:ring-1 ring-neutral-800/50"
>
<User.Provider pubkey={item.pubkey} embedProfile={item.profile}>
<User.Root className="flex flex-col w-full h-full gap-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<User.Avatar className="rounded-full size-7" />
<div className="inline-flex items-center gap-1">
<User.Name className="text-sm leadning-tight max-w-[15rem] truncate font-semibold" />
<User.NIP05 />
</div>
</div>
<button
type="button"
onClick={() => LumeWindow.openProfile(item.pubkey)}
className="inline-flex items-center justify-center w-16 text-sm font-medium rounded-md h-7 bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
>
View
</button>
</div>
<User.About className="select-text line-clamp-3 max-w-none text-neutral-800 dark:text-neutral-400" />
</User.Root>
</User.Provider>
</div>
))
)}
</Virtualizer>
</ScrollArea.Viewport>
);
}
+8 -19
View File
@@ -8,20 +8,17 @@ import {
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { Outlet, createFileRoute } from "@tanstack/react-router"; import { Outlet, createFileRoute } from "@tanstack/react-router";
import { useTranslation } from "react-i18next";
export const Route = createFileRoute("/settings")({ export const Route = createFileRoute("/settings")({
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const { t } = useTranslation();
return ( return (
<div className="flex h-full w-full flex-col"> <div className="flex flex-col w-full h-full">
<div <div
data-tauri-drag-region data-tauri-drag-region
className="flex h-20 w-full shrink-0 items-center justify-center border-b border-black/10 dark:border-white/10" className="flex items-center justify-center w-full h-20 border-b shrink-0 border-black/10 dark:border-white/10"
> >
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Link to="/settings/general"> <Link to="/settings/general">
@@ -36,9 +33,7 @@ function Screen() {
)} )}
> >
<SettingsIcon className="size-5 shrink-0" /> <SettingsIcon className="size-5 shrink-0" />
<p className="text-sm font-medium"> <p className="text-sm font-medium">General</p>
{t("settings.general.title")}
</p>
</div> </div>
); );
}} }}
@@ -55,9 +50,7 @@ function Screen() {
)} )}
> >
<UserIcon className="size-5 shrink-0" /> <UserIcon className="size-5 shrink-0" />
<p className="text-sm font-medium"> <p className="text-sm font-medium">User</p>
{t("settings.user.title")}
</p>
</div> </div>
); );
}} }}
@@ -79,7 +72,7 @@ function Screen() {
); );
}} }}
</Link> </Link>
<Link to="/settings/zap"> <Link to="/settings/wallet">
{({ isActive }) => { {({ isActive }) => {
return ( return (
<div <div
@@ -91,9 +84,7 @@ function Screen() {
)} )}
> >
<ZapIcon className="size-5 shrink-0" /> <ZapIcon className="size-5 shrink-0" />
<p className="text-sm font-medium"> <p className="text-sm font-medium">Wallet</p>
{t("settings.zap.title")}
</p>
</div> </div>
); );
}} }}
@@ -110,16 +101,14 @@ function Screen() {
)} )}
> >
<SecureIcon className="size-5 shrink-0" /> <SecureIcon className="size-5 shrink-0" />
<p className="text-sm font-medium"> <p className="text-sm font-medium">Backup</p>
{t("settings.backup.title")}
</p>
</div> </div>
); );
}} }}
</Link> </Link>
</div> </div>
</div> </div>
<div className="w-full flex-1 overflow-y-auto scrollbar-none px-5 py-4"> <div className="flex-1 w-full px-5 py-4 overflow-y-auto scrollbar-none">
<Outlet /> <Outlet />
</div> </div>
</div> </div>
+33 -87
View File
@@ -1,127 +1,73 @@
import { User } from "@/components/user"; import { User } from "@/components/user";
import type { Account } from "@lume/types"; import { NostrAccount } from "@lume/system";
import { displayNsec } from "@lume/utils"; import { displayNpub } from "@lume/utils";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { writeText } from "@tauri-apps/plugin-clipboard-manager"; import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { message } from "@tauri-apps/plugin-dialog";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner";
interface Account {
npub: string;
nsec: string;
}
export const Route = createFileRoute("/settings/backup")({ export const Route = createFileRoute("/settings/backup")({
component: Screen, beforeLoad: async () => {
loader: async ({ context }) => { const accounts = await NostrAccount.getAccounts();
const ark = context.ark; return { accounts };
const npubs = await ark.get_accounts();
const accounts: Account[] = [];
for (const account of npubs) {
const nsec: string = await invoke("get_stored_nsec", {
npub: account.npub,
});
accounts.push({ ...account, nsec });
}
return accounts;
}, },
component: Screen,
}); });
function Screen() { function Screen() {
const accounts = Route.useLoaderData(); const { accounts } = Route.useRouteContext();
return ( return (
<div className="mx-auto w-full max-w-xl"> <div className="w-full max-w-xl mx-auto">
<div className="flex flex-col gap-3 divide-y divide-neutral-300 dark:divide-neutral-700"> <div className="flex flex-col gap-3 divide-y divide-neutral-300 dark:divide-neutral-700">
{accounts.map((account) => ( {accounts.map((account) => (
<NostrAccount key={account.npub} account={account} /> <Account key={account} account={account} />
))} ))}
</div> </div>
</div> </div>
); );
} }
function NostrAccount({ account }: { account: Account }) { function Account({ account }: { account: string }) {
const [key, setKey] = useState(account.nsec);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [passphase, setPassphase] = useState("");
const encrypt = async () => {
const encrypted: string = await invoke("get_encrypted_key", {
npub: account.npub,
password: passphase,
});
setKey(encrypted);
};
const copyKey = async () => { const copyKey = async () => {
try { try {
await writeText(key); const data: string = await invoke("get_private_key", { npub: account });
await writeText(data);
setCopied(true); setCopied(true);
} catch (e) { } catch (e) {
toast.error(e); await message(String(e), { title: "Backup", kind: "error" });
} }
}; };
return ( return (
<div className="flex flex-1 flex-col gap-2 py-3"> <div className="flex items-center justify-between gap-2 py-3">
<User.Provider pubkey={account.npub}> <User.Provider pubkey={account}>
<User.Root className="flex items-center gap-2"> <User.Root className="flex items-center gap-2">
<User.Avatar className="size-8 rounded-full object-cover" /> <User.Avatar className="rounded-full size-8" />
<div className="flex flex-col"> <div className="flex flex-col">
<User.Name className="text-sm leading-tight" /> <User.Name className="text-sm leading-tight" />
<User.NIP05 /> <span className="text-sm leading-tight text-black/50 dark:text-white/50">
{displayNpub(account, 16)}
</span>
</div> </div>
</User.Root> </User.Root>
</User.Provider> </User.Provider>
<div className="flex flex-col gap-2"> <div className="flex items-center gap-2">
<div className="flex w-full flex-col gap-1"> <button
<label type="button"
htmlFor="nsec" onClick={() => copyKey()}
className="text-sm font-medium text-neutral-700 dark:text-neutral-300" className="inline-flex items-center justify-center h-8 text-sm font-medium rounded-md w-36 bg-neutral-200 hover:bg-neutral-300 dark:bg-white/10 dark:hover:bg-white/20"
> >
Private Key {copied ? "Copied" : "Copy Private Key"}
</label> </button>
<div className="flex items-center gap-2">
<input
readOnly
name="nsec"
type="text"
value={displayNsec(key, 36)}
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
<button
type="button"
onClick={() => copyKey()}
className="inline-flex h-9 w-24 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-900 dark:hover:bg-neutral-700"
>
{copied ? "Copied" : "Copy"}
</button>
</div>
</div>
<div className="flex w-full flex-col gap-1">
<label
htmlFor="passphase"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Set a passphase to secure your key
</label>
<div className="flex items-center gap-2">
<input
name="passphase"
type="password"
value={passphase}
onChange={(e) => setPassphase(e.target.value)}
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
<button
type="button"
onClick={() => encrypt()}
className="inline-flex h-9 w-24 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-900 dark:hover:bg-neutral-700"
>
Update
</button>
</div>
</div>
</div> </div>
</div> </div>
); );

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