Skip to content

Commit 3a3501c

Browse files
fix(desktop): autofocus message composer on channel/thread open (#572)
Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
1 parent 1d8a130 commit 3a3501c

4 files changed

Lines changed: 170 additions & 1 deletion

File tree

desktop/scripts/check-file-sizes.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ const overrides = new Map([
4040
["src/features/channels/ui/ChannelScreen.tsx", 550], // profile panel state + mutual exclusion wiring + ProfilePanelProvider context + agent typing classification
4141
["src/features/notifications/hooks.ts", 535], // notification settings + feed notification lifecycle + profile batch resolution + truncated-pubkey guard + badge state
4242
["src/features/messages/hooks.ts", 500], // message query/mutation hooks + optimistic updates
43-
["src/features/messages/ui/MessageComposer.tsx", 700], // media upload handlers (paste, drop, dialog) + channelId reset effect + edit mode (pre-fill, save, cancel, escape)
43+
["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
4444
["src/features/settings/ui/SettingsView.tsx", 600],
4545
["src/features/sidebar/ui/AppSidebar.tsx", 860], // channels + forums creation forms + Pulse nav
4646
["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)
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import * as React from "react";
2+
3+
/**
4+
* Focus the composer editor on mount and whenever the active draft key
5+
* changes (channel switch, thread open).
6+
*
7+
* Matches the behaviour of Slack/Discord/Signal: the composer is ready to
8+
* accept typing without an explicit click. The `focus` callback is expected
9+
* to no-op until the underlying editor is mounted, and to change identity
10+
* once that happens — so listing it as a dep recovers from the
11+
* editor-not-ready-yet case on first render.
12+
*
13+
* The effect trigger deliberately excludes `disabled`: callers pass a
14+
* disabled flag that includes transient state like `isSending`, which would
15+
* otherwise re-fire autofocus after every send. When the main channel and
16+
* an open thread panel both have composers mounted, that race let the main
17+
* composer steal focus from the thread composer post-send. We only autofocus
18+
* on mount and on real navigation events (draft-key change).
19+
*
20+
* Guards:
21+
* - Skip if the composer is currently disabled (archived channel, no
22+
* channel, or in-flight send at the moment of mount).
23+
* - Skip if focus already lives in another text-entry surface (open
24+
* dialog input, search box, etc.) so we don't yank focus from the user.
25+
*/
26+
export function useComposerAutofocus(
27+
focus: () => void,
28+
draftKey: string | null | undefined,
29+
disabled: boolean,
30+
) {
31+
// We read `disabled` at execution time but intentionally don't depend on
32+
// it — see the comment above.
33+
const disabledRef = React.useRef(disabled);
34+
disabledRef.current = disabled;
35+
36+
// biome-ignore lint/correctness/useExhaustiveDependencies: draftKey is the trigger; disabled is read via ref
37+
React.useEffect(() => {
38+
if (disabledRef.current) return;
39+
if (typeof document === "undefined") return;
40+
const active = document.activeElement as HTMLElement | null;
41+
if (active && active !== document.body) {
42+
const tag = active.tagName;
43+
if (
44+
tag === "INPUT" ||
45+
tag === "TEXTAREA" ||
46+
tag === "SELECT" ||
47+
active.isContentEditable
48+
) {
49+
return;
50+
}
51+
}
52+
focus();
53+
}, [draftKey, focus]);
54+
}

desktop/src/features/messages/ui/MessageComposer.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import * as React from "react";
33
import { EditorContent } from "@tiptap/react";
44
import { X } from "lucide-react";
55
import { useChannelLinks } from "@/features/messages/lib/useChannelLinks";
6+
import { useComposerAutofocus } from "@/features/messages/lib/useComposerAutofocus";
67
import type { ChannelSuggestion } from "@/features/messages/lib/useChannelLinks";
78
import { useDrafts } from "@/features/messages/lib/useDrafts";
89
import { useEmojiAutocomplete } from "@/features/messages/lib/useEmojiAutocomplete";
@@ -222,6 +223,9 @@ export function MessageComposer({
222223
richText.focus();
223224
}, [disabled, replyTarget, richText.focus]);
224225

226+
// ── Autofocus on mount / channel switch ─────────────────────────────
227+
useComposerAutofocus(richText.focus, effectiveDraftKey, disabled);
228+
225229
// ── Mention / channel autocomplete insertion ────────────────────────
226230
const applyMentionInsert = React.useCallback(
227231
(suggestion: MentionSuggestion) => {

desktop/tests/e2e/messaging.spec.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,3 +522,114 @@ test("thread panel width uses session storage and reset handle", async ({
522522
})
523523
.toBe(defaultWidthPx);
524524
});
525+
526+
test("composer is focused after selecting a channel", async ({ page }) => {
527+
await page.goto("/");
528+
await page.getByTestId("channel-general").click();
529+
await expect(page.getByTestId("chat-title")).toHaveText("general");
530+
531+
// Without clicking the input, typing should land in the composer.
532+
const input = page.getByTestId("message-input");
533+
await expect(input).toBeFocused();
534+
535+
await page.keyboard.type("autofocus-on-channel-select");
536+
await expect(input).toHaveText("autofocus-on-channel-select");
537+
});
538+
539+
test("composer is focused after switching to a different channel", async ({
540+
page,
541+
}) => {
542+
await page.goto("/");
543+
await page.getByTestId("channel-general").click();
544+
await expect(page.getByTestId("chat-title")).toHaveText("general");
545+
546+
await page.getByTestId("channel-random").click();
547+
await expect(page.getByTestId("chat-title")).toHaveText("random");
548+
549+
const input = page.getByTestId("message-input");
550+
await expect(input).toBeFocused();
551+
});
552+
553+
test("thread composer is focused after clicking the reply icon", async ({
554+
page,
555+
}) => {
556+
await page.goto("/");
557+
await page.getByTestId("channel-general").click();
558+
await expect(page.getByTestId("chat-title")).toHaveText("general");
559+
560+
// Seed a message to reply to.
561+
const seed = `Thread autofocus seed ${Date.now()}`;
562+
const mainInput = page.getByTestId("message-input");
563+
await mainInput.fill(seed);
564+
await page.getByTestId("send-message").click();
565+
await expect(page.getByTestId("message-timeline")).toContainText(seed);
566+
567+
const rootMessage = page
568+
.getByTestId("message-timeline")
569+
.getByTestId("message-row")
570+
.last();
571+
await rootMessage.hover();
572+
await rootMessage.getByRole("button", { name: "Reply" }).click();
573+
574+
const threadPanel = page.getByTestId("message-thread-panel");
575+
await expect(threadPanel).toBeVisible();
576+
577+
const threadInput = threadPanel.getByTestId("message-input");
578+
await expect(threadInput).toBeFocused();
579+
580+
await page.keyboard.type("typed-into-thread");
581+
await expect(threadInput).toHaveText("typed-into-thread");
582+
});
583+
584+
test("thread composer keeps focus after sending a thread reply", async ({
585+
page,
586+
}) => {
587+
await page.goto("/");
588+
await page.getByTestId("channel-general").click();
589+
await expect(page.getByTestId("chat-title")).toHaveText("general");
590+
591+
// Seed a root message we can open a thread on. At this point only one
592+
// composer is mounted, so plain getByTestId is unambiguous.
593+
const seed = `Thread focus-after-send seed ${Date.now()}`;
594+
await page.getByTestId("message-input").fill(seed);
595+
await page.getByTestId("send-message").click();
596+
await expect(page.getByTestId("message-timeline")).toContainText(seed);
597+
598+
const rootMessage = page
599+
.getByTestId("message-timeline")
600+
.getByTestId("message-row")
601+
.last();
602+
await rootMessage.hover();
603+
await rootMessage.getByRole("button", { name: "Reply" }).click();
604+
605+
const threadPanel = page.getByTestId("message-thread-panel");
606+
await expect(threadPanel).toBeVisible();
607+
608+
const threadInput = threadPanel.getByTestId("message-input");
609+
await expect(threadInput).toBeFocused();
610+
611+
// Send a thread reply. After the send, `isSending` flips and back to false
612+
// in both the main and thread composers; the thread input must keep focus.
613+
const reply = `Thread reply ${Date.now()}`;
614+
await page.keyboard.type(reply);
615+
await expect(threadInput).toHaveText(reply);
616+
await page.keyboard.press("Enter");
617+
618+
// Wait for the send to settle.
619+
await expect(threadPanel).toContainText(reply);
620+
621+
// The thread input should still be focused — not the main composer.
622+
// Both composers expose the same `message-input` data-testid, so we
623+
// verify directly that `document.activeElement` lives inside the thread
624+
// panel rather than the main pane.
625+
const focusInThreadPanel = await page.evaluate(() => {
626+
const panel = document.querySelector<HTMLElement>(
627+
'[data-testid="message-thread-panel"]',
628+
);
629+
const active = document.activeElement as HTMLElement | null;
630+
return Boolean(panel && active && panel.contains(active));
631+
});
632+
expect(focusInThreadPanel).toBe(true);
633+
634+
await expect(threadInput).toBeFocused();
635+
});

0 commit comments

Comments
 (0)