Make existing and new issue URLs configurable (#10710)

* Make existing and new issue URLs configurable

* Apply a deep merge over sdk config to allow sane nested structures

* Defaultize

* Fix types

* Iterate

* Add FeedbackDialog snapshot test

* Add SdkConfig snapshot tests

* Iterate

* Fix tests

* Iterate types

* Fix test
This commit is contained in:
Michael Telatynski 2023-04-26 10:36:00 +01:00 committed by GitHub
parent e4610e4672
commit 6166dbb661
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 259 additions and 78 deletions

View file

@ -54,3 +54,25 @@ export type KeysStartingWith<Input extends object, Str extends string> = {
}[keyof Input];
export type NonEmptyArray<T> = [T, ...T[]];
export type Defaultize<P, D> = P extends any
? string extends keyof P
? P
: Pick<P, Exclude<keyof P, keyof D>> &
Partial<Pick<P, Extract<keyof P, keyof D>>> &
Partial<Pick<D, Exclude<keyof D, keyof P>>>
: never;
export type DeepReadonly<T> = T extends (infer R)[]
? DeepReadonlyArray<R>
: T extends Function
? T
: T extends object
? DeepReadonlyObject<T>
: T;
interface DeepReadonlyArray<T> extends ReadonlyArray<DeepReadonly<T>> {}
type DeepReadonlyObject<T> = {
readonly [P in keyof T]: DeepReadonly<T[P]>;
};

View file

@ -49,6 +49,7 @@ import ActiveWidgetStore from "../stores/ActiveWidgetStore";
import AutoRageshakeStore from "../stores/AutoRageshakeStore";
import { IConfigOptions } from "../IConfigOptions";
import { MatrixDispatcher } from "../dispatcher/dispatcher";
import { DeepReadonly } from "./common";
/* eslint-disable @typescript-eslint/naming-convention */
@ -59,7 +60,7 @@ declare global {
Olm: {
init: () => Promise<void>;
};
mxReactSdkConfig: IConfigOptions;
mxReactSdkConfig: DeepReadonly<IConfigOptions>;
// Needed for Safari, unknown to TypeScript
webkitAudioContext: typeof AudioContext;

View file

@ -186,6 +186,11 @@ export interface IConfigOptions {
description: string;
show_once?: boolean;
};
feedback: {
existing_issues_url: string;
new_issue_url: string;
};
}
export interface ISsoRedirectOptions {

View file

@ -23,6 +23,7 @@ import { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter"
import dis from "./dispatcher/dispatcher";
import AsyncWrapper from "./AsyncWrapper";
import { Defaultize } from "./@types/common";
const DIALOG_CONTAINER_ID = "mx_Dialog_Container";
const STATIC_DIALOG_CONTAINER_ID = "mx_Dialog_StaticContainer";
@ -32,14 +33,6 @@ export type ComponentType = React.ComponentType<{
onFinished?(...args: any): void;
}>;
type Defaultize<P, D> = P extends any
? string extends keyof P
? P
: Pick<P, Exclude<keyof P, keyof D>> &
Partial<Pick<P, Extract<keyof P, keyof D>>> &
Partial<Pick<D, Exclude<keyof D, keyof P>>>
: never;
// Generic type which returns the props of the Modal component with the onFinished being optional.
export type ComponentProps<C extends ComponentType> = Defaultize<
Omit<React.ComponentProps<C>, "onFinished">,

View file

@ -16,12 +16,15 @@ limitations under the License.
*/
import { Optional } from "matrix-events-sdk";
import { mergeWith } from "lodash";
import { SnakedObject } from "./utils/SnakedObject";
import { IConfigOptions, ISsoRedirectOptions } from "./IConfigOptions";
import { isObject, objectClone } from "./utils/objects";
import { DeepReadonly, Defaultize } from "./@types/common";
// see element-web config.md for docs, or the IConfigOptions interface for dev docs
export const DEFAULTS: IConfigOptions = {
export const DEFAULTS: DeepReadonly<IConfigOptions> = {
brand: "Element",
integrations_ui_url: "https://scalar.vector.im/",
integrations_rest_url: "https://scalar.vector.im/api",
@ -50,13 +53,43 @@ export const DEFAULTS: IConfigOptions = {
chunk_length: 2 * 60, // two minutes
max_length: 4 * 60 * 60, // four hours
},
feedback: {
existing_issues_url:
"https://github.com/vector-im/element-web/issues?q=is%3Aopen+is%3Aissue+sort%3Areactions-%2B1-desc",
new_issue_url: "https://github.com/vector-im/element-web/issues/new/choose",
},
};
export default class SdkConfig {
private static instance: IConfigOptions;
private static fallback: SnakedObject<IConfigOptions>;
export type ConfigOptions = Defaultize<IConfigOptions, typeof DEFAULTS>;
private static setInstance(i: IConfigOptions): void {
function mergeConfig(
config: DeepReadonly<IConfigOptions>,
changes: DeepReadonly<Partial<IConfigOptions>>,
): DeepReadonly<IConfigOptions> {
// return { ...config, ...changes };
return mergeWith(objectClone(config), changes, (objValue, srcValue) => {
// Don't merge arrays, prefer values from newer object
if (Array.isArray(objValue)) {
return srcValue;
}
// Don't allow objects to get nulled out, this will break our types
if (isObject(objValue) && !isObject(srcValue)) {
return objValue;
}
});
}
type ObjectType<K extends keyof IConfigOptions> = IConfigOptions[K] extends object
? SnakedObject<NonNullable<IConfigOptions[K]>>
: Optional<SnakedObject<NonNullable<IConfigOptions[K]>>>;
export default class SdkConfig {
private static instance: DeepReadonly<IConfigOptions>;
private static fallback: SnakedObject<DeepReadonly<IConfigOptions>>;
private static setInstance(i: DeepReadonly<IConfigOptions>): void {
SdkConfig.instance = i;
SdkConfig.fallback = new SnakedObject(i);
@ -69,7 +102,7 @@ export default class SdkConfig {
public static get<K extends keyof IConfigOptions = never>(
key?: K,
altCaseName?: string,
): IConfigOptions | IConfigOptions[K] {
): DeepReadonly<IConfigOptions> | DeepReadonly<IConfigOptions>[K] {
if (key === undefined) {
// safe to cast as a fallback - we want to break the runtime contract in this case
return SdkConfig.instance || <IConfigOptions>{};
@ -77,32 +110,29 @@ export default class SdkConfig {
return SdkConfig.fallback.get(key, altCaseName);
}
public static getObject<K extends keyof IConfigOptions>(
key: K,
altCaseName?: string,
): Optional<SnakedObject<NonNullable<IConfigOptions[K]>>> {
public static getObject<K extends keyof IConfigOptions>(key: K, altCaseName?: string): ObjectType<K> {
const val = SdkConfig.get(key, altCaseName);
if (val !== null && val !== undefined) {
if (isObject(val)) {
return new SnakedObject(val);
}
// return the same type for sensitive callers (some want `undefined` specifically)
return val === undefined ? undefined : null;
return (val === undefined ? undefined : null) as ObjectType<K>;
}
public static put(cfg: Partial<IConfigOptions>): void {
SdkConfig.setInstance({ ...DEFAULTS, ...cfg });
public static put(cfg: DeepReadonly<ConfigOptions>): void {
SdkConfig.setInstance(mergeConfig(DEFAULTS, cfg));
}
/**
* Resets the config to be completely empty.
* Resets the config.
*/
public static unset(): void {
SdkConfig.setInstance(<IConfigOptions>{}); // safe to cast - defaults will be applied
public static reset(): void {
SdkConfig.setInstance(mergeConfig(DEFAULTS, {})); // safe to cast - defaults will be applied
}
public static add(cfg: Partial<IConfigOptions>): void {
SdkConfig.put({ ...SdkConfig.get(), ...cfg });
public static add(cfg: Partial<ConfigOptions>): void {
SdkConfig.put(mergeConfig(SdkConfig.get(), cfg));
}
}

View file

@ -66,11 +66,11 @@ import RightPanelStore from "../../stores/right-panel/RightPanelStore";
import { TimelineRenderingType } from "../../contexts/RoomContext";
import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts";
import { SwitchSpacePayload } from "../../dispatcher/payloads/SwitchSpacePayload";
import { IConfigOptions } from "../../IConfigOptions";
import LeftPanelLiveShareWarning from "../views/beacon/LeftPanelLiveShareWarning";
import { UserOnboardingPage } from "../views/user-onboarding/UserOnboardingPage";
import { PipContainer } from "./PipContainer";
import { monitorSyncedPushRules } from "../../utils/pushRules/monitorSyncedPushRules";
import { ConfigOptions } from "../../SdkConfig";
// We need to fetch each pinned message individually (if we don't already have it)
// so each pinned message may trigger a request. Limit the number per room for sanity.
@ -98,7 +98,7 @@ interface IProps {
roomOobData?: IOOBData;
currentRoomId: string;
collapseLhs: boolean;
config: IConfigOptions;
config: ConfigOptions;
currentUserId?: string;
justRegistered?: boolean;
roomJustCreatedOpts?: IOpts;

View file

@ -28,10 +28,6 @@ import { submitFeedback } from "../../../rageshake/submit-rageshake";
import { useStateToggle } from "../../../hooks/useStateToggle";
import StyledCheckbox from "../elements/StyledCheckbox";
const existingIssuesUrl =
"https://github.com/vector-im/element-web/issues" + "?q=is%3Aopen+is%3Aissue+sort%3Areactions-%2B1-desc";
const newIssueUrl = "https://github.com/vector-im/element-web/issues/new/choose";
interface IProps {
feature?: string;
onFinished(): void;
@ -117,6 +113,9 @@ const FeedbackDialog: React.FC<IProps> = (props: IProps) => {
);
}
const existingIssuesUrl = SdkConfig.getObject("feedback").get("existing_issues_url");
const newIssueUrl = SdkConfig.getObject("feedback").get("new_issue_url");
return (
<QuestionDialog
className="mx_FeedbackDialog"

View file

@ -53,7 +53,7 @@ import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import { isVideoRoom as calcIsVideoRoom } from "../../../utils/video-rooms";
import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../../LegacyCallHandler";
import { useFeatureEnabled, useSettingValue } from "../../../hooks/useSettings";
import SdkConfig, { DEFAULTS } from "../../../SdkConfig";
import SdkConfig from "../../../SdkConfig";
import { useEventEmitterState, useTypedEventEmitterState } from "../../../hooks/useEventEmitter";
import { useWidgets } from "../right_panel/RoomSummaryCard";
import { WidgetType } from "../../../widgets/WidgetType";
@ -207,7 +207,7 @@ const VideoCallButton: FC<VideoCallButtonProps> = ({ room, busy, setBusy, behavi
let menu: JSX.Element | null = null;
if (menuOpen) {
const buttonRect = buttonRef.current!.getBoundingClientRect();
const brand = SdkConfig.get("element_call").brand ?? DEFAULTS.element_call.brand;
const brand = SdkConfig.get("element_call").brand;
menu = (
<IconizedContextMenu {...aboveLeftOf(buttonRect)} onFinished={closeMenu}>
<IconizedContextMenuOptionList>
@ -250,7 +250,7 @@ const CallButtons: FC<CallButtonsProps> = ({ room }) => {
const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms");
const isVideoRoom = useMemo(() => videoRoomsEnabled && calcIsVideoRoom(room), [videoRoomsEnabled, room]);
const useElementCallExclusively = useMemo(() => {
return SdkConfig.get("element_call").use_exclusively ?? DEFAULTS.element_call.use_exclusively;
return SdkConfig.get("element_call").use_exclusively;
}, []);
const hasLegacyCall = useEventEmitterState(

View file

@ -18,6 +18,7 @@ import { MatrixClient } from "matrix-js-sdk/src/client";
import BasePlatform from "../../BasePlatform";
import { IConfigOptions } from "../../IConfigOptions";
import { DeepReadonly } from "../../@types/common";
export type DeviceClientInformation = {
name?: string;
@ -49,7 +50,7 @@ export const getClientInformationEventType = (deviceId: string): string => `${cl
*/
export const recordClientInformation = async (
matrixClient: MatrixClient,
sdkConfig: IConfigOptions,
sdkConfig: DeepReadonly<IConfigOptions>,
platform?: BasePlatform,
): Promise<void> => {
const deviceId = matrixClient.getDeviceId()!;

View file

@ -141,3 +141,12 @@ export function objectKeyChanges<O extends {}>(a: O, b: O): (keyof O)[] {
export function objectClone<O extends {}>(obj: O): O {
return JSON.parse(JSON.stringify(obj));
}
/**
* Simple object check.
* @param item
* @returns {boolean}
*/
export function isObject(item: any): item is object {
return item && typeof item === "object" && !Array.isArray(item);
}