Compare commits

...

30 Commits

Author SHA1 Message Date
David Baker
b1a75a5033 Merge pull request #165 from vector-im/dbkr/nginx_unpriv
Use unprivilged nginx image
2021-12-20 18:47:07 +00:00
David Baker
032d623acb Use unprivilged nginx image 2021-12-20 18:43:45 +00:00
David Baker
8f4347887b Merge pull request #164 from dbkr/main
Add CI
2021-12-20 18:01:57 +00:00
David Baker
ccd8b484a6 Add env vars to build script
with hardcoded values for now
2021-12-20 17:53:23 +00:00
Robert Long
493445a6b0 Update user avatars 2021-12-20 09:12:28 -08:00
David Baker
f66158a4da Add CI 2021-12-20 16:59:39 +00:00
Robert Long
61552af5fb Remove console.log 2021-12-17 16:30:33 -08:00
Robert Long
39d28a0488 Add configurable default homeserver 2021-12-17 16:30:10 -08:00
Robert Long
34d0483c99 Fix button props 2021-12-17 15:47:54 -08:00
Robert Long
32800a82fc Fix copy button className 2021-12-17 15:08:24 -08:00
Robert Long
8203715bc0 Join existing call modal 2021-12-17 15:01:59 -08:00
Robert Long
591211f698 Fix user menu for guests 2021-12-17 11:51:12 -08:00
Robert Long
6ea821a36e Hide avatar input for unregistered users 2021-12-17 11:40:13 -08:00
Robert Long
d493b95782 Remove register menu item 2021-12-17 11:27:33 -08:00
Robert Long
486f264673 Fix end call screen styling 2021-12-17 11:22:07 -08:00
Robert Long
473a027702 Fix avatar styling 2021-12-17 11:01:40 -08:00
Robert Long
bcb4ccf66d Reduce homepage padding 2021-12-17 10:40:09 -08:00
Robert Long
0ce29cfd45 Fix undefined setUserName method. 2021-12-17 10:39:58 -08:00
Robert Long
a7539eb34e Add change password flow for anonymous users 2021-12-16 16:43:35 -08:00
Robert Long
d7fd06a250 Fix tooltip text alignment 2021-12-16 10:14:51 -08:00
Robert Long
6389899f63 Remove extra padding at top of home page 2021-12-16 10:13:40 -08:00
Robert Long
4385301cfb Merge branch 'main' of github.com:vector-im/matrix-video-chat 2021-12-16 10:10:58 -08:00
Robert Long
4debc9e3f5 Fix leaving call with screenshare active 2021-12-16 10:10:55 -08:00
Robert Long
24944f1cac Initial avatar work 2021-12-15 14:31:07 -08:00
Robert Long
b57eaee5ca Add password confirmation input 2021-12-15 11:10:38 -08:00
Robert Long
382ca2baa4 Remove homeserver input 2021-12-15 10:54:01 -08:00
Robert Long
81144a7421 Remove logout item for passwordless user 2021-12-15 10:37:40 -08:00
Robert Long
3fd90915d8 Disable logout button in call 2021-12-15 10:25:18 -08:00
Robert Long
b6ee7cfefc Remove preview screen title 2021-12-15 10:05:51 -08:00
Robert Long
3157cf65ef Fix tooltip behaviors on click/focus 2021-12-14 22:00:00 -08:00
33 changed files with 957 additions and 418 deletions

1
.dockerignore Normal file
View File

@@ -0,0 +1 @@
node_modules

3
.env
View File

@@ -4,6 +4,9 @@
# https://vitejs.dev/guide/env-and-mode.html#env-files
####
# Used for determining the homeserver to use for short urls etc.
# VITE_DEFAULT_HOMESERVER=http://localhost:8008
# The room id for the space to use for listing public group call rooms
# VITE_PUBLIC_SPACE_ROOM_ID=!hjdfshkdskjdsk:myhomeserver.com

41
.github/workflows/publish.yaml vendored Normal file
View File

@@ -0,0 +1,41 @@
name: Build & publish images to the package registry for tags
on:
release:
types: [published]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
name: Build & publish
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Check it out
uses: actions/checkout@v2
- name: Log in to container registry
uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push Docker image
uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

14
Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM node:16-buster as builder
WORKDIR /src
COPY . /src/matrix-video-chat
RUN matrix-video-chat/scripts/dockerbuild.sh
# App
FROM nginxinc/nginx-unprivileged:alpine
COPY --from=builder /src/matrix-video-chat/dist /app
RUN rm -rf /usr/share/nginx/html \
&& ln -s /app /usr/share/nginx/html

View File

@@ -13,10 +13,12 @@
"@react-aria/overlays": "^3.7.3",
"@react-aria/select": "^3.6.0",
"@react-aria/tabs": "^3.1.0",
"@react-aria/tooltip": "^3.1.3",
"@react-aria/utils": "^3.10.0",
"@react-stately/collections": "^3.3.4",
"@react-stately/overlays": "^3.1.3",
"@react-stately/select": "^3.1.3",
"@react-stately/tooltip": "^3.0.5",
"@react-stately/tree": "^3.2.0",
"@sentry/react": "^6.13.3",
"@sentry/tracing": "^6.13.3",

29
scripts/dockerbuild.sh Executable file
View File

@@ -0,0 +1,29 @@
#!/bin/sh
set -ex
VITE_DEFAULT_HOMESERVER=https://call.ems.host
VITE_SENTRY_DSN=https://b1e328d49be3402ba96101338989fb35@sentry.matrix.org/41
git clone https://github.com/matrix-org/matrix-js-sdk.git
cd matrix-js-sdk
git checkout robertlong/group-call
yarn install
yarn run build
yarn link
cd ..
git clone https://github.com/matrix-org/matrix-react-sdk.git
cd matrix-react-sdk
git checkout robertlong/group-call
yarn link matrix-js-sdk
yarn install
yarn run build
yarn link
cd ..
cd matrix-video-chat
yarn link matrix-js-sdk
yarn link matrix-react-sdk
yarn install
yarn run build

View File

