make DM suck less

This commit is contained in:
hzrd149
2023-12-04 15:22:35 -06:00
parent a0e814bfce
commit c119e02a8a
8 changed files with 312 additions and 113 deletions

View File

@@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add decrypt all button to DMs

View File

@@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Cache decrypted events

View File

@@ -233,8 +233,11 @@ const router = createHashRouter([
{ path: "r/:relay", element: <RelayView /> }, { path: "r/:relay", element: <RelayView /> },
{ path: "notifications", element: <NotificationsView /> }, { path: "notifications", element: <NotificationsView /> },
{ path: "search", element: <SearchView /> }, { 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: "profile", element: <ProfileView /> },
{ {
path: "tools", path: "tools",

View File

@@ -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>;
}

View File

@@ -12,6 +12,7 @@ import { DefaultEmojiProvider, UserEmojiProvider } from "./emoji-provider";
import { AllUserSearchDirectoryProvider } from "./user-directory-provider"; import { AllUserSearchDirectoryProvider } from "./user-directory-provider";
import MuteModalProvider from "./mute-modal-provider"; import MuteModalProvider from "./mute-modal-provider";
import BreakpointProvider from "./breakpoint-provider"; import BreakpointProvider from "./breakpoint-provider";
import DecryptionProvider from "./dycryption-provider";
// Top level providers, should be render as close to the root as possible // Top level providers, should be render as close to the root as possible
export const GlobalProviders = ({ children }: { children: React.ReactNode }) => { export const GlobalProviders = ({ children }: { children: React.ReactNode }) => {
@@ -33,21 +34,23 @@ export function PageProviders({ children }: { children: React.ReactNode }) {
return ( return (
<BreakpointProvider> <BreakpointProvider>
<SigningProvider> <SigningProvider>
<DeleteEventProvider> <DecryptionProvider>
<MuteModalProvider> <DeleteEventProvider>
<InvoiceModalProvider> <MuteModalProvider>
<NotificationTimelineProvider> <InvoiceModalProvider>
<DefaultEmojiProvider> <NotificationTimelineProvider>
<UserEmojiProvider> <DefaultEmojiProvider>
<AllUserSearchDirectoryProvider> <UserEmojiProvider>
<PostModalProvider>{children}</PostModalProvider> <AllUserSearchDirectoryProvider>
</AllUserSearchDirectoryProvider> <PostModalProvider>{children}</PostModalProvider>
</UserEmojiProvider> </AllUserSearchDirectoryProvider>
</DefaultEmojiProvider> </UserEmojiProvider>
</NotificationTimelineProvider> </DefaultEmojiProvider>
</InvoiceModalProvider> </NotificationTimelineProvider>
</MuteModalProvider> </InvoiceModalProvider>
</DeleteEventProvider> </MuteModalProvider>
</DeleteEventProvider>
</DecryptionProvider>
</SigningProvider> </SigningProvider>
</BreakpointProvider> </BreakpointProvider>
); );

View File

@@ -1,13 +1,13 @@
import { useState } from "react"; 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 dayjs from "dayjs";
import { Kind } from "nostr-tools"; import { Kind, nip19 } from "nostr-tools";
import { Navigate, useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { ChevronLeftIcon } from "../../components/icons"; import { ChevronLeftIcon } from "../../components/icons";
import UserAvatar from "../../components/user-avatar"; import UserAvatar from "../../components/user-avatar";
import { UserLink } from "../../components/user-link"; import { UserLink } from "../../components/user-link";
import { normalizeToHex } from "../../helpers/nip19"; import { isHexKey } from "../../helpers/nip19";
import useSubject from "../../hooks/use-subject"; import useSubject from "../../hooks/use-subject";
import { useSigningContext } from "../../providers/signing-provider"; import { useSigningContext } from "../../providers/signing-provider";
import clientRelaysService from "../../services/client-relays"; 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 TimelineActionAndStatus from "../../components/timeline-page/timeline-action-and-status";
import NostrPublishAction from "../../classes/nostr-publish-action"; import NostrPublishAction from "../../classes/nostr-publish-action";
import { LightboxProvider } from "../../components/lightbox-provider"; 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 }) { function DirectMessageChatPage({ pubkey }: { pubkey: string }) {
const toast = useToast(); const toast = useToast();
const navigate = useNavigate(); const navigate = useNavigate();
const account = useCurrentAccount()!; const account = useCurrentAccount()!;
const { getOrCreateContainer, addToQueue, startQueue } = useDecryptionContext();
const { requestEncrypt, requestSignature } = useSigningContext(); const { requestEncrypt, requestSignature } = useSigningContext();
const [content, setContent] = useState<string>(""); 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], kinds: [Kind.EncryptedDirectMessage],
"#p": [account.pubkey], "#p": [account.pubkey],
@@ -59,48 +68,88 @@ function DirectMessageChatPage({ pubkey }: { pubkey: string }) {
}; };
const signed = await requestSignature(event); const signed = await requestSignature(event);
const writeRelays = clientRelaysService.getWriteUrls(); const writeRelays = clientRelaysService.getWriteUrls();
const pub = new NostrPublishAction("Send DM", writeRelays, signed); const relays = unique([...writeRelays, ...usersInbox]);
new NostrPublishAction("Send DM", relays, signed);
setContent(""); setContent("");
} catch (e) { } catch (e) {
if (e instanceof Error) toast({ status: "error", description: e.message }); 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); const callback = useTimelineCurserIntersectionCallback(timeline);
return ( return (
<LightboxProvider> <LightboxProvider>
<IntersectionObserverProvider callback={callback}> <IntersectionObserverProvider callback={callback}>
<Flex maxH={{ base: "calc(100vh - 3.5rem)", md: "100vh" }} overflow="hidden" direction="column"> <Card size="sm" flexShrink={0} p="2" flexDirection="row">
<Card size="sm" flexShrink={0}> <Flex gap="2" alignItems="center">
<CardBody display="flex" gap="2" alignItems="center"> <IconButton
<IconButton variant="ghost" icon={<ChevronLeftIcon />} aria-label="Back" onClick={() => navigate(-1)} /> variant="ghost"
<UserAvatar pubkey={pubkey} size="sm" /> icon={<ChevronLeftIcon />}
<UserLink pubkey={pubkey} /> aria-label="Back"
</CardBody> onClick={() => navigate(-1)}
</Card> hideFrom="xl"
<Flex h="0" flex={1} overflowX="hidden" overflowY="scroll" direction="column-reverse" gap="2" py="4" px="2"> />
{[...messages].map((event) => ( <UserAvatar pubkey={pubkey} size="sm" />
<Message key={event.id} event={event} /> <UserLink pubkey={pubkey} fontWeight="bold" />
))} <UserDnsIdentityIcon pubkey={pubkey} onlyIcon />
<TimelineActionAndStatus timeline={timeline} />
</Flex>
<Flex shrink={0}>
<Textarea value={content} onChange={(e) => setContent(e.target.value)} />
<Button isDisabled={!content} onClick={sendMessage}>
Send
</Button>
</Flex> </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> </Flex>
</IntersectionObserverProvider> </IntersectionObserverProvider>
</LightboxProvider> </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() { export default function DirectMessageChatView() {
const { key } = useParams(); const { pubkey } = useUserPointer();
if (!key) return <Navigate to="/" />;
const pubkey = normalizeToHex(key);
if (!pubkey) throw new Error("invalid pubkey");
return ( return (
<RequireCurrentAccount> <RequireCurrentAccount>
<DirectMessageChatPage pubkey={pubkey} /> <DirectMessageChatPage pubkey={pubkey} />

View File

@@ -1,8 +1,8 @@
import { Alert, AlertDescription, AlertIcon, Button } from "@chakra-ui/react";
import { useState } from "react"; import { useState } from "react";
import { Alert, AlertDescription, AlertIcon, Button } from "@chakra-ui/react";
import { UnlockIcon } from "../../components/icons"; import { UnlockIcon } from "../../components/icons";
import { useSigningContext } from "../../providers/signing-provider"; import { useDecryptionContainer } from "../../providers/dycryption-provider";
export default function DecryptPlaceholder({ export default function DecryptPlaceholder({
children, children,
@@ -13,30 +13,28 @@ export default function DecryptPlaceholder({
data: string; data: string;
pubkey: string; pubkey: string;
}): JSX.Element { }): JSX.Element {
const { requestDecrypt } = useSigningContext();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [decrypted, setDecrypted] = useState<string>(); const { requestDecrypt, plaintext, error } = useDecryptionContainer(pubkey, data);
const [error, setError] = useState<Error>();
const decrypt = async () => { const decrypt = async () => {
setLoading(true); setLoading(true);
try { try {
const decrypted = await requestDecrypt(data, pubkey); await requestDecrypt();
if (decrypted) setDecrypted(decrypted); } catch (e) {}
} catch (e) {
if (e instanceof Error) setError(e);
}
setLoading(false); setLoading(false);
}; };
if (decrypted) { if (plaintext) {
return children(decrypted); return children(plaintext);
} }
if (error) { if (error) {
return ( return (
<Alert status="error"> <Alert status="error">
<AlertIcon /> <AlertIcon />
<AlertDescription>{error.message}</AlertDescription> <AlertDescription>{error.message}</AlertDescription>
<Button isLoading={loading} leftIcon={<UnlockIcon />} onClick={decrypt} size="sm" ml="auto">
Try again
</Button>
</Alert> </Alert>
); );
} }

View File

@@ -9,13 +9,16 @@ import {
Card, Card,
CardBody, CardBody,
Flex, Flex,
Input,
Link, Link,
LinkBox, LinkBox,
LinkOverlay, LinkOverlay,
Text, Text,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { Link as RouterLink } from "react-router-dom"; import { Outlet, Link as RouterLink, useLocation, useParams } from "react-router-dom";
import { nip19 } from "nostr-tools";
import UserAvatar from "../../components/user-avatar"; import UserAvatar from "../../components/user-avatar";
import { getUserDisplayName } from "../../helpers/user-metadata"; import { getUserDisplayName } from "../../helpers/user-metadata";
import useSubject from "../../hooks/use-subject"; import useSubject from "../../hooks/use-subject";
@@ -23,14 +26,16 @@ import { useUserMetadata } from "../../hooks/use-user-metadata";
import directMessagesService from "../../services/direct-messages"; import directMessagesService from "../../services/direct-messages";
import { ExternalLinkIcon } from "../../components/icons"; import { ExternalLinkIcon } from "../../components/icons";
import RequireCurrentAccount from "../../providers/require-current-account"; import RequireCurrentAccount from "../../providers/require-current-account";
import { nip19 } from "nostr-tools";
import Timestamp from "../../components/timestamp"; import Timestamp from "../../components/timestamp";
import VerticalPageLayout from "../../components/vertical-page-layout"; import VerticalPageLayout from "../../components/vertical-page-layout";
import PeopleListSelection from "../../components/people-list-selection/people-list-selection";
import PeopleListProvider, { usePeopleListContext } from "../../providers/people-list-provider";
function ContactCard({ pubkey }: { pubkey: string }) { function ContactCard({ pubkey }: { pubkey: string }) {
const subject = useMemo(() => directMessagesService.getUserMessages(pubkey), [pubkey]); const subject = useMemo(() => directMessagesService.getUserMessages(pubkey), [pubkey]);
const messages = useSubject(subject); const messages = useSubject(subject);
const metadata = useUserMetadata(pubkey); const metadata = useUserMetadata(pubkey);
const location = useLocation();
return ( return (
<LinkBox as={Card} size="sm"> <LinkBox as={Card} size="sm">
@@ -41,12 +46,14 @@ function ContactCard({ pubkey }: { pubkey: string }) {
{messages[0] && <Timestamp flexShrink={0} timestamp={messages[0].created_at} />} {messages[0] && <Timestamp flexShrink={0} timestamp={messages[0].created_at} />}
</Flex> </Flex>
</CardBody> </CardBody>
<LinkOverlay as={RouterLink} to={`/dm/${nip19.npubEncode(pubkey)}`} /> <LinkOverlay as={RouterLink} to={`/dm/${nip19.npubEncode(pubkey)}` + location.search} />
</LinkBox> </LinkBox>
); );
} }
function DirectMessagesPage() { function DirectMessagesPage() {
const params = useParams();
const { people } = usePeopleListContext();
const [from, setFrom] = useState(dayjs().subtract(2, "days").unix()); const [from, setFrom] = useState(dayjs().subtract(2, "days").unix());
const conversations = useSubject(directMessagesService.conversations); const conversations = useSubject(directMessagesService.conversations);
@@ -62,67 +69,54 @@ function DirectMessagesPage() {
}; };
const sortedConversations = useMemo(() => { const sortedConversations = useMemo(() => {
return Array.from(conversations).sort((a, b) => { return Array.from(conversations)
const latestA = directMessagesService.getUserMessages(a).value[0]?.created_at ?? 0; .filter((pubkey) => (people ? people.some((p) => p.pubkey === pubkey) : true))
const latestB = directMessagesService.getUserMessages(b).value[0]?.created_at ?? 0; .sort((a, b) => {
const latestA = directMessagesService.getUserMessages(a).value[0]?.created_at ?? 0;
const latestB = directMessagesService.getUserMessages(b).value[0]?.created_at ?? 0;
return latestB - latestA; return latestB - latestA;
}); });
}, [conversations]); }, [conversations, people]);
const isChatOpen = !!params.pubkey;
return ( return (
<VerticalPageLayout> <Flex gap="4" maxH={{ base: "calc(100vh - 3.5rem)", md: "100vh" }}>
<Alert status="info" flexShrink={0}> <Flex
<AlertIcon /> gap="2"
<Flex direction={{ base: "column", lg: "row" }}> direction="column"
<AlertTitle>Give Blowater a try</AlertTitle> w={!isChatOpen ? { base: "full", lg: "sm" } : "sm"}
<AlertDescription> overflowX="hidden"
<Text> overflowY="auto"
Its a much better chat app than what I can build inside of noStrudel.{" "} py="2"
<Link href="https://blowater.app/" isExternal> px={{ base: "2", lg: 0 }}
blowater.app <ExternalLinkIcon /> hideBelow={!isChatOpen ? undefined : "xl"}
</Link> >
</Text> <Flex gap="2">
</AlertDescription> {/* <Input type="search" placeholder="Search" /> */}
<PeopleListSelection flexShrink={0} />
</Flex> </Flex>
</Alert> {sortedConversations.map((pubkey) => (
<ContactCard key={pubkey} pubkey={pubkey} />
{conversations.length === 0 ? ( ))}
<Alert <Button onClick={loadMore} isLoading={loading} flexShrink={0}>
status="info" Load More
variant="subtle" </Button>
flexDirection="column" </Flex>
alignItems="center" <Flex gap="2" direction="column" flex={1} hideBelow={!isChatOpen ? "xl" : undefined}>
justifyContent="center" <Outlet />
textAlign="center" </Flex>
height="200px" </Flex>
>
<AlertIcon boxSize="40px" mr={0} />
<AlertTitle mt={4} mb={1} fontSize="lg">
No direct messages yet :(
</AlertTitle>
<AlertDescription maxWidth="sm">
Click <ChatIcon /> on another users profile to start a conversation.
</AlertDescription>
</Alert>
) : (
<>
{sortedConversations.map((pubkey) => (
<ContactCard key={pubkey} pubkey={pubkey} />
))}
<Button onClick={loadMore} isLoading={loading} flexShrink={0}>
Load More
</Button>
</>
)}
</VerticalPageLayout>
); );
} }
export default function DirectMessagesView() { export default function DirectMessagesView() {
return ( return (
<RequireCurrentAccount> <RequireCurrentAccount>
<DirectMessagesPage /> <PeopleListProvider initList="global">
<DirectMessagesPage />
</PeopleListProvider>
</RequireCurrentAccount> </RequireCurrentAccount>
); );
} }