Skip to content

Commit 6166dbb

Browse files
authored
Make existing and new issue URLs configurable (matrix-org#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
1 parent e4610e4 commit 6166dbb

23 files changed

+258
-77
lines changed

src/@types/common.ts

+22
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,25 @@ export type KeysStartingWith<Input extends object, Str extends string> = {
5454
}[keyof Input];
5555

5656
export type NonEmptyArray<T> = [T, ...T[]];
57+
58+
export type Defaultize<P, D> = P extends any
59+
? string extends keyof P
60+
? P
61+
: Pick<P, Exclude<keyof P, keyof D>> &
62+
Partial<Pick<P, Extract<keyof P, keyof D>>> &
63+
Partial<Pick<D, Exclude<keyof D, keyof P>>>
64+
: never;
65+
66+
export type DeepReadonly<T> = T extends (infer R)[]
67+
? DeepReadonlyArray<R>
68+
: T extends Function
69+
? T
70+
: T extends object
71+
? DeepReadonlyObject<T>
72+
: T;
73+
74+
interface DeepReadonlyArray<T> extends ReadonlyArray<DeepReadonly<T>> {}
75+
76+
type DeepReadonlyObject<T> = {
77+
readonly [P in keyof T]: DeepReadonly<T[P]>;
78+
};

src/@types/global.d.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import ActiveWidgetStore from "../stores/ActiveWidgetStore";
4949
import AutoRageshakeStore from "../stores/AutoRageshakeStore";
5050
import { IConfigOptions } from "../IConfigOptions";
5151
import { MatrixDispatcher } from "../dispatcher/dispatcher";
52+
import { DeepReadonly } from "./common";
5253

5354
/* eslint-disable @typescript-eslint/naming-convention */
5455

@@ -59,7 +60,7 @@ declare global {
5960
Olm: {
6061
init: () => Promise<void>;
6162
};
62-
mxReactSdkConfig: IConfigOptions;
63+
mxReactSdkConfig: DeepReadonly<IConfigOptions>;
6364

6465
// Needed for Safari, unknown to TypeScript
6566
webkitAudioContext: typeof AudioContext;

src/IConfigOptions.ts

+5
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,11 @@ export interface IConfigOptions {
186186
description: string;
187187
show_once?: boolean;
188188
};
189+
190+
feedback: {
191+
existing_issues_url: string;
192+
new_issue_url: string;
193+
};
189194
}
190195

191196
export interface ISsoRedirectOptions {

src/Modal.tsx

+1-8
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter"
2323

2424
import dis from "./dispatcher/dispatcher";
2525
import AsyncWrapper from "./AsyncWrapper";
26+
import { Defaultize } from "./@types/common";
2627

2728
const DIALOG_CONTAINER_ID = "mx_Dialog_Container";
2829
const STATIC_DIALOG_CONTAINER_ID = "mx_Dialog_StaticContainer";
@@ -32,14 +33,6 @@ export type ComponentType = React.ComponentType<{
3233
onFinished?(...args: any): void;
3334
}>;
3435

35-
type Defaultize<P, D> = P extends any
36-
? string extends keyof P
37-
? P
38-
: Pick<P, Exclude<keyof P, keyof D>> &
39-
Partial<Pick<P, Extract<keyof P, keyof D>>> &
40-
Partial<Pick<D, Exclude<keyof D, keyof P>>>
41-
: never;
42-
4336
// Generic type which returns the props of the Modal component with the onFinished being optional.
4437
export type ComponentProps<C extends ComponentType> = Defaultize<
4538
Omit<React.ComponentProps<C>, "onFinished">,

src/SdkConfig.ts

+48-18
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,15 @@ limitations under the License.
1616
*/
1717

1818
import { Optional } from "matrix-events-sdk";
19+
import { mergeWith } from "lodash";
1920

2021
import { SnakedObject } from "./utils/SnakedObject";
2122
import { IConfigOptions, ISsoRedirectOptions } from "./IConfigOptions";
23+
import { isObject, objectClone } from "./utils/objects";
24+
import { DeepReadonly, Defaultize } from "./@types/common";
2225

2326
// see element-web config.md for docs, or the IConfigOptions interface for dev docs
24-
export const DEFAULTS: IConfigOptions = {
27+
export const DEFAULTS: DeepReadonly<IConfigOptions> = {
2528
brand: "Element",
2629
integrations_ui_url: "https://scalar.vector.im/",
2730
integrations_rest_url: "https://scalar.vector.im/api",
@@ -50,13 +53,43 @@ export const DEFAULTS: IConfigOptions = {
5053
chunk_length: 2 * 60, // two minutes
5154
max_length: 4 * 60 * 60, // four hours
5255
},
56+
57+
feedback: {
58+
existing_issues_url:
59+
"https://github.com/vector-im/element-web/issues?q=is%3Aopen+is%3Aissue+sort%3Areactions-%2B1-desc",
60+
new_issue_url: "https://github.com/vector-im/element-web/issues/new/choose",
61+
},
5362
};
5463

64+
export type ConfigOptions = Defaultize<IConfigOptions, typeof DEFAULTS>;
65+
66+
function mergeConfig(
67+
config: DeepReadonly<IConfigOptions>,
68+
changes: DeepReadonly<Partial<IConfigOptions>>,
69+
): DeepReadonly<IConfigOptions> {
70+
// return { ...config, ...changes };
71+
return mergeWith(objectClone(config), changes, (objValue, srcValue) => {
72+
// Don't merge arrays, prefer values from newer object
73+
if (Array.isArray(objValue)) {
74+
return srcValue;
75+
}
76+
77+
// Don't allow objects to get nulled out, this will break our types
78+
if (isObject(objValue) && !isObject(srcValue)) {
79+
return objValue;
80+
}
81+
});
82+
}
83+
84+
type ObjectType<K extends keyof IConfigOptions> = IConfigOptions[K] extends object
85+
? SnakedObject<NonNullable<IConfigOptions[K]>>
86+
: Optional<SnakedObject<NonNullable<IConfigOptions[K]>>>;
87+
5588
export default class SdkConfig {
56-
private static instance: IConfigOptions;
57-
private static fallback: SnakedObject<IConfigOptions>;
89+
private static instance: DeepReadonly<IConfigOptions>;
90+
private static fallback: SnakedObject<DeepReadonly<IConfigOptions>>;
5891

59-
private static setInstance(i: IConfigOptions): void {
92+
private static setInstance(i: DeepReadonly<IConfigOptions>): void {
6093
SdkConfig.instance = i;
6194
SdkConfig.fallback = new SnakedObject(i);
6295

@@ -69,40 +102,37 @@ export default class SdkConfig {
69102
public static get<K extends keyof IConfigOptions = never>(
70103
key?: K,
71104
altCaseName?: string,
72-
): IConfigOptions | IConfigOptions[K] {
105+
): DeepReadonly<IConfigOptions> | DeepReadonly<IConfigOptions>[K] {
73106
if (key === undefined) {
74107
// safe to cast as a fallback - we want to break the runtime contract in this case
75108
return SdkConfig.instance || <IConfigOptions>{};
76109
}
77110
return SdkConfig.fallback.get(key, altCaseName);
78111
}
79112

80-
public static getObject<K extends keyof IConfigOptions>(
81-
key: K,
82-
altCaseName?: string,
83-
): Optional<SnakedObject<NonNullable<IConfigOptions[K]>>> {
113+
public static getObject<K extends keyof IConfigOptions>(key: K, altCaseName?: string): ObjectType<K> {
84114
const val = SdkConfig.get(key, altCaseName);
85-
if (val !== null && val !== undefined) {
115+
if (isObject(val)) {
86116
return new SnakedObject(val);
87117
}
88118

89119
// return the same type for sensitive callers (some want `undefined` specifically)
90-
return val === undefined ? undefined : null;
120+
return (val === undefined ? undefined : null) as ObjectType<K>;
91121
}
92122

93-
public static put(cfg: Partial<IConfigOptions>): void {
94-
SdkConfig.setInstance({ ...DEFAULTS, ...cfg });
123+
public static put(cfg: DeepReadonly<ConfigOptions>): void {
124+
SdkConfig.setInstance(mergeConfig(DEFAULTS, cfg));
95125
}
96126

97127
/**
98-
* Resets the config to be completely empty.
128+
* Resets the config.
99129
*/
100-
public static unset(): void {
101-
SdkConfig.setInstance(<IConfigOptions>{}); // safe to cast - defaults will be applied
130+
public static reset(): void {
131+
SdkConfig.setInstance(mergeConfig(DEFAULTS, {})); // safe to cast - defaults will be applied
102132
}
103133

104-
public static add(cfg: Partial<IConfigOptions>): void {
105-
SdkConfig.put({ ...SdkConfig.get(), ...cfg });
134+
public static add(cfg: Partial<ConfigOptions>): void {
135+
SdkConfig.put(mergeConfig(SdkConfig.get(), cfg));
106136
}
107137
}
108138

src/components/structures/LoggedInView.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,11 @@ import RightPanelStore from "../../stores/right-panel/RightPanelStore";
6666
import { TimelineRenderingType } from "../../contexts/RoomContext";
6767
import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts";
6868
import { SwitchSpacePayload } from "../../dispatcher/payloads/SwitchSpacePayload";
69-
import { IConfigOptions } from "../../IConfigOptions";
7069
import LeftPanelLiveShareWarning from "../views/beacon/LeftPanelLiveShareWarning";
7170
import { UserOnboardingPage } from "../views/user-onboarding/UserOnboardingPage";
7271
import { PipContainer } from "./PipContainer";
7372
import { monitorSyncedPushRules } from "../../utils/pushRules/monitorSyncedPushRules";
73+
import { ConfigOptions } from "../../SdkConfig";
7474

7575
// We need to fetch each pinned message individually (if we don't already have it)
7676
// so each pinned message may trigger a request. Limit the number per room for sanity.
@@ -98,7 +98,7 @@ interface IProps {
9898
roomOobData?: IOOBData;
9999
currentRoomId: string;
100100
collapseLhs: boolean;
101-
config: IConfigOptions;
101+
config: ConfigOptions;
102102
currentUserId?: string;
103103
justRegistered?: boolean;
104104
roomJustCreatedOpts?: IOpts;

src/components/views/dialogs/FeedbackDialog.tsx

+3-4
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,6 @@ import { submitFeedback } from "../../../rageshake/submit-rageshake";
2828
import { useStateToggle } from "../../../hooks/useStateToggle";
2929
import StyledCheckbox from "../elements/StyledCheckbox";
3030

31-
const existingIssuesUrl =
32-
"https://github.com/vector-im/element-web/issues" + "?q=is%3Aopen+is%3Aissue+sort%3Areactions-%2B1-desc";
33-
const newIssueUrl = "https://github.com/vector-im/element-web/issues/new/choose";
34-
3531
interface IProps {
3632
feature?: string;
3733
onFinished(): void;
@@ -117,6 +113,9 @@ const FeedbackDialog: React.FC<IProps> = (props: IProps) => {
117113
);
118114
}
119115

116+
const existingIssuesUrl = SdkConfig.getObject("feedback").get("existing_issues_url");
117+
const newIssueUrl = SdkConfig.getObject("feedback").get("new_issue_url");
118+
120119
return (
121120
<QuestionDialog
122121
className="mx_FeedbackDialog"

src/components/views/rooms/RoomHeader.tsx

+3-3
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ import { UPDATE_EVENT } from "../../../stores/AsyncStore";
5353
import { isVideoRoom as calcIsVideoRoom } from "../../../utils/video-rooms";
5454
import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../../LegacyCallHandler";
5555
import { useFeatureEnabled, useSettingValue } from "../../../hooks/useSettings";
56-
import SdkConfig, { DEFAULTS } from "../../../SdkConfig";
56+
import SdkConfig from "../../../SdkConfig";
5757
import { useEventEmitterState, useTypedEventEmitterState } from "../../../hooks/useEventEmitter";
5858
import { useWidgets } from "../right_panel/RoomSummaryCard";
5959
import { WidgetType } from "../../../widgets/WidgetType";
@@ -207,7 +207,7 @@ const VideoCallButton: FC<VideoCallButtonProps> = ({ room, busy, setBusy, behavi
207207
let menu: JSX.Element | null = null;
208208
if (menuOpen) {
209209
const buttonRect = buttonRef.current!.getBoundingClientRect();
210-
const brand = SdkConfig.get("element_call").brand ?? DEFAULTS.element_call.brand;
210+
const brand = SdkConfig.get("element_call").brand;
211211
menu = (
212212
<IconizedContextMenu {...aboveLeftOf(buttonRect)} onFinished={closeMenu}>
213213
<IconizedContextMenuOptionList>
@@ -250,7 +250,7 @@ const CallButtons: FC<CallButtonsProps> = ({ room }) => {
250250
const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms");
251251
const isVideoRoom = useMemo(() => videoRoomsEnabled && calcIsVideoRoom(room), [videoRoomsEnabled, room]);
252252
const useElementCallExclusively = useMemo(() => {
253-
return SdkConfig.get("element_call").use_exclusively ?? DEFAULTS.element_call.use_exclusively;
253+
return SdkConfig.get("element_call").use_exclusively;
254254
}, []);
255255

256256
const hasLegacyCall = useEventEmitterState(

src/utils/device/clientInformation.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { MatrixClient } from "matrix-js-sdk/src/client";
1818

1919
import BasePlatform from "../../BasePlatform";
2020
import { IConfigOptions } from "../../IConfigOptions";
21+
import { DeepReadonly } from "../../@types/common";
2122

2223
export type DeviceClientInformation = {
2324
name?: string;
@@ -49,7 +50,7 @@ export const getClientInformationEventType = (deviceId: string): string => `${cl
4950
*/
5051
export const recordClientInformation = async (
5152
matrixClient: MatrixClient,
52-
sdkConfig: IConfigOptions,
53+
sdkConfig: DeepReadonly<IConfigOptions>,
5354
platform?: BasePlatform,
5455
): Promise<void> => {
5556
const deviceId = matrixClient.getDeviceId()!;

src/utils/objects.ts

+9
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,12 @@ export function objectKeyChanges<O extends {}>(a: O, b: O): (keyof O)[] {
141141
export function objectClone<O extends {}>(obj: O): O {
142142
return JSON.parse(JSON.stringify(obj));
143143
}
144+
145+
/**
146+
* Simple object check.
147+
* @param item
148+
* @returns {boolean}
149+
*/
150+
export function isObject(item: any): item is object {
151+
return item && typeof item === "object" && !Array.isArray(item);
152+
}

test/LegacyCallHandler-test.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,7 @@ describe("LegacyCallHandler", () => {
305305
MatrixClientPeg.unset();
306306

307307
document.body.removeChild(audioElement);
308-
SdkConfig.unset();
308+
SdkConfig.reset();
309309
});
310310

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

518518
document.body.removeChild(audioElement);
519-
SdkConfig.unset();
519+
SdkConfig.reset();
520520
});
521521

522522
it("should still start a native call", async () => {

test/PosthogAnalytics-test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ describe("PosthogAnalytics", () => {
7777
Object.defineProperty(window, "crypto", {
7878
value: null,
7979
});
80-
SdkConfig.unset(); // we touch the config, so clean up
80+
SdkConfig.reset(); // we touch the config, so clean up
8181
});
8282

8383
describe("Initialisation", () => {

test/SdkConfig-test.ts

+12
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,26 @@ describe("SdkConfig", () => {
3030
chunk_length: 42,
3131
max_length: 1337,
3232
},
33+
feedback: {
34+
existing_issues_url: "https://existing",
35+
} as any,
3336
});
3437
});
3538

3639
it("should return the custom config", () => {
3740
const customConfig = JSON.parse(JSON.stringify(DEFAULTS));
3841
customConfig.voice_broadcast.chunk_length = 42;
3942
customConfig.voice_broadcast.max_length = 1337;
43+
customConfig.feedback.existing_issues_url = "https://existing";
4044
expect(SdkConfig.get()).toEqual(customConfig);
4145
});
46+
47+
it("should allow overriding individual fields of sub-objects", () => {
48+
const feedback = SdkConfig.getObject("feedback");
49+
expect(feedback.get("existing_issues_url")).toMatchInlineSnapshot(`"https://existing"`);
50+
expect(feedback.get("new_issue_url")).toMatchInlineSnapshot(
51+
`"https://github.com/vector-im/element-web/issues/new/choose"`,
52+
);
53+
});
4254
});
4355
});

test/components/structures/auth/Login-test.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ describe("Login", function () {
6161

6262
afterEach(function () {
6363
fetchMock.restore();
64-
SdkConfig.unset(); // we touch the config, so clean up
64+
SdkConfig.reset(); // we touch the config, so clean up
6565
unmockPlatformPeg();
6666
});
6767

test/components/structures/auth/Registration-test.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ describe("Registration", function () {
6666

6767
afterEach(function () {
6868
fetchMock.restore();
69-
SdkConfig.unset(); // we touch the config, so clean up
69+
SdkConfig.reset(); // we touch the config, so clean up
7070
unmockPlatformPeg();
7171
});
7272

test/components/views/auth/CountryDropdown-test.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import SdkConfig from "../../../../src/SdkConfig";
2323
describe("CountryDropdown", () => {
2424
describe("default_country_code", () => {
2525
afterEach(() => {
26-
SdkConfig.unset();
26+
SdkConfig.reset();
2727
});
2828

2929
it.each([

0 commit comments

Comments
 (0)