Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 93 additions & 1 deletion desktop/src/features/messages/ui/MessageComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<HTMLFormElement | null>(null);
const resizeDragRef = React.useRef<{
pointerId: number;
startY: number;
startHeight: number;
} | null>(null);
const [spoileredAttachmentUrls, setSpoileredAttachmentUrls] = React.useState<
Set<string>
>(() => 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<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
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);
Expand Down Expand Up @@ -889,6 +962,7 @@ function MessageComposerImpl({
/>
) : null}
<form
ref={formShellRef}
className={cn(
"relative z-10 isolate rounded-2xl border border-border/50 bg-background/80 px-3 pb-2 pt-3 shadow-none supports-[backdrop-filter]:bg-background/70 dark:bg-background/70 dark:supports-[backdrop-filter]:bg-background/55 sm:px-4",
layoutMode === "standalone" &&
Expand All @@ -913,6 +987,23 @@ function MessageComposerImpl({
handleSubmit(event);
}}
>
{/* Drag handle: raise the max height above the default 128px cap. */}
<button
type="button"
aria-label="Resize message composer. Drag up to grow, double-click to reset."
data-testid="composer-resize-handle"
className="absolute inset-x-0 top-0 z-20 flex h-3 -translate-y-1/2 cursor-ns-resize items-center justify-center touch-none border-0 bg-transparent p-0"
onPointerDown={handleResizePointerDown}
onPointerMove={handleResizePointerMove}
onPointerUp={handleResizePointerUp}
onPointerCancel={handleResizePointerUp}
onDoubleClick={handleResizeDoubleClick}
>
<span
aria-hidden
className="h-1 w-10 rounded-full bg-border/80 transition-colors hover:bg-muted-foreground/50"
/>
</button>
{ownsDropZone && media.isDragOver && <DropZoneOverlay />}
<EmojiAutocomplete
onSelect={applyEmojiInsert}
Expand Down Expand Up @@ -976,9 +1067,10 @@ function MessageComposerImpl({

{/* biome-ignore lint/a11y/noStaticElementInteractions: keydown handler bridges Tiptap editor to autocomplete and submit */}
<div
className="rich-text-composer relative max-h-32 overflow-y-auto"
className="rich-text-composer relative overflow-y-auto"
data-testid="message-input-scroll"
ref={composerScrollRef}
style={{ maxHeight: composerMaxHeightPx }}
onKeyDown={handleEditorKeyDown}
>
<EditorContent editor={richText.editor} />
Expand Down
32 changes: 32 additions & 0 deletions desktop/src/features/messages/ui/composerMaxHeight.test.mjs
Original file line number Diff line number Diff line change
@@ -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,
);
});
});
49 changes: 49 additions & 0 deletions desktop/src/features/messages/ui/composerMaxHeight.ts
Original file line number Diff line number Diff line change
@@ -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.
}
}