Compare commits

..

49 Commits

Author SHA1 Message Date
reya
e06b0334a5 chore: bump version 2024-08-29 08:00:58 +07:00
reya
74d8bf2ead fix: cannot import ncryptsec 2024-08-29 07:48:26 +07:00
reya
d128af1db8 chore: clean up 2024-08-28 08:48:17 +07:00
reya
f6eb5eea44 chore: update ci 2024-08-27 19:43:51 +07:00
reya
bca2e0b7b7 chore: bump version 2024-08-27 19:42:35 +07:00
雨宮蓮
61ad96ca63 Release v4.1 (#229)
* refactor: remove custom icon packs

* fix: command not work on windows

* fix: make open_window command async

* feat: improve commands

* feat: improve

* refactor: column

* feat: improve thread column

* feat: improve

* feat: add stories column

* feat: improve

* feat: add search column

* feat: add reset password

* feat: add subscription

* refactor: settings

* chore: improve commands

* fix: crash on production

* feat: use tauri store plugin for cache

* feat: new icon

* chore: update icon for windows

* chore: improve some columns

* chore: polish code
2024-08-27 19:37:30 +07:00
reya
26ae473521 fix: disable some default webview behaviors 2024-08-19 13:46:22 +07:00
reya
bcc5e18082 fix: adjust window controls position 2024-08-19 10:48:29 +07:00
reya
307fff7a53 fix: titlebar on windows 2024-08-19 10:28:38 +07:00
reya
ce7828310b chore: add some improvements and remove linux support 2024-08-19 09:58:29 +07:00
reya
beac1a189e chore: update deps 2024-08-18 15:51:34 +07:00
reya
4cb49d44c7 feat: improve account management 2024-08-13 10:33:21 +07:00
reya
be16d5c21d chore: upgrade to react 19 rc 2024-08-12 10:32:20 +07:00
reya
da8162069b refactor: remove turborepo 2024-08-12 09:07:11 +07:00
reya
e2103ae23a feat: upgrade to tauri v2 rc 2024-08-10 16:45:56 +07:00
XIAO YU
4c6d1c768a Handle errors when adding and connecting relays in init_nip65 (#227) 2024-08-03 08:56:38 +07:00
reya
9b75a04f91 chore: update deps 2024-07-31 13:03:27 +07:00
reya
a5255fa503 chore: bump version 2024-07-31 12:51:41 +07:00
reya
954a17b541 fix: build on linux 2024-07-31 12:51:17 +07:00
reya
a55b31b0e6 feat: add keyring support for linux and windows 2024-07-31 10:59:54 +07:00
reya
bdf3ffd7bf fix: tray panel is missing 2024-07-19 13:58:32 +07:00
reya
07ce253f5b fix: child webview is not reposition after scroll 2024-07-19 13:10:29 +07:00
reya
f3db010c74 chore: update tauri config 2024-07-19 10:01:20 +07:00
reya
dcf2791fe5 fix: build 2024-07-19 09:28:03 +07:00
reya
8fcf3551d8 chore: bump version 2024-07-19 08:33:46 +07:00
reya
2d987849d8 feat: use native border in macos 2024-07-19 08:33:16 +07:00
reya
3b99926f3b feat: adapt latest changes in tauri v2 2024-07-19 08:25:36 +07:00
reya
113d69a4df chore: update deps 2024-07-18 18:57:28 +07:00
reya
5d12ba7216 fix: some screen too large 2024-07-03 14:36:54 +07:00
reya
72b59020b4 fix: build on linux and windows 2024-07-03 14:09:07 +07:00
XIAO YU
4c323b9daa chore: Refactor code to use HashSet for account search results (#222) 2024-07-03 07:33:16 +07:00
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
雨宮蓮
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
雨宮蓮
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 YU
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
雨宮蓮
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 YU
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
459 changed files with 18926 additions and 23228 deletions

View File

@@ -1,44 +0,0 @@
# Dependencies
**/node_modules
.pnp
.pnp.js
# Local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Testing
coverage
# Turbo
.turbo
# Vercel
.vercel
# Build Outputs
**/.next/
**/out/
**/build
**/dist
**/target
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Misc
.DS_Store
*.pem
# Unnecessary files
**/.git/
.github/
flatpak/*.xml
flatpak/*.desktop
flatpak/*.yml

1
.envrc
View File

@@ -1 +0,0 @@
use flake

View File

@@ -1,72 +0,0 @@
name: Flatpak
on: workflow_dispatch
jobs:
prepare-repo:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: 'recursive'
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: cache of container
id: cache-container
uses: actions/cache@v3
with:
path: prepare-dist
key: ${{ runner.os }}-container-${{ hashFiles('prepare-dist') }}
- name: Run latest-tag
id: latest-tag
uses: oprypin/find-latest-tag@v1
with:
repository:
lumehq/lume
#FIXME: lumehq after merged fix, now it just won't find tags
# repository: ${{ github.repository }}
- name: Build container
# if: steps.cache-container.outputs.cache-hit != 'true'
run: |
docker buildx build -t flatpak-prepare-lume --build-arg=${{steps.latest-tag.outputs.tag}} --rm --output=. --target=final -f flatpak/Containerfile .
- name: Copy flatpak files content
run: |
cp -r flatpak/*.xml flatpak/*.desktop flatpak/*.yml prepare-dist
- uses: actions/upload-artifact@v4
with:
name: repo-dist
path: prepare-dist
flatpak:
name: flatpak-bundle
needs: prepare-repo
runs-on: ubuntu-latest
container:
image: bilelmoussaoui/flatpak-github-actions:gnome-45
options: --privileged
steps:
- uses: actions/download-artifact@v4
with:
name: repo-dist
- uses: actions/checkout@v4
with:
repository: flathub/shared-modules
path: shared-modules
- uses: flatpak/flatpak-github-actions/flatpak-builder@v6
with:
bundle: lume.flatpak
manifest-path: nu.lume.Lume.yml
restore-cache: false
# cache-key: flatpak-builder-${{ github.sha }}
- name: Release
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
append_body: true
files: lume.flatpak
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: geekyeggo/delete-artifact@v4
with:
name: repo-dist
token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -16,12 +16,10 @@ jobs:
args: "--target aarch64-apple-darwin"
- platform: "macos-latest" # for Intel based macs.
args: "--target x86_64-apple-darwin"
- platform: "macos-latest" # for Intel based macs.
- platform: "macos-latest" # for Intel & Arm based macs.
args: "--target universal-apple-darwin"
#- platform: 'ubuntu-22.04'
# args: ''
#- platform: 'windows-latest'
# args: '--target x86_64-pc-windows-msvc'
- platform: 'windows-latest'
args: '--target x86_64-pc-windows-msvc'
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4

56
.gitignore vendored
View File

@@ -1,38 +1,26 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Dependencies
node_modules
.pnp
.pnp.js
dist
dist-ssr
*.local
# Local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Testing
coverage/
# Turbo
.turbo/
# Vercel
.vercel/
# Build Outputs
.next/
out/
build/
dist/
# Debug
*.log.*
# Misc
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.pem
.vscode/
.idea/
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
src/router.gen.ts

View File

@@ -1,26 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
src/router.gen.ts

View File

@@ -1,14 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lume Desktop</title>
</head>
<body
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>
<script type="module" src="/src/app.tsx"></script>
</body>
</html>

View File

@@ -1,61 +0,0 @@
{
"name": "@lume/desktop2",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@getalby/bitcoin-connect-react": "^3.5.3",
"@lume/icons": "workspace:^",
"@lume/system": "workspace:^",
"@lume/ui": "workspace:^",
"@lume/utils": "workspace:^",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-tooltip": "^1.0.7",
"@tanstack/query-persist-client-core": "^5.45.0",
"@tanstack/react-query": "^5.45.0",
"@tanstack/react-router": "^1.38.1",
"embla-carousel-react": "^8.1.5",
"i18next": "^23.11.5",
"i18next-resources-to-backend": "^1.2.1",
"minidenticons": "^4.2.1",
"nanoid": "^5.0.7",
"nostr-tools": "^2.7.0",
"react": "^18.3.1",
"react-currency-input-field": "^3.8.0",
"react-dom": "^18.3.1",
"react-hook-form": "^7.52.0",
"react-i18next": "^14.1.2",
"react-string-replace": "^1.1.1",
"slate": "^0.103.0",
"slate-react": "^0.105.0",
"use-debounce": "^10.0.1",
"virtua": "^0.31.0"
},
"devDependencies": {
"@lume/tailwindcss": "workspace:^",
"@lume/tsconfig": "workspace:^",
"@lume/types": "workspace:^",
"@tanstack/router-devtools": "^1.38.1",
"@tanstack/router-vite-plugin": "^1.38.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react-swc": "^3.7.0",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.4",
"typescript": "^5.4.5",
"vite": "^5.3.1",
"vite-plugin-top-level-await": "^1.4.1",
"vite-tsconfig-paths": "^4.3.2"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 211 KiB

View File

@@ -1,110 +0,0 @@
@tailwind base;
@tailwind utilities;
@tailwind components;
@layer utilities {
.content-break {
word-break: break-word;
word-wrap: break-word;
overflow-wrap: break-word;
}
.shadow-toolbar {
box-shadow:
0 0 #0000,
0 0 #0000,
0 8px 24px 0 rgba(0, 0, 0, 0.2),
0 2px 8px 0 rgba(0, 0, 0, 0.08),
inset 0 0 0 1px rgba(0, 0, 0, 0.2),
inset 0 0 0 2px hsla(0, 0%, 100%, 0.14);
}
.shadow-primary {
box-shadow: 0px 0px 4px rgba(66, 65, 73, 0.14);
}
}
/*
Overide some default styles
*/
html {
font-size: 14px;
}
a {
@apply cursor-default no-underline !important;
}
button {
@apply cursor-default focus:outline-none;
}
input::-ms-reveal,
input::-ms-clear {
display: none;
}
::-webkit-input-placeholder {
line-height: normal;
}
.spinner-leaf {
position: absolute;
top: 0;
left: calc(50% - 12.5% / 2);
width: 12.5%;
height: 100%;
animation: spinner-leaf-fade 800ms linear infinite;
&::before {
content: "";
display: block;
width: 100%;
height: 30%;
background-color: currentColor;
@apply rounded;
}
&:where(:nth-child(1)) {
transform: rotate(0deg);
animation-delay: -800ms;
}
&:where(:nth-child(2)) {
transform: rotate(45deg);
animation-delay: -700ms;
}
&:where(:nth-child(3)) {
transform: rotate(90deg);
animation-delay: -600ms;
}
&:where(:nth-child(4)) {
transform: rotate(135deg);
animation-delay: -500ms;
}
&:where(:nth-child(5)) {
transform: rotate(180deg);
animation-delay: -400ms;
}
&:where(:nth-child(6)) {
transform: rotate(225deg);
animation-delay: -300ms;
}
&:where(:nth-child(7)) {
transform: rotate(270deg);
animation-delay: -200ms;
}
&:where(:nth-child(8)) {
transform: rotate(315deg);
animation-delay: -100ms;
}
}
@keyframes spinner-leaf-fade {
from {
opacity: 1;
}
to {
opacity: 0.25;
}
}

View File

@@ -1,53 +0,0 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createRouter } from "@tanstack/react-router";
import React, { StrictMode } from "react";
import ReactDOM from "react-dom/client";
import { I18nextProvider } from "react-i18next";
import "./app.css";
import { type } from "@tauri-apps/plugin-os";
import i18n from "./locale";
import { routeTree } from "./router.gen"; // auto generated file
const queryClient = new QueryClient();
const os = await type();
// Set up a Router instance
const router = createRouter({
routeTree,
context: {
queryClient,
platform: os,
},
Wrap: ({ children }) => {
return (
<I18nextProvider i18n={i18n} defaultNS={"translation"}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</I18nextProvider>
);
},
});
// Register things for typesafety
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
function App() {
return <RouterProvider router={router} />;
}
// biome-ignore lint/style/noNonNullAssertion: idk
const rootElement = document.getElementById("root")!;
if (!rootElement.innerHTML) {
const root = ReactDOM.createRoot(rootElement);
root.render(
<StrictMode>
<App />
</StrictMode>,
);
}

View File

@@ -1,43 +0,0 @@
import { NostrQuery } from "@lume/system";
import { Spinner } from "@lume/ui";
import { cn } from "@lume/utils";
import { message } from "@tauri-apps/plugin-dialog";
import {
type Dispatch,
type ReactNode,
type SetStateAction,
useState,
} from "react";
export function AvatarUploader({
setPicture,
children,
className,
}: {
setPicture: Dispatch<SetStateAction<string>>;
children: ReactNode;
className?: string;
}) {
const [loading, setLoading] = useState(false);
const uploadAvatar = async () => {
try {
setLoading(true);
const image = await NostrQuery.upload();
setPicture(image);
} catch (e) {
setLoading(false);
await message(String(e), { title: "Lume", kind: "error" });
}
};
return (
<button
type="button"
onClick={() => uploadAvatar()}
className={cn("size-4", className)}
>
{loading ? <Spinner className="size-4" /> : children}
</button>
);
}

View File

@@ -1,41 +0,0 @@
import { User } from "@/components/user";
import { NostrAccount } from "@lume/system";
import { getBitcoinDisplayValues } from "@lume/utils";
import { useEffect, useMemo, useState } from "react";
export function Balance({ account }: { account: string }) {
const [balance, setBalance] = useState(0);
const value = useMemo(() => getBitcoinDisplayValues(balance), [balance]);
useEffect(() => {
async function getBalance() {
const val = await NostrAccount.getBalance();
setBalance(val);
}
getBalance();
}, []);
return (
<div
data-tauri-drag-region
className="flex h-16 items-center justify-end px-3"
>
<div className="flex items-center gap-2">
<div className="text-end">
<div className="text-sm leading-tight text-neutral-700 dark:text-neutral-300">
Your balance
</div>
<div className="font-medium leading-tight">
{value.bitcoinFormatted}
</div>
</div>
<User.Provider pubkey={account}>
<User.Root>
<User.Avatar className="size-9 rounded-full" />
</User.Root>
</User.Provider>
</div>
</div>
);
}

View File

@@ -1,159 +0,0 @@
import { CancelIcon, CheckIcon } from "@lume/icons";
import type { LumeColumn } from "@lume/types";
import { cn } from "@lume/utils";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { getCurrent } from "@tauri-apps/api/webviewWindow";
import { useCallback, useEffect, useRef, useState } from "react";
type WindowEvent = {
scroll: boolean;
resize: boolean;
};
export function Column({
column,
account,
}: {
column: LumeColumn;
account: string;
}) {
const container = useRef<HTMLDivElement>(null);
const webviewLabel = `column-${account}_${column.label}`;
const [isCreated, setIsCreated] = useState(false);
const repositionWebview = useCallback(async () => {
const newRect = container.current.getBoundingClientRect();
await invoke("reposition_column", {
label: webviewLabel,
x: newRect.x,
y: newRect.y,
});
}, []);
const resizeWebview = useCallback(async () => {
const newRect = container.current.getBoundingClientRect();
await invoke("resize_column", {
label: webviewLabel,
width: newRect.width,
height: newRect.height,
});
}, []);
useEffect(() => {
if (!isCreated) return;
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(() => {
if (!container?.current) return;
const rect = container.current.getBoundingClientRect();
const url = `${column.content}?account=${account}&label=${column.label}&name=${column.name}`;
// create new webview
invoke("create_column", {
label: webviewLabel,
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
url,
}).then(() => {
console.log("created: ", webviewLabel);
setIsCreated(true);
});
// close webview when unmounted
return () => {
invoke("close_column", { label: webviewLabel }).then(() => {
console.log("closed: ", webviewLabel);
});
};
}, [account]);
return (
<div className="h-full w-[500px] shrink-0 p-2">
<div
className={cn(
"flex flex-col w-full h-full rounded-xl",
column.label !== "open"
? "bg-black/5 dark:bg-white/5 backdrop-blur-sm"
: "",
)}
>
<Header label={column.label} name={column.name} />
<div ref={container} className="flex-1 w-full h-full" />
</div>
</div>
);
}
function Header({ label, name }: { label: string; name: string }) {
const [title, setTitle] = useState(name);
const [isChanged, setIsChanged] = useState(false);
const saveNewTitle = async () => {
const mainWindow = getCurrent();
await mainWindow.emit("columns", { type: "set_title", label, title });
// update search params
// @ts-ignore, hahaha
search.name = title;
// reset state
setIsChanged(false);
};
const close = async () => {
const mainWindow = getCurrent();
await mainWindow.emit("columns", { type: "remove", label });
};
useEffect(() => {
if (title.length !== name.length) setIsChanged(true);
}, [title]);
return (
<div className="flex items-center justify-between w-full px-1 h-9 shrink-0">
<div className="size-7" />
<div className="flex items-center justify-center shrink-0 h-9">
<div className="relative flex items-center gap-2">
<div
contentEditable
suppressContentEditableWarning={true}
onBlur={(e) => setTitle(e.currentTarget.textContent)}
className="text-sm font-medium focus:outline-none"
>
{name}
</div>
{isChanged ? (
<button
type="button"
onClick={() => saveNewTitle()}
className="text-teal-500 hover:text-teal-600"
>
<CheckIcon className="size-4" />
</button>
) : null}
</div>
</div>
<button
type="button"
onClick={() => close()}
className="inline-flex items-center justify-center rounded-lg size-7 hover:bg-black/10 dark:hover:bg-white/10 text-neutral-600 dark:text-neutral-400 hover:text-neutral-800 dark:hover:text-neutral-200"
>
<CancelIcon className="size-4" />
</button>
</div>
);
}

View File

@@ -1,28 +0,0 @@
import { ZapIcon } from "@lume/icons";
import { LumeWindow } from "@lume/system";
import { cn } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router";
import { useNoteContext } from "../provider";
export function NoteZap({ large = false }: { large?: boolean }) {
const event = useNoteContext();
const { settings } = useRouteContext({ strict: false });
if (!settings.display_zap_button) return null;
return (
<button
type="button"
onClick={() => LumeWindow.openZap(event.id, event.pubkey)}
className={cn(
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
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"
: "size-7",
)}
>
<ZapIcon className="size-4" />
{large ? "Zap" : null}
</button>
);
}

View File

@@ -1,10 +0,0 @@
export function Hashtag({ tag }: { tag: string }) {
return (
<span className="leading-normal break-all cursor-default group text-start">
<span className="text-blue-500">#</span>
<span className="underline underline-offset-1 decoration-2 decoration-blue-200 dark:decoration-blue-800 group-hover:decoration-blue-500">
{tag.replace("#", "")}
</span>
</span>
);
}

View File

@@ -1,76 +0,0 @@
import { LumeWindow, useEvent } from "@lume/system";
import { LinkIcon } from "@lume/icons";
import { useTranslation } from "react-i18next";
import { cn } from "@lume/utils";
import { User } from "@/components/user";
import { Spinner } from "@lume/ui";
export function MentionNote({
eventId,
openable = true,
}: {
eventId: string;
openable?: boolean;
}) {
const { t } = useTranslation();
const { isLoading, isError, data } = useEvent(eventId);
if (isLoading) {
return (
<div className="flex items-center justify-center w-full h-20 mt-2 border rounded-xl border-black/10 dark:border-white/10">
<Spinner className="size-5" />
</div>
);
}
if (isError || !data) {
return (
<div className="w-full p-3 mt-2 border rounded-xl border-black/10 dark:border-white/10">
{t("note.error")}
</div>
);
}
return (
<div className="flex flex-col w-full border rounded-lg cursor-default border-black/10 dark:border-white/10">
<User.Provider pubkey={data.pubkey}>
<User.Root className="flex items-center gap-2 px-3 h-11">
<User.Avatar className="object-cover rounded-full size-6 shrink-0" />
<div className="inline-flex items-center flex-1 gap-2">
<User.Name className="font-semibold text-neutral-900 dark:text-neutral-100" />
<span className="text-neutral-600 dark:text-neutral-400">·</span>
<User.Time
time={data.created_at}
className="text-neutral-600 dark:text-neutral-400"
/>
</div>
</User.Root>
</User.Provider>
<div
className={cn(
"px-3 select-text whitespace-normal text-pretty content-break leading-normal",
data.content.length > 400 ? "max-h-[150px] gradient-mask-b-0" : "",
)}
>
{data.content}
</div>
{openable ? (
<div className="flex items-center justify-end px-2 h-11">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
LumeWindow.openEvent(data);
}}
className="z-10 inline-flex items-center justify-center gap-1 text-sm rounded-full h-7 w-28 bg-black/10 dark:bg-white/10 text-neutral-600 hover:text-blue-500 dark:text-neutral-400"
>
View post
<LinkIcon className="size-4" />
</button>
</div>
) : (
<div className="h-3" />
)}
</div>
);
}

View File

@@ -1,36 +0,0 @@
import { Carousel, CarouselItem } from "@lume/ui";
export function Videos({ urls }: { urls: string[] }) {
if (urls.length === 1) {
return (
<div className="group px-3">
<video
className="w-full h-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
controls
muted
>
<source src={urls[0]} type="video/mp4" />
Your browser does not support the video tag.
</video>
</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>
)}
/>
);
}

View File

@@ -1,15 +0,0 @@
import type { ReactNode } from "@tanstack/react-router";
import { useLayoutEffect, useState } from "react";
import { createPortal } from "react-dom";
export function Toolbar({ children }: { children: ReactNode }) {
const [domReady, setDomReady] = useState(false);
useLayoutEffect(() => {
setDomReady(true);
}, []);
return domReady
? createPortal(children, document.getElementById("toolbar"))
: null;
}

View File

@@ -1,65 +0,0 @@
import { cn } from "@lume/utils";
import * as Avatar from "@radix-ui/react-avatar";
import { useRouteContext } from "@tanstack/react-router";
import { minidenticon } from "minidenticons";
import { nanoid } from "nanoid";
import { useMemo } from "react";
import { useUserContext } from "./provider";
export function UserAvatar({ className }: { className?: string }) {
const user = useUserContext();
const { settings } = useRouteContext({ strict: false });
const picture = useMemo(() => {
if (
settings?.image_resize_service?.length &&
user.profile?.picture?.length
) {
const url = `${settings.image_resize_service}?url=${user.profile?.picture}&w=100&h=100&default=1&n=-1`;
return url;
} else {
return user.profile?.picture;
}
}, [user.profile?.picture]);
const fallbackAvatar = useMemo(
() =>
`data:image/svg+xml;utf8,${encodeURIComponent(
minidenticon(user.pubkey || nanoid(), 90, 50),
)}`,
[user.pubkey],
);
if (settings && !settings.display_avatar) {
return (
<Avatar.Root className="shrink-0">
<Avatar.Fallback delayMs={120}>
<img
src={fallbackAvatar}
alt={user.pubkey}
className={cn("bg-black dark:bg-white", className)}
/>
</Avatar.Fallback>
</Avatar.Root>
);
}
return (
<Avatar.Root className="shrink-0">
<Avatar.Image
src={picture}
alt={user.pubkey}
loading="eager"
decoding="async"
className={cn("outline-[.5px] outline-black/5 object-cover", className)}
/>
<Avatar.Fallback delayMs={120}>
<img
src={fallbackAvatar}
alt={user.pubkey}
className={cn("bg-black dark:bg-white", className)}
/>
</Avatar.Fallback>
</Avatar.Root>
);
}

View File

@@ -1,60 +0,0 @@
import { cn } from "@lume/utils";
import { useEffect, useState } from "react";
import { Spinner } from "@lume/ui";
import { useUserContext } from "./provider";
import { NostrAccount } from "@lume/system";
export function UserFollowButton({
simple = false,
className,
}: {
simple?: boolean;
className?: string;
}) {
const user = useUserContext();
const [loading, setLoading] = useState(false);
const [followed, setFollowed] = useState(false);
const toggleFollow = async () => {
setLoading(true);
const toggle = await NostrAccount.toggleContact(user.pubkey);
if (toggle) {
setFollowed((prev) => !prev);
setLoading(false);
}
};
useEffect(() => {
let mounted = true;
NostrAccount.checkContact(user.pubkey).then((status) => {
if (mounted) setFollowed(status);
});
return () => {
mounted = false;
};
}, []);
return (
<button
type="button"
disabled={loading}
onClick={() => toggleFollow()}
className={cn("w-max", className)}
>
{loading ? (
<Spinner className="size-4" />
) : followed ? (
!simple ? (
"Unfollow"
) : null
) : (
"Follow"
)}
</button>
);
}

View File

@@ -1,187 +0,0 @@
import { Column } from "@/components/column";
import { Toolbar } from "@/components/toolbar";
import { ArrowLeftIcon, ArrowRightIcon, PlusSquareIcon } from "@lume/icons";
import { NostrQuery } from "@lume/system";
import type { ColumnEvent, LumeColumn } from "@lume/types";
import { createFileRoute } from "@tanstack/react-router";
import { listen } from "@tauri-apps/api/event";
import { getCurrent } from "@tauri-apps/api/window";
import useEmblaCarousel from "embla-carousel-react";
import { nanoid } from "nanoid";
import { useCallback, useEffect, useState } from "react";
import { useDebouncedCallback } from "use-debounce";
export const Route = createFileRoute("/$account/home")({
loader: async () => {
const columns = await NostrQuery.getColumns();
return columns;
},
component: Screen,
});
function Screen() {
const { account } = Route.useParams();
const initialColumnList = Route.useLoaderData();
const [columns, setColumns] = useState<LumeColumn[]>([]);
const [emblaRef, emblaApi] = useEmblaCarousel({
watchDrag: false,
loop: false,
});
const scrollPrev = useCallback(() => {
if (emblaApi) emblaApi.scrollPrev(true);
}, [emblaApi]);
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 = useDebouncedCallback(async () => {
await getCurrent().emit("columns", {
type: "add",
column: {
label: "store",
name: "Store",
content: "/store/official",
},
});
}, 150);
const add = useDebouncedCallback((column: LumeColumn) => {
column.label = `${column.label}-${nanoid()}`; // update col label
setColumns((prev) => [column, ...prev]);
}, 150);
const remove = useDebouncedCallback((label: string) => {
setColumns((prev) => prev.filter((t) => t.label !== label));
}, 150);
const updateName = useDebouncedCallback((label: string, title: string) => {
const currentColIndex = columns.findIndex((col) => col.label === label);
const updatedCol = Object.assign({}, columns[currentColIndex]);
updatedCol.name = title;
const newCols = columns.slice();
newCols[currentColIndex] = updatedCol;
setColumns(newCols);
}, 150);
const reset = useDebouncedCallback(() => setColumns([]), 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(() => {
if (emblaApi) {
emblaApi.on("scroll", emitScrollEvent);
emblaApi.on("resize", emitResizeEvent);
emblaApi.on("slidesChanged", emitScrollEvent);
}
return () => {
emblaApi?.off("scroll", emitScrollEvent);
emblaApi?.off("resize", emitResizeEvent);
emblaApi?.off("slidesChanged", emitScrollEvent);
};
}, [emblaApi, emitScrollEvent, emitResizeEvent]);
useEffect(() => {
if (columns?.length) {
NostrQuery.setColumns(columns).then(() => console.log("saved"));
}
}, [columns]);
useEffect(() => {
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 === "remove") remove(data.payload.label);
if (data.payload.type === "set_title")
updateName(data.payload.label, data.payload.title);
});
return () => {
unlisten.then((f) => f());
};
}, []);
return (
<div className="size-full">
<div ref={emblaRef} className="overflow-hidden size-full">
<div className="flex size-full">
{columns?.map((column) => (
<Column
key={account + column.label}
column={column}
account={account}
/>
))}
</div>
</div>
<Toolbar>
<div className="flex items-center h-8 gap-1 p-[2px] rounded-full bg-black/5 dark:bg-white/5">
<button
type="button"
onClick={() => scrollPrev()}
className="inline-flex items-center justify-center rounded-full size-7 text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
>
<ArrowLeftIcon className="size-4" />
</button>
<button
type="button"
onClick={() => openLumeStore()}
className="inline-flex items-center justify-center rounded-full size-7 text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
>
<PlusSquareIcon className="size-4" />
</button>
<button
type="button"
onClick={() => scrollNext()}
className="inline-flex items-center justify-center rounded-full size-7 text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
>
<ArrowRightIcon className="size-4" />
</button>
</div>
</Toolbar>
</div>
);
}

