mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-10-04 16:37:00 +02:00
make DM suck less
This commit is contained in:
5
.changeset/eleven-buckets-decide.md
Normal file
5
.changeset/eleven-buckets-decide.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"nostrudel": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add decrypt all button to DMs
|
5
.changeset/smart-monkeys-boil.md
Normal file
5
.changeset/smart-monkeys-boil.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"nostrudel": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Cache decrypted events
|
@@ -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",
|
||||||
|
142
src/providers/dycryption-provider.tsx
Normal file
142
src/providers/dycryption-provider.tsx
Normal 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>;
|
||||||
|
}
|
@@ -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>
|
||||||
);
|
);
|
||||||
|
@@ -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} />
|
||||||
|
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user