From 38f9a79bd3d79fad83822b0e49314f00c0270fa0 Mon Sep 17 00:00:00 2001
From: Robert Long <robert@robertlong.me>
Date: Fri, 22 Apr 2022 18:05:48 -0700
Subject: [PATCH] Initial PTT designs

---
 src/button/Button.jsx              |  1 +
 src/button/Button.module.css       |  9 +++
 src/home/RegisteredView.jsx        | 12 +++-
 src/home/RegisteredView.module.css |  4 ++
 src/home/UnauthenticatedView.jsx   | 11 +++-
 src/matrix-utils.js                |  5 +-
 src/room/GroupCallView.jsx         | 63 ++++++++++++++-------
 src/room/PTTButton.jsx             | 44 +++++++++++++++
 src/room/PTTButton.module.css      | 11 ++++
 src/room/PTTCallView.jsx           | 88 ++++++++++++++++++++++++++++++
 src/room/PTTCallView.module.css    | 40 ++++++++++++++
 src/room/PTTFeed.jsx               | 10 ++++
 src/room/PTTFeed.module.css        |  3 +
 13 files changed, 277 insertions(+), 24 deletions(-)
 create mode 100644 src/room/PTTButton.jsx
 create mode 100644 src/room/PTTButton.module.css
 create mode 100644 src/room/PTTCallView.jsx
 create mode 100644 src/room/PTTCallView.module.css
 create mode 100644 src/room/PTTFeed.jsx
 create mode 100644 src/room/PTTFeed.module.css

