redesign message components

This commit is contained in:
hzrd149
2025-06-24 17:02:49 -05:00
parent c18959fd74
commit 983c2fe821
11 changed files with 314 additions and 46 deletions

View File

@@ -0,0 +1,6 @@
---
description:
globs:
alwaysApply: true
---
When writing async actions or callbacks in a component use the `useAsyncAction` hook instead of using `try/catch`. the hook is cleaner and handles errors by showing a toast

View File

@@ -0,0 +1,56 @@
import { Box, Flex } from "@chakra-ui/react";
import { NostrEvent } from "nostr-tools";
import UserAvatarLink from "../user/user-avatar-link";
import MessageSlack, { MessageSlackProps } from "./message-slack";
export type MessageSlackBlockProps = {
messages: NostrEvent[];
reverse?: boolean;
renderContent: MessageSlackProps["renderContent"];
renderActions?: MessageSlackProps["renderActions"];
};
export default function MessageSlackBlock({
messages,
reverse = false,
renderContent,
renderActions,
}: MessageSlackBlockProps) {
const lastEvent = messages[messages.length - 1];
return (
<Box
width="full"
_hover={{
bg: "var(--chakra-colors-card-hover-overlay)",
"& .message-actions": { opacity: 1 },
}}
transition="background-color 0.1s ease"
borderRadius="md"
px="3"
py="2"
position="relative"
>
<Flex direction="row" gap="2" alignItems="flex-start" width="100%">
{/* Avatar - shown only once per message group */}
<Box flexShrink={0}>
<UserAvatarLink pubkey={lastEvent.pubkey} size="sm" />
</Box>
{/* Messages container */}
<Flex direction={reverse ? "column-reverse" : "column"} gap="2" flex={1}>
{messages.map((message, i, arr) => (
<MessageSlack
key={message.id}
message={message}
showHeader={reverse ? i === arr.length - 1 : i === 0}
renderContent={renderContent}
renderActions={renderActions}
/>
))}
</Flex>
</Flex>
</Box>
);
}

View File

@@ -0,0 +1,56 @@
import { Box, ButtonGroup, Flex } from "@chakra-ui/react";
import { NostrEvent } from "nostr-tools";
import { ReactNode } from "react";
import useEventIntersectionRef from "../../hooks/use-event-intersection-ref";
import useEventReactions from "../../hooks/use-event-reactions";
import EventReactionButtons from "../event-reactions/event-reactions";
import Timestamp from "../timestamp";
import UserLink from "../user/user-link";
export type MessageSlackProps = {
message: NostrEvent;
showHeader?: boolean;
renderContent: (message: NostrEvent) => ReactNode;
renderActions?: (message: NostrEvent, onReply?: (message: NostrEvent) => void) => ReactNode;
};
export default function MessageSlack({ message, showHeader = true, renderContent, renderActions }: MessageSlackProps) {
const reactions = useEventReactions(message) ?? [];
const hasReactions = reactions.length > 0;
const ref = useEventIntersectionRef(message);
return (
<Box ref={ref} position="relative" width="full">
{/* Quick Actions - float right in message */}
{renderActions && (
<Box
className="message-actions"
opacity="0"
transition="opacity 0.1s ease"
float="right"
ml="2"
mt="-1"
mr="-1"
>
{renderActions(message)}
</Box>
)}
{showHeader && (
<Flex align="flex-start" gap="2">
<UserLink pubkey={message.pubkey} fontWeight="bold" />
<Timestamp timestamp={message.created_at} fontSize="sm" color="GrayText" />
</Flex>
)}
{renderContent(message)}
{hasReactions && (
<ButtonGroup size="xs" variant="outline" spacing="2">
<EventReactionButtons event={message} />
</ButtonGroup>
)}
</Box>
);
}

View File

@@ -59,27 +59,4 @@ export function sortConversationsByLastReceived(conversations: KnownConversation
}); });
} }
/** Groups messages into bubble sets based on the pubkey and time */ export { groupMessageEvents as groupMessages } from "applesauce-core/helpers/messages";
export function groupMessages(messages: NostrEvent[], minutes = 5, ascending = false) {
const sorted = messages.sort(sortByDate);
const groups: { id: string; pubkey: string; events: NostrEvent[] }[] = [];
for (const message of sorted) {
const last = groups[groups.length - 1];
if (last && last.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") < minutes) {
last.events.push(message);
continue;
}
}
const group = { id: message.id, pubkey: message.pubkey, events: [message] };
groups.push(group);
}
if (ascending) {
for (const group of groups) group.events.reverse();
return groups.reverse();
} else return groups;
}

View File

