Merge branch 'main' into robertlong/spotlight-layout

This commit is contained in:
Robert Long
2022-02-02 15:15:39 -08:00
53 changed files with 3531 additions and 2987 deletions

View File

@@ -46,7 +46,6 @@ limitations under the License.
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
}
.logo {
@@ -70,4 +69,8 @@ limitations under the License.
.container {
min-height: calc(100% - 76px);
}
.main {
justify-content: center;
}
}

View File

@@ -16,11 +16,7 @@ export function GridLayoutMenu({ layout, setLayout }) {
<Button variant="icon">
{layout === "spotlight" ? <SpotlightIcon /> : <FreedomIcon />}
</Button>
{(props) => (
<Tooltip position="bottom" {...props}>
Layout Type
</Tooltip>
)}
{() => "Layout Type"}
</TooltipTrigger>
{(props) => (
<Menu {...props} label="Grid layout menu" onAction={setLayout}>

View File

@@ -1,7 +1,17 @@
import { Resizable } from "re-resizable";
import React, { useEffect, useState, useMemo } from "react";
import { useCallback } from "react";
import React, {
useEffect,
useState,
useReducer,
useRef,
createContext,
useContext,
} from "react";
import ReactJson from "react-json-view";
import mermaid from "mermaid";
import styles from "./GroupCallInspector.module.css";
import { SelectInput } from "../input/SelectInput";
import { Item } from "@react-stately/collections";
function getCallUserId(call) {
return call.getOpponentMember()?.userId || call.invitee || null;
@@ -23,203 +33,434 @@ function getHangupCallState(call) {
};
}
export function GroupCallInspector({ client, groupCall, show }) {
const [roomStateEvents, setRoomStateEvents] = useState([]);
const [toDeviceEvents, setToDeviceEvents] = useState([]);
const [sentVoipEvents, setSentVoipEvents] = useState([]);
const [state, setState] = useState({
userId: client.getUserId(),
});
const dateFormatter = new Intl.DateTimeFormat([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
fractionalSecondDigits: 3,
});
const updateState = useCallback(
(next) => setState((prev) => ({ ...prev, ...next })),
[]
const defaultCollapsedFields = [
"org.matrix.msc3401.call",
"org.matrix.msc3401.call.member",
"calls",
"callStats",
"hangupCalls",
"toDeviceEvents",
"sentVoipEvents",
"content",
];
function shouldCollapse({ name, src, type, namespace }) {
return defaultCollapsedFields.includes(name);
}
function getUserName(userId) {
const match = userId.match(/@([^\:]+):/);
return match && match.length > 0
? match[1].replace("-", " ").replace("W", "")
: userId.replace("W", "");
}
function formatContent(type, content) {
if (type === "m.call.hangup") {
return `callId: ${content.call_id.slice(-4)} reason: ${
content.reason
} senderSID: ${content.sender_session_id} destSID: ${
content.dest_session_id
}`;
}
if (type.startsWith("m.call.")) {
return `callId: ${content.call_id?.slice(-4)} senderSID: ${
content.sender_session_id
} destSID: ${content.dest_session_id}`;
} else if (type === "org.matrix.msc3401.call.member") {
const call =
content["m.calls"] &&
content["m.calls"].length > 0 &&
content["m.calls"][0];
const device =
call &&
call["m.devices"] &&
call["m.devices"].length > 0 &&
call["m.devices"][0];
return `conf_id: ${call && call["m.call_id"].slice(-4)} sessionId: ${
device && device.session_id
}`;
} else {
return "";
}
}
function formatTimestamp(timestamp) {
return dateFormatter.format(timestamp);
}
export const InspectorContext = createContext();
export function InspectorContextProvider({ children }) {
const context = useState({});
return (
<InspectorContext.Provider value={context}>
{children}
</InspectorContext.Provider>
);
}
export function SequenceDiagramViewer({
localUserId,
remoteUserIds,
selectedUserId,
onSelectUserId,
events,
}) {
const mermaidElRef = useRef();
useEffect(() => {
mermaid.initialize({
startOnLoad: true,
theme: "dark",
sequence: {
showSequenceNumbers: true,
},
});
}, []);
useEffect(() => {
const graphDefinition = `sequenceDiagram
participant ${getUserName(localUserId)}
participant Room
participant ${selectedUserId ? getUserName(selectedUserId) : "unknown"}
${
events
? events
.map(
({ to, from, timestamp, type, content, ignored }) =>
`${getUserName(from)} ${ignored ? "-x" : "->>"} ${getUserName(
to
)}: ${formatTimestamp(timestamp)} ${type} ${formatContent(
type,
content
)}`
)
.join("\n ")
: ""
}
`;
mermaid.mermaidAPI.render("mermaid", graphDefinition, (svgCode) => {
mermaidElRef.current.innerHTML = svgCode;
});
}, [events, localUserId, selectedUserId]);
return (
<div className={styles.scrollContainer}>
<div className={styles.sequenceDiagramViewer}>
<SelectInput
className={styles.selectInput}
label="Remote User"
selectedKey={selectedUserId}
onSelectionChange={onSelectUserId}
>
{remoteUserIds.map((userId) => (
<Item key={userId}>{userId}</Item>
))}
</SelectInput>
<div id="mermaid" />
<div ref={mermaidElRef} />
</div>
</div>
);
}
function reducer(state, action) {
switch (action.type) {
case "receive_room_state_event": {
const { event, callStateEvent, memberStateEvents } = action;
let eventsByUserId = state.eventsByUserId;
let remoteUserIds = state.remoteUserIds;
if (event) {
const fromId = event.getStateKey();
remoteUserIds =
fromId === state.localUserId || eventsByUserId[fromId]
? state.remoteUserIds
: [...state.remoteUserIds, fromId];
eventsByUserId = { ...state.eventsByUserId };
if (event.getStateKey() === state.localUserId) {
for (const userId in eventsByUserId) {
eventsByUserId[userId] = [
...(eventsByUserId[userId] || []),
{
from: fromId,
to: "Room",
type: event.getType(),
content: event.getContent(),
timestamp: event.getTs() || Date.now(),
ignored: false,
},
];
}
} else {
eventsByUserId[fromId] = [
...(eventsByUserId[fromId] || []),
{
from: fromId,
to: "Room",
type: event.getType(),
content: event.getContent(),
timestamp: event.getTs() || Date.now(),
ignored: false,
},
];
}
}
return {
...state,
eventsByUserId,
remoteUserIds,
callStateEvent: callStateEvent.getContent(),
memberStateEvents: Object.fromEntries(
memberStateEvents.map((e) => [e.getStateKey(), e.getContent()])
),
};
}
case "receive_to_device_event": {
const event = action.event;
const eventsByUserId = { ...state.eventsByUserId };
const fromId = event.getSender();
const toId = state.localUserId;
const content = event.getContent();
const remoteUserIds = eventsByUserId[fromId]
? state.remoteUserIds
: [...state.remoteUserIds, fromId];
eventsByUserId[fromId] = [
...(eventsByUserId[fromId] || []),
{
from: fromId,
to: toId,
type: event.getType(),
content,
timestamp: event.getTs() || Date.now(),
ignored: state.localSessionId !== content.dest_session_id,
},
];
return { ...state, eventsByUserId, remoteUserIds };
}
case "send_voip_event": {
const event = action.event;
const eventsByUserId = { ...state.eventsByUserId };
const fromId = state.localUserId;
const toId = event.userId;
const remoteUserIds = eventsByUserId[toId]
? state.remoteUserIds
: [...state.remoteUserIds, toId];
eventsByUserId[toId] = [
...(eventsByUserId[toId] || []),
{
from: fromId,
to: toId,
type: event.eventType,
content: event.content,
timestamp: Date.now(),
ignored: false,
},
];
return { ...state, eventsByUserId, remoteUserIds };
}
default:
return state;
}
}
function useGroupCallState(client, groupCall, pollCallStats) {
const [state, dispatch] = useReducer(reducer, {
localUserId: client.getUserId(),
localSessionId: client.getSessionId(),
eventsByUserId: {},
remoteUserIds: [],
callStateEvent: null,
memberStateEvents: {},
});
useEffect(() => {
function onUpdateRoomState(event) {
if (event) {
setRoomStateEvents((prev) => [
...prev,
{
eventType: event.getType(),
stateKey: event.getStateKey(),
content: event.getContent(),
},
]);
}
const roomEvent = groupCall.room.currentState
.getStateEvents("org.matrix.msc3401.call", groupCall.groupCallId)
.getContent();
const memberEvents = Object.fromEntries(
groupCall.room.currentState
.getStateEvents("org.matrix.msc3401.call.member")
.map((event) => [event.getStateKey(), event.getContent()])
const callStateEvent = groupCall.room.currentState.getStateEvents(
"org.matrix.msc3401.call",
groupCall.groupCallId
);
updateState({
["org.matrix.msc3401.call"]: roomEvent,
["org.matrix.msc3401.call.member"]: memberEvents,
const memberStateEvents = groupCall.room.currentState.getStateEvents(
"org.matrix.msc3401.call.member"
);
dispatch({
type: "receive_room_state_event",
event,
callStateEvent,
memberStateEvents,
});
}
function onCallsChanged() {
const calls = groupCall.calls.reduce((obj, call) => {
obj[
`${call.callId} (${call.getOpponentMember()?.userId || call.sender})`
] = getCallState(call);
return obj;
}, {});
// function onCallsChanged() {
// const calls = groupCall.calls.reduce((obj, call) => {
// obj[
// `${call.callId} (${call.getOpponentMember()?.userId || call.sender})`
// ] = getCallState(call);
// return obj;
// }, {});
updateState({ calls });
}
// updateState({ calls });
// }
function onCallHangup(call) {
setState(({ hangupCalls, ...rest }) => ({
...rest,
hangupCalls: {
...hangupCalls,
[`${call.callId} (${
call.getOpponentMember()?.userId || call.sender
})`]: getHangupCallState(call),
},
}));
}
// function onCallHangup(call) {
// setState(({ hangupCalls, ...rest }) => ({
// ...rest,
// hangupCalls: {
// ...hangupCalls,
// [`${call.callId} (${
// call.getOpponentMember()?.userId || call.sender
// })`]: getHangupCallState(call),
// },
// }));
// dispatch({ type: "call_hangup", call });
// }
function onToDeviceEvent(event) {
const eventType = event.getType();
if (
!(
eventType.startsWith("m.call.") ||
eventType.startsWith("org.matrix.call.")
)
) {
return;
}
const content = event.getContent();
if (content.conf_id && content.conf_id !== groupCall.groupCallId) {
return;
}
setToDeviceEvents((prev) => [
...prev,
{ eventType, content, sender: event.getSender() },
]);
dispatch({ type: "receive_to_device_event", event });
}
function onSendVoipEvent(event) {
setSentVoipEvents((prev) => [...prev, event]);
dispatch({ type: "send_voip_event", event });
}
client.on("RoomState.events", onUpdateRoomState);
groupCall.on("calls_changed", onCallsChanged);
//groupCall.on("calls_changed", onCallsChanged);
groupCall.on("send_voip_event", onSendVoipEvent);
client.on("state", onCallsChanged);
client.on("hangup", onCallHangup);
//client.on("state", onCallsChanged);
//client.on("hangup", onCallHangup);
client.on("toDeviceEvent", onToDeviceEvent);
onUpdateRoomState();
}, [client, groupCall]);
const toDeviceEventsByCall = useMemo(() => {
const result = {};
for (const event of toDeviceEvents) {
const callId = event.content.call_id;
const key = `${callId} (${event.sender})`;
result[key] = result[key] || [];
result[key].push(event);
}
return result;
}, [toDeviceEvents]);
const sentVoipEventsByCall = useMemo(() => {
const result = {};
for (const event of sentVoipEvents) {
const callId = event.content.call_id;
const key = `${callId} (${event.userId})`;
result[key] = result[key] || [];
result[key].push(event);
}
return result;
}, [sentVoipEvents]);
useEffect(() => {
let timeout;
async function updateCallStats() {
const callIds = groupCall.calls.map(
(call) =>
`${call.callId} (${call.getOpponentMember()?.userId || call.sender})`
);
const stats = await Promise.all(
groupCall.calls.map((call) =>
call.peerConn
? call.peerConn
.getStats(null)
.then((stats) =>
Object.fromEntries(
Array.from(stats).map(([_id, report], i) => [
report.type + i,
report,
])
)
)
: Promise.resolve(null)
)
);
const callStats = {};
for (let i = 0; i < groupCall.calls.length; i++) {
callStats[callIds[i]] = stats[i];
}
updateState({ callStats });
timeout = setTimeout(updateCallStats, 1000);
}
if (show) {
updateCallStats();
}
return () => {
clearTimeout(timeout);
client.removeListener("RoomState.events", onUpdateRoomState);
//groupCall.removeListener("calls_changed", onCallsChanged);
groupCall.removeListener("send_voip_event", onSendVoipEvent);
//client.removeListener("state", onCallsChanged);
//client.removeListener("hangup", onCallHangup);
client.removeListener("toDeviceEvent", onToDeviceEvent);
};
}, [show]);
}, [client, groupCall]);
// useEffect(() => {
// let timeout;
// async function updateCallStats() {
// const callIds = groupCall.calls.map(
// (call) =>
// `${call.callId} (${call.getOpponentMember()?.userId || call.sender})`
// );
// const stats = await Promise.all(
// groupCall.calls.map((call) =>
// call.peerConn
// ? call.peerConn
// .getStats(null)
// .then((stats) =>
// Object.fromEntries(
// Array.from(stats).map(([_id, report], i) => [
// report.type + i,
// report,
// ])
// )
// )
// : Promise.resolve(null)
// )
// );
// const callStats = {};
// for (let i = 0; i < groupCall.calls.length; i++) {
// callStats[callIds[i]] = stats[i];
// }
// dispatch({ type: "callStats", callStats });
// timeout = setTimeout(updateCallStats, 1000);
// }
// if (pollCallStats) {
// updateCallStats();
// }
// return () => {
// clearTimeout(timeout);
// };
// }, [pollCallStats]);
return state;
}
export function GroupCallInspector({ client, groupCall, show }) {
const [currentTab, setCurrentTab] = useState("sequence-diagrams");
const [selectedUserId, setSelectedUserId] = useState();
const state = useGroupCallState(client, groupCall, show);
const [_, setState] = useContext(InspectorContext);
useEffect(() => {
setState({ json: state });
}, [setState, state]);
if (!show) {
return null;
}
return (
<Resizable enable={{ top: true }} defaultSize={{ height: 200 }}>
<ReactJson
theme="monokai"
src={{
...state,
roomStateEvents,
toDeviceEvents,
toDeviceEventsByCall,
sentVoipEvents,
sentVoipEventsByCall,
}}
name={null}
indentWidth={2}
collapsed={1}
displayDataTypes={false}
displayObjectSize={false}
enableClipboard={false}
style={{ height: "100%", overflowY: "scroll" }}
/>
<Resizable
enable={{ top: true }}
defaultSize={{ height: 200 }}
className={styles.inspector}
>
<div className={styles.toolbar}>
<button onClick={() => setCurrentTab("sequence-diagrams")}>
Sequence Diagrams
</button>
<button onClick={() => setCurrentTab("inspector")}>Inspector</button>
</div>
{currentTab === "sequence-diagrams" && (
<SequenceDiagramViewer
localUserId={state.localUserId}
selectedUserId={selectedUserId}
onSelectUserId={setSelectedUserId}
remoteUserIds={state.remoteUserIds}
events={state.eventsByUserId[selectedUserId]}
/>
)}
{currentTab === "inspector" && (
<ReactJson
theme="monokai"
src={state}
name={null}
indentWidth={2}
shouldCollapse={shouldCollapse}
displayDataTypes={false}
displayObjectSize={false}
enableClipboard
style={{ height: "100%", overflowY: "scroll" }}
/>
)}
</Resizable>
);
}

View File

@@ -0,0 +1,25 @@
.inspector {
background-color: var(--bgColor2);
}
.scrollContainer {
height: 100%;
overflow-y: auto;
}
.sequenceDiagramViewer {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
}
.selectInput {
align-self: flex-start;
}
.sequenceDiagramViewer :global(.messageText) {
font-size: 12px;
fill: var(--textColor1) !important;
stroke: var(--textColor1) !important;
}

View File

@@ -1,6 +1,7 @@
import React from "react";
import { useLoadGroupCall } from "./useLoadGroupCall";
import { ErrorView, FullScreenView } from "../FullScreenView";
import { usePageTitle } from "../usePageTitle";
export function GroupCallLoader({ client, roomId, viaServers, children }) {
const { loading, error, groupCall } = useLoadGroupCall(
@@ -9,6 +10,8 @@ export function GroupCallLoader({ client, roomId, viaServers, children }) {
viaServers
);
usePageTitle(groupCall ? groupCall.room.name : "Loading...");
if (loading) {
return (
<FullScreenView>

View File

@@ -15,7 +15,19 @@ export function GroupCallView({
groupCall,
simpleGrid,
}) {
const [showInspector, setShowInspector] = useState(false);
const [showInspector, setShowInspector] = useState(
() => !!localStorage.getItem("matrix-group-call-inspector")
);
const onChangeShowInspector = useCallback((show) => {
setShowInspector(show);
if (show) {
localStorage.setItem("matrix-group-call-inspector", "true");
} else {
localStorage.removeItem("matrix-group-call-inspector");
}
}, []);
const {
state,
error,
@@ -46,12 +58,11 @@ export function GroupCallView({
const history = useHistory();
const onLeave = useCallback(() => {
setLeft(true);
leave();
if (!isPasswordlessUser) {
history.push("/");
} else {
setLeft(true);
}
}, [leave, history]);
@@ -75,7 +86,7 @@ export function GroupCallView({
localScreenshareFeed={localScreenshareFeed}
screenshareFeeds={screenshareFeeds}
simpleGrid={simpleGrid}
setShowInspector={setShowInspector}
setShowInspector={onChangeShowInspector}
showInspector={showInspector}
roomId={roomId}
/>
@@ -102,7 +113,7 @@ export function GroupCallView({
localVideoMuted={localVideoMuted}
toggleLocalVideoMuted={toggleLocalVideoMuted}
toggleMicrophoneMuted={toggleMicrophoneMuted}
setShowInspector={setShowInspector}
setShowInspector={onChangeShowInspector}
showInspector={showInspector}
roomId={roomId}
/>

View File

@@ -128,7 +128,7 @@ export function InCallView({
</LeftNav>
<RightNav>
<GridLayoutMenu layout={layout} setLayout={setLayout} />
<UserMenuContainer disableLogout />
<UserMenuContainer preventNavigation />
</RightNav>
</Header>
{items.length === 0 ? (

View File

@@ -9,6 +9,11 @@ import { getRoomUrl } from "../matrix-utils";
import { OverflowMenu } from "./OverflowMenu";
import { UserMenuContainer } from "../UserMenuContainer";
import { Body, Link } from "../typography/Typography";
import { Avatar } from "../Avatar";
import { getAvatarUrl } from "../matrix-utils";
import { useProfile } from "../profile/useProfile";
import useMeasure from "react-use-measure";
import { ResizeObserver } from "@juggle/resize-observer";
export function LobbyView({
client,
@@ -27,9 +32,11 @@ export function LobbyView({
}) {
const { stream } = useCallFeed(localCallFeed);
const videoRef = useMediaStream(stream, true);
const { displayName, avatarUrl } = useProfile(client);
const [previewRef, previewBounds] = useMeasure({ polyfill: ResizeObserver });
const avatarSize = (previewBounds.height - 66) / 2;
useEffect(() => {
// TODO: Only init once
onInitLocalCallFeed();
}, [onInitLocalCallFeed]);
@@ -45,7 +52,7 @@ export function LobbyView({
</Header>
<div className={styles.joinRoom}>
<div className={styles.joinRoomContent}>
<div className={styles.preview}>
<div className={styles.preview} ref={previewRef}>
<video ref={videoRef} muted playsInline disablePictureInPicture />
{state === GroupCallState.LocalCallFeedUninitialized && (
<Body fontWeight="semiBold" className={styles.webcamPermissions}>
@@ -59,6 +66,20 @@ export function LobbyView({
)}
{state === GroupCallState.LocalCallFeedInitialized && (
<>
{localVideoMuted && (
<div className={styles.avatarContainer}>
<Avatar
style={{
width: avatarSize,
height: avatarSize,
borderRadius: avatarSize,
fontSize: Math.round(avatarSize / 2),
}}
src={avatarUrl && getAvatarUrl(client, avatarUrl, 96)}
fallback={displayName.slice(0, 1).toUpperCase()}
/>
</div>
)}
<Button
className={styles.joinCallButton}
disabled={state !== GroupCallState.LocalCallFeedInitialized}

View File

@@ -53,15 +53,28 @@ limitations under the License.
border-radius: 24px;
overflow: hidden;
background-color: var(--bgColor3);
margin: 40px 20px 20px 20px;
margin: 20px;
}
.preview video {
width: 100%;
width: calc(100% + 1px);
height: 100%;
object-fit: contain;
background-color: black;
transform: scaleX(-1);
/* transform scale doesn't perfectly match width, so make -1.01 border issues */
transform: scaleX(-1.01);
}
.avatarContainer {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 66px;
display: flex;
justify-content: center;
align-items: center;
background-color: var(--bgColor3);
}
.webcamPermissions {
@@ -108,3 +121,9 @@ limitations under the License.
.previewButtons > :last-child {
margin-right: 0px;
}
@media (min-width: 800px) {
.preview {
margin-top: 40px;
}
}

View File

@@ -38,15 +38,11 @@ export function OverflowMenu({
return (
<>
<PopoverMenuTrigger disableOnState>
<TooltipTrigger>
<TooltipTrigger placement="top">
<Button variant="toolbar">
<OverflowIcon />
</Button>
{(props) => (
<Tooltip position="top" {...props}>
More
</Tooltip>
)}
{() => "More"}
</TooltipTrigger>
{(props) => (
<Menu {...props} label="More menu" onAction={onAction}>

View File

@@ -49,7 +49,7 @@ export function RoomAuthView() {
<HeaderLogo />
</LeftNav>
<RightNav>
<UserMenuContainer disableLogout />
<UserMenuContainer preventNavigation />
</RightNav>
</Header>
<div className={styles.container}>
@@ -60,15 +60,15 @@ export function RoomAuthView() {
<InputField
id="userName"
name="userName"
label="Your name"
placeholder="Your name"
label="Username"
placeholder="Username"
type="text"
required
autoComplete="off"
/>
</FieldRow>
<Caption>
By clicking "Go", you agree to our{" "}
By clicking "Join call now", you agree to our{" "}
<Link href={privacyPolicyUrl}>Terms and conditions</Link>
</Caption>
{error && (