fix(settings): validate seasonalThemeOverride; commit page zoom on blur; GIF picker opt-in
- seasonalThemeOverride is coerced to 'auto' when the persisted value is outside the union, like every other enum setting (tested). - PageZoomInput commits on blur as well as Enter. - New gifPickerEnabled setting (default off) with a disclosure that GIF searches go to Giphy; the picker doesn't mount and the composer button is hidden while off. Fixes #74 Fixes #75 Fixes #68 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
@@ -209,6 +209,13 @@ type GifPickerProps = {
|
|||||||
|
|
||||||
export function GifPicker({ apiKey, onSelect, requestClose }: GifPickerProps) {
|
export function GifPicker({ apiKey, onSelect, requestClose }: GifPickerProps) {
|
||||||
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||||
|
const [gifPickerEnabled] = useSetting(settingsAtom, 'gifPickerEnabled');
|
||||||
|
|
||||||
|
// Searches (and every keystroke) go straight to Giphy's API, so the picker
|
||||||
|
// is opt-in (Settings → Messages) and must not render or fetch until then.
|
||||||
|
if (!gifPickerEnabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const containerStyle = lotusTerminal
|
const containerStyle = lotusTerminal
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -241,7 +241,10 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
const showFormat = composerToolbarButtons?.showFormat ?? true;
|
const showFormat = composerToolbarButtons?.showFormat ?? true;
|
||||||
const showEmoji = composerToolbarButtons?.showEmoji ?? true;
|
const showEmoji = composerToolbarButtons?.showEmoji ?? true;
|
||||||
const showSticker = composerToolbarButtons?.showSticker ?? true;
|
const showSticker = composerToolbarButtons?.showSticker ?? true;
|
||||||
const showGif = composerToolbarButtons?.showGif ?? true;
|
// [Gitea #68] The GIF picker is opt-in (searches go to Giphy); hide the
|
||||||
|
// toolbar button entirely when it's off so it never opens an empty popover.
|
||||||
|
const [gifPickerEnabled] = useSetting(settingsAtom, 'gifPickerEnabled');
|
||||||
|
const showGif = (composerToolbarButtons?.showGif ?? true) && gifPickerEnabled;
|
||||||
const showLocation = composerToolbarButtons?.showLocation ?? true;
|
const showLocation = composerToolbarButtons?.showLocation ?? true;
|
||||||
const showPoll = composerToolbarButtons?.showPoll ?? true;
|
const showPoll = composerToolbarButtons?.showPoll ?? true;
|
||||||
const showVoice = composerToolbarButtons?.showVoice ?? true;
|
const showVoice = composerToolbarButtons?.showVoice ?? true;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, {
|
import React, {
|
||||||
ChangeEventHandler,
|
ChangeEventHandler,
|
||||||
|
FocusEventHandler,
|
||||||
FormEventHandler,
|
FormEventHandler,
|
||||||
KeyboardEventHandler,
|
KeyboardEventHandler,
|
||||||
MouseEventHandler,
|
MouseEventHandler,
|
||||||
@@ -395,6 +396,16 @@ function PageZoomInput() {
|
|||||||
setCurrentZoom(evt.target.value);
|
setCurrentZoom(evt.target.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Shared by Enter and blur so a typed-but-unconfirmed value is never
|
||||||
|
// silently dropped when the input loses focus.
|
||||||
|
const commitZoom = (value: string) => {
|
||||||
|
const newZoom = parseInt(value, 10);
|
||||||
|
if (Number.isNaN(newZoom)) return;
|
||||||
|
const safeZoom = Math.max(Math.min(newZoom, 150), 75);
|
||||||
|
setPageZoom(safeZoom);
|
||||||
|
setCurrentZoom(safeZoom.toString());
|
||||||
|
};
|
||||||
|
|
||||||
const handleZoomEnter: KeyboardEventHandler<HTMLInputElement> = (evt) => {
|
const handleZoomEnter: KeyboardEventHandler<HTMLInputElement> = (evt) => {
|
||||||
if (isKeyHotkey('escape', evt)) {
|
if (isKeyHotkey('escape', evt)) {
|
||||||
evt.stopPropagation();
|
evt.stopPropagation();
|
||||||
@@ -405,14 +416,14 @@ function PageZoomInput() {
|
|||||||
'value' in evt.target &&
|
'value' in evt.target &&
|
||||||
typeof evt.target.value === 'string'
|
typeof evt.target.value === 'string'
|
||||||
) {
|
) {
|
||||||
const newZoom = parseInt(evt.target.value, 10);
|
commitZoom(evt.target.value);
|
||||||
if (Number.isNaN(newZoom)) return;
|
|
||||||
const safeZoom = Math.max(Math.min(newZoom, 150), 75);
|
|
||||||
setPageZoom(safeZoom);
|
|
||||||
setCurrentZoom(safeZoom.toString());
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleZoomBlur: FocusEventHandler<HTMLInputElement> = (evt) => {
|
||||||
|
commitZoom(evt.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
style={{ width: toRem(100) }}
|
style={{ width: toRem(100) }}
|
||||||
@@ -426,6 +437,7 @@ function PageZoomInput() {
|
|||||||
value={currentZoom}
|
value={currentZoom}
|
||||||
onChange={handleZoomChange}
|
onChange={handleZoomChange}
|
||||||
onKeyDown={handleZoomEnter}
|
onKeyDown={handleZoomEnter}
|
||||||
|
onBlur={handleZoomBlur}
|
||||||
after={<Text size="T300">%</Text>}
|
after={<Text size="T300">%</Text>}
|
||||||
outlined
|
outlined
|
||||||
/>
|
/>
|
||||||
@@ -2344,6 +2356,7 @@ function Messages() {
|
|||||||
'translateTargetLang',
|
'translateTargetLang',
|
||||||
);
|
);
|
||||||
const [autoTranslate, setAutoTranslate] = useSetting(settingsAtom, 'autoTranslate');
|
const [autoTranslate, setAutoTranslate] = useSetting(settingsAtom, 'autoTranslate');
|
||||||
|
const [gifPickerEnabled, setGifPickerEnabled] = useSetting(settingsAtom, 'gifPickerEnabled');
|
||||||
const translationSupported = chromeTranslationEngine.isSupported();
|
const translationSupported = chromeTranslationEngine.isSupported();
|
||||||
const selectedTargetLang = isSupportedTargetLang(translateTargetLang)
|
const selectedTargetLang = isSupportedTargetLang(translateTargetLang)
|
||||||
? normalizeLang(translateTargetLang)
|
? normalizeLang(translateTargetLang)
|
||||||
@@ -2446,6 +2459,15 @@ function Messages() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
|
<SettingTile
|
||||||
|
title="GIF Picker"
|
||||||
|
description="Every search term (and your IP address) is sent directly to Giphy, not through your homeserver. Off by default."
|
||||||
|
after={
|
||||||
|
<Switch variant="Primary" value={gifPickerEnabled} onChange={setGifPickerEnabled} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Show Hidden Events"
|
title="Show Hidden Events"
|
||||||
|
|||||||
@@ -56,6 +56,18 @@ test('coerces unknown persisted denoise model / ringtone id back to defaults', (
|
|||||||
assert.equal(valid.ringtoneId, 'chime');
|
assert.equal(valid.ringtoneId, 'chime');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('coerces unknown persisted seasonalThemeOverride back to auto', () => {
|
||||||
|
setStored(null);
|
||||||
|
const defaults = getSettings();
|
||||||
|
assert.equal(defaults.seasonalThemeOverride, 'auto');
|
||||||
|
|
||||||
|
setStored(JSON.stringify({ seasonalThemeOverride: 'retired-theme' }));
|
||||||
|
assert.equal(getSettings().seasonalThemeOverride, 'auto');
|
||||||
|
|
||||||
|
setStored(JSON.stringify({ seasonalThemeOverride: 'earthday' }));
|
||||||
|
assert.equal(getSettings().seasonalThemeOverride, 'earthday');
|
||||||
|
});
|
||||||
|
|
||||||
test('merges stored values over defaults', () => {
|
test('merges stored values over defaults', () => {
|
||||||
setStored(JSON.stringify({ callNoiseSuppression: 'off', someUnknownKey: 1 }));
|
setStored(JSON.stringify({ callNoiseSuppression: 'off', someUnknownKey: 1 }));
|
||||||
const s = getSettings();
|
const s = getSettings();
|
||||||
|
|||||||
+45
-14
@@ -25,6 +25,38 @@ export type DenoiseModelId = 'rnnoise' | 'speex' | 'dtln' | 'deepfilternet';
|
|||||||
// 'soft' / 'retro' are synthesized in-browser (see utils/ringtones.ts);
|
// 'soft' / 'retro' are synthesized in-browser (see utils/ringtones.ts);
|
||||||
// 'none' is silent (visual-only incoming-call UI).
|
// 'none' is silent (visual-only incoming-call UI).
|
||||||
export type RingtoneId = 'classic' | 'chime' | 'soft' | 'retro' | 'none';
|
export type RingtoneId = 'classic' | 'chime' | 'soft' | 'retro' | 'none';
|
||||||
|
|
||||||
|
export type SeasonalThemeOverride =
|
||||||
|
| 'auto'
|
||||||
|
| 'off'
|
||||||
|
| 'halloween'
|
||||||
|
| 'christmas'
|
||||||
|
| 'newyear'
|
||||||
|
| 'autumn'
|
||||||
|
| 'aprilfools'
|
||||||
|
| 'lunar'
|
||||||
|
| 'valentines'
|
||||||
|
| 'stpatricks'
|
||||||
|
| 'earthday'
|
||||||
|
| 'deepspace'
|
||||||
|
| 'arcade';
|
||||||
|
|
||||||
|
// Allow-list used to validate persisted values below.
|
||||||
|
const SEASONAL_THEME_OVERRIDES: SeasonalThemeOverride[] = [
|
||||||
|
'auto',
|
||||||
|
'off',
|
||||||
|
'halloween',
|
||||||
|
'christmas',
|
||||||
|
'newyear',
|
||||||
|
'autumn',
|
||||||
|
'aprilfools',
|
||||||
|
'lunar',
|
||||||
|
'valentines',
|
||||||
|
'stpatricks',
|
||||||
|
'earthday',
|
||||||
|
'deepspace',
|
||||||
|
'arcade',
|
||||||
|
];
|
||||||
// [P5-31] Granular call quality caps. 'auto' = don't cap (the EC fork keeps its
|
// [P5-31] Granular call quality caps. 'auto' = don't cap (the EC fork keeps its
|
||||||
// default encoding). Numbers are kbps (audio/screenshare bitrate) or fps
|
// default encoding). Numbers are kbps (audio/screenshare bitrate) or fps
|
||||||
// (screenshare framerate); converted to the fork's bits/sec + fps payload in
|
// (screenshare framerate); converted to the fork's bits/sec + fps payload in
|
||||||
@@ -254,24 +286,15 @@ export interface Settings {
|
|||||||
soundboardEnabled: boolean;
|
soundboardEnabled: boolean;
|
||||||
soundboardVolume: number; // 0–100
|
soundboardVolume: number; // 0–100
|
||||||
|
|
||||||
seasonalThemeOverride:
|
seasonalThemeOverride: SeasonalThemeOverride;
|
||||||
| 'auto'
|
|
||||||
| 'off'
|
|
||||||
| 'halloween'
|
|
||||||
| 'christmas'
|
|
||||||
| 'newyear'
|
|
||||||
| 'autumn'
|
|
||||||
| 'aprilfools'
|
|
||||||
| 'lunar'
|
|
||||||
| 'valentines'
|
|
||||||
| 'stpatricks'
|
|
||||||
| 'earthday'
|
|
||||||
| 'deepspace'
|
|
||||||
| 'arcade';
|
|
||||||
|
|
||||||
// On-device message translation
|
// On-device message translation
|
||||||
translateTargetLang: string; // BCP-47 base code, default 'en'
|
translateTargetLang: string; // BCP-47 base code, default 'en'
|
||||||
autoTranslate: boolean; // auto-translate incoming foreign messages (opt-in)
|
autoTranslate: boolean; // auto-translate incoming foreign messages (opt-in)
|
||||||
|
|
||||||
|
// GIF picker sends every search term (and the user's IP) directly to Giphy,
|
||||||
|
// so it's opt-in and off by default.
|
||||||
|
gifPickerEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultSettings: Settings = {
|
const defaultSettings: Settings = {
|
||||||
@@ -374,6 +397,8 @@ const defaultSettings: Settings = {
|
|||||||
|
|
||||||
translateTargetLang: 'en',
|
translateTargetLang: 'en',
|
||||||
autoTranslate: false,
|
autoTranslate: false,
|
||||||
|
|
||||||
|
gifPickerEnabled: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSettings = (): Settings => {
|
export const getSettings = (): Settings => {
|
||||||
@@ -422,6 +447,12 @@ export const getSettings = (): Settings => {
|
|||||||
typeof saved.autoTranslate === 'boolean'
|
typeof saved.autoTranslate === 'boolean'
|
||||||
? saved.autoTranslate
|
? saved.autoTranslate
|
||||||
: defaultSettings.autoTranslate,
|
: defaultSettings.autoTranslate,
|
||||||
|
// Coerce any unknown/retired persisted seasonal theme id back to 'auto'.
|
||||||
|
seasonalThemeOverride: SEASONAL_THEME_OVERRIDES.includes(
|
||||||
|
saved.seasonalThemeOverride as SeasonalThemeOverride,
|
||||||
|
)
|
||||||
|
? (saved.seasonalThemeOverride as SeasonalThemeOverride)
|
||||||
|
: defaultSettings.seasonalThemeOverride,
|
||||||
composerToolbarButtons: {
|
composerToolbarButtons: {
|
||||||
...DEFAULT_COMPOSER_TOOLBAR,
|
...DEFAULT_COMPOSER_TOOLBAR,
|
||||||
...(saved.composerToolbarButtons ?? {}),
|
...(saved.composerToolbarButtons ?? {}),
|
||||||
|
|||||||
Reference in New Issue
Block a user