= [T, ...T[]];
+
+export type Defaultize = P extends any
+ ? string extends keyof P
+ ? P
+ : Pick
> &
+ Partial>> &
+ Partial>>
+ : never;
+
+export type DeepReadonly = T extends (infer R)[]
+ ? DeepReadonlyArray
+ : T extends Function
+ ? T
+ : T extends object
+ ? DeepReadonlyObject
+ : T;
+
+interface DeepReadonlyArray extends ReadonlyArray> {}
+
+type DeepReadonlyObject = {
+ readonly [P in keyof T]: DeepReadonly;
+};
diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts
index 587dc99dc7..d8f01cd4be 100644
--- a/src/@types/global.d.ts
+++ b/src/@types/global.d.ts
@@ -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;
};
- mxReactSdkConfig: IConfigOptions;
+ mxReactSdkConfig: DeepReadonly;
// Needed for Safari, unknown to TypeScript
webkitAudioContext: typeof AudioContext;
diff --git a/src/IConfigOptions.ts b/src/IConfigOptions.ts
index b2e44f23ce..c6913d2eb2 100644
--- a/src/IConfigOptions.ts
+++ b/src/IConfigOptions.ts
@@ -186,6 +186,11 @@ export interface IConfigOptions {
description: string;
show_once?: boolean;
};
+
+ feedback: {
+ existing_issues_url: string;
+ new_issue_url: string;
+ };
}
export interface ISsoRedirectOptions {
diff --git a/src/Modal.tsx b/src/Modal.tsx
index c92741cfc6..e8c514b801 100644
--- a/src/Modal.tsx
+++ b/src/Modal.tsx
@@ -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 extends any
- ? string extends keyof P
- ? P
- : Pick
> &
- Partial>> &
- Partial>>
- : never;
-
// Generic type which returns the props of the Modal component with the onFinished being optional.
export type ComponentProps = Defaultize<
Omit, "onFinished">,
diff --git a/src/SdkConfig.ts b/src/SdkConfig.ts
index 70032cdabb..be94bb5b08 100644
--- a/src/SdkConfig.ts
+++ b/src/SdkConfig.ts
@@ -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 = {
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;
+export type ConfigOptions = Defaultize;
- private static setInstance(i: IConfigOptions): void {
+function mergeConfig(
+ config: DeepReadonly,
+ changes: DeepReadonly>,
+): DeepReadonly {
+ // 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 = IConfigOptions[K] extends object
+ ? SnakedObject>
+ : Optional>>;
+
+export default class SdkConfig {
+ private static instance: DeepReadonly;
+ private static fallback: SnakedObject>;
+
+ private static setInstance(i: DeepReadonly): void {
SdkConfig.instance = i;
SdkConfig.fallback = new SnakedObject(i);
@@ -69,7 +102,7 @@ export default class SdkConfig {
public static get(
key?: K,
altCaseName?: string,
- ): IConfigOptions | IConfigOptions[K] {
+ ): DeepReadonly | DeepReadonly[K] {
if (key === undefined) {
// safe to cast as a fallback - we want to break the runtime contract in this case
return SdkConfig.instance || {};
@@ -77,32 +110,29 @@ export default class SdkConfig {
return SdkConfig.fallback.get(key, altCaseName);
}
- public static getObject(
- key: K,
- altCaseName?: string,
- ): Optional>> {
+ public static getObject(key: K, altCaseName?: string): ObjectType {
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;
}
- public static put(cfg: Partial): void {
- SdkConfig.setInstance({ ...DEFAULTS, ...cfg });
+ public static put(cfg: DeepReadonly): void {
+ SdkConfig.setInstance(mergeConfig(DEFAULTS, cfg));
}
/**
- * Resets the config to be completely empty.
+ * Resets the config.
*/
- public static unset(): void {
- SdkConfig.setInstance({}); // 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): void {
- SdkConfig.put({ ...SdkConfig.get(), ...cfg });
+ public static add(cfg: Partial): void {
+ SdkConfig.put(mergeConfig(SdkConfig.get(), cfg));
}
}
diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx
index 9c72b269f1..afc35508ac 100644
--- a/src/components/structures/LoggedInView.tsx
+++ b/src/components/structures/LoggedInView.tsx
@@ -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;
diff --git a/src/components/views/dialogs/FeedbackDialog.tsx b/src/components/views/dialogs/FeedbackDialog.tsx
index 7ee24e05a4..6b204d68e1 100644
--- a/src/components/views/dialogs/FeedbackDialog.tsx
+++ b/src/components/views/dialogs/FeedbackDialog.tsx
@@ -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 = (props: IProps) => {
);
}
+ const existingIssuesUrl = SdkConfig.getObject("feedback").get("existing_issues_url");
+ const newIssueUrl = SdkConfig.getObject("feedback").get("new_issue_url");
+
return (
= ({ 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 = (
@@ -250,7 +250,7 @@ const CallButtons: FC = ({ 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(
diff --git a/src/utils/device/clientInformation.ts b/src/utils/device/clientInformation.ts
index 86ab7faa2d..4b7d8e60e0 100644
--- a/src/utils/device/clientInformation.ts
+++ b/src/utils/device/clientInformation.ts
@@ -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,
platform?: BasePlatform,
): Promise => {
const deviceId = matrixClient.getDeviceId()!;
diff --git a/src/utils/objects.ts b/src/utils/objects.ts
index c2496b4c7c..f505b71a4c 100644
--- a/src/utils/objects.ts
+++ b/src/utils/objects.ts
@@ -141,3 +141,12 @@ export function objectKeyChanges(a: O, b: O): (keyof O)[] {
export function objectClone(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);
+}
diff --git a/test/LegacyCallHandler-test.ts b/test/LegacyCallHandler-test.ts
index aafbc1275c..be902e54f8 100644
--- a/test/LegacyCallHandler-test.ts
+++ b/test/LegacyCallHandler-test.ts
@@ -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 () => {
@@ -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 () => {
diff --git a/test/PosthogAnalytics-test.ts b/test/PosthogAnalytics-test.ts
index 21d0d7cf0f..1ba7c01d53 100644
--- a/test/PosthogAnalytics-test.ts
+++ b/test/PosthogAnalytics-test.ts
@@ -77,7 +77,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", () => {
diff --git a/test/SdkConfig-test.ts b/test/SdkConfig-test.ts
index a6ac58e9c5..aba0e9646a 100644
--- a/test/SdkConfig-test.ts
+++ b/test/SdkConfig-test.ts
@@ -30,6 +30,9 @@ describe("SdkConfig", () => {
chunk_length: 42,
max_length: 1337,
},
+ feedback: {
+ existing_issues_url: "https://existing",
+ } as any,
});
});
@@ -37,7 +40,16 @@ describe("SdkConfig", () => {
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"`,
+ );
+ });
});
});
diff --git a/test/components/structures/auth/Login-test.tsx b/test/components/structures/auth/Login-test.tsx
index bf9e8d567c..a84e88b17c 100644
--- a/test/components/structures/auth/Login-test.tsx
+++ b/test/components/structures/auth/Login-test.tsx
@@ -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();
});
diff --git a/test/components/structures/auth/Registration-test.tsx b/test/components/structures/auth/Registration-test.tsx
index 16b64bc393..3f6f44db7e 100644
--- a/test/components/structures/auth/Registration-test.tsx
+++ b/test/components/structures/auth/Registration-test.tsx
@@ -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();
});
diff --git a/test/components/views/auth/CountryDropdown-test.tsx b/test/components/views/auth/CountryDropdown-test.tsx
index a6beeda233..95cd5abe75 100644
--- a/test/components/views/auth/CountryDropdown-test.tsx
+++ b/test/components/views/auth/CountryDropdown-test.tsx
@@ -23,7 +23,7 @@ import SdkConfig from "../../../../src/SdkConfig";
describe("CountryDropdown", () => {
describe("default_country_code", () => {
afterEach(() => {
- SdkConfig.unset();
+ SdkConfig.reset();
});
it.each([
diff --git a/test/components/views/dialogs/FeedbackDialog-test.tsx b/test/components/views/dialogs/FeedbackDialog-test.tsx
new file mode 100644
index 0000000000..73dadd00b5
--- /dev/null
+++ b/test/components/views/dialogs/FeedbackDialog-test.tsx
@@ -0,0 +1,35 @@
+/*
+Copyright 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React from "react";
+import { render } from "@testing-library/react";
+
+import SdkConfig from "../../../../src/SdkConfig";
+import FeedbackDialog from "../../../../src/components/views/dialogs/FeedbackDialog";
+
+describe("FeedbackDialog", () => {
+ it("should respect feedback config", () => {
+ SdkConfig.put({
+ feedback: {
+ existing_issues_url: "http://existing?foo=bar",
+ new_issue_url: "https://new.issue.url?foo=bar",
+ },
+ });
+
+ const { asFragment } = render();
+ expect(asFragment()).toMatchSnapshot();
+ });
+});
diff --git a/test/components/views/dialogs/__snapshots__/FeedbackDialog-test.tsx.snap b/test/components/views/dialogs/__snapshots__/FeedbackDialog-test.tsx.snap
new file mode 100644
index 0000000000..2682f5234c
--- /dev/null
+++ b/test/components/views/dialogs/__snapshots__/FeedbackDialog-test.tsx.snap
@@ -0,0 +1,82 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`FeedbackDialog should respect feedback config 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/test/components/views/rooms/RoomHeader-test.tsx b/test/components/views/rooms/RoomHeader-test.tsx
index 35cb5d200c..c74d856a85 100644
--- a/test/components/views/rooms/RoomHeader-test.tsx
+++ b/test/components/views/rooms/RoomHeader-test.tsx
@@ -120,7 +120,7 @@ describe("RoomHeader", () => {
await Promise.all([CallStore.instance, WidgetStore.instance].map(resetAsyncStoreWithClient));
client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]);
jest.restoreAllMocks();
- SdkConfig.put({});
+ SdkConfig.reset();
});
const mockRoomType = (type: string) => {
diff --git a/test/languageHandler-test.ts b/test/languageHandler-test.ts
index 556c12fe05..0f22962831 100644
--- a/test/languageHandler-test.ts
+++ b/test/languageHandler-test.ts
@@ -42,7 +42,7 @@ async function setupTranslationOverridesForTests(overrides: ICustomTranslations)
describe("languageHandler", () => {
afterEach(() => {
- SdkConfig.unset();
+ SdkConfig.reset();
CustomTranslationOptions.lookupFn = undefined;
});
diff --git a/test/utils/device/clientInformation-test.ts b/test/utils/device/clientInformation-test.ts
index 4133619f91..9b90d6cb10 100644
--- a/test/utils/device/clientInformation-test.ts
+++ b/test/utils/device/clientInformation-test.ts
@@ -20,6 +20,8 @@ import BasePlatform from "../../../src/BasePlatform";
import { IConfigOptions } from "../../../src/IConfigOptions";
import { getDeviceClientInformation, recordClientInformation } from "../../../src/utils/device/clientInformation";
import { getMockClientWithEventEmitter } from "../../test-utils";
+import { DEFAULTS } from "../../../src/SdkConfig";
+import { DeepReadonly } from "../../../src/@types/common";
describe("recordClientInformation()", () => {
const deviceId = "my-device-id";
@@ -31,7 +33,8 @@ describe("recordClientInformation()", () => {
setAccountData: jest.fn(),
});
- const sdkConfig: IConfigOptions = {
+ const sdkConfig: DeepReadonly = {
+ ...DEFAULTS,
brand: "Test Brand",
element_call: { url: "", use_exclusively: false, brand: "Element Call" },
};
diff --git a/test/voice-broadcast/utils/getChunkLength-test.ts b/test/voice-broadcast/utils/getChunkLength-test.ts
index a046a47f76..5610bd6caf 100644
--- a/test/voice-broadcast/utils/getChunkLength-test.ts
+++ b/test/voice-broadcast/utils/getChunkLength-test.ts
@@ -14,24 +14,24 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-import { mocked } from "jest-mock";
-
-import SdkConfig, { DEFAULTS } from "../../../src/SdkConfig";
+import SdkConfig from "../../../src/SdkConfig";
import { SettingLevel } from "../../../src/settings/SettingLevel";
import { Features } from "../../../src/settings/Settings";
import SettingsStore from "../../../src/settings/SettingsStore";
import { getChunkLength } from "../../../src/voice-broadcast/utils/getChunkLength";
-jest.mock("../../../src/SdkConfig");
-
describe("getChunkLength", () => {
afterEach(() => {
- jest.resetAllMocks();
+ SdkConfig.reset();
});
describe("when there is a value provided by Sdk config", () => {
beforeEach(() => {
- mocked(SdkConfig.get).mockReturnValue({ chunk_length: 42 });
+ SdkConfig.add({
+ voice_broadcast: {
+ chunk_length: 42,
+ },
+ });
});
it("should return this value", () => {
@@ -41,9 +41,11 @@ describe("getChunkLength", () => {
describe("when Sdk config does not provide a value", () => {
beforeEach(() => {
- DEFAULTS.voice_broadcast = {
- chunk_length: 23,
- };
+ SdkConfig.add({
+ voice_broadcast: {
+ chunk_length: 23,
+ },
+ });
});
it("should return this value", () => {
@@ -52,10 +54,6 @@ describe("getChunkLength", () => {
});
describe("when there are no defaults", () => {
- beforeEach(() => {
- DEFAULTS.voice_broadcast = undefined;
- });
-
it("should return the fallback value", () => {
expect(getChunkLength()).toBe(120);
});
diff --git a/test/voice-broadcast/utils/getMaxBroadcastLength-test.ts b/test/voice-broadcast/utils/getMaxBroadcastLength-test.ts
index f2dd138954..3f40dd0efc 100644
--- a/test/voice-broadcast/utils/getMaxBroadcastLength-test.ts
+++ b/test/voice-broadcast/utils/getMaxBroadcastLength-test.ts
@@ -14,21 +14,21 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-import { mocked } from "jest-mock";
-
import SdkConfig, { DEFAULTS } from "../../../src/SdkConfig";
import { getMaxBroadcastLength } from "../../../src/voice-broadcast";
-jest.mock("../../../src/SdkConfig");
-
describe("getMaxBroadcastLength", () => {
afterEach(() => {
- jest.resetAllMocks();
+ SdkConfig.reset();
});
describe("when there is a value provided by Sdk config", () => {
beforeEach(() => {
- mocked(SdkConfig.get).mockReturnValue({ max_length: 42 });
+ SdkConfig.put({
+ voice_broadcast: {
+ max_length: 42,
+ },
+ });
});
it("should return this value", () => {
@@ -37,23 +37,14 @@ describe("getMaxBroadcastLength", () => {
});
describe("when Sdk config does not provide a value", () => {
- beforeEach(() => {
- DEFAULTS.voice_broadcast = {
- max_length: 23,
- };
- });
-
it("should return this value", () => {
- expect(getMaxBroadcastLength()).toBe(23);
+ expect(getMaxBroadcastLength()).toBe(DEFAULTS.voice_broadcast!.max_length);
});
});
describe("if there are no defaults", () => {
- beforeEach(() => {
- DEFAULTS.voice_broadcast = undefined;
- });
-
it("should return the fallback value", () => {
+ expect(DEFAULTS.voice_broadcast!.max_length).toBe(4 * 60 * 60);
expect(getMaxBroadcastLength()).toBe(4 * 60 * 60);
});
});