Untangle the semantics of isEmbedded
This deletes the isEmbedded flag from UrlParams, replacing it with an alternative set of flags that I think is more sensible and well-defined.
This commit is contained in:
@@ -27,6 +27,11 @@ interface RoomIdentifier {
|
|||||||
viaServers: string[];
|
viaServers: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If you need to add a new flag to this interface, prefer a name that describes
|
||||||
|
// a specific behavior (such as 'confineToRoom'), rather than one that describes
|
||||||
|
// the situations that call for this behavior ('isEmbedded'). This makes it
|
||||||
|
// clearer what each flag means, and helps us avoid coupling Element Call's
|
||||||
|
// behavior to the needs of specific consumers.
|
||||||
interface UrlParams {
|
interface UrlParams {
|
||||||
/**
|
/**
|
||||||
* Anything about what room we're pointed to should be from useRoomIdentifier which
|
* Anything about what room we're pointed to should be from useRoomIdentifier which
|
||||||
@@ -37,10 +42,14 @@ interface UrlParams {
|
|||||||
*/
|
*/
|
||||||
roomId: string | null;
|
roomId: string | null;
|
||||||
/**
|
/**
|
||||||
* Whether the app is running in embedded mode, and should keep the user
|
* Whether the app should keep the user confined to the current call/room.
|
||||||
* confined to the current room.
|
|
||||||
*/
|
*/
|
||||||
isEmbedded: boolean;
|
confineToRoom: boolean;
|
||||||
|
/**
|
||||||
|
* Whether upon entering a room, the user should be prompted to launch the
|
||||||
|
* native mobile app. (Affects only Android and iOS.)
|
||||||
|
*/
|
||||||
|
appPrompt: boolean;
|
||||||
/**
|
/**
|
||||||
* Whether the app should pause before joining the call until it sees an
|
* Whether the app should pause before joining the call until it sees an
|
||||||
* io.element.join widget action, allowing it to be preloaded.
|
* io.element.join widget action, allowing it to be preloaded.
|
||||||
@@ -101,6 +110,10 @@ interface UrlParams {
|
|||||||
password: string | null;
|
password: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This is here as a stopgap, but what would be far nicer is a function that
|
||||||
|
// takes a UrlParams and returns a query string. That would enable us to
|
||||||
|
// consolidate all the data about URL parameters and their meanings to this one
|
||||||
|
// file.
|
||||||
export function editFragmentQuery(
|
export function editFragmentQuery(
|
||||||
hash: string,
|
hash: string,
|
||||||
edit: (params: URLSearchParams) => URLSearchParams
|
edit: (params: URLSearchParams) => URLSearchParams
|
||||||
@@ -169,11 +182,15 @@ export const getUrlParams = (
|
|||||||
// the room ID is, then that's what it is.
|
// the room ID is, then that's what it is.
|
||||||
roomId: parser.getParam("roomId"),
|
roomId: parser.getParam("roomId"),
|
||||||
password: parser.getParam("password"),
|
password: parser.getParam("password"),
|
||||||
isEmbedded: parser.hasParam("embed"),
|
// This flag has 'embed' as an alias for historical reasons
|
||||||
|
confineToRoom: parser.hasParam("confineToRoom") || parser.hasParam("embed"),
|
||||||
|
// Defaults to true
|
||||||
|
appPrompt: parser.getParam("appPrompt") !== "false",
|
||||||
preload: parser.hasParam("preload"),
|
preload: parser.hasParam("preload"),
|
||||||
hideHeader: parser.hasParam("hideHeader"),
|
hideHeader: parser.hasParam("hideHeader"),
|
||||||
hideScreensharing: parser.hasParam("hideScreensharing"),
|
hideScreensharing: parser.hasParam("hideScreensharing"),
|
||||||
e2eEnabled: parser.getParam("enableE2e") !== "false", // Defaults to true
|
// Defaults to true
|
||||||
|
e2eEnabled: parser.getParam("enableE2e") !== "false",
|
||||||
userId: parser.getParam("userId"),
|
userId: parser.getParam("userId"),
|
||||||
displayName: parser.getParam("displayName"),
|
displayName: parser.getParam("displayName"),
|
||||||
deviceId: parser.getParam("deviceId"),
|
deviceId: parser.getParam("deviceId"),
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { useEnableE2EE } from "../settings/useSetting";
|
|||||||
import { useLocalStorage } from "../useLocalStorage";
|
import { useLocalStorage } from "../useLocalStorage";
|
||||||
import { useClient } from "../ClientContext";
|
import { useClient } from "../ClientContext";
|
||||||
import { PASSWORD_STRING, useUrlParams } from "../UrlParams";
|
import { PASSWORD_STRING, useUrlParams } from "../UrlParams";
|
||||||
|
import { widget } from "../widget";
|
||||||
|
|
||||||
export const getRoomSharedKeyLocalStorageKey = (roomId: string): string =>
|
export const getRoomSharedKeyLocalStorageKey = (roomId: string): string =>
|
||||||
`room-shared-key-${roomId}`;
|
`room-shared-key-${roomId}`;
|
||||||
@@ -67,19 +68,13 @@ export const useManageRoomSharedKey = (roomId: string): string | null => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const useIsRoomE2EE = (roomId: string): boolean | null => {
|
export const useIsRoomE2EE = (roomId: string): boolean | null => {
|
||||||
const { isEmbedded } = useUrlParams();
|
const { client } = useClient();
|
||||||
const client = useClient();
|
const room = useMemo(() => client?.getRoom(roomId) ?? null, [roomId, client]);
|
||||||
const room = useMemo(
|
// For now, rooms in widget mode are never considered encrypted.
|
||||||
() => client.client?.getRoom(roomId) ?? null,
|
// In the future, when widget mode gains encryption support, then perhaps we
|
||||||
[roomId, client.client]
|
// should inspect the e2eEnabled URL parameter here?
|
||||||
|
return useMemo(
|
||||||
|
() => widget === null && (room === null || !room.getCanonicalAlias()),
|
||||||
|
[room]
|
||||||
);
|
);
|
||||||
const isE2EE = useMemo(() => {
|
|
||||||
if (isEmbedded) {
|
|
||||||
return false;
|
|
||||||
} else {
|
|
||||||
return room ? !room?.getCanonicalAlias() : null;
|
|
||||||
}
|
|
||||||
}, [isEmbedded, room]);
|
|
||||||
|
|
||||||
return isE2EE;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -50,12 +50,11 @@ export const AppSelectionModal: FC<Props> = ({ roomId }) => {
|
|||||||
? window.location.href
|
? window.location.href
|
||||||
: getRoomUrl(roomId, roomSharedKey ?? undefined)
|
: getRoomUrl(roomId, roomSharedKey ?? undefined)
|
||||||
);
|
);
|
||||||
// Edit the URL so that it opens in embedded mode. We do this for two
|
// Edit the URL to prevent the app selection prompt from appearing a second
|
||||||
// reasons: It causes the mobile app to limit the user to only visiting the
|
// time within the app, and to keep the user confined to the current room
|
||||||
// room in question, and it prevents this app selection prompt from being
|
|
||||||
// shown a second time.
|
|
||||||
url.hash = editFragmentQuery(url.hash, (params) => {
|
url.hash = editFragmentQuery(url.hash, (params) => {
|
||||||
params.set("embed", "");
|
params.set("appPrompt", "false");
|
||||||
|
params.set("confineToRoom", "");
|
||||||
return params;
|
return params;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { FormEventHandler, useCallback, useState } from "react";
|
import { FC, FormEventHandler, useCallback, useState } from "react";
|
||||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { useHistory } from "react-router-dom";
|
import { useHistory } from "react-router-dom";
|
||||||
@@ -30,19 +30,23 @@ import { FieldRow, InputField } from "../input/Input";
|
|||||||
import { StarRatingInput } from "../input/StarRatingInput";
|
import { StarRatingInput } from "../input/StarRatingInput";
|
||||||
import { RageshakeButton } from "../settings/RageshakeButton";
|
import { RageshakeButton } from "../settings/RageshakeButton";
|
||||||
|
|
||||||
export function CallEndedView({
|
interface Props {
|
||||||
client,
|
|
||||||
isPasswordlessUser,
|
|
||||||
endedCallId,
|
|
||||||
leaveError,
|
|
||||||
reconnect,
|
|
||||||
}: {
|
|
||||||
client: MatrixClient;
|
client: MatrixClient;
|
||||||
isPasswordlessUser: boolean;
|
isPasswordlessUser: boolean;
|
||||||
|
confineToRoom: boolean;
|
||||||
endedCallId: string;
|
endedCallId: string;
|
||||||
leaveError?: Error;
|
leaveError?: Error;
|
||||||
reconnect: () => void;
|
reconnect: () => void;
|
||||||
}) {
|
}
|
||||||
|
|
||||||
|
export const CallEndedView: FC<Props> = ({
|
||||||
|
client,
|
||||||
|
isPasswordlessUser,
|
||||||
|
confineToRoom,
|
||||||
|
endedCallId,
|
||||||
|
leaveError,
|
||||||
|
reconnect,
|
||||||
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|
||||||
@@ -72,14 +76,14 @@ export function CallEndedView({
|
|||||||
if (isPasswordlessUser) {
|
if (isPasswordlessUser) {
|
||||||
// setting this renders the callEndedView with the invitation to create an account
|
// setting this renders the callEndedView with the invitation to create an account
|
||||||
setSurverySubmitted(true);
|
setSurverySubmitted(true);
|
||||||
} else {
|
} else if (!confineToRoom) {
|
||||||
// if the user already has an account immediately go back to the home screen
|
// if the user already has an account immediately go back to the home screen
|
||||||
history.push("/");
|
history.push("/");
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}, 1000);
|
}, 1000);
|
||||||
},
|
},
|
||||||
[endedCallId, history, isPasswordlessUser, starRating]
|
[endedCallId, history, isPasswordlessUser, confineToRoom, starRating]
|
||||||
);
|
);
|
||||||
|
|
||||||
const createAccountDialog = isPasswordlessUser && (
|
const createAccountDialog = isPasswordlessUser && (
|
||||||
@@ -161,11 +165,13 @@ export function CallEndedView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<Body className={styles.footer}>
|
{!confineToRoom && (
|
||||||
<Link color="primary" to="/">
|
<Body className={styles.footer}>
|
||||||
{t("Return to home screen")}
|
<Link color="primary" to="/">
|
||||||
</Link>
|
{t("Return to home screen")}
|
||||||
</Body>
|
</Link>
|
||||||
|
</Body>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -183,15 +189,18 @@ export function CallEndedView({
|
|||||||
"\n" +
|
"\n" +
|
||||||
t("How did it go?")}
|
t("How did it go?")}
|
||||||
</Headline>
|
</Headline>
|
||||||
{!surveySubmitted && PosthogAnalytics.instance.isEnabled()
|
{(!surveySubmitted || confineToRoom) &&
|
||||||
|
PosthogAnalytics.instance.isEnabled()
|
||||||
? qualitySurveyDialog
|
? qualitySurveyDialog
|
||||||
: createAccountDialog}
|
: createAccountDialog}
|
||||||
</main>
|
</main>
|
||||||
<Body className={styles.footer}>
|
{!confineToRoom && (
|
||||||
<Link color="primary" to="/">
|
<Body className={styles.footer}>
|
||||||
{t("Not now, return to home screen")}
|
<Link color="primary" to="/">
|
||||||
</Link>
|
{t("Not now, return to home screen")}
|
||||||
</Body>
|
</Link>
|
||||||
|
</Body>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -200,12 +209,10 @@ export function CallEndedView({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header>
|
<Header>
|
||||||
<LeftNav>
|
<LeftNav>{!confineToRoom && <HeaderLogo />}</LeftNav>
|
||||||
<HeaderLogo />
|
|
||||||
</LeftNav>
|
|
||||||
<RightNav />
|
<RightNav />
|
||||||
</Header>
|
</Header>
|
||||||
<div className={styles.container}>{renderBody()}</div>
|
<div className={styles.container}>{renderBody()}</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ declare global {
|
|||||||
interface Props {
|
interface Props {
|
||||||
client: MatrixClient;
|
client: MatrixClient;
|
||||||
isPasswordlessUser: boolean;
|
isPasswordlessUser: boolean;
|
||||||
isEmbedded: boolean;
|
confineToRoom: boolean;
|
||||||
preload: boolean;
|
preload: boolean;
|
||||||
hideHeader: boolean;
|
hideHeader: boolean;
|
||||||
rtcSession: MatrixRTCSession;
|
rtcSession: MatrixRTCSession;
|
||||||
@@ -65,7 +65,7 @@ interface Props {
|
|||||||
export function GroupCallView({
|
export function GroupCallView({
|
||||||
client,
|
client,
|
||||||
isPasswordlessUser,
|
isPasswordlessUser,
|
||||||
isEmbedded,
|
confineToRoom,
|
||||||
preload,
|
preload,
|
||||||
hideHeader,
|
hideHeader,
|
||||||
rtcSession,
|
rtcSession,
|
||||||
@@ -233,13 +233,13 @@ export function GroupCallView({
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
!isPasswordlessUser &&
|
!isPasswordlessUser &&
|
||||||
!isEmbedded &&
|
!confineToRoom &&
|
||||||
!PosthogAnalytics.instance.isEnabled()
|
!PosthogAnalytics.instance.isEnabled()
|
||||||
) {
|
) {
|
||||||
history.push("/");
|
history.push("/");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[rtcSession, isPasswordlessUser, isEmbedded, history]
|
[rtcSession, isPasswordlessUser, confineToRoom, history]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -334,7 +334,7 @@ export function GroupCallView({
|
|||||||
// submitting anything.
|
// submitting anything.
|
||||||
if (
|
if (
|
||||||
isPasswordlessUser ||
|
isPasswordlessUser ||
|
||||||
(PosthogAnalytics.instance.isEnabled() && !isEmbedded) ||
|
(PosthogAnalytics.instance.isEnabled() && widget === null) ||
|
||||||
leaveError
|
leaveError
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
@@ -342,6 +342,7 @@ export function GroupCallView({
|
|||||||
endedCallId={rtcSession.room.roomId}
|
endedCallId={rtcSession.room.roomId}
|
||||||
client={client}
|
client={client}
|
||||||
isPasswordlessUser={isPasswordlessUser}
|
isPasswordlessUser={isPasswordlessUser}
|
||||||
|
confineToRoom={confineToRoom}
|
||||||
leaveError={leaveError}
|
leaveError={leaveError}
|
||||||
reconnect={onReconnect}
|
reconnect={onReconnect}
|
||||||
/>
|
/>
|
||||||
@@ -363,7 +364,7 @@ export function GroupCallView({
|
|||||||
matrixInfo={matrixInfo}
|
matrixInfo={matrixInfo}
|
||||||
muteStates={muteStates}
|
muteStates={muteStates}
|
||||||
onEnter={() => enterRTCSession(rtcSession)}
|
onEnter={() => enterRTCSession(rtcSession)}
|
||||||
isEmbedded={isEmbedded}
|
confineToRoom={confineToRoom}
|
||||||
hideHeader={hideHeader}
|
hideHeader={hideHeader}
|
||||||
participatingMembers={participatingMembers}
|
participatingMembers={participatingMembers}
|
||||||
onShareClick={onShareClick}
|
onShareClick={onShareClick}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ interface Props {
|
|||||||
matrixInfo: MatrixInfo;
|
matrixInfo: MatrixInfo;
|
||||||
muteStates: MuteStates;
|
muteStates: MuteStates;
|
||||||
onEnter: () => void;
|
onEnter: () => void;
|
||||||
isEmbedded: boolean;
|
confineToRoom: boolean;
|
||||||
hideHeader: boolean;
|
hideHeader: boolean;
|
||||||
participatingMembers: RoomMember[];
|
participatingMembers: RoomMember[];
|
||||||
onShareClick: (() => void) | null;
|
onShareClick: (() => void) | null;
|
||||||
@@ -53,7 +53,7 @@ export const LobbyView: FC<Props> = ({
|
|||||||
matrixInfo,
|
matrixInfo,
|
||||||
muteStates,
|
muteStates,
|
||||||
onEnter,
|
onEnter,
|
||||||
isEmbedded,
|
confineToRoom,
|
||||||
hideHeader,
|
hideHeader,
|
||||||
participatingMembers,
|
participatingMembers,
|
||||||
onShareClick,
|
onShareClick,
|
||||||
@@ -85,7 +85,7 @@ export const LobbyView: FC<Props> = ({
|
|||||||
const onLeaveClick = useCallback(() => history.push("/"), [history]);
|
const onLeaveClick = useCallback(() => history.push("/"), [history]);
|
||||||
|
|
||||||
const recentsButtonInFooter = useMediaQuery("(max-height: 500px)");
|
const recentsButtonInFooter = useMediaQuery("(max-height: 500px)");
|
||||||
const recentsButton = !isEmbedded && (
|
const recentsButton = !confineToRoom && (
|
||||||
<Link className={styles.recents} href="#" onClick={onLeaveClick}>
|
<Link className={styles.recents} href="#" onClick={onLeaveClick}>
|
||||||
{t("Back to recents")}
|
{t("Back to recents")}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -140,7 +140,7 @@ export const LobbyView: FC<Props> = ({
|
|||||||
disabled={muteStates.audio.setEnabled === null}
|
disabled={muteStates.audio.setEnabled === null}
|
||||||
/>
|
/>
|
||||||
<SettingsButton onPress={openSettings} />
|
<SettingsButton onPress={openSettings} />
|
||||||
{!isEmbedded && <HangupButton onPress={onLeaveClick} />}
|
{!confineToRoom && <HangupButton onPress={onLeaveClick} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ import { platform } from "../Platform";
|
|||||||
import { AppSelectionModal } from "./AppSelectionModal";
|
import { AppSelectionModal } from "./AppSelectionModal";
|
||||||
|
|
||||||
export const RoomPage: FC = () => {
|
export const RoomPage: FC = () => {
|
||||||
const { isEmbedded, preload, hideHeader, displayName } = useUrlParams();
|
const { confineToRoom, appPrompt, preload, hideHeader, displayName } =
|
||||||
|
useUrlParams();
|
||||||
|
|
||||||
const { roomAlias, roomId, viaServers } = useRoomIdentifier();
|
const { roomAlias, roomId, viaServers } = useRoomIdentifier();
|
||||||
|
|
||||||
@@ -74,12 +75,12 @@ export const RoomPage: FC = () => {
|
|||||||
client={client!}
|
client={client!}
|
||||||
rtcSession={rtcSession}
|
rtcSession={rtcSession}
|
||||||
isPasswordlessUser={passwordlessUser}
|
isPasswordlessUser={passwordlessUser}
|
||||||
isEmbedded={isEmbedded}
|
confineToRoom={confineToRoom}
|
||||||
preload={preload}
|
preload={preload}
|
||||||
hideHeader={hideHeader}
|
hideHeader={hideHeader}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
[client, passwordlessUser, isEmbedded, preload, hideHeader]
|
[client, passwordlessUser, confineToRoom, preload, hideHeader]
|
||||||
);
|
);
|
||||||
|
|
||||||
let content: ReactNode;
|
let content: ReactNode;
|
||||||
@@ -107,9 +108,8 @@ export const RoomPage: FC = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{content}
|
{content}
|
||||||
{/* On mobile, show a prompt to launch the mobile app. If in embedded mode,
|
{/* On Android and iOS, show a prompt to launch the mobile app. */}
|
||||||
that means we *are* in the mobile app and should show no further prompt. */}
|
{appPrompt && (platform === "android" || platform === "ios") && (
|
||||||
{(platform === "android" || platform === "ios") && !isEmbedded && (
|
|
||||||
<AppSelectionModal roomId={roomId} />
|
<AppSelectionModal roomId={roomId} />
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -43,12 +43,12 @@ import { Body, Caption } from "../typography/Typography";
|
|||||||
import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
|
import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
|
||||||
import { ProfileSettingsTab } from "./ProfileSettingsTab";
|
import { ProfileSettingsTab } from "./ProfileSettingsTab";
|
||||||
import { FeedbackSettingsTab } from "./FeedbackSettingsTab";
|
import { FeedbackSettingsTab } from "./FeedbackSettingsTab";
|
||||||
import { useUrlParams } from "../UrlParams";
|
|
||||||
import {
|
import {
|
||||||
useMediaDevices,
|
useMediaDevices,
|
||||||
MediaDevice,
|
MediaDevice,
|
||||||
useMediaDeviceNames,
|
useMediaDeviceNames,
|
||||||
} from "../livekit/MediaDevicesContext";
|
} from "../livekit/MediaDevicesContext";
|
||||||
|
import { widget } from "../widget";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -61,8 +61,6 @@ interface Props {
|
|||||||
export const SettingsModal = (props: Props) => {
|
export const SettingsModal = (props: Props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { isEmbedded } = useUrlParams();
|
|
||||||
|
|
||||||
const [showInspector, setShowInspector] = useShowInspector();
|
const [showInspector, setShowInspector] = useShowInspector();
|
||||||
const [optInAnalytics, setOptInAnalytics] = useOptInAnalytics();
|
const [optInAnalytics, setOptInAnalytics] = useOptInAnalytics();
|
||||||
const [developerSettingsTab, setDeveloperSettingsTab] =
|
const [developerSettingsTab, setDeveloperSettingsTab] =
|
||||||
@@ -282,7 +280,7 @@ export const SettingsModal = (props: Props) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const tabs = [audioTab, videoTab];
|
const tabs = [audioTab, videoTab];
|
||||||
if (!isEmbedded) tabs.push(profileTab);
|
if (widget === null) tabs.push(profileTab);
|
||||||
tabs.push(feedbackTab, moreTab);
|
tabs.push(feedbackTab, moreTab);
|
||||||
if (developerSettingsTab) tabs.push(developerTab);
|
if (developerSettingsTab) tabs.push(developerTab);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user