feat(composer): a single pasted/dropped image lands in its caption field (#129)
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s

Verified first: after a paste the focus stayed in the composer and the
caption needed a click. Now, when exactly one image is pasted or dropped
into an empty composer, its upload card's caption input takes focus;
Enter there sends the board (with any composer text) and Escape returns
focus to the composer. Multi-file drops and non-empty composers are
unchanged. The target card is matched by file name + mtime because the
metadata strip re-wraps the File.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-20 15:11:05 -04:00
co-authored by Claude Opus 5
parent 082b8fc879
commit 0c45bde832
2 changed files with 41 additions and 1 deletions
@@ -283,6 +283,12 @@ type UploadCardRendererProps = {
setMetadata: (fileItem: TUploadItem, metadata: TUploadMetadata) => void;
onRemove: (file: TUploadContent) => void;
onComplete?: (upload: UploadSuccess) => void;
/** [Gitea #129] Focus the caption field on mount (single image into an empty composer). */
autoFocusCaption?: boolean;
/** Enter in the caption field: send the board (and any composer text). */
onCaptionSubmit?: () => void;
/** Escape in the caption field: hand focus back to the composer. */
onCaptionEscape?: () => void;
};
export function UploadCardRenderer({
isEncrypted,
@@ -290,6 +296,9 @@ export function UploadCardRenderer({
setMetadata,
onRemove,
onComplete,
autoFocusCaption,
onCaptionSubmit,
onCaptionEscape,
}: UploadCardRendererProps) {
const mx = useMatrixClient();
const mediaConfig = useMediaConfig();
@@ -379,6 +388,16 @@ export function UploadCardRenderer({
size="300"
radii="300"
style={{ marginTop: config.space.S200, width: '100%' }}
autoFocus={autoFocusCaption}
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
onCaptionSubmit?.();
} else if (e.key === 'Escape') {
e.preventDefault();
onCaptionEscape?.();
}
}}
/>
)}
<CompressionCheckbox fileItem={fileItem} metadata={metadata} setMetadata={setMetadata} />
+22 -1
View File
@@ -158,6 +158,13 @@ const EmojiBoard = React.lazy(() =>
);
/** [Gitea #37] Debounce for persisting the composer draft while typing. */
/** [Gitea #129] Same picked file, allowing for re-wrapped File objects. */
const isAutoFocusTarget = (file: TUploadContent, target: File | undefined): boolean =>
!!target &&
file instanceof File &&
file.name === target.name &&
file.lastModified === target.lastModified;
const DRAFT_PERSIST_DEBOUNCE_MS = 500;
interface RoomInputProps {
@@ -253,6 +260,9 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
selectedFiles.map((f) => f.file),
);
const uploadBoardHandlers = useRef<UploadBoardImperativeHandlers | undefined>(undefined);
// [Gitea #129] The file whose caption input should take focus once its card mounts.
// (Matched by name + mtime: the metadata strip and safeFile may re-wrap the File.)
const autoFocusCaptionRef = useRef<File | undefined>(undefined);
const imagePackRooms: Room[] = useImagePackRooms(roomId, roomToParents);
@@ -379,12 +389,17 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const handleFiles = useCallback(
async (files: File[]) => {
setUploadBoard(true);
// [Gitea #129] One image into an empty composer: land in its caption
// field so "here's the bug" is one motion (Enter sends, Esc returns).
const focusCaption =
files.length === 1 && files[0].type.startsWith('image/') && isEmptyEditor(editor);
setSelectedFiles({
type: 'PUT',
item: await filesToUploadItems(room, files, stripImageMetadata),
});
if (focusCaption) autoFocusCaptionRef.current = files[0];
},
[setSelectedFiles, room, stripImageMetadata],
[setSelectedFiles, room, stripImageMetadata, editor],
);
const pickFile = useFilePicker(handleFiles, true);
const handleFilePaste = useFilePasteHandler(handleFiles);
@@ -1037,6 +1052,12 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
fileItem={fileItem}
setMetadata={handleFileMetadata}
onRemove={handleRemoveUpload}
autoFocusCaption={isAutoFocusTarget(
fileItem.originalFile,
autoFocusCaptionRef.current,
)}
onCaptionSubmit={submit}
onCaptionEscape={() => ReactEditor.focus(editor)}
/>
))}
</UploadBoardContent>