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:
雨宮蓮
2024-06-17 13:52:06 +07:00
committed by GitHub
parent 7c99ed39e4
commit 843895d876
79 changed files with 1738 additions and 1975 deletions

View File

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

6
.idea/lume.iml generated
View File

@@ -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" />

View File

@@ -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"
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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" />

View File

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

View File

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

View File

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

View File

@@ -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" />

View File

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

View File

@@ -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";

View File

@@ -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";

View File

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

View File

@@ -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() {

View File

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

View File

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

View File

@@ -1,19 +1,15 @@
import { QuoteIcon } from "@lume/icons"; import { 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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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" />

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",

View File

@@ -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"
}, },

View File

@@ -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";

View File

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

View File

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

View File

@@ -5,7 +5,9 @@
"main": "./src/index.ts", "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": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import type { Metadata } from "@lume/types"; import 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 };

View File

@@ -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";

View File

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

View File

@@ -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}&quote=${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";
} }

View File

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

View File

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

View File

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

View File

@@ -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";

View File

@@ -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:^",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

1
src-tauri/Cargo.lock generated
View File

@@ -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",

View File

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

View File

@@ -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" }
] ]

View File

@@ -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(())

View File

@@ -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()),
} }

View File

@@ -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, ()> {

View File

@@ -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,
}, },

View File

@@ -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::*;