mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-10 21:55:19 +00:00
Stop asking whether Element Call is a widget
The remaining reads of the widget global were all asking one of two different questions, so they get two different answers. The app shell — the auth hooks, automatic guest registration, the group call loader's diagnostic and the initial mute state — wants to know whether Element Call was launched as a widget. That is a property of the URL it was launched with, so expose the isWidget that computeUrlParams already computed internally, documented as being for shell use only. The call interface — whether to offer the profile settings tab — wants to know something about its host, so it asks the bridge. A host that can dismiss Element Call owns the user's account, so their profile is not ours to edit; this reuses the close capability as a proxy, with a TODO alongside the others. ClientContext also takes supportsReactions from the bridge rather than checking four widget capabilities itself, which removes widgetApi from InitResult — a field that was always null outside widget mode. Note this changes behaviour for a malformed widget URL: one carrying a widget ID and parent URL but missing the room, user, device or base URL would previously have fallen back to registering a guest user, and will now not.
This commit is contained in:
+6
-28
@@ -21,9 +21,9 @@ import { logger } from "matrix-js-sdk/lib/logger";
|
|||||||
import { type ISyncStateData, type SyncState } from "matrix-js-sdk/lib/sync";
|
import { type ISyncStateData, type SyncState } from "matrix-js-sdk/lib/sync";
|
||||||
import { ClientEvent, type MatrixClient } from "matrix-js-sdk";
|
import { ClientEvent, type MatrixClient } from "matrix-js-sdk";
|
||||||
|
|
||||||
import type { WidgetApi } from "matrix-widget-api";
|
|
||||||
import { ErrorPage } from "./FullScreenView";
|
import { ErrorPage } from "./FullScreenView";
|
||||||
import { widget } from "./widget";
|
import { widget } from "./widget";
|
||||||
|
import { useHostBridge } from "./HostBridge";
|
||||||
import {
|
import {
|
||||||
PosthogAnalytics,
|
PosthogAnalytics,
|
||||||
RegistrationType,
|
RegistrationType,
|
||||||
@@ -138,6 +138,7 @@ interface Props {
|
|||||||
|
|
||||||
export const ClientProvider: FC<Props> = ({ children }) => {
|
export const ClientProvider: FC<Props> = ({ children }) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const hostBridge = useHostBridge();
|
||||||
|
|
||||||
// null = signed out, undefined = loading
|
// null = signed out, undefined = loading
|
||||||
const [initClientState, setInitClientState] = useState<
|
const [initClientState, setInitClientState] = useState<
|
||||||
@@ -201,7 +202,6 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
|||||||
|
|
||||||
saveSession(session);
|
saveSession(session);
|
||||||
setInitClientState({
|
setInitClientState({
|
||||||
widgetApi: null,
|
|
||||||
client,
|
client,
|
||||||
passwordlessUser: session.passwordlessUser,
|
passwordlessUser: session.passwordlessUser,
|
||||||
});
|
});
|
||||||
@@ -307,36 +307,16 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
|||||||
initClientState.client.on(ClientEvent.Sync, onSync);
|
initClientState.client.on(ClientEvent.Sync, onSync);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (initClientState.widgetApi) {
|
if (!hostBridge.supportsReactions)
|
||||||
const reactSend = initClientState.widgetApi.hasCapability(
|
logger.warn("The host does not permit reactions");
|
||||||
"org.matrix.msc2762.send.event:m.reaction",
|
setSupportsReactions(hostBridge.supportsReactions);
|
||||||
);
|
|
||||||
const redactSend = initClientState.widgetApi.hasCapability(
|
|
||||||
"org.matrix.msc2762.send.event:m.room.redaction",
|
|
||||||
);
|
|
||||||
const reactRcv = initClientState.widgetApi.hasCapability(
|
|
||||||
"org.matrix.msc2762.receive.event:m.reaction",
|
|
||||||
);
|
|
||||||
const redactRcv = initClientState.widgetApi.hasCapability(
|
|
||||||
"org.matrix.msc2762.receive.event:m.room.redaction",
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!reactSend || !reactRcv || !redactSend || !redactRcv) {
|
|
||||||
logger.warn("Widget does not support reactions");
|
|
||||||
setSupportsReactions(false);
|
|
||||||
} else {
|
|
||||||
setSupportsReactions(true);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setSupportsReactions(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (): void => {
|
return (): void => {
|
||||||
if (initClientState.client) {
|
if (initClientState.client) {
|
||||||
initClientState.client.removeListener(ClientEvent.Sync, onSync);
|
initClientState.client.removeListener(ClientEvent.Sync, onSync);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [initClientState, onSync]);
|
}, [initClientState, onSync, hostBridge]);
|
||||||
|
|
||||||
if (alreadyOpenedErr) {
|
if (alreadyOpenedErr) {
|
||||||
return <ErrorPage error={alreadyOpenedErr} />;
|
return <ErrorPage error={alreadyOpenedErr} />;
|
||||||
@@ -346,7 +326,6 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type InitResult = {
|
export type InitResult = {
|
||||||
widgetApi: WidgetApi | null;
|
|
||||||
client: MatrixClient;
|
client: MatrixClient;
|
||||||
passwordlessUser: boolean;
|
passwordlessUser: boolean;
|
||||||
};
|
};
|
||||||
@@ -357,7 +336,6 @@ async function loadClient(): Promise<InitResult | null> {
|
|||||||
logger.log("Using a matryoshka client");
|
logger.log("Using a matryoshka client");
|
||||||
const client = await widget.client;
|
const client = await widget.client;
|
||||||
return {
|
return {
|
||||||
widgetApi: widget.api,
|
|
||||||
client,
|
client,
|
||||||
passwordlessUser: false,
|
passwordlessUser: false,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -59,6 +59,17 @@ export interface UrlProperties {
|
|||||||
// Widget api related params
|
// Widget api related params
|
||||||
widgetId: string | null;
|
widgetId: string | null;
|
||||||
parentUrl: string | null;
|
parentUrl: string | null;
|
||||||
|
/**
|
||||||
|
* Whether Element Call was started as a widget of a Matrix client, which is
|
||||||
|
* to say whether it was given a widget ID and a parent to talk to.
|
||||||
|
*
|
||||||
|
* Only meaningful to the standalone and widget builds, which own the URL —
|
||||||
|
* so use it for decisions that belong to the app shell, such as whether
|
||||||
|
* Element Call is responsible for authenticating the user. Anything the call
|
||||||
|
* interface itself needs to know about its host should come from the host
|
||||||
|
* bridge instead.
|
||||||
|
*/
|
||||||
|
isWidget: boolean;
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
* parses the path and resolves alias with respect to the default server name, however
|
* parses the path and resolves alias with respect to the default server name, however
|
||||||
@@ -448,6 +459,7 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
|
|||||||
const properties: UrlProperties = {
|
const properties: UrlProperties = {
|
||||||
widgetId,
|
widgetId,
|
||||||
parentUrl,
|
parentUrl,
|
||||||
|
isWidget,
|
||||||
// NB. we don't validate roomId here as we do in getRoomIdentifierFromUrl:
|
// NB. we don't validate roomId here as we do in getRoomIdentifierFromUrl:
|
||||||
// what would we do if it were invalid? If the widget API says that's what
|
// what would we do if it were invalid? If the widget API says that's what
|
||||||
// the room ID is, then that's what it is.
|
// the room ID is, then that's what it is.
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { logger } from "matrix-js-sdk/lib/logger";
|
|||||||
import { initClient } from "../utils/matrix";
|
import { initClient } from "../utils/matrix";
|
||||||
import { type Session } from "../ClientContext";
|
import { type Session } from "../ClientContext";
|
||||||
import { Config } from "../config/Config";
|
import { Config } from "../config/Config";
|
||||||
import { widget } from "../widget";
|
import { useUrlParams } from "../UrlParams";
|
||||||
|
|
||||||
export const useInteractiveRegistration = (
|
export const useInteractiveRegistration = (
|
||||||
oldClient?: MatrixClient,
|
oldClient?: MatrixClient,
|
||||||
@@ -32,6 +32,7 @@ export const useInteractiveRegistration = (
|
|||||||
passwordlessUser: boolean,
|
passwordlessUser: boolean,
|
||||||
) => Promise<[MatrixClient, Session]>;
|
) => Promise<[MatrixClient, Session]>;
|
||||||
} => {
|
} => {
|
||||||
|
const { isWidget } = useUrlParams();
|
||||||
const [privacyPolicyUrl, setPrivacyPolicyUrl] = useState<string | undefined>(
|
const [privacyPolicyUrl, setPrivacyPolicyUrl] = useState<string | undefined>(
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
@@ -47,7 +48,7 @@ export const useInteractiveRegistration = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (widget) return;
|
if (isWidget) return;
|
||||||
// An empty registerRequest is used to get the privacy policy and recaptcha key.
|
// An empty registerRequest is used to get the privacy policy and recaptcha key.
|
||||||
authClient.current!.registerRequest({}).catch((error) => {
|
authClient.current!.registerRequest({}).catch((error) => {
|
||||||
setPrivacyPolicyUrl(
|
setPrivacyPolicyUrl(
|
||||||
@@ -55,7 +56,7 @@ export const useInteractiveRegistration = (
|
|||||||
);
|
);
|
||||||
setRecaptchaKey(error.data?.params["m.login.recaptcha"]?.public_key);
|
setRecaptchaKey(error.data?.params["m.login.recaptcha"]?.public_key);
|
||||||
});
|
});
|
||||||
}, []);
|
}, [isWidget]);
|
||||||
|
|
||||||
const register = useCallback(
|
const register = useCallback(
|
||||||
async (
|
async (
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { useClient } from "../ClientContext";
|
|||||||
import { useInteractiveRegistration } from "../auth/useInteractiveRegistration";
|
import { useInteractiveRegistration } from "../auth/useInteractiveRegistration";
|
||||||
import { generateRandomName } from "../auth/generateRandomName";
|
import { generateRandomName } from "../auth/generateRandomName";
|
||||||
import { useRecaptcha } from "../auth/useRecaptcha";
|
import { useRecaptcha } from "../auth/useRecaptcha";
|
||||||
import { widget } from "../widget";
|
import { useUrlParams } from "../UrlParams";
|
||||||
|
|
||||||
interface UseRegisterPasswordlessUserType {
|
interface UseRegisterPasswordlessUserType {
|
||||||
privacyPolicyUrl?: string;
|
privacyPolicyUrl?: string;
|
||||||
@@ -22,6 +22,7 @@ interface UseRegisterPasswordlessUserType {
|
|||||||
|
|
||||||
export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType {
|
export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType {
|
||||||
const { setClient } = useClient();
|
const { setClient } = useClient();
|
||||||
|
const { isWidget } = useUrlParams();
|
||||||
const { privacyPolicyUrl, recaptchaKey, register } =
|
const { privacyPolicyUrl, recaptchaKey, register } =
|
||||||
useInteractiveRegistration();
|
useInteractiveRegistration();
|
||||||
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
|
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
|
||||||
@@ -31,7 +32,7 @@ export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType {
|
|||||||
if (!setClient) {
|
if (!setClient) {
|
||||||
throw new Error("No client context");
|
throw new Error("No client context");
|
||||||
}
|
}
|
||||||
if (widget) {
|
if (isWidget) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Registration was skipped: We should never try to register password-less user in embedded mode.",
|
"Registration was skipped: We should never try to register password-less user in embedded mode.",
|
||||||
);
|
);
|
||||||
@@ -53,7 +54,7 @@ export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType {
|
|||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[execute, reset, register, setClient],
|
[execute, reset, register, setClient, isWidget],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { privacyPolicyUrl, registerPasswordlessUser, recaptchaId };
|
return { privacyPolicyUrl, registerPasswordlessUser, recaptchaId };
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
|
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
|
||||||
import { HeaderStyle, useUrlParams } from "../UrlParams";
|
import { HeaderStyle, useUrlParams } from "../UrlParams";
|
||||||
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
|
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
|
||||||
import { widget } from "../widget";
|
|
||||||
import { useHostBridge } from "../HostBridge.ts";
|
import { useHostBridge } from "../HostBridge.ts";
|
||||||
import styles from "./InCallView.module.css";
|
import styles from "./InCallView.module.css";
|
||||||
import { GridTile } from "../tile/GridTile";
|
import { GridTile } from "../tile/GridTile";
|
||||||
@@ -251,6 +250,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const logger = rootLogger.getChild("[InCallView]");
|
const logger = rootLogger.getChild("[InCallView]");
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const hostBridge = useHostBridge();
|
||||||
const { sendReaction, toggleRaisedHand } = useReactionsSender();
|
const { sendReaction, toggleRaisedHand } = useReactionsSender();
|
||||||
|
|
||||||
useWakeLock();
|
useWakeLock();
|
||||||
@@ -334,14 +334,17 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
|
|
||||||
const openProfile = useMemo(
|
const openProfile = useMemo(
|
||||||
() =>
|
() =>
|
||||||
// Profile settings are unavailable in widget mode
|
// A host that can dismiss us is a host that owns the user's account, so
|
||||||
widget === null
|
// their profile is not ours to edit.
|
||||||
|
// TODO: another use of the close capability as a proxy — see the note in
|
||||||
|
// GroupCallView.
|
||||||
|
hostBridge.close === undefined
|
||||||
? (): void => {
|
? (): void => {
|
||||||
setSettingsTab("profile");
|
setSettingsTab("profile");
|
||||||
setSettingsOpen(true);
|
setSettingsOpen(true);
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
[setSettingsTab, setSettingsOpen],
|
[setSettingsTab, setSettingsOpen, hostBridge],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [headerRef, headerBounds] = useMeasure();
|
const [headerRef, headerBounds] = useMeasure();
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ import { GroupCallView } from "./GroupCallView";
|
|||||||
import { useRoomIdentifier, useUrlParams } from "../UrlParams";
|
import { useRoomIdentifier, useUrlParams } from "../UrlParams";
|
||||||
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
|
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
|
||||||
import { HomePage } from "../home/HomePage";
|
import { HomePage } from "../home/HomePage";
|
||||||
import { widget } from "../widget";
|
|
||||||
import { useHostBridge } from "../HostBridge.ts";
|
import { useHostBridge } from "../HostBridge.ts";
|
||||||
import { CallTerminatedMessage, useLoadGroupCall } from "./useLoadGroupCall";
|
import { CallTerminatedMessage, useLoadGroupCall } from "./useLoadGroupCall";
|
||||||
import { LobbyView } from "./LobbyView";
|
import { LobbyView } from "./LobbyView";
|
||||||
@@ -77,7 +76,7 @@ export const RoomPage: FC = (): ReactNode => {
|
|||||||
calculateInitialMuteState(
|
calculateInitialMuteState(
|
||||||
urlParams.skipLobby,
|
urlParams.skipLobby,
|
||||||
urlParams.callIntent,
|
urlParams.callIntent,
|
||||||
widget !== null,
|
urlParams.isWidget,
|
||||||
),
|
),
|
||||||
hostBridge,
|
hostBridge,
|
||||||
),
|
),
|
||||||
@@ -88,7 +87,7 @@ export const RoomPage: FC = (): ReactNode => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// If we've finished loading, are not already authed and we've been given a display name as
|
// If we've finished loading, are not already authed and we've been given a display name as
|
||||||
// a URL param, automatically register a passwordless user
|
// a URL param, automatically register a passwordless user
|
||||||
if (!loading && !authenticated && displayName && !widget) {
|
if (!loading && !authenticated && displayName && !urlParams.isWidget) {
|
||||||
setIsRegistering(true);
|
setIsRegistering(true);
|
||||||
registerPasswordlessUser(displayName)
|
registerPasswordlessUser(displayName)
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
@@ -102,6 +101,7 @@ export const RoomPage: FC = (): ReactNode => {
|
|||||||
loading,
|
loading,
|
||||||
authenticated,
|
authenticated,
|
||||||
displayName,
|
displayName,
|
||||||
|
urlParams.isWidget,
|
||||||
setIsRegistering,
|
setIsRegistering,
|
||||||
registerPasswordlessUser,
|
registerPasswordlessUser,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import {
|
|||||||
EndCallIcon,
|
EndCallIcon,
|
||||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
|
|
||||||
import { widget } from "../widget";
|
import { useUrlParams } from "../UrlParams";
|
||||||
|
|
||||||
export type GroupCallLoaded = {
|
export type GroupCallLoaded = {
|
||||||
kind: "loaded";
|
kind: "loaded";
|
||||||
@@ -132,6 +132,7 @@ export const useLoadGroupCall = (
|
|||||||
const [state, setState] = useState<GroupCallStatus>({ kind: "loading" });
|
const [state, setState] = useState<GroupCallStatus>({ kind: "loading" });
|
||||||
const activeRoom = useRef<Room | undefined>(undefined);
|
const activeRoom = useRef<Room | undefined>(undefined);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { isWidget } = useUrlParams();
|
||||||
|
|
||||||
const bannedError = useCallback(
|
const bannedError = useCallback(
|
||||||
(): CallTerminatedMessage =>
|
(): CallTerminatedMessage =>
|
||||||
@@ -249,7 +250,7 @@ export const useLoadGroupCall = (
|
|||||||
// room already joined so we are done here already.
|
// room already joined so we are done here already.
|
||||||
return room!;
|
return room!;
|
||||||
}
|
}
|
||||||
if (widget)
|
if (isWidget)
|
||||||
// in widget mode we never should reach this point. (getRoom should return the room.)
|
// in widget mode we never should reach this point. (getRoom should return the room.)
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Room not found. The widget-api did not pass over the relevant room events/information.",
|
"Room not found. The widget-api did not pass over the relevant room events/information.",
|
||||||
@@ -373,6 +374,7 @@ export const useLoadGroupCall = (
|
|||||||
}, [
|
}, [
|
||||||
bannedError,
|
bannedError,
|
||||||
client,
|
client,
|
||||||
|
isWidget,
|
||||||
knockRejectError,
|
knockRejectError,
|
||||||
removeNoticeError,
|
removeNoticeError,
|
||||||
roomIdOrAlias,
|
roomIdOrAlias,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { ProfileSettingsTab } from "./ProfileSettingsTab";
|
|||||||
import { FeedbackSettingsTab } from "./FeedbackSettingsTab";
|
import { FeedbackSettingsTab } from "./FeedbackSettingsTab";
|
||||||
import { iosDeviceMenu$ } from "../state/MediaDevices";
|
import { iosDeviceMenu$ } from "../state/MediaDevices";
|
||||||
import { useMediaDevices } from "../MediaDevicesContext";
|
import { useMediaDevices } from "../MediaDevicesContext";
|
||||||
import { widget } from "../widget";
|
import { useHostBridge } from "../HostBridge";
|
||||||
import {
|
import {
|
||||||
useSetting,
|
useSetting,
|
||||||
soundEffectVolume as soundEffectVolumeSetting,
|
soundEffectVolume as soundEffectVolumeSetting,
|
||||||
@@ -123,6 +123,7 @@ export const SettingsModal: FC<Props> = ({
|
|||||||
// On EC, we decided that it is less confusing for the user if they see those options in the output section
|
// On EC, we decided that it is less confusing for the user if they see those options in the output section
|
||||||
// rather than the input section.
|
// rather than the input section.
|
||||||
const { controlledAudioDevices } = useUrlParams();
|
const { controlledAudioDevices } = useUrlParams();
|
||||||
|
const hostBridge = useHostBridge();
|
||||||
// If we are on iOS we will show a button to open the native audio device picker.
|
// If we are on iOS we will show a button to open the native audio device picker.
|
||||||
const iosDeviceMenu = useBehavior(iosDeviceMenu$);
|
const iosDeviceMenu = useBehavior(iosDeviceMenu$);
|
||||||
|
|
||||||
@@ -234,7 +235,9 @@ export const SettingsModal: FC<Props> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const tabs = [audioTab, videoTab];
|
const tabs = [audioTab, videoTab];
|
||||||
if (widget === null) tabs.push(profileTab);
|
// A host that can dismiss us is a host that owns the user's account, so their
|
||||||
|
// profile is not ours to edit.
|
||||||
|
if (hostBridge.close === undefined) tabs.push(profileTab);
|
||||||
tabs.push(preferencesTab);
|
tabs.push(preferencesTab);
|
||||||
if (isRageshakeAvailable || import.meta.env.VITE_PACKAGE === "full") {
|
if (isRageshakeAvailable || import.meta.env.VITE_PACKAGE === "full") {
|
||||||
// for full package we want to show the analytics consent checkbox
|
// for full package we want to show the analytics consent checkbox
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ export async function initSPA(
|
|||||||
try {
|
try {
|
||||||
const client = await initClient(initClientParams, true);
|
const client = await initClient(initClientParams, true);
|
||||||
return {
|
return {
|
||||||
widgetApi: null,
|
|
||||||
client,
|
client,
|
||||||
passwordlessUser,
|
passwordlessUser,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user