View File

@@ -1,210 +0,0 @@
import { User } from "@/components/user";
import { ComposeFilledIcon, HorizontalDotsIcon, PlusIcon } from "@lume/icons";
import { LumeWindow, NostrAccount } from "@lume/system";
import { cn } from "@lume/utils";
import * as Popover from "@radix-ui/react-popover";
import { Outlet, createFileRoute } from "@tanstack/react-router";
import { Link } from "@tanstack/react-router";
import { Menu, MenuItem } from "@tauri-apps/api/menu";
import { getCurrent } from "@tauri-apps/api/window";
import { message } from "@tauri-apps/plugin-dialog";
import { useCallback, useEffect, useMemo, useState } from "react";
export const Route = createFileRoute("/$account")({
beforeLoad: async () => {
const accounts = await NostrAccount.getAccounts();
return { accounts };
},
component: Screen,
});
function Screen() {
const { platform } = Route.useRouteContext();
return (
<div className="flex flex-col w-screen h-screen">
<div
data-tauri-drag-region
className={cn(
"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">
<Accounts />
<Link
to="/landing"
className="inline-flex items-center justify-center rounded-full size-8 shrink-0 bg-black/10 text-neutral-800 hover:bg-black/20 dark:bg-white/10 dark:text-neutral-200 dark:hover:bg-white/20"
>
<PlusIcon className="size-5" />
</Link>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => LumeWindow.openEditor()}
className="inline-flex items-center justify-center h-8 gap-1 px-3 text-sm font-medium text-white bg-blue-500 rounded-full w-max hover:bg-blue-600"
>
<ComposeFilledIcon className="size-4" />
New Post
</button>
<div id="toolbar" />
</div>
</div>
<div className="flex-1">
<Outlet />
</div>
</div>
);
}
function Accounts() {
const navigate = Route.useNavigate();
const { accounts } = Route.useRouteContext();
const { account } = Route.useParams();
const [windowWidth, setWindowWidth] = useState<number>(null);
const sortedList = useMemo(() => {
const list = accounts;
for (const [i, item] of list.entries()) {
if (item === account) {
list.splice(i, 1);
list.unshift(item);
}
}
return list;
}, [accounts]);
const showContextMenu = useCallback(
async (e: React.MouseEvent, npub: string) => {
e.preventDefault();
const menuItems = await Promise.all([
MenuItem.new({
text: "View Profile",
action: () => LumeWindow.openProfile(npub),
}),
MenuItem.new({
text: "Open Settings",
action: () => LumeWindow.openSettings(),
}),
]);
const menu = await Menu.new({
items: menuItems,
});
await menu.popup().catch((e) => console.error(e));
},
[],
);
const changeAccount = async (e: React.MouseEvent, npub: string) => {
if (npub === account) {
return showContextMenu(e, npub);
}
// 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" });
}
};
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 (
<div data-tauri-drag-region className="flex items-center gap-3">
{sortedList
.slice(0, windowWidth > 500 ? account.length : 2)
.map((user) => (
<button
key={user}
type="button"
onClick={(e) => changeAccount(e, user)}
>
<User.Provider pubkey={user}>
<User.Root
className={cn(
"shrink-0 rounded-full transition-all ease-in-out duration-150 will-change-auto",
user === account
? "ring-1 ring-teal-500 ring-offset-2 ring-offset-neutral-200 dark:ring-offset-neutral-950"
: "",
)}
>
<User.Avatar
className={cn(
"aspect-square h-auto rounded-full object-cover transition-all ease-in-out duration-150 will-change-auto",
user === account ? "w-7" : "w-8",
)}
/>
</User.Root>
</User.Provider>
</button>
))}
{accounts.length >= 3 && windowWidth <= 700 ? (
<Popover.Root>
<Popover.Trigger className="inline-flex items-center justify-center rounded-full size-8 shrink-0 bg-black/10 text-neutral-800 hover:bg-black/20 dark:bg-white/10 dark:text-neutral-200 dark:hover:bg-white/20">
<HorizontalDotsIcon className="size-5" />
</Popover.Trigger>
<Popover.Portal>
<Popover.Content className="flex h-11 select-none items-center justify-center rounded-md bg-black/20 p-1 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">
{sortedList.slice(2).map((user) => (
<button
key={user}
type="button"
onClick={(e) => changeAccount(e, user)}
className="inline-flex items-center justify-center rounded-md size-9 hover:bg-white/10"
>
<User.Provider pubkey={user}>
<User.Root className="rounded-full ring-1 ring-white/10">
<User.Avatar className="object-cover h-auto rounded-full size-7 aspect-square" />
</User.Root>
</User.Provider>
</button>
))}
<Popover.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Popover.Content>
</Popover.Portal>
</Popover.Root>
) : null}
</div>
);
}

