mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-10-05 17:55:01 +02:00
group DMs
This commit is contained in:
@@ -15,7 +15,7 @@ import { RelayMode } from "../../classes/relay";
|
|||||||
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
|
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
|
||||||
import IntersectionObserverProvider from "../../providers/intersection-observer";
|
import IntersectionObserverProvider from "../../providers/intersection-observer";
|
||||||
import useSubject from "../../hooks/use-subject";
|
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 { LightboxProvider } from "../lightbox-provider";
|
||||||
import { useSigningContext } from "../../providers/signing-provider";
|
import { useSigningContext } from "../../providers/signing-provider";
|
||||||
import { DraftNostrEvent } from "../../types/nostr-event";
|
import { DraftNostrEvent } from "../../types/nostr-event";
|
||||||
@@ -99,7 +99,7 @@ export default function ChatWindow({ pubkey, onClose }: { pubkey: string; onClos
|
|||||||
<LightboxProvider>
|
<LightboxProvider>
|
||||||
<IntersectionObserverProvider callback={callback}>
|
<IntersectionObserverProvider callback={callback}>
|
||||||
{messages.map((event) => (
|
{messages.map((event) => (
|
||||||
<Message key={event.id} event={event} />
|
<MessageBlock key={event.id} events={event} />
|
||||||
))}
|
))}
|
||||||
</IntersectionObserverProvider>
|
</IntersectionObserverProvider>
|
||||||
</LightboxProvider>
|
</LightboxProvider>
|
||||||
|
@@ -6,7 +6,7 @@ import UserAvatarLink from "../../user-avatar-link";
|
|||||||
import UserLink from "../../user-link";
|
import UserLink from "../../user-link";
|
||||||
import Timestamp from "../../timestamp";
|
import Timestamp from "../../timestamp";
|
||||||
import DecryptPlaceholder from "../../../views/dms/decrypt-placeholder";
|
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 { getMessageRecipient } from "../../../services/direct-messages";
|
||||||
import useCurrentAccount from "../../../hooks/use-current-account";
|
import useCurrentAccount from "../../../hooks/use-current-account";
|
||||||
|
|
||||||
|
@@ -9,7 +9,7 @@ import UserLink from "../../components/user-link";
|
|||||||
import { isHexKey } from "../../helpers/nip19";
|
import { isHexKey } from "../../helpers/nip19";
|
||||||
import useSubject from "../../hooks/use-subject";
|
import useSubject from "../../hooks/use-subject";
|
||||||
import RequireCurrentAccount from "../../providers/require-current-account";
|
import RequireCurrentAccount from "../../providers/require-current-account";
|
||||||
import Message from "./message";
|
import MessageBlock from "./message-block";
|
||||||
import useTimelineLoader from "../../hooks/use-timeline-loader";
|
import useTimelineLoader from "../../hooks/use-timeline-loader";
|
||||||
import useCurrentAccount from "../../hooks/use-current-account";
|
import useCurrentAccount from "../../hooks/use-current-account";
|
||||||
import { useReadRelayUrls } from "../../hooks/use-client-relays";
|
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 { UserDnsIdentityIcon } from "../../components/user-dns-identity-icon";
|
||||||
import { useDecryptionContext } from "../../providers/dycryption-provider";
|
import { useDecryptionContext } from "../../providers/dycryption-provider";
|
||||||
import SendMessageForm from "./send-message-form";
|
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 }) {
|
function DirectMessageChatPage({ pubkey }: { pubkey: string }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const account = useCurrentAccount()!;
|
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 [loading, setLoading] = useState(false);
|
||||||
const decryptAll = async () => {
|
const decryptAll = async () => {
|
||||||
@@ -81,8 +101,8 @@ function DirectMessageChatPage({ pubkey }: { pubkey: string }) {
|
|||||||
</Button>
|
</Button>
|
||||||
</Card>
|
</Card>
|
||||||
<Flex h="0" flex={1} overflowX="hidden" overflowY="scroll" direction="column-reverse" gap="2" py="4" px="2">
|
<Flex h="0" flex={1} overflowX="hidden" overflowY="scroll" direction="column-reverse" gap="2" py="4" px="2">
|
||||||
{[...messages].map((event) => (
|
{grouped.map((group) => (
|
||||||
<Message key={event.id} event={event} />
|
<MessageBlock key={group.id} events={group.events} />
|
||||||
))}
|
))}
|
||||||
<TimelineActionAndStatus timeline={timeline} />
|
<TimelineActionAndStatus timeline={timeline} />
|
||||||
</Flex>
|
</Flex>
|
||||||
|
@@ -82,7 +82,7 @@ function DirectMessagesPage() {
|
|||||||
const isChatOpen = !!params.pubkey;
|
const isChatOpen = !!params.pubkey;
|
||||||
|
|
||||||
return (
|
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
|
<Flex
|
||||||
gap="2"
|
gap="2"
|
||||||
direction="column"
|
direction="column"
|
||||||
@@ -104,7 +104,7 @@ function DirectMessagesPage() {
|
|||||||
Load More
|
Load More
|
||||||
</Button>
|
</Button>
|
||||||
</Flex>
|
</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 />
|
<Outlet />
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
139
src/views/dms/message-block.tsx
Normal file
139
src/views/dms/message-block.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
Reference in New Issue
Block a user