Compare commits

..

1 Commits

Author SHA1 Message Date
Robin Townsend
e53b0c0244 WIP 2023-07-13 23:15:43 -04:00
42 changed files with 730 additions and 348 deletions

View File

@@ -88,16 +88,20 @@ yarn dev
### Backend
A docker compose file is provided to start a LiveKit server and auth
service for development. These use a test 'secret' published in this
repository, so this must be used only for local development and
**_never be exposed to the public Internet._**
Add in you `.env` in root dir with:
To use it, add SFU parameter in your local config `./public/config.yml`:
```yaml
# Develop backend settings:
LIVEKIT_KEY="devkey"
LIVEKIT_SECRET="secret"
```
Add SFU parameter in your local config `./public/config.yml`:
```yaml
"livekit": {
"jwt_service_url": "http://localhost:8881"
"server_url": "ws://localhost:7880",
"jwt_service_url": "http:/localhost:8881"
},
```

View File

@@ -1,3 +1,5 @@
# LiveKit requires host networking, which is only available on Linux
# This compose will not function correctly on Mac or Windows
version: "3.9"
networks:
@@ -5,14 +7,15 @@ networks:
services:
auth-service:
image: ghcr.io/vector-im/lk-jwt-service:latest-ci
build:
context: ./backend/auth
container_name: auth-server
hostname: auth-server
ports:
- 8881:8080
environment:
- LIVEKIT_URL=ws://localhost:7880
- LIVEKIT_KEY=devkey
- LIVEKIT_SECRET=secret
- LIVEKIT_KEY=${LIVEKIT_KEY}
- LIVEKIT_SECRET=${LIVEKIT_SECRET}
deploy:
restart_policy:
condition: on-failure

15
backend/auth/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM golang:1.20-alpine
WORKDIR /app
COPY go.mod ./
COPY go.sum ./
RUN go mod download
COPY *.go ./
RUN go build -o /auth-server
EXPOSE 8080
CMD [ "/auth-server" ]

20
backend/auth/go.mod Normal file
View File

