[web] Album cast dialog - Non functional tweaks (#3384)

This commit is contained in:
Manav Rathi
2024-09-21 18:16:44 +05:30
committed by GitHub
10 changed files with 188 additions and 210 deletions

View File

@@ -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>();

View File

@@ -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() {

View File

@@ -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

View File

@@ -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>
);
}
};

View File

@@ -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}
/>
</>
);

View File

@@ -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;
};

View File

@@ -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
{

View File

@@ -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(

View 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;
};
})();