feat: typing indicator orange dots, push-to-deafen hotkey, night light filter, message length counter

- #108: TypingIndicator reads lotusTerminal setting; applies var(--lt-accent-orange)
  to container so dots inherit via backgroundColor:currentColor
- #100: CallControls registers KeyM as push-to-deafen (e.code, e.repeat guard,
  ownerDocument.body iframe-safe editable check, [callEmbed] dep array)
- P5-5: nightLightEnabled/nightLightOpacity settings; position:fixed rgba(255,140,0)
  overlay inside JotaiProvider; Night Light tile + intensity slider (5–80%) in
  Settings → Appearance
- #101: RoomInput charCount state via Slate onChange + toPlainText; resets on
  room switch; displayed before send button when count > 0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 15:36:45 -04:00
parent 26f1e234a2
commit dedbd54199
6 changed files with 130 additions and 15 deletions
@@ -1,5 +1,7 @@
import React from 'react';
import { Box, as, toRem } from 'folds';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import * as css from './TypingIndicator.css';
export type TypingIndicatorProps = {
@@ -8,18 +10,25 @@ export type TypingIndicatorProps = {
};
export const TypingIndicator = as<'div', TypingIndicatorProps>(
({ size, disableAnimation, style, ...props }, ref) => (
<Box
as="span"
alignItems="Center"
shrink="No"
style={{ gap: toRem(size === '300' ? 1 : 2), ...style }}
{...props}
ref={ref}
>
<span className={css.TypingDot({ size, index: '0', animated: !disableAnimation })} />
<span className={css.TypingDot({ size, index: '1', animated: !disableAnimation })} />
<span className={css.TypingDot({ size, index: '2', animated: !disableAnimation })} />
</Box>
),
function TypingIndicatorInner({ size, disableAnimation, style, ...props }, ref) {
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
return (
<Box
as="span"
alignItems="Center"
shrink="No"
style={{
gap: toRem(size === '300' ? 1 : 2),
color: lotusTerminal ? 'var(--lt-accent-orange)' : undefined,
...style,
}}
{...props}
ref={ref}
>
<span className={css.TypingDot({ size, index: '0', animated: !disableAnimation })} />
<span className={css.TypingDot({ size, index: '1', animated: !disableAnimation })} />
<span className={css.TypingDot({ size, index: '2', animated: !disableAnimation })} />
</Box>
);
},
);
+23
View File
@@ -196,6 +196,29 @@ export function CallControls({ callEmbed }: CallControlsProps) {
// microphone intentionally read via microphoneRef — excluded from deps to avoid listener churn
}, [pttMode, pttKey, callEmbed]);
useEffect(() => {
const isEditable = (el: HTMLElement): boolean => {
const tag = el.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
let node: HTMLElement | null = el;
while (node && node !== el.ownerDocument.body) {
if (node.contentEditable === 'true') return true;
if (node.contentEditable === 'false') return false;
node = node.parentElement;
}
return false;
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.code !== 'KeyM') return;
if (e.repeat) return;
if (isEditable(e.target as HTMLElement)) return;
e.preventDefault();
callEmbed.control.toggleSound();
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [callEmbed]);
const [hangupState, hangup] = useAsyncCallback(
useCallback(() => callEmbed.hangup(), [callEmbed]),
);
+20
View File
@@ -150,6 +150,9 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room);
const [charCount, setCharCount] = useState(0);
useEffect(() => { setCharCount(0); }, [roomId]);
const alive = useAlive();
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId));
const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(roomId));
@@ -718,6 +721,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onPaste={handlePaste}
onChange={(value) => setCharCount(toPlainText(value, isMarkdown).trim().length)}
top={
replyDraft && (
<div>
@@ -953,6 +957,22 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
setTimeout(() => setLocationError(null), 4000);
}}
/>
{charCount > 0 && (
<Text
size="T200"
style={{
color: 'var(--tc-surface-low)',
padding: '0 4px',
alignSelf: 'center',
userSelect: 'none',
minWidth: '2rem',
textAlign: 'right',
opacity: 0.7,
}}
>
{charCount}
</Text>
)}
<IconButton
onClick={submit}
variant="SurfaceVariant"
@@ -325,6 +325,8 @@ function Appearance() {
'perMessageProfiles',
);
const [lotusTerminal, setLotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
const [nightLightEnabled, setNightLightEnabled] = useSetting(settingsAtom, 'nightLightEnabled');
const [nightLightOpacity, setNightLightOpacity] = useSetting(settingsAtom, 'nightLightOpacity');
return (
<Box direction="Column" gap="100">
@@ -393,6 +395,42 @@ function Appearance() {
}
/>
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="200"
>
<SettingTile
title="Night Light"
description="Reduce blue light with a warm orange tint overlay."
after={
<Switch variant="Primary" value={nightLightEnabled} onChange={setNightLightEnabled} />
}
/>
{nightLightEnabled && (
<Box
direction="Column"
gap="100"
style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}
>
<Text size="T200" style={{ opacity: 0.7 }}>
Intensity: {nightLightOpacity}%
</Text>
<input
type="range"
min={5}
max={80}
value={nightLightOpacity}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setNightLightOpacity(parseInt(e.target.value, 10))
}
style={{ width: '100%' }}
/>
</Box>
)}
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Lotus Terminal Mode"
+20 -1
View File
@@ -1,6 +1,6 @@
import React from 'react';
import * as Sentry from '@sentry/react';
import { Provider as JotaiProvider } from 'jotai';
import { Provider as JotaiProvider, useAtomValue } from 'jotai';
import { OverlayContainerProvider, PopOutContainerProvider, TooltipContainerProvider } from 'folds';
import { RouterProvider } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
@@ -13,6 +13,24 @@ import { FeatureCheck } from './FeatureCheck';
import { createRouter } from './Router';
import { ScreenSizeProvider, useScreenSize } from '../hooks/useScreenSize';
import { useCompositionEndTracking } from '../hooks/useComposingCheck';
import { settingsAtom } from '../state/settings';
function NightLightOverlay() {
const settings = useAtomValue(settingsAtom);
if (!settings.nightLightEnabled) return null;
return (
<div
aria-hidden="true"
style={{
position: 'fixed',
inset: 0,
pointerEvents: 'none',
zIndex: 9998,
backgroundColor: `rgba(255, 140, 0, ${(settings.nightLightOpacity ?? 30) / 100})`,
}}
/>
);
}
const queryClient = new QueryClient();
@@ -76,6 +94,7 @@ function App() {
<QueryClientProvider client={queryClient}>
<JotaiProvider>
<RouterProvider router={createRouter(clientConfig, screenSize)} />
<NightLightOverlay />
</JotaiProvider>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
+6
View File
@@ -76,6 +76,9 @@ export interface Settings {
callNoiseSuppression: boolean;
pttMode: boolean;
pttKey: string;
nightLightEnabled: boolean;
nightLightOpacity: number;
}
const defaultSettings: Settings = {
@@ -121,6 +124,9 @@ const defaultSettings: Settings = {
callNoiseSuppression: true,
pttMode: false,
pttKey: 'Space',
nightLightEnabled: false,
nightLightOpacity: 30,
};
export const getSettings = (): Settings => {