diff --git a/.env b/.env
index 1ea63ac..2785e05 100644
--- a/.env
+++ b/.env
@@ -7,9 +7,6 @@
# Used for determining the homeserver to use for short urls etc.
# VITE_DEFAULT_HOMESERVER=http://localhost:8008
-# The room id for the space to use for listing public group call rooms
-# VITE_PUBLIC_SPACE_ROOM_ID=!hjdfshkdskjdsk:myhomeserver.com
-
# The Sentry DSN to use for error reporting. Leave undefined to disable.
# VITE_SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0
diff --git a/.storybook/main.js b/.storybook/main.js
new file mode 100644
index 0000000..54b5050
--- /dev/null
+++ b/.storybook/main.js
@@ -0,0 +1,14 @@
+const svgrPlugin = require("vite-plugin-svgr");
+
+module.exports = {
+ stories: ["../src/**/*.stories.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"],
+ addons: ["@storybook/addon-links", "@storybook/addon-essentials"],
+ framework: "@storybook/react",
+ core: {
+ builder: "storybook-builder-vite",
+ },
+ async viteFinal(config) {
+ config.plugins.push(svgrPlugin());
+ return config;
+ },
+};
diff --git a/.storybook/preview.jsx b/.storybook/preview.jsx
new file mode 100644
index 0000000..9fda6df
--- /dev/null
+++ b/.storybook/preview.jsx
@@ -0,0 +1,25 @@
+import React from "react";
+import { addDecorator } from "@storybook/react";
+import { MemoryRouter } from "react-router-dom";
+import { usePageFocusStyle } from "../src/usePageFocusStyle";
+import { OverlayProvider } from "@react-aria/overlays";
+import "../src/index.css";
+
+export const parameters = {
+ actions: { argTypesRegex: "^on[A-Z].*" },
+ controls: {
+ matchers: {
+ color: /(background|color)$/i,
+ date: /Date$/,
+ },
+ },
+};
+
+addDecorator((story) => {
+ usePageFocusStyle();
+ return (
+
+ {story()}
+
+ );
+});
diff --git a/package.json b/package.json
index 4d4198e..04bb997 100644
--- a/package.json
+++ b/package.json
@@ -3,7 +3,9 @@
"scripts": {
"dev": "vite",
"build": "vite build",
- "serve": "vite preview"
+ "serve": "vite preview",
+ "storybook": "start-storybook -p 6006",
+ "build-storybook": "build-storybook"
},
"dependencies": {
"@react-aria/button": "^3.3.4",
@@ -27,6 +29,7 @@
"events": "^3.3.0",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#robertlong/group-call",
"matrix-react-sdk": "github:matrix-org/matrix-react-sdk#robertlong/group-call",
+ "normalize.css": "^8.0.1",
"postcss-preset-env": "^6.7.0",
"re-resizable": "^6.9.0",
"react": "^17.0.0",
@@ -37,7 +40,14 @@
"react-use-clipboard": "^1.0.7"
},
"devDependencies": {
+ "@babel/core": "^7.16.5",
+ "@storybook/addon-actions": "^6.5.0-alpha.5",
+ "@storybook/addon-essentials": "^6.5.0-alpha.5",
+ "@storybook/addon-links": "^6.5.0-alpha.5",
+ "@storybook/react": "^6.5.0-alpha.5",
+ "babel-loader": "^8.2.3",
"sass": "^1.42.1",
+ "storybook-builder-vite": "^0.1.12",
"vite": "^2.4.2",
"vite-plugin-svgr": "^0.4.0"
}
diff --git a/src/App.jsx b/src/App.jsx
index d9fb5ee..5dea73e 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -14,47 +14,22 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-import React, { useEffect, useState } from "react";
-import {
- BrowserRouter as Router,
- Switch,
- Route,
- useLocation,
- useHistory,
-} from "react-router-dom";
+import React from "react";
+import { BrowserRouter as Router, Switch, Route } from "react-router-dom";
import * as Sentry from "@sentry/react";
import { OverlayProvider } from "@react-aria/overlays";
-import { Home } from "./Home";
-import { LoginPage } from "./LoginPage";
-import { RegisterPage } from "./RegisterPage";
-import { Room } from "./Room";
-import {
- ClientProvider,
- defaultHomeserverHost,
-} from "./ConferenceCallManagerHooks";
-import { useFocusVisible } from "@react-aria/interactions";
-import styles from "./App.module.css";
-import { LoadingView } from "./FullScreenView";
+import { HomePage } from "./home/HomePage";
+import { LoginPage } from "./auth/LoginPage";
+import { RegisterPage } from "./auth/RegisterPage";
+import { RoomPage } from "./room/RoomPage";
+import { RoomRedirect } from "./room/RoomRedirect";
+import { ClientProvider } from "./ClientContext";
+import { usePageFocusStyle } from "./usePageFocusStyle";
const SentryRoute = Sentry.withSentryRouting(Route);
export default function App({ history }) {
- const { isFocusVisible } = useFocusVisible();
-
- useEffect(() => {
- const classList = document.body.classList;
- const hasClass = classList.contains(styles.hideFocus);
-
- if (isFocusVisible && hasClass) {
- classList.remove(styles.hideFocus);
- } else if (!isFocusVisible && !hasClass) {
- classList.add(styles.hideFocus);
- }
-
- return () => {
- classList.remove(styles.hideFocus);
- };
- }, [isFocusVisible]);
+ usePageFocusStyle();
return (
@@ -62,7 +37,7 @@ export default function App({ history }) {
-
+
@@ -71,7 +46,7 @@ export default function App({ history }) {
-
+
@@ -82,24 +57,3 @@ export default function App({ history }) {
);
}
-
-function RoomRedirect() {
- const { pathname } = useLocation();
- const history = useHistory();
-
- useEffect(() => {
- let roomId = pathname;
-
- if (pathname.startsWith("/")) {
- roomId = roomId.substr(1, roomId.length);
- }
-
- if (!roomId.startsWith("#") && !roomId.startsWith("!")) {
- roomId = `#${roomId}:${defaultHomeserverHost}`;
- }
-
- history.replace(`/room/${roomId}`);
- }, [pathname, history]);
-
- return ;
-}
diff --git a/src/ClientContext.jsx b/src/ClientContext.jsx
new file mode 100644
index 0000000..2034629
--- /dev/null
+++ b/src/ClientContext.jsx
@@ -0,0 +1,205 @@
+/*
+Copyright 2021 New Vector Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React, {
+ useCallback,
+ useEffect,
+ useState,
+ createContext,
+ useMemo,
+ useContext,
+} from "react";
+import { useHistory } from "react-router-dom";
+import { initClient, defaultHomeserver } from "./matrix-utils";
+
+const ClientContext = createContext();
+
+export function ClientProvider({ children }) {
+ const history = useHistory();
+ const [
+ { loading, isAuthenticated, isPasswordlessUser, client, userName },
+ setState,
+ ] = useState({
+ loading: true,
+ isAuthenticated: false,
+ isPasswordlessUser: false,
+ client: undefined,
+ userName: null,
+ });
+
+ useEffect(() => {
+ async function restore() {
+ try {
+ const authStore = localStorage.getItem("matrix-auth-store");
+
+ if (authStore) {
+ const {
+ user_id,
+ device_id,
+ access_token,
+ passwordlessUser,
+ tempPassword,
+ } = JSON.parse(authStore);
+
+ const client = await initClient({
+ baseUrl: defaultHomeserver,
+ accessToken: access_token,
+ userId: user_id,
+ deviceId: device_id,
+ });
+
+ localStorage.setItem(
+ "matrix-auth-store",
+ JSON.stringify({
+ user_id,
+ device_id,
+ access_token,
+
+ passwordlessUser,
+ tempPassword,
+ })
+ );
+
+ return { client, passwordlessUser };
+ }
+
+ return { client: undefined };
+ } catch (err) {
+ console.error(err);
+ localStorage.removeItem("matrix-auth-store");
+ throw err;
+ }
+ }
+
+ restore()
+ .then(({ client, passwordlessUser }) => {
+ setState({
+ client,
+ loading: false,
+ isAuthenticated: !!client,
+ isPasswordlessUser: !!passwordlessUser,
+ userName: client?.getUserIdLocalpart(),
+ });
+ })
+ .catch(() => {
+ setState({
+ client: undefined,
+ loading: false,
+ isAuthenticated: false,
+ isPasswordlessUser: false,
+ userName: null,
+ });
+ });
+ }, []);
+
+ const changePassword = useCallback(
+ async (password) => {
+ const { tempPassword, passwordlessUser, ...existingSession } = JSON.parse(
+ localStorage.getItem("matrix-auth-store")
+ );
+
+ await client.setPassword(
+ {
+ type: "m.login.password",
+ identifier: {
+ type: "m.id.user",
+ user: existingSession.user_id,
+ },
+ user: existingSession.user_id,
+ password: tempPassword,
+ },
+ password
+ );
+
+ localStorage.setItem(
+ "matrix-auth-store",
+ JSON.stringify({
+ ...existingSession,
+ passwordlessUser: false,
+ })
+ );
+
+ setState({
+ client,
+ loading: false,
+ isAuthenticated: true,
+ isPasswordlessUser: false,
+ userName: client.getUserIdLocalpart(),
+ });
+ },
+ [client]
+ );
+
+ const setClient = useCallback((client, session) => {
+ if (client) {
+ localStorage.setItem("matrix-auth-store", JSON.stringify(session));
+
+ setState({
+ client,
+ loading: false,
+ isAuthenticated: true,
+ isPasswordlessUser: !!session.passwordlessUser,
+ userName: client.getUserIdLocalpart(),
+ });
+ } else {
+ localStorage.removeItem("matrix-auth-store");
+
+ setState({
+ client: undefined,
+ loading: false,
+ isAuthenticated: false,
+ isPasswordlessUser: false,
+ userName: null,
+ });
+ }
+ }, []);
+
+ const logout = useCallback(() => {
+ localStorage.removeItem("matrix-auth-store");
+ window.location = "/";
+ }, [history]);
+
+ const context = useMemo(
+ () => ({
+ loading,
+ isAuthenticated,
+ isPasswordlessUser,
+ client,
+ changePassword,
+ logout,
+ userName,
+ setClient,
+ }),
+ [
+ loading,
+ isAuthenticated,
+ isPasswordlessUser,
+ client,
+ changePassword,
+ logout,
+ userName,
+ setClient,
+ ]
+ );
+
+ return (
+ {children}
+ );
+}
+
+export function useClient() {
+ return useContext(ClientContext);
+}
diff --git a/src/ConferenceCallDebugger.js b/src/ConferenceCallDebugger.js
deleted file mode 100644
index c9317e5..0000000
--- a/src/ConferenceCallDebugger.js
+++ /dev/null
@@ -1,437 +0,0 @@
-/*
-Copyright 2021 New Vector Ltd
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-import EventEmitter from "events";
-
-export class ConferenceCallDebugger extends EventEmitter {
- constructor(client, groupCall) {
- super();
-
- this.client = client;
- this.groupCall = groupCall;
-
- this.debugState = {
- users: new Map(),
- calls: new Map(),
- };
-
- this.bufferedEvents = [];
-
- client.on("event", this._onEvent);
- groupCall.on("call", this._onCall);
- groupCall.on("debugstate", this._onDebugStateChanged);
- groupCall.on("entered", this._onEntered);
- groupCall.on("left", this._onLeft);
- }
-
- _onEntered = () => {
- const eventCount = this.bufferedEvents.length;
-
- for (let i = 0; i < eventCount; i++) {
- const event = this.bufferedEvents.pop();
- this._onEvent(event);
- }
- };
-
- _onLeft = () => {
- this.bufferedEvents = [];
- this.debugState = {
- users: new Map(),
- calls: new Map(),
- };
- this.emit("debug");
- };
-
- _onEvent = (event) => {
- if (!this.groupCall.entered) {
- this.bufferedEvents.push(event);
- return;
- }
-
- const roomId = event.getRoomId();
- const type = event.getType();
-
- if (
- roomId === this.groupCall.room.roomId &&
- (type.startsWith("m.call.") ||
- type === "me.robertlong.call.info" ||
- type === "m.room.member")
- ) {
- const sender = event.getSender();
- const { call_id } = event.getContent();
-
- if (call_id) {
- if (this.debugState.calls.has(call_id)) {
- const callState = this.debugState.calls.get(call_id);
- callState.events.push(event);
- } else {
- this.debugState.calls.set(call_id, {
- state: "unknown",
- events: [event],
- });
- }
- }
-
- if (this.debugState.users.has(sender)) {
- const userState = this.debugState.users.get(sender);
- userState.events.push(event);
- } else {
- this.debugState.users.set(sender, {
- state: "unknown",
- events: [event],
- });
- }
-
- this.emit("debug");
- }
- };
-
- _onDebugStateChanged = (userId, callId, state) => {
- if (userId) {
- const userState = this.debugState.users.get(userId);
-
- if (userState) {
- userState.state = state;
- } else {
- this.debugState.users.set(userId, {
- state,
- events: [],
- });
- }
- }
-
- if (callId) {
- const callState = this.debugState.calls.get(callId);
-
- if (callState) {
- callState.state = state;
- } else {
- this.debugState.calls.set(callId, {
- state,
- events: [],
- });
- }
- }
-
- this.emit("debug");
- };
-
- _onCall = (call) => {
- const peerConnection = call.peerConn;
-
- if (!peerConnection) {
- return;
- }
-
- const sendWebRTCInfoEvent = async (eventType) => {
- const event = {
- call_id: call.callId,
- eventType,
- iceConnectionState: peerConnection.iceConnectionState,
- iceGatheringState: peerConnection.iceGatheringState,
- signalingState: peerConnection.signalingState,
- selectedCandidatePair: null,
- localCandidate: null,
- remoteCandidate: null,
- };
-
- // getStats doesn't support selectors in Firefox so get all stats by passing null.
- // https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/getStats#browser_compatibility
- const stats = await peerConnection.getStats(null);
-
- const statsArr = Array.from(stats.values());
-
- // Matrix doesn't support floats so we convert time in seconds to ms
- function secToMs(time) {
- if (time === undefined) {
- return undefined;
- }
-
- return Math.round(time * 1000);
- }
-
- function processTransportStats(transportStats) {
- if (!transportStats) {
- return undefined;
- }
-
- return {
- packetsSent: transportStats.packetsSent,
- packetsReceived: transportStats.packetsReceived,
- bytesSent: transportStats.bytesSent,
- bytesReceived: transportStats.bytesReceived,
- iceRole: transportStats.iceRole,
- iceState: transportStats.iceState,
- dtlsState: transportStats.dtlsState,
- dtlsCipher: transportStats.dtlsCipher,
- tlsVersion: transportStats.tlsVersion,
- };
- }
-
- function processCandidateStats(candidateStats) {
- if (!candidateStats) {
- return undefined;
- }
-
- // TODO: Figure out how to normalize ip and address across browsers
- // networkType property excluded for privacy reasons:
- // https://www.w3.org/TR/webrtc-stats/#sotd
- return {
- priority:
- candidateStats.priority && candidateStats.priority.toString(),
- candidateType: candidateStats.candidateType,
- protocol: candidateStats.protocol,
- address: !!candidateStats.address
- ? candidateStats.address
- : candidateStats.ip,
- port: candidateStats.port,
- url: candidateStats.url,
- relayProtocol: candidateStats.relayProtocol,
- };
- }
-
- function processCandidatePair(candidatePairStats) {
- if (!candidatePairStats) {
- return undefined;
- }
-
- const localCandidateStats = statsArr.find(
- (stat) => stat.id === candidatePairStats.localCandidateId
- );
- event.localCandidate = processCandidateStats(localCandidateStats);
-
- const remoteCandidateStats = statsArr.find(
- (stat) => stat.id === candidatePairStats.remoteCandidateId
- );
- event.remoteCandidate = processCandidateStats(remoteCandidateStats);
-
- const transportStats = statsArr.find(
- (stat) => stat.id === candidatePairStats.transportId
- );
- event.transport = processTransportStats(transportStats);
-
- return {
- state: candidatePairStats.state,
- bytesSent: candidatePairStats.bytesSent,
- bytesReceived: candidatePairStats.bytesReceived,
- requestsSent: candidatePairStats.requestsSent,
- requestsReceived: candidatePairStats.requestsReceived,
- responsesSent: candidatePairStats.responsesSent,
- responsesReceived: candidatePairStats.responsesReceived,
- currentRoundTripTime: secToMs(
- candidatePairStats.currentRoundTripTime
- ),
- totalRoundTripTime: secToMs(candidatePairStats.totalRoundTripTime),
- };
- }
-
- // Firefox uses the deprecated "selected" property for the nominated ice candidate.
- const selectedCandidatePair = statsArr.find(
- (stat) =>
- stat.type === "candidate-pair" && (stat.selected || stat.nominated)
- );
-
- event.selectedCandidatePair = processCandidatePair(selectedCandidatePair);
-
- function processCodecStats(codecStats) {
- if (!codecStats) {
- return undefined;
- }
-
- // Payload type enums and MIME types listed here:
- // https://www.iana.org/assignments/rtp-parameters/rtp-parameters.xhtml
- return {
- mimeType: codecStats.mimeType,
- clockRate: codecStats.clockRate,
- payloadType: codecStats.payloadType,
- channels: codecStats.channels,
- sdpFmtpLine: codecStats.sdpFmtpLine,
- };
- }
-
- function processRTPStreamStats(rtpStreamStats) {
- const codecStats = statsArr.find(
- (stat) => stat.id === rtpStreamStats.codecId
- );
- const codec = processCodecStats(codecStats);
-
- return {
- kind: rtpStreamStats.kind,
- codec,
- };
- }
-
- function processInboundRTPStats(inboundRTPStats) {
- const rtpStreamStats = processRTPStreamStats(inboundRTPStats);
-
- return {
- ...rtpStreamStats,
- decoderImplementation: inboundRTPStats.decoderImplementation,
- bytesReceived: inboundRTPStats.bytesReceived,
- packetsReceived: inboundRTPStats.packetsReceived,
- packetsLost: inboundRTPStats.packetsLost,
- jitter: secToMs(inboundRTPStats.jitter),
- frameWidth: inboundRTPStats.frameWidth,
- frameHeight: inboundRTPStats.frameHeight,
- frameBitDepth: inboundRTPStats.frameBitDepth,
- framesPerSecond:
- inboundRTPStats.framesPerSecond &&
- inboundRTPStats.framesPerSecond.toString(),
- framesReceived: inboundRTPStats.framesReceived,
- framesDecoded: inboundRTPStats.framesDecoded,
- framesDropped: inboundRTPStats.framesDropped,
- totalSamplesDecoded: inboundRTPStats.totalSamplesDecoded,
- totalDecodeTime: secToMs(inboundRTPStats.totalDecodeTime),
- totalProcessingDelay: secToMs(inboundRTPStats.totalProcessingDelay),
- };
- }
-
- function processOutboundRTPStats(outboundRTPStats) {
- const rtpStreamStats = processRTPStreamStats(outboundRTPStats);
-
- return {
- ...rtpStreamStats,
- encoderImplementation: outboundRTPStats.encoderImplementation,
- bytesSent: outboundRTPStats.bytesSent,
- packetsSent: outboundRTPStats.packetsSent,
- frameWidth: outboundRTPStats.frameWidth,
- frameHeight: outboundRTPStats.frameHeight,
- frameBitDepth: outboundRTPStats.frameBitDepth,
- framesPerSecond:
- outboundRTPStats.framesPerSecond &&
- outboundRTPStats.framesPerSecond.toString(),
- framesSent: outboundRTPStats.framesSent,
- framesEncoded: outboundRTPStats.framesEncoded,
- qualityLimitationReason: outboundRTPStats.qualityLimitationReason,
- qualityLimitationResolutionChanges:
- outboundRTPStats.qualityLimitationResolutionChanges,
- totalEncodeTime: secToMs(outboundRTPStats.totalEncodeTime),
- totalPacketSendDelay: secToMs(outboundRTPStats.totalPacketSendDelay),
- };
- }
-
- function processRemoteInboundRTPStats(remoteInboundRTPStats) {
- const rtpStreamStats = processRTPStreamStats(remoteInboundRTPStats);
-
- return {
- ...rtpStreamStats,
- packetsReceived: remoteInboundRTPStats.packetsReceived,
- packetsLost: remoteInboundRTPStats.packetsLost,
- jitter: secToMs(remoteInboundRTPStats.jitter),
- framesDropped: remoteInboundRTPStats.framesDropped,
- roundTripTime: secToMs(remoteInboundRTPStats.roundTripTime),
- totalRoundTripTime: secToMs(remoteInboundRTPStats.totalRoundTripTime),
- fractionLost:
- remoteInboundRTPStats.fractionLost !== undefined &&
- remoteInboundRTPStats.fractionLost.toString(),
- reportsReceived: remoteInboundRTPStats.reportsReceived,
- roundTripTimeMeasurements:
- remoteInboundRTPStats.roundTripTimeMeasurements,
- };
- }
-
- function processRemoteOutboundRTPStats(remoteOutboundRTPStats) {
- const rtpStreamStats = processRTPStreamStats(remoteOutboundRTPStats);
-
- return {
- ...rtpStreamStats,
- encoderImplementation: remoteOutboundRTPStats.encoderImplementation,
- bytesSent: remoteOutboundRTPStats.bytesSent,
- packetsSent: remoteOutboundRTPStats.packetsSent,
- roundTripTime: secToMs(remoteOutboundRTPStats.roundTripTime),
- totalRoundTripTime: secToMs(
- remoteOutboundRTPStats.totalRoundTripTime
- ),
- reportsSent: remoteOutboundRTPStats.reportsSent,
- roundTripTimeMeasurements:
- remoteOutboundRTPStats.roundTripTimeMeasurements,
- };
- }
-
- event.inboundRTP = statsArr
- .filter((stat) => stat.type === "inbound-rtp")
- .map(processInboundRTPStats);
-
- event.outboundRTP = statsArr
- .filter((stat) => stat.type === "outbound-rtp")
- .map(processOutboundRTPStats);
-
- event.remoteInboundRTP = statsArr
- .filter((stat) => stat.type === "remote-inbound-rtp")
- .map(processRemoteInboundRTPStats);
-
- event.remoteOutboundRTP = statsArr
- .filter((stat) => stat.type === "remote-outbound-rtp")
- .map(processRemoteOutboundRTPStats);
-
- this.client.sendEvent(
- this.groupCall.room.roomId,
- "me.robertlong.call.info",
- event
- );
- };
-
- let statsTimeout;
-
- const sendStats = () => {
- if (
- call.state === "ended" ||
- peerConnection.connectionState === "closed"
- ) {
- clearTimeout(statsTimeout);
- return;
- }
-
- sendWebRTCInfoEvent("stats");
- statsTimeout = setTimeout(sendStats, 30 * 1000);
- };
-
- setTimeout(sendStats, 30 * 1000);
-
- peerConnection.addEventListener("iceconnectionstatechange", () => {
- sendWebRTCInfoEvent("iceconnectionstatechange");
- });
- peerConnection.addEventListener("icegatheringstatechange", () => {
- sendWebRTCInfoEvent("icegatheringstatechange");
- });
- peerConnection.addEventListener("negotiationneeded", () => {
- sendWebRTCInfoEvent("negotiationneeded");
- });
- peerConnection.addEventListener("track", () => {
- sendWebRTCInfoEvent("track");
- });
- // NOTE: Not available on Firefox
- // https://bugzilla.mozilla.org/show_bug.cgi?id=1561441
- peerConnection.addEventListener(
- "icecandidateerror",
- ({ errorCode, url, errorText }) => {
- this.client.sendEvent(
- this.groupCall.room.roomId,
- "me.robertlong.call.ice_error",
- {
- call_id: call.callId,
- errorCode,
- url,
- errorText,
- }
- );
- }
- );
- peerConnection.addEventListener("signalingstatechange", () => {
- sendWebRTCInfoEvent("signalingstatechange");
- });
- };
-}
diff --git a/src/ConferenceCallManagerHooks.jsx b/src/ConferenceCallManagerHooks.jsx
deleted file mode 100644
index a433d14..0000000
--- a/src/ConferenceCallManagerHooks.jsx
+++ /dev/null
@@ -1,872 +0,0 @@
-/*
-Copyright 2021 New Vector Ltd
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-import React, {
- useCallback,
- useEffect,
- useState,
- createContext,
- useMemo,
- useContext,
- useRef,
-} from "react";
-import matrix, { InteractiveAuth } from "matrix-js-sdk/src/browser-index";
-import {
- GroupCallIntent,
- GroupCallType,
-} from "matrix-js-sdk/src/browser-index";
-import { useHistory } from "react-router-dom";
-
-export const defaultHomeserver =
- import.meta.env.VITE_DEFAULT_HOMESERVER ||
- `${window.location.protocol}//${window.location.host}`;
-
-export const defaultHomeserverHost = new URL(defaultHomeserver).host;
-
-const ClientContext = createContext();
-
-function waitForSync(client) {
- return new Promise((resolve, reject) => {
- const onSync = (state, _old, data) => {
- if (state === "PREPARED") {
- resolve();
- client.removeListener("sync", onSync);
- } else if (state === "ERROR") {
- reject(data?.error);
- client.removeListener("sync", onSync);
- }
- };
- client.on("sync", onSync);
- });
-}
-
-async function initClient(clientOptions, guest) {
- const client = matrix.createClient(clientOptions);
-
- if (guest) {
- client.setGuest(true);
- }
-
- await client.startClient({
- // dirty hack to reduce chance of gappy syncs
- // should be fixed by spotting gaps and backpaginating
- initialSyncLimit: 50,
- });
-
- await waitForSync(client);
-
- return client;
-}
-
-export async function fetchGroupCall(
- client,
- roomIdOrAlias,
- viaServers = undefined,
- timeout = 5000
-) {
- const { roomId } = await client.joinRoom(roomIdOrAlias, { viaServers });
-
- return new Promise((resolve, reject) => {
- let timeoutId;
-
- function onGroupCallIncoming(groupCall) {
- if (groupCall && groupCall.room.roomId === roomId) {
- clearTimeout(timeoutId);
- client.removeListener("GroupCall.incoming", onGroupCallIncoming);
- resolve(groupCall);
- }
- }
-
- const groupCall = client.getGroupCallForRoom(roomId);
-
- if (groupCall) {
- resolve(groupCall);
- }
-
- client.on("GroupCall.incoming", onGroupCallIncoming);
-
- if (timeout) {
- timeoutId = setTimeout(() => {
- client.removeListener("GroupCall.incoming", onGroupCallIncoming);
- reject(new Error("Fetching group call timed out."));
- }, timeout);
- }
- });
-}
-
-export function ClientProvider({ children }) {
- const history = useHistory();
- const [
- { loading, isAuthenticated, isPasswordlessUser, isGuest, client, userName },
- setState,
- ] = useState({
- loading: true,
- isAuthenticated: false,
- isPasswordlessUser: false,
- isGuest: false,
- client: undefined,
- userName: null,
- });
-
- useEffect(() => {
- async function restore() {
- try {
- const authStore = localStorage.getItem("matrix-auth-store");
-
- if (authStore) {
- const {
- user_id,
- device_id,
- access_token,
- guest,
- passwordlessUser,
- tempPassword,
- } = JSON.parse(authStore);
-
- const client = await initClient(
- {
- baseUrl: defaultHomeserver,
- accessToken: access_token,
- userId: user_id,
- deviceId: device_id,
- },
- guest
- );
-
- localStorage.setItem(
- "matrix-auth-store",
- JSON.stringify({
- user_id,
- device_id,
- access_token,
- guest,
- passwordlessUser,
- tempPassword,
- })
- );
-
- return { client, guest, passwordlessUser };
- }
-
- return { client: undefined, guest: false };
- } catch (err) {
- localStorage.removeItem("matrix-auth-store");
- throw err;
- }
- }
-
- restore()
- .then(({ client, guest, passwordlessUser }) => {
- setState({
- client,
- loading: false,
- isAuthenticated: !!client,
- isPasswordlessUser: !!passwordlessUser,
- isGuest: guest,
- userName: client?.getUserIdLocalpart(),
- });
- })
- .catch(() => {
- setState({
- client: undefined,
- loading: false,
- isAuthenticated: false,
- isPasswordlessUser: false,
- isGuest: false,
- userName: null,
- });
- });
- }, []);
-
- const login = useCallback(async (homeserver, username, password) => {
- try {
- let loginHomeserverUrl = homeserver.trim();
-
- if (!loginHomeserverUrl.includes("://")) {
- loginHomeserverUrl = "https://" + loginHomeserverUrl;
- }
-
- try {
- const wellKnownUrl = new URL(
- "/.well-known/matrix/client",
- window.location
- );
- const response = await fetch(wellKnownUrl);
- const config = await response.json();
-
- if (config["m.homeserver"]) {
- loginHomeserverUrl = config["m.homeserver"];
- }
- } catch (error) {}
-
- const registrationClient = matrix.createClient(loginHomeserverUrl);
-
- const { user_id, device_id, access_token } =
- await registrationClient.loginWithPassword(username, password);
-
- const client = await initClient({
- baseUrl: loginHomeserverUrl,
- accessToken: access_token,
- userId: user_id,
- deviceId: device_id,
- });
-
- localStorage.setItem(
- "matrix-auth-store",
- JSON.stringify({ user_id, device_id, access_token })
- );
-
- setState({
- client,
- loading: false,
- isAuthenticated: true,
- isPasswordlessUser: false,
- isGuest: false,
- userName: client.getUserIdLocalpart(),
- });
- } catch (err) {
- localStorage.removeItem("matrix-auth-store");
- setState({
- client: undefined,
- loading: false,
- isAuthenticated: false,
- isPasswordlessUser: false,
- isGuest: false,
- userName: null,
- });
- throw err;
- }
- }, []);
-
- const registerGuest = useCallback(async () => {
- try {
- const registrationClient = matrix.createClient(defaultHomeserver);
-
- const { user_id, device_id, access_token } =
- await registrationClient.registerGuest({});
-
- const client = await initClient(
- {
- baseUrl: defaultHomeserver,
- accessToken: access_token,
- userId: user_id,
- deviceId: device_id,
- },
- true
- );
-
- await client.setProfileInfo("displayname", {
- displayname: `Guest ${client.getUserIdLocalpart()}`,
- });
-
- localStorage.setItem(
- "matrix-auth-store",
- JSON.stringify({ user_id, device_id, access_token, guest: true })
- );
-
- setState({
- client,
- loading: false,
- isAuthenticated: true,
- isGuest: true,
- isPasswordlessUser: false,
- userName: client.getUserIdLocalpart(),
- });
- } catch (err) {
- localStorage.removeItem("matrix-auth-store");
- setState({
- client: undefined,
- loading: false,
- isAuthenticated: false,
- isPasswordlessUser: false,
- isGuest: false,
- userName: null,
- });
- throw err;
- }
- }, []);
-
- const register = useCallback(async (username, password, passwordlessUser) => {
- try {
- const registrationClient = matrix.createClient(defaultHomeserver);
-
- const { user_id, device_id, access_token } =
- await registrationClient.register(username, password, null, {
- type: "m.login.dummy",
- });
-
- const client = await initClient({
- baseUrl: defaultHomeserver,
- accessToken: access_token,
- userId: user_id,
- deviceId: device_id,
- });
-
- const session = { user_id, device_id, access_token, passwordlessUser };
-
- if (passwordlessUser) {
- session.tempPassword = password;
- }
-
- localStorage.setItem("matrix-auth-store", JSON.stringify(session));
-
- setState({
- client,
- loading: false,
- isGuest: false,
- isAuthenticated: true,
- isPasswordlessUser: passwordlessUser,
- userName: client.getUserIdLocalpart(),
- });
-
- return client;
- } catch (err) {
- localStorage.removeItem("matrix-auth-store");
- setState({
- client: undefined,
- loading: false,
- isGuest: false,
- isAuthenticated: false,
- isPasswordlessUser: false,
- userName: null,
- });
- throw err;
- }
- }, []);
-
- const changePassword = useCallback(
- async (password) => {
- const { tempPassword, passwordlessUser, ...existingSession } = JSON.parse(
- localStorage.getItem("matrix-auth-store")
- );
-
- await client.setPassword(
- {
- type: "m.login.password",
- identifier: {
- type: "m.id.user",
- user: existingSession.user_id,
- },
- user: existingSession.user_id,
- password: tempPassword,
- },
- password
- );
-
- localStorage.setItem(
- "matrix-auth-store",
- JSON.stringify({
- ...existingSession,
- passwordlessUser: false,
- })
- );
-
- setState({
- client,
- loading: false,
- isGuest: false,
- isAuthenticated: true,
- isPasswordlessUser: false,
- userName: client.getUserIdLocalpart(),
- });
- },
- [client]
- );
-
- const setClient = useCallback((client, session) => {
- if (client) {
- localStorage.setItem("matrix-auth-store", JSON.stringify(session));
-
- setState({
- client,
- loading: false,
- isAuthenticated: true,
- isPasswordlessUser: !!session.passwordlessUser,
- isGuest: false,
- userName: client.getUserIdLocalpart(),
- });
- } else {
- localStorage.removeItem("matrix-auth-store");
-
- setState({
- client: undefined,
- loading: false,
- isAuthenticated: false,
- isPasswordlessUser: false,
- isGuest: false,
- userName: null,
- });
- }
- }, []);
-
- const logout = useCallback(() => {
- localStorage.removeItem("matrix-auth-store");
- window.location = "/";
- }, [history]);
-
- const context = useMemo(
- () => ({
- loading,
- isAuthenticated,
- isPasswordlessUser,
- isGuest,
- client,
- login,
- registerGuest,
- register,
- changePassword,
- logout,
- userName,
- setClient,
- }),
- [
- loading,
- isAuthenticated,
- isPasswordlessUser,
- isGuest,
- client,
- login,
- registerGuest,
- register,
- changePassword,
- logout,
- userName,
- setClient,
- ]
- );
-
- return (
- {children}
- );
-}
-
-export function useClient() {
- return useContext(ClientContext);
-}
-
-export function roomAliasFromRoomName(roomName) {
- return roomName
- .trim()
- .replace(/\s/g, "-")
- .replace(/[^\w-]/g, "")
- .toLowerCase();
-}
-
-export async function createRoom(client, name) {
- const { room_id, room_alias } = await client.createRoom({
- visibility: "private",
- preset: "public_chat",
- name,
- room_alias_name: roomAliasFromRoomName(name),
- power_level_content_override: {
- invite: 100,
- kick: 100,
- ban: 100,
- redact: 50,
- state_default: 0,
- events_default: 0,
- users_default: 0,
- events: {
- "m.room.power_levels": 100,
- "m.room.history_visibility": 100,
- "m.room.tombstone": 100,
- "m.room.encryption": 100,
- "m.room.name": 50,
- "m.room.message": 0,
- "m.room.encrypted": 50,
- "m.sticker": 50,
- "org.matrix.msc3401.call.member": 0,
- },
- users: {
- [client.getUserId()]: 100,
- },
- },
- });
-
- await client.setGuestAccess(room_id, {
- allowJoin: true,
- allowRead: true,
- });
-
- await client.createGroupCall(
- room_id,
- GroupCallType.Video,
- GroupCallIntent.Prompt
- );
-
- return room_alias || room_id;
-}
-
-export function useLoadGroupCall(client, roomId, viaServers) {
- const [state, setState] = useState({
- loading: true,
- error: undefined,
- groupCall: undefined,
- });
-
- useEffect(() => {
- setState({ loading: true });
- fetchGroupCall(client, roomId, viaServers, 30000)
- .then((groupCall) => setState({ loading: false, groupCall }))
- .catch((error) => setState({ loading: false, error }));
- }, [client, roomId]);
-
- return state;
-}
-
-const tsCache = {};
-
-function getLastTs(client, r) {
- if (tsCache[r.roomId]) {
- return tsCache[r.roomId];
- }
-
- if (!r || !r.timeline) {
- const ts = Number.MAX_SAFE_INTEGER;
- tsCache[r.roomId] = ts;
- return ts;
- }
-
- const myUserId = client.getUserId();
-
- if (r.getMyMembership() !== "join") {
- const membershipEvent = r.currentState.getStateEvents(
- "m.room.member",
- myUserId
- );
-
- if (membershipEvent && !Array.isArray(membershipEvent)) {
- const ts = membershipEvent.getTs();
- tsCache[r.roomId] = ts;
- return ts;
- }
- }
-
- for (let i = r.timeline.length - 1; i >= 0; --i) {
- const ev = r.timeline[i];
- const ts = ev.getTs();
-
- if (ts) {
- tsCache[r.roomId] = ts;
- return ts;
- }
- }
-
- const ts = Number.MAX_SAFE_INTEGER;
- tsCache[r.roomId] = ts;
- return ts;
-}
-
-function sortRooms(client, rooms) {
- return rooms.sort((a, b) => {
- return getLastTs(client, b) - getLastTs(client, a);
- });
-}
-
-export function useGroupCallRooms(client) {
- const [rooms, setRooms] = useState([]);
-
- useEffect(() => {
- function updateRooms() {
- const groupCalls = client.groupCallEventHandler.groupCalls.values();
- const rooms = Array.from(groupCalls).map((groupCall) => groupCall.room);
- const sortedRooms = sortRooms(client, rooms);
- const items = sortedRooms.map((room) => {
- const groupCall = client.getGroupCallForRoom(room.roomId);
-
- return {
- roomId: room.getCanonicalAlias() || room.roomId,
- roomName: room.name,
- avatarUrl: null,
- room,
- groupCall,
- participants: [...groupCall.participants],
- };
- });
- setRooms(items);
- }
-
- updateRooms();
-
- client.on("GroupCall.incoming", updateRooms);
- client.on("GroupCall.participants", updateRooms);
-
- return () => {
- client.removeListener("GroupCall.incoming", updateRooms);
- client.removeListener("GroupCall.participants", updateRooms);
- };
- }, []);
-
- return rooms;
-}
-
-export function usePublicRooms(client, publicSpaceRoomId, maxRooms = 50) {
- const [rooms, setRooms] = useState([]);
-
- useEffect(() => {
- if (publicSpaceRoomId) {
- client.getRoomHierarchy(publicSpaceRoomId, maxRooms).then(({ rooms }) => {
- const filteredRooms = rooms
- .filter((room) => room.room_type !== "m.space")
- .map((room) => ({
- roomId: room.room_alias || room.room_id,
- roomName: room.name,
- avatarUrl: null,
- room,
- participants: [],
- }));
-
- setRooms(filteredRooms);
- });
- } else {
- setRooms([]);
- }
- }, [publicSpaceRoomId]);
-
- return rooms;
-}
-
-export function getRoomUrl(roomId) {
- if (roomId.startsWith("#")) {
- const [localPart, host] = roomId.replace("#", "").split(":");
-
- if (host !== defaultHomeserverHost) {
- return `${window.location.host}/room/${roomId}`;
- } else {
- return `${window.location.host}/${localPart}`;
- }
- } else {
- return `${window.location.host}/room/${roomId}`;
- }
-}
-
-export function getAvatarUrl(client, mxcUrl, avatarSize = 96) {
- const width = Math.floor(avatarSize * window.devicePixelRatio);
- const height = Math.floor(avatarSize * window.devicePixelRatio);
- return mxcUrl && client.mxcUrlToHttp(mxcUrl, width, height, "crop");
-}
-
-export function useProfile(client) {
- const [{ loading, displayName, avatarUrl, error, success }, setState] =
- useState(() => {
- const user = client?.getUser(client.getUserId());
-
- return {
- success: false,
- loading: false,
- displayName: user?.displayName,
- avatarUrl: user && client && getAvatarUrl(client, user.avatarUrl),
- error: null,
- };
- });
-
- useEffect(() => {
- const onChangeUser = (_event, { displayName, avatarUrl }) => {
- setState({
- success: false,
- loading: false,
- displayName,
- avatarUrl: getAvatarUrl(client, avatarUrl),
- error: null,
- });
- };
-
- let user;
-
- if (client) {
- const userId = client.getUserId();
- user = client.getUser(userId);
- user.on("User.displayName", onChangeUser);
- user.on("User.avatarUrl", onChangeUser);
- }
-
- return () => {
- if (user) {
- user.removeListener("User.displayName", onChangeUser);
- user.removeListener("User.avatarUrl", onChangeUser);
- }
- };
- }, [client]);
-
- const saveProfile = useCallback(
- async ({ displayName, avatar }) => {
- if (client) {
- setState((prev) => ({
- ...prev,
- loading: true,
- error: null,
- success: false,
- }));
-
- try {
- await client.setDisplayName(displayName);
-
- let mxcAvatarUrl;
-
- if (avatar) {
- mxcAvatarUrl = await client.uploadContent(avatar);
- await client.setAvatarUrl(mxcAvatarUrl);
- }
-
- setState((prev) => ({
- ...prev,
- displayName,
- avatarUrl: mxcAvatarUrl
- ? getAvatarUrl(client, mxcAvatarUrl)
- : prev.avatarUrl,
- loading: false,
- success: true,
- }));
- } catch (error) {
- setState((prev) => ({
- ...prev,
- loading: false,
- error,
- success: false,
- }));
- }
- } else {
- console.error("Client not initialized before calling saveProfile");
- }
- },
- [client]
- );
-
- return { loading, error, displayName, avatarUrl, saveProfile, success };
-}
-
-export function useInteractiveLogin() {
- const { setClient } = useClient();
- const [state, setState] = useState({ loading: false });
-
- const auth = useCallback(async (homeserver, username, password) => {
- const authClient = matrix.createClient(homeserver);
-
- const interactiveAuth = new InteractiveAuth({
- matrixClient: authClient,
- busyChanged(loading) {
- setState((prev) => ({ ...prev, loading }));
- },
- async doRequest(auth, _background) {
- return authClient.login("m.login.password", {
- identifier: {
- type: "m.id.user",
- user: username,
- },
- password,
- });
- },
- stateUpdated(nextStage, status) {
- console.log({ nextStage, status });
- },
- });
-
- const { user_id, access_token, device_id } =
- await interactiveAuth.attemptAuth();
-
- const client = await initClient({
- baseUrl: defaultHomeserver,
- accessToken: access_token,
- userId: user_id,
- deviceId: device_id,
- });
-
- setClient(client, { user_id, access_token, device_id });
-
- return client;
- }, []);
-
- return [state, auth];
-}
-
-export function useInteractiveRegistration() {
- const { setClient } = useClient();
- const [state, setState] = useState({ privacyPolicyUrl: "#", loading: false });
-
- const authClientRef = useRef();
-
- useEffect(() => {
- authClientRef.current = matrix.createClient(defaultHomeserver);
-
- authClientRef.current.registerRequest({}).catch((error) => {
- const privacyPolicyUrl =
- error.data?.params["m.login.terms"]?.policies?.privacy_policy?.en?.url;
-
- const recaptchaKey = error.data?.params["m.login.recaptcha"]?.public_key;
-
- if (privacyPolicyUrl || recaptchaKey) {
- setState((prev) => ({ ...prev, privacyPolicyUrl, recaptchaKey }));
- }
- });
- }, []);
-
- const register = useCallback(
- async (username, password, recaptchaResponse, passwordlessUser) => {
- const interactiveAuth = new InteractiveAuth({
- matrixClient: authClientRef.current,
- busyChanged(loading) {
- setState((prev) => ({ ...prev, loading }));
- },
- async doRequest(auth, _background) {
- return authClientRef.current.registerRequest({
- username,
- password,
- auth: auth || undefined,
- });
- },
- stateUpdated(nextStage, status) {
- if (status.error) {
- throw new Error(error);
- }
-
- if (nextStage === "m.login.terms") {
- interactiveAuth.submitAuthDict({
- type: "m.login.terms",
- });
- } else if (nextStage === "m.login.recaptcha") {
- interactiveAuth.submitAuthDict({
- type: "m.login.recaptcha",
- response: recaptchaResponse,
- });
- }
- },
- });
-
- const { user_id, access_token, device_id } =
- await interactiveAuth.attemptAuth();
-
- const client = await initClient({
- baseUrl: defaultHomeserver,
- accessToken: access_token,
- userId: user_id,
- deviceId: device_id,
- });
-
- const session = { user_id, device_id, access_token, passwordlessUser };
-
- if (passwordlessUser) {
- session.tempPassword = password;
- }
-
- setClient(client, session);
-
- return client;
- },
- []
- );
-
- return [state, register];
-}
diff --git a/src/Facepile.jsx b/src/Facepile.jsx
index bc084e4..53b0b94 100644
--- a/src/Facepile.jsx
+++ b/src/Facepile.jsx
@@ -2,7 +2,7 @@ import React from "react";
import styles from "./Facepile.module.css";
import classNames from "classnames";
import { Avatar } from "./Avatar";
-import { getAvatarUrl } from "./ConferenceCallManagerHooks";
+import { getAvatarUrl } from "./matrix-utils";
export function Facepile({ className, client, participants, ...rest }) {
return (
diff --git a/src/Header.jsx b/src/Header.jsx
index 7c1cbcc..c2d7db3 100644
--- a/src/Header.jsx
+++ b/src/Header.jsx
@@ -6,6 +6,7 @@ import { ReactComponent as Logo } from "./icons/Logo.svg";
import { ReactComponent as VideoIcon } from "./icons/Video.svg";
import { ReactComponent as ArrowLeftIcon } from "./icons/ArrowLeft.svg";
import { useButton } from "@react-aria/button";
+import { Subtitle } from "./typography/Typography";
export function Header({ children, className, ...rest }) {
return (
@@ -15,10 +16,15 @@ export function Header({ children, className, ...rest }) {
);
}
-export function LeftNav({ children, className, ...rest }) {
+export function LeftNav({ children, className, hideMobile, ...rest }) {
return (
{children}
@@ -26,10 +32,15 @@ export function LeftNav({ children, className, ...rest }) {
);
}
-export function RightNav({ children, className, ...rest }) {
+export function RightNav({ children, className, hideMobile, ...rest }) {
return (
{children}
@@ -37,9 +48,9 @@ export function RightNav({ children, className, ...rest }) {
);
}
-export function HeaderLogo() {
+export function HeaderLogo({ className }) {
return (
-
+
);
@@ -51,7 +62,7 @@ export function RoomHeaderInfo({ roomName }) {
-
{roomName}
+
{roomName}
>
);
}
diff --git a/src/Header.module.css b/src/Header.module.css
index dc2b8ca..27de0e4 100644
--- a/src/Header.module.css
+++ b/src/Header.module.css
@@ -16,16 +16,24 @@
height: 64px;
}
-.logo {
- display: flex;
+.headerLogo {
+ display: none;
align-items: center;
text-decoration: none;
}
+.leftNav.hideMobile {
+ display: none;
+}
+
.leftNav > * {
margin-right: 12px;
}
+.leftNav h3 {
+ margin: 0;
+}
+
.rightNav {
justify-content: flex-end;
}
@@ -34,13 +42,17 @@
margin-right: 24px;
}
+.rightNav.hideMobile {
+ display: none;
+}
+
.nav > :last-child {
margin-right: 0;
}
.roomAvatar {
position: relative;
- display: flex;
+ display: none;
justify-content: center;
align-items: center;
width: 36px;
@@ -93,7 +105,18 @@
}
@media (min-width: 800px) {
+ .headerLogo,
+ .roomAvatar,
+ .leftNav.hideMobile,
+ .rightNav.hideMobile {
+ display: flex;
+ }
+
+ .leftNav h3 {
+ font-size: 18px;
+ }
+
.nav {
- height: 98px;
+ height: 76px;
}
}
diff --git a/src/Header.stories.jsx b/src/Header.stories.jsx
new file mode 100644
index 0000000..6d9a714
--- /dev/null
+++ b/src/Header.stories.jsx
@@ -0,0 +1,106 @@
+import React from "react";
+import { GridLayoutMenu } from "./GridLayoutMenu";
+import {
+ Header,
+ HeaderLogo,
+ LeftNav,
+ RightNav,
+ RoomHeaderInfo,
+} from "./Header";
+import { UserMenu } from "./UserMenu";
+
+export default {
+ title: "Header",
+ component: Header,
+ parameters: {
+ layout: "fullscreen",
+ },
+};
+
+export const HomeAnonymous = () => (
+
+);
+
+export const HomeNamedGuest = () => (
+
+);
+
+export const HomeLoggedIn = () => (
+
+);
+
+export const LobbyNamedGuest = () => (
+
+);
+
+export const LobbyLoggedIn = () => (
+
+);
+
+export const InRoomNamedGuest = () => (
+
+);
+
+export const InRoomLoggedIn = () => (
+
+);
+
+export const CreateAccount = () => (
+
+);
diff --git a/src/Home.jsx b/src/Home.jsx
deleted file mode 100644
index 3f9769e..0000000
--- a/src/Home.jsx
+++ /dev/null
@@ -1,411 +0,0 @@
-/*
-Copyright 2021 New Vector Ltd
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-import React, { useCallback, useState, useRef, useEffect } from "react";
-import { useHistory, Link } from "react-router-dom";
-import {
- useClient,
- useGroupCallRooms,
- usePublicRooms,
- createRoom,
- roomAliasFromRoomName,
- useInteractiveRegistration,
-} from "./ConferenceCallManagerHooks";
-import { Header, HeaderLogo, LeftNav, RightNav } from "./Header";
-import styles from "./Home.module.css";
-import { FieldRow, InputField, ErrorMessage } from "./Input";
-import { UserMenu } from "./UserMenu";
-import { Button } from "./button";
-import { CallList } from "./CallList";
-import classNames from "classnames";
-import { ErrorView, LoadingView } from "./FullScreenView";
-import { useModalTriggerState } from "./Modal";
-import { randomString } from "matrix-js-sdk/src/randomstring";
-import { JoinExistingCallModal } from "./JoinExistingCallModal";
-import { RecaptchaInput } from "./RecaptchaInput";
-
-export function Home() {
- const {
- isAuthenticated,
- isGuest,
- isPasswordlessUser,
- loading,
- error,
- client,
- } = useClient();
-
- const [{ privacyPolicyUrl, recaptchaKey }, register] =
- useInteractiveRegistration();
- const [recaptchaResponse, setRecaptchaResponse] = useState();
-
- const history = useHistory();
- const [creatingRoom, setCreatingRoom] = useState(false);
- const [createRoomError, setCreateRoomError] = useState();
- const { modalState, modalProps } = useModalTriggerState();
- const [existingRoomId, setExistingRoomId] = useState();
-
- const onCreateRoom = useCallback(
- (e) => {
- e.preventDefault();
- const data = new FormData(e.target);
- const roomName = data.get("roomName");
- const userName = data.get("userName");
-
- async function onCreateRoom() {
- let _client = client;
-
- if (!recaptchaResponse) {
- return;
- }
-
- if (!_client || isGuest) {
- _client = await register(
- userName,
- randomString(16),
- recaptchaResponse,
- true
- );
- }
-
- const roomIdOrAlias = await createRoom(_client, roomName);
-
- if (roomIdOrAlias) {
- history.push(`/room/${roomIdOrAlias}`);
- }
- }
-
- setCreateRoomError(undefined);
- setCreatingRoom(true);
-
- return onCreateRoom().catch((error) => {
- if (error.errcode === "M_ROOM_IN_USE") {
- setExistingRoomId(roomAliasFromRoomName(roomName));
- setCreateRoomError(undefined);
- modalState.open();
- } else {
- setCreateRoomError(error);
- }
-
- setCreatingRoom(false);
- });
- },
- [client, history, register, isGuest, recaptchaResponse]
- );
-
- const onJoinRoom = useCallback(
- (e) => {
- e.preventDefault();
- const data = new FormData(e.target);
- const roomId = data.get("roomId");
- history.push(`/${roomId}`);
- },
- [history]
- );
-
- const onJoinExistingRoom = useCallback(() => {
- history.push(`/${existingRoomId}`);
- }, [history, existingRoomId]);
-
- if (loading) {
- return
;
- } else if (error) {
- return
;
- } else {
- return (
- <>
- {!isAuthenticated || isGuest ? (
-
- ) : (
-
- )}
- {modalState.isOpen && (
-
- )}
- >
- );
- }
-}
-
-function UnregisteredView({
- onCreateRoom,
- createRoomError,
- creatingRoom,
- onJoinRoom,
- privacyPolicyUrl,
- recaptchaKey,
- setRecaptchaResponse,
-}) {
- const acceptTermsRef = useRef();
- const [acceptTerms, setAcceptTerms] = useState(false);
-
- useEffect(() => {
- if (!acceptTermsRef.current) {
- return;
- }
-
- if (!acceptTerms) {
- acceptTermsRef.current.setCustomValidity(
- "You must accept the terms to continue."
- );
- } else {
- acceptTermsRef.current.setCustomValidity("");
- }
- }, [acceptTerms]);
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
- Not registered yet?{" "}
- Create an account
-
-
-
-
-
-
-
- );
-}
-
-function RegisteredView({
- client,
- isPasswordlessUser,
- isGuest,
- onCreateRoom,
- createRoomError,
- creatingRoom,
- onJoinRoom,
-}) {
- const publicRooms = usePublicRooms(
- client,
- import.meta.env.VITE_PUBLIC_SPACE_ROOM_ID
- );
- const recentRooms = useGroupCallRooms(client);
-
- const hideCallList = publicRooms.length === 0 && recentRooms.length === 0;
-
- return (
-
-
-
-
-
-
-
-
-
- {(isPasswordlessUser || isGuest) && (
-
-
- Not registered yet?{" "}
- Create an account
-
-
- )}
-
-
-
- {!hideCallList && (
-
-
- {publicRooms.length > 0 && (
-
- )}
- {recentRooms.length > 0 && (
-
- )}
-
-
- )}
-
-
- );
-}
diff --git a/src/Home.module.css b/src/Home.module.css
deleted file mode 100644
index 15e8534..0000000
--- a/src/Home.module.css
+++ /dev/null
@@ -1,139 +0,0 @@
-.home {
- display: flex;
- flex: 1;
- flex-direction: column;
- min-height: 100%;
-}
-
-.splitContainer {
- display: flex;
- flex: 1;
- flex-direction: column;
-}
-
-.left,
-.right {
- display: flex;
- flex-direction: column;
- flex: 1;
-}
-
-.fullWidth {
- background-color: var(--bgColor1);
-}
-
-.fullWidth .header {
- background-color: var(--bgColor1);
-}
-
-.centered {
- display: flex;
- flex-direction: column;
- flex: 1;
- width: 100%;
- max-width: 512px;
- min-width: 0;
-}
-
-.content {
- flex: 1;
-}
-
-.left .content {
- display: flex;
- flex-direction: column;
- align-items: center;
-}
-
-.left .content form > * {
- margin-top: 0;
- margin-bottom: 24px;
-}
-
-.left .content form > :last-child {
- margin-bottom: 0;
-}
-
-.left .content hr:after {
- background-color: var(--bgColor1);
- content: "OR";
- padding: 0 12px;
- position: relative;
- top: -12px;
-}
-
-.left .content form {
- display: flex;
- flex-direction: column;
- align-items: center;
- padding: 40px 92px;
-}
-
-.fieldRow {
- width: 100%;
-}
-
-.button {
- height: 40px;
- width: 100%;
- font-size: 15px;
- font-weight: 600;
-}
-
-.left .content form:first-child {
- padding-top: 0;
-}
-
-.left .content form:last-child {
- padding-bottom: 40px;
-}
-
-.right .content {
- padding: 0 40px 40px 40px;
-}
-
-.right .content h3:first-child {
- margin-top: 0;
-}
-
-.authLinks {
- display: flex;
- flex-direction: column;
- justify-content: flex-end;
- align-items: center;
-}
-
-.authLinks {
- margin-bottom: 100px;
- font-size: 15px;
-}
-
-.authLinks a {
- color: #0dbd8b;
- font-weight: normal;
- text-decoration: none;
-}
-
-@media (min-width: 800px) {
- .left {
- background-color: var(--bgColor2);
- }
-
- .home:not(.fullWidth) .left {
- max-width: 50%;
- }
-
- .home:not(.fullWidth) .leftNav {
- background-color: var(--bgColor2);
- }
-
- .splitContainer {
- flex-direction: row;
- }
-
- .fullWidth .content hr:after,
- .left .content hr:after,
- .fullWidth .header {
- background-color: var(--bgColor2);
- }
-}
diff --git a/src/RecaptchaInput.jsx b/src/RecaptchaInput.jsx
deleted file mode 100644
index 1901196..0000000
--- a/src/RecaptchaInput.jsx
+++ /dev/null
@@ -1,46 +0,0 @@
-import React, { useEffect, useRef } from "react";
-
-export function RecaptchaInput({ publicKey, onResponse }) {
- const containerRef = useRef();
- const recaptchaRef = useRef();
-
- useEffect(() => {
- const onRecaptchaLoaded = () => {
- if (!recaptchaRef.current) {
- return;
- }
-
- window.grecaptcha.render(recaptchaRef.current, {
- sitekey: publicKey,
- callback: (response) => {
- if (!recaptchaRef.current) {
- return;
- }
-
- onResponse(response);
- },
- });
- };
-
- if (
- typeof window.grecaptcha !== "undefined" &&
- typeof window.grecaptcha.render === "function"
- ) {
- onRecaptchaLoaded();
- } else {
- window.mxOnRecaptchaLoaded = onRecaptchaLoaded;
- const scriptTag = document.createElement("script");
- scriptTag.setAttribute(
- "src",
- `https://www.recaptcha.net/recaptcha/api.js?onload=mxOnRecaptchaLoaded&render=explicit`
- );
- containerRef.current.appendChild(scriptTag);
- }
- }, []);
-
- return (
-
- );
-}
diff --git a/src/Room.jsx b/src/Room.jsx
deleted file mode 100644
index 36bc411..0000000
--- a/src/Room.jsx
+++ /dev/null
@@ -1,572 +0,0 @@
-/*
-Copyright 2021 New Vector Ltd
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-import React, { useCallback, useEffect, useMemo, useState } from "react";
-import styles from "./Room.module.css";
-import { useLocation, useParams, useHistory, Link } from "react-router-dom";
-import {
- Button,
- CopyButton,
- HangupButton,
- MicButton,
- VideoButton,
- ScreenshareButton,
- LinkButton,
-} from "./button";
-import { Header, LeftNav, RightNav, RoomHeaderInfo } from "./Header";
-import { GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
-import VideoGrid, {
- useVideoGridLayout,
-} from "matrix-react-sdk/src/components/views/voip/GroupCallView/VideoGrid";
-import SimpleVideoGrid from "matrix-react-sdk/src/components/views/voip/GroupCallView/SimpleVideoGrid";
-import "matrix-react-sdk/res/css/views/voip/GroupCallView/_VideoGrid.scss";
-import { useGroupCall } from "matrix-react-sdk/src/hooks/useGroupCall";
-import { useCallFeed } from "matrix-react-sdk/src/hooks/useCallFeed";
-import { useMediaStream } from "matrix-react-sdk/src/hooks/useMediaStream";
-import {
- getAvatarUrl,
- getRoomUrl,
- useClient,
- useLoadGroupCall,
- useProfile,
-} from "./ConferenceCallManagerHooks";
-import { ErrorView, LoadingView, FullScreenView } from "./FullScreenView";
-import { GroupCallInspector } from "./GroupCallInspector";
-import * as Sentry from "@sentry/react";
-import { OverflowMenu } from "./OverflowMenu";
-import { GridLayoutMenu } from "./GridLayoutMenu";
-import { UserMenu } from "./UserMenu";
-import classNames from "classnames";
-import { Avatar } from "./Avatar";
-
-const canScreenshare = "getDisplayMedia" in navigator.mediaDevices;
-// There is currently a bug in Safari our our code with cloning and sending MediaStreams
-// or with getUsermedia and getDisplaymedia being used within the same session.
-// For now we can disable screensharing in Safari.
-const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
-
-export function Room() {
- const [registeringGuest, setRegisteringGuest] = useState(false);
- const [registrationError, setRegistrationError] = useState();
- const {
- loading,
- isAuthenticated,
- error,
- client,
- registerGuest,
- isGuest,
- isPasswordlessUser,
- } = useClient();
-
- useEffect(() => {
- if (!loading && !isAuthenticated) {
- setRegisteringGuest(true);
-
- registerGuest()
- .then(() => {
- setRegisteringGuest(false);
- })
- .catch((error) => {
- setRegistrationError(error);
- setRegisteringGuest(false);
- });
- }
- }, [loading, isAuthenticated]);
-
- if (loading || registeringGuest) {
- return
;
- }
-
- if (registrationError || error) {
- return
;
- }
-
- return (
-
- );
-}
-
-export function GroupCall({ client, isGuest, isPasswordlessUser }) {
- const { roomId: maybeRoomId } = useParams();
- const { hash, search } = useLocation();
- const [simpleGrid, viaServers] = useMemo(() => {
- const params = new URLSearchParams(search);
- return [params.has("simple"), params.getAll("via")];
- }, [search]);
- const roomId = maybeRoomId || hash;
- const { loading, error, groupCall } = useLoadGroupCall(
- client,
- roomId,
- viaServers
- );
-
- useEffect(() => {
- window.groupCall = groupCall;
- }, [groupCall]);
-
- if (loading) {
- return
;
- }
-
- if (error) {
- return
;
- }
-
- return (
-
- );
-}
-
-export function GroupCallView({
- client,
- isGuest,
- isPasswordlessUser,
- roomId,
- groupCall,
- simpleGrid,
-}) {
- const [showInspector, setShowInspector] = useState(false);
- const {
- state,
- error,
- activeSpeaker,
- userMediaFeeds,
- microphoneMuted,
- localVideoMuted,
- localCallFeed,
- initLocalCallFeed,
- enter,
- leave,
- toggleLocalVideoMuted,
- toggleMicrophoneMuted,
- toggleScreensharing,
- isScreensharing,
- localScreenshareFeed,
- screenshareFeeds,
- hasLocalParticipant,
- } = useGroupCall(groupCall);
-
- useEffect(() => {
- function onHangup(call) {
- if (call.hangupReason === "ice_failed") {
- Sentry.captureException(new Error("Call hangup due to ICE failure."));
- }
- }
-
- function onError(error) {
- Sentry.captureException(error);
- }
-
- if (groupCall) {
- groupCall.on("hangup", onHangup);
- groupCall.on("error", onError);
- }
-
- return () => {
- if (groupCall) {
- groupCall.removeListener("hangup", onHangup);
- groupCall.removeListener("error", onError);
- }
- };
- }, [groupCall]);
-
- const [left, setLeft] = useState(false);
- const history = useHistory();
-
- const onLeave = useCallback(() => {
- leave();
-
- if (!isGuest && !isPasswordlessUser) {
- history.push("/");
- } else {
- setLeft(true);
- }
- }, [leave, history, isGuest]);
-
- if (error) {
- return
;
- } else if (state === GroupCallState.Entered) {
- return (
-
- );
- } else if (state === GroupCallState.Entering) {
- return
;
- } else if (left) {
- if (isPasswordlessUser) {
- return
;
- } else {
- return
;
- }
- } else {
- return (
-
- );
- }
-}
-
-export function LoadingRoomView() {
- return (
-
- Loading room...
-
- );
-}
-
-export function EnteringRoomView() {
- return (
-
- Entering room...
-
- );
-}
-
-function RoomSetupView({
- client,
- roomName,
- state,
- onInitLocalCallFeed,
- onEnter,
- localCallFeed,
- microphoneMuted,
- localVideoMuted,
- toggleLocalVideoMuted,
- toggleMicrophoneMuted,
- setShowInspector,
- showInspector,
- roomId,
-}) {
- const { stream } = useCallFeed(localCallFeed);
- const videoRef = useMediaStream(stream, true);
-
- useEffect(() => {
- onInitLocalCallFeed();
- }, [onInitLocalCallFeed]);
-
- return (
-
-
-
-
-
-
- {state === GroupCallState.LocalCallFeedUninitialized && (
-
- Webcam/microphone permissions needed to join the call.
-
- )}
- {state === GroupCallState.InitializingLocalCallFeed && (
-
- Accept webcam/microphone permissions to join the call.
-
- )}
- {state === GroupCallState.LocalCallFeedInitialized && (
- <>
-
-
-
-
-
-
- >
- )}
-
-
Or
-
- Copy call link and join later
-
-
-
-
- Take me Home
-
-
-
-
- );
-}
-
-function InRoomView({
- client,
- isGuest,
- groupCall,
- roomName,
- microphoneMuted,
- localVideoMuted,
- toggleLocalVideoMuted,
- toggleMicrophoneMuted,
- userMediaFeeds,
- activeSpeaker,
- onLeave,
- toggleScreensharing,
- isScreensharing,
- screenshareFeeds,
- simpleGrid,
- setShowInspector,
- showInspector,
- roomId,
-}) {
- const [layout, setLayout] = useVideoGridLayout();
-
- const items = useMemo(() => {
- const items = [];
-
- for (const callFeed of userMediaFeeds) {
- items.push({
- id: callFeed.stream.id,
- callFeed,
- focused:
- screenshareFeeds.length === 0
- ? callFeed.userId === activeSpeaker
- : false,
- });
- }
-
- for (const callFeed of screenshareFeeds) {
- const userMediaItem = items.find(
- (item) => item.callFeed.userId === callFeed.userId
- );
-
- if (userMediaItem) {
- userMediaItem.presenter = true;
- }
-
- items.push({
- id: callFeed.stream.id,
- callFeed,
- focused: true,
- });
- }
-
- return items;
- }, [userMediaFeeds, activeSpeaker, screenshareFeeds]);
-
- const onFocusTile = useCallback(
- (tiles, focusedTile) => {
- if (layout === "freedom") {
- return tiles.map((tile) => {
- if (tile === focusedTile) {
- return { ...tile, presenter: !tile.presenter };
- }
-
- return tile;
- });
- } else {
- return tiles;
- }
- },
- [layout, setLayout]
- );
-
- const renderAvatar = useCallback(
- (roomMember, width, height) => {
- const avatarUrl = roomMember.user?.avatarUrl;
- const size = Math.round(Math.min(width, height) / 2);
-
- return (
-
- );
- },
- [client]
- );
-
- return (
-
-
-
-
-
-
-
- {!isGuest && }
-
-
- {items.length === 0 ? (
-
-
Waiting for other participants...
-
- ) : simpleGrid ? (
-
- ) : (
-
- )}
-
-
-
- {canScreenshare && !isSafari && (
-
- )}
-
-
-
-
-
- );
-}
-
-export function GuestCallEndedScreen() {
- return (
-
- Your call is now ended
-
-
Why not finish by creating an account?
-
You'll be able to:
-
- - Easily access all your previous call links
- - Set a username and avatar
-
-
- Create account
-
-
- Not now, return to home screen
-
- );
-}
-
-export function PasswordlessUserCallEndedScreen({ client }) {
- const { displayName } = useProfile(client);
-
- return (
-
- {displayName}, your call is now ended
-
-
Why not finish by setting up a password to keep your account?
-
- You'll be able to keep your name and set an avatar for use on future
- calls
-
-
- Create account
-
-
- Not now, return to home screen
-
- );
-}
diff --git a/src/Tooltip.jsx b/src/Tooltip.jsx
index fdc0798..e9d3596 100644
--- a/src/Tooltip.jsx
+++ b/src/Tooltip.jsx
@@ -1,7 +1,7 @@
-import React, { forwardRef, useRef } from "react";
+import React, { forwardRef } from "react";
import { useTooltipTriggerState } from "@react-stately/tooltip";
import { useTooltipTrigger, useTooltip } from "@react-aria/tooltip";
-import { mergeProps } from "@react-aria/utils";
+import { mergeProps, useObjectRef } from "@react-aria/utils";
import styles from "./Tooltip.module.css";
import classNames from "classnames";
@@ -20,8 +20,7 @@ export function Tooltip({ position, state, ...props }) {
export const TooltipTrigger = forwardRef(({ children, ...rest }, ref) => {
const tooltipState = useTooltipTriggerState(rest);
- const fallbackRef = useRef();
- const triggerRef = ref || fallbackRef;
+ const triggerRef = useObjectRef(ref);
const { triggerProps, tooltipProps } = useTooltipTrigger(
rest,
tooltipState,
diff --git a/src/UserMenu.jsx b/src/UserMenu.jsx
index a6d5d07..8e83ca9 100644
--- a/src/UserMenu.jsx
+++ b/src/UserMenu.jsx
@@ -1,58 +1,34 @@
-import React, { useCallback, useMemo } from "react";
+import React, { useMemo } from "react";
+import { Item } from "@react-stately/collections";
import { Button, LinkButton } from "./button";
-import { PopoverMenuTrigger } from "./PopoverMenu";
+import { PopoverMenuTrigger } from "./popover/PopoverMenu";
+import { Menu } from "./Menu";
+import { Tooltip, TooltipTrigger } from "./Tooltip";
+import { Avatar } from "./Avatar";
import { ReactComponent as UserIcon } from "./icons/User.svg";
import { ReactComponent as LoginIcon } from "./icons/Login.svg";
import { ReactComponent as LogoutIcon } from "./icons/Logout.svg";
import styles from "./UserMenu.module.css";
-import { Item } from "@react-stately/collections";
-import { Menu } from "./Menu";
-import { useHistory, useLocation } from "react-router-dom";
-import { useClient, useProfile } from "./ConferenceCallManagerHooks";
-import { useModalTriggerState } from "./Modal";
-import { ProfileModal } from "./ProfileModal";
-import { Tooltip, TooltipTrigger } from "./Tooltip";
-import { Avatar } from "./Avatar";
+import { useLocation } from "react-router-dom";
-export function UserMenu({ disableLogout }) {
+export function UserMenu({
+ disableLogout,
+ isAuthenticated,
+ isPasswordlessUser,
+ displayName,
+ avatarUrl,
+ onAction,
+}) {
const location = useLocation();
- const history = useHistory();
- const {
- isAuthenticated,
- isGuest,
- isPasswordlessUser,
- logout,
- userName,
- client,
- } = useClient();
- const { displayName, avatarUrl } = useProfile(client);
- const { modalState, modalProps } = useModalTriggerState();
-
- const onAction = useCallback(
- (value) => {
- switch (value) {
- case "user":
- modalState.open();
- break;
- case "logout":
- logout();
- break;
- case "login":
- history.push("/login", { state: { from: location } });
- break;
- }
- },
- [history, location, logout, modalState]
- );
const items = useMemo(() => {
const arr = [];
- if (isAuthenticated && !isGuest) {
+ if (isAuthenticated) {
arr.push({
key: "user",
icon: UserIcon,
- label: displayName || userName,
+ label: displayName,
});
if (isPasswordlessUser) {
@@ -73,9 +49,9 @@ export function UserMenu({ disableLogout }) {
}
return arr;
- }, [isAuthenticated, isGuest, userName, displayName]);
+ }, [isAuthenticated, isPasswordlessUser, displayName, disableLogout]);
- if (isGuest || !isAuthenticated) {
+ if (!isAuthenticated) {
return (
Log in
@@ -84,46 +60,36 @@ export function UserMenu({ disableLogout }) {
}
return (
- <>
-
-
-
- {(props) => (
-
- Profile
-
+
+
+
+
{(props) => (
-
+
+ Profile
+
)}
-
- {modalState.isOpen && (
-
+
+ {(props) => (
+
)}
- >
+
);
}
diff --git a/src/UserMenu.module.css b/src/UserMenu.module.css
index 60597b4..8cda2eb 100644
--- a/src/UserMenu.module.css
+++ b/src/UserMenu.module.css
@@ -1,3 +1,17 @@
.userButton svg * {
fill: var(--textColor1);
}
+
+.avatar {
+ width: 24px;
+ height: 24px;
+ font-size: 12px;
+}
+
+@media (min-width: 800px) {
+ .avatar {
+ width: 32px;
+ height: 32px;
+ font-size: 15px;
+ }
+}
diff --git a/src/UserMenuContainer.jsx b/src/UserMenuContainer.jsx
new file mode 100644
index 0000000..b4af921
--- /dev/null
+++ b/src/UserMenuContainer.jsx
@@ -0,0 +1,56 @@
+import React, { useCallback } from "react";
+import { useHistory, useLocation } from "react-router-dom";
+import { useClient } from "./ClientContext";
+import { useProfile } from "./profile/useProfile";
+import { useModalTriggerState } from "./Modal";
+import { ProfileModal } from "./profile/ProfileModal";
+import { UserMenu } from "./UserMenu";
+
+export function UserMenuContainer({ disableLogout }) {
+ const location = useLocation();
+ const history = useHistory();
+ const { isAuthenticated, isPasswordlessUser, logout, userName, client } =
+ useClient();
+ const { displayName, avatarUrl } = useProfile(client);
+ const { modalState, modalProps } = useModalTriggerState();
+
+ const onAction = useCallback(
+ (value) => {
+ switch (value) {
+ case "user":
+ modalState.open();
+ break;
+ case "logout":
+ logout();
+ break;
+ case "login":
+ history.push("/login", { state: { from: location } });
+ break;
+ }
+ },
+ [history, location, logout, modalState]
+ );
+
+ return (
+ <>
+
+ {modalState.isOpen && (
+
+ )}
+ >
+ );
+}
diff --git a/src/LoginPage.jsx b/src/auth/LoginPage.jsx
similarity index 92%
rename from src/LoginPage.jsx
rename to src/auth/LoginPage.jsx
index 6099261..a2bbe16 100644
--- a/src/LoginPage.jsx
+++ b/src/auth/LoginPage.jsx
@@ -16,15 +16,12 @@ limitations under the License.
import React, { useCallback, useRef, useState, useMemo } from "react";
import { useHistory, useLocation, Link } from "react-router-dom";
-import { ReactComponent as Logo } from "./icons/LogoLarge.svg";
-import { FieldRow, InputField, ErrorMessage } from "./Input";
-import { Button } from "./button";
-import {
- defaultHomeserver,
- defaultHomeserverHost,
- useInteractiveLogin,
-} from "./ConferenceCallManagerHooks";
+import { ReactComponent as Logo } from "../icons/LogoLarge.svg";
+import { FieldRow, InputField, ErrorMessage } from "../input/Input";
+import { Button } from "../button";
+import { defaultHomeserver, defaultHomeserverHost } from "../matrix-utils";
import styles from "./LoginPage.module.css";
+import { useInteractiveLogin } from "./useInteractiveLogin";
export function LoginPage() {
const [_, login] = useInteractiveLogin();
diff --git a/src/LoginPage.module.css b/src/auth/LoginPage.module.css
similarity index 100%
rename from src/LoginPage.module.css
rename to src/auth/LoginPage.module.css
diff --git a/src/RegisterPage.jsx b/src/auth/RegisterPage.jsx
similarity index 63%
rename from src/RegisterPage.jsx
rename to src/auth/RegisterPage.jsx
index 81c6662..26a9cce 100644
--- a/src/RegisterPage.jsx
+++ b/src/auth/RegisterPage.jsx
@@ -15,18 +15,17 @@ limitations under the License.
*/
import React, { useCallback, useEffect, useRef, useState } from "react";
-import { useHistory, useLocation, Link } from "react-router-dom";
-import { FieldRow, InputField, ErrorMessage, Field } from "./Input";
-import { Button } from "./button";
-import {
- useClient,
- defaultHomeserverHost,
- useInteractiveRegistration,
-} from "./ConferenceCallManagerHooks";
+import { useHistory, useLocation } from "react-router-dom";
+import { FieldRow, InputField, ErrorMessage } from "../input/Input";
+import { Button } from "../button";
+import { useClient } from "../ClientContext";
+import { defaultHomeserverHost } from "../matrix-utils";
+import { useInteractiveRegistration } from "./useInteractiveRegistration";
import styles from "./LoginPage.module.css";
-import { ReactComponent as Logo } from "./icons/LogoLarge.svg";
-import { LoadingView } from "./FullScreenView";
-import { RecaptchaInput } from "./RecaptchaInput";
+import { ReactComponent as Logo } from "../icons/LogoLarge.svg";
+import { LoadingView } from "../FullScreenView";
+import { useRecaptcha } from "./useRecaptcha";
+import { Caption, Link } from "../typography/Typography";
export function RegisterPage() {
const {
@@ -37,17 +36,15 @@ export function RegisterPage() {
isPasswordlessUser,
} = useClient();
const confirmPasswordRef = useRef();
- const acceptTermsRef = useRef();
const history = useHistory();
const location = useLocation();
const [registering, setRegistering] = useState(false);
const [error, setError] = useState();
const [password, setPassword] = useState("");
const [passwordConfirmation, setPasswordConfirmation] = useState("");
- const [acceptTerms, setAcceptTerms] = useState(false);
const [{ privacyPolicyUrl, recaptchaKey }, register] =
useInteractiveRegistration();
- const [recaptchaResponse, setRecaptchaResponse] = useState();
+ const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
const onSubmitRegisterForm = useCallback(
(e) => {
@@ -56,51 +53,35 @@ export function RegisterPage() {
const userName = data.get("userName");
const password = data.get("password");
const passwordConfirmation = data.get("passwordConfirmation");
- const acceptTerms = data.get("acceptTerms");
- if (isPasswordlessUser) {
- if (password !== passwordConfirmation) {
- return;
- }
-
- setRegistering(true);
-
- changePassword(password)
- .then(() => {
- if (location.state && location.state.from) {
- history.push(location.state.from);
- } else {
- history.push("/");
- }
- })
- .catch((error) => {
- setError(error);
- setRegistering(false);
- });
- } else {
- if (
- password !== passwordConfirmation ||
- !acceptTerms ||
- !recaptchaResponse
- ) {
- return;
- }
-
- setRegistering(true);
-
- register(userName, password, recaptchaResponse)
- .then(() => {
- if (location.state && location.state.from) {
- history.push(location.state.from);
- } else {
- history.push("/");
- }
- })
- .catch((error) => {
- setError(error);
- setRegistering(false);
- });
+ if (password !== passwordConfirmation) {
+ return;
}
+
+ async function submit() {
+ setRegistering(true);
+
+ if (isPasswordlessUser) {
+ await changePassword(password);
+ } else {
+ const recaptchaResponse = await execute();
+ await register(userName, password, recaptchaResponse);
+ }
+ }
+
+ submit()
+ .then(() => {
+ if (location.state && location.state.from) {
+ history.push(location.state.from);
+ } else {
+ history.push("/");
+ }
+ })
+ .catch((error) => {
+ setError(error);
+ setRegistering(false);
+ reset();
+ });
},
[
register,
@@ -108,7 +89,8 @@ export function RegisterPage() {
location,
history,
isPasswordlessUser,
- recaptchaResponse,
+ reset,
+ execute,
]
);
@@ -124,20 +106,6 @@ export function RegisterPage() {
}
}, [password, passwordConfirmation]);
- useEffect(() => {
- if (!acceptTermsRef.current) {
- return;
- }
-
- if (!acceptTerms) {
- acceptTermsRef.current.setCustomValidity(
- "You must accept the terms to continue."
- );
- } else {
- acceptTermsRef.current.setCustomValidity("");
- }
- }, [acceptTerms]);
-
useEffect(() => {
if (!loading && isAuthenticated && !isPasswordlessUser) {
history.push("/");
@@ -198,28 +166,20 @@ export function RegisterPage() {
/>
{!isPasswordlessUser && (
-
- setAcceptTerms(e.target.checked)}
- checked={acceptTerms}
- label="Accept Privacy Policy"
- ref={acceptTermsRef}
- />
-
+
+ This site is protected by ReCAPTCHA and the Google{" "}
+
Privacy Policy
-
-
- )}
- {!isPasswordlessUser && recaptchaKey && (
-
-
-
+ {" "}
+ and{" "}
+
+ Terms of Service
+ {" "}
+ apply.
+
+ By clicking "Go", you agree to our{" "}
+ Terms and conditions
+
)}
{error && (
@@ -231,6 +191,7 @@ export function RegisterPage() {
{registering ? "Registering..." : "Register"}
+
diff --git a/src/auth/useInteractiveLogin.js b/src/auth/useInteractiveLogin.js
new file mode 100644
index 0000000..4b1bc54
--- /dev/null
+++ b/src/auth/useInteractiveLogin.js
@@ -0,0 +1,45 @@
+import matrix, { InteractiveAuth } from "matrix-js-sdk/src/browser-index";
+import { useState, useCallback } from "react";
+import { useClient } from "../ClientContext";
+import { initClient, defaultHomeserver } from "../matrix-utils";
+
+export function useInteractiveLogin() {
+ const { setClient } = useClient();
+ const [state, setState] = useState({ loading: false });
+
+ const auth = useCallback(async (homeserver, username, password) => {
+ const authClient = matrix.createClient(homeserver);
+
+ const interactiveAuth = new InteractiveAuth({
+ matrixClient: authClient,
+ busyChanged(loading) {
+ setState((prev) => ({ ...prev, loading }));
+ },
+ async doRequest(_auth, _background) {
+ return authClient.login("m.login.password", {
+ identifier: {
+ type: "m.id.user",
+ user: username,
+ },
+ password,
+ });
+ },
+ });
+
+ const { user_id, access_token, device_id } =
+ await interactiveAuth.attemptAuth();
+
+ const client = await initClient({
+ baseUrl: defaultHomeserver,
+ accessToken: access_token,
+ userId: user_id,
+ deviceId: device_id,
+ });
+
+ setClient(client, { user_id, access_token, device_id });
+
+ return client;
+ }, []);
+
+ return [state, auth];
+}
diff --git a/src/auth/useInteractiveRegistration.js b/src/auth/useInteractiveRegistration.js
new file mode 100644
index 0000000..57e2178
--- /dev/null
+++ b/src/auth/useInteractiveRegistration.js
@@ -0,0 +1,83 @@
+import matrix, { InteractiveAuth } from "matrix-js-sdk/src/browser-index";
+import { useState, useEffect, useCallback, useRef } from "react";
+import { useClient } from "../ClientContext";
+import { initClient, defaultHomeserver } from "../matrix-utils";
+
+export function useInteractiveRegistration() {
+ const { setClient } = useClient();
+ const [state, setState] = useState({ privacyPolicyUrl: "#", loading: false });
+
+ const authClientRef = useRef();
+
+ useEffect(() => {
+ authClientRef.current = matrix.createClient(defaultHomeserver);
+
+ authClientRef.current.registerRequest({}).catch((error) => {
+ const privacyPolicyUrl =
+ error.data?.params["m.login.terms"]?.policies?.privacy_policy?.en?.url;
+
+ const recaptchaKey = error.data?.params["m.login.recaptcha"]?.public_key;
+
+ if (privacyPolicyUrl || recaptchaKey) {
+ setState((prev) => ({ ...prev, privacyPolicyUrl, recaptchaKey }));
+ }
+ });
+ }, []);
+
+ const register = useCallback(
+ async (username, password, recaptchaResponse, passwordlessUser) => {
+ const interactiveAuth = new InteractiveAuth({
+ matrixClient: authClientRef.current,
+ busyChanged(loading) {
+ setState((prev) => ({ ...prev, loading }));
+ },
+ async doRequest(auth, _background) {
+ return authClientRef.current.registerRequest({
+ username,
+ password,
+ auth: auth || undefined,
+ });
+ },
+ stateUpdated(nextStage, status) {
+ if (status.error) {
+ throw new Error(error);
+ }
+
+ if (nextStage === "m.login.terms") {
+ interactiveAuth.submitAuthDict({
+ type: "m.login.terms",
+ });
+ } else if (nextStage === "m.login.recaptcha") {
+ interactiveAuth.submitAuthDict({
+ type: "m.login.recaptcha",
+ response: recaptchaResponse,
+ });
+ }
+ },
+ });
+
+ const { user_id, access_token, device_id } =
+ await interactiveAuth.attemptAuth();
+
+ const client = await initClient({
+ baseUrl: defaultHomeserver,
+ accessToken: access_token,
+ userId: user_id,
+ deviceId: device_id,
+ });
+
+ const session = { user_id, device_id, access_token, passwordlessUser };
+
+ if (passwordlessUser) {
+ session.tempPassword = password;
+ }
+
+ setClient(client, session);
+
+ return client;
+ },
+ []
+ );
+
+ return [state, register];
+}
diff --git a/src/auth/useRecaptcha.js b/src/auth/useRecaptcha.js
new file mode 100644
index 0000000..c7dc4b4
--- /dev/null
+++ b/src/auth/useRecaptcha.js
@@ -0,0 +1,106 @@
+import { randomString } from "matrix-js-sdk/src/randomstring";
+import { useEffect, useCallback, useRef, useState } from "react";
+
+const RECAPTCHA_SCRIPT_URL =
+ "https://www.recaptcha.net/recaptcha/api.js?onload=mxOnRecaptchaLoaded&render=explicit";
+
+export function useRecaptcha(sitekey) {
+ const [recaptchaId] = useState(() => randomString(16));
+ const promiseRef = useRef();
+
+ useEffect(() => {
+ if (!sitekey) {
+ return;
+ }
+
+ const onRecaptchaLoaded = () => {
+ if (!document.getElementById(recaptchaId)) {
+ return;
+ }
+
+ window.grecaptcha.render(recaptchaId, {
+ sitekey,
+ size: "invisible",
+ callback: (response) => {
+ if (promiseRef.current) {
+ promiseRef.current.resolve(response);
+ }
+ },
+ "error-callback": (error) => {
+ if (promiseRef.current) {
+ promiseRef.current.reject(error);
+ }
+ },
+ });
+ };
+
+ if (
+ typeof window.grecaptcha !== "undefined" &&
+ typeof window.grecaptcha.render === "function"
+ ) {
+ onRecaptchaLoaded();
+ } else {
+ window.mxOnRecaptchaLoaded = onRecaptchaLoaded;
+
+ if (!document.querySelector(`script[src="${RECAPTCHA_SCRIPT_URL}"]`)) {
+ const scriptTag = document.createElement("script");
+ scriptTag.src = RECAPTCHA_SCRIPT_URL;
+ scriptTag.async = true;
+ document.body.appendChild(scriptTag);
+ }
+ }
+ }, [recaptchaId, sitekey]);
+
+ const execute = useCallback(() => {
+ if (!sitekey) {
+ return Promise.resolve(null);
+ }
+
+ if (!window.grecaptcha) {
+ return Promise.reject(new Error("Recaptcha not loaded"));
+ }
+
+ return new Promise((resolve, reject) => {
+ const observer = new MutationObserver((mutationsList) => {
+ for (const item of mutationsList) {
+ if (item.target.style?.visibility !== "visible") {
+ reject(new Error("Recaptcha dismissed"));
+ observer.disconnect();
+ return;
+ }
+ }
+ });
+
+ promiseRef.current = {
+ resolve: (value) => {
+ resolve(value);
+ observer.disconnect();
+ },
+ reject: (error) => {
+ reject(error);
+ observer.disconnect();
+ },
+ };
+
+ window.grecaptcha.execute();
+
+ const iframe = document.querySelector(
+ 'iframe[src*="recaptcha/api2/bframe"]'
+ );
+
+ if (iframe?.parentNode?.parentNode) {
+ observer.observe(iframe?.parentNode?.parentNode, {
+ attributes: true,
+ });
+ }
+ });
+ }, [recaptchaId]);
+
+ const reset = useCallback(() => {
+ if (window.grecaptcha) {
+ window.grecaptcha.reset();
+ }
+ }, [recaptchaId]);
+
+ return { execute, reset, recaptchaId };
+}
diff --git a/src/button/Button.jsx b/src/button/Button.jsx
index 434d4fd..a98fbb6 100644
--- a/src/button/Button.jsx
+++ b/src/button/Button.jsx
@@ -62,7 +62,7 @@ export const Button = forwardRef(
[styles.off]: off,
}
)}
- {...filteredButtonProps}
+ {...mergeProps(rest, filteredButtonProps)}
ref={buttonRef}
>
{children}
diff --git a/src/form/Form.jsx b/src/form/Form.jsx
new file mode 100644
index 0000000..c46064f
--- /dev/null
+++ b/src/form/Form.jsx
@@ -0,0 +1,11 @@
+import classNames from "classnames";
+import React, { forwardRef } from "react";
+import styles from "./Form.module.css";
+
+export const Form = forwardRef(({ children, className, ...rest }, ref) => {
+ return (
+
+ );
+});
diff --git a/src/form/Form.module.css b/src/form/Form.module.css
new file mode 100644
index 0000000..c1f7262
--- /dev/null
+++ b/src/form/Form.module.css
@@ -0,0 +1,4 @@
+.form {
+ display: flex;
+ flex-direction: column;
+}
diff --git a/src/CallList.jsx b/src/home/CallList.jsx
similarity index 56%
rename from src/CallList.jsx
rename to src/home/CallList.jsx
index 63cd8e5..35b4da6 100644
--- a/src/CallList.jsx
+++ b/src/home/CallList.jsx
@@ -1,16 +1,16 @@
-import React, { useMemo } from "react";
+import React from "react";
import { Link } from "react-router-dom";
-import { CopyButton } from "./button";
-import { Facepile } from "./Facepile";
-import { Avatar } from "./Avatar";
-import { ReactComponent as VideoIcon } from "./icons/Video.svg";
+import { CopyButton } from "../button";
+import { Facepile } from "../Facepile";
+import { Avatar } from "../Avatar";
+import { ReactComponent as VideoIcon } from "../icons/Video.svg";
import styles from "./CallList.module.css";
-import { getRoomUrl } from "./ConferenceCallManagerHooks";
+import { getRoomUrl } from "../matrix-utils";
+import { Body, Caption } from "../typography/Typography";
-export function CallList({ title, rooms, client }) {
+export function CallList({ rooms, client }) {
return (
<>
-
{title}
{rooms.map(({ roomId, roomName, avatarUrl, participants }) => (
))}
+ {rooms.length > 3 && (
+ <>
+
+
+ >
+ )}
>
);
@@ -32,17 +38,23 @@ function CallTile({ name, avatarUrl, roomId, participants, client }) {
}
className={styles.avatar}
/>
-
{name}
-
{getRoomUrl(roomId)}
+
+ {name}
+
+
{getRoomUrl(roomId)}
{participants && (
-
+
)}
diff --git a/src/CallList.module.css b/src/home/CallList.module.css
similarity index 60%
rename from src/CallList.module.css
rename to src/home/CallList.module.css
index 1d2b62b..5fbd163 100644
--- a/src/CallList.module.css
+++ b/src/home/CallList.module.css
@@ -1,6 +1,14 @@
+.callTileSpacer,
.callTile {
- min-width: 240px;
- height: 94px;
+ width: 329px;
+}
+
+.callTileSpacer {
+ height: 0;
+}
+
+.callTile {
+ height: 95px;
padding: 12px;
background-color: var(--bgColor2);
border-radius: 8px;
@@ -31,28 +39,11 @@
}
.callInfo > * {
- margin-top: 0;
- margin-bottom: 8px;
-}
-
-.callInfo > :last-child {
margin-bottom: 0;
}
-.callInfo h5 {
- font-size: 15px;
- font-weight: 600;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.callInfo p {
- font-weight: 400;
- font-size: 12px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
+.facePile {
+ margin-top: 8px;
}
.copyButtonSpacer,
@@ -68,7 +59,12 @@
}
.callList {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
+ display: flex;
+ flex-wrap: wrap;
+ max-width: calc((329px + 24px) * 3);
+ width: calc(100% - 48px);
gap: 24px;
+ padding: 0 24px;
+ justify-content: center;
+ margin-bottom: 24px;
}
diff --git a/src/home/HomePage.jsx b/src/home/HomePage.jsx
new file mode 100644
index 0000000..daed470
--- /dev/null
+++ b/src/home/HomePage.jsx
@@ -0,0 +1,38 @@
+/*
+Copyright 2021 New Vector Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React from "react";
+import { useClient } from "../ClientContext";
+import { ErrorView, LoadingView } from "../FullScreenView";
+import { UnauthenticatedView } from "./UnauthenticatedView";
+import { RegisteredView } from "./RegisteredView";
+
+export function HomePage() {
+ const { isAuthenticated, isPasswordlessUser, loading, error, client } =
+ useClient();
+
+ if (loading) {
+ return
;
+ } else if (error) {
+ return
;
+ } else {
+ return isAuthenticated ? (
+
+ ) : (
+
+ );
+ }
+}
diff --git a/src/JoinExistingCallModal.jsx b/src/home/JoinExistingCallModal.jsx
similarity index 80%
rename from src/JoinExistingCallModal.jsx
rename to src/home/JoinExistingCallModal.jsx
index 20a0a18..8a749f9 100644
--- a/src/JoinExistingCallModal.jsx
+++ b/src/home/JoinExistingCallModal.jsx
@@ -1,7 +1,7 @@
import React from "react";
-import { Modal, ModalContent } from "./Modal";
-import { Button } from "./button";
-import { FieldRow } from "./Input";
+import { Modal, ModalContent } from "../Modal";
+import { Button } from "../button";
+import { FieldRow } from "../input/Input";
import styles from "./JoinExistingCallModal.module.css";
export function JoinExistingCallModal({ onJoin, ...rest }) {
diff --git a/src/JoinExistingCallModal.module.css b/src/home/JoinExistingCallModal.module.css
similarity index 100%
rename from src/JoinExistingCallModal.module.css
rename to src/home/JoinExistingCallModal.module.css
diff --git a/src/home/RegisteredView.jsx b/src/home/RegisteredView.jsx
new file mode 100644
index 0000000..f728adf
--- /dev/null
+++ b/src/home/RegisteredView.jsx
@@ -0,0 +1,120 @@
+import React, { useState, useCallback } from "react";
+import { createRoom, roomAliasFromRoomName } from "../matrix-utils";
+import { useGroupCallRooms } from "./useGroupCallRooms";
+import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
+import commonStyles from "./common.module.css";
+import styles from "./RegisteredView.module.css";
+import { FieldRow, InputField, ErrorMessage } from "../input/Input";
+import { Button } from "../button";
+import { CallList } from "./CallList";
+import { UserMenuContainer } from "../UserMenuContainer";
+import { useModalTriggerState } from "../Modal";
+import { JoinExistingCallModal } from "./JoinExistingCallModal";
+import { useHistory } from "react-router-dom";
+import { Headline, Title } from "../typography/Typography";
+import { Form } from "../form/Form";
+
+export function RegisteredView({ client }) {
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState();
+ const onSubmit = useCallback(
+ (e) => {
+ e.preventDefault();
+ const data = new FormData(e.target);
+ const roomName = data.get("callName");
+
+ async function submit() {
+ setError(undefined);
+ setLoading(true);
+
+ const roomIdOrAlias = await createRoom(client, roomName);
+
+ if (roomIdOrAlias) {
+ history.push(`/room/${roomIdOrAlias}`);
+ }
+ }
+
+ submit().catch((error) => {
+ if (error.errcode === "M_ROOM_IN_USE") {
+ setExistingRoomId(roomAliasFromRoomName(roomName));
+ setLoading(false);
+ setError(undefined);
+ modalState.open();
+ } else {
+ console.error(error);
+ setLoading(false);
+ setError(error);
+ reset();
+ }
+ });
+ },
+ [client]
+ );
+
+ const recentRooms = useGroupCallRooms(client);
+
+ const { modalState, modalProps } = useModalTriggerState();
+ const [existingRoomId, setExistingRoomId] = useState();
+ const history = useHistory();
+ const onJoinExistingRoom = useCallback(() => {
+ history.push(`/${existingRoomId}`);
+ }, [history, existingRoomId]);
+
+ return (
+ <>
+
+
+
+
+
+ Enter a call name
+
+
+ {recentRooms.length > 0 && (
+ <>
+
+ Your recent Calls
+
+
+ >
+ )}
+
+
+ {modalState.isOpen && (
+
+ )}
+ >
+ );
+}
diff --git a/src/home/RegisteredView.module.css b/src/home/RegisteredView.module.css
new file mode 100644
index 0000000..3783d5e
--- /dev/null
+++ b/src/home/RegisteredView.module.css
@@ -0,0 +1,19 @@
+.form {
+ padding: 0 24px;
+ justify-content: center;
+ max-width: 409px;
+ width: calc(100% - 48px);
+ margin-bottom: 72px;
+}
+
+.fieldRow {
+ margin-bottom: 0;
+}
+
+.button {
+ padding: 0 24px;
+}
+
+.recentCallsTitle {
+ margin-bottom: 32px;
+}
diff --git a/src/home/UnauthenticatedView.jsx b/src/home/UnauthenticatedView.jsx
new file mode 100644
index 0000000..f9af6b8
--- /dev/null
+++ b/src/home/UnauthenticatedView.jsx
@@ -0,0 +1,146 @@
+import React, { useCallback, useState } from "react";
+import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
+import { UserMenuContainer } from "../UserMenuContainer";
+import { useHistory } from "react-router-dom";
+import { FieldRow, InputField, ErrorMessage } from "../input/Input";
+import { Button } from "../button";
+import { randomString } from "matrix-js-sdk/src/randomstring";
+import { createRoom, roomAliasFromRoomName } from "../matrix-utils";
+import { useInteractiveRegistration } from "../auth/useInteractiveRegistration";
+import { useModalTriggerState } from "../Modal";
+import { JoinExistingCallModal } from "./JoinExistingCallModal";
+import { useRecaptcha } from "../auth/useRecaptcha";
+import { Body, Caption, Link, Headline } from "../typography/Typography";
+import { Form } from "../form/Form";
+import styles from "./UnauthenticatedView.module.css";
+import commonStyles from "./common.module.css";
+
+export function UnauthenticatedView() {
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState();
+ const [{ privacyPolicyUrl, recaptchaKey }, register] =
+ useInteractiveRegistration();
+ const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
+ const onSubmit = useCallback(
+ (e) => {
+ e.preventDefault();
+ const data = new FormData(e.target);
+ const roomName = data.get("callName");
+ const userName = data.get("userName");
+
+ async function submit() {
+ setError(undefined);
+ setLoading(true);
+ const recaptchaResponse = await execute();
+ const client = await register(
+ userName,
+ randomString(16),
+ recaptchaResponse,
+ true
+ );
+
+ const roomIdOrAlias = await createRoom(client, roomName);
+
+ if (roomIdOrAlias) {
+ history.push(`/room/${roomIdOrAlias}`);
+ }
+ }
+
+ submit().catch((error) => {
+ if (error.errcode === "M_ROOM_IN_USE") {
+ setExistingRoomId(roomAliasFromRoomName(roomName));
+ setLoading(false);
+ setError(undefined);
+ modalState.open();
+ } else {
+ console.error(error);
+ setLoading(false);
+ setError(error);
+ reset();
+ }
+ });
+ },
+ [register, reset, execute]
+ );
+
+ const { modalState, modalProps } = useModalTriggerState();
+ const [existingRoomId, setExistingRoomId] = useState();
+ const history = useHistory();
+ const onJoinExistingRoom = useCallback(() => {
+ history.push(`/${existingRoomId}`);
+ }, [history, existingRoomId]);
+
+ return (
+ <>
+
+
+
+
+
+ Enter a call name
+
+
+
+
+
+ {modalState.isOpen && (
+
+ )}
+ >
+ );
+}
diff --git a/src/home/UnauthenticatedView.module.css b/src/home/UnauthenticatedView.module.css
new file mode 100644
index 0000000..f336ef1
--- /dev/null
+++ b/src/home/UnauthenticatedView.module.css
@@ -0,0 +1,31 @@
+.footer {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 28px;
+}
+
+.footer p {
+ margin-bottom: 0;
+}
+
+.footer .mobileLoginLink {
+ display: flex;
+ margin-bottom: 24px;
+}
+
+.form {
+ padding: 0 24px;
+ justify-content: center;
+ max-width: 360px;
+}
+
+.form > * + * {
+ margin-bottom: 24px;
+}
+
+@media (min-width: 800px) {
+ .mobileLoginLink {
+ display: none;
+ }
+}
diff --git a/src/home/common.module.css b/src/home/common.module.css
new file mode 100644
index 0000000..13eac14
--- /dev/null
+++ b/src/home/common.module.css
@@ -0,0 +1,33 @@
+.container {
+ display: flex;
+ min-height: calc(100% - 64px);
+ flex-direction: column;
+ justify-content: space-between;
+}
+
+.main {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+}
+
+.logo {
+ display: flex;
+ margin-bottom: 54px;
+}
+
+.headline {
+ margin-bottom: 40px;
+}
+
+@media (min-width: 800px) {
+ .logo {
+ display: none;
+ }
+
+ .container {
+ min-height: calc(100% - 76px);
+ }
+}
diff --git a/src/home/useGroupCallRooms.js b/src/home/useGroupCallRooms.js
new file mode 100644
index 0000000..ea075f8
--- /dev/null
+++ b/src/home/useGroupCallRooms.js
@@ -0,0 +1,87 @@
+import { useState, useEffect } from "react";
+
+const tsCache = {};
+
+function getLastTs(client, r) {
+ if (tsCache[r.roomId]) {
+ return tsCache[r.roomId];
+ }
+
+ if (!r || !r.timeline) {
+ const ts = Number.MAX_SAFE_INTEGER;
+ tsCache[r.roomId] = ts;
+ return ts;
+ }
+
+ const myUserId = client.getUserId();
+
+ if (r.getMyMembership() !== "join") {
+ const membershipEvent = r.currentState.getStateEvents(
+ "m.room.member",
+ myUserId
+ );
+
+ if (membershipEvent && !Array.isArray(membershipEvent)) {
+ const ts = membershipEvent.getTs();
+ tsCache[r.roomId] = ts;
+ return ts;
+ }
+ }
+
+ for (let i = r.timeline.length - 1; i >= 0; --i) {
+ const ev = r.timeline[i];
+ const ts = ev.getTs();
+
+ if (ts) {
+ tsCache[r.roomId] = ts;
+ return ts;
+ }
+ }
+
+ const ts = Number.MAX_SAFE_INTEGER;
+ tsCache[r.roomId] = ts;
+ return ts;
+}
+
+function sortRooms(client, rooms) {
+ return rooms.sort((a, b) => {
+ return getLastTs(client, b) - getLastTs(client, a);
+ });
+}
+
+export function useGroupCallRooms(client) {
+ const [rooms, setRooms] = useState([]);
+
+ useEffect(() => {
+ function updateRooms() {
+ const groupCalls = client.groupCallEventHandler.groupCalls.values();
+ const rooms = Array.from(groupCalls).map((groupCall) => groupCall.room);
+ const sortedRooms = sortRooms(client, rooms);
+ const items = sortedRooms.map((room) => {
+ const groupCall = client.getGroupCallForRoom(room.roomId);
+
+ return {
+ roomId: room.getCanonicalAlias() || room.roomId,
+ roomName: room.name,
+ avatarUrl: null,
+ room,
+ groupCall,
+ participants: [...groupCall.participants],
+ };
+ });
+ setRooms(items);
+ }
+
+ updateRooms();
+
+ client.on("GroupCall.incoming", updateRooms);
+ client.on("GroupCall.participants", updateRooms);
+
+ return () => {
+ client.removeListener("GroupCall.incoming", updateRooms);
+ client.removeListener("GroupCall.participants", updateRooms);
+ };
+ }, []);
+
+ return rooms;
+}
diff --git a/src/icons/Copy.svg b/src/icons/Copy.svg
index 5f977f0..e539711 100644
--- a/src/icons/Copy.svg
+++ b/src/icons/Copy.svg
@@ -1,3 +1,3 @@
-