fix(desktop): autofocus message composer on channel/thread open (#572)

Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
This commit is contained in:
tlongwell-block
2026-05-14 20:35:44 -04:00
committed by GitHub
parent 1d8a130b32
commit 3a3501c77b
4 changed files with 170 additions and 1 deletions
+1 -1
View File
@@ -40,7 +40,7 @@ const overrides = new Map([
["src/features/channels/ui/ChannelScreen.tsx", 550], // profile panel state + mutual exclusion wiring + ProfilePanelProvider context + agent typing classification
["src/features/notifications/hooks.ts", 535], // notification settings + feed notification lifecycle + profile batch resolution + truncated-pubkey guard + badge state
["src/features/messages/hooks.ts", 500], // message query/mutation hooks + optimistic updates
["src/features/messages/ui/MessageComposer.tsx", 700], // media upload handlers (paste, drop, dialog) + channelId reset effect + edit mode (pre-fill, save, cancel, escape)
["src/features/messages/ui/MessageComposer.tsx", 710], // media upload handlers (paste, drop, dialog) + channelId reset effect + edit mode (pre-fill, save, cancel, escape) + autofocus on mount/channel switch
["src/features/settings/ui/SettingsView.tsx", 600],
["src/features/sidebar/ui/AppSidebar.tsx", 860], // channels + forums creation forms + Pulse nav
["src/shared/api/relayClientSession.ts", 930], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore + subscribeToChannelLive (huddle TTS) + subscribeToHuddleEvents (huddle indicator) + disconnect() for workspace switch teardown + fetchEvents/subscribeLive/publishEvent for NIP-RS read state + publishUserStatus/subscribeToUserStatusUpdates (NIP-38)
@@ -0,0 +1,54 @@
import * as React from "react";
/**
* Focus the composer editor on mount and whenever the active draft key
* changes (channel switch, thread open).
*
* Matches the behaviour of Slack/Discord/Signal: the composer is ready to
* accept typing without an explicit click. The `focus` callback is expected
* to no-op until the underlying editor is mounted, and to change identity
* once that happens — so listing it as a dep recovers from the
* editor-not-ready-yet case on first render.
*
* The effect trigger deliberately excludes `disabled`: callers pass a
* disabled flag that includes transient state like `isSending`, which would
* otherwise re-fire autofocus after every send. When the main channel and
* an open thread panel both have composers mounted, that race let the main
* composer steal focus from the thread composer post-send. We only autofocus
* on mount and on real navigation events (draft-key change).
*
* Guards:
* - Skip if the composer is currently disabled (archived channel, no
* channel, or in-flight send at the moment of mount).
* - Skip if focus already lives in another text-entry surface (open
* dialog input, search box, etc.) so we don't yank focus from the user.
*/
export function useComposerAutofocus(
focus: () => void,
draftKey: string | null | undefined,
disabled: boolean,
) {
// We read `disabled` at execution time but intentionally don't depend on
// it — see the comment above.
const disabledRef = React.useRef(disabled);
disabledRef.current = disabled;
// biome-ignore lint/correctness/useExhaustiveDependencies: draftKey is the trigger; disabled is read via ref
React.useEffect(() => {
if (disabledRef.current) return;
if (typeof document === "undefined") return;
const active = document.activeElement as HTMLElement | null;
if (active && active !== document.body) {
const tag = active.tagName;
if (
tag === "INPUT" ||
tag === "TEXTAREA" ||
tag === "SELECT" ||
active.isContentEditable
) {
return;
}
}
focus();
}, [draftKey, focus]);
}
@@ -3,6 +3,7 @@ import * as React from "react";
import { EditorContent } from "@tiptap/react";
import { X } from "lucide-react";
import { useChannelLinks } from "@/features/messages/lib/useChannelLinks";
import { useComposerAutofocus } from "@/features/messages/lib/useComposerAutofocus";
import type { ChannelSuggestion } from "@/features/messages/lib/useChannelLinks";
import { useDrafts } from "@/features/messages/lib/useDrafts";
import { useEmojiAutocomplete } from "@/features/messages/lib/useEmojiAutocomplete";
@@ -222,6 +223,9 @@ export function MessageComposer({
richText.focus();
}, [disabled, replyTarget, richText.focus]);
// ── Autofocus on mount / channel switch ─────────────────────────────
useComposerAutofocus(richText.focus, effectiveDraftKey, disabled);
// ── Mention / channel autocomplete insertion ────────────────────────
const applyMentionInsert = React.useCallback(
(suggestion: MentionSuggestion) => {
+111
View File
@@ -522,3 +522,114 @@ test("thread panel width uses session storage and reset handle", async ({
})
.toBe(defaultWidthPx);
});
test("composer is focused after selecting a channel", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
// Without clicking the input, typing should land in the composer.
const input = page.getByTestId("message-input");
await expect(input).toBeFocused();
await page.keyboard.type("autofocus-on-channel-select");
await expect(input).toHaveText("autofocus-on-channel-select");
});
test("composer is focused after switching to a different channel", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
const input = page.getByTestId("message-input");
await expect(input).toBeFocused();
});
test("thread composer is focused after clicking the reply icon", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
// Seed a message to reply to.
const seed = `Thread autofocus seed ${Date.now()}`;
const mainInput = page.getByTestId("message-input");
await mainInput.fill(seed);
await page.getByTestId("send-message").click();
await expect(page.getByTestId("message-timeline")).toContainText(seed);
const rootMessage = page
.getByTestId("message-timeline")
.getByTestId("message-row")
.last();
await rootMessage.hover();
await rootMessage.getByRole("button", { name: "Reply" }).click();
const threadPanel = page.getByTestId("message-thread-panel");
await expect(threadPanel).toBeVisible();
const threadInput = threadPanel.getByTestId("message-input");
await expect(threadInput).toBeFocused();
await page.keyboard.type("typed-into-thread");
await expect(threadInput).toHaveText("typed-into-thread");
});
test("thread composer keeps focus after sending a thread reply", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
// Seed a root message we can open a thread on. At this point only one
// composer is mounted, so plain getByTestId is unambiguous.
const seed = `Thread focus-after-send seed ${Date.now()}`;
await page.getByTestId("message-input").fill(seed);
await page.getByTestId("send-message").click();
await expect(page.getByTestId("message-timeline")).toContainText(seed);
const rootMessage = page
.getByTestId("message-timeline")
.getByTestId("message-row")
.last();
await rootMessage.hover();
await rootMessage.getByRole("button", { name: "Reply" }).click();
const threadPanel = page.getByTestId("message-thread-panel");
await expect(threadPanel).toBeVisible();
const threadInput = threadPanel.getByTestId("message-input");
await expect(threadInput).toBeFocused();
// Send a thread reply. After the send, `isSending` flips and back to false
// in both the main and thread composers; the thread input must keep focus.
const reply = `Thread reply ${Date.now()}`;
await page.keyboard.type(reply);
await expect(threadInput).toHaveText(reply);
await page.keyboard.press("Enter");
// Wait for the send to settle.
await expect(threadPanel).toContainText(reply);
// The thread input should still be focused — not the main composer.
// Both composers expose the same `message-input` data-testid, so we
// verify directly that `document.activeElement` lives inside the thread
// panel rather than the main pane.
const focusInThreadPanel = await page.evaluate(() => {
const panel = document.querySelector<HTMLElement>(
'[data-testid="message-thread-panel"]',
);
const active = document.activeElement as HTMLElement | null;
return Boolean(panel && active && panel.contains(active));
});
expect(focusInThreadPanel).toBe(true);
await expect(threadInput).toBeFocused();
});