Harden settings types (#33311)

* Harden settings types

* Fix types

* Update apps/web/src/emojipicker/recent.ts

Co-authored-by: Will Hunt <2072976+Half-Shot@users.noreply.github.com>

* Fix suggestion

---------

Co-authored-by: Will Hunt <2072976+Half-Shot@users.noreply.github.com>
This commit is contained in:
Michael Telatynski 2026-04-29 09:46:09 +01:00 committed by GitHub
parent 4bee845010
commit 5ff302539e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 104 additions and 97 deletions

View File

@ -25,9 +25,9 @@ export interface MediaPreviewConfig extends Record<string, unknown> {
/**
* Media preview setting for thumbnails of media in rooms.
*/
media_previews: MediaPreviewValue;
media_previews?: MediaPreviewValue;
/**
* Media preview settings for avatars of rooms we have been invited to.
*/
invite_avatars: MediaPreviewValue.On | MediaPreviewValue.Off;
invite_avatars?: MediaPreviewValue.On | MediaPreviewValue.Off;
}

View File

@ -725,7 +725,7 @@ export default class LegacyCallHandler extends TypedEventEmitter<LegacyCallHandl
);
finished.then(([allow]) => {
SettingsStore.setValue("fallbackICEServerAllowed", null, SettingLevel.DEVICE, allow);
SettingsStore.setValue("fallbackICEServerAllowed", null, SettingLevel.DEVICE, allow ?? null);
});
}

View File

