feat: Implement microphone monitoring feature; enhance text overflow handling and line height across components

This commit is contained in:
2026-05-13 03:37:32 +10:00
parent 5d4d518af8
commit 4fdedb87c8
8 changed files with 187 additions and 35 deletions

View File

@@ -9,30 +9,13 @@
"xmr.se" "xmr.se"
], ],
"allowCustomHomeservers": true, "allowCustomHomeservers": true,
"calling": {
"livekitServiceUrl": "https://b.ruv.wtf/matrix-rtc/livekit/jwt"
},
"featuredCommunities": { "featuredCommunities": {
"openAsDefault": false, "openAsDefault": false,
"spaces": [ "spaces": [
"#cinny-space:matrix.org",
"#community:matrix.org",
"#space:envs.net",
"#science-space:matrix.org",
"#libregaming-games:tchncs.de",
"#mathematics-on:matrix.org"
], ],
"rooms": [ "rooms": [
"#cinny:matrix.org",
"#freesoftware:matrix.org",
"#pcapdroid:matrix.org",
"#gentoo:matrix.org",
"#PrivSec.dev:arcticfoxes.net",
"#disroot:aria-net.org"
], ],
"servers": ["envs.net", "matrix.org", "monero.social", "mozilla.org"] "servers": []
}, },
"hashRouter": { "hashRouter": {

View File

@@ -159,6 +159,24 @@ export function RenderMessageContent({
</> </>
); );
// Check for custom renderer from plugins BEFORE standard type handling
// so plugins can intercept any msgtype including custom ones.
const customRendererEarly = pluginRegistry.getRenderer('message');
if (customRendererEarly) {
try {
const messageDataEarly = {
msgtype: msgType,
...getContent(),
};
const customContentEarly = customRendererEarly(messageDataEarly, () => null);
if (customContentEarly) {
return customContentEarly as React.ReactElement;
}
} catch (error) {
console.error('[RenderMessageContent] Custom renderer error (early):', error);
}
}
if (msgType === MsgType.Text) { if (msgType === MsgType.Text) {
return ( return (
<MText <MText

View File

@@ -32,7 +32,9 @@ export const Reply = style({
export const ReplyContent = style({ export const ReplyContent = style({
opacity: config.opacity.P300, opacity: config.opacity.P300,
overflow: 'clip',
overflowY: 'clip',
lineHeight: '1.5',
selectors: { selectors: {
[`${Reply}:hover &`]: { [`${Reply}:hover &`]: {
opacity: config.opacity.P500, opacity: config.opacity.P500,

View File

@@ -188,8 +188,10 @@ export const BubbleLeftArrow = style({
export const Username = style({ export const Username = style({
overflow: 'hidden', overflow: 'hidden',
overflowY: 'clip',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
lineHeight: '1.5',
// Allow text selection while keeping button clickable // Allow text selection while keeping button clickable
userSelect: 'text', userSelect: 'text',
WebkitUserSelect: 'text', WebkitUserSelect: 'text',
@@ -210,6 +212,9 @@ export const UsernameBold = style({
export const MessageTextBody = recipe({ export const MessageTextBody = recipe({
base: { base: {
wordBreak: 'break-word', wordBreak: 'break-word',
overflow: 'clip',
overflowY: 'clip',
lineHeight: '1.5',
}, },
variants: { variants: {
preWrap: { preWrap: {

View File

@@ -24,4 +24,7 @@ export const RoomViewTyping = style([
]); ]);
export const TypingText = style({ export const TypingText = style({
flexGrow: 1, flexGrow: 1,
overflow: 'clip',
overflowY: 'clip',
lineHeight: '1.5',
}); });

View File

@@ -454,7 +454,11 @@ export function Audio({ requestClose }: AudioProps) {
const [micTestState, setMicTestState] = useState<'idle' | 'testing' | 'done' | 'error'>('idle'); const [micTestState, setMicTestState] = useState<'idle' | 'testing' | 'done' | 'error'>('idle');
const [micLevel, setMicLevel] = useState(0); const [micLevel, setMicLevel] = useState(0);
const [speakerTestState, setSpeakerTestState] = useState<'idle' | 'testing' | 'done' | 'error'>('idle'); const [speakerTestState, setSpeakerTestState] = useState<'idle' | 'testing' | 'done' | 'error'>('idle');
const [micMonitoring, setMicMonitoring] = useState(false);
const micAnimFrameRef = useRef<number | null>(null); const micAnimFrameRef = useRef<number | null>(null);
const micMonitorContextRef = useRef<AudioContext | null>(null);
const micMonitorStreamRef = useRef<MediaStream | null>(null);
const micMonitorGainRef = useRef<GainNode | null>(null);
const [showRemoteCursor, setShowRemoteCursor] = useSetting(settingsAtom, 'showRemoteCursor'); const [showRemoteCursor, setShowRemoteCursor] = useSetting(settingsAtom, 'showRemoteCursor');
useEffect(() => { useEffect(() => {
@@ -627,6 +631,101 @@ export function Audio({ requestClose }: AudioProps) {
} }
}, [speakerTestState]); }, [speakerTestState]);
const handleMicMonitoringToggle = useCallback(async () => {
if (micMonitoring) {
// Stop monitoring
if (micAnimFrameRef.current !== null) {
cancelAnimationFrame(micAnimFrameRef.current);
micAnimFrameRef.current = null;
}
if (micMonitorStreamRef.current) {
micMonitorStreamRef.current.getTracks().forEach((track) => track.stop());
micMonitorStreamRef.current = null;
}
if (micMonitorContextRef.current) {
micMonitorContextRef.current.close();
micMonitorContextRef.current = null;
}
micMonitorGainRef.current = null;
setMicLevel(0);
setMicMonitoring(false);
} else {
// Start monitoring
try {
const constraints: MediaStreamConstraints = {
audio: settings.microphoneId
? {
deviceId: { exact: settings.microphoneId },
noiseSuppression: settings.noiseSuppression,
echoCancellation: settings.echoCancellation,
autoGainControl: settings.autoGainControl,
}
: {
noiseSuppression: settings.noiseSuppression,
echoCancellation: settings.echoCancellation,
autoGainControl: settings.autoGainControl,
},
};
const stream = await navigator.mediaDevices.getUserMedia(constraints);
const audioContext = new AudioContext();
const analyser = audioContext.createAnalyser();
analyser.fftSize = 256;
const dataArray = new Uint8Array(analyser.frequencyBinCount);
const microphone = audioContext.createMediaStreamSource(stream);
const gainNode = audioContext.createGain();
// Connect microphone to analyser for level monitoring
microphone.connect(analyser);
// Connect microphone to speakers for playback
microphone.connect(gainNode);
gainNode.connect(audioContext.destination);
gainNode.gain.value = 1.0; // Full volume playback
micMonitorContextRef.current = audioContext;
micMonitorStreamRef.current = stream;
micMonitorGainRef.current = gainNode;
const tick = () => {
analyser.getByteFrequencyData(dataArray);
const avg = dataArray.reduce((a, b) => a + b, 0) / dataArray.length;
setMicLevel(Math.min(100, Math.round((avg / 255) * 100 * 3)));
micAnimFrameRef.current = requestAnimationFrame(tick);
};
micAnimFrameRef.current = requestAnimationFrame(tick);
setMicMonitoring(true);
} catch (e) {
console.error('Microphone monitoring failed:', e);
}
}
}, [micMonitoring, settings.microphoneId, settings.noiseSuppression, settings.echoCancellation, settings.autoGainControl]);
// Cleanup monitoring on unmount or when settings change
useEffect(() => {
return () => {
if (micAnimFrameRef.current !== null) {
cancelAnimationFrame(micAnimFrameRef.current);
}
if (micMonitorStreamRef.current) {
micMonitorStreamRef.current.getTracks().forEach((track) => track.stop());
}
if (micMonitorContextRef.current) {
micMonitorContextRef.current.close();
}
};
}, []);
// Restart monitoring when mic settings change
useEffect(() => {
if (micMonitoring) {
handleMicMonitoringToggle(); // Stop
setTimeout(() => handleMicMonitoringToggle(), 100); // Restart
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settings.microphoneId, settings.noiseSuppression, settings.echoCancellation, settings.autoGainControl]);
return ( return (
<Page> <Page>
<PageHeader> <PageHeader>
@@ -686,21 +785,35 @@ export function Audio({ requestClose }: AudioProps) {
onSelect={handleMicrophoneSelect} onSelect={handleMicrophoneSelect}
/> />
<Box direction="Column" gap="100" alignItems="End"> <Box direction="Column" gap="100" alignItems="End">
<Button <Box gap="200" alignItems="Center">
size="300" <Button
variant={micTestState === 'error' ? 'Critical' : micTestState === 'done' ? 'Success' : 'Secondary'} size="300"
fill="Soft" variant={micTestState === 'error' ? 'Critical' : micTestState === 'done' ? 'Success' : 'Secondary'}
radii="300" fill="Soft"
onClick={handleTestMicrophone} radii="300"
disabled={micTestState === 'testing'} onClick={handleTestMicrophone}
> disabled={micTestState === 'testing' || micMonitoring}
<Text size="T300"> >
{micTestState === 'testing' ? 'Testing...' : micTestState === 'done' ? 'Done ✓' : micTestState === 'error' ? 'Failed' : 'Test'} <Text size="T300">
</Text> {micTestState === 'testing' ? 'Testing...' : micTestState === 'done' ? 'Done ✓' : micTestState === 'error' ? 'Failed' : 'Test'}
</Button> </Text>
{micTestState === 'testing' && ( </Button>
<Box style={{ width: toRem(80), height: toRem(6), background: color.Surface.Container, borderRadius: toRem(3), overflow: 'hidden' }}> <Button
<Box style={{ width: `${micLevel}%`, height: '100%', background: color.Primary.Main, transition: 'width 0.05s ease' }} /> size="300"
variant={micMonitoring ? 'Primary' : 'Secondary'}
fill={micMonitoring ? 'Solid' : 'Soft'}
radii="300"
onClick={handleMicMonitoringToggle}
disabled={micTestState === 'testing'}
>
<Text size="T300">
{micMonitoring ? 'Stop' : 'Monitor'}
</Text>
</Button>
</Box>
{(micTestState === 'testing' || micMonitoring) && (
<Box style={{ width: toRem(160), height: toRem(6), background: color.Surface.Container, borderRadius: toRem(3), overflow: 'hidden' }}>
<Box style={{ width: `${micLevel}%`, height: '100%', background: micMonitoring ? color.Success.Main : color.Primary.Main, transition: 'width 0.05s ease' }} />
</Box> </Box>
)} )}
</Box> </Box>

View File

@@ -16,13 +16,21 @@ export const MarginSpaced = style({
}, },
}); });
export const Paragraph = style([DefaultReset]); export const Paragraph = style([
DefaultReset,
{
overflow: 'hidden',
lineHeight: '1.5',
},
]);
export const Heading = style([ export const Heading = style([
DefaultReset, DefaultReset,
MarginSpaced, MarginSpaced,
{ {
marginTop: config.space.S400, marginTop: config.space.S400,
overflow: 'hidden',
lineHeight: '1.5',
selectors: { selectors: {
'&:first-child': { '&:first-child': {
marginTop: 0, marginTop: 0,
@@ -38,6 +46,8 @@ export const BlockQuote = style([
paddingLeft: config.space.S200, paddingLeft: config.space.S200,
borderLeft: `${config.borderWidth.B700} solid ${color.SurfaceVariant.ContainerLine}`, borderLeft: `${config.borderWidth.B700} solid ${color.SurfaceVariant.ContainerLine}`,
fontStyle: 'italic', fontStyle: 'italic',
overflow: 'hidden',
lineHeight: '1.5',
}, },
]); ]);
@@ -63,6 +73,8 @@ export const Code = style([
{ {
padding: `0 ${config.space.S100}`, padding: `0 ${config.space.S100}`,
display: 'inline-block', display: 'inline-block',
overflow: 'hidden',
lineHeight: '1.5',
}, },
]); ]);
@@ -108,6 +120,8 @@ export const Spoiler = recipe({
padding: `0 ${config.space.S100}`, padding: `0 ${config.space.S100}`,
backgroundColor: color.SurfaceVariant.ContainerActive, backgroundColor: color.SurfaceVariant.ContainerActive,
borderRadius: config.radii.R300, borderRadius: config.radii.R300,
overflow: 'hidden',
lineHeight: '1.5',
selectors: { selectors: {
'&[aria-pressed=true]': { '&[aria-pressed=true]': {
color: 'transparent', color: 'transparent',
@@ -132,6 +146,7 @@ export const CodeBlock = style([
fontStyle: 'normal', fontStyle: 'normal',
position: 'relative', position: 'relative',
overflow: 'hidden', overflow: 'hidden',
lineHeight: '1.5',
}, },
]); ]);
export const CodeBlockHeader = style([ export const CodeBlockHeader = style([
@@ -167,6 +182,8 @@ export const List = style([
{ {
padding: `0 ${config.space.S100}`, padding: `0 ${config.space.S100}`,
paddingLeft: config.space.S600, paddingLeft: config.space.S600,
overflow: 'hidden',
lineHeight: '1.5',
}, },
]); ]);
@@ -194,6 +211,8 @@ export const Mention = recipe({
padding: `0 ${toRem(2)}`, padding: `0 ${toRem(2)}`,
borderRadius: config.radii.R300, borderRadius: config.radii.R300,
fontWeight: config.fontWeight.W500, fontWeight: config.fontWeight.W500,
overflow: 'hidden',
lineHeight: '1.5',
}, },
], ],
variants: { variants: {
@@ -219,6 +238,8 @@ export const Command = recipe({
padding: `0 ${toRem(2)}`, padding: `0 ${toRem(2)}`,
borderRadius: config.radii.R300, borderRadius: config.radii.R300,
fontWeight: config.fontWeight.W500, fontWeight: config.fontWeight.W500,
overflow: 'hidden',
lineHeight: '1.5',
}, },
], ],
variants: { variants: {
@@ -287,5 +308,7 @@ export const highlightText = style([
{ {
backgroundColor: 'yellow', backgroundColor: 'yellow',
color: 'black', color: 'black',
overflow: 'hidden',
lineHeight: '1.5',
}, },
]); ]);

View File

@@ -94,6 +94,11 @@ body {
font-variant-ligatures: no-contextual; font-variant-ligatures: no-contextual;
} }
/* Prevent zalgo text and combining diacritics from overflowing line boundaries */
p, span, div, a, button, li, td, th, h1, h2, h3, h4, h5, h6 {
line-height: 1.5;
}
/* Theme-specific body backgrounds for safe area padding */ /* Theme-specific body backgrounds for safe area padding */
body.light-theme { body.light-theme {
background-color: #F0F0F0; background-color: #F0F0F0;