Rebuild direct message chat view using timeline loader

This commit is contained in:
hzrd149 2023-07-05 21:03:25 -05:00
parent e6b773980a
commit bdc1c98d78
10 changed files with 185 additions and 148 deletions

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Rebuild direct message chat view using timeline loader

@ -0,0 +1,5 @@
---
"nostrudel": patch
---
Dont show multiple images on open-graph link card

@ -25,8 +25,8 @@ import RelaysView from "./views/relays";
import LoginNip05View from "./views/login/nip05";
import LoginNsecView from "./views/login/nsec";
import UserZapsTab from "./views/user/zaps";
import DirectMessagesView from "./views/dm";
import DirectMessageChatView from "./views/dm/chat";
import DirectMessagesView from "./views/messages";
import DirectMessageChatView from "./views/messages/chat";
import NostrLinkView from "./views/link";
import UserReportsTab from "./views/user/reports";
import appSettings from "./services/app-settings";

@ -1,8 +1,8 @@
import { Box, CardProps, Code, Heading, Image, Link, LinkBox, LinkOverlay, Text } from "@chakra-ui/react";
import { Box, CardProps, Heading, Image, Link, LinkBox, LinkOverlay, Text } from "@chakra-ui/react";
import useOpenGraphData from "../hooks/use-open-graph-data";
export default function OpenGraphCard({ url, ...props }: { url: URL } & Omit<CardProps, "children">) {
const { value: data, loading } = useOpenGraphData(url);
const { value: data } = useOpenGraphData(url);
const link = (
<Link href={url.toString()} isExternal color="blue.500">
@ -14,14 +14,12 @@ export default function OpenGraphCard({ url, ...props }: { url: URL } & Omit<Car
return (
<LinkBox borderRadius="lg" borderWidth={1} overflow="hidden" {...props}>
{data.ogImage?.map((ogImage) => (
<Image key={ogImage.url} src={ogImage.url} mx="auto" />
))}
{data.ogImage?.length === 1 && <Image key={data.ogImage[0].url} src={data.ogImage[0].url} mx="auto" />}
<Box m="2" mt="4">
<Heading size="sm" my="2">
<LinkOverlay href={url.toString()} isExternal>
{data.ogTitle ?? data.dcTitle}
{data.ogTitle?.trim() ?? data.dcTitle?.trim()}
</LinkOverlay>
</Heading>
<Text isTruncated>{data.ogDescription || data.dcDescription}</Text>

@ -94,7 +94,11 @@ class DirectMessagesService {
const account = accountService.current.value;
if (!account) return;
if (this.incomingSub.query?.since && dayjs.unix(this.incomingSub.query.since).isBefore(from)) {
if (
!Array.isArray(this.incomingSub.query) &&
this.incomingSub.query?.since &&
dayjs.unix(this.incomingSub.query.since).isBefore(from)
) {
// "since" is already set on the subscription and its older than "from"
return;
}

@ -1,138 +0,0 @@
import { Box, Button, Card, CardBody, CardProps, Flex, IconButton, Spacer, Text, Textarea } from "@chakra-ui/react";
import dayjs from "dayjs";
import { Kind } from "nostr-tools";
import { useEffect, useMemo, useState } from "react";
import { Link, Navigate, useParams } from "react-router-dom";
import { nostrPostAction } from "../../classes/nostr-post-action";
import { ArrowLeftSIcon } from "../../components/icons";
import { UserAvatar } from "../../components/user-avatar";
import { UserLink } from "../../components/user-link";
import { normalizeToHex } from "../../helpers/nip19";
import { useCurrentAccount } from "../../hooks/use-current-account";
import { useIsMobile } from "../../hooks/use-is-mobile";
import useSubject from "../../hooks/use-subject";
import { useSigningContext } from "../../providers/signing-provider";
import clientRelaysService from "../../services/client-relays";
import directMessagesService, { getMessageRecipient } from "../../services/direct-messages";
import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event";
import DecryptPlaceholder from "./decrypt-placeholder";
import { EmbedableContent, embedUrls } from "../../helpers/embeds";
import { embedNostrLinks, renderGenericUrl, renderImageUrl, renderVideoUrl } from "../../components/embed-types";
import RequireCurrentAccount from "../../providers/require-current-account";
function MessageContent({ event, text }: { event: NostrEvent; text: string }) {
let content: EmbedableContent = [text];
content = embedNostrLinks(content);
content = embedUrls(content, [renderImageUrl, renderVideoUrl, renderGenericUrl]);
return <Box whiteSpace="pre-wrap">{content}</Box>;
}
function Message({ event }: { event: NostrEvent } & Omit<CardProps, "children">) {
const account = useCurrentAccount()!;
const isOwnMessage = account.pubkey === event.pubkey;
return (
<Flex direction="column">
<Text size="sm" textAlign={isOwnMessage ? "right" : "left"} px="2">
{dayjs.unix(event.created_at).fromNow()}
</Text>
<Card size="sm" mr={isOwnMessage ? 0 : "8"} ml={isOwnMessage ? "8" : 0}>
<CardBody position="relative">
<DecryptPlaceholder
data={event.content}
pubkey={isOwnMessage ? getMessageRecipient(event) ?? "" : event.pubkey}
>
{(text) => <MessageContent event={event} text={text} />}
</DecryptPlaceholder>
</CardBody>
</Card>
</Flex>
);
}
function DirectMessageChatPage() {
const { key } = useParams();
if (!key) return <Navigate to="/" />;
const pubkey = normalizeToHex(key);
if (!pubkey) throw new Error("invalid pubkey");
const { requestEncrypt, requestSignature } = useSigningContext();
const isMobile = useIsMobile();
const [loading, setLoading] = useState(false);
const [from, setFrom] = useState(dayjs().subtract(1, "week"));
const [content, setContent] = useState<string>("");
useEffect(() => directMessagesService.loadDateRange(from), [from]);
const loadMore = () => {
setLoading(true);
setFrom((date) => dayjs(date).subtract(1, "week"));
setTimeout(() => {
setLoading(false);
}, 1000);
};
const subject = useMemo(() => directMessagesService.getUserMessages(pubkey), [pubkey]);
const messages = useSubject(subject);
const sendMessage = async () => {
if (!content) return;
const encrypted = await requestEncrypt(content, pubkey);
if (!encrypted) return;
const event: DraftNostrEvent = {
kind: Kind.EncryptedDirectMessage,
content: encrypted,
tags: [["p", pubkey]],
created_at: dayjs().unix(),
};
const signed = await requestSignature(event);
if (!signed) return;
const writeRelays = clientRelaysService.getWriteUrls();
nostrPostAction(writeRelays, signed);
setContent("");
};
return (
<Flex height="100%" overflow="hidden" direction="column">
<Card size="sm" flexShrink={0}>
<CardBody display="flex" gap="2" alignItems="center">
<IconButton
as={Link}
variant="ghost"
icon={<ArrowLeftSIcon />}
aria-label="Back"
to="/dm"
size={isMobile ? "sm" : "md"}
/>
<UserAvatar pubkey={pubkey} size={isMobile ? "sm" : "md"} />
<UserLink pubkey={pubkey} />
</CardBody>
</Card>
<Flex flex={1} overflowX="hidden" overflowY="scroll" direction="column" gap="4" py="4">
<Spacer height="100vh" />
<Button onClick={loadMore} mx="auto" flexShrink={0} isLoading={loading}>
Load More
</Button>
{[...messages].reverse().map((event) => (
<Message key={event.id} event={event} />
))}
</Flex>
<Flex shrink={0}>
<Textarea value={content} onChange={(e) => setContent(e.target.value)} />
<Button isDisabled={!content} onClick={sendMessage}>
Send
</Button>
</Flex>
</Flex>
);
}
export default function DirectMessageChatView() {
return (
<RequireCurrentAccount>
<DirectMessageChatPage />
</RequireCurrentAccount>
);
}

112
src/views/messages/chat.tsx Normal file

@ -0,0 +1,112 @@
import { Button, Card, CardBody, Flex, IconButton, Textarea } from "@chakra-ui/react";
import dayjs from "dayjs";
import { Kind } from "nostr-tools";
import { useRef, useState } from "react";
import { Link, Navigate, useParams } from "react-router-dom";
import { nostrPostAction } from "../../classes/nostr-post-action";
import { ArrowLeftSIcon } from "../../components/icons";
import { UserAvatar } from "../../components/user-avatar";
import { UserLink } from "../../components/user-link";
import { normalizeToHex } from "../../helpers/nip19";
import { useIsMobile } from "../../hooks/use-is-mobile";
import useSubject from "../../hooks/use-subject";
import { useSigningContext } from "../../providers/signing-provider";
import clientRelaysService from "../../services/client-relays";
import { DraftNostrEvent } from "../../types/nostr-event";
import RequireCurrentAccount from "../../providers/require-current-account";
import { Message } from "./message";
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
import { truncatedId } from "../../helpers/nostr-event";
import { useCurrentAccount } from "../../hooks/use-current-account";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import IntersectionObserverProvider from "../../providers/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import TimelineActionAndStatus from "../../components/timeline-action-and-status";
function DirectMessageChatPage({ pubkey }: { pubkey: string }) {
const isMobile = useIsMobile();
const account = useCurrentAccount()!;
const { requestEncrypt, requestSignature } = useSigningContext();
const [content, setContent] = useState<string>("");
const readRelays = useReadRelayUrls();
const timeline = useTimelineLoader(`${truncatedId(pubkey)}-${truncatedId(account.pubkey)}-messages`, readRelays, [
{
kinds: [Kind.EncryptedDirectMessage],
"#p": [account.pubkey],
authors: [pubkey],
},
{
kinds: [Kind.EncryptedDirectMessage],
"#p": [pubkey],
authors: [account.pubkey],
},
]);
const messages = useSubject(timeline.timeline);
const sendMessage = async () => {
if (!content) return;
const encrypted = await requestEncrypt(content, pubkey);
if (!encrypted) return;
const event: DraftNostrEvent = {
kind: Kind.EncryptedDirectMessage,
content: encrypted,
tags: [["p", pubkey]],
created_at: dayjs().unix(),
};
const signed = await requestSignature(event);
if (!signed) return;
const writeRelays = clientRelaysService.getWriteUrls();
nostrPostAction(writeRelays, signed);
setContent("");
};
const scrollBox = useRef<HTMLDivElement | null>(null);
const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<IntersectionObserverProvider root={scrollBox} callback={callback}>
<Flex height="100%" overflow="hidden" direction="column">
<Card size="sm" flexShrink={0}>
<CardBody display="flex" gap="2" alignItems="center">
<IconButton
as={Link}
variant="ghost"
icon={<ArrowLeftSIcon />}
aria-label="Back"
to="/dm"
size={isMobile ? "sm" : "md"}
/>
<UserAvatar pubkey={pubkey} size={isMobile ? "sm" : "md"} />
<UserLink pubkey={pubkey} />
</CardBody>
</Card>
<Flex flex={1} overflowX="hidden" overflowY="scroll" direction="column-reverse" gap="4" py="4">
{[...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>
);
}
export default function DirectMessageChatView() {
const { key } = useParams();
if (!key) return <Navigate to="/" />;
const pubkey = normalizeToHex(key);
if (!pubkey) throw new Error("invalid pubkey");
return (
<RequireCurrentAccount>
<DirectMessageChatPage pubkey={pubkey} />
</RequireCurrentAccount>
);
}

@ -14,7 +14,7 @@ import {
Text,
} from "@chakra-ui/react";
import dayjs from "dayjs";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Link as RouterLink } from "react-router-dom";
import { UserAvatar } from "../../components/user-avatar";
import { Bech32Prefix, normalizeToBech32 } from "../../helpers/nip19";

@ -0,0 +1,51 @@
import { Box, Card, CardBody, CardHeader, CardProps, Flex, Heading, Text } from "@chakra-ui/react";
import dayjs from "dayjs";
import { useCurrentAccount } from "../../hooks/use-current-account";
import { getMessageRecipient } from "../../services/direct-messages";
import { NostrEvent } from "../../types/nostr-event";
import DecryptPlaceholder from "./decrypt-placeholder";
import { EmbedableContent, embedUrls } from "../../helpers/embeds";
import { embedNostrLinks, renderGenericUrl, renderImageUrl, renderVideoUrl } from "../../components/embed-types";
import { useRef } from "react";
import { useRegisterIntersectionEntity } from "../../providers/intersection-observer";
import { UserAvatar } from "../../components/user-avatar";
import { UserLink } from "../../components/user-link";
export function MessageContent({ event, text }: { event: NostrEvent; text: string }) {
let content: EmbedableContent = [text];
content = embedNostrLinks(content);
content = embedUrls(content, [renderImageUrl, renderVideoUrl, renderGenericUrl]);
return <Box whiteSpace="pre-wrap">{content}</Box>;
}
export function Message({ event }: { event: NostrEvent } & Omit<CardProps, "children">) {
const account = useCurrentAccount()!;
const isOwnMessage = account.pubkey === event.pubkey;
const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, event.id);
return (
<Flex direction="column" ref={ref}>
<Card size="sm">
<CardHeader display="flex" gap="2" alignItems="center" pb="0">
<UserAvatar pubkey={event.pubkey} size="xs" />
<Heading size="md">
<UserLink pubkey={event.pubkey} />
</Heading>
<Text ml="auto">{dayjs.unix(event.created_at).fromNow()}</Text>
</CardHeader>
<CardBody position="relative">
<DecryptPlaceholder
data={event.content}
pubkey={isOwnMessage ? getMessageRecipient(event) ?? "" : event.pubkey}
>
{(text) => <MessageContent event={event} text={text} />}
</DecryptPlaceholder>
</CardBody>
</Card>
</Flex>
);
}