group DMs

This commit is contained in:
hzrd149 2023-12-07 11:25:09 -06:00
parent 193b7b6a17
commit 6d0fd9e960
6 changed files with 168 additions and 104 deletions

View File

@ -15,7 +15,7 @@ import { RelayMode } from "../../classes/relay";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import IntersectionObserverProvider from "../../providers/intersection-observer";
import useSubject from "../../hooks/use-subject";
import Message from "../../views/dms/message";
import MessageBlock from "../../views/dms/message-block";
import { LightboxProvider } from "../lightbox-provider";
import { useSigningContext } from "../../providers/signing-provider";
import { DraftNostrEvent } from "../../types/nostr-event";
@ -99,7 +99,7 @@ export default function ChatWindow({ pubkey, onClose }: { pubkey: string; onClos
<LightboxProvider>
<IntersectionObserverProvider callback={callback}>
{messages.map((event) => (
<Message key={event.id} event={event} />
<MessageBlock key={event.id} events={event} />
))}
</IntersectionObserverProvider>
</LightboxProvider>

View File

@ -6,7 +6,7 @@ import UserAvatarLink from "../../user-avatar-link";
import UserLink from "../../user-link";
import Timestamp from "../../timestamp";
import DecryptPlaceholder from "../../../views/dms/decrypt-placeholder";
import { MessageContent } from "../../../views/dms/message";
import { MessageContent } from "../../../views/dms/message-block";
import { getMessageRecipient } from "../../../services/direct-messages";
import useCurrentAccount from "../../../hooks/use-current-account";

View File

@ -9,7 +9,7 @@ import UserLink from "../../components/user-link";
import { isHexKey } from "../../helpers/nip19";
import useSubject from "../../hooks/use-subject";
import RequireCurrentAccount from "../../providers/require-current-account";
import Message from "./message";
import MessageBlock from "./message-block";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import useCurrentAccount from "../../hooks/use-current-account";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
@ -20,7 +20,10 @@ import { LightboxProvider } from "../../components/lightbox-provider";
import { UserDnsIdentityIcon } from "../../components/user-dns-identity-icon";
import { useDecryptionContext } from "../../providers/dycryption-provider";
import SendMessageForm from "./send-message-form";
import { NostrEvent } from "../../types/nostr-event";
import dayjs from "dayjs";
const GROUP_MESSAGES_LESS_THAN_MIN = 5;
function DirectMessageChatPage({ pubkey }: { pubkey: string }) {
const navigate = useNavigate();
const account = useCurrentAccount()!;
@ -41,7 +44,24 @@ function DirectMessageChatPage({ pubkey }: { pubkey: string }) {
},
]);
const messages = useSubject(timeline.timeline);
const messages = useSubject(timeline.timeline).filter((e) => !e.tags.some((t) => t[0] === "e" && t[3] === "root"));
const grouped: { id: string; events: NostrEvent[] }[] = [];
for (const message of messages) {
const last = grouped[grouped.length - 1];
if (last && last.events[0]?.pubkey === message.pubkey) {
const lastEvent = last.events[last.events.length - 1];
if (
lastEvent &&
dayjs.unix(lastEvent.created_at).diff(dayjs.unix(message.created_at), "minute") < GROUP_MESSAGES_LESS_THAN_MIN
) {
last.events.push(message);
continue;
}
}
grouped.push({ id: message.id, events: [message] });
}
const [loading, setLoading] = useState(false);
const decryptAll = async () => {
@ -81,8 +101,8 @@ function DirectMessageChatPage({ pubkey }: { pubkey: string }) {
</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} />
{grouped.map((group) => (
<MessageBlock key={group.id} events={group.events} />
))}
<TimelineActionAndStatus timeline={timeline} />
</Flex>

View File

@ -82,7 +82,7 @@ function DirectMessagesPage() {
const isChatOpen = !!params.pubkey;
return (
<Flex gap="4" h={{ base: "calc(100vh - 3.5rem)", md: "100vh" }}>
<Flex gap="4" h={{ base: "calc(100vh - 3.5rem)", md: "100vh" }} overflow="hidden">
<Flex
gap="2"
direction="column"
@ -104,7 +104,7 @@ function DirectMessagesPage() {
Load More
</Button>
</Flex>
<Flex gap="2" direction="column" flex={1} hideBelow={!isChatOpen ? "xl" : undefined}>
<Flex gap="2" direction="column" flex={1} hideBelow={!isChatOpen ? "xl" : undefined} overflow="hidden">
<Outlet />
</Flex>
</Flex>

View File

@ -0,0 +1,139 @@
import { useRef } from "react";
import { Box, BoxProps, Card, CardBody, CardFooter, CardHeader, CardProps, Flex } from "@chakra-ui/react";
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 {
embedCashuTokens,
embedNostrLinks,
renderGenericUrl,
renderImageUrl,
renderVideoUrl,
} from "../../components/embed-types";
import { useRegisterIntersectionEntity } from "../../providers/intersection-observer";
import UserAvatar from "../../components/user-avatar";
import { getEventUID } from "../../helpers/nostr/events";
import Timestamp from "../../components/timestamp";
import NoteZapButton from "../../components/note/note-zap-button";
import UserLink from "../../components/user-link";
import { UserDnsIdentityIcon } from "../../components/user-dns-identity-icon";
import useEventReactions from "../../hooks/use-event-reactions";
import AddReactionButton from "../../components/note/components/add-reaction-button";
import { TrustProvider } from "../../providers/trust";
import NoteReactions from "../../components/note/components/note-reactions";
export function MessageContent({ event, text, children, ...props }: { event: NostrEvent; text: string } & BoxProps) {
let content: EmbedableContent = [text];
content = embedNostrLinks(content);
content = embedUrls(content, [renderImageUrl, renderVideoUrl, renderGenericUrl]);
// cashu
content = embedCashuTokens(content);
return (
<TrustProvider event={event}>
<Box whiteSpace="pre-wrap" {...props}>
{content}
{children}
</Box>
</TrustProvider>
);
}
function MessageBubble({
event,
showHeader = true,
...props
}: { event: NostrEvent; showHeader?: boolean } & Omit<CardProps, "children">) {
const account = useCurrentAccount()!;
const isOwn = account.pubkey === event.pubkey;
const reactions = useEventReactions(event.id) ?? [];
const hasReactions = reactions.length > 0;
const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, getEventUID(event));
return (
<Card {...props} borderRadius="lg" ref={ref}>
{showHeader && (
<CardHeader px="2" pt="2" pb="0" gap="2" display="flex" alignItems="center">
<UserLink pubkey={event.pubkey} fontWeight="bold" />
<UserDnsIdentityIcon pubkey={event.pubkey} onlyIcon />
<NoteZapButton event={event} size="xs" ml="auto" variant="ghost" />
<AddReactionButton event={event} size="xs" variant="ghost" />
</CardHeader>
)}
<CardBody px="2" py="2">
<DecryptPlaceholder
data={event.content}
pubkey={isOwn ? getMessageRecipient(event) ?? "" : event.pubkey}
variant="link"
py="4"
px="6rem"
>
{(text) => (
<MessageContent event={event} text={text} display="inline">
{!hasReactions && (
<Flex float="right">
{!showHeader && (
<>
<NoteZapButton event={event} size="xs" ml="auto" variant="ghost" />
<AddReactionButton event={event} size="xs" variant="ghost" ml="1" />
</>
)}
<Timestamp timestamp={event.created_at} ml="2" />
</Flex>
)}
</MessageContent>
)}
</DecryptPlaceholder>
</CardBody>
{hasReactions && (
<CardFooter alignItems="center" display="flex" gap="2" px="2" pt="0" pb="2">
<NoteReactions event={event} size="xs" variant="ghost" />
<NoteZapButton event={event} size="xs" mr="auto" variant="ghost" />
<Timestamp ml="auto" timestamp={event.created_at} />
</CardFooter>
)}
</Card>
);
}
export default function MessageBlock({ events }: { events: NostrEvent[] } & Omit<CardProps, "children">) {
const lastEvent = events[events.length - 1];
const account = useCurrentAccount()!;
const isOwn = account.pubkey === lastEvent.pubkey;
const avatar = <UserAvatar pubkey={lastEvent.pubkey} size="sm" my="1" />;
return (
<Flex direction="row" gap="2" alignItems="flex-end">
{!isOwn && avatar}
<Flex
direction="column-reverse"
gap="1"
ml={isOwn ? "auto" : 0}
mr={isOwn ? 0 : "auto"}
maxW="2xl"
alignItems={isOwn ? "flex-end" : "flex-start"}
overflowX="hidden"
overflowY="visible"
>
{events.map((event, i, arr) => (
<MessageBubble
key={event.id}
event={event}
showHeader={i === arr.length - 1}
minW={{ base: 0, sm: "sm", md: "md" }}
overflow="hidden"
/>
))}
</Flex>
{isOwn && avatar}
</Flex>
);
}

View File

@ -1,95 +0,0 @@
import { useRef } from "react";
import { Box, BoxProps, ButtonGroup, Card, CardBody, CardFooter, CardHeader, CardProps, Flex } from "@chakra-ui/react";
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 {
embedCashuTokens,
embedNostrLinks,
renderGenericUrl,
renderImageUrl,
renderVideoUrl,
} from "../../components/embed-types";
import { useRegisterIntersectionEntity } from "../../providers/intersection-observer";
import UserAvatar from "../../components/user-avatar";
import { getEventUID } from "../../helpers/nostr/events";
import Timestamp from "../../components/timestamp";
import NoteZapButton from "../../components/note/note-zap-button";
import UserLink from "../../components/user-link";
import { UserDnsIdentityIcon } from "../../components/user-dns-identity-icon";
import EventReactionButtons from "../../components/event-reactions/event-reactions";
import useEventReactions from "../../hooks/use-event-reactions";
import AddReactionButton from "../../components/note/components/add-reaction-button";
import { TrustProvider } from "../../providers/trust";
export function MessageContent({ event, text, children, ...props }: { event: NostrEvent; text: string } & BoxProps) {
let content: EmbedableContent = [text];
content = embedNostrLinks(content);
content = embedUrls(content, [renderImageUrl, renderVideoUrl, renderGenericUrl]);
// cashu
content = embedCashuTokens(content);
return (
<TrustProvider event={event}>
<Box whiteSpace="pre-wrap" {...props}>
{content}
{children}
</Box>
</TrustProvider>
);
}
export default function Message({ event }: { event: NostrEvent } & Omit<CardProps, "children">) {
const account = useCurrentAccount()!;
const isOwn = account.pubkey === event.pubkey;
const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, getEventUID(event));
const avatar = <UserAvatar pubkey={event.pubkey} size="sm" my="1" />;
const reactions = useEventReactions(event.id) ?? [];
return (
<Flex direction="row" gap="2" alignItems="flex-end" ref={ref}>
{!isOwn && avatar}
<Card variant="outline" w="full" ml={isOwn ? "auto" : 0} mr={isOwn ? 0 : "auto"} maxW="2xl">
{!isOwn && (
<CardHeader px="2" pt="2" pb="0" gap="2" display="flex" alignItems="center">
<UserLink pubkey={event.pubkey} fontWeight="bold" />
<UserDnsIdentityIcon pubkey={event.pubkey} onlyIcon />
<NoteZapButton event={event} size="xs" ml="auto" variant="ghost" />
<AddReactionButton event={event} size="xs" variant="ghost" />
</CardHeader>
)}
<CardBody px="2" py="2">
<DecryptPlaceholder
data={event.content}
pubkey={isOwn ? getMessageRecipient(event) ?? "" : event.pubkey}
variant="link"
py="4"
>
{(text) => (
<MessageContent event={event} text={text} display="inline">
{reactions.length === 0 && <Timestamp float="right" timestamp={event.created_at} />}
</MessageContent>
)}
</DecryptPlaceholder>
</CardBody>
{reactions.length > 0 && (
<CardFooter alignItems="center" display="flex" gap="2" px="2" pt="0" pb="2">
<ButtonGroup size="sm" mr="auto" variant="ghost">
<EventReactionButtons event={event} />
</ButtonGroup>
<Timestamp ml="auto" timestamp={event.created_at} />
</CardFooter>
)}
</Card>
{isOwn && avatar}
</Flex>
);
}