From 94d400cab2c4e8cd0d885acbcb0ab2674f1edf1f Mon Sep 17 00:00:00 2001 From: reya Date: Tue, 16 Apr 2024 07:49:44 +0700 Subject: [PATCH] feat: support nip-36 --- apps/desktop2/package.json | 1 + apps/desktop2/src/routes/$account.home.tsx | 2 +- apps/desktop2/src/routes/__root.tsx | 10 +- apps/desktop2/src/routes/auth/settings.tsx | 29 +++-- .../src/routes/editor/-components/media.tsx | 44 +++++--- .../src/routes/editor/-components/nsfw.tsx | 40 +++++++ apps/desktop2/src/routes/editor/index.tsx | 102 ++++++++++-------- packages/ark/src/ark.ts | 79 +++++++++----- packages/icons/index.ts | 1 + packages/icons/src/addMedia.tsx | 29 ++--- packages/icons/src/nsfw.tsx | 13 +++ packages/types/index.d.ts | 2 + packages/ui/src/note/index.ts | 1 - pnpm-lock.yaml | 15 ++- src-tauri/src/main.rs | 2 - src-tauri/src/nostr/event.rs | 33 +----- 16 files changed, 253 insertions(+), 150 deletions(-) create mode 100644 apps/desktop2/src/routes/editor/-components/nsfw.tsx create mode 100644 packages/icons/src/nsfw.tsx diff --git a/apps/desktop2/package.json b/apps/desktop2/package.json index e31d8443..216dbb94 100644 --- a/apps/desktop2/package.json +++ b/apps/desktop2/package.json @@ -19,6 +19,7 @@ "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-switch": "^1.0.3", + "@radix-ui/react-tooltip": "^1.0.7", "@tanstack/query-sync-storage-persister": "^5.29.0", "@tanstack/react-query": "^5.29.0", "@tanstack/react-query-persist-client": "^5.29.0", diff --git a/apps/desktop2/src/routes/$account.home.tsx b/apps/desktop2/src/routes/$account.home.tsx index 9f9dad30..47e7c9d8 100644 --- a/apps/desktop2/src/routes/$account.home.tsx +++ b/apps/desktop2/src/routes/$account.home.tsx @@ -10,7 +10,7 @@ import { getCurrent } from "@tauri-apps/api/webviewWindow"; import { readTextFile } from "@tauri-apps/plugin-fs"; import { nanoid } from "nanoid"; import { useEffect, useRef, useState } from "react"; -import { useDebounce, useDebouncedCallback } from "use-debounce"; +import { useDebouncedCallback } from "use-debounce"; import { VList, VListHandle } from "virtua"; export const Route = createFileRoute("/$account/home")({ diff --git a/apps/desktop2/src/routes/__root.tsx b/apps/desktop2/src/routes/__root.tsx index bc3f3b4b..a4111c79 100644 --- a/apps/desktop2/src/routes/__root.tsx +++ b/apps/desktop2/src/routes/__root.tsx @@ -2,8 +2,15 @@ import { Outlet, createRootRouteWithContext } from "@tanstack/react-router"; import { type Ark } from "@lume/ark"; import { type QueryClient } from "@tanstack/react-query"; import { type Platform } from "@tauri-apps/plugin-os"; -import { Account, Interests, Settings } from "@lume/types"; +import type { Account, Interests, Settings } from "@lume/types"; import { Spinner } from "@lume/ui"; +import { type Descendant } from "slate"; + +type EditorElement = { + type: string; + children: Descendant[]; + eventId?: string; +}; interface RouterContext { ark: Ark; @@ -13,6 +20,7 @@ interface RouterContext { settings?: Settings; interests?: Interests; accounts?: Account[]; + initialValue?: EditorElement[]; } export const Route = createRootRouteWithContext()({ diff --git a/apps/desktop2/src/routes/auth/settings.tsx b/apps/desktop2/src/routes/auth/settings.tsx index 16676347..3eccb622 100644 --- a/apps/desktop2/src/routes/auth/settings.tsx +++ b/apps/desktop2/src/routes/auth/settings.tsx @@ -69,6 +69,13 @@ function Screen() { })); }; + const toggleNsfw = () => { + setNewSettings((prev) => ({ + ...prev, + nsfw: !newSettings.nsfw, + })); + }; + const submit = async () => { try { // start loading @@ -167,18 +174,28 @@ function Screen() {

-
-

- There are many more settings you can configure from the 'Settings' - Screen. Be sure to visit it later. -

+
+ toggleNsfw()} + className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800" + > + + +
+

Filter sensitive content

+

+ By default, Lume will display all content which have Content + Warning tag, it's may include NSFW content. +

+
diff --git a/apps/desktop2/src/routes/editor/-components/media.tsx b/apps/desktop2/src/routes/editor/-components/media.tsx index bfcbddaf..e367d7cf 100644 --- a/apps/desktop2/src/routes/editor/-components/media.tsx +++ b/apps/desktop2/src/routes/editor/-components/media.tsx @@ -7,6 +7,7 @@ import { getCurrent } from "@tauri-apps/api/window"; import { UnlistenFn } from "@tauri-apps/api/event"; import { useRouteContext } from "@tanstack/react-router"; import { Spinner } from "@lume/ui"; +import * as Tooltip from "@radix-ui/react-tooltip"; export function MediaButton({ className }: { className?: string }) { const { ark } = useRouteContext({ strict: false }); @@ -16,14 +17,13 @@ export function MediaButton({ className }: { className?: string }) { const uploadToNostrBuild = async () => { try { + // start loading setLoading(true); const image = await ark.upload(); + insertImage(editor, image); - if (image) { - insertImage(editor, image); - } - + // reset loading setLoading(false); } catch (e) { setLoading(false); @@ -63,17 +63,29 @@ export function MediaButton({ className }: { className?: string }) { }, []); return ( - + + + + + + + + Upload media + + + + + ); } diff --git a/apps/desktop2/src/routes/editor/-components/nsfw.tsx b/apps/desktop2/src/routes/editor/-components/nsfw.tsx new file mode 100644 index 00000000..079ed5e5 --- /dev/null +++ b/apps/desktop2/src/routes/editor/-components/nsfw.tsx @@ -0,0 +1,40 @@ +import { NsfwIcon } from "@lume/icons"; +import { cn } from "@lume/utils"; +import * as Tooltip from "@radix-ui/react-tooltip"; +import { Dispatch, SetStateAction } from "react"; + +export function NsfwToggle({ + nsfw, + setNsfw, + className, +}: { + nsfw: boolean; + setNsfw: Dispatch>; + className?: string; +}) { + return ( + + + + + + + + Mark as sensitive content + + + + + + ); +} diff --git a/apps/desktop2/src/routes/editor/index.tsx b/apps/desktop2/src/routes/editor/index.tsx index c8576602..8b24edb4 100644 --- a/apps/desktop2/src/routes/editor/index.tsx +++ b/apps/desktop2/src/routes/editor/index.tsx @@ -1,4 +1,4 @@ -import { LoaderIcon, TrashIcon } from "@lume/icons"; +import { ComposeFilledIcon, NsfwIcon, TrashIcon } from "@lume/icons"; import { Portal, cn, @@ -35,11 +35,11 @@ import { Spinner, User } from "@lume/ui"; import { nip19 } from "nostr-tools"; import { queryOptions, useSuspenseQuery } from "@tanstack/react-query"; import { invoke } from "@tauri-apps/api/core"; +import { NsfwToggle } from "./-components/nsfw"; -type EditorElement = { - type: string; - children: Descendant[]; - eventId?: string; +type EditorSearch = { + reply_to: string; + quote: boolean; }; const contactQueryOptions = queryOptions({ @@ -51,46 +51,48 @@ const contactQueryOptions = queryOptions({ }); export const Route = createFileRoute("/editor/")({ - loader: ({ context }) => - context.queryClient.ensureQueryData(contactQueryOptions), + validateSearch: (search: Record): EditorSearch => { + return { + reply_to: search.reply_to, + quote: search.quote === "true" ?? false, + }; + }, + beforeLoad: async ({ search }) => { + return { + initialValue: search.quote + ? [ + { + type: "paragraph", + children: [{ text: "" }], + }, + { + type: "event", + eventId: `nostr:${nip19.noteEncode(search.reply_to)}`, + children: [{ text: "" }], + }, + { + type: "paragraph", + children: [{ text: "" }], + }, + ] + : [ + { + type: "paragraph", + children: [{ text: "" }], + }, + ], + }; + }, + loader: ({ context }) => { + context.queryClient.ensureQueryData(contactQueryOptions); + }, component: Screen, pendingComponent: Pending, }); function Screen() { - // @ts-ignore, useless const { reply_to, quote } = Route.useSearch(); - const { ark } = Route.useRouteContext(); - - let initialValue: EditorElement[]; - - if (quote) { - initialValue = [ - { - type: "paragraph", - children: [{ text: "" }], - }, - { - type: "event", - eventId: `nostr:${nip19.noteEncode(reply_to)}`, - children: [{ text: "" }], - }, - { - type: "paragraph", - children: [{ text: "" }], - }, - ]; - } else { - initialValue = [ - { - type: "paragraph", - children: [{ text: "" }], - }, - ]; - } - - const ref = useRef(); - const contacts = useSuspenseQuery(contactQueryOptions).data as Contact[]; + const { ark, initialValue } = Route.useRouteContext(); const [t] = useTranslation(); const [editorValue, setEditorValue] = useState(initialValue); @@ -98,10 +100,14 @@ function Screen() { 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 ref = useRef(); + const contacts = useSuspenseQuery(contactQueryOptions).data as Contact[]; + const filters = contacts ?.filter((c) => c?.profile.name?.toLowerCase().startsWith(search.toLowerCase()), @@ -204,15 +210,25 @@ function Screen() { >
- + +
diff --git a/packages/ark/src/ark.ts b/packages/ark/src/ark.ts index 7e37c8ad..ac109d2b 100644 --- a/packages/ark/src/ark.ts +++ b/packages/ark/src/ark.ts @@ -23,9 +23,11 @@ enum NSTORE_KEYS { export class Ark { public windows: WebviewWindow[]; + public settings: Settings; constructor() { this.windows = []; + this.settings = undefined; } public async get_all_accounts() { @@ -144,7 +146,6 @@ export class Ark { if (asOf && asOf > 0) until = asOf.toString(); - const dedup = true; const seenIds = new Set(); const dedupQueue = new Set(); @@ -155,31 +156,37 @@ export class Ark { global: isGlobal, }); - if (dedup) { - for (const event of nostrEvents) { - const tags = event.tags - .filter((el) => el[0] === "e") - ?.map((item) => item[1]); + 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); + 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); } - return nostrEvents; + const events = nostrEvents + .filter((event) => !dedupQueue.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.error(String(e)); + console.info(String(e)); return []; } } @@ -229,7 +236,12 @@ export class Ark { return nostrEvents.sort((a, b) => b.created_at - a.created_at); } - public async publish(content: string, reply_to?: string, quote?: boolean) { + public async publish( + content: string, + reply_to?: string, + quote?: boolean, + nsfw?: boolean, + ) { try { const g = await generateContentTags(content); @@ -238,26 +250,34 @@ export class Ark { 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, - replyEvent.relay || "", - "mention", - ]); + 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] || "", "root"]); + eventTags.push([ + "e", + rootEvent[1], + rootEvent[2] || relayHint, + "root", + ]); } - eventTags.push(["e", replyEvent.id, replyEvent.relay || "", "reply"]); + 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, @@ -605,6 +625,7 @@ export class Ark { key: NSTORE_KEYS.settings, }); const settings: Settings = cmd ? JSON.parse(cmd) : null; + this.settings = settings; return settings; } catch { const defaultSettings: Settings = { @@ -612,7 +633,9 @@ export class Ark { enhancedPrivacy: false, notification: false, zap: false, + nsfw: false, }; + this.settings = defaultSettings; return defaultSettings; } } diff --git a/packages/icons/index.ts b/packages/icons/index.ts index d0a5f4ea..516179e4 100644 --- a/packages/icons/index.ts +++ b/packages/icons/index.ts @@ -123,3 +123,4 @@ export * from "./src/laurel"; export * from "./src/quote"; export * from "./src/key"; export * from "./src/remote"; +export * from "./src/nsfw"; diff --git a/packages/icons/src/addMedia.tsx b/packages/icons/src/addMedia.tsx index 29884065..71f20f80 100644 --- a/packages/icons/src/addMedia.tsx +++ b/packages/icons/src/addMedia.tsx @@ -1,20 +1,13 @@ export function AddMediaIcon(props: JSX.IntrinsicElements["svg"]) { - return ( - - - - ); + return ( + + + + ); } diff --git a/packages/icons/src/nsfw.tsx b/packages/icons/src/nsfw.tsx new file mode 100644 index 00000000..8bf8d9d5 --- /dev/null +++ b/packages/icons/src/nsfw.tsx @@ -0,0 +1,13 @@ +export function NsfwIcon(props: JSX.IntrinsicElements["svg"]) { + return ( + + + + ); +} diff --git a/packages/types/index.d.ts b/packages/types/index.d.ts index 98d5dd2d..21f056ac 100644 --- a/packages/types/index.d.ts +++ b/packages/types/index.d.ts @@ -3,6 +3,8 @@ export interface Settings { enhancedPrivacy: boolean; autoUpdate: boolean; zap: boolean; + nsfw: boolean; + [key: string]: string | number | boolean; } export interface Keys { diff --git a/packages/ui/src/note/index.ts b/packages/ui/src/note/index.ts index ac3d0de3..c4ffd9c7 100644 --- a/packages/ui/src/note/index.ts +++ b/packages/ui/src/note/index.ts @@ -20,7 +20,6 @@ export const Note = { Pin: NotePin, Content: NoteContent, Zap: NoteZap, - Pin: NotePin, Child: NoteChild, Thread: NoteThread, }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 47b3262d..e2087896 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,6 +87,9 @@ importers: '@radix-ui/react-switch': specifier: ^1.0.3 version: 1.0.3(@types/react-dom@18.2.24)(@types/react@18.2.75)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-tooltip': + specifier: ^1.0.7 + version: 1.0.7(@types/react-dom@18.2.24)(@types/react@18.2.75)(react-dom@18.2.0)(react@18.2.0) '@tanstack/query-sync-storage-persister': specifier: ^5.29.0 version: 5.29.0 @@ -259,7 +262,7 @@ importers: version: 1.0.7(@types/react-dom@18.2.24)(@types/react@18.2.75)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-tooltip': specifier: ^1.0.7 - version: 1.0.7(@types/react@18.2.75)(react-dom@18.2.0)(react@18.2.0) + version: 1.0.7(@types/react-dom@18.2.24)(@types/react@18.2.75)(react-dom@18.2.0)(react@18.2.0) '@tanstack/react-query': specifier: ^5.29.0 version: 5.29.0(react@18.2.0) @@ -410,7 +413,7 @@ importers: version: 1.0.7(@types/react-dom@18.2.24)(@types/react@18.2.75)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-tooltip': specifier: ^1.0.7 - version: 1.0.7(@types/react@18.2.75)(react-dom@18.2.0)(react@18.2.0) + version: 1.0.7(@types/react-dom@18.2.24)(@types/react@18.2.75)(react-dom@18.2.0)(react@18.2.0) '@tanstack/react-query': specifier: ^5.29.0 version: 5.29.0(react@18.2.0) @@ -2283,7 +2286,7 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false - /@radix-ui/react-tooltip@1.0.7(@types/react@18.2.75)(react-dom@18.2.0)(react@18.2.0): + /@radix-ui/react-tooltip@1.0.7(@types/react-dom@18.2.24)(@types/react@18.2.75)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-lPh5iKNFVQ/jav/j6ZrWq3blfDJ0OH9R6FlNUHPMqdLuQ9vwDgFsRxvl8b7Asuy5c8xmoojHUxKHQSOAvMHxyw==} peerDependencies: '@types/react': '*' @@ -2308,8 +2311,9 @@ packages: '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.24)(@types/react@18.2.75)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-slot': 1.0.2(@types/react@18.2.75)(react@18.2.0) '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.75)(react@18.2.0) - '@radix-ui/react-visually-hidden': 1.0.3(@types/react@18.2.75)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-visually-hidden': 1.0.3(@types/react-dom@18.2.24)(@types/react@18.2.75)(react-dom@18.2.0)(react@18.2.0) '@types/react': 18.2.75 + '@types/react-dom': 18.2.24 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) dev: false @@ -2416,7 +2420,7 @@ packages: react: 18.2.0 dev: false - /@radix-ui/react-visually-hidden@1.0.3(@types/react@18.2.75)(react-dom@18.2.0)(react@18.2.0): + /@radix-ui/react-visually-hidden@1.0.3(@types/react-dom@18.2.24)(@types/react@18.2.75)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-D4w41yN5YRKtu464TLnByKzMDG/JlMPHtfZgQAu9v6mNakUqGUI9vUrfQKz8NK41VMm/xbZbh76NUTVtIYqOMA==} peerDependencies: '@types/react': '*' @@ -2432,6 +2436,7 @@ packages: '@babel/runtime': 7.24.4 '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.24)(@types/react@18.2.75)(react-dom@18.2.0)(react@18.2.0) '@types/react': 18.2.75 + '@types/react-dom': 18.2.24 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) dev: false diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index f4ca0210..a6f413e6 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -126,8 +126,6 @@ fn main() { nostr::event::get_event_thread, nostr::event::publish, nostr::event::repost, - nostr::event::upvote, - nostr::event::downvote, commands::folder::show_in_folder, commands::folder::get_accounts, commands::opg::fetch_opg, diff --git a/src-tauri/src/nostr/event.rs b/src-tauri/src/nostr/event.rs index 3de7fa4d..c8f38a04 100644 --- a/src-tauri/src/nostr/event.rs +++ b/src-tauri/src/nostr/event.rs @@ -222,16 +222,15 @@ pub async fn get_event_thread(id: &str, state: State<'_, Nostr>) -> Result>, + tags: Vec>, state: State<'_, Nostr>, ) -> Result { let client = &state.client; let final_tags = tags.into_iter().map(|val| Tag::parse(&val).unwrap()); - if let Ok(event_id) = client.publish_text_note(content, final_tags).await { - Ok(event_id.to_bech32().unwrap()) - } else { - Err("Publish text note failed".into()) + match client.publish_text_note(content, final_tags).await { + Ok(event_id) => Ok(event_id.to_bech32().unwrap()), + Err(err) => Err(err.to_string()), } } @@ -246,27 +245,3 @@ pub async fn repost(raw: &str, state: State<'_, Nostr>) -> Result) -> Result { - let client = &state.client; - let event = Event::from_json(raw).unwrap(); - - if let Ok(event_id) = client.like(&event).await { - Ok(event_id) - } else { - Err("Upvote failed".into()) - } -} - -#[tauri::command] -pub async fn downvote(raw: &str, state: State<'_, Nostr>) -> Result { - let client = &state.client; - let event = Event::from_json(raw).unwrap(); - - if let Ok(event_id) = client.dislike(&event).await { - Ok(event_id) - } else { - Err("Downvote failed".into()) - } -}