feat: Implement microphone monitoring feature; enhance text overflow handling and line height across components
This commit is contained in:
@@ -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) {
|
||||
return (
|
||||
<MText
|
||||
|
||||
@@ -32,7 +32,9 @@ export const Reply = style({
|
||||
|
||||
export const ReplyContent = style({
|
||||
opacity: config.opacity.P300,
|
||||
|
||||
overflow: 'clip',
|
||||
overflowY: 'clip',
|
||||
lineHeight: '1.5',
|
||||
selectors: {
|
||||
[`${Reply}:hover &`]: {
|
||||
opacity: config.opacity.P500,
|
||||
|
||||
@@ -188,8 +188,10 @@ export const BubbleLeftArrow = style({
|
||||
|
||||
export const Username = style({
|
||||
overflow: 'hidden',
|
||||
overflowY: 'clip',
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
lineHeight: '1.5',
|
||||
// Allow text selection while keeping button clickable
|
||||
userSelect: 'text',
|
||||
WebkitUserSelect: 'text',
|
||||
@@ -210,6 +212,9 @@ export const UsernameBold = style({
|
||||
export const MessageTextBody = recipe({
|
||||
base: {
|
||||
wordBreak: 'break-word',
|
||||
overflow: 'clip',
|
||||
overflowY: 'clip',
|
||||
lineHeight: '1.5',
|
||||
},
|
||||
variants: {
|
||||
preWrap: {
|
||||
|
||||
@@ -24,4 +24,7 @@ export const RoomViewTyping = style([
|
||||
]);
|
||||
export const TypingText = style({
|
||||
flexGrow: 1,
|
||||
overflow: 'clip',
|
||||
overflowY: 'clip',
|
||||
lineHeight: '1.5',
|
||||
});
|
||||
|
||||
@@ -454,7 +454,11 @@ export function Audio({ requestClose }: AudioProps) {
|
||||
const [micTestState, setMicTestState] = useState<'idle' | 'testing' | 'done' | 'error'>('idle');
|
||||
const [micLevel, setMicLevel] = useState(0);
|
||||
const [speakerTestState, setSpeakerTestState] = useState<'idle' | 'testing' | 'done' | 'error'>('idle');
|
||||
const [micMonitoring, setMicMonitoring] = useState(false);
|
||||
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');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -627,6 +631,101 @@ export function Audio({ requestClose }: AudioProps) {
|
||||
}
|
||||
}, [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 (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
@@ -686,21 +785,35 @@ export function Audio({ requestClose }: AudioProps) {
|
||||
onSelect={handleMicrophoneSelect}
|
||||
/>
|
||||
<Box direction="Column" gap="100" alignItems="End">
|
||||
<Button
|
||||
size="300"
|
||||
variant={micTestState === 'error' ? 'Critical' : micTestState === 'done' ? 'Success' : 'Secondary'}
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
onClick={handleTestMicrophone}
|
||||
disabled={micTestState === 'testing'}
|
||||
>
|
||||
<Text size="T300">
|
||||
{micTestState === 'testing' ? 'Testing...' : micTestState === 'done' ? 'Done ✓' : micTestState === 'error' ? 'Failed' : 'Test'}
|
||||
</Text>
|
||||
</Button>
|
||||
{micTestState === 'testing' && (
|
||||
<Box style={{ width: toRem(80), height: toRem(6), background: color.Surface.Container, borderRadius: toRem(3), overflow: 'hidden' }}>
|
||||
<Box style={{ width: `${micLevel}%`, height: '100%', background: color.Primary.Main, transition: 'width 0.05s ease' }} />
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Button
|
||||
size="300"
|
||||
variant={micTestState === 'error' ? 'Critical' : micTestState === 'done' ? 'Success' : 'Secondary'}
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
onClick={handleTestMicrophone}
|
||||
disabled={micTestState === 'testing' || micMonitoring}
|
||||
>
|
||||
<Text size="T300">
|
||||
{micTestState === 'testing' ? 'Testing...' : micTestState === 'done' ? 'Done ✓' : micTestState === 'error' ? 'Failed' : 'Test'}
|
||||
</Text>
|
||||
</Button>
|
||||
<Button
|
||||
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>
|
||||
|
||||
@@ -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([
|
||||
DefaultReset,
|
||||
MarginSpaced,
|
||||
{
|
||||
marginTop: config.space.S400,
|
||||
overflow: 'hidden',
|
||||
lineHeight: '1.5',
|
||||
selectors: {
|
||||
'&:first-child': {
|
||||
marginTop: 0,
|
||||
@@ -38,6 +46,8 @@ export const BlockQuote = style([
|
||||
paddingLeft: config.space.S200,
|
||||
borderLeft: `${config.borderWidth.B700} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||
fontStyle: 'italic',
|
||||
overflow: 'hidden',
|
||||
lineHeight: '1.5',
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -63,6 +73,8 @@ export const Code = style([
|
||||
{
|
||||
padding: `0 ${config.space.S100}`,
|
||||
display: 'inline-block',
|
||||
overflow: 'hidden',
|
||||
lineHeight: '1.5',
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -108,6 +120,8 @@ export const Spoiler = recipe({
|
||||
padding: `0 ${config.space.S100}`,
|
||||
backgroundColor: color.SurfaceVariant.ContainerActive,
|
||||
borderRadius: config.radii.R300,
|
||||
overflow: 'hidden',
|
||||
lineHeight: '1.5',
|
||||
selectors: {
|
||||
'&[aria-pressed=true]': {
|
||||
color: 'transparent',
|
||||
@@ -132,6 +146,7 @@ export const CodeBlock = style([
|
||||
fontStyle: 'normal',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
lineHeight: '1.5',
|
||||
},
|
||||
]);
|
||||
export const CodeBlockHeader = style([
|
||||
@@ -167,6 +182,8 @@ export const List = style([
|
||||
{
|
||||
padding: `0 ${config.space.S100}`,
|
||||
paddingLeft: config.space.S600,
|
||||
overflow: 'hidden',
|
||||
lineHeight: '1.5',
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -194,6 +211,8 @@ export const Mention = recipe({
|
||||
padding: `0 ${toRem(2)}`,
|
||||
borderRadius: config.radii.R300,
|
||||
fontWeight: config.fontWeight.W500,
|
||||
overflow: 'hidden',
|
||||
lineHeight: '1.5',
|
||||
},
|
||||
],
|
||||
variants: {
|
||||
@@ -219,6 +238,8 @@ export const Command = recipe({
|
||||
padding: `0 ${toRem(2)}`,
|
||||
borderRadius: config.radii.R300,
|
||||
fontWeight: config.fontWeight.W500,
|
||||
overflow: 'hidden',
|
||||
lineHeight: '1.5',
|
||||
},
|
||||
],
|
||||
variants: {
|
||||
@@ -287,5 +308,7 @@ export const highlightText = style([
|
||||
{
|
||||
backgroundColor: 'yellow',
|
||||
color: 'black',
|
||||
overflow: 'hidden',
|
||||
lineHeight: '1.5',
|
||||
},
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user