View File

@@ -1,31 +0,0 @@
import type { Settings } from "@lume/system";
import { Spinner } from "@lume/ui";
import type { QueryClient } from "@tanstack/react-query";
import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
import type { Platform } from "@tauri-apps/plugin-os";
interface RouterContext {
// System
queryClient: QueryClient;
// App info
platform?: Platform;
locale?: string;
// Settings
settings?: Settings;
// Accounts
accounts?: string[];
}
export const Route = createRootRouteWithContext<RouterContext>()({
component: () => <Outlet />,
pendingComponent: Pending,
wrapInSuspense: true,
});
function Pending() {
return (
<div className="flex flex-col items-center justify-center w-screen h-screen">
<Spinner className="size-5" />
</div>
);
}

View File

@@ -1,16 +0,0 @@
import { Box, Container } from "@lume/ui";
import { Outlet, createLazyFileRoute } from "@tanstack/react-router";
export const Route = createLazyFileRoute("/auth")({
component: Screen,
});
function Screen() {
return (
<Container withDrag>
<Box className="px-3 pt-3">
<Outlet />
</Box>
</Container>
);
}

View File

@@ -1,144 +0,0 @@
import { AvatarUploader } from "@/components/avatarUploader";
import { PlusIcon } from "@lume/icons";
import { NostrAccount } from "@lume/system";
import type { Metadata } from "@lume/types";
import { Spinner } from "@lume/ui";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { message } from "@tauri-apps/plugin-dialog";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
export const Route = createFileRoute("/auth/create-profile")({
component: Screen,
loader: async () => {
const account = await NostrAccount.createAccount();
return account;
},
});
function Screen() {
const account = Route.useLoaderData();
const navigate = useNavigate();
const { t } = useTranslation();
const { register, handleSubmit } = useForm();
const [picture, setPicture] = useState<string>("");
const [loading, setLoading] = useState(false);
const onSubmit = async (data: {
name: string;
about: string;
website: string;
}) => {
setLoading(true);
try {
// Save account keys
const save = await NostrAccount.saveAccount(account.nsec);
// Then create profile
if (save) {
const profile: Metadata = { ...data, picture };
const eventId = await NostrAccount.createProfile(profile);
if (eventId) {
navigate({
to: "/auth/$account/backup",
params: { account: account.npub },
replace: true,
});
}
}
} catch (e) {
setLoading(false);
await message(String(e), { title: "Create Profile", kind: "error" });
}
};
return (
<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">
<h3 className="text-xl font-semibold">Let's set up your profile.</h3>
</div>
<div>
<div className="relative rounded-full size-24 bg-gradient-to-tr from-orange-100 via-red-50 to-blue-200">
{picture ? (
<img
src={picture}
alt="avatar"
loading="lazy"
decoding="async"
className="absolute inset-0 z-10 object-cover w-full h-full rounded-full"
/>
) : null}
<AvatarUploader
setPicture={setPicture}
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" />
</AvatarUploader>
</div>
</div>
<form
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col w-full gap-3"
>
<div className="flex flex-col gap-1">
<label htmlFor="display_name" className="font-medium">
{t("user.displayName")} *
</label>
<input
type={"text"}
{...register("display_name", { required: true, minLength: 1 })}
placeholder="e.g. Alice in Nostrland"
spellCheck={false}
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 className="flex flex-col gap-1">
<label htmlFor="name" className="font-medium">
{t("user.name")}
</label>
<input
type={"text"}
{...register("name")}
placeholder="e.g. alice"
spellCheck={false}
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 className="flex flex-col gap-1">
<label htmlFor="about" className="font-medium">
{t("user.bio")}
</label>
<textarea
{...register("about")}
placeholder="e.g. Artist, anime-lover, and k-pop fan"
spellCheck={false}
className="relative h-24 w-full resize-none rounded-lg border-transparent bg-neutral-100 px-3 py-2 !outline-none placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="website" className="font-medium">
{t("user.website")}
</label>
<input
type="url"
{...register("website")}
placeholder="e.g. https://alice.me"
spellCheck={false}
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>
<button
type="submit"
className="inline-flex items-center justify-center w-full mt-3 font-semibold text-white bg-blue-500 rounded-lg h-11 shrink-0 hover:bg-blue-600 disabled:opacity-50"
>
{loading ? <Spinner /> : t("global.continue")}
</button>
</form>
</div>
);
}

View File

@@ -1,88 +0,0 @@
import { NostrAccount } from "@lume/system";
import { Spinner } from "@lume/ui";
import { createLazyFileRoute } from "@tanstack/react-router";
import { message } from "@tauri-apps/plugin-dialog";
import { useState } from "react";
export const Route = createLazyFileRoute("/auth/import")({
component: Screen,
});
function Screen() {
const navigate = Route.useNavigate();
const [key, setKey] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const submit = async () => {
if (!key.startsWith("nsec1")) {
return await message(
"You need to enter a valid private key starts with nsec or ncryptsec",
{ title: "Import Key", kind: "info" },
);
}
try {
setLoading(true);
const npub = await NostrAccount.saveAccount(key, password);
if (npub) {
navigate({ to: "/", replace: true });
}
} catch (e) {
setLoading(false);
await message(String(e), { title: "Import Key", kind: "error" });
}
};
return (
<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">
<h3 className="text-xl font-semibold">Continue with Private Key</h3>
</div>
<div className="flex flex-col w-full gap-3">
<div className="flex flex-col gap-1">
<label
htmlFor="key"
className="font-medium text-neutral-900 dark:text-neutral-100"
>
Private Key
</label>
<input
name="key"
type="text"
placeholder="nsec or ncryptsec..."
value={key}
onChange={(e) => setKey(e.target.value)}
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 className="flex flex-col gap-1">
<label
htmlFor="password"
className="font-medium text-neutral-900 dark:text-neutral-100"
>
Password (Optional)
</label>
<input
name="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
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>
<button
type="button"
onClick={() => submit()}
disabled={loading}
className="inline-flex items-center justify-center w-full mt-3 font-semibold text-white bg-blue-500 rounded-lg h-11 shrink-0 hover:bg-blue-600 disabled:opacity-50"
>
{loading ? <Spinner /> : "Login"}
</button>
</div>
</div>
);
}

View File

@@ -1,79 +0,0 @@
import { NostrAccount } from "@lume/system";
import { Spinner } from "@lume/ui";
import { createLazyFileRoute } from "@tanstack/react-router";
import { message } from "@tauri-apps/plugin-dialog";
import { useState } from "react";
export const Route = createLazyFileRoute("/auth/remote")({
component: Screen,
});
function Screen() {
const navigate = Route.useNavigate();
const [uri, setUri] = useState("");
const [loading, setLoading] = useState(false);
const submit = async () => {
if (!uri.startsWith("bunker://")) {
return await message(
"You need to enter a valid Connect URI starts with bunker://",
{ title: "Nostr Connect", kind: "info" },
);
}
try {
setLoading(true);
const remoteAccount = await NostrAccount.connectRemoteAccount(uri);
if (remoteAccount?.length) {
navigate({ to: "/", replace: true });
}
} catch (e) {
setLoading(false);
await message(String(e), { title: "Nostr Connect", kind: "error" });
}
};
return (
<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">
<h3 className="text-xl font-semibold">Continue with Nostr Connect</h3>
</div>
<div className="flex flex-col w-full gap-3">
<div className="flex flex-col gap-1">
<label
htmlFor="uri"
className="font-medium text-neutral-900 dark:text-neutral-100"
>
Connect URI
</label>
<input
name="uri"
type="text"
placeholder="bunker://..."
value={uri}
onChange={(e) => setUri(e.target.value)}
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 className="flex flex-col items-center gap-1">
<button
type="button"
onClick={() => submit()}
disabled={loading}
className="inline-flex items-center justify-center w-full mt-3 font-semibold text-white bg-blue-500 rounded-lg h-11 shrink-0 hover:bg-blue-600 disabled:opacity-50"
>
{loading ? <Spinner /> : "Login"}
</button>
{loading ? (
<p className="text-sm text-center text-neutral-600 dark:text-neutral-400">
Waiting confirmation...
</p>
) : null}
</div>
</div>
</div>
);
}

View File

@@ -1,131 +0,0 @@
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 {
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 className="flex flex-col items-center justify-center w-screen h-screen">
<div className="w-full max-w-sm mx-auto lg:max-w-lg">
<div className="text-center h-11">
<h1 className="font-semibold">Customize Bootstrap Relays</h1>
</div>
<div className="flex flex-col w-full px-2 bg-white rounded-xl shadow-primary backdrop-blur-lg dark:bg-white/20 dark:ring-1 ring-neutral-800/50">
{relays.map((relay) => (
<div
key={relay.url}
className="flex items-center justify-between h-11"
>
<div className="inline-flex items-center gap-2 text-sm font-medium">
{relay.url}
</div>
<div className="flex items-center gap-2">
{relay.purpose?.length ? (
<button
type="button"
className="inline-flex items-center justify-center px-2 text-xs font-medium uppercase rounded-md h-7 w-max hover:bg-black/10 dark:hover:bg-white/10"
>
{relay.purpose}
</button>
) : null}
<button
type="button"
onClick={() => removeRelay(relay.url)}
className="inline-flex items-center justify-center rounded-md size-7 text-neutral-700 dark:text-white/20 hover:bg-black/10 dark:hover:bg-white/10"
>
<CancelIcon className="size-3" />
</button>
</div>
</div>
))}
<div className="flex items-center border-t h-14 border-neutral-100 dark:border-white/5">
<form
onSubmit={handleSubmit(onSubmit)}
className="flex items-center w-full gap-2 mb-0"
>
<div className="flex items-center flex-1 gap-2 border rounded-lg border-neutral-300 dark:border-white/20">
<input
{...register("url", {
required: true,
minLength: 1,
})}
name="url"
placeholder="wss://..."
spellCheck={false}
className="flex-1 px-3 bg-transparent border-none rounded-l-lg h-9 placeholder:text-neutral-500 dark:placeholder:text-neutral-400"
/>
<select
{...register("purpose")}
className="flex-1 p-0 m-0 text-sm bg-transparent border-none outline-none h-9 ring-0 focus:outline-none focus:ring-0"
>
<option value="read">Read</option>
<option value="write">Write</option>
<option value="">Both</option>
</select>
</div>
<button
type="submit"
className="inline-flex items-center justify-center px-2 text-sm font-medium text-white rounded-lg shrink-0 h-9 w-14 bg-black/20 dark:bg-white/20 hover:bg-blue-500 disabled:opacity-50"
>
<PlusIcon className="size-7" />
</button>
</form>
</div>
</div>
<button
type="button"
onClick={() => save()}
disabled={isLoading}
className="inline-flex items-center justify-center w-full h-10 mt-4 text-sm font-semibold text-white bg-blue-500 rounded-lg shrink-0 hover:bg-blue-600 disabled:opacity-50"
>
{isLoading ? <Spinner /> : "Save & Relaunch"}
</button>
</div>
</div>
);
}

View File

@@ -1,131 +0,0 @@
import { User } from "@/components/user";
import { NostrAccount } from "@lume/system";
import type { ColumnRouteSearch } from "@lume/types";
import { Spinner } from "@lume/ui";
import { createFileRoute } from "@tanstack/react-router";
import { Await, defer } from "@tanstack/react-router";
import { message } from "@tauri-apps/plugin-dialog";
import { Suspense, useState } from "react";
export const Route = createFileRoute("/create-newsfeed/users")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
return {
account: search.account,
label: search.label,
name: search.name,
};
},
loader: async ({ abortController }) => {
try {
return {
data: defer(
fetch("https://api.nostr.band/v0/trending/profiles", {
signal: abortController.signal,
}).then((res) => res.json()),
),
};
} catch (e) {
throw new Error(String(e));
}
},
component: Screen,
});
function Screen() {
const { data } = Route.useLoaderData();
const { redirect } = Route.useSearch();
const [isLoading, setIsLoading] = useState(false);
const [follows, setFollows] = useState<string[]>([]);
const navigate = Route.useNavigate();
const toggleFollow = (pubkey: string) => {
setFollows((prev) =>
prev.includes(pubkey)
? prev.filter((i) => i !== pubkey)
: [...prev, pubkey],
);
};
const submit = async () => {
try {
setIsLoading(true);
const newContactList = await NostrAccount.setContactList(follows);
if (newContactList) {
return navigate({ to: redirect });
}
} catch (e) {
setIsLoading(false);
await message(String(e), {
title: "Create Group",
kind: "error",
});
}
};
return (
<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">
<Suspense
fallback={
<div className="flex flex-col items-center justify-center w-full h-20 gap-1">
<button
type="button"
className="inline-flex items-center gap-2 text-sm font-medium"
disabled
>
<Spinner className="size-5" />
Loading...
</button>
</div>
}
>
<Await promise={data}>
{(users) =>
users.profiles.map((item: { pubkey: string }) => (
<div
key={item.pubkey}
className="w-full p-2 mb-2 overflow-hidden bg-white rounded-lg h-max dark:bg-black/20 backdrop-blur-lg shadow-primary dark:ring-1 ring-neutral-800/50"
>
<User.Provider pubkey={item.pubkey}>
<User.Root>
<div 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="object-cover rounded-full size-7 shrink-0" />
<User.Name className="text-sm leadning-tight max-w-[15rem] truncate font-semibold" />
</div>
<button
type="button"
onClick={() => toggleFollow(item.pubkey)}
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)
? "Unfollow"
: "Follow"}
</button>
</div>
<User.About className="select-text line-clamp-3 max-w-none text-neutral-800 dark:text-neutral-400" />
</div>
</User.Root>
</User.Provider>
</div>
))
}
</Await>
</Suspense>
</div>
<button
type="button"
onClick={() => submit()}
disabled={isLoading || follows.length < 1}
className="inline-flex items-center justify-center text-sm font-medium text-white bg-blue-500 rounded-full w-36 h-9 hover:bg-blue-600 disabled:opacity-50"
>
{isLoading ? <Spinner /> : "Confirm"}
</button>
</div>
);
}

View File

