diff --git a/.changeset/eleven-buckets-decide.md b/.changeset/eleven-buckets-decide.md new file mode 100644 index 000000000..0bc33a0f0 --- /dev/null +++ b/.changeset/eleven-buckets-decide.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Add decrypt all button to DMs diff --git a/.changeset/smart-monkeys-boil.md b/.changeset/smart-monkeys-boil.md new file mode 100644 index 000000000..40c01f8e0 --- /dev/null +++ b/.changeset/smart-monkeys-boil.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Cache decrypted events diff --git a/src/app.tsx b/src/app.tsx index 81e36759d..b5d685291 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -233,8 +233,11 @@ const router = createHashRouter([ { path: "r/:relay", element: }, { path: "notifications", element: }, { path: "search", element: }, - { path: "dm", element: }, - { path: "dm/:key", element: }, + { + path: "dm", + element: , + children: [{ path: ":pubkey", element: }], + }, { path: "profile", element: }, { path: "tools", diff --git a/src/providers/dycryption-provider.tsx b/src/providers/dycryption-provider.tsx new file mode 100644 index 000000000..4fa509a7b --- /dev/null +++ b/src/providers/dycryption-provider.tsx @@ -0,0 +1,142 @@ +import { PropsWithChildren, createContext, useCallback, useContext, useMemo, useRef } from "react"; +import { nanoid } from "nanoid"; + +import Subject from "../classes/subject"; +import { useSigningContext } from "./signing-provider"; +import useSubject from "../hooks/use-subject"; +import createDefer, { Deferred } from "../classes/deferred"; + +class DecryptionContainer { + id = nanoid(); + pubkey: string; + data: string; + + plaintext = new Subject(); + error = new Subject(); + + constructor(pubkey: string, data: string) { + this.pubkey = pubkey; + this.data = data; + } +} + +type DecryptionContextType = { + getOrCreateContainer: (pubkey: string, data: string) => DecryptionContainer; + startQueue: () => void; + clearQueue: () => void; + addToQueue: (container: DecryptionContainer) => Promise; + getQueue: () => DecryptionContainer[]; +}; +const DecryptionContext = createContext({ + getOrCreateContainer: () => { + throw new Error("No DecryptionProvider"); + }, + startQueue: () => {}, + clearQueue: () => {}, + addToQueue: () => Promise.reject(new Error("No DecryptionProvider")), + getQueue: () => [], +}); + +export function useDecryptionContext(){ + return useContext(DecryptionContext) +} +export function useDecryptionContainer(pubkey: string, data: string) { + const { getOrCreateContainer, addToQueue, startQueue } = useContext(DecryptionContext); + const container = getOrCreateContainer(pubkey, data); + + const plaintext = useSubject(container.plaintext); + const error = useSubject(container.error); + + const requestDecrypt = useCallback(() => { + const p = addToQueue(container); + startQueue(); + return p; + }, [addToQueue, startQueue]); + + return { container, error, plaintext, requestDecrypt }; +} + +export default function DecryptionProvider({ children }: PropsWithChildren) { + const { requestDecrypt } = useSigningContext(); + + const containers = useRef([]); + const queue = useRef([]); + const promises = useRef>>(new Map()); + const running = useRef(false); + + const getQueue = useCallback(() => queue.current, []); + const clearQueue = useCallback(() => { + queue.current = []; + promises.current.clear(); + }, []); + const addToQueue = useCallback((container: DecryptionContainer) => { + queue.current.unshift(container); + let p = promises.current.get(container); + if (!p) { + p = createDefer(); + promises.current.set(container, p); + } + return p; + }, []); + + const getOrCreateContainer = useCallback((pubkey: string, data: string) => { + let container = containers.current.find((c) => c.pubkey === pubkey && c.data === data); + if (!container) { + container = new DecryptionContainer(pubkey, data); + containers.current.push(container); + } + return container; + }, []); + + const startQueue = useCallback(() => { + if (running.current === true) return; + running.current = false; + + async function decryptNext() { + if (running.current === true) return; + + const container = queue.current.pop(); + if (!container) { + running.current = false; + promises.current.clear(); + return; + } + + const promise = promises.current.get(container)!; + + try { + const plaintext = await requestDecrypt(container.data, container.pubkey); + + // set plaintext + container.plaintext.next(plaintext); + promise.resolve(plaintext); + + // remove promise + promises.current.delete(container); + + setTimeout(() => decryptNext(), 100); + } catch (e) { + if (e instanceof Error) { + // set error + container.error.next(e); + promise.reject(e); + + // clear queue + running.current = false; + queue.current = []; + promises.current.clear(); + } + } + } + + // start cycle + decryptNext(); + }, [requestDecrypt]); + + const context = useMemo( + () => ({ getQueue, addToQueue, clearQueue, getOrCreateContainer, startQueue }), + [getQueue, addToQueue, clearQueue, getOrCreateContainer, startQueue], + ); + + return {children}; +} diff --git a/src/providers/index.tsx b/src/providers/index.tsx index 007a2b564..8055b115b 100644 --- a/src/providers/index.tsx +++ b/src/providers/index.tsx @@ -12,6 +12,7 @@ import { DefaultEmojiProvider, UserEmojiProvider } from "./emoji-provider"; import { AllUserSearchDirectoryProvider } from "./user-directory-provider"; import MuteModalProvider from "./mute-modal-provider"; import BreakpointProvider from "./breakpoint-provider"; +import DecryptionProvider from "./dycryption-provider"; // Top level providers, should be render as close to the root as possible export const GlobalProviders = ({ children }: { children: React.ReactNode }) => { @@ -33,21 +34,23 @@ export function PageProviders({ children }: { children: React.ReactNode }) { return ( - - - - - - - - {children} - - - - - - - + + + + + + + + + {children} + + + + + + + + ); diff --git a/src/views/messages/chat.tsx b/src/views/messages/chat.tsx index 1e01a085b..e36e6f2d8 100644 --- a/src/views/messages/chat.tsx +++ b/src/views/messages/chat.tsx @@ -1,13 +1,13 @@ import { useState } from "react"; -import { Button, Card, CardBody, Flex, IconButton, Textarea, useToast } from "@chakra-ui/react"; +import { Button, Card, Flex, IconButton, Textarea, useToast } from "@chakra-ui/react"; import dayjs from "dayjs"; -import { Kind } from "nostr-tools"; -import { Navigate, useNavigate, useParams } from "react-router-dom"; +import { Kind, nip19 } from "nostr-tools"; +import { useNavigate, useParams } from "react-router-dom"; import { ChevronLeftIcon } from "../../components/icons"; import UserAvatar from "../../components/user-avatar"; import { UserLink } from "../../components/user-link"; -import { normalizeToHex } from "../../helpers/nip19"; +import { isHexKey } from "../../helpers/nip19"; import useSubject from "../../hooks/use-subject"; import { useSigningContext } from "../../providers/signing-provider"; import clientRelaysService from "../../services/client-relays"; @@ -22,17 +22,26 @@ import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline- import TimelineActionAndStatus from "../../components/timeline-page/timeline-action-and-status"; import NostrPublishAction from "../../classes/nostr-publish-action"; import { LightboxProvider } from "../../components/lightbox-provider"; +import { UserDnsIdentityIcon } from "../../components/user-dns-identity-icon"; +import { useDecryptionContext } from "../../providers/dycryption-provider"; +import { useUserRelays } from "../../hooks/use-user-relays"; +import { RelayMode } from "../../classes/relay"; +import { unique } from "../../helpers/array"; function DirectMessageChatPage({ pubkey }: { pubkey: string }) { const toast = useToast(); const navigate = useNavigate(); const account = useCurrentAccount()!; + const { getOrCreateContainer, addToQueue, startQueue } = useDecryptionContext(); const { requestEncrypt, requestSignature } = useSigningContext(); const [content, setContent] = useState(""); - const readRelays = useReadRelayUrls(); + const myInbox = useReadRelayUrls(); + const usersInbox = useUserRelays(pubkey) + .filter((r) => r.mode & RelayMode.READ) + .map((r) => r.url); - const timeline = useTimelineLoader(`${pubkey}-${account.pubkey}-messages`, readRelays, [ + const timeline = useTimelineLoader(`${pubkey}-${account.pubkey}-messages`, myInbox, [ { kinds: [Kind.EncryptedDirectMessage], "#p": [account.pubkey], @@ -59,48 +68,88 @@ function DirectMessageChatPage({ pubkey }: { pubkey: string }) { }; const signed = await requestSignature(event); const writeRelays = clientRelaysService.getWriteUrls(); - const pub = new NostrPublishAction("Send DM", writeRelays, signed); + const relays = unique([...writeRelays, ...usersInbox]); + new NostrPublishAction("Send DM", relays, signed); setContent(""); } catch (e) { if (e instanceof Error) toast({ status: "error", description: e.message }); } }; + const [loading, setLoading] = useState(false); + const decryptAll = async () => { + const promises = messages + .map((message) => { + const container = getOrCreateContainer(pubkey, message.content); + if (container.plaintext.value === undefined) return addToQueue(container); + }) + .filter(Boolean); + + startQueue(); + + setLoading(true); + Promise.all(promises).finally(() => setLoading(false)); + }; + const callback = useTimelineCurserIntersectionCallback(timeline); return ( - - - - } aria-label="Back" onClick={() => navigate(-1)} /> - - - - - - {[...messages].map((event) => ( - - ))} - - - -