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:
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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]),
|
||||
);
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user