mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-09-25 19:23:45 +02:00
Rebuild direct message chat view using timeline loader
This commit is contained in:
5
.changeset/selfish-pants-design.md
Normal file
5
.changeset/selfish-pants-design.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"nostrudel": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Rebuild direct message chat view using timeline loader
|
5
.changeset/slimy-pandas-check.md
Normal file
5
.changeset/slimy-pandas-check.md
Normal file
@@ -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 LoginNip05View from "./views/login/nip05";
|
||||||
import LoginNsecView from "./views/login/nsec";
|
import LoginNsecView from "./views/login/nsec";
|
||||||
import UserZapsTab from "./views/user/zaps";
|
import UserZapsTab from "./views/user/zaps";
|
||||||
import DirectMessagesView from "./views/dm";
|
import DirectMessagesView from "./views/messages";
|
||||||
import DirectMessageChatView from "./views/dm/chat";
|
import DirectMessageChatView from "./views/messages/chat";
|
||||||
import NostrLinkView from "./views/link";
|
import NostrLinkView from "./views/link";
|
||||||
import UserReportsTab from "./views/user/reports";
|
import UserReportsTab from "./views/user/reports";
|
||||||
import appSettings from "./services/app-settings";
|
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";
|
import useOpenGraphData from "../hooks/use-open-graph-data";
|
||||||
|
|
||||||
export default function OpenGraphCard({ url, ...props }: { url: URL } & Omit<CardProps, "children">) {
|
export default function OpenGraphCard({ url, ...props }: { url: URL } & Omit<CardProps, "children">) {
|
||||||
const { value: data, loading } = useOpenGraphData(url);
|
const { value: data } = useOpenGraphData(url);
|
||||||
|
|
||||||
const link = (
|
const link = (
|
||||||
<Link href={url.toString()} isExternal color="blue.500">
|
<Link href={url.toString()} isExternal color="blue.500">
|
||||||
@@ -14,14 +14,12 @@ export default function OpenGraphCard({ url, ...props }: { url: URL } & Omit<Car
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<LinkBox borderRadius="lg" borderWidth={1} overflow="hidden" {...props}>
|
<LinkBox borderRadius="lg" borderWidth={1} overflow="hidden" {...props}>
|
||||||
{data.ogImage?.map((ogImage) => (
|
{data.ogImage?.length === 1 && <Image key={data.ogImage[0].url} src={data.ogImage[0].url} mx="auto" />}
|
||||||
<Image key={ogImage.url} src={ogImage.url} mx="auto" />
|
|
||||||
))}
|
|
||||||
|
|
||||||
<Box m="2" mt="4">
|
<Box m="2" mt="4">
|
||||||
<Heading size="sm" my="2">
|
<Heading size="sm" my="2">
|
||||||
<LinkOverlay href={url.toString()} isExternal>
|
<LinkOverlay href={url.toString()} isExternal>
|
||||||
{data.ogTitle ?? data.dcTitle}
|
{data.ogTitle?.trim() ?? data.dcTitle?.trim()}
|
||||||
</LinkOverlay>
|
</LinkOverlay>
|
||||||
</Heading>
|
</Heading>
|
||||||
<Text isTruncated>{data.ogDescription || data.dcDescription}</Text>
|
<Text isTruncated>{data.ogDescription || data.dcDescription}</Text>
|
||||||
|
@@ -94,7 +94,11 @@ class DirectMessagesService {
|
|||||||
const account = accountService.current.value;
|
const account = accountService.current.value;
|
||||||
if (!account) return;
|
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"
|
// "since" is already set on the subscription and its older than "from"
|
||||||
return;
|
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
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,
|
Text,
|
||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
import dayjs from "dayjs";
|
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 { Link as RouterLink } from "react-router-dom";
|
||||||
import { UserAvatar } from "../../components/user-avatar";
|
import { UserAvatar } from "../../components/user-avatar";
|
||||||
import { Bech32Prefix, normalizeToBech32 } from "../../helpers/nip19";
|
import { Bech32Prefix, normalizeToBech32 } from "../../helpers/nip19";
|
51
src/views/messages/message.tsx
Normal file
51
src/views/messages/message.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
Reference in New Issue
Block a user