mirror of
https://github.com/block/buzz.git
synced 2026-11-08 10:21:46 +00:00
feat(desktop): edit last message with arrow up
Let an empty composer enter inline edit mode for the current user's latest message when ArrowUp is pressed. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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={
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<void>;
|
||||
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 ? (
|
||||
<div
|
||||
className="mb-3 flex items-start justify-between gap-3 rounded-2xl border border-primary/30 bg-primary/5 px-3 py-2"
|
||||
data-testid="edit-target"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Editing message
|
||||
</p>
|
||||
<p className="truncate text-sm text-foreground/80">
|
||||
{editTarget.body}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className="shrink-0"
|
||||
onClick={onCancelEdit}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
{editTarget && !isInlineEditPreview ? (
|
||||
<MessageComposerEditTarget
|
||||
body={editTarget.body}
|
||||
onCancelEdit={onCancelEdit}
|
||||
/>
|
||||
) : replyTarget ? (
|
||||
<div
|
||||
className="mb-3 flex items-start justify-between gap-3 rounded-2xl border border-border/70 bg-muted/40 px-3 py-2"
|
||||
data-testid="reply-target"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Replying to {replyTarget.author}
|
||||
</p>
|
||||
<p className="truncate text-sm text-foreground/80">
|
||||
{replyTarget.body}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
aria-label="Cancel reply"
|
||||
className="h-7 w-7 shrink-0 px-0"
|
||||
onClick={onCancelReply}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<MessageComposerReplyTarget
|
||||
author={replyTarget.author}
|
||||
body={replyTarget.body}
|
||||
onCancelReply={onCancelReply}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{media.uploadState.status === "error" ? (
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className="mb-3 flex items-start justify-between gap-3 rounded-2xl border border-primary/30 bg-primary/5 px-3 py-2"
|
||||
data-testid="edit-target"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Editing message
|
||||
</p>
|
||||
<p className="truncate text-sm text-foreground/80">{body}</p>
|
||||
</div>
|
||||
<Button
|
||||
aria-label="Cancel edit"
|
||||
className="h-7 w-7 shrink-0 px-0"
|
||||
onClick={onCancelEdit}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
className="mb-3 flex items-start justify-between gap-3 rounded-2xl border border-border/70 bg-muted/40 px-3 py-2"
|
||||
data-testid="reply-target"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Replying to {author}
|
||||
</p>
|
||||
<p className="truncate text-sm text-foreground/80">{body}</p>
|
||||
</div>
|
||||
<Button
|
||||
aria-label="Cancel reply"
|
||||
className="h-7 w-7 shrink-0 px-0"
|
||||
onClick={onCancelReply}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user