Merge branch 'main' into robertlong/spotlight-layout

This commit is contained in:
Robert Long 2022-01-07 11:42:23 -08:00
commit e9fc90c55b
84 changed files with 12245 additions and 2989 deletions

3
.env
View file

@ -7,9 +7,6 @@
# Used for determining the homeserver to use for short urls etc. # Used for determining the homeserver to use for short urls etc.
# VITE_DEFAULT_HOMESERVER=http://localhost:8008 # 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. # The Sentry DSN to use for error reporting. Leave undefined to disable.
# VITE_SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0 # VITE_SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0

14
.storybook/main.js Normal file
View 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
View 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>
);
});

View file

@ -3,7 +3,9 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"serve": "vite preview" "serve": "vite preview",
"storybook": "start-storybook -p 6006",
"build-storybook": "build-storybook"
}, },
"dependencies": { "dependencies": {
"@react-aria/button": "^3.3.4", "@react-aria/button": "^3.3.4",
@ -27,6 +29,7 @@
"events": "^3.3.0", "events": "^3.3.0",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#robertlong/group-call", "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#robertlong/group-call",
"matrix-react-sdk": "github:matrix-org/matrix-react-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", "postcss-preset-env": "^6.7.0",
"re-resizable": "^6.9.0", "re-resizable": "^6.9.0",
"react": "^17.0.0", "react": "^17.0.0",
@ -37,7 +40,14 @@
"react-use-clipboard": "^1.0.7" "react-use-clipboard": "^1.0.7"
}, },
"devDependencies": { "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", "sass": "^1.42.1",
"storybook-builder-vite": "^0.1.12",
"vite": "^2.4.2", "vite": "^2.4.2",
"vite-plugin-svgr": "^0.4.0" "vite-plugin-svgr": "^0.4.0"
} }

View file

@ -14,47 +14,22 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { useEffect, useState } from "react"; import React from "react";
import { import { BrowserRouter as Router, Switch, Route } from "react-router-dom";
BrowserRouter as Router,
Switch,
Route,
useLocation,
useHistory,
} from "react-router-dom";
import * as Sentry from "@sentry/react"; import * as Sentry from "@sentry/react";
import { OverlayProvider } from "@react-aria/overlays"; import { OverlayProvider } from "@react-aria/overlays";
import { Home } from "./Home"; import { HomePage } from "./home/HomePage";
import { LoginPage } from "./LoginPage"; import { LoginPage } from "./auth/LoginPage";
import { RegisterPage } from "./RegisterPage"; import { RegisterPage } from "./auth/RegisterPage";
import { Room } from "./Room"; import { RoomPage } from "./room/RoomPage";
import { import { RoomRedirect } from "./room/RoomRedirect";
ClientProvider, import { ClientProvider } from "./ClientContext";
defaultHomeserverHost, import { usePageFocusStyle } from "./usePageFocusStyle";
} from "./ConferenceCallManagerHooks";
import { useFocusVisible } from "@react-aria/interactions";
import styles from "./App.module.css";
import { LoadingView } from "./FullScreenView";
const SentryRoute = Sentry.withSentryRouting(Route); const SentryRoute = Sentry.withSentryRouting(Route);
export default function App({ history }) { export default function App({ history }) {
const { isFocusVisible } = useFocusVisible(); usePageFocusStyle();
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]);
return ( return (
<Router history={history}> <Router history={history}>
@ -62,7 +37,7 @@ export default function App({ history }) {
<OverlayProvider> <OverlayProvider>
<Switch> <Switch>
<SentryRoute exact path="/"> <SentryRoute exact path="/">
<Home /> <HomePage />
</SentryRoute> </SentryRoute>
<SentryRoute exact path="/login"> <SentryRoute exact path="/login">
<LoginPage /> <LoginPage />
@ -71,7 +46,7 @@ export default function App({ history }) {
<RegisterPage /> <RegisterPage />
</SentryRoute> </SentryRoute>
<SentryRoute path="/room/:roomId?"> <SentryRoute path="/room/:roomId?">
<Room /> <RoomPage />
</SentryRoute> </SentryRoute>
<SentryRoute path="*"> <SentryRoute path="*">
<RoomRedirect /> <RoomRedirect />
@ -82,24 +57,3 @@ export default function App({ history }) {
</Router> </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
View 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);
}

View file

@ -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");
});
};
}

View file

@ -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];
}

View file

