Compare commits

..
34 Commits
Author SHA1 Message Date
reya 6996e30889 chore: update github ci 2024-06-07 11:26:20 +07:00
reya 7ba793fad8 chore: bump version 2024-06-07 10:03:04 +07:00
reya f11f836518 chore: update tray icon 2024-06-07 09:56:57 +07:00
reya 04fe0fcec8 feat: respect the relay hint 2024-06-07 09:07:33 +07:00
799835a629 Notification Panel (#200)
* feat: add tauri nspanel

* feat: add notification panel

* feat: move notification service to backend

* feat: add sync notification job

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

* feat: fetch notification

* feat: listen for new notification

* feat: finish panel

---------

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

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

* feat: Update get_following function to use timeout duration

* feat: Fix connect_remote_account function to return remote_npub without conversion

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

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

* refactor: system library

* chore: format
2024-05-25 15:21:40 +07:00
reya 7449000f5f chore: bump version 2024-05-23 08:01:04 +07:00
reya dc7762ca11 fix: column lose state when navigate back 2024-05-23 08:00:33 +07:00
XIAO YUandGitHub 3a3f960dde refactor: use borrowed references for adding and connecting relays (#191) 2024-05-23 07:08:39 +07:00
reya 12e066ff2e fix: web 2024-05-22 15:36:41 +07:00
reya fe4f965ed5 chore: update website 2024-05-22 15:35:41 +07:00
149 changed files with 15594 additions and 12860 deletions
+2
View File
@@ -16,6 +16,8 @@ jobs:
args: "--target aarch64-apple-darwin" args: "--target aarch64-apple-darwin"
- platform: "macos-latest" # for Intel based macs. - platform: "macos-latest" # for Intel based macs.
args: "--target x86_64-apple-darwin" args: "--target x86_64-apple-darwin"
- platform: "macos-latest" # for Intel based macs.
args: "--target universal-apple-darwin"
#- platform: 'ubuntu-22.04' #- platform: 'ubuntu-22.04'
# args: '' # args: ''
#- platform: 'windows-latest' #- platform: 'windows-latest'
+16 -16
View File
@@ -9,8 +9,8 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@lume/ark": "workspace:^",
"@lume/icons": "workspace:^", "@lume/icons": "workspace:^",
"@lume/system": "workspace:^",
"@lume/ui": "workspace:^", "@lume/ui": "workspace:^",
"@lume/utils": "workspace:^", "@lume/utils": "workspace:^",
"@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-avatar": "^1.0.4",
@@ -21,43 +21,43 @@
"@radix-ui/react-hover-card": "^1.0.7", "@radix-ui/react-hover-card": "^1.0.7",
"@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-tooltip": "^1.0.7", "@radix-ui/react-tooltip": "^1.0.7",
"@tanstack/query-sync-storage-persister": "^5.36.0", "@tanstack/query-sync-storage-persister": "^5.40.0",
"@tanstack/react-query": "^5.36.0", "@tanstack/react-query": "^5.40.0",
"@tanstack/react-query-persist-client": "^5.36.0", "@tanstack/react-query-persist-client": "^5.40.0",
"@tanstack/react-router": "1.32.5", "@tanstack/react-router": "^1.34.9",
"i18next": "^23.11.4", "i18next": "^23.11.5",
"i18next-resources-to-backend": "^1.2.1", "i18next-resources-to-backend": "^1.2.1",
"minidenticons": "^4.2.1", "minidenticons": "^4.2.1",
"nanoid": "^5.0.7", "nanoid": "^5.0.7",
"nostr-tools": "^2.5.2",
"react": "^18.3.1", "react": "^18.3.1",
"react-currency-input-field": "^3.8.0", "react-currency-input-field": "^3.8.0",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-hook-form": "^7.51.4", "react-hook-form": "^7.51.5",
"react-hotkeys-hook": "^4.5.0", "react-hotkeys-hook": "^4.5.0",
"react-i18next": "^14.1.1", "react-i18next": "^14.1.2",
"react-string-replace": "^1.1.1", "react-string-replace": "^1.1.1",
"slate": "^0.103.0", "slate": "^0.103.0",
"slate-react": "^0.102.0", "slate-react": "^0.104.0",
"sonner": "^1.4.41", "sonner": "^1.4.41",
"use-debounce": "^10.0.0", "use-debounce": "^10.0.1",
"virtua": "^0.31.0" "virtua": "^0.31.0"
}, },
"devDependencies": { "devDependencies": {
"@lume/tailwindcss": "workspace:^", "@lume/tailwindcss": "workspace:^",
"@lume/tsconfig": "workspace:^", "@lume/tsconfig": "workspace:^",
"@lume/types": "workspace:^", "@lume/types": "workspace:^",
"@tanstack/router-devtools": "^1.32.5", "@tanstack/router-devtools": "^1.34.9",
"@tanstack/router-vite-plugin": "^1.32.2", "@tanstack/router-vite-plugin": "^1.34.8",
"@types/react": "^18.3.2", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@vitejs/plugin-react-swc": "^3.6.0", "@vitejs/plugin-react-swc": "^3.7.0",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"postcss": "^8.4.38", "postcss": "^8.4.38",
"tailwindcss": "^3.4.3", "tailwindcss": "^3.4.3",
"typescript": "^5.4.5", "typescript": "^5.4.5",
"vite": "^5.2.11", "vite": "^5.2.12",
"vite-plugin-top-level-await": "^1.4.1", "vite-plugin-top-level-await": "^1.4.1",
"vite-tsconfig-paths": "^4.3.2" "vite-tsconfig-paths": "^4.3.2"
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 951 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 KiB

+3 -7
View File
@@ -1,20 +1,17 @@
import { Ark } from "@lume/ark";
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister"; import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
import { QueryClient } from "@tanstack/react-query"; import { QueryClient } from "@tanstack/react-query";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client"; import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import { RouterProvider, createRouter } from "@tanstack/react-router"; import { RouterProvider, createRouter } from "@tanstack/react-router";
import { platform } from "@tauri-apps/plugin-os";
import React, { StrictMode } from "react"; import React, { StrictMode } from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { I18nextProvider } from "react-i18next"; import { I18nextProvider } from "react-i18next";
import "./app.css"; import "./app.css";
import i18n from "./locale"; import i18n from "./locale";
import { routeTree } from "./router.gen"; // auto generated file import { routeTree } from "./router.gen"; // auto generated file
import { type } from "@tauri-apps/plugin-os";
const ark = new Ark(); const os = await type();
const queryClient = new QueryClient(); const queryClient = new QueryClient();
const platformName = await platform();
const persister = createSyncStoragePersister({ const persister = createSyncStoragePersister({
storage: window.localStorage, storage: window.localStorage,
}); });
@@ -23,9 +20,8 @@ const persister = createSyncStoragePersister({
const router = createRouter({ const router = createRouter({
routeTree, routeTree,
context: { context: {
ark,
queryClient, queryClient,
platform: platformName, platform: os,
}, },
Wrap: ({ children }) => { Wrap: ({ children }) => {
return ( return (
@@ -1,6 +1,6 @@
import { NostrQuery } from "@lume/system";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router";
import { import {
type Dispatch, type Dispatch,
type ReactNode, type ReactNode,
@@ -18,21 +18,17 @@ export function AvatarUploader({
children: ReactNode; children: ReactNode;
className?: string; className?: string;
}) { }) {
const { ark } = useRouteContext({ strict: false });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const uploadAvatar = async () => { const uploadAvatar = async () => {
// start loading
setLoading(true);
try { try {
const image = await ark.upload(); setLoading(true);
const image = await NostrQuery.upload();
setPicture(image); setPicture(image);
} catch (e) { } catch (e) {
setLoading(false);
toast.error(String(e)); toast.error(String(e));
} }
// stop loading
setLoading(false);
}; };
return ( return (
+2 -3
View File
@@ -1,16 +1,15 @@
import { User } from "@/components/user"; import { User } from "@/components/user";
import { NostrAccount } from "@lume/system";
import { getBitcoinDisplayValues } from "@lume/utils"; import { getBitcoinDisplayValues } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
export function Balance({ account }: { account: string }) { export function Balance({ account }: { account: string }) {
const { ark } = useRouteContext({ strict: false });
const [balance, setBalance] = useState(0); const [balance, setBalance] = useState(0);
const value = useMemo(() => getBitcoinDisplayValues(balance), [balance]); const value = useMemo(() => getBitcoinDisplayValues(balance), [balance]);
useEffect(() => { useEffect(() => {
async function getBalance() { async function getBalance() {
const val = await ark.get_balance(); const val = await NostrAccount.getBalance();
setBalance(val); setBalance(val);
} }
+14 -4
View File
@@ -3,7 +3,7 @@ import type { LumeColumn } from "@lume/types";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { getCurrent } from "@tauri-apps/api/webviewWindow"; import { getCurrent } from "@tauri-apps/api/webviewWindow";
import { useEffect, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
export function Column({ export function Column({
column, column,
@@ -17,7 +17,10 @@ export function Column({
isResize: boolean; isResize: boolean;
}) { }) {
const container = useRef<HTMLDivElement>(null); const container = useRef<HTMLDivElement>(null);
const webviewLabel = `column-${account}_${column.label}`; const webviewLabel = useMemo(
() => `column-${account}_${column.label}`,
[account],
);
const [isCreated, setIsCreated] = useState(false); const [isCreated, setIsCreated] = useState(false);
@@ -48,6 +51,8 @@ export function Column({
}, [isScroll]); }, [isScroll]);
useEffect(() => { useEffect(() => {
if (!container?.current) return;
const rect = container.current.getBoundingClientRect(); const rect = container.current.getBoundingClientRect();
const url = `${column.content}?account=${account}&label=${column.label}&name=${column.name}`; const url = `${column.content}?account=${account}&label=${column.label}&name=${column.name}`;
@@ -59,11 +64,16 @@ export function Column({
width: rect.width, width: rect.width,
height: rect.height, height: rect.height,
url, url,
}).then(() => setIsCreated(true)); }).then(() => {
console.log("created: ", webviewLabel);
setIsCreated(true);
});
// close webview when unmounted // close webview when unmounted
return () => { return () => {
invoke("close_column", { label: webviewLabel }); invoke("close_column", { label: webviewLabel }).then(() => {
console.log("closed: ", webviewLabel);
});
}; };
}, [account]); }, [account]);
+10 -5
View File
@@ -1,18 +1,23 @@
import { ThreadIcon } from "@lume/icons"; import { ThreadIcon } from "@lume/icons";
import type { Event } from "@lume/types"; import type { NostrEvent } from "@lume/types";
import { Note } from "@/components/note"; import { Note } from "@/components/note";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router"; import { LumeEvent } from "@lume/system";
import { useMemo } from "react";
export function Conversation({ export function Conversation({
event, event,
gossip,
className, className,
}: { }: {
event: Event; event: NostrEvent;
gossip?: boolean;
className?: string; className?: string;
}) { }) {
const { ark } = useRouteContext({ strict: false }); const thread = useMemo(
const thread = ark.get_thread(event.tags); () => LumeEvent.getEventThread(event.tags, gossip),
[event],
);
return ( return (
<Note.Provider event={event}> <Note.Provider event={event}>
@@ -4,9 +4,7 @@ import { User } from "../user";
export function NoteActivity({ className }: { className?: string }) { export function NoteActivity({ className }: { className?: string }) {
const event = useNoteContext(); const event = useNoteContext();
const mentions = event.tags const mentions = event.mentions;
.filter((tag) => tag[0] === "p")
.map((tag) => tag[1]);
return ( return (
<div className={cn("-mt-3 mb-2", className)}> <div className={cn("-mt-3 mb-2", className)}>
@@ -1,11 +1,10 @@
import { VisitIcon } from "@lume/icons"; import { VisitIcon } from "@lume/icons";
import * as Tooltip from "@radix-ui/react-tooltip"; import * as Tooltip from "@radix-ui/react-tooltip";
import { useRouteContext } from "@tanstack/react-router";
import { useNoteContext } from "../provider"; import { useNoteContext } from "../provider";
import { LumeWindow } from "@lume/system";
export function NoteOpenThread() { export function NoteOpenThread() {
const event = useNoteContext(); const event = useNoteContext();
const { ark } = useRouteContext({ strict: false });
return ( return (
<Tooltip.Provider> <Tooltip.Provider>
@@ -13,7 +12,7 @@ export function NoteOpenThread() {
<Tooltip.Trigger asChild> <Tooltip.Trigger asChild>
<button <button
type="button" type="button"
onClick={() => ark.open_event(event)} onClick={() => LumeWindow.openEvent(event)}
className="group inline-flex h-7 w-14 bg-neutral-100 dark:bg-white/10 rounded-full items-center justify-center text-sm font-medium text-neutral-800 dark:text-neutral-200 hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20" className="group inline-flex h-7 w-14 bg-neutral-100 dark:bg-white/10 rounded-full items-center justify-center text-sm font-medium text-neutral-800 dark:text-neutral-200 hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20"
> >
<VisitIcon className="shrink-0 size-4" /> <VisitIcon className="shrink-0 size-4" />
@@ -1,12 +1,11 @@
import { ReplyIcon } from "@lume/icons"; import { ReplyIcon } from "@lume/icons";
import * as Tooltip from "@radix-ui/react-tooltip"; import * as Tooltip from "@radix-ui/react-tooltip";
import { useRouteContext } from "@tanstack/react-router";
import { useNoteContext } from "../provider"; import { useNoteContext } from "../provider";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { LumeWindow } from "@lume/system";
export function NoteReply({ large = false }: { large?: boolean }) { export function NoteReply({ large = false }: { large?: boolean }) {
const event = useNoteContext(); const event = useNoteContext();
const { ark } = useRouteContext({ strict: false });
return ( return (
<Tooltip.Provider> <Tooltip.Provider>
@@ -14,7 +13,7 @@ export function NoteReply({ large = false }: { large?: boolean }) {
<Tooltip.Trigger asChild> <Tooltip.Trigger asChild>
<button <button
type="button" type="button"
onClick={() => ark.open_editor(event.id)} onClick={() => LumeWindow.openEditor(event.id)}
className={cn( className={cn(
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200", "inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
large large
@@ -2,15 +2,14 @@ import { QuoteIcon, RepostIcon } from "@lume/icons";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import * as Tooltip from "@radix-ui/react-tooltip"; import * as Tooltip from "@radix-ui/react-tooltip";
import { useRouteContext } from "@tanstack/react-router";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { useNoteContext } from "../provider"; import { useNoteContext } from "../provider";
import { LumeWindow } from "@lume/system";
export function NoteRepost({ large = false }: { large?: boolean }) { export function NoteRepost({ large = false }: { large?: boolean }) {
const { ark } = useRouteContext({ strict: false });
const event = useNoteContext(); const event = useNoteContext();
const [t] = useTranslation(); const [t] = useTranslation();
@@ -23,7 +22,7 @@ export function NoteRepost({ large = false }: { large?: boolean }) {
setLoading(true); setLoading(true);
// repost // repost
await ark.repost(event.id, event.pubkey); await event.repost();
// update state // update state
setLoading(false); setLoading(false);
@@ -86,7 +85,7 @@ export function NoteRepost({ large = false }: { large?: boolean }) {
<DropdownMenu.Item asChild> <DropdownMenu.Item asChild>
<button <button
type="button" type="button"
onClick={() => ark.open_editor(event.id, true)} onClick={() => LumeWindow.openEditor(event.id, true)}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100" className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
> >
<QuoteIcon className="size-4" /> <QuoteIcon className="size-4" />
@@ -1,33 +1,19 @@
import { ZapIcon } from "@lume/icons"; import { ZapIcon } from "@lume/icons";
import { useRouteContext, useSearch } from "@tanstack/react-router"; import { useRouteContext } from "@tanstack/react-router";
import { toast } from "sonner";
import { useNoteContext } from "../provider"; import { useNoteContext } from "../provider";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { LumeWindow } from "@lume/system";
export function NoteZap({ large = false }: { large?: boolean }) { export function NoteZap({ large = false }: { large?: boolean }) {
const event = useNoteContext(); const event = useNoteContext();
const { ark, settings } = useRouteContext({ strict: false }); const { settings } = useRouteContext({ strict: false });
const { account } = useSearch({ strict: false });
const zap = async () => {
try {
const nwc = await ark.load_nwc();
if (!nwc) {
ark.open_nwc();
} else {
ark.open_zap(event.id, event.pubkey, account);
}
} catch (e) {
toast.error(String(e));
}
};
if (!settings.zap) return null; if (!settings.zap) return null;
return ( return (
<button <button
type="button" type="button"
onClick={() => zap()} onClick={() => LumeWindow.openZap(event.id, event.pubkey)}
className={cn( className={cn(
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200", "inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
large large
+1 -1
View File
@@ -1,4 +1,4 @@
import { useEvent } from "@lume/ark"; import { useEvent } from "@lume/system";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { Note } from "."; import { Note } from ".";
import { InfoIcon } from "@lume/icons"; import { InfoIcon } from "@lume/icons";
@@ -102,7 +102,7 @@ export function NoteContent({
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div <div
className={cn( className={cn(
"select-text text-[15px] text-balance break-words overflow-hidden", "select-text text-[15px] text-pretty content-break overflow-hidden",
event.content.length > 500 ? "max-h-[300px] gradient-mask-b-0" : "", event.content.length > 500 ? "max-h-[300px] gradient-mask-b-0" : "",
className, className,
)} )}
@@ -145,7 +145,7 @@ export function NoteContentLarge({
return ( return (
<div className={cn("select-text", className)}> <div className={cn("select-text", className)}>
<div className="text-[15px] text-balance content-break leading-normal"> <div className="text-[15px] text-pretty content-break leading-normal">
{content} {content}
</div> </div>
</div> </div>
@@ -2,7 +2,7 @@ export function Hashtag({ tag }: { tag: string }) {
return ( return (
<button <button
type="button" type="button"
className="break-all cursor-default leading-normal group" className="break-all cursor-default leading-normal group text-start"
> >
<span className="text-blue-500">#</span> <span className="text-blue-500">#</span>
<span className="underline-offset-1 underline decoration-2 decoration-blue-200 dark:decoration-blue-800 group-hover:decoration-blue-500"> <span className="underline-offset-1 underline decoration-2 decoration-blue-200 dark:decoration-blue-800 group-hover:decoration-blue-500">
@@ -1,6 +1,5 @@
import { useEvent } from "@lume/ark"; import { LumeWindow, useEvent } from "@lume/system";
import { LinkIcon } from "@lume/icons"; import { LinkIcon } from "@lume/icons";
import { useRouteContext } from "@tanstack/react-router";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { User } from "@/components/user"; import { User } from "@/components/user";
@@ -13,7 +12,6 @@ export function MentionNote({
eventId: string; eventId: string;
openable?: boolean; openable?: boolean;
}) { }) {
const { ark } = useRouteContext({ strict: false });
const { t } = useTranslation(); const { t } = useTranslation();
const { isLoading, isError, data } = useEvent(eventId); const { isLoading, isError, data } = useEvent(eventId);
@@ -50,7 +48,7 @@ export function MentionNote({
</User.Provider> </User.Provider>
<div <div
className={cn( className={cn(
"px-3 select-text content-break whitespace-normal text-balance leading-normal", "px-3 select-text whitespace-normal text-pretty content-break leading-normal",
data.content.length > 100 ? "max-h-[150px] gradient-mask-b-0" : "", data.content.length > 100 ? "max-h-[150px] gradient-mask-b-0" : "",
)} )}
> >
@@ -62,9 +60,9 @@ export function MentionNote({
type="button" type="button"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
ark.open_event_id(data.id); LumeWindow.openEvent(data);
}} }}
className="z-10 h-7 w-28 inline-flex items-center justify-center gap-1 text-sm bg-neutral-100 dark:bg-white/10 rounded-full text-neutral-600 hover:text-blue-500 dark:text-neutral-400" className="z-10 h-7 w-28 inline-flex items-center justify-center gap-1 text-sm bg-black/10 dark:bg-white/10 rounded-full text-neutral-600 hover:text-blue-500 dark:text-neutral-400"
> >
View post View post
<LinkIcon className="size-4" /> <LinkIcon className="size-4" />
@@ -1,24 +1,20 @@
import { useProfile } from "@lume/ark"; import { LumeWindow, useProfile } from "@lume/system";
import { displayNpub } from "@lume/utils"; import { displayNpub } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router";
export function MentionUser({ pubkey }: { pubkey: string }) { export function MentionUser({ pubkey }: { pubkey: string }) {
const { ark } = useRouteContext({ strict: false });
const { isLoading, isError, profile } = useProfile(pubkey); const { isLoading, isError, profile } = useProfile(pubkey);
return ( return (
<button <button
type="button" type="button"
onClick={() => ark.open_profile(pubkey)} onClick={() => LumeWindow.openProfile(pubkey)}
className="break-words text-start text-blue-500 hover:text-blue-600" className="break-words text-start text-blue-500 hover:text-blue-600"
> >
{isLoading {isLoading
? "@anon" ? "@anon"
: isError : isError
? displayNpub(pubkey, 16) ? displayNpub(pubkey, 16)
: `@${ : `@${profile?.name || profile?.display_name || "anon"}`}
profile?.name || profile?.display_name || profile?.name || "anon"
}`}
</button> </button>
); );
} }
+7 -16
View File
@@ -1,37 +1,28 @@
import { HorizontalDotsIcon } from "@lume/icons"; import { HorizontalDotsIcon } from "@lume/icons";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { useRouteContext } from "@tanstack/react-router";
import { writeText } from "@tauri-apps/plugin-clipboard-manager"; import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useNoteContext } from "./provider"; import { useNoteContext } from "./provider";
import { LumeWindow } from "@lume/system";
export function NoteMenu() { export function NoteMenu() {
const { t } = useTranslation();
const event = useNoteContext(); const event = useNoteContext();
const { ark } = useRouteContext({ strict: false });
const { t } = useTranslation();
const copyID = async () => { const copyID = async () => {
await writeText(await ark.event_to_bech32(event.id, [""])); await writeText(await event.idAsBech32());
toast.success("Copied");
}; };
const copyRaw = async () => { const copyRaw = async () => {
await writeText(JSON.stringify(event)); await writeText(JSON.stringify(event));
toast.success("Copied");
}; };
const copyNpub = async () => { const copyNpub = async () => {
await writeText(await ark.user_to_bech32(event.pubkey, [""])); await writeText(await event.pubkeyAsBech32());
toast.success("Copied");
}; };
const copyLink = async () => { const copyLink = async () => {
await writeText( await writeText(`https://njump.me/${await event.idAsBech32()}`);
`https://njump.me/${await ark.event_to_bech32(event.id, [""])}`,
);
toast.success("Copied");
}; };
return ( return (
@@ -49,7 +40,7 @@ export function NoteMenu() {
<DropdownMenu.Item asChild> <DropdownMenu.Item asChild>
<button <button
type="button" type="button"
onClick={() => ark.open_event(event)} onClick={() => LumeWindow.openEvent(event)}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100" className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
> >
{t("note.menu.viewThread")} {t("note.menu.viewThread")}
@@ -84,7 +75,7 @@ export function NoteMenu() {
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item asChild> <DropdownMenu.Item asChild>
<button <button
onClick={() => ark.open_profile(event.pubkey)} onClick={() => LumeWindow.openProfile(event.pubkey)}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100" className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
> >
{t("note.menu.viewAuthor")} {t("note.menu.viewAuthor")}
@@ -1,61 +1,44 @@
import { CheckCircleIcon, DownloadIcon } from "@lume/icons";
import { downloadDir } from "@tauri-apps/api/path";
import { WebviewWindow } from "@tauri-apps/api/webviewWindow"; import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
import { download } from "@tauri-apps/plugin-upload";
import { type SyntheticEvent, useState } from "react";
export function ImagePreview({ url }: { url: string }) { export function ImagePreview({ url }: { url: string }) {
const [downloaded, setDownloaded] = useState(false); const open = async (url: string) => {
const name = new URL(url).pathname
.split("/")
.pop()
.replace(/[^a-zA-Z ]/g, "");
const label = `viewer-${name}`;
const window = WebviewWindow.getByLabel(label);
const downloadImage = async (e: { stopPropagation: () => void }) => { if (!window) {
try { const newWindow = new WebviewWindow(label, {
e.stopPropagation(); url,
title: "Image Viewer",
width: 800,
height: 800,
titleBarStyle: "overlay",
});
const downloadDirPath = await downloadDir(); return newWindow;
const filename = url.substring(url.lastIndexOf("/") + 1);
await download(url, `${downloadDirPath}/${filename}`);
setDownloaded(true);
} catch (e) {
console.error(e);
} }
};
const open = async () => { return await window.setFocus();
const name = new URL(url).pathname.split("/").pop();
return new WebviewWindow("image-viewer", {
url,
title: name,
});
};
const fallback = (event: SyntheticEvent<HTMLImageElement, Event>) => {
event.currentTarget.src = "/fallback-image.jpg";
}; };
return ( return (
// biome-ignore lint/a11y/useKeyWithClickEvents: <explanation> <div className="group relative my-1">
<div onClick={() => open()} className="group relative my-1">
<img <img
src={url} src={url}
alt={url} alt={url}
loading="lazy" loading="lazy"
decoding="async" decoding="async"
style={{ contentVisibility: "auto" }} style={{ contentVisibility: "auto" }}
onError={fallback}
className="max-h-[600px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15" className="max-h-[600px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
onClick={() => open(url)}
onError={({ currentTarget }) => {
currentTarget.onerror = null;
currentTarget.src = "/404.jpg";
}}
/> />
<button
type="button"
onClick={(e) => downloadImage(e)}
className="absolute right-2 top-2 z-20 hidden size-8 items-center justify-center rounded-md bg-white/10 text-white/70 backdrop-blur-2xl hover:bg-blue-500 hover:text-white group-hover:inline-flex"
>
{downloaded ? (
<CheckCircleIcon className="size-4" />
) : (
<DownloadIcon className="size-4" />
)}
</button>
</div> </div>
); );
} }
@@ -36,6 +36,10 @@ export function Images({ urls }: { urls: string[] }) {
style={{ contentVisibility: "auto" }} style={{ contentVisibility: "auto" }}
className="max-h-[400px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15" className="max-h-[400px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
onClick={() => open(urls[0])} onClick={() => open(urls[0])}
onError={({ currentTarget }) => {
currentTarget.onerror = null;
currentTarget.src = "/404.jpg";
}}
/> />
</div> </div>
); );
@@ -54,6 +58,10 @@ export function Images({ urls }: { urls: string[] }) {
style={{ contentVisibility: "auto" }} style={{ contentVisibility: "auto" }}
className="w-full h-full object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15" className="w-full h-full object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
onClick={() => open(item)} onClick={() => open(item)}
onError={({ currentTarget }) => {
currentTarget.onerror = null;
currentTarget.src = "/404.jpg";
}}
/> />
</CarouselItem> </CarouselItem>
)} )}
+11 -5
View File
@@ -1,19 +1,25 @@
import type { Event } from "@lume/types"; import { LumeEvent } from "@lume/system";
import type { NostrEvent } from "@lume/types";
import { type ReactNode, createContext, useContext } from "react"; import { type ReactNode, createContext, useContext } from "react";
const EventContext = createContext<Event>(null); const NoteContext = createContext<LumeEvent>(null);
export function NoteProvider({ export function NoteProvider({
event, event,
children, children,
}: { event: Event; children: ReactNode }) { }: {
event: NostrEvent;
children: ReactNode;
}) {
const lumeEvent = new LumeEvent(event);
return ( return (
<EventContext.Provider value={event}>{children}</EventContext.Provider> <NoteContext.Provider value={lumeEvent}>{children}</NoteContext.Provider>
); );
} }
export function useNoteContext() { export function useNoteContext() {
const context = useContext(EventContext); const context = useContext(NoteContext);
if (!context) { if (!context) {
throw new Error("Please import Note Provider to use useNoteContext() hook"); throw new Error("Please import Note Provider to use useNoteContext() hook");
} }
+2 -3
View File
@@ -1,11 +1,10 @@
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import * as HoverCard from "@radix-ui/react-hover-card"; import * as HoverCard from "@radix-ui/react-hover-card";
import { useRouteContext } from "@tanstack/react-router";
import { User } from "../user"; import { User } from "../user";
import { useNoteContext } from "./provider"; import { useNoteContext } from "./provider";
import { LumeWindow } from "@lume/system";
export function NoteUser({ className }: { className?: string }) { export function NoteUser({ className }: { className?: string }) {
const { ark } = useRouteContext({ strict: false });
const event = useNoteContext(); const event = useNoteContext();
return ( return (
@@ -46,7 +45,7 @@ export function NoteUser({ className }: { className?: string }) {
</div> </div>
<User.About className="line-clamp-3 text-sm text-white dark:text-neutral-900" /> <User.About className="line-clamp-3 text-sm text-white dark:text-neutral-900" />
<button <button
onClick={() => ark.open_profile(event.pubkey)} onClick={() => LumeWindow.openProfile(event.pubkey)}
className="mt-2 inline-flex h-9 w-full items-center justify-center rounded-lg bg-white text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-100 dark:text-neutral-900 dark:hover:bg-neutral-200" className="mt-2 inline-flex h-9 w-full items-center justify-center rounded-lg bg-white text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-100 dark:text-neutral-900 dark:hover:bg-neutral-200"
> >
View profile View profile
@@ -1,4 +1,4 @@
import type { Event } from "@lume/types"; import type { NostrEvent } from "@lume/types";
import { Note } from "@/components/note"; import { Note } from "@/components/note";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
@@ -6,7 +6,7 @@ export function Notification({
event, event,
className, className,
}: { }: {
event: Event; event: NostrEvent;
className?: string; className?: string;
}) { }) {
return ( return (
+2 -2
View File
@@ -1,5 +1,5 @@
import { QuoteIcon } from "@lume/icons"; import { QuoteIcon } from "@lume/icons";
import type { Event } from "@lume/types"; import type { NostrEvent } from "@lume/types";
import { Note } from "@/components/note"; import { Note } from "@/components/note";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
@@ -7,7 +7,7 @@ export function Quote({
event, event,
className, className,
}: { }: {
event: Event; event: NostrEvent;
className?: string; className?: string;
}) { }) {
const quoteEventId = event.tags.find( const quoteEventId = event.tags.find(
+5 -6
View File
@@ -1,19 +1,18 @@
import type { Event } from "@lume/types";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { Note } from "@/components/note"; import { Note } from "@/components/note";
import { User } from "@/components/user"; import { User } from "@/components/user";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useRouteContext } from "@tanstack/react-router"; import { NostrEvent } from "@lume/types";
import { NostrQuery } from "@lume/system";
export function RepostNote({ export function RepostNote({
event, event,
className, className,
}: { }: {
event: Event; event: NostrEvent;
className?: string; className?: string;
}) { }) {
const { ark } = useRouteContext({ strict: false });
const { const {
isLoading, isLoading,
isError, isError,
@@ -23,12 +22,12 @@ export function RepostNote({
queryFn: async () => { queryFn: async () => {
try { try {
if (event.content.length > 50) { if (event.content.length > 50) {
const embed: Event = JSON.parse(event.content); const embed: NostrEvent = JSON.parse(event.content);
return embed; return embed;
} }
const id = event.tags.find((el) => el[0] === "e")?.[1]; const id = event.tags.find((el) => el[0] === "e")?.[1];
const repostEvent = await ark.get_event(id); const repostEvent = await NostrQuery.getEvent(id);
return repostEvent; return repostEvent;
} catch (e) { } catch (e) {
+2 -2
View File
@@ -1,4 +1,4 @@
import type { Event } from "@lume/types"; import type { NostrEvent } from "@lume/types";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { Note } from "@/components/note"; import { Note } from "@/components/note";
@@ -6,7 +6,7 @@ export function TextNote({
event, event,
className, className,
}: { }: {
event: Event; event: NostrEvent;
className?: string; className?: string;
}) { }) {
return ( return (
+1 -1
View File
@@ -23,7 +23,7 @@ export function UserAvatar({ className }: { className?: string }) {
alt={user.pubkey} alt={user.pubkey}
loading="eager" loading="eager"
decoding="async" decoding="async"
className={cn("outline-[.5px] outline-black/5", className)} className={cn("outline-[.5px] outline-black/5 object-cover", className)}
/> />
<Avatar.Fallback delayMs={120}> <Avatar.Fallback delayMs={120}>
<img <img
@@ -1,9 +1,9 @@
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { useUserContext } from "./provider"; import { useUserContext } from "./provider";
import { NostrAccount } from "@lume/system";
export function UserFollowButton({ export function UserFollowButton({
simple = false, simple = false,
@@ -12,7 +12,6 @@ export function UserFollowButton({
simple?: boolean; simple?: boolean;
className?: string; className?: string;
}) { }) {
const { ark } = useRouteContext({ strict: false });
const user = useUserContext(); const user = useUserContext();
const [t] = useTranslation(); const [t] = useTranslation();
@@ -22,10 +21,10 @@ export function UserFollowButton({
const toggleFollow = async () => { const toggleFollow = async () => {
setLoading(true); setLoading(true);
if (!followed) { if (!followed) {
const add = await ark.follow(user.pubkey); const add = await NostrAccount.follow(user.pubkey, user.profile?.name);
if (add) setFollowed(true); if (add) setFollowed(true);
} else { } else {
const remove = await ark.unfollow(user.pubkey); const remove = await NostrAccount.unfollow(user.pubkey);
if (remove) setFollowed(false); if (remove) setFollowed(false);
} }
setLoading(false); setLoading(false);
@@ -35,7 +34,7 @@ export function UserFollowButton({
async function status() { async function status() {
setLoading(true); setLoading(true);
const contacts = await ark.get_contact_list(); const contacts = await NostrAccount.getContactList();
if (contacts?.includes(user.pubkey)) { if (contacts?.includes(user.pubkey)) {
setFollowed(true); setFollowed(true);
} }
+6 -6
View File
@@ -2,21 +2,21 @@ import { VerifiedIcon } from "@lume/icons";
import { displayLongHandle, displayNpub } from "@lume/utils"; import { displayLongHandle, displayNpub } from "@lume/utils";
import * as Tooltip from "@radix-ui/react-tooltip"; import * as Tooltip from "@radix-ui/react-tooltip";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useRouteContext } from "@tanstack/react-router";
import { useUserContext } from "./provider"; import { useUserContext } from "./provider";
import { NostrQuery } from "@lume/system";
export function UserNip05() { export function UserNip05() {
const user = useUserContext(); const user = useUserContext();
const { ark } = useRouteContext({ strict: false });
const { isLoading, data: verified } = useQuery({ const { isLoading, data: verified } = useQuery({
queryKey: ["nip05", user?.pubkey], queryKey: ["nip05", user?.pubkey],
queryFn: async () => { queryFn: async () => {
if (!user.profile?.nip05) return false; const verify = await NostrQuery.verifyNip05(
const verify = await ark.verify_nip05(user.pubkey, user.profile?.nip05); user.pubkey,
user.profile?.nip05,
);
return verify; return verify;
}, },
enabled: !!user.profile, enabled: !!user.profile?.nip05,
}); });
if (!user.profile?.nip05?.length) return; if (!user.profile?.nip05?.length) return;
@@ -1,12 +1,12 @@
import { useProfile } from "@lume/ark"; import { useProfile } from "@lume/system";
import type { Metadata } from "@lume/types"; import type { Metadata } from "@lume/types";
import { type ReactNode, createContext, useContext } from "react"; import { type ReactNode, createContext, useContext } from "react";
const UserContext = createContext<{ const UserContext = createContext<{
pubkey: string; pubkey: string;
profile: Metadata;
isError: boolean; isError: boolean;
isLoading: boolean; isLoading: boolean;
profile: Metadata;
}>(null); }>(null);
export function UserProvider({ export function UserProvider({
@@ -21,7 +21,7 @@ export function UserProvider({
const { isLoading, isError, profile } = useProfile(pubkey, embedProfile); const { isLoading, isError, profile } = useProfile(pubkey, embedProfile);
return ( return (
<UserContext.Provider value={{ pubkey, isError, isLoading, profile }}> <UserContext.Provider value={{ pubkey, profile, isError, isLoading }}>
{children} {children}
</UserContext.Provider> </UserContext.Provider>
); );
+25 -32
View File
@@ -1,50 +1,41 @@
import { Column } from "@/components/column"; import { Column } from "@/components/column";
import { Toolbar } from "@/components/toolbar"; import { Toolbar } from "@/components/toolbar";
import { ArrowLeftIcon, ArrowRightIcon } from "@lume/icons"; import { ArrowLeftIcon, ArrowRightIcon } from "@lume/icons";
import { NostrQuery } from "@lume/system";
import type { EventColumns, LumeColumn } from "@lume/types"; import type { EventColumns, LumeColumn } from "@lume/types";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { resolveResource } from "@tauri-apps/api/path";
import { getCurrent } from "@tauri-apps/api/window"; import { getCurrent } from "@tauri-apps/api/window";
import { readTextFile } from "@tauri-apps/plugin-fs";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useDebouncedCallback } from "use-debounce"; import { useDebouncedCallback } from "use-debounce";
import { VList, type VListHandle } from "virtua"; import { VList, type VListHandle } from "virtua";
export const Route = createFileRoute("/$account/home")({ export const Route = createFileRoute("/$account/home")({
loader: async ({ context }) => { loader: async () => {
try { const columns = await NostrQuery.getColumns();
const userColumns = await context.ark.get_columns(); return columns;
if (userColumns.length > 0) {
return userColumns;
} else {
const systemPath = "resources/system_columns.json";
const resourcePath = await resolveResource(systemPath);
const resourceFile = await readTextFile(resourcePath);
const systemColumns: LumeColumn[] = JSON.parse(resourceFile);
return systemColumns;
}
} catch (e) {
console.error(String(e));
}
}, },
gcTime: 0,
shouldReload: false,
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const userSavedColumns = Route.useLoaderData(); const { account } = Route.useParams();
const initialColumnList = Route.useLoaderData();
const vlistRef = useRef<VListHandle>(null); const vlistRef = useRef<VListHandle>(null);
const { account } = Route.useParams(); const [columns, setColumns] = useState<LumeColumn[]>([]);
const { ark } = Route.useRouteContext();
const [selectedIndex, setSelectedIndex] = useState(-1); const [selectedIndex, setSelectedIndex] = useState(-1);
const [columns, setColumns] = useState([]);
const [isScroll, setIsScroll] = useState(false); const [isScroll, setIsScroll] = useState(false);
const [isResize, setIsResize] = useState(false); const [isResize, setIsResize] = useState(false);
const reset = () => {
setColumns(null);
setSelectedIndex(-1);
};
const goLeft = () => { const goLeft = () => {
const prevIndex = Math.max(selectedIndex - 1, 0); const prevIndex = Math.max(selectedIndex - 1, 0);
setSelectedIndex(prevIndex); setSelectedIndex(prevIndex);
@@ -80,7 +71,7 @@ function Screen() {
// scroll to the newest column // scroll to the newest column
vlistRef.current.scrollToIndex(newCols.length - 1, { vlistRef.current.scrollToIndex(newCols.length - 1, {
align: "end", align: "center",
}); });
}, 150); }, 150);
@@ -115,16 +106,19 @@ function Screen() {
); );
useEffect(() => { useEffect(() => {
setColumns(userSavedColumns); setColumns(initialColumnList);
}, [userSavedColumns]); }, [initialColumnList]);
useEffect(() => { useEffect(() => {
// save state // save current state
ark.set_columns(columns); if (columns?.length) {
NostrQuery.setColumns(columns).then(() => console.log("saved"));
}
}, [columns]); }, [columns]);
useEffect(() => { useEffect(() => {
const unlistenColEvent = listen<EventColumns>("columns", (data) => { const unlistenColEvent = listen<EventColumns>("columns", (data) => {
if (data.payload.type === "reset") reset();
if (data.payload.type === "add") add(data.payload.column); if (data.payload.type === "add") add(data.payload.column);
if (data.payload.type === "remove") remove(data.payload.label); if (data.payload.type === "remove") remove(data.payload.label);
if (data.payload.type === "set_title") if (data.payload.type === "set_title")
@@ -148,15 +142,14 @@ function Screen() {
horizontal horizontal
tabIndex={-1} tabIndex={-1}
itemSize={440} itemSize={440}
overscan={3} overscan={5}
onScroll={() => setIsScroll(true)} onScroll={() => setIsScroll(true)}
onScrollEnd={() => setIsScroll(false)} onScrollEnd={() => setIsScroll(false)}
className="scrollbar-none h-full w-full overflow-x-auto focus:outline-none" className="scrollbar-none h-full w-full overflow-x-auto focus:outline-none"
cache={null}
> >
{columns.map((column) => ( {columns?.map((column) => (
<Column <Column
key={column.label} key={account + column.label}
column={column} column={column}
account={account} account={account}
isScroll={isScroll} isScroll={isScroll}
+25 -90
View File
@@ -1,38 +1,29 @@
import { import {
BellIcon,
ComposeFilledIcon, ComposeFilledIcon,
HorizontalDotsIcon, HorizontalDotsIcon,
PlusIcon, PlusIcon,
SearchIcon, SearchIcon,
} from "@lume/icons"; } from "@lume/icons";
import { type Event, Kind } from "@lume/types";
import { User } from "@/components/user"; import { User } from "@/components/user";
import { import { cn } from "@lume/utils";
cn,
decodeZapInvoice,
displayNpub,
sendNativeNotification,
} from "@lume/utils";
import { Outlet, createFileRoute } from "@tanstack/react-router"; import { Outlet, createFileRoute } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core";
import { getCurrent } from "@tauri-apps/api/window"; import { getCurrent } from "@tauri-apps/api/window";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import * as Popover from "@radix-ui/react-popover"; import * as Popover from "@radix-ui/react-popover";
import { LumeWindow, NostrAccount } from "@lume/system";
import { Link } from "@tanstack/react-router";
export const Route = createFileRoute("/$account")({ export const Route = createFileRoute("/$account")({
beforeLoad: async ({ context }) => { beforeLoad: async () => {
const ark = context.ark; const accounts = await NostrAccount.getAccounts();
const accounts = await ark.get_accounts();
return { accounts }; return { accounts };
}, },
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const { ark, platform } = Route.useRouteContext(); const { platform } = Route.useRouteContext();
const navigate = Route.useNavigate();
return ( return (
<div className="flex h-screen w-screen flex-col"> <div className="flex h-screen w-screen flex-col">
@@ -45,27 +36,25 @@ function Screen() {
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Accounts /> <Accounts />
<button <Link
type="button" to="/landing"
onClick={() => navigate({ to: "/landing/" })}
className="inline-flex size-8 shrink-0 items-center justify-center rounded-full bg-black/10 text-neutral-800 hover:bg-black/20 dark:bg-white/10 dark:text-neutral-200 dark:hover:bg-white/20" className="inline-flex size-8 shrink-0 items-center justify-center rounded-full bg-black/10 text-neutral-800 hover:bg-black/20 dark:bg-white/10 dark:text-neutral-200 dark:hover:bg-white/20"
> >
<PlusIcon className="size-5" /> <PlusIcon className="size-5" />
</button> </Link>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
type="button" type="button"
onClick={() => ark.open_editor()} onClick={() => LumeWindow.openEditor()}
className="inline-flex h-8 w-max items-center justify-center gap-1 rounded-full bg-blue-500 px-3 text-sm font-medium text-white hover:bg-blue-600" className="inline-flex h-8 w-max items-center justify-center gap-1 rounded-full bg-blue-500 px-3 text-sm font-medium text-white hover:bg-blue-600"
> >
<ComposeFilledIcon className="size-4" /> <ComposeFilledIcon className="size-4" />
New Post New Post
</button> </button>
<Bell />
<button <button
type="button" type="button"
onClick={() => ark.open_search()} onClick={() => LumeWindow.openSearch()}
className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10" className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
> >
<SearchIcon className="size-5" /> <SearchIcon className="size-5" />
@@ -81,7 +70,7 @@ function Screen() {
} }
function Accounts() { function Accounts() {
const { ark, accounts } = Route.useRouteContext(); const { accounts } = Route.useRouteContext();
const { account } = Route.useParams(); const { account } = Route.useParams();
const [windowWidth, setWindowWidth] = useState<number>(null); const [windowWidth, setWindowWidth] = useState<number>(null);
@@ -102,14 +91,23 @@ function Accounts() {
const changeAccount = async (npub: string) => { const changeAccount = async (npub: string) => {
if (npub === account) { if (npub === account) {
return await ark.open_profile(account); return await LumeWindow.openProfile(account);
} }
// change current account and update signer // Change current account and update signer
const select = await ark.load_account(npub); const select = await NostrAccount.loadAccount(npub);
if (select) { if (select) {
return navigate({ to: "/$account/home", params: { account: npub } }); // Reset current columns
await getCurrent().emit("columns", { type: "reset" });
// Redirect to new account
return navigate({
to: "/$account/home",
params: { account: npub },
resetScroll: true,
replace: true,
});
} else { } else {
toast.warning("Something wrong."); toast.warning("Something wrong.");
} }
@@ -188,66 +186,3 @@ function Accounts() {
</div> </div>
); );
} }
function Bell() {
const { ark } = Route.useRouteContext();
const { account } = Route.useParams();
const [count, setCount] = useState(0);
useEffect(() => {
const unlisten = getCurrent().listen<string>(
"activity",
async (payload) => {
setCount((prevCount) => prevCount + 1);
await invoke("set_badge", { count });
const event: Event = JSON.parse(payload.payload);
const user = await ark.get_profile(event.pubkey);
const userName =
user.display_name || user.name || displayNpub(event.pubkey, 16);
switch (event.kind) {
case Kind.Text: {
sendNativeNotification("Mentioned you in a note", userName);
break;
}
case Kind.Repost: {
sendNativeNotification("Reposted your note", userName);
break;
}
case Kind.ZapReceipt: {
const amount = decodeZapInvoice(event.tags);
sendNativeNotification(
`Zapped ₿ ${amount.bitcoinFormatted}`,
userName,
);
break;
}
default:
break;
}
},
);
return () => {
unlisten.then((f) => f());
};
}, []);
return (
<button
type="button"
onClick={() => {
setCount(0);
ark.open_activity(account);
}}
className="relative inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
>
<BellIcon className="size-5" />
{count > 0 ? (
<span className="absolute right-0 top-0 block size-2 rounded-full bg-teal-500 ring-1 ring-black/5" />
) : null}
</button>
);
}
+2 -16
View File
@@ -1,35 +1,21 @@
import type { Ark } from "@lume/ark";
import { CheckCircleIcon, InfoCircleIcon, CancelCircleIcon } from "@lume/icons"; import { CheckCircleIcon, InfoCircleIcon, CancelCircleIcon } from "@lume/icons";
import type { Interests, Metadata, Settings } from "@lume/types"; import type { Settings } from "@lume/types";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import type { QueryClient } from "@tanstack/react-query"; import type { QueryClient } from "@tanstack/react-query";
import { Outlet, createRootRouteWithContext } from "@tanstack/react-router"; import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
import type { Platform } from "@tauri-apps/plugin-os"; import type { Platform } from "@tauri-apps/plugin-os";
import type { Descendant } from "slate";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
type EditorElement = {
type: string;
children: Descendant[];
eventId?: string;
};
interface RouterContext { interface RouterContext {
// System // System
ark: Ark;
queryClient: QueryClient; queryClient: QueryClient;
// App info // App info
platform?: Platform; platform?: Platform;
locale?: string; locale?: string;
// Settings // Settings
settings?: Settings; settings?: Settings;
interests?: Interests; // Accounts
// Profile
accounts?: string[]; accounts?: string[];
profile?: Metadata;
isNewUser?: boolean;
// Editor
initialValue?: EditorElement[];
} }
export const Route = createRootRouteWithContext<RouterContext>()({ export const Route = createRootRouteWithContext<RouterContext>()({
@@ -1,61 +0,0 @@
import { Spinner } from "@lume/ui";
import { Note } from "@/components/note";
import { Await, createFileRoute, defer } from "@tanstack/react-router";
import { Suspense } from "react";
import { Virtualizer } from "virtua";
export const Route = createFileRoute("/activity/$account/texts")({
loader: async ({ context, params }) => {
const ark = context.ark;
return { data: defer(ark.get_activities(params.account, "1")) };
},
component: Screen,
});
function Screen() {
const { data } = Route.useLoaderData();
return (
<Virtualizer overscan={3}>
<Suspense
fallback={
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
<button
type="button"
className="inline-flex items-center gap-2 text-sm font-medium"
disabled
>
<Spinner className="size-5" />
Loading...
</button>
</div>
}
>
<Await promise={data}>
{(events) =>
events.map((event) => (
<div
key={event.id}
className="flex flex-col gap-2 mb-3 bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50"
>
<Note.Provider event={event}>
<Note.Root>
<div className="px-3 h-14 flex items-center justify-between">
<Note.User />
<Note.Menu />
</div>
<Note.Activity className="px-3" />
<Note.Content className="px-3" quote={false} clean />
<div className="mt-3 flex items-center gap-4 h-14 px-3">
<Note.Open />
</div>
</Note.Root>
</Note.Provider>
</div>
))
}
</Await>
</Suspense>
</Virtualizer>
);
}
@@ -1,50 +0,0 @@
import { Box, Container } from "@lume/ui";
import { cn } from "@lume/utils";
import { Link, Outlet } from "@tanstack/react-router";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/activity/$account")({
component: Screen,
});
function Screen() {
const { account } = Route.useParams();
return (
<Container withDrag withNavigate={false}>
<Box className="scrollbar-none shadow-none bg-black/5 dark:bg-white/5 backdrop-blur-sm flex flex-col overflow-y-auto">
<div className="h-14 shrink-0 flex w-full items-center gap-1 px-3">
<div className="inline-flex h-full w-full items-center gap-1">
<Link to="/activity/$account/texts" params={{ account }}>
{({ isActive }) => (
<div
className={cn(
"inline-flex h-7 w-max items-center justify-center gap-2 rounded-full px-3 text-sm font-medium",
isActive ? "bg-neutral-50 dark:bg-white/10" : "opacity-50",
)}
>
Notes
</div>
)}
</Link>
<Link to="/activity/$account/zaps" params={{ account }}>
{({ isActive }) => (
<div
className={cn(
"inline-flex h-7 w-max items-center justify-center gap-2 rounded-full px-3 text-sm font-medium",
isActive ? "bg-neutral-50 dark:bg-white/10" : "opacity-50",
)}
>
Zaps
</div>
)}
</Link>
</div>
</div>
<div className="px-2 flex-1 overflow-y-auto w-full h-full scrollbar-none">
<Outlet />
</div>
</Box>
</Container>
);
}
@@ -1,65 +0,0 @@
import { User } from "@/components/user";
import { Spinner } from "@lume/ui";
import { decodeZapInvoice } from "@lume/utils";
import { Await, createFileRoute, defer } from "@tanstack/react-router";
import { Suspense } from "react";
import { Virtualizer } from "virtua";
export const Route = createFileRoute("/activity/$account/zaps")({
loader: async ({ context, params }) => {
const ark = context.ark;
return { data: defer(ark.get_activities(params.account, "9735")) };
},
component: Screen,
});
function Screen() {
const { data } = Route.useLoaderData();
return (
<Virtualizer overscan={3}>
<Suspense
fallback={
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
<button
type="button"
className="inline-flex items-center gap-2 text-sm font-medium"
disabled
>
<Spinner className="size-5" />
Loading...
</button>
</div>
}
>
<Await promise={data}>
{(events) =>
events.map((event) => (
<div
key={event.id}
className="flex flex-col gap-2 mb-3 bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50"
>
<User.Provider pubkey={event.pubkey}>
<User.Root className="flex flex-col">
<div className="text-lg h-20 font-medium leading-tight flex w-full items-center justify-center">
{decodeZapInvoice(event.tags).bitcoinFormatted}
</div>
<div className="h-11 border-t border-neutral-100 dark:border-neutral-900 flex items-center gap-1 px-2">
<div className="inline-flex items-center gap-2">
<User.Avatar className="size-7 rounded-full shrink-0" />
<User.Name className="text-sm font-medium" />
</div>
<div className="text-sm text-neutral-700 dark:text-neutral-300">
zapped you
</div>
</div>
</User.Root>
</User.Provider>
</div>
))
}
</Await>
</Suspense>
</Virtualizer>
);
}
@@ -1,5 +1,4 @@
import { CheckIcon } from "@lume/icons"; import { CheckIcon } from "@lume/icons";
import type { AppRouteSearch } from "@lume/types";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { displayNsec } from "@lume/utils"; import { displayNsec } from "@lume/utils";
import * as Checkbox from "@radix-ui/react-checkbox"; import * as Checkbox from "@radix-ui/react-checkbox";
@@ -10,17 +9,12 @@ import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
export const Route = createFileRoute("/auth/new/backup")({ export const Route = createFileRoute("/auth/$account/backup")({
validateSearch: (search: Record<string, string>): AppRouteSearch => {
return {
account: search.account,
};
},
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const { account } = Route.useSearch(); const { account } = Route.useParams();
const { t } = useTranslation(); const { t } = useTranslation();
const [key, setKey] = useState(null); const [key, setKey] = useState(null);
@@ -39,8 +33,8 @@ function Screen() {
} }
return navigate({ return navigate({
to: "/auth/settings", to: "/auth/$account/settings",
search: { account }, params: { account },
}); });
} }
@@ -1,59 +1,30 @@
import { LaurelIcon } from "@lume/icons"; import { LaurelIcon } from "@lume/icons";
import type { AppRouteSearch, Settings } from "@lume/types"; import { NostrQuery } from "@lume/system";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import * as Switch from "@radix-ui/react-switch"; import * as Switch from "@radix-ui/react-switch";
import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import {
isPermissionGranted,
requestPermission,
} from "@tauri-apps/plugin-notification";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
export const Route = createFileRoute("/auth/settings")({ export const Route = createFileRoute("/auth/$account/settings")({
validateSearch: (search: Record<string, string>): AppRouteSearch => { beforeLoad: async () => {
return { const settings = await NostrQuery.getSettings();
account: search.account, return { settings };
};
},
beforeLoad: async ({ context }) => {
const permissionGranted = await isPermissionGranted(); // get notification permission
const ark = context.ark;
const settings = await ark.get_settings();
return {
settings: { ...settings, notification: permissionGranted },
};
}, },
component: Screen, component: Screen,
pendingComponent: Pending, pendingComponent: Pending,
}); });
function Screen() { function Screen() {
const navigate = useNavigate(); const { settings } = Route.useRouteContext();
const { account } = Route.useParams();
const { account } = Route.useSearch();
const { t } = useTranslation(); const { t } = useTranslation();
const { ark, settings } = Route.useRouteContext();
const [newSettings, setNewSettings] = useState<Settings>(settings); const [newSettings, setNewSettings] = useState(settings);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const toggleNofitication = async () => { const navigate = Route.useNavigate();
await requestPermission();
setNewSettings((prev) => ({
...prev,
notification: !newSettings.notification,
}));
};
const toggleAutoUpdate = () => {
setNewSettings((prev) => ({
...prev,
autoUpdate: !newSettings.autoUpdate,
}));
};
const toggleEnhancedPrivacy = () => { const toggleEnhancedPrivacy = () => {
setNewSettings((prev) => ({ setNewSettings((prev) => ({
@@ -82,7 +53,7 @@ function Screen() {
setLoading(true); setLoading(true);
// publish settings // publish settings
const eventId = await ark.set_settings(newSettings); const eventId = await NostrQuery.setSettings(newSettings);
if (eventId) { if (eventId) {
return navigate({ return navigate({
@@ -114,22 +85,6 @@ function Screen() {
</div> </div>
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
<div className="flex-1">
<h3 className="font-semibold">Push Notification</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Enabling push notifications will allow you to receive
notifications from Lume.
</p>
</div>
<Switch.Root
checked={newSettings.notification}
onClick={() => toggleNofitication()}
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/20"
>
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10"> <div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
<div className="flex-1"> <div className="flex-1">
<h3 className="font-semibold">Enhanced Privacy</h3> <h3 className="font-semibold">Enhanced Privacy</h3>
@@ -146,21 +101,6 @@ function Screen() {
<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.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> </Switch.Root>
</div> </div>
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
<div className="flex-1">
<h3 className="font-semibold">Auto Update</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Automatically download and install new version.
</p>
</div>
<Switch.Root
checked={newSettings.autoUpdate}
onClick={() => toggleAutoUpdate()}
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/20"
>
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10"> <div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
<div className="flex-1"> <div className="flex-1">
<h3 className="font-semibold">Zap</h3> <h3 className="font-semibold">Zap</h3>
@@ -200,7 +140,7 @@ function Screen() {
disabled={loading} disabled={loading}
className="mb-1 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50" className="mb-1 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
> >
{t("global.continue")} {loading ? <Spinner /> : t("global.continue")}
</button> </button>
</div> </div>
</div> </div>
@@ -1,5 +1,6 @@
import { AvatarUploader } from "@/components/avatarUploader"; import { AvatarUploader } from "@/components/avatarUploader";
import { PlusIcon } from "@lume/icons"; import { PlusIcon } from "@lume/icons";
import { NostrAccount } from "@lume/system";
import type { Metadata } from "@lume/types"; import type { Metadata } from "@lume/types";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
@@ -10,17 +11,17 @@ import { toast } from "sonner";
export const Route = createFileRoute("/auth/new/profile")({ export const Route = createFileRoute("/auth/new/profile")({
component: Screen, component: Screen,
loader: ({ context }) => { loader: async () => {
return context.ark.create_keys(); const account = await NostrAccount.createAccount();
return account;
}, },
}); });
function Screen() { function Screen() {
const keys = Route.useLoaderData(); const account = Route.useLoaderData();
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation(); const { t } = useTranslation();
const { ark } = Route.useRouteContext();
const { register, handleSubmit } = useForm(); const { register, handleSubmit } = useForm();
const [picture, setPicture] = useState<string>(""); const [picture, setPicture] = useState<string>("");
@@ -35,17 +36,17 @@ function Screen() {
try { try {
// Save account keys // Save account keys
const save = await ark.save_account(keys.nsec); const save = await NostrAccount.saveAccount(account.nsec);
// Then create profile // Then create profile
if (save) { if (save) {
const profile: Metadata = { ...data, picture }; const profile: Metadata = { ...data, picture };
const eventId = await ark.create_profile(profile); const eventId = await NostrAccount.createProfile(profile);
if (eventId) { if (eventId) {
navigate({ navigate({
to: "/auth/new/backup", to: "/auth/$account/backup",
search: { account: keys.npub }, params: { account: account.npub },
replace: true, replace: true,
}); });
} }
@@ -1,3 +1,4 @@
import { NostrAccount } from "@lume/system";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { createLazyFileRoute } from "@tanstack/react-router"; import { createLazyFileRoute } from "@tanstack/react-router";
import { useState } from "react"; import { useState } from "react";
@@ -8,7 +9,6 @@ export const Route = createLazyFileRoute("/auth/privkey")({
}); });
function Screen() { function Screen() {
const { ark } = Route.useRouteContext();
const navigate = Route.useNavigate(); const navigate = Route.useNavigate();
const [key, setKey] = useState(""); const [key, setKey] = useState("");
@@ -24,12 +24,12 @@ function Screen() {
try { try {
setLoading(true); setLoading(true);
const npub = await ark.save_account(key, password); const npub = await NostrAccount.saveAccount(key, password);
if (npub) { if (npub) {
navigate({ navigate({
to: "/auth/settings", to: "/auth/$account/settings",
search: { account: npub }, params: { account: npub },
replace: true, replace: true,
}); });
} }
@@ -1,3 +1,4 @@
import { NostrAccount } from "@lume/system";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { createLazyFileRoute } from "@tanstack/react-router"; import { createLazyFileRoute } from "@tanstack/react-router";
import { useState } from "react"; import { useState } from "react";
@@ -8,7 +9,6 @@ export const Route = createLazyFileRoute("/auth/remote")({
}); });
function Screen() { function Screen() {
const { ark } = Route.useRouteContext();
const navigate = Route.useNavigate(); const navigate = Route.useNavigate();
const [uri, setUri] = useState(""); const [uri, setUri] = useState("");
@@ -23,12 +23,12 @@ function Screen() {
try { try {
setLoading(true); setLoading(true);
const npub = await ark.nostr_connect(uri); const remoteAccount = await NostrAccount.connectRemoteAccount(uri);
if (npub) { if (remoteAccount?.length) {
navigate({ return navigate({
to: "/auth/settings", to: "/auth/$account/settings",
search: { account: npub }, params: { account: remoteAccount },
replace: true, replace: true,
}); });
} }
+14 -13
View File
@@ -1,10 +1,11 @@
import { CancelIcon, CheckCircleIcon, PlusIcon } from "@lume/icons"; import { CancelIcon, PlusIcon } from "@lume/icons";
import type { ColumnRouteSearch } from "@lume/types"; import type { ColumnRouteSearch } from "@lume/types";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { User } from "@/components/user"; import { User } from "@/components/user";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { NostrAccount, NostrQuery } from "@lume/system";
export const Route = createFileRoute("/create-group")({ export const Route = createFileRoute("/create-group")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => { validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
@@ -14,21 +15,14 @@ export const Route = createFileRoute("/create-group")({
name: search.name, name: search.name,
}; };
}, },
loader: async ({ context }) => { loader: async () => {
const ark = context.ark; const contacts = await NostrAccount.getContactList();
const contacts = await ark.get_contact_list();
return contacts; return contacts;
}, },
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const contacts = Route.useLoaderData();
const navigate = Route.useNavigate();
const { ark } = Route.useRouteContext();
const { label, redirect } = Route.useSearch();
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [npub, setNpub] = useState(""); const [npub, setNpub] = useState("");
const [users, setUsers] = useState<string[]>([ const [users, setUsers] = useState<string[]>([
@@ -36,6 +30,10 @@ function Screen() {
]); ]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const contacts = Route.useLoaderData();
const search = Route.useSearch();
const navigate = Route.useNavigate();
const toggleUser = (pubkey: string) => { const toggleUser = (pubkey: string) => {
setUsers((prev) => setUsers((prev) =>
prev.includes(pubkey) prev.includes(pubkey)
@@ -56,11 +54,14 @@ function Screen() {
try { try {
setIsLoading(true); setIsLoading(true);
const key = `lume_group_${label}`; const key = `lume_group_${search.label}`;
const createGroup = await ark.set_nstore(key, JSON.stringify(users)); const createGroup = await NostrQuery.setNstore(
key,
JSON.stringify(users),
);
if (createGroup) { if (createGroup) {
return navigate({ to: redirect }); return navigate({ to: search.redirect, search: { ...search } });
} }
} catch (e) { } catch (e) {
setIsLoading(false); setIsLoading(false);
@@ -1,7 +1,7 @@
import { NostrAccount } from "@lume/system";
import type { ColumnRouteSearch } from "@lume/types"; import type { ColumnRouteSearch } from "@lume/types";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -30,7 +30,7 @@ function Screen() {
try { try {
setIsLoading(true); setIsLoading(true);
const sync: boolean = await invoke("friend_to_friend", { npub }); const sync = await NostrAccount.f2f(npub);
if (sync) { if (sync) {
return navigate({ to: redirect }); return navigate({ to: redirect });
@@ -5,6 +5,7 @@ import { User } from "@/components/user";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { toast } from "sonner"; import { toast } from "sonner";
import type { ColumnRouteSearch } from "@lume/types"; import type { ColumnRouteSearch } from "@lume/types";
import { NostrAccount } from "@lume/system";
export const Route = createFileRoute("/create-newsfeed/users")({ export const Route = createFileRoute("/create-newsfeed/users")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => { validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
@@ -31,7 +32,6 @@ export const Route = createFileRoute("/create-newsfeed/users")({
}); });
function Screen() { function Screen() {
const { ark } = Route.useRouteContext();
const { data } = Route.useLoaderData(); const { data } = Route.useLoaderData();
const { redirect } = Route.useSearch(); const { redirect } = Route.useSearch();
@@ -52,7 +52,7 @@ function Screen() {
try { try {
setIsLoading(true); setIsLoading(true);
const newContactList = await ark.set_contact_list(follows); const newContactList = await NostrAccount.setContactList(follows);
if (newContactList) { if (newContactList) {
return navigate({ to: redirect }); return navigate({ to: redirect });
+8 -6
View File
@@ -1,4 +1,5 @@
import { CheckCircleIcon } from "@lume/icons"; import { CheckCircleIcon } from "@lume/icons";
import { NostrQuery } from "@lume/system";
import type { ColumnRouteSearch, Topic } from "@lume/types"; import type { ColumnRouteSearch, Topic } from "@lume/types";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { TOPICS } from "@lume/utils"; import { TOPICS } from "@lume/utils";
@@ -18,12 +19,10 @@ export const Route = createFileRoute("/create-topic")({
}); });
function Screen() { function Screen() {
const { label, redirect } = Route.useSearch();
const { ark } = Route.useRouteContext();
const [topics, setTopics] = useState<Topic[]>([]); const [topics, setTopics] = useState<Topic[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const search = Route.useSearch();
const navigate = Route.useNavigate(); const navigate = Route.useNavigate();
const toggleTopic = (topic: Topic) => { const toggleTopic = (topic: Topic) => {
@@ -38,11 +37,14 @@ function Screen() {
try { try {
setIsLoading(true); setIsLoading(true);
const key = `lume_topic_${label}`; const key = `lume_topic_${search.label}`;
const createTopic = await ark.set_nstore(key, JSON.stringify(topics)); const createTopic = await NostrQuery.setNstore(
key,
JSON.stringify(topics),
);
if (createTopic) { if (createTopic) {
return navigate({ to: redirect }); return navigate({ to: search.redirect, search: { ...search } });
} }
} catch (e) { } catch (e) {
setIsLoading(false); setIsLoading(false);
@@ -1,8 +1,8 @@
import { AddMediaIcon } from "@lume/icons"; import { AddMediaIcon } from "@lume/icons";
import { NostrQuery } from "@lume/system";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { cn, insertImage, isImagePath } from "@lume/utils"; import { cn, insertImage, isImagePath } from "@lume/utils";
import * as Tooltip from "@radix-ui/react-tooltip"; import * as Tooltip from "@radix-ui/react-tooltip";
import { useRouteContext } from "@tanstack/react-router";
import type { UnlistenFn } from "@tauri-apps/api/event"; import type { UnlistenFn } from "@tauri-apps/api/event";
import { getCurrent } from "@tauri-apps/api/window"; import { getCurrent } from "@tauri-apps/api/window";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -11,15 +11,14 @@ import { toast } from "sonner";
export function MediaButton({ className }: { className?: string }) { export function MediaButton({ className }: { className?: string }) {
const editor = useSlateStatic(); const editor = useSlateStatic();
const { ark } = useRouteContext({ strict: false });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const uploadToNostrBuild = async () => { const upload = async () => {
try { try {
// start loading // start loading
setLoading(true); setLoading(true);
const image = await ark.upload(); const image = await NostrQuery.upload();
insertImage(editor, image); insertImage(editor, image);
// reset loading // reset loading
@@ -44,7 +43,7 @@ export function MediaButton({ className }: { className?: string }) {
// upload all images // upload all images
for (const item of items) { for (const item of items) {
if (isImagePath(item)) { if (isImagePath(item)) {
const image = await ark.upload(item); const image = await NostrQuery.upload(item);
insertImage(editor, image); insertImage(editor, image);
} }
} }
@@ -67,7 +66,7 @@ export function MediaButton({ className }: { className?: string }) {
<Tooltip.Trigger asChild> <Tooltip.Trigger asChild>
<button <button
type="button" type="button"
onClick={() => uploadToNostrBuild()} onClick={() => upload()}
disabled={loading} disabled={loading}
className={cn("inline-flex items-center justify-center", className)} className={cn("inline-flex items-center justify-center", className)}
> >
@@ -3,20 +3,19 @@ import { cn, insertMention } from "@lume/utils";
import * as Tooltip from "@radix-ui/react-tooltip"; import * as Tooltip from "@radix-ui/react-tooltip";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useRouteContext } from "@tanstack/react-router";
import { useSlateStatic } from "slate-react"; import { useSlateStatic } from "slate-react";
import type { Contact } from "@lume/types"; import type { Contact } from "@lume/types";
import { toast } from "sonner"; import { toast } from "sonner";
import { User } from "@/components/user"; import { User } from "@/components/user";
import { NostrAccount, NostrQuery } from "@lume/system";
export function MentionButton({ className }: { className?: string }) { export function MentionButton({ className }: { className?: string }) {
const editor = useSlateStatic(); const editor = useSlateStatic();
const { ark } = useRouteContext({ strict: false });
const [contacts, setContacts] = useState<string[]>([]); const [contacts, setContacts] = useState<string[]>([]);
const select = async (user: string) => { const select = async (user: string) => {
try { try {
const metadata = await ark.get_profile(user); const metadata = await NostrQuery.getProfile(user);
const contact: Contact = { pubkey: user, profile: metadata }; const contact: Contact = { pubkey: user, profile: metadata };
insertMention(editor, contact); insertMention(editor, contact);
@@ -27,7 +26,7 @@ export function MentionButton({ className }: { className?: string }) {
useEffect(() => { useEffect(() => {
async function getContacts() { async function getContacts() {
const data = await ark.get_contact_list(); const data = await NostrAccount.getContactList();
setContacts(data); setContacts(data);
} }
@@ -61,20 +60,26 @@ export function MentionButton({ className }: { className?: string }) {
</Tooltip.Provider> </Tooltip.Provider>
<DropdownMenu.Portal> <DropdownMenu.Portal>
<DropdownMenu.Content className="flex w-[220px] h-[220px] scrollbar-none flex-col overflow-y-auto rounded-xl bg-black py-1 shadow-md shadow-neutral-500/20 focus:outline-none dark:bg-white"> <DropdownMenu.Content className="flex w-[220px] h-[220px] scrollbar-none flex-col overflow-y-auto rounded-xl bg-black py-1 shadow-md shadow-neutral-500/20 focus:outline-none dark:bg-white">
{contacts.map((contact) => ( {contacts.length < 1 ? (
<DropdownMenu.Item <div className="w-full h-full flex items-center justify-center">
key={contact} <p className="text-sm text-white">Contact List is empty.</p>
onClick={() => select(contact)} </div>
className="shrink-0 h-11 flex items-center hover:bg-white/10 px-2" ) : (
> contacts.map((contact) => (
<User.Provider pubkey={contact}> <DropdownMenu.Item
<User.Root className="flex items-center gap-2"> key={contact}
<User.Avatar className="shrink-0 size-8 rounded-full" /> onClick={() => select(contact)}
<User.Name className="text-sm font-medium text-white dark:text-black" /> className="shrink-0 h-11 flex items-center hover:bg-white/10 px-2"
</User.Root> >
</User.Provider> <User.Provider pubkey={contact}>
</DropdownMenu.Item> <User.Root className="flex items-center gap-2">
))} <User.Avatar className="shrink-0 size-8 rounded-full" />
<User.Name className="text-sm font-medium text-white dark:text-black" />
</User.Root>
</User.Provider>
</DropdownMenu.Item>
))
)}
<DropdownMenu.Arrow className="fill-neutral-950 dark:fill-neutral-50" /> <DropdownMenu.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Portal> </DropdownMenu.Portal>
@@ -3,13 +3,13 @@ import { cn } from "@lume/utils";
import * as Tooltip from "@radix-ui/react-tooltip"; import * as Tooltip from "@radix-ui/react-tooltip";
import type { Dispatch, SetStateAction } from "react"; import type { Dispatch, SetStateAction } from "react";
export function NsfwToggle({ export function WarningToggle({
nsfw, warning,
setNsfw, setWarning,
className, className,
}: { }: {
nsfw: boolean; warning: boolean;
setNsfw: Dispatch<SetStateAction<boolean>>; setWarning: Dispatch<SetStateAction<boolean>>;
className?: string; className?: string;
}) { }) {
return ( return (
@@ -18,11 +18,11 @@ export function NsfwToggle({
<Tooltip.Trigger asChild> <Tooltip.Trigger asChild>
<button <button
type="button" type="button"
onClick={() => setNsfw((prev) => !prev)} onClick={() => setWarning((prev) => !prev)}
className={cn( className={cn(
"inline-flex items-center justify-center", "inline-flex items-center justify-center",
className, className,
nsfw ? "bg-blue-500 text-white" : "", warning ? "bg-blue-500 text-white" : "",
)} )}
> >
<NsfwIcon className="size-4" /> <NsfwIcon className="size-4" />
+45 -66
View File
@@ -1,4 +1,4 @@
import { ComposeFilledIcon, TrashIcon } from "@lume/icons"; import { ComposeFilledIcon } from "@lume/icons";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { import {
cn, cn,
@@ -8,7 +8,6 @@ import {
sendNativeNotification, sendNativeNotification,
} from "@lume/utils"; } from "@lume/utils";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { nip19 } from "nostr-tools";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { type Descendant, Node, Transforms, createEditor } from "slate"; import { type Descendant, Node, Transforms, createEditor } from "slate";
@@ -22,16 +21,22 @@ import {
withReact, withReact,
} from "slate-react"; } from "slate-react";
import { MediaButton } from "./-components/media"; import { MediaButton } from "./-components/media";
import { NsfwToggle } from "./-components/nsfw";
import { MentionButton } from "./-components/mention"; import { MentionButton } from "./-components/mention";
import { LumeEvent } from "@lume/system";
import { WarningToggle } from "./-components/warning";
import { MentionNote } from "@/components/note/mentions/note"; import { MentionNote } from "@/components/note/mentions/note";
import { toast } from "sonner";
type EditorSearch = { type EditorSearch = {
reply_to: string; reply_to: string;
quote: boolean; quote: boolean;
}; };
type EditorElement = {
type: string;
children: Descendant[];
eventId?: string;
};
export const Route = createFileRoute("/editor/")({ export const Route = createFileRoute("/editor/")({
validateSearch: (search: Record<string, string>): EditorSearch => { validateSearch: (search: Record<string, string>): EditorSearch => {
return { return {
@@ -39,43 +44,23 @@ export const Route = createFileRoute("/editor/")({
quote: search.quote === "true" || false, quote: search.quote === "true" || false,
}; };
}, },
beforeLoad: async ({ search }) => {
return {
initialValue: search.quote
? [
{
type: "paragraph",
children: [{ text: "" }],
},
{
type: "event",
eventId: `nostr:${nip19.noteEncode(search.reply_to)}`,
children: [{ text: "" }],
},
{
type: "paragraph",
children: [{ text: "" }],
},
]
: [
{
type: "paragraph",
children: [{ text: "" }],
},
],
};
},
component: Screen, component: Screen,
}); });
const initialValue: EditorElement[] = [
{
type: "paragraph",
children: [{ text: "" }],
},
];
function Screen() { function Screen() {
const { reply_to, quote } = Route.useSearch(); const search = Route.useSearch();
const { ark, initialValue } = Route.useRouteContext();
const [t] = useTranslation(); const [t] = useTranslation();
const [editorValue, setEditorValue] = useState(initialValue); const [editorValue, setEditorValue] = useState(initialValue);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [nsfw, setNsfw] = useState(false); const [warning, setWarning] = useState(false);
const [editor] = useState(() => const [editor] = useState(() =>
withMentions(withNostrEvent(withImages(withReact(createEditor())))), withMentions(withNostrEvent(withImages(withReact(createEditor())))),
); );
@@ -116,7 +101,12 @@ function Screen() {
setLoading(true); setLoading(true);
const content = serialize(editor.children); const content = serialize(editor.children);
const eventId = await ark.publish(content, reply_to, quote); const eventId = await LumeEvent.publish(
content,
search.reply_to,
search.quote,
warning,
);
if (eventId) { if (eventId) {
await sendNativeNotification( await sendNativeNotification(
@@ -137,15 +127,15 @@ function Screen() {
}; };
return ( return (
<div className="w-full h-full"> <div className="w-full h-full flex flex-col">
<Slate editor={editor} initialValue={editorValue}> <Slate editor={editor} initialValue={editorValue}>
<div <div
data-tauri-drag-region data-tauri-drag-region
className="flex h-14 w-full shrink-0 items-center justify-end gap-2 px-2 border-b border-black/10 dark:border-white/10" className="shrink-0 flex h-14 w-full items-center justify-end gap-2 px-2 border-b border-black/10 dark:border-white/10"
> >
<NsfwToggle <WarningToggle
nsfw={nsfw} warning={warning}
setNsfw={setNsfw} setWarning={setWarning}
className="size-8 rounded-full bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20" className="size-8 rounded-full bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
/> />
<MentionButton className="size-8 rounded-full bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20" /> <MentionButton className="size-8 rounded-full bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20" />
@@ -163,13 +153,13 @@ function Screen() {
{t("global.post")} {t("global.post")}
</button> </button>
</div> </div>
<div className="flex h-full w-full flex-1 flex-col"> <div className="flex-1 overflow-y-auto flex flex-col">
{reply_to && !quote ? ( {search.reply_to ? (
<div className="px-4 py-2"> <div className="px-4 py-2">
<MentionNote eventId={reply_to} /> <MentionNote eventId={search.reply_to} />
</div> </div>
) : null} ) : null}
<div className="overflow-y-auto p-4"> <div className="overflow-y-auto scrollbar-none p-4">
<Editable <Editable
key={JSON.stringify(editorValue)} key={JSON.stringify(editorValue)}
autoFocus={true} autoFocus={true}
@@ -178,7 +168,7 @@ function Screen() {
spellCheck={false} spellCheck={false}
renderElement={(props) => <Element {...props} />} renderElement={(props) => <Element {...props} />}
placeholder={ placeholder={
reply_to ? "Type your reply..." : t("editor.placeholder") search.reply_to ? "Type your reply..." : t("editor.placeholder")
} }
className="focus:outline-none" className="focus:outline-none"
/> />
@@ -252,35 +242,24 @@ const withImages = (editor: ReactEditor) => {
return editor; return editor;
}; };
const Image = ({ attributes, children, element }) => { const Image = ({ attributes, element, children }) => {
const editor = useSlateStatic(); const editor = useSlateStatic();
const path = ReactEditor.findPath(editor as ReactEditor, element);
const selected = useSelected(); const selected = useSelected();
const focused = useFocused(); const focused = useFocused();
const path = ReactEditor.findPath(editor as ReactEditor, element);
return ( return (
<div {...attributes}> <div {...attributes}>
{children} {children}
<div contentEditable={false} className="relative my-2"> <img
<img src={element.url}
src={element.url} alt={element.url}
alt={element.url} className={cn(
className={cn( "my-2 h-auto w-1/2 rounded-lg object-cover ring-2 outline outline-1 -outline-offset-1 outline-black/15",
"h-auto w-full rounded-lg border border-neutral-100 object-cover ring-2 dark:border-neutral-900", selected && focused ? "ring-blue-500" : "ring-transparent",
selected && focused ? "ring-blue-500" : "ring-transparent", )}
)} onClick={() => Transforms.removeNodes(editor, { at: path })}
contentEditable={false} />
/>
<button
type="button"
contentEditable={false}
onClick={() => Transforms.removeNodes(editor, { at: path })}
className="absolute right-2 top-2 inline-flex size-8 items-center justify-center rounded-lg bg-red-500 text-white hover:bg-red-600"
>
<TrashIcon className="size-4" />
</button>
</div>
</div> </div>
); );
}; };
+5 -7
View File
@@ -1,5 +1,5 @@
import { useEvent } from "@lume/ark"; import { NostrQuery, useEvent } from "@lume/system";
import type { Event } from "@lume/types"; import type { NostrEvent } from "@lume/types";
import { Box, Container, Spinner } from "@lume/ui"; import { Box, Container, Spinner } from "@lume/ui";
import { Note } from "@/components/note"; import { Note } from "@/components/note";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
@@ -7,10 +7,8 @@ import { WindowVirtualizer } from "virtua";
import { ReplyList } from "./-components/replyList"; import { ReplyList } from "./-components/replyList";
export const Route = createFileRoute("/events/$eventId")({ export const Route = createFileRoute("/events/$eventId")({
beforeLoad: async ({ context }) => { beforeLoad: async () => {
const ark = context.ark; const settings = await NostrQuery.getSettings();
const settings = await ark.get_settings();
return { settings }; return { settings };
}, },
component: Screen, component: Screen,
@@ -52,7 +50,7 @@ function Screen() {
); );
} }
function MainNote({ data }: { data: Event }) { function MainNote({ data }: { data: NostrEvent }) {
return ( return (
<Note.Provider event={data}> <Note.Provider event={data}>
<Note.Root> <Note.Root>
@@ -1,10 +1,10 @@
import type { EventWithReplies } from "@lume/types"; import type { EventWithReplies } from "@lume/types";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Reply } from "./reply"; import { Reply } from "./reply";
import { LumeEvent } from "@lume/system";
export function ReplyList({ export function ReplyList({
eventId, eventId,
@@ -13,13 +13,12 @@ export function ReplyList({
eventId: string; eventId: string;
className?: string; className?: string;
}) { }) {
const { ark } = useRouteContext({ strict: false });
const [t] = useTranslation(); const [t] = useTranslation();
const [data, setData] = useState<null | EventWithReplies[]>(null); const [data, setData] = useState<null | EventWithReplies[]>(null);
useEffect(() => { useEffect(() => {
async function getReplies() { async function getReplies() {
const events = await ark.get_event_thread(eventId); const events = await LumeEvent.getReplies(eventId);
setData(events); setData(events);
} }
getReplies(); getReplies();
@@ -1,7 +1,12 @@
import type { Event } from "@lume/types"; import type { NostrEvent } from "@lume/types";
import { Note } from "@/components/note"; import { Note } from "@/components/note";
export function SubReply({ event }: { event: Event; rootEventId?: string }) { export function SubReply({
event,
}: {
event: NostrEvent;
rootEventId?: string;
}) {
return ( return (
<Note.Provider event={event}> <Note.Provider event={event}>
<Note.Root> <Note.Root>
+6 -8
View File
@@ -3,7 +3,8 @@ import { Quote } from "@/components/quote";
import { RepostNote } from "@/components/repost"; import { RepostNote } from "@/components/repost";
import { TextNote } from "@/components/text"; import { TextNote } from "@/components/text";
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons"; import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
import { type ColumnRouteSearch, type Event, Kind } from "@lume/types"; import { NostrQuery } from "@lume/system";
import { type ColumnRouteSearch, type NostrEvent, Kind } from "@lume/types";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { useInfiniteQuery } from "@tanstack/react-query"; import { useInfiniteQuery } from "@tanstack/react-query";
import { Link, createFileRoute } from "@tanstack/react-router"; import { Link, createFileRoute } from "@tanstack/react-router";
@@ -17,10 +18,8 @@ export const Route = createFileRoute("/global")({
name: search.name, name: search.name,
}; };
}, },
beforeLoad: async ({ context }) => { beforeLoad: async () => {
const ark = context.ark; const settings = await NostrQuery.getSettings();
const settings = await ark.get_settings();
return { settings }; return { settings };
}, },
component: Screen, component: Screen,
@@ -28,7 +27,6 @@ export const Route = createFileRoute("/global")({
export function Screen() { export function Screen() {
const { label, account } = Route.useSearch(); const { label, account } = Route.useSearch();
const { ark } = Route.useRouteContext();
const { const {
data, data,
isLoading, isLoading,
@@ -40,7 +38,7 @@ export function Screen() {
queryKey: [label, account], queryKey: [label, account],
initialPageParam: 0, initialPageParam: 0,
queryFn: async ({ pageParam }: { pageParam: number }) => { queryFn: async ({ pageParam }: { pageParam: number }) => {
const events = await ark.get_global_events(20, pageParam); const events = await NostrQuery.getGlobalEvents(pageParam);
return events; return events;
}, },
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1, getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
@@ -48,7 +46,7 @@ export function Screen() {
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}); });
const renderItem = (event: Event) => { const renderItem = (event: NostrEvent) => {
if (!event) return; if (!event) return;
switch (event.kind) { switch (event.kind) {
case Kind.Repost: case Kind.Repost:
+8 -8
View File
@@ -3,7 +3,8 @@ import { Quote } from "@/components/quote";
import { RepostNote } from "@/components/repost"; import { RepostNote } from "@/components/repost";
import { TextNote } from "@/components/text"; import { TextNote } from "@/components/text";
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons"; import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
import { type ColumnRouteSearch, type Event, Kind } from "@lume/types"; import { NostrAccount, NostrQuery } from "@lume/system";
import { type ColumnRouteSearch, type NostrEvent, Kind } from "@lume/types";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { useInfiniteQuery } from "@tanstack/react-query"; import { useInfiniteQuery } from "@tanstack/react-query";
import { Link, createFileRoute, redirect } from "@tanstack/react-router"; import { Link, createFileRoute, redirect } from "@tanstack/react-router";
@@ -17,11 +18,10 @@ export const Route = createFileRoute("/group")({
name: search.name, name: search.name,
}; };
}, },
beforeLoad: async ({ search, context }) => { beforeLoad: async ({ search }) => {
const ark = context.ark;
const key = `lume_group_${search.label}`; const key = `lume_group_${search.label}`;
const groups = (await ark.get_nstore(key)) as string[]; const groups = (await NostrQuery.getNstore(key)) as string[];
const settings = await ark.get_settings(); const settings = await NostrQuery.getSettings();
if (!groups?.length) { if (!groups?.length) {
throw redirect({ throw redirect({
@@ -43,7 +43,7 @@ export const Route = createFileRoute("/group")({
export function Screen() { export function Screen() {
const { label, account } = Route.useSearch(); const { label, account } = Route.useSearch();
const { ark, groups } = Route.useRouteContext(); const { groups } = Route.useRouteContext();
const { const {
data, data,
isLoading, isLoading,
@@ -55,7 +55,7 @@ export function Screen() {
queryKey: [label, account], queryKey: [label, account],
initialPageParam: 0, initialPageParam: 0,
queryFn: async ({ pageParam }: { pageParam: number }) => { queryFn: async ({ pageParam }: { pageParam: number }) => {
const events = await ark.get_local_events(groups, 20, pageParam); const events = await NostrQuery.getLocalEvents(groups, pageParam);
return events; return events;
}, },
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1, getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
@@ -64,7 +64,7 @@ export function Screen() {
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}); });
const renderItem = (event: Event) => { const renderItem = (event: NostrEvent) => {
if (!event) return; if (!event) return;
switch (event.kind) { switch (event.kind) {
case Kind.Repost: case Kind.Repost:
+27 -44
View File
@@ -4,28 +4,22 @@ import { User } from "@/components/user";
import { checkForAppUpdates } from "@lume/utils"; import { checkForAppUpdates } from "@lume/utils";
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { createFileRoute, redirect } from "@tanstack/react-router"; import { createFileRoute, redirect } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { NostrAccount } from "@lume/system";
export const Route = createFileRoute("/")({ export const Route = createFileRoute("/")({
beforeLoad: async ({ context }) => { beforeLoad: async () => {
// check for app updates await checkForAppUpdates(true); // check for app updates
await checkForAppUpdates(true); const accounts = await NostrAccount.getAccounts();
const ark = context.ark; if (accounts.length < 1) {
const accounts = await ark.get_accounts();
if (!accounts.length) {
throw redirect({ throw redirect({
to: "/landing/", to: "/landing",
replace: true, replace: true,
}); });
} }
// Run notification service
await invoke("run_notification", { accounts });
return { accounts }; return { accounts };
}, },
component: Screen, component: Screen,
@@ -33,7 +27,7 @@ export const Route = createFileRoute("/")({
function Screen() { function Screen() {
const navigate = Route.useNavigate(); const navigate = Route.useNavigate();
const { ark, accounts } = Route.useRouteContext(); const context = Route.useRouteContext();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -41,8 +35,9 @@ function Screen() {
try { try {
setLoading(true); setLoading(true);
const loadAccount = await ark.load_account(npub); const status = await NostrAccount.loadAccount(npub);
if (loadAccount) {
if (status) {
return navigate({ return navigate({
to: "/$account/home", to: "/$account/home",
params: { account: npub }, params: { account: npub },
@@ -62,62 +57,50 @@ function Screen() {
}); });
return ( return (
<div className="relative flex h-full w-full items-center justify-center"> <div
data-tauri-drag-region
className="relative flex h-full w-full items-center justify-center"
>
<div className="relative z-20 flex flex-col items-center gap-16"> <div className="relative z-20 flex flex-col items-center gap-16">
<div className="text-center text-white"> <div className="text-center">
<h2 className="mb-1 text-2xl">{currentDate}</h2> <h2 className="text-xl text-neutral-700 dark:text-neutral-300">
{currentDate}
</h2>
<h2 className="text-2xl font-semibold">Welcome back!</h2> <h2 className="text-2xl font-semibold">Welcome back!</h2>
</div> </div>
<div className="flex flex-wrap px-3 items-center justify-center gap-6"> <div className="flex flex-wrap px-3 items-center justify-center gap-6">
{loading ? ( {loading ? (
<div className="inline-flex size-6 items-center justify-center"> <div className="inline-flex size-6 items-center justify-center">
<Spinner className="size-6 text-white" /> <Spinner className="size-6" />
</div> </div>
) : ( ) : (
<> <>
{accounts.map((account) => ( {context.accounts.map((account) => (
<button <button
type="button"
key={account} key={account}
type="button"
onClick={() => select(account)} onClick={() => select(account)}
> >
<User.Provider pubkey={account}> <User.Provider pubkey={account}>
<User.Root className="flex h-36 w-32 flex-col items-center justify-center gap-4 rounded-2xl p-2 hover:bg-white/10 dark:hover:bg-black/10"> <User.Root className="flex h-36 w-32 flex-col items-center justify-center gap-3 rounded-2xl p-2 hover:bg-black/10 dark:hover:bg-white/10">
<User.Avatar className="size-20 rounded-full object-cover" /> <User.Avatar className="size-20 rounded-full object-cover" />
<User.Name className="max-w-[5rem] truncate text-lg font-medium leading-tight text-white" /> <User.Name className="max-w-[6rem] truncate font-medium leading-tight" />
</User.Root> </User.Root>
</User.Provider> </User.Provider>
</button> </button>
))} ))}
<Link to="/landing/"> <Link to="/landing">
<div className="flex h-36 w-32 flex-col items-center justify-center gap-4 rounded-2xl p-2 text-white hover:bg-white/10 dark:hover:bg-black/10"> <div className="flex h-36 w-32 flex-col items-center justify-center gap-3 rounded-2xl p-2 hover:bg-black/10 dark:hover:bg-white/10">
<div className="flex size-20 items-center justify-center rounded-full bg-white/20 dark:bg-black/20"> <div className="flex size-20 items-center justify-center rounded-full bg-black/5 dark:bg-white/5">
<PlusIcon className="size-8" /> <PlusIcon className="size-8" />
</div> </div>
<p className="text-lg font-medium leading-tight">Add</p> <p className="font-medium leading-tight">Add</p>
</div> </div>
</Link> </Link>
</> </>
)} )}
</div> </div>
</div> </div>
<div className="absolute z-10 h-full w-full bg-white/10 backdrop-blur-lg dark:bg-black/10" />
<div className="absolute inset-0 h-full w-full">
<img
src="/lock-screen.jpg"
srcSet="/lock-screen@2x.jpg 2x"
alt="Lock Screen Background"
className="h-full w-full object-cover"
/>
<a
href="https://njump.me/nprofile1qqs9tuz9jpn57djg7nxunhyvuvk69g5zqaxdpvpqt9hwqv7395u9rpg6zq5uw"
target="_blank"
className="absolute bottom-3 right-3 z-50 rounded-md bg-white/20 px-2 py-1 text-xs font-medium text-white dark:bg-black/20"
rel="noreferrer"
>
Design by NoGood
</a>
</div>
</div> </div>
); );
} }
+62
View File
@@ -0,0 +1,62 @@
import { KeyIcon, RemoteIcon } from "@lume/icons";
import { Link, createLazyFileRoute } from "@tanstack/react-router";
export const Route = createLazyFileRoute("/landing")({
component: Screen,
});
function Screen() {
return (
<div
data-tauri-drag-region
className="flex flex-col justify-center items-center h-screen w-screen"
>
<div className="mx-auto max-w-xs lg:max-w-md w-full">
<div className="flex w-full flex-col gap-2 bg-white rounded-xl shadow-primary backdrop-blur-lg dark:bg-white/20 dark:ring-1 ring-neutral-800/50 px-2">
<div className="h-20 flex items-center border-b border-neutral-100 dark:border-white/5">
<Link
to="/auth/new/profile"
className="h-14 w-full flex items-center justify-center gap-2 hover:bg-neutral-100 dark:hover:bg-white/10 rounded-lg px-2"
>
<div className="size-9 shrink-0 rounded-full inline-flex items-center justify-center">
<img
src="/icon.jpeg"
alt="App Icon"
className="size-9 object-cover rounded-full"
/>
</div>
<div className="flex-1 inline-flex flex-col">
<span className="leading-tight font-semibold">
Create new account
</span>
<span className="leading-tight text-sm text-neutral-500">
Use everywhere
</span>
</div>
</Link>
</div>
<div className="flex flex-col gap-1 pb-2.5">
<Link
to="/auth/privkey"
className="inline-flex h-11 w-full items-center gap-2 rounded-lg px-2 hover:bg-neutral-100 dark:hover:bg-white/10"
>
<div className="size-9 inline-flex items-center justify-center">
<KeyIcon className="size-5 text-neutral-600 dark:text-neutral-400" />
</div>
Login with Private Key
</Link>
<Link
to="/auth/remote"
className="inline-flex h-11 w-full items-center gap-2 rounded-lg px-2 hover:bg-neutral-100 dark:hover:bg-white/10"
>
<div className="size-9 inline-flex items-center justify-center">
<RemoteIcon className="size-5 text-neutral-600 dark:text-neutral-400" />
</div>
Nostr Connect
</Link>
</div>
</div>
</div>
</div>
);
}
@@ -1,85 +0,0 @@
import { KeyIcon, RemoteIcon } from "@lume/icons";
import { Link, createFileRoute } from "@tanstack/react-router";
import { useTranslation } from "react-i18next";
export const Route = createFileRoute("/landing/")({
component: Screen,
});
function Screen() {
const { t } = useTranslation();
return (
<div className="relative flex h-screen w-screen bg-black">
<div
data-tauri-drag-region
className="absolute left-0 top-0 z-50 h-16 w-full"
/>
<div className="z-20 flex h-full w-full flex-col items-center justify-between">
<div />
<div className="mx-auto flex w-full max-w-4xl flex-col items-center gap-10">
<div className="flex flex-col items-center text-center">
<img
src="/heading-en.png"
srcSet="/heading-en@2x.png 2x"
alt="lume"
className="xl:w-2/3"
/>
<p className="mt-4 whitespace-pre-line text-lg font-medium leading-snug text-white/70">
{t("welcome.title")}
</p>
</div>
<div className="mx-auto flex w-full max-w-sm flex-col gap-4">
<Link
to="/auth/new/profile"
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-white font-medium text-blue-500 backdrop-blur-lg hover:bg-white/90"
>
{t("welcome.signup")}
</Link>
<div className="flex items-center gap-2">
<div className="h-px flex-1 bg-white/20" />
<div className="text-white/70">{t("login.or")}</div>
<div className="h-px flex-1 bg-white/20" />
</div>
<div className="flex flex-col gap-2">
<Link
to="/auth/remote"
className="group inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-white/20 px-3 font-medium text-white backdrop-blur-md hover:bg-white/40"
>
<RemoteIcon className="size-5 text-neutral-600 dark:text-neutral-400 group-hover:text-neutral-400 dark:group-hover:text-neutral-600" />
Nostr Connect
<div className="size-5" />
</Link>
<Link
to="/auth/privkey"
className="group inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-white/20 px-3 font-medium text-white backdrop-blur-md hover:bg-white/40"
>
<KeyIcon className="size-5 text-neutral-600 dark:text-neutral-400 group-hover:text-neutral-400 dark:group-hover:text-neutral-600" />
Private Key
<div className="size-5" />
</Link>
</div>
</div>
</div>
<div className="flex h-11 items-center justify-center" />
</div>
<div className="absolute z-10 h-full w-full bg-black/5 backdrop-blur-sm" />
<div className="absolute inset-0 h-full w-full">
<img
src="/lock-screen.jpg"
srcSet="/lock-screen@2x.jpg 2x"
alt="Lock Screen Background"
className="h-full w-full object-cover"
/>
<a
href="https://njump.me/nprofile1qqs9tuz9jpn57djg7nxunhyvuvk69g5zqaxdpvpqt9hwqv7395u9rpg6zq5uw"
target="_blank"
className="absolute bottom-3 right-3 z-50 rounded-md bg-white/20 px-2 py-1 text-xs font-medium text-white backdrop-blur-md dark:bg-black/20"
rel="noreferrer"
>
Design by NoGood
</a>
</div>
</div>
);
}
+18 -11
View File
@@ -2,11 +2,12 @@ import { Conversation } from "@/components/conversation";
import { Quote } from "@/components/quote"; import { Quote } from "@/components/quote";
import { RepostNote } from "@/components/repost"; import { RepostNote } from "@/components/repost";
import { TextNote } from "@/components/text"; import { TextNote } from "@/components/text";
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons"; import { ArrowRightCircleIcon } from "@lume/icons";
import { type ColumnRouteSearch, type Event, Kind } from "@lume/types"; import { NostrAccount, NostrQuery } from "@lume/system";
import { type ColumnRouteSearch, type NostrEvent, Kind } from "@lume/types";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { useInfiniteQuery } from "@tanstack/react-query"; import { useInfiniteQuery } from "@tanstack/react-query";
import { Link, redirect } from "@tanstack/react-router"; import { redirect } from "@tanstack/react-router";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { Virtualizer } from "virtua"; import { Virtualizer } from "virtua";
@@ -18,10 +19,9 @@ export const Route = createFileRoute("/newsfeed")({
name: search.name, name: search.name,
}; };
}, },
beforeLoad: async ({ search, context }) => { beforeLoad: async ({ search }) => {
const ark = context.ark; const settings = await NostrQuery.getSettings();
const settings = await ark.get_settings(); const contacts = await NostrAccount.getContactList();
const contacts = await ark.get_contact_list();
if (!contacts.length) { if (!contacts.length) {
throw redirect({ throw redirect({
@@ -40,7 +40,7 @@ export const Route = createFileRoute("/newsfeed")({
export function Screen() { export function Screen() {
const { label, account } = Route.useSearch(); const { label, account } = Route.useSearch();
const { ark, contacts } = Route.useRouteContext(); const { contacts, settings } = Route.useRouteContext();
const { const {
data, data,
isLoading, isLoading,
@@ -52,7 +52,7 @@ export function Screen() {
queryKey: [label, account], queryKey: [label, account],
initialPageParam: 0, initialPageParam: 0,
queryFn: async ({ pageParam }: { pageParam: number }) => { queryFn: async ({ pageParam }: { pageParam: number }) => {
const events = await ark.get_local_events(contacts, 20, pageParam); const events = await NostrQuery.getLocalEvents(contacts, pageParam);
return events; return events;
}, },
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1, getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
@@ -60,7 +60,7 @@ export function Screen() {
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}); });
const renderItem = (event: Event) => { const renderItem = (event: NostrEvent) => {
if (!event) return; if (!event) return;
switch (event.kind) { switch (event.kind) {
case Kind.Repost: case Kind.Repost:
@@ -72,7 +72,14 @@ export function Screen() {
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0; const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0;
if (isConversation) { if (isConversation) {
return <Conversation key={event.id} event={event} className="mb-3" />; return (
<Conversation
key={event.id}
className="mb-3"
event={event}
gossip={settings?.gossip}
/>
);
} }
if (isQuote) { if (isQuote) {
+3 -4
View File
@@ -1,4 +1,5 @@
import { ZapIcon } from "@lume/icons"; import { ZapIcon } from "@lume/icons";
import { NostrAccount } from "@lume/system";
import { Container } from "@lume/ui"; import { Container } from "@lume/ui";
import { createLazyFileRoute } from "@tanstack/react-router"; import { createLazyFileRoute } from "@tanstack/react-router";
import { useState } from "react"; import { useState } from "react";
@@ -8,18 +9,16 @@ export const Route = createLazyFileRoute("/nwc")({
}); });
function Screen() { function Screen() {
const { ark } = Route.useRouteContext();
const [uri, setUri] = useState(""); const [uri, setUri] = useState("");
const [isDone, setIsDone] = useState(false); const [isDone, setIsDone] = useState(false);
const save = async () => { const save = async () => {
const nwc = await ark.set_nwc(uri); const nwc = await NostrAccount.setWallet(uri);
setIsDone(nwc); setIsDone(nwc);
}; };
return ( return (
<Container withDrag withNavigate={false}> <Container withDrag>
<div className="h-full w-full flex-1 px-5"> <div className="h-full w-full flex-1 px-5">
{!isDone ? ( {!isDone ? (
<> <>
+8
View File
@@ -34,6 +34,8 @@ function Screen() {
className="h-auto w-full aspect-square rounded-lg shadow-md transform" className="h-auto w-full aspect-square rounded-lg shadow-md transform"
controls controls
muted muted
preload="none"
poster="/poster_1.jpeg"
> >
<source <source
src="https://video.nostr.build/692f71e2be47ecfc29edcbdaa198cc5979bfb9c900f05d78682895dd546d8d4f.mp4" src="https://video.nostr.build/692f71e2be47ecfc29edcbdaa198cc5979bfb9c900f05d78682895dd546d8d4f.mp4"
@@ -55,6 +57,8 @@ function Screen() {
className="h-auto w-full aspect-square rounded-lg shadow-md transform" className="h-auto w-full aspect-square rounded-lg shadow-md transform"
controls controls
muted muted
preload="none"
poster="/poster_2.jpeg"
> >
<source <source
src="https://video.nostr.build/d33962520506d86acfb4b55a7b265821e10ae637f5ec830a173b7e6092b16ec8.mp4" src="https://video.nostr.build/d33962520506d86acfb4b55a7b265821e10ae637f5ec830a173b7e6092b16ec8.mp4"
@@ -76,6 +80,8 @@ function Screen() {
className="h-auto w-full aspect-square rounded-lg shadow-md transform" className="h-auto w-full aspect-square rounded-lg shadow-md transform"
controls controls
muted muted
preload="none"
poster="/poster_3.jpeg"
> >
<source <source
src="https://video.nostr.build/927abbfde2097e470ac751181b1db456b7e4b9149550408efff1a966a7ffb9a8.mp4" src="https://video.nostr.build/927abbfde2097e470ac751181b1db456b7e4b9149550408efff1a966a7ffb9a8.mp4"
@@ -97,6 +103,8 @@ function Screen() {
className="h-auto w-full rounded-lg shadow-md transform" className="h-auto w-full rounded-lg shadow-md transform"
controls controls
muted muted
preload="none"
poster="/poster_4.jpeg"
> >
<source <source
src="https://video.nostr.build/513de4824b6abaf7e9698c1dad2f68096574356848c0c200bc8cb8074df29410.mp4" src="https://video.nostr.build/513de4824b6abaf7e9698c1dad2f68096574356848c0c200bc8cb8074df29410.mp4"
+314
View File
@@ -0,0 +1,314 @@
import { Note } from "@/components/note";
import { User } from "@/components/user";
import { LumeWindow, NostrQuery, useEvent } from "@lume/system";
import { Kind, NostrEvent } from "@lume/types";
import { createFileRoute } from "@tanstack/react-router";
import { getCurrent } from "@tauri-apps/api/window";
import { useEffect, useMemo, useState } from "react";
import * as Tabs from "@radix-ui/react-tabs";
import { InfoIcon, RepostIcon, SettingsIcon } from "@lume/icons";
import { decodeZapInvoice, formatCreatedAt } from "@lume/utils";
interface EmitAccount {
account: string;
}
export const Route = createFileRoute("/panel")({
component: Screen,
});
function Screen() {
const [account, setAccount] = useState<string>(null);
const [events, setEvents] = useState<NostrEvent[]>([]);
const texts = useMemo(
() => events.filter((ev) => ev.kind === Kind.Text),
[events],
);
const zaps = useMemo(() => {
const groups = new Map<string, NostrEvent[]>();
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, NostrEvent[]>();
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]);
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: NostrEvent = JSON.parse(data.payload as string);
setEvents((prev) => [event, ...prev]);
});
return () => {
unlistenLoad.then((f) => f());
unlistenNewEvent.then((f) => f());
};
}, []);
if (!account) {
return (
<div className="w-full h-full flex items-center justify-center text-sm">
Please log in.
</div>
);
}
return (
<div className="w-full h-full flex flex-col">
<div className="h-11 shrink-0 flex items-center justify-between border-b border-black/5 px-4">
<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="size-7 rounded-full" />
</User.Root>
</User.Provider>
<button
type="button"
onClick={() => LumeWindow.openSettings()}
className="size-7 inline-flex items-center justify-center bg-black/5 dark:bg-white/5 rounded-full"
>
<SettingsIcon className="size-4" />
</button>
</div>
</div>
<Tabs.Root
defaultValue="replies"
className="flex-1 overflow-y-auto overflow-x-hidden 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 data-[state=active]:border-black/30 dark:data-[state=active] 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 data-[state=active]:border-black/30 dark:data-[state=active] 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 data-[state=active]:border-black/30 dark:data-[state=active] data-[state=inactive]:opacity-50"
value="zaps"
>
Zaps
</Tabs.Trigger>
</Tabs.List>
<div className="p-2">
<Tabs.Content value="replies" className="flex flex-col gap-2">
{texts.map((event) => (
<TextNote event={event} />
))}
</Tabs.Content>
<Tabs.Content value="reactions" className="flex flex-col gap-2">
{[...reactions.entries()].map(([root, events]) => (
<div
key={root}
className="shrink-0 flex flex-col gap-1 rounded-lg p-2 backdrop-blur-md bg-black/10 dark:bg-white/10"
>
<div className="min-w-0 flex-1 flex flex-col gap-2">
<div className="pb-2 border-b border-black/5 dark:border-white/5 flex items-center gap-2">
<RootNote id={root} />
</div>
<div className="flex flex-wrap items-center gap-3">
{events.map((event) => (
<User.Provider 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 size-7 rounded-full" />
<div className="flex-1 size-7 rounded-full inline-flex items-center justify-center text-xs truncate">
{event.kind === Kind.Reaction ? (
event.content === "+" ? (
"👍"
) : (
event.content
)
) : (
<RepostIcon className="size-4 dark:text-teal-600 text-teal-400" />
)}
</div>
</User.Root>
</User.Provider>
))}
</div>
</div>
</div>
))}
</Tabs.Content>
<Tabs.Content value="zaps" className="flex flex-col gap-2">
{[...zaps.entries()].map(([root, events]) => (
<div
key={root}
className="shrink-0 flex flex-col gap-1 rounded-lg p-2 backdrop-blur-md bg-black/10 dark:bg-white/10"
>
<div className="min-w-0 flex-1 flex flex-col gap-2">
<div className="pb-2 border-b border-black/5 dark:border-white/5 flex items-center gap-2">
<RootNote id={root} />
</div>
<div className="flex flex-wrap items-center gap-3">
{events.map((event) => (
<User.Provider
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 size-7 rounded-full" />
<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>
))}
</Tabs.Content>
</div>
</Tabs.Root>
</div>
);
}
function RootNote({ id }: { id: string }) {
const { isLoading, isError, data } = useEvent(id);
if (isLoading) {
return (
<div className="pb-2 mb-2 flex items-center">
<div className="size-8 shrink-0 rounded-full bg-black/20 dark:bg-white/20 animate-pulse" />
<div className="animate-pulse rounded-md h-4 w-2/3 bg-black/20 dark:bg-white/20" />
</div>
);
}
if (isError || !data) {
return (
<div className="flex items-center gap-2">
<div className="size-8 shrink-0 rounded-full bg-red-500 text-white inline-flex items-center justify-center">
<InfoIcon className="size-5" />
</div>
<p className="text-red-500 text-sm">
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="size-8 shrink-0 rounded-full" />
</User.Root>
</User.Provider>
<div className="line-clamp-1">{data.content}</div>
</Note.Root>
</Note.Provider>
);
}
function TextNote({ event }: { event: NostrEvent }) {
const pTags = event.tags
.filter((tag) => tag[0] === "p")
.map((tag) => tag[1])
.slice(0, 3);
return (
<button
type="button"
key={event.id}
onClick={() => LumeWindow.openEvent(event)}
>
<Note.Provider event={event}>
<Note.Root className="shrink-0 flex flex-col gap-1 rounded-lg p-2 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="size-9 shrink-0 rounded-full" />
<div className="flex-1 flex flex-col">
<div className="w-full flex items-baseline justify-between">
<User.Name className="leading-tight text-sm font-semibold" />
<span className="leading-tight text-sm 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 pubkey={replyTo}>
<User.Root>
<User.Name className="leading-tight font-medium" />
</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">{event.content}</div>
</div>
</Note.Root>
</Note.Provider>
</button>
);
}
+7 -8
View File
@@ -1,5 +1,5 @@
import { SearchIcon } from "@lume/icons"; import { SearchIcon } from "@lume/icons";
import { type Event, Kind } from "@lume/types"; import { type NostrEvent, Kind } from "@lume/types";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { Note } from "@/components/note"; import { Note } from "@/components/note";
import { User } from "@/components/user"; import { User } from "@/components/user";
@@ -7,6 +7,7 @@ import { createFileRoute } from "@tanstack/react-router";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useDebounce } from "use-debounce"; import { useDebounce } from "use-debounce";
import { LumeWindow } from "@lume/system";
export const Route = createFileRoute("/search")({ export const Route = createFileRoute("/search")({
component: Screen, component: Screen,
@@ -14,7 +15,7 @@ export const Route = createFileRoute("/search")({
function Screen() { function Screen() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [events, setEvents] = useState<Event[]>([]); const [events, setEvents] = useState<NostrEvent[]>([]);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [searchValue] = useDebounce(search, 500); const [searchValue] = useDebounce(search, 500);
@@ -25,7 +26,7 @@ function Screen() {
const query = `https://api.nostr.wine/search?query=${searchValue}&kind=0,1`; const query = `https://api.nostr.wine/search?query=${searchValue}&kind=0,1`;
const res = await fetch(query); const res = await fetch(query);
const content = await res.json(); const content = await res.json();
const events = content.data as Event[]; const events = content.data as NostrEvent[];
const sorted = events.sort((a, b) => b.created_at - a.created_at); const sorted = events.sort((a, b) => b.created_at - a.created_at);
setLoading(false); setLoading(false);
@@ -102,14 +103,12 @@ function Screen() {
); );
} }
function SearchUser({ event }: { event: Event }) { function SearchUser({ event }: { event: NostrEvent }) {
const { ark } = Route.useRouteContext();
return ( return (
<button <button
key={event.id} key={event.id}
type="button" type="button"
onClick={() => ark.open_profile(event.pubkey)} onClick={() => LumeWindow.openProfile(event.pubkey)}
className="col-span-1 p-2 hover:bg-black/10 dark:hover:bg-white/10 rounded-lg" className="col-span-1 p-2 hover:bg-black/10 dark:hover:bg-white/10 rounded-lg"
> >
<User.Provider pubkey={event.pubkey} embedProfile={event.content}> <User.Provider pubkey={event.pubkey} embedProfile={event.content}>
@@ -125,7 +124,7 @@ function SearchUser({ event }: { event: Event }) {
); );
} }
function SearchNote({ event }: { event: Event }) { function SearchNote({ event }: { event: NostrEvent }) {
return ( return (
<div className="bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50"> <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.Provider event={event}>
+13 -12
View File
@@ -1,5 +1,5 @@
import { User } from "@/components/user"; import { User } from "@/components/user";
import type { Account } from "@lume/types"; import { NostrAccount } from "@lume/system";
import { displayNsec } from "@lume/utils"; import { displayNsec } from "@lume/utils";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
@@ -7,19 +7,20 @@ import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
interface Account {
npub: string;
nsec: string;
}
export const Route = createFileRoute("/settings/backup")({ export const Route = createFileRoute("/settings/backup")({
component: Screen, component: Screen,
loader: async ({ context }) => { loader: async () => {
const ark = context.ark; const npubs = await NostrAccount.getAccounts();
const npubs = await ark.get_accounts();
const accounts: Account[] = []; const accounts: Account[] = [];
for (const account of npubs) { for (const npub of npubs) {
const nsec: string = await invoke("get_stored_nsec", { const nsec: string = await invoke("get_stored_nsec", { npub });
npub: account.npub, accounts.push({ npub, nsec });
});
accounts.push({ ...account, nsec });
} }
return accounts; return accounts;
@@ -33,14 +34,14 @@ function Screen() {
<div className="mx-auto w-full max-w-xl"> <div className="mx-auto w-full max-w-xl">
<div className="flex flex-col gap-3 divide-y divide-neutral-300 dark:divide-neutral-700"> <div className="flex flex-col gap-3 divide-y divide-neutral-300 dark:divide-neutral-700">
{accounts.map((account) => ( {accounts.map((account) => (
<NostrAccount key={account.npub} account={account} /> <List key={account.npub} account={account} />
))} ))}
</div> </div>
</div> </div>
); );
} }
function NostrAccount({ account }: { account: Account }) { function List({ account }: { account: Account }) {
const [key, setKey] = useState(account.nsec); const [key, setKey] = useState(account.nsec);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [passphase, setPassphase] = useState(""); const [passphase, setPassphase] = useState("");
+82 -32
View File
@@ -1,28 +1,22 @@
import { NostrQuery } from "@lume/system";
import type { Settings } from "@lume/types"; import type { Settings } from "@lume/types";
import * as Switch from "@radix-ui/react-switch"; import * as Switch from "@radix-ui/react-switch";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { import { invoke } from "@tauri-apps/api/core";
isPermissionGranted, import { requestPermission } from "@tauri-apps/plugin-notification";
requestPermission,
} from "@tauri-apps/plugin-notification";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useDebouncedCallback } from "use-debounce"; import { useDebouncedCallback } from "use-debounce";
export const Route = createFileRoute("/settings/general")({ export const Route = createFileRoute("/settings/general")({
beforeLoad: async ({ context }) => { beforeLoad: async () => {
const permissionGranted = await isPermissionGranted(); // get notification permission const settings = await NostrQuery.getSettings();
const ark = context.ark; return { settings };
const settings = await ark.get_settings();
return {
settings: { ...settings, notification: permissionGranted },
};
}, },
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const { ark, settings } = Route.useRouteContext(); const { settings } = Route.useRouteContext();
const [newSettings, setNewSettings] = useState<Settings>(settings); const [newSettings, setNewSettings] = useState<Settings>(settings);
const toggleNofitication = async () => { const toggleNofitication = async () => {
@@ -33,6 +27,13 @@ function Screen() {
})); }));
}; };
const toggleGossip = async () => {
setNewSettings((prev) => ({
...prev,
gossip: !newSettings.gossip,
}));
};
const toggleAutoUpdate = () => { const toggleAutoUpdate = () => {
setNewSettings((prev) => ({ setNewSettings((prev) => ({
...prev, ...prev,
@@ -61,8 +62,21 @@ function Screen() {
})); }));
}; };
const changeTheme = (theme: string) => {
if (theme === "auto" || theme === "light" || theme === "dark") {
invoke("plugin:theme|set_theme", {
theme: theme,
}).then(() =>
setNewSettings((prev) => ({
...prev,
theme,
})),
);
}
};
const updateSettings = useDebouncedCallback(() => { const updateSettings = useDebouncedCallback(() => {
ark.set_settings(newSettings); NostrQuery.setSettings(newSettings);
}, 200); }, 200);
useEffect(() => { useEffect(() => {
@@ -95,6 +109,24 @@ function Screen() {
</Switch.Root> </Switch.Root>
</div> </div>
</div> </div>
<div className="flex w-full items-start justify-between 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">
Automatically connect to the necessary relay suggested by
Relay Hint when fetching a new event.
</p>
</div>
<div className="w-36 flex justify-end shrink-0">
<Switch.Root
checked={newSettings.gossip}
onClick={() => toggleGossip()}
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 w-full items-start justify-between gap-4 py-3"> <div className="flex w-full items-start justify-between gap-4 py-3">
<div className="flex-1"> <div className="flex-1">
<h3 className="font-medium">Enhanced Privacy</h3> <h3 className="font-medium">Enhanced Privacy</h3>
@@ -155,24 +187,42 @@ function Screen() {
Interface Interface
</h2> </h2>
<div className="flex flex-col divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl px-3"> <div className="flex flex-col divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl px-3">
<div className="flex flex-col gap-4"> <div className="flex w-full items-start justify-between gap-4 py-3">
<div className="flex w-full items-start justify-between gap-4 py-3"> <div className="flex-1">
<div className="flex-1"> <h3 className="font-semibold">Zap</h3>
<h3 className="font-semibold">Zap</h3> <p className="text-sm text-neutral-700 dark:text-neutral-300">
<p className="text-sm text-neutral-700 dark:text-neutral-300"> Show the Zap button in each note and user's profile screen,
Show the Zap button in each note and user's profile screen, use for send bitcoin tip to other users.
use for send bitcoin tip to other users. </p>
</p> </div>
</div> <div className="w-36 flex justify-end shrink-0">
<div className="w-36 flex justify-end shrink-0"> <Switch.Root
<Switch.Root checked={newSettings.zap}
checked={newSettings.zap} onClick={() => toggleZap()}
onClick={() => toggleZap()} 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"
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.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>
</Switch.Root> </div>
</div> </div>
<div className="flex w-full items-start justify-between gap-4 py-3">
<div className="flex-1">
<h3 className="font-semibold">Appearance</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
* Require restarting the app to take effect.
</p>
</div>
<div className="w-36 flex justify-end shrink-0">
<select
name="theme"
className="bg-transparent shadow-none outline-none rounded-lg border-1 border-black/10 dark:border-white/10 py-1 w-24"
defaultValue={settings.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> </div>
</div> </div>
+5 -6
View File
@@ -1,14 +1,13 @@
import { CancelIcon, PlusIcon } from "@lume/icons"; import { CancelIcon, PlusIcon } from "@lume/icons";
import { NostrQuery } from "@lume/system";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { useState } from "react"; import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
export const Route = createFileRoute("/settings/relay")({ export const Route = createFileRoute("/settings/relay")({
loader: async ({ context }) => { loader: async () => {
const ark = context.ark; const relays = await NostrQuery.getRelays();
const relays = await ark.get_relays();
return relays; return relays;
}, },
component: Screen, component: Screen,
@@ -18,12 +17,11 @@ function Screen() {
const relayList = Route.useLoaderData(); const relayList = Route.useLoaderData();
const [relays, setRelays] = useState(relayList.connected); const [relays, setRelays] = useState(relayList.connected);
const { ark } = Route.useRouteContext();
const { register, reset, handleSubmit } = useForm(); const { register, reset, handleSubmit } = useForm();
const onSubmit = async (data: { url: string }) => { const onSubmit = async (data: { url: string }) => {
try { try {
const add = await ark.add_relay(data.url); const add = await NostrQuery.connectRelay(data.url);
if (add) { if (add) {
setRelays((prev) => [...prev, data.url]); setRelays((prev) => [...prev, data.url]);
reset(); reset();
@@ -56,6 +54,7 @@ function Screen() {
<div> <div>
<button <button
type="button" type="button"
onClick={() => NostrQuery.removeRelay(relay)}
className="inline-flex items-center justify-center size-7 rounded-md hover:bg-black/10 dark:hover:bg-white/10" className="inline-flex items-center justify-center size-7 rounded-md hover:bg-black/10 dark:hover:bg-white/10"
> >
<CancelIcon className="size-4" /> <CancelIcon className="size-4" />
+5 -5
View File
@@ -1,5 +1,6 @@
import { AvatarUploader } from "@/components/avatarUploader"; import { AvatarUploader } from "@/components/avatarUploader";
import { PlusIcon } from "@lume/icons"; import { PlusIcon } from "@lume/icons";
import { NostrAccount } from "@lume/system";
import type { Metadata } from "@lume/types"; import type { Metadata } from "@lume/types";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
@@ -9,16 +10,15 @@ import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
export const Route = createFileRoute("/settings/user")({ export const Route = createFileRoute("/settings/user")({
beforeLoad: async ({ context }) => { beforeLoad: async () => {
const ark = context.ark; const profile = await NostrAccount.getProfile();
const profile = await ark.get_current_user_profile();
return { profile }; return { profile };
}, },
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const { ark, profile } = Route.useRouteContext(); const { profile } = Route.useRouteContext();
const { register, handleSubmit } = useForm({ defaultValues: profile }); const { register, handleSubmit } = useForm({ defaultValues: profile });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -29,7 +29,7 @@ function Screen() {
setLoading(true); setLoading(true);
const newProfile: Metadata = { ...profile, ...data, picture }; const newProfile: Metadata = { ...profile, ...data, picture };
await ark.create_profile(newProfile); await NostrAccount.createProfile(newProfile);
setLoading(false); setLoading(false);
} catch (e) { } catch (e) {
+13 -8
View File
@@ -3,7 +3,13 @@ import { Quote } from "@/components/quote";
import { RepostNote } from "@/components/repost"; import { RepostNote } from "@/components/repost";
import { TextNote } from "@/components/text"; import { TextNote } from "@/components/text";
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons"; import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
import { type ColumnRouteSearch, type Event, Kind, Topic } from "@lume/types"; import { NostrQuery } from "@lume/system";
import {
type ColumnRouteSearch,
type NostrEvent,
Kind,
Topic,
} from "@lume/types";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { useInfiniteQuery } from "@tanstack/react-query"; import { useInfiniteQuery } from "@tanstack/react-query";
import { Link, createFileRoute, redirect } from "@tanstack/react-router"; import { Link, createFileRoute, redirect } from "@tanstack/react-router";
@@ -17,11 +23,10 @@ export const Route = createFileRoute("/topic")({
name: search.name, name: search.name,
}; };
}, },
beforeLoad: async ({ search, context }) => { beforeLoad: async ({ search }) => {
const ark = context.ark;
const key = `lume_topic_${search.label}`; const key = `lume_topic_${search.label}`;
const topics = (await ark.get_nstore(key)) as unknown as Topic[]; const topics = (await NostrQuery.getNstore(key)) as unknown as Topic[];
const settings = await ark.get_settings(); const settings = await NostrQuery.getSettings();
if (!topics?.length) { if (!topics?.length) {
throw redirect({ throw redirect({
@@ -49,7 +54,7 @@ export const Route = createFileRoute("/topic")({
export function Screen() { export function Screen() {
const { label, account } = Route.useSearch(); const { label, account } = Route.useSearch();
const { ark, hashtags } = Route.useRouteContext(); const { hashtags } = Route.useRouteContext();
const { const {
data, data,
isLoading, isLoading,
@@ -61,7 +66,7 @@ export function Screen() {
queryKey: [label, account], queryKey: [label, account],
initialPageParam: 0, initialPageParam: 0,
queryFn: async ({ pageParam }: { pageParam: number }) => { queryFn: async ({ pageParam }: { pageParam: number }) => {
const events = ark.get_hashtag_events(hashtags, 20, pageParam); const events = NostrQuery.getHashtagEvents(hashtags, pageParam);
return events; return events;
}, },
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1, getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
@@ -69,7 +74,7 @@ export function Screen() {
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}); });
const renderItem = (event: Event) => { const renderItem = (event: NostrEvent) => {
if (!event) return; if (!event) return;
switch (event.kind) { switch (event.kind) {
case Kind.Repost: case Kind.Repost:
+2 -2
View File
@@ -1,5 +1,5 @@
import { TextNote } from "@/components/text"; import { TextNote } from "@/components/text";
import type { Event } from "@lume/types"; import type { NostrEvent } from "@lume/types";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { Await, createFileRoute } from "@tanstack/react-router"; import { Await, createFileRoute } from "@tanstack/react-router";
import { defer } from "@tanstack/react-router"; import { defer } from "@tanstack/react-router";
@@ -15,7 +15,7 @@ export const Route = createFileRoute("/trending/notes")({
signal: abortController.signal, signal: abortController.signal,
}) })
.then((res) => res.json()) .then((res) => res.json())
.then((res) => res.notes.map((item) => item.event) as Event[]), .then((res) => res.notes.map((item) => item.event) as NostrEvent[]),
), ),
}; };
} catch (e) { } catch (e) {
+3 -4
View File
@@ -1,4 +1,5 @@
import { ArticleIcon, GroupFeedsIcon } from "@lume/icons"; import { ArticleIcon, GroupFeedsIcon } from "@lume/icons";
import { NostrQuery } from "@lume/system";
import type { ColumnRouteSearch } from "@lume/types"; import type { ColumnRouteSearch } from "@lume/types";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { Link, Outlet } from "@tanstack/react-router"; import { Link, Outlet } from "@tanstack/react-router";
@@ -12,10 +13,8 @@ export const Route = createFileRoute("/trending")({
name: search.name, name: search.name,
}; };
}, },
beforeLoad: async ({ context }) => { beforeLoad: async () => {
const ark = context.ark; const settings = await NostrQuery.getSettings();
const settings = await ark.get_settings();
return { settings }; return { settings };
}, },
component: Screen, component: Screen,
+7 -9
View File
@@ -6,20 +6,18 @@ import { Conversation } from "@/components/conversation";
import { Quote } from "@/components/quote"; import { Quote } from "@/components/quote";
import { RepostNote } from "@/components/repost"; import { RepostNote } from "@/components/repost";
import { TextNote } from "@/components/text"; import { TextNote } from "@/components/text";
import { type Event, Kind } from "@lume/types"; import { type NostrEvent, Kind } from "@lume/types";
import { Suspense } from "react"; import { Suspense } from "react";
import { Await } from "@tanstack/react-router"; import { Await } from "@tanstack/react-router";
import { NostrQuery } from "@lume/system";
export const Route = createFileRoute("/users/$pubkey")({ export const Route = createFileRoute("/users/$pubkey")({
beforeLoad: async ({ context }) => { beforeLoad: async () => {
const ark = context.ark; const settings = await NostrQuery.getSettings();
const settings = await ark.get_settings();
return { settings }; return { settings };
}, },
loader: async ({ params, context }) => { loader: async ({ params }) => {
const ark = context.ark; return { data: defer(NostrQuery.getUserEvents(params.pubkey)) };
return { data: defer(ark.get_events_by(params.pubkey, 50)) };
}, },
component: Screen, component: Screen,
}); });
@@ -28,7 +26,7 @@ function Screen() {
const { pubkey } = Route.useParams(); const { pubkey } = Route.useParams();
const { data } = Route.useLoaderData(); const { data } = Route.useLoaderData();
const renderItem = (event: Event) => { const renderItem = (event: NostrEvent) => {
if (!event) return; if (!event) return;
switch (event.kind) { switch (event.kind) {
case Kind.Repost: case Kind.Repost:
@@ -1,71 +0,0 @@
import { RepostNote } from "@/components/repost";
import { TextNote } from "@/components/text";
import { ArrowRightCircleIcon, InfoIcon } from "@lume/icons";
import { type Event, Kind } from "@lume/types";
import { Spinner } from "@lume/ui";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useRouteContext } from "@tanstack/react-router";
export function EventList({ id }: { id: string }) {
const { ark } = useRouteContext({ strict: false });
const { data, hasNextPage, isLoading, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: ["events", id],
initialPageParam: 0,
queryFn: async ({ pageParam }: { pageParam: number }) => {
const events = await ark.get_events_by(id, 20, pageParam);
return events;
},
getNextPageParam: (lastPage) => {
const lastEvent = lastPage?.at(-1);
return lastEvent ? lastEvent.created_at - 1 : null;
},
refetchOnWindowFocus: false,
});
const renderItem = (event: Event) => {
if (!event) return;
switch (event.kind) {
case Kind.Repost:
return <RepostNote key={event.id} event={event} />;
default:
return <TextNote key={event.id} event={event} />;
}
};
return (
<div>
{isLoading ? (
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
<Spinner className="size-5" />
</div>
) : !data.length ? (
<div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-5 dark:bg-neutral-950">
<InfoIcon className="size-6" />
<p>Empty newsfeed.</p>
</div>
) : (
data.map((item) => renderItem(item))
)}
<div className="flex h-20 items-center justify-center">
{hasNextPage ? (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-12 w-36 items-center justify-center gap-2 rounded-full bg-neutral-100 px-3 font-medium hover:bg-neutral-200 focus:outline-none dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
{isFetchingNextPage ? (
<Spinner className="size-5" />
) : (
<>
<ArrowRightCircleIcon className="size-5" />
Load more
</>
)}
</button>
) : null}
</div>
</div>
);
}
+2 -2
View File
@@ -7,6 +7,7 @@ import { useState } from "react";
import CurrencyInput from "react-currency-input-field"; import CurrencyInput from "react-currency-input-field";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import { LumeEvent } from "@lume/system";
const DEFAULT_VALUES = [69, 100, 200, 500]; const DEFAULT_VALUES = [69, 100, 200, 500];
@@ -16,7 +17,6 @@ export const Route = createLazyFileRoute("/zap/$id")({
function Screen() { function Screen() {
const { t } = useTranslation(); const { t } = useTranslation();
const { ark } = Route.useRouteContext();
const { id } = Route.useParams(); const { id } = Route.useParams();
// @ts-ignore, magic !!! // @ts-ignore, magic !!!
const { pubkey, account } = Route.useSearch(); const { pubkey, account } = Route.useSearch();
@@ -31,7 +31,7 @@ function Screen() {
// start loading // start loading
setIsLoading(true); setIsLoading(true);
const val = await ark.zap_event(id, amount, message); const val = await LumeEvent.zap(id, amount, message);
if (val) { if (val) {
setIsCompleted(true); setIsCompleted(true);
+2 -2
View File
@@ -12,8 +12,8 @@
"dependencies": { "dependencies": {
"@astrojs/check": "^0.5.10", "@astrojs/check": "^0.5.10",
"@astrojs/tailwind": "^5.1.0", "@astrojs/tailwind": "^5.1.0",
"@fontsource/geist-mono": "^5.0.3", "@fontsource/alice": "^5.0.13",
"astro": "^4.8.3", "astro": "^4.9.2",
"astro-seo-meta": "^4.1.1", "astro-seo-meta": "^4.1.1",
"astro-seo-schema": "^4.0.2", "astro-seo-schema": "^4.0.2",
"schema-dts": "^1.1.2", "schema-dts": "^1.1.2",
Binary file not shown.

After

Width:  |  Height:  |  Size: 889 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

-37
View File
@@ -1,37 +0,0 @@
<svg width="824" height="824" viewBox="0 0 824 824" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_564_71)">
<rect width="824" height="824" rx="184" fill="white" style="fill:white;fill-opacity:1;"/>
<circle cx="267" cy="594" r="42" fill="black" style="fill:black;fill-opacity:1;"/>
<circle cx="267" cy="594" r="42" fill="url(#paint0_radial_564_71)" fill-opacity="0.5" style=""/>
<circle cx="267" cy="594" r="42" fill="url(#paint1_radial_564_71)" fill-opacity="0.3" style=""/>
<circle cx="557" cy="594" r="42" fill="black" style="fill:black;fill-opacity:1;"/>
<circle cx="557" cy="594" r="42" fill="url(#paint2_radial_564_71)" fill-opacity="0.5" style=""/>
<circle cx="557" cy="594" r="42" fill="url(#paint3_radial_564_71)" fill-opacity="0.3" style=""/>
<path d="M412 691C382.859 691 353.717 686.063 337.654 682.804C333.024 681.865 329.866 686.676 333.074 690.144C345.098 703.138 370.814 724 412 724C453.186 724 478.902 703.138 490.926 690.144C494.134 686.676 490.976 681.865 486.346 682.804C470.283 686.063 441.141 691 412 691Z" fill="url(#paint4_linear_564_71)" style=""/>
</g>
<defs>
<radialGradient id="paint0_radial_564_71" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(241.807 578.038) rotate(112.103) scale(88.2816 69.6512)">
<stop stop-color="white" style="stop-color:white;stop-opacity:1;"/>
<stop offset="1" stop-opacity="0" style="stop-color:none;stop-opacity:0;"/>
</radialGradient>
<radialGradient id="paint1_radial_564_71" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(288.309 621.165) rotate(126.504) scale(25.5816 15.4047)">
<stop stop-color="white" style="stop-color:white;stop-opacity:1;"/>
<stop offset="1" stop-opacity="0" style="stop-color:none;stop-opacity:0;"/>
</radialGradient>
<radialGradient id="paint2_radial_564_71" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(531.807 578.038) rotate(112.103) scale(88.2816 69.6512)">
<stop stop-color="white" style="stop-color:white;stop-opacity:1;"/>
<stop offset="1" stop-opacity="0" style="stop-color:none;stop-opacity:0;"/>
</radialGradient>
<radialGradient id="paint3_radial_564_71" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(578.309 621.165) rotate(126.504) scale(25.5816 15.4047)">
<stop stop-color="white" style="stop-color:white;stop-opacity:1;"/>
<stop offset="1" stop-opacity="0" style="stop-color:none;stop-opacity:0;"/>
</radialGradient>
<linearGradient id="paint4_linear_564_71" x1="293.565" y1="686.595" x2="316.497" y2="774.784" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9F5A" style="stop-color:#FF9F5A;stop-color:color(display-p3 1.0000 0.6235 0.3529);stop-opacity:1;"/>
<stop offset="1" stop-color="#FF9F5A" style="stop-color:#FF9F5A;stop-color:color(display-p3 1.0000 0.6235 0.3529);stop-opacity:1;"/>
</linearGradient>
<clipPath id="clip0_564_71">
<rect width="824" height="824" fill="white" style="fill:white;fill-opacity:1;"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 714 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 318 KiB

+92 -149
View File
@@ -3,153 +3,96 @@ import { Seo } from "astro-seo-meta";
--- ---
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width" />
<meta name="generator" content={Astro.generator} /> <meta name="generator" content={Astro.generator} />
<title>Lume</title> <title>Lume: The nostr client for desktop</title>
<Seo <Seo
title="Lume" title="Lume"
description="A multiple columns Nostr client for desktop." description="A friendly and scalable Nostr desktop client."
keywords={[ keywords={[
"nostr", "nostr",
"nostr client", "nostr client",
"social network", "social network",
"desktop app", "desktop app",
"timeline", "timeline",
"application", "application",
"columns", "columns",
]} "tweetdeck",
themeColor="#fafafa" ]}
colorScheme="light" themeColor="#fafafa"
facebook={{ colorScheme="light"
image: "/og-image.jpg", facebook={{
url: "https://lume.nu", image: "/og-image.jpg",
type: "website", url: "https://lume.nu",
}} type: "website",
twitter={{ }}
image: "/og-image.jpg", twitter={{
card: "summary", image: "/og-image.jpg",
}} card: "summary",
/> }}
</head> />
<body </head>
class="w-full h-full antialiased font-mono bg-neutral-50 dark:bg-neutral-950 text-neutral-950 dark:text-neutral-50" <body
> class="w-full h-full antialiased bg-neutral-50 dark:bg-neutral-950 text-neutral-950 dark:text-neutral-50"
<div class="max-w-2xl mx-auto w-full py-16 md:px-0 px-2"> >
<div class="flex flex-col gap-16"> <div class="py-10 flex flex-col gap-10">
<div class="prose dark:prose-invert prose-neutral max-w-none"> <div class="mx-auto max-w-xl w-full flex flex-col gap-2">
<h3>About Lume</h3> <div class="mb-5">
<p> <img
Lume is a <b>Nostr client</b> for desktop include Linux, Windows and src="/icon.png"
macOS. It is free and open source, you can look at source code <a alt="App Icon"
href="https://github.com/lumehq/lume" class="size-14 shadow-md shadow-neutral-500/50 rounded-xl object-cover transform-gpu -rotate-6 hover:animate-spin"
target="_blank">on Github</a />
>. Lume is actively improving the app and adding new features, you </div>
can expect new update every month. <h1 class="text-xl font-serif font-semibold">
</p> A friendly and scalable Nostr desktop client.
<a href="#download">Download</a> </h1>
<h3>What is nostr & how does it work?</h3> <p class="text-sm font-medium text-neutral-700">
<p> Lume is a <b>Nostr client</b> for desktop, including Linux, Windows, and
Nostr stands for Notes and Other Stuff Transmitted by Relays. It is macOS. It is free and open-source; you can look at the source code on <a
an open, permission-less protocol that aims to provide href="https://github.com/lumehq/lume">GitHub</a
censorship-resistance and interoperability. It can be used to create >. Lume is actively improving the app and adding new features; you can
social networks or just about any other type of app (other stuff expect a new update every month.
part of the acronym). It is not a single website or app, but the </p>
glue that holds together many apps (clients) and <b>Lume</b> is one of <p class="text-sm font-medium text-neutral-700">
it. <b>Latest version</b>: 4.0.4
</p> </p>
<p> <div
At its core, nostr consists of relays and events. A person does class="w-full h-[120px] sm:h-[80px] flex flex-col sm:flex-row sm:items-center sm:justify-start justify-center gap-2"
something (event) and this event is sent to a relay. The relay >
stores the event, then waits for another person to request it. The <a
most common types of events are notes and reactions - the stuff href="https://github.com/lumehq/lume/releases/latest"
social media is made of, but there are many other types of events. 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"
It works very similar to how any other app would work with a >Download for macOS</a
database, except in nostr there is no single database, rather a >
large number of relays that store the events. <span class="italic text-xs text-neutral-700"
</p> >(Windows & Linux are coming later)</span
<h3>Lume is multiple columns experience</h3> >
<p> </div>
Lume is display your timeline as multiple column, each column is <div class="text-sm italic text-neutral-600">
each different content and you can define your experience * If you still need to use Lume on Windows and Linux, you can try v3 <a
</p> href="https://github.com/lumehq/lume/releases/tag/v3.0.2"
<p> class="text-blue-500">here</a
You can create a column to display newsfeed from specific people, >
you can create a column to display all contents related to some </div>
hashtags. It all up to you. </div>
</p> <div class="sm:max-w-3xl w-full mx-auto px-3 sm:px-0">
<img <video
src="https://image.nostr.build/fd3e3cdeb4fb9f0f3de5c5e668a11dcae55f50cc9a78fc2b57b063240191a0f9.png" class="aspect-video w-full h-auto rounded-xl"
alt="columns" autoplay
loading="lazy" muted
decoding="async" controls
class="w-full h-auto rounded-lg" >
/> <source
<h3>"For You"</h3> src="https://video.nostr.build/4cc4df88caeb861b62e3f73bddbb5e0b5cf63617472a97d22f427e273ee0e127.mp4"
<p> type="video/mp4"
Unlike some social networks, they feed you by algorithm. In Lume, />
you totally control what to will see Your browser does not support the video tag.
</p> </video>
<img </div>
src="https://image.nostr.build/5afd79de15929a4ac6f6e933791c942555baa4206fecee54fed61dde9fe167e1.png" </div>
alt="for you" </body>
loading="lazy"
decoding="async"
class="w-full h-auto rounded-lg"
/>
<h3 id="download">Download and Explore</h3>
<p>
(Universal) macOS: <a
href="https://github.com/lumehq/lume/releases/download/v3.0.0/Lume_3.0.0_universal.dmg"
>Lume_3.0.0_universal.dmg
</a>
</p>
<p>
(x86-64) Windows 11: <a
href="https://github.com/lumehq/lume/releases/download/v3.0.0/Lume_3.0.0_x64-setup.exe"
>Lume_3.0.0_x64-setup.exe
</a>
</p>
<p>
(x86-64) Ubuntu: <a
href="https://github.com/lumehq/lume/releases/download/v3.0.0/lume_3.0.0_amd64.deb"
>lume_3.0.0_amd64.deb
</a>
</p>
<p>
(x86-64) Fedora: <a
href="https://github.com/lumehq/lume/releases/download/v3.0.0/lume-3.0.0-1.x86_64.rpm"
>lume-3.0.0-1.x86_64.rpm
</a>
</p>
<p>
(x86-64) Linux Flatpak: <a
href="https://github.com/lumehq/lume/releases/download/v3.0.0/lume_3.0.0_amd64.flatpak"
>lume_3.0.0_amd64.flatpak
</a>
</p>
<p>
(x86-64) Linux AppImage: <a
href="https://github.com/lumehq/lume/releases/download/v3.0.0/lume_3.0.0_amd64.AppImage"
>lume_3.0.0_amd64.AppImage
</a>
</p>
<p>
Support for ARM, RISC-V and Loongarch architecture are coming soon.
</p>
</div>
<div class="text-center">
<p class="text-sm text-neutral-500 dark:text-neutral-600">
Supported by <a
href="https://opensats.org"
target="_blank"
class="text-orange-500">Open Sats</a
> and Community
</p>
</div>
</div>
</div>
</body>
</html> </html>
+1 -1
View File
@@ -7,7 +7,7 @@ export default {
theme: { theme: {
extend: { extend: {
fontFamily: { fontFamily: {
mono: ["Geist Mono", ...defaultTheme.fontFamily.mono], serif: ["Alice", ...defaultTheme.fontFamily.serif],
}, },
}, },
}, },
+33 -34
View File
@@ -1,36 +1,35 @@
{ {
"name": "lume", "name": "lume",
"private": true, "private": true,
"version": "4.0.0", "version": "4.0.0",
"scripts": { "scripts": {
"build": "turbo run build", "build": "turbo run build",
"dev": "turbo run dev", "dev": "turbo run dev",
"web:dev": "turbo run dev --filter web", "web:dev": "turbo run dev --filter web",
"desktop:dev": "turbo run dev --filter desktop2", "desktop:dev": "turbo run dev --filter desktop2",
"desktop:build": "turbo run build --filter desktop2", "desktop:build": "turbo run build --filter desktop2",
"tauri": "tauri" "tauri": "tauri"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "1.7.3", "@biomejs/biome": "1.7.3",
"@tauri-apps/cli": "2.0.0-beta.12", "@tauri-apps/cli": "2.0.0-beta.20",
"turbo": "^1.13.3" "turbo": "^1.13.3"
}, },
"packageManager": "pnpm@8.9.0", "packageManager": "pnpm@8.9.0",
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.0.0-beta.7", "@tauri-apps/api": "2.0.0-beta.13",
"@tauri-apps/plugin-autostart": "2.0.0-beta.3", "@tauri-apps/plugin-clipboard-manager": "2.1.0-beta.3",
"@tauri-apps/plugin-clipboard-manager": "2.1.0-beta.1", "@tauri-apps/plugin-dialog": "2.0.0-beta.5",
"@tauri-apps/plugin-dialog": "2.0.0-beta.3", "@tauri-apps/plugin-fs": "2.0.0-beta.5",
"@tauri-apps/plugin-fs": "2.0.0-beta.3", "@tauri-apps/plugin-http": "2.0.0-beta.5",
"@tauri-apps/plugin-http": "2.0.0-beta.3", "@tauri-apps/plugin-notification": "2.0.0-beta.5",
"@tauri-apps/plugin-notification": "2.0.0-beta.3", "@tauri-apps/plugin-os": "2.0.0-beta.5",
"@tauri-apps/plugin-os": "2.0.0-beta.3", "@tauri-apps/plugin-process": "2.0.0-beta.5",
"@tauri-apps/plugin-process": "2.0.0-beta.3", "@tauri-apps/plugin-shell": "2.0.0-beta.6",
"@tauri-apps/plugin-shell": "2.0.0-beta.4", "@tauri-apps/plugin-updater": "2.0.0-beta.5",
"@tauri-apps/plugin-updater": "2.0.0-beta.3", "@tauri-apps/plugin-upload": "2.0.0-beta.6"
"@tauri-apps/plugin-upload": "2.0.0-beta.4" }
}
} }
-902
View File
@@ -1,902 +0,0 @@
import {
type Event,
type EventWithReplies,
type Interests,
type Keys,
type LumeColumn,
type Metadata,
type Settings,
Relays,
} from "@lume/types";
import { generateContentTags } from "@lume/utils";
import { invoke } from "@tauri-apps/api/core";
import type { WebviewWindow } from "@tauri-apps/api/webviewWindow";
import { open } from "@tauri-apps/plugin-dialog";
import { readFile } from "@tauri-apps/plugin-fs";
enum NSTORE_KEYS {
settings = "lume_user_settings",
columns = "lume_user_columns",
}
export class Ark {
public windows: WebviewWindow[];
public settings: Settings;
public accounts: string[];
constructor() {
this.windows = [];
this.settings = undefined;
}
public async get_accounts() {
try {
const cmd: string = await invoke("get_accounts");
const parse = cmd.split(/\s+/).filter((v) => v.startsWith("npub1"));
const accounts = [...new Set(parse)];
if (!this.accounts) {
this.accounts = accounts;
}
return accounts;
} catch (e) {
console.info(String(e));
return [];
}
}
public async load_account(npub: string) {
try {
const cmd: boolean = await invoke("load_account", {
npub,
});
return cmd;
} catch (e) {
throw new Error(String(e));
}
}
public async nostr_connect(uri: string) {
try {
const remoteKey = uri.replace("bunker://", "").split("?")[0];
const npub: string = await invoke("to_npub", { hex: remoteKey });
if (npub) {
const connect: string = await invoke("nostr_connect", {
npub,
uri,
});
return connect;
}
} catch (e) {
throw new Error(String(e));
}
}
public async create_keys() {
try {
const cmd: Keys = await invoke("create_account");
return cmd;
} catch (e) {
console.error(String(e));
}
}
public async save_account(nsec: string, password = "") {
try {
const cmd: string = await invoke("save_account", {
nsec,
password,
});
return cmd;
} catch (e) {
throw new Error(String(e));
}
}
public async event_to_bech32(id: string, relays: string[]) {
try {
const cmd: string = await invoke("event_to_bech32", {
id,
relays,
});
return cmd;
} catch (e) {
throw new Error(String(e));
}
}
public async get_relays() {
try {
const cmd: Relays = await invoke("get_relays");
return cmd;
} catch (e) {
console.error(String(e));
return null;
}
}
public async add_relay(url: string) {
try {
const relayUrl = new URL(url);
if (relayUrl.protocol === "wss:" || relayUrl.protocol === "ws:") {
const cmd: boolean = await invoke("connect_relay", { relay: relayUrl });
return cmd;
}
} catch (e) {
throw new Error(String(e));
}
}
public async remove_relay(url: string) {
try {
const relayUrl = new URL(url);
if (relayUrl.protocol === "wss:" || relayUrl.protocol === "ws:") {
const cmd: boolean = await invoke("remove_relay", { relay: relayUrl });
return cmd;
}
} catch (e) {
throw new Error(String(e));
}
}
public async get_activities(account: string, kind: "1" | "6" | "9735" = "1") {
try {
const events: Event[] = await invoke("get_activities", { account, kind });
return events;
} catch (e) {
console.error(String(e));
return null;
}
}
public async get_event(id: string) {
try {
const eventId: string = id.replace("nostr:", "").replace(/[^\w\s]/gi, "");
const cmd: string = await invoke("get_event", { id: eventId });
const event: Event = JSON.parse(cmd);
return event;
} catch (e) {
console.error(id, String(e));
throw new Error(String(e));
}
}
public async search(content: string, limit: number) {
try {
if (content.length < 1) return [];
const events: Event[] = await invoke("search", {
content: content.trim(),
limit,
});
return events;
} catch (e) {
console.info(String(e));
return [];
}
}
private dedup_events(nostrEvents: Event[]) {
const seens = new Set<string>();
const events = nostrEvents.filter((event) => {
const eTags = event.tags.filter((el) => el[0] === "e");
const ids = eTags.map((item) => item[1]);
const isDup = ids.some((id) => seens.has(id));
// Add found ids to seen list
for (const id of ids) {
seens.add(id);
}
// Filter NSFW event
if (this.settings?.nsfw) {
const wTags = event.tags.filter((t) => t[0] === "content-warning");
const isLewd = wTags.length > 0;
return !isDup && !isLewd;
}
// Filter duplicate event
return !isDup;
});
return events;
}
public async get_local_events(
pubkeys: string[],
limit: number,
asOf?: number,
) {
try {
const until: string = asOf && asOf > 0 ? asOf.toString() : undefined;
const nostrEvents: Event[] = await invoke("get_local_events", {
pubkeys,
limit,
until,
});
const events = this.dedup_events(nostrEvents);
return events;
} catch (e) {
console.error("[get_local_events] failed", String(e));
return [];
}
}
public async get_global_events(limit: number, asOf?: number) {
try {
const until: string = asOf && asOf > 0 ? asOf.toString() : undefined;
const nostrEvents: Event[] = await invoke("get_global_events", {
limit,
until,
});
const events = this.dedup_events(nostrEvents);
return events;
} catch (e) {
console.error("[get_global_events] failed", String(e));
return [];
}
}
public async get_hashtag_events(
hashtags: string[],
limit: number,
asOf?: number,
) {
try {
const until: string = asOf && asOf > 0 ? asOf.toString() : undefined;
const nostrTags = hashtags.map((tag) => tag.replace("#", ""));
const nostrEvents: Event[] = await invoke("get_hashtag_events", {
hashtags: nostrTags,
limit,
until,
});
const events = this.dedup_events(nostrEvents);
return events;
} catch (e) {
console.error("[get_hashtag_events] failed", String(e));
return [];
}
}
public async get_group_events(
contacts: string[],
limit: number,
asOf?: number,
) {
try {
const until: string = asOf && asOf > 0 ? asOf.toString() : undefined;
const nostrEvents: Event[] = await invoke("get_group_events", {
list: contacts,
limit,
until,
});
const events = this.dedup_events(nostrEvents);
return events;
} catch (e) {
console.error("[get_group_events] failed", String(e));
return [];
}
}
public async get_events_by(pubkey: string, limit: number, asOf?: number) {
try {
const until: string = asOf && asOf > 0 ? asOf.toString() : undefined;
const nostrEvents: Event[] = await invoke("get_events_by", {
publicKey: pubkey,
limit,
as_of: until,
});
return nostrEvents.sort((a, b) => b.created_at - a.created_at);
} catch (e) {
console.error("[get_events_by] failed", String(e));
return [];
}
}
public async publish(
content: string,
reply_to?: string,
quote?: boolean,
nsfw?: boolean,
) {
try {
const g = await generateContentTags(content);
const eventContent = g.content;
const eventTags = g.tags;
if (reply_to) {
const replyEvent = await this.get_event(reply_to);
const relayHint =
replyEvent.tags.find((ev) => ev[0] === "e")?.[0][2] ?? "";
if (quote) {
eventTags.push(["e", replyEvent.id, relayHint, "mention"]);
eventTags.push(["q", replyEvent.id]);
} else {
const rootEvent = replyEvent.tags.find((ev) => ev[3] === "root");
if (rootEvent) {
eventTags.push([
"e",
rootEvent[1],
rootEvent[2] || relayHint,
"root",
]);
}
eventTags.push(["e", replyEvent.id, relayHint, "reply"]);
eventTags.push(["p", replyEvent.pubkey]);
}
}
if (nsfw) {
eventTags.push(["L", "content-warning"]);
eventTags.push(["l", "reason", "content-warning"]);
eventTags.push(["content-warning", "nsfw"]);
}
const cmd: string = await invoke("publish", {
content: eventContent,
tags: eventTags,
});
return cmd;
} catch (e) {
throw new Error(String(e));
}
}
public async reply_to(content: string, tags: string[]) {
try {
const cmd: string = await invoke("reply_to", { content, tags });
return cmd;
} catch (e) {
throw new Error(String(e));
}
}
public async repost(id: string, author: string) {
try {
const cmd: string = await invoke("repost", { id, pubkey: author });
return cmd;
} catch (e) {
throw new Error(String(e));
}
}
public async get_event_thread(id: string) {
try {
const events: EventWithReplies[] = await invoke("get_thread", {
id,
});
if (events.length > 0) {
const replies = new Set();
for (const event of events) {
const tags = event.tags.filter(
(el) => el[0] === "e" && el[1] !== id && el[3] !== "mention",
);
if (tags.length > 0) {
for (const tag of tags) {
const rootIndex = events.findIndex((el) => el.id === tag[1]);
if (rootIndex !== -1) {
const rootEvent = events[rootIndex];
if (rootEvent?.replies) {
rootEvent.replies.push(event);
} else {
rootEvent.replies = [event];
}
replies.add(event.id);
}
}
}
}
const cleanEvents = events.filter((ev) => !replies.has(ev.id));
return cleanEvents;
}
return events;
} catch (e) {
return [];
}
}
public get_thread(tags: string[][], gossip: boolean = false) {
let root: string = null;
let reply: string = null;
// Get all event references from tags, ignore mention
const events = tags.filter((el) => el[0] === "e" && el[3] !== "mention");
if (gossip) {
const relays = tags.filter((el) => el[0] === "e" && el[2]?.length);
if (relays.length >= 1) {
for (const relay of relays) {
if (relay[2]?.length) this.add_relay(relay[2]);
}
}
}
if (events.length === 1) {
root = events[0][1];
}
if (events.length > 1) {
root = events.find((el) => el[3] === "root")?.[1] ?? events[0][1];
reply = events.find((el) => el[3] === "reply")?.[1] ?? events[1][1];
}
// Fix some rare case when root === reply
if (root && reply && root === reply) {
reply = null;
}
return {
root,
reply,
};
}
public async get_profile(pubkey: string) {
try {
const id = pubkey.replace("nostr:", "").replace(/[^\w\s]/gi, "");
const cmd: Metadata = await invoke("get_profile", { id });
return cmd;
} catch (e) {
console.error(pubkey, String(e));
return null;
}
}
public async get_current_user_profile() {
try {
const cmd: Metadata = await invoke("get_current_user_profile");
return cmd;
} catch {
return null;
}
}
public async create_profile(profile: Metadata) {
try {
const event: string = await invoke("create_profile", {
name: profile.name || "",
display_name: profile.display_name || "",
displayName: profile.display_name || "",
about: profile.about || "",
picture: profile.picture || "",
banner: profile.banner || "",
nip05: profile.nip05 || "",
lud16: profile.lud16 || "",
website: profile.website || "",
});
return event;
} catch (e) {
throw new Error(String(e));
}
}
public async set_contact_list(pubkeys: string[]) {
try {
const cmd: boolean = await invoke("set_contact_list", { pubkeys });
return cmd;
} catch (e) {
throw new Error(String(e));
}
}
public async get_contact_list() {
try {
const cmd: string[] = await invoke("get_contact_list");
return cmd;
} catch (e) {
console.error(e);
return [];
}
}
public async follow(id: string, alias?: string) {
try {
const cmd: string = await invoke("follow", { id, alias });
return cmd;
} catch (e) {
throw new Error(String(e));
}
}
public async unfollow(id: string) {
try {
const cmd: string = await invoke("unfollow", { id });
return cmd;
} catch (e) {
throw new Error(String(e));
}
}
public async user_to_bech32(key: string, relays: string[]) {
try {
const cmd: string = await invoke("user_to_bech32", {
key,
relays,
});
return cmd;
} catch (e) {
throw new Error(String(e));
}
}
public async verify_nip05(pubkey: string, nip05: string) {
try {
const cmd: boolean = await invoke("verify_nip05", {
key: pubkey,
nip05,
});
return cmd;
} catch {
return false;
}
}
public async set_nwc(uri: string) {
try {
const cmd: boolean = await invoke("set_nwc", { uri });
return cmd;
} catch (e) {
throw new Error(String(e));
}
}
public async load_nwc() {
try {
const cmd: boolean = await invoke("load_nwc");
return cmd;
} catch (e) {
throw new Error(String(e));
}
}
public async get_balance() {
try {
const cmd: number = await invoke("get_balance");
return cmd;
} catch (e) {
throw new Error(String(e));
}
}
public async zap_profile(id: string, amount: number, message: string) {
try {
const cmd: boolean = await invoke("zap_profile", { id, amount, message });
return cmd;
} catch (e) {
throw new Error(String(e));
}
}
public async zap_event(id: string, amount: number, message: string) {
try {
const cmd: boolean = await invoke("zap_event", { id, amount, message });
return cmd;
} catch (e) {
throw new Error(String(e));
}
}
public async upload(filePath?: string) {
const allowExts = [
"png",
"jpeg",
"jpg",
"gif",
"mp4",
"mp3",
"webm",
"mkv",
"avi",
"mov",
];
const selected =
filePath ||
(
await open({
multiple: false,
filters: [
{
name: "Media",
extensions: allowExts,
},
],
})
).path;
// User cancelled action
if (!selected) return null;
try {
const file = await readFile(selected);
const blob = new Blob([file]);
const data = new FormData();
data.append("fileToUpload", blob);
data.append("submit", "Upload Image");
const res = await fetch("https://nostr.build/api/v2/upload/files", {
method: "POST",
body: data,
});
if (!res.ok) return null;
const json = await res.json();
const content = json.data[0];
return content.url as string;
} catch (e) {
throw new Error(String(e));
}
}
public async get_columns() {
try {
const cmd: string = await invoke("get_nstore", {
key: NSTORE_KEYS.columns,
});
const columns: LumeColumn[] = cmd ? JSON.parse(cmd) : [];
return columns;
} catch {
return [];
}
}
public async set_columns(columns: LumeColumn[]) {
try {
const cmd: string = await invoke("set_nstore", {
key: NSTORE_KEYS.columns,
content: JSON.stringify(columns),
});
return cmd;
} catch (e) {
throw new Error(e);
}
}
public async get_settings() {
try {
if (this.settings) return this.settings;
const cmd: string = await invoke("get_nstore", {
key: NSTORE_KEYS.settings,
});
const settings: Settings = cmd ? JSON.parse(cmd) : null;
this.settings = settings;
return settings;
} catch {
const defaultSettings: Settings = {
autoUpdate: false,
enhancedPrivacy: false,
notification: false,
zap: false,
nsfw: false,
};
this.settings = defaultSettings;
return defaultSettings;
}
}
public async set_settings(settings: Settings) {
try {
const cmd: string = await invoke("set_nstore", {
key: NSTORE_KEYS.settings,
content: JSON.stringify(settings),
});
return cmd;
} catch (e) {
throw new Error(e);
}
}
public async get_nstore(key: string) {
try {
const cmd: string = await invoke("get_nstore", {
key,
});
const parse: string | string[] = cmd ? JSON.parse(cmd) : null;
if (!parse.length) return null;
return parse;
} catch {
return null;
}
}
public async set_nstore(key: string, content: string) {
try {
const cmd: string = await invoke("set_nstore", {
key,
content,
});
return cmd;
} catch (e) {
throw new Error(String(e));
}
}
public async open_event_id(id: string) {
try {
const label = `event-${id}`;
const url = `/events/${id}`;
await invoke("open_window", {
label,
title: "Thread",
url,
width: 500,
height: 800,
});
} catch (e) {
throw new Error(String(e));
}
}
public async open_event(event: Event) {
try {
let root: string = undefined;
let reply: string = undefined;
const eTags = event.tags.filter(
(tag) => tag[0] === "e" || tag[0] === "q",
);
root = eTags.find((el) => el[3] === "root")?.[1];
reply = eTags.find((el) => el[3] === "reply")?.[1];
if (!root) root = eTags[0]?.[1];
if (!reply) reply = eTags[1]?.[1];
const label = `event-${event.id}`;
const url = `/events/${root ?? reply ?? event.id}`;
await invoke("open_window", {
label,
title: "Thread",
url,
width: 500,
height: 800,
});
} catch (e) {
throw new Error(String(e));
}
}
public async open_profile(pubkey: string) {
try {
const label = `user-${pubkey}`;
await invoke("open_window", {
label,
title: "Profile",
url: `/users/${pubkey}`,
width: 500,
height: 800,
});
} catch (e) {
throw new Error(String(e));
}
}
public async open_editor(reply_to?: string, quote = false) {
try {
let url: string;
if (reply_to) {
url = `/editor?reply_to=${reply_to}&quote=${quote}`;
} else {
url = "/editor";
}
const label = `editor-${reply_to ? reply_to : 0}`;
await invoke("open_window", {
label,
title: "Editor",
url,
width: 560,
height: 340,
});
} catch (e) {
throw new Error(String(e));
}
}
public async open_nwc() {
try {
const label = "nwc";
await invoke("open_window", {
label,
title: "Nostr Wallet Connect",
url: "/nwc",
width: 400,
height: 600,
});
} catch (e) {
throw new Error(String(e));
}
}
public async open_zap(id: string, pubkey: string, account: string) {
try {
const label = `zap-${id}`;
await invoke("open_window", {
label,
title: "Zap",
url: `/zap/${id}?pubkey=${pubkey}&account=${account}`,
width: 400,
height: 500,
});
} catch (e) {
throw new Error(String(e));
}
}
public async open_settings() {
try {
const label = "settings";
await invoke("open_window", {
label,
title: "Settings",
url: "/settings",
width: 800,
height: 500,
});
} catch (e) {
throw new Error(String(e));
}
}
public async open_search() {
try {
const label = "search";
await invoke("open_window", {
label,
title: "Search",
url: "/search",
width: 400,
height: 600,
});
} catch (e) {
throw new Error(String(e));
}
}
public async open_activity(account: string) {
try {
const label = "activity";
await invoke("open_window", {
label,
title: "Activity",
url: `/activity/${account}/texts`,
width: 400,
height: 600,
});
} catch (e) {
throw new Error(String(e));
}
}
}
-24
View File
@@ -1,24 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { invoke } from "@tauri-apps/api/core";
export function usePreview(url: string) {
const { isLoading, isError, data } = useQuery({
queryKey: ["url", url],
queryFn: async () => {
try {
const cmd = await invoke("fetch_opg", { url });
console.log(cmd);
return cmd;
} catch (e) {
throw new Error(e);
}
},
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
staleTime: Number.POSITIVE_INFINITY,
retry: 2,
});
return { isLoading, isError, data };
}

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