Compare commits

...

30 Commits

Author SHA1 Message Date
reya
e1424b851c fix: routes 2024-06-19 14:22:45 +07:00
reya
d14e609579 chore: bump version 2024-06-19 14:14:34 +07:00
reya
8c0627ff27 fix: crash on startup 2024-06-19 14:14:03 +07:00
雨宮蓮
18c133d096 Settings Manager (#211)
* refactor: landing screen

* fix: code debt

* feat: add settings screen

* chore: clean up

* feat: settings

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

* feat: refactor contact list

* feat: refactor relay hint

* feat: add missing commands

* feat: use new cache layer for react query

* feat: refactor column

* feat: improve relay hint

* fix: replace break with continue in parser

* refactor: publish function

* feat: add reply command

* feat: improve editor

* fix: quote

* chore: update deps

* refactor: note component

* feat: improve repost

* feat: improve cache

* fix: backup screen

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

* feat: move parser and dedup to rust

* fix: parser

* fix: get event function

* feat: improve parser performance (#207)

* feat: improve parser performance

* feat: add test for video parsing

* feat: finish new parser

---------

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

* feat: add save bootstrap relays command

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

* feat: add notification panel

* feat: move notification service to backend

* feat: add sync notification job

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

* feat: fetch notification

* feat: listen for new notification

* feat: finish panel

---------

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

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

* feat: Update get_following function to use timeout duration

* feat: Fix connect_remote_account function to return remote_npub without conversion

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

* Refactor get_followers function to handle public key parsing errors and use timeout duration
2024-06-05 13:24:32 +07:00
reya
7c7b082b3a fix: memory leak in image component 2024-06-03 07:32:34 +07:00
reya
38d6c51921 feat: use nostrdb for unix and rocksdb for windows 2024-06-02 08:16:59 +07:00
reya
1738cbdd97 chore: upgrade tauri 2024-06-01 14:54:35 +07:00
reya
2e885b76a1 feat: improve text wrap 2024-06-01 08:27:22 +07:00
reya
f94680e487 fix: column overlapped after change account 2024-05-31 15:25:47 +07:00
XIAO YU
c682a58842 chore: Remove unused modules and update metadata.rs (#199) 2024-05-31 12:53:35 +07:00
reya
921cf871ee chore: bump version 2024-05-31 08:54:53 +07:00
reya
d5b1593aca feat: improve nostr connect flow 2024-05-31 08:54:17 +07:00
reya
6676b4e2a4 Revert "chore: Update dependencies and add thiserror crate (#196)"
This reverts commit e254ee3203.
2024-05-30 15:40:44 +07:00
reya
5f30ddcfca Merge branch 'main' of github.com:lumehq/lume 2024-05-30 15:23:11 +07:00
reya
41d0de539d feat: revamp nostr connect flow 2024-05-30 15:21:33 +07:00
XIAO YU
e254ee3203 chore: Update dependencies and add thiserror crate (#196) 2024-05-30 07:12:46 +07:00
130 changed files with 6046 additions and 5643 deletions

View File

@@ -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'

5
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,5 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/

5
.idea/codeStyles/codeStyleConfig.xml generated Normal file
View File

@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
</state>
</component>

View File

@@ -0,0 +1,46 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="CheckEmptyScriptTag" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="CheckValidXmlInScriptTagBody" enabled="false" level="ERROR" enabled_by_default="false" />
<inspection_tool class="GithubFunctionSignatureValidation" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="GrazieInspection" enabled="false" level="GRAMMAR_ERROR" enabled_by_default="false" />
<inspection_tool class="HtmlExtraClosingTag" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="HtmlMissingClosingTag" enabled="false" level="INFORMATION" enabled_by_default="false" />
<inspection_tool class="HtmlUnknownAnchorTarget" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="HtmlUnknownAttribute" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="HtmlUnknownBooleanAttribute" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="HtmlUnknownTag" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="HtmlUnknownTarget" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="HtmlWrongAttributeValue" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="HttpClientInappropriateProtocolUsageInspection" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
<inspection_tool class="HttpClientUnresolvedAuthId" enabled="false" level="ERROR" enabled_by_default="false" />
<inspection_tool class="HttpClientUnresolvedVariable" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="HttpRequestContentLengthIsIgnored" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="HttpRequestEnvironmentAuthConfigurationValidationInspection" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="HttpRequestPlaceholder" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="HttpRequestRequestSeparatorJsonBodyInspection" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
<inspection_tool class="HttpRequestRequestSeparatorXmlBodyInspection" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
<inspection_tool class="HttpRequestRequestSeparatorYamlBodyInspection" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
<inspection_tool class="HttpRequestWhitespaceInsideRequestTargetPath" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
<inspection_tool class="IncorrectHttpHeaderInspection" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="LanguageDetectionInspection" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="MandatoryParamsAbsent" enabled="false" level="ERROR" enabled_by_default="false" />
<inspection_tool class="MarkdownIncorrectTableFormatting" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
<inspection_tool class="MarkdownIncorrectlyNumberedListItem" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="MarkdownLinkDestinationWithSpaces" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="MarkdownNoTableBorders" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="MarkdownOutdatedTableOfContents" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="MarkdownUnresolvedFileReference" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="MarkdownUnresolvedHeaderReference" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="MarkdownUnresolvedLinkLabel" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="RequiredAttributes" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="SpellCheckingInspection" enabled="false" level="TYPO" enabled_by_default="false">
<option name="processCode" value="true" />
<option name="processLiterals" value="true" />
<option name="processComments" value="true" />
</inspection_tool>
<inspection_tool class="UndefinedAction" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="UndefinedParamsPresent" enabled="false" level="WARNING" enabled_by_default="false" />
</profile>
</component>

17
.idea/lume.iml generated Normal file
View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="EMPTY_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src-tauri/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/.github" />
<excludeFolder url="file://$MODULE_DIR$/.turbo" />
<excludeFolder url="file://$MODULE_DIR$/apps" />
<excludeFolder url="file://$MODULE_DIR$/flatpak" />
<excludeFolder url="file://$MODULE_DIR$/node_modules" />
<excludeFolder url="file://$MODULE_DIR$/packages" />
<excludeFolder url="file://$MODULE_DIR$/src-tauri/target" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/lume.iml" filepath="$PROJECT_DIR$/.idea/lume.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View File

@@ -21,26 +21,27 @@
"@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.40.0",
"@tanstack/react-query": "^5.40.0",
"@tanstack/react-query-persist-client": "^5.40.0",
"@tanstack/react-router": "^1.34.5",
"@tanstack/query-persist-client-core": "^5.45.0",
"@tanstack/react-query": "^5.45.0",
"@tanstack/react-router": "^1.38.1",
"embla-carousel-react": "^8.1.5",
"i18next": "^23.11.5",
"i18next-resources-to-backend": "^1.2.1",
"minidenticons": "^4.2.1",
"nanoid": "^5.0.7",
"nostr-tools": "^2.6.0",
"nostr-tools": "^2.7.0",
"react": "^18.3.1",
"react-currency-input-field": "^3.8.0",
"react-dom": "^18.3.1",
"react-hook-form": "^7.51.5",
"react-hook-form": "^7.52.0",
"react-hotkeys-hook": "^4.5.0",
"react-i18next": "^14.1.2",
"react-string-replace": "^1.1.1",
"slate": "^0.103.0",
"slate-react": "^0.104.0",
"sonner": "^1.4.41",
"slate-react": "^0.105.0",
"sonner": "^1.5.0",
"use-debounce": "^10.0.1",
"virtua": "^0.31.0"
},
@@ -48,16 +49,16 @@
"@lume/tailwindcss": "workspace:^",
"@lume/tsconfig": "workspace:^",
"@lume/types": "workspace:^",
"@tanstack/router-devtools": "^1.34.5",
"@tanstack/router-vite-plugin": "^1.34.1",
"@tanstack/router-devtools": "^1.38.1",
"@tanstack/router-vite-plugin": "^1.38.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react-swc": "^3.7.0",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.3",
"tailwindcss": "^3.4.4",
"typescript": "^5.4.5",
"vite": "^5.2.12",
"vite": "^5.3.1",
"vite-plugin-top-level-await": "^1.4.1",
"vite-tsconfig-paths": "^4.3.2"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -1,20 +1,15 @@
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
import { QueryClient } from "@tanstack/react-query";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createRouter } from "@tanstack/react-router";
import React, { StrictMode } from "react";
import ReactDOM from "react-dom/client";
import { I18nextProvider } from "react-i18next";
import "./app.css";
import { type } from "@tauri-apps/plugin-os";
import i18n from "./locale";
import { routeTree } from "./router.gen"; // auto generated file
import { type } from "@tauri-apps/plugin-os";
const os = await type();
const queryClient = new QueryClient();
const persister = createSyncStoragePersister({
storage: window.localStorage,
});
const os = await type();
// Set up a Router instance
const router = createRouter({
@@ -26,12 +21,9 @@ const router = createRouter({
Wrap: ({ children }) => {
return (
<I18nextProvider i18n={i18n} defaultNS={"translation"}>
<PersistQueryClientProvider
client={queryClient}
persistOptions={{ persister }}
>
<QueryClientProvider client={queryClient}>
{children}
</PersistQueryClientProvider>
</QueryClientProvider>
</I18nextProvider>
);
},

View File

@@ -2,52 +2,61 @@ import { CancelIcon, CheckIcon } from "@lume/icons";
import type { LumeColumn } from "@lume/types";
import { cn } from "@lume/utils";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { getCurrent } from "@tauri-apps/api/webviewWindow";
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
type WindowEvent = {
scroll: boolean;
resize: boolean;
};
export function Column({
column,
account,
isScroll,
isResize,
}: {
column: LumeColumn;
account: string;
isScroll: boolean;
isResize: boolean;
}) {
const container = useRef<HTMLDivElement>(null);
const webviewLabel = `column-${account}_${column.label}`;
const [isCreated, setIsCreated] = useState(false);
const repositionWebview = async () => {
const repositionWebview = useCallback(async () => {
const newRect = container.current.getBoundingClientRect();
await invoke("reposition_column", {
label: webviewLabel,
x: newRect.x,
y: newRect.y,
});
};
}, []);
const resizeWebview = async () => {
const resizeWebview = useCallback(async () => {
const newRect = container.current.getBoundingClientRect();
await invoke("resize_column", {
label: webviewLabel,
width: newRect.width,
height: newRect.height,
});
};
}, []);
useEffect(() => {
if (isCreated) resizeWebview();
}, [isResize]);
if (!isCreated) return;
const unlisten = listen<WindowEvent>("child-webview", (data) => {
if (data.payload.scroll) repositionWebview();
if (data.payload.resize) repositionWebview().then(() => resizeWebview());
});
return () => {
unlisten.then((f) => f());
};
}, [isCreated]);
useEffect(() => {
if (isScroll && isCreated) repositionWebview();
}, [isScroll]);
if (!container?.current) return;
useEffect(() => {
const rect = container.current.getBoundingClientRect();
const url = `${column.content}?account=${account}&label=${column.label}&name=${column.name}`;
@@ -59,16 +68,21 @@ 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]);
return (
<div className="h-full w-[440px] shrink-0 p-2">
<div className="h-full w-[500px] shrink-0 p-2">
<div
className={cn(
"flex flex-col w-full h-full rounded-xl",
@@ -77,9 +91,7 @@ export function Column({
: "",
)}
>
{column.label !== "open" ? (
<Header label={column.label} name={column.name} />
) : null}
<Header label={column.label} name={column.name} />
<div ref={container} className="flex-1 w-full h-full" />
</div>
</div>
@@ -112,10 +124,10 @@ function Header({ label, name }: { label: string; name: string }) {
}, [title]);
return (
<div className="h-9 w-full flex items-center justify-between shrink-0 px-1">
<div className="flex items-center justify-between w-full px-1 h-9 shrink-0">
<div className="size-7" />
<div className="shrink-0 h-9 flex items-center justify-center">
<div className="relative flex gap-2 items-center">
<div className="flex items-center justify-center shrink-0 h-9">
<div className="relative flex items-center gap-2">
<div
contentEditable
suppressContentEditableWarning={true}
@@ -138,7 +150,7 @@ function Header({ label, name }: { label: string; name: string }) {
<button
type="button"
onClick={() => close()}
className="size-7 inline-flex hover:bg-black/10 rounded-lg dark:hover:bg-white/10 items-center justify-center text-neutral-600 dark:text-neutral-400 hover:text-neutral-800 dark:hover:text-neutral-200"
className="inline-flex items-center justify-center rounded-lg size-7 hover:bg-black/10 dark:hover:bg-white/10 text-neutral-600 dark:text-neutral-400 hover:text-neutral-800 dark:hover:text-neutral-200"
>
<CancelIcon className="size-4" />
</button>

View File

@@ -1,23 +1,17 @@
import { ThreadIcon } from "@lume/icons";
import type { NostrEvent } from "@lume/types";
import { Note } from "@/components/note";
import { cn } from "@lume/utils";
import { LumeEvent } from "@lume/system";
import type { LumeEvent } from "@lume/system";
import { useMemo } from "react";
export function Conversation({
event,
gossip,
className,
}: {
event: NostrEvent;
gossip?: boolean;
event: LumeEvent;
className?: string;
}) {
const thread = useMemo(
() => LumeEvent.getEventThread(event.tags, gossip),
[event],
);
const thread = useMemo(() => event.thread, [event]);
return (
<Note.Provider event={event}>
@@ -28,7 +22,7 @@ export function Conversation({
)}
>
<div className="flex flex-col gap-3">
{thread?.root ? <Note.Child eventId={thread?.root} isRoot /> : null}
{thread?.root?.id ? <Note.Child event={thread?.root} isRoot /> : null}
<div className="flex items-center gap-2 px-3">
<div className="inline-flex items-center gap-1.5 shrink-0 text-sm font-medium text-neutral-600 dark:text-neutral-400">
<ThreadIcon className="size-4" />
@@ -36,15 +30,15 @@ export function Conversation({
</div>
<div className="flex-1 h-px bg-neutral-100 dark:bg-white/5" />
</div>
{thread?.reply ? <Note.Child eventId={thread?.reply} /> : null}
{thread?.reply?.id ? <Note.Child event={thread?.reply} /> : null}
<div>
<div className="px-3 h-14 flex items-center justify-between">
<div className="flex items-center justify-between px-3 h-14">
<Note.User />
</div>
<Note.Content className="px-3" />
</div>
</div>
<div className="flex items-center h-14 px-3">
<div className="flex items-center px-3 h-14">
<Note.Open />
</div>
</Note.Root>

View File

@@ -1,24 +0,0 @@
import { cn } from "@lume/utils";
import { useNoteContext } from "./provider";
import { User } from "../user";
export function NoteActivity({ className }: { className?: string }) {
const event = useNoteContext();
const mentions = event.mentions;
return (
<div className={cn("-mt-3 mb-2", className)}>
<div className="text-neutral-700 dark:text-neutral-300 inline-flex items-baseline gap-2 w-full overflow-hidden">
<div className="shrink-0 text-sm font-medium">To:</div>
{mentions.splice(0, 4).map((mention) => (
<User.Provider key={mention} pubkey={mention}>
<User.Root>
<User.Name className="text-sm font-medium" prefix="@" />
</User.Root>
</User.Provider>
))}
{mentions.length > 4 ? "..." : ""}
</div>
</div>
);
}

View File

@@ -1,24 +1,24 @@
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 { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Spinner } from "@lume/ui";
import { useNoteContext } from "../provider";
import { RepostIcon } from "@lume/icons";
import { LumeWindow } from "@lume/system";
import { Spinner } from "@lume/ui";
import { cn } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router";
import { Menu, MenuItem } from "@tauri-apps/api/menu";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import { useNoteContext } from "../provider";
export function NoteRepost({ large = false }: { large?: boolean }) {
const event = useNoteContext();
const { settings } = useRouteContext({ strict: false });
const [t] = useTranslation();
const [loading, setLoading] = useState(false);
const [isRepost, setIsRepost] = useState(false);
const repost = async () => {
if (isRepost) return;
try {
if (isRepost) return;
setLoading(true);
// repost
@@ -30,71 +30,52 @@ export function NoteRepost({ large = false }: { large?: boolean }) {
// notify
toast.success("You've reposted this post successfully");
} catch (e) {
} catch {
setLoading(false);
toast.error("Repost failed, try again later");
}
};
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
e.preventDefault();
const menuItems = await Promise.all([
MenuItem.new({
text: "Quote",
action: async () => repost(),
}),
MenuItem.new({
text: "Repost",
action: () => LumeWindow.openEditor(null, event.id),
}),
]);
const menu = await Menu.new({
items: menuItems,
});
await menu.popup().catch((e) => console.error(e));
}, []);
if (!settings.display_repost_button) return null;
return (
<DropdownMenu.Root>
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<DropdownMenu.Trigger asChild>
<Tooltip.Trigger asChild>
<button
type="button"
className={cn(
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200 rounded-full",
large
? "bg-neutral-100 dark:bg-white/10 h-7 gap-1.5 w-24 text-sm font-medium hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20"
: "size-7",
)}
>
{loading ? (
<Spinner className="size-4" />
) : (
<RepostIcon
className={cn("size-4", isRepost ? "text-blue-500" : "")}
/>
)}
{large ? "Repost" : null}
</button>
</Tooltip.Trigger>
</DropdownMenu.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
{t("note.buttons.repost")}
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
<DropdownMenu.Portal>
<DropdownMenu.Content className="flex w-[200px] flex-col overflow-hidden rounded-xl bg-black p-1 shadow-md shadow-neutral-500/20 focus:outline-none dark:bg-white">
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => repost()}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
>
<RepostIcon className="size-4" />
{t("note.buttons.repost")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => 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" />
{t("note.buttons.quote")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Arrow className="fill-black dark:fill-white" />
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
<button
type="button"
onClick={(e) => showContextMenu(e)}
className={cn(
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200 rounded-full",
large
? "bg-neutral-100 dark:bg-white/10 h-7 gap-1.5 w-24 text-sm font-medium hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20"
: "size-7",
)}
>
{loading ? (
<Spinner className="size-4" />
) : (
<RepostIcon className={cn("size-4", isRepost ? "text-blue-500" : "")} />
)}
{large ? "Repost" : null}
</button>
);
}

View File

@@ -1,14 +1,14 @@
import { ZapIcon } from "@lume/icons";
import { LumeWindow } from "@lume/system";
import { cn } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router";
import { useNoteContext } from "../provider";
import { cn } from "@lume/utils";
import { LumeWindow } from "@lume/system";
export function NoteZap({ large = false }: { large?: boolean }) {
const event = useNoteContext();
const { settings } = useRouteContext({ strict: false });
if (!settings.zap) return null;
if (!settings.display_zap_button) return null;
return (
<button

View File

@@ -2,32 +2,33 @@ import { useEvent } from "@lume/system";
import { cn } from "@lume/utils";
import { Note } from ".";
import { InfoIcon } from "@lume/icons";
import type { EventTag } from "@lume/types";
export function NoteChild({
eventId,
event,
isRoot,
}: {
eventId: string;
event: EventTag;
isRoot?: boolean;
}) {
const { isLoading, isError, data } = useEvent(eventId);
const { isLoading, isError, data } = useEvent(event.id, event.relayHint);
if (isLoading) {
return (
<div className="pt-3 px-3 flex items-center gap-2">
<div className="size-8 shrink-0 rounded-full bg-neutral-200 dark:bg-neutral-800 animate-pulse" />
<div className="animate-pulse rounded-md h-4 w-2/3 bg-neutral-200 dark:bg-neutral-800" />
<div className="flex items-center gap-2 px-3 pt-3">
<div className="rounded-full size-8 shrink-0 bg-neutral-200 dark:bg-neutral-800 animate-pulse" />
<div className="w-2/3 h-4 rounded-md animate-pulse bg-neutral-200 dark:bg-neutral-800" />
</div>
);
}
if (isError || !data) {
return (
<div className="pt-3 px-3 flex items-center gap-2">
<div className="size-8 shrink-0 rounded-full bg-red-500 text-white inline-flex items-center justify-center">
<div className="flex items-center gap-2 px-3 pt-3">
<div className="inline-flex items-center justify-center text-white bg-red-500 rounded-full size-8 shrink-0">
<InfoIcon className="size-5" />
</div>
<p className="text-red-500 text-sm">
<p className="text-sm text-red-500">
Event not found with your current relay set
</p>
</div>
@@ -37,7 +38,7 @@ export function NoteChild({
return (
<Note.Provider event={data}>
<Note.Root className={cn(isRoot ? "mb-3" : "")}>
<div className="h-14 px-3 flex items-center justify-between">
<div className="flex items-center justify-between px-3 h-14">
<Note.User />
</div>
<Note.Content className="px-3" />

View File

@@ -1,4 +1,6 @@
import { NOSTR_EVENTS, NOSTR_MENTIONS, cn, parser } from "@lume/utils";
import { cn } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router";
import { nanoid } from "nanoid";
import { type ReactNode, useMemo } from "react";
import reactStringReplace from "react-string-replace";
import { Hashtag } from "./mentions/hashtag";
@@ -7,7 +9,6 @@ import { MentionUser } from "./mentions/user";
import { Images } from "./preview/images";
import { Videos } from "./preview/videos";
import { useNoteContext } from "./provider";
import { nanoid } from "nanoid";
export function NoteContent({
quote = true,
@@ -20,55 +21,46 @@ export function NoteContent({
clean?: boolean;
className?: string;
}) {
const { settings } = useRouteContext({ strict: false });
const event = useNoteContext();
const data = useMemo(() => {
const { content, images, videos } = parser(event.content);
const words = content.split(/( |\n)/);
const hashtags = words.filter((word) => word.startsWith("#"));
const events = words.filter((word) =>
NOSTR_EVENTS.some((el) => word.startsWith(el)),
);
const mentions = words.filter((word) =>
NOSTR_MENTIONS.some((el) => word.startsWith(el)),
);
let richContent: ReactNode[] | string = content;
const content = useMemo(() => {
try {
if (hashtags.length) {
for (const hashtag of hashtags) {
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
richContent = reactStringReplace(richContent, regex, (_, index) => {
return <Hashtag key={hashtag + index} tag={hashtag} />;
});
// Get parsed meta
const { content, hashtags, events, mentions } = event.meta;
// Define rich content
let richContent: ReactNode[] | string = settings.display_media
? content
: event.content;
for (const hashtag of hashtags) {
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
richContent = reactStringReplace(richContent, regex, (_, index) => {
return <Hashtag key={hashtag + index} tag={hashtag} />;
});
}
for (const event of events) {
if (quote) {
richContent = reactStringReplace(richContent, event, (_, index) => (
<MentionNote key={event + index} eventId={event} />
));
}
if (!quote && clean) {
richContent = reactStringReplace(richContent, event, () => null);
}
}
if (events.length) {
for (const event of events) {
if (quote) {
richContent = reactStringReplace(richContent, event, (_, index) => (
<MentionNote key={event + index} eventId={event} />
));
}
if (!quote && clean) {
richContent = reactStringReplace(richContent, event, () => null);
}
for (const user of mentions) {
if (mention) {
richContent = reactStringReplace(richContent, user, (_, index) => (
<MentionUser key={user + index} pubkey={user} />
));
}
}
if (mentions.length) {
for (const user of mentions) {
if (mention) {
richContent = reactStringReplace(richContent, user, (_, index) => (
<MentionUser key={user + index} pubkey={user} />
));
}
if (!mention && clean) {
richContent = reactStringReplace(richContent, user, () => null);
}
if (!mention && clean) {
richContent = reactStringReplace(richContent, user, () => null);
}
}
@@ -81,7 +73,7 @@ export function NoteContent({
href={match}
target="_blank"
rel="noreferrer"
className="line-clamp-1 text-blue-500 hover:text-blue-600"
className="inline text-blue-500 hover:text-blue-600"
>
{match}
</a>
@@ -92,25 +84,30 @@ export function NoteContent({
<div key={nanoid()} className="h-3" />
));
return { content: richContent, images, videos };
return richContent;
} catch (e) {
return { content, images, videos };
console.log("[parser]: ", e);
return event.content;
}
}, []);
}, [event.content]);
return (
<div className="flex flex-col gap-2">
<div
className={cn(
"select-text text-[15px] text-balance break-words overflow-hidden",
event.content.length > 500 ? "max-h-[300px] gradient-mask-b-0" : "",
"select-text text-pretty content-break overflow-hidden",
event.content.length > 620 ? "max-h-[250px] gradient-mask-b-0" : "",
className,
)}
>
{data.content}
{content}
</div>
{data.images.length ? <Images urls={data.images} /> : null}
{data.videos.length ? <Videos urls={data.videos} /> : null}
{settings.display_media && event.meta?.images.length ? (
<Images urls={event.meta.images} />
) : null}
{settings.display_media && event.meta?.videos.length ? (
<Videos urls={event.meta.videos} />
) : null}
</div>
);
}

View File

@@ -1,13 +1,4 @@
import type { Settings } from "@lume/types";
import {
AUDIOS,
IMAGES,
NOSTR_EVENTS,
NOSTR_MENTIONS,
VIDEOS,
cn,
} from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router";
import { cn } from "@lume/utils";
import { nanoid } from "nanoid";
import { type ReactNode, useMemo } from "react";
import reactStringReplace from "react-string-replace";
@@ -19,135 +10,85 @@ import { VideoPreview } from "./preview/video";
import { useNoteContext } from "./provider";
export function NoteContentLarge({
compact = true,
className,
}: {
compact?: boolean;
className?: string;
}) {
const { settings }: { settings: Settings } = useRouteContext({
strict: false,
});
const event = useNoteContext();
const content = useMemo(() => {
const text = event.content.trim();
const words = text.split(/( |\n)/);
// @ts-ignore, kaboom !!!
let parsedContent: ReactNode[] = compact
? text.replace(/\n\s*\n/g, "\n")
: text;
const hashtags = words.filter((word) => word.startsWith("#"));
const events = words.filter((word) =>
NOSTR_EVENTS.some((el) => word.startsWith(el)),
);
const mentions = words.filter((word) =>
NOSTR_MENTIONS.some((el) => word.startsWith(el)),
);
try {
if (hashtags.length) {
for (const hashtag of hashtags) {
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
parsedContent = reactStringReplace(parsedContent, regex, () => {
return <Hashtag key={nanoid()} tag={hashtag} />;
});
}
}
// Get parsed meta
const { images, videos, hashtags, events, mentions } = event.meta;
if (events.length) {
for (const event of events) {
parsedContent = reactStringReplace(
parsedContent,
event,
(match, i) => <MentionNote key={match + i} eventId={event} />,
);
}
}
// Define rich content
let richContent: ReactNode[] | string = event.content;
if (mentions.length) {
for (const mention of mentions) {
parsedContent = reactStringReplace(
parsedContent,
mention,
(match, i) => <MentionUser key={match + i} pubkey={mention} />,
);
}
}
parsedContent = reactStringReplace(
parsedContent,
/(https?:\/\/\S+)/gi,
(match, i) => {
try {
const url = new URL(match);
const ext = url.pathname.split(".")[1];
if (!settings.enhancedPrivacy) {
if (IMAGES.includes(ext)) {
return <ImagePreview key={match + i} url={url.toString()} />;
}
if (VIDEOS.includes(ext)) {
return <VideoPreview key={match + i} url={url.toString()} />;
}
if (AUDIOS.includes(ext)) {
return <VideoPreview key={match + i} url={url.toString()} />;
}
}
return (
<a
key={match + i}
href={match}
target="_blank"
rel="noreferrer"
className="content-break w-full font-normal text-blue-500 hover:text-blue-600"
>
{match}
</a>
);
} catch {
return (
<a
key={match + i}
href={match}
target="_blank"
rel="noreferrer"
className="content-break w-full font-normal text-blue-500 hover:text-blue-600"
>
{match}
</a>
);
}
},
);
if (compact) {
parsedContent = reactStringReplace(parsedContent, /\n*\n/g, () => (
<div key={nanoid()} className="h-1.5" />
for (const hashtag of hashtags) {
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
richContent = reactStringReplace(richContent, regex, () => (
<Hashtag key={nanoid()} tag={hashtag} />
));
}
parsedContent = reactStringReplace(
parsedContent,
/[\r]?\n[\r]?\n/g,
(_, index) => <br key={event.id + "_br_" + index} />,
for (const event of events) {
richContent = reactStringReplace(richContent, event, (match, i) => (
<MentionNote key={match + i} eventId={event} />
));
}
for (const mention of mentions) {
richContent = reactStringReplace(richContent, mention, (match, i) => (
<MentionUser key={match + i} pubkey={mention} />
));
}
for (const image of images) {
richContent = reactStringReplace(richContent, image, (match, i) => (
<ImagePreview key={match + i} url={match} />
));
}
for (const video of videos) {
richContent = reactStringReplace(richContent, video, (match, i) => (
<VideoPreview key={match + i} url={match} />
));
}
richContent = reactStringReplace(
richContent,
/(https?:\/\/\S+)/gi,
(match, i) => (
<a
key={match + i}
href={match}
target="_blank"
rel="noreferrer"
className="text-blue-500 line-clamp-1 hover:text-blue-600"
>
{match}
</a>
),
);
return parsedContent;
richContent = reactStringReplace(richContent, /(\r\n|\r|\n)+/g, () => (
<div key={nanoid()} className="h-3" />
));
return richContent;
} catch (e) {
return text;
console.log("[parser]: ", e);
return event.content;
}
}, []);
}, [event.content]);
return (
<div className={cn("select-text", className)}>
<div className="text-[15px] text-balance content-break leading-normal">
{content}
</div>
<div
className={cn(
"select-text leading-normal text-pretty content-break",
className,
)}
>
{content}
</div>
);
}

View File

@@ -1,4 +1,3 @@
import { NoteActivity } from "./activity";
import { NoteOpenThread } from "./buttons/open";
import { NoteReply } from "./buttons/reply";
import { NoteRepost } from "./buttons/repost";
@@ -23,5 +22,4 @@ export const Note = {
Zap: NoteZap,
Open: NoteOpenThread,
Child: NoteChild,
Activity: NoteActivity,
};

View File

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

View File

@@ -17,7 +17,7 @@ export function MentionNote({
if (isLoading) {
return (
<div className="mt-2 w-full flex h-20 items-center justify-center rounded-xl border border-black/10 dark:border-white/10">
<div className="flex items-center justify-center w-full h-20 mt-2 border rounded-xl border-black/10 dark:border-white/10">
<Spinner className="size-5" />
</div>
);
@@ -25,18 +25,18 @@ export function MentionNote({
if (isError || !data) {
return (
<div className="mt-2 w-full rounded-xl border border-black/10 p-3 dark:border-white/10">
<div className="w-full p-3 mt-2 border rounded-xl border-black/10 dark:border-white/10">
{t("note.error")}
</div>
);
}
return (
<div className="mt-2 flex w-full cursor-default flex-col rounded-xl border border-black/10 dark:border-white/10">
<div className="flex flex-col w-full border rounded-lg cursor-default border-black/10 dark:border-white/10">
<User.Provider pubkey={data.pubkey}>
<User.Root className="flex h-12 items-center gap-2 px-3">
<User.Avatar className="size-6 shrink-0 rounded-full object-cover" />
<div className="inline-flex flex-1 items-center gap-2">
<User.Root className="flex items-center gap-2 px-3 h-11">
<User.Avatar className="object-cover rounded-full size-6 shrink-0" />
<div className="inline-flex items-center flex-1 gap-2">
<User.Name className="font-semibold text-neutral-900 dark:text-neutral-100" />
<span className="text-neutral-600 dark:text-neutral-400">·</span>
<User.Time
@@ -48,21 +48,21 @@ export function MentionNote({
</User.Provider>
<div
className={cn(
"px-3 select-text content-break whitespace-normal text-balance leading-normal",
data.content.length > 100 ? "max-h-[150px] gradient-mask-b-0" : "",
"px-3 select-text whitespace-normal text-pretty content-break leading-normal",
data.content.length > 400 ? "max-h-[150px] gradient-mask-b-0" : "",
)}
>
{data.content}
</div>
{openable ? (
<div className="flex h-14 items-center justify-end px-3">
<div className="flex items-center justify-end px-2 h-11">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
LumeWindow.openEvent(data);
}}
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"
className="z-10 inline-flex items-center justify-center gap-1 text-sm rounded-full h-7 w-28 bg-black/10 dark:bg-white/10 text-neutral-600 hover:text-blue-500 dark:text-neutral-400"
>
View post
<LinkIcon className="size-4" />

View File

@@ -1,99 +1,62 @@
import { HorizontalDotsIcon } from "@lume/icons";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { useTranslation } from "react-i18next";
import { useNoteContext } from "./provider";
import { LumeWindow } from "@lume/system";
import { useCallback } from "react";
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
export function NoteMenu() {
const { t } = useTranslation();
const event = useNoteContext();
const copyID = async () => {
await writeText(await event.idAsBech32());
};
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
e.preventDefault();
const copyRaw = async () => {
await writeText(JSON.stringify(event));
};
const menuItems = await Promise.all([
MenuItem.new({
text: "Copy Sharable Link",
action: async () => {
const eventId = await event.idAsBech32();
await writeText(`https://njump.me/${eventId}`);
},
}),
MenuItem.new({
text: "Copy Event ID",
action: async () => {
const eventId = await event.idAsBech32();
await writeText(eventId);
},
}),
MenuItem.new({
text: "Copy Public Key",
action: async () => {
const pubkey = await event.pubkeyAsBech32();
await writeText(pubkey);
},
}),
PredefinedMenuItem.new({ item: "Separator" }),
MenuItem.new({
text: "Copy Raw Event",
action: async () => {
event.meta = undefined;
const raw = JSON.stringify(event);
await writeText(raw);
},
}),
]);
const copyNpub = async () => {
await writeText(await event.pubkeyAsBech32());
};
const menu = await Menu.new({
items: menuItems,
});
const copyLink = async () => {
await writeText(`https://njump.me/${await event.idAsBech32()}`);
};
await menu.popup().catch((e) => console.error(e));
}, []);
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
type="button"
className="group inline-flex size-7 items-center justify-center text-neutral-600 dark:text-neutral-400"
>
<HorizontalDotsIcon className="size-5" />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content className="flex w-[200px] flex-col overflow-hidden rounded-xl bg-black p-1 shadow-md shadow-neutral-500/20 focus:outline-none dark:bg-white">
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => 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")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => copyLink()}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
>
{t("note.menu.copyLink")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => copyID()}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
>
{t("note.menu.copyNoteId")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => copyNpub()}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
>
{t("note.menu.copyAuthorId")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
onClick={() => 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")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Separator className="my-1 h-px bg-neutral-900 dark:bg-neutral-100" />
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => copyRaw()}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
>
{t("note.menu.copyRaw")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Arrow className="fill-black dark:fill-white" />
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
<button
type="button"
onClick={(e) => showContextMenu(e)}
className="inline-flex items-center justify-center group size-7 text-neutral-600 dark:text-neutral-400"
>
<HorizontalDotsIcon className="size-5" />
</button>
);
}

View File

@@ -1,61 +1,48 @@
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";
import { useRouteContext } from "@tanstack/react-router";
import { open } from "@tauri-apps/plugin-shell";
import { useMemo } from "react";
export function ImagePreview({ url }: { url: string }) {
const [downloaded, setDownloaded] = useState(false);
const { settings } = useRouteContext({ strict: false });
const downloadImage = async (e: { stopPropagation: () => void }) => {
try {
e.stopPropagation();
const downloadDirPath = await downloadDir();
const filename = url.substring(url.lastIndexOf("/") + 1);
await download(url, `${downloadDirPath}/${filename}`);
setDownloaded(true);
} catch (e) {
console.error(e);
const imageUrl = useMemo(() => {
if (settings.image_resize_service.length) {
const newUrl = `${settings.image_resize_service}?url=${url}&ll&af&default=1&n=-1`;
return newUrl;
} else {
return url;
}
};
}, [settings.image_resize_service]);
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";
};
if (!settings.display_media) {
return (
<a
href={url}
target="_blank"
rel="noreferrer"
className="inline text-blue-500 hover:text-blue-600"
>
{url}
</a>
);
}
return (
// biome-ignore lint/a11y/useKeyWithClickEvents: <explanation>
<div onClick={() => open()} className="group relative my-1">
<div className="my-1">
<img
src={url}
src={imageUrl}
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)}
onKeyDown={() => open(url)}
onError={({ currentTarget }) => {
currentTarget.onerror = null;
currentTarget.src = "/404.jpg";
}}
/>
<button
type="button"
onClick={(e) => downloadImage(e)}
className="absolute right-2 top-2 z-20 hidden size-8 items-center justify-center rounded-md bg-white/10 text-white/70 backdrop-blur-2xl hover:bg-blue-500 hover:text-white group-hover:inline-flex"
>
{downloaded ? (
<CheckCircleIcon className="size-4" />
) : (
<DownloadIcon className="size-4" />
)}
</button>
</div>
);
}

View File

@@ -1,41 +1,39 @@
import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
import { Carousel, CarouselItem } from "@lume/ui";
import { useRouteContext } from "@tanstack/react-router";
import { open } from "@tauri-apps/plugin-shell";
import { useMemo } from "react";
export function Images({ urls }: { urls: string[] }) {
const 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 { settings } = useRouteContext({ strict: false });
if (!window) {
const newWindow = new WebviewWindow(label, {
url,
title: "Image Viewer",
width: 800,
height: 800,
titleBarStyle: "overlay",
});
return newWindow;
const imageUrls = useMemo(() => {
if (settings.image_resize_service.length) {
const newUrls = urls.map(
(url) =>
`${settings.image_resize_service}?url=${url}&ll&af&default=1&n=-1`,
);
return newUrls;
} else {
return urls;
}
return await window.setFocus();
};
}, [settings.image_resize_service]);
if (urls.length === 1) {
return (
<div className="group px-3">
<div className="px-3 group">
<img
src={urls[0]}
src={imageUrls[0]}
alt={urls[0]}
loading="lazy"
decoding="async"
style={{ contentVisibility: "auto" }}
className="max-h-[400px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
onClick={() => open(urls[0])}
onClick={() => urls[0]}
onKeyDown={() => urls[0]}
onError={({ currentTarget }) => {
currentTarget.onerror = null;
currentTarget.src = "/404.jpg";
}}
/>
</div>
);
@@ -43,7 +41,7 @@ export function Images({ urls }: { urls: string[] }) {
return (
<Carousel
items={urls}
items={imageUrls}
renderItem={({ item, isSnapPoint }) => (
<CarouselItem key={item} isSnapPoint={isSnapPoint}>
<img
@@ -52,8 +50,13 @@ export function Images({ urls }: { urls: string[] }) {
loading="lazy"
decoding="async"
style={{ contentVisibility: "auto" }}
className="w-full h-full object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
className="object-cover w-full h-full rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
onClick={() => open(item)}
onKeyDown={() => open(item)}
onError={({ currentTarget }) => {
currentTarget.onerror = null;
currentTarget.src = "/404.jpg";
}}
/>
</CarouselItem>
)}

View File

@@ -1,87 +0,0 @@
import { useOpenGraph } from "@lume/utils";
function isImage(url: string) {
return /^https?:\/\/.+\.(jpg|jpeg|png|webp|avif)$/.test(url);
}
export function LinkPreview({ url }: { url: string }) {
const domain = new URL(url);
const { isLoading, isError, data } = useOpenGraph(url);
if (isLoading) {
return (
<div className="my-1.5 flex w-full flex-col overflow-hidden rounded-2xl border border-black/10 p-3 dark:border-white/10">
<div className="h-48 w-full shrink-0 animate-pulse bg-neutral-300 dark:bg-neutral-700" />
<div className="flex flex-col gap-2 px-3 py-3">
<div className="h-3 w-2/3 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
<div className="h-3 w-3/4 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
<span className="mt-2.5 text-sm leading-none text-neutral-600 dark:text-neutral-400">
{domain.hostname}
</span>
</div>
</div>
);
}
if (!data.title && !data.image && !data.description) {
return (
<a
href={url}
target="_blank"
rel="noreferrer"
className="inline-block text-blue-500 hover:text-blue-600"
>
{url}
</a>
);
}
if (isError) {
return (
<a
href={url}
target="_blank"
rel="noreferrer"
className="inline-block text-blue-500 hover:text-blue-600"
>
{url}
</a>
);
}
return (
<a
href={url}
target="_blank"
rel="noreferrer"
className="my-1 flex w-full flex-col overflow-hidden rounded-2xl border border-black/10 dark:border-white/10"
>
{isImage(data.image) ? (
<img
src={data.image}
alt={url}
loading="lazy"
decoding="async"
className="h-48 w-full shrink-0 rounded-t-lg bg-white object-cover"
/>
) : null}
<div className="flex flex-col items-start p-3">
<div className="flex flex-col items-start text-left">
{data.title ? (
<div className="content-break line-clamp-1 text-base font-semibold text-neutral-900 dark:text-neutral-100">
{data.title}
</div>
) : null}
{data.description ? (
<div className="content-break mb-2 line-clamp-3 text-balance text-sm text-neutral-700 dark:text-neutral-400">
{data.description}
</div>
) : null}
</div>
<div className="break-all text-sm font-semibold text-blue-500">
{domain.hostname}
</div>
</div>
</a>
);
}

View File

@@ -1,8 +1,25 @@
import { useRouteContext } from "@tanstack/react-router";
export function VideoPreview({ url }: { url: string }) {
const { settings } = useRouteContext({ strict: false });
if (settings.display_media) {
return (
<a
href={url}
target="_blank"
rel="noreferrer"
className="inline text-blue-500 hover:text-blue-600"
>
{url}
</a>
);
}
return (
<div className="my-1 overflow-hidden rounded-xl">
<div className="my-1">
<video
className="h-auto w-full bg-neutral-100 text-sm dark:bg-neutral-900"
className="max-h-[600px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
controls
muted
>

View File

@@ -1,5 +1,4 @@
import { LumeEvent } from "@lume/system";
import type { NostrEvent } from "@lume/types";
import type { LumeEvent } from "@lume/system";
import { type ReactNode, createContext, useContext } from "react";
const NoteContext = createContext<LumeEvent>(null);
@@ -8,14 +7,10 @@ export function NoteProvider({
event,
children,
}: {
event: NostrEvent;
event: LumeEvent;
children: ReactNode;
}) {
const lumeEvent = new LumeEvent(event);
return (
<NoteContext.Provider value={lumeEvent}>{children}</NoteContext.Provider>
);
return <NoteContext.Provider value={event}>{children}</NoteContext.Provider>;
}
export function useNoteContext() {

View File

@@ -15,9 +15,9 @@ export function NoteUser({ className }: { className?: string }) {
>
<div className="flex w-full gap-2">
<HoverCard.Trigger className="shrink-0">
<User.Avatar className="size-8 rounded-full object-cover outline outline-1 -outline-offset-1 outline-black/15" />
<User.Avatar className="object-cover rounded-full size-8 outline outline-1 -outline-offset-1 outline-black/15" />
</HoverCard.Trigger>
<div className="flex w-full items-center gap-3">
<div className="flex items-center w-full gap-3">
<div className="flex items-center gap-1">
<User.Name className="font-semibold text-neutral-950 dark:text-neutral-50" />
<User.NIP05 />
@@ -37,16 +37,17 @@ export function NoteUser({ className }: { className?: string }) {
side="right"
>
<div className="flex flex-col gap-2">
<User.Avatar className="size-11 rounded-lg object-cover" />
<User.Avatar className="object-cover rounded-lg size-11" />
<div className="flex flex-col gap-2">
<div className="inline-flex items-center gap-1">
<User.Name className="font-semibold leading-tight text-white dark:text-neutral-900" />
<User.NIP05 />
</div>
<User.About className="line-clamp-3 text-sm text-white dark:text-neutral-900" />
<User.About className="text-sm text-white line-clamp-3 dark:text-neutral-900" />
<button
type="button"
onClick={() => LumeWindow.openProfile(event.pubkey)}
className="mt-2 inline-flex h-9 w-full items-center justify-center rounded-lg bg-white text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-100 dark:text-neutral-900 dark:hover:bg-neutral-200"
className="inline-flex items-center justify-center w-full mt-2 text-sm font-medium bg-white rounded-lg h-9 hover:bg-neutral-200 dark:bg-neutral-100 dark:text-neutral-900 dark:hover:bg-neutral-200"
>
View profile
</button>

View File

@@ -1,32 +0,0 @@
import type { NostrEvent } from "@lume/types";
import { Note } from "@/components/note";
import { cn } from "@lume/utils";
export function Notification({
event,
className,
}: {
event: NostrEvent;
className?: string;
}) {
return (
<Note.Provider event={event}>
<Note.Root
className={cn(
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl flex flex-col gap-3 shadow-primary dark:ring-1 ring-neutral-800/50",
className,
)}
>
<div>
<div className="px-3 h-14 flex items-center justify-between">
<Note.User />
</div>
<Note.Content className="px-3" />
</div>
<div className="flex items-center h-14 px-3">
<Note.Open />
</div>
</Note.Root>
</Note.Provider>
);
}

View File

@@ -1,19 +1,15 @@
import { QuoteIcon } from "@lume/icons";
import type { NostrEvent } from "@lume/types";
import { Note } from "@/components/note";
import { cn } from "@lume/utils";
import type { LumeEvent } from "@lume/system";
export function Quote({
event,
className,
}: {
event: NostrEvent;
event: LumeEvent;
className?: string;
}) {
const quoteEventId = event.tags.find(
(tag) => tag[0] === "q" || tag[3] === "mention",
)?.[1];
return (
<Note.Provider event={event}>
<Note.Root
@@ -23,7 +19,7 @@ export function Quote({
)}
>
<div className="flex flex-col gap-3">
<Note.Child eventId={quoteEventId} isRoot />
<Note.Child event={event.quote} isRoot />
<div className="flex items-center gap-2 px-3">
<div className="inline-flex items-center gap-1.5 shrink-0 text-sm font-medium text-neutral-600 dark:text-neutral-400">
<QuoteIcon className="size-4" />
@@ -32,13 +28,13 @@ export function Quote({
<div className="flex-1 h-px bg-neutral-100 dark:bg-white/5" />
</div>
<div>
<div className="px-3 h-14 flex items-center justify-between">
<div className="flex items-center justify-between px-3 h-14">
<Note.User />
</div>
<Note.Content className="px-3" quote={false} clean />
</div>
</div>
<div className="flex items-center h-14 px-3">
<div className="flex items-center px-3 h-14">
<Note.Open />
</div>
</Note.Root>

View File

@@ -3,78 +3,75 @@ import { Note } from "@/components/note";
import { User } from "@/components/user";
import { cn } from "@lume/utils";
import { useQuery } from "@tanstack/react-query";
import { NostrEvent } from "@lume/types";
import { NostrQuery } from "@lume/system";
import { type LumeEvent, NostrQuery } from "@lume/system";
export function RepostNote({
event,
className,
}: {
event: NostrEvent;
event: LumeEvent;
className?: string;
}) {
const {
isLoading,
isError,
data: repostEvent,
} = useQuery({
queryKey: ["repost", event.id],
const { isLoading, isError, data } = useQuery({
queryKey: ["event", event.repostId],
queryFn: async () => {
try {
if (event.content.length > 50) {
const embed: NostrEvent = JSON.parse(event.content);
return embed;
}
const id = event.tags.find((el) => el[0] === "e")?.[1];
const repostEvent = await NostrQuery.getEvent(id);
return repostEvent;
const data = await NostrQuery.getRepostEvent(event);
return data;
} catch (e) {
throw new Error(e);
}
},
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
staleTime: Number.POSITIVE_INFINITY,
retry: 2,
});
return (
<Note.Root
className={cn(
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl mb-3 shadow-primary dark:ring-1 ring-neutral-800/50",
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50",
className,
)}
>
<User.Provider pubkey={event.pubkey}>
<User.Root className="flex items-center gap-2 px-3 py-3 border-b border-neutral-100 dark:border-neutral-800/50 rounded-t-xl">
<div className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
Reposted by
</div>
<User.Avatar className="size-6 shrink-0 rounded-full object-cover ring-1 ring-neutral-200/50 dark:ring-neutral-800/50" />
</User.Root>
</User.Provider>
{isLoading ? (
<div className="flex h-20 items-center justify-center gap-2">
<div className="flex items-center justify-center h-20 gap-2">
<Spinner />
Loading event...
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
Loading event...
</span>
</div>
) : isError || !repostEvent ? (
<div className="flex h-20 items-center justify-center">
) : isError || !data ? (
<div className="flex items-center justify-center h-20">
Event not found within your current relay set
</div>
) : (
<Note.Provider event={repostEvent}>
<Note.Provider event={data}>
<Note.Root>
<div className="px-3 h-14 flex items-center justify-between">
<div className="flex items-center justify-between px-3 h-14">
<Note.User />
<Note.Menu />
</div>
<Note.Content className="px-3" />
<div className="mt-3 flex items-center gap-4 h-14 px-3">
<Note.Open />
<Note.Reply />
<Note.Repost />
<Note.Zap />
<div className="flex items-center justify-between px-3 mt-3 h-14">
<div className="inline-flex items-center gap-3">
<Note.Open />
<Note.Reply />
<Note.Repost />
<Note.Zap />
</div>
<div>
<User.Provider pubkey={event.pubkey}>
<User.Root className="flex items-center gap-2">
<div className="text-sm font-medium text-neutral-800 dark:text-neutral-200">
Reposted by
</div>
<User.Avatar className="object-cover rounded-full size-6 shrink-0 ring-1 ring-neutral-200/50 dark:ring-neutral-800/50" />
</User.Root>
</User.Provider>
</div>
</div>
</Note.Root>
</Note.Provider>

View File

@@ -1,12 +1,12 @@
import type { NostrEvent } from "@lume/types";
import { cn } from "@lume/utils";
import { Note } from "@/components/note";
import type { LumeEvent } from "@lume/system";
export function TextNote({
event,
className,
}: {
event: NostrEvent;
event: LumeEvent;
className?: string;
}) {
return (
@@ -17,12 +17,12 @@ export function TextNote({
className,
)}
>
<div className="px-3 h-14 flex items-center justify-between">
<div className="flex items-center justify-between px-3 h-14">
<Note.User />
<Note.Menu />
</div>
<Note.Content className="px-3" />
<div className="mt-3 flex items-center gap-4 h-14 px-3">
<div className="flex items-center gap-4 px-3 mt-3 h-14">
<Note.Open />
<Note.Reply />
<Note.Repost />

View File

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

View File

@@ -15,7 +15,7 @@ export function UserCover({ className }: { className?: string }) {
);
}
if (user && !user.profile.banner) {
if (user && !user.profile?.banner) {
return (
<div
className={cn("bg-gradient-to-b from-blue-400 to-teal-200", className)}
@@ -25,7 +25,7 @@ export function UserCover({ className }: { className?: string }) {
return (
<img
src={user.profile.banner}
src={user?.profile?.banner}
alt="banner"
loading="lazy"
decoding="async"

View File

@@ -1,6 +1,5 @@
import { cn } from "@lume/utils";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Spinner } from "@lume/ui";
import { useUserContext } from "./provider";
import { NostrAccount } from "@lume/system";
@@ -14,34 +13,30 @@ export function UserFollowButton({
}) {
const user = useUserContext();
const [t] = useTranslation();
const [loading, setLoading] = useState(false);
const [followed, setFollowed] = useState(false);
const toggleFollow = async () => {
setLoading(true);
if (!followed) {
const add = await NostrAccount.follow(user.pubkey, user.profile?.name);
if (add) setFollowed(true);
} else {
const remove = await NostrAccount.unfollow(user.pubkey);
if (remove) setFollowed(false);
const toggle = await NostrAccount.toggleContact(user.pubkey);
if (toggle) {
setFollowed((prev) => !prev);
setLoading(false);
}
setLoading(false);
};
useEffect(() => {
async function status() {
setLoading(true);
let mounted = true;
const contacts = await NostrAccount.getContactList();
if (contacts?.includes(user.pubkey)) {
setFollowed(true);
}
NostrAccount.checkContact(user.pubkey).then((status) => {
if (mounted) setFollowed(status);
});
setLoading(false);
}
status();
return () => {
mounted = false;
};
}, []);
return (
@@ -55,10 +50,10 @@ export function UserFollowButton({
<Spinner className="size-4" />
) : followed ? (
!simple ? (
t("user.unfollow")
"Unfollow"
) : null
) : (
t("user.follow")
"Follow"
)}
</button>
);

View File

@@ -4,19 +4,32 @@ import * as Tooltip from "@radix-ui/react-tooltip";
import { useQuery } from "@tanstack/react-query";
import { useUserContext } from "./provider";
import { NostrQuery } from "@lume/system";
import { experimental_createPersister } from "@tanstack/query-persist-client-core";
export function UserNip05() {
const user = useUserContext();
const { isLoading, data: verified } = useQuery({
queryKey: ["nip05", user?.pubkey],
queryFn: async () => {
if (!user.profile?.nip05?.length) return false;
const verify = await NostrQuery.verifyNip05(
user.pubkey,
user.profile?.nip05,
);
return verify;
},
enabled: !!user.profile?.nip05,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
staleTime: Number.POSITIVE_INFINITY,
retry: false,
persister: experimental_createPersister({
storage: localStorage,
maxAge: 1000 * 60 * 60 * 72, // 72 hours
}),
});
if (!user.profile?.nip05?.length) return;
@@ -26,7 +39,7 @@ export function UserNip05() {
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger>
{!isLoading && verified ? (
<VerifiedIcon className="size-4 text-teal-500" />
<VerifiedIcon className="text-teal-500 size-4" />
) : null}
</Tooltip.Trigger>
<Tooltip.Portal>

View File

@@ -1,19 +1,19 @@
import { Column } from "@/components/column";
import { Toolbar } from "@/components/toolbar";
import { ArrowLeftIcon, ArrowRightIcon } from "@lume/icons";
import { ArrowLeftIcon, ArrowRightIcon, PlusSquareIcon } from "@lume/icons";
import { NostrQuery } from "@lume/system";
import type { EventColumns, LumeColumn } from "@lume/types";
import type { ColumnEvent, LumeColumn } from "@lume/types";
import { createFileRoute } from "@tanstack/react-router";
import { listen } from "@tauri-apps/api/event";
import { getCurrent } from "@tauri-apps/api/window";
import useEmblaCarousel from "embla-carousel-react";
import { nanoid } from "nanoid";
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useDebouncedCallback } from "use-debounce";
import { VList, type VListHandle } from "virtua";
export const Route = createFileRoute("/$account/home")({
loader: async () => {
const columns = NostrQuery.getColumns();
const columns = await NostrQuery.getColumns();
return columns;
},
component: Screen,
@@ -22,63 +22,47 @@ export const Route = createFileRoute("/$account/home")({
function Screen() {
const { account } = Route.useParams();
const initialColumnList = Route.useLoaderData();
const vlistRef = useRef<VListHandle>(null);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [columns, setColumns] = useState([]);
const [isScroll, setIsScroll] = useState(false);
const [isResize, setIsResize] = useState(false);
const [columns, setColumns] = useState<LumeColumn[]>([]);
const [emblaRef, emblaApi] = useEmblaCarousel({
watchDrag: false,
loop: false,
});
const goLeft = () => {
const prevIndex = Math.max(selectedIndex - 1, 0);
setSelectedIndex(prevIndex);
vlistRef.current.scrollToIndex(prevIndex, {
align: "center",
});
};
const scrollPrev = useCallback(() => {
if (emblaApi) emblaApi.scrollPrev(true);
}, [emblaApi]);
const goRight = () => {
const nextIndex = Math.min(selectedIndex + 1, columns.length - 1);
setSelectedIndex(nextIndex);
vlistRef.current.scrollToIndex(nextIndex, {
align: "center",
});
};
const scrollNext = useCallback(() => {
if (emblaApi) emblaApi.scrollNext(true);
}, [emblaApi]);
const add = useDebouncedCallback((column: LumeColumn) => {
// update col label
column.label = `${column.label}-${nanoid()}`;
const emitScrollEvent = useCallback(() => {
getCurrent().emit("child-webview", { scroll: true });
}, []);
// create new cols
const cols = [...columns];
const openColIndex = cols.findIndex((col) => col.label === "open");
const newCols = [
...cols.slice(0, openColIndex),
column,
...cols.slice(openColIndex),
];
const emitResizeEvent = useCallback(() => {
getCurrent().emit("child-webview", { resize: true, direction: "x" });
}, []);
setColumns(newCols);
setSelectedIndex(newCols.length);
setIsScroll(true);
// scroll to the newest column
vlistRef.current.scrollToIndex(newCols.length - 1, {
align: "end",
const openLumeStore = useDebouncedCallback(async () => {
await getCurrent().emit("columns", {
type: "add",
column: {
label: "store",
name: "Store",
content: "/store/official",
},
});
}, 150);
const add = useDebouncedCallback((column: LumeColumn) => {
column.label = `${column.label}-${nanoid()}`; // update col label
setColumns((prev) => [column, ...prev]);
}, 150);
const remove = useDebouncedCallback((label: string) => {
const newCols = columns.filter((t) => t.label !== label);
setColumns(newCols);
setSelectedIndex(newCols.length);
setIsScroll(true);
// scroll to the first column
vlistRef.current.scrollToIndex(newCols.length - 1, {
align: "start",
});
setColumns((prev) => prev.filter((t) => t.label !== label));
}, 150);
const updateName = useDebouncedCallback((label: string, title: string) => {
@@ -93,75 +77,108 @@ function Screen() {
setColumns(newCols);
}, 150);
const startResize = useDebouncedCallback(
() => setIsResize((prev) => !prev),
150,
);
const reset = useDebouncedCallback(() => setColumns([]), 150);
const handleKeyDown = useDebouncedCallback((event) => {
if (event.defaultPrevented) return;
switch (event.code) {
case "ArrowLeft":
if (emblaApi) emblaApi.scrollPrev(true);
break;
case "ArrowRight":
if (emblaApi) emblaApi.scrollNext(true);
break;
default:
break;
}
event.preventDefault();
}, 150);
useEffect(() => {
if (emblaApi) {
emblaApi.on("scroll", emitScrollEvent);
emblaApi.on("resize", emitResizeEvent);
emblaApi.on("slidesChanged", emitScrollEvent);
}
return () => {
emblaApi?.off("scroll", emitScrollEvent);
emblaApi?.off("resize", emitResizeEvent);
emblaApi?.off("slidesChanged", emitScrollEvent);
};
}, [emblaApi, emitScrollEvent, emitResizeEvent]);
useEffect(() => {
if (columns?.length) {
NostrQuery.setColumns(columns).then(() => console.log("saved"));
}
}, [columns]);
useEffect(() => {
setColumns(initialColumnList);
}, [initialColumnList]);
// Listen for keyboard event
useEffect(() => {
// save state
NostrQuery.setColumns(columns);
}, [columns]);
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [handleKeyDown]);
// Listen for columns event
useEffect(() => {
const unlistenColEvent = listen<EventColumns>("columns", (data) => {
const unlisten = listen<ColumnEvent>("columns", (data) => {
if (data.payload.type === "reset") reset();
if (data.payload.type === "add") add(data.payload.column);
if (data.payload.type === "remove") remove(data.payload.label);
if (data.payload.type === "set_title")
updateName(data.payload.label, data.payload.title);
});
const unlistenWindowResize = getCurrent().listen("tauri://resize", () => {
startResize();
});
return () => {
unlistenColEvent.then((f) => f());
unlistenWindowResize.then((f) => f());
unlisten.then((f) => f());
};
}, []);
return (
<div className="h-full w-full">
<VList
ref={vlistRef}
horizontal
tabIndex={-1}
itemSize={440}
overscan={5}
onScroll={() => setIsScroll(true)}
onScrollEnd={() => setIsScroll(false)}
className="scrollbar-none h-full w-full overflow-x-auto focus:outline-none"
>
{columns.map((column) => (
<Column
key={column.label}
column={column}
account={account}
isScroll={isScroll}
isResize={isResize}
/>
))}
</VList>
<div className="size-full">
<div ref={emblaRef} className="overflow-hidden size-full">
<div className="flex size-full">
{columns?.map((column) => (
<Column
key={account + column.label}
column={column}
account={account}
/>
))}
</div>
</div>
<Toolbar>
<div className="flex items-center gap-1">
<div className="flex items-center h-8 gap-1 p-[2px] rounded-full bg-black/5 dark:bg-white/5">
<button
type="button"
onClick={() => goLeft()}
className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
onClick={() => scrollPrev()}
className="inline-flex items-center justify-center rounded-full size-7 text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
>
<ArrowLeftIcon className="size-5" />
<ArrowLeftIcon className="size-4" />
</button>
<button
type="button"
onClick={() => goRight()}
className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
onClick={() => openLumeStore()}
className="inline-flex items-center justify-center rounded-full size-7 text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
>
<ArrowRightIcon className="size-5" />
<PlusSquareIcon className="size-4" />
</button>
<button
type="button"
onClick={() => scrollNext()}
className="inline-flex items-center justify-center rounded-full size-7 text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
>
<ArrowRightIcon className="size-4" />
</button>
</div>
</Toolbar>

View File

@@ -1,36 +1,23 @@
import { User } from "@/components/user";
import {
BellIcon,
ComposeFilledIcon,
HorizontalDotsIcon,
PlusIcon,
SearchIcon,
} from "@lume/icons";
import { type NostrEvent, Kind } from "@lume/types";
import { User } from "@/components/user";
import {
cn,
decodeZapInvoice,
displayNpub,
sendNativeNotification,
} from "@lume/utils";
import { LumeWindow, NostrAccount } from "@lume/system";
import { cn } from "@lume/utils";
import * as Popover from "@radix-ui/react-popover";
import { Outlet, createFileRoute } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core";
import { Link } from "@tanstack/react-router";
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, NostrQuery } from "@lume/system";
import { Link } from "@tanstack/react-router";
type AccountSearch = {
accounts?: string[];
};
export const Route = createFileRoute("/$account")({
validateSearch: (search: Record<string, unknown>): AccountSearch => {
return {
accounts: (search?.accounts as string[]) || [],
};
beforeLoad: async () => {
const accounts = await NostrAccount.getAccounts();
return { accounts };
},
component: Screen,
});
@@ -39,7 +26,7 @@ function Screen() {
const { platform } = Route.useRouteContext();
return (
<div className="flex h-screen w-screen flex-col">
<div className="flex flex-col w-screen h-screen">
<div
data-tauri-drag-region
className={cn(
@@ -50,8 +37,8 @@ function Screen() {
<div className="flex items-center gap-3">
<Accounts />
<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"
to="/landing"
className="inline-flex items-center justify-center rounded-full size-8 shrink-0 bg-black/10 text-neutral-800 hover:bg-black/20 dark:bg-white/10 dark:text-neutral-200 dark:hover:bg-white/20"
>
<PlusIcon className="size-5" />
</Link>
@@ -60,19 +47,11 @@ function Screen() {
<button
type="button"
onClick={() => LumeWindow.openEditor()}
className="inline-flex h-8 w-max items-center justify-center gap-1 rounded-full bg-blue-500 px-3 text-sm font-medium text-white hover:bg-blue-600"
className="inline-flex items-center justify-center h-8 gap-1 px-3 text-sm font-medium text-white bg-blue-500 rounded-full w-max hover:bg-blue-600"
>
<ComposeFilledIcon className="size-4" />
New Post
</button>
<Bell />
<button
type="button"
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" />
</button>
<div id="toolbar" />
</div>
</div>
@@ -84,7 +63,7 @@ function Screen() {
}
function Accounts() {
const { accounts } = Route.useSearch();
const { accounts } = Route.useRouteContext();
const { account } = Route.useParams();
const [windowWidth, setWindowWidth] = useState<number>(null);
@@ -108,11 +87,20 @@ function Accounts() {
return await LumeWindow.openProfile(account);
}
// change current account and update signer
// 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.");
}
@@ -131,10 +119,15 @@ function Accounts() {
setWindowWidth(getWindowDimensions().width);
}
if (!windowWidth) setWindowWidth(getWindowDimensions().width);
if (!windowWidth) {
setWindowWidth(getWindowDimensions().width);
}
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
return (
@@ -164,7 +157,7 @@ function Accounts() {
))}
{accounts.length >= 3 && windowWidth <= 700 ? (
<Popover.Root>
<Popover.Trigger className="inline-flex size-8 shrink-0 items-center justify-center rounded-full bg-black/10 text-neutral-800 hover:bg-black/20 dark:bg-white/10 dark:text-neutral-200 dark:hover:bg-white/20">
<Popover.Trigger className="inline-flex items-center justify-center rounded-full size-8 shrink-0 bg-black/10 text-neutral-800 hover:bg-black/20 dark:bg-white/10 dark:text-neutral-200 dark:hover:bg-white/20">
<HorizontalDotsIcon className="size-5" />
</Popover.Trigger>
<Popover.Portal>
@@ -174,11 +167,11 @@ function Accounts() {
key={user}
type="button"
onClick={() => changeAccount(user)}
className="size-9 inline-flex items-center justify-center hover:bg-white/10 rounded-md"
className="inline-flex items-center justify-center rounded-md size-9 hover:bg-white/10"
>
<User.Provider pubkey={user}>
<User.Root className="rounded-full ring-1 ring-white/10">
<User.Avatar className="size-7 aspect-square h-auto rounded-full object-cover" />
<User.Avatar className="object-cover h-auto rounded-full size-7 aspect-square" />
</User.Root>
</User.Provider>
</button>
@@ -191,64 +184,3 @@ function Accounts() {
</div>
);
}
function Bell() {
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: NostrEvent = JSON.parse(payload.payload);
const user = await NostrQuery.getProfile(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);
LumeWindow.openActivity(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>
);
}

View File

@@ -1,5 +1,5 @@
import { CheckCircleIcon, InfoCircleIcon, CancelCircleIcon } from "@lume/icons";
import type { Settings } from "@lume/types";
import { CancelCircleIcon, CheckCircleIcon, InfoCircleIcon } from "@lume/icons";
import type { Settings } from "@lume/system";
import { Spinner } from "@lume/ui";
import type { QueryClient } from "@tanstack/react-query";
import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
@@ -40,7 +40,7 @@ export const Route = createRootRouteWithContext<RouterContext>()({
function Pending() {
return (
<div className="flex h-screen w-screen flex-col items-center justify-center">
<div className="flex flex-col items-center justify-center w-screen h-screen">
<Spinner className="size-5" />
</div>
);

View File

@@ -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";
import { NostrQuery } from "@lume/system";
export const Route = createFileRoute("/activity/$account/texts")({
loader: async ({ params }) => {
return { data: defer(NostrQuery.getUserActivities(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>
);
}

View File

@@ -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>
<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>
);
}

View File

@@ -1,67 +0,0 @@
import { User } from "@/components/user";
import { NostrQuery } from "@lume/system";
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 ({ params }) => {
return {
data: defer(NostrQuery.getUserActivities(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>
);
}

View File

@@ -32,9 +32,7 @@ function Screen() {
return toast.warning("You need to confirm before continue");
}
return navigate({
to: "/auth/settings",
});
navigate({ to: "/", replace: true });
}
// start loading
@@ -64,7 +62,7 @@ function Screen() {
};
return (
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
<div className="flex flex-col items-center justify-center w-full h-full gap-6 px-5 mx-auto xl:max-w-xl">
<div className="flex flex-col text-center">
<h3 className="text-xl font-semibold">Backup your sign in keys</h3>
<p className="text-neutral-700 dark:text-neutral-300">
@@ -72,7 +70,7 @@ function Screen() {
access to your account if you lose this key.
</p>
</div>
<div className="flex w-full flex-col gap-5">
<div className="flex flex-col w-full gap-5">
<div className="flex flex-col gap-2">
<label htmlFor="passphase" className="font-medium">
Set a passphase to secure your key
@@ -83,7 +81,7 @@ function Screen() {
type="password"
value={passphase}
onChange={(e) => setPassphase(e.target.value)}
className="w-full h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
className="w-full px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
/>
</div>
</div>
@@ -99,12 +97,12 @@ function Screen() {
type="text"
value={displayNsec(key, 36)}
readOnly
className="w-full h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
className="w-full px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
/>
<button
type="button"
onClick={() => copyKey()}
className="inline-flex h-11 w-24 items-center justify-center rounded-lg bg-neutral-200 hover:bg-neutral-300 dark:bg-white/20 dark:hover:bg-white/30"
className="inline-flex items-center justify-center w-24 rounded-lg h-11 bg-neutral-200 hover:bg-neutral-300 dark:bg-white/20 dark:hover:bg-white/30"
>
{copied ? "Copied" : "Copy"}
</button>
@@ -119,7 +117,7 @@ function Screen() {
onCheckedChange={() =>
setConfirm((state) => ({ ...state, c1: !state.c1 }))
}
className="flex size-6 appearance-none items-center justify-center rounded-md bg-neutral-100 outline-none dark:bg-white/10 dark:hover:bg-white/20"
className="flex items-center justify-center rounded-md outline-none appearance-none size-6 bg-neutral-100 dark:bg-white/10 dark:hover:bg-white/20"
id="confirm1"
>
<Checkbox.Indicator className="text-blue-500">
@@ -139,7 +137,7 @@ function Screen() {
onCheckedChange={() =>
setConfirm((state) => ({ ...state, c2: !state.c2 }))
}
className="flex size-6 appearance-none items-center justify-center rounded-md bg-neutral-100 outline-none dark:bg-white/10 dark:hover:bg-white/20"
className="flex items-center justify-center rounded-md outline-none appearance-none size-6 bg-neutral-100 dark:bg-white/10 dark:hover:bg-white/20"
id="confirm2"
>
<Checkbox.Indicator className="text-blue-500">
@@ -159,7 +157,7 @@ function Screen() {
onCheckedChange={() =>
setConfirm((state) => ({ ...state, c3: !state.c3 }))
}
className="flex size-6 appearance-none items-center justify-center rounded-md bg-neutral-100 outline-none dark:bg-white/10 dark:hover:bg-white/20"
className="flex items-center justify-center rounded-md outline-none appearance-none size-6 bg-neutral-100 dark:bg-white/10 dark:hover:bg-white/20"
id="confirm3"
>
<Checkbox.Indicator className="text-blue-500">
@@ -182,7 +180,7 @@ function Screen() {
type="button"
onClick={() => submit()}
disabled={loading}
className="inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
className="inline-flex items-center justify-center w-full font-semibold text-white bg-blue-500 rounded-lg h-11 shrink-0 hover:bg-blue-600 disabled:opacity-50"
>
{loading ? <Spinner /> : t("global.continue")}
</button>

View File

@@ -9,7 +9,7 @@ import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
export const Route = createFileRoute("/auth/new/profile")({
export const Route = createFileRoute("/auth/create-profile")({
component: Screen,
loader: async () => {
const account = await NostrAccount.createAccount();
@@ -58,24 +58,24 @@ function Screen() {
};
return (
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
<div className="flex flex-col items-center justify-center w-full h-full gap-6 px-5 mx-auto xl:max-w-xl">
<div className="text-center">
<h3 className="text-xl font-semibold">Let's set up your profile.</h3>
</div>
<div>
<div className="relative size-24 rounded-full bg-gradient-to-tr from-orange-100 via-red-50 to-blue-200">
<div className="relative rounded-full size-24 bg-gradient-to-tr from-orange-100 via-red-50 to-blue-200">
{picture ? (
<img
src={picture}
alt="avatar"
loading="lazy"
decoding="async"
className="absolute inset-0 z-10 h-full w-full rounded-full object-cover"
className="absolute inset-0 z-10 object-cover w-full h-full rounded-full"
/>
) : null}
<AvatarUploader
setPicture={setPicture}
className="absolute inset-0 z-20 flex h-full w-full items-center justify-center rounded-full dark:text-black bg-black/10 text-white hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
className="absolute inset-0 z-20 flex items-center justify-center w-full h-full text-white rounded-full dark:text-black bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
>
<PlusIcon className="size-8" />
</AvatarUploader>
@@ -83,7 +83,7 @@ function Screen() {
</div>
<form
onSubmit={handleSubmit(onSubmit)}
className="flex w-full flex-col gap-3"
className="flex flex-col w-full gap-3"
>
<div className="flex flex-col gap-1">
<label htmlFor="display_name" className="font-medium">
@@ -94,7 +94,7 @@ function Screen() {
{...register("display_name", { required: true, minLength: 1 })}
placeholder="e.g. Alice in Nostrland"
spellCheck={false}
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
className="px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
/>
</div>
<div className="flex flex-col gap-1">
@@ -106,7 +106,7 @@ function Screen() {
{...register("name")}
placeholder="e.g. alice"
spellCheck={false}
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
className="px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
/>
</div>
<div className="flex flex-col gap-1">
@@ -129,12 +129,12 @@ function Screen() {
{...register("website")}
placeholder="e.g. https://alice.me"
spellCheck={false}
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
className="px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-500 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
/>
</div>
<button
type="submit"
className="mt-3 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
className="inline-flex items-center justify-center w-full mt-3 font-semibold text-white bg-blue-500 rounded-lg h-11 shrink-0 hover:bg-blue-600 disabled:opacity-50"
>
{loading ? <Spinner /> : t("global.continue")}
</button>

View File

@@ -4,7 +4,7 @@ import { createLazyFileRoute } from "@tanstack/react-router";
import { useState } from "react";
import { toast } from "sonner";
export const Route = createLazyFileRoute("/auth/privkey")({
export const Route = createLazyFileRoute("/auth/import")({
component: Screen,
});
@@ -27,10 +27,7 @@ function Screen() {
const npub = await NostrAccount.saveAccount(key, password);
if (npub) {
navigate({
to: "/auth/settings",
replace: true,
});
navigate({ to: "/", replace: true });
}
} catch (e) {
setLoading(false);
@@ -39,11 +36,11 @@ function Screen() {
};
return (
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
<div className="flex flex-col items-center justify-center w-full h-full gap-6 px-5 mx-auto xl:max-w-xl">
<div className="text-center">
<h3 className="text-xl font-semibold">Continue with Private Key</h3>
</div>
<div className="flex w-full flex-col gap-3">
<div className="flex flex-col w-full gap-3">
<div className="flex flex-col gap-1">
<label
htmlFor="key"
@@ -57,7 +54,7 @@ function Screen() {
placeholder="nsec or ncryptsec..."
value={key}
onChange={(e) => setKey(e.target.value)}
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
className="px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
/>
</div>
<div className="flex flex-col gap-1">
@@ -72,14 +69,14 @@ function Screen() {
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
className="px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
/>
</div>
<button
type="button"
onClick={() => submit()}
disabled={loading}
className="mt-3 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
className="inline-flex items-center justify-center w-full mt-3 font-semibold text-white bg-blue-500 rounded-lg h-11 shrink-0 hover:bg-blue-600 disabled:opacity-50"
>
{loading ? <Spinner /> : "Login"}
</button>

View File

@@ -23,13 +23,10 @@ function Screen() {
try {
setLoading(true);
const npub = await NostrAccount.connectRemoteAccount(uri);
const remoteAccount = await NostrAccount.connectRemoteAccount(uri);
if (npub) {
navigate({
to: "/auth/settings",
replace: true,
});
if (remoteAccount?.length) {
navigate({ to: "/", replace: true });
}
} catch (e) {
setLoading(false);
@@ -38,11 +35,11 @@ function Screen() {
};
return (
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
<div className="flex flex-col items-center justify-center w-full h-full gap-6 px-5 mx-auto xl:max-w-xl">
<div className="text-center">
<h3 className="text-xl font-semibold">Continue with Nostr Connect</h3>
</div>
<div className="flex w-full flex-col gap-3">
<div className="flex flex-col w-full gap-3">
<div className="flex flex-col gap-1">
<label
htmlFor="uri"
@@ -56,20 +53,20 @@ function Screen() {
placeholder="bunker://..."
value={uri}
onChange={(e) => setUri(e.target.value)}
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
className="px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
/>
</div>
<div className="flex flex-col gap-1 items-center">
<div className="flex flex-col items-center gap-1">
<button
type="button"
onClick={() => submit()}
disabled={loading}
className="mt-3 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
className="inline-flex items-center justify-center w-full mt-3 font-semibold text-white bg-blue-500 rounded-lg h-11 shrink-0 hover:bg-blue-600 disabled:opacity-50"
>
{loading ? <Spinner /> : "Login"}
</button>
{loading ? (
<p className="text-neutral-600 dark:text-neutral-400 text-sm text-center">
<p className="text-sm text-center text-neutral-600 dark:text-neutral-400">
Waiting confirmation...
</p>
) : null}

View File

@@ -1,203 +0,0 @@
import { LaurelIcon } from "@lume/icons";
import { NostrQuery } from "@lume/system";
import { Spinner } from "@lume/ui";
import * as Switch from "@radix-ui/react-switch";
import { createFileRoute } from "@tanstack/react-router";
import { requestPermission } from "@tauri-apps/plugin-notification";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
export const Route = createFileRoute("/auth/settings")({
beforeLoad: async () => {
const settings = await NostrQuery.getSettings();
return { settings };
},
component: Screen,
pendingComponent: Pending,
});
function Screen() {
const { settings } = Route.useRouteContext();
const { t } = useTranslation();
const [newSettings, setNewSettings] = useState(settings);
const [loading, setLoading] = useState(false);
const navigate = Route.useNavigate();
const toggleNofitication = async () => {
await requestPermission();
setNewSettings((prev) => ({
...prev,
notification: !newSettings.notification,
}));
};
const toggleAutoUpdate = () => {
setNewSettings((prev) => ({
...prev,
autoUpdate: !newSettings.autoUpdate,
}));
};
const toggleEnhancedPrivacy = () => {
setNewSettings((prev) => ({
...prev,
enhancedPrivacy: !newSettings.enhancedPrivacy,
}));
};
const toggleZap = () => {
setNewSettings((prev) => ({
...prev,
zap: !newSettings.zap,
}));
};
const toggleNsfw = () => {
setNewSettings((prev) => ({
...prev,
nsfw: !newSettings.nsfw,
}));
};
const submit = async () => {
try {
// start loading
setLoading(true);
// publish settings
const eventId = await NostrQuery.setSettings(newSettings);
if (eventId) {
return navigate({
to: "/",
replace: true,
});
}
} catch (e) {
setLoading(false);
toast.error(e);
}
};
return (
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
<div className="flex flex-col items-center gap-5 text-center">
<div className="flex size-20 items-center justify-center rounded-full bg-teal-100 dark:bg-teal-950 text-teal-500">
<LaurelIcon className="size-8" />
</div>
<div>
<h1 className="text-xl font-semibold">
{t("onboardingSettings.title")}
</h1>
<p className="leading-snug text-neutral-600 dark:text-neutral-400">
{t("onboardingSettings.subtitle")}
</p>
</div>
</div>
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-3">
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
<div className="flex-1">
<h3 className="font-semibold">Push Notification</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Enabling push notifications will allow you to receive
notifications from Lume.
</p>
</div>
<Switch.Root
checked={newSettings.notification}
onClick={() => toggleNofitication()}
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/20"
>
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
<div className="flex-1">
<h3 className="font-semibold">Enhanced Privacy</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Lume will display external resources like image, video or link
preview as plain text.
</p>
</div>
<Switch.Root
checked={newSettings.enhancedPrivacy}
onClick={() => toggleEnhancedPrivacy()}
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/20"
>
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
<div className="flex-1">
<h3 className="font-semibold">Auto Update</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Automatically download and install new version.
</p>
</div>
<Switch.Root
checked={newSettings.autoUpdate}
onClick={() => toggleAutoUpdate()}
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/20"
>
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
<div className="flex-1">
<h3 className="font-semibold">Zap</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Show the Zap button in each note and user's profile screen, use
for send Bitcoin tip to other users.
</p>
</div>
<Switch.Root
checked={newSettings.zap}
onClick={() => toggleZap()}
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/20"
>
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
<div className="flex-1">
<h3 className="font-semibold">Filter sensitive content</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
By default, Lume will display all content which have Content
Warning tag, it's may include NSFW content.
</p>
</div>
<Switch.Root
checked={newSettings.nsfw}
onClick={() => toggleNsfw()}
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/20"
>
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
</div>
<button
type="button"
onClick={() => submit()}
disabled={loading}
className="mb-1 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
>
{t("global.continue")}
</button>
</div>
</div>
);
}
function Pending() {
return (
<div className="flex h-full w-full items-center justify-center">
<button type="button" className="size-5" disabled>
<Spinner className="size-5" />
</button>
</div>
);
}

View File

@@ -0,0 +1,132 @@
import { CancelIcon, PlusIcon } from "@lume/icons";
import { NostrQuery } from "@lume/system";
import type { Relay } from "@lume/types";
import { Spinner } from "@lume/ui";
import { createFileRoute } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
export const Route = createFileRoute("/bootstrap-relays")({
loader: async () => {
const bootstrapRelays = await NostrQuery.getBootstrapRelays();
return bootstrapRelays;
},
component: Screen,
});
function Screen() {
const bootstrapRelays = Route.useLoaderData();
const { register, reset, handleSubmit } = useForm();
const [relays, setRelays] = useState<Relay[]>([]);
const [isLoading, setIsLoading] = useState(false);
const removeRelay = (url: string) => {
setRelays((prev) => prev.filter((relay) => relay.url !== url));
};
const onSubmit = async (data: { url: string; purpose: string }) => {
try {
const relay: Relay = { url: data.url, purpose: data.purpose };
setRelays((prev) => [...prev, relay]);
reset();
} catch (e) {
toast.error(String(e));
}
};
const save = async () => {
try {
setIsLoading(true);
await NostrQuery.saveBootstrapRelays(relays);
} catch (e) {
setIsLoading(false);
toast.error(String(e));
}
};
useEffect(() => {
setRelays(bootstrapRelays);
}, [bootstrapRelays]);
return (
<div className="flex flex-col items-center justify-center w-screen h-screen">
<div className="w-full max-w-sm mx-auto lg:max-w-lg">
<div className="text-center h-11">
<h1 className="font-semibold">Customize Bootstrap Relays</h1>
</div>
<div className="flex flex-col w-full px-2 bg-white rounded-xl shadow-primary backdrop-blur-lg dark:bg-white/20 dark:ring-1 ring-neutral-800/50">
{relays.map((relay) => (
<div
key={relay.url}
className="flex items-center justify-between h-11"
>
<div className="inline-flex items-center gap-2 text-sm font-medium">
{relay.url}
</div>
<div className="flex items-center gap-2">
{relay.purpose?.length ? (
<button
type="button"
className="inline-flex items-center justify-center px-2 text-xs font-medium uppercase rounded-md h-7 w-max hover:bg-black/10 dark:hover:bg-white/10"
>
{relay.purpose}
</button>
) : null}
<button
type="button"
onClick={() => removeRelay(relay.url)}
className="inline-flex items-center justify-center rounded-md size-7 text-neutral-700 dark:text-white/20 hover:bg-black/10 dark:hover:bg-white/10"
>
<CancelIcon className="size-3" />
</button>
</div>
</div>
))}
<div className="flex items-center border-t h-14 border-neutral-100 dark:border-white/5">
<form
onSubmit={handleSubmit(onSubmit)}
className="flex items-center w-full gap-2 mb-0"
>
<div className="flex items-center flex-1 gap-2 border rounded-lg border-neutral-300 dark:border-white/20">
<input
{...register("url", {
required: true,
minLength: 1,
})}
name="url"
placeholder="wss://..."
spellCheck={false}
className="flex-1 px-3 bg-transparent border-none rounded-l-lg h-9 placeholder:text-neutral-500 dark:placeholder:text-neutral-400"
/>
<select
{...register("purpose")}
className="flex-1 p-0 m-0 text-sm bg-transparent border-none outline-none h-9 ring-0 focus:outline-none focus:ring-0"
>
<option value="read">Read</option>
<option value="write">Write</option>
<option value="">Both</option>
</select>
</div>
<button
type="submit"
className="inline-flex items-center justify-center px-2 text-sm font-medium text-white rounded-lg shrink-0 h-9 w-14 bg-black/20 dark:bg-white/20 hover:bg-blue-500 disabled:opacity-50"
>
<PlusIcon className="size-7" />
</button>
</form>
</div>
</div>
<button
type="button"
onClick={() => save()}
disabled={isLoading}
className="inline-flex items-center justify-center w-full h-10 mt-4 text-sm font-semibold text-white bg-blue-500 rounded-lg shrink-0 hover:bg-blue-600 disabled:opacity-50"
>
{isLoading ? <Spinner /> : "Save & Relaunch"}
</button>
</div>
</div>
);
}

View File

@@ -1,25 +1,38 @@
import type { ColumnRouteSearch } from "@lume/types";
import { cn } from "@lume/utils";
import { Link, Outlet } from "@tanstack/react-router";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/create-newsfeed")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
return {
account: search.account,
label: search.label,
name: search.name,
};
},
component: Screen,
});
function Screen() {
const search = Route.useSearch();
return (
<div className="w-full h-full flex flex-col items-center justify-center gap-4">
<div className="text-center flex flex-col items-center justify-center">
<h1 className="text-2xl font-serif font-medium">
<div className="flex flex-col items-center justify-center w-full h-full gap-4">
<div className="flex flex-col items-center justify-center text-center">
<h1 className="font-serif text-2xl font-medium">
Build up your timeline.
</h1>
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
Follow some people to keep up to date with them.
</p>
</div>
<div className="w-4/5 max-w-full flex flex-col gap-3">
<div className="flex flex-col w-4/5 max-w-full gap-3">
<div className="w-full h-9 shrink-0 flex items-center justify-between bg-black/5 dark:bg-white/5 rounded-lg px-0.5">
<Link to="/create-newsfeed/users" className="flex-1 h-8">
<Link
to="/create-newsfeed/users"
search={search}
className="flex-1 h-8"
>
{({ isActive }) => (
<div
className={cn(
@@ -33,7 +46,11 @@ function Screen() {
</div>
)}
</Link>
<Link to="/create-newsfeed/f2f" className="flex-1 h-8">
<Link
to="/create-newsfeed/f2f"
search={search}
className="flex-1 h-8"
>
{({ isActive }) => (
<div
className={cn(

View File

@@ -1,12 +1,17 @@
import { CheckCircleIcon } from "@lume/icons";
import { NostrQuery } from "@lume/system";
import type { ColumnRouteSearch, Topic } from "@lume/types";
import type { ColumnRouteSearch } from "@lume/types";
import { Spinner } from "@lume/ui";
import { TOPICS } from "@lume/utils";
import { createFileRoute } from "@tanstack/react-router";
import { useState } from "react";
import { toast } from "sonner";
type Topic = {
title: string;
content: string[];
};
export const Route = createFileRoute("/create-topic")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
return {
@@ -53,33 +58,34 @@ function Screen() {
};
return (
<div className="w-full h-full flex flex-col items-center justify-center gap-4">
<div className="text-center flex flex-col items-center justify-center">
<h1 className="text-2xl font-serif font-medium">
<div className="flex flex-col items-center justify-center w-full h-full gap-4">
<div className="flex flex-col items-center justify-center text-center">
<h1 className="font-serif text-2xl font-medium">
What are your interests?
</h1>
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
Add some topics you want to focus on.
</p>
</div>
<div className="w-4/5 max-w-full flex flex-col gap-3">
<div className="w-full h-9 shrink-0 flex items-center justify-between bg-black/5 dark:bg-white/5 rounded-lg px-3">
<div className="flex flex-col w-4/5 max-w-full gap-3">
<div className="flex items-center justify-between w-full px-3 rounded-lg h-9 shrink-0 bg-black/5 dark:bg-white/5">
<span className="text-sm font-medium">Added: {topics.length}</span>
</div>
<div className="w-full flex flex-col items-center gap-3">
<div className="flex flex-col items-center w-full gap-3">
<div className="overflow-y-auto scrollbar-none p-2 w-full h-[450px] bg-black/5 dark:bg-white/5 backdrop-blur-lg rounded-xl">
<div className="flex flex-col gap-3">
{TOPICS.map((topic) => (
<button
key={topic.title}
type="button"
onClick={() => toggleTopic(topic)}
className="h-11 px-3 flex items-center justify-between bg-white dark:bg-black/20 backdrop-blur-lg border border-transparent hover:border-blue-500 rounded-lg shadow-primary dark:ring-1 ring-neutral-800/50"
className="flex items-center justify-between px-3 bg-white border border-transparent rounded-lg h-11 dark:bg-black/20 backdrop-blur-lg hover:border-blue-500 shadow-primary dark:ring-1 ring-neutral-800/50"
>
<div className="inline-flex items-center gap-1">
<div>{topic.icon}</div>
<div className="text-sm font-medium">
<span>{topic.title}</span>
<span className="ml-1 italic text-neutral-400 dark:text-neutral-600 font-normal">
<span className="ml-1 italic font-normal text-neutral-400 dark:text-neutral-600">
{topic.content.length} hashtags
</span>
</div>
@@ -95,7 +101,7 @@ function Screen() {
type="button"
onClick={() => submit()}
disabled={isLoading || topics.length < 1}
className="inline-flex items-center justify-center w-36 rounded-full h-9 bg-blue-500 text-white text-sm font-medium hover:bg-blue-600 disabled:opacity-50"
className="inline-flex items-center justify-center text-sm font-medium text-white bg-blue-500 rounded-full w-36 h-9 hover:bg-blue-600 disabled:opacity-50"
>
{isLoading ? <Spinner /> : "Confirm"}
</button>

View File

@@ -1,15 +1,14 @@
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 { insertImage, isImagePath } from "@lume/utils";
import type { UnlistenFn } from "@tauri-apps/api/event";
import { getCurrent } from "@tauri-apps/api/window";
import { useEffect, useState } from "react";
import { useSlateStatic } from "slate-react";
import { toast } from "sonner";
export function MediaButton({ className }: { className?: string }) {
export function MediaButton() {
const editor = useSlateStatic();
const [loading, setLoading] = useState(false);
@@ -61,29 +60,18 @@ export function MediaButton({ className }: { className?: string }) {
}, []);
return (
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button
type="button"
onClick={() => upload()}
disabled={loading}
className={cn("inline-flex items-center justify-center", className)}
>
{loading ? (
<Spinner className="size-4" />
) : (
<AddMediaIcon className="size-4" />
)}
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
Upload media
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
<button
type="button"
onClick={() => upload()}
disabled={loading}
className="inline-flex items-center h-8 gap-2 px-2.5 text-sm rounded-lg text-black/70 dark:text-white/70 w-max hover:bg-black/10 dark:hover:bg-white/10"
>
{loading ? (
<Spinner className="size-4" />
) : (
<AddMediaIcon className="size-4" />
)}
Add media
</button>
);
}

View File

@@ -1,88 +0,0 @@
import { MentionIcon } from "@lume/icons";
import { cn, insertMention } from "@lume/utils";
import * as Tooltip from "@radix-ui/react-tooltip";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { useEffect, useState } from "react";
import { 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 [contacts, setContacts] = useState<string[]>([]);
const select = async (user: string) => {
try {
const metadata = await NostrQuery.getProfile(user);
const contact: Contact = { pubkey: user, profile: metadata };
insertMention(editor, contact);
} catch (e) {
toast.error(String(e));
}
};
useEffect(() => {
async function getContacts() {
const data = await NostrAccount.getContactList();
setContacts(data);
}
getContacts();
}, []);
return (
<DropdownMenu.Root>
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<DropdownMenu.Trigger asChild>
<Tooltip.Trigger asChild>
<button
type="button"
className={cn(
"inline-flex items-center justify-center",
className,
)}
>
<MentionIcon className="size-4" />
</button>
</Tooltip.Trigger>
</DropdownMenu.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
Mention
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
<DropdownMenu.Portal>
<DropdownMenu.Content className="flex w-[220px] h-[220px] scrollbar-none flex-col overflow-y-auto rounded-xl bg-black py-1 shadow-md shadow-neutral-500/20 focus:outline-none dark:bg-white">
{contacts.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>
</DropdownMenu.Root>
);
}

View File

@@ -1,40 +1,21 @@
import { NsfwIcon } from "@lume/icons";
import { cn } from "@lume/utils";
import * as Tooltip from "@radix-ui/react-tooltip";
import { PowIcon } from "@lume/icons";
import type { Dispatch, SetStateAction } from "react";
export function PowToggle({
pow,
setPow,
className,
export function PowButton({
setDifficulty,
}: {
pow: boolean;
setPow: Dispatch<SetStateAction<boolean>>;
className?: string;
setDifficulty: Dispatch<SetStateAction<{ enable: boolean; num: number }>>;
}) {
return (
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button
type="button"
onClick={() => setPow((prev) => !prev)}
className={cn(
"inline-flex items-center justify-center",
className,
pow ? "bg-blue-500 text-white" : "",
)}
>
<NsfwIcon className="size-4" />
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
Proof of Work
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
<button
type="button"
onClick={() =>
setDifficulty((prev) => ({ ...prev, enable: !prev.enable }))
}
className="inline-flex items-center h-8 gap-2 px-2.5 text-sm rounded-lg text-black/70 dark:text-white/70 w-max hover:bg-black/10 dark:hover:bg-white/10"
>
<PowIcon className="size-4" />
PoW
</button>
);
}

View File

@@ -1,40 +1,19 @@
import { NsfwIcon } from "@lume/icons";
import { cn } from "@lume/utils";
import * as Tooltip from "@radix-ui/react-tooltip";
import type { Dispatch, SetStateAction } from "react";
export function WarningToggle({
warning,
export function WarningButton({
setWarning,
className,
}: {
warning: boolean;
setWarning: Dispatch<SetStateAction<boolean>>;
className?: string;
setWarning: Dispatch<SetStateAction<{ enable: boolean; reason: string }>>;
}) {
return (
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button
type="button"
onClick={() => setWarning((prev) => !prev)}
className={cn(
"inline-flex items-center justify-center",
className,
warning ? "bg-blue-500 text-white" : "",
)}
>
<NsfwIcon className="size-4" />
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
Mark as sensitive content
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
<button
type="button"
onClick={() => setWarning((prev) => ({ ...prev, enable: !prev.enable }))}
className="inline-flex items-center h-8 gap-2 px-2.5 text-sm rounded-lg text-black/70 dark:text-white/70 w-max hover:bg-black/10 dark:hover:bg-white/10"
>
<NsfwIcon className="size-4" />
Mark as sensitive
</button>
);
}

View File

@@ -1,15 +1,8 @@
import { ComposeFilledIcon } from "@lume/icons";
import { Spinner } from "@lume/ui";
import {
cn,
insertImage,
insertNostrEvent,
isImageUrl,
sendNativeNotification,
} from "@lume/utils";
import { cn, insertImage, insertNostrEvent, isImageUrl } from "@lume/utils";
import { createFileRoute } from "@tanstack/react-router";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useEffect, useState } from "react";
import { type Descendant, Node, Transforms, createEditor } from "slate";
import {
Editable,
@@ -21,14 +14,17 @@ import {
withReact,
} from "slate-react";
import { MediaButton } from "./-components/media";
import { MentionButton } from "./-components/mention";
import { LumeEvent } from "@lume/system";
import { WarningToggle } from "./-components/warning";
import { LumeEvent, useEvent } from "@lume/system";
import { WarningButton } from "./-components/warning";
import { MentionNote } from "@/components/note/mentions/note";
import { PowButton } from "./-components/pow";
import { User } from "@/components/user";
import { Note } from "@/components/note";
import { nip19 } from "nostr-tools";
type EditorSearch = {
reply_to: string;
quote: boolean;
quote: string;
};
type EditorElement = {
@@ -41,26 +37,47 @@ export const Route = createFileRoute("/editor/")({
validateSearch: (search: Record<string, string>): EditorSearch => {
return {
reply_to: search.reply_to,
quote: search.quote === "true" || false,
quote: search.quote,
};
},
beforeLoad: ({ search }) => {
let initialValue: EditorElement[];
if (search?.quote?.length) {
const eventId = nip19.noteEncode(search.quote);
initialValue = [
{
type: "paragraph",
children: [{ text: "" }],
},
{
type: "event",
eventId: `nostr:${eventId}`,
children: [{ text: "" }],
},
];
} else {
initialValue = [
{
type: "paragraph",
children: [{ text: "" }],
},
];
}
return { initialValue };
},
component: Screen,
});
const initialValue: EditorElement[] = [
{
type: "paragraph",
children: [{ text: "" }],
},
];
function Screen() {
const search = Route.useSearch();
const { reply_to } = Route.useSearch();
const { initialValue } = Route.useRouteContext();
const [t] = useTranslation();
const [editorValue, setEditorValue] = useState(initialValue);
const [editorValue, setEditorValue] = useState<EditorElement[]>(null);
const [loading, setLoading] = useState(false);
const [warning, setWarning] = useState(false);
const [warning, setWarning] = useState({ enable: false, reason: "" });
const [difficulty, setDifficulty] = useState({ enable: false, num: 21 });
const [editor] = useState(() =>
withMentions(withNostrEvent(withImages(withReact(createEditor())))),
);
@@ -103,63 +120,40 @@ function Screen() {
const content = serialize(editor.children);
const eventId = await LumeEvent.publish(
content,
search.reply_to,
search.quote,
warning,
warning.enable && warning.reason.length ? warning.reason : null,
difficulty.enable && difficulty.num > 0 ? difficulty.num : null,
reply_to,
);
if (eventId) {
await sendNativeNotification(
"Your note has been published successfully.",
"Lume",
);
// stop loading
setLoading(false);
// reset form
reset();
}
// stop loading
setLoading(false);
// reset form
reset();
} catch (e) {
setLoading(false);
await sendNativeNotification(String(e));
}
};
useEffect(() => {
setEditorValue(initialValue);
}, [initialValue]);
if (!editorValue) return null;
return (
<div className="w-full h-full flex flex-col">
<div className="flex flex-col w-full h-full">
<Slate editor={editor} initialValue={editorValue}>
<div
data-tauri-drag-region
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"
>
<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" />
<MediaButton className="size-8 rounded-full bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20" />
<button
type="button"
onClick={() => publish()}
className="inline-flex h-8 w-max items-center justify-center gap-1 rounded-full bg-blue-500 px-3 text-sm font-medium text-white hover:bg-blue-600"
>
{loading ? (
<Spinner className="size-4" />
) : (
<ComposeFilledIcon className="size-4" />
)}
{t("global.post")}
</button>
</div>
<div className="flex-1 overflow-y-auto flex flex-col">
{search.reply_to ? (
<div className="px-4 py-2">
<MentionNote eventId={search.reply_to} />
<div data-tauri-drag-region className="h-9 shrink-0" />
<div className="flex flex-col flex-1 overflow-y-auto">
{reply_to?.length ? (
<div className="flex items-center gap-3 px-2.5 pb-3 border-b border-black/5 dark:border-white/5">
<div className="text-sm font-semibold shrink-0">Reply to:</div>
<ChildNote id={reply_to} />
</div>
) : null}
<div className="overflow-y-auto scrollbar-none p-4">
<div className="px-4 py-4 overflow-y-auto">
<Editable
key={JSON.stringify(editorValue)}
autoFocus={true}
@@ -168,17 +162,103 @@ function Screen() {
spellCheck={false}
renderElement={(props) => <Element {...props} />}
placeholder={
search.reply_to ? "Type your reply..." : t("editor.placeholder")
reply_to ? "Type your reply..." : "What're you up to?"
}
className="focus:outline-none"
/>
</div>
</div>
{warning.enable ? (
<div className="flex items-center w-full px-4 border-t h-11 shrink-0 border-black/5 dark:border-white/5">
<span className="text-sm shrink-0 text-black/50 dark:text-white/50">
Reason:
</span>
<input
type="text"
placeholder="NSFW..."
value={warning.reason}
onChange={(e) =>
setWarning((prev) => ({ ...prev, reason: e.target.value }))
}
className="flex-1 text-sm bg-transparent border-none focus:outline-none focus:ring-0 placeholder:text-black/50 dark:placeholder:text-white/50"
/>
</div>
) : null}
{difficulty.enable ? (
<div className="flex items-center w-full px-4 border-t h-11 shrink-0 border-black/5 dark:border-white/5">
<span className="text-sm shrink-0 text-black/50 dark:text-white/50">
Difficulty:
</span>
<input
type="text"
inputMode="numeric"
pattern="[0-9]"
onKeyDown={(event) => {
if (!/[0-9]/.test(event.key)) {
event.preventDefault();
}
}}
placeholder="21"
defaultValue={difficulty.num}
onChange={(e) =>
setWarning((prev) => ({ ...prev, num: Number(e.target.value) }))
}
className="flex-1 text-sm bg-transparent border-none focus:outline-none focus:ring-0 placeholder:text-black/50 dark:placeholder:text-white/50"
/>
</div>
) : null}
<div
data-tauri-drag-region
className="flex items-center w-full h-16 gap-4 px-4 border-t divide-x divide-black/5 dark:divide-white/5 shrink-0 border-black/5 dark:border-white/5"
>
<button
type="button"
onClick={() => publish()}
className="inline-flex items-center justify-center h-8 gap-1 px-2.5 text-sm font-medium rounded-lg bg-black/10 w-max hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
>
{loading ? (
<Spinner className="size-4" />
) : (
<ComposeFilledIcon className="size-4" />
)}
Publish
</button>
<div className="inline-flex items-center flex-1 gap-2 pl-4">
<MediaButton />
<WarningButton setWarning={setWarning} />
<PowButton setDifficulty={setDifficulty} />
</div>
</div>
</Slate>
</div>
);
}
function ChildNote({ id }: { id: string }) {
const { isLoading, isError, data } = useEvent(id);
if (isLoading) {
return <Spinner className="size-5" />;
}
if (isError || !data) {
return <div>Event not found with your current relay set.</div>;
}
return (
<Note.Provider event={data}>
<Note.Root className="flex items-center gap-2">
<User.Provider pubkey={data.pubkey}>
<User.Root className="shrink-0">
<User.Avatar className="rounded-full size-8 shrink-0" />
</User.Root>
</User.Provider>
<div className="content-break line-clamp-1">{data.content}</div>
</Note.Root>
</Note.Provider>
);
}
const withNostrEvent = (editor: ReactEditor) => {
const { insertData, isVoid } = editor;
@@ -190,7 +270,7 @@ const withNostrEvent = (editor: ReactEditor) => {
editor.insertData = (data) => {
const text = data.getData("text/plain");
if (text.startsWith("nevent1") || text.startsWith("note1")) {
if (text.startsWith("nevent") || text.startsWith("note")) {
insertNostrEvent(editor, text);
} else {
insertData(data);
@@ -259,6 +339,7 @@ const Image = ({ attributes, element, children }) => {
selected && focused ? "ring-blue-500" : "ring-transparent",
)}
onClick={() => Transforms.removeNodes(editor, { at: path })}
onKeyDown={() => Transforms.removeNodes(editor, { at: path })}
/>
</div>
);
@@ -274,7 +355,7 @@ const Mention = ({ attributes, element }) => {
type="button"
contentEditable={false}
onClick={() => Transforms.removeNodes(editor, { at: path })}
className="inline-block align-baseline text-blue-500 hover:text-blue-600"
className="inline-block text-blue-500 align-baseline hover:text-blue-600"
>{`@${element.name}`}</span>
);
};
@@ -286,16 +367,13 @@ const Event = ({ attributes, element, children }) => {
return (
<div {...attributes}>
{children}
{/* biome-ignore lint/a11y/useKeyWithClickEvents: <explanation> */}
<div
contentEditable={false}
className="relative my-2 user-select-none"
onClick={() => Transforms.removeNodes(editor, { at: path })}
className="user-select-none relative my-2"
onKeyDown={() => Transforms.removeNodes(editor, { at: path })}
>
<MentionNote
eventId={element.eventId.replace("nostr:", "")}
openable={false}
/>
<MentionNote eventId={element.eventId} openable={false} />
</div>
</div>
);

View File

@@ -1,14 +1,13 @@
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 { type LumeEvent, NostrQuery, useEvent } from "@lume/system";
import { Box, Container, Spinner } from "@lume/ui";
import { createFileRoute } from "@tanstack/react-router";
import { WindowVirtualizer } from "virtua";
import { ReplyList } from "./-components/replyList";
export const Route = createFileRoute("/events/$eventId")({
beforeLoad: async () => {
const settings = await NostrQuery.getSettings();
const settings = await NostrQuery.getUserSettings();
return { settings };
},
component: Screen,
@@ -20,14 +19,14 @@ function Screen() {
if (isLoading) {
return (
<div className="flex h-full w-full items-center justify-center">
<div className="flex items-center justify-center w-full h-full">
<Spinner className="size-5" />
</div>
);
}
if (isError) {
<div className="flex h-full w-full items-center justify-center">
<div className="flex items-center justify-center w-full h-full">
<p>Not found.</p>
</div>;
}
@@ -40,7 +39,7 @@ function Screen() {
{data ? (
<ReplyList eventId={eventId} />
) : (
<div className="flex h-full w-full items-center justify-center">
<div className="flex items-center justify-center w-full h-full">
<Spinner className="size-5" />
</div>
)}
@@ -50,16 +49,16 @@ function Screen() {
);
}
function MainNote({ data }: { data: NostrEvent }) {
function MainNote({ data }: { data: LumeEvent }) {
return (
<Note.Provider event={data}>
<Note.Root>
<div className="px-3 h-14 flex items-center justify-between">
<div className="flex items-center justify-between px-3 h-14">
<Note.User />
<Note.Menu />
</div>
<Note.ContentLarge className="px-3" />
<div className="mt-4 h-11 gap-2 flex items-center justify-end px-3">
<div className="flex items-center justify-end gap-2 px-3 mt-4 h-11">
<Note.Reply large />
<Note.Repost large />
<Note.Zap large />

View File

@@ -2,12 +2,13 @@ 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 { NostrQuery } from "@lume/system";
import { type ColumnRouteSearch, type NostrEvent, Kind } from "@lume/types";
import { ArrowRightCircleIcon } from "@lume/icons";
import { type LumeEvent, NostrQuery } from "@lume/system";
import { type ColumnRouteSearch, Kind } from "@lume/types";
import { Spinner } from "@lume/ui";
import { useInfiniteQuery } from "@tanstack/react-query";
import { Link, createFileRoute } from "@tanstack/react-router";
import { createFileRoute } from "@tanstack/react-router";
import { useCallback } from "react";
import { Virtualizer } from "virtua";
export const Route = createFileRoute("/global")({
@@ -19,7 +20,7 @@ export const Route = createFileRoute("/global")({
};
},
beforeLoad: async () => {
const settings = await NostrQuery.getSettings();
const settings = await NostrQuery.getUserSettings();
return { settings };
},
component: Screen,
@@ -42,38 +43,36 @@ export function Screen() {
return events;
},
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
select: (data) => data?.pages.flatMap((page) => page),
select: (data) => data?.pages.flat(),
refetchOnWindowFocus: false,
});
const renderItem = (event: NostrEvent) => {
if (!event) return;
switch (event.kind) {
case Kind.Repost:
return <RepostNote key={event.id} event={event} />;
default: {
const isConversation =
event.tags.filter((tag) => tag[0] === "e" && tag[3] !== "mention")
.length > 0;
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0;
if (isConversation) {
return <Conversation key={event.id} event={event} className="mb-3" />;
const renderItem = useCallback(
(event: LumeEvent) => {
if (!event) return;
switch (event.kind) {
case Kind.Repost:
return <RepostNote key={event.id} event={event} className="mb-3" />;
default: {
if (event.isConversation) {
return (
<Conversation key={event.id} className="mb-3" event={event} />
);
}
if (event.isQuote) {
return <Quote key={event.id} event={event} className="mb-3" />;
}
return <TextNote key={event.id} event={event} className="mb-3" />;
}
if (isQuote) {
return <Quote key={event.id} event={event} className="mb-3" />;
}
return <TextNote key={event.id} event={event} className="mb-3" />;
}
}
};
},
[data],
);
return (
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none">
<div className="w-full h-full p-3 overflow-y-auto scrollbar-none">
{isFetching && !isLoading && !isFetchingNextPage ? (
<div className="w-full h-11 flex items-center justify-center">
<div className="flex items-center justify-center w-full mb-3 h-11 bg-black/10 dark:bg-white/10 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50">
<div className="flex items-center justify-center gap-2">
<Spinner className="size-5" />
<span className="text-sm font-medium">Fetching new notes...</span>
@@ -81,12 +80,14 @@ export function Screen() {
</div>
) : null}
{isLoading ? (
<div className="flex h-16 w-full items-center justify-center gap-2">
<div className="flex items-center justify-center w-full h-16 gap-2">
<Spinner className="size-5" />
<span className="text-sm font-medium">Loading...</span>
</div>
) : !data.length ? (
<Empty />
<div className="flex items-center justify-center">
Yo. You're catching up on all the things happening around you.
</div>
) : (
<Virtualizer overscan={3}>
{data.map((item) => renderItem(item))}
@@ -98,7 +99,7 @@ export function Screen() {
type="button"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage || isLoading}
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-black/5 px-3 font-medium hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
className="inline-flex items-center justify-center w-full gap-2 px-3 font-medium h-9 rounded-xl bg-black/5 hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
>
{isFetchingNextPage ? (
<Spinner className="size-5" />
@@ -114,35 +115,3 @@ export function Screen() {
</div>
);
}
function Empty() {
return (
<div className="flex flex-col py-10 gap-10">
<div className="text-center flex flex-col items-center justify-center">
<div className="size-24 bg-blue-100 flex flex-col items-center justify-end overflow-hidden dark:bg-blue-900 rounded-full mb-8">
<div className="w-12 h-16 bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900 rounded-t-lg" />
</div>
<p className="text-lg font-medium">Your newsfeed is empty</p>
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
Here are few suggestions to get started.
</p>
</div>
<div className="flex flex-col px-3 gap-2">
<Link
to="/trending/notes"
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
>
<ArrowRightIcon className="size-5" />
Show trending notes
</Link>
<Link
to="/trending/users"
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
>
<ArrowRightIcon className="size-5" />
Discover trending users
</Link>
</div>
</div>
);
}

View File

@@ -2,12 +2,13 @@ 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 { NostrAccount, NostrQuery } from "@lume/system";
import { type ColumnRouteSearch, type NostrEvent, Kind } from "@lume/types";
import { ArrowRightCircleIcon } from "@lume/icons";
import { type LumeEvent, NostrQuery } from "@lume/system";
import { type ColumnRouteSearch, Kind } from "@lume/types";
import { Spinner } from "@lume/ui";
import { useInfiniteQuery } from "@tanstack/react-query";
import { Link, createFileRoute, redirect } from "@tanstack/react-router";
import { createFileRoute, redirect } from "@tanstack/react-router";
import { useCallback } from "react";
import { Virtualizer } from "virtua";
export const Route = createFileRoute("/group")({
@@ -19,9 +20,9 @@ export const Route = createFileRoute("/group")({
};
},
beforeLoad: async ({ search }) => {
const key = `lume_group_${search.label}`;
const groups = (await NostrQuery.getNstore(key)) as string[];
const settings = await NostrQuery.getSettings();
const key = `lume:group:${search.label}`;
const groups: string[] = await NostrQuery.getNstore(key);
const settings = await NostrQuery.getUserSettings();
if (!groups?.length) {
throw redirect({
@@ -33,10 +34,7 @@ export const Route = createFileRoute("/group")({
});
}
return {
groups,
settings,
};
return { groups, settings };
},
component: Screen,
});
@@ -55,43 +53,40 @@ export function Screen() {
queryKey: [label, account],
initialPageParam: 0,
queryFn: async ({ pageParam }: { pageParam: number }) => {
const events = await NostrQuery.getLocalEvents(groups, pageParam);
const events = await NostrQuery.getGroupEvents(groups, pageParam);
return events;
},
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
select: (data) =>
data?.pages.flatMap((page) => page.filter((ev) => ev.kind === Kind.Text)),
select: (data) => data?.pages.flat(),
refetchOnWindowFocus: false,
});
const renderItem = (event: NostrEvent) => {
if (!event) return;
switch (event.kind) {
case Kind.Repost:
return <RepostNote key={event.id} event={event} />;
default: {
const isConversation =
event.tags.filter((tag) => tag[0] === "e" && tag[3] !== "mention")
.length > 0;
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0;
if (isConversation) {
return <Conversation key={event.id} event={event} className="mb-3" />;
const renderItem = useCallback(
(event: LumeEvent) => {
if (!event) return;
switch (event.kind) {
case Kind.Repost:
return <RepostNote key={event.id} event={event} className="mb-3" />;
default: {
if (event.isConversation) {
return (
<Conversation key={event.id} className="mb-3" event={event} />
);
}
if (event.isQuote) {
return <Quote key={event.id} event={event} className="mb-3" />;
}
return <TextNote key={event.id} event={event} className="mb-3" />;
}
if (isQuote) {
return <Quote key={event.id} event={event} className="mb-3" />;
}
return <TextNote key={event.id} event={event} className="mb-3" />;
}
}
};
},
[data],
);
return (
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none">
<div className="w-full h-full p-3 overflow-y-auto scrollbar-none">
{isFetching && !isLoading && !isFetchingNextPage ? (
<div className="mb-3 w-full h-11 flex items-center justify-center bg-black/10 dark:bg-white/10 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50">
<div className="flex items-center justify-center w-full mb-3 h-11 bg-black/10 dark:bg-white/10 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50">
<div className="flex items-center justify-center gap-2">
<Spinner className="size-5" />
<span className="text-sm font-medium">Fetching new notes...</span>
@@ -99,12 +94,14 @@ export function Screen() {
</div>
) : null}
{isLoading ? (
<div className="flex h-16 w-full items-center justify-center gap-2">
<div className="flex items-center justify-center w-full h-16 gap-2">
<Spinner className="size-5" />
<span className="text-sm font-medium">Loading...</span>
</div>
) : !data.length ? (
<Empty />
<div className="flex items-center justify-center">
Yo. You're catching up on all the things happening around you.
</div>
) : (
<Virtualizer overscan={3}>
{data.map((item) => renderItem(item))}
@@ -116,7 +113,7 @@ export function Screen() {
type="button"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage || isLoading}
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-neutral-100 px-3 font-medium hover:bg-neutral-50 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
className="inline-flex items-center justify-center w-full gap-2 px-3 font-medium h-9 rounded-xl bg-black/5 hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
>
{isFetchingNextPage ? (
<Spinner className="size-5" />
@@ -132,35 +129,3 @@ export function Screen() {
</div>
);
}
function Empty() {
return (
<div className="flex flex-col py-10 gap-10">
<div className="text-center flex flex-col items-center justify-center">
<div className="size-24 bg-blue-100 flex flex-col items-center justify-end overflow-hidden dark:bg-blue-900 rounded-full mb-8">
<div className="w-12 h-16 bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900 rounded-t-lg" />
</div>
<p className="text-lg font-medium">Your newsfeed is empty</p>
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
Here are few suggestions to get started.
</p>
</div>
<div className="flex flex-col px-3 gap-2">
<Link
to="/trending/notes"
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
>
<ArrowRightIcon className="size-5" />
Show trending notes
</Link>
<Link
to="/trending/users"
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
>
<ArrowRightIcon className="size-5" />
Discover trending users
</Link>
</div>
</div>
);
}

View File

@@ -1,7 +1,7 @@
import { PlusIcon } from "@lume/icons";
import { PlusIcon, RelayIcon } from "@lume/icons";
import { Spinner } from "@lume/ui";
import { User } from "@/components/user";
import { checkForAppUpdates } from "@lume/utils";
import { checkForAppUpdates, displayNpub } from "@lume/utils";
import { Link } from "@tanstack/react-router";
import { createFileRoute, redirect } from "@tanstack/react-router";
import { useState } from "react";
@@ -10,7 +10,12 @@ import { NostrAccount } from "@lume/system";
export const Route = createFileRoute("/")({
beforeLoad: async () => {
await checkForAppUpdates(true); // check for app updates
// Check for app updates
// TODO: move this function to rust
await checkForAppUpdates(true);
// Get all accounts
// TODO: use emit & listen
const accounts = await NostrAccount.getAccounts();
if (accounts.length < 1) {
@@ -29,11 +34,11 @@ function Screen() {
const navigate = Route.useNavigate();
const context = Route.useRouteContext();
const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState({ npub: "", status: false });
const select = async (npub: string) => {
try {
setLoading(true);
setLoading({ npub, status: true });
const status = await NostrAccount.loadAccount(npub);
@@ -41,14 +46,11 @@ function Screen() {
return navigate({
to: "/$account/home",
params: { account: npub },
search: {
accounts: context.accounts,
},
replace: true,
});
}
} catch (e) {
setLoading(false);
setLoading({ npub: "", status: false });
toast.error(String(e));
}
};
@@ -60,47 +62,72 @@ function Screen() {
});
return (
<div className="relative flex h-full w-full items-center justify-center">
<div className="relative z-20 flex flex-col items-center gap-16">
<div
data-tauri-drag-region
className="flex flex-col items-center justify-between w-full h-full"
>
<div className="flex items-end justify-center flex-1 w-full px-4 pb-10">
<div className="text-center">
<h2 className="text-xl text-neutral-700 dark:text-neutral-300">
<h2 className="mb-1 text-lg text-neutral-700 dark:text-neutral-300">
{currentDate}
</h2>
<h2 className="text-2xl font-semibold">Welcome back!</h2>
</div>
<div 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" />
</div>
) : (
<>
{context.accounts.map((account) => (
<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-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-[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-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>
<div className="flex flex-col items-center flex-1 w-full gap-3">
<div className="flex flex-col w-full max-w-sm mx-auto overflow-hidden bg-white divide-y divide-neutral-100 dark:divide-white/5 rounded-xl shadow-primary backdrop-blur-lg dark:bg-white/10 dark:ring-1 ring-white/15">
{context.accounts.map((account) => (
<div
key={account}
onClick={() => select(account)}
onKeyDown={() => select(account)}
className="flex items-center justify-between hover:bg-black/5 dark:hover:bg-white/5"
>
<User.Provider pubkey={account}>
<User.Root className="flex items-center gap-2.5 p-3">
<User.Avatar className="object-cover rounded-full size-10 shrink-0" />
<div className="inline-flex flex-col items-start">
<User.Name className="max-w-[6rem] truncate font-medium leading-tight" />
<span className="text-sm text-neutral-700 dark:text-neutral-300">
{displayNpub(account, 16)}
</span>
</div>
<p className="font-medium leading-tight">Add</p>
</div>
</Link>
</>
)}
</User.Root>
</User.Provider>
<div className="inline-flex items-center justify-center size-10">
{loading.npub === account ? (
loading.status ? (
<Spinner />
) : null
) : null}
</div>
</div>
))}
<Link
to="/landing"
className="flex items-center justify-between hover:bg-black/5 dark:hover:bg-white/5"
>
<div className="flex items-center gap-2.5 p-3">
<div className="inline-flex items-center justify-center rounded-full size-10 bg-neutral-200 dark:bg-white/10">
<PlusIcon className="size-5" />
</div>
<span className="max-w-[6rem] truncate text-sm font-medium leading-tight">
Add account
</span>
</div>
</Link>
</div>
<div className="w-full max-w-sm mx-auto">
<Link
to="/bootstrap-relays"
className="inline-flex items-center justify-center w-full h-8 gap-2 px-2 text-xs font-medium rounded-lg bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 text-neutral-700 dark:text-white/40"
>
<RelayIcon className="size-4" />
Custom Bootstrap Relays
</Link>
</div>
</div>
<div className="flex-1" />
</div>
);
}

View File

@@ -9,27 +9,27 @@ function Screen() {
return (
<div
data-tauri-drag-region
className="flex flex-col justify-center items-center h-screen w-screen"
className="flex flex-col items-center justify-center w-screen h-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">
<div className="w-full max-w-xs mx-auto lg:max-w-md">
<div className="flex flex-col w-full gap-2 px-2 bg-white rounded-xl shadow-primary backdrop-blur-lg dark:bg-white/20 dark:ring-1 ring-neutral-800/50">
<div className="flex items-center h-20 border-b border-neutral-100 dark:border-white/5">
<Link
to="/auth/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"
to="/auth/create-profile"
className="flex items-center justify-center w-full gap-2 px-2 rounded-lg h-14 hover:bg-neutral-100 dark:hover:bg-white/10"
>
<div className="size-9 shrink-0 rounded-full inline-flex items-center justify-center">
<div className="inline-flex items-center justify-center rounded-full size-9 shrink-0">
<img
src="/icon.jpeg"
alt="App Icon"
className="size-9 object-cover rounded-full"
className="object-cover rounded-full size-9"
/>
</div>
<div className="flex-1 inline-flex flex-col">
<span className="leading-tight font-semibold">
<div className="inline-flex flex-col flex-1">
<span className="font-semibold leading-tight">
Create new account
</span>
<span className="leading-tight text-sm text-neutral-500">
<span className="text-sm leading-tight text-neutral-500">
Use everywhere
</span>
</div>
@@ -37,19 +37,19 @@ function Screen() {
</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"
to="/auth/import"
className="inline-flex items-center w-full gap-2 px-2 rounded-lg h-11 hover:bg-neutral-100 dark:hover:bg-white/10"
>
<div className="size-9 inline-flex items-center justify-center">
<div className="inline-flex items-center justify-center size-9">
<KeyIcon className="size-5 text-neutral-600 dark:text-neutral-400" />
</div>
Login with Private Key
</Link>
<Link
to="/auth/remote"
className="inline-flex h-11 w-full items-center gap-2 rounded-lg px-2 hover:bg-neutral-100 dark:hover:bg-white/10"
className="inline-flex items-center w-full gap-2 px-2 rounded-lg h-11 hover:bg-neutral-100 dark:hover:bg-white/10"
>
<div className="size-9 inline-flex items-center justify-center">
<div className="inline-flex items-center justify-center size-9">
<RemoteIcon className="size-5 text-neutral-600 dark:text-neutral-400" />
</div>
Nostr Connect

View File

@@ -3,12 +3,12 @@ import { Quote } from "@/components/quote";
import { RepostNote } from "@/components/repost";
import { TextNote } from "@/components/text";
import { ArrowRightCircleIcon } from "@lume/icons";
import { NostrAccount, NostrQuery } from "@lume/system";
import { type ColumnRouteSearch, type NostrEvent, Kind } from "@lume/types";
import { type LumeEvent, NostrAccount, NostrQuery } from "@lume/system";
import { type ColumnRouteSearch, Kind } from "@lume/types";
import { Spinner } from "@lume/ui";
import { useInfiniteQuery } from "@tanstack/react-query";
import { redirect } from "@tanstack/react-router";
import { createFileRoute } from "@tanstack/react-router";
import { createFileRoute, redirect } from "@tanstack/react-router";
import { useCallback } from "react";
import { Virtualizer } from "virtua";
export const Route = createFileRoute("/newsfeed")({
@@ -20,10 +20,10 @@ export const Route = createFileRoute("/newsfeed")({
};
},
beforeLoad: async ({ search }) => {
const settings = await NostrQuery.getSettings();
const contacts = await NostrAccount.getContactList();
const isContactListEmpty = await NostrAccount.isContactListEmpty();
const settings = await NostrQuery.getUserSettings();
if (!contacts.length) {
if (isContactListEmpty) {
throw redirect({
to: "/create-newsfeed/users",
search: {
@@ -33,14 +33,13 @@ export const Route = createFileRoute("/newsfeed")({
});
}
return { settings, contacts };
return { settings };
},
component: Screen,
});
export function Screen() {
const { label, account } = Route.useSearch();
const { contacts, settings } = Route.useRouteContext();
const {
data,
isLoading,
@@ -52,49 +51,40 @@ export function Screen() {
queryKey: [label, account],
initialPageParam: 0,
queryFn: async ({ pageParam }: { pageParam: number }) => {
const events = await NostrQuery.getLocalEvents(contacts, pageParam);
const events = await NostrQuery.getLocalEvents(pageParam);
return events;
},
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
select: (data) => data?.pages.flatMap((page) => page),
select: (data) => data?.pages.flat(),
refetchOnWindowFocus: false,
});
const renderItem = (event: NostrEvent) => {
if (!event) return;
switch (event.kind) {
case Kind.Repost:
return <RepostNote key={event.id} event={event} />;
default: {
const isConversation =
event.tags.filter((tag) => tag[0] === "e" && tag[3] !== "mention")
.length > 0;
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0;
if (isConversation) {
return (
<Conversation
key={event.id}
className="mb-3"
event={event}
gossip={settings?.gossip}
/>
);
const renderItem = useCallback(
(event: LumeEvent) => {
if (!event) return;
switch (event.kind) {
case Kind.Repost:
return <RepostNote key={event.id} event={event} className="mb-3" />;
default: {
if (event.isConversation) {
return (
<Conversation key={event.id} className="mb-3" event={event} />
);
}
if (event.isQuote) {
return <Quote key={event.id} event={event} className="mb-3" />;
}
return <TextNote key={event.id} event={event} className="mb-3" />;
}
if (isQuote) {
return <Quote key={event.id} event={event} className="mb-3" />;
}
return <TextNote key={event.id} event={event} className="mb-3" />;
}
}
};
},
[data],
);
return (
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none">
<div className="w-full h-full p-3 overflow-y-auto scrollbar-none">
{isFetching && !isLoading && !isFetchingNextPage ? (
<div className="mb-3 w-full h-11 flex items-center justify-center bg-black/10 dark:bg-white/10 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50">
<div className="flex items-center justify-center w-full mb-3 h-11 bg-black/10 dark:bg-white/10 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50">
<div className="flex items-center justify-center gap-2">
<Spinner className="size-5" />
<span className="text-sm font-medium">Fetching new notes...</span>
@@ -102,7 +92,7 @@ export function Screen() {
</div>
) : null}
{isLoading ? (
<div className="flex h-16 w-full items-center justify-center gap-2">
<div className="flex items-center justify-center w-full h-16 gap-2">
<Spinner className="size-5" />
<span className="text-sm font-medium">Loading...</span>
</div>
@@ -121,7 +111,7 @@ export function Screen() {
type="button"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage || isLoading}
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-black/5 px-3 font-medium hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
className="inline-flex items-center justify-center w-full gap-2 px-3 font-medium h-9 rounded-xl bg-black/5 hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
>
{isFetchingNextPage ? (
<Spinner className="size-5" />

View File

@@ -1,48 +0,0 @@
import { PlusIcon } from "@lume/icons";
import type { LumeColumn } from "@lume/types";
import { createLazyRoute } from "@tanstack/react-router";
import { getCurrent } from "@tauri-apps/api/window";
export const Route = createLazyRoute("/open")({
component: Screen,
});
function Screen() {
const install = async (column: LumeColumn) => {
const mainWindow = getCurrent();
await mainWindow.emit("columns", { type: "add", column });
};
return (
<div className="relative flex h-full w-full items-center justify-center">
<div className="group absolute left-0 top-0 z-10 h-full w-12">
<button
type="button"
onClick={() =>
install({
label: "store",
name: "Store",
content: "/store/official",
})
}
className="flex h-full w-full items-center justify-center rounded-xl bg-transparent transition-colors duration-100 ease-in-out group-hover:bg-black/5 dark:group-hover:bg-white/5"
>
<PlusIcon className="size-6 scale-0 transform transition-transform duration-150 ease-in-out will-change-transform group-hover:scale-100" />
</button>
</div>
<button
type="button"
onClick={() =>
install({
label: "store",
name: "Store",
content: "/store/official",
})
}
className="inline-flex size-14 items-center justify-center rounded-full bg-black/10 backdrop-blur-lg hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
>
<PlusIcon className="size-8" />
</button>
</div>
);
}

View File

@@ -0,0 +1,377 @@
import { Note } from "@/components/note";
import { User } from "@/components/user";
import {
HorizontalDotsIcon,
InfoIcon,
RepostIcon,
SearchIcon,
} from "@lume/icons";
import { type LumeEvent, LumeWindow, NostrQuery, useEvent } from "@lume/system";
import { Kind } from "@lume/types";
import {
checkForAppUpdates,
decodeZapInvoice,
formatCreatedAt,
} from "@lume/utils";
import * as Tabs from "@radix-ui/react-tabs";
import { createFileRoute } from "@tanstack/react-router";
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
import { getCurrent } from "@tauri-apps/api/window";
import { exit } from "@tauri-apps/plugin-process";
import { open } from "@tauri-apps/plugin-shell";
import { useCallback, useEffect, useMemo, useState } from "react";
interface EmitAccount {
account: string;
}
export const Route = createFileRoute("/panel")({
component: Screen,
});
function Screen() {
const [account, setAccount] = useState<string>(null);
const [events, setEvents] = useState<LumeEvent[]>([]);
const texts = useMemo(
() => events.filter((ev) => ev.kind === Kind.Text),
[events],
);
const zaps = useMemo(() => {
const groups = new Map<string, LumeEvent[]>();
const list = events.filter((ev) => ev.kind === Kind.ZapReceipt);
for (const event of list) {
const rootId = event.tags.filter((tag) => tag[0] === "e")[0]?.[1];
if (rootId) {
if (groups.has(rootId)) {
groups.get(rootId).push(event);
} else {
groups.set(rootId, [event]);
}
}
}
return groups;
}, [events]);
const reactions = useMemo(() => {
const groups = new Map<string, LumeEvent[]>();
const list = events.filter(
(ev) => ev.kind === Kind.Repost || ev.kind === Kind.Reaction,
);
for (const event of list) {
const rootId = event.tags.filter((tag) => tag[0] === "e")[0]?.[1];
if (rootId) {
if (groups.has(rootId)) {
groups.get(rootId).push(event);
} else {
groups.set(rootId, [event]);
}
}
}
return groups;
}, [events]);
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
e.preventDefault();
const menuItems = await Promise.all([
MenuItem.new({
text: "Open Lume",
action: () => LumeWindow.openMainWindow(),
}),
MenuItem.new({
text: "New Post",
action: () => LumeWindow.openEditor(),
}),
MenuItem.new({
text: "Search",
action: () => LumeWindow.openSearch(),
}),
PredefinedMenuItem.new({ item: "Separator" }),
MenuItem.new({
text: "About Lume",
action: async () => await open("https://lume.nu"),
}),
MenuItem.new({
text: "Check for Updates",
action: async () => await checkForAppUpdates(false),
}),
MenuItem.new({
text: "Settings",
action: () => LumeWindow.openSettings(),
}),
PredefinedMenuItem.new({ item: "Separator" }),
MenuItem.new({
text: "Quit",
action: async () => await exit(0),
}),
]);
const menu = await Menu.new({
items: menuItems,
});
await menu.popup().catch((e) => console.error(e));
}, []);
useEffect(() => {
if (account?.length && account?.startsWith("npub1")) {
NostrQuery.getNotifications()
.then((data) => {
const sorted = data.sort((a, b) => b.created_at - a.created_at);
setEvents(sorted);
})
.catch((e) => console.log(e));
}
}, [account]);
useEffect(() => {
const unlistenLoad = getCurrent().listen<EmitAccount>(
"load-notification",
(data) => {
setAccount(data.payload.account);
},
);
const unlistenNewEvent = getCurrent().listen("notification", (data) => {
const event: LumeEvent = JSON.parse(data.payload as string);
setEvents((prev) => [event, ...prev]);
});
return () => {
unlistenLoad.then((f) => f());
unlistenNewEvent.then((f) => f());
};
}, []);
if (!account) {
return (
<div className="flex items-center justify-center w-full h-full text-sm">
Please log in.
</div>
);
}
return (
<div className="flex flex-col w-full h-full">
<div className="flex items-center justify-between px-4 border-b h-11 shrink-0 border-black/5">
<div>
<h1 className="text-sm font-semibold">Notifications</h1>
</div>
<div className="inline-flex items-center gap-2">
<User.Provider pubkey={account}>
<User.Root>
<User.Avatar className="rounded-full size-7" />
</User.Root>
</User.Provider>
<button
type="button"
onClick={() => LumeWindow.openSearch()}
className="inline-flex items-center justify-center rounded-full size-7 bg-black/5 dark:bg-white/5"
>
<SearchIcon className="size-4" />
</button>
<button
type="button"
onClick={(e) => showContextMenu(e)}
className="inline-flex items-center justify-center rounded-full size-7 bg-black/5 dark:bg-white/5"
>
<HorizontalDotsIcon className="size-4" />
</button>
</div>
</div>
<Tabs.Root
defaultValue="replies"
className="flex-1 overflow-x-hidden overflow-y-auto scrollbar-none"
>
<Tabs.List className="flex items-center">
<Tabs.Trigger
className="flex-1 inline-flex h-8 items-center justify-center gap-2 px-2 text-sm font-medium border-b border-black/10 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 key={event.id} event={event} />
))}
</Tabs.Content>
<Tabs.Content value="reactions" className="flex flex-col gap-2">
{[...reactions.entries()].map(([root, events]) => (
<div
key={root}
className="flex flex-col gap-1 p-2 rounded-lg shrink-0 backdrop-blur-md bg-black/10 dark:bg-white/10"
>
<div className="flex flex-col flex-1 min-w-0 gap-2">
<div className="flex items-center gap-2 pb-2 border-b border-black/5 dark:border-white/5">
<RootNote id={root} />
</div>
<div className="flex flex-wrap items-center gap-3">
{events.map((event) => (
<User.Provider key={event.id} pubkey={event.pubkey}>
<User.Root className="shrink-0 flex rounded-full h-8 bg-black/10 dark:bg-white/10 backdrop-blur-md p-[2px]">
<User.Avatar className="flex-1 rounded-full size-7" />
<div className="inline-flex items-center justify-center flex-1 text-xs truncate rounded-full size-7">
{event.kind === Kind.Reaction ? (
event.content === "+" ? (
"👍"
) : (
event.content
)
) : (
<RepostIcon className="text-teal-400 size-4 dark:text-teal-600" />
)}
</div>
</User.Root>
</User.Provider>
))}
</div>
</div>
</div>
))}
</Tabs.Content>
<Tabs.Content value="zaps" className="flex flex-col gap-2">
{[...zaps.entries()].map(([root, events]) => (
<div
key={root}
className="flex flex-col gap-1 p-2 rounded-lg shrink-0 backdrop-blur-md bg-black/10 dark:bg-white/10"
>
<div className="flex flex-col flex-1 min-w-0 gap-2">
<div className="flex items-center gap-2 pb-2 border-b border-black/5 dark:border-white/5">
<RootNote id={root} />
</div>
<div className="flex flex-wrap items-center gap-3">
{events.map((event) => (
<User.Provider
key={event.id}
pubkey={event.tags.find((tag) => tag[0] === "P")[1]}
>
<User.Root className="shrink-0 flex gap-1.5 rounded-full h-8 bg-black/10 dark:bg-white/10 backdrop-blur-md p-[2px]">
<User.Avatar className="flex-1 rounded-full size-7" />
<div className="flex-1 h-7 w-max pr-1.5 rounded-full inline-flex items-center justify-center text-sm truncate">
{decodeZapInvoice(event.tags).bitcoinFormatted}
</div>
</User.Root>
</User.Provider>
))}
</div>
</div>
</div>
))}
</Tabs.Content>
</div>
</Tabs.Root>
</div>
);
}
function RootNote({ id }: { id: string }) {
const { isLoading, isError, data } = useEvent(id);
if (isLoading) {
return (
<div className="flex items-center pb-2 mb-2">
<div className="rounded-full size-8 shrink-0 bg-black/20 dark:bg-white/20 animate-pulse" />
<div className="w-2/3 h-4 rounded-md animate-pulse bg-black/20 dark:bg-white/20" />
</div>
);
}
if (isError || !data) {
return (
<div className="flex items-center gap-2">
<div className="inline-flex items-center justify-center text-white bg-red-500 rounded-full size-8 shrink-0">
<InfoIcon className="size-5" />
</div>
<p className="text-sm text-red-500">
Event not found with your current relay set
</p>
</div>
);
}
return (
<Note.Provider event={data}>
<Note.Root className="flex items-center gap-2">
<User.Provider pubkey={data.pubkey}>
<User.Root className="shrink-0">
<User.Avatar className="rounded-full size-8 shrink-0" />
</User.Root>
</User.Provider>
<div className="line-clamp-1">{data.content}</div>
</Note.Root>
</Note.Provider>
);
}
function TextNote({ event }: { event: LumeEvent }) {
const pTags = event.tags
.filter((tag) => tag[0] === "p")
.map((tag) => tag[1])
.slice(0, 3);
return (
<button
type="button"
key={event.id}
onClick={() => LumeWindow.openEvent(event)}
>
<Note.Provider event={event}>
<Note.Root className="flex flex-col p-2 rounded-lg shrink-0 backdrop-blur-md bg-black/10 dark:bg-white/10">
<User.Provider pubkey={event.pubkey}>
<User.Root className="inline-flex items-center gap-2">
<User.Avatar className="rounded-full size-9 shrink-0" />
<div className="flex flex-col flex-1">
<div className="flex items-baseline justify-between w-full">
<User.Name className="text-sm font-semibold leading-tight" />
<span className="text-sm leading-tight text-black/50 dark:text-white/50">
{formatCreatedAt(event.created_at)}
</span>
</div>
<div className="inline-flex items-baseline gap-1 text-xs">
<span className="leading-tight text-black/50 dark:text-white/50">
Reply to:
</span>
<div className="inline-flex items-baseline gap-1">
{pTags.map((replyTo) => (
<User.Provider key={replyTo} pubkey={replyTo}>
<User.Root>
<User.Name className="font-medium leading-tight" />
</User.Root>
</User.Provider>
))}
</div>
</div>
</div>
</User.Root>
</User.Provider>
<div className="flex gap-2">
<div className="w-9 shrink-0" />
<div className="line-clamp-1 text-start">{event.content}</div>
</div>
</Note.Root>
</Note.Provider>
</button>
);
}

View File

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

View File

@@ -18,10 +18,10 @@ function Screen() {
const { t } = useTranslation();
return (
<div className="flex h-full w-full flex-col">
<div className="flex flex-col w-full h-full">
<div
data-tauri-drag-region
className="flex h-20 w-full shrink-0 items-center justify-center border-b border-black/10 dark:border-white/10"
className="flex items-center justify-center w-full h-20 border-b shrink-0 border-black/10 dark:border-white/10"
>
<div className="flex items-center gap-1">
<Link to="/settings/general">
@@ -119,7 +119,7 @@ function Screen() {
</Link>
</div>
</div>
<div className="w-full flex-1 overflow-y-auto scrollbar-none px-5 py-4">
<div className="flex-1 w-full px-5 py-4 overflow-y-auto scrollbar-none">
<Outlet />
</div>
</div>

View File

@@ -1,6 +1,6 @@
import { User } from "@/components/user";
import { NostrAccount } from "@lume/system";
import { displayNsec } from "@lume/utils";
import { displayNpub, displayNsec } from "@lume/utils";
import { createFileRoute } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core";
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
@@ -13,50 +13,34 @@ interface Account {
}
export const Route = createFileRoute("/settings/backup")({
component: Screen,
loader: async () => {
const npubs = await NostrAccount.getAccounts();
const accounts: Account[] = [];
for (const npub of npubs) {
const nsec: string = await invoke("get_stored_nsec", { npub });
accounts.push({ npub, nsec });
}
return accounts;
beforeLoad: async () => {
const accounts = await NostrAccount.getAccounts();
return { accounts };
},
component: Screen,
});
function Screen() {
const accounts = Route.useLoaderData();
const { accounts } = Route.useRouteContext();
return (
<div className="mx-auto w-full max-w-xl">
<div className="w-full max-w-xl mx-auto">
<div className="flex flex-col gap-3 divide-y divide-neutral-300 dark:divide-neutral-700">
{accounts.map((account) => (
<List key={account.npub} account={account} />
<Account key={account} account={account} />
))}
</div>
</div>
);
}
function List({ account }: { account: Account }) {
const [key, setKey] = useState(account.nsec);
function Account({ account }: { account: string }) {
const [copied, setCopied] = useState(false);
const [passphase, setPassphase] = useState("");
const encrypt = async () => {
const encrypted: string = await invoke("get_encrypted_key", {
npub: account.npub,
password: passphase,
});
setKey(encrypted);
};
const copyKey = async () => {
try {
await writeText(key);
const data: string = await invoke("get_private_key", { npub: account });
await writeText(data);
setCopied(true);
} catch (e) {
toast.error(e);
@@ -64,65 +48,26 @@ function List({ account }: { account: Account }) {
};
return (
<div className="flex flex-1 flex-col gap-2 py-3">
<User.Provider pubkey={account.npub}>
<div className="flex items-center justify-between gap-2 py-3">
<User.Provider pubkey={account}>
<User.Root className="flex items-center gap-2">
<User.Avatar className="size-8 rounded-full object-cover" />
<User.Avatar className="object-cover rounded-full size-8" />
<div className="flex flex-col">
<User.Name className="text-sm leading-tight" />
<User.NIP05 />
<span className="text-sm leading-tight text-black/50 dark:text-white/50">
{displayNpub(account, 16)}
</span>
</div>
</User.Root>
</User.Provider>
<div className="flex flex-col gap-2">
<div className="flex w-full flex-col gap-1">
<label
htmlFor="nsec"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Private Key
</label>
<div className="flex items-center gap-2">
<input
readOnly
name="nsec"
type="text"
value={displayNsec(key, 36)}
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
<button
type="button"
onClick={() => copyKey()}
className="inline-flex h-9 w-24 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-900 dark:hover:bg-neutral-700"
>
{copied ? "Copied" : "Copy"}
</button>
</div>
</div>
<div className="flex w-full flex-col gap-1">
<label
htmlFor="passphase"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Set a passphase to secure your key
</label>
<div className="flex items-center gap-2">
<input
name="passphase"
type="password"
value={passphase}
onChange={(e) => setPassphase(e.target.value)}
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
<button
type="button"
onClick={() => encrypt()}
className="inline-flex h-9 w-24 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-900 dark:hover:bg-neutral-700"
>
Update
</button>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => copyKey()}
className="inline-flex items-center justify-center h-8 text-sm font-medium rounded-md w-36 bg-neutral-200 hover:bg-neutral-300 dark:bg-white/10 dark:hover:bg-white/20"
>
{copied ? "Copied" : "Copy Private Key"}
</button>
</div>
</div>
);

View File

@@ -1,179 +1,102 @@
import { NostrQuery } from "@lume/system";
import type { Settings } from "@lume/types";
import { NostrQuery, type Settings } from "@lume/system";
import * as Switch from "@radix-ui/react-switch";
import { createFileRoute } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core";
import { requestPermission } from "@tauri-apps/plugin-notification";
import { useEffect, useState } from "react";
import { useDebouncedCallback } from "use-debounce";
type Theme = "auto" | "light" | "dark";
export const Route = createFileRoute("/settings/general")({
beforeLoad: async () => {
const settings = await NostrQuery.getSettings();
return { settings };
const initialSettings = await NostrQuery.getUserSettings();
return { initialSettings };
},
component: Screen,
});
function Screen() {
const { settings } = Route.useRouteContext();
const [newSettings, setNewSettings] = useState<Settings>(settings);
const { initialSettings } = Route.useRouteContext();
const toggleNofitication = async () => {
await requestPermission();
setNewSettings((prev) => ({
...prev,
notification: !newSettings.notification,
}));
};
const [theme, setTheme] = useState<Theme>(null);
const [settings, setSettings] = useState<Settings>(null);
const toggleGossip = async () => {
setNewSettings((prev) => ({
...prev,
gossip: !newSettings.gossip,
}));
};
const toggleAutoUpdate = () => {
setNewSettings((prev) => ({
...prev,
autoUpdate: !newSettings.autoUpdate,
}));
};
const toggleEnhancedPrivacy = () => {
setNewSettings((prev) => ({
...prev,
enhancedPrivacy: !newSettings.enhancedPrivacy,
}));
};
const toggleZap = () => {
setNewSettings((prev) => ({
...prev,
zap: !newSettings.zap,
}));
};
const toggleNsfw = () => {
setNewSettings((prev) => ({
...prev,
nsfw: !newSettings.nsfw,
}));
};
const changeTheme = (theme: string) => {
const changeTheme = async (theme: string) => {
if (theme === "auto" || theme === "light" || theme === "dark") {
invoke("plugin:theme|set_theme", {
theme: theme,
}).then(() =>
setNewSettings((prev) => ({
...prev,
theme,
})),
);
}).then(() => setTheme(theme));
}
};
const updateSettings = useDebouncedCallback(() => {
NostrQuery.setSettings(newSettings);
const updateSettings = useDebouncedCallback(async () => {
const newSettings = JSON.stringify(settings);
await NostrQuery.setUserSettings(newSettings);
}, 200);
useEffect(() => {
updateSettings();
}, [newSettings]);
}, [settings]);
useEffect(() => {
invoke("plugin:theme|get_theme").then((data: Theme) => setTheme(data));
}, []);
useEffect(() => {
setSettings(initialSettings);
}, [initialSettings]);
if (!settings) return null;
return (
<div className="mx-auto w-full max-w-xl">
<div className="w-full max-w-xl mx-auto">
<div className="flex flex-col gap-6">
<div className="flex items-center w-full h-12 px-3 text-sm rounded-xl bg-black/5 dark:bg-white/5">
* Setting changes require restarting the app to take effect.
</div>
<div className="flex flex-col gap-2">
<h2 className="font-semibold text-sm text-neutral-700 dark:text-neutral-300">
<h2 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
General
</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 w-full items-start justify-between gap-4 py-3">
<div className="flex-1">
<h3 className="font-medium">Notification</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
By turning on push notifications, you'll start getting
notifications from Lume directly.
</p>
</div>
<div className="w-36 flex justify-end shrink-0">
<Switch.Root
checked={newSettings.notification}
onClick={() => toggleNofitication()}
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 flex-col px-3 divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl">
<div className="flex items-start justify-between w-full gap-4 py-3">
<div className="flex-1">
<h3 className="font-medium">Relay Hint</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Automatically connect to the necessary relay suggested by
Relay Hint when fetching a new event.
Use the relay hint if necessary.
</p>
</div>
<div className="w-36 flex justify-end shrink-0">
<div className="flex justify-end w-36 shrink-0">
<Switch.Root
checked={newSettings.gossip}
onClick={() => toggleGossip()}
checked={settings.use_relay_hint}
onClick={() =>
setSettings((prev) => ({
...prev,
use_relay_hint: !prev.use_relay_hint,
}))
}
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
>
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
</div>
<div className="flex w-full items-start justify-between gap-4 py-3">
<div className="flex items-start justify-between w-full gap-4 py-3">
<div className="flex-1">
<h3 className="font-medium">Enhanced Privacy</h3>
<h3 className="font-medium">Content Warning</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Lume presents external resources like images, videos, or link
previews in plain text.
Shows a warning for notes that have a content warning.
</p>
</div>
<div className="w-36 flex justify-end shrink-0">
<div className="flex justify-end w-36 shrink-0">
<Switch.Root
checked={newSettings.enhancedPrivacy}
onClick={() => toggleEnhancedPrivacy()}
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">Auto Update</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Automatically download and install new version.
</p>
</div>
<div className="w-36 flex justify-end shrink-0">
<Switch.Root
checked={newSettings.autoUpdate}
onClick={() => toggleAutoUpdate()}
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">Filter sensitive content</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
By default, Lume will display all content which have Content
Warning tag, it's may include NSFW content.
</p>
</div>
<div className="w-36 flex justify-end shrink-0">
<Switch.Root
checked={newSettings.nsfw}
onClick={() => toggleNsfw()}
checked={settings.content_warning}
onClick={() =>
setSettings((prev) => ({
...prev,
content_warning: !prev.content_warning,
}))
}
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
>
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
@@ -183,40 +106,22 @@ function Screen() {
</div>
</div>
<div className="flex flex-col gap-2">
<h2 className="font-semibold text-sm text-neutral-700 dark:text-neutral-300">
Interface
<h2 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
Appearance
</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 w-full items-start justify-between gap-4 py-3">
<div className="flex flex-col px-3 divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl">
<div className="flex items-start justify-between w-full gap-4 py-3">
<div className="flex-1">
<h3 className="font-semibold">Zap</h3>
<h3 className="font-medium">Appearance</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.
Require restarting the app to take effect.
</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">
<div className="flex justify-end w-36 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}
className="w-24 py-1 bg-transparent rounded-lg shadow-none outline-none border-1 border-black/10 dark:border-white/10"
defaultValue={theme}
onChange={(e) => changeTheme(e.target.value)}
>
<option value="auto">Auto</option>
@@ -225,6 +130,121 @@ function Screen() {
</select>
</div>
</div>
<div className="flex items-start justify-between w-full gap-4 py-3">
<div className="flex-1">
<h3 className="font-medium">Zap Button</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Shows the Zap button when viewing a note.
</p>
</div>
<div className="flex justify-end w-36 shrink-0">
<Switch.Root
checked={settings.display_zap_button}
onClick={() =>
setSettings((prev) => ({
...prev,
display_zap_button: !prev.display_zap_button,
}))
}
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
>
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
</div>
<div className="flex items-start justify-between w-full gap-4 py-3">
<div className="flex-1">
<h3 className="font-medium">Repost Button</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Shows the Repost button when viewing a note.
</p>
</div>
<div className="flex justify-end w-36 shrink-0">
<Switch.Root
checked={settings.display_zap_button}
onClick={() =>
setSettings((prev) => ({
...prev,
display_zap_button: !prev.display_zap_button,
}))
}
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
>
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
</div>
</div>
</div>
<div className="flex flex-col gap-2">
<h2 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
Privacy & Performance
</h2>
<div className="flex flex-col px-3 divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl">
<div className="flex items-start justify-between w-full gap-4 py-3">
<div className="flex-1">
<h3 className="font-medium">Proxy</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Set proxy address.
</p>
</div>
<div className="flex justify-end w-36 shrink-0">
<input
type="url"
defaultValue={settings.proxy}
onChange={(e) =>
setSettings((prev) => ({
...prev,
proxy: e.target.value,
}))
}
className="py-1 bg-transparent rounded-lg shadow-none outline-none w-44 border-1 border-black/10 dark:border-white/10"
/>
</div>
</div>
<div className="flex items-start justify-between w-full gap-4 py-3">
<div className="flex-1">
<h3 className="font-medium">Image Resize Service</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Use weserv/images for resize image on-the-fly.
</p>
</div>
<div className="flex justify-end w-36 shrink-0">
<input
type="url"
defaultValue={settings.image_resize_service}
onChange={(e) =>
setSettings((prev) => ({
...prev,
image_resize_service: e.target.value,
}))
}
className="py-1 bg-transparent rounded-lg shadow-none outline-none w-44 border-1 border-black/10 dark:border-white/10"
/>
</div>
</div>
<div className="flex items-start justify-between w-full gap-4 py-3">
<div className="flex-1">
<h3 className="font-medium">Load Remote Media</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
View the remote media directly.
</p>
</div>
<div className="flex justify-end w-36 shrink-0">
<Switch.Root
checked={settings.display_media}
onClick={() =>
setSettings((prev) => ({
...prev,
display_image_link: !prev.display_media,
}))
}
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
>
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,7 +1,7 @@
import { CancelIcon, PlusIcon } from "@lume/icons";
import { NostrQuery } from "@lume/system";
import { createFileRoute } from "@tanstack/react-router";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -15,22 +15,32 @@ export const Route = createFileRoute("/settings/relay")({
function Screen() {
const relayList = Route.useLoaderData();
const [relays, setRelays] = useState(relayList.connected);
const { register, reset, handleSubmit } = useForm();
const [relays, setRelays] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const onSubmit = async (data: { url: string }) => {
try {
setIsLoading(true);
const add = await NostrQuery.connectRelay(data.url);
if (add) {
setRelays((prev) => [...prev, data.url]);
setIsLoading(false);
reset();
}
} catch (e) {
setIsLoading(false);
toast.error(String(e));
}
};
useEffect(() => {
setRelays(relayList.connected);
}, [relayList]);
return (
<div className="mx-auto w-full max-w-xl">
<div className="flex flex-col gap-6">
@@ -79,6 +89,7 @@ function Screen() {
/>
<button
type="submit"
disabled={isLoading}
className="shrink-0 inline-flex h-9 w-16 px-2 items-center justify-center rounded-lg bg-black/20 dark:bg-white/20 font-medium text-sm text-white hover:bg-blue-500 disabled:opacity-50"
>
<PlusIcon className="size-7" />

View File

@@ -1,17 +1,9 @@
import { GlobalIcon, LaurelIcon } from "@lume/icons";
import type { ColumnRouteSearch } from "@lume/types";
import { cn } from "@lume/utils";
import { Link } from "@tanstack/react-router";
import { Outlet, createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/store")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
return {
account: search.account,
label: search.label,
name: search.name,
};
},
component: Screen,
});
@@ -19,7 +11,7 @@ function Screen() {
return (
<div className="flex flex-col h-full">
<div className="px-3 mt-2 mb-1">
<div className="p-1 shrink-0 inline-flex w-full rounded-lg items-center gap-1 bg-black/5 dark:bg-white/5">
<div className="inline-flex items-center w-full gap-1 p-1 rounded-lg shrink-0 bg-black/5 dark:bg-white/5">
<Link to="/store/official" className="flex-1">
{({ isActive }) => (
<div

View File

@@ -2,19 +2,19 @@ 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 { NostrQuery } from "@lume/system";
import {
type ColumnRouteSearch,
type NostrEvent,
Kind,
Topic,
} from "@lume/types";
import { ArrowRightCircleIcon } from "@lume/icons";
import { type LumeEvent, NostrQuery } from "@lume/system";
import { type ColumnRouteSearch, Kind } from "@lume/types";
import { Spinner } from "@lume/ui";
import { useInfiniteQuery } from "@tanstack/react-query";
import { Link, createFileRoute, redirect } from "@tanstack/react-router";
import { createFileRoute, redirect } from "@tanstack/react-router";
import { useCallback } from "react";
import { Virtualizer } from "virtua";
type Topic = {
content: string[];
};
export const Route = createFileRoute("/topic")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
return {
@@ -24,9 +24,9 @@ export const Route = createFileRoute("/topic")({
};
},
beforeLoad: async ({ search }) => {
const key = `lume_topic_${search.label}`;
const topics = (await NostrQuery.getNstore(key)) as unknown as Topic[];
const settings = await NostrQuery.getSettings();
const key = `lume:topic:${search.label}`;
const topics: Topic[] = await NostrQuery.getNstore(key);
const settings = await NostrQuery.getUserSettings();
if (!topics?.length) {
throw redirect({
@@ -38,16 +38,13 @@ export const Route = createFileRoute("/topic")({
});
}
let hashtags: string[] = [];
const hashtags: string[] = [];
for (const topic of topics) {
hashtags.push(...topic.content);
}
return {
hashtags,
settings,
};
return { settings, hashtags };
},
component: Screen,
});
@@ -70,38 +67,36 @@ export function Screen() {
return events;
},
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
select: (data) => data?.pages.flatMap((page) => page),
select: (data) => data?.pages.flat(),
refetchOnWindowFocus: false,
});
const renderItem = (event: NostrEvent) => {
if (!event) return;
switch (event.kind) {
case Kind.Repost:
return <RepostNote key={event.id} event={event} />;
default: {
const isConversation =
event.tags.filter((tag) => tag[0] === "e" && tag[3] !== "mention")
.length > 0;
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0;
if (isConversation) {
return <Conversation key={event.id} event={event} className="mb-3" />;
const renderItem = useCallback(
(event: LumeEvent) => {
if (!event) return;
switch (event.kind) {
case Kind.Repost:
return <RepostNote key={event.id} event={event} className="mb-3" />;
default: {
if (event.isConversation) {
return (
<Conversation key={event.id} className="mb-3" event={event} />
);
}
if (event.isQuote) {
return <Quote key={event.id} event={event} className="mb-3" />;
}
return <TextNote key={event.id} event={event} className="mb-3" />;
}
if (isQuote) {
return <Quote key={event.id} event={event} className="mb-3" />;
}
return <TextNote key={event.id} event={event} className="mb-3" />;
}
}
};
},
[data],
);
return (
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none">
<div className="w-full h-full p-3 overflow-y-auto scrollbar-none">
{isFetching && !isLoading && !isFetchingNextPage ? (
<div className="mb-3 w-full h-11 flex items-center justify-center bg-black/10 dark:bg-white/10 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50">
<div className="flex items-center justify-center w-full mb-3 h-11 bg-black/10 dark:bg-white/10 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50">
<div className="flex items-center justify-center gap-2">
<Spinner className="size-5" />
<span className="text-sm font-medium">Fetching new notes...</span>
@@ -109,12 +104,14 @@ export function Screen() {
</div>
) : null}
{isLoading ? (
<div className="flex h-16 w-full items-center justify-center gap-2">
<div className="flex items-center justify-center w-full h-16 gap-2">
<Spinner className="size-5" />
<span className="text-sm font-medium">Loading...</span>
</div>
) : !data.length ? (
<Empty />
<div className="flex items-center justify-center">
Yo. You're catching up on all the things happening around you.
</div>
) : (
<Virtualizer overscan={3}>
{data.map((item) => renderItem(item))}
@@ -126,7 +123,7 @@ export function Screen() {
type="button"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage || isLoading}
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-neutral-100 px-3 font-medium hover:bg-neutral-50 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
className="inline-flex items-center justify-center w-full gap-2 px-3 font-medium h-9 rounded-xl bg-black/5 hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
>
{isFetchingNextPage ? (
<Spinner className="size-5" />
@@ -142,35 +139,3 @@ export function Screen() {
</div>
);
}
function Empty() {
return (
<div className="flex flex-col py-10 gap-10">
<div className="text-center flex flex-col items-center justify-center">
<div className="size-24 bg-blue-100 flex flex-col items-center justify-end overflow-hidden dark:bg-blue-900 rounded-full mb-8">
<div className="w-12 h-16 bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900 rounded-t-lg" />
</div>
<p className="text-lg font-medium">Your newsfeed is empty</p>
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
Here are few suggestions to get started.
</p>
</div>
<div className="flex flex-col px-3 gap-2">
<Link
to="/trending/notes"
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
>
<ArrowRightIcon className="size-5" />
Show trending notes
</Link>
<Link
to="/trending/users"
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3"
>
<ArrowRightIcon className="size-5" />
Discover trending users
</Link>
</div>
</div>
);
}

View File

@@ -1,4 +1,5 @@
import { TextNote } from "@/components/text";
import { LumeEvent } from "@lume/system";
import type { NostrEvent } from "@lume/types";
import { Spinner } from "@lume/ui";
import { Await, createFileRoute } from "@tanstack/react-router";
@@ -15,7 +16,13 @@ export const Route = createFileRoute("/trending/notes")({
signal: abortController.signal,
})
.then((res) => res.json())
.then((res) => res.notes.map((item) => item.event) as NostrEvent[]),
.then((res) => {
const events: NostrEvent[] = res.notes.map(
(item: { event: NostrEvent }) => item.event,
);
const lumeEvents = events.map((ev) => new LumeEvent(ev));
return lumeEvents;
}),
),
};
} catch (e) {
@@ -33,7 +40,7 @@ export function Screen() {
<Virtualizer overscan={3}>
<Suspense
fallback={
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
<div className="flex flex-col items-center justify-center w-full h-20 gap-1">
<button
type="button"
className="inline-flex items-center gap-2 text-sm font-medium"

View File

@@ -14,18 +14,20 @@ export const Route = createFileRoute("/trending")({
};
},
beforeLoad: async () => {
const settings = await NostrQuery.getSettings();
const settings = await NostrQuery.getUserSettings();
return { settings };
},
component: Screen,
});
function Screen() {
const search = Route.useSearch();
return (
<div className="flex flex-col h-full">
<div className="h-11 shrink-0 inline-flex w-full items-center gap-1 px-3">
<div className="inline-flex h-full w-full items-center gap-1">
<Link to="/trending/notes">
<div className="inline-flex items-center w-full gap-1 px-3 h-11 shrink-0">
<div className="inline-flex items-center w-full h-full gap-1">
<Link to="/trending/notes" search={search}>
{({ isActive }) => (
<div
className={cn(
@@ -38,7 +40,7 @@ function Screen() {
</div>
)}
</Link>
<Link to="/trending/users">
<Link to="/trending/users" search={search}>
{({ isActive }) => (
<div
className={cn(
@@ -53,7 +55,7 @@ function Screen() {
</Link>
</div>
</div>
<div className="p-2 flex-1 overflow-y-auto w-full h-full scrollbar-none">
<div className="flex-1 w-full h-full p-2 overflow-y-auto scrollbar-none">
<Outlet />
</div>
</div>

View File

@@ -6,16 +6,12 @@ import { Conversation } from "@/components/conversation";
import { Quote } from "@/components/quote";
import { RepostNote } from "@/components/repost";
import { TextNote } from "@/components/text";
import { type NostrEvent, Kind } from "@lume/types";
import { Suspense } from "react";
import { Kind } from "@lume/types";
import { Suspense, useCallback } from "react";
import { Await } from "@tanstack/react-router";
import { NostrQuery } from "@lume/system";
import { type LumeEvent, NostrQuery } from "@lume/system";
export const Route = createFileRoute("/users/$pubkey")({
beforeLoad: async () => {
const settings = await NostrQuery.getSettings();
return { settings };
},
loader: async ({ params }) => {
return { data: defer(NostrQuery.getUserEvents(params.pubkey)) };
},
@@ -26,29 +22,27 @@ function Screen() {
const { pubkey } = Route.useParams();
const { data } = Route.useLoaderData();
const renderItem = (event: NostrEvent) => {
if (!event) return;
switch (event.kind) {
case Kind.Repost:
return <RepostNote key={event.id} event={event} />;
default: {
const isConversation =
event.tags.filter((tag) => tag[0] === "e" && tag[3] !== "mention")
.length > 0;
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0;
if (isConversation) {
return <Conversation key={event.id} event={event} className="mb-3" />;
const renderItem = useCallback(
(event: LumeEvent) => {
if (!event) return;
switch (event.kind) {
case Kind.Repost:
return <RepostNote key={event.id} event={event} className="mb-3" />;
default: {
if (event.isConversation) {
return (
<Conversation key={event.id} className="mb-3" event={event} />
);
}
if (event.isQuote) {
return <Quote key={event.id} event={event} className="mb-3" />;
}
return <TextNote key={event.id} event={event} className="mb-3" />;
}
if (isQuote) {
return <Quote key={event.id} event={event} className="mb-3" />;
}
return <TextNote key={event.id} event={event} className="mb-3" />;
}
}
};
},
[data],
);
return (
<Container withDrag>
@@ -56,15 +50,15 @@ function Screen() {
<WindowVirtualizer>
<User.Provider pubkey={pubkey}>
<User.Root>
<User.Cover className="h-44 w-full object-cover" />
<div className="relative -mt-8 flex flex-col px-3">
<User.Avatar className="size-14 rounded-full" />
<div className="mb-4 inline-flex items-center justify-between">
<User.Cover className="object-cover w-full h-44" />
<div className="relative flex flex-col px-3 -mt-8">
<User.Avatar className="rounded-full size-14" />
<div className="inline-flex items-center justify-between mb-4">
<div className="flex items-center gap-1">
<User.Name className="text-lg font-semibold leading-tight" />
<User.NIP05 />
</div>
<User.Button className="h-9 w-24 rounded-full inline-flex items-center justify-center bg-black text-sm font-medium text-white hover:bg-neutral-900 dark:bg-neutral-900" />
<User.Button className="inline-flex items-center justify-center w-24 text-sm font-medium text-white bg-black rounded-full h-9 hover:bg-neutral-900 dark:bg-neutral-900" />
</div>
<User.About />
</div>

View File

@@ -13,11 +13,11 @@
"@astrojs/check": "^0.5.10",
"@astrojs/tailwind": "^5.1.0",
"@fontsource/alice": "^5.0.13",
"astro": "^4.9.2",
"astro": "^4.10.2",
"astro-seo-meta": "^4.1.1",
"astro-seo-schema": "^4.0.2",
"schema-dts": "^1.1.2",
"tailwindcss": "^3.4.3",
"tailwindcss": "^3.4.4",
"typescript": "^5.4.5"
},
"devDependencies": {

View File

@@ -11,13 +11,17 @@
"rules": {
"recommended": true,
"style": {
"noNonNullAssertion": "warn"
"noNonNullAssertion": "warn",
"noUselessElse": "off"
},
"correctness": {
"useExhaustiveDependencies": "off"
},
"a11y": {
"noSvgWithoutTitle": "off"
},
"complexity": {
"noStaticOnlyClass": "off"
}
}
}

View File

@@ -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.13",
"@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.8.1",
"@tauri-apps/cli": "2.0.0-beta.20",
"turbo": "^1.13.4"
},
"packageManager": "pnpm@8.9.0",
"engines": {
"node": ">=18"
},
"dependencies": {
"@tauri-apps/api": "2.0.0-beta.13",
"@tauri-apps/plugin-clipboard-manager": "2.1.0-beta.3",
"@tauri-apps/plugin-dialog": "2.0.0-beta.5",
"@tauri-apps/plugin-fs": "2.0.0-beta.5",
"@tauri-apps/plugin-http": "2.0.0-beta.5",
"@tauri-apps/plugin-notification": "2.0.0-beta.5",
"@tauri-apps/plugin-os": "2.0.0-beta.5",
"@tauri-apps/plugin-process": "2.0.0-beta.5",
"@tauri-apps/plugin-shell": "2.0.0-beta.6",
"@tauri-apps/plugin-updater": "2.0.0-beta.5",
"@tauri-apps/plugin-upload": "2.0.0-beta.6"
}
}

View File

@@ -125,3 +125,4 @@ export * from "./src/key";
export * from "./src/remote";
export * from "./src/nsfw";
export * from "./src/visit";
export * from "./src/pow";

View File

@@ -1,19 +1,9 @@
export function PlusSquareIcon(props: JSX.IntrinsicElements["svg"]) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
{...props}
>
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M12 15v-3m0 0V9m0 3H9m3 0h3m-3 9c-2.796 0-4.193 0-5.296-.457a6 6 0 01-3.247-3.247C3 16.194 3 14.796 3 12c0-2.796 0-4.193.457-5.296a6 6 0 013.247-3.247C7.807 3 9.204 3 12 3c2.796 0 4.194 0 5.296.457a6 6 0 013.247 3.247C21 7.807 21 9.204 21 12c0 2.796 0 4.194-.457 5.296a6 6 0 01-3.247 3.247C16.194 21 14.796 21 12 21z"
fill="currentColor"
d="m4.842 20.032.34-.668-.34.668Zm-.874-.874.668-.34-.668.34Zm16.064 0-.668-.34.668.34Zm-.874.874-.34-.668.34.668Zm.874-15.19-.668.34.668-.34Zm-.874-.874-.34.668.34-.668Zm-15.19.874-.668-.34.668.34Zm.874-.874-.34-.668.34.668ZM15.25 12.75a.75.75 0 0 0 0-1.5v1.5Zm-6.493-1.5a.75.75 0 0 0 0 1.5v-1.5Zm2.493 3.993a.75.75 0 0 0 1.5 0h-1.5Zm1.5-6.485a.75.75 0 0 0-1.5 0h1.5ZM19.5 6.95v10.1H21V6.95h-1.5ZM17.05 19.5H6.95V21h10.1v-1.5ZM4.5 17.05V6.95H3v10.1h1.5ZM6.95 4.5h10.1V3H6.95v1.5Zm0 15c-.572 0-.957 0-1.253-.025-.287-.023-.424-.065-.514-.111L4.502 20.7c.337.172.693.24 1.073.27.371.03.827.03 1.375.03v-1.5ZM3 17.05c0 .548 0 1.003.03 1.375.03.38.098.736.27 1.073l1.336-.68c-.046-.091-.088-.228-.111-.516-.024-.295-.025-.68-.025-1.252H3Zm2.183 2.314a1.25 1.25 0 0 1-.547-.547l-1.336.681A2.75 2.75 0 0 0 4.502 20.7l.68-1.336ZM19.5 17.05c0 .572 0 .957-.025 1.252-.023.288-.065.425-.111.515l1.336.681c.172-.337.24-.693.27-1.073.03-.372.03-.827.03-1.375h-1.5ZM17.05 21c.548 0 1.003 0 1.375-.03.38-.03.736-.098 1.073-.27l-.68-1.336c-.091.046-.228.088-.516.111-.295.024-.68.025-1.252.025V21Zm2.314-2.183a1.25 1.25 0 0 1-.547.547l.681 1.336a2.751 2.751 0 0 0 1.202-1.2l-1.336-.681ZM21 6.95c0-.548 0-1.004-.03-1.375-.03-.38-.098-.736-.27-1.073l-1.336.68c.046.091.088.228.111.515.024.296.025.68.025 1.253H21ZM17.05 4.5c.572 0 .957 0 1.252.025.288.023.425.065.515.111l.681-1.336c-.337-.172-.693-.24-1.073-.27C18.053 3 17.598 3 17.05 3v1.5Zm3.65.002A2.75 2.75 0 0 0 19.5 3.3l-.681 1.336c.235.12.426.311.546.547l1.336-.681ZM4.5 6.95c0-.572 0-.957.025-1.253.023-.287.065-.424.111-.514L3.3 4.502c-.172.337-.24.693-.27 1.073C3 5.946 3 6.402 3 6.95h1.5ZM6.95 3c-.548 0-1.004 0-1.375.03-.38.03-.736.098-1.073.27l.68 1.336c.091-.046.228-.088.515-.111.296-.024.68-.025 1.253-.025V3ZM4.636 5.183a1.25 1.25 0 0 1 .547-.547L4.502 3.3A2.75 2.75 0 0 0 3.3 4.502l1.336.68ZM15.25 11.25H8.757v1.5h6.493v-1.5Zm-2.5 3.993V8.758h-1.5v6.485h1.5Z"
/>
</svg>
);

View File

@@ -0,0 +1,19 @@
export function PowIcon(props: JSX.IntrinsicElements["svg"]) {
return (
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
d="M19 2.75a2.25 2.25 0 1 1 0 4.5 2.25 2.25 0 0 1 0-4.5ZM5.567 18.434a6.196 6.196 0 0 1 0-8.763L9.4 5.837a1.807 1.807 0 1 1 2.556 2.556l-3.834 3.834a2.582 2.582 0 1 0 3.651 3.65l3.834-3.833a1.807 1.807 0 0 1 2.556 2.556l-3.834 3.834a6.177 6.177 0 0 1-4.382 1.815 6.177 6.177 0 0 1-4.381-1.815Z"
/>
<path
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.5"
d="m13.965 13.687 2.556 2.556M7.758 7.48l2.556 2.556"
/>
</svg>
);
}

View File

@@ -5,7 +5,9 @@
"main": "./src/index.ts",
"dependencies": {
"@lume/utils": "workspace:^",
"@tanstack/react-query": "^5.40.0",
"@tanstack/query-persist-client-core": "^5.45.0",
"@tanstack/react-query": "^5.45.0",
"nostr-tools": "^2.7.0",
"react": "^18.3.1"
},
"devDependencies": {

View File

@@ -1,5 +1,6 @@
import { Metadata } from "@lume/types";
import { commands } from "./commands";
import type { Metadata } from "@lume/types";
import { type Result, commands } from "./commands";
import { Window } from "@tauri-apps/api/window";
export class NostrAccount {
static async getAccounts() {
@@ -13,9 +14,19 @@ export class NostrAccount {
}
static async loadAccount(npub: string) {
const query = await commands.loadAccount(npub);
const bunker: string = localStorage.getItem(`${npub}_bunker`);
let query: Result<boolean, string>;
if (bunker?.length && bunker?.startsWith("bunker://")) {
query = await commands.loadAccount(npub, bunker);
} else {
query = await commands.loadAccount(npub, null);
}
if (query.status === "ok") {
const panel = Window.getByLabel("panel");
panel.emit("load-notification", { account: npub }); // trigger load notification
return query.data;
} else {
throw new Error(query.error);
@@ -62,19 +73,19 @@ export class NostrAccount {
}
static async connectRemoteAccount(uri: string) {
const remoteKey = uri.replace("bunker://", "").split("?")[0];
const npub = await commands.toNpub(remoteKey);
const connect = await commands.connectRemoteAccount(uri);
if (npub.status === "ok") {
const connect = await commands.nostrConnect(npub.data, uri);
if (connect.status === "ok") {
const npub = connect.data;
const parsed = new URL(uri);
parsed.searchParams.delete("secret");
if (connect.status === "ok") {
return connect.data;
} else {
throw new Error(connect.error);
}
// save connection string
localStorage.setItem(`${npub}_bunker`, parsed.toString());
return npub;
} else {
throw new Error(npub.error);
throw new Error(connect.error);
}
}
@@ -112,7 +123,7 @@ export class NostrAccount {
const query = await commands.getBalance();
if (query.status === "ok") {
return parseInt(query.data);
return Number.parseInt(query.data);
} else {
return 0;
}
@@ -128,8 +139,18 @@ export class NostrAccount {
}
}
static async follow(pubkey: string, alias?: string) {
const query = await commands.follow(pubkey, alias);
static async isContactListEmpty() {
const query = await commands.isContactListEmpty();
if (query.status === "ok") {
return query.data;
} else {
return true;
}
}
static async checkContact(pubkey: string) {
const query = await commands.checkContact(pubkey);
if (query.status === "ok") {
return query.data;
@@ -138,8 +159,8 @@ export class NostrAccount {
}
}
static async unfollow(pubkey: string) {
const query = await commands.unfollow(pubkey);
static async toggleContact(pubkey: string, alias?: string) {
const query = await commands.toggleContact(pubkey, alias);
if (query.status === "ok") {
return query.data;

View File

@@ -1,5 +1,8 @@
// This file was generated by [tauri-specta](https://github.com/oscartbeaumont/tauri-specta). Do not edit this file manually.
/** user-defined commands **/
export const commands = {
async getRelays() : Promise<Result<Relays, null>> {
try {
@@ -25,6 +28,22 @@ try {
else return { status: "error", error: e as any };
}
},
async getBootstrapRelays() : Promise<Result<string[], null>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_bootstrap_relays") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async saveBootstrapRelays(relays: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("save_bootstrap_relays", { relays }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getAccounts() : Promise<Result<string[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_accounts") };
@@ -57,65 +76,33 @@ try {
else return { status: "error", error: e as any };
}
},
async nostrConnect(npub: string, uri: string) : Promise<Result<string, string>> {
async getPrivateKey(npub: string) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("nostr_connect", { npub, uri }) };
return { status: "ok", data: await TAURI_INVOKE("get_private_key", { npub }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async loadAccount(npub: string) : Promise<Result<boolean, string>> {
async connectRemoteAccount(uri: string) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("load_account", { npub }) };
return { status: "ok", data: await TAURI_INVOKE("connect_remote_account", { uri }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async eventToBech32(id: string, relays: string[]) : Promise<Result<string, null>> {
async loadAccount(npub: string, bunker: string | null) : Promise<Result<boolean, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("event_to_bech32", { id, relays }) };
return { status: "ok", data: await TAURI_INVOKE("load_account", { npub, bunker }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async userToBech32(key: string, relays: string[]) : Promise<Result<string, null>> {
async getCurrentProfile() : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("user_to_bech32", { key, relays }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async toNpub(hex: string) : Promise<Result<string, null>> {
try {
return { status: "ok", data: await TAURI_INVOKE("to_npub", { hex }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async verifyNip05(key: string, nip05: string) : Promise<Result<boolean, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("verify_nip05", { key, nip05 }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getActivities(account: string, kind: string) : Promise<Result<string[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_activities", { account, kind }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getCurrentUserProfile() : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_current_user_profile") };
return { status: "ok", data: await TAURI_INVOKE("get_current_profile") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
@@ -137,9 +124,9 @@ try {
else return { status: "error", error: e as any };
}
},
async setContactList(pubkeys: string[]) : Promise<Result<boolean, string>> {
async setContactList(publicKeys: string[]) : Promise<Result<boolean, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("set_contact_list", { pubkeys }) };
return { status: "ok", data: await TAURI_INVOKE("set_contact_list", { publicKeys }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
@@ -153,17 +140,25 @@ try {
else return { status: "error", error: e as any };
}
},
async follow(id: string, alias: string | null) : Promise<Result<string, string>> {
async isContactListEmpty() : Promise<Result<boolean, null>> {
try {
return { status: "ok", data: await TAURI_INVOKE("follow", { id, alias }) };
return { status: "ok", data: await TAURI_INVOKE("is_contact_list_empty") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async unfollow(id: string) : Promise<Result<string, string>> {
async checkContact(hex: string) : Promise<Result<boolean, null>> {
try {
return { status: "ok", data: await TAURI_INVOKE("unfollow", { id }) };
return { status: "ok", data: await TAURI_INVOKE("check_contact", { hex }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async toggleContact(hex: string, alias: string | null) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("toggle_contact", { hex, alias }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
@@ -233,7 +228,47 @@ try {
else return { status: "error", error: e as any };
}
},
async getEvent(id: string) : Promise<Result<string, string>> {
async getNotifications() : Promise<Result<string[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_notifications") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getSettings() : Promise<Result<Settings, null>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_settings") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async setNewSettings(settings: string) : Promise<Result<null, null>> {
try {
return { status: "ok", data: await TAURI_INVOKE("set_new_settings", { settings }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async verifyNip05(key: string, nip05: string) : Promise<Result<boolean, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("verify_nip05", { key, nip05 }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getEventMeta(content: string) : Promise<Result<Meta, null>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_event_meta", { content }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getEvent(id: string) : Promise<Result<RichEvent, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_event", { id }) };
} catch (e) {
@@ -241,7 +276,15 @@ try {
else return { status: "error", error: e as any };
}
},
async getReplies(id: string) : Promise<Result<string[], string>> {
async getEventFrom(id: string, relayHint: string) : Promise<Result<RichEvent, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_event_from", { id, relayHint }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getReplies(id: string) : Promise<Result<RichEvent[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_replies", { id }) };
} catch (e) {
@@ -249,7 +292,7 @@ try {
else return { status: "error", error: e as any };
}
},
async getEventsBy(publicKey: string, asOf: string | null) : Promise<Result<string[], string>> {
async getEventsBy(publicKey: string, asOf: string | null) : Promise<Result<RichEvent[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_events_by", { publicKey, asOf }) };
} catch (e) {
@@ -257,15 +300,23 @@ try {
else return { status: "error", error: e as any };
}
},
async getLocalEvents(pubkeys: string[], until: string | null) : Promise<Result<string[], string>> {
async getLocalEvents(until: string | null) : Promise<Result<RichEvent[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_local_events", { pubkeys, until }) };
return { status: "ok", data: await TAURI_INVOKE("get_local_events", { until }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getGlobalEvents(until: string | null) : Promise<Result<string[], string>> {
async getGroupEvents(publicKeys: string[], until: string | null) : Promise<Result<RichEvent[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_group_events", { publicKeys, until }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getGlobalEvents(until: string | null) : Promise<Result<RichEvent[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_global_events", { until }) };
} catch (e) {
@@ -273,7 +324,7 @@ try {
else return { status: "error", error: e as any };
}
},
async getHashtagEvents(hashtags: string[], until: string | null) : Promise<Result<string[], string>> {
async getHashtagEvents(hashtags: string[], until: string | null) : Promise<Result<RichEvent[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_hashtag_events", { hashtags, until }) };
} catch (e) {
@@ -281,9 +332,17 @@ try {
else return { status: "error", error: e as any };
}
},
async publish(content: string, tags: string[][]) : Promise<Result<string, string>> {
async publish(content: string, warning: string | null, difficulty: number | null) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("publish", { content, tags }) };
return { status: "ok", data: await TAURI_INVOKE("publish", { content, warning, difficulty }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async reply(content: string, to: string, root: string | null) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("reply", { content, to, root }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
@@ -297,6 +356,22 @@ try {
else return { status: "error", error: e as any };
}
},
async eventToBech32(id: string) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("event_to_bech32", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async userToBech32(user: string) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("user_to_bech32", { user }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async showInFolder(path: string) : Promise<void> {
await TAURI_INVOKE("show_in_folder", { path });
},
@@ -340,17 +415,29 @@ try {
else return { status: "error", error: e as any };
}
},
async openMainWindow() : Promise<void> {
await TAURI_INVOKE("open_main_window");
},
async setBadge(count: number) : Promise<void> {
await TAURI_INVOKE("set_badge", { count });
}
}
/** user-defined events **/
/** user-defined statics **/
/** user-defined types **/
export type Account = { npub: string; nsec: string }
export type Meta = { content: string; images: string[]; videos: string[]; events: string[]; mentions: string[]; hashtags: string[] }
export type Relays = { connected: string[]; read: string[] | null; write: string[] | null; both: string[] | null }
export type RichEvent = { raw: string; parsed: Meta | null }
export type Settings = { proxy: string | null; image_resize_service: string | null; use_relay_hint: boolean; content_warning: boolean; display_avatar: boolean; display_zap_button: boolean; display_repost_button: boolean; display_media: boolean }
/** tauri-specta globals **/

View File

@@ -1,28 +0,0 @@
import { NostrEvent } from "@lume/types";
export function dedupEvents(nostrEvents: NostrEvent[], nsfw: boolean = false) {
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 (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;
}

View File

@@ -1,6 +1,11 @@
import { EventWithReplies, Kind, NostrEvent } from "@lume/types";
import { commands } from "./commands";
import { generateContentTags } from "@lume/utils";
import type {
EventTag,
EventWithReplies,
Kind,
Meta,
NostrEvent,
} from "@lume/types";
import { type Result, commands } from "./commands";
export class LumeEvent {
public id: string;
@@ -10,6 +15,7 @@ export class LumeEvent {
public tags: string[][];
public content: string;
public sig: string;
public meta: Meta;
public relay?: string;
#raw: NostrEvent;
@@ -18,41 +24,57 @@ export class LumeEvent {
Object.assign(this, event);
}
get isWarning() {
const tag = this.tags.find((tag) => tag[0] === "content-warning");
return tag?.[1]; // return: reason;
}
get isQuote() {
return this.tags.filter((tag) => tag[0] === "q").length > 0;
}
get isConversation() {
const tags = this.tags.filter(
(tag) => tag[0] === "e" && tag[3] !== "mention",
);
return tags.length > 0;
}
get mentions() {
return this.tags.filter((tag) => tag[0] === "p").map((tag) => tag[1]);
}
static getEventThread(tags: string[][], gossip?: boolean) {
let root: string = null;
let reply: string = null;
get repostId() {
return this.tags.find((tag) => tag[0] === "e")[1];
}
// Get all event references from tags, ignore mention
const events = tags.filter((el) => el[0] === "e" && el[3] !== "mention");
get thread() {
let root: EventTag = null;
let reply: EventTag = null;
if (gossip) {
const relays = tags.filter((el) => el[0] === "e" && el[2]?.length);
// Get all event references from tags, ignore mention.
const events = this.tags.filter(
(el) => el[0] === "e" && el[3] !== "mention",
);
if (relays.length >= 1) {
for (const relay of relays) {
if (relay[2]?.length)
commands
.connectRelay(relay[2])
.then(() => console.log("[gossip]: ", relay[2]));
}
if (events.length === 1) {
root = { id: events[0][1], relayHint: events[0][2] };
}
if (events.length === 2) {
root = { id: events[0][1], relayHint: events[0][2] };
reply = { id: events[1][1], relayHint: events[1][2] };
}
if (events.length > 2) {
for (const tag of events) {
if (tag[3] === "root") root = { id: tag[1], relayHint: tag[2] };
if (tag[3] === "reply") reply = { id: tag[1], relayHint: tag[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) {
// Fix some rare case when root same as reply
if (root && reply && root.id === reply.id) {
reply = null;
}
@@ -62,13 +84,31 @@ export class LumeEvent {
};
}
static async getReplies(id: string) {
get quote() {
const tag = this.tags.filter(
(tag) => tag[0] === "q" || tag[3] === "mention",
);
const id = tag[0][1];
const relayHint = tag[0][2];
return { id, relayHint };
}
public async getReplies(id: string) {
const query = await commands.getReplies(id);
if (query.status === "ok") {
const events = query.data.map(
(item) => JSON.parse(item) as EventWithReplies,
);
const events = query.data.map((item) => {
const raw = JSON.parse(item.raw) as EventWithReplies;
if (item.parsed) {
raw.meta = item.parsed;
} else {
raw.meta = null;
}
return raw;
});
if (events.length > 0) {
const replies = new Set();
@@ -104,63 +144,8 @@ export class LumeEvent {
}
}
static async publish(
content: string,
reply_to?: string,
quote?: boolean,
nsfw?: boolean,
) {
const g = await generateContentTags(content);
const eventContent = g.content;
const eventTags = g.tags;
if (reply_to) {
const queryReply = await commands.getEvent(reply_to);
if (queryReply.status === "ok") {
const replyEvent = JSON.parse(queryReply.data) as NostrEvent;
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 query = await commands.publish(eventContent, eventTags);
if (query.status === "ok") {
return query.data;
} else {
throw new Error(query.error);
}
}
static async zap(id: string, amount: number, message: string) {
const query = await commands.zapEvent(id, amount.toString(), message);
public async zap(amount: number, message: string) {
const query = await commands.zapEvent(this.id, amount.toString(), message);
if (query.status === "ok") {
return query.data;
@@ -170,7 +155,7 @@ export class LumeEvent {
}
public async idAsBech32() {
const query = await commands.eventToBech32(this.id, []);
const query = await commands.eventToBech32(this.id);
if (query.status === "ok") {
return query.data;
@@ -180,7 +165,7 @@ export class LumeEvent {
}
public async pubkeyAsBech32() {
const query = await commands.userToBech32(this.pubkey, []);
const query = await commands.userToBech32(this.pubkey);
if (query.status === "ok") {
return query.data;
@@ -198,4 +183,26 @@ export class LumeEvent {
throw new Error(query.error);
}
}
static async publish(
content: string,
warning?: string,
difficulty?: number,
reply_to?: string,
root_to?: string,
) {
let query: Result<string, string>;
if (reply_to) {
query = await commands.reply(content, reply_to, root_to);
} else {
query = await commands.publish(content, warning, difficulty);
}
if (query.status === "ok") {
return query.data;
} else {
throw new Error(query.error);
}
}
}

View File

@@ -1,18 +1,13 @@
import type { Event, NostrEvent } from "@lume/types";
import { useQuery } from "@tanstack/react-query";
import { invoke } from "@tauri-apps/api/core";
import { NostrQuery } from "../query";
import { experimental_createPersister } from "@tanstack/query-persist-client-core";
export function useEvent(id: string) {
export function useEvent(id: string, relayHint?: string) {
const { isLoading, isError, data } = useQuery({
queryKey: ["event", id],
queryFn: async () => {
try {
const eventId: string = id
.replace("nostr:", "")
.split("'")[0]
.split(".")[0];
const cmd: string = await invoke("get_event", { id: eventId });
const event: NostrEvent = JSON.parse(cmd);
const event = await NostrQuery.getEvent(id, relayHint);
return event;
} catch (e) {
throw new Error(e);
@@ -23,6 +18,10 @@ export function useEvent(id: string) {
refetchOnReconnect: false,
staleTime: Number.POSITIVE_INFINITY,
retry: 2,
persister: experimental_createPersister({
storage: localStorage,
maxAge: 1000 * 60 * 60 * 12, // 12 hours
}),
});
return { isLoading, isError, data };

View File

@@ -1,53 +0,0 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import { commands } from "../commands";
import { dedupEvents } from "../dedup";
import { NostrEvent } from "@lume/types";
export function useInfiniteEvents(
contacts: string[],
label: string,
account: string,
nsfw?: boolean,
) {
const pubkeys = contacts;
const {
data,
isLoading,
isFetching,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
} = useInfiniteQuery({
queryKey: [label, account],
initialPageParam: 0,
queryFn: async ({ pageParam }: { pageParam: number }) => {
try {
const until: string = pageParam > 0 ? pageParam.toString() : undefined;
const query = await commands.getLocalEvents(pubkeys, until);
if (query.status === "ok") {
const nostrEvents = query.data as unknown as NostrEvent[];
const events = dedupEvents(nostrEvents, nsfw);
return events;
} else {
throw new Error(query.error);
}
} catch (e) {
throw new Error(e);
}
},
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
select: (data) => data?.pages.flatMap((page) => page),
refetchOnWindowFocus: false,
});
return {
data,
isLoading,
isFetching,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
};
}

View File

@@ -1,6 +1,7 @@
import type { Metadata } from "@lume/types";
import { useQuery } from "@tanstack/react-query";
import { commands } from "../commands";
import { experimental_createPersister } from "@tanstack/query-persist-client-core";
export function useProfile(pubkey: string, embed?: string) {
const {
@@ -8,7 +9,7 @@ export function useProfile(pubkey: string, embed?: string) {
isError,
data: profile,
} = useQuery({
queryKey: ["user", pubkey],
queryKey: ["profile", pubkey],
queryFn: async () => {
try {
if (embed) return JSON.parse(embed) as Metadata;
@@ -30,6 +31,10 @@ export function useProfile(pubkey: string, embed?: string) {
refetchOnReconnect: false,
staleTime: Number.POSITIVE_INFINITY,
retry: 2,
persister: experimental_createPersister({
storage: localStorage,
maxAge: 1000 * 60 * 60 * 24, // 24 hours
}),
});
return { isLoading, isError, profile };

View File

@@ -4,5 +4,4 @@ export * from "./query";
export * from "./window";
export * from "./commands";
export * from "./hooks/useEvent";
export * from "./hooks/useInfiniteEvents";
export * from "./hooks/useProfile";

View File

@@ -1,18 +1,31 @@
import { LumeColumn, Metadata, NostrEvent, Settings } from "@lume/types";
import { commands } from "./commands";
import type { LumeColumn, Metadata, NostrEvent, Relay } from "@lume/types";
import { resolveResource } from "@tauri-apps/api/path";
import { readFile, readTextFile } from "@tauri-apps/plugin-fs";
import { isPermissionGranted } from "@tauri-apps/plugin-notification";
import { open } from "@tauri-apps/plugin-dialog";
import { dedupEvents } from "./dedup";
import { invoke } from "@tauri-apps/api/core";
enum NSTORE_KEYS {
settings = "lume_user_settings",
columns = "lume_user_columns",
}
import { readFile, readTextFile } from "@tauri-apps/plugin-fs";
import { relaunch } from "@tauri-apps/plugin-process";
import { nip19 } from "nostr-tools";
import { type Result, type RichEvent, commands } from "./commands";
import { LumeEvent } from "./event";
export class NostrQuery {
static #toLumeEvents(richEvents: RichEvent[]) {
const events = richEvents.map((item) => {
const nostrEvent = JSON.parse(item.raw) as NostrEvent;
if (item.parsed) {
nostrEvent.meta = item.parsed;
} else {
nostrEvent.meta = null;
}
const lumeEvent = new LumeEvent(nostrEvent);
return lumeEvent;
});
return events;
}
static async upload(filePath?: string) {
const allowExts = [
"png",
@@ -68,6 +81,20 @@ export class NostrQuery {
}
}
static async getNotifications() {
const query = await commands.getNotifications();
if (query.status === "ok") {
const data = query.data.map((item) => JSON.parse(item) as NostrEvent);
const events = data.map((ev) => new LumeEvent(ev));
return events;
} else {
console.error(query.error);
return [];
}
}
static async getProfile(pubkey: string) {
const normalize = pubkey.replace("nostr:", "").replace(/[^\w\s]/gi, "");
const query = await commands.getProfile(normalize);
@@ -80,53 +107,112 @@ export class NostrQuery {
}
}
static async getEvent(id: string) {
const normalize: string = id.replace("nostr:", "").replace(/[^\w\s]/gi, "");
const query = await commands.getEvent(normalize);
static async getEvent(id: string, hint?: string) {
// Validate ID
const normalizeId: string = id
.replace("nostr:", "")
.replace(/[^\w\s]/gi, "");
// Define query
let query: Result<RichEvent, string>;
let relayHint: string = hint;
if (normalizeId.startsWith("nevent1")) {
const decoded = nip19.decode(normalizeId);
if (decoded.type === "nevent") relayHint = decoded.data.relays[0];
}
// Build query
if (relayHint) {
try {
const url = new URL(relayHint);
query = await commands.getEventFrom(normalizeId, url.toString());
} catch {
query = await commands.getEvent(normalizeId);
}
} else {
query = await commands.getEvent(normalizeId);
}
if (query.status === "ok") {
const event: NostrEvent = JSON.parse(query.data);
const data = query.data;
const raw = JSON.parse(data.raw) as NostrEvent;
if (data?.parsed) {
raw.meta = data.parsed;
}
const event = new LumeEvent(raw);
return event;
} else {
console.log("[getEvent]: ", query.error);
return null;
}
}
static async getRepostEvent(event: LumeEvent) {
try {
const embed: NostrEvent = JSON.parse(event.content);
const query = await commands.getEventMeta(embed.content);
if (query.status === "ok") {
embed.meta = query.data;
const lumeEvent = new LumeEvent(embed);
return lumeEvent;
}
} catch {
const query = await commands.getEvent(event.repostId);
if (query.status === "ok") {
const data = query.data;
const raw = JSON.parse(data.raw) as NostrEvent;
if (data?.parsed) {
raw.meta = data.parsed;
}
const event = new LumeEvent(raw);
return event;
} else {
console.log("[getRepostEvent]: ", query.error);
return null;
}
}
}
static async getUserEvents(pubkey: string, asOf?: number) {
const until: string = asOf && asOf > 0 ? asOf.toString() : undefined;
const query = await commands.getEventsBy(pubkey, until);
if (query.status === "ok") {
const events = query.data.map((item) => JSON.parse(item) as NostrEvent);
return events;
const data = NostrQuery.#toLumeEvents(query.data);
return data;
} else {
return [];
}
}
static async getUserActivities(
account: string,
kind: "1" | "6" | "9735" = "1",
) {
const query = await commands.getActivities(account, kind);
if (query.status === "ok") {
const events = query.data.map((item) => JSON.parse(item) as NostrEvent);
return events;
} else {
return [];
}
}
static async getLocalEvents(pubkeys: string[], asOf?: number) {
static async getLocalEvents(asOf?: number) {
const until: string = asOf && asOf > 0 ? asOf.toString() : undefined;
const query = await commands.getLocalEvents(pubkeys, until);
const query = await commands.getLocalEvents(until);
if (query.status === "ok") {
const events = query.data.map((item) => JSON.parse(item) as NostrEvent);
const dedup = dedupEvents(events);
const data = NostrQuery.#toLumeEvents(query.data);
return data;
} else {
return [];
}
}
return dedup;
static async getGroupEvents(pubkeys: string[], asOf?: number) {
const until: string = asOf && asOf > 0 ? asOf.toString() : undefined;
const query = await commands.getGroupEvents(pubkeys, until);
if (query.status === "ok") {
const data = NostrQuery.#toLumeEvents(query.data);
return data;
} else {
return [];
}
@@ -137,10 +223,8 @@ export class NostrQuery {
const query = await commands.getGlobalEvents(until);
if (query.status === "ok") {
const events = query.data.map((item) => JSON.parse(item) as NostrEvent);
const dedup = dedupEvents(events);
return dedup;
const data = NostrQuery.#toLumeEvents(query.data);
return data;
} else {
return [];
}
@@ -152,18 +236,14 @@ export class NostrQuery {
const query = await commands.getHashtagEvents(nostrTags, until);
if (query.status === "ok") {
const events = query.data.map((item) => JSON.parse(item) as NostrEvent);
const dedup = dedupEvents(events);
return dedup;
const data = NostrQuery.#toLumeEvents(query.data);
return data;
} else {
return [];
}
}
static async verifyNip05(pubkey: string, nip05?: string) {
if (!nip05) return false;
const query = await commands.verifyNip05(pubkey, nip05);
if (query.status === "ok") {
@@ -177,9 +257,7 @@ export class NostrQuery {
const query = await commands.getNstore(key);
if (query.status === "ok") {
const data: string | string[] = query.data
? JSON.parse(query.data)
: null;
const data = query.data ? JSON.parse(query.data) : null;
return data;
} else {
return null;
@@ -196,68 +274,57 @@ export class NostrQuery {
}
}
static async getSettings() {
const query = await commands.getNstore(NSTORE_KEYS.settings);
if (query.status === "ok") {
const settings: Settings = query.data ? JSON.parse(query.data) : null;
const isGranted = await isPermissionGranted();
const theme: "auto" | "light" | "dark" = await invoke(
"plugin:theme|get_theme",
);
return { ...settings, theme, notification: isGranted };
} else {
const initial: Settings = {
autoUpdate: false,
enhancedPrivacy: false,
notification: false,
zap: false,
nsfw: false,
gossip: false,
theme: "auto",
};
return initial;
}
}
static async setSettings(settings: Settings) {
const query = await commands.setNstore(
NSTORE_KEYS.settings,
JSON.stringify(settings),
);
static async getUserSettings() {
const query = await commands.getSettings();
if (query.status === "ok") {
return query.data;
} else {
throw new Error(query.error);
return query.error;
}
}
static async setUserSettings(newSettings: string) {
const query = await commands.setNewSettings(newSettings);
if (query.status === "ok") {
return query.data;
} else {
return query.error;
}
}
static async getColumns() {
const key = "lume:columns";
const systemPath = "resources/system_columns.json";
const resourcePath = await resolveResource(systemPath);
const resourceFile = await readTextFile(resourcePath);
const systemColumns: LumeColumn[] = JSON.parse(resourceFile);
const query = await commands.getNstore(NSTORE_KEYS.columns);
const query = await commands.getNstore(key);
if (query.status === "ok") {
const columns: LumeColumn[] = query.data ? JSON.parse(query.data) : [];
try {
if (query.status === "ok") {
const columns: LumeColumn[] = JSON.parse(query.data);
if (columns.length < 1) {
if (!columns?.length) {
return systemColumns;
}
// Filter "open" column
// Reason: deprecated
return columns.filter((col) => col.label !== "open");
} else {
return systemColumns;
}
return columns;
} else {
} catch {
return systemColumns;
}
}
static async setColumns(columns: LumeColumn[]) {
const key = "lume:columns";
const content = JSON.stringify(columns);
const query = await commands.setNstore(NSTORE_KEYS.columns, content);
const query = await commands.setNstore(key, content);
if (query.status === "ok") {
return query.data;
@@ -303,4 +370,37 @@ export class NostrQuery {
}
}
}
static async getBootstrapRelays() {
const query = await commands.getBootstrapRelays();
if (query.status === "ok") {
const relays: Relay[] = [];
for (const item of query.data) {
const line = item.split(",");
const url = line[0];
const purpose = line[1] ?? "";
relays.push({ url, purpose });
}
return relays;
} else {
return [];
}
}
static async saveBootstrapRelays(relays: Relay[]) {
const text = relays
.map((relay) => Object.values(relay).join(","))
.join("\n");
const query = await commands.saveBootstrapRelays(text);
if (query.status === "ok") {
return await relaunch();
} else {
throw new Error(query.error);
}
}
}

View File

@@ -1,8 +1,14 @@
import { NostrEvent } from "@lume/types";
import type { NostrEvent } from "@lume/types";
import { commands } from "./commands";
import type { LumeEvent } from "./event";
export class LumeWindow {
static async openEvent(event: NostrEvent) {
static async openMainWindow() {
const query = await commands.openMainWindow();
return query;
}
static async openEvent(event: NostrEvent | LumeEvent) {
const eTags = event.tags.filter((tag) => tag[0] === "e" || tag[0] === "q");
const root: string =
eTags.find((el) => el[3] === "root")?.[1] ?? eTags[0]?.[1];
@@ -38,12 +44,18 @@ export class LumeWindow {
}
}
static async openEditor(reply_to?: string, quote = false) {
static async openEditor(reply_to?: string, quote?: string) {
let url: string;
if (reply_to) {
url = `/editor?reply_to=${reply_to}&quote=${quote}`;
} else {
url = `/editor?reply_to=${reply_to}`;
}
if (quote?.length) {
url = `/editor?quote=${quote}`;
}
if (!reply_to?.length && !quote?.length) {
url = "/editor";
}
@@ -92,7 +104,7 @@ export class LumeWindow {
const query = await commands.openWindow(
label,
"Settings",
"/settings",
"/settings/general",
800,
500,
);

View File

@@ -15,6 +15,6 @@
"@tailwindcss/typography": "^0.5.13",
"tailwind-gradient-mask-image": "^1.2.0",
"tailwind-scrollbar": "^3.1.0",
"tailwindcss": "^3.4.3"
"tailwindcss": "^3.4.4"
}
}

View File

@@ -1,14 +1,3 @@
export interface Settings {
notification: boolean;
enhancedPrivacy: boolean;
autoUpdate: boolean;
zap: boolean;
nsfw: boolean;
gossip: boolean;
theme: "auto" | "light" | "dark";
[key: string]: string | number | boolean;
}
export interface Keys {
npub: string;
nsec: string;
@@ -28,6 +17,15 @@ export enum Kind {
// #TODO: Add all nostr kinds
}
export interface Meta {
content: string;
images: string[];
videos: string[];
events: string[];
mentions: string[];
hashtags: string[];
}
export interface NostrEvent {
id: string;
pubkey: string;
@@ -36,12 +34,18 @@ export interface NostrEvent {
tags: string[][];
content: string;
sig: string;
meta: Meta;
}
export interface EventWithReplies extends NostrEvent {
replies: Array<NostrEvent>;
}
export interface EventTag {
id: string;
relayHint: string;
}
export interface Metadata {
name?: string;
display_name?: string;
@@ -54,42 +58,6 @@ export interface Metadata {
lud16?: string;
}
export interface Contact {
pubkey: string;
profile: Metadata;
}
export interface Account {
npub: string;
nsec?: string;
contacts?: string[];
interests?: Interests;
}
export interface Topic {
icon: string;
title: string;
content: string[];
}
export interface Interests {
hashtags: string[];
users: string[];
words: string[];
}
export interface RichContent {
parsed: string;
images: string[];
videos: string[];
links: string[];
notes: string[];
}
export interface AppRouteSearch {
account: string;
}
export interface ColumnRouteSearch {
account: string;
label: string;
@@ -109,73 +77,21 @@ export interface LumeColumn {
featured?: boolean;
}
export interface EventColumns {
type: "add" | "remove" | "update" | "left" | "right" | "set_title";
export interface ColumnEvent {
type: "reset" | "add" | "remove" | "update" | "left" | "right" | "set_title";
label?: string;
title?: string;
column?: LumeColumn;
}
export interface Opengraph {
url: string;
title?: string;
description?: string;
image?: string;
}
export interface NostrBuildResponse {
ok: boolean;
data?: {
message: string;
status: string;
data: Array<{
blurhash: string;
dimensions: {
width: number;
height: number;
};
mime: string;
name: string;
sha256: string;
size: number;
url: string;
}>;
};
}
export interface NIP11 {
name: string;
description: string;
pubkey: string;
contact: string;
supported_nips: number[];
software: string;
version: string;
limitation: {
[key: string]: string | number | boolean;
};
relay_countries: string[];
language_tags: string[];
tags: string[];
posting_policy: string;
payments_url: string;
icon: string[];
}
export interface NIP05 {
names: {
[key: string]: string;
};
nip46: {
[key: string]: {
[key: string]: string[];
};
};
}
export interface Relays {
connected: string[];
read: string[];
write: string[];
both: string[];
}
export interface Relay {
url: string;
purpose: "read" | "write" | string;
}

View File

@@ -15,7 +15,7 @@
"@lume/tsconfig": "workspace:^",
"@lume/types": "workspace:^",
"@types/react": "^18.3.3",
"tailwindcss": "^3.4.3",
"tailwindcss": "^3.4.4",
"typescript": "^5.4.5"
}
}

View File

@@ -25,7 +25,7 @@ export const Carousel = <T,>({ items, renderItem }: CarouselProps<T>) => {
snapPointIndexes,
} = useSnapCarousel();
return (
<div className="group relative">
<div className="relative group">
<ul
ref={scrollRef}
className="relative flex overflow-auto snap-x scrollbar-none"
@@ -39,9 +39,10 @@ export const Carousel = <T,>({ items, renderItem }: CarouselProps<T>) => {
</ul>
<div
aria-hidden
className="hidden group-hover:flex z-10 absolute left-1/2 top-1/2 transform -translate-x-1/2 -translate-y-1/2 w-full justify-between items-center px-5"
className="absolute z-10 items-center justify-between hidden w-full px-5 transform -translate-x-1/2 -translate-y-1/2 group-hover:flex left-1/2 top-1/2"
>
<button
type="button"
className={cn(
"size-11 rounded-full bg-black/50 backdrop-blur-sm flex items-center justify-center text-white",
activePageIndex <= 0 ? "opacity-50" : "",
@@ -51,6 +52,7 @@ export const Carousel = <T,>({ items, renderItem }: CarouselProps<T>) => {
<ArrowLeftIcon className="size-6" />
</button>
<button
type="button"
className={cn(
"size-11 rounded-full bg-black/50 backdrop-blur-sm flex items-center justify-center text-white",
activePageIndex <= 0 ? "opacity-50" : "",
@@ -60,7 +62,7 @@ export const Carousel = <T,>({ items, renderItem }: CarouselProps<T>) => {
<ArrowRightIcon className="size-6" />
</button>
</div>
<div className="absolute top-3 right-3 flex justify-center bg-black mix-blend-multiply bg-opacity-20 backdrop-blur-sm h-6 w-12 items-center rounded-full text-sm font-medium text-white">
<div className="absolute flex items-center justify-center w-12 h-6 text-sm font-medium text-white bg-black rounded-full top-3 right-3 mix-blend-multiply bg-opacity-20 backdrop-blur-sm">
{activePageIndex + 1} / {pages.length}
</div>
</div>

View File

@@ -33,7 +33,7 @@ export function Spinner({
<span
aria-hidden
style={{ display: "contents", visibility: "hidden" }}
// Workaround to use `inert` until https://github.com/facebook/react/pull/24730 is merged.
// biome-ignore lint/correctness/noConstantCondition: Workaround to use `inert` until https://github.com/facebook/react/pull/24730 is merged.
{...{ inert: true ? "" : undefined }}
>
{children}

View File

@@ -2,16 +2,6 @@ export * from "./src/constants";
export * from "./src/delay";
export * from "./src/formater";
export * from "./src/editor";
export * from "./src/nip01";
export * from "./src/nip94";
export * from "./src/notification";
export * from "./src/cn";
export * from "./src/image";
export * from "./src/parser";
export * from "./src/groupBy";
export * from "./src/invoice";
export * from "./src/update";
// Hooks
export * from "./src/hooks/useNetworkStatus";
export * from "./src/hooks/useOpenGraph";

View File

@@ -8,16 +8,15 @@
"access": "public"
},
"dependencies": {
"@tanstack/react-query": "^5.40.0",
"bitcoin-units": "^1.0.0",
"clsx": "^2.1.1",
"dayjs": "^1.11.11",
"light-bolt11-decoder": "^3.1.1",
"nostr-tools": "^2.6.0",
"nostr-tools": "^2.7.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"slate": "^0.103.0",
"slate-react": "^0.104.0"
"slate-react": "^0.105.0"
},
"devDependencies": {
"@lume/tsconfig": "workspace:^",

View File

@@ -1,21 +0,0 @@
export const groupBy = <T>(
array: T[],
predicate: (value: T, index: number, array: T[]) => string,
) =>
array.reduce(
(acc, value, index, array) => {
(acc[predicate(value, index, array)] ||= []).push(value);
return acc;
},
{} as { [key: string]: T[] },
);
export const groupByToMap = <T, Q>(
array: T[],
predicate: (value: T, index: number, array: T[]) => Q,
) =>
array.reduce((map, value, index, array) => {
const key = predicate(value, index, array);
map.get(key)?.push(value) ?? map.set(key, [value]);
return map;
}, new Map<Q, T[]>());

View File

@@ -1,25 +0,0 @@
import { useEffect, useState } from "react";
const getOnLineStatus = () =>
typeof navigator !== "undefined" && typeof navigator.onLine === "boolean"
? navigator.onLine
: true;
export function useNetworkStatus() {
const [status, setStatus] = useState(getOnLineStatus());
const setOnline = () => setStatus(true);
const setOffline = () => setStatus(false);
useEffect(() => {
window.addEventListener("online", setOnline);
window.addEventListener("offline", setOffline);
return () => {
window.removeEventListener("online", setOnline);
window.removeEventListener("offline", setOffline);
};
}, []);
return status;
}

View File

@@ -1,27 +0,0 @@
import type { Opengraph } from "@lume/types";
import { useQuery } from "@tanstack/react-query";
import { invoke } from "@tauri-apps/api/core";
export function useOpenGraph(url: string) {
const { isLoading, isError, data } = useQuery({
queryKey: ["opg", url],
queryFn: async () => {
try {
const res: Opengraph = await invoke("fetch_opg", { url });
return res;
} catch {
throw new Error("fetch preview failed");
}
},
staleTime: Number.POSITIVE_INFINITY,
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
});
return {
isLoading,
isError,
data,
};
}

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