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 React from 'react';
|
||||||
import { Box, as, toRem } from 'folds';
|
import { Box, as, toRem } from 'folds';
|
||||||
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../../state/settings';
|
||||||
import * as css from './TypingIndicator.css';
|
import * as css from './TypingIndicator.css';
|
||||||
|
|
||||||
export type TypingIndicatorProps = {
|
export type TypingIndicatorProps = {
|
||||||
@@ -8,18 +10,25 @@ export type TypingIndicatorProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const TypingIndicator = as<'div', TypingIndicatorProps>(
|
export const TypingIndicator = as<'div', TypingIndicatorProps>(
|
||||||
({ size, disableAnimation, style, ...props }, ref) => (
|
function TypingIndicatorInner({ size, disableAnimation, style, ...props }, ref) {
|
||||||
<Box
|
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||||
as="span"
|
return (
|
||||||
alignItems="Center"
|
<Box
|
||||||
shrink="No"
|
as="span"
|
||||||
style={{ gap: toRem(size === '300' ? 1 : 2), ...style }}
|
alignItems="Center"
|
||||||
{...props}
|
shrink="No"
|
||||||
ref={ref}
|
style={{
|
||||||
>
|
gap: toRem(size === '300' ? 1 : 2),
|
||||||
<span className={css.TypingDot({ size, index: '0', animated: !disableAnimation })} />
|
color: lotusTerminal ? 'var(--lt-accent-orange)' : undefined,
|
||||||
<span className={css.TypingDot({ size, index: '1', animated: !disableAnimation })} />
|
...style,
|
||||||
<span className={css.TypingDot({ size, index: '2', animated: !disableAnimation })} />
|
}}
|
||||||
</Box>
|
{...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
|
// microphone intentionally read via microphoneRef — excluded from deps to avoid listener churn
|
||||||
}, [pttMode, pttKey, callEmbed]);
|
}, [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(
|
const [hangupState, hangup] = useAsyncCallback(
|
||||||
useCallback(() => callEmbed.hangup(), [callEmbed]),
|
useCallback(() => callEmbed.hangup(), [callEmbed]),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -150,6 +150,9 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
const powerLevels = usePowerLevelsContext();
|
const powerLevels = usePowerLevelsContext();
|
||||||
const creators = useRoomCreators(room);
|
const creators = useRoomCreators(room);
|
||||||
|
|
||||||
|
const [charCount, setCharCount] = useState(0);
|
||||||
|
useEffect(() => { setCharCount(0); }, [roomId]);
|
||||||
|
|
||||||
const alive = useAlive();
|
const alive = useAlive();
|
||||||
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId));
|
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId));
|
||||||
const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(roomId));
|
const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(roomId));
|
||||||
@@ -718,6 +721,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onKeyUp={handleKeyUp}
|
onKeyUp={handleKeyUp}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
|
onChange={(value) => setCharCount(toPlainText(value, isMarkdown).trim().length)}
|
||||||
top={
|
top={
|
||||||
replyDraft && (
|
replyDraft && (
|
||||||
<div>
|
<div>
|
||||||
@@ -953,6 +957,22 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
setTimeout(() => setLocationError(null), 4000);
|
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
|
<IconButton
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
|
|||||||
@@ -325,6 +325,8 @@ function Appearance() {
|
|||||||
'perMessageProfiles',
|
'perMessageProfiles',
|
||||||
);
|
);
|
||||||
const [lotusTerminal, setLotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
const [lotusTerminal, setLotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||||
|
const [nightLightEnabled, setNightLightEnabled] = useSetting(settingsAtom, 'nightLightEnabled');
|
||||||
|
const [nightLightOpacity, setNightLightOpacity] = useSetting(settingsAtom, 'nightLightOpacity');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
@@ -393,6 +395,42 @@ function Appearance() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</SequenceCard>
|
</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">
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Lotus Terminal Mode"
|
title="Lotus Terminal Mode"
|
||||||
|
|||||||
+20
-1
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import * as Sentry from '@sentry/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 { OverlayContainerProvider, PopOutContainerProvider, TooltipContainerProvider } from 'folds';
|
||||||
import { RouterProvider } from 'react-router-dom';
|
import { RouterProvider } from 'react-router-dom';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
@@ -13,6 +13,24 @@ import { FeatureCheck } from './FeatureCheck';
|
|||||||
import { createRouter } from './Router';
|
import { createRouter } from './Router';
|
||||||
import { ScreenSizeProvider, useScreenSize } from '../hooks/useScreenSize';
|
import { ScreenSizeProvider, useScreenSize } from '../hooks/useScreenSize';
|
||||||
import { useCompositionEndTracking } from '../hooks/useComposingCheck';
|
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();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
@@ -76,6 +94,7 @@ function App() {
|
|||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<JotaiProvider>
|
<JotaiProvider>
|
||||||
<RouterProvider router={createRouter(clientConfig, screenSize)} />
|
<RouterProvider router={createRouter(clientConfig, screenSize)} />
|
||||||
|
<NightLightOverlay />
|
||||||
</JotaiProvider>
|
</JotaiProvider>
|
||||||
<ReactQueryDevtools initialIsOpen={false} />
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ export interface Settings {
|
|||||||
callNoiseSuppression: boolean;
|
callNoiseSuppression: boolean;
|
||||||
pttMode: boolean;
|
pttMode: boolean;
|
||||||
pttKey: string;
|
pttKey: string;
|
||||||
|
|
||||||
|
nightLightEnabled: boolean;
|
||||||
|
nightLightOpacity: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultSettings: Settings = {
|
const defaultSettings: Settings = {
|
||||||
@@ -121,6 +124,9 @@ const defaultSettings: Settings = {
|
|||||||
callNoiseSuppression: true,
|
callNoiseSuppression: true,
|
||||||
pttMode: false,
|
pttMode: false,
|
||||||
pttKey: 'Space',
|
pttKey: 'Space',
|
||||||
|
|
||||||
|
nightLightEnabled: false,
|
||||||
|
nightLightOpacity: 30,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSettings = (): Settings => {
|
export const getSettings = (): Settings => {
|
||||||
|
|||||||
Reference in New Issue
Block a user