@@ -1,115 +0,0 @@
import { CheckCircleIcon } from "@lume/icons";
import { NostrQuery } from "@lume/system";
import type { ColumnRouteSearch } from "@lume/types";
import { Spinner } from "@lume/ui";
import { TOPICS } from "@lume/utils";
import { createFileRoute } from "@tanstack/react-router";
import { message } from "@tauri-apps/plugin-dialog";
import { useState } from "react";
type Topic = {
title: string;
content: string[];
};
export const Route = createFileRoute("/create-topic")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
return {
account: search.account,
label: search.label,
name: search.name,
};
},
component: Screen,
});
function Screen() {
const [topics, setTopics] = useState<Topic[]>([]);
const [isLoading, setIsLoading] = useState(false);
const search = Route.useSearch();
const navigate = Route.useNavigate();
const toggleTopic = (topic: Topic) => {
setTopics((prev) =>
prev.find((item) => item.title === topic.title)
? prev.filter((i) => i.title !== topic.title)
: [...prev, topic],
);
};
const submit = async () => {
try {
setIsLoading(true);
const key = `lume_topic_${search.label}`;
const createTopic = await NostrQuery.setNstore(
key,
JSON.stringify(topics),
);
if (createTopic) {
return navigate({ to: search.redirect, search: { ...search } });
}
} catch (e) {
setIsLoading(false);
await message(String(e), {
title: "Create Topic",
kind: "error",
});
}
};
return (
<div className="flex flex-col items-center justify-center w-full h-full gap-4">
<div className="flex flex-col items-center justify-center text-center">
<h1 className="font-serif text-2xl font-medium">
What are your interests?
</h1>
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
Add some topics you want to focus on.
</p>
</div>
<div className="flex flex-col w-4/5 max-w-full gap-3">
<div className="flex items-center justify-between w-full px-3 rounded-lg h-9 shrink-0 bg-black/5 dark:bg-white/5">
<span className="text-sm font-medium">Added: {topics.length}</span>
</div>
<div className="flex flex-col items-center w-full gap-3">
<div className="overflow-y-auto scrollbar-none p-2 w-full h-[450px] bg-black/5 dark:bg-white/5 backdrop-blur-lg rounded-xl">
<div className="flex flex-col gap-3">
{TOPICS.map((topic) => (
<button
key={topic.title}
type="button"
onClick={() => toggleTopic(topic)}
className="flex items-center justify-between px-3 bg-white border border-transparent rounded-lg h-11 dark:bg-black/20 backdrop-blur-lg hover:border-blue-500 shadow-primary dark:ring-1 ring-neutral-800/50"
>
<div className="inline-flex items-center gap-1">
<div>{topic.icon}</div>
<div className="text-sm font-medium">
<span>{topic.title}</span>
<span className="ml-1 italic font-normal text-neutral-400 dark:text-neutral-600">
{topic.content.length} hashtags
</span>
</div>
</div>
{topics.find((item) => item.title === topic.title) ? (
<CheckCircleIcon className="text-teal-500 size-4" />
) : null}
</button>
))}
</div>
</div>
<button
type="button"
onClick={() => submit()}
disabled={isLoading || topics.length < 1}
className="inline-flex items-center justify-center text-sm font-medium text-white bg-blue-500 rounded-full w-36 h-9 hover:bg-blue-600 disabled:opacity-50"
>
{isLoading ? <Spinner /> : "Confirm"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,77 +0,0 @@
import { AddMediaIcon } from "@lume/icons";
import { NostrQuery } from "@lume/system";
import { Spinner } from "@lume/ui";
import { insertImage, isImagePath } from "@lume/utils";
import type { UnlistenFn } from "@tauri-apps/api/event";
import { getCurrent } from "@tauri-apps/api/window";
import { message } from "@tauri-apps/plugin-dialog";
import { useEffect, useState } from "react";
import { useSlateStatic } from "slate-react";
export function MediaButton() {
const editor = useSlateStatic();
const [loading, setLoading] = useState(false);
const upload = async () => {
try {
// start loading
setLoading(true);
const image = await NostrQuery.upload();
insertImage(editor, image);
// reset loading
setLoading(false);
} catch (e) {
setLoading(false);
await message(String(e), { title: "Upload", kind: "error" });
}
};
useEffect(() => {
let unlisten: UnlistenFn = undefined;
async function listenFileDrop() {
const window = getCurrent();
if (!unlisten) {
unlisten = await window.listen("tauri://file-drop", async (event) => {
// @ts-ignore, lfg !!!
const items: string[] = event.payload.paths;
// start loading
setLoading(true);
// upload all images
for (const item of items) {
if (isImagePath(item)) {
const image = await NostrQuery.upload(item);
insertImage(editor, image);
}
}
// stop loading
setLoading(false);
});
}
}
listenFileDrop();
return () => {
if (unlisten) unlisten();
};
}, []);
return (
<button
type="button"
onClick={() => upload()}
disabled={loading}
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"
>
{loading ? (
<Spinner className="size-4" />
) : (
<AddMediaIcon className="size-4" />
)}
Add media
</button>
);
}

View File

@@ -1,82 +0,0 @@
import { Note } from "@/components/note";
import { type LumeEvent, NostrQuery } from "@lume/system";
import { Box, Container, Spinner } from "@lume/ui";
import { createFileRoute } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { WindowVirtualizer } from "virtua";
import { Reply } from "./-components/reply";
export const Route = createFileRoute("/events/$eventId")({
beforeLoad: async () => {
const settings = await NostrQuery.getUserSettings();
return { settings };
},
loader: async ({ params }) => {
const event = await NostrQuery.getEvent(params.eventId);
return event;
},
component: Screen,
});
function Screen() {
const event = Route.useLoaderData();
const [reload, setReload] = useState(false);
const [replies, setReplies] = useState<LumeEvent[]>(null);
useEffect(() => {
let mounted = true;
if (event) {
event.getAllReplies().then((data) => {
if (mounted) setReplies(data);
});
}
return () => {
mounted = false;
};
}, [event]);
return (
<Container withDrag>
<Box className="scrollbar-none">
<WindowVirtualizer>
<Note.Provider event={event}>
<Note.Root>
<div className="flex items-center justify-between px-3 h-14">
<Note.User />
<Note.Menu />
</div>
<Note.ContentLarge className="px-3" />
<div className="flex items-center justify-end gap-2 px-3 mt-4 h-11">
<Note.Reply large />
<Note.Repost large />
<Note.Zap large />
</div>
</Note.Root>
</Note.Provider>
<div className="flex flex-col">
<div className="flex items-center px-3 text-sm font-semibold border-t h-11 text-neutral-700 dark:text-neutral-300 border-neutral-100 dark:border-neutral-900">
Replies ({replies?.length ?? 0})
</div>
{!replies ? (
<Spinner />
) : !replies.length ? (
<div className="flex items-center justify-center w-full">
<div className="flex flex-col items-center justify-center gap-2 py-6">
<h3 className="text-3xl">👋</h3>
<p className="leading-none text-neutral-600 dark:text-neutral-400">
Be the first to Reply!
</p>
</div>
</div>
) : (
replies.map((event) => <Reply key={event.id} event={event} />)
)}
</div>
</WindowVirtualizer>
</Box>
</Container>
);
}

View File

@@ -1,36 +0,0 @@
import { Note } from "@/components/note";
import type { LumeEvent } from "@lume/system";
import { cn } from "@lume/utils";
import { SubReply } from "./subReply";
export function Reply({ event }: { event: LumeEvent }) {
return (
<Note.Provider event={event}>
<Note.Root className="border-t border-neutral-100 dark:border-neutral-900">
<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-4 px-3 mt-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>
);
}

View File

@@ -1,50 +0,0 @@
import type { EventWithReplies } from "@lume/types";
import { Spinner } from "@lume/ui";
import { cn } from "@lume/utils";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Reply } from "./reply";
import { LumeEvent } from "@lume/system";
export function ReplyList({
eventId,
className,
}: {
eventId: string;
className?: string;
}) {
const [t] = useTranslation();
const [data, setData] = useState<null | EventWithReplies[]>(null);
useEffect(() => {
async function getReplies() {
const events = await LumeEvent.getReplies(eventId);
setData(events);
}
getReplies();
}, [eventId]);
return (
<div className={cn("flex flex-col", className)}>
<div className="h-11 flex px-3 items-center text-sm font-semibold text-neutral-700 dark:text-neutral-300 border-t border-neutral-100 dark:border-neutral-900">
Replies ({data?.length ?? 0})
</div>
{!data ? (
<div className="flex h-16 items-center justify-center p-3">
<Spinner className="size-5" />
</div>
) : data.length === 0 ? (
<div className="flex w-full items-center justify-center">
<div className="flex flex-col items-center justify-center gap-2 py-6">
<h3 className="text-3xl">👋</h3>
<p className="leading-none text-neutral-600 dark:text-neutral-400">
{t("note.reply.empty")}
</p>
</div>
</div>
) : (
data.map((event) => <Reply key={event.id} event={event} />)
)}
</div>
);
}

View File

@@ -1,26 +0,0 @@
import type { NostrEvent } from "@lume/types";
import { Note } from "@/components/note";
export function SubReply({
event,
}: {
event: NostrEvent;
rootEventId?: string;
}) {
return (
<Note.Provider event={event}>
<Note.Root>
<div className="px-3 h-14 flex items-center justify-between">
<Note.User />
<Note.Menu />
</div>
<Note.ContentLarge className="px-3" />
<div className="mt-3 flex items-center gap-4 px-3">
<Note.Reply />
<Note.Repost />
<Note.Zap />
</div>
</Note.Root>
</Note.Provider>
);
}

View File

@@ -1,136 +0,0 @@
import { User } from "@/components/user";
import { PlusIcon, RelayIcon } from "@lume/icons";
import { NostrAccount } from "@lume/system";
import { Spinner } from "@lume/ui";
import { checkForAppUpdates, displayNpub } from "@lume/utils";
import { Link } from "@tanstack/react-router";
import { createFileRoute, redirect } from "@tanstack/react-router";
import { message } from "@tauri-apps/plugin-dialog";
import { useState } from "react";
export const Route = createFileRoute("/")({
beforeLoad: async () => {
// Check for app updates
// TODO: move this function to rust
await checkForAppUpdates(true);
// Get all accounts
// TODO: use emit & listen
const accounts = await NostrAccount.getAccounts();
if (accounts.length < 1) {
throw redirect({
to: "/landing",
replace: true,
});
}
return { accounts };
},
component: Screen,
});
function Screen() {
const navigate = Route.useNavigate();
const context = Route.useRouteContext();
const [loading, setLoading] = useState({ npub: "", status: false });
const select = async (npub: string) => {
try {
setLoading({ npub, status: true });
const status = await NostrAccount.loadAccount(npub);
if (status) {
return navigate({
to: "/$account/home",
params: { account: npub },
replace: true,
});
}
} catch (e) {
setLoading({ npub: "", status: false });
await message(String(e), {
title: "Account",
kind: "error",
});
}
};
const currentDate = new Date().toLocaleString("default", {
weekday: "long",
month: "long",
day: "numeric",
});
return (
<div
data-tauri-drag-region
className="flex flex-col items-center justify-between w-full h-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>
</div>
</div>
<div className="flex flex-col items-center flex-1 w-full gap-3">
<div className="flex flex-col w-full max-w-sm mx-auto overflow-hidden bg-white divide-y divide-neutral-100 dark:divide-white/5 rounded-xl shadow-primary backdrop-blur-lg dark:bg-white/10 dark:ring-1 ring-white/15">
{context.accounts.map((account) => (
<div
key={account}
onClick={() => select(account)}
onKeyDown={() => select(account)}
className="flex items-center justify-between hover:bg-black/5 dark:hover:bg-white/5"
>
<User.Provider pubkey={account}>
<User.Root className="flex items-center gap-2.5 p-3">
<User.Avatar className="object-cover rounded-full size-10 shrink-0" />
<div className="inline-flex flex-col items-start">
<User.Name className="max-w-[6rem] truncate font-medium leading-tight" />
<span className="text-sm text-neutral-700 dark:text-neutral-300">
{displayNpub(account, 16)}
</span>
</div>
</User.Root>
</User.Provider>
<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 className="flex-1" />
</div>
);
}

View File

@@ -1,62 +0,0 @@
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 backdrop-blur-lg 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>
);
}

View File

@@ -1,52 +0,0 @@
import { ZapIcon } from "@lume/icons";
import { NostrAccount } from "@lume/system";
import { Container } from "@lume/ui";
import { createLazyFileRoute } from "@tanstack/react-router";
import { useState } from "react";
export const Route = createLazyFileRoute("/nwc")({
component: Screen,
});
function Screen() {
const [uri, setUri] = useState("");
const [isDone, setIsDone] = useState(false);
const save = async () => {
const nwc = await NostrAccount.setWallet(uri);
setIsDone(nwc);
};
return (
<Container withDrag>
<div className="flex-1 w-full h-full px-5">
<div className="flex flex-col gap-2">
<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="flex flex-col gap-2 mt-10">
<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="w-full h-24 px-3 bg-transparent rounded-lg border-neutral-300 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<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>
</Container>
);
}

View File

@@ -1,406 +0,0 @@
import { Note } from "@/components/note";
import { User } from "@/components/user";
import {
HorizontalDotsIcon,
InfoIcon,
RepostIcon,
SearchIcon,
} from "@lume/icons";
import { type LumeEvent, LumeWindow, NostrQuery, useEvent } from "@lume/system";
import { Kind } from "@lume/types";
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 { createFileRoute } from "@tanstack/react-router";
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
import { getCurrent } from "@tauri-apps/api/window";
import { exit } from "@tauri-apps/plugin-process";
import { open } from "@tauri-apps/plugin-shell";
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Virtualizer } from "virtua";
interface EmitAccount {
account: string;
}
export const Route = createFileRoute("/panel")({
component: Screen,
});
function Screen() {
const [account, setAccount] = useState<string>(null);
const [events, setEvents] = useState<LumeEvent[]>([]);
const texts = useMemo(
() => events.filter((ev) => ev.kind === Kind.Text),
[events],
);
const zaps = useMemo(() => {
const groups = new Map<string, LumeEvent[]>();
const list = events.filter((ev) => ev.kind === Kind.ZapReceipt);
for (const event of list) {
const rootId = event.tags.filter((tag) => tag[0] === "e")[0]?.[1];
if (rootId) {
if (groups.has(rootId)) {
groups.get(rootId).push(event);
} else {
groups.set(rootId, [event]);
}
}
}
return groups;
}, [events]);
const reactions = useMemo(() => {
const groups = new Map<string, LumeEvent[]>();
const list = events.filter(
(ev) => ev.kind === Kind.Repost || ev.kind === Kind.Reaction,
);
for (const event of list) {
const rootId = event.tags.filter((tag) => tag[0] === "e")[0]?.[1];
if (rootId) {
if (groups.has(rootId)) {
groups.get(rootId).push(event);
} else {
groups.set(rootId, [event]);
}
}
}
return groups;
}, [events]);
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(),
}),
MenuItem.new({
text: "Search",
action: () => LumeWindow.openSearch(),
}),
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 exit(0),
}),
]);
const menu = await Menu.new({
items: menuItems,
});
await menu.popup().catch((e) => console.error(e));
}, []);
useEffect(() => {
if (account?.length && account?.startsWith("npub1")) {
NostrQuery.getNotifications()
.then((data) => {
const sorted = data.sort((a, b) => b.created_at - a.created_at);
setEvents(sorted);
})
.catch((e) => console.log(e));
}
}, [account]);
useEffect(() => {
const unlistenLoad = getCurrent().listen<EmitAccount>(
"load-notification",
(data) => {
setAccount(data.payload.account);
},
);
const unlistenNewEvent = getCurrent().listen("notification", (data) => {
const event: LumeEvent = JSON.parse(data.payload as string);
setEvents((prev) => [event, ...prev]);
});
return () => {
unlistenLoad.then((f) => f());
unlistenNewEvent.then((f) => f());
};
}, []);
if (!account) {
return (
<div className="flex items-center justify-center w-full h-full text-sm">
Please log in.
</div>
);
}
return (
<div className="flex flex-col w-full h-full">
<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={() => LumeWindow.openSearch()}
className="inline-flex items-center justify-center rounded-full size-7 bg-black/5 dark:bg-white/5"
>
<SearchIcon className="size-4" />
</button>
<button
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-1 overflow-x-hidden overflow-y-auto scrollbar-none"
>
<Tabs.List className="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>
<div className="h-full">
<Tab value="replies">
{texts.map((event, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
<TextNote key={event.id + index} event={event} />
))}
</Tab>
<Tab value="reactions">
{[...reactions.entries()].map(([root, events]) => (
<div
key={root}
className="flex flex-col gap-1 p-2 mb-2 rounded-lg shrink-0 backdrop-blur-md 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 backdrop-blur-md 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">
{[...zaps.entries()].map(([root, events]) => (
<div
key={root}
className="flex flex-col gap-1 p-2 mb-2 rounded-lg shrink-0 backdrop-blur-md 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 backdrop-blur-md p-[2px]">
<User.Avatar className="flex-1 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>
</div>
</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.Root
type={"scroll"}
scrollHideDelay={300}
className="overflow-hidden size-full"
>
<ScrollArea.Viewport ref={ref} className="h-full px-2 pt-2">
<Virtualizer scrollRef={ref}>{children}</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>
</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 shrink-0" />
</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 backdrop-blur-md 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 shrink-0" />
<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">
{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>
);
}

View File

@@ -1,148 +0,0 @@
import { Note } from "@/components/note";
import { User } from "@/components/user";
import { SearchIcon } from "@lume/icons";
import { LumeEvent, LumeWindow } from "@lume/system";
import { Kind, type NostrEvent } 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 { useDebounce } from "use-debounce";
export const Route = createFileRoute("/search")({
component: Screen,
});
function Screen() {
const [loading, setLoading] = useState(false);
const [events, setEvents] = useState<LumeEvent[]>([]);
const [search, setSearch] = useState("");
const [searchValue] = useDebounce(search, 500);
const searchEvents = async () => {
try {
setLoading(true);
const query = `https://api.nostr.wine/search?query=${searchValue}&kind=0,1`;
const res = await fetch(query);
const content = await res.json();
const events = content.data as NostrEvent[];
const lumeEvents = events.map((ev) => new LumeEvent(ev));
const sorted = lumeEvents.sort((a, b) => b.created_at - a.created_at);
setLoading(false);
setEvents(sorted);
} catch (e) {
setLoading(false);
await message(String(e), {
title: "Search",
kind: "error",
});
}
};
useEffect(() => {
if (searchValue.length >= 3 && searchValue.length < 500) {
searchEvents();
}
}, [searchValue]);
return (
<div data-tauri-drag-region className="flex flex-col w-full h-full">
<div className="relative flex flex-col h-24 border-b shrink-0 border-black/5 dark:border-white/5">
<div data-tauri-drag-region className="w-full h-4 shrink-0" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") searchEvents();
}}
placeholder="Search anything..."
className="w-full h-20 px-3 pt-10 text-lg bg-transparent border-none focus:outline-none focus:ring-0 placeholder:text-neutral-500 dark:placeholder:text-neutral-600"
/>
</div>
<div className="flex-1 p-3 overflow-y-auto scrollbar-none">
{loading ? (
<div className="flex items-center justify-center w-full h-full">
<Spinner />
</div>
) : events.length ? (
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-1.5">
<div className="text-sm font-medium text-neutral-700 dark:text-neutral-300 shrink-0">
Users
</div>
<div className="flex flex-col flex-1 gap-1">
{events
.filter((ev) => ev.kind === Kind.Metadata)
.map((event) => (
<SearchUser key={event.pubkey} event={event} />
))}
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="text-sm font-medium text-neutral-700 dark:text-neutral-300 shrink-0">
Notes
</div>
<div className="flex flex-col flex-1 gap-3">
{events
.filter((ev) => ev.kind === Kind.Text)
.map((event) => (
<SearchNote key={event.id} event={event} />
))}
</div>
</div>
</div>
) : null}
{!loading && !events.length ? (
<div className="flex flex-col items-center justify-center h-full gap-3">
<div className="inline-flex items-center justify-center rounded-full size-16 bg-black/10 dark:bg-white/10">
<SearchIcon className="size-6" />
</div>
Try searching for people, notes, or keywords
</div>
) : null}
</div>
</div>
);
}
function SearchUser({ event }: { event: LumeEvent }) {
return (
<button
key={event.id}
type="button"
onClick={() => LumeWindow.openProfile(event.pubkey)}
className="col-span-1 p-2 rounded-lg hover:bg-black/10 dark:hover:bg-white/10"
>
<User.Provider pubkey={event.pubkey} embedProfile={event.content}>
<User.Root className="flex items-center gap-2">
<User.Avatar className="rounded-full size-9 shrink-0" />
<div className="inline-flex items-center gap-1.5">
<User.Name className="font-semibold" />
<User.NIP05 />
</div>
</User.Root>
</User.Provider>
</button>
);
}
function SearchNote({ event }: { event: LumeEvent }) {
return (
<div className="bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50">
<Note.Provider event={event}>
<Note.Root>
<div className="flex items-center justify-between px-3 h-14">
<Note.User />
<Note.Menu />
</div>
<Note.Content className="px-3" quote={false} mention={false} />
<div className="flex items-center gap-4 px-3 mt-3 h-14">
<Note.Open />
</div>
</Note.Root>
</Note.Provider>
</div>
);
}

View File

@@ -1,125 +0,0 @@
import {
RelayIcon,
SecureIcon,
SettingsIcon,
UserIcon,
ZapIcon,
} from "@lume/icons";
import { cn } from "@lume/utils";
import { Link } from "@tanstack/react-router";
import { Outlet, createFileRoute } from "@tanstack/react-router";
import { useTranslation } from "react-i18next";
export const Route = createFileRoute("/settings")({
component: Screen,
});
function Screen() {
const { t } = useTranslation();
return (
<div className="flex flex-col w-full h-full">
<div
data-tauri-drag-region
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">
<Link to="/settings/general">
{({ isActive }) => {
return (
<div
className={cn(
"flex h-14 w-20 shrink-0 flex-col items-center justify-center rounded-lg p-2",
isActive
? "bg-black/10 hover:bg-black/20 dark:bg-white/10 text-neutral-900 dark:text-neutral-100 dark:hover:bg-bg-white/20"
: "text-neutral-700 hover:bg-black/10 dark:text-neutral-300 dark:hover:bg-white/10",
)}
>
<SettingsIcon className="size-5 shrink-0" />
<p className="text-sm font-medium">
{t("settings.general.title")}
</p>
</div>
);
}}
</Link>
<Link to="/settings/user">
{({ isActive }) => {
return (
<div
className={cn(
"flex h-14 w-20 shrink-0 flex-col items-center justify-center rounded-lg p-2",
isActive
? "bg-black/10 hover:bg-black/20 dark:bg-white/10 text-neutral-900 dark:text-neutral-100 dark:hover:bg-bg-white/20"
: "text-neutral-700 hover:bg-black/10 dark:text-neutral-300 dark:hover:bg-white/10",
)}
>
<UserIcon className="size-5 shrink-0" />
<p className="text-sm font-medium">
{t("settings.user.title")}
</p>
</div>
);
}}
</Link>
<Link to="/settings/relay">
{({ isActive }) => {
return (
<div
className={cn(
"flex h-14 w-20 shrink-0 flex-col items-center justify-center rounded-lg p-2",
isActive
? "bg-black/10 hover:bg-black/20 dark:bg-white/10 text-neutral-900 dark:text-neutral-100 dark:hover:bg-bg-white/20"
: "text-neutral-700 hover:bg-black/10 dark:text-neutral-300 dark:hover:bg-white/10",
)}
>
<RelayIcon className="size-5 shrink-0" />
<p className="text-sm font-medium">Relay</p>
</div>
);
}}
</Link>
<Link to="/settings/wallet">
{({ isActive }) => {
return (
<div
className={cn(
"flex h-14 w-20 shrink-0 flex-col items-center justify-center rounded-lg p-2",
isActive
? "bg-black/10 hover:bg-black/20 dark:bg-white/10 text-neutral-900 dark:text-neutral-100 dark:hover:bg-bg-white/20"
: "text-neutral-700 hover:bg-black/10 dark:text-neutral-300 dark:hover:bg-white/10",
)}
>
<ZapIcon className="size-5 shrink-0" />
<p className="text-sm font-medium">Wallet</p>
</div>
);
}}
</Link>
<Link to="/settings/backup">
{({ isActive }) => {
return (
<div
className={cn(
"flex h-14 w-20 shrink-0 flex-col items-center justify-center rounded-lg p-2",
isActive
? "bg-black/10 hover:bg-black/20 dark:bg-white/10 text-neutral-900 dark:text-neutral-100 dark:hover:bg-bg-white/20"
: "text-neutral-700 hover:bg-black/10 dark:text-neutral-300 dark:hover:bg-white/10",
)}
>
<SecureIcon className="size-5 shrink-0" />
<p className="text-sm font-medium">
{t("settings.backup.title")}
</p>
</div>
);
}}
</Link>
</div>
</div>
<div className="flex-1 w-full px-5 py-4 overflow-y-auto scrollbar-none">
<Outlet />
</div>
</div>
);
}

View File

@@ -1,74 +0,0 @@
import { User } from "@/components/user";
import { NostrAccount } from "@lume/system";
import { displayNpub } from "@lume/utils";
import { createFileRoute } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core";
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { message } from "@tauri-apps/plugin-dialog";
import { useState } from "react";
interface Account {
npub: string;
nsec: string;
}
export const Route = createFileRoute("/settings/backup")({
beforeLoad: async () => {
const accounts = await NostrAccount.getAccounts();
return { accounts };
},
component: Screen,
});
function Screen() {
const { accounts } = Route.useRouteContext();
return (
<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">
{accounts.map((account) => (
<Account key={account} account={account} />
))}
</div>
</div>
);
}
function Account({ account }: { account: string }) {
const [copied, setCopied] = useState(false);
const copyKey = async () => {
try {
const data: string = await invoke("get_private_key", { npub: account });
await writeText(data);
setCopied(true);
} catch (e) {
await message(String(e), { title: "Backup", kind: "error" });
}
};
return (
<div className="flex items-center justify-between gap-2 py-3">
<User.Provider pubkey={account}>
<User.Root className="flex items-center gap-2">
<User.Avatar className="object-cover rounded-full size-8" />
<div className="flex flex-col">
<User.Name className="text-sm leading-tight" />
<span className="text-sm leading-tight text-black/50 dark:text-white/50">
{displayNpub(account, 16)}
</span>
</div>
</User.Root>
</User.Provider>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => copyKey()}
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"
>
{copied ? "Copied" : "Copy Private Key"}
</button>
</div>
</div>
);
}

View File

@@ -1,39 +0,0 @@
import { Button, init } from "@getalby/bitcoin-connect-react";
import { NostrAccount } from "@lume/system";
import { createFileRoute } from "@tanstack/react-router";
import { getCurrent } from "@tauri-apps/api/webviewWindow";
export const Route = createFileRoute("/settings/bitcoin-connect")({
beforeLoad: () => {
init({
appName: "Lume",
filters: ["nwc"],
showBalance: true,
});
},
component: Screen,
});
function Screen() {
const setNwcUri = async (uri: string) => {
const cmd = await NostrAccount.setWallet(uri);
if (cmd) getCurrent().close();
};
return (
<div className="flex items-center justify-center size-full">
<div className="flex flex-col items-center justify-center gap-3 text-center">
<div>
<p className="text-sm text-black/70 dark:text-white/70">
Click to the button below to connect with your Bitcoin wallet.
</p>
</div>
<Button
onConnected={(provider) =>
setNwcUri(provider.client.nostrWalletConnectUrl)
}
/>
</div>
</div>
);
}

View File

@@ -1,253 +0,0 @@
import { NostrQuery, type Settings } from "@lume/system";
import * as Switch from "@radix-ui/react-switch";
import { createFileRoute } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core";
import { useEffect, useState } from "react";
import { useDebouncedCallback } from "use-debounce";
type Theme = "auto" | "light" | "dark";
export const Route = createFileRoute("/settings/general")({
beforeLoad: async () => {
const initialSettings = await NostrQuery.getUserSettings();
return { initialSettings };
},
component: Screen,
});
function Screen() {
const { initialSettings } = Route.useRouteContext();
const [theme, setTheme] = useState<Theme>(null);
const [settings, setSettings] = useState<Settings>(null);
const changeTheme = async (theme: string) => {
if (theme === "auto" || theme === "light" || theme === "dark") {
invoke("plugin:theme|set_theme", {
theme: theme,
}).then(() => setTheme(theme));
}
};
const updateSettings = useDebouncedCallback(async () => {
const newSettings = JSON.stringify(settings);
await NostrQuery.setUserSettings(newSettings);
}, 200);
useEffect(() => {
updateSettings();
}, [settings]);
useEffect(() => {
invoke("plugin:theme|get_theme").then((data: Theme) => setTheme(data));
}, []);
useEffect(() => {
setSettings(initialSettings);
}, [initialSettings]);
if (!settings) return null;
return (
<div className="w-full max-w-xl mx-auto">
<div className="flex flex-col gap-6">
<div className="flex items-center w-full px-3 text-sm rounded-lg h-11 bg-black/5 dark:bg-white/5">
* Setting changes require restarting the app to take effect.
</div>
<div className="flex flex-col gap-2">
<h2 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
General
</h2>
<div className="flex flex-col px-3 divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl">
<div className="flex items-start justify-between w-full gap-4 py-3">
<div className="flex-1">
<h3 className="font-medium">Relay Hint</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Use the relay hint if necessary.
</p>
</div>
<div className="flex justify-end w-36 shrink-0">
<Switch.Root
checked={settings.use_relay_hint}
onClick={() =>
setSettings((prev) => ({
...prev,
use_relay_hint: !prev.use_relay_hint,
}))
}
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
>
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
</div>
<div className="flex items-start justify-between w-full gap-4 py-3">
<div className="flex-1">
<h3 className="font-medium">Content Warning</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Shows a warning for notes that have a content warning.
</p>
</div>
<div className="flex justify-end w-36 shrink-0">
<Switch.Root
checked={settings.content_warning}
onClick={() =>
setSettings((prev) => ({
...prev,
content_warning: !prev.content_warning,
}))
}
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
>
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
</div>
</div>
</div>
<div className="flex flex-col gap-2">
<h2 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
Appearance
</h2>
<div className="flex flex-col px-3 divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl">
<div className="flex items-start justify-between w-full gap-4 py-3">
<div className="flex-1">
<h3 className="font-medium">Appearance</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Require restarting the app to take effect.
</p>
</div>
<div className="flex justify-end w-36 shrink-0">
<select
name="theme"
className="w-24 py-1 bg-transparent rounded-lg shadow-none outline-none border-1 border-black/10 dark:border-white/10"
defaultValue={theme}
onChange={(e) => changeTheme(e.target.value)}
>
<option value="auto">Auto</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</div>
</div>
<div className="flex items-start justify-between w-full gap-4 py-3">
<div className="flex-1">
<h3 className="font-medium">Zap Button</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Shows the Zap button when viewing a note.
</p>
</div>
<div className="flex justify-end w-36 shrink-0">
<Switch.Root
checked={settings.display_zap_button}
onClick={() =>
setSettings((prev) => ({
...prev,
display_zap_button: !prev.display_zap_button,
}))
}
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
>
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
</div>
<div className="flex items-start justify-between w-full gap-4 py-3">
<div className="flex-1">
<h3 className="font-medium">Repost Button</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Shows the Repost button when viewing a note.
</p>
</div>
<div className="flex justify-end w-36 shrink-0">
<Switch.Root
checked={settings.display_zap_button}
onClick={() =>
setSettings((prev) => ({
...prev,
display_zap_button: !prev.display_zap_button,
}))
}
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
>
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
</div>
</div>
</div>
<div className="flex flex-col gap-2">
<h2 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
Privacy & Performance
</h2>
<div className="flex flex-col px-3 divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl">
<div className="flex items-start justify-between w-full gap-4 py-3">
<div className="flex-1">
<h3 className="font-medium">Proxy</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Set proxy address.
</p>
</div>
<div className="flex justify-end w-36 shrink-0">
<input
type="url"
defaultValue={settings.proxy}
onChange={(e) =>
setSettings((prev) => ({
...prev,
proxy: e.target.value,
}))
}
className="py-1 bg-transparent rounded-lg shadow-none outline-none w-44 border-1 border-black/10 dark:border-white/10"
/>
</div>
</div>
<div className="flex items-start justify-between w-full gap-4 py-3">
<div className="flex-1">
<h3 className="font-medium">Image Resize Service</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Use weserv/images for resize image on-the-fly.
</p>
</div>
<div className="flex justify-end w-36 shrink-0">
<input
type="url"
defaultValue={settings.image_resize_service}
onChange={(e) =>
setSettings((prev) => ({
...prev,
image_resize_service: e.target.value,
}))
}
className="py-1 bg-transparent rounded-lg shadow-none outline-none w-44 border-1 border-black/10 dark:border-white/10"
/>
</div>
</div>
<div className="flex items-start justify-between w-full gap-4 py-3">
<div className="flex-1">
<h3 className="font-medium">Load Remote Media</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
View the remote media directly.
</p>
</div>
<div className="flex justify-end w-36 shrink-0">
<Switch.Root
checked={settings.display_media}
onClick={() =>
setSettings((prev) => ({
...prev,
display_image_link: !prev.display_media,
}))
}
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
>
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,180 +0,0 @@
import { AvatarUploader } from "@/components/avatarUploader";
import { PlusIcon } from "@lume/icons";
import { NostrAccount } from "@lume/system";
import type { Metadata } from "@lume/types";
import { Spinner } from "@lume/ui";
import { Link } from "@tanstack/react-router";
import { createFileRoute } from "@tanstack/react-router";
import { message } from "@tauri-apps/plugin-dialog";
import { useState } from "react";
import { useForm } from "react-hook-form";
export const Route = createFileRoute("/settings/user")({
beforeLoad: async () => {
const profile = await NostrAccount.getProfile();
return { profile };
},
component: Screen,
});
function Screen() {
const { profile } = Route.useRouteContext();
const { register, handleSubmit } = useForm({ defaultValues: profile });
const [loading, setLoading] = useState(false);
const [picture, setPicture] = useState<string>("");
const onSubmit = async (data: Metadata) => {
try {
setLoading(true);
const newProfile: Metadata = { ...profile, ...data, picture };
await NostrAccount.createProfile(newProfile);
setLoading(false);
} catch (e) {
setLoading(false);
await message(String(e), { title: "Profile", kind: "error" });
}
};
return (
<div className="flex w-full h-full">
<div className="flex flex-col items-center justify-center flex-1 h-full gap-3">
<div className="relative rounded-full size-24 bg-gradient-to-tr from-orange-100 via-red-50 to-blue-200">
{profile.picture ? (
<img
src={picture || profile.picture}
alt="avatar"
loading="lazy"
decoding="async"
className="absolute inset-0 z-10 object-cover w-full h-full rounded-full"
/>
) : null}
<AvatarUploader
setPicture={setPicture}
className="absolute inset-0 z-20 flex items-center justify-center w-full h-full text-white rounded-full bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
>
<PlusIcon className="size-8" />
</AvatarUploader>
</div>
<div className="flex flex-col items-center text-center">
<div className="text-lg font-semibold">{profile.display_name}</div>
<div className="text-neutral-800 dark:text-neutral-200">
{profile.nip05}
</div>
<div className="mt-4">
<Link
to="/settings/backup"
className="inline-flex items-center justify-center px-5 text-sm font-medium text-blue-500 bg-blue-100 border border-blue-300 rounded-full h-9 hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800"
>
Backup Account
</Link>
</div>
</div>
</div>
<div className="flex-1 h-full">
<form
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-3 mb-0"
>
<div className="flex flex-col w-full gap-1">
<label
htmlFor="display_name"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Display Name
</label>
<input
name="display_name"
{...register("display_name")}
spellCheck={false}
className="w-full px-3 bg-transparent rounded-lg h-9 border-neutral-300 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div className="flex flex-col w-full gap-1">
<label
htmlFor="name"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Name
</label>
<input
name="name"
{...register("name")}
spellCheck={false}
className="w-full px-3 bg-transparent rounded-lg h-9 border-neutral-300 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div className="flex flex-col w-full gap-1">
<label
htmlFor="website"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Website
</label>
<input
name="website"
type="url"
{...register("website")}
spellCheck={false}
className="w-full px-3 bg-transparent rounded-lg h-9 border-neutral-300 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div className="flex flex-col w-full gap-1">
<label
htmlFor="banner"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Cover
</label>
<input
name="banner"
type="url"
{...register("banner")}
spellCheck={false}
className="w-full px-3 bg-transparent rounded-lg h-9 border-neutral-300 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div className="flex flex-col w-full gap-1">
<label
htmlFor="nip05"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
NIP-05
</label>
<input
name="nip05"
type="email"
{...register("nip05")}
spellCheck={false}
className="w-full px-3 bg-transparent rounded-lg h-9 border-neutral-300 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div className="flex flex-col w-full gap-1">
<label
htmlFor="lnaddress"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Lightning Address
</label>
<input
name="lnaddress"
type="email"
{...register("lud16")}
className="w-full px-3 bg-transparent rounded-lg h-9 border-neutral-300 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div className="flex items-center justify-end">
<button
type="submit"
className="inline-flex items-center justify-center w-32 px-2 text-sm font-medium text-white bg-blue-500 rounded-lg h-9 hover:bg-blue-600 disabled:opacity-50"
>
{loading ? <Spinner className="size-4" /> : "Update Profile"}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -1,21 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/store/community")({
component: Screen,
});
function Screen() {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 p-3">
<div className="size-24 bg-blue-100 flex flex-col items-center justify-end overflow-hidden dark:bg-blue-900 rounded-full">
<div className="w-12 h-16 bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900 rounded-t-lg" />
</div>
<div className="text-center">
<h1 className="font-semibold text-lg">Coming Soon</h1>
<p className="text-sm text-neutral-700 dark:text-neutral-300 leading-tight">
Enhance your experience <br /> by adding column shared by community.
</p>
</div>
</div>
);
}

View File

@@ -1,69 +0,0 @@
import type { LumeColumn } from "@lume/types";
import { createFileRoute } from "@tanstack/react-router";
import { resolveResource } from "@tauri-apps/api/path";
import { getCurrent } from "@tauri-apps/api/window";
import { readTextFile } from "@tauri-apps/plugin-fs";
export const Route = createFileRoute("/store/official")({
beforeLoad: async () => {
const resourcePath = await resolveResource(
"resources/official_columns.json",
);
const officialColumns: LumeColumn[] = JSON.parse(
await readTextFile(resourcePath),
);
return {
officialColumns,
};
},
component: Screen,
});
function Screen() {
const { officialColumns } = Route.useRouteContext();
const install = async (column: LumeColumn) => {
const mainWindow = getCurrent();
await mainWindow.emit("columns", { type: "add", column });
};
return (
<div className="flex flex-col gap-3 p-3">
{officialColumns.map((column) => (
<div
key={column.label}
className="relative h-[200px] w-full overflow-hidden rounded-xl bg-gradient-to-tr from-orange-100 to-blue-200 px-3 pt-3"
>
{column.cover ? (
<img
src={column.cover}
srcSet={column.coverRetina}
alt={column.name}
loading="lazy"
decoding="async"
className="absolute left-0 top-0 z-10 h-full w-full object-cover"
/>
) : null}
<div className="absolute bottom-0 left-0 z-20 h-16 w-full bg-black/40 px-3 backdrop-blur-xl">
<div className="flex h-full items-center justify-between">
<div>
<h1 className="font-semibold text-white">{column.name}</h1>
<p className="max-w-[24rem] truncate text-sm text-white/80">
{column.description}
</p>
</div>
<button
type="button"
onClick={() => install(column)}
className="inline-flex h-8 w-16 shrink-0 items-center justify-center rounded-full bg-white/20 text-sm font-medium text-white hover:bg-white hover:text-blue-500"
>
Add
</button>
</div>
</div>
</div>
))}
</div>
);
}

View File

@@ -1,48 +0,0 @@
import { GlobalIcon, LaurelIcon } from "@lume/icons";
import { cn } from "@lume/utils";
import { Link } from "@tanstack/react-router";
import { Outlet, createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/store")({
component: Screen,
});
function Screen() {
return (
<div className="flex flex-col h-full">
<div className="px-3 mt-2 mb-1">
<div className="inline-flex items-center w-full gap-1 p-1 rounded-lg shrink-0 bg-black/5 dark:bg-white/5">
<Link to="/store/official" className="flex-1">
{({ isActive }) => (
<div
className={cn(
"inline-flex h-8 w-full items-center justify-center gap-1.5 rounded-md text-sm font-medium leading-tight",
isActive ? "bg-neutral-50 dark:bg-white/10" : "opacity-50",
)}
>
<LaurelIcon className="size-4" />
Official
</div>
)}
</Link>
<Link to="/store/community" className="flex-1">
{({ isActive }) => (
<div
className={cn(
"inline-flex h-8 w-full items-center justify-center gap-1.5 rounded-md text-sm font-medium leading-tight",
isActive ? "bg-neutral-50 dark:bg-white/10" : "opacity-50",
)}
>
<GlobalIcon className="size-4" />
Community
</div>
)}
</Link>
</div>
</div>
<div className="flex-1 overflow-y-auto scrollbar-none">
<Outlet />
</div>
</div>
);
}

View File

@@ -1,159 +0,0 @@
import { Conversation } from "@/components/conversation";
import { Quote } from "@/components/quote";
import { RepostNote } from "@/components/repost";
import { TextNote } from "@/components/text";
import { ArrowRightCircleIcon } from "@lume/icons";
import { type LumeEvent, NostrQuery } from "@lume/system";
import { type ColumnRouteSearch, Kind } from "@lume/types";
import { Spinner } from "@lume/ui";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { useInfiniteQuery } from "@tanstack/react-query";
import { createFileRoute, redirect } from "@tanstack/react-router";
import { useCallback, useRef } from "react";
import { Virtualizer } from "virtua";
type Topic = {
content: string[];
};
export const Route = createFileRoute("/topic")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
return {
account: search.account,
label: search.label,
name: search.name,
};
},
beforeLoad: async ({ search }) => {
const key = `lume:topic:${search.label}`;
const topics: Topic[] = await NostrQuery.getNstore(key);
const settings = await NostrQuery.getUserSettings();
if (!topics?.length) {
throw redirect({
to: "/create-topic",
search: {
...search,
redirect: "/topic",
},
});
}
const hashtags: string[] = [];
for (const topic of topics) {
hashtags.push(...topic.content);
}
return { settings, hashtags };
},
component: Screen,
});
export function Screen() {
const { label, account } = Route.useSearch();
const { hashtags } = Route.useRouteContext();
const {
data,
isLoading,
isFetching,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
} = useInfiniteQuery({
queryKey: [label, account],
initialPageParam: 0,
queryFn: async ({ pageParam }: { pageParam: number }) => {
const events = NostrQuery.getHashtagEvents(hashtags, pageParam);
return events;
},
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
select: (data) => data?.pages.flat(),
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.Root
type={"scroll"}
scrollHideDelay={300}
className="overflow-hidden size-full"
>
<ScrollArea.Viewport ref={ref} className="h-full px-3 pb-3">
<Virtualizer scrollRef={ref}>
{isFetching && !isLoading && !isFetchingNextPage ? (
<div className="flex items-center justify-center w-full mb-3 h-11 bg-black/10 dark:bg-white/10 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50">
<div className="flex items-center justify-center gap-2">
<Spinner className="size-5" />
<span className="text-sm font-medium">
Fetching new notes...
</span>
</div>
</div>
) : null}
{isLoading ? (
<div className="flex items-center justify-center w-full h-16 gap-2">
<Spinner className="size-5" />
<span className="text-sm font-medium">Loading...</span>
</div>
) : !data.length ? (
<div className="flex items-center justify-center">
Yo. You're catching up on all the things happening around you.
</div>
) : (
data.map((item) => renderItem(item))
)}
{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>
);
}

View File

@@ -1,81 +0,0 @@
import { TextNote } from "@/components/text";
import { LumeEvent } from "@lume/system";
import type { NostrEvent } from "@lume/types";
import { Spinner } from "@lume/ui";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { Await, createFileRoute } from "@tanstack/react-router";
import { defer } from "@tanstack/react-router";
import { Suspense, useRef } from "react";
import { Virtualizer } from "virtua";
export const Route = createFileRoute("/trending/notes")({
loader: async ({ abortController }) => {
try {
return {
data: defer(
fetch("https://api.nostr.band/v0/trending/notes", {
signal: abortController.signal,
})
.then((res) => res.json())
.then((res) => {
const events: NostrEvent[] = res.notes.map(
(item: { event: NostrEvent }) => item.event,
);
const lumeEvents = events.map((ev) => new LumeEvent(ev));
return lumeEvents;
}),
),
};
} catch (e) {
throw new Error(String(e));
}
},
component: Screen,
});
export function Screen() {
const { data } = Route.useLoaderData();
const ref = useRef<HTMLDivElement>(null);
return (
<ScrollArea.Root
type={"scroll"}
scrollHideDelay={300}
className="overflow-hidden size-full"
>
<ScrollArea.Viewport ref={ref} className="h-full px-3 pb-3">
<Virtualizer scrollRef={ref}>
<Suspense
fallback={
<div className="flex flex-col items-center justify-center w-full h-20 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}>
{(notes) =>
notes.map((event) => (
<TextNote key={event.id} event={event} className="mb-3" />
))
}
</Await>
</Suspense>
</Virtualizer>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
orientation="vertical"
>
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
</ScrollArea.Scrollbar>
<ScrollArea.Corner className="bg-transparent" />
</ScrollArea.Root>
);
}

View File

@@ -1,63 +0,0 @@
import { ArticleIcon, GroupFeedsIcon } from "@lume/icons";
import { NostrQuery } from "@lume/system";
import type { ColumnRouteSearch } from "@lume/types";
import { cn } from "@lume/utils";
import { Link, Outlet } from "@tanstack/react-router";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/trending")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
return {
account: search.account,
label: search.label,
name: search.name,
};
},
beforeLoad: async () => {
const settings = await NostrQuery.getUserSettings();
return { settings };
},
component: Screen,
});
function Screen() {
const search = Route.useSearch();
return (
<div className="flex flex-col h-full">
<div className="inline-flex items-center w-full gap-1 px-3 h-11 shrink-0">
<div className="inline-flex items-center w-full h-full gap-1">
<Link to="/trending/notes" search={search}>
{({ 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",
)}
>
<ArticleIcon className="size-4" />
Notes
</div>
)}
</Link>
<Link to="/trending/users" search={search}>
{({ 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",
)}
>
<GroupFeedsIcon className="size-4" />
Users
</div>
)}
</Link>
</div>
</div>
<div className="flex-1 w-full h-full p-2 overflow-y-auto scrollbar-none">
<Outlet />
</div>
</div>
);
}

