Add autofocus to text boxes

This commit is contained in:
Jack Kingsman
2026-04-06 21:59:46 -07:00
parent eeaa11b8b0
commit ca7349a1a8
6 changed files with 178 additions and 108 deletions

View File

@@ -25,7 +25,8 @@ import { DistanceUnitProvider } from './contexts/DistanceUnitContext';
import { messageContainsMention } from './utils/messageParser';
import { getStateKey } from './utils/conversationState';
import type { BulkCreateHashtagChannelsResult, Conversation, Message, RawPacket } from './types';
import { CONTACT_TYPE_ROOM } from './types';
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from './types';
import { shouldAutoFocusInput } from './utils/autoFocusInput';
interface ChannelUnreadMarker {
channelId: string;
@@ -296,6 +297,21 @@ export function App() {
} = useConversationMessages(activeConversation, targetMessageId);
removeConversationMessagesRef.current = removeConversationMessages;
// Auto-focus the message input on conversation change (desktop only by default)
useEffect(() => {
if (!activeConversation) return;
if (activeConversation.type !== 'channel' && activeConversation.type !== 'contact') return;
// Repeaters show a login form, not a message input
if (activeConversation.type === 'contact') {
const contact = contacts.find((c) => c.public_key === activeConversation.id);
if (contact?.type === CONTACT_TYPE_REPEATER) return;
}
if (!shouldAutoFocusInput()) return;
// Defer to let the input mount/render first
const raf = requestAnimationFrame(() => messageInputRef.current?.focus?.());
return () => cancelAnimationFrame(raf);
}, [activeConversation?.id]); // eslint-disable-line react-hooks/exhaustive-deps
// Room servers replay stored history as a burst of DMs, all arriving with similar received_at
// but spanning a wide range of sender_timestamps. Sort by sender_timestamp for room contacts
// so the display reflects the original send order rather than our radio's receipt order.

View File

@@ -44,6 +44,7 @@ type LimitState = 'normal' | 'warning' | 'danger' | 'error';
export interface MessageInputHandle {
appendText: (text: string) => void;
focus: () => void;
}
export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(function MessageInput(
@@ -60,6 +61,9 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
// Focus the input after appending
inputRef.current?.focus();
},
focus: () => {
inputRef.current?.focus();
},
}));
// Calculate character limits based on conversation type

View File