@ -2,7 +2,7 @@ import React from "react";
import styles from "./Facepile.module.css"; import styles from "./Facepile.module.css";
import classNames from "classnames"; import classNames from "classnames";
import { Avatar } from "./Avatar"; import { Avatar } from "./Avatar";
import { getAvatarUrl } from "./ConferenceCallManagerHooks"; import { getAvatarUrl } from "./matrix-utils";
export function Facepile({ className, client, participants, ...rest }) { export function Facepile({ className, client, participants, ...rest }) {
return ( return (

View file

@ -6,6 +6,7 @@ import { ReactComponent as Logo } from "./icons/Logo.svg";
import { ReactComponent as VideoIcon } from "./icons/Video.svg"; import { ReactComponent as VideoIcon } from "./icons/Video.svg";
import { ReactComponent as ArrowLeftIcon } from "./icons/ArrowLeft.svg"; import { ReactComponent as ArrowLeftIcon } from "./icons/ArrowLeft.svg";
import { useButton } from "@react-aria/button"; import { useButton } from "@react-aria/button";
import { Subtitle } from "./typography/Typography";
export function Header({ children, className, ...rest }) { export function Header({ children, className, ...rest }) {
return ( 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 ( return (
<div <div
className={classNames(styles.nav, styles.leftNav, className)} className={classNames(
styles.nav,
styles.leftNav,
{ [styles.hideMobile]: hideMobile },
className
)}
{...rest} {...rest}
> >
{children} {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 ( return (
<div <div
className={classNames(styles.nav, styles.rightNav, className)} className={classNames(
styles.nav,
styles.rightNav,
{ [styles.hideMobile]: hideMobile },
className
)}
{...rest} {...rest}
> >
{children} {children}
@ -37,9 +48,9 @@ export function RightNav({ children, className, ...rest }) {
); );
} }
export function HeaderLogo() { export function HeaderLogo({ className }) {
return ( return (
<Link className={styles.logo} to="/"> <Link className={classNames(styles.headerLogo, className)} to="/">
<Logo /> <Logo />
</Link> </Link>
); );
@ -51,7 +62,7 @@ export function RoomHeaderInfo({ roomName }) {
<div className={styles.roomAvatar}> <div className={styles.roomAvatar}>
<VideoIcon width={16} height={16} /> <VideoIcon width={16} height={16} />
</div> </div>
<h3>{roomName}</h3> <Subtitle fontWeight="semiBold">{roomName}</Subtitle>
</> </>
); );
} }

View file

@ -16,16 +16,24 @@
height: 64px; height: 64px;
} }
.logo { .headerLogo {
display: flex; display: none;
align-items: center; align-items: center;
text-decoration: none; text-decoration: none;
} }
.leftNav.hideMobile {
display: none;
}
.leftNav > * { .leftNav > * {
margin-right: 12px; margin-right: 12px;
} }
.leftNav h3 {
margin: 0;
}
.rightNav { .rightNav {
justify-content: flex-end; justify-content: flex-end;
} }
@ -34,13 +42,17 @@
margin-right: 24px; margin-right: 24px;
} }
.rightNav.hideMobile {
display: none;
}
.nav > :last-child { .nav > :last-child {
margin-right: 0; margin-right: 0;
} }
.roomAvatar { .roomAvatar {
position: relative; position: relative;
display: flex; display: none;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
width: 36px; width: 36px;
@ -93,7 +105,18 @@
} }
@media (min-width: 800px) { @media (min-width: 800px) {
.headerLogo,
.roomAvatar,
.leftNav.hideMobile,
.rightNav.hideMobile {
display: flex;
}
.leftNav h3 {
font-size: 18px;
}
.nav { .nav {
height: 98px; height: 76px;
} }
} }

106
src/Header.stories.jsx Normal file
View 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>
);

View file

@ -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>
);
}

View file

@ -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);
}
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -1,7 +1,7 @@
import React, { forwardRef, useRef } from "react"; import React, { forwardRef } from "react";
import { useTooltipTriggerState } from "@react-stately/tooltip"; import { useTooltipTriggerState } from "@react-stately/tooltip";
import { useTooltipTrigger, useTooltip } from "@react-aria/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 styles from "./Tooltip.module.css";
import classNames from "classnames"; import classNames from "classnames";
@ -20,8 +20,7 @@ export function Tooltip({ position, state, ...props }) {
export const TooltipTrigger = forwardRef(({ children, ...rest }, ref) => { export const TooltipTrigger = forwardRef(({ children, ...rest }, ref) => {
const tooltipState = useTooltipTriggerState(rest); const tooltipState = useTooltipTriggerState(rest);
const fallbackRef = useRef(); const triggerRef = useObjectRef(ref);
const triggerRef = ref || fallbackRef;
const { triggerProps, tooltipProps } = useTooltipTrigger( const { triggerProps, tooltipProps } = useTooltipTrigger(
rest, rest,
tooltipState, tooltipState,

View file

@ -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 { 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 UserIcon } from "./icons/User.svg";
import { ReactComponent as LoginIcon } from "./icons/Login.svg"; import { ReactComponent as LoginIcon } from "./icons/Login.svg";
import { ReactComponent as LogoutIcon } from "./icons/Logout.svg"; import { ReactComponent as LogoutIcon } from "./icons/Logout.svg";
import styles from "./UserMenu.module.css"; import styles from "./UserMenu.module.css";
import { Item } from "@react-stately/collections"; import { useLocation } from "react-router-dom";
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";
export function UserMenu({ disableLogout }) { export function UserMenu({
const location = useLocation(); disableLogout,
const history = useHistory();
const {
isAuthenticated, isAuthenticated,
isGuest,
isPasswordlessUser, isPasswordlessUser,
logout, displayName,
userName, avatarUrl,
client, onAction,
} = useClient(); }) {
const { displayName, avatarUrl } = useProfile(client); const location = useLocation();
const { modalState, modalProps } = useModalTriggerState();
const onAction = useCallback(
(value) => {
switch (value) {
case "user":
modalState.open();
break;
case "logout":
logout();
break;
case "login":
history.push("/login", { state: { from: location } });
break;
}
},
[history, location, logout, modalState]
);
const items = useMemo(() => { const items = useMemo(() => {
const arr = []; const arr = [];
if (isAuthenticated && !isGuest) { if (isAuthenticated) {
arr.push({ arr.push({
key: "user", key: "user",
icon: UserIcon, icon: UserIcon,
label: displayName || userName, label: displayName,
}); });
if (isPasswordlessUser) { if (isPasswordlessUser) {
@ -73,9 +49,9 @@ export function UserMenu({ disableLogout }) {
} }
return arr; return arr;
}, [isAuthenticated, isGuest, userName, displayName]); }, [isAuthenticated, isPasswordlessUser, displayName, disableLogout]);
if (isGuest || !isAuthenticated) { if (!isAuthenticated) {
return ( return (
<LinkButton to={{ pathname: "/login", state: { from: location } }}> <LinkButton to={{ pathname: "/login", state: { from: location } }}>
Log in Log in
@ -84,15 +60,15 @@ export function UserMenu({ disableLogout }) {
} }
return ( return (
<>
<PopoverMenuTrigger placement="bottom right"> <PopoverMenuTrigger placement="bottom right">
<TooltipTrigger> <TooltipTrigger>
<Button variant="icon" className={styles.userButton}> <Button variant="icon" className={styles.userButton}>
{isAuthenticated && !isGuest && !isPasswordlessUser ? ( {isAuthenticated && !isPasswordlessUser ? (
<Avatar <Avatar
size="sm" size="sm"
className={styles.avatar}
src={avatarUrl} src={avatarUrl}
fallback={(displayName || userName).slice(0, 1).toUpperCase()} fallback={displayName.slice(0, 1).toUpperCase()}
/> />
) : ( ) : (
<UserIcon /> <UserIcon />
@ -115,15 +91,5 @@ export function UserMenu({ disableLogout }) {
</Menu> </Menu>
)} )}
</PopoverMenuTrigger> </PopoverMenuTrigger>
{modalState.isOpen && (
<ProfileModal
client={client}
isAuthenticated={isAuthenticated}
isGuest={isGuest}
isPasswordlessUser={isPasswordlessUser}
{...modalProps}
/>
)}
</>
); );
} }

View file

@ -1,3 +1,17 @@
.userButton svg * { .userButton svg * {
fill: var(--textColor1); 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
View 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}
/>
)}
</>
);
}

View file

@ -16,15 +16,12 @@ limitations under the License.
import React, { useCallback, useRef, useState, useMemo } from "react"; import React, { useCallback, useRef, useState, useMemo } from "react";
import { useHistory, useLocation, Link } from "react-router-dom"; import { useHistory, useLocation, Link } from "react-router-dom";
import { ReactComponent as Logo } from "./icons/LogoLarge.svg"; import { ReactComponent as Logo } from "../icons/LogoLarge.svg";
import { FieldRow, InputField, ErrorMessage } from "./Input"; import { FieldRow, InputField, ErrorMessage } from "../input/Input";
import { Button } from "./button"; import { Button } from "../button";
import { import { defaultHomeserver, defaultHomeserverHost } from "../matrix-utils";
defaultHomeserver,
defaultHomeserverHost,
useInteractiveLogin,
} from "./ConferenceCallManagerHooks";
import styles from "./LoginPage.module.css"; import styles from "./LoginPage.module.css";
import { useInteractiveLogin } from "./useInteractiveLogin";
export function LoginPage() { export function LoginPage() {
const [_, login] = useInteractiveLogin(); const [_, login] = useInteractiveLogin();

View file

@ -15,18 +15,17 @@ limitations under the License.
*/ */
import React, { useCallback, useEffect, useRef, useState } from "react"; import React, { useCallback, useEffect, useRef, useState } from "react";
import { useHistory, useLocation, Link } from "react-router-dom"; import { useHistory, useLocation } from "react-router-dom";
import { FieldRow, InputField, ErrorMessage, Field } from "./Input"; import { FieldRow, InputField, ErrorMessage } from "../input/Input";
import { Button } from "./button"; import { Button } from "../button";
import { import { useClient } from "../ClientContext";
useClient, import { defaultHomeserverHost } from "../matrix-utils";
defaultHomeserverHost, import { useInteractiveRegistration } from "./useInteractiveRegistration";
useInteractiveRegistration,
} from "./ConferenceCallManagerHooks";
import styles from "./LoginPage.module.css"; import styles from "./LoginPage.module.css";
import { ReactComponent as Logo } from "./icons/LogoLarge.svg"; import { ReactComponent as Logo } from "../icons/LogoLarge.svg";
import { LoadingView } from "./FullScreenView"; import { LoadingView } from "../FullScreenView";
import { RecaptchaInput } from "./RecaptchaInput"; import { useRecaptcha } from "./useRecaptcha";
import { Caption, Link } from "../typography/Typography";
export function RegisterPage() { export function RegisterPage() {
const { const {
@ -37,17 +36,15 @@ export function RegisterPage() {
isPasswordlessUser, isPasswordlessUser,
} = useClient(); } = useClient();
const confirmPasswordRef = useRef(); const confirmPasswordRef = useRef();
const acceptTermsRef = useRef();
const history = useHistory(); const history = useHistory();
const location = useLocation(); const location = useLocation();
const [registering, setRegistering] = useState(false); const [registering, setRegistering] = useState(false);
const [error, setError] = useState(); const [error, setError] = useState();
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [passwordConfirmation, setPasswordConfirmation] = useState(""); const [passwordConfirmation, setPasswordConfirmation] = useState("");
const [acceptTerms, setAcceptTerms] = useState(false);
const [{ privacyPolicyUrl, recaptchaKey }, register] = const [{ privacyPolicyUrl, recaptchaKey }, register] =
useInteractiveRegistration(); useInteractiveRegistration();
const [recaptchaResponse, setRecaptchaResponse] = useState(); const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
const onSubmitRegisterForm = useCallback( const onSubmitRegisterForm = useCallback(
(e) => { (e) => {
@ -56,16 +53,23 @@ export function RegisterPage() {
const userName = data.get("userName"); const userName = data.get("userName");
const password = data.get("password"); const password = data.get("password");
const passwordConfirmation = data.get("passwordConfirmation"); const passwordConfirmation = data.get("passwordConfirmation");
const acceptTerms = data.get("acceptTerms");
if (isPasswordlessUser) {
if (password !== passwordConfirmation) { if (password !== passwordConfirmation) {
return; return;
} }
async function submit() {
setRegistering(true); setRegistering(true);
changePassword(password) if (isPasswordlessUser) {
await changePassword(password);
} else {
const recaptchaResponse = await execute();
await register(userName, password, recaptchaResponse);
}
}
submit()
.then(() => { .then(() => {
if (location.state && location.state.from) { if (location.state && location.state.from) {
history.push(location.state.from); history.push(location.state.from);
@ -76,31 +80,8 @@ export function RegisterPage() {
.catch((error) => { .catch((error) => {
setError(error); setError(error);
setRegistering(false); 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, register,
@ -108,7 +89,8 @@ export function RegisterPage() {
location, location,
history, history,
isPasswordlessUser, isPasswordlessUser,
recaptchaResponse, reset,
execute,
] ]
); );
@ -124,20 +106,6 @@ export function RegisterPage() {
} }
}, [password, passwordConfirmation]); }, [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(() => { useEffect(() => {
if (!loading && isAuthenticated && !isPasswordlessUser) { if (!loading && isAuthenticated && !isPasswordlessUser) {
history.push("/"); history.push("/");
@ -198,28 +166,20 @@ export function RegisterPage() {
/> />
</FieldRow> </FieldRow>
{!isPasswordlessUser && ( {!isPasswordlessUser && (
<FieldRow> <Caption>
<InputField This site is protected by ReCAPTCHA and the Google{" "}
id="acceptTerms" <Link href="https://www.google.com/policies/privacy/">
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 Privacy Policy
</a> </Link>{" "}
</FieldRow> and{" "}
)} <Link href="https://policies.google.com/terms">
{!isPasswordlessUser && recaptchaKey && ( Terms of Service
<FieldRow> </Link>{" "}
<RecaptchaInput apply.
publicKey={recaptchaKey} <br />
onResponse={setRecaptchaResponse} By clicking "Go", you agree to our{" "}
/> <Link href={privacyPolicyUrl}>Terms and conditions</Link>
</FieldRow> </Caption>
)} )}
{error && ( {error && (
<FieldRow> <FieldRow>
@ -231,6 +191,7 @@ export function RegisterPage() {
{registering ? "Registering..." : "Register"} {registering ? "Registering..." : "Register"}
</Button> </Button>
</FieldRow> </FieldRow>
<div id={recaptchaId} />
</form> </form>
</div> </div>
<div className={styles.authLinks}> <div className={styles.authLinks}>

View 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];
}

View 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
View 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 };
}

View file

@ -62,7 +62,7 @@ export const Button = forwardRef(
[styles.off]: off, [styles.off]: off,
} }
)} )}
{...filteredButtonProps} {...mergeProps(rest, filteredButtonProps)}
ref={buttonRef} ref={buttonRef}
> >
{children} {children}

11
src/form/Form.jsx Normal file
View 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
View file

@ -0,0 +1,4 @@
.form {
display: flex;
flex-direction: column;
}

View file

@ -1,16 +1,16 @@
import React, { useMemo } from "react"; import React from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { CopyButton } from "./button"; import { CopyButton } from "../button";
import { Facepile } from "./Facepile"; import { Facepile } from "../Facepile";
import { Avatar } from "./Avatar"; import { Avatar } from "../Avatar";
import { ReactComponent as VideoIcon } from "./icons/Video.svg"; import { ReactComponent as VideoIcon } from "../icons/Video.svg";
import styles from "./CallList.module.css"; 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 ( return (
<> <>
<h3>{title}</h3>
<div className={styles.callList}> <div className={styles.callList}>
{rooms.map(({ roomId, roomName, avatarUrl, participants }) => ( {rooms.map(({ roomId, roomName, avatarUrl, participants }) => (
<CallTile <CallTile
@ -22,6 +22,12 @@ export function CallList({ title, rooms, client }) {
participants={participants} participants={participants}
/> />
))} ))}
{rooms.length > 3 && (
<>
<div className={styles.callTileSpacer} />
<div className={styles.callTileSpacer} />
</>
)}
</div> </div>
</> </>
); );
@ -32,17 +38,23 @@ function CallTile({ name, avatarUrl, roomId, participants, client }) {
<div className={styles.callTile}> <div className={styles.callTile}>
<Link to={`/room/${roomId}`} className={styles.callTileLink}> <Link to={`/room/${roomId}`} className={styles.callTileLink}>
<Avatar <Avatar
size="md" size="lg"
bgKey={name} bgKey={name}
src={avatarUrl} src={avatarUrl}
fallback={<VideoIcon width={16} height={16} />} fallback={<VideoIcon width={16} height={16} />}
className={styles.avatar} className={styles.avatar}
/> />
<div className={styles.callInfo}> <div className={styles.callInfo}>
<h5>{name}</h5> <Body overflowEllipsis fontWeight="semiBold">
<p>{getRoomUrl(roomId)}</p> {name}
</Body>
<Caption overflowEllipsis>{getRoomUrl(roomId)}</Caption>
{participants && ( {participants && (
<Facepile client={client} participants={participants} /> <Facepile
className={styles.facePile}
client={client}
participants={participants}
/>
)} )}
</div> </div>
<div className={styles.copyButtonSpacer} /> <div className={styles.copyButtonSpacer} />

View file

@ -1,6 +1,14 @@
.callTileSpacer,
.callTile { .callTile {
min-width: 240px; width: 329px;
height: 94px; }
.callTileSpacer {
height: 0;
}
.callTile {
height: 95px;
padding: 12px; padding: 12px;
background-color: var(--bgColor2); background-color: var(--bgColor2);
border-radius: 8px; border-radius: 8px;
@ -31,28 +39,11 @@
} }
.callInfo > * { .callInfo > * {
margin-top: 0;
margin-bottom: 8px;
}
.callInfo > :last-child {
margin-bottom: 0; margin-bottom: 0;
} }
.callInfo h5 { .facePile {
font-size: 15px; margin-top: 8px;
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;
} }
.copyButtonSpacer, .copyButtonSpacer,
@ -68,7 +59,12 @@
} }
.callList { .callList {
display: grid; display: flex;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); flex-wrap: wrap;
max-width: calc((329px + 24px) * 3);
width: calc(100% - 48px);
gap: 24px; gap: 24px;
padding: 0 24px;
justify-content: center;
margin-bottom: 24px;
} }

38
src/home/HomePage.jsx Normal file
View 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 />
);
}
}

View file

@ -1,7 +1,7 @@
import React from "react"; import React from "react";
import { Modal, ModalContent } from "./Modal"; import { Modal, ModalContent } from "../Modal";
import { Button } from "./button"; import { Button } from "../button";
import { FieldRow } from "./Input"; import { FieldRow } from "../input/Input";
import styles from "./JoinExistingCallModal.module.css"; import styles from "./JoinExistingCallModal.module.css";
export function JoinExistingCallModal({ onJoin, ...rest }) { export function JoinExistingCallModal({ onJoin, ...rest }) {

120
src/home/RegisteredView.jsx Normal file
View 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} />
)}
</>
);
}

View 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;
}

View 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} />
)}
</>
);
}

View 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;
}
}

View 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);
}
}

View 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;
}

View file

@ -1,3 +1,3 @@
<svg width="21" height="24" viewBox="0 0 21 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <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="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"/> <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> </svg>

Before

Width:  |  Height:  |  Size: 820 B

After

Width:  |  Height:  |  Size: 872 B

View file

@ -1,6 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <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"/> <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"/>
<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"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 354 B

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -20,6 +20,8 @@ limitations under the License.
Therefore we define a unicode-range to load which excludes the glyphs Therefore we define a unicode-range to load which excludes the glyphs
(to avoid having to maintain a fork of Inter). */ (to avoid having to maintain a fork of Inter). */
@import "normalize.css/normalize.css";
:root { :root {
--inter-unicode-range: U+0000-20e2, U+20e4-23ce, U+23d0-24c1, U+24c3-259f, --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; 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; --textColor4: #a9b2bc;
--inputBorderColor: #394049; --inputBorderColor: #394049;
--inputBorderColorFocused: #0086e6; --inputBorderColorFocused: #0086e6;
--linkColor: #0086e6;
} }
@font-face { @font-face {
@ -139,6 +142,44 @@ body,
flex-direction: column; 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 { a {
color: var(--primaryColor); color: var(--primaryColor);
text-decoration: none; text-decoration: none;

View file

@ -1,7 +1,7 @@
import React, { forwardRef } from "react"; import React, { forwardRef } from "react";
import classNames from "classnames"; import classNames from "classnames";
import styles from "./Input.module.css"; 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 }) { export function FieldRow({ children, rightAlign, className, ...rest }) {
return ( return (

View file

@ -2,11 +2,11 @@ import React, { useRef } from "react";
import { HiddenSelect, useSelect } from "@react-aria/select"; import { HiddenSelect, useSelect } from "@react-aria/select";
import { useButton } from "@react-aria/button"; import { useButton } from "@react-aria/button";
import { useSelectState } from "@react-stately/select"; import { useSelectState } from "@react-stately/select";
import { Popover } from "./Popover"; import { Popover } from "../popover/Popover";
import { ListBox } from "./ListBox"; import { ListBox } from "../ListBox";
import styles from "./SelectInput.module.css"; import styles from "./SelectInput.module.css";
import classNames from "classnames"; import classNames from "classnames";
import { ReactComponent as ArrowDownIcon } from "./icons/ArrowDown.svg"; import { ReactComponent as ArrowDownIcon } from "../icons/ArrowDown.svg";
export function SelectInput(props) { export function SelectInput(props) {
const state = useSelectState(props); const state = useSelectState(props);

111
src/matrix-utils.js Normal file
View 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");
}

View file

@ -3,11 +3,11 @@ import { DismissButton, useOverlay } from "@react-aria/overlays";
import { FocusScope } from "@react-aria/focus"; import { FocusScope } from "@react-aria/focus";
import classNames from "classnames"; import classNames from "classnames";
import styles from "./Popover.module.css"; import styles from "./Popover.module.css";
import { useObjectRef } from "@react-aria/utils";
export const Popover = forwardRef( export const Popover = forwardRef(
({ isOpen = true, onClose, className, children, ...rest }, ref) => { ({ isOpen = true, onClose, className, children, ...rest }, ref) => {
const fallbackRef = useRef(); const popoverRef = useObjectRef(ref);
const popoverRef = ref || fallbackRef;
const { overlayProps } = useOverlay( const { overlayProps } = useOverlay(
{ {

View file

@ -1,14 +1,13 @@
import React, { useCallback, useEffect, useState } from "react"; import React, { useCallback, useEffect, useState } from "react";
import { Button } from "./button"; import { Button } from "../button";
import { useProfile } from "./ConferenceCallManagerHooks"; import { useProfile } from "./useProfile";
import { FieldRow, InputField, ErrorMessage } from "./Input"; import { FieldRow, InputField, ErrorMessage } from "../input/Input";
import { Modal, ModalContent } from "./Modal"; import { Modal, ModalContent } from "../Modal";
export function ProfileModal({ export function ProfileModal({
client, client,
isAuthenticated, isAuthenticated,
isPasswordlessUser, isPasswordlessUser,
isGuest,
...rest ...rest
}) { }) {
const { onClose } = rest; const { onClose } = rest;
@ -66,7 +65,7 @@ export function ProfileModal({
onChange={onChangeDisplayName} onChange={onChangeDisplayName}
/> />
</FieldRow> </FieldRow>
{isAuthenticated && !isGuest && !isPasswordlessUser && ( {isAuthenticated && !isPasswordlessUser && (
<FieldRow> <FieldRow>
<InputField <InputField
type="file" type="file"

91
src/profile/useProfile.js Normal file
View 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 };
}

View 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>
</>
);
}

View 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);
}
}

View file

@ -1,13 +1,13 @@
import React from "react"; import React from "react";
import { Button } from "./button"; import { Button } from "../button";
import { PopoverMenuTrigger } from "./PopoverMenu"; import { PopoverMenuTrigger } from "../popover/PopoverMenu";
import { ReactComponent as SpotlightIcon } from "./icons/Spotlight.svg"; import { ReactComponent as SpotlightIcon } from "../icons/Spotlight.svg";
import { ReactComponent as FreedomIcon } from "./icons/Freedom.svg"; import { ReactComponent as FreedomIcon } from "../icons/Freedom.svg";
import { ReactComponent as CheckIcon } from "./icons/Check.svg"; import { ReactComponent as CheckIcon } from "../icons/Check.svg";
import styles from "./GridLayoutMenu.module.css"; import styles from "./GridLayoutMenu.module.css";
import { Menu } from "./Menu"; import { Menu } from "../Menu";
import { Item } from "@react-stately/collections"; import { Item } from "@react-stately/collections";
import { Tooltip, TooltipTrigger } from "./Tooltip"; import { Tooltip, TooltipTrigger } from "../Tooltip";
export function GridLayoutMenu({ layout, setLayout }) { export function GridLayoutMenu({ layout, setLayout }) {
return ( return (

View file

@ -26,6 +26,7 @@ function getHangupCallState(call) {
export function GroupCallInspector({ client, groupCall, show }) { export function GroupCallInspector({ client, groupCall, show }) {
const [roomStateEvents, setRoomStateEvents] = useState([]); const [roomStateEvents, setRoomStateEvents] = useState([]);
const [toDeviceEvents, setToDeviceEvents] = useState([]); const [toDeviceEvents, setToDeviceEvents] = useState([]);
const [sentVoipEvents, setSentVoipEvents] = useState([]);
const [state, setState] = useState({ const [state, setState] = useState({
userId: client.getUserId(), 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); client.on("RoomState.events", onUpdateRoomState);
groupCall.on("calls_changed", onCallsChanged); groupCall.on("calls_changed", onCallsChanged);
groupCall.on("send_voip_event", onSendVoipEvent);
client.on("state", onCallsChanged); client.on("state", onCallsChanged);
client.on("hangup", onCallHangup); client.on("hangup", onCallHangup);
client.on("toDeviceEvent", onToDeviceEvent); client.on("toDeviceEvent", onToDeviceEvent);
@ -133,6 +139,19 @@ export function GroupCallInspector({ client, groupCall, show }) {
return result; return result;
}, [toDeviceEvents]); }, [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(() => { useEffect(() => {
let timeout; let timeout;
@ -190,6 +209,8 @@ export function GroupCallInspector({ client, groupCall, show }) {
roomStateEvents, roomStateEvents,
toDeviceEvents, toDeviceEvents,
toDeviceEventsByCall, toDeviceEventsByCall,
sentVoipEvents,
sentVoipEventsByCall,
}} }}
name={null} name={null}
indentWidth={2} indentWidth={2}

View 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
View 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
View 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>
);
}

View 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;
}
}

View file

@ -1,7 +1,7 @@
import React from "react"; import React from "react";
import { Modal, ModalContent } from "./Modal"; import { Modal, ModalContent } from "../Modal";
import { CopyButton } from "./button"; import { CopyButton } from "../button";
import { getRoomUrl } from "./ConferenceCallManagerHooks"; import { getRoomUrl } from "../matrix-utils";
import styles from "./InviteModal.module.css"; import styles from "./InviteModal.module.css";
export function InviteModal({ roomId, ...rest }) { export function InviteModal({ roomId, ...rest }) {

105
src/room/LobbyView.jsx Normal file
View 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>
);
}

View file

@ -22,12 +22,6 @@ limitations under the License.
min-height: 100%; min-height: 100%;
} }
.inRoom {
position: fixed;
height: 100%;
width: 100%;
}
.joinRoom { .joinRoom {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -44,21 +38,12 @@ limitations under the License.
flex: 1; flex: 1;
} }
.joinRoomContent h1 {
display: none;
margin: 0;
}
.joinRoomFooter { .joinRoomFooter {
margin: 20px 0; margin: 20px 0;
} }
.homeLink { .homeLink {
margin-top: 50px; margin-top: 50px;
color: #0dbd8b;
text-decoration: none;
font-weight: normal;
font-size: 15px;
} }
.preview { .preview {
@ -85,8 +70,6 @@ limitations under the License.
left: 50%; left: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
margin: 0; margin: 0;
font-size: 13px;
font-weight: 600;
text-align: center; text-align: center;
} }
@ -125,93 +108,3 @@ limitations under the License.
.previewButtons > :last-child { .previewButtons > :last-child {
margin-right: 0px; 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;
}
}

View file

@ -1,15 +1,15 @@
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { Button } from "./button"; import { Button } from "../button";
import { Menu } from "./Menu"; import { Menu } from "../Menu";
import { PopoverMenuTrigger } from "./PopoverMenu"; import { PopoverMenuTrigger } from "../popover/PopoverMenu";
import { Item } from "@react-stately/collections"; import { Item } from "@react-stately/collections";
import { ReactComponent as SettingsIcon } from "./icons/Settings.svg"; import { ReactComponent as SettingsIcon } from "../icons/Settings.svg";
import { ReactComponent as AddUserIcon } from "./icons/AddUser.svg"; import { ReactComponent as AddUserIcon } from "../icons/AddUser.svg";
import { ReactComponent as OverflowIcon } from "./icons/Overflow.svg"; import { ReactComponent as OverflowIcon } from "../icons/Overflow.svg";
import { useModalTriggerState } from "./Modal"; import { useModalTriggerState } from "../Modal";
import { SettingsModal } from "./SettingsModal"; import { SettingsModal } from "../settings/SettingsModal";
import { InviteModal } from "./InviteModal"; import { InviteModal } from "./InviteModal";
import { Tooltip, TooltipTrigger } from "./Tooltip"; import { Tooltip, TooltipTrigger } from "../Tooltip";
export function OverflowMenu({ export function OverflowMenu({
roomId, roomId,

97
src/room/RoomAuthView.jsx Normal file
View 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>
</>
);
}

View 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
View 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
View 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 />;
}

View 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;
}

View 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]);
}

View file

@ -1,14 +1,14 @@
import React from "react"; import React from "react";
import { Modal } from "./Modal"; import { Modal } from "../Modal";
import styles from "./SettingsModal.module.css"; import styles from "./SettingsModal.module.css";
import { TabContainer, TabItem } from "./Tabs"; import { TabContainer, TabItem } from "../Tabs";
import { ReactComponent as AudioIcon } from "./icons/Audio.svg"; import { ReactComponent as AudioIcon } from "../icons/Audio.svg";
import { ReactComponent as VideoIcon } from "./icons/Video.svg"; import { ReactComponent as VideoIcon } from "../icons/Video.svg";
import { ReactComponent as DeveloperIcon } from "./icons/Developer.svg"; import { ReactComponent as DeveloperIcon } from "../icons/Developer.svg";
import { SelectInput } from "./SelectInput"; import { SelectInput } from "../input/SelectInput";
import { Item } from "@react-stately/collections"; import { Item } from "@react-stately/collections";
import { useMediaHandler } from "./useMediaHandler"; import { useMediaHandler } from "./useMediaHandler";
import { FieldRow, InputField } from "./Input"; import { FieldRow, InputField } from "../input/Input";
export function SettingsModal({ export function SettingsModal({
client, client,

View file

@ -1,7 +1,5 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
let audioOutput;
export function useMediaHandler(client) { export function useMediaHandler(client) {
const [{ audioInput, videoInput, audioInputs, videoInputs }, setState] = const [{ audioInput, videoInput, audioInputs, videoInputs }, setState] =
useState(() => { useState(() => {
@ -27,7 +25,7 @@ export function useMediaHandler(client) {
(device) => device.kind === "videoinput" (device) => device.kind === "videoinput"
); );
setState((prevState) => ({ setState(() => ({
audioInput: mediaHandler.audioInput, audioInput: mediaHandler.audioInput,
videoInput: mediaHandler.videoInput, videoInput: mediaHandler.videoInput,
audioInputs, audioInputs,

View 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>
);
}
);

View 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;
}

View 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
View 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]);
}

9402
yarn.lock

File diff suppressed because it is too large Load diff