diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 39dade9999..7ab682d355 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -15,7 +15,11 @@ import { } from "@/features/channels/ui/BotActivityBar"; import { Button } from "@/shared/ui/button"; import type { useChannelFind } from "@/features/search/useChannelFind"; -import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel"; +import { + buildMainTimelineEntries, + type MainTimelineEntry, +} from "@/features/messages/lib/threadPanel"; +import { findLatestEditableMessage } from "@/features/messages/lib/findLatestEditableMessage"; import type { TimelineMessage } from "@/features/messages/types"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { Channel, ManagedAgent } from "@/shared/api/types"; @@ -234,6 +238,14 @@ export const ChannelPane = React.memo(function ChannelPane({ threadMessages.some((entry) => entry.message.id === editTarget.id)); const mainEditTarget = editTarget && !isEditInThread ? editTarget : null; const threadEditTarget = editTarget && isEditInThread ? editTarget : null; + const latestEditableMainMessage = React.useMemo( + () => + findLatestEditableMessage( + buildMainTimelineEntries(messages).map((entry) => entry.message), + currentPubkey, + ), + [currentPubkey, messages], + ); const isNonMemberView = activeChannel !== null && @@ -376,6 +388,11 @@ export const ChannelPane = React.memo(function ChannelPane({ isSending={isSending} onCancelEdit={onCancelEdit} onEditSave={onEditSave} + onEditLastMessage={ + latestEditableMainMessage && onEdit + ? () => onEdit(latestEditableMainMessage) + : undefined + } onSend={onSendMessage} profiles={profiles} toolbarExtraActions={ diff --git a/desktop/src/features/messages/lib/findLatestEditableMessage.ts b/desktop/src/features/messages/lib/findLatestEditableMessage.ts new file mode 100644 index 0000000000..30530bb718 --- /dev/null +++ b/desktop/src/features/messages/lib/findLatestEditableMessage.ts @@ -0,0 +1,20 @@ +import type { TimelineMessage } from "@/features/messages/types"; + +export function findLatestEditableMessage( + messages: TimelineMessage[], + currentPubkey: string | undefined, +): TimelineMessage | null { + if (!currentPubkey) { + return null; + } + + const normalizedCurrentPubkey = currentPubkey.toLowerCase(); + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message?.pubkey?.toLowerCase() === normalizedCurrentPubkey) { + return message; + } + } + + return null; +} diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index fe27151139..8c4840cae5 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -1,7 +1,6 @@ import * as React from "react"; import { EditorContent } from "@tiptap/react"; -import { X } from "lucide-react"; import { useChannelLinks } from "@/features/messages/lib/useChannelLinks"; import type { ChannelSuggestion } from "@/features/messages/lib/useChannelLinks"; import { useDrafts } from "@/features/messages/lib/useDrafts"; @@ -21,7 +20,6 @@ import { import { useRichTextEditor } from "@/features/messages/lib/useRichTextEditor"; import { useTypingBroadcast } from "@/features/messages/useTypingBroadcast"; import { cn } from "@/shared/lib/cn"; -import { Button } from "@/shared/ui/button"; import { ChannelAutocomplete } from "./ChannelAutocomplete"; import { ComposerAttachments, DropZoneOverlay } from "./ComposerAttachments"; import { EmojiAutocomplete } from "./EmojiAutocomplete"; @@ -29,6 +27,8 @@ import { MentionAutocomplete, type MentionSuggestion, } from "./MentionAutocomplete"; +import { MessageComposerEditTarget } from "./MessageComposerEditTarget"; +import { MessageComposerReplyTarget } from "./MessageComposerReplyTarget"; import { MessageComposerToolbar } from "./MessageComposerToolbar"; type MessageComposerProps = { @@ -45,6 +45,7 @@ type MessageComposerProps = { onCancelEdit?: () => void; onCancelReply?: () => void; onEditSave?: (content: string) => Promise; + onEditLastMessage?: () => void; onSend: ( content: string, mentionPubkeys: string[], @@ -73,6 +74,7 @@ export function MessageComposer({ onCancelEdit, onCancelReply, onEditSave, + onEditLastMessage, onSend, placeholder, profiles, @@ -89,6 +91,7 @@ export function MessageComposer({ const [isEmojiPickerOpen, setIsEmojiPickerOpen] = React.useState(false); const [isFormattingOpen, setIsFormattingOpen] = React.useState(false); + const [isInlineEditPreview, setIsInlineEditPreview] = React.useState(false); const handleFormattingToggle = React.useCallback((pressed: boolean) => { if (pressed) setIsEmojiPickerOpen(false); @@ -118,12 +121,14 @@ export function MessageComposer({ const isSendingRef = React.useRef(isSending); const onSendRef = React.useRef(onSend); const onEditSaveRef = React.useRef(onEditSave); + const onEditLastMessageRef = React.useRef(onEditLastMessage); const editTargetRef = React.useRef(editTarget); const channelIdRef = React.useRef(channelId); disabledRef.current = disabled; isSendingRef.current = isSending; onSendRef.current = onSend; onEditSaveRef.current = onEditSave; + onEditLastMessageRef.current = onEditLastMessage; editTargetRef.current = editTarget; channelIdRef.current = channelId; @@ -211,12 +216,15 @@ export function MessageComposer({ contentRef.current = editTarget.body; richText.setContent(editTarget.body); richText.focus(); - } else if (preEditContentRef.current !== null) { - const restored = preEditContentRef.current; - preEditContentRef.current = null; - setContent(restored); - contentRef.current = restored; - restored ? richText.setContent(restored) : richText.clearContent(); + } else { + setIsInlineEditPreview(false); + if (preEditContentRef.current !== null) { + const restored = preEditContentRef.current; + preEditContentRef.current = null; + setContent(restored); + contentRef.current = restored; + restored ? richText.setContent(restored) : richText.clearContent(); + } } }, [editTarget?.id]); @@ -462,6 +470,21 @@ export function MessageComposer({ onCancelEdit(); return; } + + if ( + event.key === "ArrowUp" && + !editTargetRef.current && + !replyTarget && + !disabledRef.current && + !isSendingRef.current && + contentRef.current.trim().length === 0 && + media.pendingImetaRef.current.length === 0 && + onEditLastMessageRef.current + ) { + event.preventDefault(); + setIsInlineEditPreview(true); + onEditLastMessageRef.current(); + } }, [ emojiAutocomplete.handleEmojiKeyDown, @@ -471,6 +494,8 @@ export function MessageComposer({ mentions.handleMentionKeyDown, applyMentionInsert, onCancelEdit, + replyTarget, + media.pendingImetaRef, ], ); @@ -594,53 +619,17 @@ export function MessageComposer({ selectedIndex={mentions.mentionSelectedIndex} suggestions={mentions.isMentionOpen ? mentions.suggestions : []} /> - {editTarget ? ( -
-
-