diff --git a/src/button/Button.jsx b/src/button/Button.jsx
index b6c3df9..ab6ed77 100644
--- a/src/button/Button.jsx
+++ b/src/button/Button.jsx
@@ -20,6 +20,7 @@ export const variantToClassName = {
   copy: [styles.copyButton],
   iconCopy: [styles.iconCopyButton],
   secondaryCopy: [styles.copyButton],
+  secondaryHangup: [styles.secondaryHangup],
 };
 
 export const sizeToClassName = {
diff --git a/src/button/Button.module.css b/src/button/Button.module.css
index 72450d5..5c3ec30 100644
--- a/src/button/Button.module.css
+++ b/src/button/Button.module.css
@@ -20,6 +20,7 @@ limitations under the License.
 .iconButton,
 .iconCopyButton,
 .secondary,
+.secondaryHangup,
 .copyButton {
   position: relative;
   display: flex;
@@ -34,6 +35,7 @@ limitations under the License.
 }
 
 .secondary,
+.secondaryHangup,
 .button,
 .copyButton {
   padding: 7px 15px;
@@ -53,6 +55,7 @@ limitations under the License.
 .iconButton:focus,
 .iconCopyButton:focus,
 .secondary:focus,
+.secondaryHangup:focus,
 .copyButton:focus {
   outline: auto;
 }
@@ -119,6 +122,12 @@ limitations under the License.
   background-color: transparent;
 }
 
+.secondaryHangup {
+  color: #ff5b55;
+  border: 2px solid #ff5b55;
+  background-color: transparent;
+}
+
 .copyButton.secondaryCopy {
   color: var(--textColor1);
   border-color: var(--textColor1);
diff --git a/src/home/RegisteredView.jsx b/src/home/RegisteredView.jsx
index 6e43f20..6849d9f 100644
--- a/src/home/RegisteredView.jsx
+++ b/src/home/RegisteredView.jsx
@@ -23,12 +23,13 @@ export function RegisteredView({ client }) {
       e.preventDefault();
       const data = new FormData(e.target);
       const roomName = data.get("callName");
+      const ptt = data.get("ptt") !== null;
 
       async function submit() {
         setError(undefined);
         setLoading(true);
 
-        const roomIdOrAlias = await createRoom(client, roomName);
+        const roomIdOrAlias = await createRoom(client, roomName, ptt);
 
         if (roomIdOrAlias) {
           history.push(`/room/${roomIdOrAlias}`);
@@ -87,6 +88,7 @@ export function RegisteredView({ client }) {
                 required
                 autoComplete="off"
               />
+
               <Button
                 type="submit"
                 size="lg"
@@ -96,6 +98,14 @@ export function RegisteredView({ client }) {
                 {loading ? "Loading..." : "Go"}
               </Button>
             </FieldRow>
+            <FieldRow className={styles.fieldRow}>
+              <InputField
+                id="ptt"
+                name="ptt"
+                label="Push to Talk"
+                type="checkbox"
+              />
+            </FieldRow>
             {error && (
               <FieldRow className={styles.fieldRow}>
                 <ErrorMessage>{error.message}</ErrorMessage>
diff --git a/src/home/RegisteredView.module.css b/src/home/RegisteredView.module.css
index 3783d5e..03f5b21 100644
--- a/src/home/RegisteredView.module.css
+++ b/src/home/RegisteredView.module.css
@@ -7,6 +7,10 @@
 }
 
 .fieldRow {
+  margin-bottom: 24px;;
+}
+
+.fieldRow:last-child {
   margin-bottom: 0;
 }
 
diff --git a/src/home/UnauthenticatedView.jsx b/src/home/UnauthenticatedView.jsx
index 21db6ac..b39bfc7 100644
--- a/src/home/UnauthenticatedView.jsx
+++ b/src/home/UnauthenticatedView.jsx
@@ -28,6 +28,7 @@ export function UnauthenticatedView() {
       const data = new FormData(e.target);
       const roomName = data.get("callName");
       const displayName = data.get("displayName");
+      const ptt = data.get("ptt") !== null;
 
       async function submit() {
         setError(undefined);
@@ -41,7 +42,7 @@ export function UnauthenticatedView() {
           recaptchaResponse,
           true
         );
-        const roomIdOrAlias = await createRoom(client, roomName);
+        const roomIdOrAlias = await createRoom(client, roomName, ptt);
 
         if (roomIdOrAlias) {
           history.push(`/room/${roomIdOrAlias}`);
@@ -111,6 +112,14 @@ export function UnauthenticatedView() {
                 autoComplete="off"
               />
             </FieldRow>
+            <FieldRow>
+              <InputField
+                id="ptt"
+                name="ptt"
+                label="Push to Talk"
+                type="checkbox"
+              />
+            </FieldRow>
             <Caption>
               By clicking "Go", you agree to our{" "}
               <Link href={privacyPolicyUrl}>Terms and conditions</Link>
diff --git a/src/matrix-utils.js b/src/matrix-utils.js
index 04ce3df..bef1b80 100644
--- a/src/matrix-utils.js
+++ b/src/matrix-utils.js
@@ -76,7 +76,7 @@ export function isLocalRoomId(roomId) {
   return parts[1] === defaultHomeserverHost;
 }
 
-export async function createRoom(client, name) {
+export async function createRoom(client, name, isPtt = false) {
   const { room_id, room_alias } = await client.createRoom({
     visibility: "private",
     preset: "public_chat",
@@ -107,9 +107,12 @@ export async function createRoom(client, name) {
     },
   });
 
+  console.log({ isPtt });
+
   await client.createGroupCall(
     room_id,
     GroupCallType.Video,
+    isPtt,
     GroupCallIntent.Prompt
   );
 
diff --git a/src/room/GroupCallView.jsx b/src/room/GroupCallView.jsx
index 908613f..3f21a3b 100644
--- a/src/room/GroupCallView.jsx
+++ b/src/room/GroupCallView.jsx
@@ -5,6 +5,7 @@ import { useGroupCall } from "./useGroupCall";
 import { ErrorView, FullScreenView } from "../FullScreenView";
 import { LobbyView } from "./LobbyView";
 import { InCallView } from "./InCallView";
+import { PTTCallView } from "./PTTCallView";
 import { CallEndedView } from "./CallEndedView";
 import { useSentryGroupCallHandler } from "./useSentryGroupCallHandler";
 import { useLocationNavigation } from "../useLocationNavigation";
@@ -47,6 +48,7 @@ export function GroupCallView({
     localScreenshareFeed,
     screenshareFeeds,
     hasLocalParticipant,
+    participants,
   } = useGroupCall(groupCall);
 
   useEffect(() => {
@@ -72,27 +74,46 @@ export function GroupCallView({
   if (error) {
     return <ErrorView error={error} />;
   } else if (state === GroupCallState.Entered) {
-    return (
-      <InCallView
-        groupCall={groupCall}
-        client={client}
-        roomName={groupCall.room.name}
-        microphoneMuted={microphoneMuted}
-        localVideoMuted={localVideoMuted}
-        toggleLocalVideoMuted={toggleLocalVideoMuted}
-        toggleMicrophoneMuted={toggleMicrophoneMuted}
-        userMediaFeeds={userMediaFeeds}
-        activeSpeaker={activeSpeaker}
-        onLeave={onLeave}
-        toggleScreensharing={toggleScreensharing}
-        isScreensharing={isScreensharing}
-        localScreenshareFeed={localScreenshareFeed}
-        screenshareFeeds={screenshareFeeds}
-        setShowInspector={onChangeShowInspector}
-        showInspector={showInspector}
-        roomId={roomId}
-      />
-    );
+    if (groupCall.isPtt) {
+      return (
+        <PTTCallView
+          groupCall={groupCall}
+          participants={participants}
+          client={client}
+          roomName={groupCall.room.name}
+          microphoneMuted={microphoneMuted}
+          toggleMicrophoneMuted={toggleMicrophoneMuted}
+          userMediaFeeds={userMediaFeeds}
+          activeSpeaker={activeSpeaker}
+          onLeave={onLeave}
+          setShowInspector={onChangeShowInspector}
+          showInspector={showInspector}
+          roomId={roomId}
+        />
+      );
+    } else {
+      return (
+        <InCallView
+          groupCall={groupCall}
+          client={client}
+          roomName={groupCall.room.name}
+          microphoneMuted={microphoneMuted}
+          localVideoMuted={localVideoMuted}
+          toggleLocalVideoMuted={toggleLocalVideoMuted}
+          toggleMicrophoneMuted={toggleMicrophoneMuted}
+          userMediaFeeds={userMediaFeeds}
+          activeSpeaker={activeSpeaker}
+          onLeave={onLeave}
+          toggleScreensharing={toggleScreensharing}
+          isScreensharing={isScreensharing}
+          localScreenshareFeed={localScreenshareFeed}
+          screenshareFeeds={screenshareFeeds}
+          setShowInspector={onChangeShowInspector}
+          showInspector={showInspector}
+          roomId={roomId}
+        />
+      );
+    }
   } else if (state === GroupCallState.Entering) {
     return (
       <FullScreenView>
diff --git a/src/room/PTTButton.jsx b/src/room/PTTButton.jsx
new file mode 100644
index 0000000..d943ae4
--- /dev/null
+++ b/src/room/PTTButton.jsx
@@ -0,0 +1,44 @@
+import classNames from "classnames";
+import React from "react";
+import styles from "./PTTButton.module.css";
+import { ReactComponent as MicIcon } from "../icons/Mic.svg";
+import { Avatar } from "../Avatar";
+
+export function PTTButton({ client, activeSpeaker }) {
+  const size = 232;
+
+  const isLocal = client.userId === activeSpeaker;
+  const avatarUrl = activeSpeaker?.user?.avatarUrl;
+
+  return (
+    <button
+      className={classNames(styles.pttButton, {
+        [styles.speaking]: !!activeSpeaker,
+      })}
+    >
+      {isLocal || !avatarUrl || !activeSpeaker ? (
+        <MicIcon
+          classNames={styles.micIcon}
+          width={size / 3}
+          height={size / 3}
+        />
+      ) : (
+        <Avatar
+          key={activeSpeaker.userId}
+          style={{
+            width: size,
+            height: size,
+            borderRadius: size,
+            fontSize: Math.round(size / 2),
+          }}
+          src={
+            activeSpeaker.user.avatarUrl &&
+            getAvatarUrl(client, activeSpeaker.user.avatarUrl, size)
+          }
+          fallback={activeSpeaker.name.slice(0, 1).toUpperCase()}
+          className={styles.avatar}
+        />
+      )}
+    </button>
+  );
+}
diff --git a/src/room/PTTButton.module.css b/src/room/PTTButton.module.css
new file mode 100644
index 0000000..6bfc1a1
--- /dev/null
+++ b/src/room/PTTButton.module.css
@@ -0,0 +1,11 @@
+.pttButton {
+  width: 100vw;
+  height: 100vh;
+  max-height: 232px;
+  max-width: 232px;
+  border-radius: 116px;
+  
+  color: ##fff;
+  border: 6px solid #0dbd8b;
+  background-color: #21262C;
+}
\ No newline at end of file
diff --git a/src/room/PTTCallView.jsx b/src/room/PTTCallView.jsx
new file mode 100644
index 0000000..29d90da
--- /dev/null
+++ b/src/room/PTTCallView.jsx
@@ -0,0 +1,88 @@
+import React from "react";
+import { useModalTriggerState } from "../Modal";
+import { SettingsModal } from "../settings/SettingsModal";
+import { InviteModal } from "./InviteModal";
+import { Button } from "../button";
+import { Header, LeftNav, RightNav, RoomSetupHeaderInfo } from "../Header";
+import { ReactComponent as AddUserIcon } from "../icons/AddUser.svg";
+import { ReactComponent as SettingsIcon } from "../icons/Settings.svg";
+import styles from "./PTTCallView.module.css";
+import { Facepile } from "../Facepile";
+import { PTTButton } from "./PTTButton";
+import { PTTFeed } from "./PTTFeed";
+import { useMediaHandler } from "../settings/useMediaHandler";
+
+export function PTTCallView({
+  groupCall,
+  participants,
+  client,
+  roomName,
+  microphoneMuted,
+  toggleMicrophoneMuted,
+  userMediaFeeds,
+  activeSpeaker,
+  onLeave,
+  setShowInspector,
+  showInspector,
+  roomId,
+}) {
+  const { modalState: inviteModalState, modalProps: inviteModalProps } =
+    useModalTriggerState();
+  const { modalState: settingsModalState, modalProps: settingsModalProps } =
+    useModalTriggerState();
+  const { audioOutput } = useMediaHandler();
+
+  return (
+    <div className={styles.pttCallView}>
+      <Header className={styles.header}>
+        <LeftNav>
+          <RoomSetupHeaderInfo roomName={roomName} onPress={onLeave} />
+        </LeftNav>
+        <RightNav>
+          <Button variant="secondaryHangup" onPress={onLeave}>
+            Leave
+          </Button>
+          <Button variant="icon" onPress={() => inviteModalState.open()}>
+            <AddUserIcon />
+          </Button>
+          <Button variant="icon" onPress={() => settingsModalState.open()}>
+            <SettingsIcon />
+          </Button>
+        </RightNav>
+      </Header>
+      <div className={styles.headerSeparator} />
+      <div className={styles.participants}>
+        <p>{`${participants.length} user${
+          participants.length > 1 ? "s" : ""
+        } connected`}</p>
+        <Facepile client={client} participants={participants} />
+      </div>
+      <div className={styles.center}>
+        <PTTButton
+          client={client}
+          activeSpeaker={activeSpeaker}
+          groupCall={groupCall}
+        />
+        <p className={styles.actionTip}>Press and hold spacebar to talk</p>
+        {userMediaFeeds.map((callFeed) => (
+          <PTTFeed
+            key={callFeed.userId}
+            callFeed={callFeed}
+            audioOutputDevice={audioOutput}
+          />
+        ))}
+      </div>
+
+      {settingsModalState.isOpen && (
+        <SettingsModal
+          {...settingsModalProps}
+          setShowInspector={setShowInspector}
+          showInspector={showInspector}
+        />
+      )}
+      {inviteModalState.isOpen && (
+        <InviteModal roomId={roomId} {...inviteModalProps} />
+      )}
+    </div>
+  );
+}
diff --git a/src/room/PTTCallView.module.css b/src/room/PTTCallView.module.css
new file mode 100644
index 0000000..f513b79
--- /dev/null
+++ b/src/room/PTTCallView.module.css
@@ -0,0 +1,40 @@
+.pttCallView {
+  position: relative;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  min-height: 100%;
+  position: fixed;
+  height: 100%;
+  width: 100%;
+}
+
+.headerSeparator {
+  width: calc(100% - 40px);
+  height: 1px;
+  margin: 0 20px;
+  background-color: #21262C;
+}
+
+.center {
+  width: 100%;
+  display: flex;
+  flex: 1;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+}
+
+.actionTip {
+  margin-top: 42px;
+  font-size: 17px;
+}
+
+.participants {
+  margin: 24px 20px 0 20px;
+}
+
+.participants > p {
+  color: #A9B2BC;
+  margin-bottom: 8px;
+}
\ No newline at end of file
diff --git a/src/room/PTTFeed.jsx b/src/room/PTTFeed.jsx
new file mode 100644
index 0000000..cc655c7
--- /dev/null
+++ b/src/room/PTTFeed.jsx
@@ -0,0 +1,10 @@
+import React from "react";
+import { useCallFeed } from "../video-grid/useCallFeed";
+import { useMediaStream } from "../video-grid/useMediaStream";
+import styles from "./PTTFeed.module.css";
+
+export function PTTFeed({ callFeed, audioOutputDevice }) {
+  const { isLocal, stream } = useCallFeed(callFeed);
+  const mediaRef = useMediaStream(stream, audioOutputDevice, isLocal);
+  return <audio ref={mediaRef} className={styles.audioFeed} playsInline />;
+}
diff --git a/src/room/PTTFeed.module.css b/src/room/PTTFeed.module.css
new file mode 100644
index 0000000..d7237c3
--- /dev/null
+++ b/src/room/PTTFeed.module.css
@@ -0,0 +1,3 @@
+.audioFeed {
+  display: none;
+}
\ No newline at end of file