[web] Album cast dialog - Non functional tweaks (#3384)
This commit is contained in:
@@ -6,7 +6,7 @@ import { useRouter } from "next/router";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { readCastData, storeCastData } from "services/cast-data";
|
||||
import { getCastData, register } from "services/pair";
|
||||
import { advertiseOnChromecast } from "../services/chromecast";
|
||||
import { advertiseOnChromecast } from "../services/chromecast-receiver";
|
||||
|
||||
export default function Index() {
|
||||
const [publicKeyB64, setPublicKeyB64] = useState<string | undefined>();
|
||||
|
||||
@@ -5,7 +5,7 @@ import { FilledCircleCheck } from "components/FilledCircleCheck";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { readCastData } from "services/cast-data";
|
||||
import { isChromecast } from "services/chromecast";
|
||||
import { isChromecast } from "services/chromecast-receiver";
|
||||
import { imageURLGenerator } from "services/render";
|
||||
|
||||
export default function Slideshow() {
|
||||
|
||||
@@ -28,7 +28,7 @@ import HTTPService from "@ente/shared/network/HTTPService";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import type { CastData } from "services/cast-data";
|
||||
import { detectMediaMIMEType } from "services/detect-type";
|
||||
import { isChromecast } from "./chromecast";
|
||||
import { isChromecast } from "./chromecast-receiver";
|
||||
|
||||
/**
|
||||
* An async generator function that loops through all the files in the
|
||||
|
||||
@@ -1,52 +1,47 @@
|
||||
import { boxSeal } from "@/base/crypto/libsodium";
|
||||
import log from "@/base/log";
|
||||
import type { Collection } from "@/media/collection";
|
||||
import { VerticallyCentered } from "@ente/shared/components/Container";
|
||||
import { loadCast } from "@/new/photos/utils/chromecast-sender";
|
||||
import DialogBoxV2 from "@ente/shared/components/DialogBoxV2";
|
||||
import EnteButton from "@ente/shared/components/EnteButton";
|
||||
import EnteSpinner from "@ente/shared/components/EnteSpinner";
|
||||
import SingleInputForm, {
|
||||
type SingleInputFormProps,
|
||||
} from "@ente/shared/components/SingleInputForm";
|
||||
import castGateway from "@ente/shared/network/cast";
|
||||
import { Link, Typography } from "@mui/material";
|
||||
import { Button, Link, Stack, Typography } from "@mui/material";
|
||||
import { t } from "i18next";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Trans } from "react-i18next";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { loadSender } from "../../utils/useCastSender";
|
||||
|
||||
interface Props {
|
||||
show: boolean;
|
||||
onHide: () => void;
|
||||
currentCollection: Collection;
|
||||
interface AlbumCastDialogProps {
|
||||
/** If `true`, the dialog is shown. */
|
||||
open: boolean;
|
||||
/** Callback fired when the dialog wants to be closed. */
|
||||
onClose: () => void;
|
||||
/** The collection that we want to cast. */
|
||||
collection: Collection;
|
||||
}
|
||||
|
||||
enum AlbumCastError {
|
||||
TV_NOT_FOUND = "tv_not_found",
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
chrome: any;
|
||||
}
|
||||
}
|
||||
|
||||
export default function AlbumCastDialog({
|
||||
show,
|
||||
onHide,
|
||||
currentCollection,
|
||||
}: Props) {
|
||||
/**
|
||||
* A dialog that shows various options that the user has for casting an album.
|
||||
*/
|
||||
export const AlbumCastDialog: React.FC<AlbumCastDialogProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
collection,
|
||||
}) => {
|
||||
const [view, setView] = useState<
|
||||
"choose" | "auto" | "pin" | "auto-cast-error"
|
||||
>("choose");
|
||||
|
||||
const [browserCanCast, setBrowserCanCast] = useState(false);
|
||||
// Make API call on component mount
|
||||
|
||||
// Make API call to clear all previous sessions on component mount.
|
||||
useEffect(() => {
|
||||
castGateway.revokeAllTokens();
|
||||
|
||||
setBrowserCanCast(!!window.chrome);
|
||||
setBrowserCanCast(typeof window["chrome"] !== "undefined");
|
||||
}, []);
|
||||
|
||||
const onSubmit: SingleInputFormProps["callback"] = async (
|
||||
@@ -55,55 +50,47 @@ export default function AlbumCastDialog({
|
||||
) => {
|
||||
try {
|
||||
await doCast(value.trim());
|
||||
onHide();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
let fieldError: string;
|
||||
switch (error.message) {
|
||||
case AlbumCastError.TV_NOT_FOUND:
|
||||
fieldError = t("tv_not_found");
|
||||
break;
|
||||
default:
|
||||
fieldError = t("UNKNOWN_ERROR");
|
||||
break;
|
||||
if (e instanceof Error && e.message == "tv-not-found") {
|
||||
setFieldError(t("tv_not_found"));
|
||||
} else {
|
||||
setFieldError(t("UNKNOWN_ERROR"));
|
||||
}
|
||||
|
||||
setFieldError(fieldError);
|
||||
}
|
||||
};
|
||||
|
||||
const doCast = async (pin: string) => {
|
||||
// does the TV exist? have they advertised their existence?
|
||||
// Does the TV exist? have they advertised their existence?
|
||||
const tvPublicKeyB64 = await castGateway.getPublicKey(pin);
|
||||
if (!tvPublicKeyB64) {
|
||||
throw new Error(AlbumCastError.TV_NOT_FOUND);
|
||||
throw new Error("tv-not-found");
|
||||
}
|
||||
// generate random uuid string
|
||||
|
||||
// Generate random id.
|
||||
const castToken = uuidv4();
|
||||
|
||||
// ok, they exist. let's give them the good stuff.
|
||||
// Ok, they exist. let's give them the good stuff.
|
||||
const payload = JSON.stringify({
|
||||
castToken: castToken,
|
||||
collectionID: currentCollection.id,
|
||||
collectionKey: currentCollection.key,
|
||||
collectionID: collection.id,
|
||||
collectionKey: collection.key,
|
||||
});
|
||||
const encryptedPayload = await boxSeal(btoa(payload), tvPublicKeyB64);
|
||||
|
||||
// hey TV, we acknowlege you!
|
||||
// Hey TV, we acknowlege you!
|
||||
await castGateway.publishCastPayload(
|
||||
pin,
|
||||
encryptedPayload,
|
||||
currentCollection.id,
|
||||
collection.id,
|
||||
castToken,
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (view === "auto") {
|
||||
loadSender().then(async (sender) => {
|
||||
const { cast } = sender;
|
||||
|
||||
const instance = await cast.framework.CastContext.getInstance();
|
||||
loadCast().then(async (cast) => {
|
||||
const instance = cast.framework.CastContext.getInstance();
|
||||
try {
|
||||
await instance.requestSession();
|
||||
} catch (e) {
|
||||
@@ -123,105 +110,80 @@ export default function AlbumCastDialog({
|
||||
doCast(code)
|
||||
.then(() => {
|
||||
setView("choose");
|
||||
onHide();
|
||||
onClose();
|
||||
})
|
||||
.catch((e) => {
|
||||
setView("auto-cast-error");
|
||||
log.error("Error casting to TV", e);
|
||||
setView("auto-cast-error");
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const collectionID = currentCollection.id;
|
||||
const collectionID = collection.id;
|
||||
session
|
||||
.sendMessage("urn:x-cast:pair-request", { collectionID })
|
||||
.then(() => {
|
||||
log.debug(() => "Message sent successfully");
|
||||
})
|
||||
.catch((e) => {
|
||||
log.error("Error sending message", e);
|
||||
log.debug(() => "urn:x-cast:pair-request sent");
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [view]);
|
||||
|
||||
useEffect(() => {
|
||||
if (show) {
|
||||
castGateway.revokeAllTokens();
|
||||
}
|
||||
}, [show]);
|
||||
if (open) castGateway.revokeAllTokens();
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<DialogBoxV2
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
attributes={{ title: t("cast_album_to_tv") }}
|
||||
sx={{ zIndex: 1600 }}
|
||||
open={show}
|
||||
onClose={onHide}
|
||||
attributes={{
|
||||
title: t("cast_album_to_tv"),
|
||||
}}
|
||||
>
|
||||
{view === "choose" && (
|
||||
<>
|
||||
{view == "choose" && (
|
||||
<Stack sx={{ py: 1, gap: 4 }}>
|
||||
{browserCanCast && (
|
||||
<>
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<Typography color={"text.muted"}>
|
||||
{t("cast_auto_pair_description")}
|
||||
</Typography>
|
||||
|
||||
<EnteButton
|
||||
style={{
|
||||
marginBottom: "1rem",
|
||||
}}
|
||||
onClick={() => {
|
||||
setView("auto");
|
||||
}}
|
||||
>
|
||||
<Button onClick={() => setView("auto")}>
|
||||
{t("cast_auto_pair")}
|
||||
</EnteButton>
|
||||
</>
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
<Typography color="text.muted">
|
||||
{t("pair_with_pin_description")}
|
||||
</Typography>
|
||||
|
||||
<EnteButton
|
||||
onClick={() => {
|
||||
setView("pin");
|
||||
}}
|
||||
>
|
||||
{t("pair_with_pin")}
|
||||
</EnteButton>
|
||||
</>
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<Typography color="text.muted">
|
||||
{t("pair_with_pin_description")}
|
||||
</Typography>
|
||||
<Button onClick={() => setView("pin")}>
|
||||
{t("pair_with_pin")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
{view === "auto" && (
|
||||
<VerticallyCentered gap="1rem">
|
||||
<EnteSpinner />
|
||||
{view == "auto" && (
|
||||
<Stack sx={{ pt: 1, gap: 3, textAlign: "center" }}>
|
||||
<div>
|
||||
<EnteSpinner />
|
||||
</div>
|
||||
<Typography>{t("choose_device_from_browser")}</Typography>
|
||||
<EnteButton
|
||||
variant="text"
|
||||
onClick={() => {
|
||||
setView("choose");
|
||||
}}
|
||||
>
|
||||
<Button color="secondary" onClick={() => setView("choose")}>
|
||||
{t("GO_BACK")}
|
||||
</EnteButton>
|
||||
</VerticallyCentered>
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
{view === "auto-cast-error" && (
|
||||
<VerticallyCentered gap="1rem">
|
||||
{view == "auto-cast-error" && (
|
||||
<Stack sx={{ pt: 1, gap: 3, textAlign: "center" }}>
|
||||
<Typography>{t("cast_auto_pair_failed")}</Typography>
|
||||
<EnteButton
|
||||
variant="text"
|
||||
onClick={() => {
|
||||
setView("choose");
|
||||
}}
|
||||
>
|
||||
<Button color="secondary" onClick={() => setView("choose")}>
|
||||
{t("GO_BACK")}
|
||||
</EnteButton>
|
||||
</VerticallyCentered>
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
{view === "pin" && (
|
||||
{view == "pin" && (
|
||||
<>
|
||||
<Typography>
|
||||
<Trans
|
||||
@@ -246,16 +208,11 @@ export default function AlbumCastDialog({
|
||||
buttonText={t("pair_device_to_tv")}
|
||||
submitButtonProps={{ sx: { mt: 1, mb: 2 } }}
|
||||
/>
|
||||
<EnteButton
|
||||
variant="text"
|
||||
onClick={() => {
|
||||
setView("choose");
|
||||
}}
|
||||
>
|
||||
<Button variant="text" onClick={() => setView("choose")}>
|
||||
{t("GO_BACK")}
|
||||
</EnteButton>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogBoxV2>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
isFilesDownloadCancelled,
|
||||
isFilesDownloadCompleted,
|
||||
} from "../FilesDownloadProgress";
|
||||
import AlbumCastDialog from "./AlbumCastDialog";
|
||||
import { AlbumCastDialog } from "./AlbumCastDialog";
|
||||
|
||||
/**
|
||||
* Specifies what the bar is displaying currently.
|
||||
@@ -68,11 +68,11 @@ export const Collections: React.FC<CollectionsProps> = ({
|
||||
filesDownloadProgressAttributesList,
|
||||
setFilesDownloadProgressAttributesCreator,
|
||||
}) => {
|
||||
const [allCollectionView, setAllCollectionView] = useState(false);
|
||||
const [collectionShareModalView, setCollectionShareModalView] =
|
||||
const [openAllCollectionDialog, setOpenAllCollectionDialog] =
|
||||
useState(false);
|
||||
|
||||
const [showAlbumCastDialog, setShowAlbumCastDialog] = useState(false);
|
||||
const [openCollectionShareView, setOpenCollectionShareView] =
|
||||
useState(false);
|
||||
const [openAlbumCastDialog, setOpenAlbumCastDialog] = useState(false);
|
||||
|
||||
const [collectionListSortBy, setCollectionListSortBy] =
|
||||
useLocalState<COLLECTION_LIST_SORT_BY>(
|
||||
@@ -128,7 +128,7 @@ export const Collections: React.FC<CollectionsProps> = ({
|
||||
activeCollection={activeCollection}
|
||||
setCollectionNamerAttributes={setCollectionNamerAttributes}
|
||||
showCollectionShareModal={() =>
|
||||
setCollectionShareModalView(true)
|
||||
setOpenCollectionShareView(true)
|
||||
}
|
||||
setFilesDownloadProgressAttributesCreator={
|
||||
setFilesDownloadProgressAttributesCreator
|
||||
@@ -137,7 +137,7 @@ export const Collections: React.FC<CollectionsProps> = ({
|
||||
isActiveCollectionDownloadInProgress
|
||||
}
|
||||
setActiveCollectionID={setActiveCollectionID}
|
||||
setShowAlbumCastDialog={setShowAlbumCastDialog}
|
||||
setShowAlbumCastDialog={setOpenAlbumCastDialog}
|
||||
/>
|
||||
),
|
||||
itemType: ITEM_TYPE.HEADER,
|
||||
@@ -157,11 +157,6 @@ export const Collections: React.FC<CollectionsProps> = ({
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const closeAllCollections = () => setAllCollectionView(false);
|
||||
const openAllCollections = () => setAllCollectionView(true);
|
||||
const closeCollectionShare = () => setCollectionShareModalView(false);
|
||||
const closeAlbumCastDialog = () => setShowAlbumCastDialog(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CollectionListBar
|
||||
@@ -176,15 +171,15 @@ export const Collections: React.FC<CollectionsProps> = ({
|
||||
collectionListSortBy,
|
||||
setCollectionListSortBy,
|
||||
}}
|
||||
onShowAllCollections={openAllCollections}
|
||||
onShowAllCollections={() => setOpenAllCollectionDialog(true)}
|
||||
collectionSummaries={sortedCollectionSummaries.filter((x) =>
|
||||
shouldBeShownOnCollectionBar(x.type),
|
||||
)}
|
||||
/>
|
||||
|
||||
<AllCollections
|
||||
open={allCollectionView}
|
||||
onClose={closeAllCollections}
|
||||
open={openAllCollectionDialog}
|
||||
onClose={() => setOpenAllCollectionDialog(false)}
|
||||
collectionSummaries={sortedCollectionSummaries.filter(
|
||||
(x) => !isSystemCollection(x.type),
|
||||
)}
|
||||
@@ -193,19 +188,18 @@ export const Collections: React.FC<CollectionsProps> = ({
|
||||
collectionListSortBy={collectionListSortBy}
|
||||
isInHiddenSection={mode == "hidden-albums"}
|
||||
/>
|
||||
|
||||
<CollectionShare
|
||||
collectionSummary={toShowCollectionSummaries.get(
|
||||
activeCollectionID,
|
||||
)}
|
||||
open={collectionShareModalView}
|
||||
onClose={closeCollectionShare}
|
||||
open={openCollectionShareView}
|
||||
onClose={() => setOpenCollectionShareView(false)}
|
||||
collection={activeCollection}
|
||||
/>
|
||||
<AlbumCastDialog
|
||||
currentCollection={activeCollection}
|
||||
show={showAlbumCastDialog}
|
||||
onHide={closeAlbumCastDialog}
|
||||
open={openAlbumCastDialog}
|
||||
onClose={() => setOpenAlbumCastDialog(false)}
|
||||
collection={activeCollection}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
declare const chrome: any;
|
||||
declare const cast: any;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__onGCastApiAvailable: (isAvailable: boolean) => void;
|
||||
}
|
||||
}
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Sender = {
|
||||
chrome: typeof chrome;
|
||||
cast: typeof cast;
|
||||
};
|
||||
|
||||
export const loadSender = (() => {
|
||||
let promise: Promise<Sender> | null = null;
|
||||
|
||||
return () => {
|
||||
if (promise === null) {
|
||||
promise = new Promise((resolve) => {
|
||||
const script = document.createElement("script");
|
||||
script.src =
|
||||
"https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1";
|
||||
window.__onGCastApiAvailable = (isAvailable) => {
|
||||
if (isAvailable) {
|
||||
cast.framework.CastContext.getInstance().setOptions({
|
||||
receiverApplicationId: "F5BCEC64",
|
||||
autoJoinPolicy:
|
||||
chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED,
|
||||
});
|
||||
|
||||
resolve({
|
||||
chrome,
|
||||
cast,
|
||||
});
|
||||
}
|
||||
};
|
||||
document.body.appendChild(script);
|
||||
});
|
||||
}
|
||||
return promise;
|
||||
};
|
||||
})();
|
||||
|
||||
export const useCastSender = () => {
|
||||
const [sender, setSender] = useState<Sender | { chrome: null; cast: null }>(
|
||||
{
|
||||
chrome: null,
|
||||
cast: null,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadSender().then((sender) => {
|
||||
setSender(sender);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return sender;
|
||||
};
|
||||
@@ -115,10 +115,10 @@ func (u *PasskeyUser) WebAuthnCredentials() []webauthn.Credential {
|
||||
|
||||
##### Response body (JSON)
|
||||
|
||||
| Key | Type | Value |
|
||||
| --------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| options | object | The credential creation options that will be provided to the browser. |
|
||||
| sessionID | string (uuidv4) | The identifier the server uses to persist metadata about the registration ceremony, like the user ID and challenge to prevent replay attacks. |
|
||||
| Key | Type | Value |
|
||||
| --------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| options | object | The credential creation options that will be provided to the browser. |
|
||||
| sessionID | string (uuid) | The identifier the server uses to persist metadata about the registration ceremony, like the user ID and challenge to prevent replay attacks. |
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
@@ -682,6 +682,12 @@ export async function boxSealOpen(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt the given {@link input} using the given {@link publicKey}.
|
||||
*
|
||||
* This function performs asymmetric (public-key) encryption. To decrypt the
|
||||
* result, use {@link boxSealOpen}.
|
||||
*/
|
||||
export async function boxSeal(input: string, publicKey: string) {
|
||||
await sodium.ready;
|
||||
return await toB64(
|
||||
|
||||
83
web/packages/new/photos/utils/chromecast-sender.ts
Normal file
83
web/packages/new/photos/utils/chromecast-sender.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/* eslint-disable @typescript-eslint/no-namespace */
|
||||
/**
|
||||
* The types for the sender are already available as
|
||||
* "@types/chromecast-caf-sender", however installing them breaks the types for
|
||||
* the cast receiver in apps/cast. Vice-versa, having those types for the
|
||||
* receiver ("@types/chromecast-caf-receiver") conflicts with the types that we
|
||||
* add for the sender.
|
||||
*
|
||||
* As a workaround, this file includes the handpicked interface from
|
||||
* "@types/chromecast-caf-sender" for only the parts that we use.
|
||||
*/
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
cast: typeof cast;
|
||||
__onGCastApiAvailable(available: boolean, reason?: string): void;
|
||||
}
|
||||
}
|
||||
|
||||
declare namespace chrome.cast {
|
||||
/**
|
||||
* @see https://developers.google.com/cast/docs/reference/chrome/chrome.cast#.AutoJoinPolicy
|
||||
*/
|
||||
export enum AutoJoinPolicy {
|
||||
ORIGIN_SCOPED = "origin_scoped",
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cast Application Framework
|
||||
* @see https://developers.google.com/cast/docs/reference/chrome/cast.framework
|
||||
*/
|
||||
declare namespace cast.framework {
|
||||
interface CastOptions {
|
||||
autoJoinPolicy: chrome.cast.AutoJoinPolicy;
|
||||
receiverApplicationId?: string | undefined;
|
||||
}
|
||||
|
||||
class CastContext {
|
||||
static getInstance(): CastContext;
|
||||
setOptions(options: CastOptions): void;
|
||||
requestSession(): Promise<unknown>;
|
||||
getCurrentSession(): CastSession | null;
|
||||
}
|
||||
|
||||
class CastSession {
|
||||
sendMessage(namespace: string, data: unknown): Promise<unknown>;
|
||||
addMessageListener(
|
||||
namespace: string,
|
||||
listener: (namespace: string, message: string) => void,
|
||||
): void;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the Chromecast script, resolving with the global `cast` object.
|
||||
*/
|
||||
export const loadCast = (() => {
|
||||
let promise: Promise<typeof cast> | undefined;
|
||||
|
||||
return () => {
|
||||
if (promise === undefined) {
|
||||
promise = new Promise((resolve) => {
|
||||
const script = document.createElement("script");
|
||||
script.src =
|
||||
"https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1";
|
||||
window.__onGCastApiAvailable = (isAvailable) => {
|
||||
if (isAvailable) {
|
||||
cast.framework.CastContext.getInstance().setOptions({
|
||||
receiverApplicationId: "F5BCEC64",
|
||||
autoJoinPolicy:
|
||||
chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED,
|
||||
});
|
||||
|
||||
resolve(cast);
|
||||
}
|
||||
};
|
||||
document.body.appendChild(script);
|
||||
});
|
||||
}
|
||||
return promise;
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user