Merge branch 'main' into connectionLostBanner

This commit is contained in:
Timo K
2023-06-12 10:05:17 +02:00
46 changed files with 1112 additions and 225 deletions

View File

@@ -79,6 +79,11 @@ export interface UrlParams {
* The Posthog analytics ID. It is only available if the user has given consent for sharing telemetry in element web.
*/
analyticsID: string | null;
/**
* Whether the app is allowed to use fallback STUN servers for ICE in case the
* user's homeserver doesn't provide any.
*/
allowIceFallback: boolean;
}
/**
@@ -135,6 +140,7 @@ export const getUrlParams = (
fonts: getAllParams("font"),
fontScale: Number.isNaN(fontScale) ? null : fontScale,
analyticsID: getParam("analyticsID"),
allowIceFallback: hasParam("allowIceFallback"),
};
};

View File

@@ -29,6 +29,7 @@ import {
MuteCameraTracker,
MuteMicrophoneTracker,
UndecryptableToDeviceEventTracker,
QualitySurveyEventTracker,
} from "./PosthogEvents";
import { Config } from "../config/Config";
import { getUrlParams } from "../UrlParams";
@@ -431,4 +432,5 @@ export class PosthogAnalytics {
public eventMuteMicrophone = new MuteMicrophoneTracker();
public eventMuteCamera = new MuteCameraTracker();
public eventUndecryptableToDevice = new UndecryptableToDeviceEventTracker();
public eventQualitySurvey = new QualitySurveyEventTracker();
}

View File

@@ -163,3 +163,21 @@ export class UndecryptableToDeviceEventTracker {
});
}
}
interface QualitySurveyEvent {
eventName: "QualitySurvey";
callId: string;
feedbackText: string;
stars: number;
}
export class QualitySurveyEventTracker {
track(callId: string, feedbackText: string, stars: number) {
PosthogAnalytics.instance.trackEvent<QualitySurveyEvent>({
eventName: "QualitySurvey",
callId,
feedbackText,
stars,
});
}
}

View File

@@ -126,6 +126,11 @@ export class PosthogSpanProcessor implements SpanProcessor {
const maxPacketLoss = `${attributes["matrix.stats.summary.maxPacketLoss"]}`;
const peerConnections = `${attributes["matrix.stats.summary.peerConnections"]}`;
const percentageConcealedAudio = `${attributes["matrix.stats.summary.percentageConcealedAudio"]}`;
const opponentUsersInCall = `${attributes["matrix.stats.summary.opponentUsersInCall"]}`;
const opponentDevicesInCall = `${attributes["matrix.stats.summary.opponentDevicesInCall"]}`;
const diffDevicesToPeerConnections = `${attributes["matrix.stats.summary.diffDevicesToPeerConnections"]}`;
const ratioPeerConnectionToDevices = `${attributes["matrix.stats.summary.ratioPeerConnectionToDevices"]}`;
PosthogAnalytics.instance.trackEvent(
{
eventName: "MediaReceived",
@@ -137,6 +142,10 @@ export class PosthogSpanProcessor implements SpanProcessor {
maxPacketLoss: maxPacketLoss,
peerConnections: peerConnections,
percentageConcealedAudio: percentageConcealedAudio,
opponentUsersInCall: opponentUsersInCall,
opponentDevicesInCall: opponentDevicesInCall,
diffDevicesToPeerConnections: diffDevicesToPeerConnections,
ratioPeerConnectionToDevices: ratioPeerConnectionToDevices,
},
// Send instantly because the window might be closing
{ send_instantly: true }

View File

@@ -0,0 +1,3 @@
<svg width="28" height="26" viewBox="0 0 28 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 21.0267L22.24 26.0001L20.0533 16.6267L27.3333 10.3201L17.7466 9.50675L14 0.666748L10.2533 9.50675L0.666626 10.3201L7.94663 16.6267L5.75996 26.0001L14 21.0267Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 290 B

View File

@@ -0,0 +1,4 @@
<svg width="28" height="26" viewBox="0 0 28 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<path id="Vector" d="M14 7.50675L15.2933 10.5601L15.92 12.0401L17.52 12.1734L20.8133 12.4534L18.3066 14.6267L17.0933 15.6801L17.4533 17.2534L18.2 20.4667L15.3733 18.7601L14 17.9067L12.6266 18.7334L9.79996 20.4401L10.5466 17.2267L10.9066 15.6534L9.69329 14.6001L7.18663 12.4267L10.48 12.1467L12.08 12.0134L12.7066 10.5334L14 7.50675M14 0.666748L10.2533 9.50675L0.666626 10.3201L7.94663 16.6267L5.75996 26.0001L14 21.0267L22.24 26.0001L20.0533 16.6267L27.3333 10.3201L17.7466 9.50675L14 0.666748Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 620 B

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.
*/
.feedback textarea {
height: 75px;
border-radius: 8px;
}
.feedback {
border-radius: 8px;
}

