/* 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> ] >(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({}); return ( {children} ); } 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(); 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 (
{remoteUserIds.map((userId) => ( {userId} ))}
); } 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(); 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(); 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 (
{currentTab === "sequence-diagrams" && ( )} {currentTab === "inspector" && ( )}
); }