View File

@@ -1,71 +0,0 @@
import { Spinner } from "@lume/ui";
import { User } from "@/components/user";
import { Await, defer } from "@tanstack/react-router";
import { createFileRoute } from "@tanstack/react-router";
import { Suspense } from "react";
export const Route = createFileRoute("/trending/users")({
loader: async ({ abortController }) => {
try {
return {
data: defer(
fetch("https://api.nostr.band/v0/trending/profiles", {
signal: abortController.signal,
}).then((res) => res.json()),
),
};
} catch (e) {
throw new Error(String(e));
}
},
component: Screen,
});
export function Screen() {
const { data } = Route.useLoaderData();
return (
<div className="w-full h-full">
<Suspense
fallback={
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
<button
type="button"
className="inline-flex items-center gap-2 text-sm font-medium"
disabled
>
<Spinner className="size-5" />
Loading...
</button>
</div>
}
>
<Await promise={data}>
{(users) =>
users.profiles.map((item: { pubkey: string }) => (
<div
key={item.pubkey}
className="h-max w-full overflow-hidden mb-3 p-2 bg-black/5 dark:bg-white/5 backdrop-blur-lg rounded-xl"
>
<User.Provider pubkey={item.pubkey}>
<User.Root>
<div className="flex h-full w-full flex-col gap-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<User.Avatar className="size-10 shrink-0 rounded-full object-cover" />
<User.Name className="leadning-tight max-w-[15rem] truncate font-semibold" />
</div>
<User.Button className="inline-flex h-8 w-20 items-center justify-center rounded-lg bg-black/10 text-sm font-medium hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20" />
</div>
<User.About className="mt-1 line-clamp-3 max-w-none select-text text-neutral-800 dark:text-neutral-400" />
</div>
</User.Root>
</User.Provider>
</div>
))
}
</Await>
</Suspense>
</div>
);
}

