/************************************************************************ Copyright (C) Ayushya Amitabh - All Rights Reserved Unauthorized copying of this file, via any medium is strictly prohibited Proprietary and confidential Written by Ayushya Amitabh , 2020 - 2022 https://www.chromewatchparty.com/copyright ========================================================================= ALL CONTENT ON WATCH PARTY'S WEBSITE OR IN WATCH PARTY'S CHROME EXTENSION APP, UNLESS OTHERWISE CITED ARE THE SOLE INTELLECTUAL PROPERTY OF AYUSHYA AMITABH, AND MAY NOT BE COPIED, EDITED, OR REPRODUCED WITHOUT PERMISSION. ************************************************************************/ /*global chrome*/ /*global browser*/ import React, { Component } from "react"; import { Typography, TextField, InputAdornment, IconButton, ButtonGroup, Button, ExpansionPanel, ExpansionPanelSummary, ExpansionPanelDetails, Card, CardContent, Tooltip, Switch, FormControlLabel, Chip, CircularProgress, Paper, Select, MenuItem, FormControl, InputLabel, } from "@material-ui/core"; import Thread from "./Thread"; import { SendMessageApi, LeaveRoomApi, DeleteRoomApi, UpdatePlayerApi, LocalData, PLAYER_STATUS, updateLocalData, UpdateAdminOnly, scrollThread, removeLocalData, PLAYER_SOURCE, getPlayerSource, } from "./Api"; import { Send, ExitToApp as Exit, Share, Delete, CallRounded as PhoneEnable, CallEndRounded as PhoneDisable, MicOff, Mic as MicOn, Gif, KeyboardArrowDown, PauseCircleFilled, LinkOutlined, PlayCircleFilled, Cancel, Error, FullscreenExit, Fullscreen, CancelRounded, VideocamRounded, VideocamOffRounded, Remove, Add, } from "@material-ui/icons"; import Create from "./Create"; import { goTo } from "route-lite"; import CallManager from "./CallManager"; import VideoListener from "./VideoListener"; import ReactGiphySearchbox from "react-giphy-searchbox"; import VoxeetSDK from "@voxeet/voxeet-web-sdk"; let CHROME_OR_BROWSER = chrome || browser; export default class Chat extends Component { constructor(props) { super(props); this.state = { name: "", messages: [], newMessage: "", admin: null, adminOnly: false, adminModeUpdate: false, users: [], gif: false, onCall: false, muted: false, callActionPending: false, menuPopover: true, allowChatNotification: LocalData().allowChatNotification !== undefined ? LocalData().allowChatNotification : true, isFullscreen: document.fullscreen, allowAutoRejoin: LocalData().allowAutoRejoin !== undefined ? LocalData().allowAutoRejoin : true, allowAutoFollow: LocalData().allowAutoFollow !== undefined ? LocalData().allowAutoFollow : false, replyTo: null, sendingMessages: [], usingVideo: false, audioInputs: [], selectedAudioInput: "", audioOutputs: [], selectedAudioOutput: "", videoInputs: [], selectedVideoInput: "", minimized: false, hideVideoContainer: LocalData().hideVideoContainer || false, }; this.port = null; this.gifClick = this.gifClick.bind(this); this.endParty = this.endParty.bind(this); this.handleMute = this.handleMute.bind(this); this.leaveParty = this.leaveParty.bind(this); this.shareParty = this.shareParty.bind(this); this.syncPlayer = this.syncPlayer.bind(this); this.setReplyTo = this.setReplyTo.bind(this); this.sendMessage = this.sendMessage.bind(this); this.updateAdminOnly = this.updateAdminOnly.bind(this); this.handlePhoneCall = this.handlePhoneCall.bind(this); this.getReplyToContent = this.getReplyToContent.bind(this); this.callEventListener = this.callEventListener.bind(this); this.sendMessageInvoker = this.sendMessageInvoker.bind(this); this.videoEventListener = this.videoEventListener.bind(this); this.mVideoListener = new VideoListener( this.videoEventListener, props.selectors, props.nameSelectors ); this.updateAllowAutoRejoin = this.updateAllowAutoRejoin.bind(this); this.updateAllowAutoFollow = this.updateAllowAutoFollow.bind(this); this.updateAllowChatNotification = this.updateAllowChatNotification.bind(this); this.mCallManager = new CallManager( this.callEventListener, this.props.notify ); this.updateHideVideoContainer = this.updateHideVideoContainer.bind(this); } componentDidMount() { // EVENT CAPTURE FOR ENTER TO SEND window.onkeypress = (ev) => { if (ev.code === "Enter") { if (ev.target === document.getElementById("chat-message-box-input")) { if (ev.shiftKey === false && this.state.gif === true) ev.preventDefault(); if (ev.shiftKey === false && this.state.gif === false) { ev.preventDefault(); this.sendMessageInvoker(); } } } }; // FULLSCREEN LISTENER document.addEventListener("fullscreenchange", (ev) => { this.setState({ isFullscreen: document.fullscreen }); }); // ACTIVE TRACKER if ( LocalData().allowAutoRejoin === undefined || LocalData().allowAutoRejoin === null ) { updateLocalData({ allowAutoRejoin: this.state.allowAutoRejoin }); } if (this.state.allowAutoRejoin === true) { updateLocalData({ lastActive: new Date().getTime() }); } setInterval(() => { if (this.state.allowAutoRejoin === true) { updateLocalData({ lastActive: new Date().getTime() }); } }, 60000); // VIDEO LISTENER START this.mVideoListener.start(); // CHROME FIREBASE FIRESTORE LISTENER this.port = CHROME_OR_BROWSER.runtime.connect({ name: LocalData().partyName, }); // Initialize listener this.port.postMessage({ type: "INIT", roomId: LocalData().partyName, roomPin: LocalData().partyPin, }); // Listener this.port.onMessage.addListener((response) => { if (response.isError === true) { this.props.notify("Uh oh. Got lost from the party."); goTo(Create, this.props); return; } else { // CONTINUE TUTORIAL if (this.props.tutorialComplete === false) { this.props.resetTutorial("chat"); } } if (response.messages) { scrollThread(); } // IF CHAT IS HIDDEN AND ALLOWS NOTIFICATION if ( !document.querySelector("body.visible") && this.state.allowChatNotification === true ) { if ( response.messages && response.messages.length !== this.state.messages.length ) { const lastMessage = response.messages[response.messages.length - 1]; if (lastMessage.uid !== "") { this.props.notify( `${lastMessage.name}${ lastMessage.type === "giphy" ? " sent a gif." : ": " + lastMessage.text }` ); } } } // ADMIN MODE UPDATE COMPLETE if (response.adminOnly !== this.state.adminOnly) { this.setState({ adminModeUpdate: false }); } this.setState(response); if (response.player) this.mVideoListener.doUpdate(response.player); }); // GIPHY IPC const giphyInject = `var giphySearchInteraction=function(e){if(e.source===window){const{data}=e;if(data.watchPartyDirection==='TO_SCRIPT'){if(data.type==='WATCH_PARTY_SEARCH_GIF'){const giphyInput=document.querySelector('.giphy-search-form input');if(giphyInput){giphyInput.value=data.value;giphyInput.dispatchEvent(new Event('change',{bubbles:true}))}}}}};window.addEventListener("message",giphySearchInteraction);`; const giphyInjectScript = document.createElement("script"); giphyInjectScript.text = giphyInject; document.head.appendChild(giphyInjectScript); // DONE WITH MOUNTING TASKS this.props.updateLoading(false); this.updateHideVideoContainer(); setInterval(() => { if ( this.state.player && this.state.player.time && this.state.player.status === PLAYER_STATUS.PLAYING ) { const oldPlayer = this.state.player; oldPlayer.time += 1; this.setState({ player: oldPlayer, }); } }, 1000); } componentWillUnmount() { this.mVideoListener.end(); this.leaveParty(); } videoEventListener(video) { const { adminOnly, admin } = this.state; if (!adminOnly || (adminOnly && admin)) { this.syncPlayer(video); } } callEventListener(event) { this.setState(event); if (event.onCall === true) { VoxeetSDK.mediaDevice.enumerateAudioDevices("input").then((devices) => { this.setState({ audioInputs: devices, selectedAudioInput: devices.length > 0 ? devices[0].deviceId : "", }); }); VoxeetSDK.mediaDevice.enumerateAudioDevices("output").then((devices) => { this.setState({ audioOutputs: devices, selectedAudioOutput: devices.length > 0 ? devices[0].deviceId : "", }); }); VoxeetSDK.mediaDevice.enumerateVideoDevices("output").then((devices) => { this.setState({ videoInputs: devices, selectedVideoInput: devices.length > 0 ? devices[0].deviceId : "", }); }); } } syncPlayer(video) { const { status, showName, time, source, element, showChanged } = video; if (element) { UpdatePlayerApi({ roomId: LocalData().partyName || this.state.name, showChanged, userName: LocalData().userName, player: { status, showName, time, source, updatedBy: LocalData().userUid, url: window.location.href, }, }).catch((err) => { this.props.updateLoading(false); this.props.notify(err.message || err.statusText); console.error("Sync Party API error", err); }); } else { this.mVideoListener.start(); } } endParty() { this.props.notify("Ending party..."); this.props.updateLoading(true); const data = { partyName: LocalData().partyName, }; if (this.state.onCall) this.mCallManager.toggleCall( this.state.onCall, this.state.conference, this.state.admin, this.state.adminOnly, false ); DeleteRoomApi(data) .then((res) => { this.props.notify("Ended party"); removeLocalData("partyPin"); removeLocalData("partyName"); goTo(Create, this.props); this.props.updateLoading(false); }) .catch((err) => { this.props.updateLoading(false); this.props.notify(err.message || err.statusText); console.error("End Party API error", err); }); } leaveParty() { this.props.notify("Leaving party..."); this.props.updateLoading(true); const data = { partyName: LocalData().partyName, userUid: LocalData().userUid, }; if (this.state.onCall) this.mCallManager.toggleCall( this.state.onCall, this.state.conference, this.state.admin, this.state.adminOnly, false ); LeaveRoomApi(data) .then((res) => { this.props.notify("Left party"); removeLocalData("partyPin"); removeLocalData("partyName"); goTo(Create, this.props); this.props.updateLoading(false); }) .catch((err) => { this.props.updateLoading(false); this.props.notify(err.message || err.statusText); console.error("End Party API error", err); }); } shareParty() { const el = document.createElement("textarea"); el.value = `Join my party on Watch Party!\nParty Name: ${ LocalData().partyName }\nParty Pin: ${LocalData().partyPin}\n${ window.location.href }\n---\nGet Watch Party at https:\\\\get.chromewatchparty.com`; document.body.appendChild(el); el.select(); document.execCommand("copy"); document.body.removeChild(el); this.props.notify("Copied party invitation"); } sendMessage(message) { const { sendingMessages } = this.state; const data = { id: `sending-${sendingMessages.length}`, roomId: LocalData().partyName, name: LocalData().userName, uid: LocalData().userUid, text: message, time: new Date(), type: "", }; if (this.state.gif === true) data.type = "giphy"; if (this.state.replyTo !== null) data.replyTo = this.state.replyTo.id; sendingMessages.push(data); this.setState({ sendingMessages, }); SendMessageApi(data) .then(() => { const { sendingMessages: sm } = this.state; const rmi = sm.indexOf(data); sm.splice(rmi, 1); this.setState({ sendingMessages: sm }); scrollThread(); }) .catch((err) => { const { sendingMessages: sm } = this.state; const rmi = sm.indexOf(data); sm.splice(rmi, 1); this.setState({ sendingMessages: sm }); scrollThread(); this.props.notify(err.message || err.statusText); console.error("Send Message API error", err); }); this.setState({ replyTo: null }); scrollThread(); } sendMessageInvoker(overrideMessage) { if (overrideMessage) this.sendMessage(overrideMessage); else { const trim = this.state.newMessage.replace(/^\s+|\s+$/g, ""); if (trim !== "") this.sendMessage(trim); } this.setState({ newMessage: "" }); } gifClick(iGif) { this.sendMessageInvoker(iGif.images.original.url); } handlePhoneCall(useVideo = false) { const { onCall, conference, admin, adminOnly } = this.state; if (window.navigator && onCall === false) { try { const overlay = document.createElement("div"); overlay.innerHTML = `

Grant Mic / Camera Permission

`; overlay.id = "mic-permission-overlay"; document.body.appendChild(overlay); window.navigator .getUserMedia({ audio: true, }) .then(() => { if (useVideo === true) { window.navigator .getUserMedia({ video: true, }) .then(() => { document.getElementById("mic-permission-overlay").remove(); this.mCallManager.toggleCall( onCall, conference, admin, adminOnly, useVideo ); }) .catch(() => { document.getElementById("mic-permission-overlay").remove(); this.props.notify( "Check your camera permission for this website. You can modify site permission by clicking on the lock (🔒) icon in the top left, then clicking site permissions.", 15000 ); }); } else { document.getElementById("mic-permission-overlay").remove(); this.mCallManager.toggleCall( onCall, conference, admin, adminOnly, useVideo ); } }) .catch(() => { document.getElementById("mic-permission-overlay").remove(); this.props.notify( "Check your mic permission for this website. You can modify site permission by clicking on the lock (🔒) icon in the top left, then clicking site permissions.", 15000 ); }); } catch (err) { console.error("Handle Phone Call Error", err); } } else this.mCallManager.toggleCall( onCall, conference, admin, adminOnly, useVideo ); } handleMute() { this.mCallManager.toggleMute(this.state.muted); } getPrettyTime(t) { return t > 3600 ? new Date(t * 1000).toISOString().substr(11, 8) : new Date(t * 1000).toISOString().substr(14, 5); } updateAllowChatNotification() { updateLocalData({ allowChatNotification: !this.state.allowChatNotification, }); this.setState({ allowChatNotification: !this.state.allowChatNotification }); } updateAllowAutoRejoin() { updateLocalData({ allowAutoRejoin: !this.state.allowAutoRejoin }); this.setState({ allowAutoRejoin: !this.state.allowAutoRejoin }); } updateAllowAutoFollow() { updateLocalData({ allowAutoFollow: !this.state.allowAutoFollow }); this.setState({ allowAutoFollow: !this.state.allowAutoFollow }); } toggleFullscreen() { if (document.fullscreen) { document.exitFullscreen(); } else { document.documentElement.requestFullscreen(); } } updateAdminOnly() { this.setState({ adminModeUpdate: true }); UpdateAdminOnly({ partyName: this.state.name, adminOnly: !this.state.adminOnly, }).catch((err) => { this.props.notify(err.message || err.statusText); console.error("Update Admin Only API error", err); }); } setReplyTo(message) { this.setState({ replyTo: message, }); scrollThread(); } getReplyToContent() { const { replyTo } = this.state; if (replyTo) { if (replyTo.type === "giphy") { return gif; } return ( {replyTo.text} ); } return null; } handleVideo() { const { usingVideo } = this.state; this.mCallManager.toggleVideo(!usingVideo); } updateHideVideoContainer() { const hideVideoContainer = !this.state.hideVideoContainer; document.documentElement.style.setProperty('--video-container-pos', hideVideoContainer ? 'relative' : 'fixed'); updateLocalData({ hideVideoContainer }); this.setState({ hideVideoContainer }); } render() { const { admin, name, messages, newMessage, gif, onCall, muted, player, allowChatNotification, users, callActionPending, allowAutoRejoin, allowAutoFollow, isFullscreen, adminModeUpdate, adminOnly, replyTo, sendingMessages, usingVideo, audioInputs, selectedAudioInput, audioOutputs, selectedAudioOutput, videoInputs, selectedVideoInput, minimized, hideVideoContainer } = this.state; const { status } = this.props; const { callEnabled } = status; const alerts = [ { condition: () => adminOnly, message: "Party is currently in admin only mode.", }, { condition: () => getPlayerSource() === PLAYER_SOURCE.FUNIMATION, message: "If sync stops working, try (Ctrl + Shift + R) or (⌘ + Shift + R)", }, { condition: () => window.location.href.includes(PLAYER_SOURCE.PRIMEVIDEO), message: "Support on Prime Video is untested, and may be buggy.", }, ]; return (
} >
WATCH PARTY {name}
{ this.setState({ minimized: !minimized }); ev.preventDefault(); ev.stopPropagation(); }} > {minimized ? : }
), }} /> {admin && ( )} } label={ Allow chat notification when side panel is closed
{allowChatNotification ? "Notifications Enabled" : "Notifications Disabled"}
} labelPlacement="end" /> } label={ Allow Watch Party to auto rejoin (within 1 minute)
{allowAutoRejoin ? "Auto Rejoin Enabled" : "Auto Rejoin Disabled"}
} labelPlacement="end" /> {admin ? ( } label={ Admin Only Mode
{adminOnly ? "Only admin can update player and start calls" : "All party members can update player and start calls"}
} labelPlacement="end" /> ) : null} } label={ (BETA) Allow Watch Party to change to the currently playing
{allowAutoFollow ? "Auto Follow Enabled" : "Auto Follow Disabled"}
} labelPlacement="end" /> } label={ Hide Video Container when sidebar is collapsed
{hideVideoContainer ? "Video containers will be hidden" : "Video containers will be shown"}
} labelPlacement="end" /> PARTY MEMBERS
{users.map((user) => ( ))}
{onCall && ( DEVICE SELECT )} {onCall && (
Mic Speaker Camera
)}
{!minimized && player ? ( {player.showName && player.status !== PLAYER_STATUS.NOT_PLAYING ? ( Last updated by {player.updatedByName} {player.status === PLAYER_STATUS.PAUSED ? `PAUSED AT ${this.getPrettyTime(player.time)}` : player.status === PLAYER_STATUS.PLAYING ? `CURRENTLY PLAYING (${this.getPrettyTime(player.time)})` : `SHOW IS AT ${this.getPrettyTime(player.time)}`}
{player.status === PLAYER_STATUS.PAUSED ? ( ) : ( )}
{player.showName}
{allowAutoFollow ? null : ( Watch Party will not automatically change the show for you. )}
) : ( Nothing playing right now Pause / Play once after starting the show to start sync.
Watch Party will not automatically change the show for you.
)} {player && player.showName && player.status ? ( ) : null}
) : null}
{alerts.map((a) => { if (a.condition() === true) { return ( {a.message} ); } else return null; })}
REPLYING TO {this.getReplyToContent()}
this.setReplyTo(null)}>
{gif || newMessage ? null : ( {isFullscreen ? : } )} {gif || newMessage || !callEnabled ? null : callActionPending ? ( ) : ( this.handleVideo() : () => this.handlePhoneCall(true) } color={ (onCall && usingVideo) || !onCall ? "secondary" : "primary" } > {(onCall && usingVideo) || !onCall ? ( ) : ( )} )} {gif || newMessage || !callEnabled ? null : callActionPending ? ( ) : ( this.handlePhoneCall(false)} color={!onCall ? "secondary" : "primary"} className="icon-button" > {onCall ? : } )} {!gif && !newMessage && onCall && !muted ? ( ) : !gif && !newMessage && onCall && muted ? ( ) : null} { e.stopPropagation(); this.setState({ newMessage: e.target.value }); scrollThread(); if (gif) { window.postMessage({ watchPartyDirection: "TO_SCRIPT", type: "WATCH_PARTY_SEARCH_GIF", value: e.target.value, }); } }} InputProps={{ id: "chat-message-box-input", endAdornment: ( this.sendMessageInvoker(newMessage)} edge="end" > ), startAdornment: ( { this.setState({ gif: !gif }); if (!gif) { window.postMessage({ watchPartyDirection: "TO_SCRIPT", type: "WATCH_PARTY_SEARCH_GIF", value: newMessage, }); scrollThread(); } }} edge="start" > {gif ? : } ), }} />
); } }