@@ -2,6 +2,7 @@ import { useCallback, type FormEvent } from 'react';
import { Input } from './ui/input';
import { Button } from './ui/button';
import { Checkbox } from './ui/checkbox';
import { shouldAutoFocusInput } from '../utils/autoFocusInput';
interface RepeaterLoginProps {
repeaterName: string;
@@ -64,7 +65,7 @@ export function RepeaterLogin({
placeholder={passwordPlaceholder}
aria-label="Repeater password"
disabled={loading}
autoFocus
autoFocus={shouldAutoFocusInput()}
/>
<label

View File

@@ -27,6 +27,7 @@ import {
getSavedFontScale,
setSavedFontScale,
} from '../../utils/fontScale';
import { getAutoFocusInputEnabled, setAutoFocusInputEnabled } from '../../utils/autoFocusInput';
export function SettingsLocalSection({
onLocalLabelChange,
@@ -48,6 +49,7 @@ export function SettingsLocalSection({
});
const [localLabelText, setLocalLabelText] = useState(() => getLocalLabel().text);
const [localLabelColor, setLocalLabelColor] = useState(() => getLocalLabel().color);
const [autoFocusInput, setAutoFocusInput] = useState(getAutoFocusInputEnabled);
const [fontScale, setFontScale] = useState(getSavedFontScale);
const [fontScaleSlider, setFontScaleSlider] = useState(getSavedFontScale);
const [fontScaleInput, setFontScaleInput] = useState(() => String(getSavedFontScale()));
@@ -129,85 +131,6 @@ export function SettingsLocalSection({
<Separator />
<div className="space-y-3">
<Label htmlFor="font-scale-input">Relative Font Size</Label>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<input
type="range"
min={MIN_FONT_SCALE}
max={MAX_FONT_SCALE}
step={FONT_SCALE_SLIDER_STEP}
value={fontScaleSlider}
onChange={(event) => handleSliderChange(Number(event.target.value))}
onMouseUp={(event) => handleSliderCommit(Number(event.currentTarget.value))}
onTouchEnd={(event) => handleSliderCommit(Number(event.currentTarget.value))}
onKeyUp={(event) => handleSliderCommit(Number(event.currentTarget.value))}
onBlur={(event) => handleSliderCommit(Number(event.currentTarget.value))}
aria-label="Relative font size slider"
className="w-full accent-primary sm:flex-1"
/>
<div className="flex items-center gap-2 sm:w-40">
<Input
id="font-scale-input"
type="number"
inputMode="decimal"
min={MIN_FONT_SCALE}
max={MAX_FONT_SCALE}
step="any"
value={fontScaleInput}
onChange={(event) => {
const nextValue = event.target.value;
setFontScaleInput(nextValue);
if (nextValue === '') {
return;
}
if (event.target.validity.valid && Number.isFinite(event.target.valueAsNumber)) {
commitFontScale(event.target.valueAsNumber);
}
}}
onBlur={() => {
const parsed = Number.parseFloat(fontScaleInput);
if (!Number.isFinite(parsed)) {
restoreFontScaleInput();
return;
}
commitFontScale(parsed);
}}
onKeyDown={(event) => {
if (event.key !== 'Enter') {
return;
}
event.preventDefault();
const parsed = Number.parseFloat(fontScaleInput);
if (!Number.isFinite(parsed)) {
restoreFontScaleInput();
return;
}
commitFontScale(parsed);
}}
aria-label="Relative font size percentage"
/>
<span className="text-sm text-muted-foreground">%</span>
</div>
<button
type="button"
onClick={() => commitFontScale(DEFAULT_FONT_SCALE)}
className="inline-flex h-9 items-center justify-center rounded-md border border-input px-3 text-sm font-medium transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
disabled={fontScale === DEFAULT_FONT_SCALE}
>
Reset
</button>
</div>
<p className="text-xs text-muted-foreground">
Scales the app&apos;s typography for this browser only. The slider moves in 5% steps; the
number field accepts any value from 25% to 400%.
</p>
</div>
<Separator />
<div className="space-y-3">
<Label htmlFor="distance-units">Distance Units</Label>
<select
@@ -233,33 +156,128 @@ export function SettingsLocalSection({
<Separator />
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={reopenLastConversation}
onChange={(e) => handleToggleReopenLastConversation(e.target.checked)}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-sm">Reopen to last viewed channel/conversation</span>
</label>
<div className="space-y-3">
<Label>UI Tweaks</Label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={darkMap}
onChange={(e) => {
const v = e.target.checked;
setDarkMap(v);
try {
localStorage.setItem('remoteterm-dark-map', String(v));
} catch {
// localStorage may be disabled
}
}}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-sm">Dark mode map tiles</span>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={reopenLastConversation}
onChange={(e) => handleToggleReopenLastConversation(e.target.checked)}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-sm">Reopen to last viewed channel/conversation</span>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={darkMap}
onChange={(e) => {
const v = e.target.checked;
setDarkMap(v);
try {
localStorage.setItem('remoteterm-dark-map', String(v));
} catch {
// localStorage may be disabled
}
}}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-sm">Dark mode map tiles</span>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={autoFocusInput}
onChange={(e) => {
const v = e.target.checked;
setAutoFocusInput(v);
setAutoFocusInputEnabled(v);
}}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-sm">Auto-focus input on conversation load (desktop only)</span>
</label>
<div className="space-y-3">
<Label htmlFor="font-scale-input">Relative Font Size</Label>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<input
type="range"
min={MIN_FONT_SCALE}
max={MAX_FONT_SCALE}
step={FONT_SCALE_SLIDER_STEP}
value={fontScaleSlider}
onChange={(event) => handleSliderChange(Number(event.target.value))}
onMouseUp={(event) => handleSliderCommit(Number(event.currentTarget.value))}
onTouchEnd={(event) => handleSliderCommit(Number(event.currentTarget.value))}
onKeyUp={(event) => handleSliderCommit(Number(event.currentTarget.value))}
onBlur={(event) => handleSliderCommit(Number(event.currentTarget.value))}
aria-label="Relative font size slider"
className="w-full accent-primary sm:flex-1"
/>
<div className="flex items-center gap-2 sm:w-40">
<Input
id="font-scale-input"
type="number"
inputMode="decimal"
min={MIN_FONT_SCALE}
max={MAX_FONT_SCALE}
step="any"
value={fontScaleInput}
onChange={(event) => {
const nextValue = event.target.value;
setFontScaleInput(nextValue);
if (nextValue === '') {
return;
}
if (event.target.validity.valid && Number.isFinite(event.target.valueAsNumber)) {
commitFontScale(event.target.valueAsNumber);
}
}}
onBlur={() => {
const parsed = Number.parseFloat(fontScaleInput);
if (!Number.isFinite(parsed)) {
restoreFontScaleInput();
return;
}
commitFontScale(parsed);
}}
onKeyDown={(event) => {
if (event.key !== 'Enter') {
return;
}
event.preventDefault();
const parsed = Number.parseFloat(fontScaleInput);
if (!Number.isFinite(parsed)) {
restoreFontScaleInput();
return;
}
commitFontScale(parsed);
}}
aria-label="Relative font size percentage"
/>
<span className="text-sm text-muted-foreground">%</span>
</div>
<button
type="button"
onClick={() => commitFontScale(DEFAULT_FONT_SCALE)}
className="inline-flex h-9 items-center justify-center rounded-md border border-input px-3 text-sm font-medium transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
disabled={fontScale === DEFAULT_FONT_SCALE}
>
Reset
</button>
</div>
<p className="text-xs text-muted-foreground">
Scales the app&apos;s typography for this browser only. The slider moves in 5% steps;
the number field accepts any value from 25% to 400%.
</p>
</div>
</div>
</div>
);
}

View File

@@ -65,7 +65,7 @@ function createArgs(overrides: Partial<Parameters<typeof useConversationActions>
setContacts: vi.fn(),
setChannels: vi.fn(),
observeMessage: vi.fn(() => ({ added: true, activeConversation: true })),
messageInputRef: { current: { appendText: vi.fn() } },
messageInputRef: { current: { appendText: vi.fn(), focus: vi.fn() } },
...overrides,
};
}

View File

@@ -0,0 +1,31 @@
const KEY = 'remoteterm-auto-focus-input';
export function getAutoFocusInputEnabled(): boolean {
try {
const raw = localStorage.getItem(KEY);
return raw === null || raw !== 'false';
} catch {
return true;
}
}
export function setAutoFocusInputEnabled(enabled: boolean): void {
try {
if (enabled) {
localStorage.removeItem(KEY);
} else {
localStorage.setItem(KEY, 'false');
}
} catch {
// localStorage may be unavailable
}
}
/**
* Returns true when auto-focus should fire: the setting is enabled
* AND the viewport is wide enough that focusing won't summon a
* mobile keyboard (matches the md: Tailwind breakpoint).
*/
export function shouldAutoFocusInput(): boolean {
return getAutoFocusInputEnabled() && window.innerWidth >= 768;
}