View File

@@ -1,92 +0,0 @@
import { Conversation } from "@/components/conversation";
import { Quote } from "@/components/quote";
import { RepostNote } from "@/components/repost";
import { TextNote } from "@/components/text";
import { User } from "@/components/user";
import { type LumeEvent, NostrQuery } from "@lume/system";
import { Kind } from "@lume/types";
import { Box, Container, Spinner } from "@lume/ui";
import { createFileRoute, defer } from "@tanstack/react-router";
import { Await } from "@tanstack/react-router";
import { Suspense, useCallback } from "react";
import { WindowVirtualizer } from "virtua";
export const Route = createFileRoute("/users/$pubkey")({
beforeLoad: async () => {
const settings = await NostrQuery.getUserSettings();
return { settings };
},
loader: async ({ params }) => {
return { data: defer(NostrQuery.getUserEvents(params.pubkey)) };
},
component: Screen,
});
function Screen() {
const { pubkey } = Route.useParams();
const { data } = Route.useLoaderData();
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 (
<Container withDrag>
<Box className="px-0 scrollbar-none bg-black/5 dark:bg-white/5 backdrop-blur-sm">
<WindowVirtualizer>
<User.Provider pubkey={pubkey}>
<User.Root>
<User.Cover className="object-cover w-full h-44" />
<div className="relative flex flex-col px-3 -mt-8">
<User.Avatar className="rounded-full size-14" />
<div className="inline-flex items-center justify-between mb-4">
<div className="flex items-center gap-1">
<User.Name className="text-lg font-semibold leading-tight" />
<User.NIP05 />
</div>
<User.Button className="inline-flex items-center justify-center w-24 text-sm font-medium text-white bg-black rounded-full h-9 hover:bg-neutral-900 dark:bg-neutral-900" />
</div>
<User.About />
</div>
</User.Root>
</User.Provider>
<div className="px-3 mt-5">
<div className="mb-3">
<h3 className="text-lg font-semibold">Latest notes</h3>
</div>
<Suspense
fallback={
<div className="flex h-20 w-full items-center justify-center gap-1.5 text-sm font-medium">
<Spinner className="size-5" />
Loading...
</div>
}
>
<Await promise={data}>
{(events) => events.map((event) => renderItem(event))}
</Await>
</Suspense>
</div>
</WindowVirtualizer>
</Box>
</Container>
);
}

