From de39edcd4bb8f6467b5dba6376ce6ee2f7515a12 Mon Sep 17 00:00:00 2001 From: Nawazish Khan <175596916+nawazish2@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:06:39 +0530 Subject: [PATCH] feat(desktop): drag to raise the message composer height cap The editor stopped at max-h-32, so long drafts were written through a six-line window. Add a top-edge drag handle that raises the cap (up to 60% of the pane), persists the choice on the device, and double-click resets to the default 128px. Signed-off-by: Nawazish Khan <175596916+nawazish2@users.noreply.github.com> --- .../features/messages/ui/MessageComposer.tsx | 94 ++++++++++++++++++- .../messages/ui/composerMaxHeight.test.mjs | 32 +++++++ .../features/messages/ui/composerMaxHeight.ts | 49 ++++++++++ 3 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 desktop/src/features/messages/ui/composerMaxHeight.test.mjs create mode 100644 desktop/src/features/messages/ui/composerMaxHeight.ts diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 9e1fee53d31..e70ca038622 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -58,6 +58,13 @@ import { useComposerContentState } from "./useComposerContentState"; import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot"; import { submitMessageEdit } from "./submitMessageEdit"; import type { MessageComposerProps } from "./MessageComposer.types"; +import { + clampComposerMaxHeight, + DEFAULT_COMPOSER_MAX_HEIGHT_PX, + readStoredComposerMaxHeight, + writeStoredComposerMaxHeight, +} from "./composerMaxHeight"; + function MessageComposerImpl({ audienceContext = null, channelId = null, @@ -100,11 +107,77 @@ function MessageComposerImpl({ } = useComposerContentState(); const [isEmojiPickerOpen, setIsEmojiPickerOpen] = React.useState(false); const [isFormattingOpen, setIsFormattingOpen] = React.useState(false); + const [composerMaxHeightPx, setComposerMaxHeightPx] = React.useState( + readStoredComposerMaxHeight, + ); + const formShellRef = React.useRef(null); + const resizeDragRef = React.useRef<{ + pointerId: number; + startY: number; + startHeight: number; + } | null>(null); const [spoileredAttachmentUrls, setSpoileredAttachmentUrls] = React.useState< Set >(() => new Set()); const spoileredAttachmentUrlsRef = React.useRef(spoileredAttachmentUrls); spoileredAttachmentUrlsRef.current = spoileredAttachmentUrls; + + const applyComposerMaxHeight = React.useCallback((next: number) => { + // Prefer the channel column (footer's flex parent) over the viewport so + // split-thread layouts don't let the composer claim most of the window. + const form = formShellRef.current; + const pane = + form?.parentElement?.parentElement ?? form?.parentElement ?? null; + const paneHeightPx = pane?.clientHeight ?? globalThis.innerHeight ?? 800; + const clamped = clampComposerMaxHeight(next, paneHeightPx); + setComposerMaxHeightPx(clamped); + writeStoredComposerMaxHeight(clamped); + return clamped; + }, []); + + const handleResizePointerDown = React.useCallback( + (event: React.PointerEvent) => { + if (event.button !== 0) return; + event.preventDefault(); + const target = event.currentTarget; + target.setPointerCapture(event.pointerId); + resizeDragRef.current = { + pointerId: event.pointerId, + startY: event.clientY, + startHeight: composerMaxHeightPx, + }; + }, + [composerMaxHeightPx], + ); + + const handleResizePointerMove = React.useCallback( + (event: React.PointerEvent) => { + const drag = resizeDragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + // Dragging the top edge upward increases the max height. + const delta = drag.startY - event.clientY; + applyComposerMaxHeight(drag.startHeight + delta); + }, + [applyComposerMaxHeight], + ); + + const handleResizePointerUp = React.useCallback( + (event: React.PointerEvent) => { + const drag = resizeDragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + resizeDragRef.current = null; + try { + event.currentTarget.releasePointerCapture(event.pointerId); + } catch { + // Capture may already be released. + } + }, + [], + ); + + const handleResizeDoubleClick = React.useCallback(() => { + applyComposerMaxHeight(DEFAULT_COMPOSER_MAX_HEIGHT_PX); + }, [applyComposerMaxHeight]); const handleFormattingToggle = React.useCallback((pressed: boolean) => { if (pressed) setIsEmojiPickerOpen(false); setIsFormattingOpen(pressed); @@ -889,6 +962,7 @@ function MessageComposerImpl({ /> ) : null}
+ {/* Drag handle: raise the max height above the default 128px cap. */} + {ownsDropZone && media.isDragOver && } diff --git a/desktop/src/features/messages/ui/composerMaxHeight.test.mjs b/desktop/src/features/messages/ui/composerMaxHeight.test.mjs new file mode 100644 index 00000000000..1814ec974b5 --- /dev/null +++ b/desktop/src/features/messages/ui/composerMaxHeight.test.mjs @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + clampComposerMaxHeight, + DEFAULT_COMPOSER_MAX_HEIGHT_PX, +} from "./composerMaxHeight.ts"; + +describe("clampComposerMaxHeight", () => { + it("never goes below the default 128px cap", () => { + assert.equal( + clampComposerMaxHeight(40, 800), + DEFAULT_COMPOSER_MAX_HEIGHT_PX, + ); + assert.equal( + clampComposerMaxHeight(DEFAULT_COMPOSER_MAX_HEIGHT_PX, 800), + DEFAULT_COMPOSER_MAX_HEIGHT_PX, + ); + }); + + it("allows raising the cap up to 60% of the pane", () => { + assert.equal(clampComposerMaxHeight(300, 800), 300); + assert.equal(clampComposerMaxHeight(900, 800), 480); // 0.6 * 800 + }); + + it("uses the default as the upper bound when the pane is tiny", () => { + assert.equal( + clampComposerMaxHeight(400, 100), + DEFAULT_COMPOSER_MAX_HEIGHT_PX, + ); + }); +}); diff --git a/desktop/src/features/messages/ui/composerMaxHeight.ts b/desktop/src/features/messages/ui/composerMaxHeight.ts new file mode 100644 index 00000000000..aef6501a519 --- /dev/null +++ b/desktop/src/features/messages/ui/composerMaxHeight.ts @@ -0,0 +1,49 @@ +/** Matches the previous `max-h-32` hard cap (128px). */ +export const DEFAULT_COMPOSER_MAX_HEIGHT_PX = 128; + +/** localStorage key — device preference, not per-channel. */ +export const COMPOSER_MAX_HEIGHT_STORAGE_KEY = "buzz.composer.maxHeightPx"; + +/** + * Drag-up raises the cap; never go below the default, and never above 60% of + * the channel pane so the timeline stays usable. + */ +export function clampComposerMaxHeight( + heightPx: number, + paneHeightPx: number, +): number { + const upper = Math.max( + DEFAULT_COMPOSER_MAX_HEIGHT_PX, + Math.floor(paneHeightPx * 0.6), + ); + return Math.min( + upper, + Math.max(DEFAULT_COMPOSER_MAX_HEIGHT_PX, Math.round(heightPx)), + ); +} + +export function readStoredComposerMaxHeight(): number { + try { + const raw = globalThis.localStorage?.getItem( + COMPOSER_MAX_HEIGHT_STORAGE_KEY, + ); + if (raw == null) return DEFAULT_COMPOSER_MAX_HEIGHT_PX; + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed)) return DEFAULT_COMPOSER_MAX_HEIGHT_PX; + // Pane height unknown at cold start — only enforce the floor. + return Math.max(DEFAULT_COMPOSER_MAX_HEIGHT_PX, parsed); + } catch { + return DEFAULT_COMPOSER_MAX_HEIGHT_PX; + } +} + +export function writeStoredComposerMaxHeight(heightPx: number): void { + try { + globalThis.localStorage?.setItem( + COMPOSER_MAX_HEIGHT_STORAGE_KEY, + String(Math.round(heightPx)), + ); + } catch { + // Best-effort persistence. + } +}