From 437cd71f7eb7a14149afec9c72a537e90df4ce2c Mon Sep 17 00:00:00 2001 From: reya <123083837+reyamir@users.noreply.github.com> Date: Tue, 7 May 2024 14:14:21 +0700 Subject: [PATCH] feat: improve editor --- apps/desktop2/src/routes/$account.home.tsx | 296 +-- apps/desktop2/src/routes/$account.tsx | 296 +-- apps/desktop2/src/routes/__root.tsx | 50 +- .../src/routes/editor/-components/media.tsx | 3 +- .../src/routes/editor/-components/mention.tsx | 83 + .../src/routes/editor/-components/pow.tsx | 40 + apps/desktop2/src/routes/editor/index.tsx | 166 +- apps/desktop2/src/routes/index.tsx | 194 +- packages/ark/src/ark.ts | 1763 +++++++++-------- packages/icons/src/mention.tsx | 14 +- packages/types/index.d.ts | 228 +-- packages/ui/src/note/buttons/reply.tsx | 1 + src-tauri/src/nostr/event.rs | 4 +- src-tauri/src/tray.rs | 34 +- 14 files changed, 1608 insertions(+), 1564 deletions(-) create mode 100644 apps/desktop2/src/routes/editor/-components/mention.tsx create mode 100644 apps/desktop2/src/routes/editor/-components/pow.tsx diff --git a/apps/desktop2/src/routes/$account.home.tsx b/apps/desktop2/src/routes/$account.home.tsx index 57b93537..34624897 100644 --- a/apps/desktop2/src/routes/$account.home.tsx +++ b/apps/desktop2/src/routes/$account.home.tsx @@ -13,179 +13,179 @@ import { useDebouncedCallback } from "use-debounce"; import { VList, type VListHandle } from "virtua"; export const Route = createFileRoute("/$account/home")({ - beforeLoad: async ({ context }) => { - try { - const ark = context.ark; - const resourcePath = await resolveResource( - "resources/system_columns.json", - ); - const systemColumns: LumeColumn[] = JSON.parse( - await readTextFile(resourcePath), - ); - const userColumns = await ark.get_columns(); + beforeLoad: async ({ context }) => { + try { + const ark = context.ark; + const resourcePath = await resolveResource( + "resources/system_columns.json", + ); + const systemColumns: LumeColumn[] = JSON.parse( + await readTextFile(resourcePath), + ); + const userColumns = await ark.get_columns(); - return { - storedColumns: !userColumns.length ? systemColumns : userColumns, - }; - } catch (e) { - console.error(String(e)); - } - }, - component: Screen, + return { + storedColumns: !userColumns.length ? systemColumns : userColumns, + }; + } catch (e) { + console.error(String(e)); + } + }, + component: Screen, }); function Screen() { - const vlistRef = useRef(null); + const vlistRef = useRef(null); - const { account } = Route.useParams(); - const { ark, storedColumns } = Route.useRouteContext(); + const { account } = Route.useParams(); + const { ark, storedColumns } = Route.useRouteContext(); - const [selectedIndex, setSelectedIndex] = useState(-1); - const [columns, setColumns] = useState(storedColumns); - const [isScroll, setIsScroll] = useState(false); - const [isResize, setIsResize] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(-1); + const [columns, setColumns] = useState(storedColumns); + const [isScroll, setIsScroll] = useState(false); + const [isResize, setIsResize] = useState(false); - const goLeft = () => { - const prevIndex = Math.max(selectedIndex - 1, 0); - setSelectedIndex(prevIndex); - vlistRef.current.scrollToIndex(prevIndex, { - align: "center", - }); - }; + const goLeft = () => { + const prevIndex = Math.max(selectedIndex - 1, 0); + setSelectedIndex(prevIndex); + vlistRef.current.scrollToIndex(prevIndex, { + align: "center", + }); + }; - const goRight = () => { - const nextIndex = Math.min(selectedIndex + 1, columns.length - 1); - setSelectedIndex(nextIndex); - vlistRef.current.scrollToIndex(nextIndex, { - align: "center", - }); - }; + const goRight = () => { + const nextIndex = Math.min(selectedIndex + 1, columns.length - 1); + setSelectedIndex(nextIndex); + vlistRef.current.scrollToIndex(nextIndex, { + align: "center", + }); + }; - const add = useDebouncedCallback((column: LumeColumn) => { - // update col label - column.label = `${column.label}-${nanoid()}`; + const add = useDebouncedCallback((column: LumeColumn) => { + // update col label + column.label = `${column.label}-${nanoid()}`; - // create new cols - const cols = [...columns]; - const openColIndex = cols.findIndex((col) => col.label === "open"); - const newCols = [ - ...cols.slice(0, openColIndex), - column, - ...cols.slice(openColIndex), - ]; + // create new cols + const cols = [...columns]; + const openColIndex = cols.findIndex((col) => col.label === "open"); + const newCols = [ + ...cols.slice(0, openColIndex), + column, + ...cols.slice(openColIndex), + ]; - setColumns(newCols); - setSelectedIndex(newCols.length); - setIsScroll(true); + setColumns(newCols); + setSelectedIndex(newCols.length); + setIsScroll(true); - // scroll to the newest column - vlistRef.current.scrollToIndex(newCols.length - 1, { - align: "end", - }); - }, 150); + // scroll to the newest column + vlistRef.current.scrollToIndex(newCols.length - 1, { + align: "end", + }); + }, 150); - const remove = useDebouncedCallback((label: string) => { - const newCols = columns.filter((t) => t.label !== label); + const remove = useDebouncedCallback((label: string) => { + const newCols = columns.filter((t) => t.label !== label); - setColumns(newCols); - setSelectedIndex(newCols.length); - setIsScroll(true); + setColumns(newCols); + setSelectedIndex(newCols.length); + setIsScroll(true); - // scroll to the first column - vlistRef.current.scrollToIndex(newCols.length - 1, { - align: "start", - }); - }, 150); + // scroll to the first column + vlistRef.current.scrollToIndex(newCols.length - 1, { + align: "start", + }); + }, 150); - const updateName = useDebouncedCallback((label: string, title: string) => { - const currentColIndex = columns.findIndex((col) => col.label === label); + const updateName = useDebouncedCallback((label: string, title: string) => { + const currentColIndex = columns.findIndex((col) => col.label === label); - const updatedCol = Object.assign({}, columns[currentColIndex]); - updatedCol.name = title; + const updatedCol = Object.assign({}, columns[currentColIndex]); + updatedCol.name = title; - const newCols = columns.slice(); - newCols[currentColIndex] = updatedCol; + const newCols = columns.slice(); + newCols[currentColIndex] = updatedCol; - setColumns(newCols); - }, 150); + setColumns(newCols); + }, 150); - const startResize = useDebouncedCallback( - () => setIsResize((prev) => !prev), - 150, - ); + const startResize = useDebouncedCallback( + () => setIsResize((prev) => !prev), + 150, + ); - useEffect(() => { - // save state - ark.set_columns(columns); - }, [columns]); + useEffect(() => { + // save state + ark.set_columns(columns); + }, [columns]); - useEffect(() => { - let unlistenColEvent: Awaited> | undefined = - undefined; - let unlistenWindowResize: Awaited> | undefined = - undefined; + useEffect(() => { + let unlistenColEvent: Awaited> | undefined = + undefined; + let unlistenWindowResize: Awaited> | undefined = + undefined; - (async () => { - if (unlistenColEvent && unlistenWindowResize) return; + (async () => { + if (unlistenColEvent && unlistenWindowResize) return; - unlistenColEvent = await listen("columns", (data) => { - if (data.payload.type === "add") add(data.payload.column); - if (data.payload.type === "remove") remove(data.payload.label); - if (data.payload.type === "set_title") - updateName(data.payload.label, data.payload.title); - }); + unlistenColEvent = await listen("columns", (data) => { + if (data.payload.type === "add") add(data.payload.column); + if (data.payload.type === "remove") remove(data.payload.label); + if (data.payload.type === "set_title") + updateName(data.payload.label, data.payload.title); + }); - unlistenWindowResize = await getCurrent().listen("tauri://resize", () => { - startResize(); - }); - })(); + unlistenWindowResize = await getCurrent().listen("tauri://resize", () => { + startResize(); + }); + })(); - return () => { - if (unlistenColEvent) unlistenColEvent(); - if (unlistenWindowResize) unlistenWindowResize(); - }; - }, []); + return () => { + if (unlistenColEvent) unlistenColEvent(); + if (unlistenWindowResize) unlistenWindowResize(); + }; + }, []); - return ( -
- setIsScroll(true)} - onScrollEnd={() => setIsScroll(false)} - className="scrollbar-none h-full w-full overflow-x-auto focus:outline-none" - > - {columns.map((column) => ( - - ))} - - -
- - -
-
-
- ); + return ( +
+ setIsScroll(true)} + onScrollEnd={() => setIsScroll(false)} + className="scrollbar-none h-full w-full overflow-x-auto focus:outline-none" + > + {columns.map((column) => ( + + ))} + + +
+ + +
+
+
+ ); } diff --git a/apps/desktop2/src/routes/$account.tsx b/apps/desktop2/src/routes/$account.tsx index c0cd34b6..f921cfbb 100644 --- a/apps/desktop2/src/routes/$account.tsx +++ b/apps/desktop2/src/routes/$account.tsx @@ -2,10 +2,10 @@ import { BellIcon, ComposeFilledIcon, PlusIcon, SearchIcon } from "@lume/icons"; import { Event, Kind } from "@lume/types"; import { User } from "@lume/ui"; import { - cn, - decodeZapInvoice, - displayNpub, - sendNativeNotification, + cn, + decodeZapInvoice, + displayNpub, + sendNativeNotification, } from "@lume/utils"; import { Outlet, createFileRoute } from "@tanstack/react-router"; import { UnlistenFn } from "@tauri-apps/api/event"; @@ -13,171 +13,171 @@ import { getCurrent } from "@tauri-apps/api/window"; import { useEffect, useState } from "react"; export const Route = createFileRoute("/$account")({ - beforeLoad: async ({ context }) => { - const ark = context.ark; - const accounts = await ark.get_all_accounts(); + beforeLoad: async ({ context }) => { + const ark = context.ark; + const accounts = await ark.get_all_accounts(); - return { accounts }; - }, - component: Screen, + return { accounts }; + }, + component: Screen, }); function Screen() { - const { ark, platform } = Route.useRouteContext(); - const navigate = Route.useNavigate(); + const { ark, platform } = Route.useRouteContext(); + const navigate = Route.useNavigate(); - return ( -
-
-
- - -
-
- - - -
-
-
-
- -
-
- ); + return ( +
+
+
+ + +
+
+ + + +
+
+
+
+ +
+
+ ); } function Accounts() { - const navigate = Route.useNavigate(); - const { ark, accounts } = Route.useRouteContext(); - const { account } = Route.useParams(); + const navigate = Route.useNavigate(); + const { ark, accounts } = Route.useRouteContext(); + const { account } = Route.useParams(); - const changeAccount = async (npub: string) => { - if (npub === account) return; + const changeAccount = async (npub: string) => { + if (npub === account) return; - const select = await ark.load_selected_account(npub); + const select = await ark.load_selected_account(npub); - if (select) { - return navigate({ to: "/$account/home", params: { account: npub } }); - } - }; + if (select) { + return navigate({ to: "/$account/home", params: { account: npub } }); + } + }; - return ( -
- {accounts.map((user) => ( - - ))} -
- ); + return ( +
+ {accounts.map((user) => ( + + ))} +
+ ); } function Bell() { - const { ark } = Route.useRouteContext(); - const { account } = Route.useParams(); + const { ark } = Route.useRouteContext(); + const { account } = Route.useParams(); - const [isRing, setIsRing] = useState(false); + const [isRing, setIsRing] = useState(false); - useEffect(() => { - let unlisten: UnlistenFn = undefined; + useEffect(() => { + let unlisten: UnlistenFn = undefined; - async function listenNotify() { - unlisten = await getCurrent().listen( - "activity", - async (payload) => { - setIsRing(true); + async function listenNotify() { + unlisten = await getCurrent().listen( + "activity", + async (payload) => { + setIsRing(true); - const event: Event = JSON.parse(payload.payload); - const user = await ark.get_profile(event.pubkey); - const userName = - user.display_name || user.name || displayNpub(event.pubkey, 16); + const event: Event = JSON.parse(payload.payload); + const user = await ark.get_profile(event.pubkey); + const userName = + user.display_name || user.name || displayNpub(event.pubkey, 16); - switch (event.kind) { - case Kind.Text: { - sendNativeNotification("Mentioned you in a note", userName); - break; - } - case Kind.Repost: { - sendNativeNotification("Reposted your note", userName); - break; - } - case Kind.ZapReceipt: { - const amount = decodeZapInvoice(event.tags); - sendNativeNotification( - `Zapped ₿ ${amount.bitcoinFormatted}`, - userName, - ); - break; - } - default: - break; - } - }, - ); - } + switch (event.kind) { + case Kind.Text: { + sendNativeNotification("Mentioned you in a note", userName); + break; + } + case Kind.Repost: { + sendNativeNotification("Reposted your note", userName); + break; + } + case Kind.ZapReceipt: { + const amount = decodeZapInvoice(event.tags); + sendNativeNotification( + `Zapped ₿ ${amount.bitcoinFormatted}`, + userName, + ); + break; + } + default: + break; + } + }, + ); + } - if (!unlisten) listenNotify(); + if (!unlisten) listenNotify(); - return () => { - if (unlisten) unlisten(); - }; - }, []); + return () => { + if (unlisten) unlisten(); + }; + }, []); - return ( - - ); + return ( + + ); } diff --git a/apps/desktop2/src/routes/__root.tsx b/apps/desktop2/src/routes/__root.tsx index 3bcc7c57..f5b701c3 100644 --- a/apps/desktop2/src/routes/__root.tsx +++ b/apps/desktop2/src/routes/__root.tsx @@ -7,38 +7,38 @@ import type { Platform } from "@tauri-apps/plugin-os"; import type { Descendant } from "slate"; type EditorElement = { - type: string; - children: Descendant[]; - eventId?: string; + type: string; + children: Descendant[]; + eventId?: string; }; interface RouterContext { - // System - ark: Ark; - queryClient: QueryClient; - // App info - platform?: Platform; - locale?: string; - // Settings - settings?: Settings; - interests?: Interests; - // Profile - accounts?: string[]; - profile?: Metadata; - // Editor - initialValue?: EditorElement[]; + // System + ark: Ark; + queryClient: QueryClient; + // App info + platform?: Platform; + locale?: string; + // Settings + settings?: Settings; + interests?: Interests; + // Profile + accounts?: string[]; + profile?: Metadata; + // Editor + initialValue?: EditorElement[]; } export const Route = createRootRouteWithContext()({ - component: () => , - pendingComponent: Pending, - wrapInSuspense: true, + component: () => , + pendingComponent: Pending, + wrapInSuspense: true, }); function Pending() { - return ( -
- -
- ); + return ( +
+ +
+ ); } diff --git a/apps/desktop2/src/routes/editor/-components/media.tsx b/apps/desktop2/src/routes/editor/-components/media.tsx index 387f9350..c658b217 100644 --- a/apps/desktop2/src/routes/editor/-components/media.tsx +++ b/apps/desktop2/src/routes/editor/-components/media.tsx @@ -10,9 +10,8 @@ import { useSlateStatic } from "slate-react"; import { toast } from "sonner"; export function MediaButton({ className }: { className?: string }) { - const { ark } = useRouteContext({ strict: false }); const editor = useSlateStatic(); - + const { ark } = useRouteContext({ strict: false }); const [loading, setLoading] = useState(false); const uploadToNostrBuild = async () => { diff --git a/apps/desktop2/src/routes/editor/-components/mention.tsx b/apps/desktop2/src/routes/editor/-components/mention.tsx new file mode 100644 index 00000000..ade7540a --- /dev/null +++ b/apps/desktop2/src/routes/editor/-components/mention.tsx @@ -0,0 +1,83 @@ +import { MentionIcon } from "@lume/icons"; +import { cn, insertMention } from "@lume/utils"; +import * as Tooltip from "@radix-ui/react-tooltip"; +import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; +import { useEffect, useState } from "react"; +import { useRouteContext } from "@tanstack/react-router"; +import { User } from "@lume/ui"; +import { useSlateStatic } from "slate-react"; +import type { Contact } from "@lume/types"; +import { toast } from "sonner"; + +export function MentionButton({ className }: { className?: string }) { + const editor = useSlateStatic(); + const { ark } = useRouteContext({ strict: false }); + const [contacts, setContacts] = useState([]); + + const select = async (user: string) => { + try { + const metadata = await ark.get_profile(user); + const contact: Contact = { pubkey: user, profile: metadata }; + + insertMention(editor, contact); + } catch (e) { + toast.error(String(e)); + } + }; + + useEffect(() => { + async function getContacts() { + const data = await ark.get_contact_list(); + setContacts(data); + } + + getContacts(); + }, []); + + return ( + + + + + + + + + + + Mention + + + + + + + + {contacts.map((contact) => ( + select(contact)} + className="shrink-0 h-11 flex items-center hover:bg-white/10 px-2" + > + + + + + + + + ))} + + + + + ); +} diff --git a/apps/desktop2/src/routes/editor/-components/pow.tsx b/apps/desktop2/src/routes/editor/-components/pow.tsx new file mode 100644 index 00000000..c24e51a3 --- /dev/null +++ b/apps/desktop2/src/routes/editor/-components/pow.tsx @@ -0,0 +1,40 @@ +import { NsfwIcon } from "@lume/icons"; +import { cn } from "@lume/utils"; +import * as Tooltip from "@radix-ui/react-tooltip"; +import type { Dispatch, SetStateAction } from "react"; + +export function PowToggle({ + pow, + setPow, + className, +}: { + pow: boolean; + setPow: Dispatch>; + className?: string; +}) { + return ( + + + + + + + + Proof of Work + + + + + + ); +} diff --git a/apps/desktop2/src/routes/editor/index.tsx b/apps/desktop2/src/routes/editor/index.tsx index 64b80323..469994f6 100644 --- a/apps/desktop2/src/routes/editor/index.tsx +++ b/apps/desktop2/src/routes/editor/index.tsx @@ -1,27 +1,18 @@ import { ComposeFilledIcon, TrashIcon } from "@lume/icons"; -import { Spinner, User } from "@lume/ui"; +import { Spinner } from "@lume/ui"; import { MentionNote } from "@lume/ui/src/note/mentions/note"; import { - Portal, cn, insertImage, - insertMention, insertNostrEvent, isImageUrl, sendNativeNotification, } from "@lume/utils"; import { createFileRoute } from "@tanstack/react-router"; import { nip19 } from "nostr-tools"; -import { useEffect, useRef, useState } from "react"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { - type Descendant, - Editor, - Node, - Range, - Transforms, - createEditor, -} from "slate"; +import { type Descendant, Node, Transforms, createEditor } from "slate"; import { Editable, ReactEditor, @@ -33,6 +24,7 @@ import { } from "slate-react"; import { MediaButton } from "./-components/media"; import { NsfwToggle } from "./-components/nsfw"; +import { MentionButton } from "./-components/mention"; type EditorSearch = { reply_to: string; @@ -73,32 +65,20 @@ export const Route = createFileRoute("/editor/")({ }; }, component: Screen, - pendingComponent: Pending, }); function Screen() { - const ref = useRef(); const { reply_to, quote } = Route.useSearch(); - const { ark, initialValue, contacts } = Route.useRouteContext(); + const { ark, initialValue } = Route.useRouteContext(); const [t] = useTranslation(); const [editorValue, setEditorValue] = useState(initialValue); - const [target, setTarget] = useState(); - const [index, setIndex] = useState(0); - const [search, setSearch] = useState(""); const [loading, setLoading] = useState(false); const [nsfw, setNsfw] = useState(false); const [editor] = useState(() => withMentions(withNostrEvent(withImages(withReact(createEditor())))), ); - const filters = - contacts - ?.filter((c) => - c?.profile.name?.toLowerCase().startsWith(search.toLowerCase()), - ) - ?.slice(0, 5) ?? []; - const reset = () => { // @ts-expect-error, backlog editor.children = [{ type: "paragraph", children: [{ text: "" }] }]; @@ -138,11 +118,15 @@ function Screen() { const eventId = await ark.publish(content, reply_to, quote); if (eventId) { - await sendNativeNotification("You've publish new post successfully."); + await sendNativeNotification( + "Your note has been published successfully.", + "Lume", + ); } // stop loading setLoading(false); + // reset form reset(); } catch (e) { @@ -151,58 +135,20 @@ function Screen() { } }; - useEffect(() => { - if (target && filters.length > 0) { - const el = ref.current; - const domRange = ReactEditor.toDOMRange(editor, target); - const rect = domRange.getBoundingClientRect(); - el.style.top = `${rect.top + window.scrollY + 24}px`; - el.style.left = `${rect.left + window.scrollX}px`; - } - }, [filters.length, editor, index, search, target]); - return ( -
- { - const { selection } = editor; - - if (selection && Range.isCollapsed(selection)) { - const [start] = Range.edges(selection); - const wordBefore = Editor.before(editor, start, { unit: "word" }); - const before = wordBefore && Editor.before(editor, wordBefore); - const beforeRange = before && Editor.range(editor, before, start); - const beforeText = - beforeRange && Editor.string(editor, beforeRange); - const beforeMatch = beforeText?.match(/^@(\w+)$/); - const after = Editor.after(editor, start); - const afterRange = Editor.range(editor, start, after); - const afterText = Editor.string(editor, afterRange); - const afterMatch = afterText.match(/^(\s|$)/); - - if (beforeMatch && afterMatch) { - setTarget(beforeRange); - setSearch(beforeMatch[1]); - setIndex(0); - return; - } - } - - setTarget(null); - }} - > +
+
- + +
-
-
- {reply_to && !quote ? : null} -
- } - placeholder={ - reply_to ? "Type your reply..." : t("editor.placeholder") - } - className="focus:outline-none" - /> - {target && filters.length > 0 && ( - -
- {filters.map((contact) => ( - - ))} -
-
- )} +
+ {reply_to && !quote ? ( +
+
+ ) : null} +
+ } + placeholder={ + reply_to ? "Type your reply..." : t("editor.placeholder") + } + className="focus:outline-none" + />
@@ -270,20 +188,6 @@ function Screen() { ); } -function Pending() { - return ( -
- -

Loading cache...

-
- ); -} - const withNostrEvent = (editor: ReactEditor) => { const { insertData, isVoid } = editor; @@ -429,7 +333,7 @@ const Element = (props) => { return ; default: return ( -

+

{children}

); diff --git a/apps/desktop2/src/routes/index.tsx b/apps/desktop2/src/routes/index.tsx index 70e91a28..b04b5067 100644 --- a/apps/desktop2/src/routes/index.tsx +++ b/apps/desktop2/src/routes/index.tsx @@ -7,112 +7,112 @@ import { useState } from "react"; import { toast } from "sonner"; export const Route = createFileRoute("/")({ - beforeLoad: async ({ context }) => { - const ark = context.ark; - const accounts = await ark.get_all_accounts(); + beforeLoad: async ({ context }) => { + const ark = context.ark; + const accounts = await ark.get_all_accounts(); - if (!accounts.length) { - throw redirect({ - to: "/landing", - replace: true, - }); - } + if (!accounts.length) { + throw redirect({ + to: "/landing", + replace: true, + }); + } - // Run notification service - await invoke("run_notification", { accounts }); + // Run notification service + await invoke("run_notification", { accounts }); - return { accounts }; - }, - component: Screen, + return { accounts }; + }, + component: Screen, }); function Screen() { - const navigate = Route.useNavigate(); - const { ark, accounts } = Route.useRouteContext(); + const navigate = Route.useNavigate(); + const { ark, accounts } = Route.useRouteContext(); - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(false); - const select = async (npub: string) => { - try { - setLoading(true); + const select = async (npub: string) => { + try { + setLoading(true); - const loadAccount = await ark.load_selected_account(npub); - if (loadAccount) { - return navigate({ - to: "/$account/home", - params: { account: npub }, - replace: true, - }); - } - } catch (e) { - setLoading(false); - toast.error(String(e)); - } - }; + const loadAccount = await ark.load_selected_account(npub); + if (loadAccount) { + return navigate({ + to: "/$account/home", + params: { account: npub }, + replace: true, + }); + } + } catch (e) { + setLoading(false); + toast.error(String(e)); + } + }; - const currentDate = new Date().toLocaleString("default", { - weekday: "long", - month: "long", - day: "numeric", - }); + const currentDate = new Date().toLocaleString("default", { + weekday: "long", + month: "long", + day: "numeric", + }); - return ( -
-
-
-

{currentDate}

-

Welcome back!

-
-
- {loading ? ( -
- -
- ) : ( - <> - {accounts.map((account) => ( - - ))} - -
-
- -
-

Add

-
- - - )} -
-
- - ); + return ( +
+
+
+

{currentDate}

+

Welcome back!

+
+
+ {loading ? ( +
+ +
+ ) : ( + <> + {accounts.map((account) => ( + + ))} + +
+
+ +
+

Add

+
+ + + )} +
+
+ + ); } diff --git a/packages/ark/src/ark.ts b/packages/ark/src/ark.ts index 76041fb3..44191b09 100644 --- a/packages/ark/src/ark.ts +++ b/packages/ark/src/ark.ts @@ -1,13 +1,13 @@ import { - Kind, - type Contact, - type Event, - type EventWithReplies, - type Interests, - type Keys, - type LumeColumn, - type Metadata, - type Settings, + Kind, + type Contact, + type Event, + type EventWithReplies, + type Interests, + type Keys, + type LumeColumn, + type Metadata, + type Settings, } from "@lume/types"; import { generateContentTags } from "@lume/utils"; import { invoke } from "@tauri-apps/api/core"; @@ -16,879 +16,880 @@ import { open } from "@tauri-apps/plugin-dialog"; import { readFile } from "@tauri-apps/plugin-fs"; enum NSTORE_KEYS { - settings = "lume_user_settings", - interests = "lume_user_interests", - columns = "lume_user_columns", + settings = "lume_user_settings", + interests = "lume_user_interests", + columns = "lume_user_columns", } export class Ark { - public windows: WebviewWindow[]; - public settings: Settings; - public accounts: string[]; - - constructor() { - this.windows = []; - this.settings = undefined; - } - - public async get_all_accounts() { - try { - const cmd: string[] = await invoke("get_accounts"); - const accounts: string[] = cmd.map((item) => item.replace(".npub", "")); - - if (!this.accounts) this.accounts = accounts; - - return accounts; - } catch (e) { - throw new Error(String(e)); - } - } - - public async load_selected_account(npub: string) { - try { - const cmd: boolean = await invoke("load_selected_account", { - npub, - }); - return cmd; - } catch (e) { - throw new Error(String(e)); - } - } - - public async get_activities(account: string, kind: "1" | "6" | "9735" = "1") { - try { - const events: Event[] = await invoke("get_activities", { account, kind }); - return events; - } catch (e) { - console.error(String(e)); - return null; - } - } - - public async nostr_connect(uri: string) { - try { - const remoteKey = uri.replace("bunker://", "").split("?")[0]; - const npub: string = await invoke("to_npub", { hex: remoteKey }); - - if (npub) { - const connect: string = await invoke("nostr_connect", { - npub, - uri, - }); - - return connect; - } - } catch (e) { - throw new Error(String(e)); - } - } - - public async create_keys() { - try { - const cmd: Keys = await invoke("create_keys"); - return cmd; - } catch (e) { - console.error(String(e)); - } - } - - public async save_account(nsec: string, password = "") { - try { - const cmd: string = await invoke("save_key", { - nsec, - password, - }); - - return cmd; - } catch (e) { - throw new Error(String(e)); - } - } - - public async event_to_bech32(id: string, relays: string[]) { - try { - const cmd: string = await invoke("event_to_bech32", { - id, - relays, - }); - return cmd; - } catch (e) { - throw new Error(String(e)); - } - } - - public async get_event(id: string) { - try { - const eventId: string = id.replace("nostr:", "").replace(/[^\w\s]/gi, ""); - const cmd: string = await invoke("get_event", { id: eventId }); - const event: Event = JSON.parse(cmd); - return event; - } catch (e) { - console.error(id, String(e)); - throw new Error(String(e)); - } - } - - public async get_events_from(pubkey: string, limit: number, asOf?: number) { - try { - let until: string = undefined; - if (asOf && asOf > 0) until = asOf.toString(); - - const nostrEvents: Event[] = await invoke("get_events_from", { - publicKey: pubkey, - limit, - as_of: until, - }); - - return nostrEvents.sort((a, b) => b.created_at - a.created_at); - } catch (e) { - console.error(String(e)); - return []; - } - } - - public async search(content: string, limit: number) { - try { - if (content.length < 1) return []; - - const events: Event[] = await invoke("search", { - content: content.trim(), - limit, - }); - - return events; - } catch (e) { - console.info(String(e)); - return []; - } - } - - public async get_events( - limit: number, - asOf?: number, - contacts?: string[], - global?: boolean, - ) { - try { - let until: string = undefined; - const isGlobal = global ?? false; - - if (asOf && asOf > 0) until = asOf.toString(); - - const seenIds = new Set(); - const nostrEvents: Event[] = await invoke("get_events", { - limit, - until, - contacts, - global: isGlobal, - }); - - // remove duplicate event - for (const event of nostrEvents) { - if (event.kind === Kind.Repost) { - const repostId = event.tags.find((tag) => tag[0] === "e")?.[1]; - seenIds.add(repostId); - } - - const eventIds = event.tags - .filter((el) => el[0] === "e") - ?.map((item) => item[1]); - - if (eventIds && eventIds.length) { - eventIds.forEach((id) => seenIds.add(id)); - } - } - - const events = nostrEvents - .filter((event) => !seenIds.has(event.id)) - .sort((a, b) => b.created_at - a.created_at); - - if (this.settings?.nsfw) { - return events.filter( - (event) => - event.tags.filter((event) => event[0] === "content-warning") - .length > 0, - ); - } - - return events; - } catch (e) { - console.info(String(e)); - return []; - } - } - - public async get_events_from_interests( - hashtags: string[], - limit: number, - asOf?: number, - ) { - let until: string = undefined; - if (asOf && asOf > 0) until = asOf.toString(); - - const seenIds = new Set(); - const dedupQueue = new Set(); - const nostrTags = hashtags.map((tag) => tag.replace("#", "").toLowerCase()); - - const nostrEvents: Event[] = await invoke("get_events_from_interests", { - hashtags: nostrTags, - limit, - until, - }); - - for (const event of nostrEvents) { - const tags = event.tags - .filter((el) => el[0] === "e") - ?.map((item) => item[1]); - - if (tags.length) { - for (const tag of tags) { - if (seenIds.has(tag)) { - dedupQueue.add(event.id); - break; - } - seenIds.add(tag); - } - } - } - - return nostrEvents - .filter((event) => !dedupQueue.has(event.id)) - .sort((a, b) => b.created_at - a.created_at); - } - - public async publish( - content: string, - reply_to?: string, - quote?: boolean, - nsfw?: boolean, - ) { - try { - const g = await generateContentTags(content); - - const eventContent = g.content; - const eventTags = g.tags; - - if (reply_to) { - const replyEvent = await this.get_event(reply_to); - const relayHint = - replyEvent.tags.find((ev) => ev[0] === "e")?.[0][2] ?? ""; - - if (quote) { - eventTags.push(["e", replyEvent.id, relayHint, "mention"]); - } else { - const rootEvent = replyEvent.tags.find((ev) => ev[3] === "root"); - - if (rootEvent) { - eventTags.push([ - "e", - rootEvent[1], - rootEvent[2] || relayHint, - "root", - ]); - } - - eventTags.push(["e", replyEvent.id, relayHint, "reply"]); - eventTags.push(["p", replyEvent.pubkey]); - } - } - - if (nsfw) { - eventTags.push(["L", "content-warning"]); - eventTags.push(["l", "reason", "content-warning"]); - eventTags.push(["content-warning", "nsfw"]); - } - - const cmd: string = await invoke("publish", { - content: eventContent, - tags: eventTags, - }); - - return cmd; - } catch (e) { - throw new Error(String(e)); - } - } - - public async reply_to(content: string, tags: string[]) { - try { - const cmd: string = await invoke("reply_to", { content, tags }); - return cmd; - } catch (e) { - throw new Error(String(e)); - } - } - - public async repost(id: string, author: string) { - try { - const cmd: string = await invoke("repost", { id, pubkey: author }); - return cmd; - } catch (e) { - throw new Error(String(e)); - } - } - - public async upvote(id: string, author: string) { - try { - const cmd: string = await invoke("upvote", { id, pubkey: author }); - return cmd; - } catch (e) { - throw new Error(String(e)); - } - } - - public async downvote(id: string, author: string) { - try { - const cmd: string = await invoke("downvote", { id, pubkey: author }); - return cmd; - } catch (e) { - throw new Error(String(e)); - } - } - - public async get_event_thread(id: string) { - try { - const events: EventWithReplies[] = await invoke("get_event_thread", { - id, - }); - - if (events.length > 0) { - const replies = new Set(); - for (const event of events) { - const tags = event.tags.filter( - (el) => el[0] === "e" && el[1] !== id && el[3] !== "mention", - ); - if (tags.length > 0) { - for (const tag of tags) { - const rootIndex = events.findIndex((el) => el.id === tag[1]); - if (rootIndex !== -1) { - const rootEvent = events[rootIndex]; - if (rootEvent?.replies) { - rootEvent.replies.push(event); - } else { - rootEvent.replies = [event]; - } - replies.add(event.id); - } - } - } - } - const cleanEvents = events.filter((ev) => !replies.has(ev.id)); - return cleanEvents; - } - - return events; - } catch (e) { - return []; - } - } - - public parse_event_thread(tags: string[][]) { - let root: string = null; - let reply: string = null; - - // Get all event references from tags, ignore mention - const events = tags.filter((el) => el[0] === "e" && el[3] !== "mention"); - - if (events.length === 1) { - root = events[0][1]; - } - - if (events.length > 1) { - root = events.find((el) => el[3] === "root")?.[1] ?? events[0][1]; - reply = events.find((el) => el[3] === "reply")?.[1] ?? events[1][1]; - } - - // Fix some rare case when root === reply - if (root && reply && root === reply) { - reply = null; - } - - return { - root, - reply, - }; - } - - public async get_profile(pubkey: string) { - try { - const id = pubkey.replace("nostr:", "").replace(/[^\w\s]/gi, ""); - const cmd: Metadata = await invoke("get_profile", { id }); - - return cmd; - } catch (e) { - console.error(pubkey, String(e)); - return null; - } - } - - public async get_current_user_profile() { - try { - const cmd: Metadata = await invoke("get_current_user_profile"); - return cmd; - } catch { - return null; - } - } - - public async create_profile(profile: Metadata) { - try { - const event: string = await invoke("create_profile", { - name: profile.name || "", - display_name: profile.display_name || "", - displayName: profile.display_name || "", - about: profile.about || "", - picture: profile.picture || "", - banner: profile.banner || "", - nip05: profile.nip05 || "", - lud16: profile.lud16 || "", - website: profile.website || "", - }); - return event; - } catch (e) { - throw new Error(String(e)); - } - } - - public async get_contact_list() { - try { - const cmd: string[] = await invoke("get_contact_list"); - return cmd; - } catch (e) { - console.error(e); - return []; - } - } - - public async get_contact_metadata() { - try { - const cmd: Contact[] = await invoke("get_contact_metadata"); - return cmd; - } catch (e) { - console.error(e); - return []; - } - } - - public async follow(id: string, alias?: string) { - try { - const cmd: string = await invoke("follow", { id, alias }); - return cmd; - } catch (e) { - throw new Error(String(e)); - } - } - - public async unfollow(id: string) { - try { - const cmd: string = await invoke("unfollow", { id }); - return cmd; - } catch (e) { - throw new Error(String(e)); - } - } - - public async user_to_bech32(key: string, relays: string[]) { - try { - const cmd: string = await invoke("user_to_bech32", { - key, - relays, - }); - return cmd; - } catch (e) { - throw new Error(String(e)); - } - } - - public async verify_nip05(pubkey: string, nip05: string) { - try { - const cmd: boolean = await invoke("verify_nip05", { - key: pubkey, - nip05, - }); - return cmd; - } catch { - return false; - } - } - - public async set_nwc(uri: string) { - try { - const cmd: boolean = await invoke("set_nwc", { uri }); - return cmd; - } catch (e) { - throw new Error(String(e)); - } - } - - public async load_nwc() { - try { - const cmd: boolean = await invoke("load_nwc"); - return cmd; - } catch (e) { - throw new Error(String(e)); - } - } - - public async get_balance() { - try { - const cmd: number = await invoke("get_balance"); - return cmd; - } catch (e) { - throw new Error(String(e)); - } - } - - public async zap_profile(id: string, amount: number, message: string) { - try { - const cmd: boolean = await invoke("zap_profile", { id, amount, message }); - return cmd; - } catch (e) { - throw new Error(String(e)); - } - } - - public async zap_event(id: string, amount: number, message: string) { - try { - const cmd: boolean = await invoke("zap_event", { id, amount, message }); - return cmd; - } catch (e) { - throw new Error(String(e)); - } - } - - public async upload(filePath?: string) { - const allowExts = [ - "png", - "jpeg", - "jpg", - "gif", - "mp4", - "mp3", - "webm", - "mkv", - "avi", - "mov", - ]; - - const selected = - filePath || - ( - await open({ - multiple: false, - filters: [ - { - name: "Media", - extensions: allowExts, - }, - ], - }) - ).path; - - // User cancelled action - if (!selected) return null; - - try { - const file = await readFile(selected); - const blob = new Blob([file]); - - const data = new FormData(); - data.append("fileToUpload", blob); - data.append("submit", "Upload Image"); - - const res = await fetch("https://nostr.build/api/v2/upload/files", { - method: "POST", - body: data, - }); - - if (!res.ok) return null; - - const json = await res.json(); - const content = json.data[0]; - - return content.url as string; - } catch (e) { - throw new Error(String(e)); - } - } - - public async get_columns() { - try { - const cmd: string = await invoke("get_nstore", { - key: NSTORE_KEYS.columns, - }); - const columns: LumeColumn[] = cmd ? JSON.parse(cmd) : []; - return columns; - } catch { - return []; - } - } - - public async set_columns(columns: LumeColumn[]) { - try { - const cmd: string = await invoke("set_nstore", { - key: NSTORE_KEYS.columns, - content: JSON.stringify(columns), - }); - return cmd; - } catch (e) { - throw new Error(e); - } - } - - public async get_settings() { - try { - if (this.settings) return this.settings; - - const cmd: string = await invoke("get_nstore", { - key: NSTORE_KEYS.settings, - }); - const settings: Settings = cmd ? JSON.parse(cmd) : null; - - this.settings = settings; - - return settings; - } catch { - const defaultSettings: Settings = { - autoUpdate: false, - enhancedPrivacy: false, - notification: false, - zap: false, - nsfw: false, - }; - this.settings = defaultSettings; - return defaultSettings; - } - } - - public async set_settings(settings: Settings) { - try { - const cmd: string = await invoke("set_nstore", { - key: NSTORE_KEYS.settings, - content: JSON.stringify(settings), - }); - return cmd; - } catch (e) { - throw new Error(e); - } - } - - public async get_interest() { - try { - const cmd: string = await invoke("get_nstore", { - key: NSTORE_KEYS.interests, - }); - const interests: Interests = cmd ? JSON.parse(cmd) : null; - return interests; - } catch { - return null; - } - } - - public async set_interest( - words: string[], - users: string[], - hashtags: string[], - ) { - try { - const interests: Interests = { - words: words ?? [], - users: users ?? [], - hashtags: hashtags ?? [], - }; - const cmd: string = await invoke("set_nstore", { - key: NSTORE_KEYS.interests, - content: JSON.stringify(interests), - }); - return cmd; - } catch (e) { - throw new Error(String(e)); - } - } - - public async get_nstore(key: string) { - try { - const cmd: string = await invoke("get_nstore", { - key, - }); - const parse: string | string[] = cmd ? JSON.parse(cmd) : null; - if (!parse.length) return null; - return parse; - } catch { - return null; - } - } - - public async set_nstore(key: string, content: string) { - try { - const cmd: string = await invoke("set_nstore", { - key, - content, - }); - return cmd; - } catch (e) { - throw new Error(String(e)); - } - } - - public async open_event_id(id: string) { - try { - const label = `event-${id}`; - const url = `/events/${id}`; - - await invoke("open_window", { - label, - title: "Thread", - url, - width: 500, - height: 800, - }); - } catch (e) { - throw new Error(String(e)); - } - } - - public async open_event(event: Event) { - try { - let root: string = undefined; - let reply: string = undefined; - - const eTags = event.tags.filter( - (tag) => tag[0] === "e" || tag[0] === "q", - ); - - root = eTags.find((el) => el[3] === "root")?.[1]; - reply = eTags.find((el) => el[3] === "reply")?.[1]; - - if (!root) root = eTags[0]?.[1]; - if (!reply) reply = eTags[1]?.[1]; - - const label = `event-${event.id}`; - const url = `/events/${root ?? reply ?? event.id}`; - - await invoke("open_window", { - label, - title: "Thread", - url, - width: 500, - height: 800, - }); - } catch (e) { - throw new Error(String(e)); - } - } - - public async open_profile(pubkey: string) { - try { - const label = `user-${pubkey}`; - await invoke("open_window", { - label, - title: "Profile", - url: `/users/${pubkey}`, - width: 500, - height: 800, - }); - } catch (e) { - throw new Error(String(e)); - } - } - - public async open_editor(reply_to?: string, quote = false) { - try { - let url: string; - - if (reply_to) { - url = `/editor?reply_to=${reply_to}"e=${quote}`; - } else { - url = "/editor"; - } - - const label = `editor-${reply_to ? reply_to : 0}`; - - await invoke("open_window", { - label, - title: "Editor", - url, - width: 500, - height: 360, - }); - } catch (e) { - throw new Error(String(e)); - } - } - - public async open_nwc() { - try { - const label = "nwc"; - await invoke("open_window", { - label, - title: "Nostr Wallet Connect", - url: "/nwc", - width: 400, - height: 600, - }); - } catch (e) { - throw new Error(String(e)); - } - } - - public async open_zap(id: string, pubkey: string, account: string) { - try { - const label = `zap-${id}`; - await invoke("open_window", { - label, - title: "Zap", - url: `/zap/${id}?pubkey=${pubkey}&account=${account}`, - width: 400, - height: 500, - }); - } catch (e) { - throw new Error(String(e)); - } - } - - public async open_settings() { - try { - const label = "settings"; - await invoke("open_window", { - label, - title: "Settings", - url: "/settings", - width: 800, - height: 500, - }); - } catch (e) { - throw new Error(String(e)); - } - } - - public async open_search() { - try { - const label = "search"; - await invoke("open_window", { - label, - title: "Search", - url: "/search", - width: 750, - height: 470, - }); - } catch (e) { - throw new Error(String(e)); - } - } - - public async open_activity(account: string) { - try { - const label = "activity"; - await invoke("open_window", { - label, - title: "Activity", - url: `/activity/${account}/texts`, - width: 400, - height: 600, - }); - } catch (e) { - throw new Error(String(e)); - } - } + public windows: WebviewWindow[]; + public settings: Settings; + public accounts: string[]; + + constructor() { + this.windows = []; + this.settings = undefined; + } + + public async get_all_accounts() { + try { + const cmd: string[] = await invoke("get_accounts"); + const accounts: string[] = cmd.map((item) => item.replace(".npub", "")); + + if (!this.accounts) this.accounts = accounts; + + return accounts; + } catch (e) { + throw new Error(String(e)); + } + } + + public async load_selected_account(npub: string) { + try { + const cmd: boolean = await invoke("load_selected_account", { + npub, + }); + return cmd; + } catch (e) { + throw new Error(String(e)); + } + } + + public async get_activities(account: string, kind: "1" | "6" | "9735" = "1") { + try { + const events: Event[] = await invoke("get_activities", { account, kind }); + return events; + } catch (e) { + console.error(String(e)); + return null; + } + } + + public async nostr_connect(uri: string) { + try { + const remoteKey = uri.replace("bunker://", "").split("?")[0]; + const npub: string = await invoke("to_npub", { hex: remoteKey }); + + if (npub) { + const connect: string = await invoke("nostr_connect", { + npub, + uri, + }); + + return connect; + } + } catch (e) { + throw new Error(String(e)); + } + } + + public async create_keys() { + try { + const cmd: Keys = await invoke("create_keys"); + return cmd; + } catch (e) { + console.error(String(e)); + } + } + + public async save_account(nsec: string, password = "") { + try { + const cmd: string = await invoke("save_key", { + nsec, + password, + }); + + return cmd; + } catch (e) { + throw new Error(String(e)); + } + } + + public async event_to_bech32(id: string, relays: string[]) { + try { + const cmd: string = await invoke("event_to_bech32", { + id, + relays, + }); + return cmd; + } catch (e) { + throw new Error(String(e)); + } + } + + public async get_event(id: string) { + try { + const eventId: string = id.replace("nostr:", "").replace(/[^\w\s]/gi, ""); + const cmd: string = await invoke("get_event", { id: eventId }); + const event: Event = JSON.parse(cmd); + return event; + } catch (e) { + console.error(id, String(e)); + throw new Error(String(e)); + } + } + + public async get_events_from(pubkey: string, limit: number, asOf?: number) { + try { + let until: string = undefined; + if (asOf && asOf > 0) until = asOf.toString(); + + const nostrEvents: Event[] = await invoke("get_events_from", { + publicKey: pubkey, + limit, + as_of: until, + }); + + return nostrEvents.sort((a, b) => b.created_at - a.created_at); + } catch (e) { + console.error(String(e)); + return []; + } + } + + public async search(content: string, limit: number) { + try { + if (content.length < 1) return []; + + const events: Event[] = await invoke("search", { + content: content.trim(), + limit, + }); + + return events; + } catch (e) { + console.info(String(e)); + return []; + } + } + + public async get_events( + limit: number, + asOf?: number, + contacts?: string[], + global?: boolean, + ) { + try { + let until: string = undefined; + const isGlobal = global ?? false; + + if (asOf && asOf > 0) until = asOf.toString(); + + const seenIds = new Set(); + const nostrEvents: Event[] = await invoke("get_events", { + limit, + until, + contacts, + global: isGlobal, + }); + + // remove duplicate event + for (const event of nostrEvents) { + if (event.kind === Kind.Repost) { + const repostId = event.tags.find((tag) => tag[0] === "e")?.[1]; + seenIds.add(repostId); + } + + const eventIds = event.tags + .filter((el) => el[0] === "e") + ?.map((item) => item[1]); + + if (eventIds && eventIds.length) { + eventIds.forEach((id) => seenIds.add(id)); + } + } + + const events = nostrEvents + .filter((event) => !seenIds.has(event.id)) + .sort((a, b) => b.created_at - a.created_at); + + if (this.settings?.nsfw) { + return events.filter( + (event) => + event.tags.filter((event) => event[0] === "content-warning") + .length > 0, + ); + } + + return events; + } catch (e) { + console.info(String(e)); + return []; + } + } + + public async get_events_from_interests( + hashtags: string[], + limit: number, + asOf?: number, + ) { + let until: string = undefined; + if (asOf && asOf > 0) until = asOf.toString(); + + const seenIds = new Set(); + const dedupQueue = new Set(); + const nostrTags = hashtags.map((tag) => tag.replace("#", "").toLowerCase()); + + const nostrEvents: Event[] = await invoke("get_events_from_interests", { + hashtags: nostrTags, + limit, + until, + }); + + for (const event of nostrEvents) { + const tags = event.tags + .filter((el) => el[0] === "e") + ?.map((item) => item[1]); + + if (tags.length) { + for (const tag of tags) { + if (seenIds.has(tag)) { + dedupQueue.add(event.id); + break; + } + seenIds.add(tag); + } + } + } + + return nostrEvents + .filter((event) => !dedupQueue.has(event.id)) + .sort((a, b) => b.created_at - a.created_at); + } + + public async publish( + content: string, + reply_to?: string, + quote?: boolean, + nsfw?: boolean, + ) { + try { + const g = await generateContentTags(content); + + const eventContent = g.content; + const eventTags = g.tags; + + if (reply_to) { + const replyEvent = await this.get_event(reply_to); + const relayHint = + replyEvent.tags.find((ev) => ev[0] === "e")?.[0][2] ?? ""; + + if (quote) { + eventTags.push(["e", replyEvent.id, relayHint, "mention"]); + eventTags.push(["q", replyEvent.id]); + } else { + const rootEvent = replyEvent.tags.find((ev) => ev[3] === "root"); + + if (rootEvent) { + eventTags.push([ + "e", + rootEvent[1], + rootEvent[2] || relayHint, + "root", + ]); + } + + eventTags.push(["e", replyEvent.id, relayHint, "reply"]); + eventTags.push(["p", replyEvent.pubkey]); + } + } + + if (nsfw) { + eventTags.push(["L", "content-warning"]); + eventTags.push(["l", "reason", "content-warning"]); + eventTags.push(["content-warning", "nsfw"]); + } + + const cmd: string = await invoke("publish", { + content: eventContent, + tags: eventTags, + }); + + return cmd; + } catch (e) { + throw new Error(String(e)); + } + } + + public async reply_to(content: string, tags: string[]) { + try { + const cmd: string = await invoke("reply_to", { content, tags }); + return cmd; + } catch (e) { + throw new Error(String(e)); + } + } + + public async repost(id: string, author: string) { + try { + const cmd: string = await invoke("repost", { id, pubkey: author }); + return cmd; + } catch (e) { + throw new Error(String(e)); + } + } + + public async upvote(id: string, author: string) { + try { + const cmd: string = await invoke("upvote", { id, pubkey: author }); + return cmd; + } catch (e) { + throw new Error(String(e)); + } + } + + public async downvote(id: string, author: string) { + try { + const cmd: string = await invoke("downvote", { id, pubkey: author }); + return cmd; + } catch (e) { + throw new Error(String(e)); + } + } + + public async get_event_thread(id: string) { + try { + const events: EventWithReplies[] = await invoke("get_event_thread", { + id, + }); + + if (events.length > 0) { + const replies = new Set(); + for (const event of events) { + const tags = event.tags.filter( + (el) => el[0] === "e" && el[1] !== id && el[3] !== "mention", + ); + if (tags.length > 0) { + for (const tag of tags) { + const rootIndex = events.findIndex((el) => el.id === tag[1]); + if (rootIndex !== -1) { + const rootEvent = events[rootIndex]; + if (rootEvent?.replies) { + rootEvent.replies.push(event); + } else { + rootEvent.replies = [event]; + } + replies.add(event.id); + } + } + } + } + const cleanEvents = events.filter((ev) => !replies.has(ev.id)); + return cleanEvents; + } + + return events; + } catch (e) { + return []; + } + } + + public parse_event_thread(tags: string[][]) { + let root: string = null; + let reply: string = null; + + // Get all event references from tags, ignore mention + const events = tags.filter((el) => el[0] === "e" && el[3] !== "mention"); + + if (events.length === 1) { + root = events[0][1]; + } + + if (events.length > 1) { + root = events.find((el) => el[3] === "root")?.[1] ?? events[0][1]; + reply = events.find((el) => el[3] === "reply")?.[1] ?? events[1][1]; + } + + // Fix some rare case when root === reply + if (root && reply && root === reply) { + reply = null; + } + + return { + root, + reply, + }; + } + + public async get_profile(pubkey: string) { + try { + const id = pubkey.replace("nostr:", "").replace(/[^\w\s]/gi, ""); + const cmd: Metadata = await invoke("get_profile", { id }); + + return cmd; + } catch (e) { + console.error(pubkey, String(e)); + return null; + } + } + + public async get_current_user_profile() { + try { + const cmd: Metadata = await invoke("get_current_user_profile"); + return cmd; + } catch { + return null; + } + } + + public async create_profile(profile: Metadata) { + try { + const event: string = await invoke("create_profile", { + name: profile.name || "", + display_name: profile.display_name || "", + displayName: profile.display_name || "", + about: profile.about || "", + picture: profile.picture || "", + banner: profile.banner || "", + nip05: profile.nip05 || "", + lud16: profile.lud16 || "", + website: profile.website || "", + }); + return event; + } catch (e) { + throw new Error(String(e)); + } + } + + public async get_contact_list() { + try { + const cmd: string[] = await invoke("get_contact_list"); + return cmd; + } catch (e) { + console.error(e); + return []; + } + } + + public async get_contact_metadata() { + try { + const cmd: Contact[] = await invoke("get_contact_metadata"); + return cmd; + } catch (e) { + console.error(e); + return []; + } + } + + public async follow(id: string, alias?: string) { + try { + const cmd: string = await invoke("follow", { id, alias }); + return cmd; + } catch (e) { + throw new Error(String(e)); + } + } + + public async unfollow(id: string) { + try { + const cmd: string = await invoke("unfollow", { id }); + return cmd; + } catch (e) { + throw new Error(String(e)); + } + } + + public async user_to_bech32(key: string, relays: string[]) { + try { + const cmd: string = await invoke("user_to_bech32", { + key, + relays, + }); + return cmd; + } catch (e) { + throw new Error(String(e)); + } + } + + public async verify_nip05(pubkey: string, nip05: string) { + try { + const cmd: boolean = await invoke("verify_nip05", { + key: pubkey, + nip05, + }); + return cmd; + } catch { + return false; + } + } + + public async set_nwc(uri: string) { + try { + const cmd: boolean = await invoke("set_nwc", { uri }); + return cmd; + } catch (e) { + throw new Error(String(e)); + } + } + + public async load_nwc() { + try { + const cmd: boolean = await invoke("load_nwc"); + return cmd; + } catch (e) { + throw new Error(String(e)); + } + } + + public async get_balance() { + try { + const cmd: number = await invoke("get_balance"); + return cmd; + } catch (e) { + throw new Error(String(e)); + } + } + + public async zap_profile(id: string, amount: number, message: string) { + try { + const cmd: boolean = await invoke("zap_profile", { id, amount, message }); + return cmd; + } catch (e) { + throw new Error(String(e)); + } + } + + public async zap_event(id: string, amount: number, message: string) { + try { + const cmd: boolean = await invoke("zap_event", { id, amount, message }); + return cmd; + } catch (e) { + throw new Error(String(e)); + } + } + + public async upload(filePath?: string) { + const allowExts = [ + "png", + "jpeg", + "jpg", + "gif", + "mp4", + "mp3", + "webm", + "mkv", + "avi", + "mov", + ]; + + const selected = + filePath || + ( + await open({ + multiple: false, + filters: [ + { + name: "Media", + extensions: allowExts, + }, + ], + }) + ).path; + + // User cancelled action + if (!selected) return null; + + try { + const file = await readFile(selected); + const blob = new Blob([file]); + + const data = new FormData(); + data.append("fileToUpload", blob); + data.append("submit", "Upload Image"); + + const res = await fetch("https://nostr.build/api/v2/upload/files", { + method: "POST", + body: data, + }); + + if (!res.ok) return null; + + const json = await res.json(); + const content = json.data[0]; + + return content.url as string; + } catch (e) { + throw new Error(String(e)); + } + } + + public async get_columns() { + try { + const cmd: string = await invoke("get_nstore", { + key: NSTORE_KEYS.columns, + }); + const columns: LumeColumn[] = cmd ? JSON.parse(cmd) : []; + return columns; + } catch { + return []; + } + } + + public async set_columns(columns: LumeColumn[]) { + try { + const cmd: string = await invoke("set_nstore", { + key: NSTORE_KEYS.columns, + content: JSON.stringify(columns), + }); + return cmd; + } catch (e) { + throw new Error(e); + } + } + + public async get_settings() { + try { + if (this.settings) return this.settings; + + const cmd: string = await invoke("get_nstore", { + key: NSTORE_KEYS.settings, + }); + const settings: Settings = cmd ? JSON.parse(cmd) : null; + + this.settings = settings; + + return settings; + } catch { + const defaultSettings: Settings = { + autoUpdate: false, + enhancedPrivacy: false, + notification: false, + zap: false, + nsfw: false, + }; + this.settings = defaultSettings; + return defaultSettings; + } + } + + public async set_settings(settings: Settings) { + try { + const cmd: string = await invoke("set_nstore", { + key: NSTORE_KEYS.settings, + content: JSON.stringify(settings), + }); + return cmd; + } catch (e) { + throw new Error(e); + } + } + + public async get_interest() { + try { + const cmd: string = await invoke("get_nstore", { + key: NSTORE_KEYS.interests, + }); + const interests: Interests = cmd ? JSON.parse(cmd) : null; + return interests; + } catch { + return null; + } + } + + public async set_interest( + words: string[], + users: string[], + hashtags: string[], + ) { + try { + const interests: Interests = { + words: words ?? [], + users: users ?? [], + hashtags: hashtags ?? [], + }; + const cmd: string = await invoke("set_nstore", { + key: NSTORE_KEYS.interests, + content: JSON.stringify(interests), + }); + return cmd; + } catch (e) { + throw new Error(String(e)); + } + } + + public async get_nstore(key: string) { + try { + const cmd: string = await invoke("get_nstore", { + key, + }); + const parse: string | string[] = cmd ? JSON.parse(cmd) : null; + if (!parse.length) return null; + return parse; + } catch { + return null; + } + } + + public async set_nstore(key: string, content: string) { + try { + const cmd: string = await invoke("set_nstore", { + key, + content, + }); + return cmd; + } catch (e) { + throw new Error(String(e)); + } + } + + public async open_event_id(id: string) { + try { + const label = `event-${id}`; + const url = `/events/${id}`; + + await invoke("open_window", { + label, + title: "Thread", + url, + width: 500, + height: 800, + }); + } catch (e) { + throw new Error(String(e)); + } + } + + public async open_event(event: Event) { + try { + let root: string = undefined; + let reply: string = undefined; + + const eTags = event.tags.filter( + (tag) => tag[0] === "e" || tag[0] === "q", + ); + + root = eTags.find((el) => el[3] === "root")?.[1]; + reply = eTags.find((el) => el[3] === "reply")?.[1]; + + if (!root) root = eTags[0]?.[1]; + if (!reply) reply = eTags[1]?.[1]; + + const label = `event-${event.id}`; + const url = `/events/${root ?? reply ?? event.id}`; + + await invoke("open_window", { + label, + title: "Thread", + url, + width: 500, + height: 800, + }); + } catch (e) { + throw new Error(String(e)); + } + } + + public async open_profile(pubkey: string) { + try { + const label = `user-${pubkey}`; + await invoke("open_window", { + label, + title: "Profile", + url: `/users/${pubkey}`, + width: 500, + height: 800, + }); + } catch (e) { + throw new Error(String(e)); + } + } + + public async open_editor(reply_to?: string, quote = false) { + try { + let url: string; + + if (reply_to) { + url = `/editor?reply_to=${reply_to}"e=${quote}`; + } else { + url = "/editor"; + } + + const label = `editor-${reply_to ? reply_to : 0}`; + + await invoke("open_window", { + label, + title: "Editor", + url, + width: 560, + height: 340, + }); + } catch (e) { + throw new Error(String(e)); + } + } + + public async open_nwc() { + try { + const label = "nwc"; + await invoke("open_window", { + label, + title: "Nostr Wallet Connect", + url: "/nwc", + width: 400, + height: 600, + }); + } catch (e) { + throw new Error(String(e)); + } + } + + public async open_zap(id: string, pubkey: string, account: string) { + try { + const label = `zap-${id}`; + await invoke("open_window", { + label, + title: "Zap", + url: `/zap/${id}?pubkey=${pubkey}&account=${account}`, + width: 400, + height: 500, + }); + } catch (e) { + throw new Error(String(e)); + } + } + + public async open_settings() { + try { + const label = "settings"; + await invoke("open_window", { + label, + title: "Settings", + url: "/settings", + width: 800, + height: 500, + }); + } catch (e) { + throw new Error(String(e)); + } + } + + public async open_search() { + try { + const label = "search"; + await invoke("open_window", { + label, + title: "Search", + url: "/search", + width: 750, + height: 470, + }); + } catch (e) { + throw new Error(String(e)); + } + } + + public async open_activity(account: string) { + try { + const label = "activity"; + await invoke("open_window", { + label, + title: "Activity", + url: `/activity/${account}/texts`, + width: 400, + height: 600, + }); + } catch (e) { + throw new Error(String(e)); + } + } } diff --git a/packages/icons/src/mention.tsx b/packages/icons/src/mention.tsx index a60aadd8..22e64730 100644 --- a/packages/icons/src/mention.tsx +++ b/packages/icons/src/mention.tsx @@ -4,21 +4,13 @@ export function MentionIcon( props: JSX.IntrinsicAttributes & SVGProps, ) { return ( - + + d="M16.868 19.867A9.25 9.25 0 1 1 21.25 12c0 1.98-.984 4.024-3.279 3.816a3.312 3.312 0 0 1-2.978-3.767l.53-3.646m-.585 4.077c-.308 2.188-2.109 3.744-4.023 3.474-1.914-.269-3.217-2.26-2.91-4.448.308-2.187 2.11-3.743 4.023-3.474 1.914.27 3.217 2.26 2.91 4.448Z" + /> ); } diff --git a/packages/types/index.d.ts b/packages/types/index.d.ts index c5c16e40..c0707797 100644 --- a/packages/types/index.d.ts +++ b/packages/types/index.d.ts @@ -1,167 +1,167 @@ export interface Settings { - notification: boolean; - enhancedPrivacy: boolean; - autoUpdate: boolean; - zap: boolean; - nsfw: boolean; - [key: string]: string | number | boolean; + notification: boolean; + enhancedPrivacy: boolean; + autoUpdate: boolean; + zap: boolean; + nsfw: boolean; + [key: string]: string | number | boolean; } export interface Keys { - npub: string; - nsec: string; + npub: string; + nsec: string; } export enum Kind { - Metadata = 0, - Text = 1, - RecommendRelay = 2, - Contacts = 3, - Repost = 6, - Reaction = 7, - ZapReceipt = 9735, - // NIP-89: App Metadata - AppRecommendation = 31989, - AppHandler = 31990, - // #TODO: Add all nostr kinds + Metadata = 0, + Text = 1, + RecommendRelay = 2, + Contacts = 3, + Repost = 6, + Reaction = 7, + ZapReceipt = 9735, + // NIP-89: App Metadata + AppRecommendation = 31989, + AppHandler = 31990, + // #TODO: Add all nostr kinds } export interface Event { - id: string; - pubkey: string; - created_at: number; - kind: Kind; - tags: string[][]; - content: string; - sig: string; - relay?: string; + id: string; + pubkey: string; + created_at: number; + kind: Kind; + tags: string[][]; + content: string; + sig: string; + relay?: string; } export interface EventWithReplies extends Event { - replies: Array; + replies: Array; } export interface Metadata { - name?: string; - display_name?: string; - about?: string; - website?: string; - picture?: string; - banner?: string; - nip05?: string; - lud06?: string; - lud16?: string; + name?: string; + display_name?: string; + about?: string; + website?: string; + picture?: string; + banner?: string; + nip05?: string; + lud06?: string; + lud16?: string; } export interface Contact { - pubkey: string; - profile: Metadata; + pubkey: string; + profile: Metadata; } export interface Account { - npub: string; - nsec?: string; - contacts?: string[]; - interests?: Interests; + npub: string; + nsec?: string; + contacts?: string[]; + interests?: Interests; } export interface Interests { - hashtags: string[]; - users: string[]; - words: string[]; + hashtags: string[]; + users: string[]; + words: string[]; } export interface RichContent { - parsed: string; - images: string[]; - videos: string[]; - links: string[]; - notes: string[]; + parsed: string; + images: string[]; + videos: string[]; + links: string[]; + notes: string[]; } export interface AppRouteSearch { - account: string; + account: string; } export interface ColumnRouteSearch { - account: string; - label: string; - name: string; - redirect?: string; + account: string; + label: string; + name: string; + redirect?: string; } export interface LumeColumn { - label: string; - name: string; - content: URL | string; - description?: string; - author?: string; - logo?: string; - cover?: string; - coverRetina?: string; - featured?: boolean; + label: string; + name: string; + content: URL | string; + description?: string; + author?: string; + logo?: string; + cover?: string; + coverRetina?: string; + featured?: boolean; } export interface EventColumns { - type: "add" | "remove" | "update" | "left" | "right" | "set_title"; - label?: string; - title?: string; - column?: LumeColumn; + type: "add" | "remove" | "update" | "left" | "right" | "set_title"; + label?: string; + title?: string; + column?: LumeColumn; } export interface Opengraph { - url: string; - title?: string; - description?: string; - image?: string; + url: string; + title?: string; + description?: string; + image?: string; } export interface NostrBuildResponse { - ok: boolean; - data?: { - message: string; - status: string; - data: Array<{ - blurhash: string; - dimensions: { - width: number; - height: number; - }; - mime: string; - name: string; - sha256: string; - size: number; - url: string; - }>; - }; + ok: boolean; + data?: { + message: string; + status: string; + data: Array<{ + blurhash: string; + dimensions: { + width: number; + height: number; + }; + mime: string; + name: string; + sha256: string; + size: number; + url: string; + }>; + }; } export interface NIP11 { - name: string; - description: string; - pubkey: string; - contact: string; - supported_nips: number[]; - software: string; - version: string; - limitation: { - [key: string]: string | number | boolean; - }; - relay_countries: string[]; - language_tags: string[]; - tags: string[]; - posting_policy: string; - payments_url: string; - icon: string[]; + name: string; + description: string; + pubkey: string; + contact: string; + supported_nips: number[]; + software: string; + version: string; + limitation: { + [key: string]: string | number | boolean; + }; + relay_countries: string[]; + language_tags: string[]; + tags: string[]; + posting_policy: string; + payments_url: string; + icon: string[]; } export interface NIP05 { - names: { - [key: string]: string; - }; - nip46: { - [key: string]: { - [key: string]: string[]; - }; - }; + names: { + [key: string]: string; + }; + nip46: { + [key: string]: { + [key: string]: string[]; + }; + }; } diff --git a/packages/ui/src/note/buttons/reply.tsx b/packages/ui/src/note/buttons/reply.tsx index 58973043..5d55974a 100644 --- a/packages/ui/src/note/buttons/reply.tsx +++ b/packages/ui/src/note/buttons/reply.tsx @@ -23,6 +23,7 @@ export function NoteReply({ large = false }: { large?: boolean }) { )} > + {large ? "Reply" : null} diff --git a/src-tauri/src/nostr/event.rs b/src-tauri/src/nostr/event.rs index b04a2341..38ed3028 100644 --- a/src-tauri/src/nostr/event.rs +++ b/src-tauri/src/nostr/event.rs @@ -187,9 +187,9 @@ pub async fn publish( state: State<'_, Nostr>, ) -> Result { let client = &state.client; - let final_tags = tags.into_iter().map(|val| Tag::parse(&val).unwrap()); + let event_tags = tags.into_iter().map(|val| Tag::parse(&val).unwrap()); - match client.publish_text_note(content, final_tags).await { + match client.publish_text_note(content, event_tags).await { Ok(event_id) => Ok(event_id.to_bech32().unwrap()), Err(err) => Err(err.to_string()), } diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index cbcc6fae..f429f7b1 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -1,7 +1,10 @@ use std::path::PathBuf; #[cfg(target_os = "macos")] use tauri::TitleBarStyle; -use tauri::{Manager, Runtime, WebviewUrl, WebviewWindowBuilder}; +use tauri::{ + utils::config::WindowEffectsConfig, window::Effect, Manager, Runtime, WebviewUrl, + WebviewWindowBuilder, +}; use tauri_plugin_shell::ShellExt; pub fn create_tray(app: &tauri::AppHandle) -> tauri::Result<()> { @@ -60,18 +63,25 @@ pub fn create_tray(app: &tauri::AppHandle) -> tauri::Result<()> { let _ = WebviewWindowBuilder::new(app, "editor-0", WebviewUrl::App(PathBuf::from("editor"))) .title("Editor") - .min_inner_size(500., 400.) - .inner_size(600., 400.) + .min_inner_size(560., 340.) + .inner_size(560., 340.) .hidden_title(true) .title_bar_style(TitleBarStyle::Overlay) + .transparent(true) + .effects(WindowEffectsConfig { + state: None, + effects: vec![Effect::WindowBackground], + radius: None, + color: None, + }) .build() .unwrap(); #[cfg(not(target_os = "macos"))] let _ = WebviewWindowBuilder::new(app, "editor-0", WebviewUrl::App(PathBuf::from("editor"))) .title("Editor") - .min_inner_size(500., 400.) - .inner_size(600., 400.) + .min_inner_size(560., 340.) + .inner_size(560., 340.) .build() .unwrap(); } @@ -92,6 +102,13 @@ pub fn create_tray(app: &tauri::AppHandle) -> tauri::Result<()> { .minimizable(false) .resizable(false) .title_bar_style(TitleBarStyle::Overlay) + .transparent(true) + .effects(WindowEffectsConfig { + state: None, + effects: vec![Effect::WindowBackground], + radius: None, + color: None, + }) .build() .unwrap(); #[cfg(not(target_os = "macos"))] @@ -131,6 +148,13 @@ pub fn create_tray(app: &tauri::AppHandle) -> tauri::Result<()> { .hidden_title(true) .resizable(false) .minimizable(false) + .transparent(true) + .effects(WindowEffectsConfig { + state: None, + effects: vec![Effect::WindowBackground], + radius: None, + color: None, + }) .build() .unwrap(); #[cfg(not(target_os = "macos"))]