- Editing message -

-

- {editTarget.body} -

-
- -
+ {editTarget && !isInlineEditPreview ? ( + ) : replyTarget ? ( -
-
-

- Replying to {replyTarget.author} -

-

- {replyTarget.body} -

-
- -
+ ) : null} {media.uploadState.status === "error" ? ( diff --git a/desktop/src/features/messages/ui/MessageComposerEditTarget.tsx b/desktop/src/features/messages/ui/MessageComposerEditTarget.tsx new file mode 100644 index 0000000000..afcfbc01ba --- /dev/null +++ b/desktop/src/features/messages/ui/MessageComposerEditTarget.tsx @@ -0,0 +1,37 @@ +import { X } from "lucide-react"; + +import { Button } from "@/shared/ui/button"; + +type MessageComposerEditTargetProps = { + body: string; + onCancelEdit?: () => void; +}; + +export function MessageComposerEditTarget({ + body, + onCancelEdit, +}: MessageComposerEditTargetProps) { + return ( +
+
+

+ Editing message +

+

{body}

+
+ +
+ ); +} diff --git a/desktop/src/features/messages/ui/MessageComposerReplyTarget.tsx b/desktop/src/features/messages/ui/MessageComposerReplyTarget.tsx new file mode 100644 index 0000000000..33a60942f8 --- /dev/null +++ b/desktop/src/features/messages/ui/MessageComposerReplyTarget.tsx @@ -0,0 +1,39 @@ +import { X } from "lucide-react"; + +import { Button } from "@/shared/ui/button"; + +type MessageComposerReplyTargetProps = { + author: string; + body: string; + onCancelReply?: () => void; +}; + +export function MessageComposerReplyTarget({ + author, + body, + onCancelReply, +}: MessageComposerReplyTargetProps) { + return ( +
+
+

+ Replying to {author} +

+

{body}

+
+ +
+ ); +} diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index 5119469854..21ab366a5c 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import { ArrowDown, X } from "lucide-react"; import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel"; +import { findLatestEditableMessage } from "@/features/messages/lib/findLatestEditableMessage"; import type { TimelineMessage } from "@/features/messages/types"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { Channel } from "@/shared/api/types"; @@ -123,6 +124,14 @@ export function MessageThreadPanel({ () => threadReplies.map((entry) => entry.message), [threadReplies], ); + const latestEditableThreadMessage = React.useMemo( + () => + findLatestEditableMessage( + threadHead ? [threadHead, ...threadMessages] : threadMessages, + currentPubkey, + ), + [currentPubkey, threadHead, threadMessages], + ); const { bottomAnchorRef, @@ -306,6 +315,11 @@ export function MessageThreadPanel({ onCancelEdit={onCancelEdit} onCancelReply={composerReplyTarget ? onCancelReply : undefined} onEditSave={onEditSave} + onEditLastMessage={ + latestEditableThreadMessage && onEdit + ? () => onEdit(latestEditableThreadMessage) + : undefined + } onSend={onSend} placeholder={`Reply in thread to ${threadHead.author}`} profiles={profiles}