View File

@@ -1,16 +0,0 @@
/** @type {import('tailwindcss').Config} */
import preset from "@lume/tailwindcss";
const config = {
content: [
"./src/**/*.{js,ts,jsx,tsx}",
"../../packages/@columns/**/*{.js,.ts,.jsx,.tsx}",
"../../packages/ark/**/*{.js,.ts,.jsx,.tsx}",
"../../packages/ui/**/*{.js,.ts,.jsx,.tsx}",
"index.html",
],
presets: [preset],
};
export default config;

View File

@@ -1,12 +0,0 @@
{
"extends": "@lume/tsconfig/base.json",
"compilerOptions": {
"outDir": "dist",
"baseUrl": "./",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -1,25 +0,0 @@
import { TanStackRouterVite } from "@tanstack/router-vite-plugin";
import react from "@vitejs/plugin-react-swc";
import { defineConfig } from "vite";
import topLevelAwait from "vite-plugin-top-level-await";
import viteTsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [
react(),
viteTsconfigPaths(),
topLevelAwait({
promiseExportName: "__tla",
promiseImportName: (i) => `__tla_${i}`,
}),
TanStackRouterVite(),
],
build: {
outDir: "../../dist",
},
server: {
strictPort: true,
port: 3000,
},
clearScreen: false,
});

21
apps/web/.gitignore vendored
View File

@@ -1,21 +0,0 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store

View File

@@ -1,47 +0,0 @@
# Astro Starter Kit: Minimal
```sh
npm create astro@latest -- --template minimal
```
[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/astro/tree/latest/examples/minimal)
[![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/minimal)
[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/minimal/devcontainer.json)
> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun!
## 🚀 Project Structure
Inside of your Astro project, you'll see the following folders and files:
```text
/
├── public/
├── src/
│ └── pages/
│ └── index.astro
└── package.json
```
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
Any static assets, like images, can be placed in the `public/` directory.
## 🧞 Commands
All commands are run from the root of the project, from a terminal:
| Command | Action |
| :------------------------ | :----------------------------------------------- |
| `npm install` | Installs dependencies |
| `npm run dev` | Starts local dev server at `localhost:4321` |
| `npm run build` | Build your production site to `./dist/` |
| `npm run preview` | Preview your build locally, before deploying |
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
| `npm run astro -- --help` | Get help using the Astro CLI |
## 👀 Want to learn more?
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).

View File

@@ -1,8 +0,0 @@
import { defineConfig } from "astro/config";
import tailwind from "@astrojs/tailwind";
// https://astro.build/config
export default defineConfig({
integrations: [tailwind()],
});

View File

@@ -1,26 +0,0 @@
{
"name": "@lume/web",
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro check && astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"@astrojs/check": "^0.5.10",
"@astrojs/tailwind": "^5.1.0",
"@fontsource/alice": "^5.0.13",
"astro": "^4.10.2",
"astro-seo-meta": "^4.1.1",
"astro-seo-schema": "^4.0.2",
"schema-dts": "^1.1.2",
"tailwindcss": "^3.4.4",
"typescript": "^5.4.5"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.13"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 889 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 714 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 318 KiB

View File

@@ -1 +0,0 @@
/// <reference types="astro/client" />

View File

@@ -1,98 +0,0 @@
---
import { Seo } from "astro-seo-meta";
---
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width" />
<meta name="generator" content={Astro.generator} />
<title>Lume: The nostr client for desktop</title>
<Seo
title="Lume"
description="A friendly and scalable Nostr desktop client."
keywords={[
"nostr",
"nostr client",
"social network",
"desktop app",
"timeline",
"application",
"columns",
"tweetdeck",
]}
themeColor="#fafafa"
colorScheme="light"
facebook={{
image: "/og-image.jpg",
url: "https://lume.nu",
type: "website",
}}
twitter={{
image: "/og-image.jpg",
card: "summary",
}}
/>
</head>
<body
class="w-full h-full antialiased bg-neutral-50 dark:bg-neutral-950 text-neutral-950 dark:text-neutral-50"
>
<div class="py-10 flex flex-col gap-10">
<div class="mx-auto max-w-xl w-full flex flex-col gap-2">
<div class="mb-5">
<img
src="/icon.png"
alt="App Icon"
class="size-14 shadow-md shadow-neutral-500/50 rounded-xl object-cover transform-gpu -rotate-6 hover:animate-spin"
/>
</div>
<h1 class="text-xl font-serif font-semibold">
A friendly and scalable Nostr desktop client.
</h1>
<p class="text-sm font-medium text-neutral-700">
Lume is a <b>Nostr client</b> for desktop, including Linux, Windows, and
macOS. It is free and open-source; you can look at the source code on <a
href="https://github.com/lumehq/lume">GitHub</a
>. Lume is actively improving the app and adding new features; you can
expect a new update every month.
</p>
<p class="text-sm font-medium text-neutral-700">
<b>Latest version</b>: 4.0.4
</p>
<div
class="w-full h-[120px] sm:h-[80px] flex flex-col sm:flex-row sm:items-center sm:justify-start justify-center gap-2"
>
<a
href="https://github.com/lumehq/lume/releases/latest"
class="inline-flex items-center justify-center w-44 h-11 rounded-full bg-black hover:ring-2 ring-blue-500 ring-offset-2 text-white font-medium text-sm"
>Download for macOS</a
>
<span class="italic text-xs text-neutral-700"
>(Windows & Linux are coming later)</span
>
</div>
<div class="text-sm italic text-neutral-600">
* If you still need to use Lume on Windows and Linux, you can try v3 <a
href="https://github.com/lumehq/lume/releases/tag/v3.0.2"
class="text-blue-500">here</a
>
</div>
</div>
<div class="sm:max-w-3xl w-full mx-auto px-3 sm:px-0">
<video
class="aspect-video w-full h-auto rounded-xl"
autoplay
muted
controls
>
<source
src="https://video.nostr.build/4cc4df88caeb861b62e3f73bddbb5e0b5cf63617472a97d22f427e273ee0e127.mp4"
type="video/mp4"
/>
Your browser does not support the video tag.
</video>
</div>
</div>
</body>
</html>

View File

@@ -1,15 +0,0 @@
/** @type {import('tailwindcss').Config} */
const defaultTheme = require("tailwindcss/defaultTheme");
export default {
content: ["./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}"],
theme: {
extend: {
fontFamily: {
serif: ["Alice", ...defaultTheme.fontFamily.serif],
},
},
},
plugins: [require("@tailwindcss/typography")],
};

View File

@@ -1,3 +0,0 @@
{
"extends": "astro/tsconfigs/strict"
}

View File

@@ -4,8 +4,11 @@
"enabled": true
},
"files": {
"ignore": ["apps/desktop2/src/router.gen.ts"]
},
"ignore": [
"./src/routes.gen.ts",
"./src/commands.gen.ts"
]
},
"linter": {
"enabled": true,
"rules": {

130
flake.lock generated
View File

@@ -1,130 +0,0 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1694529238,
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"flake-utils_2": {
"inputs": {
"systems": "systems_2"
},
"locked": {
"lastModified": 1681202837,
"narHash": "sha256-H+Rh19JDwRtpVPAWp64F+rlEtxUWBAQW28eAi3SRSzg=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "cfacdce06f30d2b68473a46042957675eebb3401",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1697723726,
"narHash": "sha256-SaTWPkI8a5xSHX/rrKzUe+/uVNy6zCGMXgoeMb7T9rg=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "7c9cc5a6e5d38010801741ac830a3f8fd667a7a0",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1681358109,
"narHash": "sha256-eKyxW4OohHQx9Urxi7TQlFBTDWII+F+x2hklDOQPB50=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "96ba1c52e54e74c3197f4d43026b3f3d92e83ff9",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"flake-utils": "flake-utils_2",
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1697940838,
"narHash": "sha256-eyk92QqAoRNC0V99KOcKcBZjLPixxNBS0PRc4KlSQVs=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "a3e829c06eadf848f13d109c7648570ce37ebccd",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_2": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

View File

@@ -1,72 +0,0 @@
# Nix.flake to build Lume based on Tauri's Guides:
# Prerequisites -> Installing -> Setting Up Linux -> NixOS
# https://tauri.app/v1/guides/getting-started/prerequisites/#1-system-dependencies
#
# To build Rust backend of Tauri `rust-overlay` is used
# https://github.com/oxalica/rust-overlay
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
rust-overlay.url = "github:oxalica/rust-overlay";
};
outputs = { self, nixpkgs, flake-utils, rust-overlay }:
flake-utils.lib.eachDefaultSystem (system:
let
overlays = [ (import rust-overlay) ];
pkgs = import nixpkgs {
inherit system overlays;
};
libraries = with pkgs;[
webkitgtk
gtk3
cairo
gdk-pixbuf
glib
dbus
openssl_3
librsvg
libappindicator-gtk3
];
packages = with pkgs; [
curl
wget
pkg-config
dbus
openssl_3
glib
gtk3
libsoup
webkitgtk
librsvg
];
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
extensions = [ "rust-src" ]; # needed by rust-analyzer
};
in
{
devShells.default = pkgs.mkShell {
buildInputs = [
rustToolchain
pkgs.nodejs
pkgs.nodePackages.pnpm
pkgs.bun # experimental in Lume
] ++ packages;
shellHook =
''
export LD_LIBRARY_PATH=${pkgs.lib.makeLibraryPath libraries}:$LD_LIBRARY_PATH
export XDG_DATA_DIRS=${pkgs.gsettings-desktop-schemas}/share/gsettings-schemas/${pkgs.gsettings-desktop-schemas.name}:${pkgs.gtk3}/share/gsettings-schemas/${pkgs.gtk3.name}:$XDG_DATA_DIRS
'';
# Avoid white screen running with Nix
# https://github.com/tauri-apps/tauri/issues/4315#issuecomment-1207755694
WEBKIT_DISABLE_COMPOSITING_MODE = 1;
};
});
}

View File

@@ -1,13 +0,0 @@
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index 21f5d9a5..9a46f36d 100644
--- a/src-tauri/tauri.conf.json
+++ b/src-tauri/tauri.conf.json
@@ -64,7 +64,7 @@
"shortDescription": "",
"targets": "all",
"updater": {
- "active": true,
+ "active": false,
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEU3OTdCMkM3RjU5QzE2NzkKUldSNUZwejF4N0tYNTVHYjMrU0JkL090SlEyNUVLYU5TM2hTU3RXSWtEWngrZWJ4a0pydUhXZHEK",
"windows": {
"installMode": "quiet"

View File

@@ -1,52 +0,0 @@
FROM node:20-slim as prepare
RUN apt update && apt install -y git
# Taken from tauri docs https://beta.tauri.app/guides/prerequisites/#rust
RUN apt install libwebkit2gtk-4.1-dev -y \
build-essential \
curl \
wget \
file \
libssl-dev \
libayatana-appindicator3-dev \
protobuf-compiler \
librsvg2-dev
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
FROM prepare as build
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
ENV PATH="/root/.cargo/bin:${PATH}"
#RUN corepack prepare pnpm@latest --activate
RUN corepack enable
ADD . /lume/.
WORKDIR /lume
RUN pnpm install --frozen-lockfile
# Path for disable updater
#ADD flatpak/0001-disable-tauri-updater.patch .
#RUN patch -p1 -t -i flatpak/0001-disable-tauri-updater.patch
#ENV VITE_FLATPAK_RESOURCE="/app/lib/lume/resources/config.toml"
# debian build
RUN pnpm tauri build -b deb
ARG VERSION=3.0.1
ARG ARCH=amd64
RUN cp -r ./src-tauri/target/release/bundle/deb/lume_${VERSION}_${ARCH}/data lume-package
FROM scratch as final
COPY --from=build lume/lume-package prepare-dist
#ADD flatpak/*.xml flatpak/*.desktop flatpak/*.yml prepare-dist

View File

@@ -1,78 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>
nu.lume.Lume
</id>
<launchable type="desktop-id">
nu.lume.Lume.desktop
</launchable>
<name>
Lume
</name>
<summary>
A cross-platform desktop nostr client
</summary>
<developer_name>
Ren Amamiya
</developer_name>
<metadata_license>
CC0-1.0
</metadata_license>
<project_license>
GPL-3.0-only
</project_license>
<url type="homepage">
https://lume.nu
</url>
<url type="bugtracker">
https://github.com/lumehq/lume/issues
</url>
<url type="donation">
https://nostree.me/npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445
</url>
<supports>
<control>
pointing
</control>
<control>
keyboard
</control>
<control>
touch
</control>
</supports>
<description>
<p>
Lume a cross-platform nostr client, supported nsecbunker, chats and notifications
</p>
</description>
<custom>
<value key="Purism::form_factor">
workstation
</value>
<value key="Purism::form_factor">
mobile
</value>
</custom>
<screenshots>
<screenshot type="default">
<image>
https://raw.githubusercontent.com/lumehq/lume/flatpak/screenshots/login-screen.png
</image>
</screenshot>
<screenshot>
<image>
https://raw.githubusercontent.com/lumehq/lume/flatpak/screenshots/collumns.png
</image>
</screenshot>
<screenshot>
<image>
https://raw.githubusercontent.com/lumehq/lume/flatpak/screenshots/home-screen.png
</image>
</screenshot>
</screenshots>
<releases>
<release version="3.0.1" date="2024-02-02" />
</releases>
<content_rating type="oars-1.1" />
</component>

View File

@@ -1,12 +0,0 @@
[Desktop Entry]
Version=1.0
Type=Application
Name=Lume
Comment=A cross-platform desktop nostr client
Icon=lume
Exec=lume
Terminal=false
Categories=Network;InstantMessaging;
Keywords=nostr;client;chat;
X-Purism-FormFactor=Workstation;

View File

@@ -1,40 +0,0 @@
id: nu.lume.Lume
runtime: org.gnome.Platform
runtime-version: '45'
sdk: org.gnome.Sdk
command: lume
rename-icon: lume
finish-args:
- --socket=wayland
- --socket=fallback-x11
- --socket=pulseaudio
- --share=ipc
- --share=network
#- --filesystem=home
#- --filesystem=xdg-download
- --talk-name=org.freedesktop.secrets
- --talk-name=org.freedesktop.Notifications
- --talk-name=org.kde.StatusNotifierWatcher
- --filesystem=xdg-run/keyring
- --device=dri
modules:
- shared-modules/libappindicator/libappindicator-gtk3-12.10.json
- name: lume
sources:
- type: dir
path: usr
- type: file
path: nu.lume.Lume.desktop
- type: file
path: nu.lume.Lume.appdata.xml
buildsystem: simple
build-commands:
- install -Dm755 bin/lume /app/bin/lume
- mkdir -p /app/lib/lume/resources
- cp -r lib/lume/resources /app/lib/lume/resources
- mkdir -p /app/share/icons/hicolor/
- cp -r share/icons/hicolor/ /app/share/icons/
- install -Dm644 nu.lume.Lume.appdata.xml /app/share/metainfo/nu.lume.Lume.appdata.xml
- install -Dm644 nu.lume.Lume.desktop /app/share/applications/nu.lume.Lume.desktop

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 606 KiB

14
index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lume Desktop</title>
</head>
<body
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>
<script type="module" src="/src/app.tsx"></script>
</body>
</html>

View File

@@ -1,35 +1,88 @@
{
"name": "lume",
"private": true,
"version": "4.0.0",
"version": "0.0.0",
"type": "module",
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"web:dev": "turbo run dev --filter web",
"desktop:dev": "turbo run dev --filter desktop2",
"desktop:build": "turbo run build --filter desktop2",
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"tauri": "tauri"
},
"devDependencies": {
"@biomejs/biome": "^1.8.1",
"@tauri-apps/cli": "2.0.0-beta.20",
"turbo": "^1.13.4"
},
"packageManager": "pnpm@8.9.0",
"engines": {
"node": ">=18"
},
"dependencies": {
"@tauri-apps/api": "2.0.0-beta.13",
"@tauri-apps/plugin-clipboard-manager": "2.1.0-beta.3",
"@tauri-apps/plugin-dialog": "2.0.0-beta.5",
"@tauri-apps/plugin-fs": "2.0.0-beta.5",
"@tauri-apps/plugin-http": "2.0.0-beta.5",
"@tauri-apps/plugin-notification": "2.0.0-beta.5",
"@tauri-apps/plugin-os": "2.0.0-beta.5",
"@tauri-apps/plugin-process": "2.0.0-beta.5",
"@tauri-apps/plugin-shell": "2.0.0-beta.6",
"@tauri-apps/plugin-updater": "2.0.0-beta.5",
"@tauri-apps/plugin-upload": "2.0.0-beta.6"
"@getalby/bitcoin-connect-react": "^3.6.2",
"@phosphor-icons/react": "^2.1.7",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-checkbox": "^1.1.1",
"@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.2",
"@tanstack/query-persist-client-core": "^5.51.21",
"@tanstack/react-query": "^5.51.23",
"@tanstack/react-router": "^1.48.1",
"@tanstack/react-store": "^0.5.5",
"@tanstack/store": "^0.5.5",
"@tauri-apps/api": "2.0.0-rc.1",
"@tauri-apps/plugin-clipboard-manager": "2.0.0-rc.0",
"@tauri-apps/plugin-dialog": "2.0.0-rc.0",
"@tauri-apps/plugin-fs": "2.0.0-rc.1",
"@tauri-apps/plugin-http": "2.0.0-rc.1",
"@tauri-apps/plugin-os": "2.0.0-rc.0",
"@tauri-apps/plugin-process": "2.0.0-rc.0",
"@tauri-apps/plugin-shell": "2.0.0-rc.0",
"@tauri-apps/plugin-store": "2.0.0-rc.0",
"@tauri-apps/plugin-updater": "2.0.0-rc.0",
"@tauri-apps/plugin-upload": "2.0.0-rc.0",
"@tauri-apps/plugin-window-state": "2.0.0-rc.0",
"bitcoin-units": "^1.0.0",
"boring-avatars": "^1.10.2",
"dayjs": "^1.11.12",
"embla-carousel-react": "^8.1.8",
"i18next": "^23.13.0",
"i18next-resources-to-backend": "^1.2.1",
"light-bolt11-decoder": "^3.1.1",
"minidenticons": "^4.2.1",
"nanoid": "^5.0.7",
"nostr-tools": "^2.7.2",
"react": "19.0.0-rc-d025ddd3-20240722",
"react-currency-input-field": "^3.8.0",
"react-dom": "19.0.0-rc-d025ddd3-20240722",
"react-hook-form": "^7.52.2",
"react-i18next": "^15.0.1",
"react-string-replace": "^1.1.1",
"slate": "^0.103.0",
"slate-react": "^0.107.1",
"use-debounce": "^10.0.3",
"virtua": "^0.33.7"
},
"devDependencies": {
"@biomejs/biome": "^1.8.3",
"@evilmartians/harmony": "^1.2.0",
"@tailwindcss/forms": "^0.5.7",
"@tailwindcss/typography": "^0.5.14",
"@tanstack/router-devtools": "^1.48.1",
"@tanstack/router-plugin": "^1.47.0",
"@tauri-apps/cli": "2.0.0-rc.4",
"@types/react": "npm:types-react@19.0.0-rc.1",
"@types/react-dom": "npm:types-react-dom@19.0.0-rc.1",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"babel-plugin-react-compiler": "0.0.0-experimental-696af53-20240625",
"clsx": "^2.1.1",
"postcss": "^8.4.41",
"tailwind-gradient-mask-image": "^1.2.0",
"tailwind-merge": "^2.5.2",
"tailwind-scrollbar": "^3.1.0",
"tailwindcss": "^3.4.10",
"tailwindcss-content-visibility": "^0.2.0",
"typescript": "^5.5.4",
"vite": "^5.4.1",
"vite-tsconfig-paths": "^5.0.1"
},
"overrides": {
"@types/react": "npm:types-react@rc",
"@types/react-dom": "npm:types-react-dom@rc"
}
}

View File

@@ -1,128 +0,0 @@
export * from "./src/addWidget";
export * from "./src/arrowLeft";
export * from "./src/arrowRight";
export * from "./src/bell";
export * from "./src/cancel";
export * from "./src/checkCircle";
export * from "./src/chevronDown";
export * from "./src/chevronRight";
export * from "./src/compose";
export * from "./src/copy";
export * from "./src/edit";
export * from "./src/enter";
export * from "./src/eyeOff";
export * from "./src/eyeOn";
export * from "./src/feed";
export * from "./src/heartbeat";
export * from "./src/hide";
export * from "./src/image";
export * from "./src/like";
export * from "./src/lume";
export * from "./src/media";
export * from "./src/mute";
export * from "./src/space";
export * from "./src/spaceFilled";
export * from "./src/navArrowDown";
export * from "./src/plus";
export * from "./src/plusCircle";
export * from "./src/refresh";
export * from "./src/reply";
export * from "./src/replyMessage";
export * from "./src/repost";
export * from "./src/threads";
export * from "./src/trash";
export * from "./src/world";
export * from "./src/zap";
export * from "./src/trending";
export * from "./src/empty";
export * from "./src/cmd";
export * from "./src/verticalDots";
export * from "./src/signal";
export * from "./src/unverified";
export * from "./src/settings";
export * from "./src/logout";
export * from "./src/follow";
export * from "./src/unfollow";
export * from "./src/reaction";
export * from "./src/thread";
export * from "./src/strangers";
export * from "./src/download";
export * from "./src/horizontalDots";
export * from "./src/arrowRightCircle";
export * from "./src/hashtag";
export * from "./src/file";
export * from "./src/share";
export * from "./src/expand";
export * from "./src/focus";
export * from "./src/chevronUp";
export * from "./src/secure";
export * from "./src/verified";
export * from "./src/mention";
export * from "./src/groupFeeds";
export * from "./src/article";
export * from "./src/follows";
export * from "./src/alby";
export * from "./src/stars";
export * from "./src/nwc";
export * from "./src/timeline";
export * from "./src/dots";
export * from "./src/handArrowDown";
export * from "./src/relay";
export * from "./src/explore";
export * from "./src/explore2";
export * from "./src/home";
export * from "./src/chats";
export * from "./src/community";
export * from "./src/heading1";
export * from "./src/heading2";
export * from "./src/heading3";
export * from "./src/bold";
export * from "./src/italic";
export * from "./src/user";
export * from "./src/advancedSettings";
export * from "./src/info";
export * from "./src/light";
export * from "./src/dark";
export * from "./src/system";
export * from "./src/announcement";
export * from "./src/depot";
export * from "./src/search";
export * from "./src/run";
export * from "./src/gossip";
export * from "./src/userAdd";
export * from "./src/userRemove";
export * from "./src/pin";
export * from "./src/homeFilled";
export * from "./src/relayFilled";
export * from "./src/depotFilled";
export * from "./src/nwcFilled";
export * from "./src/moveLeft";
export * from "./src/moveRight";
export * from "./src/help";
export * from "./src/plusSquare";
export * from "./src/column";
export * from "./src/addMedia";
export * from "./src/check";
export * from "./src/popperFilled";
export * from "./src/composeFilled";
export * from "./src/settingsFilled";
export * from "./src/bellFilled";
export * from "./src/foryou";
export * from "./src/editInterest";
export * from "./src/newColumn";
export * from "./src/searchFilled";
export * from "./src/arrowUp";
export * from "./src/arrowUpSquare";
export * from "./src/arrowDown";
export * from "./src/link";
export * from "./src/local";
export * from "./src/global";
export * from "./src/infoCircle";
export * from "./src/cancelCircle";
export * from "./src/laurel";
export * from "./src/quote";
export * from "./src/key";
export * from "./src/remote";
export * from "./src/nsfw";
export * from "./src/visit";
export * from "./src/pow";

View File

@@ -1,14 +0,0 @@
{
"name": "@lume/icons",
"version": "0.0.0",
"private": true,
"main": "./index.ts",
"dependencies": {
"react": "^18.3.1"
},
"devDependencies": {
"@lume/tsconfig": "workspace:*",
"@types/react": "^18.3.3",
"typescript": "^5.4.5"
}
}

View File

@@ -1,13 +0,0 @@
export function AddMediaIcon(props: JSX.IntrinsicElements["svg"]) {
return (
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
d="M15.25 8.75v-4a2 2 0 0 0-2-2h-8.5a2 2 0 0 0-2 2v8.5a2 2 0 0 0 2 2h4M3.1 11.9l1.794-1.176a2 2 0 0 1 2.206.01l1.279.852M6 6.25h.5m8 8.75h.5M6.75 6.25a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0Zm7 6.95v3.6l2.8-1.8-2.8-1.8Zm5.5 8.05h-8.5a2 2 0 0 1-2-2v-8.5a2 2 0 0 1 2-2h8.5a2 2 0 0 1 2 2v8.5a2 2 0 0 1-2 2Z"
/>
</svg>
);
}

View File

@@ -1,24 +0,0 @@
import type { SVGProps } from "react";
export function AddWidgetIcon(
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
{...props}
>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
d="M12.25 21.25h-6.5a1 1 0 01-1-1V3.75a1 1 0 011-1h12.5a1 1 0 011 1v8.5m-1 3v3m0 0v3m0-3h-3m3 0h3"
/>
</svg>
);
}

View File

@@ -1,24 +0,0 @@
import type { SVGProps } from "react";
export function AdvancedSettingsIcon(
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
{...props}
>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
d="M13.75 7h-10m10 0a3.25 3.25 0 116.5 0 3.25 3.25 0 11-6.5 0zm6.5 10h-8m0 0a3.25 3.25 0 11-6.5 0m6.5 0a3.25 3.25 0 10-6.5 0m0 0h-2"
></path>
</svg>
);
}

