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
This commit is contained in:
46
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
46
.idea/inspectionProfiles/Project_Default.xml
generated
Normal 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>
|
||||||
6
.idea/lume.iml
generated
6
.idea/lume.iml
generated
@@ -3,6 +3,12 @@
|
|||||||
<component name="NewModuleRootManager">
|
<component name="NewModuleRootManager">
|
||||||
<content url="file://$MODULE_DIR$">
|
<content url="file://$MODULE_DIR$">
|
||||||
<sourceFolder url="file://$MODULE_DIR$/src-tauri/src" isTestSource="false" />
|
<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" />
|
<excludeFolder url="file://$MODULE_DIR$/src-tauri/target" />
|
||||||
</content>
|
</content>
|
||||||
<orderEntry type="inheritedJdk" />
|
<orderEntry type="inheritedJdk" />
|
||||||
|
|||||||
@@ -23,23 +23,24 @@
|
|||||||
"@radix-ui/react-switch": "^1.0.3",
|
"@radix-ui/react-switch": "^1.0.3",
|
||||||
"@radix-ui/react-tabs": "^1.0.4",
|
"@radix-ui/react-tabs": "^1.0.4",
|
||||||
"@radix-ui/react-tooltip": "^1.0.7",
|
"@radix-ui/react-tooltip": "^1.0.7",
|
||||||
"@tanstack/query-sync-storage-persister": "^5.40.0",
|
"@tanstack/query-persist-client-core": "^5.45.0",
|
||||||
"@tanstack/react-query": "^5.40.1",
|
"@tanstack/react-query": "^5.45.0",
|
||||||
"@tanstack/react-query-persist-client": "^5.40.1",
|
"@tanstack/react-router": "^1.38.1",
|
||||||
"@tanstack/react-router": "^1.35.3",
|
"embla-carousel-react": "^8.1.5",
|
||||||
"i18next": "^23.11.5",
|
"i18next": "^23.11.5",
|
||||||
"i18next-resources-to-backend": "^1.2.1",
|
"i18next-resources-to-backend": "^1.2.1",
|
||||||
"minidenticons": "^4.2.1",
|
"minidenticons": "^4.2.1",
|
||||||
"nanoid": "^5.0.7",
|
"nanoid": "^5.0.7",
|
||||||
|
"nostr-tools": "^2.7.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-currency-input-field": "^3.8.0",
|
"react-currency-input-field": "^3.8.0",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-hook-form": "^7.51.5",
|
"react-hook-form": "^7.52.0",
|
||||||
"react-hotkeys-hook": "^4.5.0",
|
"react-hotkeys-hook": "^4.5.0",
|
||||||
"react-i18next": "^14.1.2",
|
"react-i18next": "^14.1.2",
|
||||||
"react-string-replace": "^1.1.1",
|
"react-string-replace": "^1.1.1",
|
||||||
"slate": "^0.103.0",
|
"slate": "^0.103.0",
|
||||||
"slate-react": "^0.104.0",
|
"slate-react": "^0.105.0",
|
||||||
"sonner": "^1.5.0",
|
"sonner": "^1.5.0",
|
||||||
"use-debounce": "^10.0.1",
|
"use-debounce": "^10.0.1",
|
||||||
"virtua": "^0.31.0"
|
"virtua": "^0.31.0"
|
||||||
@@ -48,8 +49,8 @@
|
|||||||
"@lume/tailwindcss": "workspace:^",
|
"@lume/tailwindcss": "workspace:^",
|
||||||
"@lume/tsconfig": "workspace:^",
|
"@lume/tsconfig": "workspace:^",
|
||||||
"@lume/types": "workspace:^",
|
"@lume/types": "workspace:^",
|
||||||
"@tanstack/router-devtools": "^1.35.3",
|
"@tanstack/router-devtools": "^1.38.1",
|
||||||
"@tanstack/router-vite-plugin": "^1.35.4",
|
"@tanstack/router-vite-plugin": "^1.38.0",
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@vitejs/plugin-react-swc": "^3.7.0",
|
"@vitejs/plugin-react-swc": "^3.7.0",
|
||||||
@@ -57,7 +58,7 @@
|
|||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.38",
|
||||||
"tailwindcss": "^3.4.4",
|
"tailwindcss": "^3.4.4",
|
||||||
"typescript": "^5.4.5",
|
"typescript": "^5.4.5",
|
||||||
"vite": "^5.2.13",
|
"vite": "^5.3.1",
|
||||||
"vite-plugin-top-level-await": "^1.4.1",
|
"vite-plugin-top-level-await": "^1.4.1",
|
||||||
"vite-tsconfig-paths": "^4.3.2"
|
"vite-tsconfig-paths": "^4.3.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { QueryClient } from "@tanstack/react-query";
|
|
||||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
|
||||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||||
import React, { StrictMode } from "react";
|
import React, { StrictMode } from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
@@ -10,11 +8,8 @@ import i18n from "./locale";
|
|||||||
import { routeTree } from "./router.gen"; // auto generated file
|
import { routeTree } from "./router.gen"; // auto generated file
|
||||||
import { type } from "@tauri-apps/plugin-os";
|
import { type } from "@tauri-apps/plugin-os";
|
||||||
|
|
||||||
const os = await type();
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
const persister = createSyncStoragePersister({
|
const os = await type();
|
||||||
storage: window.localStorage,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set up a Router instance
|
// Set up a Router instance
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
@@ -26,12 +21,9 @@ const router = createRouter({
|
|||||||
Wrap: ({ children }) => {
|
Wrap: ({ children }) => {
|
||||||
return (
|
return (
|
||||||
<I18nextProvider i18n={i18n} defaultNS={"translation"}>
|
<I18nextProvider i18n={i18n} defaultNS={"translation"}>
|
||||||
<PersistQueryClientProvider
|
<QueryClientProvider client={queryClient}>
|
||||||
client={queryClient}
|
|
||||||
persistOptions={{ persister }}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</PersistQueryClientProvider>
|
</QueryClientProvider>
|
||||||
</I18nextProvider>
|
</I18nextProvider>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,53 +2,57 @@ import { CancelIcon, CheckIcon } from "@lume/icons";
|
|||||||
import type { LumeColumn } from "@lume/types";
|
import type { LumeColumn } from "@lume/types";
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { getCurrent } from "@tauri-apps/api/webviewWindow";
|
import { getCurrent } from "@tauri-apps/api/webviewWindow";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
type WindowEvent = {
|
||||||
|
scroll: boolean;
|
||||||
|
resize: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export function Column({
|
export function Column({
|
||||||
column,
|
column,
|
||||||
account,
|
account,
|
||||||
isScroll,
|
|
||||||
isResize,
|
|
||||||
}: {
|
}: {
|
||||||
column: LumeColumn;
|
column: LumeColumn;
|
||||||
account: string;
|
account: string;
|
||||||
isScroll: boolean;
|
|
||||||
isResize: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
const container = useRef<HTMLDivElement>(null);
|
const container = useRef<HTMLDivElement>(null);
|
||||||
const webviewLabel = useMemo(
|
const webviewLabel = `column-${account}_${column.label}`;
|
||||||
() => `column-${account}_${column.label}`,
|
|
||||||
[account],
|
|
||||||
);
|
|
||||||
|
|
||||||
const [isCreated, setIsCreated] = useState(false);
|
const [isCreated, setIsCreated] = useState(false);
|
||||||
|
|
||||||
const repositionWebview = async () => {
|
const repositionWebview = useCallback(async () => {
|
||||||
const newRect = container.current.getBoundingClientRect();
|
const newRect = container.current.getBoundingClientRect();
|
||||||
await invoke("reposition_column", {
|
await invoke("reposition_column", {
|
||||||
label: webviewLabel,
|
label: webviewLabel,
|
||||||
x: newRect.x,
|
x: newRect.x,
|
||||||
y: newRect.y,
|
y: newRect.y,
|
||||||
});
|
});
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const resizeWebview = async () => {
|
const resizeWebview = useCallback(async () => {
|
||||||
const newRect = container.current.getBoundingClientRect();
|
const newRect = container.current.getBoundingClientRect();
|
||||||
await invoke("resize_column", {
|
await invoke("resize_column", {
|
||||||
label: webviewLabel,
|
label: webviewLabel,
|
||||||
width: newRect.width,
|
width: newRect.width,
|
||||||
height: newRect.height,
|
height: newRect.height,
|
||||||
});
|
});
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isCreated) resizeWebview();
|
if (!isCreated) return;
|
||||||
}, [isResize]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const unlisten = listen<WindowEvent>("window", (data) => {
|
||||||
if (isScroll && isCreated) repositionWebview();
|
if (data.payload.scroll) repositionWebview();
|
||||||
}, [isScroll]);
|
if (data.payload.resize) resizeWebview();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unlisten.then((f) => f());
|
||||||
|
};
|
||||||
|
}, [isCreated]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!container?.current) return;
|
if (!container?.current) return;
|
||||||
@@ -78,7 +82,7 @@ export function Column({
|
|||||||
}, [account]);
|
}, [account]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-[440px] shrink-0 p-2">
|
<div className="h-full w-[500px] shrink-0 p-2">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col w-full h-full rounded-xl",
|
"flex flex-col w-full h-full rounded-xl",
|
||||||
@@ -87,9 +91,7 @@ export function Column({
|
|||||||
: "",
|
: "",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{column.label !== "open" ? (
|
<Header label={column.label} name={column.name} />
|
||||||
<Header label={column.label} name={column.name} />
|
|
||||||
) : null}
|
|
||||||
<div ref={container} className="flex-1 w-full h-full" />
|
<div ref={container} className="flex-1 w-full h-full" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -122,10 +124,10 @@ function Header({ label, name }: { label: string; name: string }) {
|
|||||||
}, [title]);
|
}, [title]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-9 w-full flex items-center justify-between shrink-0 px-1">
|
<div className="flex items-center justify-between w-full px-1 h-9 shrink-0">
|
||||||
<div className="size-7" />
|
<div className="size-7" />
|
||||||
<div className="shrink-0 h-9 flex items-center justify-center">
|
<div className="flex items-center justify-center shrink-0 h-9">
|
||||||
<div className="relative flex gap-2 items-center">
|
<div className="relative flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
contentEditable
|
contentEditable
|
||||||
suppressContentEditableWarning={true}
|
suppressContentEditableWarning={true}
|
||||||
@@ -148,7 +150,7 @@ function Header({ label, name }: { label: string; name: string }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => close()}
|
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" />
|
<CancelIcon className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,23 +1,17 @@
|
|||||||
import { ThreadIcon } from "@lume/icons";
|
import { ThreadIcon } from "@lume/icons";
|
||||||
import type { NostrEvent } from "@lume/types";
|
|
||||||
import { Note } from "@/components/note";
|
import { Note } from "@/components/note";
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { LumeEvent } from "@lume/system";
|
import type { LumeEvent } from "@lume/system";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
|
||||||
export function Conversation({
|
export function Conversation({
|
||||||
event,
|
event,
|
||||||
gossip,
|
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
event: NostrEvent;
|
event: LumeEvent;
|
||||||
gossip?: boolean;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const thread = useMemo(
|
const thread = useMemo(() => event.thread, [event]);
|
||||||
() => LumeEvent.getEventThread(event.tags, gossip),
|
|
||||||
[event],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Note.Provider event={event}>
|
<Note.Provider event={event}>
|
||||||
@@ -28,7 +22,7 @@ export function Conversation({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{thread?.root ? <Note.Child eventId={thread?.root} isRoot /> : null}
|
{thread?.root?.id ? <Note.Child event={thread?.root} isRoot /> : null}
|
||||||
<div className="flex items-center gap-2 px-3">
|
<div className="flex items-center gap-2 px-3">
|
||||||
<div className="inline-flex items-center gap-1.5 shrink-0 text-sm font-medium text-neutral-600 dark:text-neutral-400">
|
<div className="inline-flex items-center gap-1.5 shrink-0 text-sm font-medium text-neutral-600 dark:text-neutral-400">
|
||||||
<ThreadIcon className="size-4" />
|
<ThreadIcon className="size-4" />
|
||||||
@@ -36,15 +30,15 @@ export function Conversation({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1 h-px bg-neutral-100 dark:bg-white/5" />
|
<div className="flex-1 h-px bg-neutral-100 dark:bg-white/5" />
|
||||||
</div>
|
</div>
|
||||||
{thread?.reply ? <Note.Child eventId={thread?.reply} /> : null}
|
{thread?.reply?.id ? <Note.Child event={thread?.reply} /> : null}
|
||||||
<div>
|
<div>
|
||||||
<div className="px-3 h-14 flex items-center justify-between">
|
<div className="flex items-center justify-between px-3 h-14">
|
||||||
<Note.User />
|
<Note.User />
|
||||||
</div>
|
</div>
|
||||||
<Note.Content className="px-3" />
|
<Note.Content className="px-3" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center h-14 px-3">
|
<div className="flex items-center px-3 h-14">
|
||||||
<Note.Open />
|
<Note.Open />
|
||||||
</div>
|
</div>
|
||||||
</Note.Root>
|
</Note.Root>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -76,7 +76,7 @@ export function NoteRepost({ large = false }: { large?: boolean }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => repost()}
|
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"
|
className="inline-flex items-center gap-2 px-3 text-sm font-medium text-white rounded-lg h-9 hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||||
>
|
>
|
||||||
<RepostIcon className="size-4" />
|
<RepostIcon className="size-4" />
|
||||||
{t("note.buttons.repost")}
|
{t("note.buttons.repost")}
|
||||||
@@ -85,8 +85,8 @@ export function NoteRepost({ large = false }: { large?: boolean }) {
|
|||||||
<DropdownMenu.Item asChild>
|
<DropdownMenu.Item asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => LumeWindow.openEditor(event.id, true)}
|
onClick={() => LumeWindow.openEditor(null, event.id)}
|
||||||
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
className="inline-flex items-center gap-2 px-3 text-sm font-medium text-white rounded-lg h-9 hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||||
>
|
>
|
||||||
<QuoteIcon className="size-4" />
|
<QuoteIcon className="size-4" />
|
||||||
{t("note.buttons.quote")}
|
{t("note.buttons.quote")}
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
import { ZapIcon } from "@lume/icons";
|
import { ZapIcon } from "@lume/icons";
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
|
||||||
import { useNoteContext } from "../provider";
|
import { useNoteContext } from "../provider";
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { LumeWindow } from "@lume/system";
|
import { LumeWindow } from "@lume/system";
|
||||||
|
|
||||||
export function NoteZap({ large = false }: { large?: boolean }) {
|
export function NoteZap({ large = false }: { large?: boolean }) {
|
||||||
const event = useNoteContext();
|
const event = useNoteContext();
|
||||||
const { settings } = useRouteContext({ strict: false });
|
|
||||||
|
|
||||||
if (!settings.zap) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -2,32 +2,33 @@ import { useEvent } from "@lume/system";
|
|||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { Note } from ".";
|
import { Note } from ".";
|
||||||
import { InfoIcon } from "@lume/icons";
|
import { InfoIcon } from "@lume/icons";
|
||||||
|
import type { EventTag } from "@lume/types";
|
||||||
|
|
||||||
export function NoteChild({
|
export function NoteChild({
|
||||||
eventId,
|
event,
|
||||||
isRoot,
|
isRoot,
|
||||||
}: {
|
}: {
|
||||||
eventId: string;
|
event: EventTag;
|
||||||
isRoot?: boolean;
|
isRoot?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { isLoading, isError, data } = useEvent(eventId);
|
const { isLoading, isError, data } = useEvent(event.id, event.relayHint);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="pt-3 px-3 flex items-center gap-2">
|
<div className="flex items-center gap-2 px-3 pt-3">
|
||||||
<div className="size-8 shrink-0 rounded-full bg-neutral-200 dark:bg-neutral-800 animate-pulse" />
|
<div className="rounded-full size-8 shrink-0 bg-neutral-200 dark:bg-neutral-800 animate-pulse" />
|
||||||
<div className="animate-pulse rounded-md h-4 w-2/3 bg-neutral-200 dark:bg-neutral-800" />
|
<div className="w-2/3 h-4 rounded-md animate-pulse bg-neutral-200 dark:bg-neutral-800" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isError || !data) {
|
if (isError || !data) {
|
||||||
return (
|
return (
|
||||||
<div className="pt-3 px-3 flex items-center gap-2">
|
<div className="flex items-center gap-2 px-3 pt-3">
|
||||||
<div className="size-8 shrink-0 rounded-full bg-red-500 text-white inline-flex items-center justify-center">
|
<div className="inline-flex items-center justify-center text-white bg-red-500 rounded-full size-8 shrink-0">
|
||||||
<InfoIcon className="size-5" />
|
<InfoIcon className="size-5" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-red-500 text-sm">
|
<p className="text-sm text-red-500">
|
||||||
Event not found with your current relay set
|
Event not found with your current relay set
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -37,7 +38,7 @@ export function NoteChild({
|
|||||||
return (
|
return (
|
||||||
<Note.Provider event={data}>
|
<Note.Provider event={data}>
|
||||||
<Note.Root className={cn(isRoot ? "mb-3" : "")}>
|
<Note.Root className={cn(isRoot ? "mb-3" : "")}>
|
||||||
<div className="h-14 px-3 flex items-center justify-between">
|
<div className="flex items-center justify-between px-3 h-14">
|
||||||
<Note.User />
|
<Note.User />
|
||||||
</div>
|
</div>
|
||||||
<Note.Content className="px-3" />
|
<Note.Content className="px-3" />
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export function NoteContent({
|
|||||||
href={match}
|
href={match}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="text-blue-500 line-clamp-1 hover:text-blue-600"
|
className="inline text-blue-500 hover:text-blue-600"
|
||||||
>
|
>
|
||||||
{match}
|
{match}
|
||||||
</a>
|
</a>
|
||||||
@@ -92,7 +92,7 @@ export function NoteContent({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"select-text text-pretty content-break overflow-hidden",
|
"select-text text-pretty content-break overflow-hidden",
|
||||||
event.content.length > 420 ? "max-h-[250px] gradient-mask-b-0" : "",
|
event.content.length > 620 ? "max-h-[250px] gradient-mask-b-0" : "",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { NoteActivity } from "./activity";
|
|
||||||
import { NoteOpenThread } from "./buttons/open";
|
import { NoteOpenThread } from "./buttons/open";
|
||||||
import { NoteReply } from "./buttons/reply";
|
import { NoteReply } from "./buttons/reply";
|
||||||
import { NoteRepost } from "./buttons/repost";
|
import { NoteRepost } from "./buttons/repost";
|
||||||
@@ -23,5 +22,4 @@ export const Note = {
|
|||||||
Zap: NoteZap,
|
Zap: NoteZap,
|
||||||
Open: NoteOpenThread,
|
Open: NoteOpenThread,
|
||||||
Child: NoteChild,
|
Child: NoteChild,
|
||||||
Activity: NoteActivity,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
export function Hashtag({ tag }: { tag: string }) {
|
export function Hashtag({ tag }: { tag: string }) {
|
||||||
return (
|
return (
|
||||||
<button
|
<span className="leading-normal break-all cursor-default group text-start">
|
||||||
type="button"
|
|
||||||
className="break-all cursor-default leading-normal group text-start"
|
|
||||||
>
|
|
||||||
<span className="text-blue-500">#</span>
|
<span className="text-blue-500">#</span>
|
||||||
<span className="underline-offset-1 underline decoration-2 decoration-blue-200 dark:decoration-blue-800 group-hover:decoration-blue-500">
|
<span className="underline underline-offset-1 decoration-2 decoration-blue-200 dark:decoration-blue-800 group-hover:decoration-blue-500">
|
||||||
{tag.replace("#", "")}
|
{tag.replace("#", "")}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export function MentionNote({
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="mt-2 w-full flex h-20 items-center justify-center rounded-xl border border-black/10 dark:border-white/10">
|
<div className="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" />
|
<Spinner className="size-5" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -25,18 +25,18 @@ export function MentionNote({
|
|||||||
|
|
||||||
if (isError || !data) {
|
if (isError || !data) {
|
||||||
return (
|
return (
|
||||||
<div className="mt-2 w-full rounded-xl border border-black/10 p-3 dark:border-white/10">
|
<div className="w-full p-3 mt-2 border rounded-xl border-black/10 dark:border-white/10">
|
||||||
{t("note.error")}
|
{t("note.error")}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-2 flex w-full cursor-default flex-col rounded-xl border border-black/10 dark:border-white/10">
|
<div className="flex flex-col w-full border rounded-lg cursor-default border-black/10 dark:border-white/10">
|
||||||
<User.Provider pubkey={data.pubkey}>
|
<User.Provider pubkey={data.pubkey}>
|
||||||
<User.Root className="flex h-12 items-center gap-2 px-3">
|
<User.Root className="flex items-center gap-2 px-3 h-11">
|
||||||
<User.Avatar className="size-6 shrink-0 rounded-full object-cover" />
|
<User.Avatar className="object-cover rounded-full size-6 shrink-0" />
|
||||||
<div className="inline-flex flex-1 items-center gap-2">
|
<div className="inline-flex items-center flex-1 gap-2">
|
||||||
<User.Name className="font-semibold text-neutral-900 dark:text-neutral-100" />
|
<User.Name className="font-semibold text-neutral-900 dark:text-neutral-100" />
|
||||||
<span className="text-neutral-600 dark:text-neutral-400">·</span>
|
<span className="text-neutral-600 dark:text-neutral-400">·</span>
|
||||||
<User.Time
|
<User.Time
|
||||||
@@ -49,20 +49,20 @@ export function MentionNote({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-3 select-text whitespace-normal text-pretty content-break leading-normal",
|
"px-3 select-text whitespace-normal text-pretty content-break leading-normal",
|
||||||
data.content.length > 100 ? "max-h-[150px] gradient-mask-b-0" : "",
|
data.content.length > 400 ? "max-h-[150px] gradient-mask-b-0" : "",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{data.content}
|
{data.content}
|
||||||
</div>
|
</div>
|
||||||
{openable ? (
|
{openable ? (
|
||||||
<div className="flex h-14 items-center justify-end px-3">
|
<div className="flex items-center justify-end px-2 h-11">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
LumeWindow.openEvent(data);
|
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
|
View post
|
||||||
<LinkIcon className="size-4" />
|
<LinkIcon className="size-4" />
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export function NoteMenu() {
|
|||||||
<DropdownMenu.Trigger asChild>
|
<DropdownMenu.Trigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="group inline-flex size-7 items-center justify-center text-neutral-600 dark:text-neutral-400"
|
className="inline-flex items-center justify-center group size-7 text-neutral-600 dark:text-neutral-400"
|
||||||
>
|
>
|
||||||
<HorizontalDotsIcon className="size-5" />
|
<HorizontalDotsIcon className="size-5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -41,7 +41,7 @@ export function NoteMenu() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => LumeWindow.openEvent(event)}
|
onClick={() => LumeWindow.openEvent(event)}
|
||||||
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
className="inline-flex items-center gap-2 px-3 text-sm font-medium text-white rounded-lg h-9 hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||||
>
|
>
|
||||||
{t("note.menu.viewThread")}
|
{t("note.menu.viewThread")}
|
||||||
</button>
|
</button>
|
||||||
@@ -50,7 +50,7 @@ export function NoteMenu() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => copyLink()}
|
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"
|
className="inline-flex items-center gap-2 px-3 text-sm font-medium text-white rounded-lg h-9 hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||||
>
|
>
|
||||||
{t("note.menu.copyLink")}
|
{t("note.menu.copyLink")}
|
||||||
</button>
|
</button>
|
||||||
@@ -59,7 +59,7 @@ export function NoteMenu() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => copyID()}
|
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"
|
className="inline-flex items-center gap-2 px-3 text-sm font-medium text-white rounded-lg h-9 hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||||
>
|
>
|
||||||
{t("note.menu.copyNoteId")}
|
{t("note.menu.copyNoteId")}
|
||||||
</button>
|
</button>
|
||||||
@@ -68,25 +68,26 @@ export function NoteMenu() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => copyNpub()}
|
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"
|
className="inline-flex items-center gap-2 px-3 text-sm font-medium text-white rounded-lg h-9 hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||||
>
|
>
|
||||||
{t("note.menu.copyAuthorId")}
|
{t("note.menu.copyAuthorId")}
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
<DropdownMenu.Item asChild>
|
<DropdownMenu.Item asChild>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => LumeWindow.openProfile(event.pubkey)}
|
onClick={() => LumeWindow.openProfile(event.pubkey)}
|
||||||
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
className="inline-flex items-center gap-2 px-3 text-sm font-medium text-white rounded-lg h-9 hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||||
>
|
>
|
||||||
{t("note.menu.viewAuthor")}
|
{t("note.menu.viewAuthor")}
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
<DropdownMenu.Separator className="my-1 h-px bg-neutral-900 dark:bg-neutral-100" />
|
<DropdownMenu.Separator className="h-px my-1 bg-neutral-900 dark:bg-neutral-100" />
|
||||||
<DropdownMenu.Item asChild>
|
<DropdownMenu.Item asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => copyRaw()}
|
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"
|
className="inline-flex items-center gap-2 px-3 text-sm font-medium text-white rounded-lg h-9 hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||||
>
|
>
|
||||||
{t("note.menu.copyRaw")}
|
{t("note.menu.copyRaw")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function ImagePreview({ url }: { url: string }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group relative my-1">
|
<div className="relative my-1 group">
|
||||||
<img
|
<img
|
||||||
src={url}
|
src={url}
|
||||||
alt={url}
|
alt={url}
|
||||||
@@ -34,6 +34,7 @@ export function ImagePreview({ url }: { url: string }) {
|
|||||||
style={{ contentVisibility: "auto" }}
|
style={{ contentVisibility: "auto" }}
|
||||||
className="max-h-[600px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
className="max-h-[600px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||||
onClick={() => open(url)}
|
onClick={() => open(url)}
|
||||||
|
onKeyDown={() => open(url)}
|
||||||
onError={({ currentTarget }) => {
|
onError={({ currentTarget }) => {
|
||||||
currentTarget.onerror = null;
|
currentTarget.onerror = null;
|
||||||
currentTarget.src = "/404.jpg";
|
currentTarget.src = "/404.jpg";
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function Images({ urls }: { urls: string[] }) {
|
|||||||
|
|
||||||
if (urls.length === 1) {
|
if (urls.length === 1) {
|
||||||
return (
|
return (
|
||||||
<div className="group px-3">
|
<div className="px-3 group">
|
||||||
<img
|
<img
|
||||||
src={urls[0]}
|
src={urls[0]}
|
||||||
alt={urls[0]}
|
alt={urls[0]}
|
||||||
@@ -36,6 +36,7 @@ export function Images({ urls }: { urls: string[] }) {
|
|||||||
style={{ contentVisibility: "auto" }}
|
style={{ contentVisibility: "auto" }}
|
||||||
className="max-h-[400px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
className="max-h-[400px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||||
onClick={() => open(urls[0])}
|
onClick={() => open(urls[0])}
|
||||||
|
onKeyDown={() => open(urls[0])}
|
||||||
onError={({ currentTarget }) => {
|
onError={({ currentTarget }) => {
|
||||||
currentTarget.onerror = null;
|
currentTarget.onerror = null;
|
||||||
currentTarget.src = "/404.jpg";
|
currentTarget.src = "/404.jpg";
|
||||||
@@ -56,8 +57,9 @@ export function Images({ urls }: { urls: string[] }) {
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
style={{ contentVisibility: "auto" }}
|
style={{ contentVisibility: "auto" }}
|
||||||
className="w-full h-full object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
className="object-cover w-full h-full rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||||
onClick={() => open(item)}
|
onClick={() => open(item)}
|
||||||
|
onKeyDown={() => open(item)}
|
||||||
onError={({ currentTarget }) => {
|
onError={({ currentTarget }) => {
|
||||||
currentTarget.onerror = null;
|
currentTarget.onerror = null;
|
||||||
currentTarget.src = "/404.jpg";
|
currentTarget.src = "/404.jpg";
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
export function VideoPreview({ url }: { url: string }) {
|
export function VideoPreview({ url }: { url: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="my-1 overflow-hidden rounded-xl">
|
<div className="my-1">
|
||||||
<video
|
<video
|
||||||
className="h-auto w-full bg-neutral-100 text-sm dark:bg-neutral-900"
|
className="max-h-[600px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||||
controls
|
controls
|
||||||
muted
|
muted
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { LumeEvent } from "@lume/system";
|
import type { LumeEvent } from "@lume/system";
|
||||||
import type { NostrEvent } from "@lume/types";
|
|
||||||
import { type ReactNode, createContext, useContext } from "react";
|
import { type ReactNode, createContext, useContext } from "react";
|
||||||
|
|
||||||
const NoteContext = createContext<LumeEvent>(null);
|
const NoteContext = createContext<LumeEvent>(null);
|
||||||
@@ -8,14 +7,10 @@ export function NoteProvider({
|
|||||||
event,
|
event,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
event: NostrEvent;
|
event: LumeEvent;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const lumeEvent = new LumeEvent(event);
|
return <NoteContext.Provider value={event}>{children}</NoteContext.Provider>;
|
||||||
|
|
||||||
return (
|
|
||||||
<NoteContext.Provider value={lumeEvent}>{children}</NoteContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useNoteContext() {
|
export function useNoteContext() {
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ export function NoteUser({ className }: { className?: string }) {
|
|||||||
>
|
>
|
||||||
<div className="flex w-full gap-2">
|
<div className="flex w-full gap-2">
|
||||||
<HoverCard.Trigger className="shrink-0">
|
<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>
|
</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">
|
<div className="flex items-center gap-1">
|
||||||
<User.Name className="font-semibold text-neutral-950 dark:text-neutral-50" />
|
<User.Name className="font-semibold text-neutral-950 dark:text-neutral-50" />
|
||||||
<User.NIP05 />
|
<User.NIP05 />
|
||||||
@@ -37,16 +37,17 @@ export function NoteUser({ className }: { className?: string }) {
|
|||||||
side="right"
|
side="right"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-2">
|
<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="flex flex-col gap-2">
|
||||||
<div className="inline-flex items-center gap-1">
|
<div className="inline-flex items-center gap-1">
|
||||||
<User.Name className="font-semibold leading-tight text-white dark:text-neutral-900" />
|
<User.Name className="font-semibold leading-tight text-white dark:text-neutral-900" />
|
||||||
<User.NIP05 />
|
<User.NIP05 />
|
||||||
</div>
|
</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
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => LumeWindow.openProfile(event.pubkey)}
|
onClick={() => LumeWindow.openProfile(event.pubkey)}
|
||||||
className="mt-2 inline-flex h-9 w-full items-center justify-center rounded-lg bg-white text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-100 dark:text-neutral-900 dark:hover:bg-neutral-200"
|
className="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
|
View profile
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,19 +1,15 @@
|
|||||||
import { QuoteIcon } from "@lume/icons";
|
import { QuoteIcon } from "@lume/icons";
|
||||||
import type { NostrEvent } from "@lume/types";
|
|
||||||
import { Note } from "@/components/note";
|
import { Note } from "@/components/note";
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
|
import type { LumeEvent } from "@lume/system";
|
||||||
|
|
||||||
export function Quote({
|
export function Quote({
|
||||||
event,
|
event,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
event: NostrEvent;
|
event: LumeEvent;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const quoteEventId = event.tags.find(
|
|
||||||
(tag) => tag[0] === "q" || tag[3] === "mention",
|
|
||||||
)?.[1];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Note.Provider event={event}>
|
<Note.Provider event={event}>
|
||||||
<Note.Root
|
<Note.Root
|
||||||
@@ -23,7 +19,7 @@ export function Quote({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<Note.Child eventId={quoteEventId} isRoot />
|
<Note.Child event={event.quote} isRoot />
|
||||||
<div className="flex items-center gap-2 px-3">
|
<div className="flex items-center gap-2 px-3">
|
||||||
<div className="inline-flex items-center gap-1.5 shrink-0 text-sm font-medium text-neutral-600 dark:text-neutral-400">
|
<div className="inline-flex items-center gap-1.5 shrink-0 text-sm font-medium text-neutral-600 dark:text-neutral-400">
|
||||||
<QuoteIcon className="size-4" />
|
<QuoteIcon className="size-4" />
|
||||||
@@ -32,13 +28,13 @@ export function Quote({
|
|||||||
<div className="flex-1 h-px bg-neutral-100 dark:bg-white/5" />
|
<div className="flex-1 h-px bg-neutral-100 dark:bg-white/5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="px-3 h-14 flex items-center justify-between">
|
<div className="flex items-center justify-between px-3 h-14">
|
||||||
<Note.User />
|
<Note.User />
|
||||||
</div>
|
</div>
|
||||||
<Note.Content className="px-3" quote={false} clean />
|
<Note.Content className="px-3" quote={false} clean />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center h-14 px-3">
|
<div className="flex items-center px-3 h-14">
|
||||||
<Note.Open />
|
<Note.Open />
|
||||||
</div>
|
</div>
|
||||||
</Note.Root>
|
</Note.Root>
|
||||||
|
|||||||
@@ -3,73 +3,75 @@ import { Note } from "@/components/note";
|
|||||||
import { User } from "@/components/user";
|
import { User } from "@/components/user";
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import type { NostrEvent } from "@lume/types";
|
import { type LumeEvent, NostrQuery } from "@lume/system";
|
||||||
import { NostrQuery } from "@lume/system";
|
|
||||||
|
|
||||||
export function RepostNote({
|
export function RepostNote({
|
||||||
event,
|
event,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
event: NostrEvent;
|
event: LumeEvent;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const {
|
const { isLoading, isError, data } = useQuery({
|
||||||
isLoading,
|
queryKey: ["event", event.repostId],
|
||||||
isError,
|
|
||||||
data: repostEvent,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["repost", event.id],
|
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try {
|
try {
|
||||||
const id = event.tags.find((el) => el[0] === "e")[1];
|
const data = await NostrQuery.getRepostEvent(event);
|
||||||
const repostEvent = await NostrQuery.getEvent(id);
|
return data;
|
||||||
|
|
||||||
return repostEvent;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(e);
|
throw new Error(e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnMount: false,
|
refetchOnMount: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
staleTime: Number.POSITIVE_INFINITY,
|
||||||
|
retry: 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Note.Root
|
<Note.Root
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl mb-3 shadow-primary dark:ring-1 ring-neutral-800/50",
|
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<User.Provider pubkey={event.pubkey}>
|
|
||||||
<User.Root className="flex items-center gap-2 px-3 py-3 border-b border-neutral-100 dark:border-neutral-800/50 rounded-t-xl">
|
|
||||||
<div className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
|
|
||||||
Reposted by
|
|
||||||
</div>
|
|
||||||
<User.Avatar className="object-cover rounded-full size-6 shrink-0 ring-1 ring-neutral-200/50 dark:ring-neutral-800/50" />
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center h-20 gap-2">
|
<div className="flex items-center justify-center h-20 gap-2">
|
||||||
<Spinner />
|
<Spinner />
|
||||||
Loading event...
|
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||||
|
Loading event...
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : isError || !repostEvent ? (
|
) : isError || !data ? (
|
||||||
<div className="flex items-center justify-center h-20">
|
<div className="flex items-center justify-center h-20">
|
||||||
Event not found within your current relay set
|
Event not found within your current relay set
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Note.Provider event={repostEvent}>
|
<Note.Provider event={data}>
|
||||||
<Note.Root>
|
<Note.Root>
|
||||||
<div className="flex items-center justify-between px-3 h-14">
|
<div className="flex items-center justify-between px-3 h-14">
|
||||||
<Note.User />
|
<Note.User />
|
||||||
<Note.Menu />
|
<Note.Menu />
|
||||||
</div>
|
</div>
|
||||||
<Note.Content className="px-3" />
|
<Note.Content className="px-3" />
|
||||||
<div className="flex items-center gap-4 px-3 mt-3 h-14">
|
<div className="flex items-center justify-between px-3 mt-3 h-14">
|
||||||
<Note.Open />
|
<div className="inline-flex items-center gap-3">
|
||||||
<Note.Reply />
|
<Note.Open />
|
||||||
<Note.Repost />
|
<Note.Reply />
|
||||||
<Note.Zap />
|
<Note.Repost />
|
||||||
|
<Note.Zap />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<User.Provider pubkey={event.pubkey}>
|
||||||
|
<User.Root className="flex items-center gap-2">
|
||||||
|
<div className="text-sm font-medium text-neutral-800 dark:text-neutral-200">
|
||||||
|
Reposted by
|
||||||
|
</div>
|
||||||
|
<User.Avatar className="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>
|
</div>
|
||||||
</Note.Root>
|
</Note.Root>
|
||||||
</Note.Provider>
|
</Note.Provider>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import type { NostrEvent } from "@lume/types";
|
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { Note } from "@/components/note";
|
import { Note } from "@/components/note";
|
||||||
|
import type { LumeEvent } from "@lume/system";
|
||||||
|
|
||||||
export function TextNote({
|
export function TextNote({
|
||||||
event,
|
event,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
event: NostrEvent;
|
event: LumeEvent;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -17,12 +17,12 @@ export function TextNote({
|
|||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="px-3 h-14 flex items-center justify-between">
|
<div className="flex items-center justify-between px-3 h-14">
|
||||||
<Note.User />
|
<Note.User />
|
||||||
<Note.Menu />
|
<Note.Menu />
|
||||||
</div>
|
</div>
|
||||||
<Note.Content className="px-3" />
|
<Note.Content className="px-3" />
|
||||||
<div className="mt-3 flex items-center gap-4 h-14 px-3">
|
<div className="flex items-center gap-4 px-3 mt-3 h-14">
|
||||||
<Note.Open />
|
<Note.Open />
|
||||||
<Note.Reply />
|
<Note.Reply />
|
||||||
<Note.Repost />
|
<Note.Repost />
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Spinner } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import { useUserContext } from "./provider";
|
import { useUserContext } from "./provider";
|
||||||
import { NostrAccount } from "@lume/system";
|
import { NostrAccount } from "@lume/system";
|
||||||
@@ -14,34 +13,30 @@ export function UserFollowButton({
|
|||||||
}) {
|
}) {
|
||||||
const user = useUserContext();
|
const user = useUserContext();
|
||||||
|
|
||||||
const [t] = useTranslation();
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [followed, setFollowed] = useState(false);
|
const [followed, setFollowed] = useState(false);
|
||||||
|
|
||||||
const toggleFollow = async () => {
|
const toggleFollow = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
if (!followed) {
|
|
||||||
const add = await NostrAccount.follow(user.pubkey, user.profile?.name);
|
const toggle = await NostrAccount.toggleContact(user.pubkey);
|
||||||
if (add) setFollowed(true);
|
|
||||||
} else {
|
if (toggle) {
|
||||||
const remove = await NostrAccount.unfollow(user.pubkey);
|
setFollowed((prev) => !prev);
|
||||||
if (remove) setFollowed(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
setLoading(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function status() {
|
let mounted = true;
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const contacts = await NostrAccount.getContactList();
|
NostrAccount.checkContact(user.pubkey).then((status) => {
|
||||||
if (contacts?.includes(user.pubkey)) {
|
if (mounted) setFollowed(status);
|
||||||
setFollowed(true);
|
});
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(false);
|
return () => {
|
||||||
}
|
mounted = false;
|
||||||
status();
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -55,10 +50,10 @@ export function UserFollowButton({
|
|||||||
<Spinner className="size-4" />
|
<Spinner className="size-4" />
|
||||||
) : followed ? (
|
) : followed ? (
|
||||||
!simple ? (
|
!simple ? (
|
||||||
t("user.unfollow")
|
"Unfollow"
|
||||||
) : null
|
) : null
|
||||||
) : (
|
) : (
|
||||||
t("user.follow")
|
"Follow"
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,19 +4,32 @@ import * as Tooltip from "@radix-ui/react-tooltip";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useUserContext } from "./provider";
|
import { useUserContext } from "./provider";
|
||||||
import { NostrQuery } from "@lume/system";
|
import { NostrQuery } from "@lume/system";
|
||||||
|
import { experimental_createPersister } from "@tanstack/query-persist-client-core";
|
||||||
|
|
||||||
export function UserNip05() {
|
export function UserNip05() {
|
||||||
const user = useUserContext();
|
const user = useUserContext();
|
||||||
const { isLoading, data: verified } = useQuery({
|
const { isLoading, data: verified } = useQuery({
|
||||||
queryKey: ["nip05", user?.pubkey],
|
queryKey: ["nip05", user?.pubkey],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
|
if (!user.profile?.nip05?.length) return false;
|
||||||
|
|
||||||
const verify = await NostrQuery.verifyNip05(
|
const verify = await NostrQuery.verifyNip05(
|
||||||
user.pubkey,
|
user.pubkey,
|
||||||
user.profile?.nip05,
|
user.profile?.nip05,
|
||||||
);
|
);
|
||||||
|
|
||||||
return verify;
|
return verify;
|
||||||
},
|
},
|
||||||
enabled: !!user.profile?.nip05,
|
enabled: !!user.profile?.nip05,
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
staleTime: Number.POSITIVE_INFINITY,
|
||||||
|
retry: false,
|
||||||
|
persister: experimental_createPersister({
|
||||||
|
storage: localStorage,
|
||||||
|
maxAge: 1000 * 60 * 60 * 72, // 72 hours
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user.profile?.nip05?.length) return;
|
if (!user.profile?.nip05?.length) return;
|
||||||
@@ -26,7 +39,7 @@ export function UserNip05() {
|
|||||||
<Tooltip.Root delayDuration={150}>
|
<Tooltip.Root delayDuration={150}>
|
||||||
<Tooltip.Trigger>
|
<Tooltip.Trigger>
|
||||||
{!isLoading && verified ? (
|
{!isLoading && verified ? (
|
||||||
<VerifiedIcon className="size-4 text-teal-500" />
|
<VerifiedIcon className="text-teal-500 size-4" />
|
||||||
) : null}
|
) : null}
|
||||||
</Tooltip.Trigger>
|
</Tooltip.Trigger>
|
||||||
<Tooltip.Portal>
|
<Tooltip.Portal>
|
||||||
|
|||||||
@@ -1,91 +1,68 @@
|
|||||||
import { Column } from "@/components/column";
|
import { Column } from "@/components/column";
|
||||||
import { Toolbar } from "@/components/toolbar";
|
import { Toolbar } from "@/components/toolbar";
|
||||||
import { ArrowLeftIcon, ArrowRightIcon } from "@lume/icons";
|
import { ArrowLeftIcon, ArrowRightIcon, PlusSquareIcon } from "@lume/icons";
|
||||||
import { NostrQuery } from "@lume/system";
|
import { NostrQuery } from "@lume/system";
|
||||||
import type { EventColumns, LumeColumn } from "@lume/types";
|
import type { EventColumns, LumeColumn } from "@lume/types";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { getCurrent } from "@tauri-apps/api/window";
|
import { getCurrent } from "@tauri-apps/api/window";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useDebouncedCallback } from "use-debounce";
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
import { VList, type VListHandle } from "virtua";
|
import useEmblaCarousel from "embla-carousel-react";
|
||||||
|
|
||||||
export const Route = createFileRoute("/$account/home")({
|
export const Route = createFileRoute("/$account/home")({
|
||||||
loader: async () => {
|
loader: async () => {
|
||||||
const columns = await NostrQuery.getColumns();
|
const columns = await NostrQuery.getColumns();
|
||||||
return columns;
|
return columns;
|
||||||
},
|
},
|
||||||
gcTime: 0,
|
|
||||||
shouldReload: false,
|
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const { account } = Route.useParams();
|
const { account } = Route.useParams();
|
||||||
const initialColumnList = Route.useLoaderData();
|
const initialColumnList = Route.useLoaderData();
|
||||||
const vlistRef = useRef<VListHandle>(null);
|
|
||||||
|
|
||||||
const [columns, setColumns] = useState<LumeColumn[]>([]);
|
const [columns, setColumns] = useState<LumeColumn[]>([]);
|
||||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
const [emblaRef, emblaApi] = useEmblaCarousel({
|
||||||
const [isScroll, setIsScroll] = useState(false);
|
watchDrag: false,
|
||||||
const [isResize, setIsResize] = useState(false);
|
loop: true,
|
||||||
|
});
|
||||||
|
|
||||||
const reset = () => {
|
const scrollPrev = useCallback(() => {
|
||||||
setColumns(null);
|
if (emblaApi) emblaApi.scrollPrev(true);
|
||||||
setSelectedIndex(-1);
|
}, [emblaApi]);
|
||||||
};
|
|
||||||
|
|
||||||
const goLeft = () => {
|
const scrollNext = useCallback(() => {
|
||||||
const prevIndex = Math.max(selectedIndex - 1, 0);
|
if (emblaApi) emblaApi.scrollNext(true);
|
||||||
setSelectedIndex(prevIndex);
|
}, [emblaApi]);
|
||||||
vlistRef.current.scrollToIndex(prevIndex, {
|
|
||||||
align: "center",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const goRight = () => {
|
const emitScrollEvent = useCallback(() => {
|
||||||
const nextIndex = Math.min(selectedIndex + 1, columns.length - 1);
|
getCurrent().emit("window", { scroll: true });
|
||||||
setSelectedIndex(nextIndex);
|
}, []);
|
||||||
vlistRef.current.scrollToIndex(nextIndex, {
|
|
||||||
align: "center",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const add = useDebouncedCallback((column: LumeColumn) => {
|
const emitResizeEvent = useCallback(() => {
|
||||||
// update col label
|
getCurrent().emit("window", { resize: true });
|
||||||
column.label = `${column.label}-${nanoid()}`;
|
}, []);
|
||||||
|
|
||||||
// create new cols
|
const openLumeStore = useDebouncedCallback(async () => {
|
||||||
const cols = [...columns];
|
await getCurrent().emit("columns", {
|
||||||
const openColIndex = cols.findIndex((col) => col.label === "open");
|
type: "add",
|
||||||
const newCols = [
|
column: {
|
||||||
...cols.slice(0, openColIndex),
|
label: "store",
|
||||||
column,
|
name: "Store",
|
||||||
...cols.slice(openColIndex),
|
content: "/store/official",
|
||||||
];
|
},
|
||||||
|
|
||||||
setColumns(newCols);
|
|
||||||
setSelectedIndex(newCols.length);
|
|
||||||
setIsScroll(true);
|
|
||||||
|
|
||||||
// scroll to the newest column
|
|
||||||
vlistRef.current.scrollToIndex(newCols.length - 1, {
|
|
||||||
align: "center",
|
|
||||||
});
|
});
|
||||||
}, 150);
|
}, 150);
|
||||||
|
|
||||||
|
const add = useDebouncedCallback((column: LumeColumn) => {
|
||||||
|
column.label = `${column.label}-${nanoid()}`; // update col label
|
||||||
|
setColumns((prev) => [...prev, column]);
|
||||||
|
}, 150);
|
||||||
|
|
||||||
const remove = useDebouncedCallback((label: string) => {
|
const remove = useDebouncedCallback((label: string) => {
|
||||||
const newCols = columns.filter((t) => t.label !== label);
|
setColumns((prev) => prev.filter((t) => t.label !== label));
|
||||||
|
|
||||||
setColumns(newCols);
|
|
||||||
setSelectedIndex(newCols.length);
|
|
||||||
setIsScroll(true);
|
|
||||||
|
|
||||||
// scroll to the first column
|
|
||||||
vlistRef.current.scrollToIndex(newCols.length - 1, {
|
|
||||||
align: "start",
|
|
||||||
});
|
|
||||||
}, 150);
|
}, 150);
|
||||||
|
|
||||||
const updateName = useDebouncedCallback((label: string, title: string) => {
|
const updateName = useDebouncedCallback((label: string, title: string) => {
|
||||||
@@ -100,24 +77,46 @@ function Screen() {
|
|||||||
setColumns(newCols);
|
setColumns(newCols);
|
||||||
}, 150);
|
}, 150);
|
||||||
|
|
||||||
const startResize = useDebouncedCallback(
|
const reset = useDebouncedCallback(() => setColumns([]), 150);
|
||||||
() => setIsResize((prev) => !prev),
|
|
||||||
150,
|
const handleKeyDown = useDebouncedCallback((event) => {
|
||||||
);
|
if (event.defaultPrevented) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (event.code) {
|
||||||
|
case "ArrowLeft":
|
||||||
|
if (emblaApi) emblaApi.scrollPrev(true);
|
||||||
|
break;
|
||||||
|
case "ArrowRight":
|
||||||
|
if (emblaApi) emblaApi.scrollNext(true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
}, 150);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setColumns(initialColumnList);
|
if (emblaApi) {
|
||||||
}, [initialColumnList]);
|
emblaApi.on("scroll", emitScrollEvent);
|
||||||
|
emblaApi.on("resize", emitResizeEvent);
|
||||||
|
emblaApi.on("slidesChanged", emitScrollEvent);
|
||||||
|
}
|
||||||
|
}, [emblaApi, emitScrollEvent, emitResizeEvent]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// save current state
|
|
||||||
if (columns?.length) {
|
if (columns?.length) {
|
||||||
NostrQuery.setColumns(columns).then(() => console.log("saved"));
|
NostrQuery.setColumns(columns).then(() => console.log("saved"));
|
||||||
}
|
}
|
||||||
}, [columns]);
|
}, [columns]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unlistenColEvent = listen<EventColumns>("columns", (data) => {
|
setColumns(initialColumnList);
|
||||||
|
}, [initialColumnList]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Listen for columns event
|
||||||
|
const unlisten = listen<EventColumns>("columns", (data) => {
|
||||||
if (data.payload.type === "reset") reset();
|
if (data.payload.type === "reset") reset();
|
||||||
if (data.payload.type === "add") add(data.payload.column);
|
if (data.payload.type === "add") add(data.payload.column);
|
||||||
if (data.payload.type === "remove") remove(data.payload.label);
|
if (data.payload.type === "remove") remove(data.payload.label);
|
||||||
@@ -125,53 +124,50 @@ function Screen() {
|
|||||||
updateName(data.payload.label, data.payload.title);
|
updateName(data.payload.label, data.payload.title);
|
||||||
});
|
});
|
||||||
|
|
||||||
const unlistenWindowResize = getCurrent().listen("tauri://resize", () => {
|
// Listen for keyboard event
|
||||||
startResize();
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unlistenColEvent.then((f) => f());
|
unlisten.then((f) => f());
|
||||||
unlistenWindowResize.then((f) => f());
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full">
|
<div className="size-full">
|
||||||
<VList
|
<div ref={emblaRef} className="overflow-hidden size-full">
|
||||||
ref={vlistRef}
|
<div className="flex size-full">
|
||||||
horizontal
|
{columns?.map((column) => (
|
||||||
tabIndex={-1}
|
<Column
|
||||||
itemSize={440}
|
key={account + column.label}
|
||||||
overscan={5}
|
column={column}
|
||||||
onScroll={() => setIsScroll(true)}
|
account={account}
|
||||||
onScrollEnd={() => setIsScroll(false)}
|
/>
|
||||||
className="scrollbar-none h-full w-full overflow-x-auto focus:outline-none"
|
))}
|
||||||
>
|
</div>
|
||||||
{columns?.map((column) => (
|
</div>
|
||||||
<Column
|
|
||||||
key={account + column.label}
|
|
||||||
column={column}
|
|
||||||
account={account}
|
|
||||||
isScroll={isScroll}
|
|
||||||
isResize={isResize}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</VList>
|
|
||||||
<Toolbar>
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => goLeft()}
|
onClick={() => scrollPrev()}
|
||||||
className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
|
className="inline-flex 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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => goRight()}
|
onClick={() => openLumeStore()}
|
||||||
className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
|
className="inline-flex 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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ function Screen() {
|
|||||||
const { platform } = Route.useRouteContext();
|
const { platform } = Route.useRouteContext();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-screen flex-col">
|
<div className="flex flex-col w-screen h-screen">
|
||||||
<div
|
<div
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -38,27 +38,28 @@ function Screen() {
|
|||||||
<Accounts />
|
<Accounts />
|
||||||
<Link
|
<Link
|
||||||
to="/landing"
|
to="/landing"
|
||||||
className="inline-flex size-8 shrink-0 items-center justify-center rounded-full bg-black/10 text-neutral-800 hover:bg-black/20 dark:bg-white/10 dark:text-neutral-200 dark:hover:bg-white/20"
|
className="inline-flex 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" />
|
<PlusIcon className="size-5" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => LumeWindow.openSearch()}
|
||||||
|
className="inline-flex items-center justify-center rounded-full size-8 text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<SearchIcon className="size-5" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => LumeWindow.openEditor()}
|
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" />
|
<ComposeFilledIcon className="size-4" />
|
||||||
New Post
|
New Post
|
||||||
</button>
|
</button>
|
||||||
<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 id="toolbar" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -159,7 +160,7 @@ function Accounts() {
|
|||||||
))}
|
))}
|
||||||
{accounts.length >= 3 && windowWidth <= 700 ? (
|
{accounts.length >= 3 && windowWidth <= 700 ? (
|
||||||
<Popover.Root>
|
<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" />
|
<HorizontalDotsIcon className="size-5" />
|
||||||
</Popover.Trigger>
|
</Popover.Trigger>
|
||||||
<Popover.Portal>
|
<Popover.Portal>
|
||||||
@@ -169,11 +170,11 @@ function Accounts() {
|
|||||||
key={user}
|
key={user}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => changeAccount(user)}
|
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.Provider pubkey={user}>
|
||||||
<User.Root className="rounded-full ring-1 ring-white/10">
|
<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.Root>
|
||||||
</User.Provider>
|
</User.Provider>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -33,13 +33,6 @@ function Screen() {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleZap = () => {
|
|
||||||
setNewSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
zap: !newSettings.zap,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleNsfw = () => {
|
const toggleNsfw = () => {
|
||||||
setNewSettings((prev) => ({
|
setNewSettings((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -69,9 +62,9 @@ function Screen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
|
<div className="flex flex-col items-center justify-center w-full h-full gap-6 px-5 mx-auto xl:max-w-xl">
|
||||||
<div className="flex flex-col items-center gap-5 text-center">
|
<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">
|
<div className="flex items-center justify-center text-teal-500 bg-teal-100 rounded-full size-20 dark:bg-teal-950">
|
||||||
<LaurelIcon className="size-8" />
|
<LaurelIcon className="size-8" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -85,7 +78,7 @@ function Screen() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
|
<div className="flex items-start justify-between w-full gap-4 px-5 py-4 rounded-lg bg-neutral-100 dark:bg-white/10">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="font-semibold">Enhanced Privacy</h3>
|
<h3 className="font-semibold">Enhanced Privacy</h3>
|
||||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
@@ -101,23 +94,7 @@ function Screen() {
|
|||||||
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||||
</Switch.Root>
|
</Switch.Root>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
|
<div className="flex items-start justify-between w-full gap-4 px-5 py-4 rounded-lg bg-neutral-100 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">
|
<div className="flex-1">
|
||||||
<h3 className="font-semibold">Filter sensitive content</h3>
|
<h3 className="font-semibold">Filter sensitive content</h3>
|
||||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
@@ -138,7 +115,7 @@ function Screen() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => submit()}
|
onClick={() => submit()}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="mb-1 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
|
className="inline-flex items-center justify-center w-full mb-1 font-semibold text-white bg-blue-500 rounded-lg h-11 shrink-0 hover:bg-blue-600 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{loading ? <Spinner /> : t("global.continue")}
|
{loading ? <Spinner /> : t("global.continue")}
|
||||||
</button>
|
</button>
|
||||||
@@ -149,7 +126,7 @@ function Screen() {
|
|||||||
|
|
||||||
function Pending() {
|
function Pending() {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
<div className="flex items-center justify-center w-full h-full">
|
||||||
<button type="button" className="size-5" disabled>
|
<button type="button" className="size-5" disabled>
|
||||||
<Spinner className="size-5" />
|
<Spinner className="size-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import { AddMediaIcon } from "@lume/icons";
|
import { AddMediaIcon } from "@lume/icons";
|
||||||
import { NostrQuery } from "@lume/system";
|
import { NostrQuery } from "@lume/system";
|
||||||
import { Spinner } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import { cn, insertImage, isImagePath } from "@lume/utils";
|
import { insertImage, isImagePath } from "@lume/utils";
|
||||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
|
||||||
import type { UnlistenFn } from "@tauri-apps/api/event";
|
import type { UnlistenFn } from "@tauri-apps/api/event";
|
||||||
import { getCurrent } from "@tauri-apps/api/window";
|
import { getCurrent } from "@tauri-apps/api/window";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useSlateStatic } from "slate-react";
|
import { useSlateStatic } from "slate-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export function MediaButton({ className }: { className?: string }) {
|
export function MediaButton() {
|
||||||
const editor = useSlateStatic();
|
const editor = useSlateStatic();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
@@ -61,29 +60,18 @@ export function MediaButton({ className }: { className?: string }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip.Provider>
|
<button
|
||||||
<Tooltip.Root delayDuration={150}>
|
type="button"
|
||||||
<Tooltip.Trigger asChild>
|
onClick={() => upload()}
|
||||||
<button
|
disabled={loading}
|
||||||
type="button"
|
className="inline-flex items-center h-8 gap-2 px-2.5 text-sm rounded-lg text-black/70 dark:text-white/70 w-max hover:bg-black/10 dark:hover:bg-white/10"
|
||||||
onClick={() => upload()}
|
>
|
||||||
disabled={loading}
|
{loading ? (
|
||||||
className={cn("inline-flex items-center justify-center", className)}
|
<Spinner className="size-4" />
|
||||||
>
|
) : (
|
||||||
{loading ? (
|
<AddMediaIcon className="size-4" />
|
||||||
<Spinner className="size-4" />
|
)}
|
||||||
) : (
|
Add media
|
||||||
<AddMediaIcon className="size-4" />
|
</button>
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</Tooltip.Trigger>
|
|
||||||
<Tooltip.Portal>
|
|
||||||
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
|
|
||||||
Upload media
|
|
||||||
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
|
||||||
</Tooltip.Content>
|
|
||||||
</Tooltip.Portal>
|
|
||||||
</Tooltip.Root>
|
|
||||||
</Tooltip.Provider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export function MentionButton({ className }: { className?: string }) {
|
|||||||
<DropdownMenu.Portal>
|
<DropdownMenu.Portal>
|
||||||
<DropdownMenu.Content className="flex w-[220px] h-[220px] scrollbar-none flex-col overflow-y-auto rounded-xl bg-black py-1 shadow-md shadow-neutral-500/20 focus:outline-none dark:bg-white">
|
<DropdownMenu.Content className="flex w-[220px] h-[220px] scrollbar-none flex-col overflow-y-auto rounded-xl bg-black py-1 shadow-md shadow-neutral-500/20 focus:outline-none dark:bg-white">
|
||||||
{contacts.length < 1 ? (
|
{contacts.length < 1 ? (
|
||||||
<div className="w-full h-full flex items-center justify-center">
|
<div className="flex items-center justify-center w-full h-full">
|
||||||
<p className="text-sm text-white">Contact List is empty.</p>
|
<p className="text-sm text-white">Contact List is empty.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -69,11 +69,11 @@ export function MentionButton({ className }: { className?: string }) {
|
|||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
key={contact}
|
key={contact}
|
||||||
onClick={() => select(contact)}
|
onClick={() => select(contact)}
|
||||||
className="shrink-0 h-11 flex items-center hover:bg-white/10 px-2"
|
className="flex items-center px-2 shrink-0 h-11 hover:bg-white/10"
|
||||||
>
|
>
|
||||||
<User.Provider pubkey={contact}>
|
<User.Provider pubkey={contact}>
|
||||||
<User.Root className="flex items-center gap-2">
|
<User.Root className="flex items-center gap-2">
|
||||||
<User.Avatar className="shrink-0 size-8 rounded-full" />
|
<User.Avatar className="rounded-full shrink-0 size-8" />
|
||||||
<User.Name className="text-sm font-medium text-white dark:text-black" />
|
<User.Name className="text-sm font-medium text-white dark:text-black" />
|
||||||
</User.Root>
|
</User.Root>
|
||||||
</User.Provider>
|
</User.Provider>
|
||||||
|
|||||||
@@ -1,40 +1,21 @@
|
|||||||
import { NsfwIcon } from "@lume/icons";
|
import { PowIcon } from "@lume/icons";
|
||||||
import { cn } from "@lume/utils";
|
|
||||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
|
||||||
import type { Dispatch, SetStateAction } from "react";
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
|
|
||||||
export function PowToggle({
|
export function PowButton({
|
||||||
pow,
|
setDifficulty,
|
||||||
setPow,
|
|
||||||
className,
|
|
||||||
}: {
|
}: {
|
||||||
pow: boolean;
|
setDifficulty: Dispatch<SetStateAction<{ enable: boolean; num: number }>>;
|
||||||
setPow: Dispatch<SetStateAction<boolean>>;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Tooltip.Provider>
|
<button
|
||||||
<Tooltip.Root delayDuration={150}>
|
type="button"
|
||||||
<Tooltip.Trigger asChild>
|
onClick={() =>
|
||||||
<button
|
setDifficulty((prev) => ({ ...prev, enable: !prev.enable }))
|
||||||
type="button"
|
}
|
||||||
onClick={() => setPow((prev) => !prev)}
|
className="inline-flex items-center h-8 gap-2 px-2.5 text-sm rounded-lg text-black/70 dark:text-white/70 w-max hover:bg-black/10 dark:hover:bg-white/10"
|
||||||
className={cn(
|
>
|
||||||
"inline-flex items-center justify-center",
|
<PowIcon className="size-4" />
|
||||||
className,
|
PoW
|
||||||
pow ? "bg-blue-500 text-white" : "",
|
</button>
|
||||||
)}
|
|
||||||
>
|
|
||||||
<NsfwIcon className="size-4" />
|
|
||||||
</button>
|
|
||||||
</Tooltip.Trigger>
|
|
||||||
<Tooltip.Portal>
|
|
||||||
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
|
|
||||||
Proof of Work
|
|
||||||
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
|
||||||
</Tooltip.Content>
|
|
||||||
</Tooltip.Portal>
|
|
||||||
</Tooltip.Root>
|
|
||||||
</Tooltip.Provider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +1,19 @@
|
|||||||
import { NsfwIcon } from "@lume/icons";
|
import { NsfwIcon } from "@lume/icons";
|
||||||
import { cn } from "@lume/utils";
|
|
||||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
|
||||||
import type { Dispatch, SetStateAction } from "react";
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
|
|
||||||
export function WarningToggle({
|
export function WarningButton({
|
||||||
warning,
|
|
||||||
setWarning,
|
setWarning,
|
||||||
className,
|
|
||||||
}: {
|
}: {
|
||||||
warning: boolean;
|
setWarning: Dispatch<SetStateAction<{ enable: boolean; reason: string }>>;
|
||||||
setWarning: Dispatch<SetStateAction<boolean>>;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Tooltip.Provider>
|
<button
|
||||||
<Tooltip.Root delayDuration={150}>
|
type="button"
|
||||||
<Tooltip.Trigger asChild>
|
onClick={() => setWarning((prev) => ({ ...prev, enable: !prev.enable }))}
|
||||||
<button
|
className="inline-flex items-center h-8 gap-2 px-2.5 text-sm rounded-lg text-black/70 dark:text-white/70 w-max hover:bg-black/10 dark:hover:bg-white/10"
|
||||||
type="button"
|
>
|
||||||
onClick={() => setWarning((prev) => !prev)}
|
<NsfwIcon className="size-4" />
|
||||||
className={cn(
|
Mark as sensitive
|
||||||
"inline-flex items-center justify-center",
|
</button>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,8 @@
|
|||||||
import { ComposeFilledIcon } from "@lume/icons";
|
import { ComposeFilledIcon } from "@lume/icons";
|
||||||
import { Spinner } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import {
|
import { cn, insertImage, insertNostrEvent, isImageUrl } from "@lume/utils";
|
||||||
cn,
|
|
||||||
insertImage,
|
|
||||||
insertNostrEvent,
|
|
||||||
isImageUrl,
|
|
||||||
sendNativeNotification,
|
|
||||||
} from "@lume/utils";
|
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { type Descendant, Node, Transforms, createEditor } from "slate";
|
import { type Descendant, Node, Transforms, createEditor } from "slate";
|
||||||
import {
|
import {
|
||||||
Editable,
|
Editable,
|
||||||
@@ -21,14 +14,17 @@ import {
|
|||||||
withReact,
|
withReact,
|
||||||
} from "slate-react";
|
} from "slate-react";
|
||||||
import { MediaButton } from "./-components/media";
|
import { MediaButton } from "./-components/media";
|
||||||
import { MentionButton } from "./-components/mention";
|
import { LumeEvent, useEvent } from "@lume/system";
|
||||||
import { LumeEvent } from "@lume/system";
|
import { WarningButton } from "./-components/warning";
|
||||||
import { WarningToggle } from "./-components/warning";
|
|
||||||
import { MentionNote } from "@/components/note/mentions/note";
|
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 = {
|
type EditorSearch = {
|
||||||
reply_to: string;
|
reply_to: string;
|
||||||
quote: boolean;
|
quote: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type EditorElement = {
|
type EditorElement = {
|
||||||
@@ -41,26 +37,47 @@ export const Route = createFileRoute("/editor/")({
|
|||||||
validateSearch: (search: Record<string, string>): EditorSearch => {
|
validateSearch: (search: Record<string, string>): EditorSearch => {
|
||||||
return {
|
return {
|
||||||
reply_to: search.reply_to,
|
reply_to: search.reply_to,
|
||||||
quote: search.quote === "true" || false,
|
quote: search.quote,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
beforeLoad: ({ 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,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
const initialValue: EditorElement[] = [
|
|
||||||
{
|
|
||||||
type: "paragraph",
|
|
||||||
children: [{ text: "" }],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const search = Route.useSearch();
|
const { reply_to } = Route.useSearch();
|
||||||
|
const { initialValue } = Route.useRouteContext();
|
||||||
|
|
||||||
const [t] = useTranslation();
|
const [editorValue, setEditorValue] = useState<EditorElement[]>(null);
|
||||||
const [editorValue, setEditorValue] = useState(initialValue);
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [warning, setWarning] = useState(false);
|
const [warning, setWarning] = useState({ enable: false, reason: "" });
|
||||||
|
const [difficulty, setDifficulty] = useState({ enable: false, num: 21 });
|
||||||
const [editor] = useState(() =>
|
const [editor] = useState(() =>
|
||||||
withMentions(withNostrEvent(withImages(withReact(createEditor())))),
|
withMentions(withNostrEvent(withImages(withReact(createEditor())))),
|
||||||
);
|
);
|
||||||
@@ -103,63 +120,40 @@ function Screen() {
|
|||||||
const content = serialize(editor.children);
|
const content = serialize(editor.children);
|
||||||
const eventId = await LumeEvent.publish(
|
const eventId = await LumeEvent.publish(
|
||||||
content,
|
content,
|
||||||
search.reply_to,
|
warning.enable && warning.reason.length ? warning.reason : null,
|
||||||
search.quote,
|
difficulty.enable && difficulty.num > 0 ? difficulty.num : null,
|
||||||
warning,
|
reply_to,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (eventId) {
|
if (eventId) {
|
||||||
await sendNativeNotification(
|
// stop loading
|
||||||
"Your note has been published successfully.",
|
setLoading(false);
|
||||||
"Lume",
|
// reset form
|
||||||
);
|
reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
// stop loading
|
|
||||||
setLoading(false);
|
|
||||||
|
|
||||||
// reset form
|
|
||||||
reset();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
await sendNativeNotification(String(e));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEditorValue(initialValue);
|
||||||
|
}, [initialValue]);
|
||||||
|
|
||||||
|
if (!editorValue) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full flex flex-col">
|
<div className="flex flex-col w-full h-full">
|
||||||
<Slate editor={editor} initialValue={editorValue}>
|
<Slate editor={editor} initialValue={editorValue}>
|
||||||
<div
|
<div data-tauri-drag-region className="h-9 shrink-0" />
|
||||||
data-tauri-drag-region
|
<div className="flex flex-col flex-1 overflow-y-auto">
|
||||||
className="shrink-0 flex h-14 w-full items-center justify-end gap-2 px-2 border-b border-black/10 dark:border-white/10"
|
{reply_to?.length ? (
|
||||||
>
|
<div className="flex items-center gap-3 px-2.5 pb-3 border-b border-black/5 dark:border-white/5">
|
||||||
<WarningToggle
|
<div className="text-sm font-semibold shrink-0">Reply to:</div>
|
||||||
warning={warning}
|
<ChildNote id={reply_to} />
|
||||||
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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="overflow-y-auto scrollbar-none p-4">
|
<div className="px-4 py-4 overflow-y-auto">
|
||||||
<Editable
|
<Editable
|
||||||
key={JSON.stringify(editorValue)}
|
key={JSON.stringify(editorValue)}
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
@@ -168,17 +162,103 @@ function Screen() {
|
|||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
renderElement={(props) => <Element {...props} />}
|
renderElement={(props) => <Element {...props} />}
|
||||||
placeholder={
|
placeholder={
|
||||||
search.reply_to ? "Type your reply..." : t("editor.placeholder")
|
reply_to ? "Type your reply..." : "What're you up to?"
|
||||||
}
|
}
|
||||||
className="focus:outline-none"
|
className="focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{warning.enable ? (
|
||||||
|
<div className="flex items-center w-full px-4 border-t h-11 shrink-0 border-black/5 dark:border-white/5">
|
||||||
|
<span className="text-sm shrink-0 text-black/50 dark:text-white/50">
|
||||||
|
Reason:
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="NSFW..."
|
||||||
|
value={warning.reason}
|
||||||
|
onChange={(e) =>
|
||||||
|
setWarning((prev) => ({ ...prev, reason: e.target.value }))
|
||||||
|
}
|
||||||
|
className="flex-1 text-sm bg-transparent border-none focus:outline-none focus:ring-0 placeholder:text-black/50 dark:placeholder:text-white/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{difficulty.enable ? (
|
||||||
|
<div className="flex items-center w-full px-4 border-t h-11 shrink-0 border-black/5 dark:border-white/5">
|
||||||
|
<span className="text-sm shrink-0 text-black/50 dark:text-white/50">
|
||||||
|
Difficulty:
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
pattern="[0-9]"
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (!/[0-9]/.test(event.key)) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="21"
|
||||||
|
defaultValue={difficulty.num}
|
||||||
|
onChange={(e) =>
|
||||||
|
setWarning((prev) => ({ ...prev, num: Number(e.target.value) }))
|
||||||
|
}
|
||||||
|
className="flex-1 text-sm bg-transparent border-none focus:outline-none focus:ring-0 placeholder:text-black/50 dark:placeholder:text-white/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div
|
||||||
|
data-tauri-drag-region
|
||||||
|
className="flex items-center w-full h-16 gap-4 px-4 border-t divide-x divide-black/5 dark:divide-white/5 shrink-0 border-black/5 dark:border-white/5"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => publish()}
|
||||||
|
className="inline-flex items-center justify-center h-8 gap-1 px-2.5 text-sm font-medium rounded-lg bg-black/10 w-max hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Spinner className="size-4" />
|
||||||
|
) : (
|
||||||
|
<ComposeFilledIcon className="size-4" />
|
||||||
|
)}
|
||||||
|
Publish
|
||||||
|
</button>
|
||||||
|
<div className="inline-flex items-center flex-1 gap-2 pl-4">
|
||||||
|
<MediaButton />
|
||||||
|
<WarningButton setWarning={setWarning} />
|
||||||
|
<PowButton setDifficulty={setDifficulty} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Slate>
|
</Slate>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ChildNote({ id }: { id: string }) {
|
||||||
|
const { isLoading, isError, data } = useEvent(id);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <Spinner className="size-5" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !data) {
|
||||||
|
return <div>Event not found with your current relay set.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Note.Provider event={data}>
|
||||||
|
<Note.Root className="flex items-center gap-2">
|
||||||
|
<User.Provider pubkey={data.pubkey}>
|
||||||
|
<User.Root className="shrink-0">
|
||||||
|
<User.Avatar className="rounded-full size-8 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 withNostrEvent = (editor: ReactEditor) => {
|
||||||
const { insertData, isVoid } = editor;
|
const { insertData, isVoid } = editor;
|
||||||
|
|
||||||
@@ -190,7 +270,7 @@ const withNostrEvent = (editor: ReactEditor) => {
|
|||||||
editor.insertData = (data) => {
|
editor.insertData = (data) => {
|
||||||
const text = data.getData("text/plain");
|
const text = data.getData("text/plain");
|
||||||
|
|
||||||
if (text.startsWith("nevent1") || text.startsWith("note1")) {
|
if (text.startsWith("nevent") || text.startsWith("note")) {
|
||||||
insertNostrEvent(editor, text);
|
insertNostrEvent(editor, text);
|
||||||
} else {
|
} else {
|
||||||
insertData(data);
|
insertData(data);
|
||||||
@@ -259,6 +339,7 @@ const Image = ({ attributes, element, children }) => {
|
|||||||
selected && focused ? "ring-blue-500" : "ring-transparent",
|
selected && focused ? "ring-blue-500" : "ring-transparent",
|
||||||
)}
|
)}
|
||||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
||||||
|
onKeyDown={() => Transforms.removeNodes(editor, { at: path })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -274,7 +355,7 @@ const Mention = ({ attributes, element }) => {
|
|||||||
type="button"
|
type="button"
|
||||||
contentEditable={false}
|
contentEditable={false}
|
||||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
||||||
className="inline-block align-baseline text-blue-500 hover:text-blue-600"
|
className="inline-block text-blue-500 align-baseline hover:text-blue-600"
|
||||||
>{`@${element.name}`}</span>
|
>{`@${element.name}`}</span>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -286,16 +367,13 @@ const Event = ({ attributes, element, children }) => {
|
|||||||
return (
|
return (
|
||||||
<div {...attributes}>
|
<div {...attributes}>
|
||||||
{children}
|
{children}
|
||||||
{/* biome-ignore lint/a11y/useKeyWithClickEvents: <explanation> */}
|
|
||||||
<div
|
<div
|
||||||
contentEditable={false}
|
contentEditable={false}
|
||||||
|
className="relative my-2 user-select-none"
|
||||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
||||||
className="user-select-none relative my-2"
|
onKeyDown={() => Transforms.removeNodes(editor, { at: path })}
|
||||||
>
|
>
|
||||||
<MentionNote
|
<MentionNote eventId={element.eventId} openable={false} />
|
||||||
eventId={element.eventId.replace("nostr:", "")}
|
|
||||||
openable={false}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import { Conversation } from "@/components/conversation";
|
|||||||
import { Quote } from "@/components/quote";
|
import { Quote } from "@/components/quote";
|
||||||
import { RepostNote } from "@/components/repost";
|
import { RepostNote } from "@/components/repost";
|
||||||
import { TextNote } from "@/components/text";
|
import { TextNote } from "@/components/text";
|
||||||
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
|
import { ArrowRightCircleIcon } from "@lume/icons";
|
||||||
import { NostrQuery } from "@lume/system";
|
import { NostrQuery } from "@lume/system";
|
||||||
import { type ColumnRouteSearch, type NostrEvent, Kind } from "@lume/types";
|
import { type ColumnRouteSearch, type NostrEvent, Kind } from "@lume/types";
|
||||||
import { Spinner } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { useCallback } from "react";
|
||||||
import { Virtualizer } from "virtua";
|
import { Virtualizer } from "virtua";
|
||||||
|
|
||||||
export const Route = createFileRoute("/global")({
|
export const Route = createFileRoute("/global")({
|
||||||
@@ -18,10 +19,6 @@ export const Route = createFileRoute("/global")({
|
|||||||
name: search.name,
|
name: search.name,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
beforeLoad: async () => {
|
|
||||||
const settings = await NostrQuery.getSettings();
|
|
||||||
return { settings };
|
|
||||||
},
|
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -46,34 +43,39 @@ export function Screen() {
|
|||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const renderItem = (event: NostrEvent) => {
|
const renderItem = useCallback(
|
||||||
if (!event) return;
|
(event: NostrEvent) => {
|
||||||
switch (event.kind) {
|
if (!event) return;
|
||||||
case Kind.Repost:
|
switch (event.kind) {
|
||||||
return <RepostNote key={event.id} event={event} />;
|
case Kind.Repost:
|
||||||
default: {
|
return <RepostNote key={event.id} event={event} />;
|
||||||
const isConversation =
|
default: {
|
||||||
event.tags.filter((tag) => tag[0] === "e" && tag[3] !== "mention")
|
const isConversation =
|
||||||
.length > 0;
|
event.tags.filter((tag) => tag[0] === "e" && tag[3] !== "mention")
|
||||||
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0;
|
.length > 0;
|
||||||
|
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0;
|
||||||
|
|
||||||
if (isConversation) {
|
if (isConversation) {
|
||||||
return <Conversation key={event.id} event={event} className="mb-3" />;
|
return (
|
||||||
|
<Conversation key={event.id} 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" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isQuote) {
|
|
||||||
return <Quote key={event.id} event={event} className="mb-3" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <TextNote key={event.id} event={event} className="mb-3" />;
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
};
|
[data],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none">
|
<div className="w-full h-full p-2 overflow-y-auto scrollbar-none">
|
||||||
{isFetching && !isLoading && !isFetchingNextPage ? (
|
{isFetching && !isLoading && !isFetchingNextPage ? (
|
||||||
<div className="w-full h-11 flex items-center justify-center">
|
<div className="flex items-center justify-center w-full h-11">
|
||||||
<div className="flex items-center justify-center gap-2">
|
<div className="flex items-center justify-center gap-2">
|
||||||
<Spinner className="size-5" />
|
<Spinner className="size-5" />
|
||||||
<span className="text-sm font-medium">Fetching new notes...</span>
|
<span className="text-sm font-medium">Fetching new notes...</span>
|
||||||
@@ -81,7 +83,7 @@ export function Screen() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{isLoading ? (
|
{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" />
|
<Spinner className="size-5" />
|
||||||
<span className="text-sm font-medium">Loading...</span>
|
<span className="text-sm font-medium">Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -98,7 +100,7 @@ export function Screen() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => fetchNextPage()}
|
onClick={() => fetchNextPage()}
|
||||||
disabled={isFetchingNextPage || isLoading}
|
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 h-12 gap-2 px-3 font-medium rounded-xl bg-black/5 hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
|
||||||
>
|
>
|
||||||
{isFetchingNextPage ? (
|
{isFetchingNextPage ? (
|
||||||
<Spinner className="size-5" />
|
<Spinner className="size-5" />
|
||||||
@@ -117,31 +119,12 @@ export function Screen() {
|
|||||||
|
|
||||||
function Empty() {
|
function Empty() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col py-10 gap-10">
|
<div className="flex flex-col gap-10 py-10">
|
||||||
<div className="text-center flex flex-col items-center justify-center">
|
<div className="flex flex-col items-center justify-center text-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="flex flex-col items-center justify-end mb-8 overflow-hidden bg-blue-100 rounded-full size-24 dark:bg-blue-900">
|
||||||
<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 className="w-12 h-16 rounded-t-lg bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-medium">Your newsfeed is empty</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import { Conversation } from "@/components/conversation";
|
|||||||
import { Quote } from "@/components/quote";
|
import { Quote } from "@/components/quote";
|
||||||
import { RepostNote } from "@/components/repost";
|
import { RepostNote } from "@/components/repost";
|
||||||
import { TextNote } from "@/components/text";
|
import { TextNote } from "@/components/text";
|
||||||
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
|
import { ArrowRightCircleIcon } from "@lume/icons";
|
||||||
import { NostrAccount, NostrQuery } from "@lume/system";
|
import { NostrQuery } from "@lume/system";
|
||||||
import { type ColumnRouteSearch, type NostrEvent, Kind } from "@lume/types";
|
import { type ColumnRouteSearch, type NostrEvent, Kind } from "@lume/types";
|
||||||
import { Spinner } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { Link, createFileRoute, redirect } from "@tanstack/react-router";
|
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||||
|
import { useCallback } from "react";
|
||||||
import { Virtualizer } from "virtua";
|
import { Virtualizer } from "virtua";
|
||||||
|
|
||||||
export const Route = createFileRoute("/group")({
|
export const Route = createFileRoute("/group")({
|
||||||
@@ -21,7 +22,6 @@ export const Route = createFileRoute("/group")({
|
|||||||
beforeLoad: async ({ search }) => {
|
beforeLoad: async ({ search }) => {
|
||||||
const key = `lume_group_${search.label}`;
|
const key = `lume_group_${search.label}`;
|
||||||
const groups = (await NostrQuery.getNstore(key)) as string[];
|
const groups = (await NostrQuery.getNstore(key)) as string[];
|
||||||
const settings = await NostrQuery.getSettings();
|
|
||||||
|
|
||||||
if (!groups?.length) {
|
if (!groups?.length) {
|
||||||
throw redirect({
|
throw redirect({
|
||||||
@@ -33,10 +33,7 @@ export const Route = createFileRoute("/group")({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return { groups };
|
||||||
groups,
|
|
||||||
settings,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
@@ -55,7 +52,7 @@ export function Screen() {
|
|||||||
queryKey: [label, account],
|
queryKey: [label, account],
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||||
const events = await NostrQuery.getLocalEvents(groups, pageParam);
|
const events = await NostrQuery.getGroupEvents(groups, pageParam);
|
||||||
return events;
|
return events;
|
||||||
},
|
},
|
||||||
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
|
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
|
||||||
@@ -64,34 +61,39 @@ export function Screen() {
|
|||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const renderItem = (event: NostrEvent) => {
|
const renderItem = useCallback(
|
||||||
if (!event) return;
|
(event: NostrEvent) => {
|
||||||
switch (event.kind) {
|
if (!event) return;
|
||||||
case Kind.Repost:
|
switch (event.kind) {
|
||||||
return <RepostNote key={event.id} event={event} />;
|
case Kind.Repost:
|
||||||
default: {
|
return <RepostNote key={event.id} event={event} />;
|
||||||
const isConversation =
|
default: {
|
||||||
event.tags.filter((tag) => tag[0] === "e" && tag[3] !== "mention")
|
const isConversation =
|
||||||
.length > 0;
|
event.tags.filter((tag) => tag[0] === "e" && tag[3] !== "mention")
|
||||||
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0;
|
.length > 0;
|
||||||
|
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0;
|
||||||
|
|
||||||
if (isConversation) {
|
if (isConversation) {
|
||||||
return <Conversation key={event.id} event={event} className="mb-3" />;
|
return (
|
||||||
|
<Conversation key={event.id} 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" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isQuote) {
|
|
||||||
return <Quote key={event.id} event={event} className="mb-3" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <TextNote key={event.id} event={event} className="mb-3" />;
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
};
|
[data],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none">
|
<div className="w-full h-full p-2 overflow-y-auto scrollbar-none">
|
||||||
{isFetching && !isLoading && !isFetchingNextPage ? (
|
{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">
|
<div className="flex items-center justify-center gap-2">
|
||||||
<Spinner className="size-5" />
|
<Spinner className="size-5" />
|
||||||
<span className="text-sm font-medium">Fetching new notes...</span>
|
<span className="text-sm font-medium">Fetching new notes...</span>
|
||||||
@@ -99,7 +101,7 @@ export function Screen() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{isLoading ? (
|
{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" />
|
<Spinner className="size-5" />
|
||||||
<span className="text-sm font-medium">Loading...</span>
|
<span className="text-sm font-medium">Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -116,7 +118,7 @@ export function Screen() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => fetchNextPage()}
|
onClick={() => fetchNextPage()}
|
||||||
disabled={isFetchingNextPage || isLoading}
|
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 h-12 gap-2 px-3 font-medium rounded-xl bg-neutral-100 hover:bg-neutral-50 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
|
||||||
>
|
>
|
||||||
{isFetchingNextPage ? (
|
{isFetchingNextPage ? (
|
||||||
<Spinner className="size-5" />
|
<Spinner className="size-5" />
|
||||||
@@ -135,31 +137,12 @@ export function Screen() {
|
|||||||
|
|
||||||
function Empty() {
|
function Empty() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col py-10 gap-10">
|
<div className="flex flex-col gap-10 py-10">
|
||||||
<div className="text-center flex flex-col items-center justify-center">
|
<div className="flex flex-col items-center justify-center text-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="flex flex-col items-center justify-end mb-8 overflow-hidden bg-blue-100 rounded-full size-24 dark:bg-blue-900">
|
||||||
<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 className="w-12 h-16 rounded-t-lg bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-medium">Your newsfeed is empty</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import { Quote } from "@/components/quote";
|
|||||||
import { RepostNote } from "@/components/repost";
|
import { RepostNote } from "@/components/repost";
|
||||||
import { TextNote } from "@/components/text";
|
import { TextNote } from "@/components/text";
|
||||||
import { ArrowRightCircleIcon } from "@lume/icons";
|
import { ArrowRightCircleIcon } from "@lume/icons";
|
||||||
import { NostrAccount, NostrQuery } from "@lume/system";
|
import { type LumeEvent, NostrAccount, NostrQuery } from "@lume/system";
|
||||||
import { type ColumnRouteSearch, type NostrEvent, Kind } from "@lume/types";
|
import { type ColumnRouteSearch, Kind } from "@lume/types";
|
||||||
import { Spinner } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { redirect } from "@tanstack/react-router";
|
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { useCallback } from "react";
|
||||||
import { Virtualizer } from "virtua";
|
import { Virtualizer } from "virtua";
|
||||||
|
|
||||||
export const Route = createFileRoute("/newsfeed")({
|
export const Route = createFileRoute("/newsfeed")({
|
||||||
@@ -20,10 +20,8 @@ export const Route = createFileRoute("/newsfeed")({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
beforeLoad: async ({ search }) => {
|
beforeLoad: async ({ search }) => {
|
||||||
const settings = await NostrQuery.getSettings();
|
const isContactListEmpty = await NostrAccount.isContactListEmpty();
|
||||||
const contacts = await NostrAccount.getContactList();
|
if (isContactListEmpty) {
|
||||||
|
|
||||||
if (!contacts.length) {
|
|
||||||
throw redirect({
|
throw redirect({
|
||||||
to: "/create-newsfeed/users",
|
to: "/create-newsfeed/users",
|
||||||
search: {
|
search: {
|
||||||
@@ -32,15 +30,12 @@ export const Route = createFileRoute("/newsfeed")({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { settings, contacts };
|
|
||||||
},
|
},
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
export function Screen() {
|
export function Screen() {
|
||||||
const { label, account } = Route.useSearch();
|
const { label, account } = Route.useSearch();
|
||||||
const { contacts, settings } = Route.useRouteContext();
|
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -52,7 +47,7 @@ export function Screen() {
|
|||||||
queryKey: [label, account],
|
queryKey: [label, account],
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||||
const events = await NostrQuery.getLocalEvents(contacts, pageParam);
|
const events = await NostrQuery.getLocalEvents(pageParam);
|
||||||
return events;
|
return events;
|
||||||
},
|
},
|
||||||
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
|
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
|
||||||
@@ -60,41 +55,32 @@ export function Screen() {
|
|||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const renderItem = (event: NostrEvent) => {
|
const renderItem = useCallback(
|
||||||
if (!event) return;
|
(event: LumeEvent) => {
|
||||||
switch (event.kind) {
|
if (!event) return;
|
||||||
case Kind.Repost:
|
switch (event.kind) {
|
||||||
return <RepostNote key={event.id} event={event} />;
|
case Kind.Repost:
|
||||||
default: {
|
return <RepostNote key={event.id} event={event} className="mb-3" />;
|
||||||
const isConversation =
|
default: {
|
||||||
event.tags.filter((tag) => tag[0] === "e" && tag[3] !== "mention")
|
if (event.isConversation) {
|
||||||
.length > 0;
|
return (
|
||||||
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0;
|
<Conversation key={event.id} className="mb-3" event={event} />
|
||||||
|
);
|
||||||
if (isConversation) {
|
}
|
||||||
return (
|
if (event.isQuote) {
|
||||||
<Conversation
|
return <Quote key={event.id} event={event} className="mb-3" />;
|
||||||
key={event.id}
|
}
|
||||||
className="mb-3"
|
return <TextNote key={event.id} event={event} className="mb-3" />;
|
||||||
event={event}
|
|
||||||
gossip={settings?.gossip}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isQuote) {
|
|
||||||
return <Quote key={event.id} event={event} className="mb-3" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <TextNote key={event.id} event={event} className="mb-3" />;
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
};
|
[data],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none">
|
<div className="w-full h-full p-3 overflow-y-auto scrollbar-none">
|
||||||
{isFetching && !isLoading && !isFetchingNextPage ? (
|
{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">
|
<div className="flex items-center justify-center gap-2">
|
||||||
<Spinner className="size-5" />
|
<Spinner className="size-5" />
|
||||||
<span className="text-sm font-medium">Fetching new notes...</span>
|
<span className="text-sm font-medium">Fetching new notes...</span>
|
||||||
@@ -102,7 +88,7 @@ export function Screen() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{isLoading ? (
|
{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" />
|
<Spinner className="size-5" />
|
||||||
<span className="text-sm font-medium">Loading...</span>
|
<span className="text-sm font-medium">Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,7 +107,7 @@ export function Screen() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => fetchNextPage()}
|
onClick={() => fetchNextPage()}
|
||||||
disabled={isFetchingNextPage || isLoading}
|
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 ? (
|
{isFetchingNextPage ? (
|
||||||
<Spinner className="size-5" />
|
<Spinner className="size-5" />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Note } from "@/components/note";
|
import { Note } from "@/components/note";
|
||||||
import { User } from "@/components/user";
|
import { User } from "@/components/user";
|
||||||
import { LumeWindow, NostrQuery, useEvent } from "@lume/system";
|
import { type LumeEvent, LumeWindow, NostrQuery, useEvent } from "@lume/system";
|
||||||
import { Kind, NostrEvent } from "@lume/types";
|
import { Kind } from "@lume/types";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { getCurrent } from "@tauri-apps/api/window";
|
import { getCurrent } from "@tauri-apps/api/window";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
@@ -19,7 +19,7 @@ export const Route = createFileRoute("/panel")({
|
|||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const [account, setAccount] = useState<string>(null);
|
const [account, setAccount] = useState<string>(null);
|
||||||
const [events, setEvents] = useState<NostrEvent[]>([]);
|
const [events, setEvents] = useState<LumeEvent[]>([]);
|
||||||
|
|
||||||
const texts = useMemo(
|
const texts = useMemo(
|
||||||
() => events.filter((ev) => ev.kind === Kind.Text),
|
() => events.filter((ev) => ev.kind === Kind.Text),
|
||||||
@@ -27,7 +27,7 @@ function Screen() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const zaps = useMemo(() => {
|
const zaps = useMemo(() => {
|
||||||
const groups = new Map<string, NostrEvent[]>();
|
const groups = new Map<string, LumeEvent[]>();
|
||||||
const list = events.filter((ev) => ev.kind === Kind.ZapReceipt);
|
const list = events.filter((ev) => ev.kind === Kind.ZapReceipt);
|
||||||
|
|
||||||
for (const event of list) {
|
for (const event of list) {
|
||||||
@@ -46,7 +46,7 @@ function Screen() {
|
|||||||
}, [events]);
|
}, [events]);
|
||||||
|
|
||||||
const reactions = useMemo(() => {
|
const reactions = useMemo(() => {
|
||||||
const groups = new Map<string, NostrEvent[]>();
|
const groups = new Map<string, LumeEvent[]>();
|
||||||
const list = events.filter(
|
const list = events.filter(
|
||||||
(ev) => ev.kind === Kind.Repost || ev.kind === Kind.Reaction,
|
(ev) => ev.kind === Kind.Repost || ev.kind === Kind.Reaction,
|
||||||
);
|
);
|
||||||
@@ -86,7 +86,7 @@ function Screen() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const unlistenNewEvent = getCurrent().listen("notification", (data) => {
|
const unlistenNewEvent = getCurrent().listen("notification", (data) => {
|
||||||
const event: NostrEvent = JSON.parse(data.payload as string);
|
const event: LumeEvent = JSON.parse(data.payload as string);
|
||||||
setEvents((prev) => [event, ...prev]);
|
setEvents((prev) => [event, ...prev]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -98,28 +98,28 @@ function Screen() {
|
|||||||
|
|
||||||
if (!account) {
|
if (!account) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full flex items-center justify-center text-sm">
|
<div className="flex items-center justify-center w-full h-full text-sm">
|
||||||
Please log in.
|
Please log in.
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full flex flex-col">
|
<div className="flex flex-col w-full h-full">
|
||||||
<div className="h-11 shrink-0 flex items-center justify-between border-b border-black/5 px-4">
|
<div className="flex items-center justify-between px-4 border-b h-11 shrink-0 border-black/5">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-sm font-semibold">Notifications</h1>
|
<h1 className="text-sm font-semibold">Notifications</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="inline-flex items-center gap-2">
|
<div className="inline-flex items-center gap-2">
|
||||||
<User.Provider pubkey={account}>
|
<User.Provider pubkey={account}>
|
||||||
<User.Root>
|
<User.Root>
|
||||||
<User.Avatar className="size-7 rounded-full" />
|
<User.Avatar className="rounded-full size-7" />
|
||||||
</User.Root>
|
</User.Root>
|
||||||
</User.Provider>
|
</User.Provider>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => LumeWindow.openSettings()}
|
onClick={() => LumeWindow.openSettings()}
|
||||||
className="size-7 inline-flex items-center justify-center bg-black/5 dark:bg-white/5 rounded-full"
|
className="inline-flex items-center justify-center rounded-full size-7 bg-black/5 dark:bg-white/5"
|
||||||
>
|
>
|
||||||
<SettingsIcon className="size-4" />
|
<SettingsIcon className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -127,7 +127,7 @@ function Screen() {
|
|||||||
</div>
|
</div>
|
||||||
<Tabs.Root
|
<Tabs.Root
|
||||||
defaultValue="replies"
|
defaultValue="replies"
|
||||||
className="flex-1 overflow-y-auto overflow-x-hidden scrollbar-none"
|
className="flex-1 overflow-x-hidden overflow-y-auto scrollbar-none"
|
||||||
>
|
>
|
||||||
<Tabs.List className="flex items-center">
|
<Tabs.List className="flex items-center">
|
||||||
<Tabs.Trigger
|
<Tabs.Trigger
|
||||||
@@ -152,25 +152,25 @@ function Screen() {
|
|||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
<Tabs.Content value="replies" className="flex flex-col gap-2">
|
<Tabs.Content value="replies" className="flex flex-col gap-2">
|
||||||
{texts.map((event) => (
|
{texts.map((event) => (
|
||||||
<TextNote event={event} />
|
<TextNote key={event.id} event={event} />
|
||||||
))}
|
))}
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
<Tabs.Content value="reactions" className="flex flex-col gap-2">
|
<Tabs.Content value="reactions" className="flex flex-col gap-2">
|
||||||
{[...reactions.entries()].map(([root, events]) => (
|
{[...reactions.entries()].map(([root, events]) => (
|
||||||
<div
|
<div
|
||||||
key={root}
|
key={root}
|
||||||
className="shrink-0 flex flex-col gap-1 rounded-lg p-2 backdrop-blur-md bg-black/10 dark:bg-white/10"
|
className="flex flex-col gap-1 p-2 rounded-lg shrink-0 backdrop-blur-md bg-black/10 dark:bg-white/10"
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1 flex flex-col gap-2">
|
<div className="flex flex-col flex-1 min-w-0 gap-2">
|
||||||
<div className="pb-2 border-b border-black/5 dark:border-white/5 flex items-center gap-2">
|
<div className="flex items-center gap-2 pb-2 border-b border-black/5 dark:border-white/5">
|
||||||
<RootNote id={root} />
|
<RootNote id={root} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
{events.map((event) => (
|
{events.map((event) => (
|
||||||
<User.Provider pubkey={event.pubkey}>
|
<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.Root className="shrink-0 flex rounded-full h-8 bg-black/10 dark:bg-white/10 backdrop-blur-md p-[2px]">
|
||||||
<User.Avatar className="flex-1 size-7 rounded-full" />
|
<User.Avatar className="flex-1 rounded-full size-7" />
|
||||||
<div className="flex-1 size-7 rounded-full inline-flex items-center justify-center text-xs truncate">
|
<div className="inline-flex items-center justify-center flex-1 text-xs truncate rounded-full size-7">
|
||||||
{event.kind === Kind.Reaction ? (
|
{event.kind === Kind.Reaction ? (
|
||||||
event.content === "+" ? (
|
event.content === "+" ? (
|
||||||
"👍"
|
"👍"
|
||||||
@@ -178,7 +178,7 @@ function Screen() {
|
|||||||
event.content
|
event.content
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<RepostIcon className="size-4 dark:text-teal-600 text-teal-400" />
|
<RepostIcon className="text-teal-400 size-4 dark:text-teal-600" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</User.Root>
|
</User.Root>
|
||||||
@@ -193,19 +193,20 @@ function Screen() {
|
|||||||
{[...zaps.entries()].map(([root, events]) => (
|
{[...zaps.entries()].map(([root, events]) => (
|
||||||
<div
|
<div
|
||||||
key={root}
|
key={root}
|
||||||
className="shrink-0 flex flex-col gap-1 rounded-lg p-2 backdrop-blur-md bg-black/10 dark:bg-white/10"
|
className="flex flex-col gap-1 p-2 rounded-lg shrink-0 backdrop-blur-md bg-black/10 dark:bg-white/10"
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1 flex flex-col gap-2">
|
<div className="flex flex-col flex-1 min-w-0 gap-2">
|
||||||
<div className="pb-2 border-b border-black/5 dark:border-white/5 flex items-center gap-2">
|
<div className="flex items-center gap-2 pb-2 border-b border-black/5 dark:border-white/5">
|
||||||
<RootNote id={root} />
|
<RootNote id={root} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
{events.map((event) => (
|
{events.map((event) => (
|
||||||
<User.Provider
|
<User.Provider
|
||||||
pubkey={event.tags.find((tag) => tag[0] == "P")[1]}
|
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.Root className="shrink-0 flex gap-1.5 rounded-full h-8 bg-black/10 dark:bg-white/10 backdrop-blur-md p-[2px]">
|
||||||
<User.Avatar className="flex-1 size-7 rounded-full" />
|
<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">
|
<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}
|
₿ {decodeZapInvoice(event.tags).bitcoinFormatted}
|
||||||
</div>
|
</div>
|
||||||
@@ -228,9 +229,9 @@ function RootNote({ id }: { id: string }) {
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="pb-2 mb-2 flex items-center">
|
<div className="flex items-center pb-2 mb-2">
|
||||||
<div className="size-8 shrink-0 rounded-full bg-black/20 dark:bg-white/20 animate-pulse" />
|
<div className="rounded-full size-8 shrink-0 bg-black/20 dark:bg-white/20 animate-pulse" />
|
||||||
<div className="animate-pulse rounded-md h-4 w-2/3 bg-black/20 dark:bg-white/20" />
|
<div className="w-2/3 h-4 rounded-md animate-pulse bg-black/20 dark:bg-white/20" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -238,10 +239,10 @@ function RootNote({ id }: { id: string }) {
|
|||||||
if (isError || !data) {
|
if (isError || !data) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="size-8 shrink-0 rounded-full bg-red-500 text-white inline-flex items-center justify-center">
|
<div className="inline-flex items-center justify-center text-white bg-red-500 rounded-full size-8 shrink-0">
|
||||||
<InfoIcon className="size-5" />
|
<InfoIcon className="size-5" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-red-500 text-sm">
|
<p className="text-sm text-red-500">
|
||||||
Event not found with your current relay set
|
Event not found with your current relay set
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -253,7 +254,7 @@ function RootNote({ id }: { id: string }) {
|
|||||||
<Note.Root className="flex items-center gap-2">
|
<Note.Root className="flex items-center gap-2">
|
||||||
<User.Provider pubkey={data.pubkey}>
|
<User.Provider pubkey={data.pubkey}>
|
||||||
<User.Root className="shrink-0">
|
<User.Root className="shrink-0">
|
||||||
<User.Avatar className="size-8 shrink-0 rounded-full" />
|
<User.Avatar className="rounded-full size-8 shrink-0" />
|
||||||
</User.Root>
|
</User.Root>
|
||||||
</User.Provider>
|
</User.Provider>
|
||||||
<div className="line-clamp-1">{data.content}</div>
|
<div className="line-clamp-1">{data.content}</div>
|
||||||
@@ -262,7 +263,7 @@ function RootNote({ id }: { id: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TextNote({ event }: { event: NostrEvent }) {
|
function TextNote({ event }: { event: LumeEvent }) {
|
||||||
const pTags = event.tags
|
const pTags = event.tags
|
||||||
.filter((tag) => tag[0] === "p")
|
.filter((tag) => tag[0] === "p")
|
||||||
.map((tag) => tag[1])
|
.map((tag) => tag[1])
|
||||||
@@ -275,14 +276,14 @@ function TextNote({ event }: { event: NostrEvent }) {
|
|||||||
onClick={() => LumeWindow.openEvent(event)}
|
onClick={() => LumeWindow.openEvent(event)}
|
||||||
>
|
>
|
||||||
<Note.Provider event={event}>
|
<Note.Provider event={event}>
|
||||||
<Note.Root className="shrink-0 flex flex-col gap-1 rounded-lg p-2 backdrop-blur-md bg-black/10 dark:bg-white/10">
|
<Note.Root className="flex flex-col gap-1 p-2 rounded-lg shrink-0 backdrop-blur-md bg-black/10 dark:bg-white/10">
|
||||||
<User.Provider pubkey={event.pubkey}>
|
<User.Provider pubkey={event.pubkey}>
|
||||||
<User.Root className="inline-flex items-center gap-2">
|
<User.Root className="inline-flex items-center gap-2">
|
||||||
<User.Avatar className="size-9 shrink-0 rounded-full" />
|
<User.Avatar className="rounded-full size-9 shrink-0" />
|
||||||
<div className="flex-1 flex flex-col">
|
<div className="flex flex-col flex-1">
|
||||||
<div className="w-full flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between w-full">
|
||||||
<User.Name className="leading-tight text-sm font-semibold" />
|
<User.Name className="text-sm font-semibold leading-tight" />
|
||||||
<span className="leading-tight text-sm text-black/50 dark:text-white/50">
|
<span className="text-sm leading-tight text-black/50 dark:text-white/50">
|
||||||
{formatCreatedAt(event.created_at)}
|
{formatCreatedAt(event.created_at)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -292,9 +293,9 @@ function TextNote({ event }: { event: NostrEvent }) {
|
|||||||
</span>
|
</span>
|
||||||
<div className="inline-flex items-baseline gap-1">
|
<div className="inline-flex items-baseline gap-1">
|
||||||
{pTags.map((replyTo) => (
|
{pTags.map((replyTo) => (
|
||||||
<User.Provider pubkey={replyTo}>
|
<User.Provider key={replyTo} pubkey={replyTo}>
|
||||||
<User.Root>
|
<User.Root>
|
||||||
<User.Name className="leading-tight font-medium" />
|
<User.Name className="font-medium leading-tight" />
|
||||||
</User.Root>
|
</User.Root>
|
||||||
</User.Provider>
|
</User.Provider>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { User } from "@/components/user";
|
import { User } from "@/components/user";
|
||||||
import { NostrAccount } from "@lume/system";
|
import { NostrAccount } from "@lume/system";
|
||||||
import { displayNsec } from "@lume/utils";
|
import { displayNpub, displayNsec } from "@lume/utils";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||||
@@ -13,50 +13,34 @@ interface Account {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const Route = createFileRoute("/settings/backup")({
|
export const Route = createFileRoute("/settings/backup")({
|
||||||
component: Screen,
|
beforeLoad: async () => {
|
||||||
loader: async () => {
|
const accounts = await NostrAccount.getAccounts();
|
||||||
const npubs = await NostrAccount.getAccounts();
|
return { accounts };
|
||||||
const accounts: Account[] = [];
|
|
||||||
|
|
||||||
for (const npub of npubs) {
|
|
||||||
const nsec: string = await invoke("get_stored_nsec", { npub });
|
|
||||||
accounts.push({ npub, nsec });
|
|
||||||
}
|
|
||||||
|
|
||||||
return accounts;
|
|
||||||
},
|
},
|
||||||
|
component: Screen,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
function Screen() {
|
||||||
const accounts = Route.useLoaderData();
|
const { accounts } = Route.useRouteContext();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-xl">
|
<div className="w-full max-w-xl mx-auto">
|
||||||
<div className="flex flex-col gap-3 divide-y divide-neutral-300 dark:divide-neutral-700">
|
<div className="flex flex-col gap-3 divide-y divide-neutral-300 dark:divide-neutral-700">
|
||||||
{accounts.map((account) => (
|
{accounts.map((account) => (
|
||||||
<List key={account.npub} account={account} />
|
<Account key={account} account={account} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function List({ account }: { account: Account }) {
|
function Account({ account }: { account: string }) {
|
||||||
const [key, setKey] = useState(account.nsec);
|
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [passphase, setPassphase] = useState("");
|
|
||||||
|
|
||||||
const encrypt = async () => {
|
|
||||||
const encrypted: string = await invoke("get_encrypted_key", {
|
|
||||||
npub: account.npub,
|
|
||||||
password: passphase,
|
|
||||||
});
|
|
||||||
setKey(encrypted);
|
|
||||||
};
|
|
||||||
|
|
||||||
const copyKey = async () => {
|
const copyKey = async () => {
|
||||||
try {
|
try {
|
||||||
await writeText(key);
|
const data: string = await invoke("get_private_key", { npub: account });
|
||||||
|
await writeText(data);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e);
|
toast.error(e);
|
||||||
@@ -64,65 +48,26 @@ function List({ account }: { account: Account }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col gap-2 py-3">
|
<div className="flex items-center justify-between gap-2 py-3">
|
||||||
<User.Provider pubkey={account.npub}>
|
<User.Provider pubkey={account}>
|
||||||
<User.Root className="flex items-center gap-2">
|
<User.Root className="flex items-center gap-2">
|
||||||
<User.Avatar className="size-8 rounded-full object-cover" />
|
<User.Avatar className="object-cover rounded-full size-8" />
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<User.Name className="text-sm leading-tight" />
|
<User.Name className="text-sm leading-tight" />
|
||||||
<User.NIP05 />
|
<span className="text-sm leading-tight text-black/50 dark:text-white/50">
|
||||||
|
{displayNpub(account, 16)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</User.Root>
|
</User.Root>
|
||||||
</User.Provider>
|
</User.Provider>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex w-full flex-col gap-1">
|
<button
|
||||||
<label
|
type="button"
|
||||||
htmlFor="nsec"
|
onClick={() => copyKey()}
|
||||||
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
className="inline-flex items-center justify-center h-8 text-sm font-medium rounded-md w-36 bg-neutral-200 hover:bg-neutral-300 dark:bg-white/10 dark:hover:bg-white/20"
|
||||||
>
|
>
|
||||||
Private Key
|
{copied ? "Copied" : "Copy Private Key"}
|
||||||
</label>
|
</button>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
readOnly
|
|
||||||
name="nsec"
|
|
||||||
type="text"
|
|
||||||
value={displayNsec(key, 36)}
|
|
||||||
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => copyKey()}
|
|
||||||
className="inline-flex h-9 w-24 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-900 dark:hover:bg-neutral-700"
|
|
||||||
>
|
|
||||||
{copied ? "Copied" : "Copy"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex w-full flex-col gap-1">
|
|
||||||
<label
|
|
||||||
htmlFor="passphase"
|
|
||||||
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
|
||||||
>
|
|
||||||
Set a passphase to secure your key
|
|
||||||
</label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
name="passphase"
|
|
||||||
type="password"
|
|
||||||
value={passphase}
|
|
||||||
onChange={(e) => setPassphase(e.target.value)}
|
|
||||||
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => encrypt()}
|
|
||||||
className="inline-flex h-9 w-24 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-900 dark:hover:bg-neutral-700"
|
|
||||||
>
|
|
||||||
Update
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -48,13 +48,6 @@ function Screen() {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleZap = () => {
|
|
||||||
setNewSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
zap: !newSettings.zap,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleNsfw = () => {
|
const toggleNsfw = () => {
|
||||||
setNewSettings((prev) => ({
|
setNewSettings((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -84,14 +77,14 @@ function Screen() {
|
|||||||
}, [newSettings]);
|
}, [newSettings]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-xl">
|
<div className="w-full max-w-xl mx-auto">
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex flex-col gap-2">
|
<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
|
General
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex flex-col divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl px-3">
|
<div className="flex flex-col px-3 divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl">
|
||||||
<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">
|
<div className="flex-1">
|
||||||
<h3 className="font-medium">Notification</h3>
|
<h3 className="font-medium">Notification</h3>
|
||||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
@@ -99,7 +92,7 @@ function Screen() {
|
|||||||
notifications from Lume directly.
|
notifications from Lume directly.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-36 flex justify-end shrink-0">
|
<div className="flex justify-end w-36 shrink-0">
|
||||||
<Switch.Root
|
<Switch.Root
|
||||||
checked={newSettings.notification}
|
checked={newSettings.notification}
|
||||||
onClick={() => toggleNofitication()}
|
onClick={() => toggleNofitication()}
|
||||||
@@ -109,7 +102,7 @@ function Screen() {
|
|||||||
</Switch.Root>
|
</Switch.Root>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full items-start justify-between gap-4 py-3">
|
<div className="flex items-start justify-between w-full gap-4 py-3">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="font-medium">Relay Hint</h3>
|
<h3 className="font-medium">Relay Hint</h3>
|
||||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
@@ -117,7 +110,7 @@ function Screen() {
|
|||||||
Relay Hint when fetching a new event.
|
Relay Hint when fetching a new event.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-36 flex justify-end shrink-0">
|
<div className="flex justify-end w-36 shrink-0">
|
||||||
<Switch.Root
|
<Switch.Root
|
||||||
checked={newSettings.gossip}
|
checked={newSettings.gossip}
|
||||||
onClick={() => toggleGossip()}
|
onClick={() => toggleGossip()}
|
||||||
@@ -127,7 +120,7 @@ function Screen() {
|
|||||||
</Switch.Root>
|
</Switch.Root>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full items-start justify-between gap-4 py-3">
|
<div className="flex items-start justify-between w-full gap-4 py-3">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="font-medium">Enhanced Privacy</h3>
|
<h3 className="font-medium">Enhanced Privacy</h3>
|
||||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
@@ -135,7 +128,7 @@ function Screen() {
|
|||||||
previews in plain text.
|
previews in plain text.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-36 flex justify-end shrink-0">
|
<div className="flex justify-end w-36 shrink-0">
|
||||||
<Switch.Root
|
<Switch.Root
|
||||||
checked={newSettings.enhancedPrivacy}
|
checked={newSettings.enhancedPrivacy}
|
||||||
onClick={() => toggleEnhancedPrivacy()}
|
onClick={() => toggleEnhancedPrivacy()}
|
||||||
@@ -145,14 +138,14 @@ function Screen() {
|
|||||||
</Switch.Root>
|
</Switch.Root>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full items-start justify-between gap-4 py-3">
|
<div className="flex items-start justify-between w-full gap-4 py-3">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="font-medium">Auto Update</h3>
|
<h3 className="font-medium">Auto Update</h3>
|
||||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
Automatically download and install new version.
|
Automatically download and install new version.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-36 flex justify-end shrink-0">
|
<div className="flex justify-end w-36 shrink-0">
|
||||||
<Switch.Root
|
<Switch.Root
|
||||||
checked={newSettings.autoUpdate}
|
checked={newSettings.autoUpdate}
|
||||||
onClick={() => toggleAutoUpdate()}
|
onClick={() => toggleAutoUpdate()}
|
||||||
@@ -162,7 +155,7 @@ function Screen() {
|
|||||||
</Switch.Root>
|
</Switch.Root>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full items-start justify-between gap-4 py-3">
|
<div className="flex items-start justify-between w-full gap-4 py-3">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="font-semibold">Filter sensitive content</h3>
|
<h3 className="font-semibold">Filter sensitive content</h3>
|
||||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
@@ -170,7 +163,7 @@ function Screen() {
|
|||||||
Warning tag, it's may include NSFW content.
|
Warning tag, it's may include NSFW content.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-36 flex justify-end shrink-0">
|
<div className="flex justify-end w-36 shrink-0">
|
||||||
<Switch.Root
|
<Switch.Root
|
||||||
checked={newSettings.nsfw}
|
checked={newSettings.nsfw}
|
||||||
onClick={() => toggleNsfw()}
|
onClick={() => toggleNsfw()}
|
||||||
@@ -183,39 +176,21 @@ function Screen() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<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">
|
||||||
Interface
|
Interface
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex flex-col divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl px-3">
|
<div className="flex flex-col px-3 divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl">
|
||||||
<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-semibold">Zap</h3>
|
|
||||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
|
||||||
Show the Zap button in each note and user's profile screen,
|
|
||||||
use for send bitcoin tip to other users.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="w-36 flex justify-end shrink-0">
|
|
||||||
<Switch.Root
|
|
||||||
checked={newSettings.zap}
|
|
||||||
onClick={() => toggleZap()}
|
|
||||||
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
|
|
||||||
>
|
|
||||||
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
|
||||||
</Switch.Root>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex w-full items-start justify-between gap-4 py-3">
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="font-semibold">Appearance</h3>
|
<h3 className="font-semibold">Appearance</h3>
|
||||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
* Require restarting the app to take effect.
|
* Require restarting the app to take effect.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-36 flex justify-end shrink-0">
|
<div className="flex justify-end w-36 shrink-0">
|
||||||
<select
|
<select
|
||||||
name="theme"
|
name="theme"
|
||||||
className="bg-transparent shadow-none outline-none rounded-lg border-1 border-black/10 dark:border-white/10 py-1 w-24"
|
className="w-24 py-1 bg-transparent rounded-lg shadow-none outline-none border-1 border-black/10 dark:border-white/10"
|
||||||
defaultValue={settings.theme}
|
defaultValue={settings.theme}
|
||||||
onChange={(e) => changeTheme(e.target.value)}
|
onChange={(e) => changeTheme(e.target.value)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,17 +2,18 @@ import { Conversation } from "@/components/conversation";
|
|||||||
import { Quote } from "@/components/quote";
|
import { Quote } from "@/components/quote";
|
||||||
import { RepostNote } from "@/components/repost";
|
import { RepostNote } from "@/components/repost";
|
||||||
import { TextNote } from "@/components/text";
|
import { TextNote } from "@/components/text";
|
||||||
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
|
import { ArrowRightCircleIcon } from "@lume/icons";
|
||||||
import { NostrQuery } from "@lume/system";
|
import { NostrQuery } from "@lume/system";
|
||||||
import {
|
import {
|
||||||
type ColumnRouteSearch,
|
type ColumnRouteSearch,
|
||||||
type NostrEvent,
|
type NostrEvent,
|
||||||
|
type Topic,
|
||||||
Kind,
|
Kind,
|
||||||
Topic,
|
|
||||||
} from "@lume/types";
|
} from "@lume/types";
|
||||||
import { Spinner } from "@lume/ui";
|
import { Spinner } from "@lume/ui";
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { Link, createFileRoute, redirect } from "@tanstack/react-router";
|
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||||
|
import { useCallback } from "react";
|
||||||
import { Virtualizer } from "virtua";
|
import { Virtualizer } from "virtua";
|
||||||
|
|
||||||
export const Route = createFileRoute("/topic")({
|
export const Route = createFileRoute("/topic")({
|
||||||
@@ -26,7 +27,6 @@ export const Route = createFileRoute("/topic")({
|
|||||||
beforeLoad: async ({ search }) => {
|
beforeLoad: async ({ search }) => {
|
||||||
const key = `lume_topic_${search.label}`;
|
const key = `lume_topic_${search.label}`;
|
||||||
const topics = (await NostrQuery.getNstore(key)) as unknown as Topic[];
|
const topics = (await NostrQuery.getNstore(key)) as unknown as Topic[];
|
||||||
const settings = await NostrQuery.getSettings();
|
|
||||||
|
|
||||||
if (!topics?.length) {
|
if (!topics?.length) {
|
||||||
throw redirect({
|
throw redirect({
|
||||||
@@ -38,16 +38,13 @@ export const Route = createFileRoute("/topic")({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let hashtags: string[] = [];
|
const hashtags: string[] = [];
|
||||||
|
|
||||||
for (const topic of topics) {
|
for (const topic of topics) {
|
||||||
hashtags.push(...topic.content);
|
hashtags.push(...topic.content);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return { hashtags };
|
||||||
hashtags,
|
|
||||||
settings,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
component: Screen,
|
component: Screen,
|
||||||
});
|
});
|
||||||
@@ -74,34 +71,39 @@ export function Screen() {
|
|||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const renderItem = (event: NostrEvent) => {
|
const renderItem = useCallback(
|
||||||
if (!event) return;
|
(event: NostrEvent) => {
|
||||||
switch (event.kind) {
|
if (!event) return;
|
||||||
case Kind.Repost:
|
switch (event.kind) {
|
||||||
return <RepostNote key={event.id} event={event} />;
|
case Kind.Repost:
|
||||||
default: {
|
return <RepostNote key={event.id} event={event} />;
|
||||||
const isConversation =
|
default: {
|
||||||
event.tags.filter((tag) => tag[0] === "e" && tag[3] !== "mention")
|
const isConversation =
|
||||||
.length > 0;
|
event.tags.filter((tag) => tag[0] === "e" && tag[3] !== "mention")
|
||||||
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0;
|
.length > 0;
|
||||||
|
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0;
|
||||||
|
|
||||||
if (isConversation) {
|
if (isConversation) {
|
||||||
return <Conversation key={event.id} event={event} className="mb-3" />;
|
return (
|
||||||
|
<Conversation key={event.id} 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" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isQuote) {
|
|
||||||
return <Quote key={event.id} event={event} className="mb-3" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <TextNote key={event.id} event={event} className="mb-3" />;
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
};
|
[data],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none">
|
<div className="w-full h-full p-2 overflow-y-auto scrollbar-none">
|
||||||
{isFetching && !isLoading && !isFetchingNextPage ? (
|
{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">
|
<div className="flex items-center justify-center gap-2">
|
||||||
<Spinner className="size-5" />
|
<Spinner className="size-5" />
|
||||||
<span className="text-sm font-medium">Fetching new notes...</span>
|
<span className="text-sm font-medium">Fetching new notes...</span>
|
||||||
@@ -109,7 +111,7 @@ export function Screen() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{isLoading ? (
|
{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" />
|
<Spinner className="size-5" />
|
||||||
<span className="text-sm font-medium">Loading...</span>
|
<span className="text-sm font-medium">Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -126,7 +128,7 @@ export function Screen() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => fetchNextPage()}
|
onClick={() => fetchNextPage()}
|
||||||
disabled={isFetchingNextPage || isLoading}
|
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 h-12 gap-2 px-3 font-medium rounded-xl bg-neutral-100 hover:bg-neutral-50 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
|
||||||
>
|
>
|
||||||
{isFetchingNextPage ? (
|
{isFetchingNextPage ? (
|
||||||
<Spinner className="size-5" />
|
<Spinner className="size-5" />
|
||||||
@@ -145,31 +147,12 @@ export function Screen() {
|
|||||||
|
|
||||||
function Empty() {
|
function Empty() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col py-10 gap-10">
|
<div className="flex flex-col gap-10 py-10">
|
||||||
<div className="text-center flex flex-col items-center justify-center">
|
<div className="flex flex-col items-center justify-center text-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="flex flex-col items-center justify-end mb-8 overflow-hidden bg-blue-100 rounded-full size-24 dark:bg-blue-900">
|
||||||
<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 className="w-12 h-16 rounded-t-lg bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-medium">Your newsfeed is empty</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
"@astrojs/check": "^0.5.10",
|
"@astrojs/check": "^0.5.10",
|
||||||
"@astrojs/tailwind": "^5.1.0",
|
"@astrojs/tailwind": "^5.1.0",
|
||||||
"@fontsource/alice": "^5.0.13",
|
"@fontsource/alice": "^5.0.13",
|
||||||
"astro": "^4.10.1",
|
"astro": "^4.10.2",
|
||||||
"astro-seo-meta": "^4.1.1",
|
"astro-seo-meta": "^4.1.1",
|
||||||
"astro-seo-schema": "^4.0.2",
|
"astro-seo-schema": "^4.0.2",
|
||||||
"schema-dts": "^1.1.2",
|
"schema-dts": "^1.1.2",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
"tauri": "tauri"
|
"tauri": "tauri"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "1.7.3",
|
"@biomejs/biome": "^1.8.1",
|
||||||
"@tauri-apps/cli": "2.0.0-beta.20",
|
"@tauri-apps/cli": "2.0.0-beta.20",
|
||||||
"turbo": "^1.13.4"
|
"turbo": "^1.13.4"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -125,3 +125,4 @@ export * from "./src/key";
|
|||||||
export * from "./src/remote";
|
export * from "./src/remote";
|
||||||
export * from "./src/nsfw";
|
export * from "./src/nsfw";
|
||||||
export * from "./src/visit";
|
export * from "./src/visit";
|
||||||
|
export * from "./src/pow";
|
||||||
|
|||||||
@@ -1,19 +1,9 @@
|
|||||||
export function PlusSquareIcon(props: JSX.IntrinsicElements["svg"]) {
|
export function PlusSquareIcon(props: JSX.IntrinsicElements["svg"]) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<path
|
<path
|
||||||
stroke="currentColor"
|
fill="currentColor"
|
||||||
strokeLinecap="round"
|
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"
|
||||||
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"
|
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|||||||
19
packages/icons/src/pow.tsx
Normal file
19
packages/icons/src/pow.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,7 +5,9 @@
|
|||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lume/utils": "workspace:^",
|
"@lume/utils": "workspace:^",
|
||||||
"@tanstack/react-query": "^5.40.1",
|
"@tanstack/query-persist-client-core": "^5.45.0",
|
||||||
|
"@tanstack/react-query": "^5.45.0",
|
||||||
|
"nostr-tools": "^2.7.0",
|
||||||
"react": "^18.3.1"
|
"react": "^18.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Metadata } from "@lume/types";
|
import type { Metadata } from "@lume/types";
|
||||||
import { Result, commands } from "./commands";
|
import { type Result, commands } from "./commands";
|
||||||
import { Window } from "@tauri-apps/api/window";
|
import { Window } from "@tauri-apps/api/window";
|
||||||
|
|
||||||
export class NostrAccount {
|
export class NostrAccount {
|
||||||
@@ -123,24 +123,24 @@ export class NostrAccount {
|
|||||||
const query = await commands.getBalance();
|
const query = await commands.getBalance();
|
||||||
|
|
||||||
if (query.status === "ok") {
|
if (query.status === "ok") {
|
||||||
return parseInt(query.data);
|
return Number.parseInt(query.data);
|
||||||
} else {
|
} else {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async getContactList() {
|
static async isContactListEmpty() {
|
||||||
const query = await commands.getContactList();
|
const query = await commands.isContactListEmpty();
|
||||||
|
|
||||||
if (query.status === "ok") {
|
if (query.status === "ok") {
|
||||||
return query.data;
|
return query.data;
|
||||||
} else {
|
} else {
|
||||||
return [];
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async follow(pubkey: string, alias?: string) {
|
static async checkContact(pubkey: string) {
|
||||||
const query = await commands.follow(pubkey, alias);
|
const query = await commands.checkContact(pubkey);
|
||||||
|
|
||||||
if (query.status === "ok") {
|
if (query.status === "ok") {
|
||||||
return query.data;
|
return query.data;
|
||||||
@@ -149,8 +149,8 @@ export class NostrAccount {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async unfollow(pubkey: string) {
|
static async toggleContact(pubkey: string, alias?: string) {
|
||||||
const query = await commands.unfollow(pubkey);
|
const query = await commands.toggleContact(pubkey, alias);
|
||||||
|
|
||||||
if (query.status === "ok") {
|
if (query.status === "ok") {
|
||||||
return query.data;
|
return query.data;
|
||||||
|
|||||||
@@ -76,6 +76,14 @@ try {
|
|||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async getPrivateKey(npub: string) : Promise<Result<string, string>> {
|
||||||
|
try {
|
||||||
|
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 connectRemoteAccount(uri: string) : Promise<Result<string, string>> {
|
async connectRemoteAccount(uri: string) : Promise<Result<string, string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("connect_remote_account", { uri }) };
|
return { status: "ok", data: await TAURI_INVOKE("connect_remote_account", { uri }) };
|
||||||
@@ -140,9 +148,9 @@ try {
|
|||||||
else return { status: "error", error: e as any };
|
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 {
|
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) {
|
} catch (e) {
|
||||||
if(e instanceof Error) throw e;
|
if(e instanceof Error) throw e;
|
||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
@@ -156,17 +164,25 @@ try {
|
|||||||
else return { status: "error", error: e as any };
|
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 {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("follow", { id, alias }) };
|
return { status: "ok", data: await TAURI_INVOKE("is_contact_list_empty") };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if(e instanceof Error) throw e;
|
if(e instanceof Error) throw e;
|
||||||
else return { status: "error", error: e as any };
|
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 {
|
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) {
|
} catch (e) {
|
||||||
if(e instanceof Error) throw e;
|
if(e instanceof Error) throw e;
|
||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
@@ -244,6 +260,14 @@ try {
|
|||||||
else return { status: "error", error: e as any };
|
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>> {
|
async getEvent(id: string) : Promise<Result<RichEvent, string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("get_event", { id }) };
|
return { status: "ok", data: await TAURI_INVOKE("get_event", { id }) };
|
||||||
@@ -252,6 +276,14 @@ try {
|
|||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
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>> {
|
async getReplies(id: string) : Promise<Result<RichEvent[], string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("get_replies", { id }) };
|
return { status: "ok", data: await TAURI_INVOKE("get_replies", { id }) };
|
||||||
@@ -268,9 +300,17 @@ try {
|
|||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async getLocalEvents(pubkeys: string[], until: string | null) : Promise<Result<RichEvent[], string>> {
|
async getLocalEvents(until: string | null) : Promise<Result<RichEvent[], string>> {
|
||||||
try {
|
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 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) {
|
} catch (e) {
|
||||||
if(e instanceof Error) throw e;
|
if(e instanceof Error) throw e;
|
||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
@@ -292,9 +332,17 @@ try {
|
|||||||
else return { status: "error", error: e as any };
|
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 {
|
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) {
|
} catch (e) {
|
||||||
if(e instanceof Error) throw e;
|
if(e instanceof Error) throw e;
|
||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import type { EventWithReplies, Kind, Meta, NostrEvent } from "@lume/types";
|
import type {
|
||||||
import { commands } from "./commands";
|
EventTag,
|
||||||
import { generateContentTags } from "@lume/utils";
|
EventWithReplies,
|
||||||
|
Kind,
|
||||||
|
Meta,
|
||||||
|
NostrEvent,
|
||||||
|
} from "@lume/types";
|
||||||
|
import { type Result, commands } from "./commands";
|
||||||
|
|
||||||
export class LumeEvent {
|
export class LumeEvent {
|
||||||
public id: string;
|
public id: string;
|
||||||
@@ -10,8 +15,8 @@ export class LumeEvent {
|
|||||||
public tags: string[][];
|
public tags: string[][];
|
||||||
public content: string;
|
public content: string;
|
||||||
public sig: string;
|
public sig: string;
|
||||||
public relay?: string;
|
|
||||||
public meta: Meta;
|
public meta: Meta;
|
||||||
|
public relay?: string;
|
||||||
#raw: NostrEvent;
|
#raw: NostrEvent;
|
||||||
|
|
||||||
constructor(event: NostrEvent) {
|
constructor(event: NostrEvent) {
|
||||||
@@ -19,49 +24,52 @@ export class LumeEvent {
|
|||||||
Object.assign(this, event);
|
Object.assign(this, event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
get mentions() {
|
||||||
return this.tags.filter((tag) => tag[0] === "p").map((tag) => tag[1]);
|
return this.tags.filter((tag) => tag[0] === "p").map((tag) => tag[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
static getEventThread(tags: string[][], gossip = true) {
|
get repostId() {
|
||||||
let root: string = null;
|
return this.tags.find((tag) => tag[0] === "e")[1];
|
||||||
let reply: string = null;
|
}
|
||||||
|
|
||||||
// Get all event references from tags, ignore mention
|
get thread() {
|
||||||
const events = tags.filter((el) => el[0] === "e" && el[3] !== "mention");
|
let root: EventTag = null;
|
||||||
|
let reply: EventTag = null;
|
||||||
|
|
||||||
if (gossip) {
|
// Get all event references from tags, ignore mention.
|
||||||
const relays = tags
|
const events = this.tags.filter(
|
||||||
.filter((el) => el[0] === "e" && el[2]?.length)
|
(el) => el[0] === "e" && el[3] !== "mention",
|
||||||
.map((tag) => tag[2]);
|
);
|
||||||
|
|
||||||
if (relays.length >= 1) {
|
if (events.length === 1) {
|
||||||
for (const relay of relays) {
|
root = { id: events[0][1], relayHint: events[0][2] };
|
||||||
try {
|
}
|
||||||
if (relay.length) {
|
|
||||||
const url = new URL(relay);
|
if (events.length === 2) {
|
||||||
commands
|
root = { id: events[0][1], relayHint: events[0][2] };
|
||||||
.connectRelay(url.toString())
|
reply = { id: events[1][1], relayHint: events[1][2] };
|
||||||
.then(() => console.log("[relay hint]: ", url));
|
}
|
||||||
}
|
|
||||||
} catch (e) {
|
if (events.length > 2) {
|
||||||
console.log("[relay hint] error: ", relay);
|
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) {
|
// Fix some rare case when root same as reply
|
||||||
root = events[0][1];
|
if (root && reply && root.id === reply.id) {
|
||||||
}
|
|
||||||
|
|
||||||
if (events.length > 1) {
|
|
||||||
root = events.find((el) => el[3] === "root")?.[1] ?? events[0][1];
|
|
||||||
reply = events.find((el) => el[3] === "reply")?.[1] ?? events[1][1];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fix some rare case when root === reply
|
|
||||||
if (root && reply && root === reply) {
|
|
||||||
reply = null;
|
reply = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +79,17 @@ 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);
|
const query = await commands.getReplies(id);
|
||||||
|
|
||||||
if (query.status === "ok") {
|
if (query.status === "ok") {
|
||||||
@@ -99,14 +117,6 @@ export class LumeEvent {
|
|||||||
for (const tag of tags) {
|
for (const tag of tags) {
|
||||||
const rootIndex = events.findIndex((el) => el.id === tag[1]);
|
const rootIndex = events.findIndex((el) => el.id === tag[1]);
|
||||||
|
|
||||||
// Relay Hint
|
|
||||||
if (tag[2]?.length) {
|
|
||||||
const url = new URL(tag[2]);
|
|
||||||
commands
|
|
||||||
.connectRelay(url.toString())
|
|
||||||
.then(() => console.log("[relay hint]: ", url));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rootIndex !== -1) {
|
if (rootIndex !== -1) {
|
||||||
const rootEvent = events[rootIndex];
|
const rootEvent = events[rootIndex];
|
||||||
|
|
||||||
@@ -129,63 +139,8 @@ export class LumeEvent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async publish(
|
public async zap(amount: number, message: string) {
|
||||||
content: string,
|
const query = await commands.zapEvent(this.id, amount.toString(), message);
|
||||||
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.raw) 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);
|
|
||||||
|
|
||||||
if (query.status === "ok") {
|
if (query.status === "ok") {
|
||||||
return query.data;
|
return query.data;
|
||||||
@@ -223,4 +178,26 @@ export class LumeEvent {
|
|||||||
throw new Error(query.error);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { NostrQuery } from "../query";
|
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({
|
const { isLoading, isError, data } = useQuery({
|
||||||
queryKey: ["event", id],
|
queryKey: ["event", id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try {
|
try {
|
||||||
const event = await NostrQuery.getEvent(id);
|
const event = await NostrQuery.getEvent(id, relayHint);
|
||||||
return event;
|
return event;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(e);
|
throw new Error(e);
|
||||||
@@ -17,6 +18,10 @@ export function useEvent(id: string) {
|
|||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
staleTime: Number.POSITIVE_INFINITY,
|
staleTime: Number.POSITIVE_INFINITY,
|
||||||
retry: 2,
|
retry: 2,
|
||||||
|
persister: experimental_createPersister({
|
||||||
|
storage: localStorage,
|
||||||
|
maxAge: 1000 * 60 * 60 * 12, // 12 hours
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
return { isLoading, isError, data };
|
return { isLoading, isError, data };
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Metadata } from "@lume/types";
|
import type { Metadata } from "@lume/types";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { commands } from "../commands";
|
import { commands } from "../commands";
|
||||||
|
import { experimental_createPersister } from "@tanstack/query-persist-client-core";
|
||||||
|
|
||||||
export function useProfile(pubkey: string, embed?: string) {
|
export function useProfile(pubkey: string, embed?: string) {
|
||||||
const {
|
const {
|
||||||
@@ -8,7 +9,7 @@ export function useProfile(pubkey: string, embed?: string) {
|
|||||||
isError,
|
isError,
|
||||||
data: profile,
|
data: profile,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ["user", pubkey],
|
queryKey: ["profile", pubkey],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try {
|
try {
|
||||||
if (embed) return JSON.parse(embed) as Metadata;
|
if (embed) return JSON.parse(embed) as Metadata;
|
||||||
@@ -30,6 +31,10 @@ export function useProfile(pubkey: string, embed?: string) {
|
|||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
staleTime: Number.POSITIVE_INFINITY,
|
staleTime: Number.POSITIVE_INFINITY,
|
||||||
retry: 2,
|
retry: 2,
|
||||||
|
persister: experimental_createPersister({
|
||||||
|
storage: localStorage,
|
||||||
|
maxAge: 1000 * 60 * 60 * 24, // 24 hours
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
return { isLoading, isError, profile };
|
return { isLoading, isError, profile };
|
||||||
|
|||||||
@@ -4,5 +4,4 @@ export * from "./query";
|
|||||||
export * from "./window";
|
export * from "./window";
|
||||||
export * from "./commands";
|
export * from "./commands";
|
||||||
export * from "./hooks/useEvent";
|
export * from "./hooks/useEvent";
|
||||||
export * from "./hooks/useInfiniteEvents";
|
|
||||||
export * from "./hooks/useProfile";
|
export * from "./hooks/useProfile";
|
||||||
|
|||||||
@@ -5,13 +5,15 @@ import type {
|
|||||||
Relay,
|
Relay,
|
||||||
Settings,
|
Settings,
|
||||||
} from "@lume/types";
|
} from "@lume/types";
|
||||||
import { commands } from "./commands";
|
import { type Result, type RichEvent, commands } from "./commands";
|
||||||
import { resolveResource } from "@tauri-apps/api/path";
|
import { resolveResource } from "@tauri-apps/api/path";
|
||||||
import { readFile, readTextFile } from "@tauri-apps/plugin-fs";
|
import { readFile, readTextFile } from "@tauri-apps/plugin-fs";
|
||||||
import { isPermissionGranted } from "@tauri-apps/plugin-notification";
|
import { isPermissionGranted } from "@tauri-apps/plugin-notification";
|
||||||
import { open } from "@tauri-apps/plugin-dialog";
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { relaunch } from "@tauri-apps/plugin-process";
|
import { relaunch } from "@tauri-apps/plugin-process";
|
||||||
|
import { nip19 } from "nostr-tools";
|
||||||
|
import { LumeEvent } from "./event";
|
||||||
|
|
||||||
enum NSTORE_KEYS {
|
enum NSTORE_KEYS {
|
||||||
settings = "lume_user_settings",
|
settings = "lume_user_settings",
|
||||||
@@ -19,6 +21,24 @@ enum NSTORE_KEYS {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class NostrQuery {
|
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) {
|
static async upload(filePath?: string) {
|
||||||
const allowExts = [
|
const allowExts = [
|
||||||
"png",
|
"png",
|
||||||
@@ -78,7 +98,9 @@ export class NostrQuery {
|
|||||||
const query = await commands.getNotifications();
|
const query = await commands.getNotifications();
|
||||||
|
|
||||||
if (query.status === "ok") {
|
if (query.status === "ok") {
|
||||||
const events = query.data.map((item) => JSON.parse(item) as NostrEvent);
|
const data = query.data.map((item) => JSON.parse(item) as NostrEvent);
|
||||||
|
const events = data.map((ev) => new LumeEvent(ev));
|
||||||
|
|
||||||
return events;
|
return events;
|
||||||
} else {
|
} else {
|
||||||
console.error(query.error);
|
console.error(query.error);
|
||||||
@@ -98,9 +120,32 @@ export class NostrQuery {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async getEvent(id: string) {
|
static async getEvent(id: string, hint?: string) {
|
||||||
const normalize: string = id.replace("nostr:", "").replace(/[^\w\s]/gi, "");
|
// Validate ID
|
||||||
const query = await commands.getEvent(normalize);
|
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") {
|
if (query.status === "ok") {
|
||||||
const data = query.data;
|
const data = query.data;
|
||||||
@@ -110,13 +155,46 @@ export class NostrQuery {
|
|||||||
raw.meta = data.parsed;
|
raw.meta = data.parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
return raw;
|
const event = new LumeEvent(raw);
|
||||||
|
|
||||||
|
return event;
|
||||||
} else {
|
} else {
|
||||||
console.log("[getEvent]: ", query.error);
|
console.log("[getEvent]: ", query.error);
|
||||||
return null;
|
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) {
|
static async getUserEvents(pubkey: string, asOf?: number) {
|
||||||
const until: string = asOf && asOf > 0 ? asOf.toString() : undefined;
|
const until: string = asOf && asOf > 0 ? asOf.toString() : undefined;
|
||||||
const query = await commands.getEventsBy(pubkey, until);
|
const query = await commands.getEventsBy(pubkey, until);
|
||||||
@@ -140,9 +218,21 @@ export class NostrQuery {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async getLocalEvents(pubkeys: string[], asOf?: number) {
|
static async getLocalEvents(asOf?: number) {
|
||||||
const until: string = asOf && asOf > 0 ? asOf.toString() : undefined;
|
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 data = NostrQuery.#toLumeEvents(query.data);
|
||||||
|
return data;
|
||||||
|
} else {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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") {
|
if (query.status === "ok") {
|
||||||
const data = query.data.map((item) => {
|
const data = query.data.map((item) => {
|
||||||
@@ -211,8 +301,6 @@ export class NostrQuery {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static async verifyNip05(pubkey: string, nip05?: string) {
|
static async verifyNip05(pubkey: string, nip05?: string) {
|
||||||
if (!nip05) return false;
|
|
||||||
|
|
||||||
const query = await commands.verifyNip05(pubkey, nip05);
|
const query = await commands.verifyNip05(pubkey, nip05);
|
||||||
|
|
||||||
if (query.status === "ok") {
|
if (query.status === "ok") {
|
||||||
@@ -299,7 +387,9 @@ export class NostrQuery {
|
|||||||
return systemColumns;
|
return systemColumns;
|
||||||
}
|
}
|
||||||
|
|
||||||
return columns;
|
// Filter "open" column
|
||||||
|
// Reason: deprecated
|
||||||
|
return columns.filter((col) => col.label !== "open");
|
||||||
} else {
|
} else {
|
||||||
return systemColumns;
|
return systemColumns;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { NostrEvent } from "@lume/types";
|
import type { NostrEvent } from "@lume/types";
|
||||||
|
import type { LumeEvent } from "./event";
|
||||||
import { commands } from "./commands";
|
import { commands } from "./commands";
|
||||||
|
|
||||||
export class LumeWindow {
|
export class LumeWindow {
|
||||||
static async openEvent(event: NostrEvent) {
|
static async openEvent(event: NostrEvent | LumeEvent) {
|
||||||
const eTags = event.tags.filter((tag) => tag[0] === "e" || tag[0] === "q");
|
const eTags = event.tags.filter((tag) => tag[0] === "e" || tag[0] === "q");
|
||||||
const root: string =
|
const root: string =
|
||||||
eTags.find((el) => el[3] === "root")?.[1] ?? eTags[0]?.[1];
|
eTags.find((el) => el[3] === "root")?.[1] ?? eTags[0]?.[1];
|
||||||
@@ -38,12 +39,18 @@ export class LumeWindow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async openEditor(reply_to?: string, quote = false) {
|
static async openEditor(reply_to?: string, quote?: string) {
|
||||||
let url: string;
|
let url: string;
|
||||||
|
|
||||||
if (reply_to) {
|
if (reply_to) {
|
||||||
url = `/editor?reply_to=${reply_to}"e=${quote}`;
|
url = `/editor?reply_to=${reply_to}`;
|
||||||
} else {
|
}
|
||||||
|
|
||||||
|
if (quote?.length) {
|
||||||
|
url = `/editor?quote=${quote}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!reply_to?.length && !quote?.length) {
|
||||||
url = "/editor";
|
url = "/editor";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
5
packages/types/index.d.ts
vendored
5
packages/types/index.d.ts
vendored
@@ -52,6 +52,11 @@ export interface EventWithReplies extends NostrEvent {
|
|||||||
replies: Array<NostrEvent>;
|
replies: Array<NostrEvent>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EventTag {
|
||||||
|
id: string;
|
||||||
|
relayHint: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Metadata {
|
export interface Metadata {
|
||||||
name?: string;
|
name?: string;
|
||||||
display_name?: string;
|
display_name?: string;
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const Carousel = <T,>({ items, renderItem }: CarouselProps<T>) => {
|
|||||||
snapPointIndexes,
|
snapPointIndexes,
|
||||||
} = useSnapCarousel();
|
} = useSnapCarousel();
|
||||||
return (
|
return (
|
||||||
<div className="group relative">
|
<div className="relative group">
|
||||||
<ul
|
<ul
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
className="relative flex overflow-auto snap-x scrollbar-none"
|
className="relative flex overflow-auto snap-x scrollbar-none"
|
||||||
@@ -39,9 +39,10 @@ export const Carousel = <T,>({ items, renderItem }: CarouselProps<T>) => {
|
|||||||
</ul>
|
</ul>
|
||||||
<div
|
<div
|
||||||
aria-hidden
|
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
|
<button
|
||||||
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"size-11 rounded-full bg-black/50 backdrop-blur-sm flex items-center justify-center text-white",
|
"size-11 rounded-full bg-black/50 backdrop-blur-sm flex items-center justify-center text-white",
|
||||||
activePageIndex <= 0 ? "opacity-50" : "",
|
activePageIndex <= 0 ? "opacity-50" : "",
|
||||||
@@ -51,6 +52,7 @@ export const Carousel = <T,>({ items, renderItem }: CarouselProps<T>) => {
|
|||||||
<ArrowLeftIcon className="size-6" />
|
<ArrowLeftIcon className="size-6" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"size-11 rounded-full bg-black/50 backdrop-blur-sm flex items-center justify-center text-white",
|
"size-11 rounded-full bg-black/50 backdrop-blur-sm flex items-center justify-center text-white",
|
||||||
activePageIndex <= 0 ? "opacity-50" : "",
|
activePageIndex <= 0 ? "opacity-50" : "",
|
||||||
@@ -60,7 +62,7 @@ export const Carousel = <T,>({ items, renderItem }: CarouselProps<T>) => {
|
|||||||
<ArrowRightIcon className="size-6" />
|
<ArrowRightIcon className="size-6" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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}
|
{activePageIndex + 1} / {pages.length}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export function Spinner({
|
|||||||
<span
|
<span
|
||||||
aria-hidden
|
aria-hidden
|
||||||
style={{ display: "contents", visibility: "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 }}
|
{...{ inert: true ? "" : undefined }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -2,16 +2,6 @@ export * from "./src/constants";
|
|||||||
export * from "./src/delay";
|
export * from "./src/delay";
|
||||||
export * from "./src/formater";
|
export * from "./src/formater";
|
||||||
export * from "./src/editor";
|
export * from "./src/editor";
|
||||||
export * from "./src/nip01";
|
|
||||||
export * from "./src/nip94";
|
|
||||||
export * from "./src/notification";
|
|
||||||
export * from "./src/cn";
|
export * from "./src/cn";
|
||||||
export * from "./src/image";
|
|
||||||
export * from "./src/parser";
|
|
||||||
export * from "./src/groupBy";
|
|
||||||
export * from "./src/invoice";
|
export * from "./src/invoice";
|
||||||
export * from "./src/update";
|
export * from "./src/update";
|
||||||
|
|
||||||
// Hooks
|
|
||||||
export * from "./src/hooks/useNetworkStatus";
|
|
||||||
export * from "./src/hooks/useOpenGraph";
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
"access": "public"
|
"access": "public"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.40.1",
|
|
||||||
"bitcoin-units": "^1.0.0",
|
"bitcoin-units": "^1.0.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.11",
|
"dayjs": "^1.11.11",
|
||||||
@@ -17,7 +16,7 @@
|
|||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"slate": "^0.103.0",
|
"slate": "^0.103.0",
|
||||||
"slate-react": "^0.104.0"
|
"slate-react": "^0.105.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@lume/tsconfig": "workspace:^",
|
"@lume/tsconfig": "workspace:^",
|
||||||
|
|||||||
@@ -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[]>());
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
export function getImageMeta(
|
|
||||||
url: string,
|
|
||||||
): Promise<{ width: number; height: number }> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const img = new Image();
|
|
||||||
img.onload = () => resolve(img);
|
|
||||||
img.onerror = () => reject();
|
|
||||||
img.src = url;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -7,10 +7,10 @@ export function decodeZapInvoice(tags?: string[][]) {
|
|||||||
|
|
||||||
const decodedInvoice = decode(invoice);
|
const decodedInvoice = decode(invoice);
|
||||||
const amountSection = decodedInvoice.sections.find(
|
const amountSection = decodedInvoice.sections.find(
|
||||||
(s: any) => s.name === "amount",
|
(s: { name: string }) => s.name === "amount",
|
||||||
);
|
);
|
||||||
|
|
||||||
const amount = parseInt(amountSection.value);
|
const amount = Number.parseInt(amountSection.value);
|
||||||
const displayValue = getBitcoinDisplayValues(amount);
|
const displayValue = getBitcoinDisplayValues(amount);
|
||||||
|
|
||||||
return displayValue;
|
return displayValue;
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
import { nip19 } from "nostr-tools";
|
|
||||||
import type { EventPointer, ProfilePointer } from "nostr-tools/lib/types/nip19";
|
|
||||||
|
|
||||||
// Borrow from NDK
|
|
||||||
// https://github.com/nostr-dev-kit/ndk/blob/master/ndk/src/events/content-tagger.ts
|
|
||||||
export async function generateContentTags(content: string) {
|
|
||||||
const promises: Promise<void>[] = [];
|
|
||||||
const tags: string[][] = [];
|
|
||||||
|
|
||||||
const tagRegex = /(@|nostr:)(npub|nprofile|note|nevent|naddr)[a-zA-Z0-9]+/g;
|
|
||||||
const hashtagRegex = /#(\w+)/g;
|
|
||||||
|
|
||||||
const addTagIfNew = (t: string[]) => {
|
|
||||||
if (!tags.find((t2) => t2[0] === t[0] && t2[1] === t[1])) {
|
|
||||||
tags.push(t);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
content = content.replace(tagRegex, (tag) => {
|
|
||||||
try {
|
|
||||||
const entity = tag.split(/(@|nostr:)/)[2];
|
|
||||||
const { type, data } = nip19.decode(entity);
|
|
||||||
let t: string[] | undefined;
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case "npub":
|
|
||||||
t = ["p", data as string];
|
|
||||||
break;
|
|
||||||
case "nprofile":
|
|
||||||
t = ["p", (data as ProfilePointer).pubkey as string];
|
|
||||||
break;
|
|
||||||
case "note":
|
|
||||||
promises.push(
|
|
||||||
new Promise(async (resolve) => {
|
|
||||||
addTagIfNew(["e", data, "", "mention"]);
|
|
||||||
resolve();
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case "nevent":
|
|
||||||
promises.push(
|
|
||||||
new Promise(async (resolve) => {
|
|
||||||
let { id, relays, author } = data as EventPointer;
|
|
||||||
|
|
||||||
// If the nevent doesn't have a relay specified, try to get one
|
|
||||||
if (!relays || relays.length === 0) {
|
|
||||||
relays = [""];
|
|
||||||
}
|
|
||||||
|
|
||||||
addTagIfNew(["e", id, relays[0], "mention"]);
|
|
||||||
if (author) addTagIfNew(["p", author]);
|
|
||||||
resolve();
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case "naddr":
|
|
||||||
promises.push(
|
|
||||||
new Promise(async (resolve) => {
|
|
||||||
const id = [data.kind, data.pubkey, data.identifier].join(":");
|
|
||||||
let relays = data.relays ?? [];
|
|
||||||
|
|
||||||
// If the naddr doesn't have a relay specified, try to get one
|
|
||||||
if (relays.length === 0) {
|
|
||||||
relays = [""];
|
|
||||||
}
|
|
||||||
|
|
||||||
addTagIfNew(["a", id, relays[0], "mention"]);
|
|
||||||
addTagIfNew(["p", data.pubkey]);
|
|
||||||
resolve();
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
return tag;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (t) addTagIfNew(t);
|
|
||||||
|
|
||||||
return `nostr:${entity}`;
|
|
||||||
} catch (error) {
|
|
||||||
return tag;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await Promise.all(promises);
|
|
||||||
|
|
||||||
content = content.replace(hashtagRegex, (tag, word) => {
|
|
||||||
const t: string[] = ["t", word];
|
|
||||||
if (!tags.find((t2) => t2[0] === t[0] && t2[1] === t[1])) {
|
|
||||||
tags.push(t);
|
|
||||||
}
|
|
||||||
return tag; // keep the original tag in the content
|
|
||||||
});
|
|
||||||
|
|
||||||
return { content, tags };
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
export function fileType(url: string) {
|
|
||||||
if (url.match(/\.(jpg|jpeg|gif|png|webp|avif|tiff)$/)) {
|
|
||||||
return "image";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.match(/\.(mp4|mov|webm|wmv|flv|mts|avi|ogv|mkv)$/)) {
|
|
||||||
return "video";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.match(/\.(mp3|ogg|wav)$/)) {
|
|
||||||
return "audio";
|
|
||||||
}
|
|
||||||
|
|
||||||
return "link";
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import {
|
|
||||||
isPermissionGranted,
|
|
||||||
requestPermission,
|
|
||||||
sendNotification,
|
|
||||||
} from "@tauri-apps/plugin-notification";
|
|
||||||
|
|
||||||
export async function sendNativeNotification(content: string, title?: string) {
|
|
||||||
let permissionGranted = await isPermissionGranted();
|
|
||||||
if (!permissionGranted) {
|
|
||||||
const permission = await requestPermission();
|
|
||||||
permissionGranted = permission === "granted";
|
|
||||||
}
|
|
||||||
if (permissionGranted) {
|
|
||||||
sendNotification({ title: title || "Lume", body: content });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
import { Meta } from "@lume/types";
|
|
||||||
import { IMAGES, NOSTR_EVENTS, NOSTR_MENTIONS, VIDEOS } from "./constants";
|
|
||||||
import { fetch } from "@tauri-apps/plugin-http";
|
|
||||||
|
|
||||||
export async function parser(
|
|
||||||
content: string,
|
|
||||||
abortController?: AbortController,
|
|
||||||
) {
|
|
||||||
const words = content.split(/( |\n)/);
|
|
||||||
const urls = content.match(/(https?:\/\/\S+)/gi);
|
|
||||||
|
|
||||||
// Extract hashtags
|
|
||||||
const hashtags = words.filter((word) => word.startsWith("#"));
|
|
||||||
|
|
||||||
// Extract nostr events
|
|
||||||
const events = words.filter((word) =>
|
|
||||||
NOSTR_EVENTS.some((el) => word.startsWith(el)),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Extract nostr mentions
|
|
||||||
const mentions = words.filter((word) =>
|
|
||||||
NOSTR_MENTIONS.some((el) => word.startsWith(el)),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Extract images and videos from content
|
|
||||||
const images: string[] = [];
|
|
||||||
const videos: string[] = [];
|
|
||||||
|
|
||||||
let text: string = content;
|
|
||||||
|
|
||||||
if (urls) {
|
|
||||||
for (const url of urls) {
|
|
||||||
const ext = new URL(url).pathname.split(".")[1];
|
|
||||||
|
|
||||||
if (IMAGES.includes(ext)) {
|
|
||||||
text = text.replace(url, "");
|
|
||||||
images.push(url);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (VIDEOS.includes(ext)) {
|
|
||||||
text = text.replace(url, "");
|
|
||||||
videos.push(url);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (urls.length <= 3) {
|
|
||||||
try {
|
|
||||||
const res = await fetch(url, {
|
|
||||||
method: "HEAD",
|
|
||||||
priority: "high",
|
|
||||||
signal: abortController.signal,
|
|
||||||
// proxy: settings.proxy;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.headers.get("Content-Type").startsWith("image")) {
|
|
||||||
text = text.replace(url, "");
|
|
||||||
images.push(url);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const meta: Meta = {
|
|
||||||
content: text.trim(),
|
|
||||||
images,
|
|
||||||
videos,
|
|
||||||
events,
|
|
||||||
mentions,
|
|
||||||
hashtags,
|
|
||||||
};
|
|
||||||
|
|
||||||
return meta;
|
|
||||||
}
|
|
||||||
632
pnpm-lock.yaml
generated
632
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
1
src-tauri/Cargo.lock
generated
1
src-tauri/Cargo.lock
generated
@@ -2803,6 +2803,7 @@ dependencies = [
|
|||||||
"nostr-sdk",
|
"nostr-sdk",
|
||||||
"objc",
|
"objc",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
|
"regex",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ reqwest = "0.12.4"
|
|||||||
url = "2.5.0"
|
url = "2.5.0"
|
||||||
futures = "0.3.30"
|
futures = "0.3.30"
|
||||||
linkify = "0.10.0"
|
linkify = "0.10.0"
|
||||||
|
regex = "1.10.4"
|
||||||
|
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
cocoa = "0.25.0"
|
cocoa = "0.25.0"
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
[
|
[
|
||||||
{ "label": "onboarding", "name": "Onboarding", "content": "/onboarding" },
|
{ "label": "onboarding", "name": "Onboarding", "content": "/onboarding" },
|
||||||
{ "label": "lume_newsfeed", "name": "Newsfeed", "content": "/newsfeed" },
|
{ "label": "lume_newsfeed", "name": "Newsfeed", "content": "/newsfeed" },
|
||||||
{ "label": "lume_topic", "name": "Topic", "content": "/topic" },
|
{ "label": "lume_topic", "name": "Topic", "content": "/topic" }
|
||||||
{ "label": "lume_group", "name": "Group", "content": "/group" },
|
|
||||||
{ "label": "open", "name": "Open", "content": "/open" }
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -3,24 +3,24 @@
|
|||||||
windows_subsystem = "windows"
|
windows_subsystem = "windows"
|
||||||
)]
|
)]
|
||||||
|
|
||||||
pub mod commands;
|
|
||||||
pub mod fns;
|
|
||||||
pub mod nostr;
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
extern crate cocoa;
|
extern crate cocoa;
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
#[macro_use]
|
#[macro_use]
|
||||||
extern crate objc;
|
extern crate objc;
|
||||||
|
|
||||||
use nostr_sdk::prelude::*;
|
|
||||||
use std::{
|
use std::{
|
||||||
fs,
|
fs,
|
||||||
io::{self, BufRead},
|
io::{self, BufRead},
|
||||||
str::FromStr,
|
str::FromStr,
|
||||||
};
|
};
|
||||||
use tauri::{path::BaseDirectory, Manager};
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
use serde::Serialize;
|
||||||
|
use tauri::{Manager, path::BaseDirectory};
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
use tauri::tray::{MouseButtonState, TrayIconEvent};
|
||||||
use tauri_nspanel::ManagerExt;
|
use tauri_nspanel::ManagerExt;
|
||||||
use tauri_plugin_decorum::WebviewWindowExt;
|
use tauri_plugin_decorum::WebviewWindowExt;
|
||||||
|
|
||||||
@@ -30,11 +30,15 @@ use crate::fns::{
|
|||||||
update_menubar_appearance,
|
update_menubar_appearance,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
pub mod commands;
|
||||||
use tauri::tray::{MouseButtonState, TrayIconEvent};
|
pub mod fns;
|
||||||
|
pub mod nostr;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
pub struct Nostr {
|
pub struct Nostr {
|
||||||
|
#[serde(skip_serializing)]
|
||||||
client: Client,
|
client: Client,
|
||||||
|
contact_list: Mutex<Vec<Contact>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
@@ -50,6 +54,7 @@ fn main() {
|
|||||||
nostr::keys::create_account,
|
nostr::keys::create_account,
|
||||||
nostr::keys::save_account,
|
nostr::keys::save_account,
|
||||||
nostr::keys::get_encrypted_key,
|
nostr::keys::get_encrypted_key,
|
||||||
|
nostr::keys::get_private_key,
|
||||||
nostr::keys::connect_remote_account,
|
nostr::keys::connect_remote_account,
|
||||||
nostr::keys::load_account,
|
nostr::keys::load_account,
|
||||||
nostr::keys::event_to_bech32,
|
nostr::keys::event_to_bech32,
|
||||||
@@ -60,8 +65,9 @@ fn main() {
|
|||||||
nostr::metadata::get_contact_list,
|
nostr::metadata::get_contact_list,
|
||||||
nostr::metadata::set_contact_list,
|
nostr::metadata::set_contact_list,
|
||||||
nostr::metadata::create_profile,
|
nostr::metadata::create_profile,
|
||||||
nostr::metadata::follow,
|
nostr::metadata::is_contact_list_empty,
|
||||||
nostr::metadata::unfollow,
|
nostr::metadata::check_contact,
|
||||||
|
nostr::metadata::toggle_contact,
|
||||||
nostr::metadata::get_nstore,
|
nostr::metadata::get_nstore,
|
||||||
nostr::metadata::set_nstore,
|
nostr::metadata::set_nstore,
|
||||||
nostr::metadata::set_nwc,
|
nostr::metadata::set_nwc,
|
||||||
@@ -71,13 +77,17 @@ fn main() {
|
|||||||
nostr::metadata::zap_event,
|
nostr::metadata::zap_event,
|
||||||
nostr::metadata::friend_to_friend,
|
nostr::metadata::friend_to_friend,
|
||||||
nostr::metadata::get_notifications,
|
nostr::metadata::get_notifications,
|
||||||
|
nostr::event::get_event_meta,
|
||||||
nostr::event::get_event,
|
nostr::event::get_event,
|
||||||
|
nostr::event::get_event_from,
|
||||||
nostr::event::get_replies,
|
nostr::event::get_replies,
|
||||||
nostr::event::get_events_by,
|
nostr::event::get_events_by,
|
||||||
nostr::event::get_local_events,
|
nostr::event::get_local_events,
|
||||||
|
nostr::event::get_group_events,
|
||||||
nostr::event::get_global_events,
|
nostr::event::get_global_events,
|
||||||
nostr::event::get_hashtag_events,
|
nostr::event::get_hashtag_events,
|
||||||
nostr::event::publish,
|
nostr::event::publish,
|
||||||
|
nostr::event::reply,
|
||||||
nostr::event::repost,
|
nostr::event::repost,
|
||||||
commands::folder::show_in_folder,
|
commands::folder::show_in_folder,
|
||||||
commands::window::create_column,
|
commands::window::create_column,
|
||||||
@@ -184,7 +194,10 @@ fn main() {
|
|||||||
client.connect().await;
|
client.connect().await;
|
||||||
|
|
||||||
// Update global state
|
// Update global state
|
||||||
app.handle().manage(Nostr { client })
|
app.handle().manage(Nostr {
|
||||||
|
client,
|
||||||
|
contact_list: Mutex::new(vec![]),
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use specta::Type;
|
|||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
use crate::Nostr;
|
use crate::Nostr;
|
||||||
use crate::nostr::utils::{dedup_event, Meta, parse_event};
|
use crate::nostr::utils::{create_event_tags, dedup_event, Meta, parse_event};
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Type)]
|
#[derive(Debug, Serialize, Type)]
|
||||||
pub struct RichEvent {
|
pub struct RichEvent {
|
||||||
@@ -15,6 +15,13 @@ pub struct RichEvent {
|
|||||||
pub parsed: Option<Meta>,
|
pub parsed: Option<Meta>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn get_event_meta(content: &str) -> Result<Meta, ()> {
|
||||||
|
let meta = parse_event(content).await;
|
||||||
|
Ok(meta)
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn get_event(id: &str, state: State<'_, Nostr>) -> Result<RichEvent, String> {
|
pub async fn get_event(id: &str, state: State<'_, Nostr>) -> Result<RichEvent, String> {
|
||||||
@@ -22,14 +29,7 @@ pub async fn get_event(id: &str, state: State<'_, Nostr>) -> Result<RichEvent, S
|
|||||||
let event_id: Option<EventId> = match Nip19::from_bech32(id) {
|
let event_id: Option<EventId> = match Nip19::from_bech32(id) {
|
||||||
Ok(val) => match val {
|
Ok(val) => match val {
|
||||||
Nip19::EventId(id) => Some(id),
|
Nip19::EventId(id) => Some(id),
|
||||||
Nip19::Event(event) => {
|
Nip19::Event(event) => Some(event.event_id),
|
||||||
let relays = event.relays;
|
|
||||||
for relay in relays.into_iter() {
|
|
||||||
let _ = client.add_relay(&relay).await.unwrap_or_default();
|
|
||||||
client.connect_relay(&relay).await.unwrap_or_default();
|
|
||||||
}
|
|
||||||
Some(event.event_id)
|
|
||||||
}
|
|
||||||
_ => None,
|
_ => None,
|
||||||
},
|
},
|
||||||
Err(_) => match EventId::from_hex(id) {
|
Err(_) => match EventId::from_hex(id) {
|
||||||
@@ -40,10 +40,8 @@ pub async fn get_event(id: &str, state: State<'_, Nostr>) -> Result<RichEvent, S
|
|||||||
|
|
||||||
match event_id {
|
match event_id {
|
||||||
Some(id) => {
|
Some(id) => {
|
||||||
let filter = Filter::new().id(id);
|
|
||||||
|
|
||||||
match client
|
match client
|
||||||
.get_events_of(vec![filter], Some(Duration::from_secs(10)))
|
.get_events_of(vec![Filter::new().id(id)], Some(Duration::from_secs(10)))
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(events) => {
|
Ok(events) => {
|
||||||
@@ -67,6 +65,63 @@ pub async fn get_event(id: &str, state: State<'_, Nostr>) -> Result<RichEvent, S
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn get_event_from(
|
||||||
|
id: &str,
|
||||||
|
relay_hint: &str,
|
||||||
|
state: State<'_, Nostr>,
|
||||||
|
) -> Result<RichEvent, String> {
|
||||||
|
let client = &state.client;
|
||||||
|
|
||||||
|
let event_id: Option<EventId> = match Nip19::from_bech32(id) {
|
||||||
|
Ok(val) => match val {
|
||||||
|
Nip19::EventId(id) => Some(id),
|
||||||
|
Nip19::Event(event) => Some(event.event_id),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
Err(_) => match EventId::from_hex(id) {
|
||||||
|
Ok(val) => Some(val),
|
||||||
|
Err(_) => None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add relay hint to relay pool
|
||||||
|
if let Err(err) = client.add_relay(relay_hint).await {
|
||||||
|
return Err(err.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client.connect_relay(relay_hint).await).is_ok() {
|
||||||
|
match event_id {
|
||||||
|
Some(id) => {
|
||||||
|
match client
|
||||||
|
.get_events_from(vec![relay_hint], vec![Filter::new().id(id)], None)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(events) => {
|
||||||
|
if let Some(event) = events.first() {
|
||||||
|
let raw = event.as_json();
|
||||||
|
let parsed = if event.kind == Kind::TextNote {
|
||||||
|
Some(parse_event(&event.content).await)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(RichEvent { raw, parsed })
|
||||||
|
} else {
|
||||||
|
Err("Cannot found this event with current relay list".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => Err(err.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => Err("Event ID is not valid.".into()),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Err("Relay connection failed.".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn get_replies(id: &str, state: State<'_, Nostr>) -> Result<Vec<RichEvent>, String> {
|
pub async fn get_replies(id: &str, state: State<'_, Nostr>) -> Result<Vec<RichEvent>, String> {
|
||||||
@@ -146,25 +201,19 @@ pub async fn get_events_by(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn get_local_events(
|
pub async fn get_local_events(
|
||||||
pubkeys: Vec<String>,
|
|
||||||
until: Option<&str>,
|
until: Option<&str>,
|
||||||
state: State<'_, Nostr>,
|
state: State<'_, Nostr>,
|
||||||
) -> Result<Vec<RichEvent>, String> {
|
) -> Result<Vec<RichEvent>, String> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
|
let contact_list = state.contact_list.lock().unwrap().clone();
|
||||||
|
|
||||||
let as_of = match until {
|
let as_of = match until {
|
||||||
Some(until) => Timestamp::from_str(until).unwrap(),
|
Some(until) => Timestamp::from_str(until).unwrap(),
|
||||||
None => Timestamp::now(),
|
None => Timestamp::now(),
|
||||||
};
|
};
|
||||||
let authors: Vec<PublicKey> = pubkeys
|
|
||||||
.into_iter()
|
let authors: Vec<PublicKey> = contact_list.into_iter().map(|f| f.public_key).collect();
|
||||||
.map(|p| {
|
|
||||||
if p.starts_with("npub1") {
|
|
||||||
PublicKey::from_bech32(p).unwrap()
|
|
||||||
} else {
|
|
||||||
PublicKey::from_hex(p).unwrap()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
.kinds(vec![Kind::TextNote, Kind::Repost])
|
.kinds(vec![Kind::TextNote, Kind::Repost])
|
||||||
.limit(20)
|
.limit(20)
|
||||||
@@ -177,6 +226,7 @@ pub async fn get_local_events(
|
|||||||
{
|
{
|
||||||
Ok(events) => {
|
Ok(events) => {
|
||||||
let dedup = dedup_event(&events, false);
|
let dedup = dedup_event(&events, false);
|
||||||
|
|
||||||
let futures = dedup.into_iter().map(|ev| async move {
|
let futures = dedup.into_iter().map(|ev| async move {
|
||||||
let raw = ev.as_json();
|
let raw = ev.as_json();
|
||||||
let parsed = if ev.kind == Kind::TextNote {
|
let parsed = if ev.kind == Kind::TextNote {
|
||||||
@@ -187,6 +237,64 @@ pub async fn get_local_events(
|
|||||||
|
|
||||||
RichEvent { raw, parsed }
|
RichEvent { raw, parsed }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let rich_events = join_all(futures).await;
|
||||||
|
|
||||||
|
Ok(rich_events)
|
||||||
|
}
|
||||||
|
Err(err) => Err(err.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn get_group_events(
|
||||||
|
public_keys: Vec<&str>,
|
||||||
|
until: Option<&str>,
|
||||||
|
state: State<'_, Nostr>,
|
||||||
|
) -> Result<Vec<RichEvent>, String> {
|
||||||
|
let client = &state.client;
|
||||||
|
|
||||||
|
let as_of = match until {
|
||||||
|
Some(until) => Timestamp::from_str(until).unwrap(),
|
||||||
|
None => Timestamp::now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let authors: Vec<PublicKey> = public_keys
|
||||||
|
.into_iter()
|
||||||
|
.map(|p| {
|
||||||
|
if p.starts_with("npub1") {
|
||||||
|
PublicKey::from_bech32(p).unwrap()
|
||||||
|
} else {
|
||||||
|
PublicKey::from_hex(p).unwrap()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let filter = Filter::new()
|
||||||
|
.kinds(vec![Kind::TextNote, Kind::Repost])
|
||||||
|
.limit(20)
|
||||||
|
.until(as_of)
|
||||||
|
.authors(authors);
|
||||||
|
|
||||||
|
match client
|
||||||
|
.get_events_of(vec![filter], Some(Duration::from_secs(10)))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(events) => {
|
||||||
|
let dedup = dedup_event(&events, false);
|
||||||
|
|
||||||
|
let futures = dedup.into_iter().map(|ev| async move {
|
||||||
|
let raw = ev.as_json();
|
||||||
|
let parsed = if ev.kind == Kind::TextNote {
|
||||||
|
Some(parse_event(&ev.content).await)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
RichEvent { raw, parsed }
|
||||||
|
});
|
||||||
|
|
||||||
let rich_events = join_all(futures).await;
|
let rich_events = join_all(futures).await;
|
||||||
|
|
||||||
Ok(rich_events)
|
Ok(rich_events)
|
||||||
@@ -278,14 +386,126 @@ pub async fn get_hashtag_events(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn publish(
|
pub async fn publish(
|
||||||
content: &str,
|
content: String,
|
||||||
tags: Vec<Vec<&str>>,
|
warning: Option<String>,
|
||||||
|
difficulty: Option<u8>,
|
||||||
state: State<'_, Nostr>,
|
state: State<'_, Nostr>,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
let event_tags = tags.into_iter().map(|val| Tag::parse(&val).unwrap());
|
|
||||||
|
|
||||||
match client.publish_text_note(content, event_tags).await {
|
// Create tags from content
|
||||||
|
let mut tags = create_event_tags(&content);
|
||||||
|
|
||||||
|
// Add content-warning tag if present
|
||||||
|
if let Some(reason) = warning {
|
||||||
|
let t = TagStandard::ContentWarning {
|
||||||
|
reason: Some(reason),
|
||||||
|
};
|
||||||
|
let tag = Tag::from(t);
|
||||||
|
tags.push(tag)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get signer
|
||||||
|
let signer = match client.signer().await {
|
||||||
|
Ok(signer) => signer,
|
||||||
|
Err(_) => return Err("Signer is required.".into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get public key
|
||||||
|
let public_key = signer.public_key().await.unwrap();
|
||||||
|
|
||||||
|
// Create unsigned event
|
||||||
|
let unsigned_event = match difficulty {
|
||||||
|
Some(num) => EventBuilder::text_note(content, tags).to_unsigned_pow_event(public_key, num),
|
||||||
|
None => EventBuilder::text_note(content, tags).to_unsigned_event(public_key),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Publish
|
||||||
|
match signer.sign_event(unsigned_event).await {
|
||||||
|
Ok(event) => match client.send_event(event).await {
|
||||||
|
Ok(event_id) => Ok(event_id.to_bech32().unwrap()),
|
||||||
|
Err(err) => Err(err.to_string()),
|
||||||
|
},
|
||||||
|
Err(err) => Err(err.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn reply(
|
||||||
|
content: String,
|
||||||
|
to: String,
|
||||||
|
root: Option<String>,
|
||||||
|
state: State<'_, Nostr>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let client = &state.client;
|
||||||
|
let database = client.database();
|
||||||
|
|
||||||
|
// Create tags from content
|
||||||
|
let mut tags = create_event_tags(&content);
|
||||||
|
|
||||||
|
let reply_id = match EventId::from_hex(to) {
|
||||||
|
Ok(val) => val,
|
||||||
|
Err(_) => return Err("Event is not valid.".into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
match database
|
||||||
|
.query(vec![Filter::new().id(reply_id)], Order::Desc)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(events) => {
|
||||||
|
if let Some(event) = events.into_iter().next() {
|
||||||
|
let relay_hint =
|
||||||
|
if let Some(relays) = database.event_seen_on_relays(event.id).await.unwrap() {
|
||||||
|
relays.into_iter().next().map(UncheckedUrl::new)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let t = TagStandard::Event {
|
||||||
|
event_id: event.id,
|
||||||
|
relay_url: relay_hint,
|
||||||
|
marker: Some(Marker::Reply),
|
||||||
|
public_key: Some(event.pubkey),
|
||||||
|
};
|
||||||
|
let tag = Tag::from(t);
|
||||||
|
tags.push(tag)
|
||||||
|
} else {
|
||||||
|
return Err("Reply event is not found.".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => return Err(err.to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(id) = root {
|
||||||
|
let root_id = match EventId::from_hex(id) {
|
||||||
|
Ok(val) => val,
|
||||||
|
Err(_) => return Err("Event is not valid.".into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Ok(events) = database
|
||||||
|
.query(vec![Filter::new().id(root_id)], Order::Desc)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
if let Some(event) = events.into_iter().next() {
|
||||||
|
let relay_hint =
|
||||||
|
if let Some(relays) = database.event_seen_on_relays(event.id).await.unwrap() {
|
||||||
|
relays.into_iter().next().map(UncheckedUrl::new)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let t = TagStandard::Event {
|
||||||
|
event_id: event.id,
|
||||||
|
relay_url: relay_hint,
|
||||||
|
marker: Some(Marker::Root),
|
||||||
|
public_key: Some(event.pubkey),
|
||||||
|
};
|
||||||
|
let tag = Tag::from(t);
|
||||||
|
tags.push(tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match client.publish_text_note(content, tags).await {
|
||||||
Ok(event_id) => Ok(event_id.to_bech32().unwrap()),
|
Ok(event_id) => Ok(event_id.to_bech32().unwrap()),
|
||||||
Err(err) => Err(err.to_string()),
|
Err(err) => Err(err.to_string()),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
use crate::Nostr;
|
use std::str::FromStr;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use keyring::Entry;
|
use keyring::Entry;
|
||||||
use keyring_search::{Limit, List, Search};
|
use keyring_search::{Limit, List, Search};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use specta::Type;
|
use specta::Type;
|
||||||
use std::str::FromStr;
|
|
||||||
use std::time::Duration;
|
|
||||||
use tauri::{EventTarget, Manager, State};
|
use tauri::{EventTarget, Manager, State};
|
||||||
use tauri_plugin_notification::NotificationExt;
|
use tauri_plugin_notification::NotificationExt;
|
||||||
|
|
||||||
|
use crate::Nostr;
|
||||||
|
|
||||||
#[derive(Serialize, Type)]
|
#[derive(Serialize, Type)]
|
||||||
pub struct Account {
|
pub struct Account {
|
||||||
npub: String,
|
npub: String,
|
||||||
@@ -139,22 +141,29 @@ pub async fn load_account(
|
|||||||
let signer = client.signer().await.unwrap();
|
let signer = client.signer().await.unwrap();
|
||||||
let public_key = signer.public_key().await.unwrap();
|
let public_key = signer.public_key().await.unwrap();
|
||||||
|
|
||||||
let filter = Filter::new()
|
// Get user's contact list
|
||||||
.author(public_key)
|
let contacts = client
|
||||||
.kind(Kind::RelayList)
|
.get_contact_list(Some(Duration::from_secs(10)))
|
||||||
.limit(1);
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Update state
|
||||||
|
*state.contact_list.lock().unwrap() = contacts;
|
||||||
|
|
||||||
// Connect to user's relay (NIP-65)
|
// Connect to user's relay (NIP-65)
|
||||||
// #TODO: Let rust-nostr handle it
|
|
||||||
if let Ok(events) = client
|
if let Ok(events) = client
|
||||||
.get_events_of(vec![filter], Some(Duration::from_secs(10)))
|
.get_events_of(
|
||||||
|
vec![Filter::new()
|
||||||
|
.author(public_key)
|
||||||
|
.kind(Kind::RelayList)
|
||||||
|
.limit(1)],
|
||||||
|
Some(Duration::from_secs(10)),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
if let Some(event) = events.first() {
|
if let Some(event) = events.first() {
|
||||||
let relay_list = nip65::extract_relay_list(event);
|
let relay_list = nip65::extract_relay_list(event);
|
||||||
for item in relay_list.into_iter() {
|
for item in relay_list.into_iter() {
|
||||||
println!("connecting to relay: {} - {:?}", item.0, item.1);
|
|
||||||
|
|
||||||
let relay_url = item.0.to_string();
|
let relay_url = item.0.to_string();
|
||||||
let opts = match item.1 {
|
let opts = match item.1 {
|
||||||
Some(val) => {
|
Some(val) => {
|
||||||
@@ -164,7 +173,7 @@ pub async fn load_account(
|
|||||||
RelayOptions::new().write(true).read(false)
|
RelayOptions::new().write(true).read(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => RelayOptions::new(),
|
None => RelayOptions::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add relay to relay pool
|
// Add relay to relay pool
|
||||||
@@ -175,6 +184,7 @@ pub async fn load_account(
|
|||||||
|
|
||||||
// Connect relay
|
// Connect relay
|
||||||
client.connect_relay(relay_url).await.unwrap_or_default();
|
client.connect_relay(relay_url).await.unwrap_or_default();
|
||||||
|
println!("connecting to relay: {} - {:?}", item.0, item.1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -360,6 +370,19 @@ pub fn get_encrypted_key(npub: &str, password: &str) -> Result<String, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command(async)]
|
||||||
|
#[specta::specta]
|
||||||
|
pub fn get_private_key(npub: &str) -> Result<String, String> {
|
||||||
|
let keyring = Entry::new(npub, "nostr_secret").unwrap();
|
||||||
|
|
||||||
|
if let Ok(nsec) = keyring.get_password() {
|
||||||
|
let secret_key = SecretKey::from_bech32(nsec).unwrap();
|
||||||
|
Ok(secret_key.to_bech32().unwrap())
|
||||||
|
} else {
|
||||||
|
Err("Key not found".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub fn event_to_bech32(id: &str, relays: Vec<String>) -> Result<String, ()> {
|
pub fn event_to_bech32(id: &str, relays: Vec<String>) -> Result<String, ()> {
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
use super::get_latest_event;
|
use std::{str::FromStr, time::Duration};
|
||||||
use crate::Nostr;
|
|
||||||
use keyring::Entry;
|
use keyring::Entry;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use std::{str::FromStr, time::Duration};
|
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::Nostr;
|
||||||
|
|
||||||
|
use super::get_latest_event;
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn get_current_user_profile(state: State<'_, Nostr>) -> Result<String, String> {
|
pub async fn get_current_user_profile(state: State<'_, Nostr>) -> Result<String, String> {
|
||||||
@@ -50,7 +53,7 @@ pub async fn get_profile(id: &str, state: State<'_, Nostr>) -> Result<String, St
|
|||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
let public_key: Option<PublicKey> = match Nip19::from_bech32(id) {
|
let public_key: Option<PublicKey> = match Nip19::from_bech32(id) {
|
||||||
Ok(val) => match val {
|
Ok(val) => match val {
|
||||||
Nip19::Pubkey(pubkey) => Some(pubkey),
|
Nip19::Pubkey(key) => Some(key),
|
||||||
Nip19::Profile(profile) => {
|
Nip19::Profile(profile) => {
|
||||||
let relays = profile.relays;
|
let relays = profile.relays;
|
||||||
for relay in relays.into_iter() {
|
for relay in relays.into_iter() {
|
||||||
@@ -94,9 +97,12 @@ pub async fn get_profile(id: &str, state: State<'_, Nostr>) -> Result<String, St
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn set_contact_list(pubkeys: Vec<&str>, state: State<'_, Nostr>) -> Result<bool, String> {
|
pub async fn set_contact_list(
|
||||||
|
public_keys: Vec<&str>,
|
||||||
|
state: State<'_, Nostr>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
let contact_list: Vec<Contact> = pubkeys
|
let contact_list: Vec<Contact> = public_keys
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|p| match PublicKey::from_hex(p) {
|
.filter_map(|p| match PublicKey::from_hex(p) {
|
||||||
Ok(pk) => Some(Contact::new(pk, None, Some(""))),
|
Ok(pk) => Some(Contact::new(pk, None, Some(""))),
|
||||||
@@ -174,52 +180,54 @@ pub async fn create_profile(
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn follow(
|
pub async fn is_contact_list_empty(state: State<'_, Nostr>) -> Result<bool, ()> {
|
||||||
id: &str,
|
let contact_list = state.contact_list.lock().unwrap();
|
||||||
alias: Option<&str>,
|
Ok(contact_list.is_empty())
|
||||||
state: State<'_, Nostr>,
|
}
|
||||||
) -> Result<String, String> {
|
|
||||||
let client = &state.client;
|
|
||||||
let public_key = match PublicKey::from_str(id) {
|
|
||||||
Ok(pk) => pk,
|
|
||||||
Err(_) => return Err("Invalid public key.".into()),
|
|
||||||
};
|
|
||||||
let contact = Contact::new(public_key, None, alias); // TODO: Add relay_url
|
|
||||||
let contact_list_result = client.get_contact_list(Some(Duration::from_secs(10))).await;
|
|
||||||
|
|
||||||
match contact_list_result {
|
#[tauri::command]
|
||||||
Ok(mut old_list) => {
|
#[specta::specta]
|
||||||
old_list.push(contact);
|
pub async fn check_contact(hex: &str, state: State<'_, Nostr>) -> Result<bool, ()> {
|
||||||
let new_list = old_list.into_iter();
|
let contact_list = state.contact_list.lock().unwrap();
|
||||||
|
let public_key = PublicKey::from_str(hex).unwrap();
|
||||||
|
|
||||||
match client.set_contact_list(new_list).await {
|
match contact_list.iter().position(|x| x.public_key == public_key) {
|
||||||
Ok(event_id) => Ok(event_id.to_string()),
|
Some(_) => Ok(true),
|
||||||
Err(err) => Err(err.to_string()),
|
None => Ok(false),
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(err) => Err(err.to_string()),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn unfollow(id: &str, state: State<'_, Nostr>) -> Result<String, String> {
|
pub async fn toggle_contact(
|
||||||
|
hex: &str,
|
||||||
|
alias: Option<&str>,
|
||||||
|
state: State<'_, Nostr>,
|
||||||
|
) -> Result<String, String> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
let public_key = match PublicKey::from_str(id) {
|
|
||||||
Ok(pk) => pk,
|
|
||||||
Err(_) => return Err("Invalid public key.".into()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let contact_list_result = client.get_contact_list(Some(Duration::from_secs(10))).await;
|
match client.get_contact_list(None).await {
|
||||||
|
Ok(mut contact_list) => {
|
||||||
|
let public_key = PublicKey::from_str(hex).unwrap();
|
||||||
|
|
||||||
match contact_list_result {
|
match contact_list.iter().position(|x| x.public_key == public_key) {
|
||||||
Ok(old_list) => {
|
Some(index) => {
|
||||||
let contacts: Vec<Contact> = old_list
|
// Remove contact
|
||||||
.into_iter()
|
contact_list.remove(index);
|
||||||
.filter(|contact| contact.public_key != public_key)
|
}
|
||||||
.collect();
|
None => {
|
||||||
|
// TODO: Add relay_url
|
||||||
|
let new_contact = Contact::new(public_key, None, alias);
|
||||||
|
// Add new contact
|
||||||
|
contact_list.push(new_contact);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
match client.set_contact_list(contacts).await {
|
// Update local state
|
||||||
|
state.contact_list.lock().unwrap().clone_from(&contact_list);
|
||||||
|
|
||||||
|
// Publish
|
||||||
|
match client.set_contact_list(contact_list).await {
|
||||||
Ok(event_id) => Ok(event_id.to_string()),
|
Ok(event_id) => Ok(event_id.to_string()),
|
||||||
Err(err) => Err(err.to_string()),
|
Err(err) => Err(err.to_string()),
|
||||||
}
|
}
|
||||||
@@ -365,7 +373,7 @@ pub async fn zap_profile(
|
|||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
let public_key: Option<PublicKey> = match Nip19::from_bech32(id) {
|
let public_key: Option<PublicKey> = match Nip19::from_bech32(id) {
|
||||||
Ok(val) => match val {
|
Ok(val) => match val {
|
||||||
Nip19::Pubkey(pubkey) => Some(pubkey),
|
Nip19::Pubkey(key) => Some(key),
|
||||||
Nip19::Profile(profile) => Some(profile.public_key),
|
Nip19::Profile(profile) => Some(profile.public_key),
|
||||||
_ => None,
|
_ => None,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ use std::collections::HashSet;
|
|||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
use linkify::LinkFinder;
|
use linkify::LinkFinder;
|
||||||
use nostr_sdk::{Alphabet, Event, SingleLetterTag, Tag, TagKind};
|
use nostr_sdk::{Alphabet, Event, EventId, FromBech32, PublicKey, SingleLetterTag, Tag, TagKind};
|
||||||
|
use nostr_sdk::prelude::Nip19Event;
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use specta::Type;
|
use specta::Type;
|
||||||
@@ -79,21 +80,26 @@ pub fn dedup_event(events: &[Event], nsfw: bool) -> Vec<Event> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn parse_event(content: &str) -> Meta {
|
pub async fn parse_event(content: &str) -> Meta {
|
||||||
let words: Vec<_> = content.split_whitespace().collect();
|
|
||||||
let mut finder = LinkFinder::new();
|
let mut finder = LinkFinder::new();
|
||||||
finder.url_must_have_scheme(false);
|
finder.url_must_have_scheme(false);
|
||||||
|
|
||||||
|
// Get urls
|
||||||
let urls: Vec<_> = finder.links(content).collect();
|
let urls: Vec<_> = finder.links(content).collect();
|
||||||
|
// Get words
|
||||||
|
let words: Vec<_> = content.split_whitespace().collect();
|
||||||
|
|
||||||
let hashtags = words
|
let hashtags = words
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|&&word| word.starts_with('#'))
|
.filter(|&&word| word.starts_with('#'))
|
||||||
.map(|&s| s.to_string())
|
.map(|&s| s.to_string())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
let events = words
|
let events = words
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|&&word| NOSTR_EVENTS.iter().any(|&el| word.starts_with(el)))
|
.filter(|&&word| NOSTR_EVENTS.iter().any(|&el| word.starts_with(el)))
|
||||||
.map(|&s| s.to_string())
|
.map(|&s| s.to_string())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
let mentions = words
|
let mentions = words
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|&&word| NOSTR_MENTIONS.iter().any(|&el| word.starts_with(el)))
|
.filter(|&&word| NOSTR_MENTIONS.iter().any(|&el| word.starts_with(el)))
|
||||||
@@ -118,12 +124,14 @@ pub async fn parse_event(content: &str) -> Meta {
|
|||||||
if IMAGES.contains(&ext) {
|
if IMAGES.contains(&ext) {
|
||||||
text = text.replace(url_str, "");
|
text = text.replace(url_str, "");
|
||||||
images.push(url_str.to_string());
|
images.push(url_str.to_string());
|
||||||
break;
|
// Process the next item.
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
if VIDEOS.contains(&ext) {
|
if VIDEOS.contains(&ext) {
|
||||||
text = text.replace(url_str, "");
|
text = text.replace(url_str, "");
|
||||||
videos.push(url_str.to_string());
|
videos.push(url_str.to_string());
|
||||||
break;
|
// Process the next item.
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +141,8 @@ pub async fn parse_event(content: &str) -> Meta {
|
|||||||
if content_type.to_str().unwrap_or("").starts_with("image") {
|
if content_type.to_str().unwrap_or("").starts_with("image") {
|
||||||
text = text.replace(url_str, "");
|
text = text.replace(url_str, "");
|
||||||
images.push(url_str.to_string());
|
images.push(url_str.to_string());
|
||||||
break;
|
// Process the next item.
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,6 +163,87 @@ pub async fn parse_event(content: &str) -> Meta {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn create_event_tags(content: &str) -> Vec<Tag> {
|
||||||
|
let mut tags: Vec<Tag> = vec![];
|
||||||
|
let mut tag_set: HashSet<String> = HashSet::new();
|
||||||
|
|
||||||
|
// Get words
|
||||||
|
let words: Vec<_> = content.split_whitespace().collect();
|
||||||
|
|
||||||
|
// Get mentions
|
||||||
|
let mentions = words
|
||||||
|
.iter()
|
||||||
|
.filter(|&&word| ["nostr:", "@"].iter().any(|&el| word.starts_with(el)))
|
||||||
|
.map(|&s| s.to_string())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
// Get hashtags
|
||||||
|
let hashtags = words
|
||||||
|
.iter()
|
||||||
|
.filter(|&&word| word.starts_with('#'))
|
||||||
|
.map(|&s| s.to_string())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
for mention in mentions {
|
||||||
|
let entity = mention.replace("nostr:", "").replace("@", "");
|
||||||
|
|
||||||
|
if !tag_set.contains(&entity) {
|
||||||
|
if entity.starts_with("npub") {
|
||||||
|
if let Ok(public_key) = PublicKey::from_bech32(&entity) {
|
||||||
|
let tag = Tag::public_key(public_key);
|
||||||
|
tags.push(tag);
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if entity.starts_with("nprofile") {
|
||||||
|
if let Ok(public_key) = PublicKey::from_bech32(&entity) {
|
||||||
|
let tag = Tag::public_key(public_key);
|
||||||
|
tags.push(tag);
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if entity.starts_with("note") {
|
||||||
|
if let Ok(event_id) = EventId::from_bech32(&entity) {
|
||||||
|
let hex = event_id.to_hex();
|
||||||
|
let tag = Tag::parse(&["e", &hex, "", "mention"]).unwrap();
|
||||||
|
tags.push(tag);
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if entity.starts_with("nevent") {
|
||||||
|
if let Ok(event) = Nip19Event::from_bech32(&entity) {
|
||||||
|
let hex = event.event_id.to_hex();
|
||||||
|
let relay = event.clone().relays.into_iter().next().unwrap_or("".into());
|
||||||
|
let tag = Tag::parse(&["e", &hex, &relay, "mention"]).unwrap();
|
||||||
|
|
||||||
|
if let Some(author) = event.author {
|
||||||
|
let tag = Tag::public_key(author);
|
||||||
|
tags.push(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
tags.push(tag);
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tag_set.insert(entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for hashtag in hashtags {
|
||||||
|
if !tag_set.contains(&hashtag) {
|
||||||
|
let tag = Tag::hashtag(hashtag.clone());
|
||||||
|
tags.push(tag);
|
||||||
|
tag_set.insert(hashtag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tags
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
Reference in New Issue
Block a user