492 lines
14 KiB
TypeScript
492 lines
14 KiB
TypeScript
/*
|
|
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 * as Sentry from "@sentry/react";
|
|
import { Resizable } from "re-resizable";
|
|
import React, {
|
|
useEffect,
|
|
useState,
|
|
useReducer,
|
|
useRef,
|
|
createContext,
|
|
useContext,
|
|
} from "react";
|
|
import ReactJson, { CollapsedFieldProps } from "react-json-view";
|
|
import mermaid from "mermaid";
|
|
import { Item } from "@react-stately/collections";
|
|
import { MatrixEvent, IContent } from "matrix-js-sdk/src/models/event";
|
|
import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
|
|
import { ClientEvent, MatrixClient } from "matrix-js-sdk/src/client";
|
|
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
|
|
import { CallEvent, VoipEvent } from "matrix-js-sdk/src/webrtc/call";
|
|
|
|
import styles from "./GroupCallInspector.module.css";
|
|
import { SelectInput } from "../input/SelectInput";
|
|
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
|
|
|
interface InspectorContextState {
|
|
eventsByUserId?: { [userId: string]: SequenceDiagramMatrixEvent[] };
|
|
remoteUserIds?: string[];
|
|
localUserId?: string;
|
|
localSessionId?: string;
|
|
}
|
|
|
|
const defaultCollapsedFields = [
|
|
"org.matrix.msc3401.call",
|
|
"org.matrix.msc3401.call.member",
|
|
"calls",
|
|
"callStats",
|
|
"hangupCalls",
|
|
"toDeviceEvents",
|
|
"sentVoipEvents",
|
|
"content",
|
|
];
|
|
|
|
function shouldCollapse({ name }: CollapsedFieldProps) {
|
|
return defaultCollapsedFields.includes(name);
|
|
}
|
|
|
|
function getUserName(userId: string) {
|
|
const match = userId.match(/@([^:]+):/);
|
|
|
|
return match && match.length > 0
|
|
? match[1].replace("-", " ").replace(/\W/g, "")
|
|
: userId.replace(/\W/g, "");
|
|
}
|
|
|
|
function formatContent(type: string, content: CallEventContent) {
|
|
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 "";
|
|
}
|
|
}
|
|
|
|
const dateFormatter = new Intl.DateTimeFormat([], {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
second: "2-digit",
|
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
// @ts-ignore the linter does not know about this property of the DataTimeFormatOptions
|
|
fractionalSecondDigits: 3,
|
|
});
|
|
|
|
function formatTimestamp(timestamp: number | Date) {
|
|
return dateFormatter.format(timestamp);
|
|
}
|
|
|
|
function formatType(event: SequenceDiagramMatrixEvent): string {
|
|
if (event.content.msgtype === "m.bad.encrypted") return "Undecryptable";
|
|
return event.type;
|
|
}
|
|
|
|
function lineForEvent(event: SequenceDiagramMatrixEvent): string {
|
|
return `${getUserName(event.from)} ${
|
|
event.ignored ? "-x" : "->>"
|
|
} ${getUserName(event.to)}: ${formatTimestamp(event.timestamp)} ${formatType(
|
|
event
|
|
)} ${formatContent(event.type, event.content)}`;
|
|
}
|
|
|
|
export const InspectorContext =
|
|
createContext<
|
|
[
|
|
InspectorContextState,
|
|
React.Dispatch<React.SetStateAction<InspectorContextState>>
|
|
]
|
|
>(undefined);
|
|
|
|
export function InspectorContextProvider({
|
|
children,
|
|
}: {
|
|
children: React.ReactNode;
|
|
}) {
|
|
// We take the tuple of [currentState, setter] and stick
|
|
// it straight into the context for other things to call
|
|
// the setState method... this feels like a fairly severe
|
|
// contortion of the hooks API - is this really the best way
|
|
// to do this?
|
|
const context = useState<InspectorContextState>({});
|
|
return (
|
|
<InspectorContext.Provider value={context}>
|
|
{children}
|
|
</InspectorContext.Provider>
|
|
);
|
|
}
|
|
|
|
type CallEventContent = {
|
|
["m.calls"]: {
|
|
["m.devices"]: { session_id: string; [x: string]: unknown }[];
|
|
["m.call_id"]: string;
|
|
}[];
|
|
} & {
|
|
call_id: string;
|
|
reason: string;
|
|
sender_session_id: string;
|
|
dest_session_id: string;
|
|
} & IContent;
|
|
|
|
export type SequenceDiagramMatrixEvent = {
|
|
to: string;
|
|
from: string;
|
|
timestamp: number;
|
|
type: string;
|
|
content: CallEventContent;
|
|
ignored: boolean;
|
|
};
|
|
|
|
interface SequenceDiagramViewerProps {
|
|
localUserId: string;
|
|
remoteUserIds: string[];
|
|
selectedUserId: string;
|
|
onSelectUserId: (userId: string) => void;
|
|
events: SequenceDiagramMatrixEvent[];
|
|
}
|
|
|
|
export function SequenceDiagramViewer({
|
|
localUserId,
|
|
remoteUserIds,
|
|
selectedUserId,
|
|
onSelectUserId,
|
|
events,
|
|
}: SequenceDiagramViewerProps) {
|
|
const mermaidElRef = useRef<HTMLDivElement>();
|
|
|
|
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(lineForEvent).join("\n ") : ""}
|
|
`;
|
|
|
|
mermaid.mermaidAPI.render("mermaid", graphDefinition, (svgCode: string) => {
|
|
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: InspectorContextState,
|
|
action: {
|
|
type?: CallEvent | ClientEvent | RoomStateEvent;
|
|
event?: MatrixEvent;
|
|
rawEvent?: VoipEvent;
|
|
callStateEvent?: MatrixEvent;
|
|
memberStateEvents?: MatrixEvent[];
|
|
}
|
|
) {
|
|
switch (action.type) {
|
|
case RoomStateEvent.Events: {
|
|
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 ClientEvent.ReceivedVoipEvent: {
|
|
const event = action.event;
|
|
const eventsByUserId = { ...state.eventsByUserId };
|
|
const fromId = event.getSender();
|
|
const toId = state.localUserId;
|
|
const content = event.getContent<CallEventContent>();
|
|
|
|
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 CallEvent.SendVoipEvent: {
|
|
const event = action.rawEvent;
|
|
const eventsByUserId = { ...state.eventsByUserId };
|
|
const fromId = state.localUserId;
|
|
const toId = event.userId as string;
|
|
|
|
const remoteUserIds = eventsByUserId[toId]
|
|
? state.remoteUserIds
|
|
: [...state.remoteUserIds, toId];
|
|
|
|
eventsByUserId[toId] = [
|
|
...(eventsByUserId[toId] || []),
|
|
{
|
|
from: fromId,
|
|
to: toId,
|
|
type: event.eventType as string,
|
|
content: event.content as CallEventContent,
|
|
timestamp: Date.now(),
|
|
ignored: false,
|
|
},
|
|
];
|
|
|
|
return { ...state, eventsByUserId, remoteUserIds };
|
|
}
|
|
default:
|
|
return state;
|
|
}
|
|
}
|
|
|
|
function useGroupCallState(
|
|
client: MatrixClient,
|
|
groupCall: GroupCall,
|
|
showPollCallStats: boolean
|
|
): InspectorContextState {
|
|
const [state, dispatch] = useReducer(reducer, {
|
|
localUserId: client.getUserId(),
|
|
localSessionId: client.getSessionId(),
|
|
eventsByUserId: {},
|
|
remoteUserIds: [],
|
|
callStateEvent: null,
|
|
memberStateEvents: {},
|
|
});
|
|
|
|
useEffect(() => {
|
|
function onUpdateRoomState(event?: MatrixEvent) {
|
|
const callStateEvent = groupCall.room.currentState.getStateEvents(
|
|
"org.matrix.msc3401.call",
|
|
groupCall.groupCallId
|
|
);
|
|
|
|
const memberStateEvents = groupCall.room.currentState.getStateEvents(
|
|
"org.matrix.msc3401.call.member"
|
|
);
|
|
|
|
dispatch({
|
|
type: RoomStateEvent.Events,
|
|
event,
|
|
callStateEvent,
|
|
memberStateEvents,
|
|
});
|
|
}
|
|
|
|
function onReceivedVoipEvent(event: MatrixEvent) {
|
|
dispatch({ type: ClientEvent.ReceivedVoipEvent, event });
|
|
}
|
|
|
|
function onSendVoipEvent(event: VoipEvent) {
|
|
dispatch({ type: CallEvent.SendVoipEvent, rawEvent: event });
|
|
}
|
|
|
|
function onUndecryptableToDevice(event: MatrixEvent) {
|
|
dispatch({ type: ClientEvent.ReceivedVoipEvent, event });
|
|
|
|
Sentry.captureMessage("Undecryptable to-device Event");
|
|
PosthogAnalytics.instance.eventUndecryptableToDevice.track(
|
|
groupCall.groupCallId
|
|
);
|
|
}
|
|
|
|
client.on(RoomStateEvent.Events, onUpdateRoomState);
|
|
//groupCall.on("calls_changed", onCallsChanged);
|
|
groupCall.on(CallEvent.SendVoipEvent, onSendVoipEvent);
|
|
//client.on("state", onCallsChanged);
|
|
//client.on("hangup", onCallHangup);
|
|
client.on(ClientEvent.ReceivedVoipEvent, onReceivedVoipEvent);
|
|
client.on(ClientEvent.UndecryptableToDeviceEvent, onUndecryptableToDevice);
|
|
|
|
onUpdateRoomState();
|
|
|
|
return () => {
|
|
client.removeListener(RoomStateEvent.Events, onUpdateRoomState);
|
|
//groupCall.removeListener("calls_changed", onCallsChanged);
|
|
groupCall.removeListener(CallEvent.SendVoipEvent, onSendVoipEvent);
|
|
//client.removeListener("state", onCallsChanged);
|
|
//client.removeListener("hangup", onCallHangup);
|
|
client.removeListener(ClientEvent.ReceivedVoipEvent, onReceivedVoipEvent);
|
|
client.removeListener(
|
|
ClientEvent.UndecryptableToDeviceEvent,
|
|
onUndecryptableToDevice
|
|
);
|
|
};
|
|
}, [client, groupCall]);
|
|
|
|
return state;
|
|
}
|
|
|
|
interface GroupCallInspectorProps {
|
|
client: MatrixClient;
|
|
groupCall: GroupCall;
|
|
show: boolean;
|
|
}
|
|
|
|
export function GroupCallInspector({
|
|
client,
|
|
groupCall,
|
|
show,
|
|
}: GroupCallInspectorProps) {
|
|
const [currentTab, setCurrentTab] = useState("sequence-diagrams");
|
|
const [selectedUserId, setSelectedUserId] = useState<string>();
|
|
const state = useGroupCallState(client, groupCall, show);
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const [_, setState] = useContext(InspectorContext);
|
|
|
|
useEffect(() => {
|
|
setState(state);
|
|
}, [setState, state]);
|
|
|
|
if (!show) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<Resizable
|
|
enable={{ top: true }}
|
|
defaultSize={{ height: 200, width: undefined }}
|
|
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>
|
|
);
|
|
}
|