View File

@@ -1,76 +0,0 @@
import type { SVGProps } from "react";
export function AlbyIcon(
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="400"
height="578"
fill="none"
viewBox="0 0 400 578"
{...props}
>
<path
fill="#000"
d="M201.283 577.511c54.122 0 97.998-8.1 97.998-18.092 0-9.992-43.876-18.092-97.998-18.092-54.123 0-97.998 8.1-97.998 18.092 0 9.992 43.875 18.092 97.998 18.092z"
opacity="0.1"
></path>
<path
fill="#fff"
stroke="#000"
strokeWidth="15.077"
d="M295.75 471.344c50.627 0 73.67-112.102 73.67-154.608 0-33.13-22.86-53.208-52.913-53.208-29.866 0-54.113 12.843-54.414 28.747-.001 41.971-7.388 179.069 33.657 179.069zM110.837 471.344c-50.627 0-73.67-112.102-73.67-154.608 0-33.13 22.86-53.208 52.913-53.208 29.866 0 54.113 12.843 54.414 28.747.001 41.971 7.388 179.069-33.657 179.069z"
></path>
<path
fill="#FFDF6F"
stroke="#000"
strokeWidth="15"
d="M68.83 303.262v-.002c-.054-.519.052-.82.16-1.016.127-.232.368-.508.773-.738.84-.477 2.014-.563 3.108.076 37.603 22.042 80.976 34.678 128.13 34.678 47.163 0 91.339-12.881 129.184-35.307 1.087-.645 2.26-.565 3.102-.091.407.229.65.504.779.737.109.197.216.499.163 1.019-5.854 58.014-37.322 105.977-79.618 128.054-13.969 7.293-23.576 19.962-32.013 31.089l-.452.597-.002.002c-6.857 9.046-13.063 17.147-20.648 23.116-7.584-5.969-13.791-14.07-20.648-23.116l-.001-.002-.452-.597c-8.437-11.127-18.043-23.796-32.013-31.089-42.135-21.992-73.523-69.677-79.551-127.41z"
></path>
<path
fill="#000"
stroke="#000"
strokeWidth="15.077"
d="M201.786 346.338c73.274 0 132.674-19.8 132.674-44.225s-59.4-44.225-132.674-44.225-132.674 19.8-132.674 44.225 59.4 44.225 132.674 44.225z"
></path>
<path
stroke="#000"
strokeLinecap="round"
strokeWidth="15.077"
d="M95.245 376.491s65.44 22.112 107.546 22.112c42.105 0 107.546-22.112 107.546-22.112"
></path>
<path
fill="#000"
d="M77 143c-16.569 0-30-13.431-30-30 0-16.569 13.431-30 30-30 16.569 0 30 13.431 30 30 0 16.569-13.431 30-30 30z"
></path>
<path stroke="#000" strokeWidth="15" d="M72 108.5l56 56"></path>
<path
fill="#000"
d="M322 143c16.569 0 30-13.431 30-30 0-16.569-13.431-30-30-30-16.569 0-30 13.431-30 30 0 16.569 13.431 30 30 30z"
></path>
<path stroke="#000" strokeWidth="15" d="M327.5 108.5l-56 56"></path>
<path
fill="#FFDF6F"
fillRule="evenodd"
d="M85.516 292.019c-16.17-7.698-25.58-24.983-22.427-42.612C76.618 173.747 133 117 200.5 117c67.663 0 124.155 57.023 137.509 132.958 3.106 17.66-6.381 34.937-22.605 42.572C280.687 308.868 241.91 318 201 318c-41.335 0-80.493-9.323-115.484-25.981z"
clipRule="evenodd"
></path>
<path
fill="#000"
d="M70.472 250.728C83.544 177.62 137.582 124.5 200.5 124.5v-15c-72.082 0-130.809 60.375-144.794 138.587l14.766 2.641zM200.5 124.5c63.069 0 117.218 53.379 130.122 126.757l14.774-2.598C331.592 170.166 272.758 109.5 200.5 109.5v15zm111.71 161.244C278.472 301.621 240.783 310.5 201 310.5v15c42.037 0 81.902-9.386 117.597-26.183l-6.387-13.573zM201 310.5c-40.196 0-78.255-9.064-112.26-25.253l-6.448 13.544C118.269 315.918 158.526 325.5 201 325.5v-15zm129.622-59.243c2.49 14.159-5.091 28.219-18.412 34.487l6.387 13.573c19.128-9.002 30.52-29.497 26.799-50.658l-14.774 2.598zm-274.916-3.17c-3.778 21.124 7.524 41.629 26.586 50.704l6.447-13.544c-13.276-6.32-20.795-20.387-18.267-34.519l-14.766-2.641z"
></path>
<path
fill="#000"
fillRule="evenodd"
d="M114.365 273.209c-13.015-5.301-20.736-19.149-16.226-32.459C112.047 199.704 152.618 170 200.5 170c47.882 0 88.453 29.704 102.361 70.75 4.51 13.31-3.211 27.158-16.226 32.459C260.053 284.035 230.973 290 200.5 290c-30.473 0-59.553-5.965-86.135-16.791z"
clipRule="evenodd"
></path>
<path
fill="#fff"
d="M235 254c13.807 0 25-8.954 25-20s-11.193-20-25-20-25 8.954-25 20 11.193 20 25 20zM163.432 254.012c13.807 0 25-8.954 25-20s-11.193-20-25-20-25 8.954-25 20 11.193 20 25 20z"
></path>
</svg>
);
}

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