View File

@@ -0,0 +1,41 @@
/*
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.
*/
.starIcon {
cursor: pointer;
}
.starRating {
display: flex;
justify-content: center;
flex: 1;
}
.inputContainer {
display: inline-block;
}
.hideElement {
border: 0;
clip-path: content-box;
height: 0px;
width: 0px;
margin: -1px;
overflow: hidden;
padding: 0;
width: 1px;
display: inline-block;
}

View File

@@ -0,0 +1,85 @@
/*
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 React, { useState } from "react";
import { useTranslation } from "react-i18next";
import styles from "./StarRatingInput.module.css";
import { ReactComponent as StarSelected } from "../icons/StarSelected.svg";
import { ReactComponent as StarUnselected } from "../icons/StarUnselected.svg";
interface Props {
starCount: number;
onChange: (stars: number) => void;
required?: boolean;
}
export function StarRatingInput({
starCount,
onChange,
required,
}: Props): JSX.Element {
const [rating, setRating] = useState(0);
const [hover, setHover] = useState(0);
const { t } = useTranslation();
return (
<div className={styles.starRating}>
{[...Array(starCount)].map((_star, index) => {
index += 1;
return (
<div
className={styles.inputContainer}
onMouseEnter={() => setHover(index)}
onMouseLeave={() => setHover(rating)}
key={index}
>
<input
className={styles.hideElement}
type="radio"
id={"starInput" + String(index)}
value={String(index) + "Star"}
name="star rating"
onChange={(_ev) => {
setRating(index);
onChange(index);
}}
required
/>
<label
className={styles.hideElement}
id={"starInvisibleLabel" + String(index)}
htmlFor={"starInput" + String(index)}
>
{t("{{count}} stars", {
count: index,
})}
</label>
<label
className={styles.starIcon}
id={"starIcon" + String(index)}
htmlFor={"starInput" + String(index)}
>
{index <= (hover || rating) ? (
<StarSelected />
) : (
<StarUnselected />
)}
</label>
</div>
);
})}
</div>
);
}

View File

@@ -17,13 +17,33 @@ limitations under the License.
import { Span } from "@opentelemetry/api";
import { MatrixCall } from "matrix-js-sdk";
import { CallEvent } from "matrix-js-sdk/src/webrtc/call";
import {
TransceiverStats,
CallFeedStats,
} from "matrix-js-sdk/src/webrtc/stats/statsReport";
import { ObjectFlattener } from "./ObjectFlattener";
import { ElementCallOpenTelemetry } from "./otel";
import { OTelCallAbstractMediaStreamSpan } from "./OTelCallAbstractMediaStreamSpan";
import { OTelCallTransceiverMediaStreamSpan } from "./OTelCallTransceiverMediaStreamSpan";
import { OTelCallFeedMediaStreamSpan } from "./OTelCallFeedMediaStreamSpan";
type StreamId = string;
type MID = string;
/**
* Tracks an individual call within a group call, either to a full-mesh peer or a focus
*/
export class OTelCall {
private readonly trackFeedSpan = new Map<
StreamId,
OTelCallAbstractMediaStreamSpan
>();
private readonly trackTransceiverSpan = new Map<
MID,
OTelCallAbstractMediaStreamSpan
>();
constructor(
public userId: string,
public deviceId: string,
@@ -116,4 +136,62 @@ export class OTelCall {
this.span.addEvent("matrix.call.iceCandidateError", flatObject);
};
public onCallFeedStats(callFeeds: CallFeedStats[]): void {
let prvFeeds: StreamId[] = [...this.trackFeedSpan.keys()];
callFeeds.forEach((feed) => {
if (!this.trackFeedSpan.has(feed.stream)) {
this.trackFeedSpan.set(
feed.stream,
new OTelCallFeedMediaStreamSpan(
ElementCallOpenTelemetry.instance,
this.span,
feed
)
);
}
this.trackFeedSpan.get(feed.stream)?.update(feed);
prvFeeds = prvFeeds.filter((prvStreamId) => prvStreamId !== feed.stream);
});
prvFeeds.forEach((prvStreamId) => {
this.trackFeedSpan.get(prvStreamId)?.end();
this.trackFeedSpan.delete(prvStreamId);
});
}
public onTransceiverStats(transceiverStats: TransceiverStats[]): void {
let prvTransSpan: MID[] = [...this.trackTransceiverSpan.keys()];
transceiverStats.forEach((transStats) => {
if (!this.trackTransceiverSpan.has(transStats.mid)) {
this.trackTransceiverSpan.set(
transStats.mid,
new OTelCallTransceiverMediaStreamSpan(
ElementCallOpenTelemetry.instance,
this.span,
transStats
)
);
}
this.trackTransceiverSpan.get(transStats.mid)?.update(transStats);
prvTransSpan = prvTransSpan.filter(
(prvStreamId) => prvStreamId !== transStats.mid
);
});
prvTransSpan.forEach((prvMID) => {
this.trackTransceiverSpan.get(prvMID)?.end();
this.trackTransceiverSpan.delete(prvMID);
});
}
public end(): void {
this.trackFeedSpan.forEach((feedSpan) => feedSpan.end());
this.trackTransceiverSpan.forEach((transceiverSpan) =>
transceiverSpan.end()
);
this.span.end();
}
}

View File

@@ -0,0 +1,62 @@
import opentelemetry, { Span } from "@opentelemetry/api";
import { TrackStats } from "matrix-js-sdk/src/webrtc/stats/statsReport";
import { ElementCallOpenTelemetry } from "./otel";
import { OTelCallMediaStreamTrackSpan } from "./OTelCallMediaStreamTrackSpan";
type TrackId = string;
export abstract class OTelCallAbstractMediaStreamSpan {
protected readonly trackSpans = new Map<
TrackId,
OTelCallMediaStreamTrackSpan
>();
public readonly span;
public constructor(
readonly oTel: ElementCallOpenTelemetry,
readonly callSpan: Span,
protected readonly type: string
) {
const ctx = opentelemetry.trace.setSpan(
opentelemetry.context.active(),
callSpan
);
const options = {
links: [
{
context: callSpan.spanContext(),
},
],
};
this.span = oTel.tracer.startSpan(this.type, options, ctx);
}
protected upsertTrackSpans(tracks: TrackStats[]) {
let prvTracks: TrackId[] = [...this.trackSpans.keys()];
tracks.forEach((t) => {
if (!this.trackSpans.has(t.id)) {
this.trackSpans.set(
t.id,
new OTelCallMediaStreamTrackSpan(this.oTel, this.span, t)
);
}
this.trackSpans.get(t.id)?.update(t);
prvTracks = prvTracks.filter((prvTrackId) => prvTrackId !== t.id);
});
prvTracks.forEach((prvTrackId) => {
this.trackSpans.get(prvTrackId)?.end();
this.trackSpans.delete(prvTrackId);
});
}
public abstract update(data: Object): void;
public end(): void {
this.trackSpans.forEach((tSpan) => {
tSpan.end();
});
this.span.end();
}
}

View File

@@ -0,0 +1,57 @@
import { Span } from "@opentelemetry/api";
import {
CallFeedStats,
TrackStats,
} from "matrix-js-sdk/src/webrtc/stats/statsReport";
import { ElementCallOpenTelemetry } from "./otel";
import { OTelCallAbstractMediaStreamSpan } from "./OTelCallAbstractMediaStreamSpan";
export class OTelCallFeedMediaStreamSpan extends OTelCallAbstractMediaStreamSpan {
private readonly prev: { isAudioMuted: boolean; isVideoMuted: boolean };
constructor(
readonly oTel: ElementCallOpenTelemetry,
readonly callSpan: Span,
callFeed: CallFeedStats
) {
const postFix =
callFeed.type === "local" && callFeed.prefix === "from-call-feed"
? "(clone)"
: "";
super(oTel, callSpan, `matrix.call.feed.${callFeed.type}${postFix}`);
this.span.setAttribute("feed.streamId", callFeed.stream);
this.span.setAttribute("feed.type", callFeed.type);
this.span.setAttribute("feed.readFrom", callFeed.prefix);
this.span.setAttribute("feed.purpose", callFeed.purpose);
this.prev = {
isAudioMuted: callFeed.isAudioMuted,
isVideoMuted: callFeed.isVideoMuted,
};
this.span.addEvent("matrix.call.feed.initState", this.prev);
}
public update(callFeed: CallFeedStats): void {
if (this.prev.isAudioMuted !== callFeed.isAudioMuted) {
this.span.addEvent("matrix.call.feed.audioMuted", {
isAudioMuted: callFeed.isAudioMuted,
});
this.prev.isAudioMuted = callFeed.isAudioMuted;
}
if (this.prev.isVideoMuted !== callFeed.isVideoMuted) {
this.span.addEvent("matrix.call.feed.isVideoMuted", {
isVideoMuted: callFeed.isVideoMuted,
});
this.prev.isVideoMuted = callFeed.isVideoMuted;
}
const trackStats: TrackStats[] = [];
if (callFeed.video) {
trackStats.push(callFeed.video);
}
if (callFeed.audio) {
trackStats.push(callFeed.audio);
}
this.upsertTrackSpans(trackStats);
}
}

View File

@@ -0,0 +1,62 @@
import { TrackStats } from "matrix-js-sdk/src/webrtc/stats/statsReport";
import opentelemetry, { Span } from "@opentelemetry/api";
import { ElementCallOpenTelemetry } from "./otel";
export class OTelCallMediaStreamTrackSpan {
private readonly span: Span;
private prev: TrackStats;
public constructor(
readonly oTel: ElementCallOpenTelemetry,
readonly streamSpan: Span,
data: TrackStats
) {
const ctx = opentelemetry.trace.setSpan(
opentelemetry.context.active(),
streamSpan
);
const options = {
links: [
{
context: streamSpan.spanContext(),
},
],
};
const type = `matrix.call.track.${data.label}.${data.kind}`;
this.span = oTel.tracer.startSpan(type, options, ctx);
this.span.setAttribute("track.trackId", data.id);
this.span.setAttribute("track.kind", data.kind);
this.span.setAttribute("track.constrainDeviceId", data.constrainDeviceId);
this.span.setAttribute("track.settingDeviceId", data.settingDeviceId);
this.span.setAttribute("track.label", data.label);
this.span.addEvent("matrix.call.track.initState", {
readyState: data.readyState,
muted: data.muted,
enabled: data.enabled,
});
this.prev = data;
}
public update(data: TrackStats): void {
if (this.prev.muted !== data.muted) {
this.span.addEvent("matrix.call.track.muted", { muted: data.muted });
}
if (this.prev.enabled !== data.enabled) {
this.span.addEvent("matrix.call.track.enabled", {
enabled: data.enabled,
});
}
if (this.prev.readyState !== data.readyState) {
this.span.addEvent("matrix.call.track.readyState", {
readyState: data.readyState,
});
}
this.prev = data;
}
public end(): void {
this.span.end();
}
}

View File

@@ -0,0 +1,54 @@
import { Span } from "@opentelemetry/api";
import {
TrackStats,
TransceiverStats,
} from "matrix-js-sdk/src/webrtc/stats/statsReport";
import { ElementCallOpenTelemetry } from "./otel";
import { OTelCallAbstractMediaStreamSpan } from "./OTelCallAbstractMediaStreamSpan";
export class OTelCallTransceiverMediaStreamSpan extends OTelCallAbstractMediaStreamSpan {
private readonly prev: {
direction: string;
currentDirection: string;
};
constructor(
readonly oTel: ElementCallOpenTelemetry,
readonly callSpan: Span,
stats: TransceiverStats
) {
super(oTel, callSpan, `matrix.call.transceiver.${stats.mid}`);
this.span.setAttribute("transceiver.mid", stats.mid);
this.prev = {
direction: stats.direction,
currentDirection: stats.currentDirection,
};
this.span.addEvent("matrix.call.transceiver.initState", this.prev);
}
public update(stats: TransceiverStats): void {
if (this.prev.currentDirection !== stats.currentDirection) {
this.span.addEvent("matrix.call.transceiver.currentDirection", {
currentDirection: stats.currentDirection,
});
this.prev.currentDirection = stats.currentDirection;
}
if (this.prev.direction !== stats.direction) {
this.span.addEvent("matrix.call.transceiver.direction", {
direction: stats.direction,
});
this.prev.direction = stats.direction;
}
const trackStats: TrackStats[] = [];
if (stats.sender) {
trackStats.push(stats.sender);
}
if (stats.receiver) {
trackStats.push(stats.receiver);
}
this.upsertTrackSpans(trackStats);
}
}

View File

@@ -38,6 +38,7 @@ import {
ConnectionStatsReport,
ByteSentStatsReport,
SummaryStatsReport,
CallFeedReport,
} from "matrix-js-sdk/src/webrtc/stats/statsReport";
import { setSpan } from "@opentelemetry/api/build/esm/trace/context-utils";
@@ -174,7 +175,7 @@ export class OTelGroupCallMembership {
userCalls.get(callTrackingInfo.deviceId).callId !==
callTrackingInfo.call.callId
) {
callTrackingInfo.span.end();
callTrackingInfo.end();
this.callsByCallId.delete(callTrackingInfo.call.callId);
}
}
@@ -330,25 +331,102 @@ export class OTelGroupCallMembership {
});
}
public onCallFeedStatsReport(report: GroupCallStatsReport<CallFeedReport>) {
if (!ElementCallOpenTelemetry.instance) return;
let call: OTelCall | undefined;
const callId = report.report?.callId;
if (callId) {
call = this.callsByCallId.get(callId);
}
if (!call) {
this.callMembershipSpan?.addEvent(
OTelStatsReportType.CallFeedReport + "_unknown_callId",
{
"call.callId": callId,
"call.opponentMemberId": report.report?.opponentMemberId
? report.report?.opponentMemberId
: "unknown",
}
);
logger.error(
`Received ${OTelStatsReportType.CallFeedReport} with unknown call ID: ${callId}`
);
return;
} else {
call.onCallFeedStats(report.report.callFeeds);
call.onTransceiverStats(report.report.transceiver);
}
}
public onConnectionStatsReport(
statsReport: GroupCallStatsReport<ConnectionStatsReport>
) {
if (!ElementCallOpenTelemetry.instance) return;
const type = OTelStatsReportType.ConnectionReport;
const data =
ObjectFlattener.flattenConnectionStatsReportObject(statsReport);
this.buildStatsEventSpan({ type, data });
this.buildCallStatsSpan(
OTelStatsReportType.ConnectionReport,
statsReport.report
);
}
public onByteSentStatsReport(
statsReport: GroupCallStatsReport<ByteSentStatsReport>
) {
if (!ElementCallOpenTelemetry.instance) return;
this.buildCallStatsSpan(
OTelStatsReportType.ByteSentReport,
statsReport.report
);
}
const type = OTelStatsReportType.ByteSentReport;
const data = ObjectFlattener.flattenByteSentStatsReportObject(statsReport);
this.buildStatsEventSpan({ type, data });
public buildCallStatsSpan(
type: OTelStatsReportType,
report: ByteSentStatsReport | ConnectionStatsReport
): void {
if (!ElementCallOpenTelemetry.instance) return;
let call: OTelCall | undefined;
const callId = report?.callId;
if (callId) {
call = this.callsByCallId.get(callId);
}
if (!call) {
this.callMembershipSpan?.addEvent(type + "_unknown_callid", {
"call.callId": callId,
"call.opponentMemberId": report.opponentMemberId
? report.opponentMemberId
: "unknown",
});
logger.error(`Received ${type} with unknown call ID: ${callId}`);
return;
}
const data = ObjectFlattener.flattenReportObject(type, report);
const ctx = opentelemetry.trace.setSpan(
opentelemetry.context.active(),
call.span
);
const options = {
links: [
{
context: call.span.spanContext(),
},
],
};
const span = ElementCallOpenTelemetry.instance.tracer.startSpan(
type,
options,
ctx
);
span.setAttribute("matrix.callId", callId);
span.setAttribute(
"matrix.opponentMemberId",
report.opponentMemberId ? report.opponentMemberId : "unknown"
);
span.addEvent("matrix.call.connection_stats_event", data);
span.end();
}
public onSummaryStatsReport(
@@ -381,45 +459,6 @@ export class OTelGroupCallMembership {
span.end();
}
}
private buildStatsEventSpan(event: OTelStatsReportEvent): void {
// @ TODO: fix this - Because on multiple calls we receive multiple stats report spans.
// This could be break if stats arrived in same time from different call objects.
if (this.statsReportSpan.span === undefined && this.callMembershipSpan) {
const ctx = setSpan(
opentelemetry.context.active(),
this.callMembershipSpan
);
this.statsReportSpan.span =
ElementCallOpenTelemetry.instance?.tracer.startSpan(
"matrix.groupCallMembership.statsReport",
undefined,
ctx
);
if (this.statsReportSpan.span === undefined) {
return;
}
this.statsReportSpan.span.setAttribute(
"matrix.confId",
this.groupCall.groupCallId
);
this.statsReportSpan.span.setAttribute("matrix.userId", this.myUserId);
this.statsReportSpan.span.setAttribute(
"matrix.displayName",
this.myMember ? this.myMember.name : "unknown-name"
);
this.statsReportSpan.span.addEvent(event.type, event.data);
this.statsReportSpan.stats.push(event);
} else if (
this.statsReportSpan.span !== undefined &&
this.callMembershipSpan
) {
this.statsReportSpan.span.addEvent(event.type, event.data);
this.statsReportSpan.span.end();
this.statsReportSpan = { span: undefined, stats: [] };
}
}
}
interface OTelStatsReportEvent {
@@ -428,7 +467,8 @@ interface OTelStatsReportEvent {
}
enum OTelStatsReportType {
ConnectionReport = "matrix.stats.connection",
ByteSentReport = "matrix.stats.byteSent",
ConnectionReport = "matrix.call.stats.connection",
ByteSentReport = "matrix.call.stats.byteSent",
SummaryReport = "matrix.stats.summary",
CallFeedReport = "matrix.stats.call_feed",
}

View File

@@ -23,16 +23,12 @@ import {
} from "matrix-js-sdk/src/webrtc/stats/statsReport";
export class ObjectFlattener {
public static flattenConnectionStatsReportObject(
statsReport: GroupCallStatsReport<ConnectionStatsReport>
public static flattenReportObject(
prefix: string,
report: ConnectionStatsReport | ByteSentStatsReport
): Attributes {
const flatObject = {};
ObjectFlattener.flattenObjectRecursive(
statsReport.report,
flatObject,
"matrix.stats.conn.",
0
);
ObjectFlattener.flattenObjectRecursive(report, flatObject, `${prefix}.`, 0);
return flatObject;
}

View File

@@ -17,20 +17,31 @@ limitations under the License.
.headline {
text-align: center;
margin-bottom: 60px;
white-space: pre;
}
.callEndedContent {
text-align: center;
max-width: 360px;
max-width: 450px;
}
.callEndedContent p {
font-size: var(--font-size-subtitle);
}
.callEndedContent h3 {
margin-bottom: 32px;
}
.callEndedButton {
margin-top: 54px;
margin-left: 30px;
margin-right: 30px !important;
}
.submitButton {
width: 100%;
margin-top: 54px;
margin-left: 30px;
margin-right: 30px !important;
}
.container {

View File

@@ -14,19 +14,130 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import React, { FormEventHandler, useCallback, useState } from "react";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { Trans, useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import styles from "./CallEndedView.module.css";
import { LinkButton } from "../button";
import feedbackStyle from "../input/FeedbackInput.module.css";
import { Button, LinkButton } from "../button";
import { useProfile } from "../profile/useProfile";
import { Subtitle, Body, Link, Headline } from "../typography/Typography";
import { Body, Link, Headline } from "../typography/Typography";
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { FieldRow, InputField } from "../input/Input";
import { StarRatingInput } from "../input/StarRatingInput";
export function CallEndedView({ client }: { client: MatrixClient }) {
export function CallEndedView({
client,
isPasswordlessUser,
endedCallId,
}: {
client: MatrixClient;
isPasswordlessUser: boolean;
endedCallId: string;
}) {
const { t } = useTranslation();
const history = useHistory();
const { displayName } = useProfile(client);
const [surveySubmitted, setSurverySubmitted] = useState(false);
const [starRating, setStarRating] = useState(0);
const [submitting, setSubmitting] = useState(false);
const [submitDone, setSubmitDone] = useState(false);
const submitSurvery: FormEventHandler<HTMLFormElement> = useCallback(
(e) => {
e.preventDefault();
const data = new FormData(e.target as HTMLFormElement);
const feedbackText = data.get("feedbackText") as string;
PosthogAnalytics.instance.eventQualitySurvey.track(
endedCallId,
feedbackText,
starRating
);
setSubmitting(true);
setTimeout(() => {
setSubmitDone(true);
setTimeout(() => {
if (isPasswordlessUser) {
// setting this renders the callEndedView with the invitation to create an account
setSurverySubmitted(true);
} else {
// if the user already has an account immediately go back to the home screen
history.push("/");
}
}, 1000);
}, 1000);
},
[endedCallId, history, isPasswordlessUser, starRating]
);
const createAccountDialog = isPasswordlessUser && (
<div className={styles.callEndedContent}>
<Trans>
<p>Why not finish by setting up a password to keep your account?</p>
<p>
You'll be able to keep your name and set an avatar for use on future
calls
</p>
</Trans>
<LinkButton
className={styles.callEndedButton}
size="lg"
variant="default"
to="/register"
>
{t("Create account")}
</LinkButton>
</div>
);
const qualitySurveyDialog = (
<div className={styles.callEndedContent}>
<Trans>
<p>
We'd love to hear your feedback so we can improve your experience.
</p>
</Trans>
<form onSubmit={submitSurvery}>
<FieldRow>
<StarRatingInput starCount={5} onChange={setStarRating} required />
</FieldRow>
<FieldRow>
<InputField
className={feedbackStyle.feedback}
id="feedbackText"
name="feedbackText"
label={t("Your feedback")}
placeholder={t("Your feedback")}
type="textarea"
required
/>
</FieldRow>{" "}
<FieldRow>
{submitDone ? (
<Trans>
<p>Thanks for your feedback!</p>
</Trans>
) : (
<Button
type="submit"
className={styles.submitButton}
size="lg"
variant="default"
data-testid="home_go"
>
{submitting ? t("Submitting…") : t("Submit")}
</Button>
)}
</FieldRow>
</form>
</div>
);
return (
<>
@@ -39,27 +150,19 @@ export function CallEndedView({ client }: { client: MatrixClient }) {
<div className={styles.container}>
<main className={styles.main}>
<Headline className={styles.headline}>
{t("{{displayName}}, your call is now ended", { displayName })}
{surveySubmitted
? t("{{displayName}}, your call has ended.", {
displayName,
})
: t("{{displayName}}, your call has ended.", {
displayName,
}) +
"\n" +
t("How did it go?")}
</Headline>
<div className={styles.callEndedContent}>
<Trans>
<Subtitle>
Why not finish by setting up a password to keep your account?
</Subtitle>
<Subtitle>
You'll be able to keep your name and set an avatar for use on
future calls
</Subtitle>
</Trans>
<LinkButton
className={styles.callEndedButton}
size="lg"
variant="default"
to="/register"
>
{t("Create account")}
</LinkButton>
</div>
{!surveySubmitted && PosthogAnalytics.instance.isEnabled()
? qualitySurveyDialog
: createAccountDialog}
</main>
<Body className={styles.footer}>
<Link color="primary" to="/">

View File

@@ -203,7 +203,11 @@ export function GroupCallView({
widget.api.transport.send(ElementWidgetActions.HangupCall, {});
}
if (!isPasswordlessUser && !isEmbedded) {
if (
!isPasswordlessUser &&
!isEmbedded &&
!PosthogAnalytics.instance.isEnabled()
) {
history.push("/");
}
}, [groupCall, leave, isPasswordlessUser, isEmbedded, history]);
@@ -268,8 +272,23 @@ export function GroupCallView({
);
}
} else if (left) {
if (isPasswordlessUser) {
return <CallEndedView client={client} />;
// The call ended view is shown for two reasons: prompting guests to create
// an account, and prompting users that have opted into analytics to provide
// feedback. We don't show a feedback prompt to widget users however (at
// least for now), because we don't yet have designs that would allow widget
// users to dismiss the feedback prompt and close the call window without
// submitting anything.
if (
isPasswordlessUser ||
(PosthogAnalytics.instance.isEnabled() && !isEmbedded)
) {
return (
<CallEndedView
endedCallId={groupCall.groupCallId}
client={client}
isPasswordlessUser={isPasswordlessUser}
/>
);
} else {
// If the user is a regular user, we'll have sent them back to the homepage,
// so just sit here & do nothing: otherwise we would (briefly) mount the

View File

@@ -34,6 +34,7 @@ import {
ByteSentStatsReport,
ConnectionStatsReport,
SummaryStatsReport,
CallFeedReport,
} from "matrix-js-sdk/src/webrtc/stats/statsReport";
import { usePageUnload } from "./usePageUnload";
@@ -64,7 +65,7 @@ export interface UseGroupCallReturnType {
localVideoMuted: boolean;
error: TranslatedError | null;
initLocalCallFeed: () => void;
enter: () => void;
enter: () => Promise<void>;
leave: () => void;
toggleLocalVideoMuted: () => void;
toggleMicrophoneMuted: () => void;
@@ -363,6 +364,12 @@ export function useGroupCall(
groupCallOTelMembership?.onSummaryStatsReport(report);
}
function onCallFeedStatsReport(
report: GroupCallStatsReport<CallFeedReport>
): void {
groupCallOTelMembership?.onCallFeedStatsReport(report);
}
groupCall.on(GroupCallEvent.GroupCallStateChanged, onGroupCallStateChanged);
groupCall.on(GroupCallEvent.UserMediaFeedsChanged, onUserMediaFeedsChanged);
groupCall.on(
@@ -387,6 +394,11 @@ export function useGroupCall(
onByteSentStatsReport
);
groupCall.on(GroupCallStatsReportEvent.SummaryStats, onSummaryStatsReport);
groupCall.on(
GroupCallStatsReportEvent.CallFeedStats,
onCallFeedStatsReport
);
groupCall.room.currentState.on(
RoomStateEvent.Update,
checkForParallelCalls
@@ -450,6 +462,10 @@ export function useGroupCall(
GroupCallStatsReportEvent.SummaryStats,
onSummaryStatsReport
);
groupCall.removeListener(
GroupCallStatsReportEvent.CallFeedStats,
onCallFeedStatsReport
);
groupCall.room.currentState.off(
RoomStateEvent.Update,
checkForParallelCalls
@@ -467,7 +483,7 @@ export function useGroupCall(
[groupCall]
);
const enter = useCallback(() => {
const enter = useCallback(async () => {
if (
groupCall.state !== GroupCallState.LocalCallFeedUninitialized &&
groupCall.state !== GroupCallState.LocalCallFeedInitialized
@@ -482,7 +498,7 @@ export function useGroupCall(
// have started tracking by the time calls start getting created.
groupCallOTelMembership?.onJoinCall();
groupCall.enter().catch((error) => {
await groupCall.enter().catch((error) => {
console.error(error);
updateState({ error });
});

View File

@@ -23,6 +23,7 @@ import { FieldRow, InputField, ErrorMessage } from "../input/Input";
import { useSubmitRageshake, useRageshakeRequest } from "./submit-rageshake";
import { Body } from "../typography/Typography";
import styles from "../input/SelectInput.module.css";
import feedbackStyles from "../input/FeedbackInput.module.css";
interface Props {
roomId?: string;
@@ -68,9 +69,11 @@ export function FeedbackSettingsTab({ roomId }: Props) {
<form onSubmit={onSubmitFeedback}>
<FieldRow>
<InputField
className={feedbackStyles.feedback}
id="description"
name="description"
label={t("Your feedback")}
placeholder={t("Your feedback")}
type="textarea"
disabled={sending || sent}
/>

View File

@@ -266,11 +266,16 @@ export const NewVideoGrid: FC<Props> = ({
},
leave: { opacity: 0, scale: 0, immediate: disableAnimations },
config: { mass: 0.7, tension: 252, friction: 25 },
}),
[tiles, disableAnimations]
})
// react-spring's types are bugged and can't infer the spring type
) as unknown as [TransitionFn<Tile, TileSpring>, SpringRef<TileSpring>];
// Because we're using react-spring in imperative mode, we're responsible for
// firing animations manually whenever the tiles array updates
useEffect(() => {
springRef.start();
}, [tiles, springRef]);
const animateDraggedTile = (endOfGesture: boolean) => {
const { tileId, tileX, tileY, cursorX, cursorY } = dragState.current!;
const tile = tiles.find((t) => t.item.id === tileId)!;

View File

@@ -101,7 +101,14 @@ export const widget: WidgetHelpers | null = (() => {
// We need to do this now rather than later because it has capabilities to
// request, and is responsible for starting the transport (should it be?)
const { roomId, userId, deviceId, baseUrl, e2eEnabled } = getUrlParams();
const {
roomId,
userId,
deviceId,
baseUrl,
e2eEnabled,
allowIceFallback,
} = 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");
@@ -148,6 +155,7 @@ export const widget: WidgetHelpers | null = (() => {
deviceId,
timelineSupport: true,
useE2eForGroupCall: e2eEnabled,
fallbackICEServerAllowed: allowIceFallback,
}
);
const clientPromise = client.startClient().then(() => client);