@ -142,6 +142,16 @@ interface EmittedEvents {
[NotifierEvent.NotificationHiddenChange]: (hidden: boolean) => void;
}
/**
* Type representing a notification sound setting
*/
export type NotificationSound = {
url: string;
name?: string;
type?: string;
size?: number;
};
class NotifierClass extends TypedEventEmitter<keyof EmittedEvents, EmittedEvents> {
private notifsByRoom: Record<string, Notification[]> = {};
@ -223,12 +233,7 @@ class NotifierClass extends TypedEventEmitter<keyof EmittedEvents, EmittedEvents
}
}
public getSoundForRoom(roomId: string): {
url: string;
name: string;
type: string;
size: number;
} | null {
public getSoundForRoom(roomId: string): NotificationSound | null {
// We do no caching here because the SDK caches setting
// and the browser will cache the sound.
const content = SettingsStore.getValue("notificationSound", roomId);

View File

@ -19,7 +19,7 @@ import SettingsStore from "./settings/SettingsStore";
import { type ScreenName } from "./PosthogTrackers";
import { type ActionPayload } from "./dispatcher/payloads";
import { Action } from "./dispatcher/actions";
import { type SettingUpdatedPayload } from "./dispatcher/payloads/SettingUpdatedPayload";
import { isSettingUpdatedPayload, type SettingUpdatedPayload } from "./dispatcher/payloads/SettingUpdatedPayload";
import dis from "./dispatcher/dispatcher";
import { Layout } from "./settings/enums/Layout";
@ -199,8 +199,8 @@ export class PosthogAnalytics {
const settingsPayload = payload as SettingUpdatedPayload;
if (["layout", "useCompactLayout"].includes(settingsPayload.settingName)) {
this.onLayoutUpdated();
} else if (settingsPayload.settingName === "urlPreviewsEnabled" && !settingsPayload.roomId) {
this.onUrlPreviewSettingUpdated(settingsPayload.newValue as boolean);
} else if (isSettingUpdatedPayload(settingsPayload, "urlPreviewsEnabled") && !settingsPayload.roomId) {
this.onUrlPreviewSettingUpdated(settingsPayload.newValue);
}
};

View File

@ -122,7 +122,7 @@ export default class ManageEventIndexDialog extends React.Component<IProps, ISta
private onCrawlerSleepTimeChange = (e: ChangeEvent<HTMLInputElement>): void => {
this.setState({ crawlerSleepTime: parseInt(e.target.value, 10) });
SettingsStore.setValue("crawlerSleepTime", null, SettingLevel.DEVICE, e.target.value);
SettingsStore.setValue("crawlerSleepTime", null, SettingLevel.DEVICE, e.target.valueAsNumber);
};
public render(): React.ReactNode {

View File

@ -1794,11 +1794,11 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
SettingsStore.watchSetting(
"blacklistUnverifiedDevices",
null,
(_settingName, _roomId, atLevel, blacklistEnabled: boolean) => {
(_settingName, _roomId, atLevel, blacklistEnabled) => {
if (atLevel != SettingLevel.DEVICE) {
return;
}
crypto.globalBlacklistUnverifiedDevices = blacklistEnabled;
crypto.globalBlacklistUnverifiedDevices = !!blacklistEnabled;
},
);
}

View File

@ -38,8 +38,8 @@ function LayoutSelector(): JSX.Element {
<Root
className="mx_LayoutSwitcher_LayoutSelector"
onChange={async (evt) => {
// We don't have any file in the form, we can cast it as string safely
const newLayout = new FormData(evt.currentTarget).get("layout") as string | null;
// We don't have any file in the form, we can cast it as Layout safely
const newLayout = new FormData(evt.currentTarget).get("layout") as Layout;
await SettingsStore.setValue("layout", null, SettingLevel.DEVICE, newLayout);
}}
>

View File

@ -211,17 +211,17 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
private onAutocompleteDelayChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
this.setState({ autocompleteDelay: e.target.value });
SettingsStore.setValue("autocompleteDelay", null, SettingLevel.DEVICE, e.target.value);
SettingsStore.setValue("autocompleteDelay", null, SettingLevel.DEVICE, e.target.valueAsNumber);
};
private onReadMarkerInViewThresholdMs = (e: React.ChangeEvent<HTMLInputElement>): void => {
this.setState({ readMarkerInViewThresholdMs: e.target.value });
SettingsStore.setValue("readMarkerInViewThresholdMs", null, SettingLevel.DEVICE, e.target.value);
SettingsStore.setValue("readMarkerInViewThresholdMs", null, SettingLevel.DEVICE, e.target.valueAsNumber);
};
private onReadMarkerOutOfViewThresholdMs = (e: React.ChangeEvent<HTMLInputElement>): void => {
this.setState({ readMarkerOutOfViewThresholdMs: e.target.value });
SettingsStore.setValue("readMarkerOutOfViewThresholdMs", null, SettingLevel.DEVICE, e.target.value);
SettingsStore.setValue("readMarkerOutOfViewThresholdMs", null, SettingLevel.DEVICE, e.target.valueAsNumber);
};
private renderGroup(settingIds: BooleanSettingKey[], level = SettingLevel.ACCOUNT): JSX.Element {

View File

@ -340,7 +340,7 @@ export class DeviceListener {
});
}
private onRecordClientInformationSettingChange: CallbackFn = (
private onRecordClientInformationSettingChange: CallbackFn<"deviceClientInformationOptIn"> = (
_originalSettingName,
_roomId,
_level,

View File

@ -9,14 +9,26 @@ Please see LICENSE files in the repository root for full details.
import { type ActionPayload } from "../payloads";
import { type Action } from "../actions";
import { type SettingLevel } from "../../settings/SettingLevel";
import { type SettingValueType } from "../../settings/Settings";
import { type SettingKey, type Settings } from "../../settings/Settings";
export interface SettingUpdatedPayload extends ActionPayload {
export interface SettingUpdatedPayload<S extends SettingKey = SettingKey> extends ActionPayload {
action: Action.SettingUpdated;
settingName: string;
settingName: S;
roomId: string | null;
level: SettingLevel;
newValueAtLevel: SettingLevel;
newValue: SettingValueType;
newValueAtLevel: Settings[S]["default"];
newValue: Settings[S]["default"];
}
/**
* Type guard to check if a payload is a SettingUpdatedPayload for a specific setting.
* @param payload the payload to assert
* @param settingName the setting name to check for
*/
export function isSettingUpdatedPayload<S extends SettingKey>(
payload: SettingUpdatedPayload<any>,
settingName: S,
): payload is SettingUpdatedPayload<S> {
return payload.settingName === settingName;
}

View File

@ -29,7 +29,7 @@ const STORAGE_LIMIT = 100;
function migrate(): void {
const data: ILegacyFormat = JSON.parse(window.localStorage.mx_reaction_count || "{}");
const sorted = Object.entries(data).sort(([, [count1, date1]], [, [count2, date2]]) => date2 - date1);
const newFormat = sorted.map(([emoji, [count, date]]) => [emoji, count]);
const newFormat = sorted.map<RecentEmojiData[number]>(([emoji, [count, date]]) => [emoji, count]);
SettingsStore.setValue(SETTING_NAME, null, SettingLevel.ACCOUNT, newFormat.slice(0, STORAGE_LIMIT));
}
@ -41,7 +41,7 @@ export function add(emoji: string): void {
const recents = getRecentEmoji();
const i = recents.findIndex(([e]) => e === emoji);
let newEntry;
let newEntry: RecentEmojiData[number];
if (i >= 0) {
// first remove the existing tuple so that we can increment it and push it to the front
[newEntry] = recents.splice(i, 1);

View File

@ -11,7 +11,7 @@ import { logger } from "matrix-js-sdk/src/logger";
import { MatrixClientPeg } from "../MatrixClientPeg";
import { ALL_RULE_TYPES, BanList } from "./BanList";
import SettingsStore from "../settings/SettingsStore";
import SettingsStore, { type CallbackFn } from "../settings/SettingsStore";
import { _t } from "../languageHandler";
import dis from "../dispatcher/dispatcher";
import { SettingLevel } from "../settings/SettingLevel";
@ -38,7 +38,7 @@ export class Mjolnir {
}
public start(): void {
this.mjolnirWatchRef = SettingsStore.watchSetting("mjolnirRooms", null, this.onListsChanged.bind(this));
this.mjolnirWatchRef = SettingsStore.watchSetting("mjolnirRooms", null, this.onListsChanged);
this.dispatcherRef = dis.register(this.onAction);
dis.dispatch<DoAfterSyncPreparedPayload<ActionPayload>>({
@ -130,15 +130,10 @@ export class Mjolnir {
this.updateLists(this._roomIds);
};
private onListsChanged(
settingName: string,
roomId: string | null,
atLevel: SettingLevel,
newValue: string[],
): void {
private onListsChanged: CallbackFn<"mjolnirRooms"> = (settingName, roomId, atLevel, newValue): void => {
// We know that ban lists are only recorded at one level so we don't need to re-eval them
this.updateLists(newValue);
}
this.updateLists(newValue ?? []);
};
private updateLists(listRoomIds: string[]): void {
if (!MatrixClientPeg.get()) return;

View File

@ -54,6 +54,7 @@ import { type ComputedInviteConfig } from "../@types/invite-rules.ts";
import BlockInvitesConfigController from "./controllers/BlockInvitesConfigController.ts";
import RequiresSettingsController from "./controllers/RequiresSettingsController.ts";
import { type OrderedCustomSections, type CustomSectionsData } from "../stores/room-list-v3/section.ts";
import { type NotificationSound } from "../Notifier.ts";
export const defaultWatchManager = new WatchManager();
@ -311,15 +312,7 @@ export interface Settings {
"urlPreviewsEnabled_e2ee": IBaseSetting<boolean>;
"notificationsEnabled": IBaseSetting<boolean>;
"deviceNotificationsEnabled": IBaseSetting<boolean>;
"notificationSound": IBaseSetting<
| {
name: string;
type: string;
size: number;
url: string;
}
| false
>;
"notificationSound": IBaseSetting<NotificationSound | false>;
"notificationBodyEnabled": IBaseSetting<boolean>;
"audioNotificationsEnabled": IBaseSetting<boolean>;
"enableWidgetScreenshots": IBaseSetting<boolean>;

View File

@ -79,7 +79,7 @@ export const LEVEL_ORDER = [
SettingLevel.DEFAULT,
];
function getLevelOrder(setting: ISetting): SettingLevel[] {
function getLevelOrder(setting: Settings[keyof Settings]): SettingLevel[] {
// Settings which support only a single setting level are inherently ordered
if (setting.supportedLevelsAreOrdered || setting.supportedLevels.length === 1) {
// return a copy to prevent callers from modifying the array
@ -88,12 +88,12 @@ function getLevelOrder(setting: ISetting): SettingLevel[] {
return LEVEL_ORDER;
}
export type CallbackFn = (
settingName: SettingKey,
export type CallbackFn<S extends SettingKey> = (
settingName: S,
roomId: string | null,
atLevel: SettingLevel,
newValAtLevel: any,
newVal: any,
newValAtLevel: Settings[S]["default"] | null,
newVal: Settings[S]["default"] | null,
) => void;
type HandlerMap = Partial<{
@ -167,7 +167,11 @@ export default class SettingsStore {
* if the change in value is worthwhile enough to react upon.
* @returns {string} A reference to the watcher that was employed.
*/
public static watchSetting(settingName: SettingKey, roomId: string | null, callbackFn: CallbackFn): string {
public static watchSetting<S extends SettingKey>(
settingName: S,
roomId: string | null,
callbackFn: CallbackFn<S>,
): string {
const setting = SETTINGS[settingName];
if (!setting) throw new Error(`${settingName} is not a setting`);
@ -175,7 +179,11 @@ export default class SettingsStore {
const watcherId = `${new Date().getTime()}_${SettingsStore.watcherCount++}_${finalSettingName}_${roomId}`;
const localizedCallback = (changedInRoomId: string | null, atLevel: SettingLevel, newValAtLevel: any): void => {
const localizedCallback = (
changedInRoomId: string | null,
atLevel: SettingLevel,
newValAtLevel: Settings[S]["default"],
): void => {
if (!SettingsStore.doesSettingSupportLevel(settingName, atLevel)) {
logger.warn(
`Setting handler notified for an update of an invalid setting level: ` +
@ -220,7 +228,7 @@ export default class SettingsStore {
* @param {string} settingName The setting name to monitor.
* @param {String} roomId The room ID to monitor for changes in. Use null for all rooms.
*/
public static monitorSetting(settingName: SettingKey, roomId: string | null): void {
public static monitorSetting<S extends SettingKey>(settingName: S, roomId: string | null): void {
roomId = roomId || null; // the thing wants null specifically to work, so appease it.
if (!this.monitors.has(settingName)) this.monitors.set(settingName, new Map());
@ -228,7 +236,7 @@ export default class SettingsStore {
const registerWatcher = (): void => {
this.monitors.get(settingName)!.set(
roomId,
SettingsStore.watchSetting(
SettingsStore.watchSetting<S>(
settingName,
roomId,
(settingName, inRoomId, level, newValueAtLevel, newValue) => {
@ -449,11 +457,10 @@ export default class SettingsStore {
/**
* Gets the default value of a setting.
* @param {string} settingName The name of the setting to read the value of.
* @param {String} roomId The room ID to read the setting value in, may be null.
* @return {*} The default value
* @param settingName The name of the setting to read the value of.
* @return The default value
*/
public static getDefaultValue(settingName: SettingKey): any {
public static getDefaultValue<S extends SettingKey>(settingName: S): Settings[S]["default"] {
// Verify that the setting is actually a setting
if (!SETTINGS[settingName]) {
throw new Error("Setting '" + settingName + "' does not appear to be a setting.");
@ -462,13 +469,13 @@ export default class SettingsStore {
return SETTINGS[settingName].default;
}
private static getFinalValue(
setting: ISetting,
private static getFinalValue<S extends SettingKey>(
setting: Settings[S],
level: SettingLevel,
roomId: string | null,
calculatedValue: any,
calculatedValue: Settings[S]["default"],
calculatedAtLevel: SettingLevel | null,
): any {
): Settings[S]["default"] {
let resultingValue = calculatedValue;
if (setting.controller) {
@ -480,25 +487,22 @@ export default class SettingsStore {
return resultingValue;
}
/* eslint-disable valid-jsdoc */ //https://github.com/eslint/eslint/issues/7307
/**
* Sets the value for a setting. The room ID is optional if the setting is not being
* set for a particular room, otherwise it should be supplied. The value may be null
* to indicate that the level should no longer have an override.
* @param {string} settingName The name of the setting to change.
* @param {String} roomId The room ID to change the value in, may be null.
* @param {SettingLevel} level The level
* @param settingName The name of the setting to change.
* @param roomId The room ID to change the value in, may be null.
* @param level The level
* to change the value at.
* @param {*} value The new value of the setting, may be null.
* @return {Promise} Resolves when the setting has been changed.
* @param value The new value of the setting, may be null.
* @return Resolves when the setting has been changed.
*/
/* eslint-enable valid-jsdoc */
public static async setValue(
settingName: SettingKey,
public static async setValue<S extends SettingKey>(
settingName: S,
roomId: string | null,
level: SettingLevel,
value: any,
value: Settings[S]["default"] | null,
): Promise<void> {
// Verify that the setting is actually a setting
const setting = SETTINGS[settingName];

View File

@ -38,8 +38,8 @@ export default class MediaPreviewConfigController extends MatrixClientBackedCont
const validMediaPreviews = Object.values(MediaPreviewValue);
const validInviteAvatars = [MediaPreviewValue.Off, MediaPreviewValue.On];
return {
invite_avatars: validInviteAvatars.includes(inviteAvatars) ? inviteAvatars : undefined,
media_previews: validMediaPreviews.includes(mediaPreviews) ? mediaPreviews : undefined,
invite_avatars: validInviteAvatars.includes(inviteAvatars!) ? inviteAvatars : undefined,
media_previews: validMediaPreviews.includes(mediaPreviews!) ? mediaPreviews : undefined,
};
}

View File

@ -60,8 +60,8 @@ export type IRightPanelForRoomStored = {
history: Array<IRightPanelCardStored>;
};
export function convertToStorePanel(cacheRoom?: IRightPanelForRoom): IRightPanelForRoomStored | undefined {
if (!cacheRoom) return undefined;
export function convertToStorePanel(cacheRoom?: IRightPanelForRoom): IRightPanelForRoomStored | null {
if (!cacheRoom) return null;
const storeHistory = [...cacheRoom.history].map((panelState) => convertCardToStore(panelState));
return { isOpen: cacheRoom.isOpen, history: storeHistory };
}

View File

@ -69,7 +69,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient<EmptyObject> implem
"feature_dynamic_room_predecessors",
null,
(_settingName, _roomId, _level, _newValAtLevel, newVal) => {
this.msc3946ProcessDynamicPredecessor = newVal;
this.msc3946ProcessDynamicPredecessor = !!newVal;
this.regenerateAllLists({ trigger: true });
},
);

View File

@ -231,7 +231,7 @@ export class EventContentBodyViewModel
"TextualBody.enableBigEmoji",
null,
(_settingName, _roomId, _level, _newValAtLevel, newVal) => {
this.setEnableBigEmoji(newVal);
this.setEnableBigEmoji(!!newVal);
},
);
this.disposables.track(() => SettingsStore.unwatchSetting(enableBigEmojiWatcherRef));
@ -240,7 +240,7 @@ export class EventContentBodyViewModel
"Pill.shouldShowPillAvatar",
null,
(_settingName, _roomId, _level, _newValAtLevel, newVal) => {
this.setShouldShowPillAvatar(newVal);
this.setShouldShowPillAvatar(!!newVal);
},
);
this.disposables.track(() => SettingsStore.unwatchSetting(shouldShowPillAvatarWatcherRef));

View File

@ -75,7 +75,7 @@ export class RedactedBodyViewModel
(_settingName, _roomId, _level, _newValAtLevel, newVal) => {
if (this.showTwelveHour === newVal) return;
this.showTwelveHour = newVal;
this.showTwelveHour = !!newVal;
this.updateTooltip();
},
);

View File

@ -296,7 +296,7 @@ export class UrlPreviewGroupViewModel
null,
(_setting, _roomid, _level, compactLayout) => {
this.snapshot.merge({
compactLayout,
compactLayout: !!compactLayout,
});
},
);

View File

@ -79,7 +79,7 @@ export class DateSeparatorViewModel
"feature_jump_to_date",
null,
(_settingName, _roomId, _level, _newValAtLevel, newVal) => {
this.jumpToDateEnabled = newVal;
this.jumpToDateEnabled = !!newVal;
this.updateSnapshot();
},
);
@ -89,7 +89,7 @@ export class DateSeparatorViewModel
UIFeature.TimelineEnableRelativeDates,
null,
(_settingName, _roomId, _level, _newValAtLevel, newVal) => {
this.relativeDatesEnabled = newVal;
this.relativeDatesEnabled = !!newVal;
this.updateSnapshot();
},
);

View File

@ -557,7 +557,7 @@ describe("<MatrixChat />", () => {
});
it("should not persist device language when not available", async () => {
await SettingsStore.setValue("language", null, SettingLevel.DEVICE, undefined);
await SettingsStore.setValue("language", null, SettingLevel.DEVICE, null);
const languageBefore = SettingsStore.getValueAt(SettingLevel.DEVICE, "language", null, true, true);
jest.spyOn(Lifecycle, "attemptDelegatedAuthLogin");

View File

@ -225,7 +225,7 @@ describe("<UserSettingsDialog />", () => {
});
it("watches settings", async () => {
const watchSettingCallbacks: Record<string, CallbackFn> = {};
const watchSettingCallbacks: Record<string, CallbackFn<any>> = {};
mockSettingsStore.watchSetting.mockImplementation((settingName, roomId, callback) => {
watchSettingCallbacks[settingName] = callback;

View File

@ -28,7 +28,7 @@ describe("ReleaseAnnouncementStore", () => {
settings = {
releaseAnnouncementData: {},
};
const watchCallbacks: Array<CallbackFn> = [];
const watchCallbacks: Array<CallbackFn<any>> = [];
mocked(SettingsStore.getValue).mockImplementation((setting: string) => {
return settings[setting];

View File

@ -203,13 +203,11 @@ describe("RoomListStore", () => {
let featureFlagValue = false;
jest.spyOn(SettingsStore, "getValue").mockImplementation(() => featureFlagValue);
let watchCallback: CallbackFn | undefined;
jest.spyOn(SettingsStore, "watchSetting").mockImplementation(
(_settingName: string, _roomId: string | null, callbackFn: CallbackFn) => {
watchCallback = callbackFn;
return "dyn_pred_ref";
},
);
let watchCallback: CallbackFn<any> | undefined;
jest.spyOn(SettingsStore, "watchSetting").mockImplementation((_settingName, _roomId, callbackFn) => {
watchCallback = callbackFn;
return "dyn_pred_ref";
});
jest.spyOn(SettingsStore, "unwatchSetting");
const { store } = createStore();

View File

@ -664,7 +664,7 @@ describe("RoomListItemViewModel", () => {
});
it("should update sections when OrderedCustomSections setting changes", () => {
let watchCallback: CallbackFn = () => {};
let watchCallback: CallbackFn<"RoomList.OrderedCustomSections"> = () => {};
jest.spyOn(SettingsStore, "watchSetting").mockImplementation((setting, _room, callback) => {
if (setting === "RoomList.OrderedCustomSections") watchCallback = callback;
return "watcher-id";