Merge branch 'main' into robertlong/spotlight-layout
This commit is contained in:
commit
e9fc90c55b
84 changed files with 12245 additions and 2989 deletions
3
.env
3
.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
|
||||
|
||||
|
|
14
.storybook/main.js
Normal file
14
.storybook/main.js
Normal file
|
@ -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;
|
||||
},
|
||||
};
|
25
.storybook/preview.jsx
Normal file
25
.storybook/preview.jsx
Normal file
|
@ -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 (
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<OverlayProvider>{story()}</OverlayProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
});
|
12
package.json
12
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"
|
||||
}
|
||||
|
|
70
src/App.jsx
70
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 (
|
||||
<Router history={history}>
|
||||
|
@ -62,7 +37,7 @@ export default function App({ history }) {
|
|||
<OverlayProvider>
|
||||
<Switch>
|
||||
<SentryRoute exact path="/">
|
||||
<Home />
|
||||
<HomePage />
|
||||
</SentryRoute>
|
||||
<SentryRoute exact path="/login">
|
||||
<LoginPage />
|
||||
|
@ -71,7 +46,7 @@ export default function App({ history }) {
|
|||
<RegisterPage />
|
||||
</SentryRoute>
|
||||
<SentryRoute path="/room/:roomId?">
|
||||
<Room />
|
||||
<RoomPage />
|
||||
</SentryRoute>
|
||||
<SentryRoute path="*">
|
||||
<RoomRedirect />
|
||||
|
@ -82,24 +57,3 @@ export default function App({ history }) {
|
|||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
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 <LoadingView />;
|
||||
}
|
||||
|
|
205
src/ClientContext.jsx
Normal file
205
src/ClientContext.jsx
Normal file
|
@ -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 (
|
||||
<ClientContext.Provider value={context}>{children}</ClientContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useClient() {
|
||||
return useContext(ClientContext);
|
||||
}
|
|
@ -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");
|
||||
});
|
||||
};
|
||||
}
|
|
@ -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 (
|
||||
<ClientContext.Provider value={context}>{children}</ClientContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
|
@ -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 (
|
||||
|
|
|
@ -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 (
|
||||
<div
|
||||
className={classNames(styles.nav, styles.leftNav, className)}
|
||||
className={classNames(
|
||||
styles.nav,
|
||||
styles.leftNav,
|
||||
{ [styles.hideMobile]: hideMobile },
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{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 (
|
||||
<div
|
||||
className={classNames(styles.nav, styles.rightNav, className)}
|
||||
className={classNames(
|
||||
styles.nav,
|
||||
styles.rightNav,
|
||||
{ [styles.hideMobile]: hideMobile },
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
|
@ -37,9 +48,9 @@ export function RightNav({ children, className, ...rest }) {
|
|||
);
|
||||
}
|
||||
|
||||
export function HeaderLogo() {
|
||||
export function HeaderLogo({ className }) {
|
||||
return (
|
||||
<Link className={styles.logo} to="/">
|
||||
<Link className={classNames(styles.headerLogo, className)} to="/">
|
||||
<Logo />
|
||||
</Link>
|
||||
);
|
||||
|
@ -51,7 +62,7 @@ export function RoomHeaderInfo({ roomName }) {
|
|||
<div className={styles.roomAvatar}>
|
||||
<VideoIcon width={16} height={16} />
|
||||
</div>
|
||||
<h3>{roomName}</h3>
|
||||
<Subtitle fontWeight="semiBold">{roomName}</Subtitle>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
106
src/Header.stories.jsx
Normal file
106
src/Header.stories.jsx
Normal file
|
@ -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 = () => (
|
||||
<Header>
|
||||
<LeftNav>
|
||||
<HeaderLogo />
|
||||
</LeftNav>
|
||||
<RightNav>
|
||||
<UserMenu />
|
||||
</RightNav>
|
||||
</Header>
|
||||
);
|
||||
|
||||
export const HomeNamedGuest = () => (
|
||||
<Header>
|
||||
<LeftNav>
|
||||
<HeaderLogo />
|
||||
</LeftNav>
|
||||
<RightNav>
|
||||
<UserMenu isAuthenticated isPasswordlessUser displayName="Yara" />
|
||||
</RightNav>
|
||||
</Header>
|
||||
);
|
||||
|
||||
export const HomeLoggedIn = () => (
|
||||
<Header>
|
||||
<LeftNav>
|
||||
<HeaderLogo />
|
||||
</LeftNav>
|
||||
<RightNav>
|
||||
<UserMenu isAuthenticated displayName="Yara" />
|
||||
</RightNav>
|
||||
</Header>
|
||||
);
|
||||
|
||||
export const LobbyNamedGuest = () => (
|
||||
<Header>
|
||||
<LeftNav>
|
||||
<RoomHeaderInfo roomName="Q4Roadmap" />
|
||||
</LeftNav>
|
||||
<RightNav>
|
||||
<UserMenu isAuthenticated isPasswordlessUser displayName="Yara" />
|
||||
</RightNav>
|
||||
</Header>
|
||||
);
|
||||
|
||||
export const LobbyLoggedIn = () => (
|
||||
<Header>
|
||||
<LeftNav>
|
||||
<RoomHeaderInfo roomName="Q4Roadmap" />
|
||||
</LeftNav>
|
||||
<RightNav>
|
||||
<UserMenu isAuthenticated displayName="Yara" />
|
||||
</RightNav>
|
||||
</Header>
|
||||
);
|
||||
|
||||
export const InRoomNamedGuest = () => (
|
||||
<Header>
|
||||
<LeftNav>
|
||||
<RoomHeaderInfo roomName="Q4Roadmap" />
|
||||
</LeftNav>
|
||||
<RightNav>
|
||||
<GridLayoutMenu layout="freedom" />
|
||||
<UserMenu isAuthenticated isPasswordlessUser displayName="Yara" />
|
||||
</RightNav>
|
||||
</Header>
|
||||
);
|
||||
|
||||
export const InRoomLoggedIn = () => (
|
||||
<Header>
|
||||
<LeftNav>
|
||||
<RoomHeaderInfo roomName="Q4Roadmap" />
|
||||
</LeftNav>
|
||||
<RightNav>
|
||||
<GridLayoutMenu layout="freedom" />
|
||||
<UserMenu isAuthenticated displayName="Yara" />
|
||||
</RightNav>
|
||||
</Header>
|
||||
);
|
||||
|
||||
export const CreateAccount = () => (
|
||||
<Header>
|
||||
<LeftNav>
|
||||
<HeaderLogo />
|
||||
</LeftNav>
|
||||
<RightNav></RightNav>
|
||||
</Header>
|
||||
);
|
411
src/Home.jsx
411
src/Home.jsx
|
@ -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 <LoadingView />;
|
||||
} else if (error) {
|
||||
return <ErrorView error={error} />;
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
{!isAuthenticated || isGuest ? (
|
||||
<UnregisteredView
|
||||
onCreateRoom={onCreateRoom}
|
||||
createRoomError={createRoomError}
|
||||
creatingRoom={creatingRoom}
|
||||
onJoinRoom={onJoinRoom}
|
||||
privacyPolicyUrl={privacyPolicyUrl}
|
||||
recaptchaKey={recaptchaKey}
|
||||
setRecaptchaResponse={setRecaptchaResponse}
|
||||
/>
|
||||
) : (
|
||||
<RegisteredView
|
||||
client={client}
|
||||
isPasswordlessUser={isPasswordlessUser}
|
||||
isGuest={isGuest}
|
||||
onCreateRoom={onCreateRoom}
|
||||
createRoomError={createRoomError}
|
||||
creatingRoom={creatingRoom}
|
||||
onJoinRoom={onJoinRoom}
|
||||
/>
|
||||
)}
|
||||
{modalState.isOpen && (
|
||||
<JoinExistingCallModal onJoin={onJoinExistingRoom} {...modalProps} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={classNames(styles.home, styles.fullWidth)}>
|
||||
<Header className={styles.header}>
|
||||
<LeftNav>
|
||||
<HeaderLogo />
|
||||
</LeftNav>
|
||||
<RightNav>
|
||||
<UserMenu />
|
||||
</RightNav>
|
||||
</Header>
|
||||
<div className={styles.splitContainer}>
|
||||
<div className={styles.left}>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.centered}>
|
||||
<form onSubmit={onJoinRoom}>
|
||||
<h1>Join a call</h1>
|
||||
<FieldRow className={styles.fieldRow}>
|
||||
<InputField
|
||||
id="roomId"
|
||||
name="roomId"
|
||||
label="Call ID"
|
||||
type="text"
|
||||
required
|
||||
autoComplete="off"
|
||||
placeholder="Call ID"
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow className={styles.fieldRow}>
|
||||
<Button className={styles.button} type="submit">
|
||||
Join call
|
||||
</Button>
|
||||
</FieldRow>
|
||||
</form>
|
||||
<hr />
|
||||
<form onSubmit={onCreateRoom}>
|
||||
<h1>Create a call</h1>
|
||||
<FieldRow className={styles.fieldRow}>
|
||||
<InputField
|
||||
id="userName"
|
||||
name="userName"
|
||||
label="Username"
|
||||
type="text"
|
||||
required
|
||||
autoComplete="off"
|
||||
placeholder="Username"
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow className={styles.fieldRow}>
|
||||
<InputField
|
||||
id="roomName"
|
||||
name="roomName"
|
||||
label="Room Name"
|
||||
type="text"
|
||||
required
|
||||
autoComplete="off"
|
||||
placeholder="Room Name"
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<InputField
|
||||
id="acceptTerms"
|
||||
type="checkbox"
|
||||
name="acceptTerms"
|
||||
onChange={(e) => setAcceptTerms(e.target.checked)}
|
||||
checked={acceptTerms}
|
||||
label="Accept Privacy Policy"
|
||||
ref={acceptTermsRef}
|
||||
/>
|
||||
<a target="_blank" href={privacyPolicyUrl}>
|
||||
Privacy Policy
|
||||
</a>
|
||||
</FieldRow>
|
||||
{recaptchaKey && (
|
||||
<FieldRow>
|
||||
<RecaptchaInput
|
||||
publicKey={recaptchaKey}
|
||||
onResponse={setRecaptchaResponse}
|
||||
/>
|
||||
</FieldRow>
|
||||
)}
|
||||
{createRoomError && (
|
||||
<FieldRow className={styles.fieldRow}>
|
||||
<ErrorMessage>{createRoomError.message}</ErrorMessage>
|
||||
</FieldRow>
|
||||
)}
|
||||
<FieldRow className={styles.fieldRow}>
|
||||
<Button
|
||||
className={styles.button}
|
||||
type="submit"
|
||||
disabled={creatingRoom}
|
||||
>
|
||||
{creatingRoom ? "Creating call..." : "Create call"}
|
||||
</Button>
|
||||
</FieldRow>
|
||||
</form>
|
||||
|
||||
<div className={styles.authLinks}>
|
||||
<p>
|
||||
Not registered yet?{" "}
|
||||
<Link to="/register">Create an account</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
className={classNames(styles.home, {
|
||||
[styles.fullWidth]: hideCallList,
|
||||
})}
|
||||
>
|
||||
<Header className={styles.header}>
|
||||
<LeftNav className={styles.leftNav}>
|
||||
<HeaderLogo />
|
||||
</LeftNav>
|
||||
<RightNav>
|
||||
<UserMenu />
|
||||
</RightNav>
|
||||
</Header>
|
||||
<div className={styles.splitContainer}>
|
||||
<div className={styles.left}>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.centered}>
|
||||
<form onSubmit={onJoinRoom}>
|
||||
<h1>Join a call</h1>
|
||||
<FieldRow className={styles.fieldRow}>
|
||||
<InputField
|
||||
id="roomId"
|
||||
name="roomId"
|
||||
label="Call ID"
|
||||
type="text"
|
||||
required
|
||||
autoComplete="off"
|
||||
placeholder="Call ID"
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow className={styles.fieldRow}>
|
||||
<Button className={styles.button} type="submit">
|
||||
Join call
|
||||
</Button>
|
||||
</FieldRow>
|
||||
</form>
|
||||
<hr />
|
||||
<form onSubmit={onCreateRoom}>
|
||||
<h1>Create a call</h1>
|
||||
<FieldRow className={styles.fieldRow}>
|
||||
<InputField
|
||||
id="roomName"
|
||||
name="roomName"
|
||||
label="Room Name"
|
||||
type="text"
|
||||
required
|
||||
autoComplete="off"
|
||||
placeholder="Room Name"
|
||||
/>
|
||||
</FieldRow>
|
||||
{createRoomError && (
|
||||
<FieldRow className={styles.fieldRow}>
|
||||
<ErrorMessage>{createRoomError.message}</ErrorMessage>
|
||||
</FieldRow>
|
||||
)}
|
||||
<FieldRow className={styles.fieldRow}>
|
||||
<Button
|
||||
className={styles.button}
|
||||
type="submit"
|
||||
disabled={creatingRoom}
|
||||
>
|
||||
{creatingRoom ? "Creating call..." : "Create call"}
|
||||
</Button>
|
||||
</FieldRow>
|
||||
</form>
|
||||
{(isPasswordlessUser || isGuest) && (
|
||||
<div className={styles.authLinks}>
|
||||
<p>
|
||||
Not registered yet?{" "}
|
||||
<Link to="/register">Create an account</Link>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!hideCallList && (
|
||||
<div className={styles.right}>
|
||||
<div className={styles.content}>
|
||||
{publicRooms.length > 0 && (
|
||||
<CallList
|
||||
title="Public Calls"
|
||||
rooms={publicRooms}
|
||||
client={client}
|
||||
/>
|
||||
)}
|
||||
{recentRooms.length > 0 && (
|
||||
<CallList
|
||||
title="Recent Calls"
|
||||
rooms={recentRooms}
|
||||
client={client}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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 (
|
||||
<div ref={containerRef}>
|
||||
<div ref={recaptchaRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
572
src/Room.jsx
572
src/Room.jsx
|
@ -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 <LoadingView />;
|
||||
}
|
||||
|
||||
if (registrationError || error) {
|
||||
return <ErrorView error={registrationError || error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<GroupCall
|
||||
client={client}
|
||||
isGuest={isGuest}
|
||||
isPasswordlessUser={isPasswordlessUser}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 <LoadingRoomView />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ErrorView error={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<GroupCallView
|
||||
isGuest={isGuest}
|
||||
isPasswordlessUser={isPasswordlessUser}
|
||||
client={client}
|
||||
roomId={roomId}
|
||||
groupCall={groupCall}
|
||||
simpleGrid={simpleGrid}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 <ErrorView error={error} />;
|
||||
} else if (state === GroupCallState.Entered) {
|
||||
return (
|
||||
<InRoomView
|
||||
groupCall={groupCall}
|
||||
client={client}
|
||||
isGuest={isGuest}
|
||||
roomName={groupCall.room.name}
|
||||
microphoneMuted={microphoneMuted}
|
||||
localVideoMuted={localVideoMuted}
|
||||
toggleLocalVideoMuted={toggleLocalVideoMuted}
|
||||
toggleMicrophoneMuted={toggleMicrophoneMuted}
|
||||
userMediaFeeds={userMediaFeeds}
|
||||
activeSpeaker={activeSpeaker}
|
||||
onLeave={onLeave}
|
||||
toggleScreensharing={toggleScreensharing}
|
||||
isScreensharing={isScreensharing}
|
||||
localScreenshareFeed={localScreenshareFeed}
|
||||
screenshareFeeds={screenshareFeeds}
|
||||
simpleGrid={simpleGrid}
|
||||
setShowInspector={setShowInspector}
|
||||
showInspector={showInspector}
|
||||
roomId={roomId}
|
||||
/>
|
||||
);
|
||||
} else if (state === GroupCallState.Entering) {
|
||||
return <EnteringRoomView />;
|
||||
} else if (left) {
|
||||
if (isPasswordlessUser) {
|
||||
return <PasswordlessUserCallEndedScreen client={client} />;
|
||||
} else {
|
||||
return <GuestCallEndedScreen />;
|
||||
}
|
||||
} else {
|
||||
return (
|
||||
<RoomSetupView
|
||||
isGuest={isGuest}
|
||||
client={client}
|
||||
hasLocalParticipant={hasLocalParticipant}
|
||||
roomName={groupCall.room.name}
|
||||
state={state}
|
||||
onInitLocalCallFeed={initLocalCallFeed}
|
||||
localCallFeed={localCallFeed}
|
||||
onEnter={enter}
|
||||
microphoneMuted={microphoneMuted}
|
||||
localVideoMuted={localVideoMuted}
|
||||
toggleLocalVideoMuted={toggleLocalVideoMuted}
|
||||
toggleMicrophoneMuted={toggleMicrophoneMuted}
|
||||
setShowInspector={setShowInspector}
|
||||
showInspector={showInspector}
|
||||
roomId={roomId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function LoadingRoomView() {
|
||||
return (
|
||||
<FullScreenView>
|
||||
<h1>Loading room...</h1>
|
||||
</FullScreenView>
|
||||
);
|
||||
}
|
||||
|
||||
export function EnteringRoomView() {
|
||||
return (
|
||||
<FullScreenView>
|
||||
<h1>Entering room...</h1>
|
||||
</FullScreenView>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={styles.room}>
|
||||
<Header>
|
||||
<LeftNav>
|
||||
<RoomHeaderInfo roomName={roomName} />
|
||||
</LeftNav>
|
||||
<RightNav>
|
||||
<UserMenu />
|
||||
</RightNav>
|
||||
</Header>
|
||||
<div className={styles.joinRoom}>
|
||||
<div className={styles.joinRoomContent}>
|
||||
<div className={styles.preview}>
|
||||
<video ref={videoRef} muted playsInline disablePictureInPicture />
|
||||
{state === GroupCallState.LocalCallFeedUninitialized && (
|
||||
<p className={styles.webcamPermissions}>
|
||||
Webcam/microphone permissions needed to join the call.
|
||||
</p>
|
||||
)}
|
||||
{state === GroupCallState.InitializingLocalCallFeed && (
|
||||
<p className={styles.webcamPermissions}>
|
||||
Accept webcam/microphone permissions to join the call.
|
||||
</p>
|
||||
)}
|
||||
{state === GroupCallState.LocalCallFeedInitialized && (
|
||||
<>
|
||||
<Button
|
||||
className={styles.joinCallButton}
|
||||
disabled={state !== GroupCallState.LocalCallFeedInitialized}
|
||||
onPress={onEnter}
|
||||
>
|
||||
Join call now
|
||||
</Button>
|
||||
<div className={styles.previewButtons}>
|
||||
<MicButton
|
||||
muted={microphoneMuted}
|
||||
onPress={toggleMicrophoneMuted}
|
||||
/>
|
||||
<VideoButton
|
||||
muted={localVideoMuted}
|
||||
onPress={toggleLocalVideoMuted}
|
||||
/>
|
||||
<OverflowMenu
|
||||
roomId={roomId}
|
||||
setShowInspector={setShowInspector}
|
||||
showInspector={showInspector}
|
||||
client={client}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p>Or</p>
|
||||
<CopyButton
|
||||
value={getRoomUrl(roomId)}
|
||||
className={styles.copyButton}
|
||||
copiedMessage="Call link copied"
|
||||
>
|
||||
Copy call link and join later
|
||||
</CopyButton>
|
||||
</div>
|
||||
<div className={styles.joinRoomFooter}>
|
||||
<Link className={styles.homeLink} to="/">
|
||||
Take me Home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Avatar
|
||||
key={roomMember.userId}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size,
|
||||
fontSize: Math.round(size / 2),
|
||||
}}
|
||||
src={avatarUrl && getAvatarUrl(client, avatarUrl, 96)}
|
||||
fallback={roomMember.name.slice(0, 1).toUpperCase()}
|
||||
className={styles.avatar}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[client]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.room, styles.inRoom)}>
|
||||
<Header>
|
||||
<LeftNav>
|
||||
<RoomHeaderInfo roomName={roomName} />
|
||||
</LeftNav>
|
||||
<RightNav>
|
||||
<GridLayoutMenu layout={layout} setLayout={setLayout} />
|
||||
{!isGuest && <UserMenu disableLogout />}
|
||||
</RightNav>
|
||||
</Header>
|
||||
{items.length === 0 ? (
|
||||
<div className={styles.centerMessage}>
|
||||
<p>Waiting for other participants...</p>
|
||||
</div>
|
||||
) : simpleGrid ? (
|
||||
<SimpleVideoGrid items={items} />
|
||||
) : (
|
||||
<VideoGrid
|
||||
items={items}
|
||||
layout={layout}
|
||||
getAvatar={renderAvatar}
|
||||
onFocusTile={onFocusTile}
|
||||
disableAnimations={isSafari}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.footer}>
|
||||
<MicButton muted={microphoneMuted} onPress={toggleMicrophoneMuted} />
|
||||
<VideoButton muted={localVideoMuted} onPress={toggleLocalVideoMuted} />
|
||||
{canScreenshare && !isSafari && (
|
||||
<ScreenshareButton
|
||||
enabled={isScreensharing}
|
||||
onPress={toggleScreensharing}
|
||||
/>
|
||||
)}
|
||||
<OverflowMenu
|
||||
roomId={roomId}
|
||||
setShowInspector={setShowInspector}
|
||||
showInspector={showInspector}
|
||||
client={client}
|
||||
/>
|
||||
<HangupButton onPress={onLeave} />
|
||||
</div>
|
||||
<GroupCallInspector
|
||||
client={client}
|
||||
groupCall={groupCall}
|
||||
show={showInspector}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GuestCallEndedScreen() {
|
||||
return (
|
||||
<FullScreenView className={styles.callEndedScreen}>
|
||||
<h1>Your call is now ended</h1>
|
||||
<div className={styles.callEndedContent}>
|
||||
<p>Why not finish by creating an account?</p>
|
||||
<p>You'll be able to:</p>
|
||||
<ul>
|
||||
<li>Easily access all your previous call links</li>
|
||||
<li>Set a username and avatar</li>
|
||||
</ul>
|
||||
<LinkButton
|
||||
className={styles.callEndedButton}
|
||||
size="lg"
|
||||
variant="default"
|
||||
to="/register"
|
||||
>
|
||||
Create account
|
||||
</LinkButton>
|
||||
</div>
|
||||
<Link to="/">Not now, return to home screen</Link>
|
||||
</FullScreenView>
|
||||
);
|
||||
}
|
||||
|
||||
export function PasswordlessUserCallEndedScreen({ client }) {
|
||||
const { displayName } = useProfile(client);
|
||||
|
||||
return (
|
||||
<FullScreenView className={styles.callEndedScreen}>
|
||||
<h1>{displayName}, your call is now ended</h1>
|
||||
<div className={styles.callEndedContent}>
|
||||
<p>Why not finish by setting up a password to keep your account?</p>
|
||||
<p>
|
||||
You'll be able to keep your name and set an avatar for use on future
|
||||
calls
|
||||
</p>
|
||||
<LinkButton
|
||||
className={styles.callEndedButton}
|
||||
size="lg"
|
||||
variant="default"
|
||||
to="/register"
|
||||
>
|
||||
Create account
|
||||
</LinkButton>
|
||||
</div>
|
||||
<Link to="/">Not now, return to home screen</Link>
|
||||
</FullScreenView>
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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 }) {
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const {
|
||||
export function UserMenu({
|
||||
disableLogout,
|
||||
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]
|
||||
);
|
||||
displayName,
|
||||
avatarUrl,
|
||||
onAction,
|
||||
}) {
|
||||
const location = useLocation();
|
||||
|
||||
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 (
|
||||
<LinkButton to={{ pathname: "/login", state: { from: location } }}>
|
||||
Log in
|
||||
|
@ -84,15 +60,15 @@ export function UserMenu({ disableLogout }) {
|
|||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PopoverMenuTrigger placement="bottom right">
|
||||
<TooltipTrigger>
|
||||
<Button variant="icon" className={styles.userButton}>
|
||||
{isAuthenticated && !isGuest && !isPasswordlessUser ? (
|
||||
{isAuthenticated && !isPasswordlessUser ? (
|
||||
<Avatar
|
||||
size="sm"
|
||||
className={styles.avatar}
|
||||
src={avatarUrl}
|
||||
fallback={(displayName || userName).slice(0, 1).toUpperCase()}
|
||||
fallback={displayName.slice(0, 1).toUpperCase()}
|
||||
/>
|
||||
) : (
|
||||
<UserIcon />
|
||||
|
@ -115,15 +91,5 @@ export function UserMenu({ disableLogout }) {
|
|||
</Menu>
|
||||
)}
|
||||
</PopoverMenuTrigger>
|
||||
{modalState.isOpen && (
|
||||
<ProfileModal
|
||||
client={client}
|
||||
isAuthenticated={isAuthenticated}
|
||||
isGuest={isGuest}
|
||||
isPasswordlessUser={isPasswordlessUser}
|
||||
{...modalProps}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
56
src/UserMenuContainer.jsx
Normal file
56
src/UserMenuContainer.jsx
Normal file
|
@ -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 (
|
||||
<>
|
||||
<UserMenu
|
||||
disableLogout={disableLogout}
|
||||
isAuthenticated={isAuthenticated}
|
||||
isPasswordlessUser={isPasswordlessUser}
|
||||
avatarUrl={avatarUrl}
|
||||
onAction={onAction}
|
||||
displayName={
|
||||
displayName || (userName ? userName.replace("@", "") : undefined)
|
||||
}
|
||||
/>
|
||||
{modalState.isOpen && (
|
||||
<ProfileModal
|
||||
client={client}
|
||||
isAuthenticated={isAuthenticated}
|
||||
isPasswordlessUser={isPasswordlessUser}
|
||||
{...modalProps}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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();
|
|
@ -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,16 +53,23 @@ 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;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
setRegistering(true);
|
||||
|
||||
changePassword(password)
|
||||
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);
|
||||
|
@ -76,31 +80,8 @@ export function RegisterPage() {
|
|||
.catch((error) => {
|
||||
setError(error);
|
||||
setRegistering(false);
|
||||
reset();
|
||||
});
|
||||
} 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);
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
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() {
|
|||
/>
|
||||
</FieldRow>
|
||||
{!isPasswordlessUser && (
|
||||
<FieldRow>
|
||||
<InputField
|
||||
id="acceptTerms"
|
||||
type="checkbox"
|
||||
name="acceptTerms"
|
||||
onChange={(e) => setAcceptTerms(e.target.checked)}
|
||||
checked={acceptTerms}
|
||||
label="Accept Privacy Policy"
|
||||
ref={acceptTermsRef}
|
||||
/>
|
||||
<a target="_blank" href={privacyPolicyUrl}>
|
||||
<Caption>
|
||||
This site is protected by ReCAPTCHA and the Google{" "}
|
||||
<Link href="https://www.google.com/policies/privacy/">
|
||||
Privacy Policy
|
||||
</a>
|
||||
</FieldRow>
|
||||
)}
|
||||
{!isPasswordlessUser && recaptchaKey && (
|
||||
<FieldRow>
|
||||
<RecaptchaInput
|
||||
publicKey={recaptchaKey}
|
||||
onResponse={setRecaptchaResponse}
|
||||
/>
|
||||
</FieldRow>
|
||||
</Link>{" "}
|
||||
and{" "}
|
||||
<Link href="https://policies.google.com/terms">
|
||||
Terms of Service
|
||||
</Link>{" "}
|
||||
apply.
|
||||
<br />
|
||||
By clicking "Go", you agree to our{" "}
|
||||
<Link href={privacyPolicyUrl}>Terms and conditions</Link>
|
||||
</Caption>
|
||||
)}
|
||||
{error && (
|
||||
<FieldRow>
|
||||
|
@ -231,6 +191,7 @@ export function RegisterPage() {
|
|||
{registering ? "Registering..." : "Register"}
|
||||
</Button>
|
||||
</FieldRow>
|
||||
<div id={recaptchaId} />
|
||||
</form>
|
||||
</div>
|
||||
<div className={styles.authLinks}>
|
45
src/auth/useInteractiveLogin.js
Normal file
45
src/auth/useInteractiveLogin.js
Normal file
|
@ -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];
|
||||
}
|
83
src/auth/useInteractiveRegistration.js
Normal file
83
src/auth/useInteractiveRegistration.js
Normal file
|
@ -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];
|
||||
}
|
106
src/auth/useRecaptcha.js
Normal file
106
src/auth/useRecaptcha.js
Normal file
|
@ -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 };
|
||||
}
|
|
@ -62,7 +62,7 @@ export const Button = forwardRef(
|
|||
[styles.off]: off,
|
||||
}
|
||||
)}
|
||||
{...filteredButtonProps}
|
||||
{...mergeProps(rest, filteredButtonProps)}
|
||||
ref={buttonRef}
|
||||
>
|
||||
{children}
|
||||
|
|
11
src/form/Form.jsx
Normal file
11
src/form/Form.jsx
Normal file
|
@ -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 (
|
||||
<form {...rest} className={classNames(styles.form, className)} ref={ref}>
|
||||
{children}
|
||||
</form>
|
||||
);
|
||||
});
|
4
src/form/Form.module.css
Normal file
4
src/form/Form.module.css
Normal file
|
@ -0,0 +1,4 @@
|
|||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
|
@ -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 (
|
||||
<>
|
||||
<h3>{title}</h3>
|
||||
<div className={styles.callList}>
|
||||
{rooms.map(({ roomId, roomName, avatarUrl, participants }) => (
|
||||
<CallTile
|
||||
|
@ -22,6 +22,12 @@ export function CallList({ title, rooms, client }) {
|
|||
participants={participants}
|
||||
/>
|
||||
))}
|
||||
{rooms.length > 3 && (
|
||||
<>
|
||||
<div className={styles.callTileSpacer} />
|
||||
<div className={styles.callTileSpacer} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
@ -32,17 +38,23 @@ function CallTile({ name, avatarUrl, roomId, participants, client }) {
|
|||
<div className={styles.callTile}>
|
||||
<Link to={`/room/${roomId}`} className={styles.callTileLink}>
|
||||
<Avatar
|
||||
size="md"
|
||||
size="lg"
|
||||
bgKey={name}
|
||||
src={avatarUrl}
|
||||
fallback={<VideoIcon width={16} height={16} />}
|
||||
className={styles.avatar}
|
||||
/>
|
||||
<div className={styles.callInfo}>
|
||||
<h5>{name}</h5>
|
||||
<p>{getRoomUrl(roomId)}</p>
|
||||
<Body overflowEllipsis fontWeight="semiBold">
|
||||
{name}
|
||||
</Body>
|
||||
<Caption overflowEllipsis>{getRoomUrl(roomId)}</Caption>
|
||||
{participants && (
|
||||
<Facepile client={client} participants={participants} />
|
||||
<Facepile
|
||||
className={styles.facePile}
|
||||
client={client}
|
||||
participants={participants}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.copyButtonSpacer} />
|
|
@ -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;
|
||||
}
|
38
src/home/HomePage.jsx
Normal file
38
src/home/HomePage.jsx
Normal file
|
@ -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 <LoadingView />;
|
||||
} else if (error) {
|
||||
return <ErrorView error={error} />;
|
||||
} else {
|
||||
return isAuthenticated ? (
|
||||
<RegisteredView isPasswordlessUser={isPasswordlessUser} client={client} />
|
||||
) : (
|
||||
<UnauthenticatedView />
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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 }) {
|
120
src/home/RegisteredView.jsx
Normal file
120
src/home/RegisteredView.jsx
Normal file
|
@ -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 (
|
||||
<>
|
||||
<Header>
|
||||
<LeftNav>
|
||||
<HeaderLogo />
|
||||
</LeftNav>
|
||||
<RightNav>
|
||||
<UserMenuContainer />
|
||||
</RightNav>
|
||||
</Header>
|
||||
<div className={commonStyles.container}>
|
||||
<main className={commonStyles.main}>
|
||||
<HeaderLogo className={commonStyles.logo} />
|
||||
<Headline className={commonStyles.headline}>
|
||||
Enter a call name
|
||||
</Headline>
|
||||
<Form className={styles.form} onSubmit={onSubmit}>
|
||||
<FieldRow className={styles.fieldRow}>
|
||||
<InputField
|
||||
id="callName"
|
||||
name="callName"
|
||||
label="Call name"
|
||||
placeholder="Call name"
|
||||
type="text"
|
||||
required
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
className={styles.button}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "Loading..." : "Go"}
|
||||
</Button>
|
||||
</FieldRow>
|
||||
{error && (
|
||||
<FieldRow className={styles.fieldRow}>
|
||||
<ErrorMessage>{error.message}</ErrorMessage>
|
||||
</FieldRow>
|
||||
)}
|
||||
</Form>
|
||||
{recentRooms.length > 0 && (
|
||||
<>
|
||||
<Title className={styles.recentCallsTitle}>
|
||||
Your recent Calls
|
||||
</Title>
|
||||
<CallList rooms={recentRooms} client={client} />
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
{modalState.isOpen && (
|
||||
<JoinExistingCallModal onJoin={onJoinExistingRoom} {...modalProps} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
19
src/home/RegisteredView.module.css
Normal file
19
src/home/RegisteredView.module.css
Normal file
|
@ -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;
|
||||
}
|
146
src/home/UnauthenticatedView.jsx
Normal file
146
src/home/UnauthenticatedView.jsx
Normal file
|
@ -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 (
|
||||
<>
|
||||
<Header>
|
||||
<LeftNav>
|
||||
<HeaderLogo />
|
||||
</LeftNav>
|
||||
<RightNav hideMobile>
|
||||
<UserMenuContainer />
|
||||
</RightNav>
|
||||
</Header>
|
||||
<div className={commonStyles.container}>
|
||||
<main className={commonStyles.main}>
|
||||
<HeaderLogo className={commonStyles.logo} />
|
||||
<Headline className={commonStyles.headline}>
|
||||
Enter a call name
|
||||
</Headline>
|
||||
<Form className={styles.form} onSubmit={onSubmit}>
|
||||
<FieldRow>
|
||||
<InputField
|
||||
id="callName"
|
||||
name="callName"
|
||||
label="Call name"
|
||||
placeholder="Call name"
|
||||
type="text"
|
||||
required
|
||||
autoComplete="off"
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<InputField
|
||||
id="userName"
|
||||
name="userName"
|
||||
label="Your name"
|
||||
placeholder="Your name"
|
||||
type="text"
|
||||
required
|
||||
autoComplete="off"
|
||||
/>
|
||||
</FieldRow>
|
||||
<Caption>
|
||||
By clicking "Go", you agree to our{" "}
|
||||
<Link href={privacyPolicyUrl}>Terms and conditions</Link>
|
||||
</Caption>
|
||||
{error && (
|
||||
<FieldRow>
|
||||
<ErrorMessage>{error.message}</ErrorMessage>
|
||||
</FieldRow>
|
||||
)}
|
||||
<Button type="submit" size="lg" disabled={loading}>
|
||||
{loading ? "Loading..." : "Go"}
|
||||
</Button>
|
||||
<div id={recaptchaId} />
|
||||
</Form>
|
||||
</main>
|
||||
<footer className={styles.footer}>
|
||||
<Body className={styles.mobileLoginLink}>
|
||||
<Link color="primary" to="/login">
|
||||
Login to your account
|
||||
</Link>
|
||||
</Body>
|
||||
<Body>
|
||||
Not registered yet?{" "}
|
||||
<Link color="primary" to="/register">
|
||||
Create an account
|
||||
</Link>
|
||||
</Body>
|
||||
</footer>
|
||||
</div>
|
||||
{modalState.isOpen && (
|
||||
<JoinExistingCallModal onJoin={onJoinExistingRoom} {...modalProps} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
31
src/home/UnauthenticatedView.module.css
Normal file
31
src/home/UnauthenticatedView.module.css
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
33
src/home/common.module.css
Normal file
33
src/home/common.module.css
Normal file
|
@ -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);
|
||||
}
|
||||
}
|
87
src/home/useGroupCallRooms.js
Normal file
87
src/home/useGroupCallRooms.js
Normal file
|
@ -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;
|
||||
}
|
|
@ -1,3 +1,3 @@
|
|||
<svg width="21" height="24" viewBox="0 0 21 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.03125 3C4.3744 3 3.03125 4.34315 3.03125 6V13C3.03125 14.6569 4.3744 16 6.03125 16H7.07407V18C7.07407 19.6569 8.41722 21 10.0741 21H14.9683C16.6251 21 17.9683 19.6569 17.9683 18V11C17.9683 9.34315 16.6251 8 14.9683 8H13.9255V6C13.9255 4.34315 12.5823 3 10.9255 3H6.03125ZM11.9255 8V6C11.9255 5.44772 11.4777 5 10.9255 5H6.03125C5.47897 5 5.03125 5.44772 5.03125 6V13C5.03125 13.5523 5.47897 14 6.03125 14H7.07407V11C7.07407 9.34315 8.41722 8 10.0741 8H11.9255ZM9.07407 11C9.07407 10.4477 9.52179 10 10.0741 10H14.9683C15.5206 10 15.9683 10.4477 15.9683 11V18C15.9683 18.5523 15.5206 19 14.9683 19H10.0741C9.52179 19 9.07407 18.5523 9.07407 18V11Z" fill="#0DBD8B"/>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M4 2C2.89543 2 2 2.89543 2 4V8.66667C2 9.77124 2.89543 10.6667 4 10.6667H5.33333V12C5.33333 13.1046 6.22877 14 7.33333 14H12C13.1046 14 14 13.1046 14 12V7.33333C14 6.22876 13.1046 5.33333 12 5.33333H10.6667V4C10.6667 2.89543 9.77123 2 8.66667 2H4ZM9.33333 5.33333V4C9.33333 3.63181 9.03486 3.33333 8.66667 3.33333H4C3.63181 3.33333 3.33333 3.63181 3.33333 4V8.66667C3.33333 9.03486 3.63181 9.33333 4 9.33333H5.33333V7.33333C5.33333 6.22877 6.22876 5.33333 7.33333 5.33333H9.33333ZM6.66667 7.33333C6.66667 6.96514 6.96514 6.66667 7.33333 6.66667H12C12.3682 6.66667 12.6667 6.96514 12.6667 7.33333V12C12.6667 12.3682 12.3682 12.6667 12 12.6667H7.33333C6.96514 12.6667 6.66667 12.3682 6.66667 12V7.33333Z" fill="#8E99A4"/>
|
||||
</svg>
|
||||
|
|
Before Width: | Height: | Size: 820 B After Width: | Height: | Size: 872 B |
|
@ -1,6 +1,3 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="18" y="10" width="5" height="5" rx="1" fill="white"/>
|
||||
<rect x="18" y="16" width="5" height="5" rx="1" fill="white"/>
|
||||
<rect x="18" y="4" width="5" height="5" rx="1" fill="white"/>
|
||||
<rect x="1" y="4" width="16" height="17" rx="1" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M4 17.8689V2.51551C4 2.06669 4.53728 1.83452 4.86986 2.13591C8.18767 5.14263 10.9111 7.48209 13.102 9.36399L13.102 9.36403C18.3295 13.8544 20.5243 15.7398 20.5243 17.8689C20.5243 19.4181 19.6538 20.0153 18.1044 20.79C16.5549 21.5648 14.4534 22 12.2621 22C10.0709 22 7.96938 21.5648 6.41992 20.79C4.87047 20.0153 4 18.9646 4 17.8689ZM12.2621 20.9673C16.2548 20.9673 19.4915 19.5801 19.4915 17.869C19.4915 16.1578 16.2548 14.7707 12.2621 14.7707C8.26947 14.7707 5.03277 16.1578 5.03277 17.869C5.03277 19.5801 8.26947 20.9673 12.2621 20.9673ZM16.2618 8.67876C16.1718 8.64549 16.1718 8.51831 16.2618 8.48504L17.84 7.90103C17.8683 7.89057 17.8906 7.86828 17.901 7.84001L18.4851 6.26174C18.5183 6.17182 18.6455 6.17182 18.6788 6.26174L19.2628 7.84001C19.2733 7.86828 19.2955 7.89057 19.3238 7.90103L20.9021 8.48504C20.992 8.51831 20.992 8.64549 20.9021 8.67876L19.3238 9.26277C19.2955 9.27323 19.2733 9.29552 19.2628 9.32379L18.6788 10.9021C18.6455 10.992 18.5183 10.992 18.4851 10.9021L17.901 9.32379C17.8906 9.29552 17.8683 9.27323 17.84 9.26277L16.2618 8.67876ZM13.2618 5.45232C13.1718 5.48559 13.1718 5.61276 13.2618 5.64604L14.0862 5.95111C14.1145 5.96157 14.1368 5.98386 14.1472 6.01213L14.4523 6.83657C14.4856 6.92649 14.6127 6.92649 14.646 6.83657L14.9511 6.01213C14.9615 5.98386 14.9838 5.96157 15.0121 5.95111L15.8365 5.64603C15.9265 5.61276 15.9265 5.48559 15.8365 5.45232L15.0121 5.14725C14.9838 5.13679 14.9615 5.1145 14.9511 5.08623L14.646 4.26178C14.6127 4.17187 14.4856 4.17187 14.4523 4.26178L14.1472 5.08623C14.1368 5.1145 14.1145 5.13679 14.0862 5.14725L13.2618 5.45232Z" fill="white"/>
|
||||
</svg>
|
||||
|
|
Before Width: | Height: | Size: 354 B After Width: | Height: | Size: 1.7 KiB |
|
@ -20,6 +20,8 @@ limitations under the License.
|
|||
Therefore we define a unicode-range to load which excludes the glyphs
|
||||
(to avoid having to maintain a fork of Inter). */
|
||||
|
||||
@import "normalize.css/normalize.css";
|
||||
|
||||
:root {
|
||||
--inter-unicode-range: U+0000-20e2, U+20e4-23ce, U+23d0-24c1, U+24c3-259f,
|
||||
U+25c2-2664, U+2666-2763, U+2765-2b05, U+2b07-2b1b, U+2b1d-10FFFF;
|
||||
|
@ -35,6 +37,7 @@ limitations under the License.
|
|||
--textColor4: #a9b2bc;
|
||||
--inputBorderColor: #394049;
|
||||
--inputBorderColorFocused: #0086e6;
|
||||
--linkColor: #0086e6;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
|
@ -139,6 +142,44 @@ body,
|
|||
flex-direction: column;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
p,
|
||||
a {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Headline Semi Bold */
|
||||
h1 {
|
||||
font-weight: 600;
|
||||
font-size: 32px;
|
||||
line-height: 39px;
|
||||
}
|
||||
|
||||
/* Title */
|
||||
h2 {
|
||||
font-weight: 600;
|
||||
font-size: 24px;
|
||||
line-height: 29px;
|
||||
}
|
||||
|
||||
/* Subtitle */
|
||||
h3 {
|
||||
font-weight: 400;
|
||||
font-size: 18px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
/* Body */
|
||||
p {
|
||||
font-size: 15px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--primaryColor);
|
||||
text-decoration: none;
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import React, { forwardRef } from "react";
|
||||
import classNames from "classnames";
|
||||
import styles from "./Input.module.css";
|
||||
import { ReactComponent as CheckIcon } from "./icons/Check.svg";
|
||||
import { ReactComponent as CheckIcon } from "../icons/Check.svg";
|
||||
|
||||
export function FieldRow({ children, rightAlign, className, ...rest }) {
|
||||
return (
|
|
@ -2,11 +2,11 @@ import React, { useRef } from "react";
|
|||
import { HiddenSelect, useSelect } from "@react-aria/select";
|
||||
import { useButton } from "@react-aria/button";
|
||||
import { useSelectState } from "@react-stately/select";
|
||||
import { Popover } from "./Popover";
|
||||
import { ListBox } from "./ListBox";
|
||||
import { Popover } from "../popover/Popover";
|
||||
import { ListBox } from "../ListBox";
|
||||
import styles from "./SelectInput.module.css";
|
||||
import classNames from "classnames";
|
||||
import { ReactComponent as ArrowDownIcon } from "./icons/ArrowDown.svg";
|
||||
import { ReactComponent as ArrowDownIcon } from "../icons/ArrowDown.svg";
|
||||
|
||||
export function SelectInput(props) {
|
||||
const state = useSelectState(props);
|
111
src/matrix-utils.js
Normal file
111
src/matrix-utils.js
Normal file
|
@ -0,0 +1,111 @@
|
|||
import matrix from "matrix-js-sdk/src/browser-index";
|
||||
import {
|
||||
GroupCallIntent,
|
||||
GroupCallType,
|
||||
} from "matrix-js-sdk/src/browser-index";
|
||||
|
||||
export const defaultHomeserver =
|
||||
import.meta.env.VITE_DEFAULT_HOMESERVER ||
|
||||
`${window.location.protocol}//${window.location.host}`;
|
||||
|
||||
export const defaultHomeserverHost = new URL(defaultHomeserver).host;
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
export async function initClient(clientOptions) {
|
||||
const client = matrix.createClient({
|
||||
...clientOptions,
|
||||
useAuthorizationHeader: 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 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.createGroupCall(
|
||||
room_id,
|
||||
GroupCallType.Video,
|
||||
GroupCallIntent.Prompt
|
||||
);
|
||||
|
||||
return room_alias || room_id;
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
|
@ -3,11 +3,11 @@ import { DismissButton, useOverlay } from "@react-aria/overlays";
|
|||
import { FocusScope } from "@react-aria/focus";
|
||||
import classNames from "classnames";
|
||||
import styles from "./Popover.module.css";
|
||||
import { useObjectRef } from "@react-aria/utils";
|
||||
|
||||
export const Popover = forwardRef(
|
||||
({ isOpen = true, onClose, className, children, ...rest }, ref) => {
|
||||
const fallbackRef = useRef();
|
||||
const popoverRef = ref || fallbackRef;
|
||||
const popoverRef = useObjectRef(ref);
|
||||
|
||||
const { overlayProps } = useOverlay(
|
||||
{
|
|
@ -1,14 +1,13 @@
|
|||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { Button } from "./button";
|
||||
import { useProfile } from "./ConferenceCallManagerHooks";
|
||||
import { FieldRow, InputField, ErrorMessage } from "./Input";
|
||||
import { Modal, ModalContent } from "./Modal";
|
||||
import { Button } from "../button";
|
||||
import { useProfile } from "./useProfile";
|
||||
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
|
||||
import { Modal, ModalContent } from "../Modal";
|
||||
|
||||
export function ProfileModal({
|
||||
client,
|
||||
isAuthenticated,
|
||||
isPasswordlessUser,
|
||||
isGuest,
|
||||
...rest
|
||||
}) {
|
||||
const { onClose } = rest;
|
||||
|
@ -66,7 +65,7 @@ export function ProfileModal({
|
|||
onChange={onChangeDisplayName}
|
||||
/>
|
||||
</FieldRow>
|
||||
{isAuthenticated && !isGuest && !isPasswordlessUser && (
|
||||
{isAuthenticated && !isPasswordlessUser && (
|
||||
<FieldRow>
|
||||
<InputField
|
||||
type="file"
|
91
src/profile/useProfile.js
Normal file
91
src/profile/useProfile.js
Normal file
|
@ -0,0 +1,91 @@
|
|||
import { useState, useCallback, useEffect } from "react";
|
||||
import { getAvatarUrl } from "../matrix-utils";
|
||||
|
||||
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 };
|
||||
}
|
50
src/room/CallEndedView.jsx
Normal file
50
src/room/CallEndedView.jsx
Normal file
|
@ -0,0 +1,50 @@
|
|||
import React from "react";
|
||||
import styles from "./CallEndedView.module.css";
|
||||
import { LinkButton } from "../button";
|
||||
import { useProfile } from "../profile/useProfile";
|
||||
import { Subtitle, Body, Link, Headline } from "../typography/Typography";
|
||||
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
|
||||
|
||||
export function CallEndedView({ client }) {
|
||||
const { displayName } = useProfile(client);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header>
|
||||
<LeftNav>
|
||||
<HeaderLogo />
|
||||
</LeftNav>
|
||||
<RightNav />
|
||||
</Header>
|
||||
<div className={styles.container}>
|
||||
<main className={styles.main}>
|
||||
<Headline className={styles.headline}>
|
||||
{displayName}, your call is now ended
|
||||
</Headline>
|
||||
<div className={styles.callEndedContent}>
|
||||
<Subtitle>
|
||||
Why not finish by setting up a password to keep your account?
|
||||
</Subtitle>
|
||||
<Subtitle>
|
||||
You'll be able to keep your name and set an avatar for use on
|
||||
future calls
|
||||
</Subtitle>
|
||||
<LinkButton
|
||||
className={styles.callEndedButton}
|
||||
size="lg"
|
||||
variant="default"
|
||||
to="/register"
|
||||
>
|
||||
Create account
|
||||
</LinkButton>
|
||||
</div>
|
||||
</main>
|
||||
<Body className={styles.footer}>
|
||||
<Link color="primary" to="/">
|
||||
Not now, return to home screen
|
||||
</Link>
|
||||
</Body>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
73
src/room/CallEndedView.module.css
Normal file
73
src/room/CallEndedView.module.css
Normal file
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
.headline {
|
||||
text-align: center;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
.callEndedContent {
|
||||
text-align: center;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.callEndedContent h3 {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.callEndedButton {
|
||||
width: 100%;
|
||||
margin-top: 54px;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
min-height: calc(100% - 64px);
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
margin-bottom: 54px;
|
||||
}
|
||||
|
||||
.headline {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-bottom: 44px;
|
||||
}
|
||||
|
||||
@media (min-width: 800px) {
|
||||
.logo {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.container {
|
||||
min-height: calc(100% - 76px);
|
||||
}
|
||||
}
|
|
@ -1,13 +1,13 @@
|
|||
import React from "react";
|
||||
import { Button } from "./button";
|
||||
import { PopoverMenuTrigger } from "./PopoverMenu";
|
||||
import { ReactComponent as SpotlightIcon } from "./icons/Spotlight.svg";
|
||||
import { ReactComponent as FreedomIcon } from "./icons/Freedom.svg";
|
||||
import { ReactComponent as CheckIcon } from "./icons/Check.svg";
|
||||
import { Button } from "../button";
|
||||
import { PopoverMenuTrigger } from "../popover/PopoverMenu";
|
||||
import { ReactComponent as SpotlightIcon } from "../icons/Spotlight.svg";
|
||||
import { ReactComponent as FreedomIcon } from "../icons/Freedom.svg";
|
||||
import { ReactComponent as CheckIcon } from "../icons/Check.svg";
|
||||
import styles from "./GridLayoutMenu.module.css";
|
||||
import { Menu } from "./Menu";
|
||||
import { Menu } from "../Menu";
|
||||
import { Item } from "@react-stately/collections";
|
||||
import { Tooltip, TooltipTrigger } from "./Tooltip";
|
||||
import { Tooltip, TooltipTrigger } from "../Tooltip";
|
||||
|
||||
export function GridLayoutMenu({ layout, setLayout }) {
|
||||
return (
|
|
@ -26,6 +26,7 @@ function getHangupCallState(call) {
|
|||
export function GroupCallInspector({ client, groupCall, show }) {
|
||||
const [roomStateEvents, setRoomStateEvents] = useState([]);
|
||||
const [toDeviceEvents, setToDeviceEvents] = useState([]);
|
||||
const [sentVoipEvents, setSentVoipEvents] = useState([]);
|
||||
const [state, setState] = useState({
|
||||
userId: client.getUserId(),
|
||||
});
|
||||
|
@ -111,8 +112,13 @@ export function GroupCallInspector({ client, groupCall, show }) {
|
|||
]);
|
||||
}
|
||||
|
||||
function onSendVoipEvent(event) {
|
||||
setSentVoipEvents((prev) => [...prev, event]);
|
||||
}
|
||||
|
||||
client.on("RoomState.events", onUpdateRoomState);
|
||||
groupCall.on("calls_changed", onCallsChanged);
|
||||
groupCall.on("send_voip_event", onSendVoipEvent);
|
||||
client.on("state", onCallsChanged);
|
||||
client.on("hangup", onCallHangup);
|
||||
client.on("toDeviceEvent", onToDeviceEvent);
|
||||
|
@ -133,6 +139,19 @@ export function GroupCallInspector({ client, groupCall, show }) {
|
|||
return result;
|
||||
}, [toDeviceEvents]);
|
||||
|
||||
const sentVoipEventsByCall = useMemo(() => {
|
||||
const result = {};
|
||||
|
||||
for (const event of sentVoipEvents) {
|
||||
const callId = event.content.call_id;
|
||||
const key = `${callId} (${event.userId})`;
|
||||
result[key] = result[key] || [];
|
||||
result[key].push(event);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [sentVoipEvents]);
|
||||
|
||||
useEffect(() => {
|
||||
let timeout;
|
||||
|
||||
|
@ -190,6 +209,8 @@ export function GroupCallInspector({ client, groupCall, show }) {
|
|||
roomStateEvents,
|
||||
toDeviceEvents,
|
||||
toDeviceEventsByCall,
|
||||
sentVoipEvents,
|
||||
sentVoipEventsByCall,
|
||||
}}
|
||||
name={null}
|
||||
indentWidth={2}
|
25
src/room/GroupCallLoader.jsx
Normal file
25
src/room/GroupCallLoader.jsx
Normal file
|
@ -0,0 +1,25 @@
|
|||
import React from "react";
|
||||
import { useLoadGroupCall } from "./useLoadGroupCall";
|
||||
import { ErrorView, FullScreenView } from "../FullScreenView";
|
||||
|
||||
export function GroupCallLoader({ client, roomId, viaServers, children }) {
|
||||
const { loading, error, groupCall } = useLoadGroupCall(
|
||||
client,
|
||||
roomId,
|
||||
viaServers
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<FullScreenView>
|
||||
<h1>Loading room...</h1>
|
||||
</FullScreenView>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ErrorView error={error} />;
|
||||
}
|
||||
|
||||
return children(groupCall);
|
||||
}
|
111
src/room/GroupCallView.jsx
Normal file
111
src/room/GroupCallView.jsx
Normal file
|
@ -0,0 +1,111 @@
|
|||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
|
||||
import { useGroupCall } from "matrix-react-sdk/src/hooks/useGroupCall";
|
||||
import { ErrorView, FullScreenView } from "../FullScreenView";
|
||||
import { LobbyView } from "./LobbyView";
|
||||
import { InCallView } from "./InCallView";
|
||||
import { CallEndedView } from "./CallEndedView";
|
||||
import { useSentryGroupCallHandler } from "./useSentryGroupCallHandler";
|
||||
|
||||
export function GroupCallView({
|
||||
client,
|
||||
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(() => {
|
||||
window.groupCall = groupCall;
|
||||
}, [groupCall]);
|
||||
|
||||
useSentryGroupCallHandler(groupCall);
|
||||
|
||||
const [left, setLeft] = useState(false);
|
||||
const history = useHistory();
|
||||
|
||||
const onLeave = useCallback(() => {
|
||||
leave();
|
||||
|
||||
if (!isPasswordlessUser) {
|
||||
history.push("/");
|
||||
} else {
|
||||
setLeft(true);
|
||||
}
|
||||
}, [leave, history]);
|
||||
|
||||
if (error) {
|
||||
return <ErrorView error={error} />;
|
||||
} else if (state === GroupCallState.Entered) {
|
||||
return (
|
||||
<InCallView
|
||||
groupCall={groupCall}
|
||||
client={client}
|
||||
roomName={groupCall.room.name}
|
||||
microphoneMuted={microphoneMuted}
|
||||
localVideoMuted={localVideoMuted}
|
||||
toggleLocalVideoMuted={toggleLocalVideoMuted}
|
||||
toggleMicrophoneMuted={toggleMicrophoneMuted}
|
||||
userMediaFeeds={userMediaFeeds}
|
||||
activeSpeaker={activeSpeaker}
|
||||
onLeave={onLeave}
|
||||
toggleScreensharing={toggleScreensharing}
|
||||
isScreensharing={isScreensharing}
|
||||
localScreenshareFeed={localScreenshareFeed}
|
||||
screenshareFeeds={screenshareFeeds}
|
||||
simpleGrid={simpleGrid}
|
||||
setShowInspector={setShowInspector}
|
||||
showInspector={showInspector}
|
||||
roomId={roomId}
|
||||
/>
|
||||
);
|
||||
} else if (state === GroupCallState.Entering) {
|
||||
return (
|
||||
<FullScreenView>
|
||||
<h1>Entering room...</h1>
|
||||
</FullScreenView>
|
||||
);
|
||||
} else if (left) {
|
||||
return <CallEndedView client={client} />;
|
||||
} else {
|
||||
return (
|
||||
<LobbyView
|
||||
client={client}
|
||||
hasLocalParticipant={hasLocalParticipant}
|
||||
roomName={groupCall.room.name}
|
||||
state={state}
|
||||
onInitLocalCallFeed={initLocalCallFeed}
|
||||
localCallFeed={localCallFeed}
|
||||
onEnter={enter}
|
||||
microphoneMuted={microphoneMuted}
|
||||
localVideoMuted={localVideoMuted}
|
||||
toggleLocalVideoMuted={toggleLocalVideoMuted}
|
||||
toggleMicrophoneMuted={toggleMicrophoneMuted}
|
||||
setShowInspector={setShowInspector}
|
||||
showInspector={showInspector}
|
||||
roomId={roomId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
166
src/room/InCallView.jsx
Normal file
166
src/room/InCallView.jsx
Normal file
|
@ -0,0 +1,166 @@
|
|||
import React, { useCallback, useMemo } from "react";
|
||||
import styles from "./InCallView.module.css";
|
||||
import {
|
||||
HangupButton,
|
||||
MicButton,
|
||||
VideoButton,
|
||||
ScreenshareButton,
|
||||
} from "../button";
|
||||
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
|
||||
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 { getAvatarUrl } from "../matrix-utils";
|
||||
import { GroupCallInspector } from "./GroupCallInspector";
|
||||
import { OverflowMenu } from "./OverflowMenu";
|
||||
import { GridLayoutMenu } from "./GridLayoutMenu";
|
||||
import { Avatar } from "../Avatar";
|
||||
import { UserMenuContainer } from "../UserMenuContainer";
|
||||
|
||||
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 InCallView({
|
||||
client,
|
||||
groupCall,
|
||||
roomName,
|
||||
microphoneMuted,
|
||||
localVideoMuted,
|
||||
toggleLocalVideoMuted,
|
||||
toggleMicrophoneMuted,
|
||||
userMediaFeeds,
|
||||
activeSpeaker,
|
||||
onLeave,
|
||||
toggleScreensharing,
|
||||
isScreensharing,
|
||||
screenshareFeeds,
|
||||
simpleGrid,
|
||||
setShowInspector,
|
||||
showInspector,
|
||||
roomId,
|
||||
}) {
|
||||
const [layout, setLayout] = useVideoGridLayout();
|
||||
|
||||
const items = useMemo(() => {
|
||||
const participants = [];
|
||||
|
||||
for (const callFeed of userMediaFeeds) {
|
||||
participants.push({
|
||||
id: callFeed.stream.id,
|
||||
usermediaCallFeed: callFeed,
|
||||
isActiveSpeaker:
|
||||
screenshareFeeds.length === 0
|
||||
? callFeed.userId === activeSpeaker
|
||||
: false,
|
||||
});
|
||||
}
|
||||
|
||||
for (const callFeed of screenshareFeeds) {
|
||||
const participant = participants.find(
|
||||
(p) => p.usermediaCallFeed.userId === callFeed.userId
|
||||
);
|
||||
|
||||
if (participant) {
|
||||
participant.screenshareCallFeed = callFeed;
|
||||
}
|
||||
}
|
||||
|
||||
return participants;
|
||||
}, [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 (
|
||||
<Avatar
|
||||
key={roomMember.userId}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size,
|
||||
fontSize: Math.round(size / 2),
|
||||
}}
|
||||
src={avatarUrl && getAvatarUrl(client, avatarUrl, 96)}
|
||||
fallback={roomMember.name.slice(0, 1).toUpperCase()}
|
||||
className={styles.avatar}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[client]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.inRoom}>
|
||||
<Header>
|
||||
<LeftNav>
|
||||
<RoomHeaderInfo roomName={roomName} />
|
||||
</LeftNav>
|
||||
<RightNav>
|
||||
<GridLayoutMenu layout={layout} setLayout={setLayout} />
|
||||
<UserMenuContainer disableLogout />
|
||||
</RightNav>
|
||||
</Header>
|
||||
{items.length === 0 ? (
|
||||
<div className={styles.centerMessage}>
|
||||
<p>Waiting for other participants...</p>
|
||||
</div>
|
||||
) : simpleGrid ? (
|
||||
<SimpleVideoGrid items={items} />
|
||||
) : (
|
||||
<VideoGrid
|
||||
items={items}
|
||||
layout={layout}
|
||||
getAvatar={renderAvatar}
|
||||
onFocusTile={onFocusTile}
|
||||
disableAnimations={isSafari}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.footer}>
|
||||
<MicButton muted={microphoneMuted} onPress={toggleMicrophoneMuted} />
|
||||
<VideoButton muted={localVideoMuted} onPress={toggleLocalVideoMuted} />
|
||||
{canScreenshare && !isSafari && (
|
||||
<ScreenshareButton
|
||||
enabled={isScreensharing}
|
||||
onPress={toggleScreensharing}
|
||||
/>
|
||||
)}
|
||||
<OverflowMenu
|
||||
roomId={roomId}
|
||||
setShowInspector={setShowInspector}
|
||||
showInspector={showInspector}
|
||||
client={client}
|
||||
/>
|
||||
<HangupButton onPress={onLeave} />
|
||||
</div>
|
||||
<GroupCallInspector
|
||||
client={client}
|
||||
groupCall={groupCall}
|
||||
show={showInspector}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
68
src/room/InCallView.module.css
Normal file
68
src/room/InCallView.module.css
Normal file
|
@ -0,0 +1,68 @@
|
|||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
.inRoom {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 100%;
|
||||
position: fixed;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.centerMessage {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.centerMessage p {
|
||||
display: block;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.footer > * {
|
||||
margin-right: 30px;
|
||||
}
|
||||
|
||||
.footer > :last-child {
|
||||
margin-right: 0px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
@media (min-width: 800px) {
|
||||
.footer {
|
||||
height: 118px;
|
||||
}
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
import React from "react";
|
||||
import { Modal, ModalContent } from "./Modal";
|
||||
import { CopyButton } from "./button";
|
||||
import { getRoomUrl } from "./ConferenceCallManagerHooks";
|
||||
import { Modal, ModalContent } from "../Modal";
|
||||
import { CopyButton } from "../button";
|
||||
import { getRoomUrl } from "../matrix-utils";
|
||||
import styles from "./InviteModal.module.css";
|
||||
|
||||
export function InviteModal({ roomId, ...rest }) {
|
105
src/room/LobbyView.jsx
Normal file
105
src/room/LobbyView.jsx
Normal file
|
@ -0,0 +1,105 @@
|
|||
import React, { useEffect } from "react";
|
||||
import styles from "./LobbyView.module.css";
|
||||
import { Button, CopyButton, MicButton, VideoButton } from "../button";
|
||||
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
|
||||
import { GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
|
||||
import { useCallFeed } from "matrix-react-sdk/src/hooks/useCallFeed";
|
||||
import { useMediaStream } from "matrix-react-sdk/src/hooks/useMediaStream";
|
||||
import { getRoomUrl } from "../matrix-utils";
|
||||
import { OverflowMenu } from "./OverflowMenu";
|
||||
import { UserMenuContainer } from "../UserMenuContainer";
|
||||
import { Body, Link } from "../typography/Typography";
|
||||
|
||||
export function LobbyView({
|
||||
client,
|
||||
roomName,
|
||||
state,
|
||||
onInitLocalCallFeed,
|
||||
onEnter,
|
||||
localCallFeed,
|
||||
microphoneMuted,
|
||||
localVideoMuted,
|
||||
toggleLocalVideoMuted,
|
||||
toggleMicrophoneMuted,
|
||||
setShowInspector,
|
||||
showInspector,
|
||||
roomId,
|
||||
}) {
|
||||
const { stream } = useCallFeed(localCallFeed);
|
||||
const videoRef = useMediaStream(stream, true);
|
||||
|
||||
useEffect(() => {
|
||||
// TODO: Only init once
|
||||
onInitLocalCallFeed();
|
||||
}, [onInitLocalCallFeed]);
|
||||
|
||||
return (
|
||||
<div className={styles.room}>
|
||||
<Header>
|
||||
<LeftNav>
|
||||
<RoomHeaderInfo roomName={roomName} />
|
||||
</LeftNav>
|
||||
<RightNav>
|
||||
<UserMenuContainer />
|
||||
</RightNav>
|
||||
</Header>
|
||||
<div className={styles.joinRoom}>
|
||||
<div className={styles.joinRoomContent}>
|
||||
<div className={styles.preview}>
|
||||
<video ref={videoRef} muted playsInline disablePictureInPicture />
|
||||
{state === GroupCallState.LocalCallFeedUninitialized && (
|
||||
<Body fontWeight="semiBold" className={styles.webcamPermissions}>
|
||||
Webcam/microphone permissions needed to join the call.
|
||||
</Body>
|
||||
)}
|
||||
{state === GroupCallState.InitializingLocalCallFeed && (
|
||||
<Body fontWeight="semiBold" className={styles.webcamPermissions}>
|
||||
Accept webcam/microphone permissions to join the call.
|
||||
</Body>
|
||||
)}
|
||||
{state === GroupCallState.LocalCallFeedInitialized && (
|
||||
<>
|
||||
<Button
|
||||
className={styles.joinCallButton}
|
||||
disabled={state !== GroupCallState.LocalCallFeedInitialized}
|
||||
onPress={onEnter}
|
||||
>
|
||||
Join call now
|
||||
</Button>
|
||||
<div className={styles.previewButtons}>
|
||||
<MicButton
|
||||
muted={microphoneMuted}
|
||||
onPress={toggleMicrophoneMuted}
|
||||
/>
|
||||
<VideoButton
|
||||
muted={localVideoMuted}
|
||||
onPress={toggleLocalVideoMuted}
|
||||
/>
|
||||
<OverflowMenu
|
||||
roomId={roomId}
|
||||
setShowInspector={setShowInspector}
|
||||
showInspector={showInspector}
|
||||
client={client}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Body>Or</Body>
|
||||
<CopyButton
|
||||
value={getRoomUrl(roomId)}
|
||||
className={styles.copyButton}
|
||||
copiedMessage="Call link copied"
|
||||
>
|
||||
Copy call link and join later
|
||||
</CopyButton>
|
||||
</div>
|
||||
<Body className={styles.joinRoomFooter}>
|
||||
<Link color="primary" to="/">
|
||||
Take me Home
|
||||
</Link>
|
||||
</Body>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -22,12 +22,6 @@ limitations under the License.
|
|||
min-height: 100%;
|
||||
}
|
||||
|
||||
.inRoom {
|
||||
position: fixed;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.joinRoom {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
@ -44,21 +38,12 @@ limitations under the License.
|
|||
flex: 1;
|
||||
}
|
||||
|
||||
.joinRoomContent h1 {
|
||||
display: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.joinRoomFooter {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.homeLink {
|
||||
margin-top: 50px;
|
||||
color: #0dbd8b;
|
||||
text-decoration: none;
|
||||
font-weight: normal;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.preview {
|
||||
|
@ -85,8 +70,6 @@ limitations under the License.
|
|||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
@ -125,93 +108,3 @@ limitations under the License.
|
|||
.previewButtons > :last-child {
|
||||
margin-right: 0px;
|
||||
}
|
||||
|
||||
.centerMessage {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.centerMessage p {
|
||||
display: block;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.roomContainer {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.footer > * {
|
||||
margin-right: 30px;
|
||||
}
|
||||
|
||||
.footer > :last-child {
|
||||
margin-right: 0px;
|
||||
}
|
||||
|
||||
.callEndedScreen h1 {
|
||||
text-align: center;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
.callEndedScreen h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.callEndedScreen p {
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
|
||||
.callEndedScreen ul {
|
||||
padding: 0;
|
||||
margin-bottom: 40px;
|
||||
text-align: initial;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.callEndedButton {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.callEndedContent {
|
||||
text-align: center;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
@media (min-width: 800px) {
|
||||
.roomContainer {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.footer {
|
||||
height: 118px;
|
||||
}
|
||||
|
||||
.joinRoomContent h1 {
|
||||
display: block;
|
||||
}
|
||||
}
|
|
@ -1,15 +1,15 @@
|
|||
import React, { useCallback } from "react";
|
||||
import { Button } from "./button";
|
||||
import { Menu } from "./Menu";
|
||||
import { PopoverMenuTrigger } from "./PopoverMenu";
|
||||
import { Button } from "../button";
|
||||
import { Menu } from "../Menu";
|
||||
import { PopoverMenuTrigger } from "../popover/PopoverMenu";
|
||||
import { Item } from "@react-stately/collections";
|
||||
import { ReactComponent as SettingsIcon } from "./icons/Settings.svg";
|
||||
import { ReactComponent as AddUserIcon } from "./icons/AddUser.svg";
|
||||
import { ReactComponent as OverflowIcon } from "./icons/Overflow.svg";
|
||||
import { useModalTriggerState } from "./Modal";
|
||||
import { SettingsModal } from "./SettingsModal";
|
||||
import { ReactComponent as SettingsIcon } from "../icons/Settings.svg";
|
||||
import { ReactComponent as AddUserIcon } from "../icons/AddUser.svg";
|
||||
import { ReactComponent as OverflowIcon } from "../icons/Overflow.svg";
|
||||
import { useModalTriggerState } from "../Modal";
|
||||
import { SettingsModal } from "../settings/SettingsModal";
|
||||
import { InviteModal } from "./InviteModal";
|
||||
import { Tooltip, TooltipTrigger } from "./Tooltip";
|
||||
import { Tooltip, TooltipTrigger } from "../Tooltip";
|
||||
|
||||
export function OverflowMenu({
|
||||
roomId,
|
97
src/room/RoomAuthView.jsx
Normal file
97
src/room/RoomAuthView.jsx
Normal file
|
@ -0,0 +1,97 @@
|
|||
import React, { useCallback, useState } from "react";
|
||||
import styles from "./RoomAuthView.module.css";
|
||||
import { Button } from "../button";
|
||||
import { Body, Caption, Link, Headline } from "../typography/Typography";
|
||||
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useRecaptcha } from "../auth/useRecaptcha";
|
||||
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
|
||||
import { randomString } from "matrix-js-sdk/src/randomstring";
|
||||
import { useInteractiveRegistration } from "../auth/useInteractiveRegistration";
|
||||
import { Form } from "../form/Form";
|
||||
import { UserMenuContainer } from "../UserMenuContainer";
|
||||
|
||||
export function RoomAuthView() {
|
||||
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 userName = data.get("userName");
|
||||
|
||||
async function submit() {
|
||||
setError(undefined);
|
||||
setLoading(true);
|
||||
const recaptchaResponse = await execute();
|
||||
await register(userName, randomString(16), recaptchaResponse, true);
|
||||
}
|
||||
|
||||
submit().catch((error) => {
|
||||
console.error(error);
|
||||
setLoading(false);
|
||||
setError(error);
|
||||
reset();
|
||||
});
|
||||
},
|
||||
[register, reset, execute]
|
||||
);
|
||||
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header>
|
||||
<LeftNav>
|
||||
<HeaderLogo />
|
||||
</LeftNav>
|
||||
<RightNav>
|
||||
<UserMenuContainer disableLogout />
|
||||
</RightNav>
|
||||
</Header>
|
||||
<div className={styles.container}>
|
||||
<main className={styles.main}>
|
||||
<Headline className={styles.headline}>Join Call</Headline>
|
||||
<Form className={styles.form} onSubmit={onSubmit}>
|
||||
<FieldRow>
|
||||
<InputField
|
||||
id="userName"
|
||||
name="userName"
|
||||
label="Your name"
|
||||
placeholder="Your name"
|
||||
type="text"
|
||||
required
|
||||
autoComplete="off"
|
||||
/>
|
||||
</FieldRow>
|
||||
<Caption>
|
||||
By clicking "Go", you agree to our{" "}
|
||||
<Link href={privacyPolicyUrl}>Terms and conditions</Link>
|
||||
</Caption>
|
||||
{error && (
|
||||
<FieldRow>
|
||||
<ErrorMessage>{error.message}</ErrorMessage>
|
||||
</FieldRow>
|
||||
)}
|
||||
<Button type="submit" size="lg" disabled={loading}>
|
||||
{loading ? "Loading..." : "Join call now"}
|
||||
</Button>
|
||||
<div id={recaptchaId} />
|
||||
</Form>
|
||||
</main>
|
||||
<Body className={styles.footer}>
|
||||
{"Not registered yet? "}
|
||||
<Link
|
||||
color="primary"
|
||||
to={{ pathname: "/login", state: { from: location } }}
|
||||
>
|
||||
Create an account
|
||||
</Link>
|
||||
</Body>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
67
src/room/RoomAuthView.module.css
Normal file
67
src/room/RoomAuthView.module.css
Normal file
|
@ -0,0 +1,67 @@
|
|||
.form {
|
||||
padding: 0 24px;
|
||||
justify-content: center;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.form > * + * {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.headline {
|
||||
text-align: center;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
.callEndedContent {
|
||||
text-align: center;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.callEndedContent h3 {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.callEndedButton {
|
||||
width: 100%;
|
||||
margin-top: 54px;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
min-height: calc(100% - 64px);
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
margin-bottom: 54px;
|
||||
}
|
||||
|
||||
.headline {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-bottom: 44px;
|
||||
}
|
||||
|
||||
@media (min-width: 800px) {
|
||||
.logo {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.container {
|
||||
min-height: calc(100% - 76px);
|
||||
}
|
||||
}
|
62
src/room/RoomPage.jsx
Normal file
62
src/room/RoomPage.jsx
Normal file
|
@ -0,0 +1,62 @@
|
|||
/*
|
||||
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, { useMemo } from "react";
|
||||
import { useLocation, useParams } from "react-router-dom";
|
||||
import { useClient } from "../ClientContext";
|
||||
import { ErrorView, LoadingView } from "../FullScreenView";
|
||||
import { RoomAuthView } from "./RoomAuthView";
|
||||
import { GroupCallLoader } from "./GroupCallLoader";
|
||||
import { GroupCallView } from "./GroupCallView";
|
||||
|
||||
export function RoomPage() {
|
||||
const { loading, isAuthenticated, error, client, isPasswordlessUser } =
|
||||
useClient();
|
||||
|
||||
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;
|
||||
|
||||
if (loading) {
|
||||
return <LoadingView />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ErrorView error={error} />;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <RoomAuthView />;
|
||||
}
|
||||
|
||||
return (
|
||||
<GroupCallLoader client={client} roomId={roomId} viaServers={viaServers}>
|
||||
{(groupCall) => (
|
||||
<GroupCallView
|
||||
client={client}
|
||||
roomId={roomId}
|
||||
groupCall={groupCall}
|
||||
isPasswordlessUser={isPasswordlessUser}
|
||||
simpleGrid={simpleGrid}
|
||||
/>
|
||||
)}
|
||||
</GroupCallLoader>
|
||||
);
|
||||
}
|
25
src/room/RoomRedirect.jsx
Normal file
25
src/room/RoomRedirect.jsx
Normal file
|
@ -0,0 +1,25 @@
|
|||
import React, { useEffect } from "react";
|
||||
import { useLocation, useHistory } from "react-router-dom";
|
||||
import { defaultHomeserverHost } from "../matrix-utils";
|
||||
import { LoadingView } from "../FullScreenView";
|
||||
|
||||
export 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 <LoadingView />;
|
||||
}
|
54
src/room/useLoadGroupCall.js
Normal file
54
src/room/useLoadGroupCall.js
Normal file
|
@ -0,0 +1,54 @@
|
|||
import { useState, useEffect } from "react";
|
||||
|
||||
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 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;
|
||||
}
|
28
src/room/useSentryGroupCallHandler.js
Normal file
28
src/room/useSentryGroupCallHandler.js
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { useEffect } from "react";
|
||||
import * as Sentry from "@sentry/react";
|
||||
|
||||
export function useSentryGroupCallHandler(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]);
|
||||
}
|
|
@ -1,14 +1,14 @@
|
|||
import React from "react";
|
||||
import { Modal } from "./Modal";
|
||||
import { Modal } from "../Modal";
|
||||
import styles from "./SettingsModal.module.css";
|
||||
import { TabContainer, TabItem } from "./Tabs";
|
||||
import { ReactComponent as AudioIcon } from "./icons/Audio.svg";
|
||||
import { ReactComponent as VideoIcon } from "./icons/Video.svg";
|
||||
import { ReactComponent as DeveloperIcon } from "./icons/Developer.svg";
|
||||
import { SelectInput } from "./SelectInput";
|
||||
import { TabContainer, TabItem } from "../Tabs";
|
||||
import { ReactComponent as AudioIcon } from "../icons/Audio.svg";
|
||||
import { ReactComponent as VideoIcon } from "../icons/Video.svg";
|
||||
import { ReactComponent as DeveloperIcon } from "../icons/Developer.svg";
|
||||
import { SelectInput } from "../input/SelectInput";
|
||||
import { Item } from "@react-stately/collections";
|
||||
import { useMediaHandler } from "./useMediaHandler";
|
||||
import { FieldRow, InputField } from "./Input";
|
||||
import { FieldRow, InputField } from "../input/Input";
|
||||
|
||||
export function SettingsModal({
|
||||
client,
|
|
@ -1,7 +1,5 @@
|
|||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
let audioOutput;
|
||||
|
||||
export function useMediaHandler(client) {
|
||||
const [{ audioInput, videoInput, audioInputs, videoInputs }, setState] =
|
||||
useState(() => {
|
||||
|
@ -27,7 +25,7 @@ export function useMediaHandler(client) {
|
|||
(device) => device.kind === "videoinput"
|
||||
);
|
||||
|
||||
setState((prevState) => ({
|
||||
setState(() => ({
|
||||
audioInput: mediaHandler.audioInput,
|
||||
videoInput: mediaHandler.videoInput,
|
||||
audioInputs,
|
218
src/typography/Typography.jsx
Normal file
218
src/typography/Typography.jsx
Normal file
|
@ -0,0 +1,218 @@
|
|||
import React, { forwardRef } from "react";
|
||||
import classNames from "classnames";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import styles from "./Typography.module.css";
|
||||
|
||||
export const Headline = forwardRef(
|
||||
(
|
||||
{
|
||||
as: Component = "h1",
|
||||
children,
|
||||
className,
|
||||
fontWeight,
|
||||
overflowEllipsis,
|
||||
...rest
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
return (
|
||||
<Component
|
||||
{...rest}
|
||||
className={classNames(
|
||||
styles[fontWeight],
|
||||
{ [styles.overflowEllipsis]: overflowEllipsis },
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const Title = forwardRef(
|
||||
(
|
||||
{
|
||||
as: Component = "h2",
|
||||
children,
|
||||
className,
|
||||
fontWeight,
|
||||
overflowEllipsis,
|
||||
...rest
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
return (
|
||||
<Component
|
||||
{...rest}
|
||||
className={classNames(
|
||||
styles[fontWeight],
|
||||
{ [styles.overflowEllipsis]: overflowEllipsis },
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const Subtitle = forwardRef(
|
||||
(
|
||||
{
|
||||
as: Component = "h3",
|
||||
children,
|
||||
className,
|
||||
fontWeight,
|
||||
overflowEllipsis,
|
||||
...rest
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
return (
|
||||
<Component
|
||||
{...rest}
|
||||
className={classNames(
|
||||
styles[fontWeight],
|
||||
{ [styles.overflowEllipsis]: overflowEllipsis },
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const Body = forwardRef(
|
||||
(
|
||||
{
|
||||
as: Component = "p",
|
||||
children,
|
||||
className,
|
||||
fontWeight,
|
||||
overflowEllipsis,
|
||||
...rest
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
return (
|
||||
<Component
|
||||
{...rest}
|
||||
className={classNames(
|
||||
styles[fontWeight],
|
||||
{ [styles.overflowEllipsis]: overflowEllipsis },
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const Caption = forwardRef(
|
||||
(
|
||||
{
|
||||
as: Component = "p",
|
||||
children,
|
||||
className,
|
||||
fontWeight,
|
||||
overflowEllipsis,
|
||||
...rest
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
return (
|
||||
<Component
|
||||
{...rest}
|
||||
className={classNames(
|
||||
styles.caption,
|
||||
styles[fontWeight],
|
||||
{ [styles.overflowEllipsis]: overflowEllipsis },
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const Micro = forwardRef(
|
||||
(
|
||||
{
|
||||
as: Component = "p",
|
||||
children,
|
||||
className,
|
||||
fontWeight,
|
||||
overflowEllipsis,
|
||||
...rest
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
return (
|
||||
<Component
|
||||
{...rest}
|
||||
className={classNames(
|
||||
styles.micro,
|
||||
styles[fontWeight],
|
||||
{ [styles.overflowEllipsis]: overflowEllipsis },
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const Link = forwardRef(
|
||||
(
|
||||
{
|
||||
as,
|
||||
children,
|
||||
className,
|
||||
color = "link",
|
||||
href,
|
||||
to,
|
||||
fontWeight,
|
||||
overflowEllipsis,
|
||||
...rest
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const Component = as || (to ? RouterLink : "a");
|
||||
let externalLinkProps;
|
||||
|
||||
if (href) {
|
||||
externalLinkProps = {
|
||||
target: "_blank",
|
||||
rel: "noreferrer noopener",
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Component
|
||||
{...externalLinkProps}
|
||||
{...rest}
|
||||
to={to}
|
||||
className={classNames(
|
||||
styles[color],
|
||||
styles[fontWeight],
|
||||
{ [styles.overflowEllipsis]: overflowEllipsis },
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
);
|
35
src/typography/Typography.module.css
Normal file
35
src/typography/Typography.module.css
Normal file
|
@ -0,0 +1,35 @@
|
|||
.caption {
|
||||
font-size: 12px;
|
||||
line-height: 15px;
|
||||
}
|
||||
|
||||
.micro {
|
||||
font-size: 10px;
|
||||
line-height: 12px;
|
||||
}
|
||||
|
||||
.regular {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.semiBold {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bold {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--linkColor);
|
||||
}
|
||||
|
||||
.primary {
|
||||
color: var(--primaryColor);
|
||||
}
|
||||
|
||||
.overflowEllipsis {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
25
src/typography/Typography.stories.jsx
Normal file
25
src/typography/Typography.stories.jsx
Normal file
|
@ -0,0 +1,25 @@
|
|||
import React from "react";
|
||||
import { Headline, Title, Subtitle, Body, Caption, Micro } from "./Typography";
|
||||
|
||||
export default {
|
||||
title: "Typography",
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
},
|
||||
};
|
||||
|
||||
export const Typography = () => (
|
||||
<>
|
||||
<Headline>Headline Semi Bold</Headline>
|
||||
<Title>Title</Title>
|
||||
<Subtitle>Subtitle</Subtitle>
|
||||
<Subtitle fontWeight="semiBold">Subtitle Semi Bold</Subtitle>
|
||||
<Body>Body</Body>
|
||||
<Body fontWeight="semiBold">Body Semi Bold</Body>
|
||||
<Caption>Caption</Caption>
|
||||
<Caption fontWeight="semiBold">Caption Semi Bold</Caption>
|
||||
<Caption fontWeight="bold">Caption Bold</Caption>
|
||||
<Micro>Micro</Micro>
|
||||
<Micro fontWeight="bold">Micro bold</Micro>
|
||||
</>
|
||||
);
|
22
src/usePageFocusStyle.js
Normal file
22
src/usePageFocusStyle.js
Normal file
|
@ -0,0 +1,22 @@
|
|||
import { useEffect } from "react";
|
||||
import { useFocusVisible } from "@react-aria/interactions";
|
||||
import styles from "./usePageFocusStyle.module.css";
|
||||
|
||||
export function usePageFocusStyle() {
|
||||
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]);
|
||||
}
|
Loading…
Reference in a new issue