Skip to content

Commit

Permalink
make DM suck less
Browse files Browse the repository at this point in the history
  • Loading branch information
hzrd149 committed Dec 4, 2023
1 parent a0e814b commit c119e02
Show file tree
Hide file tree
Showing 8 changed files with 312 additions and 113 deletions.
5 changes: 5 additions & 0 deletions .changeset/eleven-buckets-decide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nostrudel": minor
---

Add decrypt all button to DMs
5 changes: 5 additions & 0 deletions .changeset/smart-monkeys-boil.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nostrudel": minor
---

Cache decrypted events
7 changes: 5 additions & 2 deletions src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -233,8 +233,11 @@ const router = createHashRouter([
{ path: "r/:relay", element: <RelayView /> },
{ path: "notifications", element: <NotificationsView /> },
{ path: "search", element: <SearchView /> },
{ path: "dm", element: <DirectMessagesView /> },
{ path: "dm/:key", element: <DirectMessageChatView /> },
{
path: "dm",
element: <DirectMessagesView />,
children: [{ path: ":pubkey", element: <DirectMessageChatView /> }],
},
{ path: "profile", element: <ProfileView /> },
{
path: "tools",
Expand Down
142 changes: 142 additions & 0 deletions src/providers/dycryption-provider.tsx
Original file line number Diff line number Diff line change
@@ -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<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>;
}
33 changes: 18 additions & 15 deletions src/providers/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand All @@ -33,21 +34,23 @@ export function PageProviders({ children }: { children: React.ReactNode }) {
return (
<BreakpointProvider>
<SigningProvider>
<DeleteEventProvider>
<MuteModalProvider>
<InvoiceModalProvider>
<NotificationTimelineProvider>
<DefaultEmojiProvider>
<UserEmojiProvider>
<AllUserSearchDirectoryProvider>
<PostModalProvider>{children}</PostModalProvider>
</AllUserSearchDirectoryProvider>
</UserEmojiProvider>
</DefaultEmojiProvider>
</NotificationTimelineProvider>
</InvoiceModalProvider>
</MuteModalProvider>
</DeleteEventProvider>
<DecryptionProvider>
<DeleteEventProvider>
<MuteModalProvider>
<InvoiceModalProvider>
<NotificationTimelineProvider>
<DefaultEmojiProvider>
<UserEmojiProvider>
<AllUserSearchDirectoryProvider>
<PostModalProvider>{children}</PostModalProvider>
</AllUserSearchDirectoryProvider>
</UserEmojiProvider>
</DefaultEmojiProvider>
</NotificationTimelineProvider>
</InvoiceModalProvider>
</MuteModalProvider>
</DeleteEventProvider>
</DecryptionProvider>
</SigningProvider>
</BreakpointProvider>
);
Expand Down
109 changes: 79 additions & 30 deletions src/views/messages/chat.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<string>("");

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],
Expand All @@ -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 (
<LightboxProvider>
<IntersectionObserverProvider callback={callback}>
<Flex maxH={{ base: "calc(100vh - 3.5rem)", md: "100vh" }} overflow="hidden" direction="column">
<Card size="sm" flexShrink={0}>
<CardBody display="flex" gap="2" alignItems="center">
<IconButton variant="ghost" icon={<ChevronLeftIcon />} aria-label="Back" onClick={() => navigate(-1)} />
<UserAvatar pubkey={pubkey} size="sm" />
<UserLink pubkey={pubkey} />
</CardBody>
</Card>
<Flex h="0" flex={1} overflowX="hidden" overflowY="scroll" direction="column-reverse" gap="2" py="4" px="2">
{[...messages].map((event) => (
<Message key={event.id} event={event} />
))}
<TimelineActionAndStatus timeline={timeline} />
</Flex>
<Flex shrink={0}>
<Textarea value={content} onChange={(e) => setContent(e.target.value)} />
<Button isDisabled={!content} onClick={sendMessage}>
Send
</Button>
<Card size="sm" flexShrink={0} p="2" flexDirection="row">
<Flex gap="2" alignItems="center">
<IconButton
variant="ghost"
icon={<ChevronLeftIcon />}
aria-label="Back"
onClick={() => navigate(-1)}
hideFrom="xl"
/>
<UserAvatar pubkey={pubkey} size="sm" />
<UserLink pubkey={pubkey} fontWeight="bold" />
<UserDnsIdentityIcon pubkey={pubkey} onlyIcon />
</Flex>
<Button onClick={decryptAll} isLoading={loading} ml="auto">
Decrypt All
</Button>
</Card>
<Flex h="0" flex={1} overflowX="hidden" overflowY="scroll" direction="column-reverse" gap="2" py="4" px="2">
{[...messages].map((event) => (
<Message key={event.id} event={event} />
))}
<TimelineActionAndStatus timeline={timeline} />
</Flex>
<Flex shrink={0}>
<Textarea value={content} onChange={(e) => setContent(e.target.value)} />
<Button isDisabled={!content} onClick={sendMessage}>
Send
</Button>
</Flex>
</IntersectionObserverProvider>
</LightboxProvider>
);
}

function useUserPointer() {
const { pubkey } = useParams() as { pubkey: string };

if (isHexKey(pubkey)) return { pubkey, relays: [] };
const pointer = nip19.decode(pubkey);

switch (pointer.type) {
case "npub":
return { pubkey: pointer.data as string, relays: [] };
case "nprofile":
const d = pointer.data as nip19.ProfilePointer;
return { pubkey: d.pubkey, relays: d.relays ?? [] };
default:
throw new Error(`Unknown type ${pointer.type}`);
}
}

export default function DirectMessageChatView() {
const { key } = useParams();
if (!key) return <Navigate to="/" />;
const pubkey = normalizeToHex(key);
if (!pubkey) throw new Error("invalid pubkey");
const { pubkey } = useUserPointer();

return (
<RequireCurrentAccount>
<DirectMessageChatPage pubkey={pubkey} />
Expand Down
Loading

0 comments on commit c119e02

Please sign in to comment.