diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx
index 9a03578da..6e75be3ae 100644
--- a/src/app/features/settings/general/General.tsx
+++ b/src/app/features/settings/general/General.tsx
@@ -1593,7 +1593,19 @@ export function Privacy() {
settingsAtom,
'warnOnUnverifiedDevices',
);
+ const [urlPreview, setUrlPreview] = useSetting(settingsAtom, 'urlPreview');
+ const [encUrlPreview, setEncUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
const [stripTracking, setStripTracking] = useSetting(settingsAtom, 'stripTrackingParams');
+ // [Gitea #461] One choice instead of two overlapping switches. Maps onto the
+ // existing settings, so every old combination keeps its behaviour:
+ // hideActivity also hides typing and other people's receipts from you.
+ let activity: 'everyone' | 'private' | 'hidden' = 'everyone';
+ if (hideActivity) activity = 'hidden';
+ else if (privateReadReceipts) activity = 'private';
+ const chooseActivity = (next: typeof activity) => {
+ setHideActivity(next === 'hidden');
+ setPrivateReadReceipts(next !== 'everyone');
+ };
const [stripImageMeta, setStripImageMeta] = useSetting(settingsAtom, 'stripImageMetadata');
return (
@@ -1615,24 +1627,87 @@ export function Privacy() {
}
+ title="Link previews"
+ description="Your homeserver fetches and caches link previews. It will see the URLs of all links you preview."
+ after={}
/>
+ title="Link previews in encrypted rooms"
+ description={
+
+
+ Your homeserver fetches link previews on your behalf. It cannot decrypt your
+ messages, but will see every URL you preview in encrypted rooms, potentially
+ revealing conversation topics.
+
+
+ Privacy risk — enabled by default
+
+
}
+ after={}
/>
+
+
+
+ {(
+ [
+ ['everyone', 'Everyone', "Others see what you've read and when you're typing."],
+ [
+ 'private',
+ 'Private read receipts',
+ "Others don't see what you've read (only you and your server do). Typing still shows.",
+ ],
+ [
+ 'hidden',
+ 'Hide my activity',
+ "No typing indicator and private read receipts. You also won't see other people's read receipts.",
+ ],
+ ] as const
+ ).map(([value, label, help]) => (
+
+ ))}
+
+
m.id === callDenoiseModel);
return (
-
- Calls
-
- }
- />
-
-
-
- {/* [Gitea #271] T200 like every other setting description. */}
-
- Filter background noise from your mic during calls. Browser-native uses the built-in
- WebRTC suppressor (Google NSNet2). ML runs a dedicated model for stronger removal.
-
-
- {!mlSupported && (
-
-
- ML options are not supported in this browser.
-
-
- {ML_DENOISE_REQUIREMENTS.map((req) => (
-
- {req}
-
- ))}
-
-
- )}
-
- }
- after={
-
- value={callNoiseSuppression}
- onChange={setCallNoiseSuppression}
- options={[
- { value: 'off', label: 'Off' },
- { value: 'browser', label: 'Browser-native' },
- {
- value: 'ml',
- label: 'ML (Advanced)',
- disabled: !mlSupported,
- },
- ]}
- />
- }
- />
-
- {callNoiseSuppression === 'ml' && (
-
- {/* ── Model selection ───────────────────────────────────────── */}
-
- Model
-
- value={callDenoiseModel}
- onChange={setCallDenoiseModel}
- options={DENOISE_MODELS.map((m) => ({ value: m.id, label: m.name }))}
- />
- }
- />
- {selectedDenoiseModel && (
-
- {selectedDenoiseModel.name}
-
- {selectedDenoiseModel.description}
-
-
- {(
- [
- { label: 'CPU', value: selectedDenoiseModel.cpuUsage },
- { label: 'Voice quality', value: selectedDenoiseModel.voiceQuality },
- { label: 'Transients', value: selectedDenoiseModel.transients },
- { label: 'Download', value: selectedDenoiseModel.binarySize },
- ] as const
- ).map((stat) => (
-
-
- {stat.label}
-
- {stat.value}
-
- ))}
-
-
- )}
-
-
-
-
- Compare all models
-
-
-
-
-
-
- Model
-
-
-
-
- CPU
-
-
-
-
- Quality
-
-
-
-
- Transients
-
-
-
- {DENOISE_MODELS.map((model) => (
-
-
- {model.name}
-
-
- {model.cpuUsage}
-
-
- {model.voiceQuality}
-
-
- {model.transients}
-
-
- ))}
-
-
-
-
- Note: Applying changes requires rejoining the call.
-
-
-
- {/* ── Enhancement toggles ───────────────────────────────────── */}
-
- Enhancements
-
- }
- />
-
-
- }
- />
-
- {callDenoiseGate && (
-
-
- Gate Threshold
- {callDenoiseGateThreshold} dB
-
- setCallDenoiseGateThreshold(parseInt(e.target.value, 10))}
- style={{ width: '100%', accentColor: color.Primary.Main }}
- />
-
- )}
-
-
- {/* ── Test & calibrate ──────────────────────────────────────── */}
-
- Test and calibrate
-
- Audition the selected model and tune the gate without joining a call. Changes to the
- settings above apply the next time you start a monitor or play a clip.
-
-
-
-
- )}
-
-
-
- value={callRejoin}
- onChange={setCallRejoin}
- options={[
- { value: 'ask', label: 'Ask' },
- { value: 'auto', label: 'Rejoin automatically' },
- { value: 'off', label: 'Do nothing' },
- ]}
- />
- }
- />
-
- }
- />
- {hapticsSupported() && (
+ // [Gitea #462] Fixed groups in every mode (it was one flat list).
+
+
+ Joining
+
}
+ title="Join with camera on"
+ description="Enable camera automatically when joining a voice or video call. Camera is off by default."
+ after={}
/>
- )}
- }
- />
- {pttMode && (
+
+
- {pttBind.listening ? 'Press a key…' : keyLabel(pttKey)}
-
- }
- />
- )}
-
-
- {deafenHotkey && (
-
- )}
-
- }
- />
- {isTauriEnv() && (
-
- }
- />
- )}
-
-
-
- }
- />
- }
- />
- {afkAutoMute && (
- setAfkTimeoutMinutes(Number(v))}
+
+ value={callRejoin}
+ onChange={setCallRejoin}
options={[
- { value: '1', label: '1 minute' },
- { value: '5', label: '5 minutes' },
- { value: '10', label: '10 minutes' },
- { value: '20', label: '20 minutes' },
- { value: '30', label: '30 minutes' },
+ { value: 'ask', label: 'Ask' },
+ { value: 'auto', label: 'Rejoin automatically' },
+ { value: 'off', label: 'Do nothing' },
]}
/>
}
/>
- )}
-
-
- handleRingtoneChange(v as RingtoneId)}
- options={RINGTONE_OPTIONS}
- />
- }
- />
-
-
-
-
- setRingtoneVolume(parseInt(e.target.value, 10))}
- aria-label="Ringtone volume"
- style={{ flex: 1, accentColor: color.Primary.Main }}
+
+ }
/>
-
- {ringtoneVolume}%
-
-
-
-
- handleJoinLeaveSoundChange(v as 'off' | 'chime' | 'soft' | 'retro')}
- options={[
- { value: 'off', label: 'Off' },
- { value: 'chime', label: 'Chime' },
- { value: 'soft', label: 'Soft' },
- { value: 'retro', label: 'Retro' },
- ]}
- />
- }
- />
-
+
+
+
+ Microphone and noise
+
+
+ {/* [Gitea #271] T200 like every other setting description. */}
+
+ Filter background noise from your mic during calls. Browser-native uses the
+ built-in WebRTC suppressor (Google NSNet2). ML runs a dedicated model for stronger
+ removal.
+
-
-
- value={callAudioBitrate}
- onChange={setCallAudioBitrate}
- options={AUDIO_BITRATE_OPTIONS}
- />
- }
- />
-
-
-
-
- value={screenshareBitrate}
- onChange={setScreenshareBitrate}
- options={SCREENSHARE_BITRATE_OPTIONS}
- />
- }
- />
-
-
-
-
- value={screenshareFramerate}
- onChange={setScreenshareFramerate}
- options={SCREENSHARE_FRAMERATE_OPTIONS}
- />
- }
- />
-
-
-
-
- }
- />
- {soundboardEnabled && (
- <>
-
-
- setSoundboardVolume(parseInt(e.target.value, 10))}
- style={{ flexGrow: 1 }}
- aria-label="Soundboard volume"
+ {!mlSupported && (
+
+
+ ML options are not supported in this browser.
+
+
+ {ML_DENOISE_REQUIREMENTS.map((req) => (
+
+ {req}
+
+ ))}
+
+
+ )}
+
+ }
+ after={
+
+ value={callNoiseSuppression}
+ onChange={setCallNoiseSuppression}
+ options={[
+ { value: 'off', label: 'Off' },
+ { value: 'browser', label: 'Browser-native' },
+ {
+ value: 'ml',
+ label: 'ML (Advanced)',
+ disabled: !mlSupported,
+ },
+ ]}
/>
-
- {soundboardVolume}%
-
+ }
+ />
+
+ {callNoiseSuppression === 'ml' && (
+
+ {/* ── Model selection ───────────────────────────────────────── */}
+
+ Model
+
+ value={callDenoiseModel}
+ onChange={setCallDenoiseModel}
+ options={DENOISE_MODELS.map((m) => ({ value: m.id, label: m.name }))}
+ />
+ }
+ />
+ {selectedDenoiseModel && (
+
+ {selectedDenoiseModel.name}
+
+ {selectedDenoiseModel.description}
+
+
+ {(
+ [
+ { label: 'CPU', value: selectedDenoiseModel.cpuUsage },
+ { label: 'Voice quality', value: selectedDenoiseModel.voiceQuality },
+ { label: 'Transients', value: selectedDenoiseModel.transients },
+ { label: 'Download', value: selectedDenoiseModel.binarySize },
+ ] as const
+ ).map((stat) => (
+
+
+ {stat.label}
+
+ {stat.value}
+
+ ))}
+
+
+ )}
+
+
+
+
+ Compare all models
+
+
+
+
+
+
+ Model
+
+
+
+
+ CPU
+
+
+
+
+ Quality
+
+
+
+
+ Transients
+
+
+
+ {DENOISE_MODELS.map((model) => (
+
+
+ {model.name}
+
+
+ {model.cpuUsage}
+
+
+ {model.voiceQuality}
+
+
+ {model.transients}
+
+
+ ))}
+
+
+
+
+ Note: Applying changes requires rejoining the call.
+
+
+
+ {/* ── Enhancement toggles ───────────────────────────────────── */}
+
+ Enhancements
+
+ }
+ />
+
+
+ }
+ />
+
+ {callDenoiseGate && (
+
+
+ Gate Threshold
+ {callDenoiseGateThreshold} dB
+
+ setCallDenoiseGateThreshold(parseInt(e.target.value, 10))}
+ style={{ width: '100%', accentColor: color.Primary.Main }}
+ />
+
+ )}
+
+
+ {/* ── Test & calibrate ──────────────────────────────────────── */}
+
+ Test and calibrate
+
+ Audition the selected model and tune the gate without joining a call. Changes to
+ the settings above apply the next time you start a monitor or play a clip.
+
+
+
- >
- )}
-
+ )}
+
+
+
+ }
+ />
+ }
+ />
+ {afkAutoMute && (
+ setAfkTimeoutMinutes(Number(v))}
+ options={[
+ { value: '1', label: '1 minute' },
+ { value: '5', label: '5 minutes' },
+ { value: '10', label: '10 minutes' },
+ { value: '20', label: '20 minutes' },
+ { value: '30', label: '30 minutes' },
+ ]}
+ />
+ }
+ />
+ )}
+
+
+
+ Push to talk and hotkeys
+
+ }
+ />
+ {pttMode && (
+
+ {pttBind.listening ? 'Press a key…' : keyLabel(pttKey)}
+
+ }
+ />
+ )}
+
+
+ {deafenHotkey && (
+
+ )}
+
+ }
+ />
+ {isTauriEnv() && (
+
+ }
+ />
+ )}
+ {hapticsSupported() && (
+ }
+ />
+ )}
+
+
+
+ Sounds
+
+ handleRingtoneChange(v as RingtoneId)}
+ options={RINGTONE_OPTIONS}
+ />
+ }
+ />
+
+
+
+
+ setRingtoneVolume(parseInt(e.target.value, 10))}
+ aria-label="Ringtone volume"
+ style={{ flex: 1, accentColor: color.Primary.Main }}
+ />
+
+ {ringtoneVolume}%
+
+
+
+
+
+ handleJoinLeaveSoundChange(v as 'off' | 'chime' | 'soft' | 'retro')
+ }
+ options={[
+ { value: 'off', label: 'Off' },
+ { value: 'chime', label: 'Chime' },
+ { value: 'soft', label: 'Soft' },
+ { value: 'retro', label: 'Retro' },
+ ]}
+ />
+ }
+ />
+
+
+
+ Quality
+
+
+ value={callAudioBitrate}
+ onChange={setCallAudioBitrate}
+ options={AUDIO_BITRATE_OPTIONS}
+ />
+ }
+ />
+
+
+
+ value={screenshareBitrate}
+ onChange={setScreenshareBitrate}
+ options={SCREENSHARE_BITRATE_OPTIONS}
+ />
+ }
+ />
+
+
+
+ value={screenshareFramerate}
+ onChange={setScreenshareFramerate}
+ options={SCREENSHARE_FRAMERATE_OPTIONS}
+ />
+ }
+ />
+
+
+
+ Soundboard
+
+
+ }
+ />
+ {soundboardEnabled && (
+ <>
+
+
+ setSoundboardVolume(parseInt(e.target.value, 10))}
+ style={{ flexGrow: 1 }}
+ aria-label="Soundboard volume"
+ />
+
+ {soundboardVolume}%
+
+
+ >
+ )}
+
+
);
}
@@ -2736,8 +2848,6 @@ export function Messages() {
'hideNickAvatarEvents',
);
const [mediaAutoLoad, setMediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
- const [urlPreview, setUrlPreview] = useSetting(settingsAtom, 'urlPreview');
- const [encUrlPreview, setEncUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
const [inlineMediaEmbeds, setInlineMediaEmbeds] = useSetting(settingsAtom, 'inlineMediaEmbeds');
const [enforceRetentionLocally, setEnforceRetentionLocally] = useSetting(
settingsAtom,
@@ -2801,37 +2911,6 @@ export function Messages() {
}
/>
-
- }
- />
-
-
-
-
- Your homeserver fetches link previews on your behalf. It cannot decrypt your
- messages, but will see every URL you preview in encrypted rooms, potentially
- revealing conversation topics.
-
-
- Privacy risk — enabled by default
-
-
- }
- after={}
- />
-