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: "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",

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 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 (
<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>
);

View File

@ -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<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],
@ -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} />

View File

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

View File

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