Files
watchparty/app/src/Chat.js
T
2020-07-25 22:41:57 -04:00

620 lines
21 KiB
JavaScript

/************************************************************************
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 <admin@chromewatchparty.com>, 2020
https://www.chromewatchparty.com/copyright
=========================================================================
ALL CONTENT ON IN WATCH PARTY'S CHROME EXTENSION APP, UNLESS OTHERWISE
CITED ARE THE SOLE INTELECTUAL PROPERTY OF AYUSHYA AMITABH, AND MAY NOT BE
COPIED, EDITED, OR REPRODUCED WITHOUT PERMISSION.
Netflix logo is a trademark and intellectual property of Netflix, Inc.
Disney+ logo is a trademark and intellectual property of The Walt Disney Company
Prime logo is a trademark and intellectual property of Amazon.com, Inc.
YouTube logo is a trademark and intellectual property of Google, Inc.
Hulu logo is a trademark and intellectual property of Hulu, LLC.
HBO Now and HBO Max logos are trademark and intellectual property of WarnerMedia Direct,LLC.
************************************************************************/
/*global chrome */
import React, { Component } from 'react';
import {
Typography,
TextField,
InputAdornment,
IconButton,
ButtonGroup,
Button,
ExpansionPanel,
ExpansionPanelSummary,
ExpansionPanelDetails,
Card,
CardContent,
Tooltip,
Switch,
FormControlLabel,
Chip,
} from '@material-ui/core';
import Thread from './Thread';
import { Carousel } from '@giphy/react-components';
import { GiphyFetch } from '@giphy/js-fetch-api';
import {
SendMessageApi,
LeaveRoomApi,
DeleteRoomApi,
UpdatePlayerApi,
getPlayerSource,
PLAYER_SOURCE,
LocalData,
PLAYER_STATUS,
emptyLocalData,
updateLocalData,
} 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,
} from '@material-ui/icons';
import Create from './Create';
import { goTo } from 'route-lite';
import CallManager from './CallManager';
import VideoListener from './VideoListener';
const giphyFetch = new GiphyFetch('ojJbIMUN6XRMn4mHiFFav3rMCpXUg8sq');
const alerts = [
{
condition: () =>
getPlayerSource() === PLAYER_SOURCE.HBOMAX || getPlayerSource() === PLAYER_SOURCE.HBONOW,
message: 'Functionality may be buggy, HBO has a horrible design.',
},
{
condition: () => getPlayerSource() === PLAYER_SOURCE.PEACOCK,
message: 'Functionality may be buggy, support on this site is in testing.',
},
];
export default class Chat extends Component {
constructor(props) {
super(props);
this.state = {
name: '',
messages: [],
newMessage: '',
admin: null,
users: [],
usersOnCall: {},
gif: false,
onCall: false,
muted: false,
callActionPending: false,
menuPopover: true,
allowChatNotification:
LocalData().allowChatNotification !== undefined ? LocalData().allowChatNotification : true,
isFullscreen: document.fullscreen,
};
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.sendMessage = this.sendMessage.bind(this);
this.getCarousel = this.getCarousel.bind(this);
this.handlePhoneCall = this.handlePhoneCall.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);
this.updateAllowChatNotification = this.updateAllowChatNotification.bind(this);
this.mCallManager = new CallManager(this.callEventListener, this.props.notify);
}
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
updateLocalData({ lastActive: new Date().getTime() });
setInterval(() => {
updateLocalData({ lastActive: new Date().getTime() });
}, 60000);
// VIDEO LISTENER START
this.mVideoListener.start();
// CHROME FIREBASE FIRESTORE LISTENER
this.port = chrome.runtime.connect({ name: LocalData().partyName });
// Initialize listener
this.port.postMessage({
type: 'INIT',
roomId: LocalData().partyName,
});
// 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;
}
// 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];
var notificationMessage = '';
if (lastMessage.uid !== '<system>') notificationMessage += lastMessage.name + ': ';
notificationMessage += lastMessage.text;
this.props.notify(notificationMessage);
}
}
this.setState(response);
if (response.player) this.mVideoListener.doUpdate(response.player);
const pcc = document.querySelector('.thread-container');
if (pcc) pcc.scrollTop = pcc.scrollHeight;
});
// DONE WITH MOUNTING TASKS
this.props.updateLoading(false);
}
componentWillUnmount() {
this.mVideoListener.end();
this.leaveParty();
}
videoEventListener(video) {
this.syncPlayer(video);
}
callEventListener(event) {
this.setState(event);
}
syncPlayer(video) {
const { status, showName, time, source, element, showChanged } = video;
if (element) {
UpdatePlayerApi({
roomId: LocalData().partyName,
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,
};
DeleteRoomApi(data)
.then((res) => {
this.props.notify('Ended party');
emptyLocalData();
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,
};
LeaveRoomApi(data)
.then((res) => {
this.props.notify('Left party');
emptyLocalData();
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 data = {
roomId: LocalData().partyName,
name: LocalData().userName,
uid: LocalData().userUid,
text: message,
time: new Date(),
type: '',
};
if (this.state.gif === true) data.type = 'giphy';
SendMessageApi(data).catch((err) => {
this.props.notify(err.message || err.statusText);
console.error('Send Message API error', err);
});
}
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, ev) {
ev.preventDefault();
this.sendMessageInvoker(iGif.images.original.url);
}
getCarousel(offset) {
return this.state.newMessage === ''
? giphyFetch.trending({ offset, limit: 1 })
: giphyFetch.search(this.state.newMessage, { offset, limit: 1 });
}
handlePhoneCall() {
const { onCall, conference, admin } = this.state;
this.mCallManager.toggleCall(onCall, conference, admin);
}
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 });
}
toggleFullscreen() {
if (document.fullscreen) {
document.exitFullscreen();
} else {
document.documentElement.requestFullscreen();
}
}
render() {
const {
admin,
name,
messages,
newMessage,
gif,
onCall,
muted,
player,
allowChatNotification,
users,
callActionPending,
usersOnCall,
isFullscreen,
} = this.state;
return (
<div className="party-chat-container">
<div className="party-chat-header">
<ExpansionPanel elevation={0} className="expander">
<ExpansionPanelSummary
expandIcon={
<Tooltip
placement="bottom-end"
title="Share, leave, and other controls"
aria-label="Share, leave, and other controls"
>
<KeyboardArrowDown />
</Tooltip>
}
>
<div className="party-chat-header-text">
<Typography
color="secondary"
className="brand-title"
variant="caption"
display="block"
>
WATCH PARTY
</Typography>
<Typography color="textPrimary" className="room-title" variant="h4" gutterBottom>
{name}
</Typography>
</div>
</ExpansionPanelSummary>
<ExpansionPanelDetails>
<TextField
className="share-textbox"
label="Party Details"
value={`Party Name: ${LocalData().partyName}\nParty Pin: ${LocalData().partyPin}\n${
window.location.href
}`}
multiline
rowsMax={2}
variant="outlined"
fullWidth
color="secondary"
InputProps={{
endAdornment: (
<Tooltip title="Share party" aria-label="Share party">
<InputAdornment position="end">
<IconButton onClick={this.shareParty}>
<Share />
</IconButton>
</InputAdornment>
</Tooltip>
),
}}
/>
<ButtonGroup fullWidth className="party-controls" size="small">
<Button onClick={this.leaveParty} startIcon={<Exit />}>
<Typography variant="caption" className="party-control-button-text">
Leave
</Typography>
</Button>
{admin && (
<Button onClick={this.endParty} startIcon={<Delete />}>
<Typography variant="caption" className="party-control-button-text">
End
</Typography>
</Button>
)}
</ButtonGroup>
<FormControlLabel
className="notification-switch"
control={
<Switch
checked={allowChatNotification}
onChange={this.updateAllowChatNotification}
name="allowChatNotification"
color={allowChatNotification ? 'secondary' : 'primary'}
/>
}
label={
<span>
Allow chat notification when side panel is closed
<br />
<span style={{ color: '#8a8a8a' }}>
{allowChatNotification ? 'Currently Enabled' : 'Currently Disabled'}
</span>
</span>
}
labelPlacement="end"
/>
<Typography className="party-controls-users-title" variant="overline">
PARTY MEMBERS
</Typography>
<div className="party-controls-users-container">
{users.map((user) => (
<Chip className="party-controls-users-chip" clickable size="small" label={user} />
))}
</div>
{Object.keys(usersOnCall).length > 0 ? (
<Typography className="party-controls-users-title" variant="overline">
MEMBERS IN CALL
</Typography>
) : null}
{Object.keys(usersOnCall).length > 0 ? (
<div className="party-controls-users-container">
{Object.keys(usersOnCall).map((id) => {
return usersOnCall[id].status === 'Connected' ||
usersOnCall[id].status === 'Connecting' ? (
<Chip
color={
usersOnCall[id].info.externalId === LocalData().userUid
? 'secondary'
: 'default'
}
className="party-controls-users-chip"
clickable
size="small"
label={usersOnCall[id].info.name || ' '}
/>
) : null;
})}
</div>
) : null}
</ExpansionPanelDetails>
</ExpansionPanel>
{player ? (
<Card elevation={0} className="media-info-card">
{player.showName && player.status !== PLAYER_STATUS.NOT_PLAYING ? (
<CardContent>
<Typography className="media-info-at" variant="overline">
{player.status === PLAYER_STATUS.PAUSED
? `PAUSED AT ${this.getPrettyTime(player.time)}`
: player.status === PLAYER_STATUS.PLAYING
? `STARTED PLAYING FROM ${this.getPrettyTime(player.time)}`
: `SHOW IS AT ${this.getPrettyTime(player.time)}`}
</Typography>
<div className="media-info-container">
{player.status === PLAYER_STATUS.PAUSED ? (
<PauseCircleFilled className="media-info-icon" />
) : (
<PlayCircleFilled className="media-info-icon" />
)}
<div className="media-info-right">
<Typography className="show-name" variant="subtitle1">
{player.showName}
</Typography>
</div>
</div>
</CardContent>
) : (
<CardContent>
<Typography className="media-info-at" variant="caption">
Nothing playing right now
</Typography>
</CardContent>
)}
{player && player.showName && player.status ? (
<Button className="media-info-link" href={player.url} startIcon={<LinkOutlined />}>
Link to show
</Button>
) : null}
</Card>
) : null}
</div>
<div className="thread-container">
<div className="alerts-container">
{alerts.map((a) => {
if (a.condition() === true) {
return (
<Card className="alert-card" variant="outlined">
<CardContent className="alert-card-content">
<Error />
<Typography variant="body2">{a.message}</Typography>
</CardContent>
</Card>
);
} else return null;
})}
</div>
<Thread messages={messages} />
</div>
<div className="text-phone-panel">
{gif || newMessage ? null : (
<Tooltip title="Fullscreen with Watch Party" aria-label="Fullscreen">
<IconButton color="secondary" onClick={this.toggleFullscreen}>
{isFullscreen ? <FullscreenExit /> : <Fullscreen />}
</IconButton>
</Tooltip>
)}
{gif || newMessage ? null : (
<IconButton
disabled={callActionPending}
onClick={this.handlePhoneCall}
color={!onCall ? 'secondary' : 'primary'}
>
{onCall ? (
<Tooltip placement="top" title="Leave Call">
<PhoneDisable />
</Tooltip>
) : (
<Tooltip placement="top" title="Join Call">
<PhoneEnable />
</Tooltip>
)}
</IconButton>
)}
{!gif && !newMessage && onCall && !muted ? (
<Tooltip title="Mute Mic" placement="top">
<IconButton disabled={callActionPending} onClick={this.handleMute} color="primary">
<MicOff />
</IconButton>
</Tooltip>
) : !gif && !newMessage && onCall && muted ? (
<Tooltip title="Unmute Mic" placement="top">
<IconButton disabled={callActionPending} onClick={this.handleMute} color="secondary">
<MicOn />
</IconButton>
</Tooltip>
) : null}
<TextField
color="secondary"
multiline
rowsMax="3"
size="small"
placeholder={this.state.gif ? 'Search something...' : 'Say something...'}
helperText={
this.state.newMessage && !this.state.gif
? 'Enter to send / Shift+Enter for new line'
: this.state.gif
? 'Click on gif to send'
: null
}
variant="outlined"
className="chat-message-box addFocus"
value={newMessage}
onChange={(e) => {
this.setState({ newMessage: e.target.value });
if (this.state.gif) {
const carousel = document.querySelector('.giphy-carousel');
setTimeout(() => carousel.scroll(carousel.scrollWidth, carousel.scrollHeight), 250);
}
}}
InputProps={{
id: 'chat-message-box-input',
endAdornment: (
<InputAdornment position="end">
<IconButton
className={`${this.state.gif ? 'gif-active' : ''} ${
this.state.newMessage ? 'message-active' : ''
} message-send`}
size="small"
onClick={() => this.sendMessageInvoker(newMessage)}
edge="end"
>
<Send />
</IconButton>
</InputAdornment>
),
startAdornment: (
<InputAdornment position="start">
<IconButton onClick={() => this.setState({ gif: !gif })} edge="start">
{gif ? <Cancel /> : <Gif />}
</IconButton>
</InputAdornment>
),
}}
/>
</div>
{gif ? (
<Carousel
fetchGifs={this.getCarousel}
gifHeight={200}
gutter={6}
onGifClick={this.gifClick}
hideAttribution
/>
) : null}
</div>
);
}
}