@@ -28,18 +28,17 @@ import { Home } from "./Home";
import { LoginPage } from "./LoginPage";
import { RegisterPage } from "./RegisterPage";
import { Room } from "./Room";
import { ClientProvider } from "./ConferenceCallManagerHooks";
import {
ClientProvider,
defaultHomeserverHost,
} from "./ConferenceCallManagerHooks";
import { useFocusVisible } from "@react-aria/interactions";
import styles from "./App.module.css";
import { ErrorView, LoadingView } from "./FullScreenView";
import { LoadingView } from "./FullScreenView";
const SentryRoute = Sentry.withSentryRouting(Route);
const { protocol, host } = window.location;
// Assume homeserver is hosted on same domain (proxied in development by vite)
const homeserverUrl = `${protocol}//${host}`;
export default function App() {
export default function App({ history }) {
const { isFocusVisible } = useFocusVisible();
useEffect(() => {
@@ -58,8 +57,8 @@ export default function App() {
}, [isFocusVisible]);
return (
<Router>
<ClientProvider homeserverUrl={homeserverUrl}>
<Router history={history}>
<ClientProvider>
<OverlayProvider>
<Switch>
<SentryRoute exact path="/">
@@ -87,50 +86,20 @@ export default function App() {
function RoomRedirect() {
const { pathname } = useLocation();
const history = useHistory();
const [error, setError] = useState();
useEffect(() => {
async function redirect() {
let roomId = pathname;
let roomId = pathname;
if (pathname.startsWith("/")) {
roomId = roomId.substr(1, roomId.length);
}
if (!roomId.startsWith("#") && !roomId.startsWith("!")) {
let loginHomeserverUrl = homeserverUrl.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 { host } = new URL(loginHomeserverUrl);
roomId = `#${roomId}:${host}`;
}
history.replace(`/room/${roomId}`);
if (pathname.startsWith("/")) {
roomId = roomId.substr(1, roomId.length);
}
redirect().catch(setError);
}, [history, pathname]);
if (!roomId.startsWith("#") && !roomId.startsWith("!")) {
roomId = `#${roomId}:${defaultHomeserverHost}`;
}
if (error) {
return <ErrorView error={error} />;
}
history.replace(`/room/${roomId}`);
}, [pathname, history]);
return <LoadingView />;
}

View File

@@ -6,6 +6,8 @@
justify-content: center;
pointer-events: none;
font-weight: 600;
overflow: hidden;
flex-shrink: 0;
}
.avatar img {
@@ -18,13 +20,24 @@
fill: #ffffff;
}
.sm {
.avatar span {
padding-top: 1px;
}
.xs {
width: 22px;
height: 22px;
border-radius: 22px;
font-size: 14px;
}
.sm {
width: 32px;
height: 32px;
border-radius: 32px;
font-size: 15px;
}
.md {
width: 36px;
height: 36px;

View File

@@ -7,7 +7,7 @@ import { ReactComponent as VideoIcon } from "./icons/Video.svg";
import styles from "./CallList.module.css";
import { getRoomUrl } from "./ConferenceCallManagerHooks";
export function CallList({ title, rooms }) {
export function CallList({ title, rooms, client }) {
return (
<>
<h3>{title}</h3>
@@ -15,6 +15,7 @@ export function CallList({ title, rooms }) {
{rooms.map(({ roomId, roomName, avatarUrl, participants }) => (
<CallTile
key={roomId}
client={client}
name={roomName}
avatarUrl={avatarUrl}
roomId={roomId}
@@ -26,7 +27,7 @@ export function CallList({ title, rooms }) {
);
}
function CallTile({ name, avatarUrl, roomId, participants }) {
function CallTile({ name, avatarUrl, roomId, participants, client }) {
return (
<div className={styles.callTile}>
<Link to={`/room/${roomId}`} className={styles.callTileLink}>
@@ -40,7 +41,9 @@ function CallTile({ name, avatarUrl, roomId, participants }) {
<div className={styles.callInfo}>
<h5>{name}</h5>
<p>{getRoomUrl(roomId)}</p>
{participants && <Facepile participants={participants} />}
{participants && (
<Facepile client={client} participants={participants} />
)}
</div>
<div className={styles.copyButtonSpacer} />
</Link>

View File

@@ -28,7 +28,12 @@ import {
GroupCallType,
} from "matrix-js-sdk/src/browser-index";
import { useHistory } from "react-router-dom";
import { randomString } from "matrix-js-sdk/src/randomstring";
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();
@@ -101,18 +106,10 @@ export async function fetchGroupCall(
});
}
export function ClientProvider({ homeserverUrl, children }) {
export function ClientProvider({ children }) {
const history = useHistory();
const [
{
loading,
isAuthenticated,
isPasswordlessUser,
isGuest,
client,
userName,
displayName,
},
{ loading, isAuthenticated, isPasswordlessUser, isGuest, client, userName },
setState,
] = useState({
loading: true,
@@ -121,7 +118,6 @@ export function ClientProvider({ homeserverUrl, children }) {
isGuest: false,
client: undefined,
userName: null,
displayName: null,
});
useEffect(() => {
@@ -130,12 +126,18 @@ export function ClientProvider({ homeserverUrl, children }) {
const authStore = localStorage.getItem("matrix-auth-store");
if (authStore) {
const { user_id, device_id, access_token, guest, passwordlessUser } =
JSON.parse(authStore);
const {
user_id,
device_id,
access_token,
guest,
passwordlessUser,
tempPassword,
} = JSON.parse(authStore);
const client = await initClient(
{
baseUrl: homeserverUrl,
baseUrl: defaultHomeserver,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
@@ -151,6 +153,7 @@ export function ClientProvider({ homeserverUrl, children }) {
access_token,
guest,
passwordlessUser,
tempPassword,
})
);
@@ -249,14 +252,14 @@ export function ClientProvider({ homeserverUrl, children }) {
const registerGuest = useCallback(async () => {
try {
const registrationClient = matrix.createClient(homeserverUrl);
const registrationClient = matrix.createClient(defaultHomeserver);
const { user_id, device_id, access_token } =
await registrationClient.registerGuest({});
const client = await initClient(
{
baseUrl: homeserverUrl,
baseUrl: defaultHomeserver,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
@@ -297,7 +300,7 @@ export function ClientProvider({ homeserverUrl, children }) {
const register = useCallback(async (username, password, passwordlessUser) => {
try {
const registrationClient = matrix.createClient(homeserverUrl);
const registrationClient = matrix.createClient(defaultHomeserver);
const { user_id, device_id, access_token } =
await registrationClient.register(username, password, null, {
@@ -305,16 +308,19 @@ export function ClientProvider({ homeserverUrl, children }) {
});
const client = await initClient({
baseUrl: homeserverUrl,
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 })
);
const session = { user_id, device_id, access_token, passwordlessUser };
if (passwordlessUser) {
session.tempPassword = password;
}
localStorage.setItem("matrix-auth-store", JSON.stringify(session));
setState({
client,
@@ -340,6 +346,45 @@ export function ClientProvider({ homeserverUrl, children }) {
}
}, []);
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 logout = useCallback(() => {
localStorage.removeItem("matrix-auth-store");
window.location = "/";
@@ -355,6 +400,7 @@ export function ClientProvider({ homeserverUrl, children }) {
login,
registerGuest,
register,
changePassword,
logout,
userName,
}),
@@ -367,6 +413,7 @@ export function ClientProvider({ homeserverUrl, children }) {
login,
registerGuest,
register,
changePassword,
logout,
userName,
]
@@ -381,7 +428,7 @@ export function useClient() {
return useContext(ClientContext);
}
function roomAliasFromRoomName(roomName) {
export function roomAliasFromRoomName(roomName) {
return roomName
.trim()
.replace(/\s/g, "-")
@@ -434,41 +481,6 @@ export async function createRoom(client, name) {
return room_alias || room_id;
}
export function useCreateRoom() {
const { register, client } = useClient();
const [creatingRoom, setCreatingRoom] = useState(false);
const [createRoomError, setCreateRoomError] = useState();
const onCreateRoom = useCallback(
(roomName, userName) => {
async function onCreateRoom(roomName, userName) {
let _client = client;
if (!_client) {
_client = await register(userName, randomString(16), true);
}
return await createRoom(_client, roomName);
}
setCreateRoomError(undefined);
setCreatingRoom(true);
return onCreateRoom(roomName, userName).catch((error) => {
setCreateRoomError(error);
setCreatingRoom(false);
});
},
[register, client]
);
return {
creatingRoom,
createRoomError,
createRoom: onCreateRoom,
};
}
export function useLoadGroupCall(client, roomId, viaServers) {
const [state, setState] = useState({
loading: true,
@@ -602,7 +614,7 @@ export function getRoomUrl(roomId) {
if (roomId.startsWith("#")) {
const [localPart, host] = roomId.replace("#", "").split(":");
if (host !== window.location.host) {
if (host !== defaultHomeserverHost) {
return `${window.location.host}/room/${roomId}`;
} else {
return `${window.location.host}/${localPart}`;
@@ -612,17 +624,35 @@ export function getRoomUrl(roomId) {
}
}
export function useDisplayName(client) {
const [{ loading, displayName, error, success }, setState] = useState(() => ({
success: false,
loading: false,
displayName: client?.getUser(client.getUserId())?.displayName,
error: null,
}));
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 onChangeDisplayName = (_event, { displayName }) => {
setState({ success: false, loading: false, displayName, error: null });
const onChangeUser = (_event, { displayName, avatarUrl }) => {
setState({
success: false,
loading: false,
displayName,
avatarUrl: getAvatarUrl(client, avatarUrl),
error: null,
});
};
let user;
@@ -630,18 +660,20 @@ export function useDisplayName(client) {
if (client) {
const userId = client.getUserId();
user = client.getUser(userId);
user.on("User.displayName", onChangeDisplayName);
user.on("User.displayName", onChangeUser);
user.on("User.avatarUrl", onChangeUser);
}
return () => {
if (user) {
user.removeListener("User.displayName", onChangeDisplayName);
user.removeListener("User.displayName", onChangeUser);
user.removeListener("User.avatarUrl", onChangeUser);
}
};
}, [client]);
const setDisplayName = useCallback(
(displayName) => {
const saveProfile = useCallback(
async ({ displayName, avatar }) => {
if (client) {
setState((prev) => ({
...prev,
@@ -650,30 +682,39 @@ export function useDisplayName(client) {
success: false,
}));
client
.setDisplayName(displayName)
.then(() => {
setState((prev) => ({
...prev,
displayName,
loading: false,
success: true,
}));
})
.catch((error) => {
setState((prev) => ({
...prev,
loading: false,
error,
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 setDisplayName");
console.error("Client not initialized before calling saveProfile");
}
},
[client]
);
return { loading, error, displayName, setDisplayName, success };
return { loading, error, displayName, avatarUrl, saveProfile, success };
}

View File

@@ -2,27 +2,32 @@ import React from "react";
import styles from "./Facepile.module.css";
import classNames from "classnames";
import { Avatar } from "./Avatar";
import { getAvatarUrl } from "./ConferenceCallManagerHooks";
export function Facepile({ className, participants, ...rest }) {
export function Facepile({ className, client, participants, ...rest }) {
return (
<div
className={classNames(styles.facepile, className)}
title={participants.map((member) => member.name).join(", ")}
{...rest}
>
{participants.slice(0, 3).map((member, i) => (
<Avatar
key={member.userId}
size="sm"
fallback={member.name.slice(0, 1).toUpperCase()}
className={styles.avatar}
style={{ left: i * 22 }}
/>
))}
{participants.slice(0, 3).map((member, i) => {
const avatarUrl = member.user?.avatarUrl;
return (
<Avatar
key={member.userId}
size="xs"
src={avatarUrl && getAvatarUrl(client, avatarUrl, 22)}
fallback={member.name.slice(0, 1).toUpperCase()}
className={styles.avatar}
style={{ left: i * 22 }}
/>
);
})}
{participants.length > 3 && (
<Avatar
key="additional"
size="sm"
size="xs"
fallback={`+${participants.length - 3}`}
className={styles.avatar}
style={{ left: 3 * 22 }}

View File

@@ -1,5 +1,5 @@
import React from "react";
import { ButtonTooltip, Button } from "./button";
import { Button } from "./button";
import { PopoverMenuTrigger } from "./PopoverMenu";
import { ReactComponent as SpotlightIcon } from "./icons/Spotlight.svg";
import { ReactComponent as FreedomIcon } from "./icons/Freedom.svg";
@@ -7,14 +7,21 @@ import { ReactComponent as CheckIcon } from "./icons/Check.svg";
import styles from "./GridLayoutMenu.module.css";
import { Menu } from "./Menu";
import { Item } from "@react-stately/collections";
import { Tooltip, TooltipTrigger } from "./Tooltip";
export function GridLayoutMenu({ layout, setLayout }) {
return (
<PopoverMenuTrigger placement="bottom right">
<Button variant="icon">
<ButtonTooltip>Layout Type</ButtonTooltip>
{layout === "spotlight" ? <SpotlightIcon /> : <FreedomIcon />}
</Button>
<TooltipTrigger>
<Button variant="icon">
{layout === "spotlight" ? <SpotlightIcon /> : <FreedomIcon />}
</Button>
{(props) => (
<Tooltip position="bottom" {...props}>
Layout Type
</Tooltip>
)}
</TooltipTrigger>
{(props) => (
<Menu {...props} label="Grid layout menu" onAction={setLayout}>
<Item key="freedom" textValue="Freedom">

View File

@@ -14,13 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback } from "react";
import React, { useCallback, useState } from "react";
import { useHistory, Link } from "react-router-dom";
import {
useClient,
useGroupCallRooms,
usePublicRooms,
useCreateRoom,
createRoom,
roomAliasFromRoomName,
} from "./ConferenceCallManagerHooks";
import { Header, HeaderLogo, LeftNav, RightNav } from "./Header";
import styles from "./Home.module.css";
@@ -30,6 +31,9 @@ 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";
export function Home() {
const {
@@ -39,10 +43,14 @@ export function Home() {
loading,
error,
client,
register,
} = useClient();
const history = useHistory();
const { createRoomError, creatingRoom, createRoom } = useCreateRoom();
const [creatingRoom, setCreatingRoom] = useState(false);
const [createRoomError, setCreateRoomError] = useState();
const { modalState, modalProps } = useModalTriggerState();
const [existingRoomId, setExistingRoomId] = useState();
const onCreateRoom = useCallback(
(e) => {
@@ -51,13 +59,36 @@ export function Home() {
const roomName = data.get("roomName");
const userName = data.get("userName");
createRoom(roomName, userName).then((roomIdOrAlias) => {
async function onCreateRoom() {
let _client = client;
if (!_client || isGuest) {
_client = await register(userName, randomString(16), 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);
});
},
[history]
[client, history, register, isGuest]
);
const onJoinRoom = useCallback(
@@ -70,30 +101,39 @@ export function Home() {
[history]
);
const onJoinExistingRoom = useCallback(() => {
history.push(`/${existingRoomId}`);
}, [history, existingRoomId]);
if (loading) {
return <LoadingView />;
} else if (error || createRoomError) {
return <ErrorView error={error || createRoomError} />;
} else if (!isAuthenticated || isGuest) {
return (
<UnregisteredView
onCreateRoom={onCreateRoom}
createRoomError={createRoomError}
creatingRoom={creatingRoom}
onJoinRoom={onJoinRoom}
/>
);
} else if (error) {
return <ErrorView error={error} />;
} else {
return (
<RegisteredView
client={client}
isPasswordlessUser={isPasswordlessUser}
isGuest={isGuest}
onCreateRoom={onCreateRoom}
createRoomError={createRoomError}
creatingRoom={creatingRoom}
onJoinRoom={onJoinRoom}
/>
<>
{!isAuthenticated || isGuest ? (
<UnregisteredView
onCreateRoom={onCreateRoom}
createRoomError={createRoomError}
creatingRoom={creatingRoom}
onJoinRoom={onJoinRoom}
/>
) : (
<RegisteredView
client={client}
isPasswordlessUser={isPasswordlessUser}
isGuest={isGuest}
onCreateRoom={onCreateRoom}
createRoomError={createRoomError}
creatingRoom={creatingRoom}
onJoinRoom={onJoinRoom}
/>
)}
{modalState.isOpen && (
<JoinExistingCallModal onJoin={onJoinExistingRoom} {...modalProps} />
)}
</>
);
}
}
@@ -290,10 +330,18 @@ function RegisteredView({
<div className={styles.right}>
<div className={styles.content}>
{publicRooms.length > 0 && (
<CallList title="Public Calls" rooms={publicRooms} />
<CallList
title="Public Calls"
rooms={publicRooms}
client={client}
/>
)}
{recentRooms.length > 0 && (
<CallList title="Recent Calls" rooms={recentRooms} />
<CallList
title="Recent Calls"
rooms={recentRooms}
client={client}
/>
)}
</div>
</div>

View File

@@ -42,7 +42,6 @@
.left .content {
display: flex;
flex-direction: column;
padding-top: 40px;
align-items: center;
}
@@ -67,7 +66,7 @@
display: flex;
flex-direction: column;
align-items: center;
padding: 92px;
padding: 40px 92px;
}
.fieldRow {
@@ -90,7 +89,7 @@
}
.right .content {
padding: 40px;
padding: 0 40px 40px 40px;
}
.right .content h3:first-child {

View File

@@ -22,15 +22,30 @@ export function Field({ children, className, ...rest }) {
}
export const InputField = forwardRef(
({ id, label, className, type, checked, ...rest }, ref) => {
(
{ id, label, className, type, checked, prefix, suffix, disabled, ...rest },
ref
) => {
return (
<Field
className={classNames(
type === "checkbox" ? styles.checkboxField : styles.inputField,
{
[styles.prefix]: !!prefix,
[styles.disabled]: disabled,
},
className
)}
>
<input id={id} {...rest} ref={ref} type={type} checked={checked} />
{prefix && <span>{prefix}</span>}
<input
id={id}
{...rest}
ref={ref}
type={type}
checked={checked}
disabled={disabled}
/>
<label htmlFor={id}>
{type === "checkbox" && (
<div className={styles.checkbox}>
@@ -39,6 +54,7 @@ export const InputField = forwardRef(
)}
{label}
</label>
{suffix && <span>{suffix}</span>}
</Field>
);
}

View File

@@ -33,13 +33,26 @@
font-size: 15px;
border: none;
border-radius: 4px;
padding: 11px 9px;
padding: 12px 9px 10px 9px;
color: var(--textColor1);
background-color: var(--bgColor1);
flex: 1;
min-width: 0;
}
.inputField.disabled input,
.inputField.disabled span {
color: var(--textColor2);
}
.inputField span {
padding: 11px 9px;
}
.inputField span:first-child {
padding-right: 0;
}
.inputField input::placeholder {
transition: color 0.25s ease-in 0s;
color: transparent;
@@ -77,7 +90,8 @@
}
.inputField input:focus + label,
.inputField input:not(:placeholder-shown) + label {
.inputField input:not(:placeholder-shown) + label,
.inputField.prefix input + label {
background-color: var(--bgColor2);
transition: font-size 0.25s ease-out 0s, color 0.25s ease-out 0s,
top 0.25s ease-out 0s, background-color 0.25s ease-out 0s;

View File

@@ -0,0 +1,19 @@
import React from "react";
import { Modal, ModalContent } from "./Modal";
import { Button } from "./button";
import { FieldRow } from "./Input";
import styles from "./JoinExistingCallModal.module.css";
export function JoinExistingCallModal({ onJoin, ...rest }) {
return (
<Modal title="Join existing call?" isDismissable {...rest}>
<ModalContent>
<p>This call already exists, would you like to join?</p>
<FieldRow rightAlign className={styles.buttons}>
<Button onPress={rest.onClose}>No</Button>
<Button onPress={onJoin}>Yes, join call</Button>
</FieldRow>
</ModalContent>
</Modal>
);
}

View File

@@ -0,0 +1,3 @@
.buttons {
margin-bottom: 0;
}

View File

@@ -14,19 +14,21 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback, useRef, useState } from "react";
import React, { useCallback, useRef, useState, useMemo } from "react";
import { useHistory, useLocation, Link } from "react-router-dom";
import { ReactComponent as Logo } from "./icons/LogoLarge.svg";
import { FieldRow, InputField, ErrorMessage } from "./Input";
import { Button } from "./button";
import { useClient } from "./ConferenceCallManagerHooks";
import {
useClient,
defaultHomeserver,
defaultHomeserverHost,
} from "./ConferenceCallManagerHooks";
import styles from "./LoginPage.module.css";
export function LoginPage() {
const { login } = useClient();
const [homeserver, setHomeServer] = useState(
`${window.location.protocol}//${window.location.host}`
);
const [homeserver, setHomeServer] = useState(defaultHomeserver);
const usernameRef = useRef();
const passwordRef = useRef();
const history = useHistory();
@@ -57,6 +59,14 @@ export function LoginPage() {
[login, location, history, homeserver]
);
const homeserverHost = useMemo(() => {
try {
return new URL(homeserver).host;
} catch (_error) {
return defaultHomeserverHost;
}
}, [homeserver]);
return (
<>
<div className={styles.container}>
@@ -67,17 +77,6 @@ export function LoginPage() {
<h2>Log In</h2>
<h4>To continue to Element</h4>
<form onSubmit={onSubmitLoginForm}>
<FieldRow>
<InputField
type="text"
value={homeserver}
onChange={(e) => setHomeServer(e.target.value)}
placeholder="Homeserver"
label="Homeserver"
autoCorrect="off"
autoCapitalize="none"
/>
</FieldRow>
<FieldRow>
<InputField
type="text"
@@ -86,6 +85,8 @@ export function LoginPage() {
label="Username"
autoCorrect="off"
autoCapitalize="none"
prefix="@"
suffix={`:${homeserverHost}`}
/>
</FieldRow>
<FieldRow>

View File

@@ -30,10 +30,12 @@
.menuItem.focused:first-child,
.menuItem:hover:first-child {
border-radius: 8px 8px 0 0;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
}
.menuItem.focused:last-child,
.menuItem:hover:last-child {
border-radius: 0 0 8px 8px;
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
}

View File

@@ -1,5 +1,5 @@
import React, { useCallback } from "react";
import { ButtonTooltip, Button } from "./button";
import { Button } from "./button";
import { Menu } from "./Menu";
import { PopoverMenuTrigger } from "./PopoverMenu";
import { Item } from "@react-stately/collections";
@@ -9,6 +9,7 @@ import { ReactComponent as OverflowIcon } from "./icons/Overflow.svg";
import { useModalTriggerState } from "./Modal";
import { SettingsModal } from "./SettingsModal";
import { InviteModal } from "./InviteModal";
import { Tooltip, TooltipTrigger } from "./Tooltip";
export function OverflowMenu({
roomId,
@@ -37,10 +38,16 @@ export function OverflowMenu({
return (
<>
<PopoverMenuTrigger disableOnState>
<Button variant="toolbar">
<ButtonTooltip>More</ButtonTooltip>
<OverflowIcon />
</Button>
<TooltipTrigger>
<Button variant="toolbar">
<OverflowIcon />
</Button>
{(props) => (
<Tooltip position="top" {...props}>
More
</Tooltip>
)}
</TooltipTrigger>
{(props) => (
<Menu {...props} label="More menu" onAction={onAction}>
<Item key="invite" textValue="Invite people">

View File

@@ -1,18 +1,24 @@
import React, { useCallback, useEffect, useState } from "react";
import { Button } from "./button";
import { useDisplayName } from "./ConferenceCallManagerHooks";
import { useProfile } from "./ConferenceCallManagerHooks";
import { FieldRow, InputField, ErrorMessage } from "./Input";
import { Modal, ModalContent } from "./Modal";
export function ProfileModal({ client, ...rest }) {
export function ProfileModal({
client,
isAuthenticated,
isPasswordlessUser,
isGuest,
...rest
}) {
const { onClose } = rest;
const {
success,
error,
loading,
displayName: initialDisplayName,
setDisplayName: submitDisplayName,
} = useDisplayName(client);
saveProfile,
} = useProfile(client);
const [displayName, setDisplayName] = useState(initialDisplayName || "");
const onChangeDisplayName = useCallback(
@@ -27,10 +33,14 @@ export function ProfileModal({ client, ...rest }) {
e.preventDefault();
const data = new FormData(e.target);
const displayName = data.get("displayName");
console.log(displayName);
submitDisplayName(displayName);
const avatar = data.get("avatar");
saveProfile({
displayName,
avatar,
});
},
[setDisplayName]
[saveProfile]
);
useEffect(() => {
@@ -56,6 +66,16 @@ export function ProfileModal({ client, ...rest }) {
onChange={onChangeDisplayName}
/>
</FieldRow>
{isAuthenticated && !isGuest && !isPasswordlessUser && (
<FieldRow>
<InputField
type="file"
id="avatar"
name="avatar"
label="Avatar"
/>
</FieldRow>
)}
{error && (
<FieldRow>
<ErrorMessage>{error.message}</ErrorMessage>

View File

@@ -14,47 +14,99 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback, useRef, useState } from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useHistory, useLocation, Link } from "react-router-dom";
import { FieldRow, InputField, ErrorMessage } from "./Input";
import { Button } from "./button";
import { useClient } from "./ConferenceCallManagerHooks";
import { useClient, defaultHomeserverHost } from "./ConferenceCallManagerHooks";
import styles from "./LoginPage.module.css";
import { ReactComponent as Logo } from "./icons/LogoLarge.svg";
import { LoadingView } from "./FullScreenView";
export function RegisterPage() {
// TODO: Handle hitting login page with authenticated client
const { register } = useClient();
const registerUsernameRef = useRef();
const registerPasswordRef = useRef();
const {
loading,
client,
register,
changePassword,
isAuthenticated,
isPasswordlessUser,
} = useClient();
const confirmPasswordRef = useRef();
const history = useHistory();
const location = useLocation();
const [loading, setLoading] = useState(false);
const [registering, setRegistering] = useState(false);
const [error, setError] = useState();
const [password, setPassword] = useState("");
const [passwordConfirmation, setPasswordConfirmation] = useState("");
const onSubmitRegisterForm = useCallback(
(e) => {
e.preventDefault();
setLoading(true);
register(
registerUsernameRef.current.value,
registerPasswordRef.current.value
)
.then(() => {
if (location.state && location.state.from) {
history.push(location.state.from);
} else {
history.push("/");
}
})
.catch((error) => {
setError(error);
setLoading(false);
});
const data = new FormData(e.target);
const userName = data.get("userName");
const password = data.get("password");
const passwordConfirmation = data.get("passwordConfirmation");
if (password !== passwordConfirmation) {
return;
}
setRegistering(true);
if (isPasswordlessUser) {
changePassword(password)
.then(() => {
if (location.state && location.state.from) {
history.push(location.state.from);
} else {
history.push("/");
}
})
.catch((error) => {
setError(error);
setRegistering(false);
});
} else {
register(userName, password)
.then(() => {
if (location.state && location.state.from) {
history.push(location.state.from);
} else {
history.push("/");
}
})
.catch((error) => {
setError(error);
setRegistering(false);
});
}
},
[register, location, history]
[register, changePassword, location, history, isPasswordlessUser]
);
useEffect(() => {
if (!confirmPasswordRef.current) {
return;
}
if (password && passwordConfirmation && password !== passwordConfirmation) {
confirmPasswordRef.current.setCustomValidity("Passwords must match");
} else {
confirmPasswordRef.current.setCustomValidity("");
}
}, [password, passwordConfirmation]);
useEffect(() => {
if (!loading && isAuthenticated && !isPasswordlessUser) {
history.push("/");
}
}, [history, isAuthenticated, isPasswordlessUser]);
if (loading) {
return <LoadingView />;
}
return (
<>
<div className={styles.container}>
@@ -66,29 +118,52 @@ export function RegisterPage() {
<FieldRow>
<InputField
type="text"
ref={registerUsernameRef}
name="userName"
placeholder="Username"
label="Username"
autoCorrect="off"
autoCapitalize="none"
prefix="@"
suffix={`:${defaultHomeserverHost}`}
value={
isAuthenticated && isPasswordlessUser
? client.getUserIdLocalpart()
: undefined
}
disabled={isAuthenticated && isPasswordlessUser}
/>
</FieldRow>
<FieldRow>
<InputField
required
name="password"
type="password"
ref={registerPasswordRef}
onChange={(e) => setPassword(e.target.value)}
value={password}
placeholder="Password"
label="Password"
/>
</FieldRow>
<FieldRow>
<InputField
required
type="password"
name="passwordConfirmation"
onChange={(e) => setPasswordConfirmation(e.target.value)}
value={passwordConfirmation}
placeholder="Confirm Password"
label="Confirm Password"
ref={confirmPasswordRef}
/>
</FieldRow>
{error && (
<FieldRow>
<ErrorMessage>{error.message}</ErrorMessage>
</FieldRow>
)}
<FieldRow>
<Button type="submit" disabled={loading}>
{loading ? "Registering..." : "Register"}
<Button type="submit" disabled={registering}>
{registering ? "Registering..." : "Register"}
</Button>
</FieldRow>
</form>

View File

@@ -26,13 +26,7 @@ import {
ScreenshareButton,
LinkButton,
} from "./button";
import {
Header,
LeftNav,
RightNav,
RoomHeaderInfo,
RoomSetupHeaderInfo,
} from "./Header";
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "./Header";
import { GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
import VideoGrid, {
useVideoGridLayout,
@@ -42,7 +36,13 @@ 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 { useClient, useLoadGroupCall } from "./ConferenceCallManagerHooks";
import {
getAvatarUrl,
getRoomUrl,
useClient,
useLoadGroupCall,
useProfile,
} from "./ConferenceCallManagerHooks";
import { ErrorView, LoadingView, FullScreenView } from "./FullScreenView";
import { GroupCallInspector } from "./GroupCallInspector";
import * as Sentry from "@sentry/react";
@@ -50,6 +50,7 @@ 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
@@ -60,8 +61,15 @@ 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 } =
useClient();
const {
loading,
isAuthenticated,
error,
client,
registerGuest,
isGuest,
isPasswordlessUser,
} = useClient();
useEffect(() => {
if (!loading && !isAuthenticated) {
@@ -86,10 +94,16 @@ export function Room() {
return <ErrorView error={registrationError || error} />;
}
return <GroupCall client={client} isGuest={isGuest} />;
return (
<GroupCall
client={client}
isGuest={isGuest}
isPasswordlessUser={isPasswordlessUser}
/>
);
}
export function GroupCall({ client, isGuest }) {
export function GroupCall({ client, isGuest, isPasswordlessUser }) {
const { roomId: maybeRoomId } = useParams();
const { hash, search } = useLocation();
const [simpleGrid, viaServers] = useMemo(() => {
@@ -118,6 +132,7 @@ export function GroupCall({ client, isGuest }) {
return (
<GroupCallView
isGuest={isGuest}
isPasswordlessUser={isPasswordlessUser}
client={client}
roomId={roomId}
groupCall={groupCall}
@@ -129,6 +144,7 @@ export function GroupCall({ client, isGuest }) {
export function GroupCallView({
client,
isGuest,
isPasswordlessUser,
roomId,
groupCall,
simpleGrid,
@@ -184,7 +200,7 @@ export function GroupCallView({
const onLeave = useCallback(() => {
leave();
if (!isGuest) {
if (!isGuest && !isPasswordlessUser) {
history.push("/");
} else {
setLeft(true);
@@ -198,6 +214,7 @@ export function GroupCallView({
<InRoomView
groupCall={groupCall}
client={client}
isGuest={isGuest}
roomName={groupCall.room.name}
microphoneMuted={microphoneMuted}
localVideoMuted={localVideoMuted}
@@ -219,7 +236,11 @@ export function GroupCallView({
} else if (state === GroupCallState.Entering) {
return <EnteringRoomView />;
} else if (left) {
return <CallEndedScreen />;
if (isPasswordlessUser) {
return <PasswordlessUserCallEndedScreen client={client} />;
} else {
return <GuestCallEndedScreen />;
}
} else {
return (
<RoomSetupView
@@ -261,7 +282,6 @@ export function EnteringRoomView() {
function RoomSetupView({
client,
isGuest,
roomName,
state,
onInitLocalCallFeed,
@@ -277,7 +297,6 @@ function RoomSetupView({
}) {
const { stream } = useCallFeed(localCallFeed);
const videoRef = useMediaStream(stream, true);
const location = useLocation();
useEffect(() => {
onInitLocalCallFeed();
@@ -290,19 +309,13 @@ function RoomSetupView({
<RoomHeaderInfo roomName={roomName} />
</LeftNav>
<RightNav>
{isGuest ? (
<LinkButton to={{ pathname: "/login", state: { from: location } }}>
Log in
</LinkButton>
) : (
<UserMenu />
)}
<UserMenu />
</RightNav>
</Header>
<div className={styles.joinRoom}>
<div className={styles.joinRoomContent}>
<h1>{roomName}</h1>
<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.
@@ -313,7 +326,6 @@ function RoomSetupView({
Accept webcam/microphone permissions to join the call.
</p>
)}
<video ref={videoRef} muted playsInline disablePictureInPicture />
{state === GroupCallState.LocalCallFeedInitialized && (
<>
<Button
@@ -344,7 +356,7 @@ function RoomSetupView({
</div>
<p>Or</p>
<CopyButton
value={window.location.href}
value={getRoomUrl(roomId)}
className={styles.copyButton}
copiedMessage="Call link copied"
>
@@ -363,6 +375,7 @@ function RoomSetupView({
function InRoomView({
client,
isGuest,
groupCall,
roomName,
microphoneMuted,
@@ -400,7 +413,10 @@ function InRoomView({
const participant = participants.find(
(p) => p.usermediaCallFeed.userId === callFeed.userId
);
participant.screenshareCallFeed = callFeed;
if (participant) {
participant.screenshareCallFeed = callFeed;
}
}
return participants;
@@ -431,6 +447,29 @@ function InRoomView({
[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>
@@ -439,7 +478,7 @@ function InRoomView({
</LeftNav>
<RightNav>
<GridLayoutMenu layout={layout} setLayout={setLayout} />
<UserMenu />
{!isGuest && <UserMenu disableLogout />}
</RightNav>
</Header>
{items.length === 0 ? (
@@ -452,6 +491,7 @@ function InRoomView({
<VideoGrid
items={items}
layout={layout}
getAvatar={renderAvatar}
onFocusTile={onFocusTile}
disableAnimations={isSafari}
/>
@@ -482,31 +522,52 @@ function InRoomView({
);
}
export function CallEndedScreen() {
export function GuestCallEndedScreen() {
return (
<FullScreenView className={styles.callEndedScreen}>
<h1>Your call is now ended</h1>
<hr />
<h2>Why not finish by creating an account?</h2>
<p>You'll be able to:</p>
<ul>
<li>Easily access all your previous call links</li>
<li>Set a username and avatar</li>
<li>
Get your own Matrix ID so you can log in to{" "}
<a href="https://element.io" target="_blank">
Element
</a>
</li>
</ul>
<LinkButton
className={styles.callEndedButton}
size="lg"
variant="default"
to="/login"
>
Create account
</LinkButton>
<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

@@ -63,8 +63,8 @@ limitations under the License.
.preview {
position: relative;
max-width: 816px;
max-height: 75vh;
min-height: 280px;
height: 50vh;
border-radius: 24px;
overflow: hidden;
background-color: var(--bgColor3);
@@ -165,6 +165,7 @@ limitations under the License.
}
.callEndedScreen h1 {
text-align: center;
margin-bottom: 60px;
}
@@ -181,13 +182,26 @@ limitations under the License.
.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;

56
src/Tooltip.jsx Normal file
View File

@@ -0,0 +1,56 @@
import React, { forwardRef, useRef } from "react";
import { useTooltipTriggerState } from "@react-stately/tooltip";
import { useTooltipTrigger, useTooltip } from "@react-aria/tooltip";
import { mergeProps } from "@react-aria/utils";
import styles from "./Tooltip.module.css";
import classNames from "classnames";
export function Tooltip({ position, state, ...props }) {
let { tooltipProps } = useTooltip(props, state);
return (
<div
className={classNames(styles.tooltip, styles[position || "bottom"])}
{...mergeProps(props, tooltipProps)}
>
{props.children}
</div>
);
}
export const TooltipTrigger = forwardRef(({ children, ...rest }, ref) => {
const tooltipState = useTooltipTriggerState(rest);
const fallbackRef = useRef();
const triggerRef = ref || fallbackRef;
const { triggerProps, tooltipProps } = useTooltipTrigger(
rest,
tooltipState,
triggerRef
);
if (
!Array.isArray(children) ||
children.length > 2 ||
typeof children[1] !== "function"
) {
throw new Error(
"TooltipTrigger must have two props. The first being a button and the second being a render prop."
);
}
const [tooltipTrigger, tooltip] = children;
return (
<div className={styles.tooltipContainer}>
<tooltipTrigger.type
{...mergeProps(triggerProps, tooltipTrigger.props, rest)}
ref={triggerRef}
/>
{tooltipState.isOpen && tooltip({ state: tooltipState, ...tooltipProps })}
</div>
);
});
TooltipTrigger.defaultProps = {
delay: 250,
};

33
src/Tooltip.module.css Normal file
View File

@@ -0,0 +1,33 @@
.tooltip {
background-color: var(--bgColor2);
position: absolute;
flex-direction: row;
justify-content: center;
align-items: center;
padding: 8px 10px;
color: var(--textColor1);
border-radius: 8px;
max-width: 135px;
width: max-content;
z-index: 1;
left: 50%;
transform: translateX(-50%);
text-align: center;
}
.tooltip.top {
bottom: calc(100% + 6px);
}
.tooltip.bottom {
top: calc(100% + 6px);
}
.tooltip.bottomLeft {
top: calc(100% + 6px);
left: -25%;
}
.tooltipContainer {
position: relative;
}

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useMemo } from "react";
import { ButtonTooltip, Button } from "./button";
import { Button, LinkButton } from "./button";
import { PopoverMenuTrigger } from "./PopoverMenu";
import { ReactComponent as UserIcon } from "./icons/User.svg";
import { ReactComponent as LoginIcon } from "./icons/Login.svg";
@@ -8,15 +8,24 @@ import styles from "./UserMenu.module.css";
import { Item } from "@react-stately/collections";
import { Menu } from "./Menu";
import { useHistory, useLocation } from "react-router-dom";
import { useClient, useDisplayName } from "./ConferenceCallManagerHooks";
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() {
export function UserMenu({ disableLogout }) {
const location = useLocation();
const history = useHistory();
const { isAuthenticated, isGuest, logout, userName, client } = useClient();
const { displayName } = useDisplayName(client);
const {
isAuthenticated,
isGuest,
isPasswordlessUser,
logout,
userName,
client,
} = useClient();
const { displayName, avatarUrl } = useProfile(client);
const { modalState, modalProps } = useModalTriggerState();
const onAction = useCallback(
@@ -31,9 +40,6 @@ export function UserMenu() {
case "login":
history.push("/login", { state: { from: location } });
break;
case "register":
history.push("/register", { state: { from: location } });
break;
}
},
[history, location, logout, modalState]
@@ -42,45 +48,62 @@ export function UserMenu() {
const items = useMemo(() => {
const arr = [];
if (isAuthenticated) {
if (isAuthenticated && !isGuest) {
arr.push({
key: "user",
icon: UserIcon,
label: displayName || userName,
});
}
if (!isAuthenticated || isGuest) {
arr.push(
{
if (isPasswordlessUser) {
arr.push({
key: "login",
label: "Sign In",
icon: LoginIcon,
},
{
key: "register",
label: "Register",
icon: LoginIcon,
}
);
} else {
arr.push({
key: "logout",
label: "Sign Out",
icon: LogoutIcon,
});
});
}
if (!isPasswordlessUser && !disableLogout) {
arr.push({
key: "logout",
label: "Sign Out",
icon: LogoutIcon,
});
}
}
return arr;
}, [isAuthenticated, isGuest, userName, displayName]);
if (isGuest || !isAuthenticated) {
return (
<LinkButton to={{ pathname: "/login", state: { from: location } }}>
Log in
</LinkButton>
);
}
return (
<>
<PopoverMenuTrigger placement="bottom right">
<Button variant="icon" className={styles.userButton}>
<ButtonTooltip>Profile</ButtonTooltip>
<UserIcon />
</Button>
<TooltipTrigger>
<Button variant="icon" className={styles.userButton}>
{isAuthenticated && !isGuest && !isPasswordlessUser ? (
<Avatar
size="sm"
src={avatarUrl}
fallback={(displayName || userName).slice(0, 1).toUpperCase()}
/>
) : (
<UserIcon />
)}
</Button>
{(props) => (
<Tooltip position="bottomLeft" {...props}>
Profile
</Tooltip>
)}
</TooltipTrigger>
{(props) => (
<Menu {...props} label="User menu" onAction={onAction}>
{items.map(({ key, icon: Icon, label }) => (
@@ -92,7 +115,15 @@ export function UserMenu() {
</Menu>
)}
</PopoverMenuTrigger>
{modalState.isOpen && <ProfileModal client={client} {...modalProps} />}
{modalState.isOpen && (
<ProfileModal
client={client}
isAuthenticated={isAuthenticated}
isGuest={isGuest}
isPasswordlessUser={isPasswordlessUser}
{...modalProps}
/>
)}
</>
);
}

View File

@@ -8,7 +8,8 @@ import { ReactComponent as DisableVideoIcon } from "../icons/DisableVideo.svg";
import { ReactComponent as HangupIcon } from "../icons/Hangup.svg";
import { ReactComponent as ScreenshareIcon } from "../icons/Screenshare.svg";
import { useButton } from "@react-aria/button";
import { useObjectRef } from "@react-aria/utils";
import { mergeProps, useObjectRef } from "@react-aria/utils";
import { Tooltip, TooltipTrigger } from "../Tooltip";
export const variantToClassName = {
default: [styles.button],
@@ -80,46 +81,64 @@ export function ButtonTooltip({ className, children }) {
export function MicButton({ muted, ...rest }) {
return (
<Button variant="toolbar" {...rest} off={muted}>
<ButtonTooltip>
{muted ? "Unmute microphone" : "Mute microphone"}
</ButtonTooltip>
{muted ? <MuteMicIcon /> : <MicIcon />}
</Button>
<TooltipTrigger>
<Button variant="toolbar" {...rest} off={muted}>
{muted ? <MuteMicIcon /> : <MicIcon />}
</Button>
{(props) => (
<Tooltip position="top" {...props}>
{muted ? "Unmute microphone" : "Mute microphone"}
</Tooltip>
)}
</TooltipTrigger>
);
}
export function VideoButton({ muted, ...rest }) {
return (
<Button variant="toolbar" {...rest} off={muted}>
<ButtonTooltip>
{muted ? "Turn on camera" : "Turn off camera"}
</ButtonTooltip>
{muted ? <DisableVideoIcon /> : <VideoIcon />}
</Button>
<TooltipTrigger>
<Button variant="toolbar" {...rest} off={muted}>
{muted ? <DisableVideoIcon /> : <VideoIcon />}
</Button>
{(props) => (
<Tooltip position="top" {...props}>
{muted ? "Turn on camera" : "Turn off camera"}
</Tooltip>
)}
</TooltipTrigger>
);
}
export function ScreenshareButton({ enabled, className, ...rest }) {
return (
<Button variant="toolbar" {...rest} on={enabled}>
<ButtonTooltip>
{enabled ? "Stop sharing screen" : "Share screen"}
</ButtonTooltip>
<ScreenshareIcon />
</Button>
<TooltipTrigger>
<Button variant="toolbar" {...rest} on={enabled}>
<ScreenshareIcon />
</Button>
{(props) => (
<Tooltip position="top" {...props}>
{enabled ? "Stop sharing screen" : "Share screen"}
</Tooltip>
)}
</TooltipTrigger>
);
}
export function HangupButton({ className, ...rest }) {
return (
<Button
variant="toolbar"
className={classNames(styles.hangupButton, className)}
{...rest}
>
<ButtonTooltip>Leave</ButtonTooltip>
<HangupIcon />
</Button>
<TooltipTrigger>
<Button
variant="toolbar"
className={classNames(styles.hangupButton, className)}
{...rest}
>
<HangupIcon />
</Button>
{(props) => (
<Tooltip position="top" {...props}>
Leave
</Tooltip>
)}
</TooltipTrigger>
);
}

View File

@@ -7,7 +7,7 @@ import { Button } from "./Button";
export function CopyButton({
value,
children,
onClassName,
className,
variant,
copiedMessage,
...rest
@@ -19,7 +19,7 @@ export function CopyButton({
{...rest}
variant={variant === "icon" ? "iconCopy" : "copy"}
on={isCopied}
onClassName={onClassName}
className={className}
onPress={setCopied}
iconStyle={isCopied ? "stroke" : "fill"}
>

View File

@@ -1,42 +0,0 @@
/*
Copyright 2018 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
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.
*/
const LOWERCASE = "abcdefghijklmnopqrstuvwxyz";
const UPPERCASE = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const DIGITS = "0123456789";
export function randomString(len) {
return randomStringFrom(len, UPPERCASE + LOWERCASE + DIGITS);
}
export function randomLowercaseString(len) {
return randomStringFrom(len, LOWERCASE);
}
export function randomUppercaseString(len) {
return randomStringFrom(len, UPPERCASE);
}
function randomStringFrom(len, chars) {
let ret = "";
for (let i = 0; i < len; ++i) {
ret += chars.charAt(Math.floor(Math.random() * chars.length));
}
return ret;
}

View File

@@ -14,22 +14,26 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { defineConfig } from "vite";
import { defineConfig, loadEnv } from "vite";
import svgrPlugin from "vite-plugin-svgr";
import path from "path";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [svgrPlugin()],
server: {
proxy: {
"/_matrix": "http://localhost:8008",
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd());
return {
plugins: [svgrPlugin()],
server: {
proxy: {
"/_matrix": env.VITE_DEFAULT_HOMESERVER || "http://localhost:8008",
},
},
},
resolve: {
alias: {
"$(res)": path.resolve(__dirname, "node_modules/matrix-react-sdk/res"),
resolve: {
alias: {
"$(res)": path.resolve(__dirname, "node_modules/matrix-react-sdk/res"),
},
dedupe: ["react", "react-dom", "matrix-js-sdk"],
},
dedupe: ["react", "react-dom", "matrix-js-sdk"],
},
};
});

View File

@@ -451,6 +451,19 @@
"@react-types/shared" "^3.10.0"
"@react-types/tabs" "^3.0.1"
"@react-aria/tooltip@^3.1.3":
version "3.1.3"
resolved "https://registry.yarnpkg.com/@react-aria/tooltip/-/tooltip-3.1.3.tgz#cf967d9306170ed2ec0ed589fe1cb4cc081ddbe6"
integrity sha512-l2/BS1XBKrLpg+dovI3xy6NdCgJ5n82TS4p8vQJa7GcynI1I64R0IjOUFv0lc6ZZsr1G8Wg71SNYfmlgTrPr2w==
dependencies:
"@babel/runtime" "^7.6.2"
"@react-aria/focus" "^3.4.1"
"@react-aria/interactions" "^3.5.1"
"@react-aria/utils" "^3.8.2"
"@react-stately/tooltip" "^3.0.5"
"@react-types/shared" "^3.8.0"
"@react-types/tooltip" "^3.1.2"
"@react-aria/utils@^3.10.0", "@react-aria/utils@^3.8.2", "@react-aria/utils@^3.9.0":
version "3.10.0"
resolved "https://registry.yarnpkg.com/@react-aria/utils/-/utils-3.10.0.tgz#2f6f0b0ccede17241fca1cbd76978e1bf8f5a2b0"
@@ -600,6 +613,16 @@
"@react-types/checkbox" "^3.2.3"
"@react-types/shared" "^3.8.0"
"@react-stately/tooltip@^3.0.5":
version "3.0.5"
resolved "https://registry.yarnpkg.com/@react-stately/tooltip/-/tooltip-3.0.5.tgz#0cb716791ef3242acd810794162d651b1c47a328"
integrity sha512-rHqPSfkxbx0T0B/j+WDl4G2CfLjFeBfyaifGiIUJWHO/0Kwvh5am88VeHtuTVzC2DPEGTdtXqYns21EuJOrDlQ==
dependencies:
"@babel/runtime" "^7.6.2"
"@react-stately/overlays" "^3.1.3"
"@react-stately/utils" "^3.2.2"
"@react-types/tooltip" "^3.1.2"
"@react-stately/tree@^3.2.0":
version "3.2.0"
resolved "https://registry.yarnpkg.com/@react-stately/tree/-/tree-3.2.0.tgz#151c90f161c5c8339b6876f59a4f0502be08670b"
@@ -688,6 +711,14 @@
dependencies:
"@react-types/shared" "^3.8.0"
"@react-types/tooltip@^3.1.2":
version "3.1.2"
resolved "https://registry.yarnpkg.com/@react-types/tooltip/-/tooltip-3.1.2.tgz#a80d1ab5a37337156881a032cdaa136a9e028e32"
integrity sha512-puyiRi3IaEeKH25AErZzQKthnxk1McU+7S+Qo2kFLy3F3PyXV0cmSqvKKOhH6kU5Cw4ZnuAlNjCI0tV8PYdlYA==
dependencies:
"@react-types/overlays" "^3.5.1"
"@react-types/shared" "^3.8.0"
"@sentry/browser@6.13.3":
version "6.13.3"
resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-6.13.3.tgz#d4511791b1e484ad48785eba3bce291fdf115c1e"