@@ -22,7 +22,7 @@ import {
useToast, useToast,
VStack, VStack,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { useObservableState } from "applesauce-react/hooks"; import { useObservableEagerState, useObservableState } from "applesauce-react/hooks";
import { useCallback, useRef, useState } from "react"; import { useCallback, useRef, useState } from "react";
import { firstValueFrom } from "rxjs"; import { firstValueFrom } from "rxjs";
@@ -33,14 +33,14 @@ import localSettings from "../../services/local-settings";
export default function RequireDecryptionCache({ children }: { children: JSX.Element }) { export default function RequireDecryptionCache({ children }: { children: JSX.Element }) {
const stats = useObservableState(decryptionCacheStats$); const stats = useObservableState(decryptionCacheStats$);
const cache = useObservableState(decryptionCache$); const cache = useObservableEagerState(decryptionCache$);
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const toast = useToast(); const toast = useToast();
const disableEncryptionModal = useDisclosure(); const disableEncryptionModal = useDisclosure();
const disableCacheModal = useDisclosure(); const disableCacheModal = useDisclosure();
const cancelRef = useRef<HTMLButtonElement>(null); const cancelRef = useRef<HTMLButtonElement>(null);
const { loading: isUnlocking, run: unlockCache } = useAsyncAction(async () => { const unlockCache = useAsyncAction(async () => {
if (!password.trim()) { if (!password.trim()) {
toast({ toast({
title: "Password required", title: "Password required",
@@ -69,7 +69,7 @@ export default function RequireDecryptionCache({ children }: { children: JSX.Ele
} }
}, [password, cache, toast]); }, [password, cache, toast]);
const { loading: isDisablingCache, run: runDisableMessageCache } = useAsyncAction(async () => { const disableCache = useAsyncAction(async () => {
// Clear the cache first // Clear the cache first
const cache = await firstValueFrom(decryptionCache$); const cache = await firstValueFrom(decryptionCache$);
if (cache) await cache.clear(); if (cache) await cache.clear();
@@ -99,12 +99,8 @@ export default function RequireDecryptionCache({ children }: { children: JSX.Ele
disableEncryptionModal.onClose(); disableEncryptionModal.onClose();
}, [toast]); }, [toast]);
const disableMessageCache = useCallback(() => {
runDisableMessageCache();
}, [runDisableMessageCache]);
// If cache is not encrypted or is already unlocked, render children // If cache is not encrypted or is already unlocked, render children
if (!stats?.isEncrypted || !stats?.isLocked || !cache) { if (stats?.isEncrypted === false || stats?.isLocked === false || cache === null) {
return children; return children;
} }
@@ -133,7 +129,7 @@ export default function RequireDecryptionCache({ children }: { children: JSX.Ele
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
onKeyPress={(e) => e.key === "Enter" && !isUnlocking && unlockCache()} onKeyPress={(e) => e.key === "Enter" && !unlockCache.loading && unlockCache.run()}
placeholder="Enter current password or new password" placeholder="Enter current password or new password"
autoFocus autoFocus
/> />
@@ -153,8 +149,8 @@ export default function RequireDecryptionCache({ children }: { children: JSX.Ele
<Button <Button
colorScheme="primary" colorScheme="primary"
w="full" w="full"
onClick={unlockCache} onClick={unlockCache.run}
isLoading={isUnlocking} isLoading={unlockCache.loading}
loadingText="Unlocking..." loadingText="Unlocking..."
> >
Unlock Cache Unlock Cache
@@ -228,9 +224,9 @@ export default function RequireDecryptionCache({ children }: { children: JSX.Ele
</Button> </Button>
<Button <Button
colorScheme="orange" colorScheme="orange"
onClick={disableMessageCache} onClick={disableCache.run}
ml={3} ml={3}
isLoading={isDisablingCache} isLoading={disableCache.loading}
loadingText="Disabling..." loadingText="Disabling..."
> >
Disable Message Cache Disable Message Cache

View File

@@ -25,6 +25,7 @@ import IntersectionObserverProvider from "../../providers/local/intersection-obs
import ThreadsProvider from "../../providers/local/thread-provider"; import ThreadsProvider from "../../providers/local/thread-provider";
import localSettings from "../../services/local-settings"; import localSettings from "../../services/local-settings";
import DirectMessageBlock from "./components/direct-message-block"; import DirectMessageBlock from "./components/direct-message-block";
import DirectMessageSlackBlock from "./components/direct-message-slack-block";
import SendMessageForm from "./components/send-message-form"; import SendMessageForm from "./components/send-message-form";
import ThreadDrawer from "./components/thread-drawer"; import ThreadDrawer from "./components/thread-drawer";
@@ -39,7 +40,7 @@ const ChatLog = memo(({ messages }: { messages: NostrEvent[] }) => {
return ( return (
<> <>
{grouped.map((group) => ( {grouped.map((group) => (
<DirectMessageBlock key={group.id} messages={group.events} reverse /> <DirectMessageSlackBlock key={group[0].id} messages={group} reverse />
))} ))}
</> </>
); );

View File

@@ -0,0 +1,73 @@
import { Alert, AlertDescription, AlertIcon, Button } from "@chakra-ui/react";
import { useObservableEagerState } from "applesauce-react/hooks";
import { NostrEvent } from "nostr-tools";
import { useEffect, useState } from "react";
import DebugEventButton from "../../../components/debug-modal/debug-event-button";
import { UnlockIcon } from "../../../components/icons";
import { useLegacyMessagePlaintext } from "../../../hooks/use-legacy-message-plaintext";
import localSettings from "../../../services/local-settings";
export default function DecryptPlaceholderSlack({
children,
message,
}: {
children: (decrypted: string) => JSX.Element;
message: NostrEvent;
}): JSX.Element {
const autoDecryptMessages = useObservableEagerState(localSettings.autoDecryptMessages);
const [loading, setLoading] = useState(false);
const { unlock, plaintext, error } = useLegacyMessagePlaintext(message);
const decrypt = async () => {
setLoading(true);
try {
await unlock();
} catch (e) {}
setLoading(false);
};
// auto decrypt
useEffect(() => {
if (autoDecryptMessages && !plaintext && !error) {
setLoading(true);
unlock()
.catch(() => {})
.finally(() => {
setLoading(false);
});
}
}, [autoDecryptMessages, error, plaintext]);
if (plaintext) {
return children(plaintext);
}
if (error) {
return (
<Alert status="error" borderRadius="md" maxW="lg">
<AlertIcon />
<AlertDescription flex="1">{error.message}</AlertDescription>
<DebugEventButton event={message} size="sm" mr="2" />
<Button isLoading={loading} leftIcon={<UnlockIcon />} onClick={decrypt} size="sm">
Try again
</Button>
</Alert>
);
}
return (
<Button
onClick={decrypt}
isLoading={loading}
leftIcon={<UnlockIcon />}
size="sm"
variant="ghost"
border="1px dashed"
w="full"
maxW="lg"
>
Decrypt message
</Button>
);
}

View File

@@ -1,7 +1,8 @@
import React from "react";
import { Box, BoxProps } from "@chakra-ui/react"; import { Box, BoxProps } from "@chakra-ui/react";
import { useRenderedContent } from "applesauce-react/hooks"; import { useRenderedContent } from "applesauce-react/hooks";
import { NostrEvent } from "nostr-tools"; import { NostrEvent } from "nostr-tools";
import { import {
renderAppleMusicUrl, renderAppleMusicUrl,
renderGenericUrl, renderGenericUrl,
@@ -50,7 +51,7 @@ export default function DirectMessageContent({
text, text,
children, children,
...props ...props
}: { event: NostrEvent; text: string } & BoxProps) { }: { event: NostrEvent; text: string; children?: React.ReactNode } & BoxProps) {
const { plaintext } = useLegacyMessagePlaintext(event); const { plaintext } = useLegacyMessagePlaintext(event);
const content = useRenderedContent(plaintext, components, { linkRenderers, cacheKey: DirectMessageContentSymbol }); const content = useRenderedContent(plaintext, components, { linkRenderers, cacheKey: DirectMessageContentSymbol });

View File

@@ -0,0 +1,103 @@
import { ButtonGroup, IconButton, Menu, MenuButton, MenuItem, MenuList, useToast } from "@chakra-ui/react";
import { useActiveAccount } from "applesauce-react/hooks";
import { NostrEvent } from "nostr-tools";
import { memo, useCallback } from "react";
import { ReplyIcon } from "../../../components/icons";
import DotsHorizontal from "../../../components/icons/dots-horizontal";
import MessageSlackBlock, { MessageSlackBlockProps } from "../../../components/message/message-slack-block";
import { useLegacyMessagePlaintext } from "../../../hooks/use-legacy-message-plaintext";
import DecryptPlaceholderSlack from "./decrypt-placeholder-slack";
import DirectMessageContent from "./direct-message-content";
function DirectMessageActions({
message,
onReply,
account,
toast,
}: {
message: NostrEvent;
onReply?: (message: NostrEvent) => void;
account: any;
toast: any;
}) {
const { plaintext } = useLegacyMessagePlaintext(message);
const isOwnMessage = message.pubkey === account.pubkey;
const handleReply = () => {
onReply?.(message);
};
const handleCopyText = async () => {
if (plaintext) {
try {
await navigator.clipboard.writeText(plaintext);
toast({
title: "Text copied to clipboard",
status: "success",
duration: 2000,
});
} catch (error) {
toast({
title: "Failed to copy text",
status: "error",
duration: 2000,
});
}
}
};
const handleDelete = () => {
// TODO: Implement delete functionality
console.log("Delete message:", message.id);
};
return (
<ButtonGroup size="xs" variant="ghost" gap="0">
<IconButton aria-label="Reply" icon={<ReplyIcon />} onClick={handleReply} size="xs" />
{/* <AddReactionButton event={message} size="xs" /> */}
{/* <EventZapButton event={message} size="xs" /> */}
<Menu>
<MenuButton as={IconButton} aria-label="More actions" icon={<DotsHorizontal />} size="xs" />
<MenuList fontSize="sm">
<MenuItem onClick={handleCopyText}>Copy text</MenuItem>
{isOwnMessage && (
<MenuItem color="red.500" onClick={handleDelete}>
Delete
</MenuItem>
)}
</MenuList>
</Menu>
</ButtonGroup>
);
}
function DirectMessageSlackBlock({
onReply,
...props
}: Omit<MessageSlackBlockProps, "renderContent"> & {
onReply?: (message: NostrEvent) => void;
}) {
const account = useActiveAccount()!;
const toast = useToast();
const renderContent = useCallback(
(message: NostrEvent) => (
<DecryptPlaceholderSlack message={message}>
{(plaintext) => <DirectMessageContent event={message} text={plaintext} />}
</DecryptPlaceholderSlack>
),
[],
);
const renderActions = useCallback(
(message: NostrEvent) => {
return <DirectMessageActions message={message} onReply={onReply} account={account} toast={toast} />;
},
[account, toast],
);
return <MessageSlackBlock renderContent={renderContent} renderActions={renderActions} {...props} />;
}
export default memo(DirectMessageSlackBlock);

View File

@@ -82,14 +82,14 @@ function ListThreads() {
} }
function ThreadMessages({ thread, pubkey }: { thread: Thread; pubkey: string }) { function ThreadMessages({ thread, pubkey }: { thread: Thread; pubkey: string }) {
const grouped = groupMessages(thread.messages, 5, true); const grouped = groupMessages(thread.messages, 5);
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 && <DirectMessageBlock messages={[thread.root]} showThreadButton={false} />} {thread.root && <DirectMessageBlock messages={[thread.root]} showThreadButton={false} />}
{grouped.map((group) => ( {grouped.map((group) => (
<DirectMessageBlock key={group.id} messages={group.events} showThreadButton={false} /> <DirectMessageBlock key={group[0].id} messages={group} showThreadButton={false} />
))} ))}
</Flex> </Flex>
<SendMessageForm flexShrink={0} pubkey={pubkey} rootId={thread.rootId} /> <SendMessageForm flexShrink={0} pubkey={pubkey} rootId={thread.rootId} />

View File

@@ -1,11 +1,11 @@
import { Button, ButtonGroup, Flex, IconButton, LinkBox, LinkOverlay, Text } from "@chakra-ui/react"; import { Button, ButtonGroup, Flex, IconButton, LinkBox, LinkOverlay, Text } from "@chakra-ui/react";
import { kinds, nip19 } from "nostr-tools"; import { useActiveAccount } from "applesauce-react/hooks";
import { NostrEvent, kinds, nip19 } from "nostr-tools";
import { useMemo } from "react"; import { useMemo } from "react";
import { Link as RouterLink, useLocation } from "react-router-dom"; import { Link as RouterLink, useLocation } from "react-router-dom";
import AutoSizer from "react-virtualized-auto-sizer"; import AutoSizer from "react-virtualized-auto-sizer";
import { FixedSizeList, ListChildComponentProps } from "react-window"; import { FixedSizeList, ListChildComponentProps } from "react-window";
import { useActiveAccount } from "applesauce-react/hooks";
import { CheckIcon, SettingsIcon } from "../../components/icons"; import { CheckIcon, SettingsIcon } from "../../components/icons";
import SimpleParentView from "../../components/layout/presets/simple-parent-view"; import SimpleParentView from "../../components/layout/presets/simple-parent-view";
import RequireActiveAccount from "../../components/router/require-active-account"; import RequireActiveAccount from "../../components/router/require-active-account";
@@ -25,7 +25,6 @@ import useUserContacts from "../../hooks/use-user-contacts";
import useUserMailboxes from "../../hooks/use-user-mailboxes"; import useUserMailboxes from "../../hooks/use-user-mailboxes";
import useUserMutes from "../../hooks/use-user-mutes"; import useUserMutes from "../../hooks/use-user-mutes";
import IntersectionObserverProvider from "../../providers/local/intersection-observer"; import IntersectionObserverProvider from "../../providers/local/intersection-observer";
import { NostrEvent } from "nostr-tools";
import RequireDecryptionCache from "../../providers/route/require-decryption-cache"; import RequireDecryptionCache from "../../providers/route/require-decryption-cache";
export function useDirectMessagesTimeline(pubkey?: string) { export function useDirectMessagesTimeline(pubkey?: string) {