Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acc17f422b |
@@ -86,6 +86,7 @@ import { CallAvatarAnimation } from '../styles/Animations.css';
|
|||||||
import { webRTCSupported } from '../utils/rtc';
|
import { webRTCSupported } from '../utils/rtc';
|
||||||
import { CallsUnavailableMessage } from '../features/call/CallsUnavailable';
|
import { CallsUnavailableMessage } from '../features/call/CallsUnavailable';
|
||||||
import { zIndices } from '../styles/zIndex';
|
import { zIndices } from '../styles/zIndex';
|
||||||
|
import { useDenoiseNotices } from '../hooks/useDenoiseNotices';
|
||||||
|
|
||||||
const PIP_MIN_W = 200;
|
const PIP_MIN_W = 200;
|
||||||
const PIP_MIN_H = 112;
|
const PIP_MIN_H = 112;
|
||||||
@@ -793,6 +794,7 @@ function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
|
|||||||
useCallPolicyRevokedToast(embed, joined);
|
useCallPolicyRevokedToast(embed, joined);
|
||||||
useCallEndedToast(embed);
|
useCallEndedToast(embed);
|
||||||
useScreenshareNotices(embed);
|
useScreenshareNotices(embed);
|
||||||
|
useDenoiseNotices(embed);
|
||||||
// [Gitea #43] The in-frame screenshare button follows the same room policy
|
// [Gitea #43] The in-frame screenshare button follows the same room policy
|
||||||
// as the host bar's (hidden where the server would refuse the share).
|
// as the host bar's (hidden where the server would refuse the share).
|
||||||
const { allowScreenshare } = useRoomCallPolicy(embed.room);
|
const { allowScreenshare } = useRoomCallPolicy(embed.room);
|
||||||
|
|||||||
@@ -47,12 +47,8 @@ import {
|
|||||||
recentForwardTargetsAtom,
|
recentForwardTargetsAtom,
|
||||||
addRecentForwardTarget,
|
addRecentForwardTarget,
|
||||||
} from '../../../state/recentForwardTargets';
|
} from '../../../state/recentForwardTargets';
|
||||||
import {
|
import { buildForwardContent, buildPlaintextAttachmentContent } from './forwardContent';
|
||||||
buildForwardContent,
|
import { useLocalRoomNames } from '../../../hooks/useRoomMeta';
|
||||||
buildPlaintextAttachmentContent,
|
|
||||||
forwardEncryptionWarning,
|
|
||||||
} from './forwardContent';
|
|
||||||
import { useLocalRoomNames, getLocalRoomName } from '../../../hooks/useRoomMeta';
|
|
||||||
|
|
||||||
type RoomRowProps = {
|
type RoomRowProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
@@ -279,14 +275,6 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
|||||||
// Selection persists across query changes: a room selected then filtered out
|
// Selection persists across query changes: a room selected then filtered out
|
||||||
// of the rendered slice stays selected.
|
// of the rendered slice stays selected.
|
||||||
const [selectedRoomIds, setSelectedRoomIds] = useState<Set<string>>(new Set());
|
const [selectedRoomIds, setSelectedRoomIds] = useState<Set<string>>(new Set());
|
||||||
const encryptionWarning = forwardEncryptionWarning(
|
|
||||||
!!mx.getRoom(mEvent.getRoomId() ?? '')?.hasEncryptionStateEvent(),
|
|
||||||
[...selectedRoomIds]
|
|
||||||
.map((id) => mx.getRoom(id))
|
|
||||||
.filter((r): r is Room => !!r && !r.hasEncryptionStateEvent())
|
|
||||||
// The name the user sees (their own "Rename for me" name, if set).
|
|
||||||
.map((r) => getLocalRoomName(mx, r.roomId) ?? r.name),
|
|
||||||
);
|
|
||||||
|
|
||||||
const toggleRoom = useCallback((roomId: string) => {
|
const toggleRoom = useCallback((roomId: string) => {
|
||||||
setSelectedRoomIds((prev) => {
|
setSelectedRoomIds((prev) => {
|
||||||
@@ -602,13 +590,7 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
|||||||
shrink="No"
|
shrink="No"
|
||||||
direction="Column"
|
direction="Column"
|
||||||
style={{ padding: `${config.space.S200} ${config.space.S400}` }}
|
style={{ padding: `${config.space.S200} ${config.space.S400}` }}
|
||||||
gap="200"
|
|
||||||
>
|
>
|
||||||
{encryptionWarning && (
|
|
||||||
<Text size="T200" role="note" style={{ color: color.Warning.Main }}>
|
|
||||||
{encryptionWarning}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
<Button
|
<Button
|
||||||
variant="Primary"
|
variant="Primary"
|
||||||
size="400"
|
size="400"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { MatrixClient, MatrixEvent } from 'matrix-js-sdk';
|
import { MatrixClient, MatrixEvent } from 'matrix-js-sdk';
|
||||||
import { buildForwardContent, forwardEncryptionWarning, getForwardedMeta } from './forwardContent';
|
import { buildForwardContent, getForwardedMeta } from './forwardContent';
|
||||||
|
|
||||||
// Pure content builder buildForwardContent: refuses undecryptable events, forwards
|
// Pure content builder buildForwardContent: refuses undecryptable events, forwards
|
||||||
// the latest edit (`m.new_content`), and strips reply fallbacks + `m.relates_to`.
|
// the latest edit (`m.new_content`), and strips reply fallbacks + `m.relates_to`.
|
||||||
@@ -171,16 +171,3 @@ test('getForwardedMeta rejects malformed metadata', () => {
|
|||||||
assert.equal(getForwardedMeta({ 'io.lotus.forwarded': { sender: 1 } }), undefined);
|
assert.equal(getForwardedMeta({ 'io.lotus.forwarded': { sender: 1 } }), undefined);
|
||||||
assert.equal(getForwardedMeta({}), undefined);
|
assert.equal(getForwardedMeta({}), undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('forwardEncryptionWarning: only from an encrypted room into unencrypted ones (#330)', () => {
|
|
||||||
assert.equal(forwardEncryptionWarning(false, ['general']), undefined);
|
|
||||||
assert.equal(forwardEncryptionWarning(true, []), undefined);
|
|
||||||
assert.match(
|
|
||||||
forwardEncryptionWarning(true, ['general']) ?? '',
|
|
||||||
/^general isn't encrypted\..*that room, along with who originally wrote it\.$/,
|
|
||||||
);
|
|
||||||
assert.match(
|
|
||||||
forwardEncryptionWarning(true, ['a', 'b']) ?? '',
|
|
||||||
/^2 of the rooms you picked aren't encrypted\..*those rooms/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -122,23 +122,3 @@ export async function buildPlaintextAttachmentContent(
|
|||||||
|
|
||||||
return plainContent;
|
return plainContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* [Gitea #330] The note shown when forwarding out of an encrypted room into
|
|
||||||
* rooms that aren't: the text (and a re-uploaded attachment) becomes readable
|
|
||||||
* by the server, and the original author is shown with it. Undefined when
|
|
||||||
* there's nothing to warn about.
|
|
||||||
*/
|
|
||||||
export function forwardEncryptionWarning(
|
|
||||||
sourceEncrypted: boolean,
|
|
||||||
unencryptedDestNames: string[],
|
|
||||||
): string | undefined {
|
|
||||||
if (!sourceEncrypted || unencryptedDestNames.length === 0) return undefined;
|
|
||||||
const where =
|
|
||||||
unencryptedDestNames.length === 1
|
|
||||||
? `${unencryptedDestNames[0]} isn't encrypted.`
|
|
||||||
: `${unencryptedDestNames.length} of the rooms you picked aren't encrypted.`;
|
|
||||||
return `${where} The forwarded message (and any attachment) will be readable by the server and everyone in ${
|
|
||||||
unencryptedDestNames.length === 1 ? 'that room' : 'those rooms'
|
|
||||||
}, along with who originally wrote it.`;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useSetAtom } from 'jotai';
|
||||||
|
import { Icons } from 'folds';
|
||||||
|
import { CallEmbed } from '../plugins/call';
|
||||||
|
import { toastQueueAtom } from '../state/toast';
|
||||||
|
import { useSetting } from '../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../state/settings';
|
||||||
|
import { denoiseNotice, DenoiseStateReport } from '../utils/denoiseNotice';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #260] Tell the user when ML noise suppression didn't attach (raw mic)
|
||||||
|
* or fell back to another model. The fork's `io.lotus.denoise_state` used to
|
||||||
|
* go unanswered ("unsupported action"), so failures were silent.
|
||||||
|
*/
|
||||||
|
export function useDenoiseNotices(embed: CallEmbed): void {
|
||||||
|
const setToast = useSetAtom(toastQueueAtom);
|
||||||
|
const [mode] = useSetting(settingsAtom, 'callNoiseSuppression');
|
||||||
|
const [model] = useSetting(settingsAtom, 'callDenoiseModel');
|
||||||
|
const latest = useRef({ mode, model });
|
||||||
|
latest.current = { mode, model };
|
||||||
|
useEffect(
|
||||||
|
() =>
|
||||||
|
embed.listenAction<{ data?: DenoiseStateReport }>('io.lotus.denoise_state', (evt) => {
|
||||||
|
if (latest.current.mode !== 'ml') return;
|
||||||
|
const body = denoiseNotice(latest.current.model, evt.detail?.data);
|
||||||
|
if (!body) return;
|
||||||
|
setToast({
|
||||||
|
id: `denoise-state-${Date.now()}`,
|
||||||
|
iconSrc: Icons.Mic,
|
||||||
|
displayName: 'Noise suppression',
|
||||||
|
body,
|
||||||
|
roomName: embed.room.name ?? 'Voice call',
|
||||||
|
roomId: embed.roomId,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
[embed, setToast],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1060,32 +1060,6 @@ function TauriUpdateFeature() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function LotusDenoiseFeature() {
|
|
||||||
const setToast = useSetAtom(toastQueueAtom);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleMessage = (event: MessageEvent) => {
|
|
||||||
if (event.data?.type === 'lotus-denoise-status') {
|
|
||||||
const { active, error } = event.data;
|
|
||||||
if (!active) {
|
|
||||||
setToast({
|
|
||||||
id: `denoise-fail-${Date.now()}`,
|
|
||||||
displayName: 'Audio Quality',
|
|
||||||
body: `ML Noise Suppression failed: ${error || 'Unknown error'}. Falling back to raw mic.`,
|
|
||||||
roomName: 'System',
|
|
||||||
roomId: '',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('message', handleMessage);
|
|
||||||
return () => window.removeEventListener('message', handleMessage);
|
|
||||||
}, [setToast]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Registers the global `?` shortcut (ignored while typing) and renders the
|
// Registers the global `?` shortcut (ignored while typing) and renders the
|
||||||
// keyboard-shortcuts help dialog. Headless — the dialog self-gates on its atom.
|
// keyboard-shortcuts help dialog. Headless — the dialog self-gates on its atom.
|
||||||
function KeyboardShortcutsFeature() {
|
function KeyboardShortcutsFeature() {
|
||||||
@@ -1167,7 +1141,6 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
|||||||
<CloseBehaviorPrompt />
|
<CloseBehaviorPrompt />
|
||||||
<ConsentRequiredPrompt />
|
<ConsentRequiredPrompt />
|
||||||
<OutboxFeature />
|
<OutboxFeature />
|
||||||
<LotusDenoiseFeature />
|
|
||||||
<DeepLinkNavigator />
|
<DeepLinkNavigator />
|
||||||
<KeyboardShortcutsFeature />
|
<KeyboardShortcutsFeature />
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { denoiseNotice } from './denoiseNotice';
|
||||||
|
|
||||||
|
test('denoiseNotice: says when ML is off or fell back (#260)', () => {
|
||||||
|
assert.equal(denoiseNotice('deepfilternet', { active: true, model: 'deepfilternet' }), undefined);
|
||||||
|
assert.match(
|
||||||
|
denoiseNotice('deepfilternet', { active: true, model: 'rnnoise' }) ?? '',
|
||||||
|
/^DeepFilterNet 3 couldn't start, so noise suppression is using RNNoise instead\.$/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
denoiseNotice('rnnoise', { active: false, error: 'boom' }) ?? '',
|
||||||
|
/unfiltered audio/,
|
||||||
|
);
|
||||||
|
assert.equal(denoiseNotice('rnnoise', undefined), undefined);
|
||||||
|
assert.equal(denoiseNotice('rnnoise', { model: 'x' }), undefined);
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* [Gitea #260] The fork reports what noise suppression actually runs as
|
||||||
|
* `io.lotus.denoise_state { active, model, error? }`. Pure: the toast text for
|
||||||
|
* a report, or undefined when what runs is what the user picked.
|
||||||
|
*/
|
||||||
|
export type DenoiseStateReport = { active?: unknown; model?: unknown; error?: unknown };
|
||||||
|
|
||||||
|
const MODEL_NAMES: Record<string, string> = {
|
||||||
|
rnnoise: 'RNNoise',
|
||||||
|
speex: 'Speex',
|
||||||
|
dtln: 'DTLN',
|
||||||
|
deepfilternet: 'DeepFilterNet 3',
|
||||||
|
};
|
||||||
|
const modelName = (m: unknown): string =>
|
||||||
|
typeof m === 'string' ? (MODEL_NAMES[m] ?? m) : 'the selected model';
|
||||||
|
|
||||||
|
export function denoiseNotice(
|
||||||
|
requestedModel: string,
|
||||||
|
report: DenoiseStateReport | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (!report || typeof report.active !== 'boolean') return undefined;
|
||||||
|
if (!report.active) {
|
||||||
|
return `ML noise suppression couldn't start, so your microphone is sending unfiltered audio. You can pick another model in Settings → Calls.`;
|
||||||
|
}
|
||||||
|
if (typeof report.model === 'string' && report.model !== requestedModel) {
|
||||||
|
return `${modelName(requestedModel)} couldn't start, so noise suppression is using ${modelName(report.model)} instead.`;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user