diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 74ceb7dd..4a16a7cb 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -561,6 +561,7 @@ export function AppShell() { { setIsChannelManagementOpen(true); }, diff --git a/desktop/src/app/AppShellContext.tsx b/desktop/src/app/AppShellContext.tsx index b4c401fa..2f483dac 100644 --- a/desktop/src/app/AppShellContext.tsx +++ b/desktop/src/app/AppShellContext.tsx @@ -5,11 +5,16 @@ type AppShellContextValue = { channelId: string, readAt: string | null | undefined, ) => void; + markChannelUnread: ( + channelId: string, + lastMessageAt: string | null | undefined, + ) => void; openChannelManagement: () => void; }; const AppShellContext = React.createContext({ markChannelRead: () => {}, + markChannelUnread: () => {}, openChannelManagement: () => {}, }); diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index f7380628..93060be3 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -82,6 +82,7 @@ type ChannelPaneProps = { onDelete?: (message: TimelineMessage) => void; onEdit?: (message: TimelineMessage) => void; onEditSave?: (content: string) => Promise; + onMarkUnread?: (message: TimelineMessage) => void; onExpandThreadReplies: (message: TimelineMessage) => void; onJoinChannel?: () => Promise; onOpenAgentSession: (pubkey: string) => void; @@ -144,6 +145,7 @@ export const ChannelPane = React.memo(function ChannelPane({ onDelete, onEdit, onEditSave, + onMarkUnread, onExpandThreadReplies, onJoinChannel, onOpenAgentSession, @@ -339,6 +341,7 @@ export const ChannelPane = React.memo(function ChannelPane({ messages={messages} onDelete={onDelete} onEdit={onEdit} + onMarkUnread={onMarkUnread} onReply={activeChannel?.archivedAt ? undefined : onOpenThread} onTargetReached={onTargetReached} onToggleReaction={onToggleReaction} @@ -444,6 +447,7 @@ export const ChannelPane = React.memo(function ChannelPane({ onDelete={onDelete} onEdit={onEdit} onEditSave={onEditSave} + onMarkUnread={onMarkUnread} onExpandReplies={onExpandThreadReplies} onSelectReplyTarget={onSelectThreadReplyTarget} onSend={onSendThreadReply} diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index a5c21275..64902e2f 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -33,6 +33,7 @@ import { formatTimelineMessages, } from "@/features/messages/lib/formatTimelineMessages"; import { buildThreadPanelData } from "@/features/messages/lib/threadPanel"; +import type { TimelineMessage } from "@/features/messages/types"; import { useFetchOlderMessages } from "@/features/messages/useFetchOlderMessages"; import { useLoadMissingAncestors } from "@/features/messages/useLoadMissingAncestors"; import { useChannelTyping } from "@/features/messages/useChannelTyping"; @@ -78,7 +79,8 @@ export function ChannelScreen({ targetMessageEvent, targetMessageId, }: ChannelScreenProps) { - const { markChannelRead, openChannelManagement } = useAppShell(); + const { markChannelRead, markChannelUnread, openChannelManagement } = + useAppShell(); const [profilePanelPubkey, setProfilePanelPubkey] = React.useState< string | null >(null); @@ -330,6 +332,16 @@ export function ChannelScreen({ : undefined, [activeChannel, handleToggleReaction], ); + + const handleMarkUnread = React.useCallback( + (message: TimelineMessage) => { + if (!activeChannelId) return; + const messageIso = new Date(message.createdAt * 1_000).toISOString(); + markChannelUnread(activeChannelId, messageIso); + }, + [activeChannelId, markChannelUnread], + ); + const { channelAgentSessionAgents, closeAgentSession: handleCloseAgentSession, @@ -477,6 +489,7 @@ export function ChannelScreen({ onEditSave={ activeChannel?.archivedAt ? undefined : handleEditSave } + onMarkUnread={handleMarkUnread} onExpandThreadReplies={handleExpandThreadReplies} onOpenAgentSession={handleOpenAgentSession} onOpenDm={handleOpenDm} diff --git a/desktop/src/features/messages/ui/MessageActionBar.tsx b/desktop/src/features/messages/ui/MessageActionBar.tsx index 469b2fdd..4aa4bf84 100644 --- a/desktop/src/features/messages/ui/MessageActionBar.tsx +++ b/desktop/src/features/messages/ui/MessageActionBar.tsx @@ -1,7 +1,16 @@ import Picker from "@emoji-mart/react"; import data from "@emoji-mart/data"; -import { CornerUpLeft, Pencil, SmilePlus, Trash2 } from "lucide-react"; +import { + Copy, + CornerUpLeft, + EllipsisVertical, + MailOpen, + Pencil, + SmilePlus, + Trash2, +} from "lucide-react"; import * as React from "react"; +import { toast } from "sonner"; import type { TimelineMessage, @@ -19,15 +28,169 @@ import { AlertDialogTitle, } from "@/shared/ui/alert-dialog"; import { Button } from "@/shared/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; import { Spinner } from "@/shared/ui/spinner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; +function copyToClipboard(text: string, successMessage: string) { + void navigator.clipboard + .writeText(text) + .then(() => { + toast.success(successMessage); + }) + .catch(() => { + toast.error("Failed to copy to clipboard"); + }); +} + +// --------------------------------------------------------------------------- +// MoreActionsMenu — dropdown with edit, mark unread, copy, and delete actions +// --------------------------------------------------------------------------- + +function MoreActionsMenu({ + message, + onDelete, + onEdit, + onMarkUnread, + onOpenChange, + open, +}: { + message: TimelineMessage; + onDelete?: (message: TimelineMessage) => void; + onEdit?: (message: TimelineMessage) => void; + onMarkUnread?: (message: TimelineMessage) => void; + onOpenChange: (open: boolean) => void; + open: boolean; +}) { + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false); + + const hasCopyActions = !message.pending; + + return ( + <> + + + + + + + + More actions + + + {onEdit ? ( + { + onEdit(message); + }} + > + + Edit message + + ) : null} + + {onMarkUnread ? ( + { + onMarkUnread(message); + }} + > + + Mark unread + + ) : null} + + {hasCopyActions ? ( + { + copyToClipboard(message.body, "Message copied to clipboard"); + }} + > + + Copy message + + ) : null} + + {onDelete ? ( + <> + + { + setIsDeleteDialogOpen(true); + }} + > + + Delete message + + + ) : null} + + + + {onDelete ? ( + + + + Delete message? + + This will permanently delete this message and cannot be undone. + + + + + + + + + + + + + ) : null} + + ); +} + +// --------------------------------------------------------------------------- +// MessageActionBar — reaction picker, reply button, and more-actions menu +// --------------------------------------------------------------------------- + export function MessageActionBar({ activeReplyTargetId = null, message, onDelete, onEdit, + onMarkUnread, onReactionSelect, onReply, reactionErrorMessage = null, @@ -38,6 +201,7 @@ export function MessageActionBar({ message: TimelineMessage; onDelete?: (message: TimelineMessage) => void; onEdit?: (message: TimelineMessage) => void; + onMarkUnread?: (message: TimelineMessage) => void; onReactionSelect?: (emoji: string) => Promise; onReply?: (message: TimelineMessage) => void; reactionErrorMessage?: string | null; @@ -45,18 +209,17 @@ export function MessageActionBar({ reactionPending?: boolean; }) { const [isReactionPickerOpen, setIsReactionPickerOpen] = React.useState(false); - const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false); - const hasDeleteAction = Boolean(onDelete); - const hasEditAction = Boolean(onEdit); + const [isDropdownOpen, setIsDropdownOpen] = React.useState(false); const hasReplyAction = Boolean(onReply); const hasReactionAction = Boolean(onReactionSelect); - if ( - !hasReplyAction && - !hasReactionAction && - !hasEditAction && - !hasDeleteAction - ) { + const hasMoreMenuActions = + Boolean(onEdit) || + Boolean(onDelete) || + Boolean(onMarkUnread) || + !message.pending; + + if (!hasReplyAction && !hasReactionAction && !hasMoreMenuActions) { return null; } @@ -72,7 +235,7 @@ export function MessageActionBar({ "translate-y-0 opacity-100 sm:max-w-0 sm:border-0 sm:shadow-none sm:translate-y-1 sm:opacity-0", "sm:group-hover/message:max-w-36 sm:group-hover/message:border sm:group-hover/message:border-border/70 sm:group-hover/message:shadow-sm sm:group-hover/message:translate-y-0 sm:group-hover/message:opacity-100", "sm:group-focus-within/message:max-w-36 sm:group-focus-within/message:border sm:group-focus-within/message:border-border/70 sm:group-focus-within/message:shadow-sm sm:group-focus-within/message:translate-y-0 sm:group-focus-within/message:opacity-100", - isReplyingToMessage || isReactionPickerOpen + isReplyingToMessage || isReactionPickerOpen || isDropdownOpen ? "sm:max-w-36 sm:border sm:border-border/70 sm:shadow-sm sm:translate-y-0 sm:opacity-100" : "", )} @@ -145,81 +308,6 @@ export function MessageActionBar({ ) : null} - {hasEditAction ? ( - - - - - Edit - - ) : null} - - {hasDeleteAction ? ( - <> - - - - - Delete - - - - - - Delete message? - - This will permanently delete this message and cannot be - undone. - - - - - - - - - - - - - - ) : null} - {hasReplyAction ? ( @@ -242,6 +330,17 @@ export function MessageActionBar({ ) : null} + + {hasMoreMenuActions ? ( + + ) : null} ); diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index ee87767e..109ec078 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -27,17 +27,20 @@ export const MessageRow = React.memo( message, onDelete, onEdit, + onMarkUnread, onToggleReaction, onReply, profiles, searchQuery, }: { activeReplyTargetId?: string | null; + channelId?: string | null; highlighted?: boolean; layoutVariant?: "default" | "thread-reply"; message: TimelineMessage; onDelete?: (message: TimelineMessage) => void; onEdit?: (message: TimelineMessage) => void; + onMarkUnread?: (message: TimelineMessage) => void; onToggleReaction?: ( message: TimelineMessage, emoji: string, @@ -184,6 +187,7 @@ export const MessageRow = React.memo( message={message} onDelete={onDelete} onEdit={onEdit} + onMarkUnread={onMarkUnread} onReactionSelect={ canToggleReactions ? handleReactionSelect : undefined } diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index cef37b6e..b8ea0fdf 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -35,6 +35,7 @@ type MessageThreadPanelProps = { onDelete?: (message: TimelineMessage) => void; onEdit?: (message: TimelineMessage) => void; onEditSave?: (content: string) => Promise; + onMarkUnread?: (message: TimelineMessage) => void; onExpandReplies: (message: TimelineMessage) => void; onResetWidth: () => void; onResizeStart: (event: React.PointerEvent) => void; @@ -87,6 +88,7 @@ export function MessageThreadPanel({ onDelete, onEdit, onEditSave, + onMarkUnread, onExpandReplies, onResetWidth, onResizeStart, @@ -201,6 +203,7 @@ export function MessageThreadPanel({
@@ -230,6 +234,7 @@ export function MessageThreadPanel({
void; onEdit?: (message: TimelineMessage) => void; + onMarkUnread?: (message: TimelineMessage) => void; onReply?: (message: TimelineMessage) => void; onToggleReaction?: ( message: TimelineMessage, @@ -61,6 +62,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({ profiles, onDelete, onEdit, + onMarkUnread, onReply, onToggleReaction, searchActiveMessageId = null, @@ -176,12 +178,14 @@ export const MessageTimeline = React.memo(function MessageTimeline({ {!isLoading && messages.length > 0 ? ( ; messages: TimelineMessage[]; onDelete?: (message: TimelineMessage) => void; onEdit?: (message: TimelineMessage) => void; + onMarkUnread?: (message: TimelineMessage) => void; onReply?: (message: TimelineMessage) => void; onToggleReaction?: ( message: TimelineMessage, @@ -40,12 +42,14 @@ type TimelineMessageListProps = { export const TimelineMessageList = React.memo(function TimelineMessageList({ activeReplyTargetId = null, + channelId, currentPubkey, highlightedMessageId = null, messageFooters, messages, onDelete, onEdit, + onMarkUnread, onReply, onToggleReaction, personaLookup, @@ -98,6 +102,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({