mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-09-17 19:14:04 +02:00
redesign message components
This commit is contained in:
6
.cursor/rules/use-async-hook.mdc
Normal file
6
.cursor/rules/use-async-hook.mdc
Normal 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
|
56
src/components/message/message-slack-block.tsx
Normal file
56
src/components/message/message-slack-block.tsx
Normal 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>
|
||||
);
|
||||
}
|
56
src/components/message/message-slack.tsx
Normal file
56
src/components/message/message-slack.tsx
Normal 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>
|
||||
);
|
||||
}
|
@@ -59,27 +59,4 @@ export function sortConversationsByLastReceived(conversations: KnownConversation
|
||||
});
|
||||
}
|
||||
|
||||
/** Groups messages into bubble sets based on the pubkey and time */
|
||||
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;
|
||||
}
|
||||
export { groupMessageEvents as groupMessages } from "applesauce-core/helpers/messages";
|
||||
|
@@ -22,7 +22,7 @@ import {
|
||||
useToast,
|
||||
VStack,
|
||||
} from "@chakra-ui/react";
|
||||
import { useObservableState } from "applesauce-react/hooks";
|
||||
import { useObservableEagerState, useObservableState } from "applesauce-react/hooks";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
@@ -33,14 +33,14 @@ import localSettings from "../../services/local-settings";
|
||||
|
||||
export default function RequireDecryptionCache({ children }: { children: JSX.Element }) {
|
||||
const stats = useObservableState(decryptionCacheStats$);
|
||||
const cache = useObservableState(decryptionCache$);
|
||||
const cache = useObservableEagerState(decryptionCache$);
|
||||
const [password, setPassword] = useState("");
|
||||
const toast = useToast();
|
||||
const disableEncryptionModal = useDisclosure();
|
||||
const disableCacheModal = useDisclosure();
|
||||
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const { loading: isUnlocking, run: unlockCache } = useAsyncAction(async () => {
|
||||
const unlockCache = useAsyncAction(async () => {
|
||||
if (!password.trim()) {
|
||||
toast({
|
||||
title: "Password required",
|
||||
@@ -69,7 +69,7 @@ export default function RequireDecryptionCache({ children }: { children: JSX.Ele
|
||||
}
|
||||
}, [password, cache, toast]);
|
||||
|
||||
const { loading: isDisablingCache, run: runDisableMessageCache } = useAsyncAction(async () => {
|
||||
const disableCache = useAsyncAction(async () => {
|
||||
// Clear the cache first
|
||||
const cache = await firstValueFrom(decryptionCache$);
|
||||
if (cache) await cache.clear();
|
||||
@@ -99,12 +99,8 @@ export default function RequireDecryptionCache({ children }: { children: JSX.Ele
|
||||
disableEncryptionModal.onClose();
|
||||
}, [toast]);
|
||||
|
||||
const disableMessageCache = useCallback(() => {
|
||||
runDisableMessageCache();
|
||||
}, [runDisableMessageCache]);
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -133,7 +129,7 @@ export default function RequireDecryptionCache({ children }: { children: JSX.Ele
|
||||
type="password"
|
||||
value={password}
|
||||
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"
|
||||
autoFocus
|
||||
/>
|
||||
@@ -153,8 +149,8 @@ export default function RequireDecryptionCache({ children }: { children: JSX.Ele
|
||||
<Button
|
||||
colorScheme="primary"
|
||||
w="full"
|
||||
onClick={unlockCache}
|
||||
isLoading={isUnlocking}
|
||||
onClick={unlockCache.run}
|
||||
isLoading={unlockCache.loading}
|
||||
loadingText="Unlocking..."
|
||||
>
|
||||
Unlock Cache
|
||||
@@ -228,9 +224,9 @@ export default function RequireDecryptionCache({ children }: { children: JSX.Ele
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="orange"
|
||||
onClick={disableMessageCache}
|
||||
onClick={disableCache.run}
|
||||
ml={3}
|
||||
isLoading={isDisablingCache}
|
||||
isLoading={disableCache.loading}
|
||||
loadingText="Disabling..."
|
||||
>
|
||||
Disable Message Cache
|
||||
|
@@ -25,6 +25,7 @@ import IntersectionObserverProvider from "../../providers/local/intersection-obs
|
||||
import ThreadsProvider from "../../providers/local/thread-provider";
|
||||
import localSettings from "../../services/local-settings";
|
||||
import DirectMessageBlock from "./components/direct-message-block";
|
||||
import DirectMessageSlackBlock from "./components/direct-message-slack-block";
|
||||
import SendMessageForm from "./components/send-message-form";
|
||||
import ThreadDrawer from "./components/thread-drawer";
|
||||
|
||||
@@ -39,7 +40,7 @@ const ChatLog = memo(({ messages }: { messages: NostrEvent[] }) => {
|
||||
return (
|
||||
<>
|
||||
{grouped.map((group) => (
|
||||
<DirectMessageBlock key={group.id} messages={group.events} reverse />
|
||||
<DirectMessageSlackBlock key={group[0].id} messages={group} reverse />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
73
src/views/messages/components/decrypt-placeholder-slack.tsx
Normal file
73
src/views/messages/components/decrypt-placeholder-slack.tsx
Normal 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>
|
||||
);
|
||||
}
|
@@ -1,7 +1,8 @@
|
||||
import React from "react";
|
||||
import { Box, BoxProps } from "@chakra-ui/react";
|
||||
import { useRenderedContent } from "applesauce-react/hooks";
|
||||
|
||||
import { NostrEvent } from "nostr-tools";
|
||||
|
||||
import {
|
||||
renderAppleMusicUrl,
|
||||
renderGenericUrl,
|
||||
@@ -50,7 +51,7 @@ export default function DirectMessageContent({
|
||||
text,
|
||||
children,
|
||||
...props
|
||||
}: { event: NostrEvent; text: string } & BoxProps) {
|
||||
}: { event: NostrEvent; text: string; children?: React.ReactNode } & BoxProps) {
|
||||
const { plaintext } = useLegacyMessagePlaintext(event);
|
||||
const content = useRenderedContent(plaintext, components, { linkRenderers, cacheKey: DirectMessageContentSymbol });
|
||||
|
||||
|
103
src/views/messages/components/direct-message-slack-block.tsx
Normal file
103
src/views/messages/components/direct-message-slack-block.tsx
Normal 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);
|
@@ -82,14 +82,14 @@ function ListThreads() {
|
||||
}
|
||||
|
||||
function ThreadMessages({ thread, pubkey }: { thread: Thread; pubkey: string }) {
|
||||
const grouped = groupMessages(thread.messages, 5, true);
|
||||
const grouped = groupMessages(thread.messages, 5);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex h="0" flex={1} overflowX="hidden" overflowY="scroll" direction="column" gap="2">
|
||||
{thread.root && <DirectMessageBlock messages={[thread.root]} showThreadButton={false} />}
|
||||
{grouped.map((group) => (
|
||||
<DirectMessageBlock key={group.id} messages={group.events} showThreadButton={false} />
|
||||
<DirectMessageBlock key={group[0].id} messages={group} showThreadButton={false} />
|
||||
))}
|
||||
</Flex>
|
||||
<SendMessageForm flexShrink={0} pubkey={pubkey} rootId={thread.rootId} />
|
||||
|
@@ -1,11 +1,11 @@
|
||||
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 { Link as RouterLink, useLocation } from "react-router-dom";
|
||||
import AutoSizer from "react-virtualized-auto-sizer";
|
||||
import { FixedSizeList, ListChildComponentProps } from "react-window";
|
||||
|
||||
import { useActiveAccount } from "applesauce-react/hooks";
|
||||
import { CheckIcon, SettingsIcon } from "../../components/icons";
|
||||
import SimpleParentView from "../../components/layout/presets/simple-parent-view";
|
||||
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 useUserMutes from "../../hooks/use-user-mutes";
|
||||
import IntersectionObserverProvider from "../../providers/local/intersection-observer";
|
||||
import { NostrEvent } from "nostr-tools";
|
||||
import RequireDecryptionCache from "../../providers/route/require-decryption-cache";
|
||||
|
||||
export function useDirectMessagesTimeline(pubkey?: string) {
|
||||
|
Reference in New Issue
Block a user