From 8c5b039fbebb4f3007ae69bc3138ab516f4f4b83 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 25 Mar 2026 13:29:56 -0700 Subject: [PATCH 1/2] Wire edit props through to ChannelPane in AppShell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connect editTarget, onCancelEdit, onEdit, and onEditSave props to the ChannelPane JSX invocation in AppShell.tsx. The state and handlers were already defined but not passed down — this was the last remaining blocker for end-to-end message editing. Co-Authored-By: Claude Opus 4.6 --- desktop/src/app/AppShell.tsx | 44 ++++++++++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 776a9c2a729..f3166bb513a 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -22,6 +22,7 @@ import { HomeView } from "@/features/home/ui/HomeView"; import { useChannelMessagesQuery, mergeMessages, + useEditMessageMutation, useSendMessageMutation, useChannelSubscription, useToggleReactionMutation, @@ -86,6 +87,7 @@ export function AppShell() { const [searchAnchorEvent, setSearchAnchorEvent] = React.useState(null); const [replyTargetId, setReplyTargetId] = React.useState(null); + const [editTargetId, setEditTargetId] = React.useState(null); const lastNonSettingsViewRef = React.useRef("home"); const queryClient = useQueryClient(); const identityQuery = useIdentityQuery(); @@ -134,6 +136,7 @@ export function AppShell() { identityQuery.data, ); const toggleReactionMutation = useToggleReactionMutation(); + const editMessageMutation = useEditMessageMutation(activeChannel); const availableChannelIds = React.useMemo( () => new Set(channels.map((channel) => channel.id)), [channels], @@ -206,14 +209,29 @@ export function AppShell() { timelineMessages.find((message) => message.id === replyTargetId) ?? null, [replyTargetId, timelineMessages], ); + const editTargetMessage = React.useMemo( + () => + timelineMessages.find((message) => message.id === editTargetId) ?? null, + [editTargetId, timelineMessages], + ); - const { handleCancelReply, handleReply, handleSend, handleToggleReaction } = - useChannelPaneHandlers({ - replyTargetId, - sendMessageMutation, - setReplyTargetId, - toggleReactionMutation, - }); + const { + handleCancelEdit, + handleCancelReply, + handleEdit, + handleEditSave, + handleReply, + handleSend, + handleToggleReaction, + } = useChannelPaneHandlers({ + editMessageMutation, + editTargetId, + replyTargetId, + sendMessageMutation, + setEditTargetId, + setReplyTargetId, + toggleReactionMutation, + }); const handleTargetReached = React.useCallback((messageId: string) => { setSearchAnchor((current) => @@ -713,10 +731,22 @@ export function AppShell() { Date: Wed, 25 Mar 2026 13:56:37 -0700 Subject: [PATCH 2/2] feat(desktop): add message editing support Implement end-to-end message editing using kind 40003 events. Includes Rust backend command, Tauri IPC binding, data layer handling, mutation hook with optimistic updates, composer edit mode with pre-fill/cancel/ escape support, edit button with auth gating, and (edited) indicator. Co-Authored-By: Claude Opus 4.6 --- desktop/scripts/check-file-sizes.mjs | 4 +- desktop/src-tauri/src/commands/messages.rs | 19 ++++ desktop/src-tauri/src/events.rs | 14 +++ desktop/src-tauri/src/lib.rs | 1 + desktop/src/app/ChannelPane.tsx | 16 ++++ desktop/src/app/useChannelPaneHandlers.ts | 54 ++++++++++- desktop/src/features/messages/hooks.ts | 35 +++++++ .../messages/lib/formatTimelineMessages.ts | 33 ++++++- desktop/src/features/messages/types.ts | 1 + .../features/messages/ui/MessageActionBar.tsx | 32 +++++-- .../features/messages/ui/MessageComposer.tsx | 93 ++++++++++++++++++- .../src/features/messages/ui/MessageRow.tsx | 12 +++ .../features/messages/ui/MessageTimeline.tsx | 3 + .../messages/ui/TimelineMessageList.tsx | 7 ++ desktop/src/shared/api/tauri.ts | 8 ++ desktop/src/shared/constants/kinds.ts | 1 + 16 files changed, 316 insertions(+), 17 deletions(-) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 1604dcc300e..22d63c92226 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -31,10 +31,10 @@ const rules = [ // Exceptions should stay rare and temporary. Prefer splitting files instead. const overrides = new Map([ ["src-tauri/src/managed_agents/persona_card.rs", 772], // PNG/ZIP persona card codec + provider/model fields + 27 unit tests (~350 lines of tests); rustfmt adds line breaks around long literals/builders - ["src/app/AppShell.tsx", 775], + ["src/app/AppShell.tsx", 810], // message edit state + handlers + ChannelPane edit prop threading ["src/features/channels/hooks.ts", 550], // canvas query + mutation hooks + DM hide mutation ["src/features/channels/ui/ChannelManagementSheet.tsx", 800], - ["src/features/messages/ui/MessageComposer.tsx", 665], // media upload handlers (paste, drop, dialog) + channelId reset effect + ["src/features/messages/ui/MessageComposer.tsx", 700], // media upload handlers (paste, drop, dialog) + channelId reset effect + edit mode (pre-fill, save, cancel, escape) ["src/features/settings/ui/SettingsView.tsx", 600], ["src/features/sidebar/ui/AppSidebar.tsx", 850], // channels + forums creation forms ["src/features/tokens/ui/TokenSettingsCard.tsx", 800], diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index a50158a2832..fcdda30ffcf 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -294,6 +294,25 @@ pub async fn remove_reaction( Ok(()) } +#[tauri::command] +pub async fn edit_message( + channel_id: String, + event_id: String, + content: String, + state: State<'_, AppState>, +) -> Result<(), String> { + let channel_uuid = uuid::Uuid::parse_str(&channel_id) + .map_err(|_| format!("invalid channel UUID: {channel_id}"))?; + let target_eid = EventId::from_hex(&event_id).map_err(|e| format!("invalid event ID: {e}"))?; + let trimmed = content.trim(); + if trimmed.is_empty() { + return Err("edit content must not be empty".into()); + } + let builder = events::build_message_edit(channel_uuid, target_eid, trimmed)?; + submit_event(builder, &state).await?; + Ok(()) +} + #[tauri::command] pub async fn delete_message(event_id: String, state: State<'_, AppState>) -> Result<(), String> { let target_eid = EventId::from_hex(&event_id).map_err(|e| format!("invalid event ID: {e}"))?; diff --git a/desktop/src-tauri/src/events.rs b/desktop/src-tauri/src/events.rs index 72755712dac..27d15bee84c 100644 --- a/desktop/src-tauri/src/events.rs +++ b/desktop/src-tauri/src/events.rs @@ -275,6 +275,20 @@ pub fn build_forum_comment( Ok(EventBuilder::new(Kind::Custom(45003), content).tags(tags)) } +/// Kind 40003 — edit a message. +pub fn build_message_edit( + channel_id: Uuid, + target_event_id: EventId, + content: &str, +) -> Result { + check_content(content)?; + let tags = vec![ + tag(vec!["h", &channel_id.to_string()])?, + tag(vec!["e", &target_event_id.to_hex()])?, + ]; + Ok(EventBuilder::new(Kind::Custom(40003), content).tags(tags)) +} + /// Kind 5 — NIP-09 deletion (messages). pub fn build_delete_compat(target_event_id: EventId) -> Result { let tags = vec![tag(vec!["e", &target_event_id.to_hex()])?]; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index cb5b390f4ce..93f62efee74 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -246,6 +246,7 @@ pub fn run() { send_channel_message, get_forum_posts, get_forum_thread, + edit_message, delete_message, add_reaction, remove_reaction, diff --git a/desktop/src/app/ChannelPane.tsx b/desktop/src/app/ChannelPane.tsx index 52bf33ab3f1..76704ceb4b8 100644 --- a/desktop/src/app/ChannelPane.tsx +++ b/desktop/src/app/ChannelPane.tsx @@ -10,10 +10,18 @@ import type { Channel } from "@/shared/api/types"; type ChannelPaneProps = { activeChannel: Channel | null; currentPubkey?: string; + editTarget?: { + author: string; + body: string; + id: string; + } | null; isSending: boolean; isTimelineLoading: boolean; messages: TimelineMessage[]; + onCancelEdit?: () => void; onCancelReply: () => void; + onEdit?: (message: TimelineMessage) => void; + onEditSave?: (content: string) => Promise; onReply: (message: TimelineMessage) => void; onSend: ( content: string, @@ -36,10 +44,14 @@ type ChannelPaneProps = { export const ChannelPane = React.memo(function ChannelPane({ activeChannel, currentPubkey, + editTarget = null, isSending, isTimelineLoading, messages, + onCancelEdit, onCancelReply, + onEdit, + onEditSave, onReply, onSend, onTargetReached, @@ -71,6 +83,7 @@ export const ChannelPane = React.memo(function ChannelPane({ } isLoading={isTimelineLoading} messages={messages} + onEdit={onEdit} onReply={onReply} onTargetReached={onTargetReached} onToggleReaction={onToggleReaction} @@ -92,8 +105,11 @@ export const ChannelPane = React.memo(function ChannelPane({ activeChannel.channelType === "forum" || isSending } + editTarget={editTarget} isSending={isSending} + onCancelEdit={onCancelEdit} onCancelReply={onCancelReply} + onEditSave={onEditSave} onSend={onSend} placeholder={ activeChannel?.archivedAt diff --git a/desktop/src/app/useChannelPaneHandlers.ts b/desktop/src/app/useChannelPaneHandlers.ts index 72a54737c7a..832272e0693 100644 --- a/desktop/src/app/useChannelPaneHandlers.ts +++ b/desktop/src/app/useChannelPaneHandlers.ts @@ -1,7 +1,10 @@ import * as React from "react"; -import type { useSendMessageMutation } from "@/features/messages/hooks"; -import type { useToggleReactionMutation } from "@/features/messages/hooks"; +import type { + useEditMessageMutation, + useSendMessageMutation, + useToggleReactionMutation, +} from "@/features/messages/hooks"; /** * Stable callback references for ChannelPane so that keystroke-driven @@ -12,13 +15,19 @@ import type { useToggleReactionMutation } from "@/features/messages/hooks"; * rather than listing the whole mutation as a dependency. */ export function useChannelPaneHandlers({ + editMessageMutation, + editTargetId, replyTargetId, sendMessageMutation, + setEditTargetId, setReplyTargetId, toggleReactionMutation, }: { + editMessageMutation: ReturnType; + editTargetId: string | null; replyTargetId: string | null; sendMessageMutation: ReturnType; + setEditTargetId: React.Dispatch>; setReplyTargetId: React.Dispatch>; toggleReactionMutation: ReturnType; }) { @@ -26,9 +35,15 @@ export function useChannelPaneHandlers({ const replyTargetIdRef = React.useRef(replyTargetId); replyTargetIdRef.current = replyTargetId; + const editTargetIdRef = React.useRef(editTargetId); + editTargetIdRef.current = editTargetId; + const sendMutateRef = React.useRef(sendMessageMutation.mutateAsync); sendMutateRef.current = sendMessageMutation.mutateAsync; + const editMutateRef = React.useRef(editMessageMutation.mutateAsync); + editMutateRef.current = editMessageMutation.mutateAsync; + const toggleMutateRef = React.useRef(toggleReactionMutation.mutateAsync); toggleMutateRef.current = toggleReactionMutation.mutateAsync; @@ -36,13 +51,43 @@ export function useChannelPaneHandlers({ setReplyTargetId(null); }, [setReplyTargetId]); + const handleCancelEdit = React.useCallback(() => { + setEditTargetId(null); + }, [setEditTargetId]); + + const handleEdit = React.useCallback( + (message: { id: string }) => { + setEditTargetId((current) => + current === message.id ? null : message.id, + ); + // Clear reply when entering edit mode. + setReplyTargetId(null); + }, + [setEditTargetId, setReplyTargetId], + ); + + const handleEditSave = React.useCallback( + async (content: string) => { + const eventId = editTargetIdRef.current; + if (!eventId) { + return; + } + + await editMutateRef.current({ eventId, content }); + setEditTargetId(null); + }, + [setEditTargetId], + ); + const handleReply = React.useCallback( (message: { id: string }) => { setReplyTargetId((current) => current === message.id ? null : message.id, ); + // Clear edit when entering reply mode. + setEditTargetId(null); }, - [setReplyTargetId], + [setReplyTargetId, setEditTargetId], ); const handleSend = React.useCallback( @@ -74,7 +119,10 @@ export function useChannelPaneHandlers({ ); return { + handleCancelEdit, handleCancelReply, + handleEdit, + handleEditSave, handleReply, handleSend, handleToggleReaction, diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index b98aee3bb4c..4bb006620bb 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -10,6 +10,7 @@ import { import { relayClient } from "@/shared/api/relayClient"; import { addReaction, + editMessage, removeReaction, sendChannelMessage, } from "@/shared/api/tauri"; @@ -451,3 +452,37 @@ export function useToggleReactionMutation() { }, }); } + +export function useEditMessageMutation(channel: Channel | null) { + const queryClient = useQueryClient(); + + return useMutation< + void, + Error, + { + eventId: string; + content: string; + } + >({ + mutationFn: async ({ eventId, content }) => { + if (!channel) { + throw new Error("No channel selected."); + } + + await editMessage(channel.id, eventId, content); + }, + onSuccess: (_data, { eventId, content }) => { + if (!channel) { + return; + } + + queryClient.setQueryData( + ["channel-messages", channel.id], + (current = []) => + current.map((message) => + message.id === eventId ? { ...message, content } : message, + ), + ); + }, + }); +} diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.ts b/desktop/src/features/messages/lib/formatTimelineMessages.ts index 24f1baf599b..c8939ae1165 100644 --- a/desktop/src/features/messages/lib/formatTimelineMessages.ts +++ b/desktop/src/features/messages/lib/formatTimelineMessages.ts @@ -13,6 +13,7 @@ import { KIND_DELETION, KIND_REACTION, KIND_STREAM_MESSAGE, + KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_DIFF, KIND_SYSTEM_MESSAGE, } from "@/shared/constants/kinds"; @@ -129,6 +130,34 @@ export function formatTimelineMessages( } } + // Build a map of latest edit per original message: targetId → { content, createdAt }. + // When multiple edits exist for the same message, the most recent one wins. + const editsByTargetId = new Map< + string, + { content: string; createdAt: number } + >(); + for (const event of events) { + if ( + event.kind !== KIND_STREAM_MESSAGE_EDIT || + deletedEventIds.has(event.id) + ) { + continue; + } + + const targetId = getReactionTargetId(event.tags); + if (!targetId || deletedEventIds.has(targetId)) { + continue; + } + + const existing = editsByTargetId.get(targetId); + if (!existing || event.created_at > existing.createdAt) { + editsByTargetId.set(targetId, { + content: event.content, + createdAt: event.created_at, + }); + } + } + const visibleEvents = events.filter( (event) => isTimelineContentEvent(event) && !deletedEventIds.has(event.id), ); @@ -258,6 +287,7 @@ export function formatTimelineMessages( requireChannelTagForPTags: true, }); const thread = getThreadReference(event.tags); + const edit = editsByTargetId.get(event.id); return { id: event.id, createdAt: event.created_at, @@ -270,12 +300,13 @@ export function formatTimelineMessages( profiles, }), time: TIME_FORMATTER.format(new Date(event.created_at * 1_000)), - body: event.content, + body: edit ? edit.content : event.content, parentId: thread.parentId, rootId: thread.rootId, depth: getDepth(event), accent: currentPubkey === authorPubkey, pending: event.pending, + edited: edit !== undefined, kind: event.kind, tags: event.tags, reactions: (() => { diff --git a/desktop/src/features/messages/types.ts b/desktop/src/features/messages/types.ts index b6c12af430a..b58957ecab6 100644 --- a/desktop/src/features/messages/types.ts +++ b/desktop/src/features/messages/types.ts @@ -19,6 +19,7 @@ export type TimelineMessage = { depth: number; accent?: boolean; pending?: boolean; + edited?: boolean; highlighted?: boolean; kind?: number; tags?: string[][]; diff --git a/desktop/src/features/messages/ui/MessageActionBar.tsx b/desktop/src/features/messages/ui/MessageActionBar.tsx index 54323a58777..d6aea42156b 100644 --- a/desktop/src/features/messages/ui/MessageActionBar.tsx +++ b/desktop/src/features/messages/ui/MessageActionBar.tsx @@ -1,6 +1,6 @@ import Picker from "@emoji-mart/react"; import data from "@emoji-mart/data"; -import { CornerUpLeft, LoaderCircle, SmilePlus } from "lucide-react"; +import { CornerUpLeft, LoaderCircle, Pencil, SmilePlus } from "lucide-react"; import * as React from "react"; import type { @@ -14,6 +14,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; export function MessageActionBar({ activeReplyTargetId = null, message, + onEdit, onReactionSelect, onReply, reactionErrorMessage = null, @@ -22,6 +23,7 @@ export function MessageActionBar({ }: { activeReplyTargetId?: string | null; message: TimelineMessage; + onEdit?: (message: TimelineMessage) => void; onReactionSelect?: (emoji: string) => Promise; onReply?: (message: TimelineMessage) => void; reactionErrorMessage?: string | null; @@ -29,10 +31,11 @@ export function MessageActionBar({ reactionPending?: boolean; }) { const [isReactionPickerOpen, setIsReactionPickerOpen] = React.useState(false); + const hasEditAction = Boolean(onEdit); const hasReplyAction = Boolean(onReply); const hasReactionAction = Boolean(onReactionSelect); - if (!hasReplyAction && !hasReactionAction) { + if (!hasReplyAction && !hasReactionAction && !hasEditAction) { return null; } @@ -44,12 +47,12 @@ export function MessageActionBar({ return (
) : null} + {hasEditAction ? ( + + ) : null} + {hasReplyAction ? ( +
+ ) : replyTarget ? (
void; onToggleReaction?: ( message: TimelineMessage, emoji: string, @@ -240,6 +242,7 @@ export const MessageRow = React.memo( ) : null} + {message.edited ? ( +

+ (edited) +

+ ) : null}

{message.time}

@@ -308,6 +319,7 @@ export const MessageRow = React.memo( prev.message.depth === next.message.depth && prev.message.kind === next.message.kind && prev.message.pending === next.message.pending && + prev.message.edited === next.message.edited && prev.message.reactions === next.message.reactions && prev.message.tags === next.message.tags && prev.message.role === next.message.role && diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index afdcdd2b4df..01167799394 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -18,6 +18,7 @@ type MessageTimelineProps = { activeReplyTargetId?: string | null; currentPubkey?: string; profiles?: UserProfileLookup; + onEdit?: (message: TimelineMessage) => void; onReply?: (message: TimelineMessage) => void; onToggleReaction?: ( message: TimelineMessage, @@ -37,6 +38,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({ activeReplyTargetId = null, currentPubkey, profiles, + onEdit, onReply, onToggleReaction, targetMessageId = null, @@ -106,6 +108,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({ currentPubkey={currentPubkey} highlightedMessageId={highlightedMessageId} messages={messages} + onEdit={onEdit} onReply={onReply} onToggleReaction={onToggleReaction} profiles={profiles} diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index d9ba3122152..3bf84ab9936 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -11,6 +11,7 @@ type TimelineMessageListProps = { currentPubkey?: string; highlightedMessageId?: string | null; messages: TimelineMessage[]; + onEdit?: (message: TimelineMessage) => void; onReply?: (message: TimelineMessage) => void; onToggleReaction?: ( message: TimelineMessage, @@ -25,6 +26,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ currentPubkey, highlightedMessageId = null, messages, + onEdit, onReply, onToggleReaction, profiles, @@ -44,6 +46,11 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ activeReplyTargetId={activeReplyTargetId} highlighted={message.id === highlightedMessageId} message={message} + onEdit={ + onEdit && currentPubkey && message.pubkey === currentPubkey + ? onEdit + : undefined + } onToggleReaction={onToggleReaction} onReply={onReply} profiles={profiles} diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 9b445c8a374..f3134595e36 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -735,6 +735,14 @@ export async function uploadMediaBytes( return invokeTauri("upload_media_bytes", { data }); } +export async function editMessage( + channelId: string, + eventId: string, + content: string, +): Promise { + await invokeTauri("edit_message", { channelId, eventId, content }); +} + export async function addReaction( eventId: string, emoji: string, diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts index 1c1fb0d2ba1..b13dd7e73be 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -14,6 +14,7 @@ export const CHANNEL_EVENT_KINDS = [ KIND_REACTION, // 7 — NIP-25 reactions KIND_STREAM_MESSAGE, // 9 — NIP-29 group chat messages 40001, // legacy: pre-migration stream messages + KIND_STREAM_MESSAGE_EDIT, // 40003 — message edits KIND_STREAM_MESSAGE_DIFF, // 40008 — message diffs KIND_SYSTEM_MESSAGE, // 40099 — system messages (join, leave, etc.) ] as const;