Merge branch 'develop' into superkenvery/webaudioapi
This commit is contained in:
commit
6821a35444
224 changed files with 22605 additions and 19617 deletions
|
@ -23,7 +23,6 @@ import "matrix-js-sdk/src/browser-index";
|
|||
|
||||
import React, { ReactElement } from "react";
|
||||
import PlatformPeg from "matrix-react-sdk/src/PlatformPeg";
|
||||
import { UserFriendlyError } from "matrix-react-sdk/src/languageHandler";
|
||||
import AutoDiscoveryUtils from "matrix-react-sdk/src/utils/AutoDiscoveryUtils";
|
||||
import { AutoDiscovery, ClientConfig } from "matrix-js-sdk/src/autodiscovery";
|
||||
import * as Lifecycle from "matrix-react-sdk/src/Lifecycle";
|
||||
|
@ -34,11 +33,13 @@ import { createClient } from "matrix-js-sdk/src/matrix";
|
|||
import { SnakedObject } from "matrix-react-sdk/src/utils/SnakedObject";
|
||||
import MatrixChat from "matrix-react-sdk/src/components/structures/MatrixChat";
|
||||
import { ValidatedServerConfig } from "matrix-react-sdk/src/utils/ValidatedServerConfig";
|
||||
import { QueryDict, encodeParams } from "matrix-js-sdk/src/utils";
|
||||
import { WrapperLifecycle, WrapperOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/WrapperLifecycle";
|
||||
import { ModuleRunner } from "matrix-react-sdk/src/modules/ModuleRunner";
|
||||
|
||||
import { parseQs } from "./url_utils";
|
||||
import VectorBasePlatform from "./platform/VectorBasePlatform";
|
||||
import { getScreenFromLocation, init as initRouting, onNewScreen } from "./routing";
|
||||
import { getInitialScreenAfterLogin, getScreenFromLocation, init as initRouting, onNewScreen } from "./routing";
|
||||
import { UserFriendlyError } from "../languageHandler";
|
||||
|
||||
// add React and ReactPerf to the global namespace, to make them easier to access via the console
|
||||
// this incidentally means we can forget our React imports in JSX files without penalty.
|
||||
|
@ -48,44 +49,23 @@ logger.log(`Application is running in ${process.env.NODE_ENV} mode`);
|
|||
|
||||
window.matrixLogger = logger;
|
||||
|
||||
// We use this to work out what URL the SDK should
|
||||
// pass through when registering to allow the user to
|
||||
// click back to the client having registered.
|
||||
// It's up to us to recognise if we're loaded with
|
||||
// this URL and tell MatrixClient to resume registration.
|
||||
//
|
||||
// If we're in electron, we should never pass through a file:// URL otherwise
|
||||
// the identity server will try to 302 the browser to it, which breaks horribly.
|
||||
// so in that instance, hardcode to use app.element.io for now instead.
|
||||
function makeRegistrationUrl(params: QueryDict): string {
|
||||
let url: string;
|
||||
if (window.location.protocol === "vector:") {
|
||||
url = "https://app.element.io/#/register";
|
||||
} else {
|
||||
url = window.location.protocol + "//" + window.location.host + window.location.pathname + "#/register";
|
||||
}
|
||||
|
||||
const encodedParams = encodeParams(params);
|
||||
if (encodedParams) {
|
||||
url += "?" + encodedParams;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
function onTokenLoginCompleted(): void {
|
||||
// if we did a token login, we're now left with the token, hs and is
|
||||
// url as query params in the url; a little nasty but let's redirect to
|
||||
// clear them.
|
||||
// url as query params in the url;
|
||||
// if we did an oidc authorization code flow login, we're left with the auth code and state
|
||||
// as query params in the url;
|
||||
// a little nasty but let's redirect to clear them.
|
||||
const url = new URL(window.location.href);
|
||||
|
||||
url.searchParams.delete("loginToken");
|
||||
url.searchParams.delete("state");
|
||||
url.searchParams.delete("code");
|
||||
|
||||
logger.log(`Redirecting to ${url.href} to drop loginToken from queryparams`);
|
||||
logger.log(`Redirecting to ${url.href} to drop delegated authentication params from queryparams`);
|
||||
window.history.replaceState(null, "", url.href);
|
||||
}
|
||||
|
||||
export async function loadApp(fragParams: {}): Promise<ReactElement> {
|
||||
export async function loadApp(fragParams: {}, matrixChatRef: React.Ref<MatrixChat>): Promise<ReactElement> {
|
||||
initRouting();
|
||||
const platform = PlatformPeg.get();
|
||||
|
||||
|
@ -109,9 +89,14 @@ export async function loadApp(fragParams: {}): Promise<ReactElement> {
|
|||
// XXX: This path matching is a bit brittle, but better to do it early instead of in the app code.
|
||||
const isWelcomeOrLanding =
|
||||
window.location.hash === "#/welcome" || window.location.hash === "#" || window.location.hash === "";
|
||||
const isLoginPage = window.location.hash === "#/login";
|
||||
|
||||
if (!autoRedirect && ssoRedirects.on_welcome_page && isWelcomeOrLanding) {
|
||||
autoRedirect = true;
|
||||
}
|
||||
if (!autoRedirect && ssoRedirects.on_login_page && isLoginPage) {
|
||||
autoRedirect = true;
|
||||
}
|
||||
if (!hasPossibleToken && !isReturningFromSso && autoRedirect) {
|
||||
logger.log("Bypassing app load to redirect to SSO");
|
||||
const tempCli = createClient({
|
||||
|
@ -129,18 +114,25 @@ export async function loadApp(fragParams: {}): Promise<ReactElement> {
|
|||
const defaultDeviceName =
|
||||
snakedConfig.get("default_device_display_name") ?? platform?.getDefaultDeviceDisplayName();
|
||||
|
||||
const initialScreenAfterLogin = getInitialScreenAfterLogin(window.location);
|
||||
|
||||
const wrapperOpts: WrapperOpts = { Wrapper: React.Fragment };
|
||||
ModuleRunner.instance.invoke(WrapperLifecycle.Wrapper, wrapperOpts);
|
||||
|
||||
return (
|
||||
<MatrixChat
|
||||
onNewScreen={onNewScreen}
|
||||
makeRegistrationUrl={makeRegistrationUrl}
|
||||
config={config}
|
||||
realQueryParams={params}
|
||||
startingFragmentQueryParams={fragParams}
|
||||
enableGuest={!config.disable_guests}
|
||||
onTokenLoginCompleted={onTokenLoginCompleted}
|
||||
initialScreenAfterLogin={getScreenFromLocation(window.location)}
|
||||
defaultDeviceDisplayName={defaultDeviceName}
|
||||
/>
|
||||
<wrapperOpts.Wrapper>
|
||||
<MatrixChat
|
||||
ref={matrixChatRef}
|
||||
onNewScreen={onNewScreen}
|
||||
config={config}
|
||||
realQueryParams={params}
|
||||
startingFragmentQueryParams={fragParams}
|
||||
enableGuest={!config.disable_guests}
|
||||
onTokenLoginCompleted={onTokenLoginCompleted}
|
||||
initialScreenAfterLogin={initialScreenAfterLogin}
|
||||
defaultDeviceDisplayName={defaultDeviceName}
|
||||
/>
|
||||
</wrapperOpts.Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -165,16 +157,13 @@ async function verifyServerConfig(): Promise<IConfigOptions> {
|
|||
const isUrl = config["default_is_url"];
|
||||
|
||||
const incompatibleOptions = [wkConfig, serverName, hsUrl].filter((i) => !!i);
|
||||
if (incompatibleOptions.length > 1) {
|
||||
if (hsUrl && (wkConfig || serverName)) {
|
||||
// noinspection ExceptionCaughtLocallyJS
|
||||
throw new UserFriendlyError(
|
||||
"Invalid configuration: can only specify one of default_server_config, default_server_name, " +
|
||||
"or default_hs_url.",
|
||||
);
|
||||
throw new UserFriendlyError("error|invalid_configuration_mixed_server");
|
||||
}
|
||||
if (incompatibleOptions.length < 1) {
|
||||
// noinspection ExceptionCaughtLocallyJS
|
||||
throw new UserFriendlyError("Invalid configuration: no default server specified.");
|
||||
throw new UserFriendlyError("error|invalid_configuration_no_server");
|
||||
}
|
||||
|
||||
if (hsUrl) {
|
||||
|
@ -197,7 +186,7 @@ async function verifyServerConfig(): Promise<IConfigOptions> {
|
|||
}
|
||||
|
||||
let discoveryResult: ClientConfig | undefined;
|
||||
if (wkConfig) {
|
||||
if (!serverName && wkConfig) {
|
||||
logger.log("Config uses a default_server_config - validating object");
|
||||
discoveryResult = await AutoDiscovery.fromDiscoveryConfig(wkConfig);
|
||||
}
|
||||
|
@ -209,9 +198,13 @@ async function verifyServerConfig(): Promise<IConfigOptions> {
|
|||
"use default_server_config instead.",
|
||||
);
|
||||
discoveryResult = await AutoDiscovery.findClientConfig(serverName);
|
||||
if (discoveryResult["m.homeserver"].base_url === null && wkConfig) {
|
||||
logger.log("Finding base_url failed but a default_server_config was found - using it as a fallback");
|
||||
discoveryResult = await AutoDiscovery.fromDiscoveryConfig(wkConfig);
|
||||
}
|
||||
}
|
||||
|
||||
validatedConfig = AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(serverName, discoveryResult, true);
|
||||
validatedConfig = await AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(serverName, discoveryResult, true);
|
||||
} catch (e) {
|
||||
const { hsUrl, isUrl, userId } = await Lifecycle.getStoredSessionVars();
|
||||
if (hsUrl && userId) {
|
||||
|
|
|
@ -49,19 +49,27 @@
|
|||
<% }
|
||||
} %>
|
||||
|
||||
<% for (var i=0; i < htmlWebpackPlugin.tags.headTags.length; i++) {
|
||||
var tag = htmlWebpackPlugin.tags.headTags[i];
|
||||
var path = tag.attributes && tag.attributes.href;
|
||||
if (path.includes("/Inter/")) { %>
|
||||
<link rel="preload" as="font" href=".<%= path %>" crossorigin="anonymous"/>
|
||||
<% for (const tag of htmlWebpackPlugin.tags.headTags) {
|
||||
let path = tag.attributes && tag.attributes.href;
|
||||
if (path && path.includes("/Inter/")) { %>
|
||||
<link rel="preload" as="font" href="<%= path %>" crossorigin="anonymous"/>
|
||||
<% }
|
||||
} %>
|
||||
|
||||
</head>
|
||||
<body style="height: 100%; margin: 0;">
|
||||
<noscript>Sorry, Element requires JavaScript to be enabled.</noscript> <!-- TODO: Translate this? -->
|
||||
<section id="matrixchat" style="height: 100%;" class="notranslate"></section>
|
||||
<script src="<%= htmlWebpackPlugin.files.js.find(entry => entry.includes("bundle.js")) %>"></script>
|
||||
<div id="matrixchat" style="height: 100%;" class="notranslate"></div>
|
||||
|
||||
<%
|
||||
// insert <script> tags for the JS entry points
|
||||
for (let file of htmlWebpackPlugin.files.js) {
|
||||
if (file.includes("bundle.js") || file.includes("unhomoglyph_data")) {
|
||||
%> <script src="<%= file %>"></script>
|
||||
<%
|
||||
}
|
||||
}
|
||||
%>
|
||||
|
||||
<!-- Legacy supporting Prefetch images -->
|
||||
<img src="<%= require('matrix-react-sdk/res/img/warning.svg').default %>" aria-hidden alt="" width="24" height="23" style="visibility: hidden; position: absolute; top: 0px; left: 0px;"/>
|
||||
|
@ -76,5 +84,11 @@
|
|||
<audio id="remoteAudio"></audio>
|
||||
<!-- let CSS themes pass constants to the app -->
|
||||
<div id="mx_theme_accentColor"></div><div id="mx_theme_secondaryAccentColor"></div><div id="mx_theme_tertiaryAccentColor"></div>
|
||||
|
||||
<!-- We eagerly create these containers to ensure their CSS stacking context order is sensible -->
|
||||
<div id="mx_PersistedElement_container"></div>
|
||||
<div id="mx_Dialog_StaticContainer"></div>
|
||||
<div id="mx_Dialog_Container"></div>
|
||||
<div id="mx_ContextualMenu_Container"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -28,7 +28,6 @@ import "./modernizr";
|
|||
// Require common CSS here; this will make webpack process it into bundle.css.
|
||||
// Our own CSS (which is themed) is imported via separate webpack entry points
|
||||
// in webpack.config.js
|
||||
require("gfm.css/gfm.css");
|
||||
require("katex/dist/katex.css");
|
||||
|
||||
/**
|
||||
|
@ -99,7 +98,7 @@ const supportedBrowser = checkBrowserFeatures();
|
|||
// try in react but fallback to an `alert`
|
||||
// We start loading stuff but don't block on it until as late as possible to allow
|
||||
// the browser to use as much parallelism as it can.
|
||||
// Load parallelism is based on research in https://github.com/vector-im/element-web/issues/12253
|
||||
// Load parallelism is based on research in https://github.com/element-hq/element-web/issues/12253
|
||||
async function start(): Promise<void> {
|
||||
// load init.ts async so that its code is not executed immediately and we can catch any exceptions
|
||||
const {
|
||||
|
@ -130,7 +129,7 @@ async function start(): Promise<void> {
|
|||
// don't try to redirect to the native apps if we're
|
||||
// verifying a 3pid (but after we've loaded the config)
|
||||
// or if the user is following a deep link
|
||||
// (https://github.com/vector-im/element-web/issues/7378)
|
||||
// (https://github.com/element-hq/element-web/issues/7378)
|
||||
const preventRedirect = fragparts.params.client_secret || fragparts.location.length > 0;
|
||||
|
||||
if (!preventRedirect) {
|
||||
|
@ -177,7 +176,7 @@ async function start(): Promise<void> {
|
|||
// error handling begins here
|
||||
// ##########################
|
||||
if (!acceptBrowser) {
|
||||
await new Promise<void>((resolve) => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
logger.error("Browser is missing required features.");
|
||||
// take to a different landing page to AWOOOOOGA at the user
|
||||
showIncompatibleBrowser(() => {
|
||||
|
@ -186,7 +185,7 @@ async function start(): Promise<void> {
|
|||
}
|
||||
logger.log("User accepts the compatibility risks.");
|
||||
resolve();
|
||||
});
|
||||
}).catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -197,17 +196,14 @@ async function start(): Promise<void> {
|
|||
// Now that we've loaded the theme (CSS), display the config syntax error if needed.
|
||||
if (error instanceof SyntaxError) {
|
||||
// This uses the default brand since the app config is unavailable.
|
||||
return showError(_t("Your Element is misconfigured"), [
|
||||
_t(
|
||||
"Your Element configuration contains invalid JSON. " +
|
||||
"Please correct the problem and reload the page.",
|
||||
),
|
||||
_t("The message from the parser is: %(message)s", {
|
||||
message: error.message || _t("Invalid JSON"),
|
||||
return showError(_t("error|misconfigured"), [
|
||||
_t("error|invalid_json"),
|
||||
_t("error|invalid_json_detail", {
|
||||
message: error.message || _t("error|invalid_json_generic"),
|
||||
}),
|
||||
]);
|
||||
}
|
||||
return showError(_t("Unable to load config file: please refresh the page to try again."));
|
||||
return showError(_t("error|cannot_load_config"));
|
||||
}
|
||||
|
||||
// ##################################
|
||||
|
@ -231,8 +227,8 @@ async function start(): Promise<void> {
|
|||
logger.error(err);
|
||||
// Like the compatibility page, AWOOOOOGA at the user
|
||||
// This uses the default brand since the app config is unavailable.
|
||||
await showError(_t("Your Element is misconfigured"), [
|
||||
extractErrorMessageFromError(err, _t("Unexpected error preparing the app. See console for details.")),
|
||||
await showError(_t("error|misconfigured"), [
|
||||
extractErrorMessageFromError(err, _t("error|app_launch_unexpected_error")),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -30,6 +30,7 @@ import SdkConfig from "matrix-react-sdk/src/SdkConfig";
|
|||
import { setTheme } from "matrix-react-sdk/src/theme";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { ModuleRunner } from "matrix-react-sdk/src/modules/ModuleRunner";
|
||||
import MatrixChat from "matrix-react-sdk/src/components/structures/MatrixChat";
|
||||
|
||||
import ElectronPlatform from "./platform/ElectronPlatform";
|
||||
import PWAPlatform from "./platform/PWAPlatform";
|
||||
|
@ -137,7 +138,7 @@ export async function loadLanguage(): Promise<void> {
|
|||
}
|
||||
|
||||
export async function loadTheme(): Promise<void> {
|
||||
setTheme();
|
||||
return setTheme();
|
||||
}
|
||||
|
||||
export async function loadApp(fragParams: {}): Promise<void> {
|
||||
|
@ -147,7 +148,10 @@ export async function loadApp(fragParams: {}): Promise<void> {
|
|||
/* webpackPreload: true */
|
||||
"./app"
|
||||
);
|
||||
window.matrixChat = ReactDOM.render(await module.loadApp(fragParams), document.getElementById("matrixchat"));
|
||||
function setWindowMatrixChat(matrixChat: MatrixChat): void {
|
||||
window.matrixChat = matrixChat;
|
||||
}
|
||||
ReactDOM.render(await module.loadApp(fragParams, setWindowMatrixChat), document.getElementById("matrixchat"));
|
||||
}
|
||||
|
||||
export async function showError(title: string, messages?: string[]): Promise<void> {
|
||||
|
@ -184,4 +188,4 @@ export async function loadModules(): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
export const _t = languageHandler._t;
|
||||
export { _t } from "../languageHandler";
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
|
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
/* TODO: Match the user's theme: https://github.com/vector-im/element-web/issues/12794 */
|
||||
/* TODO: Match the user's theme: https://github.com/element-hq/element-web/issues/12794 */
|
||||
|
||||
@font-face {
|
||||
font-family: "Nunito";
|
||||
|
|
|
@ -37,9 +37,40 @@ import type {
|
|||
AudioMuteStatusChangedEvent,
|
||||
LogEvent,
|
||||
VideoMuteStatusChangedEvent,
|
||||
ExternalAPIOptions as _ExternalAPIOptions,
|
||||
Config as _Config,
|
||||
InterfaceConfig as _InterfaceConfig,
|
||||
} from "jitsi-meet";
|
||||
import { getVectorConfig } from "../getconfig";
|
||||
|
||||
interface Config extends _Config {
|
||||
// Jitsi's types are missing these fields
|
||||
prejoinConfig?: {
|
||||
enabled: boolean;
|
||||
hideDisplayName?: boolean;
|
||||
hideExtraJoinButtons?: string[];
|
||||
};
|
||||
toolbarButtons?: string[];
|
||||
conferenceInfo?: {
|
||||
alwaysVIsible?: string[];
|
||||
autoHide?: string[];
|
||||
};
|
||||
disableSelfViewSettings?: boolean;
|
||||
}
|
||||
|
||||
interface InterfaceConfig extends _InterfaceConfig {
|
||||
// XXX: It is unclear whether this is a typo of TOOLBAR_BUTTONS or if its just really undocumented,
|
||||
// either way it is missing in types, yet we try and use it
|
||||
MAIN_TOOLBAR_BUTTONS?: string[];
|
||||
}
|
||||
|
||||
interface ExternalAPIOptions extends _ExternalAPIOptions {
|
||||
configOverwrite?: Config;
|
||||
interfaceConfigOverwrite?: InterfaceConfig;
|
||||
// Jitsi's types are missing these fields
|
||||
lang?: string;
|
||||
}
|
||||
|
||||
// We have to trick webpack into loading our CSS for us.
|
||||
require("./index.pcss");
|
||||
|
||||
|
@ -146,17 +177,17 @@ const setupCompleted = (async (): Promise<string | void> => {
|
|||
}
|
||||
}
|
||||
|
||||
await widgetApi!.transport.reply(ev.detail, response);
|
||||
widgetApi!.transport.reply(ev.detail, response);
|
||||
});
|
||||
};
|
||||
|
||||
handleAction(ElementWidgetActions.JoinCall, async ({ audioInput, videoInput }) => {
|
||||
joinConference(audioInput as string | null, videoInput as string | null);
|
||||
void joinConference(audioInput as string | null, videoInput as string | null);
|
||||
});
|
||||
handleAction(ElementWidgetActions.HangupCall, async ({ force }) => {
|
||||
if (force === true) {
|
||||
meetApi?.dispose();
|
||||
notifyHangup();
|
||||
void notifyHangup();
|
||||
meetApi = undefined;
|
||||
closeConference();
|
||||
} else {
|
||||
|
@ -261,14 +292,12 @@ function switchVisibleContainers(): void {
|
|||
|
||||
function toggleConferenceVisibility(inConference: boolean): void {
|
||||
document.getElementById("jitsiContainer")!.style.visibility = inConference ? "unset" : "hidden";
|
||||
// Video rooms have a separate UI for joining, so they should never show our join button
|
||||
document.getElementById("joinButtonContainer")!.style.visibility =
|
||||
inConference || isVideoChannel ? "hidden" : "unset";
|
||||
document.getElementById("joinButtonContainer")!.style.visibility = inConference ? "hidden" : "unset";
|
||||
}
|
||||
|
||||
function skipToJitsiSplashScreen(): void {
|
||||
// really just a function alias for self-documenting code
|
||||
joinConference();
|
||||
void joinConference();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -382,7 +411,7 @@ async function joinConference(audioInput?: string | null, videoInput?: string |
|
|||
"our fragment values and not recognizing the options.",
|
||||
);
|
||||
|
||||
const options = {
|
||||
const options: ExternalAPIOptions = {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
parentNode: document.querySelector("#jitsiContainer") ?? undefined,
|
||||
|
@ -419,22 +448,21 @@ async function joinConference(audioInput?: string | null, videoInput?: string |
|
|||
|
||||
// Video channel widgets need some more tailored config options
|
||||
if (isVideoChannel) {
|
||||
// Ensure that we skip Jitsi Meet's native prejoin screen, for
|
||||
// deployments that have it enabled
|
||||
options.configOverwrite.prejoinConfig = { enabled: false };
|
||||
// We don't skip jitsi's prejoin screen for video rooms.
|
||||
options.configOverwrite!.prejoinConfig = { enabled: true };
|
||||
// Use a simplified set of toolbar buttons
|
||||
options.configOverwrite.toolbarButtons = ["microphone", "camera", "tileview", "hangup"];
|
||||
options.configOverwrite!.toolbarButtons = ["microphone", "camera", "tileview", "hangup"];
|
||||
// Note: We can hide the screenshare button in video rooms but not in
|
||||
// normal conference calls, since in video rooms we control exactly what
|
||||
// set of controls appear, but in normal calls we need to leave that up
|
||||
// to the deployment's configuration.
|
||||
// https://github.com/vector-im/element-web/issues/4880#issuecomment-940002464
|
||||
if (supportsScreensharing) options.configOverwrite.toolbarButtons.splice(2, 0, "desktop");
|
||||
// https://github.com/element-hq/element-web/issues/4880#issuecomment-940002464
|
||||
if (supportsScreensharing) options.configOverwrite!.toolbarButtons.splice(2, 0, "desktop");
|
||||
// Hide all top bar elements
|
||||
options.configOverwrite.conferenceInfo = { autoHide: [] };
|
||||
options.configOverwrite!.conferenceInfo = { autoHide: [] };
|
||||
// Remove the ability to hide your own tile, since we're hiding the
|
||||
// settings button which would be the only way to get it back
|
||||
options.configOverwrite.disableSelfViewSettings = true;
|
||||
options.configOverwrite!.disableSelfViewSettings = true;
|
||||
}
|
||||
|
||||
meetApi = new JitsiMeetExternalAPI(jitsiDomain, options);
|
||||
|
@ -472,8 +500,8 @@ const onVideoConferenceJoined = (): void => {
|
|||
if (widgetApi) {
|
||||
// ignored promise because we don't care if it works
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
widgetApi.setAlwaysOnScreen(true);
|
||||
widgetApi.transport.send(ElementWidgetActions.JoinCall, {});
|
||||
void widgetApi.setAlwaysOnScreen(true);
|
||||
void widgetApi.transport.send(ElementWidgetActions.JoinCall, {});
|
||||
}
|
||||
|
||||
// Video rooms should start in tile mode
|
||||
|
@ -481,7 +509,7 @@ const onVideoConferenceJoined = (): void => {
|
|||
};
|
||||
|
||||
const onVideoConferenceLeft = (): void => {
|
||||
notifyHangup();
|
||||
void notifyHangup();
|
||||
meetApi = undefined;
|
||||
};
|
||||
|
||||
|
@ -489,7 +517,7 @@ const onErrorOccurred = ({ error }: Parameters<ExternalAPIEventCallbacks["errorO
|
|||
if (error.isFatal) {
|
||||
// We got disconnected. Since Jitsi Meet might send us back to the
|
||||
// prejoin screen, we're forced to act as if we hung up entirely.
|
||||
notifyHangup(error.message);
|
||||
void notifyHangup(error.message);
|
||||
meetApi = undefined;
|
||||
closeConference();
|
||||
}
|
||||
|
@ -497,7 +525,7 @@ const onErrorOccurred = ({ error }: Parameters<ExternalAPIEventCallbacks["errorO
|
|||
|
||||
const onAudioMuteStatusChanged = ({ muted }: AudioMuteStatusChangedEvent): void => {
|
||||
const action = muted ? ElementWidgetActions.MuteAudio : ElementWidgetActions.UnmuteAudio;
|
||||
widgetApi?.transport.send(action, {});
|
||||
void widgetApi?.transport.send(action, {});
|
||||
};
|
||||
|
||||
const onVideoMuteStatusChanged = ({ muted }: VideoMuteStatusChangedEvent): void => {
|
||||
|
@ -507,15 +535,15 @@ const onVideoMuteStatusChanged = ({ muted }: VideoMuteStatusChangedEvent): void
|
|||
// otherwise the React SDK will mistakenly think the user turned off
|
||||
// their video by hand
|
||||
setTimeout(() => {
|
||||
if (meetApi) widgetApi?.transport.send(ElementWidgetActions.MuteVideo, {});
|
||||
if (meetApi) void widgetApi?.transport.send(ElementWidgetActions.MuteVideo, {});
|
||||
}, 200);
|
||||
} else {
|
||||
widgetApi?.transport.send(ElementWidgetActions.UnmuteVideo, {});
|
||||
void widgetApi?.transport.send(ElementWidgetActions.UnmuteVideo, {});
|
||||
}
|
||||
};
|
||||
|
||||
const updateParticipants = (): void => {
|
||||
widgetApi?.transport.send(ElementWidgetActions.CallParticipants, {
|
||||
void widgetApi?.transport.send(ElementWidgetActions.CallParticipants, {
|
||||
participants: meetApi?.getParticipantsInfo(),
|
||||
});
|
||||
};
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Element Mobile Guide</title>
|
||||
|
|
|
@ -44,10 +44,10 @@ async function initPage(): Promise<void> {
|
|||
const defaultIsUrl = config?.["default_is_url"];
|
||||
|
||||
const incompatibleOptions = [wkConfig, serverName, defaultHsUrl].filter((i) => !!i);
|
||||
if (incompatibleOptions.length > 1) {
|
||||
if (defaultHsUrl && (wkConfig || serverName)) {
|
||||
return renderConfigError(
|
||||
"Invalid configuration: can only specify one of default_server_config, default_server_name, " +
|
||||
"or default_hs_url.",
|
||||
"Invalid configuration: a default_hs_url can't be specified along with default_server_name " +
|
||||
"or default_server_config",
|
||||
);
|
||||
}
|
||||
if (incompatibleOptions.length < 1) {
|
||||
|
@ -57,7 +57,7 @@ async function initPage(): Promise<void> {
|
|||
let hsUrl: string | undefined;
|
||||
let isUrl: string | undefined;
|
||||
|
||||
if (typeof wkConfig?.["m.homeserver"]?.["base_url"] === "string") {
|
||||
if (!serverName && typeof wkConfig?.["m.homeserver"]?.["base_url"] === "string") {
|
||||
hsUrl = wkConfig["m.homeserver"]["base_url"];
|
||||
|
||||
if (typeof wkConfig["m.identity_server"]?.["base_url"] === "string") {
|
||||
|
@ -78,8 +78,16 @@ async function initPage(): Promise<void> {
|
|||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
return renderConfigError("Unable to fetch homeserver configuration");
|
||||
if (wkConfig && wkConfig["m.homeserver"]) {
|
||||
hsUrl = wkConfig["m.homeserver"]["base_url"] || undefined;
|
||||
|
||||
if (wkConfig["m.identity_server"]) {
|
||||
isUrl = wkConfig["m.identity_server"]["base_url"] || undefined;
|
||||
}
|
||||
} else {
|
||||
logger.error(e);
|
||||
return renderConfigError("Unable to fetch homeserver configuration");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -112,4 +120,4 @@ async function initPage(): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
initPage();
|
||||
void initPage();
|
||||
|
|
|
@ -21,7 +21,6 @@ limitations under the License.
|
|||
import { UpdateCheckStatus, UpdateStatus } from "matrix-react-sdk/src/BasePlatform";
|
||||
import BaseEventIndexManager from "matrix-react-sdk/src/indexing/BaseEventIndexManager";
|
||||
import dis from "matrix-react-sdk/src/dispatcher/dispatcher";
|
||||
import { _t } from "matrix-react-sdk/src/languageHandler";
|
||||
import SdkConfig from "matrix-react-sdk/src/SdkConfig";
|
||||
import { IConfigOptions } from "matrix-react-sdk/src/IConfigOptions";
|
||||
import * as rageshake from "matrix-react-sdk/src/rageshake/rageshake";
|
||||
|
@ -43,10 +42,13 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
|||
import { BreadcrumbsStore } from "matrix-react-sdk/src/stores/BreadcrumbsStore";
|
||||
import { UPDATE_EVENT } from "matrix-react-sdk/src/stores/AsyncStore";
|
||||
import { avatarUrlForRoom, getInitialLetter } from "matrix-react-sdk/src/Avatar";
|
||||
import DesktopCapturerSourcePicker from "matrix-react-sdk/src/components/views/elements/DesktopCapturerSourcePicker";
|
||||
import { OidcRegistrationClientMetadata } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import VectorBasePlatform from "./VectorBasePlatform";
|
||||
import { SeshatIndexManager } from "./SeshatIndexManager";
|
||||
import { IPCManager } from "./IPCManager";
|
||||
import { _t } from "../../languageHandler";
|
||||
|
||||
interface SquirrelUpdate {
|
||||
releaseNotes: string;
|
||||
|
@ -55,6 +57,8 @@ interface SquirrelUpdate {
|
|||
updateURL: string;
|
||||
}
|
||||
|
||||
const SSO_ID_KEY = "element-desktop-ssoid";
|
||||
|
||||
const isMac = navigator.platform.toUpperCase().includes("MAC");
|
||||
|
||||
function platformFriendlyName(): string {
|
||||
|
@ -149,12 +153,12 @@ export default class ElectronPlatform extends VectorBasePlatform {
|
|||
|
||||
ToastStore.sharedInstance().addOrReplaceToast({
|
||||
key,
|
||||
title: _t("Download Completed"),
|
||||
title: _t("download_completed"),
|
||||
props: {
|
||||
description: name,
|
||||
acceptLabel: _t("Open"),
|
||||
acceptLabel: _t("action|open"),
|
||||
onAccept,
|
||||
dismissLabel: _t("Dismiss"),
|
||||
dismissLabel: _t("action|dismiss"),
|
||||
onDismiss,
|
||||
numSeconds: 10,
|
||||
},
|
||||
|
@ -163,7 +167,14 @@ export default class ElectronPlatform extends VectorBasePlatform {
|
|||
});
|
||||
});
|
||||
|
||||
this.ipc.call("startSSOFlow", this.ssoID);
|
||||
window.electron.on("openDesktopCapturerSourcePicker", async () => {
|
||||
const { finished } = Modal.createDialog(DesktopCapturerSourcePicker);
|
||||
const [source] = await finished;
|
||||
// getDisplayMedia promise does not return if no dummy is passed here as source
|
||||
await this.ipc.call("callDisplayMediaCallback", source ?? { id: "", name: "", thumbnailURL: "" });
|
||||
});
|
||||
|
||||
void this.ipc.call("startSSOFlow", this.ssoID);
|
||||
|
||||
BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate);
|
||||
}
|
||||
|
@ -183,7 +194,7 @@ export default class ElectronPlatform extends VectorBasePlatform {
|
|||
),
|
||||
initial: getInitialLetter(r.name),
|
||||
}));
|
||||
this.ipc.call("breadcrumbs", rooms);
|
||||
void this.ipc.call("breadcrumbs", rooms);
|
||||
};
|
||||
|
||||
private onUpdateDownloaded = async (ev: Event, { releaseNotes, releaseName }: SquirrelUpdate): Promise<void> => {
|
||||
|
@ -249,7 +260,7 @@ export default class ElectronPlatform extends VectorBasePlatform {
|
|||
const handler = notification.onclick as Function;
|
||||
notification.onclick = (): void => {
|
||||
handler?.();
|
||||
this.ipc.call("focusWindow");
|
||||
void this.ipc.call("focusWindow");
|
||||
};
|
||||
|
||||
return notification;
|
||||
|
@ -304,7 +315,7 @@ export default class ElectronPlatform extends VectorBasePlatform {
|
|||
|
||||
public getDefaultDeviceDisplayName(): string {
|
||||
const brand = SdkConfig.get().brand;
|
||||
return _t("%(brand)s Desktop: %(platformName)s", {
|
||||
return _t("desktop_default_device_name", {
|
||||
brand,
|
||||
platformName: platformFriendlyName(),
|
||||
});
|
||||
|
@ -357,7 +368,7 @@ export default class ElectronPlatform extends VectorBasePlatform {
|
|||
}
|
||||
|
||||
public supportsJitsiScreensharing(): boolean {
|
||||
// See https://github.com/vector-im/element-web/issues/4880
|
||||
// See https://github.com/element-hq/element-web/issues/4880
|
||||
return false;
|
||||
}
|
||||
|
||||
|
@ -365,10 +376,10 @@ export default class ElectronPlatform extends VectorBasePlatform {
|
|||
return this.ipc.call("getAvailableSpellCheckLanguages");
|
||||
}
|
||||
|
||||
public getSSOCallbackUrl(fragmentAfterLogin: string): URL {
|
||||
public getSSOCallbackUrl(fragmentAfterLogin?: string): URL {
|
||||
const url = super.getSSOCallbackUrl(fragmentAfterLogin);
|
||||
url.protocol = "element";
|
||||
url.searchParams.set("element-desktop-ssoid", this.ssoID);
|
||||
url.searchParams.set(SSO_ID_KEY, this.ssoID);
|
||||
return url;
|
||||
}
|
||||
|
||||
|
@ -381,13 +392,13 @@ export default class ElectronPlatform extends VectorBasePlatform {
|
|||
// this will get intercepted by electron-main will-navigate
|
||||
super.startSingleSignOn(mxClient, loginType, fragmentAfterLogin, idpId);
|
||||
Modal.createDialog(InfoDialog, {
|
||||
title: _t("Go to your browser to complete Sign In"),
|
||||
title: _t("auth|sso_complete_in_browser_dialog_title"),
|
||||
description: <Spinner />,
|
||||
});
|
||||
}
|
||||
|
||||
public navigateForwardBack(back: boolean): void {
|
||||
this.ipc.call(back ? "navigateBack" : "navigateForward");
|
||||
void this.ipc.call(back ? "navigateBack" : "navigateForward");
|
||||
}
|
||||
|
||||
public overrideBrowserShortcuts(): boolean {
|
||||
|
@ -426,4 +437,35 @@ export default class ElectronPlatform extends VectorBasePlatform {
|
|||
await this.ipc.call("clearStorage");
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
public get baseUrl(): string {
|
||||
// This configuration is element-desktop specific so the types here do not know about it
|
||||
return (SdkConfig.get() as unknown as Record<string, string>)["web_base_url"] ?? "https://app.element.io";
|
||||
}
|
||||
|
||||
public get defaultOidcClientUri(): string {
|
||||
// Default to element.io as our scheme `io.element.desktop` is within its scope on default MAS policies
|
||||
return "https://element.io";
|
||||
}
|
||||
|
||||
public async getOidcClientMetadata(): Promise<OidcRegistrationClientMetadata> {
|
||||
const baseMetadata = await super.getOidcClientMetadata();
|
||||
return {
|
||||
...baseMetadata,
|
||||
applicationType: "native",
|
||||
};
|
||||
}
|
||||
|
||||
public getOidcClientState(): string {
|
||||
return `:${SSO_ID_KEY}:${this.ssoID}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The URL to return to after a successful OIDC authentication
|
||||
*/
|
||||
public getOidcCallbackUrl(): URL {
|
||||
const url = super.getOidcCallbackUrl();
|
||||
url.protocol = "io.element.desktop";
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,11 +18,11 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import BasePlatform from "matrix-react-sdk/src/BasePlatform";
|
||||
import { _t } from "matrix-react-sdk/src/languageHandler";
|
||||
|
||||
import type { IConfigOptions } from "matrix-react-sdk/src/IConfigOptions";
|
||||
import { getVectorConfig } from "../getconfig";
|
||||
import Favicon from "../../favicon";
|
||||
import { _t } from "../../languageHandler";
|
||||
|
||||
/**
|
||||
* Vector-specific extensions to the BasePlatform template
|
||||
|
@ -41,7 +41,7 @@ export default abstract class VectorBasePlatform extends BasePlatform {
|
|||
/**
|
||||
* Delay creating the `Favicon` instance until first use (on the first notification) as
|
||||
* it uses canvas, which can trigger a permission prompt in Firefox's resist fingerprinting mode.
|
||||
* See https://github.com/vector-im/element-web/issues/9605.
|
||||
* See https://github.com/element-hq/element-web/issues/9605.
|
||||
*/
|
||||
public get favicon(): Favicon {
|
||||
if (this._favicon) {
|
||||
|
@ -85,6 +85,6 @@ export default abstract class VectorBasePlatform extends BasePlatform {
|
|||
* device Vector is running on
|
||||
*/
|
||||
public getDefaultDeviceDisplayName(): string {
|
||||
return _t("Unknown device");
|
||||
return _t("unknown_device");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
/*
|
||||
Copyright 2016 Aviral Dasgupta
|
||||
Copyright 2016 OpenMarket Ltd
|
||||
Copyright 2017-2020 New Vector Ltd
|
||||
Copyright 2017-2020, 2024 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -18,7 +18,6 @@ limitations under the License.
|
|||
|
||||
import { UpdateCheckStatus, UpdateStatus } from "matrix-react-sdk/src/BasePlatform";
|
||||
import dis from "matrix-react-sdk/src/dispatcher/dispatcher";
|
||||
import { _t } from "matrix-react-sdk/src/languageHandler";
|
||||
import { hideToast as hideUpdateToast, showToast as showUpdateToast } from "matrix-react-sdk/src/toasts/UpdateToast";
|
||||
import { Action } from "matrix-react-sdk/src/dispatcher/actions";
|
||||
import { CheckUpdatesPayload } from "matrix-react-sdk/src/dispatcher/payloads/CheckUpdatesPayload";
|
||||
|
@ -27,6 +26,7 @@ import { logger } from "matrix-js-sdk/src/logger";
|
|||
|
||||
import VectorBasePlatform from "./VectorBasePlatform";
|
||||
import { parseQs } from "../url_utils";
|
||||
import { _t } from "../../languageHandler";
|
||||
|
||||
const POKE_RATE_MS = 10 * 60 * 1000; // 10 min
|
||||
|
||||
|
@ -44,9 +44,41 @@ export default class WebPlatform extends VectorBasePlatform {
|
|||
|
||||
public constructor() {
|
||||
super();
|
||||
// Register service worker if available on this platform
|
||||
if ("serviceWorker" in navigator) {
|
||||
navigator.serviceWorker.register("sw.js");
|
||||
|
||||
// Register the service worker in the background
|
||||
this.tryRegisterServiceWorker().catch((e) => console.error("Error registering/updating service worker:", e));
|
||||
}
|
||||
|
||||
private async tryRegisterServiceWorker(): Promise<void> {
|
||||
if (!("serviceWorker" in navigator)) {
|
||||
return; // not available on this platform - don't try to register the service worker
|
||||
}
|
||||
|
||||
// sw.js is exported by webpack, sourced from `/src/serviceworker/index.ts`
|
||||
const registration = await navigator.serviceWorker.register("sw.js");
|
||||
if (!registration) {
|
||||
// Registration didn't work for some reason - assume failed and ignore.
|
||||
// This typically happens in Jest.
|
||||
return;
|
||||
}
|
||||
|
||||
await registration.update();
|
||||
navigator.serviceWorker.addEventListener("message", this.onServiceWorkerPostMessage.bind(this));
|
||||
}
|
||||
|
||||
private onServiceWorkerPostMessage(event: MessageEvent): void {
|
||||
try {
|
||||
if (event.data?.["type"] === "userinfo" && event.data?.["responseKey"]) {
|
||||
const userId = localStorage.getItem("mx_user_id");
|
||||
const deviceId = localStorage.getItem("mx_device_id");
|
||||
event.source!.postMessage({
|
||||
responseKey: event.data["responseKey"],
|
||||
userId,
|
||||
deviceId,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error responding to service worker: ", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -81,10 +113,10 @@ export default class WebPlatform extends VectorBasePlatform {
|
|||
// annoyingly, the latest spec says this returns a
|
||||
// promise, but this is only supported in Chrome 46
|
||||
// and Firefox 47, so adapt the callback API.
|
||||
return new Promise(function (resolve) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
window.Notification.requestPermission((result) => {
|
||||
resolve(result);
|
||||
});
|
||||
}).catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -116,7 +148,7 @@ export default class WebPlatform extends VectorBasePlatform {
|
|||
// Ideally, loading an old copy would be impossible with the
|
||||
// cache-control: nocache HTTP header set, but Firefox doesn't always obey it :/
|
||||
console.log("startUpdater, current version is " + getNormalizedAppVersion(WebPlatform.VERSION));
|
||||
this.pollForUpdate((version: string, newVersion: string) => {
|
||||
void this.pollForUpdate((version: string, newVersion: string) => {
|
||||
const query = parseQs(location);
|
||||
if (query.updated) {
|
||||
console.log("Update reloaded but still on an old version, stopping");
|
||||
|
@ -175,7 +207,7 @@ export default class WebPlatform extends VectorBasePlatform {
|
|||
|
||||
public startUpdateCheck(): void {
|
||||
super.startUpdateCheck();
|
||||
this.pollForUpdate(showUpdateToast, hideUpdateToast).then((updateState) => {
|
||||
void this.pollForUpdate(showUpdateToast, hideUpdateToast).then((updateState) => {
|
||||
dis.dispatch<CheckUpdatesPayload>({
|
||||
action: Action.CheckUpdates,
|
||||
...updateState,
|
||||
|
@ -202,7 +234,7 @@ export default class WebPlatform extends VectorBasePlatform {
|
|||
let osName = ua.getOS().name || "unknown OS";
|
||||
// Stylise the value from the parser to match Apple's current branding.
|
||||
if (osName === "Mac OS") osName = "macOS";
|
||||
return _t("%(appName)s: %(browserName)s on %(osName)s", {
|
||||
return _t("web_default_device_name", {
|
||||
appName,
|
||||
browserName,
|
||||
osName,
|
||||
|
|
|
@ -35,10 +35,11 @@ export function initRageshake(): Promise<void> {
|
|||
// we manually check persistence for rageshakes ourselves
|
||||
const prom = rageshake.init(/*setUpPersistence=*/ false);
|
||||
prom.then(
|
||||
() => {
|
||||
async () => {
|
||||
logger.log("Initialised rageshake.");
|
||||
logger.log(
|
||||
"To fix line numbers in Chrome: " + "Meatball menu → Settings → Ignore list → Add /rageshake\\.js$",
|
||||
"To fix line numbers in Chrome: " +
|
||||
"Meatball menu → Settings → Ignore list → Add /rageshake\\.ts & /logger\\.ts$",
|
||||
);
|
||||
|
||||
window.addEventListener("beforeunload", () => {
|
||||
|
@ -47,7 +48,7 @@ export function initRageshake(): Promise<void> {
|
|||
rageshake.flush();
|
||||
});
|
||||
|
||||
rageshake.cleanup();
|
||||
await rageshake.cleanup();
|
||||
},
|
||||
(err) => {
|
||||
logger.error("Failed to initialise rageshake: " + err);
|
||||
|
|
|
@ -76,3 +76,36 @@ export function onNewScreen(screen: string, replaceLast = false): void {
|
|||
export function init(): void {
|
||||
window.addEventListener("hashchange", onHashChange);
|
||||
}
|
||||
|
||||
const ScreenAfterLoginStorageKey = "mx_screen_after_login";
|
||||
function getStoredInitialScreenAfterLogin(): ReturnType<typeof getScreenFromLocation> | undefined {
|
||||
const screenAfterLogin = sessionStorage.getItem(ScreenAfterLoginStorageKey);
|
||||
|
||||
return screenAfterLogin ? JSON.parse(screenAfterLogin) : undefined;
|
||||
}
|
||||
|
||||
function setInitialScreenAfterLogin(screenAfterLogin?: ReturnType<typeof getScreenFromLocation>): void {
|
||||
if (screenAfterLogin?.screen) {
|
||||
sessionStorage.setItem(ScreenAfterLoginStorageKey, JSON.stringify(screenAfterLogin));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the initial screen to be displayed after login,
|
||||
* for example when trying to view a room via a link before logging in
|
||||
*
|
||||
* If the current URL has a screen set that in session storage
|
||||
* Then retrieve the screen from session storage and return it
|
||||
* Using session storage allows us to remember login fragments from when returning from OIDC login
|
||||
* @returns screen and params or undefined
|
||||
*/
|
||||
export function getInitialScreenAfterLogin(location: Location): ReturnType<typeof getScreenFromLocation> | undefined {
|
||||
const screenAfterLogin = getScreenFromLocation(location);
|
||||
|
||||
if (screenAfterLogin.screen || screenAfterLogin.params) {
|
||||
setInitialScreenAfterLogin(screenAfterLogin);
|
||||
}
|
||||
|
||||
const storedScreenAfterLogin = getStoredInitialScreenAfterLogin();
|
||||
return storedScreenAfterLogin;
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue