feat(app): add quick reply and edit keybinds (#3032)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-29 15:50:56 +02:00
1 parent 364084c819
commit 9908518f5b
43 files changed
+3475 -2972

No files matched your search

@@ -40,7 +40,7 @@ import {
createChannelStream,
getCollapsedMessageGroupKey,
} from '@app/features/messaging/utils/MessageGroupingUtils';
import {getMessageSelector} from '@app/features/messaging/utils/MessageNodeSelectors';
import {findMessageElement, getMessageSelector} from '@app/features/messaging/utils/MessageNodeSelectors';
import LocalUserSpamOverride from '@app/features/moderation/state/LocalUserSpamOverride';
import SelectedChannel from '@app/features/navigation/state/SelectedChannel';
import Permission from '@app/features/permissions/state/Permission';
@@ -281,6 +281,25 @@ export const Messages = observer(function Messages({
});
lastStateSnapshotRef.current = snapshot;
}, [channel.id, state]);
const revealMessageNode = useCallback(
(targetNode: HTMLElement) => {
const scrollerNode = scrollManager.ref.current?.getViewportElement();
if (!scrollerNode) return;
const targetRect = targetNode.getBoundingClientRect();
const scrollerRect = scrollerNode.getBoundingClientRect();
const isAbove = targetRect.top < scrollerRect.top;
const isBelow = targetRect.bottom > scrollerRect.bottom;
if (isAbove || isBelow) {
scrollManager.ref.current?.revealElement({
node: targetNode,
padding: 80,
animate: false,
});
scrollManager.scrollHandle();
}
},
[scrollManager],
);
const onMessageEdit = useCallback(
(targetNode: HTMLElement) => {
const scrollerNode = scrollManager.ref.current?.getViewportElement();
@@ -296,20 +315,9 @@ export const Messages = observer(function Messages({
return;
}
}
const targetRect = targetNode.getBoundingClientRect();
const scrollerRect = scrollerNode.getBoundingClientRect();
const isAbove = targetRect.top < scrollerRect.top;
const isBelow = targetRect.bottom > scrollerRect.bottom;
if (isAbove || isBelow) {
scrollManager.ref.current?.revealElement({
node: targetNode,
padding: 80,
animate: false,
});
scrollManager.scrollHandle();
}
revealMessageNode(targetNode);
},
[scrollManager, channel.id],
[scrollManager, channel.id, revealMessageNode],
);
const onReveal = useCallback(
(messageId: string | null) => {
@@ -432,6 +440,22 @@ export const Messages = observer(function Messages({
dispatchUnsubs.forEach((u) => u());
};
}, [channel.id, updateFromState, onScrollToPresent, onMessageSent, onEscapePressed, scrollManager]);
useEffect(() => {
return ComponentBus.subscribe('MESSAGE_REVEAL', (payload?: unknown) => {
const data = (payload ?? {}) as {channelId?: string; messageId?: string};
if (data.channelId !== channel.id || !data.messageId) return;
const messageId = data.messageId;
window.requestAnimationFrame(() => {
const node = findMessageElement(
document,
scrollManager.ref.current?.getViewportElement(),
channel.id,
messageId,
);
if (node) revealMessageNode(node);
});
});
}, [channel.id, scrollManager, revealMessageNode]);
useEffect(() => {
const editingMessageId = state.editingMessageId;
if (editingMessageId) {
@@ -17,6 +17,9 @@ import * as ReactionCommands from '@app/features/messaging/commands/ReactionComm
import * as SavedMessageCommands from '@app/features/messaging/commands/SavedMessageCommands';
import {ForwardModal, type ForwardModalSuccess} from '@app/features/messaging/components/modals/ForwardModal';
import type {Message} from '@app/features/messaging/models/MessagingMessage';
import MessageEdit from '@app/features/messaging/state/MessageEdit';
import MessageReply from '@app/features/messaging/state/MessageReply';
import Messages from '@app/features/messaging/state/MessagingMessages';
import SavedMessages from '@app/features/messaging/state/SavedMessages';
import {buildMessageJumpLink} from '@app/features/messaging/utils/MessageLinkUtils';
import {retryFailedMessage} from '@app/features/messaging/utils/MessageRetryUtils';
@@ -507,6 +510,67 @@ export function requestMessageReply(message: Message, options?: RequestMessageRe
startReply(shouldMention);
}
type MessageStepDirection = -1 | 1;
function findAdjacentMessage(
channelId: string,
currentMessageId: string | null,
direction: MessageStepDirection,
predicate: (message: Message) => boolean,
): Message | null {
const candidates = Messages.getMessages(channelId).toArray().filter(predicate);
const index = currentMessageId ? candidates.findIndex((message) => message.id === currentMessageId) : -1;
if (index === -1) return candidates[candidates.length - 1] ?? null;
if (direction < 0) return candidates[Math.max(index - 1, 0)];
return candidates[index + 1] ?? null;
}
function isReplyCandidate(message: Message): boolean {
return (
message.state === MessageStates.SENT &&
message.isUserMessage() &&
!isClientSystemMessage(message) &&
!Relationships.isBlocked(message.author.id)
);
}
function isEditCandidate(message: Message): boolean {
return (
message.state === MessageStates.SENT &&
message.isUserMessage() &&
!isClientSystemMessage(message) &&
message.isCurrentUserAuthor() &&
!message.messageSnapshots
);
}
export function requestAdjacentMessageReply(channelId: string, direction: MessageStepDirection): void {
const current = MessageReply.getReplyingMessage(channelId)?.messageId ?? null;
const message = findAdjacentMessage(channelId, current, direction, isReplyCandidate);
if (!message) {
MessageCommands.stopReply(channelId);
return;
}
if (!getMessagePermissions(message)?.canSendMessages) return;
requestMessageReply(message);
ComponentBus.dispatch('MESSAGE_REVEAL', {channelId, messageId: message.id});
}
export function startAdjacentMessageEdit(channelId: string, direction: MessageStepDirection): void {
const current = MessageEdit.getEditingMessageId(channelId);
const message = findAdjacentMessage(channelId, current, direction, isEditCandidate);
if (!message) {
if (current) {
MessageCommands.stopEdit(channelId);
ComponentBus.dispatch('FOCUS_TEXTAREA', {channelId});
}
return;
}
if (!getMessagePermissions(message)?.canEditMessage) return;
MessageCommands.startEdit(channelId, message.id, message.content);
ComponentBus.dispatch('MESSAGE_REVEAL', {channelId, messageId: message.id});
}
interface RequestMessageForwardOptions {
mediaSelection?: MessageCommands.ForwardMediaSelection;
onForwardSuccess?: (result: ForwardModalSuccess) => void;