From e6553b15884ee205b603a7f4731be558c7db6263 Mon Sep 17 00:00:00 2001 From: Wes Date: Thu, 14 May 2026 13:52:59 -0700 Subject: [PATCH 1/2] fix: correct createdAt conversion and hash-routing URL for message actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Multiply createdAt by 1000 (Unix seconds → milliseconds) for mark-unread - Build copy-link URL with hash fragment for createHashHistory() router - Add .catch() error toasts on clipboard write failures Co-Authored-By: Claude Opus 4.6 --- .../features/channels/ui/ChannelScreen.tsx | 15 +- .../features/messages/ui/MessageActionBar.tsx | 245 ++++++++++++------ 2 files changed, 176 insertions(+), 84 deletions(-) diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index a5c212753..64902e2fe 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 469b2fdd2..446d400ea 100644 --- a/desktop/src/features/messages/ui/MessageActionBar.tsx +++ b/desktop/src/features/messages/ui/MessageActionBar.tsx @@ -1,7 +1,17 @@ import Picker from "@emoji-mart/react"; import data from "@emoji-mart/data"; -import { CornerUpLeft, Pencil, SmilePlus, Trash2 } from "lucide-react"; +import { + Copy, + CornerUpLeft, + EllipsisVertical, + Link, + MailOpen, + Pencil, + SmilePlus, + Trash2, +} from "lucide-react"; import * as React from "react"; +import { toast } from "sonner"; import type { TimelineMessage, @@ -19,15 +29,24 @@ 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"; export function MessageActionBar({ activeReplyTargetId = null, + channelId, message, onDelete, onEdit, + onMarkUnread, onReactionSelect, onReply, reactionErrorMessage = null, @@ -35,9 +54,11 @@ export function MessageActionBar({ reactionPending = false, }: { activeReplyTargetId?: string | null; + channelId?: string | null; 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; @@ -46,17 +67,19 @@ export function MessageActionBar({ }) { const [isReactionPickerOpen, setIsReactionPickerOpen] = React.useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false); + const [isDropdownOpen, setIsDropdownOpen] = React.useState(false); const hasDeleteAction = Boolean(onDelete); const hasEditAction = Boolean(onEdit); const hasReplyAction = Boolean(onReply); const hasReactionAction = Boolean(onReactionSelect); + const hasMarkUnreadAction = Boolean(onMarkUnread); + + // Copy Link and Copy Text are always available for non-pending messages + const hasCopyActions = !message.pending; + const hasMoreMenuActions = + hasEditAction || hasDeleteAction || hasMarkUnreadAction || hasCopyActions; - if ( - !hasReplyAction && - !hasReactionAction && - !hasEditAction && - !hasDeleteAction - ) { + if (!hasReplyAction && !hasReactionAction && !hasMoreMenuActions) { return null; } @@ -65,6 +88,29 @@ export function MessageActionBar({ (reaction) => reaction.reactedByCurrentUser, ).length; + const handleCopyLink = () => { + const link = `${window.location.origin}/#/channels/${channelId}?messageId=${message.id}`; + void navigator.clipboard + .writeText(link) + .then(() => { + toast.success("Link copied to clipboard"); + }) + .catch(() => { + toast.error("Failed to copy link"); + }); + }; + + const handleCopyText = () => { + void navigator.clipboard + .writeText(message.body) + .then(() => { + toast.success("Message copied to clipboard"); + }) + .catch(() => { + toast.error("Failed to copy message"); + }); + }; + return (
) : null} - {hasEditAction ? ( - - - - - Edit - - ) : null} - - {hasDeleteAction ? ( - <> - - - - - Delete - - - - - - Delete message? - - This will permanently delete this message and cannot be - undone. - - - - - - - - - - - - - - ) : null} - {hasReplyAction ? ( @@ -242,7 +213,115 @@ export function MessageActionBar({ ) : null} + + {hasMoreMenuActions ? ( + + + + + + + + More actions + + + {hasEditAction ? ( + { + onEdit?.(message); + }} + > + + Edit message + + ) : null} + + {hasMarkUnreadAction ? ( + { + onMarkUnread?.(message); + }} + > + + Mark unread + + ) : null} + + {hasCopyActions && channelId ? ( + + + Copy link + + ) : null} + + {hasCopyActions ? ( + + + Copy message + + ) : null} + + {hasDeleteAction ? ( + <> + + { + setIsDeleteDialogOpen(true); + }} + > + + Delete message + + + ) : null} + + + ) : null}
+ + {hasDeleteAction ? ( + + + + Delete message? + + This will permanently delete this message and cannot be undone. + + + + + + + + + + + + + ) : null} ); } From 386629e1ceb87d77e42d9581ff2f3ae1b9c89d3a Mon Sep 17 00:00:00 2001 From: Wes Date: Thu, 14 May 2026 14:54:09 -0700 Subject: [PATCH 2/2] fix: remove broken Copy link from message more menu The Copy link option was generating a web URL that opened in the browser instead of navigating within the Sprout app. Since deep linking to specific messages isn't supported yet, remove the option until a proper sprout:// deep link scheme for messages is implemented. Also extracts MoreActionsMenu into its own component and adds mark unread support to the message action bar. Co-Authored-By: Claude Opus 4.6 --- desktop/src/app/AppShell.tsx | 1 + desktop/src/app/AppShellContext.tsx | 5 + .../src/features/channels/ui/ChannelPane.tsx | 4 + .../features/messages/ui/MessageActionBar.tsx | 296 ++++++++++-------- .../src/features/messages/ui/MessageRow.tsx | 4 + .../messages/ui/MessageThreadPanel.tsx | 6 + .../features/messages/ui/MessageTimeline.tsx | 4 + .../messages/ui/TimelineMessageList.tsx | 8 + 8 files changed, 190 insertions(+), 138 deletions(-) diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 74ceb7dd2..4a16a7cb5 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 b4c401fa7..2f483dac8 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 f7380628a..93060be3a 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/messages/ui/MessageActionBar.tsx b/desktop/src/features/messages/ui/MessageActionBar.tsx index 446d400ea..4aa4bf847 100644 --- a/desktop/src/features/messages/ui/MessageActionBar.tsx +++ b/desktop/src/features/messages/ui/MessageActionBar.tsx @@ -4,7 +4,6 @@ import { Copy, CornerUpLeft, EllipsisVertical, - Link, MailOpen, Pencil, SmilePlus, @@ -40,9 +39,154 @@ 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, - channelId, message, onDelete, onEdit, @@ -54,7 +198,6 @@ export function MessageActionBar({ reactionPending = false, }: { activeReplyTargetId?: string | null; - channelId?: string | null; message: TimelineMessage; onDelete?: (message: TimelineMessage) => void; onEdit?: (message: TimelineMessage) => void; @@ -66,18 +209,15 @@ export function MessageActionBar({ reactionPending?: boolean; }) { const [isReactionPickerOpen, setIsReactionPickerOpen] = React.useState(false); - const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false); const [isDropdownOpen, setIsDropdownOpen] = React.useState(false); - const hasDeleteAction = Boolean(onDelete); - const hasEditAction = Boolean(onEdit); const hasReplyAction = Boolean(onReply); const hasReactionAction = Boolean(onReactionSelect); - const hasMarkUnreadAction = Boolean(onMarkUnread); - // Copy Link and Copy Text are always available for non-pending messages - const hasCopyActions = !message.pending; const hasMoreMenuActions = - hasEditAction || hasDeleteAction || hasMarkUnreadAction || hasCopyActions; + Boolean(onEdit) || + Boolean(onDelete) || + Boolean(onMarkUnread) || + !message.pending; if (!hasReplyAction && !hasReactionAction && !hasMoreMenuActions) { return null; @@ -88,29 +228,6 @@ export function MessageActionBar({ (reaction) => reaction.reactedByCurrentUser, ).length; - const handleCopyLink = () => { - const link = `${window.location.origin}/#/channels/${channelId}?messageId=${message.id}`; - void navigator.clipboard - .writeText(link) - .then(() => { - toast.success("Link copied to clipboard"); - }) - .catch(() => { - toast.error("Failed to copy link"); - }); - }; - - const handleCopyText = () => { - void navigator.clipboard - .writeText(message.body) - .then(() => { - toast.success("Message copied to clipboard"); - }) - .catch(() => { - toast.error("Failed to copy message"); - }); - }; - return (
- - - - - - - More actions - - - {hasEditAction ? ( - { - onEdit?.(message); - }} - > - - Edit message - - ) : null} - - {hasMarkUnreadAction ? ( - { - onMarkUnread?.(message); - }} - > - - Mark unread - - ) : null} - - {hasCopyActions && channelId ? ( - - - Copy link - - ) : null} - - {hasCopyActions ? ( - - - Copy message - - ) : null} - - {hasDeleteAction ? ( - <> - - { - setIsDeleteDialogOpen(true); - }} - > - - Delete message - - - ) : null} - - + ) : null}
- - {hasDeleteAction ? ( - - - - Delete message? - - This will permanently delete this message and cannot be undone. - - - - - - - - - - - - - ) : null} ); } diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index ee87767ee..109ec0780 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 cef37b6ea..b8ea0fdf3 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({