@@ -0,0 +1,20 @@
module vector-auth-server
go 1.20
require github.com/livekit/protocol v1.5.7
require (
github.com/go-jose/go-jose/v3 v3.0.0 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/twitchtv/twirp v8.1.3+incompatible // indirect
golang.org/x/crypto v0.9.0 // indirect
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc // indirect
golang.org/x/net v0.10.0 // indirect
golang.org/x/sys v0.8.0 // indirect
golang.org/x/text v0.9.0 // indirect
google.golang.org/genproto v0.0.0-20230403163135-c38d8f061ccd // indirect
google.golang.org/grpc v1.55.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

94
backend/auth/go.sum Normal file
View File

@@ -0,0 +1,94 @@
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/eapache/channels v1.1.0 h1:F1taHcn7/F0i8DYqKXJnyhJcVpp2kgFcNePxXtnyu4k=
github.com/eapache/queue v1.1.0 h1:YOEu7KNc61ntiQlcEeUIoDTJ2o8mQznoNvUhiigpIqc=
github.com/frostbyte73/core v0.0.9 h1:AmE9GjgGpPsWk9ZkmY3HsYUs2hf2tZt+/W6r49URBQI=
github.com/gammazero/deque v0.2.1 h1:qSdsbG6pgp6nL7A0+K/B7s12mcCY/5l5SIUpMOl+dC0=
github.com/go-jose/go-jose/v3 v3.0.0 h1:s6rrhirfEP/CGIoc6p+PZAeogN2SxKav6Wp7+dyMWVo=
github.com/go-jose/go-jose/v3 v3.0.0/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8=
github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/jxskiss/base62 v1.1.0 h1:A5zbF8v8WXx2xixnAKD2w+abC+sIzYJX+nxmhA6HWFw=
github.com/lithammer/shortuuid/v4 v4.0.0 h1:QRbbVkfgNippHOS8PXDkti4NaWeyYfcBTHtw7k08o4c=
github.com/livekit/mageutil v0.0.0-20230125210925-54e8a70427c1 h1:jm09419p0lqTkDaKb5iXdynYrzB84ErPPO4LbRASk58=
github.com/livekit/protocol v1.5.7 h1:jZeFQEmLuIhFblXDGPRCBbfjVJHb+YU7AsO+SMoXF70=
github.com/livekit/protocol v1.5.7/go.mod h1:ZaOnsvP+JS4s7vI1UO+JVdBagvvLp/lBXDAl2hkDS0I=
github.com/livekit/psrpc v0.3.0 h1:giBZsfM3CWA0oIYXofsMITbVQtyW7u/ES9sQmVspHPM=
github.com/mackerelio/go-osstat v0.2.4 h1:qxGbdPkFo65PXOb/F/nhDKpF2nGmGaCFDLXoZjJTtUs=
github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
github.com/nats-io/nats.go v1.25.0 h1:t5/wCPGciR7X3Mu8QOi4jiJaXaWM8qtkLu4lzGZvYHE=
github.com/nats-io/nkeys v0.4.4 h1:xvBJ8d69TznjcQl9t6//Q5xXuVhyYiSos6RPtvQNTwA=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/pion/datachannel v1.5.5 h1:10ef4kwdjije+M9d7Xm9im2Y3O6A6ccQb0zcqZcJew8=
github.com/pion/dtls/v2 v2.2.6 h1:yXMxKr0Skd+Ub6A8UqXTRLSywskx93ooMRHsQUtd+Z4=
github.com/pion/ice/v2 v2.3.4 h1:tjYjTLpWyZzUjpDnzk6T1y3oQyhyY2DiM2t095iDhyQ=
github.com/pion/interceptor v0.1.16 h1:0GDZrfNO+BmVNWymS31fMlVtPO2IJVBzy2Qq5XCYMIg=
github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
github.com/pion/mdns v0.0.7 h1:P0UB4Sr6xDWEox0kTVxF0LmQihtCbSAdW0H2nEgkA3U=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/rtcp v1.2.10 h1:nkr3uj+8Sp97zyItdN60tE/S6vk4al5CPRR6Gejsdjc=
github.com/pion/rtp v1.7.13 h1:qcHwlmtiI50t1XivvoawdCGTP4Uiypzfrsap+bijcoA=
github.com/pion/sctp v1.8.7 h1:JnABvFakZueGAn4KU/4PSKg+GWbF6QWbKTWZOSGJjXw=
github.com/pion/sdp/v3 v3.0.6 h1:WuDLhtuFUUVpTfus9ILC4HRyHsW6TdugjEX/QY9OiUw=
github.com/pion/srtp/v2 v2.0.14 h1:Glt0MqEvINrDxL+aanmK4DiFjvs+uN2iYc6XD/iKpoY=
github.com/pion/stun v0.5.2 h1:J/8glQnDV91dfk2+ZnGN0o9bUJgABhTNljwfQWByoXE=
github.com/pion/transport/v2 v2.2.0 h1:u5lFqFHkXLMXMzai8tixZDfVjb8eOjH35yCunhPeb1c=
github.com/pion/turn/v2 v2.1.0 h1:5wGHSgGhJhP/RpabkUb/T9PdsAjkGLS6toYz5HNzoSI=
github.com/pion/udp/v2 v2.0.1 h1:xP0z6WNux1zWEjhC7onRA3EwwSliXqu1ElUZAQhUP54=
github.com/pion/webrtc/v3 v3.2.4 h1:gWSx4dqQb77051qBT9ipDrOyP6/sGYcAQP3UPjM8pU8=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.15.1 h1:8tXpTmJbyH5lydzFPoxSIJ0J46jdh3tylbvM1xCv0LI=
github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4=
github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI1YM=
github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI=
github.com/redis/go-redis/v9 v9.0.4 h1:FC82T+CHJ/Q/PdyLW++GeCO+Ol59Y4T7R4jbgjvktgc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/twitchtv/twirp v8.1.3+incompatible h1:+F4TdErPgSUbMZMwp13Q/KgDVuI7HJXP61mNV3/7iuU=
github.com/twitchtv/twirp v8.1.3+incompatible/go.mod h1:RRJoFSAmTEh2weEqWtpPE3vFK5YBhA6bqp2l1kfCC5A=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4=
go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc h1:mCRnTeVUjcrhlRmO0VK8a6k6Rrf6TF9htwo2pJVSjIU=
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto v0.0.0-20230403163135-c38d8f061ccd h1:sLpv7bNL1AsX3fdnWh9WVh7ejIzXdOc1RRHGeAmeStU=
google.golang.org/genproto v0.0.0-20230403163135-c38d8f061ccd/go.mod h1:UUQDJDOlWu4KYeJZffbWgBkS1YFobzKbLVfK69pe0Ak=
google.golang.org/grpc v1.55.0 h1:3Oj82/tFSCeUrRTg/5E/7d/W5A1tj6Ky1ABAuZuv5ag=
google.golang.org/grpc v1.55.0/go.mod h1:iYEXKGkEBhg1PjZQvoYEVPTDkHo1/bjTnfwTeGONTY8=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

98
backend/auth/server.go Normal file
View File

@@ -0,0 +1,98 @@
package main
import (
"encoding/json"
"log"
"net/http"
"os"
"time"
"github.com/livekit/protocol/auth"
)
type Handler struct {
key, secret string
}
func (h *Handler) handle(w http.ResponseWriter, r *http.Request) {
log.Printf("Request from %s", r.RemoteAddr)
// Set the CORS headers
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE")
w.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization")
// Handle preflight request (CORS)
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
roomName := r.URL.Query().Get("roomName")
name := r.URL.Query().Get("name")
identity := r.URL.Query().Get("identity")
log.Printf("roomName: %s, name: %s, identity: %s", roomName, name, identity)
if roomName == "" || name == "" || identity == "" {
w.WriteHeader(http.StatusBadRequest)
return
}
token, err := getJoinToken(h.key, h.secret, roomName, identity, name)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
res := Response{token}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(res)
}
func main() {
key := os.Getenv("LIVEKIT_KEY")
secret := os.Getenv("LIVEKIT_SECRET")
// Check if the key and secret are empty.
if key == "" || secret == "" {
log.Fatal("LIVEKIT_KEY and LIVEKIT_SECRET environment variables must be set")
}
log.Printf("LIVEKIT_KEY: %s and LIVEKIT_SECRET %s", key, secret)
handler := &Handler{
key: key,
secret: secret,
}
http.HandleFunc("/token", handler.handle)
log.Fatal(http.ListenAndServe(":8080", nil))
}
type Response struct {
Token string `json:"accessToken"`
}
func getJoinToken(apiKey, apiSecret, room, identity, name string) (string, error) {
at := auth.NewAccessToken(apiKey, apiSecret)
canPublish := true
canSubscribe := true
grant := &auth.VideoGrant{
RoomJoin: true,
RoomCreate: true,
CanPublish: &canPublish,
CanSubscribe: &canSubscribe,
Room: room,
}
at.AddGrant(grant).
SetIdentity(identity).
SetValidFor(time.Hour).
SetName(name)
return at.ToJWT()
}

View File

@@ -106,7 +106,6 @@
"identity-obj-proxy": "^3.0.0",
"jest": "^29.2.2",
"jest-environment-jsdom": "^29.3.1",
"jest-mock": "^29.5.0",
"prettier": "^2.6.2",
"sass": "^1.42.1",
"storybook-builder-vite": "^0.1.12",

View File

@@ -24,6 +24,7 @@ import { HomePage } from "./home/HomePage";
import { LoginPage } from "./auth/LoginPage";
import { RegisterPage } from "./auth/RegisterPage";
import { RoomPage } from "./room/RoomPage";
import { RoomRedirect } from "./room/RoomRedirect";
import { ClientProvider } from "./ClientContext";
import { usePageFocusStyle } from "./usePageFocusStyle";
import { SequenceDiagramViewerPage } from "./SequenceDiagramViewerPage";
@@ -70,11 +71,14 @@ export default function App({ history }: AppProps) {
<SentryRoute exact path="/register">
<RegisterPage />
</SentryRoute>
<SentryRoute path="/room/:roomId?">
<RoomPage />
</SentryRoute>
<SentryRoute path="/inspector">
<SequenceDiagramViewerPage />
</SentryRoute>
<SentryRoute path="*">
<RoomPage />
<RoomRedirect />
</SentryRoute>
</Switch>
</OverlayProvider>

View File

@@ -18,7 +18,6 @@ import { ReactNode, useCallback, useEffect } from "react";
import { useLocation } from "react-router-dom";
import classNames from "classnames";
import { Trans, useTranslation } from "react-i18next";
import * as Sentry from "@sentry/react";
import { Header, HeaderLogo, LeftNav, RightNav } from "./Header";
import { LinkButton, Button } from "./button";
@@ -59,7 +58,6 @@ export function ErrorView({ error }: ErrorViewProps) {
useEffect(() => {
console.error(error);
Sentry.captureException(error);
}, [error]);
const onReload = useCallback(() => {

View File

@@ -1,5 +1,5 @@
/*
Copyright 2022 - 2023 New Vector Ltd
Copyright 2022 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.
@@ -17,8 +17,6 @@ limitations under the License.
import { useMemo } from "react";
import { useLocation } from "react-router-dom";
import { Config } from "./config/Config";
interface UrlParams {
roomAlias: string | null;
roomId: string | null;
@@ -90,46 +88,19 @@ interface UrlParams {
/**
* Gets the app parameters for the current URL.
* @param ignoreRoomAlias If true, does not try to parse a room alias from the URL
* @param search The URL search string
* @param pathname The URL path name
* @param hash The URL hash
* @param query The URL query string
* @param fragment The URL fragment string
* @returns The app parameters encoded in the URL
*/
export const getUrlParams = (
ignoreRoomAlias?: boolean,
search = window.location.search,
pathname = window.location.pathname,
hash = window.location.hash
query: string = window.location.search,
fragment: string = window.location.hash
): UrlParams => {
let roomAlias: string | undefined;
if (!ignoreRoomAlias) {
if (hash === "") {
roomAlias = pathname.substring(1); // Strip the "/"
// Delete "/room/" and "?", if present
if (roomAlias.startsWith("room/")) {
roomAlias = roomAlias.substring("room/".length);
}
// Add "#", if not present
if (!roomAlias.startsWith("#")) {
roomAlias = `#${roomAlias}`;
}
} else {
roomAlias = hash;
}
// Add server part, if not present
if (!roomAlias.includes(":")) {
roomAlias = `${roomAlias}:${Config.defaultServerName()}`;
}
}
const fragmentQueryStart = hash.indexOf("?");
const fragmentQueryStart = fragment.indexOf("?");
const fragmentParams = new URLSearchParams(
fragmentQueryStart === -1 ? "" : hash.substring(fragmentQueryStart)
fragmentQueryStart === -1 ? "" : fragment.substring(fragmentQueryStart)
);
const queryParams = new URLSearchParams(search);
const queryParams = new URLSearchParams(query);
// Normally, URL params should be encoded in the fragment so as to avoid
// leaking them to the server. However, we also check the normal query
@@ -143,10 +114,16 @@ export const getUrlParams = (
...queryParams.getAll(name),
];
// The part of the fragment before the ?
const fragmentRoute =
fragmentQueryStart === -1
? fragment
: fragment.substring(0, fragmentQueryStart);
const fontScale = parseFloat(getParam("fontScale") ?? "");
return {
roomAlias: !roomAlias || roomAlias.includes("!") ? null : roomAlias,
roomAlias: fragmentRoute.length > 1 ? fragmentRoute : null,
roomId: getParam("roomId"),
viaServers: getAllParams("via"),
isEmbedded: hasParam("embed"),
@@ -172,9 +149,6 @@ export const getUrlParams = (
* @returns The app parameters for the current URL
*/
export const useUrlParams = (): UrlParams => {
const { search, pathname, hash } = useLocation();
return useMemo(
() => getUrlParams(false, search, pathname, hash),
[search, pathname, hash]
);
const { hash, search } = useLocation();
return useMemo(() => getUrlParams(search, hash), [search, hash]);
};

View File

@@ -80,7 +80,11 @@ export const RegisterPage: FC = () => {
passwordlessUser
);
if (client && client?.groupCallEventHandler && passwordlessUser) {
if (!client || !client.groupCallEventHandler || !setClient) {
return;
}
if (passwordlessUser) {
// Migrate the user's rooms
for (const groupCall of client.groupCallEventHandler.groupCalls.values()) {
const roomId = groupCall.room.roomId;
@@ -103,7 +107,7 @@ export const RegisterPage: FC = () => {
}
}
setClient?.({ client: newClient, session });
setClient({ client: newClient, session });
PosthogAnalytics.instance.eventSignup.cacheSignupEnd(new Date());
};

View File

@@ -245,6 +245,23 @@ export function SettingsButton({
);
}
export function SimulateJoinButton(props: {
className?: string;
// TODO: add all props for <Button>
[index: string]: unknown;
}) {
const { t } = useTranslation();
const tooltip = useCallback(() => "Add a fake participant", [t]);
return (
<TooltipTrigger tooltip={tooltip}>
<Button variant="toolbar" {...props}>
<AddUserIcon width={20} height={20} />
</Button>
</TooltipTrigger>
);
}
export function InviteButton({
className,
variant = "toolbar",

View File

@@ -35,13 +35,13 @@ export function CallList({ rooms, client, disableFacepile }: CallListProps) {
return (
<>
<div className={styles.callList}>
{rooms.map(({ roomAlias, roomName, avatarUrl, participants }) => (
{rooms.map(({ roomId, roomName, avatarUrl, participants }) => (
<CallTile
key={roomAlias}
key={roomId}
client={client}
name={roomName}
avatarUrl={avatarUrl}
roomAlias={roomAlias}
roomId={roomId}
participants={participants}
disableFacepile={disableFacepile}
/>
@@ -59,7 +59,7 @@ export function CallList({ rooms, client, disableFacepile }: CallListProps) {
interface CallTileProps {
name: string;
avatarUrl: string;
roomAlias: string;
roomId: string;
participants: RoomMember[];
client: MatrixClient;
disableFacepile?: boolean;
@@ -67,17 +67,14 @@ interface CallTileProps {
function CallTile({
name,
avatarUrl,
roomAlias,
roomId,
participants,
client,
disableFacepile,
}: CallTileProps) {
return (
<div className={styles.callTile}>
<Link
to={`/${roomAlias.substring(1).split(":")[0]}`}
className={styles.callTileLink}
>
<Link to={`/room/${roomId}`} className={styles.callTileLink}>
<Avatar
size={Size.LG}
bgKey={name}
@@ -89,7 +86,7 @@ function CallTile({
<Body overflowEllipsis fontWeight="semiBold">
{name}
</Body>
<Caption overflowEllipsis>{getRoomUrl(roomAlias)}</Caption>
<Caption overflowEllipsis>{getRoomUrl(roomId)}</Caption>
{participants && !disableFacepile && (
<Facepile
className={styles.facePile}
@@ -103,7 +100,7 @@ function CallTile({
<CopyButton
className={styles.copyButton}
variant="icon"
value={getRoomUrl(roomAlias)}
value={getRoomUrl(roomId)}
/>
</div>
);

View File

@@ -71,9 +71,8 @@ export function RegisteredView({ client, isPasswordlessUser }: Props) {
setLoading(true);
const [roomAlias] = await createRoom(client, roomName, ptt);
if (roomAlias) {
history.push(`/${roomAlias.substring(1).split(":")[0]}`);
history.push(`/room/${roomAlias}`);
}
}

View File

@@ -93,7 +93,8 @@ export const UnauthenticatedView: FC = () => {
setOnFinished(() => {
setClient({ client, session });
const aliasLocalpart = roomAliasLocalpartFromRoomName(roomName);
history.push(`/${aliasLocalpart}`);
const [, serverName] = client.getUserId()!.split(":");
history.push(`/room/#${aliasLocalpart}:${serverName}`);
});
setLoading(false);
@@ -110,7 +111,7 @@ export const UnauthenticatedView: FC = () => {
}
setClient({ client, session });
history.push(`/${roomAlias.substring(1).split(":")[0]}`);
history.push(`/room/${roomAlias}`);
}
submit().catch((error) => {

View File

@@ -22,7 +22,7 @@ import { GroupCallEventHandlerEvent } from "matrix-js-sdk/src/webrtc/groupCallEv
import { useState, useEffect } from "react";
export interface GroupCallRoom {
roomAlias: string;
roomId: string;
roomName: string;
avatarUrl: string;
room: Room;
@@ -89,13 +89,12 @@ export function useGroupCallRooms(client: MatrixClient): GroupCallRoom[] {
const groupCalls = client.groupCallEventHandler.groupCalls.values();
const rooms = Array.from(groupCalls).map((groupCall) => groupCall.room);
const filteredRooms = rooms.filter((r) => r.getCanonicalAlias()); // We don't display rooms without an alias
const sortedRooms = sortRooms(client, filteredRooms);
const sortedRooms = sortRooms(client, rooms);
const items = sortedRooms.map((room) => {
const groupCall = client.getGroupCallForRoom(room.roomId)!;
return {
roomAlias: room.getCanonicalAlias(),
roomId: room.getCanonicalAlias() || room.roomId,
roomName: room.name,
avatarUrl: room.getMxcAvatarUrl()!,
room,
@@ -104,7 +103,7 @@ export function useGroupCallRooms(client: MatrixClient): GroupCallRoom[] {
};
});
setRooms(items as GroupCallRoom[]);
setRooms(items);
}
updateRooms();

View File

@@ -55,7 +55,7 @@ export class Initializer {
languageDetector.addDetector({
name: "urlFragment",
// Look for a language code in the URL's fragment
lookup: () => getUrlParams(true).lang ?? undefined,
lookup: () => getUrlParams().lang ?? undefined,
});
i18n
@@ -140,7 +140,7 @@ export class Initializer {
}
// Custom fonts
const { fonts, fontScale } = getUrlParams(true);
const { fonts, fontScale } = getUrlParams();
if (fontScale !== null) {
document.documentElement.style.setProperty(
"--font-scale",

View File

@@ -34,7 +34,7 @@ import styles from "./AvatarInputField.module.css";
interface Props extends AllHTMLAttributes<HTMLInputElement> {
id: string;
label: string;
avatarUrl: string | undefined;
avatarUrl: string;
displayName: string;
onRemoveAvatar: () => void;
}

View File

@@ -41,16 +41,13 @@ export async function getSFUConfigWithOpenID(
// if the call has a livekit service URL, try it.
if (groupCall.livekitServiceURL) {
try {
logger.info(
`Trying to get JWT from call's configured URL of ${groupCall.livekitServiceURL}...`
);
logger.info(`Trying to get JWT from ${groupCall.livekitServiceURL}...`);
const sfuConfig = await getLiveKitJWT(
client,
groupCall.livekitServiceURL,
roomName,
openIdToken
);
logger.info(`Got JWT from call state event URL.`);
return sfuConfig;
} catch (e) {
@@ -74,12 +71,9 @@ export async function getSFUConfigWithOpenID(
openIdToken
);
logger.info(
`Got JWT, updating call livekit service URL with: ${urlFromConf}...`
);
logger.info(`Updating call livekit service URL with: ${urlFromConf}...`);
try {
await groupCall.updateLivekitServiceURL(urlFromConf);
logger.info(`Call livekit service URL updated.`);
} catch (e) {
logger.warn(
`Failed to update call livekit service URL: continuing anyway.`

View File

@@ -1,4 +1,4 @@
import { Room, RoomOptions, setLogLevel } from "livekit-client";
import { Room, RoomOptions } from "livekit-client";
import { useLiveKitRoom } from "@livekit/components-react";
import { useMemo } from "react";
@@ -15,8 +15,6 @@ export type DeviceChoices = {
enabled: boolean;
};
setLogLevel("debug");
export function useLiveKit(
userChoices: UserChoices,
sfuConfig?: SFUConfig

View File

@@ -345,11 +345,15 @@ export async function createRoom(
// Returns a URL to that will load Element Call with the given room
export function getRoomUrl(roomIdOrAlias: string): string {
if (roomIdOrAlias.startsWith("#")) {
return `${window.location.protocol}//${window.location.host}/${
roomIdOrAlias.substring(1).split(":")[0]
}`;
const [localPart, host] = roomIdOrAlias.replace("#", "").split(":");
if (host !== Config.defaultServerName()) {
return `${window.location.protocol}//${window.location.host}/room/${roomIdOrAlias}`;
} else {
return `${window.location.protocol}//${window.location.host}/${localPart}`;
}
} else {
return `${window.location.protocol}//${window.location.host}/room?roomId=${roomIdOrAlias}`;
return `${window.location.protocol}//${window.location.host}/room/#?roomId=${roomIdOrAlias}`;
}
}

View File

@@ -42,6 +42,7 @@ import {
ScreenshareButton,
SettingsButton,
InviteButton,
SimulateJoinButton,
} from "../button";
import {
Header,
@@ -58,6 +59,7 @@ import {
import {
useShowInspector,
useShowConnectionStats,
useVideoGridSandboxMode,
} from "../settings/useSetting";
import { useModalTriggerState } from "../Modal";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
@@ -83,6 +85,8 @@ import { useFullscreen } from "./useFullscreen";
import { useLayoutStates } from "../video-grid/Layout";
import { useSFUConfig } from "../livekit/OpenIDLoader";
import { E2EELock } from "../E2EELock";
import { useFakeTiles } from "./useFakeTiles";
import { FakeItemData, FakeVideoTile } from "../video-grid/FakeVideoTile";
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
// There is currently a bug in Safari our our code with cloning and sending MediaStreams
@@ -160,6 +164,9 @@ export function InCallView({
const [showInspector] = useShowInspector();
const [showConnectionStats] = useShowConnectionStats();
const [videoGridSandboxMode] = useVideoGridSandboxMode()
const { fakeTiles: fakeItems, simulateJoin } = useFakeTiles()
const { hideScreensharing } = useUrlParams();
@@ -227,9 +234,11 @@ export function InCallView({
const reducedControls = boundsValid && bounds.width <= 400;
const noControls = reducedControls && bounds.height <= 400;
const items = useParticipantTiles(livekitRoom, participants);
const realItems = useParticipantTiles(livekitRoom, participants);
const { fullscreenItem, toggleFullscreen, exitFullscreen } =
useFullscreen(items);
useFullscreen(realItems);
const items = useMemo<TileDescriptor<ItemData | FakeItemData>[]>(() => videoGridSandboxMode ? [...realItems, ...fakeItems] : realItems, [videoGridSandboxMode, realItems, fakeItems])
// The maximised participant: either the participant that the user has
// manually put in fullscreen, or the focused (active) participant if the
@@ -238,9 +247,9 @@ export function InCallView({
() =>
fullscreenItem ??
(noControls
? items.find((item) => item.isSpeaker) ?? items.at(0) ?? null
? realItems.find((item) => item.isSpeaker) ?? realItems.at(0) ?? null
: null),
[fullscreenItem, noControls, items]
[fullscreenItem, noControls, realItems]
);
const Grid =
@@ -283,16 +292,19 @@ export function InCallView({
disableAnimations={prefersReducedMotion || isSafari}
layoutStates={layoutStates}
>
{(props) => (
{(props) => props.data.type === "real" ? (
<VideoTile
maximised={false}
fullscreen={false}
onToggleFullscreen={toggleFullscreen}
showSpeakingIndicator={items.length > 2}
showSpeakingIndicator={realItems.length > 2}
showConnectionStats={showConnectionStats}
{...props}
data={props.data}
ref={props.ref as Ref<HTMLDivElement>}
/>
) : (
<FakeVideoTile {...props} data={props.data} />
)}
</Grid>
);
@@ -381,6 +393,8 @@ export function InCallView({
);
}
buttons.push(<SettingsButton key="4" onPress={openSettings} />);
if (videoGridSandboxMode)
buttons.push(<SimulateJoinButton key="5" onPress={simulateJoin} />)
}
buttons.push(
@@ -480,7 +494,7 @@ function useParticipantTiles(
const member = matrixParticipants.get(id);
allGhosts &&= member === undefined;
const userMediaTile = {
const userMediaTile: TileDescriptor<ItemData> = {
id,
focused: false,
isPresenter: sfuParticipant.isScreenShareEnabled,
@@ -491,6 +505,7 @@ function useParticipantTiles(
local: sfuParticipant.isLocal,
largeBaseSize: false,
data: {
type: "real",
id,
member,
sfuParticipant,

44
src/room/RoomRedirect.tsx Normal file
View File

@@ -0,0 +1,44 @@
/*
Copyright 2022 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 { useEffect } from "react";
import { useLocation, useHistory } from "react-router-dom";
import { Config } from "../config/Config";
import { LoadingView } from "../FullScreenView";
// A component that, when loaded, redirects the client to a full room URL
// based on the current URL being an abbreviated room URL
export function RoomRedirect() {
const { pathname } = useLocation();
const history = useHistory();
useEffect(() => {
let roomId = pathname;
if (pathname.startsWith("/")) {
roomId = roomId.substring(1, roomId.length);
}
if (!roomId.startsWith("#") && !roomId.startsWith("!")) {
roomId = `#${roomId}:${Config.defaultServerName()}`;
}
history.replace(`/room/${roomId.toLowerCase()}`);
}, [pathname, history]);
return <LoadingView />;
}

View File

@@ -106,11 +106,11 @@ export function VideoPreview({ matrixInfo, onUserChoicesChanged }: Props) {
onUserChoicesChanged({
video: {
selectedId: videoIn.selectedId,
enabled: videoEnabled,
enabled: videoEnabled && !!videoTrack,
},
audio: {
selectedId: audioIn.selectedId,
enabled: audioEnabled,
enabled: audioEnabled && !!audioTrack,
},
});
}, [

96
src/room/useFakeTiles.ts Normal file
View File

@@ -0,0 +1,96 @@
/*
Copyright 2023 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 { TileDescriptor } from '../video-grid/VideoGrid'
import { FakeItemData } from '../video-grid/FakeVideoTile'
import { useCallback, useState } from 'react'
import { useLatest } from '../useLatest'
import { generateRandomName } from '../auth/generateRandomName'
interface FakeTiles {
fakeTiles: TileDescriptor<FakeItemData>[]
simulateJoin: () => void
}
export const useFakeTiles = (): FakeTiles => {
const [tiles, setTiles] = useState<TileDescriptor<FakeItemData>[]>([])
const latestSetTiles = useLatest(setTiles)
const simulateJoin = useCallback(() => {
const name = generateRandomName()
const newTile: TileDescriptor<FakeItemData> = {
id: name,
focused: false,
isPresenter: false,
isSpeaker: false,
hasVideo: false,
local: false,
largeBaseSize: false,
data: {
type: "fake",
name,
screenshare: false,
speaking: false,
simulateScreenshare: () => latestSetTiles.current(ts => {
if (ts.some(t => t.data.name === newTile.id && t.data.screenshare)) {
// No-op since they're already screensharing
return ts
} else {
const newScreenshareTile: TileDescriptor<FakeItemData> = {
id: `${name}:screenshare`,
focused: false,
isPresenter: false,
isSpeaker: false,
hasVideo: true,
local: false,
largeBaseSize: true,
placeNear: newTile.id,
data: {
type: "fake",
name,
screenshare: true,
speaking: false,
remove: () => latestSetTiles.current(ts => ts.filter(t => t.id !== newScreenshareTile.id))
}
}
return [...ts, newScreenshareTile]
}
}),
simulateSpeaking: () => latestSetTiles.current(ts => ts.map(t => {
if (t.id === newTile.id) {
return {
...t,
isSpeaker: !t.isSpeaker,
data: {
...t.data,
speaking: !t.isSpeaker,
}
}
} else {
return t
}
})),
remove: () => latestSetTiles.current(ts => ts.filter(t => t.id !== newTile.id))
}
}
setTiles(ts => [...ts, newTile])
}, [setTiles, latestSetTiles])
return {
fakeTiles: tiles,
simulateJoin,
}
}

View File

@@ -16,11 +16,12 @@ limitations under the License.
*/
import { logger } from "matrix-js-sdk/src/logger";
import { useCallback, useLayoutEffect, useRef } from "react";
import { useCallback, useLayoutEffect } from "react";
import { TileDescriptor } from "../video-grid/VideoGrid";
import { useReactiveState } from "../useReactiveState";
import { useEventTarget } from "../useEvents";
import { useLatest } from "../useLatest";
const isFullscreen = () =>
Boolean(document.fullscreenElement) ||
@@ -69,11 +70,8 @@ export function useFullscreen<T>(items: TileDescriptor<T>[]): {
[items]
);
const latestItems = useRef<TileDescriptor<T>[]>(items);
latestItems.current = items;
const latestFullscreenItem = useRef<TileDescriptor<T> | null>(fullscreenItem);
latestFullscreenItem.current = fullscreenItem;
const latestItems = useLatest(items)
const latestFullscreenItem = useLatest(fullscreenItem)
const toggleFullscreen = useCallback(
(itemId: string) => {

View File

@@ -15,7 +15,6 @@ limitations under the License.
*/
import { useCallback, useEffect, useReducer, useState } from "react";
import * as Sentry from "@sentry/react";
import {
GroupCallEvent,
GroupCallState,
@@ -332,7 +331,6 @@ export function useGroupCall(
}
function onError(e: GroupCallError): void {
Sentry.captureException(e);
if (e.code === GroupCallErrorCode.UnknownDevice) {
const unknownDeviceError = e as GroupCallUnknownDeviceError;
addUnencryptedEventUser(unknownDeviceError.userId);

View File

@@ -77,7 +77,7 @@ export function ProfileSettingsTab({ client }: Props) {
return (
<form onChange={onFormChange} ref={formRef} className={styles.content}>
<FieldRow className={styles.avatarFieldRow}>
{displayName && (
{avatarUrl && displayName && (
<AvatarInputField
id="avatar"
name="avatar"

View File

@@ -34,6 +34,7 @@ import {
useOptInAnalytics,
useDeveloperSettingsTab,
useShowConnectionStats,
useVideoGridSandboxMode,
} from "./useSetting";
import { FieldRow, InputField } from "../input/Input";
import { Button } from "../button";
@@ -68,6 +69,7 @@ export const SettingsModal = (props: Props) => {
useDeveloperSettingsTab();
const [showConnectionStats, setShowConnectionStats] =
useShowConnectionStats();
const [videoGridSandboxMode, setVideoGridSandboxMode] = useVideoGridSandboxMode()
const downloadDebugLog = useDownloadDebugLog();
@@ -118,7 +120,7 @@ export const SettingsModal = (props: Props) => {
const devices = props.mediaDevicesSwitcher;
const audioTab = (
const tabs = [
<TabItem
key="audio"
title={
@@ -130,10 +132,7 @@ export const SettingsModal = (props: Props) => {
>
{devices && generateDeviceSelection(devices.audioIn, t("Microphone"))}
{devices && generateDeviceSelection(devices.audioOut, t("Speaker"))}
</TabItem>
);
const videoTab = (
</TabItem>,
<TabItem
key="video"
title={
@@ -144,24 +143,7 @@ export const SettingsModal = (props: Props) => {
}
>
{devices && generateDeviceSelection(devices.videoIn, t("Camera"))}
</TabItem>
);
const profileTab = (
<TabItem
key="profile"
title={
<>
<UserIcon width={15} height={15} />
<span>{t("Profile")}</span>
</>
}
>
<ProfileSettingsTab client={props.client} />
</TabItem>
);
const feedbackTab = (
</TabItem>,
<TabItem
key="feedback"
title={
@@ -172,10 +154,7 @@ export const SettingsModal = (props: Props) => {
}
>
<FeedbackSettingsTab roomId={props.roomId} />
</TabItem>
);
const moreTab = (
</TabItem>,
<TabItem
key="more"
title={
@@ -211,61 +190,85 @@ export const SettingsModal = (props: Props) => {
}}
/>
</FieldRow>
</TabItem>
);
</TabItem>,
];
const developerTab = (
<TabItem
key="developer"
title={
<>
<DeveloperIcon width={16} height={16} />
<span>{t("Developer")}</span>
</>
}
>
<FieldRow>
<Body className={styles.fieldRowText}>
{t("Version: {{version}}", {
version: import.meta.env.VITE_APP_VERSION || "dev",
})}
</Body>
</FieldRow>
<FieldRow>
<InputField
id="showInspector"
name="inspector"
label={t("Show call inspector")}
type="checkbox"
checked={showInspector}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setShowInspector(e.target.checked)
}
/>
</FieldRow>
<FieldRow>
<InputField
id="showConnectionStats"
name="connection-stats"
label={t("Show connection stats")}
type="checkbox"
checked={showConnectionStats}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setShowConnectionStats(e.target.checked)
}
/>
</FieldRow>
<FieldRow>
<Button onPress={downloadDebugLog}>{t("Download debug logs")}</Button>
</FieldRow>
</TabItem>
);
if (!isEmbedded) {
tabs.push(
<TabItem
key="profile"
title={
<>
<UserIcon width={15} height={15} />
<span>{t("Profile")}</span>
</>
}
>
<ProfileSettingsTab client={props.client} />
</TabItem>
);
}
const tabs: JSX.Element[] = [];
tabs.push(audioTab, videoTab);
if (!isEmbedded) tabs.push(profileTab);
tabs.push(feedbackTab, moreTab);
if (developerSettingsTab) tabs.push(developerTab);
if (developerSettingsTab) {
tabs.push(
<TabItem
key="developer"
title={
<>
<DeveloperIcon width={16} height={16} />
<span>{t("Developer")}</span>
</>
}
>
<FieldRow>
<Body className={styles.fieldRowText}>
{t("Version: {{version}}", {
version: import.meta.env.VITE_APP_VERSION || "dev",
})}
</Body>
</FieldRow>
<FieldRow>
<InputField
id="showInspector"
name="inspector"
label={t("Show call inspector")}
type="checkbox"
checked={showInspector}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setShowInspector(e.target.checked)
}
/>
</FieldRow>
<FieldRow>
<InputField
id="showConnectionStats"
name="connection-stats"
label={t("Show connection stats")}
type="checkbox"
checked={showConnectionStats}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setShowConnectionStats(e.target.checked)
}
/>
</FieldRow>
<FieldRow>
<InputField
id="videoGridSandboxMode"
name="video-grid-sandbox-mode"
label="Video grid sandbox mode"
type="checkbox"
checked={videoGridSandboxMode}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setVideoGridSandboxMode(e.target.checked)
}
/>
</FieldRow>
<FieldRow>
<Button onPress={downloadDebugLog}>{t("Download debug logs")}</Button>
</FieldRow>
</TabItem>
);
}
return (
<Modal

View File

@@ -104,6 +104,8 @@ export const useDeveloperSettingsTab = () =>
export const useShowConnectionStats = () =>
useSetting("show-connection-stats", false);
export const useVideoGridSandboxMode = () => useSetting("video-grid-sandbox-mode", false)
export const useDefaultDevices = () =>
useSetting("defaultDevices", {
audioinput: "",

27
src/useLatest.ts Normal file
View File

@@ -0,0 +1,27 @@
/*
Copyright 2023 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 { useRef } from "react"
/**
* Returns a ref that always holds the latest value passed to this hook.
*/
export const useLatest = <T,>(t: T): { current: T } => {
const latest = useRef(t)
latest.current = t
return latest
}

View File

@@ -0,0 +1,42 @@
/*
Copyright 2023 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.
*/
.tile {
background-color: var(--system);
--tileRadius: 20px;
border-radius: var(--tileRadius);
box-sizing: border-box;
padding: 12px;
user-select: none;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
}
.speaking {
border: 4px solid var(--accent);
}
.name {
font-size: var(--font-size-caption);
}
.buttons {
display: flex;
gap: 8px;
}

View File

@@ -0,0 +1,55 @@
/*
Copyright 2023 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 { animated } from "@react-spring/web";
import classNames from "classnames";
import { ComponentProps, forwardRef, Ref } from "react"
import { Avatar } from "../Avatar";
import { ReactComponent as HangupIcon } from "../icons/Hangup.svg";
import { ReactComponent as ScreenshareIcon } from "../icons/Screenshare.svg";
import { ReactComponent as MicIcon } from "../icons/Mic.svg";
import styles from "./FakeVideoTile.module.css";
export interface FakeItemData {
type: "fake", // To differentiate from ItemData
name: string,
screenshare: boolean,
speaking: boolean,
simulateScreenshare?: () => void
simulateSpeaking?: () => void
remove: () => void
}
interface Props {
data: FakeItemData
className?: string;
style?: ComponentProps<typeof animated.div>["style"];
}
export const FakeVideoTile = forwardRef<HTMLElement, Props>(({ data, className, style }, ref) => {
return <animated.div className={classNames(className, styles.tile, { [styles.speaking]: data.speaking })} ref={ref as Ref<HTMLDivElement>} style={style}>
<Avatar fallback={data.name[0].toUpperCase()} />
<div className={styles.name}>
{data.screenshare ? `${data.name} (sharing screen)` : data.name}
</div>
<div className={styles.buttons}>
{data.simulateSpeaking && <MicIcon onClick={data.simulateSpeaking} />}
{data.simulateScreenshare && <ScreenshareIcon onClick={data.simulateScreenshare} />}
<HangupIcon onClick={data.remove} />
</div>
</animated.div>
})

View File

@@ -0,0 +1,23 @@
/*
Copyright 2023 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.
*/
.tile {
position: absolute;
contain: strict;
overflow: hidden;
cursor: pointer;
top: 0;
}

View File

@@ -19,6 +19,7 @@ import { EventTypes, Handler, useDrag } from "@use-gesture/react";
import { SpringValue, to } from "@react-spring/web";
import { ChildrenProperties } from "./VideoGrid";
import styles from "./TileWrapper.module.css"
interface Props<T> {
id: string;
@@ -77,6 +78,7 @@ export const TileWrapper = memo(
<>
{children({
ref,
className: styles.tile,
style: {
opacity,
scale,

View File

@@ -802,6 +802,7 @@ interface DragTileData {
export interface ChildrenProperties<T> {
ref: Ref<HTMLElement>;
className?: string;
style: ComponentProps<typeof animated.div>["style"];
/**
* The width this tile will have once its animations have settled.

View File

@@ -15,15 +15,11 @@ limitations under the License.
*/
.videoTile {
position: absolute;
contain: strict;
top: 0;
container-name: videoTile;
container-type: size;
--tileRadius: 8px;
border-radius: var(--tileRadius);
overflow: hidden;
cursor: pointer;
/* HACK: This has no visual effect due to the short duration, but allows the
JS to detect movement via the transform property for audio spatialization */
@@ -154,7 +150,7 @@ limitations under the License.
.videoMutedOverlay {
width: 100%;
height: 100%;
background-color: #21262c;
background-color: var(--system);
}
.presenterLabel {

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { ComponentProps, forwardRef, useCallback, useEffect } from "react";
import { ComponentProps, forwardRef, Ref, useCallback, useEffect } from "react";
import { animated } from "@react-spring/web";
import classNames from "classnames";
import { useTranslation } from "react-i18next";
@@ -39,6 +39,7 @@ import { useModalTriggerState } from "../Modal";
import { VideoTileSettingsModal } from "./VideoTileSettingsModal";
export interface ItemData {
type: "real", // To differentiate from FakeItemData
id: string;
member?: RoomMember;
sfuParticipant: LocalParticipant | RemoteParticipant;
@@ -64,7 +65,7 @@ interface Props {
showConnectionStats: boolean;
}
export const VideoTile = forwardRef<HTMLDivElement, Props>(
export const VideoTile = forwardRef<HTMLElement, Props>(
(
{
data,
@@ -159,7 +160,7 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
[styles.maximised]: maximised,
})}
style={style}
ref={tileRef}
ref={tileRef as Ref<HTMLDivElement>}
data-testid="videoTile"
>
{toolbarButtons.length > 0 && (!maximised || fullscreen) && (

View File

@@ -109,7 +109,7 @@ export const widget: WidgetHelpers | null = (() => {
baseUrl,
e2eEnabled,
allowIceFallback,
} = getUrlParams(true);
} = getUrlParams();
if (!roomId) throw new Error("Room ID must be supplied");
if (!userId) throw new Error("User ID must be supplied");
if (!deviceId) throw new Error("Device ID must be supplied");

View File

@@ -1,98 +0,0 @@
/*
Copyright 2023 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 { mocked } from "jest-mock";
import { getUrlParams } from "../src/UrlParams";
import { Config } from "../src/config/Config";
const ROOM_NAME = "roomNameHere";
const ROOM_ID = "d45f138fsd";
const ORIGIN = "https://call.element.io";
const HOMESERVER = "call.ems.host";
jest.mock("../src/config/Config");
describe("UrlParams", () => {
beforeAll(() => {
mocked(Config.defaultServerName).mockReturnValue("call.ems.host");
});
describe("handles URL with /room/", () => {
it("and nothing else", () => {
expect(getUrlParams(false, "", `/room/${ROOM_NAME}`, "").roomAlias).toBe(
`#${ROOM_NAME}:${HOMESERVER}`
);
});
it("and #", () => {
expect(
getUrlParams(false, "", `${ORIGIN}/room/`, `#${ROOM_NAME}`).roomAlias
).toBe(`#${ROOM_NAME}:${HOMESERVER}`);
});
it("and # and server part", () => {
expect(
getUrlParams(false, "", `/room/`, `#${ROOM_NAME}:${HOMESERVER}`)
.roomAlias
).toBe(`#${ROOM_NAME}:${HOMESERVER}`);
});
it("and server part", () => {
expect(
getUrlParams(false, "", `/room/${ROOM_NAME}:${HOMESERVER}`, "")
.roomAlias
).toBe(`#${ROOM_NAME}:${HOMESERVER}`);
});
});
describe("handles URL without /room/", () => {
it("and nothing else", () => {
expect(getUrlParams(false, "", `/${ROOM_NAME}`, "").roomAlias).toBe(
`#${ROOM_NAME}:${HOMESERVER}`
);
});
it("and with #", () => {
expect(getUrlParams(false, "", "", `#${ROOM_NAME}`).roomAlias).toBe(
`#${ROOM_NAME}:${HOMESERVER}`
);
});
it("and with # and server part", () => {
expect(
getUrlParams(false, "", "", `#${ROOM_NAME}:${HOMESERVER}`).roomAlias
).toBe(`#${ROOM_NAME}:${HOMESERVER}`);
});
it("and with server part", () => {
expect(
getUrlParams(false, "", `/${ROOM_NAME}:${HOMESERVER}`, "").roomAlias
).toBe(`#${ROOM_NAME}:${HOMESERVER}`);
});
});
describe("handles search params", () => {
it("(roomId)", () => {
expect(getUrlParams(true, `?roomId=${ROOM_ID}`).roomId).toBe(ROOM_ID);
});
});
it("ignores room alias", () => {
expect(
getUrlParams(true, "", `/room/${ROOM_NAME}:${HOMESERVER}`).roomAlias
).toBeFalsy();
});
});

View File

@@ -34,7 +34,7 @@ describe("CallList", () => {
it("should show room", async () => {
const rooms = [
{ roomName: "Room #1", roomAlias: "#room-name:server.org" },
{ roomName: "Room #1", roomId: "!roomId" },
] as GroupCallRoom[];
const result = renderComponent(rooms);

View File

@@ -2014,13 +2014,6 @@
dependencies:
"@sinclair/typebox" "^0.24.1"
"@jest/schemas@^29.4.3":
version "29.4.3"
resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-29.4.3.tgz#39cf1b8469afc40b6f5a2baaa146e332c4151788"
integrity sha512-VLYKXQmtmuEz6IxJsrZwzG9NvtkQsWNnWMsKxqWNu3+CnfzJQhp0WDDKWLVV9hLKr0l3SLLFRqcYHjhtyuDVxg==
dependencies:
"@sinclair/typebox" "^0.25.16"
"@jest/source-map@^29.2.0":
version "29.2.0"
resolved "https://registry.yarnpkg.com/@jest/source-map/-/source-map-29.2.0.tgz#ab3420c46d42508dcc3dc1c6deee0b613c235744"
@@ -2095,18 +2088,6 @@
"@types/yargs" "^17.0.8"
chalk "^4.0.0"
"@jest/types@^29.5.0":
version "29.5.0"
resolved "https://registry.yarnpkg.com/@jest/types/-/types-29.5.0.tgz#f59ef9b031ced83047c67032700d8c807d6e1593"
integrity sha512-qbu7kN6czmVRc3xWFQcAN03RAUamgppVUdXrvl1Wr3jlNF93o9mJbGcDWrwGB6ht44u7efB1qCFgVQmca24Uog==
dependencies:
"@jest/schemas" "^29.4.3"
"@types/istanbul-lib-coverage" "^2.0.0"
"@types/istanbul-reports" "^3.0.0"
"@types/node" "*"
"@types/yargs" "^17.0.8"
chalk "^4.0.0"
"@joshwooding/vite-plugin-react-docgen-typescript@0.0.2":
version "0.0.2"
resolved "https://registry.yarnpkg.com/@joshwooding/vite-plugin-react-docgen-typescript/-/vite-plugin-react-docgen-typescript-0.0.2.tgz#e0ae8c94f468da3a273a7b0acf23ba3565f86cbc"
@@ -3117,11 +3098,6 @@
resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.24.51.tgz#645f33fe4e02defe26f2f5c0410e1c094eac7f5f"
integrity sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA==
"@sinclair/typebox@^0.25.16":
version "0.25.24"
resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.25.24.tgz#8c7688559979f7079aacaf31aa881c3aa410b718"
integrity sha512-XJfwUVUKDHF5ugKwIcxEgc9k8b7HbznCp6eUfWgu710hMPNIO4aw4/zB5RogDQz8nd6gyCDpU9O/m6qYEWY6yQ==
"@sinonjs/commons@^1.7.0":
version "1.8.3"
resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.3.tgz#3802ddd21a50a949b6721ddd72da36e67e7f1b2d"
@@ -10333,15 +10309,6 @@ jest-mock@^29.3.1:
"@types/node" "*"
jest-util "^29.3.1"
jest-mock@^29.5.0:
version "29.5.0"
resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-29.5.0.tgz#26e2172bcc71d8b0195081ff1f146ac7e1518aed"
integrity sha512-GqOzvdWDE4fAV2bWQLQCkujxYWL7RxjCnj71b5VhDAGOevB3qj3Ovg26A5NI84ZpODxyzaozXLOh2NCgkbvyaw==
dependencies:
"@jest/types" "^29.5.0"
"@types/node" "*"
jest-util "^29.5.0"
jest-pnp-resolver@^1.2.2:
version "1.2.2"
resolved "https://registry.yarnpkg.com/jest-pnp-resolver/-/jest-pnp-resolver-1.2.2.tgz#b704ac0ae028a89108a4d040b3f919dfddc8e33c"
@@ -10484,18 +10451,6 @@ jest-util@^29.3.1:
graceful-fs "^4.2.9"
picomatch "^2.2.3"
jest-util@^29.5.0:
version "29.5.0"
resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-29.5.0.tgz#24a4d3d92fc39ce90425311b23c27a6e0ef16b8f"
integrity sha512-RYMgG/MTadOr5t8KdhejfvUU82MxsCu5MF6KuDUHl+NuwzUt+Sm6jJWxTJVrDR1j5M/gJVCPKQEpWXY+yIQ6lQ==
dependencies:
"@jest/types" "^29.5.0"
"@types/node" "*"
chalk "^4.0.0"
ci-info "^3.2.0"
graceful-fs "^4.2.9"
picomatch "^2.2.3"
jest-validate@^29.2.2:
version "29.2.2"
resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-29.2.2.tgz#e43ce1931292dfc052562a11bc681af3805eadce"