convert decryption cache to service

This commit is contained in:
hzrd149 2024-08-08 08:23:59 -05:00
parent cbddaaa44f
commit 7ebf09c24f
13 changed files with 226 additions and 197 deletions

View File

@ -25,6 +25,11 @@ export const DEFAULT_ICE_SERVERS: RTCIceServer[] = [
{
urls: ["stun:stun.l.google.com:19302"],
},
{
urls: ["turn:172.234.18.173:3478"],
username: "free",
credential: "free",
},
];
export const NOSTR_CONNECT_PERMISSIONS = [

View File

@ -0,0 +1,29 @@
import { useCallback, useMemo } from "react";
import { NostrEvent } from "nostr-tools";
import decryptionCacheService from "../services/decryption-cache";
import useCurrentAccount from "./use-current-account";
import useSubject from "./use-subject";
import { getDMRecipient, getDMSender } from "../helpers/nostr/dms";
export function useKind4Decrypt(event: NostrEvent, pubkey?: string) {
const account = useCurrentAccount()!;
pubkey = pubkey || event.pubkey === account.pubkey ? getDMRecipient(event) : getDMSender(event);
const container = useMemo(
() => decryptionCacheService.getOrCreateContainer(event.id, "nip04", pubkey, event.content),
[event, pubkey],
);
const plaintext = useSubject(container.plaintext);
const error = useSubject(container.error);
const requestDecrypt = useCallback(() => {
const p = decryptionCacheService.requestDecrypt(container);
decryptionCacheService.startDecryptionQueue();
return p;
}, [container]);
return { container, error, plaintext, requestDecrypt };
}

View File

@ -1,142 +0,0 @@
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(8);
pubkey: string;
data: string;
plaintext = new Subject<string>();
error = new Subject<Error>();
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<string>;
getQueue: () => DecryptionContainer[];
};
const DecryptionContext = createContext<DecryptionContextType>({
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<DecryptionContainer[]>([]);
const queue = useRef<DecryptionContainer[]>([]);
const promises = useRef<Map<DecryptionContainer, Deferred<string>>>(new Map());
const running = useRef<boolean>(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<string>();
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 <DecryptionContext.Provider value={context}>{children}</DecryptionContext.Provider>;
}

View File

@ -8,7 +8,6 @@ import NotificationsProvider from "./notifications-provider";
import { DefaultEmojiProvider, UserEmojiProvider } from "./emoji-provider";
import { AllUserSearchDirectoryProvider } from "./user-directory-provider";
import BreakpointProvider from "./breakpoint-provider";
import DecryptionProvider from "./decryption-provider";
import DMTimelineProvider from "./dms-provider";
import PublishProvider from "./publish-provider";
import WebOfTrustProvider from "./web-of-trust-provider";
@ -26,19 +25,17 @@ export const GlobalProviders = ({ children }: { children: React.ReactNode }) =>
<BreakpointProvider>
<SigningProvider>
<PublishProvider>
<DecryptionProvider>
<NotificationsProvider>
<DMTimelineProvider>
<DefaultEmojiProvider>
<UserEmojiProvider>
<AllUserSearchDirectoryProvider>
<WebOfTrustProvider>{children}</WebOfTrustProvider>
</AllUserSearchDirectoryProvider>
</UserEmojiProvider>
</DefaultEmojiProvider>
</DMTimelineProvider>
</NotificationsProvider>
</DecryptionProvider>
<NotificationsProvider>
<DMTimelineProvider>
<DefaultEmojiProvider>
<UserEmojiProvider>
<AllUserSearchDirectoryProvider>
<WebOfTrustProvider>{children}</WebOfTrustProvider>
</AllUserSearchDirectoryProvider>
</UserEmojiProvider>
</DefaultEmojiProvider>
</DMTimelineProvider>
</NotificationsProvider>
</PublishProvider>
</SigningProvider>
</BreakpointProvider>

View File

@ -42,14 +42,14 @@ export function SigningProvider({ children }: { children: React.ReactNode }) {
const requestDecrypt = useCallback(
async (data: string, pubkey: string) => {
if (!current) throw new Error("No account");
return await signingService.requestDecrypt(data, pubkey, current);
return await signingService.nip04Decrypt(data, pubkey, current);
},
[toast, current],
);
const requestEncrypt = useCallback(
async (data: string, pubkey: string) => {
if (!current) throw new Error("No account");
return await signingService.requestEncrypt(data, pubkey, current);
return await signingService.nip04Encrypt(data, pubkey, current);
},
[toast, current],
);

View File

@ -0,0 +1,126 @@
import Subject from "../classes/subject";
import _throttle from "lodash.throttle";
import createDefer, { Deferred } from "../classes/deferred";
import signingService from "./signing";
import accountService from "./account";
import { logger } from "../helpers/debug";
type EncryptionType = "nip04" | "nip44";
class DecryptionContainer {
/** event id */
id: string;
type: "nip04" | "nip44";
pubkey: string;
cipherText: string;
plaintext = new Subject<string>();
error = new Subject<Error>();
constructor(id: string, type: EncryptionType = "nip04", pubkey: string, cipherText: string) {
this.id = id;
this.pubkey = pubkey;
this.cipherText = cipherText;
this.type = type;
}
}
class DecryptionCache {
containers = new Map<string, DecryptionContainer>();
log = logger.extend("DecryptionCache");
getContainer(id: string) {
return this.containers.get(id);
}
getOrCreateContainer(id: string, type: EncryptionType, pubkey: string, cipherText: string) {
let container = this.containers.get(id);
if (!container) {
container = new DecryptionContainer(id, type, pubkey, cipherText);
this.containers.set(id, container);
}
return container;
}
private async decryptContainer(container: DecryptionContainer) {
const account = accountService.current.value;
if (!account) throw new Error("Missing account");
switch (container.type) {
case "nip04":
return await signingService.nip04Decrypt(container.cipherText, container.pubkey, account);
case "nip44":
return await signingService.nip44Decrypt(container.cipherText, container.pubkey, account);
}
}
promises = new Map<DecryptionContainer, Deferred<string>>();
private decryptQueue: DecryptionContainer[] = [];
private decryptQueueRunning = false;
private async decryptNext() {
const container = this.decryptQueue.pop();
if (!container) {
this.decryptQueueRunning = false;
this.decryptQueue = [];
return;
}
const promise = this.promises.get(container)!;
try {
if (!container.plaintext.value) {
const plaintext = await this.decryptContainer(container);
// set plaintext
container.plaintext.next(plaintext);
promise.resolve(plaintext);
// remove promise
this.promises.delete(container);
}
setTimeout(() => this.decryptNext(), 100);
} catch (e) {
if (e instanceof Error) {
// set error
container.error.next(e);
promise.reject(e);
// clear queue
this.decryptQueueRunning = false;
this.decryptQueue = [];
}
}
}
startDecryptionQueue() {
if (!this.decryptQueueRunning) {
this.decryptQueueRunning = true;
this.decryptNext();
}
}
requestDecrypt(container: DecryptionContainer) {
if (container.plaintext.value) return Promise.resolve(container.plaintext.value);
let p = this.promises.get(container);
if (!p) {
p = createDefer<string>();
this.promises.set(container, p);
this.decryptQueue.unshift(container);
this.startDecryptionQueue();
}
return p;
}
}
const decryptionCacheService = new DecryptionCache();
if (import.meta.env.DEV) {
// @ts-expect-error
window.decryptionCacheService = decryptionCacheService;
}
export default decryptionCacheService;

View File

@ -26,7 +26,7 @@ class SigningService {
return signed;
}
async requestEncrypt(plaintext: string, pubkey: string, account: Account) {
async nip04Encrypt(plaintext: string, pubkey: string, account: Account) {
if (account.readonly) throw new Error("Can not encrypt in readonly mode");
await this.unlockAccount(account);
@ -35,7 +35,7 @@ class SigningService {
return account.signer.nip04.encrypt(pubkey, plaintext);
}
async requestDecrypt(ciphertext: string, pubkey: string, account: Account) {
async nip04Decrypt(ciphertext: string, pubkey: string, account: Account) {
if (account.readonly) throw new Error("Can not decrypt in readonly mode");
await this.unlockAccount(account);
@ -43,6 +43,24 @@ class SigningService {
if (!account.signer.nip04) throw new Error("Signer does not support NIP-04");
return account.signer.nip04.decrypt(pubkey, ciphertext);
}
async nip44Encrypt(plaintext: string, pubkey: string, account: Account) {
if (account.readonly) throw new Error("Can not encrypt in readonly mode");
await this.unlockAccount(account);
if (!account.signer) throw new Error("Account missing signer");
if (!account.signer.nip44) throw new Error("Signer does not support NIP-44");
return account.signer.nip44.encrypt(pubkey, plaintext);
}
async nip44Decrypt(ciphertext: string, pubkey: string, account: Account) {
if (account.readonly) throw new Error("Can not decrypt in readonly mode");
await this.unlockAccount(account);
if (!account.signer) throw new Error("Account missing signer");
if (!account.signer.nip44) throw new Error("Signer does not support NIP-44");
return account.signer.nip44.decrypt(pubkey, ciphertext);
}
}
const signingService = new SigningService();

View File

@ -3,7 +3,7 @@ import { Button, ButtonGroup, Card, Flex, IconButton } from "@chakra-ui/react";
import { UNSAFE_DataRouterContext, useLocation, useNavigate } from "react-router-dom";
import { NostrEvent, kinds } from "nostr-tools";
import { ChevronLeftIcon, ThreadIcon } from "../../components/icons";
import { ThreadIcon } from "../../components/icons";
import UserAvatar from "../../components/user/user-avatar";
import UserLink from "../../components/user/user-link";
import useSubject from "../../hooks/use-subject";
@ -14,7 +14,6 @@ import IntersectionObserverProvider from "../../providers/local/intersection-obs
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import TimelineActionAndStatus from "../../components/timeline/timeline-action-and-status";
import UserDnsIdentity from "../../components/user/user-dns-identity";
import { useDecryptionContext } from "../../providers/global/decryption-provider";
import SendMessageForm from "./components/send-message-form";
import { groupMessages } from "../../helpers/nostr/dms";
import ThreadDrawer from "./components/thread-drawer";
@ -27,7 +26,8 @@ import RelaySet from "../../classes/relay-set";
import useAppSettings from "../../hooks/use-app-settings";
import { truncateId } from "../../helpers/string";
import useRouterMarker from "../../hooks/use-router-marker";
import BackButton, { BackIconButton } from "../../components/router/back-button";
import { BackIconButton } from "../../components/router/back-button";
import decryptionCacheService from "../../services/decryption-cache";
/** This is broken out from DirectMessageChatPage for performance reasons. Don't use outside of file */
const ChatLog = memo(({ timeline }: { timeline: TimelineLoader }) => {
@ -52,7 +52,6 @@ function DirectMessageChatPage({ pubkey }: { pubkey: string }) {
const { autoDecryptDMs } = useAppSettings();
const navigate = useNavigate();
const location = useLocation();
const { getOrCreateContainer, addToQueue, startQueue } = useDecryptionContext();
const { router } = useContext(UNSAFE_DataRouterContext)!;
const marker = useRouterMarker(router);
@ -104,13 +103,11 @@ function DirectMessageChatPage({ pubkey }: { pubkey: string }) {
const decryptAll = async () => {
const promises = timeline.timeline.value
.map((message) => {
const container = getOrCreateContainer(pubkey, message.content);
if (container.plaintext.value === undefined) return addToQueue(container);
const container = decryptionCacheService.getOrCreateContainer(message.id, "nip04", pubkey, message.content);
return decryptionCacheService.requestDecrypt(container);
})
.filter(Boolean);
startQueue();
setLoading(true);
Promise.all(promises).finally(() => setLoading(false));
};

View File

@ -3,11 +3,9 @@ import { Alert, AlertDescription, AlertIcon, Button, ButtonProps } from "@chakra
import { NostrEvent } from "nostr-tools";
import { UnlockIcon } from "../../../components/icons";
import { useDecryptionContainer } from "../../../providers/global/decryption-provider";
import useCurrentAccount from "../../../hooks/use-current-account";
import { getDMRecipient, getDMSender } from "../../../helpers/nostr/dms";
import DebugEventButton from "../../../components/debug-modal/debug-event-button";
import useAppSettings from "../../../hooks/use-app-settings";
import { useKind4Decrypt } from "../../../hooks/use-kind4-decryption";
export default function DecryptPlaceholder({
children,
@ -17,14 +15,9 @@ export default function DecryptPlaceholder({
children: (decrypted: string) => JSX.Element;
message: NostrEvent;
} & Omit<ButtonProps, "children">): JSX.Element {
const account = useCurrentAccount();
const { autoDecryptDMs } = useAppSettings();
const isOwn = account?.pubkey === message.pubkey;
const [loading, setLoading] = useState(false);
const { requestDecrypt, plaintext, error } = useDecryptionContainer(
isOwn ? getDMRecipient(message) : getDMSender(message),
message.content,
);
const { requestDecrypt, plaintext, error } = useKind4Decrypt(message);
const decrypt = async () => {
setLoading(true);

View File

@ -8,10 +8,10 @@ import { useSigningContext } from "../../../providers/global/signing-provider";
import MagicTextArea, { RefType } from "../../../components/magic-textarea";
import { useTextAreaUploadFileWithForm } from "../../../hooks/use-textarea-upload-file";
import { DraftNostrEvent } from "../../../types/nostr-event";
import { useDecryptionContext } from "../../../providers/global/decryption-provider";
import useUserMailboxes from "../../../hooks/use-user-mailboxes";
import { usePublishEvent } from "../../../providers/global/publish-provider";
import useCacheForm from "../../../hooks/use-cache-form";
import decryptionCacheService from "../../../services/decryption-cache";
export default function SendMessageForm({
pubkey,
@ -20,7 +20,6 @@ export default function SendMessageForm({
}: { pubkey: string; rootId?: string } & Omit<FlexProps, "children">) {
const publish = usePublishEvent();
const { requestEncrypt } = useSigningContext();
const { getOrCreateContainer } = useDecryptionContext();
const [loadingMessage, setLoadingMessage] = useState("");
const { getValues, setValue, watch, handleSubmit, formState, reset } = useForm({
@ -64,7 +63,9 @@ export default function SendMessageForm({
reset({ content: "" });
// add plaintext to decryption context
getOrCreateContainer(pubkey, encrypted).plaintext.next(values.content);
decryptionCacheService
.getOrCreateContainer(pub.event.id, "nip04", pubkey, encrypted)
.plaintext.next(values.content);
// refocus input
setTimeout(() => textAreaRef.current?.focus(), 50);

View File

@ -27,8 +27,8 @@ import { Thread, useThreadsContext } from "../../../providers/local/thread-provi
import ThreadButton from "../../../components/message/thread-button";
import SendMessageForm from "./send-message-form";
import { groupMessages } from "../../../helpers/nostr/dms";
import { useDecryptionContext } from "../../../providers/global/decryption-provider";
import DirectMessageBlock from "./direct-message-block";
import decryptionCacheService from "../../../services/decryption-cache";
function MessagePreview({ message, ...props }: { message: NostrEvent } & Omit<TextProps, "children">) {
return (
@ -102,7 +102,6 @@ export default function ThreadDrawer({
...props
}: Omit<DrawerProps, "children"> & { threadId: string; pubkey: string }) {
const { threads, getRoot } = useThreadsContext();
const { startQueue, getOrCreateContainer, addToQueue } = useDecryptionContext();
const thread = threads[threadId];
const [loading, setLoading] = useState(false);
@ -111,18 +110,21 @@ export default function ThreadDrawer({
const promises = thread.messages
.map((message) => {
const container = getOrCreateContainer(pubkey, message.content);
if (container.plaintext.value === undefined) return addToQueue(container);
const container = decryptionCacheService.getOrCreateContainer(message.id, "nip04", pubkey, message.content);
if (container.plaintext.value === undefined) return decryptionCacheService.requestDecrypt(container);
})
.filter(Boolean);
if (thread.root) {
const rootContainer = getOrCreateContainer(pubkey, thread.root.content);
if (rootContainer.plaintext.value === undefined) addToQueue(rootContainer);
const rootContainer = decryptionCacheService.getOrCreateContainer(
thread.root.id,
"nip04",
pubkey,
thread.root.content,
);
if (rootContainer.plaintext.value === undefined) decryptionCacheService.requestDecrypt(rootContainer);
}
startQueue();
setLoading(true);
Promise.all(promises).finally(() => setLoading(false));
};

View File

@ -16,16 +16,16 @@ import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-
import TimelineActionAndStatus from "../../components/timeline/timeline-action-and-status";
import { useDMTimeline } from "../../providers/global/dms-provider";
import UserName from "../../components/user/user-name";
import { useDecryptionContainer } from "../../providers/global/decryption-provider";
import { NostrEvent } from "../../types/nostr-event";
import { CheckIcon } from "../../components/icons";
import UserDnsIdentity from "../../components/user/user-dns-identity";
import useEventIntersectionRef from "../../hooks/use-event-intersection-ref";
import { useKind4Decrypt } from "../../hooks/use-kind4-decryption";
function MessagePreview({ message, pubkey }: { message: NostrEvent; pubkey: string }) {
const ref = useEventIntersectionRef(message);
const { plaintext } = useDecryptionContainer(pubkey, message.content);
const { plaintext } = useKind4Decrypt(message);
return (
<Text isTruncated ref={ref}>
{plaintext || "<Encrypted>"}

View File

@ -19,11 +19,12 @@ import UserAvatar from "../../../components/user/user-avatar";
import HoverLinkOverlay from "../../../components/hover-link-overlay";
import UserName from "../../../components/user/user-name";
import UserDnsIdentity from "../../../components/user/user-dns-identity";
import { useDecryptionContainer, useDecryptionContext } from "../../../providers/global/decryption-provider";
import Timestamp from "../../../components/timestamp";
import { useKind4Decrypt } from "../../../hooks/use-kind4-decryption";
import decryptionCacheService from "../../../services/decryption-cache";
function MessagePreview({ message, pubkey }: { message: NostrEvent; pubkey: string }) {
const { plaintext } = useDecryptionContainer(pubkey, message.content);
const { plaintext } = useKind4Decrypt(message);
return <Text isTruncated>{plaintext || "<Encrypted>"}</Text>;
}
@ -50,7 +51,6 @@ function Conversation({ conversation }: { conversation: KnownConversation }) {
export default function DMsCard({ ...props }: Omit<CardProps, "children">) {
const navigate = useNavigate();
const account = useCurrentAccount()!;
const { getOrCreateContainer, addToQueue, startQueue } = useDecryptionContext();
const timeline = useDMTimeline();
@ -74,13 +74,16 @@ export default function DMsCard({ ...props }: Omit<CardProps, "children">) {
const last = conversation.messages.find((m) => m.pubkey === conversation.correspondent);
if (!last) return;
const container = getOrCreateContainer(conversation.correspondent, last.content);
if (container.plaintext.value === undefined) return addToQueue(container);
const container = decryptionCacheService.getOrCreateContainer(
last.id,
"nip04",
conversation.correspondent,
last.content,
);
return decryptionCacheService.requestDecrypt(container);
})
.filter(Boolean);
startQueue();
setLoading(true);
Promise.all(promises).finally(() => setLoading(false));
};