Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Make existing and new issue URLs configurable #10710

Merged
merged 12 commits into from
Apr 26, 2023
22 changes: 22 additions & 0 deletions src/@types/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]>;
};
3 changes: 2 additions & 1 deletion src/@types/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */

Expand All @@ -59,7 +60,7 @@ declare global {
Olm: {
init: () => Promise<void>;
};
mxReactSdkConfig: IConfigOptions;
mxReactSdkConfig: DeepReadonly<IConfigOptions>;

// Needed for Safari, unknown to TypeScript
webkitAudioContext: typeof AudioContext;
Expand Down
5 changes: 5 additions & 0 deletions src/IConfigOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,11 @@ export interface IConfigOptions {
description: string;
show_once?: boolean;
};

feedback: {
existing_issues_url: string;
new_issue_url: string;
};
}

export interface ISsoRedirectOptions {
Expand Down
9 changes: 1 addition & 8 deletions src/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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">,
Expand Down
66 changes: 48 additions & 18 deletions src/SdkConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 type ConfigOptions = Defaultize<IConfigOptions, typeof DEFAULTS>;

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: IConfigOptions;
private static fallback: SnakedObject<IConfigOptions>;
private static instance: DeepReadonly<IConfigOptions>;
private static fallback: SnakedObject<DeepReadonly<IConfigOptions>>;

private static setInstance(i: IConfigOptions): void {
private static setInstance(i: DeepReadonly<IConfigOptions>): void {
SdkConfig.instance = i;
SdkConfig.fallback = new SnakedObject(i);

Expand All @@ -69,40 +102,37 @@ 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>{};
}
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));
}
}

Expand Down
4 changes: 2 additions & 2 deletions src/components/structures/LoggedInView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -98,7 +98,7 @@ interface IProps {
roomOobData?: IOOBData;
currentRoomId: string;
collapseLhs: boolean;
config: IConfigOptions;
config: ConfigOptions;
currentUserId?: string;
justRegistered?: boolean;
roomJustCreatedOpts?: IOpts;
Expand Down
7 changes: 3 additions & 4 deletions src/components/views/dialogs/FeedbackDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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"
Expand Down
6 changes: 3 additions & 3 deletions src/components/views/rooms/RoomHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -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(
Expand Down
3 changes: 2 additions & 1 deletion src/utils/device/clientInformation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()!;
Expand Down
9 changes: 9 additions & 0 deletions src/utils/objects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
4 changes: 2 additions & 2 deletions test/LegacyCallHandler-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ describe("LegacyCallHandler", () => {
MatrixClientPeg.unset();

document.body.removeChild(audioElement);
SdkConfig.unset();
SdkConfig.reset();
});

it("should look up the correct user and start a call in the room when a phone number is dialled", async () => {
Expand Down Expand Up @@ -516,7 +516,7 @@ describe("LegacyCallHandler without third party protocols", () => {
MatrixClientPeg.unset();

document.body.removeChild(audioElement);
SdkConfig.unset();
SdkConfig.reset();
});

it("should still start a native call", async () => {
Expand Down
2 changes: 1 addition & 1 deletion test/PosthogAnalytics-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ describe("PosthogAnalytics", () => {
Object.defineProperty(window, "crypto", {
value: null,
});
SdkConfig.unset(); // we touch the config, so clean up
SdkConfig.reset(); // we touch the config, so clean up
});

describe("Initialisation", () => {
Expand Down
12 changes: 12 additions & 0 deletions test/SdkConfig-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,26 @@ describe("SdkConfig", () => {
chunk_length: 42,
max_length: 1337,
},
feedback: {
existing_issues_url: "https://existing",
} as any,
});
});

it("should return the custom config", () => {
const customConfig = JSON.parse(JSON.stringify(DEFAULTS));
customConfig.voice_broadcast.chunk_length = 42;
customConfig.voice_broadcast.max_length = 1337;
customConfig.feedback.existing_issues_url = "https://existing";
expect(SdkConfig.get()).toEqual(customConfig);
});

it("should allow overriding individual fields of sub-objects", () => {
const feedback = SdkConfig.getObject("feedback");
expect(feedback.get("existing_issues_url")).toMatchInlineSnapshot(`"https://existing"`);
expect(feedback.get("new_issue_url")).toMatchInlineSnapshot(
`"https://github.com/vector-im/element-web/issues/new/choose"`,
);
});
});
});
2 changes: 1 addition & 1 deletion test/components/structures/auth/Login-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ describe("Login", function () {

afterEach(function () {
fetchMock.restore();
SdkConfig.unset(); // we touch the config, so clean up
SdkConfig.reset(); // we touch the config, so clean up
unmockPlatformPeg();
});

Expand Down
2 changes: 1 addition & 1 deletion test/components/structures/auth/Registration-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ describe("Registration", function () {

afterEach(function () {
fetchMock.restore();
SdkConfig.unset(); // we touch the config, so clean up
SdkConfig.reset(); // we touch the config, so clean up
unmockPlatformPeg();
});

Expand Down
2 changes: 1 addition & 1 deletion test/components/views/auth/CountryDropdown-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import SdkConfig from "../../../../src/SdkConfig";
describe("CountryDropdown", () => {
describe("default_country_code", () => {
afterEach(() => {
SdkConfig.unset();
SdkConfig.reset();
});

it.each([
Expand Down
Loading