mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user