mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-09-19 12:00:32 +02:00
Improve channel message layout
This commit is contained in:
5
.changeset/new-planes-warn.md
Normal file
5
.changeset/new-planes-warn.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"nostrudel": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Improve channel message layout
|
@@ -8,7 +8,7 @@ import Timestamp from "../../timestamp";
|
|||||||
import DecryptPlaceholder from "../../../views/dms/components/decrypt-placeholder";
|
import DecryptPlaceholder from "../../../views/dms/components/decrypt-placeholder";
|
||||||
import useCurrentAccount from "../../../hooks/use-current-account";
|
import useCurrentAccount from "../../../hooks/use-current-account";
|
||||||
import { getDMRecipient, getDMSender } from "../../../helpers/nostr/dms";
|
import { getDMRecipient, getDMSender } from "../../../helpers/nostr/dms";
|
||||||
import { MessageContent } from "../../../views/dms/components/message-bubble";
|
import DirectMessageContent from "../../../views/dms/components/direct-message-content";
|
||||||
|
|
||||||
export default function EmbeddedDM({ dm, ...props }: Omit<CardProps, "children"> & { dm: NostrEvent }) {
|
export default function EmbeddedDM({ dm, ...props }: Omit<CardProps, "children"> & { dm: NostrEvent }) {
|
||||||
const account = useCurrentAccount();
|
const account = useCurrentAccount();
|
||||||
@@ -31,7 +31,7 @@ export default function EmbeddedDM({ dm, ...props }: Omit<CardProps, "children">
|
|||||||
{(sender === account?.pubkey || receiver === account?.pubkey) && (
|
{(sender === account?.pubkey || receiver === account?.pubkey) && (
|
||||||
<CardBody px="2" pt="0" pb="2">
|
<CardBody px="2" pt="0" pb="2">
|
||||||
<DecryptPlaceholder message={dm}>
|
<DecryptPlaceholder message={dm}>
|
||||||
{(plaintext) => <MessageContent event={dm} text={plaintext} />}
|
{(plaintext) => <DirectMessageContent event={dm} text={plaintext} />}
|
||||||
</DecryptPlaceholder>
|
</DecryptPlaceholder>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
)}
|
)}
|
||||||
|
@@ -1,12 +1,11 @@
|
|||||||
import { memo } from "react";
|
|
||||||
import { CardProps, Flex } from "@chakra-ui/react";
|
import { CardProps, Flex } from "@chakra-ui/react";
|
||||||
|
|
||||||
import useCurrentAccount from "../../../hooks/use-current-account";
|
import useCurrentAccount from "../hooks/use-current-account";
|
||||||
import { NostrEvent } from "../../../types/nostr-event";
|
import { NostrEvent } from "../types/nostr-event";
|
||||||
import UserAvatar from "../../../components/user-avatar";
|
import UserAvatar from "./user-avatar";
|
||||||
import MessageBubble, { MessageBubbleProps } from "./message-bubble";
|
import MessageBubble, { MessageBubbleProps } from "./message-bubble";
|
||||||
import { useThreadsContext } from "./thread-provider";
|
import { useThreadsContext } from "../providers/thread-provider";
|
||||||
import ThreadButton from "./thread-button";
|
import ThreadButton from "../views/dms/components/thread-button";
|
||||||
|
|
||||||
function MessageBubbleWithThread({ message, showThreadButton = true, ...props }: MessageBubbleProps) {
|
function MessageBubbleWithThread({ message, showThreadButton = true, ...props }: MessageBubbleProps) {
|
||||||
const { threads } = useThreadsContext();
|
const { threads } = useThreadsContext();
|
||||||
@@ -20,11 +19,19 @@ function MessageBubbleWithThread({ message, showThreadButton = true, ...props }:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MessageBlock({
|
export type MessageBlockProps = Omit<CardProps, "children"> & {
|
||||||
|
messages: NostrEvent[];
|
||||||
|
showThreadButton?: boolean;
|
||||||
|
reverse?: boolean;
|
||||||
|
renderContent: MessageBubbleProps["renderContent"];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MessageBlock({
|
||||||
messages,
|
messages,
|
||||||
showThreadButton = true,
|
showThreadButton = true,
|
||||||
reverse = false,
|
reverse = false,
|
||||||
}: { messages: NostrEvent[]; showThreadButton?: boolean; reverse?: boolean } & Omit<CardProps, "children">) {
|
renderContent,
|
||||||
|
}: MessageBlockProps) {
|
||||||
const lastEvent = messages[messages.length - 1];
|
const lastEvent = messages[messages.length - 1];
|
||||||
const account = useCurrentAccount()!;
|
const account = useCurrentAccount()!;
|
||||||
const isOwn = account.pubkey === lastEvent.pubkey;
|
const isOwn = account.pubkey === lastEvent.pubkey;
|
||||||
@@ -55,6 +62,7 @@ function MessageBlock({
|
|||||||
maxW="full"
|
maxW="full"
|
||||||
overflow="hidden"
|
overflow="hidden"
|
||||||
showThreadButton={showThreadButton}
|
showThreadButton={showThreadButton}
|
||||||
|
renderContent={renderContent}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Flex>
|
</Flex>
|
||||||
@@ -62,5 +70,3 @@ function MessageBlock({
|
|||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(MessageBlock);
|
|
82
src/components/message-bubble.tsx
Normal file
82
src/components/message-bubble.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { ReactNode, useRef } from "react";
|
||||||
|
import { ButtonGroup, Card, CardBody, CardFooter, CardHeader, CardProps } from "@chakra-ui/react";
|
||||||
|
|
||||||
|
import { NostrEvent } from "../types/nostr-event";
|
||||||
|
import { useRegisterIntersectionEntity } from "../providers/intersection-observer";
|
||||||
|
import { getEventUID } from "../helpers/nostr/events";
|
||||||
|
import Timestamp from "./timestamp";
|
||||||
|
import NoteZapButton from "./note/note-zap-button";
|
||||||
|
import UserLink from "./user-link";
|
||||||
|
import { UserDnsIdentityIcon } from "./user-dns-identity-icon";
|
||||||
|
import useEventReactions from "../hooks/use-event-reactions";
|
||||||
|
import AddReactionButton from "./note/components/add-reaction-button";
|
||||||
|
import EventReactionButtons from "./event-reactions/event-reactions";
|
||||||
|
import { IconThreadButton } from "../views/dms/components/thread-button";
|
||||||
|
|
||||||
|
export type MessageBubbleProps = {
|
||||||
|
message: NostrEvent;
|
||||||
|
showHeader?: boolean;
|
||||||
|
showThreadButton?: boolean;
|
||||||
|
renderContent: (message: NostrEvent, inlineButtons: ReactNode | null) => ReactNode;
|
||||||
|
} & Omit<CardProps, "children">;
|
||||||
|
|
||||||
|
export default function MessageBubble({
|
||||||
|
message,
|
||||||
|
showHeader = true,
|
||||||
|
showThreadButton = true,
|
||||||
|
renderContent,
|
||||||
|
...props
|
||||||
|
}: MessageBubbleProps) {
|
||||||
|
const reactions = useEventReactions(message.id) ?? [];
|
||||||
|
const hasReactions = reactions.length > 0;
|
||||||
|
|
||||||
|
let actionPosition = showHeader ? "header" : "inline";
|
||||||
|
if (hasReactions && actionPosition === "inline") actionPosition = "footer";
|
||||||
|
|
||||||
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
useRegisterIntersectionEntity(ref, getEventUID(message));
|
||||||
|
|
||||||
|
const actions = (
|
||||||
|
<>
|
||||||
|
<NoteZapButton event={message} />
|
||||||
|
<AddReactionButton event={message} portal />
|
||||||
|
{showThreadButton && <IconThreadButton event={message} label="Open Thread" />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card {...props} borderRadius="lg" ref={ref}>
|
||||||
|
{showHeader && (
|
||||||
|
<CardHeader px="2" pt="2" pb="0" gap="2" display="flex" alignItems="center">
|
||||||
|
<UserLink pubkey={message.pubkey} fontWeight="bold" />
|
||||||
|
<UserDnsIdentityIcon pubkey={message.pubkey} onlyIcon />
|
||||||
|
{actionPosition === "header" && (
|
||||||
|
<ButtonGroup size="xs" variant="ghost" ml="auto">
|
||||||
|
{actions}
|
||||||
|
</ButtonGroup>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
)}
|
||||||
|
<CardBody px="2" py="2">
|
||||||
|
{renderContent(
|
||||||
|
message,
|
||||||
|
!hasReactions ? (
|
||||||
|
<ButtonGroup size="xs" variant="ghost" float="right" ml="2">
|
||||||
|
{actionPosition === "inline" && actions}
|
||||||
|
<Timestamp timestamp={message.created_at} ml="2" userSelect="none" />
|
||||||
|
</ButtonGroup>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
{hasReactions && (
|
||||||
|
<CardFooter alignItems="center" display="flex" gap="2" px="2" pt="0" pb="2">
|
||||||
|
<ButtonGroup size="xs" variant="ghost">
|
||||||
|
{actionPosition === "footer" ? actions : <AddReactionButton event={message} portal />}
|
||||||
|
<EventReactionButtons event={message} />
|
||||||
|
</ButtonGroup>
|
||||||
|
<Timestamp ml="auto" timestamp={message.created_at} />
|
||||||
|
</CardFooter>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
@@ -1,8 +1,8 @@
|
|||||||
import { PropsWithChildren, createContext, useCallback, useContext, useMemo } from "react";
|
import { PropsWithChildren, createContext, useCallback, useContext, useMemo } from "react";
|
||||||
|
|
||||||
import TimelineLoader from "../../../classes/timeline-loader";
|
import TimelineLoader from "../classes/timeline-loader";
|
||||||
import { NostrEvent } from "../../../types/nostr-event";
|
import { NostrEvent } from "../types/nostr-event";
|
||||||
import useSubject from "../../../hooks/use-subject";
|
import useSubject from "../hooks/use-subject";
|
||||||
|
|
||||||
export type Thread = {
|
export type Thread = {
|
||||||
root?: NostrEvent;
|
root?: NostrEvent;
|
@@ -1,6 +1,7 @@
|
|||||||
import { useMemo } from "react";
|
import { memo, useCallback, useMemo } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { Button, Flex, Heading, Spacer, Spinner, useDisclosure } from "@chakra-ui/react";
|
import { Button, Flex, Heading, Spacer, Spinner, useDisclosure } from "@chakra-ui/react";
|
||||||
|
import { Kind } from "nostr-tools";
|
||||||
|
|
||||||
import { safeDecode } from "../../helpers/nip19";
|
import { safeDecode } from "../../helpers/nip19";
|
||||||
import useSingleEvent from "../../hooks/use-single-event";
|
import useSingleEvent from "../../hooks/use-single-event";
|
||||||
@@ -12,8 +13,35 @@ import { ChevronLeftIcon } from "../../components/icons";
|
|||||||
import RelaySelectionButton from "../../components/relay-selection/relay-selection-button";
|
import RelaySelectionButton from "../../components/relay-selection/relay-selection-button";
|
||||||
import ChannelMetadataDrawer from "./components/channel-metadata-drawer";
|
import ChannelMetadataDrawer from "./components/channel-metadata-drawer";
|
||||||
import ChannelJoinButton from "./components/channel-join-button";
|
import ChannelJoinButton from "./components/channel-join-button";
|
||||||
import ChannelChatLog from "./components/channel-chat-log";
|
|
||||||
import ChannelMenu from "./components/channel-menu";
|
import ChannelMenu from "./components/channel-menu";
|
||||||
|
import useClientSideMuteFilter from "../../hooks/use-client-side-mute-filter";
|
||||||
|
import useTimelineLoader from "../../hooks/use-timeline-loader";
|
||||||
|
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
|
||||||
|
import IntersectionObserverProvider from "../../providers/intersection-observer";
|
||||||
|
import ThreadsProvider from "../../providers/thread-provider";
|
||||||
|
import TimelineLoader from "../../classes/timeline-loader";
|
||||||
|
import useSubject from "../../hooks/use-subject";
|
||||||
|
import { groupMessages } from "../../helpers/nostr/dms";
|
||||||
|
import ChannelMessageBlock from "./components/channel-message-block";
|
||||||
|
import TimelineActionAndStatus from "../../components/timeline-page/timeline-action-and-status";
|
||||||
|
import ChannelMessageForm from "./components/send-message-form";
|
||||||
|
|
||||||
|
const ChannelChatLog = memo(({ timeline, channel }: { timeline: TimelineLoader; channel: NostrEvent }) => {
|
||||||
|
const messages = useSubject(timeline.timeline);
|
||||||
|
const filteredMessages = useMemo(
|
||||||
|
() => messages.filter((e) => !e.tags.some((t) => t[0] === "e" && t[1] !== channel.id && t[3] === "root")),
|
||||||
|
[messages.length, channel.id],
|
||||||
|
);
|
||||||
|
const grouped = useMemo(() => groupMessages(filteredMessages), [filteredMessages]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{grouped.map((group) => (
|
||||||
|
<ChannelMessageBlock key={group.id} messages={group.events} reverse />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
function ChannelPage({ channel }: { channel: NostrEvent }) {
|
function ChannelPage({ channel }: { channel: NostrEvent }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -21,26 +49,62 @@ function ChannelPage({ channel }: { channel: NostrEvent }) {
|
|||||||
const { metadata } = useChannelMetadata(channel.id, relays);
|
const { metadata } = useChannelMetadata(channel.id, relays);
|
||||||
const drawer = useDisclosure();
|
const drawer = useDisclosure();
|
||||||
|
|
||||||
|
const clientMuteFilter = useClientSideMuteFilter();
|
||||||
|
const eventFilter = useCallback(
|
||||||
|
(e: NostrEvent) => {
|
||||||
|
if (clientMuteFilter(e)) return false;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[clientMuteFilter],
|
||||||
|
);
|
||||||
|
const timeline = useTimelineLoader(
|
||||||
|
`${channel.id}-chat-messages`,
|
||||||
|
relays,
|
||||||
|
{
|
||||||
|
kinds: [Kind.ChannelMessage],
|
||||||
|
"#e": [channel.id],
|
||||||
|
},
|
||||||
|
{ eventFilter },
|
||||||
|
);
|
||||||
|
const callback = useTimelineCurserIntersectionCallback(timeline);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex h="full" overflow="hidden" direction="column" p="2" gap="2" flexGrow={1}>
|
<ThreadsProvider timeline={timeline}>
|
||||||
<Flex gap="2" alignItems="center">
|
<IntersectionObserverProvider callback={callback}>
|
||||||
<Button leftIcon={<ChevronLeftIcon />} onClick={() => navigate(-1)}>
|
<Flex h="full" overflow="hidden" direction="column" p="2" gap="2" flexGrow={1}>
|
||||||
Back
|
<Flex gap="2" alignItems="center">
|
||||||
</Button>
|
<Button leftIcon={<ChevronLeftIcon />} onClick={() => navigate(-1)}>
|
||||||
<RelaySelectionButton hideBelow="lg" />
|
Back
|
||||||
<Heading hideBelow="lg" size="lg">
|
</Button>
|
||||||
{metadata?.name}
|
<RelaySelectionButton hideBelow="lg" />
|
||||||
</Heading>
|
<Heading hideBelow="lg" size="lg">
|
||||||
<Spacer />
|
{metadata?.name}
|
||||||
<ChannelJoinButton channel={channel} hideBelow="lg" />
|
</Heading>
|
||||||
<Button onClick={drawer.onOpen}>Channel Info</Button>
|
<Spacer />
|
||||||
<ChannelMenu channel={channel} aria-label="More Options" />
|
<ChannelJoinButton channel={channel} hideBelow="lg" />
|
||||||
</Flex>
|
<Button onClick={drawer.onOpen}>Channel Info</Button>
|
||||||
|
<ChannelMenu channel={channel} aria-label="More Options" />
|
||||||
|
</Flex>
|
||||||
|
|
||||||
<ChannelChatLog channel={channel} flexGrow={1} relays={relays} />
|
<Flex
|
||||||
|
h="0"
|
||||||
|
flexGrow={1}
|
||||||
|
overflowX="hidden"
|
||||||
|
overflowY="scroll"
|
||||||
|
direction="column-reverse"
|
||||||
|
gap="2"
|
||||||
|
py="4"
|
||||||
|
px="2"
|
||||||
|
>
|
||||||
|
<ChannelChatLog timeline={timeline} channel={channel} />
|
||||||
|
<TimelineActionAndStatus timeline={timeline} />
|
||||||
|
</Flex>
|
||||||
|
|
||||||
{drawer.isOpen && <ChannelMetadataDrawer isOpen onClose={drawer.onClose} channel={channel} size="lg" />}
|
<ChannelMessageForm channel={channel} />
|
||||||
</Flex>
|
</Flex>
|
||||||
|
{drawer.isOpen && <ChannelMetadataDrawer isOpen onClose={drawer.onClose} channel={channel} size="lg" />}
|
||||||
|
</IntersectionObserverProvider>
|
||||||
|
</ThreadsProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,51 +0,0 @@
|
|||||||
import { useCallback } from "react";
|
|
||||||
import { Flex, FlexProps } from "@chakra-ui/react";
|
|
||||||
import { Kind } from "nostr-tools";
|
|
||||||
|
|
||||||
import { NostrEvent } from "../../../types/nostr-event";
|
|
||||||
import useTimelineLoader from "../../../hooks/use-timeline-loader";
|
|
||||||
import { useTimelineCurserIntersectionCallback } from "../../../hooks/use-timeline-cursor-intersection-callback";
|
|
||||||
import IntersectionObserverProvider from "../../../providers/intersection-observer";
|
|
||||||
import useSubject from "../../../hooks/use-subject";
|
|
||||||
import ChannelChatMessage from "./channel-chat-message";
|
|
||||||
import useClientSideMuteFilter from "../../../hooks/use-client-side-mute-filter";
|
|
||||||
import { LightboxProvider } from "../../../components/lightbox-provider";
|
|
||||||
|
|
||||||
export default function ChannelChatLog({
|
|
||||||
channel,
|
|
||||||
relays,
|
|
||||||
...props
|
|
||||||
}: Omit<FlexProps, "children"> & { channel: NostrEvent; relays: string[] }) {
|
|
||||||
const clientMuteFilter = useClientSideMuteFilter();
|
|
||||||
const eventFilter = useCallback(
|
|
||||||
(e: NostrEvent) => {
|
|
||||||
if (clientMuteFilter(e)) return false;
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
[clientMuteFilter],
|
|
||||||
);
|
|
||||||
const timeline = useTimelineLoader(
|
|
||||||
`${channel.id}-chat-messages`,
|
|
||||||
relays,
|
|
||||||
{
|
|
||||||
kinds: [Kind.ChannelMessage],
|
|
||||||
"#e": [channel.id],
|
|
||||||
},
|
|
||||||
{ eventFilter },
|
|
||||||
);
|
|
||||||
|
|
||||||
const messages = useSubject(timeline.timeline);
|
|
||||||
const callback = useTimelineCurserIntersectionCallback(timeline);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<IntersectionObserverProvider callback={callback}>
|
|
||||||
<LightboxProvider>
|
|
||||||
<Flex direction="column-reverse" overflowX="hidden" overflowY="auto" gap="2" h="0" {...props}>
|
|
||||||
{messages.map((message) => (
|
|
||||||
<ChannelChatMessage key={message.id} channel={channel} message={message} />
|
|
||||||
))}
|
|
||||||
</Flex>
|
|
||||||
</LightboxProvider>
|
|
||||||
</IntersectionObserverProvider>
|
|
||||||
);
|
|
||||||
}
|
|
@@ -1,71 +0,0 @@
|
|||||||
import { Box, Text } from "@chakra-ui/react";
|
|
||||||
import { NostrEvent } from "../../../types/nostr-event";
|
|
||||||
import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer";
|
|
||||||
import { TrustProvider } from "../../../providers/trust";
|
|
||||||
import UserAvatar from "../../../components/user-avatar";
|
|
||||||
import UserLink from "../../../components/user-link";
|
|
||||||
import { memo, useMemo, useRef } from "react";
|
|
||||||
import { EmbedableContent, embedUrls } from "../../../helpers/embeds";
|
|
||||||
import {
|
|
||||||
embedEmoji,
|
|
||||||
embedNostrHashtags,
|
|
||||||
embedNostrLinks,
|
|
||||||
embedNostrMentions,
|
|
||||||
renderGenericUrl,
|
|
||||||
renderImageUrl,
|
|
||||||
renderSoundCloudUrl,
|
|
||||||
renderStemstrUrl,
|
|
||||||
renderWavlakeUrl,
|
|
||||||
} from "../../../components/embed-types";
|
|
||||||
import NoteZapButton from "../../../components/note/note-zap-button";
|
|
||||||
import Timestamp from "../../../components/timestamp";
|
|
||||||
|
|
||||||
const ChatMessageContent = memo(({ message }: { message: NostrEvent }) => {
|
|
||||||
const content = useMemo(() => {
|
|
||||||
let c: EmbedableContent = [message.content];
|
|
||||||
|
|
||||||
c = embedUrls(c, [renderImageUrl, renderWavlakeUrl, renderStemstrUrl, renderSoundCloudUrl, renderGenericUrl]);
|
|
||||||
|
|
||||||
// nostr
|
|
||||||
c = embedNostrLinks(c);
|
|
||||||
c = embedNostrMentions(c, message);
|
|
||||||
c = embedNostrHashtags(c, message);
|
|
||||||
c = embedEmoji(c, message);
|
|
||||||
|
|
||||||
return c;
|
|
||||||
}, [message.content]);
|
|
||||||
|
|
||||||
return <>{content}</>;
|
|
||||||
});
|
|
||||||
|
|
||||||
function ChannelChatMessage({ message, channel }: { message: NostrEvent; channel: NostrEvent }) {
|
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
|
||||||
useRegisterIntersectionEntity(ref, message.id);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TrustProvider event={message}>
|
|
||||||
<Box>
|
|
||||||
<Box overflow="hidden" maxH="lg" ref={ref}>
|
|
||||||
<UserAvatar pubkey={message.pubkey} size="xs" display="inline-block" mr="2" />
|
|
||||||
<Text as="span" fontWeight="bold" color={message.pubkey === channel.pubkey ? "purple.200" : "blue.200"}>
|
|
||||||
<UserLink pubkey={message.pubkey} />
|
|
||||||
{": "}
|
|
||||||
</Text>
|
|
||||||
<Timestamp timestamp={message.created_at} float="right" />
|
|
||||||
<NoteZapButton
|
|
||||||
display="inline-block"
|
|
||||||
event={message}
|
|
||||||
size="xs"
|
|
||||||
variant="ghost"
|
|
||||||
float="right"
|
|
||||||
mx="2"
|
|
||||||
allowComment={false}
|
|
||||||
/>
|
|
||||||
<ChatMessageContent message={message} />
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</TrustProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default memo(ChannelChatMessage);
|
|
20
src/views/channels/components/channel-message-block.tsx
Normal file
20
src/views/channels/components/channel-message-block.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { ReactNode, memo, useCallback } from "react";
|
||||||
|
|
||||||
|
import { NostrEvent } from "../../../types/nostr-event";
|
||||||
|
import MessageBlock, { MessageBlockProps } from "../../../components/message-block";
|
||||||
|
import ChannelMessageContent from "./channel-message-content";
|
||||||
|
|
||||||
|
function ChannelMessageBlock({ ...props }: Omit<MessageBlockProps, "renderContent">) {
|
||||||
|
const renderContent = useCallback(
|
||||||
|
(message: NostrEvent, buttons: ReactNode | null) => (
|
||||||
|
<ChannelMessageContent message={message} display="inline">
|
||||||
|
{buttons}
|
||||||
|
</ChannelMessageContent>
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <MessageBlock renderContent={renderContent} showThreadButton={false} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(ChannelMessageBlock);
|
116
src/views/channels/components/channel-message-content.tsx
Normal file
116
src/views/channels/components/channel-message-content.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { memo, useMemo } from "react";
|
||||||
|
import { Box, BoxProps } from "@chakra-ui/react";
|
||||||
|
|
||||||
|
import { NostrEvent } from "../../../types/nostr-event";
|
||||||
|
import { TrustProvider } from "../../../providers/trust";
|
||||||
|
import { EmbedableContent, embedUrls } from "../../../helpers/embeds";
|
||||||
|
import {
|
||||||
|
embedCashuTokens,
|
||||||
|
embedEmoji,
|
||||||
|
embedImageGallery,
|
||||||
|
embedLightningInvoice,
|
||||||
|
embedNostrHashtags,
|
||||||
|
embedNostrLinks,
|
||||||
|
embedNostrMentions,
|
||||||
|
renderAppleMusicUrl,
|
||||||
|
renderGenericUrl,
|
||||||
|
renderImageUrl,
|
||||||
|
renderRedditUrl,
|
||||||
|
renderSimpleXLink,
|
||||||
|
renderSongDotLinkUrl,
|
||||||
|
renderSoundCloudUrl,
|
||||||
|
renderSpotifyUrl,
|
||||||
|
renderStemstrUrl,
|
||||||
|
renderTidalUrl,
|
||||||
|
renderTwitterUrl,
|
||||||
|
renderVideoUrl,
|
||||||
|
renderWavlakeUrl,
|
||||||
|
renderYoutubeUrl,
|
||||||
|
} from "../../../components/embed-types";
|
||||||
|
import { LightboxProvider } from "../../../components/lightbox-provider";
|
||||||
|
|
||||||
|
const ChannelMessageContent = memo(({ message, children, ...props }: BoxProps & { message: NostrEvent }) => {
|
||||||
|
const content = useMemo(() => {
|
||||||
|
let c: EmbedableContent = [message.content];
|
||||||
|
|
||||||
|
// image gallery
|
||||||
|
c = embedImageGallery(c, message);
|
||||||
|
|
||||||
|
// common
|
||||||
|
c = embedUrls(c, [
|
||||||
|
renderSimpleXLink,
|
||||||
|
renderYoutubeUrl,
|
||||||
|
renderTwitterUrl,
|
||||||
|
renderRedditUrl,
|
||||||
|
renderWavlakeUrl,
|
||||||
|
renderAppleMusicUrl,
|
||||||
|
renderSpotifyUrl,
|
||||||
|
renderTidalUrl,
|
||||||
|
renderSongDotLinkUrl,
|
||||||
|
renderStemstrUrl,
|
||||||
|
renderSoundCloudUrl,
|
||||||
|
renderImageUrl,
|
||||||
|
renderVideoUrl,
|
||||||
|
renderGenericUrl,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// bitcoin
|
||||||
|
c = embedLightningInvoice(c);
|
||||||
|
|
||||||
|
// cashu
|
||||||
|
c = embedCashuTokens(c);
|
||||||
|
|
||||||
|
// nostr
|
||||||
|
c = embedNostrLinks(c);
|
||||||
|
c = embedNostrMentions(c, message);
|
||||||
|
c = embedNostrHashtags(c, message);
|
||||||
|
c = embedEmoji(c, message);
|
||||||
|
|
||||||
|
return c;
|
||||||
|
}, [message.content]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TrustProvider event={message}>
|
||||||
|
<LightboxProvider>
|
||||||
|
<Box whiteSpace="pre-wrap" {...props}>
|
||||||
|
{content}
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
</LightboxProvider>
|
||||||
|
</TrustProvider>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ChannelMessageContent;
|
||||||
|
|
||||||
|
// function ChannelChatMessage({ message, channel }: { message: NostrEvent; channel: NostrEvent }) {
|
||||||
|
// const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
// useRegisterIntersectionEntity(ref, message.id);
|
||||||
|
|
||||||
|
// return (
|
||||||
|
// <TrustProvider event={message}>
|
||||||
|
// <Box>
|
||||||
|
// <Box overflow="hidden" maxH="lg" ref={ref}>
|
||||||
|
// <UserAvatar pubkey={message.pubkey} size="xs" display="inline-block" mr="2" />
|
||||||
|
// <Text as="span" fontWeight="bold" color={message.pubkey === channel.pubkey ? "purple.200" : "blue.200"}>
|
||||||
|
// <UserLink pubkey={message.pubkey} />
|
||||||
|
// {": "}
|
||||||
|
// </Text>
|
||||||
|
// <Timestamp timestamp={message.created_at} float="right" />
|
||||||
|
// <NoteZapButton
|
||||||
|
// display="inline-block"
|
||||||
|
// event={message}
|
||||||
|
// size="xs"
|
||||||
|
// variant="ghost"
|
||||||
|
// float="right"
|
||||||
|
// mx="2"
|
||||||
|
// allowComment={false}
|
||||||
|
// />
|
||||||
|
// <ChannelMessageContent message={message} />
|
||||||
|
// </Box>
|
||||||
|
// </Box>
|
||||||
|
// </TrustProvider>
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
// export default memo(ChannelChatMessage);
|
94
src/views/channels/components/send-message-form.tsx
Normal file
94
src/views/channels/components/send-message-form.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import { Kind } from "nostr-tools";
|
||||||
|
|
||||||
|
import { Button, Flex, FlexProps, Heading, useToast } from "@chakra-ui/react";
|
||||||
|
import { useSigningContext } from "../../../providers/signing-provider";
|
||||||
|
import MagicTextArea, { RefType } from "../../../components/magic-textarea";
|
||||||
|
import { useTextAreaUploadFileWithForm } from "../../../hooks/use-textarea-upload-file";
|
||||||
|
import clientRelaysService from "../../../services/client-relays";
|
||||||
|
import { DraftNostrEvent, NostrEvent } from "../../../types/nostr-event";
|
||||||
|
import NostrPublishAction from "../../../classes/nostr-publish-action";
|
||||||
|
import { createEmojiTags, ensureNotifyPubkeys, getContentMentions } from "../../../helpers/nostr/post";
|
||||||
|
import { useContextEmojis } from "../../../providers/emoji-provider";
|
||||||
|
|
||||||
|
export default function ChannelMessageForm({
|
||||||
|
channel,
|
||||||
|
rootId,
|
||||||
|
...props
|
||||||
|
}: { channel: NostrEvent; rootId?: string } & Omit<FlexProps, "children">) {
|
||||||
|
const toast = useToast();
|
||||||
|
const emojis = useContextEmojis();
|
||||||
|
const { requestSignature } = useSigningContext();
|
||||||
|
|
||||||
|
const [loadingMessage, setLoadingMessage] = useState("");
|
||||||
|
const { getValues, setValue, watch, handleSubmit, formState, reset } = useForm({
|
||||||
|
defaultValues: {
|
||||||
|
content: "",
|
||||||
|
},
|
||||||
|
mode: "all",
|
||||||
|
});
|
||||||
|
watch("content");
|
||||||
|
|
||||||
|
const textAreaRef = useRef<RefType | null>(null);
|
||||||
|
const { onPaste } = useTextAreaUploadFileWithForm(textAreaRef, getValues, setValue);
|
||||||
|
|
||||||
|
const sendMessage = handleSubmit(async (values) => {
|
||||||
|
try {
|
||||||
|
if (!values.content) return;
|
||||||
|
|
||||||
|
let draft: DraftNostrEvent = {
|
||||||
|
kind: Kind.ChannelMessage,
|
||||||
|
content: values.content,
|
||||||
|
tags: [["e", channel.id]],
|
||||||
|
created_at: dayjs().unix(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const contentMentions = getContentMentions(draft.content);
|
||||||
|
draft = createEmojiTags(draft, emojis);
|
||||||
|
draft = ensureNotifyPubkeys(draft, contentMentions);
|
||||||
|
|
||||||
|
if (rootId) {
|
||||||
|
draft.tags.push(["e", rootId, "", "root"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingMessage("Signing...");
|
||||||
|
const signed = await requestSignature(draft);
|
||||||
|
const writeRelays = clientRelaysService.getWriteUrls();
|
||||||
|
new NostrPublishAction("Send DM", writeRelays, signed);
|
||||||
|
reset();
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error) toast({ status: "error", description: e.message });
|
||||||
|
}
|
||||||
|
setLoadingMessage("");
|
||||||
|
});
|
||||||
|
|
||||||
|
const formRef = useRef<HTMLFormElement | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex as="form" gap="2" onSubmit={sendMessage} ref={formRef} {...props}>
|
||||||
|
{loadingMessage ? (
|
||||||
|
<Heading size="md" mx="auto" my="4">
|
||||||
|
{loadingMessage}
|
||||||
|
</Heading>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<MagicTextArea
|
||||||
|
mb="2"
|
||||||
|
value={getValues().content}
|
||||||
|
onChange={(e) => setValue("content", e.target.value, { shouldDirty: true })}
|
||||||
|
rows={2}
|
||||||
|
isRequired
|
||||||
|
instanceRef={(inst) => (textAreaRef.current = inst)}
|
||||||
|
onPaste={onPaste}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.ctrlKey && e.key === "Enter" && formRef.current) formRef.current.requestSubmit();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button type="submit">Send</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
@@ -1,5 +1,5 @@
|
|||||||
import { memo, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
import { memo, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||||
import { Button, ButtonGroup, Card, Flex, IconButton, useDisclosure } from "@chakra-ui/react";
|
import { Button, ButtonGroup, Card, Flex, IconButton } from "@chakra-ui/react";
|
||||||
import { Kind, nip19 } from "nostr-tools";
|
import { Kind, nip19 } from "nostr-tools";
|
||||||
import { UNSAFE_DataRouterContext, useLocation, useNavigate, useParams } from "react-router-dom";
|
import { UNSAFE_DataRouterContext, useLocation, useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
@@ -9,7 +9,6 @@ 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 MessageBlock from "./components/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";
|
||||||
@@ -21,9 +20,10 @@ import { useDecryptionContext } from "../../providers/dycryption-provider";
|
|||||||
import SendMessageForm from "./components/send-message-form";
|
import SendMessageForm from "./components/send-message-form";
|
||||||
import { groupMessages } from "../../helpers/nostr/dms";
|
import { groupMessages } from "../../helpers/nostr/dms";
|
||||||
import ThreadDrawer from "./components/thread-drawer";
|
import ThreadDrawer from "./components/thread-drawer";
|
||||||
import ThreadsProvider from "./components/thread-provider";
|
import ThreadsProvider from "../../providers/thread-provider";
|
||||||
import { useRouterMarker } from "../../providers/drawer-sub-view-provider";
|
import { useRouterMarker } from "../../providers/drawer-sub-view-provider";
|
||||||
import TimelineLoader from "../../classes/timeline-loader";
|
import TimelineLoader from "../../classes/timeline-loader";
|
||||||
|
import DirectMessageBlock from "./components/direct-message-block";
|
||||||
|
|
||||||
/** This is broken out from DirectMessageChatPage for performance reasons. Don't use outside of file */
|
/** This is broken out from DirectMessageChatPage for performance reasons. Don't use outside of file */
|
||||||
const ChatLog = memo(({ timeline }: { timeline: TimelineLoader }) => {
|
const ChatLog = memo(({ timeline }: { timeline: TimelineLoader }) => {
|
||||||
@@ -37,7 +37,7 @@ const ChatLog = memo(({ timeline }: { timeline: TimelineLoader }) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{grouped.map((group) => (
|
{grouped.map((group) => (
|
||||||
<MessageBlock key={group.id} messages={group.events} reverse />
|
<DirectMessageBlock key={group.id} messages={group.events} reverse />
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
25
src/views/dms/components/direct-message-block.tsx
Normal file
25
src/views/dms/components/direct-message-block.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { ReactNode, memo, useCallback } from "react";
|
||||||
|
|
||||||
|
import { NostrEvent } from "../../../types/nostr-event";
|
||||||
|
import MessageBlock, { MessageBlockProps } from "../../../components/message-block";
|
||||||
|
import DecryptPlaceholder from "./decrypt-placeholder";
|
||||||
|
import DirectMessageContent from "./direct-message-content";
|
||||||
|
|
||||||
|
function DirectMessageBlock({ ...props }: Omit<MessageBlockProps, "renderContent">) {
|
||||||
|
const renderContent = useCallback(
|
||||||
|
(message: NostrEvent, buttons: ReactNode | null) => (
|
||||||
|
<DecryptPlaceholder message={message} variant="link" py="4" px="6rem">
|
||||||
|
{(plaintext) => (
|
||||||
|
<DirectMessageContent event={message} text={plaintext} display="inline">
|
||||||
|
{buttons}
|
||||||
|
</DirectMessageContent>
|
||||||
|
)}
|
||||||
|
</DecryptPlaceholder>
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <MessageBlock renderContent={renderContent} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(DirectMessageBlock);
|
64
src/views/dms/components/direct-message-content.tsx
Normal file
64
src/views/dms/components/direct-message-content.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { Box, BoxProps } from "@chakra-ui/react";
|
||||||
|
import { EmbedableContent, embedUrls } from "../../../helpers/embeds";
|
||||||
|
import { NostrEvent } from "../../../types/nostr-event";
|
||||||
|
import {
|
||||||
|
embedCashuTokens,
|
||||||
|
embedNostrLinks,
|
||||||
|
renderAppleMusicUrl,
|
||||||
|
renderGenericUrl,
|
||||||
|
renderImageUrl,
|
||||||
|
renderRedditUrl,
|
||||||
|
renderSimpleXLink,
|
||||||
|
renderSongDotLinkUrl,
|
||||||
|
renderSoundCloudUrl,
|
||||||
|
renderSpotifyUrl,
|
||||||
|
renderStemstrUrl,
|
||||||
|
renderTidalUrl,
|
||||||
|
renderTwitterUrl,
|
||||||
|
renderVideoUrl,
|
||||||
|
renderWavlakeUrl,
|
||||||
|
renderYoutubeUrl,
|
||||||
|
} from "../../../components/embed-types";
|
||||||
|
import { TrustProvider } from "../../../providers/trust";
|
||||||
|
import { LightboxProvider } from "../../../components/lightbox-provider";
|
||||||
|
|
||||||
|
export default function DirectMessageContent({
|
||||||
|
event,
|
||||||
|
text,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: { event: NostrEvent; text: string } & BoxProps) {
|
||||||
|
let content: EmbedableContent = [text];
|
||||||
|
|
||||||
|
content = embedNostrLinks(content);
|
||||||
|
content = embedUrls(content, [
|
||||||
|
renderSimpleXLink,
|
||||||
|
renderYoutubeUrl,
|
||||||
|
renderTwitterUrl,
|
||||||
|
renderRedditUrl,
|
||||||
|
renderWavlakeUrl,
|
||||||
|
renderAppleMusicUrl,
|
||||||
|
renderSpotifyUrl,
|
||||||
|
renderTidalUrl,
|
||||||
|
renderSongDotLinkUrl,
|
||||||
|
renderStemstrUrl,
|
||||||
|
renderSoundCloudUrl,
|
||||||
|
renderImageUrl,
|
||||||
|
renderVideoUrl,
|
||||||
|
renderGenericUrl,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// cashu
|
||||||
|
content = embedCashuTokens(content);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TrustProvider event={event}>
|
||||||
|
<LightboxProvider>
|
||||||
|
<Box whiteSpace="pre-wrap" {...props}>
|
||||||
|
{content}
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
</LightboxProvider>
|
||||||
|
</TrustProvider>
|
||||||
|
);
|
||||||
|
}
|
@@ -1,148 +0,0 @@
|
|||||||
import { useRef } from "react";
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
BoxProps,
|
|
||||||
ButtonGroup,
|
|
||||||
Card,
|
|
||||||
CardBody,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
CardProps,
|
|
||||||
IconButton,
|
|
||||||
IconButtonProps,
|
|
||||||
} from "@chakra-ui/react";
|
|
||||||
|
|
||||||
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 { 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 { useLocation, useNavigate } from "react-router-dom";
|
|
||||||
import { ThreadIcon } from "../../../components/icons";
|
|
||||||
import EventReactionButtons from "../../../components/event-reactions/event-reactions";
|
|
||||||
import { LightboxProvider } from "../../../components/lightbox-provider";
|
|
||||||
|
|
||||||
export function IconThreadButton({
|
|
||||||
event,
|
|
||||||
...props
|
|
||||||
}: { event: NostrEvent } & Omit<IconButtonProps, "aria-label" | "icon">) {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
const onClick = () => {
|
|
||||||
navigate(`.`, { state: { ...location.state, thread: event.id } });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<IconButton
|
|
||||||
icon={<ThreadIcon />}
|
|
||||||
onClick={onClick}
|
|
||||||
aria-label="Reply in thread"
|
|
||||||
title="Reply in thread"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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}>
|
|
||||||
<LightboxProvider>
|
|
||||||
<Box whiteSpace="pre-wrap" {...props}>
|
|
||||||
{content}
|
|
||||||
{children}
|
|
||||||
</Box>
|
|
||||||
</LightboxProvider>
|
|
||||||
</TrustProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MessageBubbleProps = { message: NostrEvent; showHeader?: boolean; showThreadButton?: boolean } & Omit<
|
|
||||||
CardProps,
|
|
||||||
"children"
|
|
||||||
>;
|
|
||||||
|
|
||||||
export default function MessageBubble({
|
|
||||||
message,
|
|
||||||
showHeader = true,
|
|
||||||
showThreadButton = true,
|
|
||||||
...props
|
|
||||||
}: MessageBubbleProps) {
|
|
||||||
const reactions = useEventReactions(message.id) ?? [];
|
|
||||||
const hasReactions = reactions.length > 0;
|
|
||||||
|
|
||||||
let actionPosition = showHeader ? "header" : "inline";
|
|
||||||
if (hasReactions && actionPosition === "inline") actionPosition = "footer";
|
|
||||||
|
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
|
||||||
useRegisterIntersectionEntity(ref, getEventUID(message));
|
|
||||||
|
|
||||||
const actions = (
|
|
||||||
<>
|
|
||||||
<NoteZapButton event={message} />
|
|
||||||
<AddReactionButton event={message} portal />
|
|
||||||
{showThreadButton && <IconThreadButton event={message} />}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card {...props} borderRadius="lg" ref={ref}>
|
|
||||||
{showHeader && (
|
|
||||||
<CardHeader px="2" pt="2" pb="0" gap="2" display="flex" alignItems="center">
|
|
||||||
<UserLink pubkey={message.pubkey} fontWeight="bold" />
|
|
||||||
<UserDnsIdentityIcon pubkey={message.pubkey} onlyIcon />
|
|
||||||
{actionPosition === "header" && (
|
|
||||||
<ButtonGroup size="xs" variant="ghost" ml="auto">
|
|
||||||
{actions}
|
|
||||||
</ButtonGroup>
|
|
||||||
)}
|
|
||||||
</CardHeader>
|
|
||||||
)}
|
|
||||||
<CardBody px="2" py="2">
|
|
||||||
<DecryptPlaceholder message={message} variant="link" py="4" px="6rem">
|
|
||||||
{(plaintext) => (
|
|
||||||
<MessageContent event={message} text={plaintext} display="inline">
|
|
||||||
{!hasReactions && (
|
|
||||||
<ButtonGroup size="xs" variant="ghost" float="right" ml="2">
|
|
||||||
{actionPosition === "inline" && actions}
|
|
||||||
<Timestamp timestamp={message.created_at} ml="2" />
|
|
||||||
</ButtonGroup>
|
|
||||||
)}
|
|
||||||
</MessageContent>
|
|
||||||
)}
|
|
||||||
</DecryptPlaceholder>
|
|
||||||
</CardBody>
|
|
||||||
{hasReactions && (
|
|
||||||
<CardFooter alignItems="center" display="flex" gap="2" px="2" pt="0" pb="2">
|
|
||||||
<ButtonGroup size="xs" variant="ghost">
|
|
||||||
{actionPosition === "footer" ? actions : <AddReactionButton event={message} portal />}
|
|
||||||
<EventReactionButtons event={message} />
|
|
||||||
</ButtonGroup>
|
|
||||||
<Timestamp ml="auto" timestamp={message.created_at} />
|
|
||||||
</CardFooter>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
@@ -2,7 +2,7 @@ import { Button, IconButton } from "@chakra-ui/react";
|
|||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import UserAvatar from "../../../components/user-avatar";
|
import UserAvatar from "../../../components/user-avatar";
|
||||||
import { Thread } from "./thread-provider";
|
import { Thread } from "../../../providers/thread-provider";
|
||||||
import { ChevronRightIcon, ThreadIcon } from "../../../components/icons";
|
import { ChevronRightIcon, ThreadIcon } from "../../../components/icons";
|
||||||
import { IconButtonProps } from "yet-another-react-lightbox";
|
import { IconButtonProps } from "yet-another-react-lightbox";
|
||||||
import { NostrEvent } from "../../../types/nostr-event";
|
import { NostrEvent } from "../../../types/nostr-event";
|
||||||
@@ -27,3 +27,25 @@ export default function ThreadButton({ thread }: { thread: Thread }) {
|
|||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function IconThreadButton({
|
||||||
|
event,
|
||||||
|
...props
|
||||||
|
}: { event: NostrEvent } & Omit<IconButtonProps, "aria-label" | "icon">) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const onClick = () => {
|
||||||
|
navigate(`.`, { state: { ...location.state, thread: event.id } });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IconButton
|
||||||
|
icon={<ThreadIcon />}
|
||||||
|
onClick={onClick}
|
||||||
|
aria-label="Reply in thread"
|
||||||
|
title="Reply in thread"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@@ -23,12 +23,12 @@ import UserAvatar from "../../../components/user-avatar";
|
|||||||
import UserLink from "../../../components/user-link";
|
import UserLink from "../../../components/user-link";
|
||||||
import DecryptPlaceholder from "./decrypt-placeholder";
|
import DecryptPlaceholder from "./decrypt-placeholder";
|
||||||
import Timestamp from "../../../components/timestamp";
|
import Timestamp from "../../../components/timestamp";
|
||||||
import { Thread, useThreadsContext } from "./thread-provider";
|
import { Thread, useThreadsContext } from "../../../providers/thread-provider";
|
||||||
import ThreadButton from "./thread-button";
|
import ThreadButton from "./thread-button";
|
||||||
import MessageBlock from "./message-block";
|
|
||||||
import SendMessageForm from "./send-message-form";
|
import SendMessageForm from "./send-message-form";
|
||||||
import { groupMessages } from "../../../helpers/nostr/dms";
|
import { groupMessages } from "../../../helpers/nostr/dms";
|
||||||
import { useDecryptionContext } from "../../../providers/dycryption-provider";
|
import { useDecryptionContext } from "../../../providers/dycryption-provider";
|
||||||
|
import DirectMessageBlock from "./direct-message-block";
|
||||||
|
|
||||||
function MessagePreview({ message, ...props }: { message: NostrEvent } & Omit<TextProps, "children">) {
|
function MessagePreview({ message, ...props }: { message: NostrEvent } & Omit<TextProps, "children">) {
|
||||||
return (
|
return (
|
||||||
@@ -82,9 +82,9 @@ function ThreadMessages({ thread, pubkey }: { thread: Thread; pubkey: string })
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Flex h="0" flex={1} overflowX="hidden" overflowY="scroll" direction="column" gap="2">
|
<Flex h="0" flex={1} overflowX="hidden" overflowY="scroll" direction="column" gap="2">
|
||||||
{thread.root && <MessageBlock messages={[thread.root]} showThreadButton={false} />}
|
{thread.root && <DirectMessageBlock messages={[thread.root]} showThreadButton={false} />}
|
||||||
{grouped.map((group) => (
|
{grouped.map((group) => (
|
||||||
<MessageBlock key={group.id} messages={group.events} showThreadButton={false} />
|
<DirectMessageBlock key={group.id} messages={group.events} showThreadButton={false} />
|
||||||
))}
|
))}
|
||||||
</Flex>
|
</Flex>
|
||||||
<SendMessageForm flexShrink={0} pubkey={pubkey} rootId={thread.rootId} />
|
<SendMessageForm flexShrink={0} pubkey={pubkey} rootId={thread.rootId} />
|
||||||
|
@@ -6,7 +6,6 @@ import { EMOJI_PACK_KIND } from "../../helpers/nostr/emoji-packs";
|
|||||||
import { NOTE_LIST_KIND, PEOPLE_LIST_KIND } from "../../helpers/nostr/lists";
|
import { NOTE_LIST_KIND, PEOPLE_LIST_KIND } from "../../helpers/nostr/lists";
|
||||||
import { ErrorBoundary } from "../../components/error-boundary";
|
import { ErrorBoundary } from "../../components/error-boundary";
|
||||||
import { COMMUNITY_DEFINITION_KIND } from "../../helpers/nostr/communities";
|
import { COMMUNITY_DEFINITION_KIND } from "../../helpers/nostr/communities";
|
||||||
import { decode } from "ngeohash";
|
|
||||||
import { TORRENT_KIND } from "../../helpers/nostr/torrents";
|
import { TORRENT_KIND } from "../../helpers/nostr/torrents";
|
||||||
|
|
||||||
function NostrLinkPage() {
|
function NostrLinkPage() {
|
||||||
|
Reference in New Issue
Block a user