Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6996e30889 | ||
|
|
7ba793fad8 | ||
|
|
f11f836518 | ||
|
|
04fe0fcec8 | ||
|
|
799835a629 | ||
|
|
4e7da4108b | ||
|
|
7c7b082b3a | ||
|
|
38d6c51921 | ||
|
|
1738cbdd97 | ||
|
|
2e885b76a1 | ||
|
|
f94680e487 | ||
|
|
c682a58842 | ||
|
|
921cf871ee | ||
|
|
d5b1593aca | ||
|
|
6676b4e2a4 | ||
|
|
5f30ddcfca | ||
|
|
41d0de539d | ||
|
|
e254ee3203 | ||
|
|
6d42360549 | ||
|
|
70c5143445 | ||
|
|
41b66b18f5 | ||
|
|
dda0720ed4 | ||
|
|
4b60b39119 | ||
|
|
d2e5122d5a | ||
|
|
32f3315344 | ||
|
|
5ca9444358 | ||
|
|
4dc13385a5 | ||
|
|
b90ad1421f | ||
|
|
bba324ea53 | ||
|
|
7449000f5f | ||
|
|
dc7762ca11 | ||
|
|
3a3f960dde | ||
|
|
12e066ff2e | ||
|
|
fe4f965ed5 |
2
.github/workflows/main.yml
vendored
@@ -16,6 +16,8 @@ jobs:
|
||||
args: "--target aarch64-apple-darwin"
|
||||
- platform: "macos-latest" # for Intel based macs.
|
||||
args: "--target x86_64-apple-darwin"
|
||||
- platform: "macos-latest" # for Intel based macs.
|
||||
args: "--target universal-apple-darwin"
|
||||
#- platform: 'ubuntu-22.04'
|
||||
# args: ''
|
||||
#- platform: 'windows-latest'
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lume/ark": "workspace:^",
|
||||
"@lume/icons": "workspace:^",
|
||||
"@lume/system": "workspace:^",
|
||||
"@lume/ui": "workspace:^",
|
||||
"@lume/utils": "workspace:^",
|
||||
"@radix-ui/react-avatar": "^1.0.4",
|
||||
@@ -21,43 +21,43 @@
|
||||
"@radix-ui/react-hover-card": "^1.0.7",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-switch": "^1.0.3",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@tanstack/query-sync-storage-persister": "^5.36.0",
|
||||
"@tanstack/react-query": "^5.36.0",
|
||||
"@tanstack/react-query-persist-client": "^5.36.0",
|
||||
"@tanstack/react-router": "1.32.5",
|
||||
"i18next": "^23.11.4",
|
||||
"@tanstack/query-sync-storage-persister": "^5.40.0",
|
||||
"@tanstack/react-query": "^5.40.0",
|
||||
"@tanstack/react-query-persist-client": "^5.40.0",
|
||||
"@tanstack/react-router": "^1.34.9",
|
||||
"i18next": "^23.11.5",
|
||||
"i18next-resources-to-backend": "^1.2.1",
|
||||
"minidenticons": "^4.2.1",
|
||||
"nanoid": "^5.0.7",
|
||||
"nostr-tools": "^2.5.2",
|
||||
"react": "^18.3.1",
|
||||
"react-currency-input-field": "^3.8.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.51.4",
|
||||
"react-hook-form": "^7.51.5",
|
||||
"react-hotkeys-hook": "^4.5.0",
|
||||
"react-i18next": "^14.1.1",
|
||||
"react-i18next": "^14.1.2",
|
||||
"react-string-replace": "^1.1.1",
|
||||
"slate": "^0.103.0",
|
||||
"slate-react": "^0.102.0",
|
||||
"slate-react": "^0.104.0",
|
||||
"sonner": "^1.4.41",
|
||||
"use-debounce": "^10.0.0",
|
||||
"use-debounce": "^10.0.1",
|
||||
"virtua": "^0.31.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lume/tailwindcss": "workspace:^",
|
||||
"@lume/tsconfig": "workspace:^",
|
||||
"@lume/types": "workspace:^",
|
||||
"@tanstack/router-devtools": "^1.32.5",
|
||||
"@tanstack/router-vite-plugin": "^1.32.2",
|
||||
"@types/react": "^18.3.2",
|
||||
"@tanstack/router-devtools": "^1.34.9",
|
||||
"@tanstack/router-vite-plugin": "^1.34.8",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react-swc": "^3.6.0",
|
||||
"@vitejs/plugin-react-swc": "^3.7.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.2.11",
|
||||
"vite": "^5.2.12",
|
||||
"vite-plugin-top-level-await": "^1.4.1",
|
||||
"vite-tsconfig-paths": "^4.3.2"
|
||||
}
|
||||
|
||||
BIN
apps/desktop2/public/404.jpg
Normal file
|
After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 296 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 186 KiB |
BIN
apps/desktop2/public/icon.jpeg
Normal file
|
After Width: | Height: | Size: 163 KiB |
|
Before Width: | Height: | Size: 310 KiB |
|
Before Width: | Height: | Size: 951 KiB |
BIN
apps/desktop2/public/poster_1.jpeg
Normal file
|
After Width: | Height: | Size: 171 KiB |
BIN
apps/desktop2/public/poster_2.jpeg
Normal file
|
After Width: | Height: | Size: 201 KiB |
BIN
apps/desktop2/public/poster_3.jpeg
Normal file
|
After Width: | Height: | Size: 170 KiB |
BIN
apps/desktop2/public/poster_4.jpeg
Normal file
|
After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 257 KiB |
@@ -1,20 +1,17 @@
|
||||
import { Ark } from "@lume/ark";
|
||||
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||
import { platform } from "@tauri-apps/plugin-os";
|
||||
import React, { StrictMode } from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import "./app.css";
|
||||
import i18n from "./locale";
|
||||
import { routeTree } from "./router.gen"; // auto generated file
|
||||
import { type } from "@tauri-apps/plugin-os";
|
||||
|
||||
const ark = new Ark();
|
||||
const os = await type();
|
||||
const queryClient = new QueryClient();
|
||||
const platformName = await platform();
|
||||
|
||||
const persister = createSyncStoragePersister({
|
||||
storage: window.localStorage,
|
||||
});
|
||||
@@ -23,9 +20,8 @@ const persister = createSyncStoragePersister({
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
context: {
|
||||
ark,
|
||||
queryClient,
|
||||
platform: platformName,
|
||||
platform: os,
|
||||
},
|
||||
Wrap: ({ children }) => {
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NostrQuery } from "@lume/system";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { cn } from "@lume/utils";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import {
|
||||
type Dispatch,
|
||||
type ReactNode,
|
||||
@@ -18,21 +18,17 @@ export function AvatarUploader({
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const uploadAvatar = async () => {
|
||||
// start loading
|
||||
setLoading(true);
|
||||
try {
|
||||
const image = await ark.upload();
|
||||
setLoading(true);
|
||||
const image = await NostrQuery.upload();
|
||||
setPicture(image);
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
toast.error(String(e));
|
||||
}
|
||||
|
||||
// stop loading
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { User } from "@/components/user";
|
||||
import { NostrAccount } from "@lume/system";
|
||||
import { getBitcoinDisplayValues } from "@lume/utils";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
export function Balance({ account }: { account: string }) {
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
const [balance, setBalance] = useState(0);
|
||||
const value = useMemo(() => getBitcoinDisplayValues(balance), [balance]);
|
||||
|
||||
useEffect(() => {
|
||||
async function getBalance() {
|
||||
const val = await ark.get_balance();
|
||||
const val = await NostrAccount.getBalance();
|
||||
setBalance(val);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { LumeColumn } from "@lume/types";
|
||||
import { cn } from "@lume/utils";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { getCurrent } from "@tauri-apps/api/webviewWindow";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
export function Column({
|
||||
column,
|
||||
@@ -17,7 +17,10 @@ export function Column({
|
||||
isResize: boolean;
|
||||
}) {
|
||||
const container = useRef<HTMLDivElement>(null);
|
||||
const webviewLabel = `column-${account}_${column.label}`;
|
||||
const webviewLabel = useMemo(
|
||||
() => `column-${account}_${column.label}`,
|
||||
[account],
|
||||
);
|
||||
|
||||
const [isCreated, setIsCreated] = useState(false);
|
||||
|
||||
@@ -48,6 +51,8 @@ export function Column({
|
||||
}, [isScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!container?.current) return;
|
||||
|
||||
const rect = container.current.getBoundingClientRect();
|
||||
const url = `${column.content}?account=${account}&label=${column.label}&name=${column.name}`;
|
||||
|
||||
@@ -59,11 +64,16 @@ export function Column({
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
url,
|
||||
}).then(() => setIsCreated(true));
|
||||
}).then(() => {
|
||||
console.log("created: ", webviewLabel);
|
||||
setIsCreated(true);
|
||||
});
|
||||
|
||||
// close webview when unmounted
|
||||
return () => {
|
||||
invoke("close_column", { label: webviewLabel });
|
||||
invoke("close_column", { label: webviewLabel }).then(() => {
|
||||
console.log("closed: ", webviewLabel);
|
||||
});
|
||||
};
|
||||
}, [account]);
|
||||
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import { ThreadIcon } from "@lume/icons";
|
||||
import type { Event } from "@lume/types";
|
||||
import type { NostrEvent } from "@lume/types";
|
||||
import { Note } from "@/components/note";
|
||||
import { cn } from "@lume/utils";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { LumeEvent } from "@lume/system";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export function Conversation({
|
||||
event,
|
||||
gossip,
|
||||
className,
|
||||
}: {
|
||||
event: Event;
|
||||
event: NostrEvent;
|
||||
gossip?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
const thread = ark.get_thread(event.tags);
|
||||
const thread = useMemo(
|
||||
() => LumeEvent.getEventThread(event.tags, gossip),
|
||||
[event],
|
||||
);
|
||||
|
||||
return (
|
||||
<Note.Provider event={event}>
|
||||
|
||||
@@ -4,9 +4,7 @@ import { User } from "../user";
|
||||
|
||||
export function NoteActivity({ className }: { className?: string }) {
|
||||
const event = useNoteContext();
|
||||
const mentions = event.tags
|
||||
.filter((tag) => tag[0] === "p")
|
||||
.map((tag) => tag[1]);
|
||||
const mentions = event.mentions;
|
||||
|
||||
return (
|
||||
<div className={cn("-mt-3 mb-2", className)}>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { VisitIcon } from "@lume/icons";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { useNoteContext } from "../provider";
|
||||
import { LumeWindow } from "@lume/system";
|
||||
|
||||
export function NoteOpenThread() {
|
||||
const event = useNoteContext();
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
@@ -13,7 +12,7 @@ export function NoteOpenThread() {
|
||||
<Tooltip.Trigger asChild>
|
||||
<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"
|
||||
>
|
||||
<VisitIcon className="shrink-0 size-4" />
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { ReplyIcon } from "@lume/icons";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { useNoteContext } from "../provider";
|
||||
import { cn } from "@lume/utils";
|
||||
import { LumeWindow } from "@lume/system";
|
||||
|
||||
export function NoteReply({ large = false }: { large?: boolean }) {
|
||||
const event = useNoteContext();
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
@@ -14,7 +13,7 @@ export function NoteReply({ large = false }: { large?: boolean }) {
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => ark.open_editor(event.id)}
|
||||
onClick={() => LumeWindow.openEditor(event.id)}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
|
||||
large
|
||||
|
||||
@@ -2,15 +2,14 @@ import { QuoteIcon, RepostIcon } from "@lume/icons";
|
||||
import { cn } from "@lume/utils";
|
||||
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { useNoteContext } from "../provider";
|
||||
import { LumeWindow } from "@lume/system";
|
||||
|
||||
export function NoteRepost({ large = false }: { large?: boolean }) {
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
const event = useNoteContext();
|
||||
|
||||
const [t] = useTranslation();
|
||||
@@ -23,7 +22,7 @@ export function NoteRepost({ large = false }: { large?: boolean }) {
|
||||
setLoading(true);
|
||||
|
||||
// repost
|
||||
await ark.repost(event.id, event.pubkey);
|
||||
await event.repost();
|
||||
|
||||
// update state
|
||||
setLoading(false);
|
||||
@@ -86,7 +85,7 @@ export function NoteRepost({ large = false }: { large?: boolean }) {
|
||||
<DropdownMenu.Item asChild>
|
||||
<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"
|
||||
>
|
||||
<QuoteIcon className="size-4" />
|
||||
|
||||
@@ -1,33 +1,19 @@
|
||||
import { ZapIcon } from "@lume/icons";
|
||||
import { useRouteContext, useSearch } from "@tanstack/react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { useNoteContext } from "../provider";
|
||||
import { cn } from "@lume/utils";
|
||||
import { LumeWindow } from "@lume/system";
|
||||
|
||||
export function NoteZap({ large = false }: { large?: boolean }) {
|
||||
const event = useNoteContext();
|
||||
const { ark, 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));
|
||||
}
|
||||
};
|
||||
const { settings } = useRouteContext({ strict: false });
|
||||
|
||||
if (!settings.zap) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => zap()}
|
||||
onClick={() => LumeWindow.openZap(event.id, event.pubkey)}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
|
||||
large
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEvent } from "@lume/ark";
|
||||
import { useEvent } from "@lume/system";
|
||||
import { cn } from "@lume/utils";
|
||||
import { Note } from ".";
|
||||
import { InfoIcon } from "@lume/icons";
|
||||
|
||||
@@ -102,7 +102,7 @@ export function NoteContent({
|
||||
<div className="flex flex-col gap-2">
|
||||
<div
|
||||
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" : "",
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -145,7 +145,7 @@ export function NoteContentLarge({
|
||||
|
||||
return (
|
||||
<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}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ export function Hashtag({ tag }: { tag: string }) {
|
||||
return (
|
||||
<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="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 { useRouteContext } from "@tanstack/react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@lume/utils";
|
||||
import { User } from "@/components/user";
|
||||
@@ -13,7 +12,6 @@ export function MentionNote({
|
||||
eventId: string;
|
||||
openable?: boolean;
|
||||
}) {
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
const { t } = useTranslation();
|
||||
const { isLoading, isError, data } = useEvent(eventId);
|
||||
|
||||
@@ -50,7 +48,7 @@ export function MentionNote({
|
||||
</User.Provider>
|
||||
<div
|
||||
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" : "",
|
||||
)}
|
||||
>
|
||||
@@ -62,9 +60,9 @@ export function MentionNote({
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
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
|
||||
<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 { useRouteContext } from "@tanstack/react-router";
|
||||
|
||||
export function MentionUser({ pubkey }: { pubkey: string }) {
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
const { isLoading, isError, profile } = useProfile(pubkey);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => ark.open_profile(pubkey)}
|
||||
onClick={() => LumeWindow.openProfile(pubkey)}
|
||||
className="break-words text-start text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
{isLoading
|
||||
? "@anon"
|
||||
: isError
|
||||
? displayNpub(pubkey, 16)
|
||||
: `@${
|
||||
profile?.name || profile?.display_name || profile?.name || "anon"
|
||||
}`}
|
||||
: `@${profile?.name || profile?.display_name || "anon"}`}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,37 +1,28 @@
|
||||
import { HorizontalDotsIcon } from "@lume/icons";
|
||||
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useNoteContext } from "./provider";
|
||||
import { LumeWindow } from "@lume/system";
|
||||
|
||||
export function NoteMenu() {
|
||||
const { t } = useTranslation();
|
||||
const event = useNoteContext();
|
||||
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
const { t } = useTranslation();
|
||||
|
||||
const copyID = async () => {
|
||||
await writeText(await ark.event_to_bech32(event.id, [""]));
|
||||
toast.success("Copied");
|
||||
await writeText(await event.idAsBech32());
|
||||
};
|
||||
|
||||
const copyRaw = async () => {
|
||||
await writeText(JSON.stringify(event));
|
||||
toast.success("Copied");
|
||||
};
|
||||
|
||||
const copyNpub = async () => {
|
||||
await writeText(await ark.user_to_bech32(event.pubkey, [""]));
|
||||
toast.success("Copied");
|
||||
await writeText(await event.pubkeyAsBech32());
|
||||
};
|
||||
|
||||
const copyLink = async () => {
|
||||
await writeText(
|
||||
`https://njump.me/${await ark.event_to_bech32(event.id, [""])}`,
|
||||
);
|
||||
toast.success("Copied");
|
||||
await writeText(`https://njump.me/${await event.idAsBech32()}`);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -49,7 +40,7 @@ export function NoteMenu() {
|
||||
<DropdownMenu.Item asChild>
|
||||
<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"
|
||||
>
|
||||
{t("note.menu.viewThread")}
|
||||
@@ -84,7 +75,7 @@ export function NoteMenu() {
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item asChild>
|
||||
<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"
|
||||
>
|
||||
{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 { download } from "@tauri-apps/plugin-upload";
|
||||
import { type SyntheticEvent, useState } from "react";
|
||||
|
||||
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 }) => {
|
||||
try {
|
||||
e.stopPropagation();
|
||||
if (!window) {
|
||||
const newWindow = new WebviewWindow(label, {
|
||||
url,
|
||||
title: "Image Viewer",
|
||||
width: 800,
|
||||
height: 800,
|
||||
titleBarStyle: "overlay",
|
||||
});
|
||||
|
||||
const downloadDirPath = await downloadDir();
|
||||
const filename = url.substring(url.lastIndexOf("/") + 1);
|
||||
await download(url, `${downloadDirPath}/${filename}`);
|
||||
|
||||
setDownloaded(true);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return newWindow;
|
||||
}
|
||||
};
|
||||
|
||||
const open = async () => {
|
||||
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 await window.setFocus();
|
||||
};
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/useKeyWithClickEvents: <explanation>
|
||||
<div onClick={() => open()} className="group relative my-1">
|
||||
<div className="group relative my-1">
|
||||
<img
|
||||
src={url}
|
||||
alt={url}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ contentVisibility: "auto" }}
|
||||
onError={fallback}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,6 +36,10 @@ export function Images({ urls }: { urls: string[] }) {
|
||||
style={{ contentVisibility: "auto" }}
|
||||
className="max-h-[400px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||
onClick={() => open(urls[0])}
|
||||
onError={({ currentTarget }) => {
|
||||
currentTarget.onerror = null;
|
||||
currentTarget.src = "/404.jpg";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -54,6 +58,10 @@ export function Images({ urls }: { urls: string[] }) {
|
||||
style={{ contentVisibility: "auto" }}
|
||||
className="w-full h-full object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||
onClick={() => open(item)}
|
||||
onError={({ currentTarget }) => {
|
||||
currentTarget.onerror = null;
|
||||
currentTarget.src = "/404.jpg";
|
||||
}}
|
||||
/>
|
||||
</CarouselItem>
|
||||
)}
|
||||
|
||||
@@ -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";
|
||||
|
||||
const EventContext = createContext<Event>(null);
|
||||
const NoteContext = createContext<LumeEvent>(null);
|
||||
|
||||
export function NoteProvider({
|
||||
event,
|
||||
children,
|
||||
}: { event: Event; children: ReactNode }) {
|
||||
}: {
|
||||
event: NostrEvent;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const lumeEvent = new LumeEvent(event);
|
||||
|
||||
return (
|
||||
<EventContext.Provider value={event}>{children}</EventContext.Provider>
|
||||
<NoteContext.Provider value={lumeEvent}>{children}</NoteContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useNoteContext() {
|
||||
const context = useContext(EventContext);
|
||||
const context = useContext(NoteContext);
|
||||
if (!context) {
|
||||
throw new Error("Please import Note Provider to use useNoteContext() hook");
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { cn } from "@lume/utils";
|
||||
import * as HoverCard from "@radix-ui/react-hover-card";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { User } from "../user";
|
||||
import { useNoteContext } from "./provider";
|
||||
import { LumeWindow } from "@lume/system";
|
||||
|
||||
export function NoteUser({ className }: { className?: string }) {
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
const event = useNoteContext();
|
||||
|
||||
return (
|
||||
@@ -46,7 +45,7 @@ export function NoteUser({ className }: { className?: string }) {
|
||||
</div>
|
||||
<User.About className="line-clamp-3 text-sm text-white dark:text-neutral-900" />
|
||||
<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"
|
||||
>
|
||||
View profile
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Event } from "@lume/types";
|
||||
import type { NostrEvent } from "@lume/types";
|
||||
import { Note } from "@/components/note";
|
||||
import { cn } from "@lume/utils";
|
||||
|
||||
@@ -6,7 +6,7 @@ export function Notification({
|
||||
event,
|
||||
className,
|
||||
}: {
|
||||
event: Event;
|
||||
event: NostrEvent;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { QuoteIcon } from "@lume/icons";
|
||||
import type { Event } from "@lume/types";
|
||||
import type { NostrEvent } from "@lume/types";
|
||||
import { Note } from "@/components/note";
|
||||
import { cn } from "@lume/utils";
|
||||
|
||||
@@ -7,7 +7,7 @@ export function Quote({
|
||||
event,
|
||||
className,
|
||||
}: {
|
||||
event: Event;
|
||||
event: NostrEvent;
|
||||
className?: string;
|
||||
}) {
|
||||
const quoteEventId = event.tags.find(
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import type { Event } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { Note } from "@/components/note";
|
||||
import { User } from "@/components/user";
|
||||
import { cn } from "@lume/utils";
|
||||
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({
|
||||
event,
|
||||
className,
|
||||
}: {
|
||||
event: Event;
|
||||
event: NostrEvent;
|
||||
className?: string;
|
||||
}) {
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
const {
|
||||
isLoading,
|
||||
isError,
|
||||
@@ -23,12 +22,12 @@ export function RepostNote({
|
||||
queryFn: async () => {
|
||||
try {
|
||||
if (event.content.length > 50) {
|
||||
const embed: Event = JSON.parse(event.content);
|
||||
const embed: NostrEvent = JSON.parse(event.content);
|
||||
return embed;
|
||||
}
|
||||
|
||||
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;
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Event } from "@lume/types";
|
||||
import type { NostrEvent } from "@lume/types";
|
||||
import { cn } from "@lume/utils";
|
||||
import { Note } from "@/components/note";
|
||||
|
||||
@@ -6,7 +6,7 @@ export function TextNote({
|
||||
event,
|
||||
className,
|
||||
}: {
|
||||
event: Event;
|
||||
event: NostrEvent;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
|
||||
@@ -23,7 +23,7 @@ export function UserAvatar({ className }: { className?: string }) {
|
||||
alt={user.pubkey}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
className={cn("outline-[.5px] outline-black/5", className)}
|
||||
className={cn("outline-[.5px] outline-black/5 object-cover", className)}
|
||||
/>
|
||||
<Avatar.Fallback delayMs={120}>
|
||||
<img
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { cn } from "@lume/utils";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { useUserContext } from "./provider";
|
||||
import { NostrAccount } from "@lume/system";
|
||||
|
||||
export function UserFollowButton({
|
||||
simple = false,
|
||||
@@ -12,7 +12,6 @@ export function UserFollowButton({
|
||||
simple?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
const user = useUserContext();
|
||||
|
||||
const [t] = useTranslation();
|
||||
@@ -22,10 +21,10 @@ export function UserFollowButton({
|
||||
const toggleFollow = async () => {
|
||||
setLoading(true);
|
||||
if (!followed) {
|
||||
const add = await ark.follow(user.pubkey);
|
||||
const add = await NostrAccount.follow(user.pubkey, user.profile?.name);
|
||||
if (add) setFollowed(true);
|
||||
} else {
|
||||
const remove = await ark.unfollow(user.pubkey);
|
||||
const remove = await NostrAccount.unfollow(user.pubkey);
|
||||
if (remove) setFollowed(false);
|
||||
}
|
||||
setLoading(false);
|
||||
@@ -35,7 +34,7 @@ export function UserFollowButton({
|
||||
async function status() {
|
||||
setLoading(true);
|
||||
|
||||
const contacts = await ark.get_contact_list();
|
||||
const contacts = await NostrAccount.getContactList();
|
||||
if (contacts?.includes(user.pubkey)) {
|
||||
setFollowed(true);
|
||||
}
|
||||
|
||||
@@ -2,21 +2,21 @@ import { VerifiedIcon } from "@lume/icons";
|
||||
import { displayLongHandle, displayNpub } from "@lume/utils";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { useUserContext } from "./provider";
|
||||
import { NostrQuery } from "@lume/system";
|
||||
|
||||
export function UserNip05() {
|
||||
const user = useUserContext();
|
||||
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
const { isLoading, data: verified } = useQuery({
|
||||
queryKey: ["nip05", user?.pubkey],
|
||||
queryFn: async () => {
|
||||
if (!user.profile?.nip05) return false;
|
||||
const verify = await ark.verify_nip05(user.pubkey, user.profile?.nip05);
|
||||
const verify = await NostrQuery.verifyNip05(
|
||||
user.pubkey,
|
||||
user.profile?.nip05,
|
||||
);
|
||||
return verify;
|
||||
},
|
||||
enabled: !!user.profile,
|
||||
enabled: !!user.profile?.nip05,
|
||||
});
|
||||
|
||||
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 ReactNode, createContext, useContext } from "react";
|
||||
|
||||
const UserContext = createContext<{
|
||||
pubkey: string;
|
||||
profile: Metadata;
|
||||
isError: boolean;
|
||||
isLoading: boolean;
|
||||
profile: Metadata;
|
||||
}>(null);
|
||||
|
||||
export function UserProvider({
|
||||
@@ -21,7 +21,7 @@ export function UserProvider({
|
||||
const { isLoading, isError, profile } = useProfile(pubkey, embedProfile);
|
||||
|
||||
return (
|
||||
<UserContext.Provider value={{ pubkey, isError, isLoading, profile }}>
|
||||
<UserContext.Provider value={{ pubkey, profile, isError, isLoading }}>
|
||||
{children}
|
||||
</UserContext.Provider>
|
||||
);
|
||||
|
||||
@@ -1,50 +1,41 @@
|
||||
import { Column } from "@/components/column";
|
||||
import { Toolbar } from "@/components/toolbar";
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "@lume/icons";
|
||||
import { NostrQuery } from "@lume/system";
|
||||
import type { EventColumns, LumeColumn } from "@lume/types";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { resolveResource } from "@tauri-apps/api/path";
|
||||
import { getCurrent } from "@tauri-apps/api/window";
|
||||
import { readTextFile } from "@tauri-apps/plugin-fs";
|
||||
import { nanoid } from "nanoid";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import { VList, type VListHandle } from "virtua";
|
||||
|
||||
export const Route = createFileRoute("/$account/home")({
|
||||
loader: async ({ context }) => {
|
||||
try {
|
||||
const userColumns = await context.ark.get_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));
|
||||
}
|
||||
loader: async () => {
|
||||
const columns = await NostrQuery.getColumns();
|
||||
return columns;
|
||||
},
|
||||
gcTime: 0,
|
||||
shouldReload: false,
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const userSavedColumns = Route.useLoaderData();
|
||||
const { account } = Route.useParams();
|
||||
const initialColumnList = Route.useLoaderData();
|
||||
const vlistRef = useRef<VListHandle>(null);
|
||||
|
||||
const { account } = Route.useParams();
|
||||
const { ark } = Route.useRouteContext();
|
||||
|
||||
const [columns, setColumns] = useState<LumeColumn[]>([]);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const [columns, setColumns] = useState([]);
|
||||
const [isScroll, setIsScroll] = useState(false);
|
||||
const [isResize, setIsResize] = useState(false);
|
||||
|
||||
const reset = () => {
|
||||
setColumns(null);
|
||||
setSelectedIndex(-1);
|
||||
};
|
||||
|
||||
const goLeft = () => {
|
||||
const prevIndex = Math.max(selectedIndex - 1, 0);
|
||||
setSelectedIndex(prevIndex);
|
||||
@@ -80,7 +71,7 @@ function Screen() {
|
||||
|
||||
// scroll to the newest column
|
||||
vlistRef.current.scrollToIndex(newCols.length - 1, {
|
||||
align: "end",
|
||||
align: "center",
|
||||
});
|
||||
}, 150);
|
||||
|
||||
@@ -115,16 +106,19 @@ function Screen() {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setColumns(userSavedColumns);
|
||||
}, [userSavedColumns]);
|
||||
setColumns(initialColumnList);
|
||||
}, [initialColumnList]);
|
||||
|
||||
useEffect(() => {
|
||||
// save state
|
||||
ark.set_columns(columns);
|
||||
// save current state
|
||||
if (columns?.length) {
|
||||
NostrQuery.setColumns(columns).then(() => console.log("saved"));
|
||||
}
|
||||
}, [columns]);
|
||||
|
||||
useEffect(() => {
|
||||
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 === "remove") remove(data.payload.label);
|
||||
if (data.payload.type === "set_title")
|
||||
@@ -148,15 +142,14 @@ function Screen() {
|
||||
horizontal
|
||||
tabIndex={-1}
|
||||
itemSize={440}
|
||||
overscan={3}
|
||||
overscan={5}
|
||||
onScroll={() => setIsScroll(true)}
|
||||
onScrollEnd={() => setIsScroll(false)}
|
||||
className="scrollbar-none h-full w-full overflow-x-auto focus:outline-none"
|
||||
cache={null}
|
||||
>
|
||||
{columns.map((column) => (
|
||||
{columns?.map((column) => (
|
||||
<Column
|
||||
key={column.label}
|
||||
key={account + column.label}
|
||||
column={column}
|
||||
account={account}
|
||||
isScroll={isScroll}
|
||||
|
||||
@@ -1,38 +1,29 @@
|
||||
import {
|
||||
BellIcon,
|
||||
ComposeFilledIcon,
|
||||
HorizontalDotsIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
} from "@lume/icons";
|
||||
import { type Event, Kind } from "@lume/types";
|
||||
import { User } from "@/components/user";
|
||||
import {
|
||||
cn,
|
||||
decodeZapInvoice,
|
||||
displayNpub,
|
||||
sendNativeNotification,
|
||||
} from "@lume/utils";
|
||||
import { cn } from "@lume/utils";
|
||||
import { Outlet, createFileRoute } from "@tanstack/react-router";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { getCurrent } from "@tauri-apps/api/window";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
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")({
|
||||
beforeLoad: async ({ context }) => {
|
||||
const ark = context.ark;
|
||||
const accounts = await ark.get_accounts();
|
||||
|
||||
beforeLoad: async () => {
|
||||
const accounts = await NostrAccount.getAccounts();
|
||||
return { accounts };
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { ark, platform } = Route.useRouteContext();
|
||||
const navigate = Route.useNavigate();
|
||||
const { platform } = Route.useRouteContext();
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen flex-col">
|
||||
@@ -45,27 +36,25 @@ function Screen() {
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Accounts />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate({ to: "/landing/" })}
|
||||
<Link
|
||||
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"
|
||||
>
|
||||
<PlusIcon className="size-5" />
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<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"
|
||||
>
|
||||
<ComposeFilledIcon className="size-4" />
|
||||
New Post
|
||||
</button>
|
||||
<Bell />
|
||||
<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"
|
||||
>
|
||||
<SearchIcon className="size-5" />
|
||||
@@ -81,7 +70,7 @@ function Screen() {
|
||||
}
|
||||
|
||||
function Accounts() {
|
||||
const { ark, accounts } = Route.useRouteContext();
|
||||
const { accounts } = Route.useRouteContext();
|
||||
const { account } = Route.useParams();
|
||||
|
||||
const [windowWidth, setWindowWidth] = useState<number>(null);
|
||||
@@ -102,14 +91,23 @@ function Accounts() {
|
||||
|
||||
const changeAccount = async (npub: string) => {
|
||||
if (npub === account) {
|
||||
return await ark.open_profile(account);
|
||||
return await LumeWindow.openProfile(account);
|
||||
}
|
||||
|
||||
// change current account and update signer
|
||||
const select = await ark.load_account(npub);
|
||||
// Change current account and update signer
|
||||
const select = await NostrAccount.loadAccount(npub);
|
||||
|
||||
if (select) {
|
||||
return navigate({ to: "/$account/home", params: { account: npub } });
|
||||
// Reset current columns
|
||||
await getCurrent().emit("columns", { type: "reset" });
|
||||
|
||||
// Redirect to new account
|
||||
return navigate({
|
||||
to: "/$account/home",
|
||||
params: { account: npub },
|
||||
resetScroll: true,
|
||||
replace: true,
|
||||
});
|
||||
} else {
|
||||
toast.warning("Something wrong.");
|
||||
}
|
||||
@@ -188,66 +186,3 @@ function Accounts() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,35 +1,21 @@
|
||||
import type { Ark } from "@lume/ark";
|
||||
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 type { QueryClient } from "@tanstack/react-query";
|
||||
import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
|
||||
import type { Platform } from "@tauri-apps/plugin-os";
|
||||
import type { Descendant } from "slate";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
type EditorElement = {
|
||||
type: string;
|
||||
children: Descendant[];
|
||||
eventId?: string;
|
||||
};
|
||||
|
||||
interface RouterContext {
|
||||
// System
|
||||
ark: Ark;
|
||||
queryClient: QueryClient;
|
||||
// App info
|
||||
platform?: Platform;
|
||||
locale?: string;
|
||||
// Settings
|
||||
settings?: Settings;
|
||||
interests?: Interests;
|
||||
// Profile
|
||||
// Accounts
|
||||
accounts?: string[];
|
||||
profile?: Metadata;
|
||||
isNewUser?: boolean;
|
||||
// Editor
|
||||
initialValue?: EditorElement[];
|
||||
}
|
||||
|
||||
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 type { AppRouteSearch } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { displayNsec } from "@lume/utils";
|
||||
import * as Checkbox from "@radix-ui/react-checkbox";
|
||||
@@ -10,17 +9,12 @@ import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/auth/new/backup")({
|
||||
validateSearch: (search: Record<string, string>): AppRouteSearch => {
|
||||
return {
|
||||
account: search.account,
|
||||
};
|
||||
},
|
||||
export const Route = createFileRoute("/auth/$account/backup")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { account } = Route.useSearch();
|
||||
const { account } = Route.useParams();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [key, setKey] = useState(null);
|
||||
@@ -39,8 +33,8 @@ function Screen() {
|
||||
}
|
||||
|
||||
return navigate({
|
||||
to: "/auth/settings",
|
||||
search: { account },
|
||||
to: "/auth/$account/settings",
|
||||
params: { account },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,59 +1,30 @@
|
||||
import { LaurelIcon } from "@lume/icons";
|
||||
import type { AppRouteSearch, Settings } from "@lume/types";
|
||||
import { NostrQuery } from "@lume/system";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import * as Switch from "@radix-ui/react-switch";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import {
|
||||
isPermissionGranted,
|
||||
requestPermission,
|
||||
} from "@tauri-apps/plugin-notification";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/auth/settings")({
|
||||
validateSearch: (search: Record<string, string>): AppRouteSearch => {
|
||||
return {
|
||||
account: search.account,
|
||||
};
|
||||
},
|
||||
beforeLoad: async ({ context }) => {
|
||||
const permissionGranted = await isPermissionGranted(); // get notification permission
|
||||
const ark = context.ark;
|
||||
const settings = await ark.get_settings();
|
||||
|
||||
return {
|
||||
settings: { ...settings, notification: permissionGranted },
|
||||
};
|
||||
export const Route = createFileRoute("/auth/$account/settings")({
|
||||
beforeLoad: async () => {
|
||||
const settings = await NostrQuery.getSettings();
|
||||
return { settings };
|
||||
},
|
||||
component: Screen,
|
||||
pendingComponent: Pending,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { account } = Route.useSearch();
|
||||
const { settings } = Route.useRouteContext();
|
||||
const { account } = Route.useParams();
|
||||
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 toggleNofitication = async () => {
|
||||
await requestPermission();
|
||||
setNewSettings((prev) => ({
|
||||
...prev,
|
||||
notification: !newSettings.notification,
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleAutoUpdate = () => {
|
||||
setNewSettings((prev) => ({
|
||||
...prev,
|
||||
autoUpdate: !newSettings.autoUpdate,
|
||||
}));
|
||||
};
|
||||
const navigate = Route.useNavigate();
|
||||
|
||||
const toggleEnhancedPrivacy = () => {
|
||||
setNewSettings((prev) => ({
|
||||
@@ -82,7 +53,7 @@ function Screen() {
|
||||
setLoading(true);
|
||||
|
||||
// publish settings
|
||||
const eventId = await ark.set_settings(newSettings);
|
||||
const eventId = await NostrQuery.setSettings(newSettings);
|
||||
|
||||
if (eventId) {
|
||||
return navigate({
|
||||
@@ -114,22 +85,6 @@ function Screen() {
|
||||
</div>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold">Push Notification</h3>
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
Enabling push notifications will allow you to receive
|
||||
notifications from Lume.
|
||||
</p>
|
||||
</div>
|
||||
<Switch.Root
|
||||
checked={newSettings.notification}
|
||||
onClick={() => toggleNofitication()}
|
||||
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/20"
|
||||
>
|
||||
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||
</Switch.Root>
|
||||
</div>
|
||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold">Enhanced Privacy</h3>
|
||||
@@ -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.Root>
|
||||
</div>
|
||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold">Auto Update</h3>
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
Automatically download and install new version.
|
||||
</p>
|
||||
</div>
|
||||
<Switch.Root
|
||||
checked={newSettings.autoUpdate}
|
||||
onClick={() => toggleAutoUpdate()}
|
||||
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/20"
|
||||
>
|
||||
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||
</Switch.Root>
|
||||
</div>
|
||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold">Zap</h3>
|
||||
@@ -200,7 +140,7 @@ function Screen() {
|
||||
disabled={loading}
|
||||
className="mb-1 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{t("global.continue")}
|
||||
{loading ? <Spinner /> : t("global.continue")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AvatarUploader } from "@/components/avatarUploader";
|
||||
import { PlusIcon } from "@lume/icons";
|
||||
import { NostrAccount } from "@lume/system";
|
||||
import type { Metadata } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
@@ -10,17 +11,17 @@ import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/auth/new/profile")({
|
||||
component: Screen,
|
||||
loader: ({ context }) => {
|
||||
return context.ark.create_keys();
|
||||
loader: async () => {
|
||||
const account = await NostrAccount.createAccount();
|
||||
return account;
|
||||
},
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const keys = Route.useLoaderData();
|
||||
const account = Route.useLoaderData();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { ark } = Route.useRouteContext();
|
||||
const { register, handleSubmit } = useForm();
|
||||
|
||||
const [picture, setPicture] = useState<string>("");
|
||||
@@ -35,17 +36,17 @@ function Screen() {
|
||||
|
||||
try {
|
||||
// Save account keys
|
||||
const save = await ark.save_account(keys.nsec);
|
||||
const save = await NostrAccount.saveAccount(account.nsec);
|
||||
|
||||
// Then create profile
|
||||
if (save) {
|
||||
const profile: Metadata = { ...data, picture };
|
||||
const eventId = await ark.create_profile(profile);
|
||||
const eventId = await NostrAccount.createProfile(profile);
|
||||
|
||||
if (eventId) {
|
||||
navigate({
|
||||
to: "/auth/new/backup",
|
||||
search: { account: keys.npub },
|
||||
to: "/auth/$account/backup",
|
||||
params: { account: account.npub },
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { NostrAccount } from "@lume/system";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
@@ -8,7 +9,6 @@ export const Route = createLazyFileRoute("/auth/privkey")({
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { ark } = Route.useRouteContext();
|
||||
const navigate = Route.useNavigate();
|
||||
|
||||
const [key, setKey] = useState("");
|
||||
@@ -24,12 +24,12 @@ function Screen() {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const npub = await ark.save_account(key, password);
|
||||
const npub = await NostrAccount.saveAccount(key, password);
|
||||
|
||||
if (npub) {
|
||||
navigate({
|
||||
to: "/auth/settings",
|
||||
search: { account: npub },
|
||||
to: "/auth/$account/settings",
|
||||
params: { account: npub },
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { NostrAccount } from "@lume/system";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
@@ -8,7 +9,6 @@ export const Route = createLazyFileRoute("/auth/remote")({
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { ark } = Route.useRouteContext();
|
||||
const navigate = Route.useNavigate();
|
||||
|
||||
const [uri, setUri] = useState("");
|
||||
@@ -23,12 +23,12 @@ function Screen() {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const npub = await ark.nostr_connect(uri);
|
||||
const remoteAccount = await NostrAccount.connectRemoteAccount(uri);
|
||||
|
||||
if (npub) {
|
||||
navigate({
|
||||
to: "/auth/settings",
|
||||
search: { account: npub },
|
||||
if (remoteAccount?.length) {
|
||||
return navigate({
|
||||
to: "/auth/$account/settings",
|
||||
params: { account: remoteAccount },
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { CancelIcon, CheckCircleIcon, PlusIcon } from "@lume/icons";
|
||||
import { CancelIcon, PlusIcon } from "@lume/icons";
|
||||
import type { ColumnRouteSearch } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { User } from "@/components/user";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { NostrAccount, NostrQuery } from "@lume/system";
|
||||
|
||||
export const Route = createFileRoute("/create-group")({
|
||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||
@@ -14,21 +15,14 @@ export const Route = createFileRoute("/create-group")({
|
||||
name: search.name,
|
||||
};
|
||||
},
|
||||
loader: async ({ context }) => {
|
||||
const ark = context.ark;
|
||||
const contacts = await ark.get_contact_list();
|
||||
loader: async () => {
|
||||
const contacts = await NostrAccount.getContactList();
|
||||
return contacts;
|
||||
},
|
||||
component: 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 [npub, setNpub] = useState("");
|
||||
const [users, setUsers] = useState<string[]>([
|
||||
@@ -36,6 +30,10 @@ function Screen() {
|
||||
]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const contacts = Route.useLoaderData();
|
||||
const search = Route.useSearch();
|
||||
const navigate = Route.useNavigate();
|
||||
|
||||
const toggleUser = (pubkey: string) => {
|
||||
setUsers((prev) =>
|
||||
prev.includes(pubkey)
|
||||
@@ -56,11 +54,14 @@ function Screen() {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const key = `lume_group_${label}`;
|
||||
const createGroup = await ark.set_nstore(key, JSON.stringify(users));
|
||||
const key = `lume_group_${search.label}`;
|
||||
const createGroup = await NostrQuery.setNstore(
|
||||
key,
|
||||
JSON.stringify(users),
|
||||
);
|
||||
|
||||
if (createGroup) {
|
||||
return navigate({ to: redirect });
|
||||
return navigate({ to: search.redirect, search: { ...search } });
|
||||
}
|
||||
} catch (e) {
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NostrAccount } from "@lume/system";
|
||||
import type { ColumnRouteSearch } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -30,7 +30,7 @@ function Screen() {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const sync: boolean = await invoke("friend_to_friend", { npub });
|
||||
const sync = await NostrAccount.f2f(npub);
|
||||
|
||||
if (sync) {
|
||||
return navigate({ to: redirect });
|
||||
|
||||
@@ -5,6 +5,7 @@ import { User } from "@/components/user";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { toast } from "sonner";
|
||||
import type { ColumnRouteSearch } from "@lume/types";
|
||||
import { NostrAccount } from "@lume/system";
|
||||
|
||||
export const Route = createFileRoute("/create-newsfeed/users")({
|
||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||
@@ -31,7 +32,6 @@ export const Route = createFileRoute("/create-newsfeed/users")({
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { ark } = Route.useRouteContext();
|
||||
const { data } = Route.useLoaderData();
|
||||
const { redirect } = Route.useSearch();
|
||||
|
||||
@@ -52,7 +52,7 @@ function Screen() {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const newContactList = await ark.set_contact_list(follows);
|
||||
const newContactList = await NostrAccount.setContactList(follows);
|
||||
|
||||
if (newContactList) {
|
||||
return navigate({ to: redirect });
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CheckCircleIcon } from "@lume/icons";
|
||||
import { NostrQuery } from "@lume/system";
|
||||
import type { ColumnRouteSearch, Topic } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { TOPICS } from "@lume/utils";
|
||||
@@ -18,12 +19,10 @@ export const Route = createFileRoute("/create-topic")({
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { label, redirect } = Route.useSearch();
|
||||
const { ark } = Route.useRouteContext();
|
||||
|
||||
const [topics, setTopics] = useState<Topic[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const search = Route.useSearch();
|
||||
const navigate = Route.useNavigate();
|
||||
|
||||
const toggleTopic = (topic: Topic) => {
|
||||
@@ -38,11 +37,14 @@ function Screen() {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const key = `lume_topic_${label}`;
|
||||
const createTopic = await ark.set_nstore(key, JSON.stringify(topics));
|
||||
const key = `lume_topic_${search.label}`;
|
||||
const createTopic = await NostrQuery.setNstore(
|
||||
key,
|
||||
JSON.stringify(topics),
|
||||
);
|
||||
|
||||
if (createTopic) {
|
||||
return navigate({ to: redirect });
|
||||
return navigate({ to: search.redirect, search: { ...search } });
|
||||
}
|
||||
} catch (e) {
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { AddMediaIcon } from "@lume/icons";
|
||||
import { NostrQuery } from "@lume/system";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { cn, insertImage, isImagePath } from "@lume/utils";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import type { UnlistenFn } from "@tauri-apps/api/event";
|
||||
import { getCurrent } from "@tauri-apps/api/window";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -11,15 +11,14 @@ import { toast } from "sonner";
|
||||
|
||||
export function MediaButton({ className }: { className?: string }) {
|
||||
const editor = useSlateStatic();
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const uploadToNostrBuild = async () => {
|
||||
const upload = async () => {
|
||||
try {
|
||||
// start loading
|
||||
setLoading(true);
|
||||
|
||||
const image = await ark.upload();
|
||||
const image = await NostrQuery.upload();
|
||||
insertImage(editor, image);
|
||||
|
||||
// reset loading
|
||||
@@ -44,7 +43,7 @@ export function MediaButton({ className }: { className?: string }) {
|
||||
// upload all images
|
||||
for (const item of items) {
|
||||
if (isImagePath(item)) {
|
||||
const image = await ark.upload(item);
|
||||
const image = await NostrQuery.upload(item);
|
||||
insertImage(editor, image);
|
||||
}
|
||||
}
|
||||
@@ -67,7 +66,7 @@ export function MediaButton({ className }: { className?: string }) {
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => uploadToNostrBuild()}
|
||||
onClick={() => upload()}
|
||||
disabled={loading}
|
||||
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 DropdownMenu from "@radix-ui/react-dropdown-menu";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { useSlateStatic } from "slate-react";
|
||||
import type { Contact } from "@lume/types";
|
||||
import { toast } from "sonner";
|
||||
import { User } from "@/components/user";
|
||||
import { NostrAccount, NostrQuery } from "@lume/system";
|
||||
|
||||
export function MentionButton({ className }: { className?: string }) {
|
||||
const editor = useSlateStatic();
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
const [contacts, setContacts] = useState<string[]>([]);
|
||||
|
||||
const select = async (user: string) => {
|
||||
try {
|
||||
const metadata = await ark.get_profile(user);
|
||||
const metadata = await NostrQuery.getProfile(user);
|
||||
const contact: Contact = { pubkey: user, profile: metadata };
|
||||
|
||||
insertMention(editor, contact);
|
||||
@@ -27,7 +26,7 @@ export function MentionButton({ className }: { className?: string }) {
|
||||
|
||||
useEffect(() => {
|
||||
async function getContacts() {
|
||||
const data = await ark.get_contact_list();
|
||||
const data = await NostrAccount.getContactList();
|
||||
setContacts(data);
|
||||
}
|
||||
|
||||
@@ -61,20 +60,26 @@ export function MentionButton({ className }: { className?: string }) {
|
||||
</Tooltip.Provider>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content className="flex w-[220px] h-[220px] scrollbar-none flex-col overflow-y-auto rounded-xl bg-black py-1 shadow-md shadow-neutral-500/20 focus:outline-none dark:bg-white">
|
||||
{contacts.map((contact) => (
|
||||
<DropdownMenu.Item
|
||||
key={contact}
|
||||
onClick={() => select(contact)}
|
||||
className="shrink-0 h-11 flex items-center hover:bg-white/10 px-2"
|
||||
>
|
||||
<User.Provider pubkey={contact}>
|
||||
<User.Root className="flex items-center gap-2">
|
||||
<User.Avatar className="shrink-0 size-8 rounded-full" />
|
||||
<User.Name className="text-sm font-medium text-white dark:text-black" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
{contacts.length < 1 ? (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<p className="text-sm text-white">Contact List is empty.</p>
|
||||
</div>
|
||||
) : (
|
||||
contacts.map((contact) => (
|
||||
<DropdownMenu.Item
|
||||
key={contact}
|
||||
onClick={() => select(contact)}
|
||||
className="shrink-0 h-11 flex items-center hover:bg-white/10 px-2"
|
||||
>
|
||||
<User.Provider pubkey={contact}>
|
||||
<User.Root className="flex items-center gap-2">
|
||||
<User.Avatar className="shrink-0 size-8 rounded-full" />
|
||||
<User.Name className="text-sm font-medium text-white dark:text-black" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</DropdownMenu.Item>
|
||||
))
|
||||
)}
|
||||
<DropdownMenu.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
|
||||
@@ -3,13 +3,13 @@ import { cn } from "@lume/utils";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
|
||||
export function NsfwToggle({
|
||||
nsfw,
|
||||
setNsfw,
|
||||
export function WarningToggle({
|
||||
warning,
|
||||
setWarning,
|
||||
className,
|
||||
}: {
|
||||
nsfw: boolean;
|
||||
setNsfw: Dispatch<SetStateAction<boolean>>;
|
||||
warning: boolean;
|
||||
setWarning: Dispatch<SetStateAction<boolean>>;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
@@ -18,11 +18,11 @@ export function NsfwToggle({
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNsfw((prev) => !prev)}
|
||||
onClick={() => setWarning((prev) => !prev)}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center",
|
||||
className,
|
||||
nsfw ? "bg-blue-500 text-white" : "",
|
||||
warning ? "bg-blue-500 text-white" : "",
|
||||
)}
|
||||
>
|
||||
<NsfwIcon className="size-4" />
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ComposeFilledIcon, TrashIcon } from "@lume/icons";
|
||||
import { ComposeFilledIcon } from "@lume/icons";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import {
|
||||
cn,
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
sendNativeNotification,
|
||||
} from "@lume/utils";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type Descendant, Node, Transforms, createEditor } from "slate";
|
||||
@@ -22,16 +21,22 @@ import {
|
||||
withReact,
|
||||
} from "slate-react";
|
||||
import { MediaButton } from "./-components/media";
|
||||
import { NsfwToggle } from "./-components/nsfw";
|
||||
import { MentionButton } from "./-components/mention";
|
||||
import { LumeEvent } from "@lume/system";
|
||||
import { WarningToggle } from "./-components/warning";
|
||||
import { MentionNote } from "@/components/note/mentions/note";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type EditorSearch = {
|
||||
reply_to: string;
|
||||
quote: boolean;
|
||||
};
|
||||
|
||||
type EditorElement = {
|
||||
type: string;
|
||||
children: Descendant[];
|
||||
eventId?: string;
|
||||
};
|
||||
|
||||
export const Route = createFileRoute("/editor/")({
|
||||
validateSearch: (search: Record<string, string>): EditorSearch => {
|
||||
return {
|
||||
@@ -39,43 +44,23 @@ export const Route = createFileRoute("/editor/")({
|
||||
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,
|
||||
});
|
||||
|
||||
const initialValue: EditorElement[] = [
|
||||
{
|
||||
type: "paragraph",
|
||||
children: [{ text: "" }],
|
||||
},
|
||||
];
|
||||
|
||||
function Screen() {
|
||||
const { reply_to, quote } = Route.useSearch();
|
||||
const { ark, initialValue } = Route.useRouteContext();
|
||||
const search = Route.useSearch();
|
||||
|
||||
const [t] = useTranslation();
|
||||
const [editorValue, setEditorValue] = useState(initialValue);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [nsfw, setNsfw] = useState(false);
|
||||
const [warning, setWarning] = useState(false);
|
||||
const [editor] = useState(() =>
|
||||
withMentions(withNostrEvent(withImages(withReact(createEditor())))),
|
||||
);
|
||||
@@ -116,7 +101,12 @@ function Screen() {
|
||||
setLoading(true);
|
||||
|
||||
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) {
|
||||
await sendNativeNotification(
|
||||
@@ -137,15 +127,15 @@ function Screen() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<div className="w-full h-full flex flex-col">
|
||||
<Slate editor={editor} initialValue={editorValue}>
|
||||
<div
|
||||
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
|
||||
nsfw={nsfw}
|
||||
setNsfw={setNsfw}
|
||||
<WarningToggle
|
||||
warning={warning}
|
||||
setWarning={setWarning}
|
||||
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")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex h-full w-full flex-1 flex-col">
|
||||
{reply_to && !quote ? (
|
||||
<div className="flex-1 overflow-y-auto flex flex-col">
|
||||
{search.reply_to ? (
|
||||
<div className="px-4 py-2">
|
||||
<MentionNote eventId={reply_to} />
|
||||
<MentionNote eventId={search.reply_to} />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="overflow-y-auto p-4">
|
||||
<div className="overflow-y-auto scrollbar-none p-4">
|
||||
<Editable
|
||||
key={JSON.stringify(editorValue)}
|
||||
autoFocus={true}
|
||||
@@ -178,7 +168,7 @@ function Screen() {
|
||||
spellCheck={false}
|
||||
renderElement={(props) => <Element {...props} />}
|
||||
placeholder={
|
||||
reply_to ? "Type your reply..." : t("editor.placeholder")
|
||||
search.reply_to ? "Type your reply..." : t("editor.placeholder")
|
||||
}
|
||||
className="focus:outline-none"
|
||||
/>
|
||||
@@ -252,35 +242,24 @@ const withImages = (editor: ReactEditor) => {
|
||||
return editor;
|
||||
};
|
||||
|
||||
const Image = ({ attributes, children, element }) => {
|
||||
const Image = ({ attributes, element, children }) => {
|
||||
const editor = useSlateStatic();
|
||||
const path = ReactEditor.findPath(editor as ReactEditor, element);
|
||||
|
||||
const selected = useSelected();
|
||||
const focused = useFocused();
|
||||
const path = ReactEditor.findPath(editor as ReactEditor, element);
|
||||
|
||||
return (
|
||||
<div {...attributes}>
|
||||
{children}
|
||||
<div contentEditable={false} className="relative my-2">
|
||||
<img
|
||||
src={element.url}
|
||||
alt={element.url}
|
||||
className={cn(
|
||||
"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",
|
||||
)}
|
||||
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>
|
||||
<img
|
||||
src={element.url}
|
||||
alt={element.url}
|
||||
className={cn(
|
||||
"my-2 h-auto w-1/2 rounded-lg object-cover ring-2 outline outline-1 -outline-offset-1 outline-black/15",
|
||||
selected && focused ? "ring-blue-500" : "ring-transparent",
|
||||
)}
|
||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEvent } from "@lume/ark";
|
||||
import type { Event } from "@lume/types";
|
||||
import { NostrQuery, useEvent } from "@lume/system";
|
||||
import type { NostrEvent } from "@lume/types";
|
||||
import { Box, Container, Spinner } from "@lume/ui";
|
||||
import { Note } from "@/components/note";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
@@ -7,10 +7,8 @@ import { WindowVirtualizer } from "virtua";
|
||||
import { ReplyList } from "./-components/replyList";
|
||||
|
||||
export const Route = createFileRoute("/events/$eventId")({
|
||||
beforeLoad: async ({ context }) => {
|
||||
const ark = context.ark;
|
||||
const settings = await ark.get_settings();
|
||||
|
||||
beforeLoad: async () => {
|
||||
const settings = await NostrQuery.getSettings();
|
||||
return { settings };
|
||||
},
|
||||
component: Screen,
|
||||
@@ -52,7 +50,7 @@ function Screen() {
|
||||
);
|
||||
}
|
||||
|
||||
function MainNote({ data }: { data: Event }) {
|
||||
function MainNote({ data }: { data: NostrEvent }) {
|
||||
return (
|
||||
<Note.Provider event={data}>
|
||||
<Note.Root>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { EventWithReplies } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { cn } from "@lume/utils";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Reply } from "./reply";
|
||||
import { LumeEvent } from "@lume/system";
|
||||
|
||||
export function ReplyList({
|
||||
eventId,
|
||||
@@ -13,13 +13,12 @@ export function ReplyList({
|
||||
eventId: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const { ark } = useRouteContext({ strict: false });
|
||||
const [t] = useTranslation();
|
||||
const [data, setData] = useState<null | EventWithReplies[]>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function getReplies() {
|
||||
const events = await ark.get_event_thread(eventId);
|
||||
const events = await LumeEvent.getReplies(eventId);
|
||||
setData(events);
|
||||
}
|
||||
getReplies();
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { Event } from "@lume/types";
|
||||
import type { NostrEvent } from "@lume/types";
|
||||
import { Note } from "@/components/note";
|
||||
|
||||
export function SubReply({ event }: { event: Event; rootEventId?: string }) {
|
||||
export function SubReply({
|
||||
event,
|
||||
}: {
|
||||
event: NostrEvent;
|
||||
rootEventId?: string;
|
||||
}) {
|
||||
return (
|
||||
<Note.Provider event={event}>
|
||||
<Note.Root>
|
||||
|
||||
@@ -3,7 +3,8 @@ import { Quote } from "@/components/quote";
|
||||
import { RepostNote } from "@/components/repost";
|
||||
import { TextNote } from "@/components/text";
|
||||
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 { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
@@ -17,10 +18,8 @@ export const Route = createFileRoute("/global")({
|
||||
name: search.name,
|
||||
};
|
||||
},
|
||||
beforeLoad: async ({ context }) => {
|
||||
const ark = context.ark;
|
||||
const settings = await ark.get_settings();
|
||||
|
||||
beforeLoad: async () => {
|
||||
const settings = await NostrQuery.getSettings();
|
||||
return { settings };
|
||||
},
|
||||
component: Screen,
|
||||
@@ -28,7 +27,6 @@ export const Route = createFileRoute("/global")({
|
||||
|
||||
export function Screen() {
|
||||
const { label, account } = Route.useSearch();
|
||||
const { ark } = Route.useRouteContext();
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
@@ -40,7 +38,7 @@ export function Screen() {
|
||||
queryKey: [label, account],
|
||||
initialPageParam: 0,
|
||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||
const events = await ark.get_global_events(20, pageParam);
|
||||
const events = await NostrQuery.getGlobalEvents(pageParam);
|
||||
return events;
|
||||
},
|
||||
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
|
||||
@@ -48,7 +46,7 @@ export function Screen() {
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const renderItem = (event: Event) => {
|
||||
const renderItem = (event: NostrEvent) => {
|
||||
if (!event) return;
|
||||
switch (event.kind) {
|
||||
case Kind.Repost:
|
||||
|
||||
@@ -3,7 +3,8 @@ import { Quote } from "@/components/quote";
|
||||
import { RepostNote } from "@/components/repost";
|
||||
import { TextNote } from "@/components/text";
|
||||
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 { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { Link, createFileRoute, redirect } from "@tanstack/react-router";
|
||||
@@ -17,11 +18,10 @@ export const Route = createFileRoute("/group")({
|
||||
name: search.name,
|
||||
};
|
||||
},
|
||||
beforeLoad: async ({ search, context }) => {
|
||||
const ark = context.ark;
|
||||
beforeLoad: async ({ search }) => {
|
||||
const key = `lume_group_${search.label}`;
|
||||
const groups = (await ark.get_nstore(key)) as string[];
|
||||
const settings = await ark.get_settings();
|
||||
const groups = (await NostrQuery.getNstore(key)) as string[];
|
||||
const settings = await NostrQuery.getSettings();
|
||||
|
||||
if (!groups?.length) {
|
||||
throw redirect({
|
||||
@@ -43,7 +43,7 @@ export const Route = createFileRoute("/group")({
|
||||
|
||||
export function Screen() {
|
||||
const { label, account } = Route.useSearch();
|
||||
const { ark, groups } = Route.useRouteContext();
|
||||
const { groups } = Route.useRouteContext();
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
@@ -55,7 +55,7 @@ export function Screen() {
|
||||
queryKey: [label, account],
|
||||
initialPageParam: 0,
|
||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||
const events = await ark.get_local_events(groups, 20, pageParam);
|
||||
const events = await NostrQuery.getLocalEvents(groups, pageParam);
|
||||
return events;
|
||||
},
|
||||
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
|
||||
@@ -64,7 +64,7 @@ export function Screen() {
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const renderItem = (event: Event) => {
|
||||
const renderItem = (event: NostrEvent) => {
|
||||
if (!event) return;
|
||||
switch (event.kind) {
|
||||
case Kind.Repost:
|
||||
|
||||
@@ -4,28 +4,22 @@ import { User } from "@/components/user";
|
||||
import { checkForAppUpdates } from "@lume/utils";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { NostrAccount } from "@lume/system";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
beforeLoad: async ({ context }) => {
|
||||
// check for app updates
|
||||
await checkForAppUpdates(true);
|
||||
beforeLoad: async () => {
|
||||
await checkForAppUpdates(true); // check for app updates
|
||||
const accounts = await NostrAccount.getAccounts();
|
||||
|
||||
const ark = context.ark;
|
||||
const accounts = await ark.get_accounts();
|
||||
|
||||
if (!accounts.length) {
|
||||
if (accounts.length < 1) {
|
||||
throw redirect({
|
||||
to: "/landing/",
|
||||
to: "/landing",
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Run notification service
|
||||
await invoke("run_notification", { accounts });
|
||||
|
||||
return { accounts };
|
||||
},
|
||||
component: Screen,
|
||||
@@ -33,7 +27,7 @@ export const Route = createFileRoute("/")({
|
||||
|
||||
function Screen() {
|
||||
const navigate = Route.useNavigate();
|
||||
const { ark, accounts } = Route.useRouteContext();
|
||||
const context = Route.useRouteContext();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -41,8 +35,9 @@ function Screen() {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const loadAccount = await ark.load_account(npub);
|
||||
if (loadAccount) {
|
||||
const status = await NostrAccount.loadAccount(npub);
|
||||
|
||||
if (status) {
|
||||
return navigate({
|
||||
to: "/$account/home",
|
||||
params: { account: npub },
|
||||
@@ -62,62 +57,50 @@ function Screen() {
|
||||
});
|
||||
|
||||
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="text-center text-white">
|
||||
<h2 className="mb-1 text-2xl">{currentDate}</h2>
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl text-neutral-700 dark:text-neutral-300">
|
||||
{currentDate}
|
||||
</h2>
|
||||
<h2 className="text-2xl font-semibold">Welcome back!</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap px-3 items-center justify-center gap-6">
|
||||
{loading ? (
|
||||
<div className="inline-flex size-6 items-center justify-center">
|
||||
<Spinner className="size-6 text-white" />
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{accounts.map((account) => (
|
||||
{context.accounts.map((account) => (
|
||||
<button
|
||||
type="button"
|
||||
key={account}
|
||||
type="button"
|
||||
onClick={() => select(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.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.Provider>
|
||||
</button>
|
||||
))}
|
||||
<Link to="/landing/">
|
||||
<div className="flex h-36 w-32 flex-col items-center justify-center gap-4 rounded-2xl p-2 text-white hover:bg-white/10 dark:hover:bg-black/10">
|
||||
<div className="flex size-20 items-center justify-center rounded-full bg-white/20 dark:bg-black/20">
|
||||
<Link to="/landing">
|
||||
<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-black/5 dark:bg-white/5">
|
||||
<PlusIcon className="size-8" />
|
||||
</div>
|
||||
<p className="text-lg font-medium leading-tight">Add</p>
|
||||
<p className="font-medium leading-tight">Add</p>
|
||||
</div>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
62
apps/desktop2/src/routes/landing.lazy.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -2,11 +2,12 @@ import { Conversation } from "@/components/conversation";
|
||||
import { Quote } from "@/components/quote";
|
||||
import { RepostNote } from "@/components/repost";
|
||||
import { TextNote } from "@/components/text";
|
||||
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
|
||||
import { type ColumnRouteSearch, type Event, Kind } from "@lume/types";
|
||||
import { ArrowRightCircleIcon } from "@lume/icons";
|
||||
import { NostrAccount, NostrQuery } from "@lume/system";
|
||||
import { type ColumnRouteSearch, type NostrEvent, Kind } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
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 { Virtualizer } from "virtua";
|
||||
|
||||
@@ -18,10 +19,9 @@ export const Route = createFileRoute("/newsfeed")({
|
||||
name: search.name,
|
||||
};
|
||||
},
|
||||
beforeLoad: async ({ search, context }) => {
|
||||
const ark = context.ark;
|
||||
const settings = await ark.get_settings();
|
||||
const contacts = await ark.get_contact_list();
|
||||
beforeLoad: async ({ search }) => {
|
||||
const settings = await NostrQuery.getSettings();
|
||||
const contacts = await NostrAccount.getContactList();
|
||||
|
||||
if (!contacts.length) {
|
||||
throw redirect({
|
||||
@@ -40,7 +40,7 @@ export const Route = createFileRoute("/newsfeed")({
|
||||
|
||||
export function Screen() {
|
||||
const { label, account } = Route.useSearch();
|
||||
const { ark, contacts } = Route.useRouteContext();
|
||||
const { contacts, settings } = Route.useRouteContext();
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
@@ -52,7 +52,7 @@ export function Screen() {
|
||||
queryKey: [label, account],
|
||||
initialPageParam: 0,
|
||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||
const events = await ark.get_local_events(contacts, 20, pageParam);
|
||||
const events = await NostrQuery.getLocalEvents(contacts, pageParam);
|
||||
return events;
|
||||
},
|
||||
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
|
||||
@@ -60,7 +60,7 @@ export function Screen() {
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const renderItem = (event: Event) => {
|
||||
const renderItem = (event: NostrEvent) => {
|
||||
if (!event) return;
|
||||
switch (event.kind) {
|
||||
case Kind.Repost:
|
||||
@@ -72,7 +72,14 @@ export function Screen() {
|
||||
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0;
|
||||
|
||||
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) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ZapIcon } from "@lume/icons";
|
||||
import { NostrAccount } from "@lume/system";
|
||||
import { Container } from "@lume/ui";
|
||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
@@ -8,18 +9,16 @@ export const Route = createLazyFileRoute("/nwc")({
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { ark } = Route.useRouteContext();
|
||||
|
||||
const [uri, setUri] = useState("");
|
||||
const [isDone, setIsDone] = useState(false);
|
||||
|
||||
const save = async () => {
|
||||
const nwc = await ark.set_nwc(uri);
|
||||
const nwc = await NostrAccount.setWallet(uri);
|
||||
setIsDone(nwc);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container withDrag withNavigate={false}>
|
||||
<Container withDrag>
|
||||
<div className="h-full w-full flex-1 px-5">
|
||||
{!isDone ? (
|
||||
<>
|
||||
|
||||
@@ -34,6 +34,8 @@ function Screen() {
|
||||
className="h-auto w-full aspect-square rounded-lg shadow-md transform"
|
||||
controls
|
||||
muted
|
||||
preload="none"
|
||||
poster="/poster_1.jpeg"
|
||||
>
|
||||
<source
|
||||
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"
|
||||
controls
|
||||
muted
|
||||
preload="none"
|
||||
poster="/poster_2.jpeg"
|
||||
>
|
||||
<source
|
||||
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"
|
||||
controls
|
||||
muted
|
||||
preload="none"
|
||||
poster="/poster_3.jpeg"
|
||||
>
|
||||
<source
|
||||
src="https://video.nostr.build/927abbfde2097e470ac751181b1db456b7e4b9149550408efff1a966a7ffb9a8.mp4"
|
||||
@@ -97,6 +103,8 @@ function Screen() {
|
||||
className="h-auto w-full rounded-lg shadow-md transform"
|
||||
controls
|
||||
muted
|
||||
preload="none"
|
||||
poster="/poster_4.jpeg"
|
||||
>
|
||||
<source
|
||||
src="https://video.nostr.build/513de4824b6abaf7e9698c1dad2f68096574356848c0c200bc8cb8074df29410.mp4"
|
||||
|
||||
314
apps/desktop2/src/routes/panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { Note } from "@/components/note";
|
||||
import { User } from "@/components/user";
|
||||
@@ -7,6 +7,7 @@ import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import { LumeWindow } from "@lume/system";
|
||||
|
||||
export const Route = createFileRoute("/search")({
|
||||
component: Screen,
|
||||
@@ -14,7 +15,7 @@ export const Route = createFileRoute("/search")({
|
||||
|
||||
function Screen() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [events, setEvents] = useState<NostrEvent[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
const [searchValue] = useDebounce(search, 500);
|
||||
|
||||
@@ -25,7 +26,7 @@ function Screen() {
|
||||
const query = `https://api.nostr.wine/search?query=${searchValue}&kind=0,1`;
|
||||
const res = await fetch(query);
|
||||
const content = await res.json();
|
||||
const events = content.data as Event[];
|
||||
const events = content.data as NostrEvent[];
|
||||
const sorted = events.sort((a, b) => b.created_at - a.created_at);
|
||||
|
||||
setLoading(false);
|
||||
@@ -102,14 +103,12 @@ function Screen() {
|
||||
);
|
||||
}
|
||||
|
||||
function SearchUser({ event }: { event: Event }) {
|
||||
const { ark } = Route.useRouteContext();
|
||||
|
||||
function SearchUser({ event }: { event: NostrEvent }) {
|
||||
return (
|
||||
<button
|
||||
key={event.id}
|
||||
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"
|
||||
>
|
||||
<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 (
|
||||
<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}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { User } from "@/components/user";
|
||||
import type { Account } from "@lume/types";
|
||||
import { NostrAccount } from "@lume/system";
|
||||
import { displayNsec } from "@lume/utils";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
@@ -7,19 +7,20 @@ import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Account {
|
||||
npub: string;
|
||||
nsec: string;
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/settings/backup")({
|
||||
component: Screen,
|
||||
loader: async ({ context }) => {
|
||||
const ark = context.ark;
|
||||
const npubs = await ark.get_accounts();
|
||||
|
||||
loader: async () => {
|
||||
const npubs = await NostrAccount.getAccounts();
|
||||
const accounts: Account[] = [];
|
||||
|
||||
for (const account of npubs) {
|
||||
const nsec: string = await invoke("get_stored_nsec", {
|
||||
npub: account.npub,
|
||||
});
|
||||
accounts.push({ ...account, nsec });
|
||||
for (const npub of npubs) {
|
||||
const nsec: string = await invoke("get_stored_nsec", { npub });
|
||||
accounts.push({ npub, nsec });
|
||||
}
|
||||
|
||||
return accounts;
|
||||
@@ -33,14 +34,14 @@ function Screen() {
|
||||
<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">
|
||||
{accounts.map((account) => (
|
||||
<NostrAccount key={account.npub} account={account} />
|
||||
<List key={account.npub} account={account} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NostrAccount({ account }: { account: Account }) {
|
||||
function List({ account }: { account: Account }) {
|
||||
const [key, setKey] = useState(account.nsec);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [passphase, setPassphase] = useState("");
|
||||
|
||||
@@ -1,28 +1,22 @@
|
||||
import { NostrQuery } from "@lume/system";
|
||||
import type { Settings } from "@lume/types";
|
||||
import * as Switch from "@radix-ui/react-switch";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import {
|
||||
isPermissionGranted,
|
||||
requestPermission,
|
||||
} from "@tauri-apps/plugin-notification";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { requestPermission } from "@tauri-apps/plugin-notification";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
|
||||
export const Route = createFileRoute("/settings/general")({
|
||||
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 },
|
||||
};
|
||||
beforeLoad: async () => {
|
||||
const settings = await NostrQuery.getSettings();
|
||||
return { settings };
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { ark, settings } = Route.useRouteContext();
|
||||
const { settings } = Route.useRouteContext();
|
||||
const [newSettings, setNewSettings] = useState<Settings>(settings);
|
||||
|
||||
const toggleNofitication = async () => {
|
||||
@@ -33,6 +27,13 @@ function Screen() {
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleGossip = async () => {
|
||||
setNewSettings((prev) => ({
|
||||
...prev,
|
||||
gossip: !newSettings.gossip,
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleAutoUpdate = () => {
|
||||
setNewSettings((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(() => {
|
||||
ark.set_settings(newSettings);
|
||||
NostrQuery.setSettings(newSettings);
|
||||
}, 200);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -95,6 +109,24 @@ function Screen() {
|
||||
</Switch.Root>
|
||||
</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-1">
|
||||
<h3 className="font-medium">Enhanced Privacy</h3>
|
||||
@@ -155,24 +187,42 @@ function Screen() {
|
||||
Interface
|
||||
</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 gap-4">
|
||||
<div className="flex w-full items-start justify-between gap-4 py-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold">Zap</h3>
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
Show the Zap button in each note and user's profile screen,
|
||||
use for send bitcoin tip to other users.
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-36 flex justify-end shrink-0">
|
||||
<Switch.Root
|
||||
checked={newSettings.zap}
|
||||
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"
|
||||
>
|
||||
<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 py-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold">Zap</h3>
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
Show the Zap button in each note and user's profile screen,
|
||||
use for send bitcoin tip to other users.
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-36 flex justify-end shrink-0">
|
||||
<Switch.Root
|
||||
checked={newSettings.zap}
|
||||
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"
|
||||
>
|
||||
<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-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>
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { CancelIcon, PlusIcon } from "@lume/icons";
|
||||
import { NostrQuery } from "@lume/system";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/settings/relay")({
|
||||
loader: async ({ context }) => {
|
||||
const ark = context.ark;
|
||||
const relays = await ark.get_relays();
|
||||
|
||||
loader: async () => {
|
||||
const relays = await NostrQuery.getRelays();
|
||||
return relays;
|
||||
},
|
||||
component: Screen,
|
||||
@@ -18,12 +17,11 @@ function Screen() {
|
||||
const relayList = Route.useLoaderData();
|
||||
const [relays, setRelays] = useState(relayList.connected);
|
||||
|
||||
const { ark } = Route.useRouteContext();
|
||||
const { register, reset, handleSubmit } = useForm();
|
||||
|
||||
const onSubmit = async (data: { url: string }) => {
|
||||
try {
|
||||
const add = await ark.add_relay(data.url);
|
||||
const add = await NostrQuery.connectRelay(data.url);
|
||||
if (add) {
|
||||
setRelays((prev) => [...prev, data.url]);
|
||||
reset();
|
||||
@@ -56,6 +54,7 @@ function Screen() {
|
||||
<div>
|
||||
<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"
|
||||
>
|
||||
<CancelIcon className="size-4" />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AvatarUploader } from "@/components/avatarUploader";
|
||||
import { PlusIcon } from "@lume/icons";
|
||||
import { NostrAccount } from "@lume/system";
|
||||
import type { Metadata } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
@@ -9,16 +10,15 @@ import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/settings/user")({
|
||||
beforeLoad: async ({ context }) => {
|
||||
const ark = context.ark;
|
||||
const profile = await ark.get_current_user_profile();
|
||||
beforeLoad: async () => {
|
||||
const profile = await NostrAccount.getProfile();
|
||||
return { profile };
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { ark, profile } = Route.useRouteContext();
|
||||
const { profile } = Route.useRouteContext();
|
||||
const { register, handleSubmit } = useForm({ defaultValues: profile });
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -29,7 +29,7 @@ function Screen() {
|
||||
setLoading(true);
|
||||
|
||||
const newProfile: Metadata = { ...profile, ...data, picture };
|
||||
await ark.create_profile(newProfile);
|
||||
await NostrAccount.createProfile(newProfile);
|
||||
|
||||
setLoading(false);
|
||||
} catch (e) {
|
||||
|
||||
@@ -3,7 +3,13 @@ import { Quote } from "@/components/quote";
|
||||
import { RepostNote } from "@/components/repost";
|
||||
import { TextNote } from "@/components/text";
|
||||
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 { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { Link, createFileRoute, redirect } from "@tanstack/react-router";
|
||||
@@ -17,11 +23,10 @@ export const Route = createFileRoute("/topic")({
|
||||
name: search.name,
|
||||
};
|
||||
},
|
||||
beforeLoad: async ({ search, context }) => {
|
||||
const ark = context.ark;
|
||||
beforeLoad: async ({ search }) => {
|
||||
const key = `lume_topic_${search.label}`;
|
||||
const topics = (await ark.get_nstore(key)) as unknown as Topic[];
|
||||
const settings = await ark.get_settings();
|
||||
const topics = (await NostrQuery.getNstore(key)) as unknown as Topic[];
|
||||
const settings = await NostrQuery.getSettings();
|
||||
|
||||
if (!topics?.length) {
|
||||
throw redirect({
|
||||
@@ -49,7 +54,7 @@ export const Route = createFileRoute("/topic")({
|
||||
|
||||
export function Screen() {
|
||||
const { label, account } = Route.useSearch();
|
||||
const { ark, hashtags } = Route.useRouteContext();
|
||||
const { hashtags } = Route.useRouteContext();
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
@@ -61,7 +66,7 @@ export function Screen() {
|
||||
queryKey: [label, account],
|
||||
initialPageParam: 0,
|
||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||
const events = ark.get_hashtag_events(hashtags, 20, pageParam);
|
||||
const events = NostrQuery.getHashtagEvents(hashtags, pageParam);
|
||||
return events;
|
||||
},
|
||||
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
|
||||
@@ -69,7 +74,7 @@ export function Screen() {
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const renderItem = (event: Event) => {
|
||||
const renderItem = (event: NostrEvent) => {
|
||||
if (!event) return;
|
||||
switch (event.kind) {
|
||||
case Kind.Repost:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TextNote } from "@/components/text";
|
||||
import type { Event } from "@lume/types";
|
||||
import type { NostrEvent } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { Await, createFileRoute } from "@tanstack/react-router";
|
||||
import { defer } from "@tanstack/react-router";
|
||||
@@ -15,7 +15,7 @@ export const Route = createFileRoute("/trending/notes")({
|
||||
signal: abortController.signal,
|
||||
})
|
||||
.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) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ArticleIcon, GroupFeedsIcon } from "@lume/icons";
|
||||
import { NostrQuery } from "@lume/system";
|
||||
import type { ColumnRouteSearch } from "@lume/types";
|
||||
import { cn } from "@lume/utils";
|
||||
import { Link, Outlet } from "@tanstack/react-router";
|
||||
@@ -12,10 +13,8 @@ export const Route = createFileRoute("/trending")({
|
||||
name: search.name,
|
||||
};
|
||||
},
|
||||
beforeLoad: async ({ context }) => {
|
||||
const ark = context.ark;
|
||||
const settings = await ark.get_settings();
|
||||
|
||||
beforeLoad: async () => {
|
||||
const settings = await NostrQuery.getSettings();
|
||||
return { settings };
|
||||
},
|
||||
component: Screen,
|
||||
|
||||
@@ -6,20 +6,18 @@ import { Conversation } from "@/components/conversation";
|
||||
import { Quote } from "@/components/quote";
|
||||
import { RepostNote } from "@/components/repost";
|
||||
import { TextNote } from "@/components/text";
|
||||
import { type Event, Kind } from "@lume/types";
|
||||
import { type NostrEvent, Kind } from "@lume/types";
|
||||
import { Suspense } from "react";
|
||||
import { Await } from "@tanstack/react-router";
|
||||
import { NostrQuery } from "@lume/system";
|
||||
|
||||
export const Route = createFileRoute("/users/$pubkey")({
|
||||
beforeLoad: async ({ context }) => {
|
||||
const ark = context.ark;
|
||||
const settings = await ark.get_settings();
|
||||
|
||||
beforeLoad: async () => {
|
||||
const settings = await NostrQuery.getSettings();
|
||||
return { settings };
|
||||
},
|
||||
loader: async ({ params, context }) => {
|
||||
const ark = context.ark;
|
||||
return { data: defer(ark.get_events_by(params.pubkey, 50)) };
|
||||
loader: async ({ params }) => {
|
||||
return { data: defer(NostrQuery.getUserEvents(params.pubkey)) };
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
@@ -28,7 +26,7 @@ function Screen() {
|
||||
const { pubkey } = Route.useParams();
|
||||
const { data } = Route.useLoaderData();
|
||||
|
||||
const renderItem = (event: Event) => {
|
||||
const renderItem = (event: NostrEvent) => {
|
||||
if (!event) return;
|
||||
switch (event.kind) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { useState } from "react";
|
||||
import CurrencyInput from "react-currency-input-field";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { LumeEvent } from "@lume/system";
|
||||
|
||||
const DEFAULT_VALUES = [69, 100, 200, 500];
|
||||
|
||||
@@ -16,7 +17,6 @@ export const Route = createLazyFileRoute("/zap/$id")({
|
||||
|
||||
function Screen() {
|
||||
const { t } = useTranslation();
|
||||
const { ark } = Route.useRouteContext();
|
||||
const { id } = Route.useParams();
|
||||
// @ts-ignore, magic !!!
|
||||
const { pubkey, account } = Route.useSearch();
|
||||
@@ -31,7 +31,7 @@ function Screen() {
|
||||
// start loading
|
||||
setIsLoading(true);
|
||||
|
||||
const val = await ark.zap_event(id, amount, message);
|
||||
const val = await LumeEvent.zap(id, amount, message);
|
||||
|
||||
if (val) {
|
||||
setIsCompleted(true);
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.5.10",
|
||||
"@astrojs/tailwind": "^5.1.0",
|
||||
"@fontsource/geist-mono": "^5.0.3",
|
||||
"astro": "^4.8.3",
|
||||
"@fontsource/alice": "^5.0.13",
|
||||
"astro": "^4.9.2",
|
||||
"astro-seo-meta": "^4.1.1",
|
||||
"astro-seo-schema": "^4.0.2",
|
||||
"schema-dts": "^1.1.2",
|
||||
|
||||
BIN
apps/web/public/bg.jpeg
Normal file
|
After Width: | Height: | Size: 889 KiB |
BIN
apps/web/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
@@ -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 |
BIN
apps/web/public/icon.png
Normal file
|
After Width: | Height: | Size: 714 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 318 KiB |
@@ -3,153 +3,96 @@ import { Seo } from "astro-seo-meta";
|
||||
---
|
||||
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<title>Lume</title>
|
||||
<Seo
|
||||
title="Lume"
|
||||
description="A multiple columns Nostr client for desktop."
|
||||
keywords={[
|
||||
"nostr",
|
||||
"nostr client",
|
||||
"social network",
|
||||
"desktop app",
|
||||
"timeline",
|
||||
"application",
|
||||
"columns",
|
||||
]}
|
||||
themeColor="#fafafa"
|
||||
colorScheme="light"
|
||||
facebook={{
|
||||
image: "/og-image.jpg",
|
||||
url: "https://lume.nu",
|
||||
type: "website",
|
||||
}}
|
||||
twitter={{
|
||||
image: "/og-image.jpg",
|
||||
card: "summary",
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body
|
||||
class="w-full h-full antialiased font-mono 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="prose dark:prose-invert prose-neutral max-w-none">
|
||||
<h3>About Lume</h3>
|
||||
<p>
|
||||
Lume is a <b>Nostr client</b> for desktop include Linux, Windows and
|
||||
macOS. It is free and open source, you can look at source code <a
|
||||
href="https://github.com/lumehq/lume"
|
||||
target="_blank">on Github</a
|
||||
>. Lume is actively improving the app and adding new features, you
|
||||
can expect new update every month.
|
||||
</p>
|
||||
<a href="#download">Download</a>
|
||||
<h3>What is nostr & how does it work?</h3>
|
||||
<p>
|
||||
Nostr stands for Notes and Other Stuff Transmitted by Relays. It is
|
||||
an open, permission-less protocol that aims to provide
|
||||
censorship-resistance and interoperability. It can be used to create
|
||||
social networks or just about any other type of app (other stuff
|
||||
part of the acronym). It is not a single website or app, but the
|
||||
glue that holds together many apps (clients) and <b>Lume</b> is one of
|
||||
it.
|
||||
</p>
|
||||
<p>
|
||||
At its core, nostr consists of relays and events. A person does
|
||||
something (event) and this event is sent to a relay. The relay
|
||||
stores the event, then waits for another person to request it. The
|
||||
most common types of events are notes and reactions - the stuff
|
||||
social media is made of, but there are many other types of events.
|
||||
It works very similar to how any other app would work with a
|
||||
database, except in nostr there is no single database, rather a
|
||||
large number of relays that store the events.
|
||||
</p>
|
||||
<h3>Lume is multiple columns experience</h3>
|
||||
<p>
|
||||
Lume is display your timeline as multiple column, each column is
|
||||
each different content and you can define your experience
|
||||
</p>
|
||||
<p>
|
||||
You can create a column to display newsfeed from specific people,
|
||||
you can create a column to display all contents related to some
|
||||
hashtags. It all up to you.
|
||||
</p>
|
||||
<img
|
||||
src="https://image.nostr.build/fd3e3cdeb4fb9f0f3de5c5e668a11dcae55f50cc9a78fc2b57b063240191a0f9.png"
|
||||
alt="columns"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="w-full h-auto rounded-lg"
|
||||
/>
|
||||
<h3>"For You"</h3>
|
||||
<p>
|
||||
Unlike some social networks, they feed you by algorithm. In Lume,
|
||||
you totally control what to will see
|
||||
</p>
|
||||
<img
|
||||
src="https://image.nostr.build/5afd79de15929a4ac6f6e933791c942555baa4206fecee54fed61dde9fe167e1.png"
|
||||
alt="for you"
|
||||
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>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<title>Lume: The nostr client for desktop</title>
|
||||
<Seo
|
||||
title="Lume"
|
||||
description="A friendly and scalable Nostr desktop client."
|
||||
keywords={[
|
||||
"nostr",
|
||||
"nostr client",
|
||||
"social network",
|
||||
"desktop app",
|
||||
"timeline",
|
||||
"application",
|
||||
"columns",
|
||||
"tweetdeck",
|
||||
]}
|
||||
themeColor="#fafafa"
|
||||
colorScheme="light"
|
||||
facebook={{
|
||||
image: "/og-image.jpg",
|
||||
url: "https://lume.nu",
|
||||
type: "website",
|
||||
}}
|
||||
twitter={{
|
||||
image: "/og-image.jpg",
|
||||
card: "summary",
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body
|
||||
class="w-full h-full antialiased bg-neutral-50 dark:bg-neutral-950 text-neutral-950 dark:text-neutral-50"
|
||||
>
|
||||
<div class="py-10 flex flex-col gap-10">
|
||||
<div class="mx-auto max-w-xl w-full flex flex-col gap-2">
|
||||
<div class="mb-5">
|
||||
<img
|
||||
src="/icon.png"
|
||||
alt="App Icon"
|
||||
class="size-14 shadow-md shadow-neutral-500/50 rounded-xl object-cover transform-gpu -rotate-6 hover:animate-spin"
|
||||
/>
|
||||
</div>
|
||||
<h1 class="text-xl font-serif font-semibold">
|
||||
A friendly and scalable Nostr desktop client.
|
||||
</h1>
|
||||
<p class="text-sm font-medium text-neutral-700">
|
||||
Lume is a <b>Nostr client</b> for desktop, including Linux, Windows, and
|
||||
macOS. It is free and open-source; you can look at the source code on <a
|
||||
href="https://github.com/lumehq/lume">GitHub</a
|
||||
>. Lume is actively improving the app and adding new features; you can
|
||||
expect a new update every month.
|
||||
</p>
|
||||
<p class="text-sm font-medium text-neutral-700">
|
||||
<b>Latest version</b>: 4.0.4
|
||||
</p>
|
||||
<div
|
||||
class="w-full h-[120px] sm:h-[80px] flex flex-col sm:flex-row sm:items-center sm:justify-start justify-center gap-2"
|
||||
>
|
||||
<a
|
||||
href="https://github.com/lumehq/lume/releases/latest"
|
||||
class="inline-flex items-center justify-center w-44 h-11 rounded-full bg-black hover:ring-2 ring-blue-500 ring-offset-2 text-white font-medium text-sm"
|
||||
>Download for macOS</a
|
||||
>
|
||||
<span class="italic text-xs text-neutral-700"
|
||||
>(Windows & Linux are coming later)</span
|
||||
>
|
||||
</div>
|
||||
<div class="text-sm italic text-neutral-600">
|
||||
* If you still need to use Lume on Windows and Linux, you can try v3 <a
|
||||
href="https://github.com/lumehq/lume/releases/tag/v3.0.2"
|
||||
class="text-blue-500">here</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sm:max-w-3xl w-full mx-auto px-3 sm:px-0">
|
||||
<video
|
||||
class="aspect-video w-full h-auto rounded-xl"
|
||||
autoplay
|
||||
muted
|
||||
controls
|
||||
>
|
||||
<source
|
||||
src="https://video.nostr.build/4cc4df88caeb861b62e3f73bddbb5e0b5cf63617472a97d22f427e273ee0e127.mp4"
|
||||
type="video/mp4"
|
||||
/>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -7,7 +7,7 @@ export default {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
mono: ["Geist Mono", ...defaultTheme.fontFamily.mono],
|
||||
serif: ["Alice", ...defaultTheme.fontFamily.serif],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
67
package.json
@@ -1,36 +1,35 @@
|
||||
{
|
||||
"name": "lume",
|
||||
"private": true,
|
||||
"version": "4.0.0",
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"dev": "turbo run dev",
|
||||
"web:dev": "turbo run dev --filter web",
|
||||
"desktop:dev": "turbo run dev --filter desktop2",
|
||||
"desktop:build": "turbo run build --filter desktop2",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.7.3",
|
||||
"@tauri-apps/cli": "2.0.0-beta.12",
|
||||
"turbo": "^1.13.3"
|
||||
},
|
||||
"packageManager": "pnpm@8.9.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.0.0-beta.7",
|
||||
"@tauri-apps/plugin-autostart": "2.0.0-beta.3",
|
||||
"@tauri-apps/plugin-clipboard-manager": "2.1.0-beta.1",
|
||||
"@tauri-apps/plugin-dialog": "2.0.0-beta.3",
|
||||
"@tauri-apps/plugin-fs": "2.0.0-beta.3",
|
||||
"@tauri-apps/plugin-http": "2.0.0-beta.3",
|
||||
"@tauri-apps/plugin-notification": "2.0.0-beta.3",
|
||||
"@tauri-apps/plugin-os": "2.0.0-beta.3",
|
||||
"@tauri-apps/plugin-process": "2.0.0-beta.3",
|
||||
"@tauri-apps/plugin-shell": "2.0.0-beta.4",
|
||||
"@tauri-apps/plugin-updater": "2.0.0-beta.3",
|
||||
"@tauri-apps/plugin-upload": "2.0.0-beta.4"
|
||||
}
|
||||
"name": "lume",
|
||||
"private": true,
|
||||
"version": "4.0.0",
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"dev": "turbo run dev",
|
||||
"web:dev": "turbo run dev --filter web",
|
||||
"desktop:dev": "turbo run dev --filter desktop2",
|
||||
"desktop:build": "turbo run build --filter desktop2",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.7.3",
|
||||
"@tauri-apps/cli": "2.0.0-beta.20",
|
||||
"turbo": "^1.13.3"
|
||||
},
|
||||
"packageManager": "pnpm@8.9.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "2.0.0-beta.13",
|
||||
"@tauri-apps/plugin-clipboard-manager": "2.1.0-beta.3",
|
||||
"@tauri-apps/plugin-dialog": "2.0.0-beta.5",
|
||||
"@tauri-apps/plugin-fs": "2.0.0-beta.5",
|
||||
"@tauri-apps/plugin-http": "2.0.0-beta.5",
|
||||
"@tauri-apps/plugin-notification": "2.0.0-beta.5",
|
||||
"@tauri-apps/plugin-os": "2.0.0-beta.5",
|
||||
"@tauri-apps/plugin-process": "2.0.0-beta.5",
|
||||
"@tauri-apps/plugin-shell": "2.0.0-beta.6",
|
||||
"@tauri-apps/plugin-updater": "2.0.0-beta.5",
|
||||
"@tauri-apps/plugin-upload": "2.0.0-beta.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"e=${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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||