Merge remote-tracking branch 'origin/main' into dbkr/otel_peerconn_events

This commit is contained in:
David Baker 2023-04-05 09:35:43 +01:00
commit 23ddd73f4f
17 changed files with 182 additions and 120 deletions

View file

@ -8,7 +8,7 @@ receivers:
# This can't be '*' because opentelemetry-js uses sendBeacon which always operates # This can't be '*' because opentelemetry-js uses sendBeacon which always operates
# in 'withCredentials' mode, which browsers don't allow with an allow-origin of '*' # in 'withCredentials' mode, which browsers don't allow with an allow-origin of '*'
#- "https://pr976--element-call.netlify.app" #- "https://pr976--element-call.netlify.app"
- "https://*" - "http://*"
allowed_headers: allowed_headers:
- "*" - "*"
processors: processors:

View file

@ -8,6 +8,7 @@
"{{name}} is talking…": "{{name}} is talking…", "{{name}} is talking…": "{{name}} is talking…",
"{{names}}, {{name}}": "{{names}}, {{name}}", "{{names}}, {{name}}": "{{names}}, {{name}}",
"{{roomName}} - Walkie-talkie call": "{{roomName}} - Walkie-talkie call", "{{roomName}} - Walkie-talkie call": "{{roomName}} - Walkie-talkie call",
"<0></0><1></1>You may withdraw consent by unchecking this box. If you are currently in a call, this setting will take effect at the end of the call.": "<0></0><1></1>You may withdraw consent by unchecking this box. If you are currently in a call, this setting will take effect at the end of the call.",
"<0>Already have an account?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>": "<0>Already have an account?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>", "<0>Already have an account?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>": "<0>Already have an account?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>",
"<0>Create an account</0> Or <2>Access as a guest</2>": "<0>Create an account</0> Or <2>Access as a guest</2>", "<0>Create an account</0> Or <2>Access as a guest</2>": "<0>Create an account</0> Or <2>Access as a guest</2>",
"<0>Join call now</0><1>Or</1><2>Copy call link and join later</2>": "<0>Join call now</0><1>Or</1><2>Copy call link and join later</2>", "<0>Join call now</0><1>Or</1><2>Copy call link and join later</2>": "<0>Join call now</0><1>Or</1><2>Copy call link and join later</2>",
@ -21,7 +22,7 @@
"Avatar": "Avatar", "Avatar": "Avatar",
"By clicking \"Go\", you agree to our <2>Terms and conditions</2>": "By clicking \"Go\", you agree to our <2>Terms and conditions</2>", "By clicking \"Go\", you agree to our <2>Terms and conditions</2>": "By clicking \"Go\", you agree to our <2>Terms and conditions</2>",
"By clicking \"Join call now\", you agree to our <2>Terms and conditions</2>": "By clicking \"Join call now\", you agree to our <2>Terms and conditions</2>", "By clicking \"Join call now\", you agree to our <2>Terms and conditions</2>": "By clicking \"Join call now\", you agree to our <2>Terms and conditions</2>",
"By ticking this box you consent to the collection of anonymous data, which we use to improve your experience. You can find more information about which data we track in our ": "By ticking this box you consent to the collection of anonymous data, which we use to improve your experience. You can find more information about which data we track in our ", "By participating in this beta, you consent to the collection of anonymous data, which we use to improve the product. You can find more information about which data we track in our <2>Privacy Policy</2> and our <5>Cookie Policy</5>.": "By participating in this beta, you consent to the collection of anonymous data, which we use to improve the product. You can find more information about which data we track in our <2>Privacy Policy</2> and our <5>Cookie Policy</5>.",
"Call link copied": "Call link copied", "Call link copied": "Call link copied",
"Call type menu": "Call type menu", "Call type menu": "Call type menu",
"Camera": "Camera", "Camera": "Camera",
@ -85,7 +86,6 @@
"Press and hold spacebar to talk over {{name}}": "Press and hold spacebar to talk over {{name}}", "Press and hold spacebar to talk over {{name}}": "Press and hold spacebar to talk over {{name}}",
"Press and hold to talk": "Press and hold to talk", "Press and hold to talk": "Press and hold to talk",
"Press and hold to talk over {{name}}": "Press and hold to talk over {{name}}", "Press and hold to talk over {{name}}": "Press and hold to talk over {{name}}",
"Privacy Policy": "Privacy Policy",
"Profile": "Profile", "Profile": "Profile",
"Recaptcha dismissed": "Recaptcha dismissed", "Recaptcha dismissed": "Recaptcha dismissed",
"Recaptcha not loaded": "Recaptcha not loaded", "Recaptcha not loaded": "Recaptcha not loaded",

View file

@ -0,0 +1,14 @@
import React, { FC } from "react";
import { Trans } from "react-i18next";
import { Link } from "../typography/Typography";
export const AnalyticsNotice: FC = () => (
<Trans>
By participating in this beta, you consent to the collection of anonymous
data, which we use to improve the product. You can find more information
about which data we track in our{" "}
<Link href="https://element.io/privacy">Privacy Policy</Link> and our{" "}
<Link href="https://element.io/cookie-policy">Cookie Policy</Link>.
</Trans>
);

View file

@ -1,20 +0,0 @@
import { t } from "i18next";
import React from "react";
import { Link } from "../typography/Typography";
export const optInDescription: () => JSX.Element = () => {
return (
<>
<>
{t(
"By ticking this box you consent to the collection of anonymous data, which we use to improve your experience. You can find more information about which data we track in our "
)}
</>
<Link color="primary" href="https://element.io/privacy">
<>{t("Privacy Policy")}</>
</Link>
.
</>
);
};

View file

@ -16,12 +16,29 @@ limitations under the License.
import { SpanExporter, ReadableSpan } from "@opentelemetry/sdk-trace-base"; import { SpanExporter, ReadableSpan } from "@opentelemetry/sdk-trace-base";
import { ExportResult, ExportResultCode } from "@opentelemetry/core"; import { ExportResult, ExportResultCode } from "@opentelemetry/core";
import { logger } from "matrix-js-sdk/src/logger";
import { HrTime } from "@opentelemetry/api";
import { PosthogAnalytics } from "./PosthogAnalytics"; import { PosthogAnalytics } from "./PosthogAnalytics";
interface PrevCall {
callId: string;
hangupTs: number;
}
function hrTimeToMs(time: HrTime): number {
return time[0] * 1000 + time[1] * 0.000001;
}
/** /**
* This is implementation of {@link SpanExporter} that sends spans * The maximum time between hanging up and joining the same call that we would
* to Posthog * consider a 'rejoin' on the user's part.
*/
const maxRejoinMs = 2 * 60 * 1000; // 2 minutes
/**
* This is implementation of {@link SpanExporter} that extracts certain metrics
* from spans to send to PostHog
*/ */
export class PosthogSpanExporter implements SpanExporter { export class PosthogSpanExporter implements SpanExporter {
/** /**
@ -33,41 +50,68 @@ export class PosthogSpanExporter implements SpanExporter {
spans: ReadableSpan[], spans: ReadableSpan[],
resultCallback: (result: ExportResult) => void resultCallback: (result: ExportResult) => void
): Promise<void> { ): Promise<void> {
console.log("POSTHOGEXPORTER", spans); await Promise.all(
for (const span of spans) { spans.map((span) => {
const sendInstantly = [ switch (span.name) {
"otel_callEnded", case "matrix.groupCallMembership":
"otel_otherSentInstantlyEventName", return this.exportGroupCallMembershipSpan(span);
].includes(span.name); // TBD if there are other spans that we want to process for export to
// PostHog
for (const spanEvent of span.events) {
await PosthogAnalytics.instance.trackFromSpan(
{
eventName: spanEvent.name,
...spanEvent.attributes,
},
{
send_instantly: sendInstantly,
} }
})
); );
}
await PosthogAnalytics.instance.trackFromSpan(
{ eventName: span.name, ...span.attributes },
{
send_instantly: sendInstantly,
}
);
resultCallback({ code: ExportResultCode.SUCCESS }); resultCallback({ code: ExportResultCode.SUCCESS });
} }
private get prevCall(): PrevCall | null {
// This is stored in localStorage so we can remember the previous call
// across app restarts
const data = localStorage.getItem("matrix-prev-call");
if (data === null) return null;
try {
return JSON.parse(data);
} catch (e) {
logger.warn("Invalid prev call data", data);
return null;
} }
}
private set prevCall(data: PrevCall | null) {
localStorage.setItem("matrix-prev-call", JSON.stringify(data));
}
async exportGroupCallMembershipSpan(span: ReadableSpan): Promise<void> {
const prevCall = this.prevCall;
const newPrevCall = (this.prevCall = {
callId: span.attributes["matrix.confId"] as string,
hangupTs: hrTimeToMs(span.endTime),
});
// If the user joined the same call within a short time frame, log this as a
// rejoin. This is interesting as a call quality metric, since rejoins may
// indicate that users had to intervene to make the product work.
if (prevCall !== null && newPrevCall.callId === prevCall.callId) {
const duration = hrTimeToMs(span.startTime) - prevCall.hangupTs;
if (duration <= maxRejoinMs) {
PosthogAnalytics.instance.trackEvent(
{
eventName: "Rejoin",
callId: prevCall.callId,
rejoinDuration: duration,
},
// Send instantly because the window might be closing
{ send_instantly: true }
);
}
}
}
/** /**
* Shutdown the exporter. * Shutdown the exporter.
*/ */
shutdown(): Promise<void> { shutdown(): Promise<void> {
console.log("POSTHOGEXPORTER shutdown of otelPosthogExporter"); return Promise.resolve();
return new Promise<void>((resolve, _reject) => {
resolve();
});
} }
} }

View file

@ -385,22 +385,6 @@ export class PosthogAnalytics {
this.capture(eventName, properties, options); this.capture(eventName, properties, options);
} }
public async trackFromSpan(
{ eventName, ...properties },
options?: CaptureOptions
): Promise<void> {
if (this.identificationPromise) {
// only make calls to posthog after the identificaion is done
await this.identificationPromise;
}
if (
this.anonymity == Anonymity.Disabled ||
this.anonymity == Anonymity.Anonymous
)
return;
this.capture(eventName, properties, options);
}
public startListeningToSettingsChanges(): void { public startListeningToSettingsChanges(): void {
// Listen to account data changes from sync so we can observe changes to relevant flags and update. // Listen to account data changes from sync so we can observe changes to relevant flags and update.
// This is called - // This is called -

View file

@ -15,14 +15,14 @@ limitations under the License.
*/ */
import classNames from "classnames"; import classNames from "classnames";
import React, { FormEventHandler, forwardRef } from "react"; import React, { FormEventHandler, forwardRef, ReactNode } from "react";
import styles from "./Form.module.css"; import styles from "./Form.module.css";
interface FormProps { interface FormProps {
className: string; className: string;
onSubmit: FormEventHandler<HTMLFormElement>; onSubmit: FormEventHandler<HTMLFormElement>;
children: JSX.Element[]; children: ReactNode[];
} }
export const Form = forwardRef<HTMLFormElement, FormProps>( export const Form = forwardRef<HTMLFormElement, FormProps>(

View file

@ -37,3 +37,7 @@ limitations under the License.
.recentCallsTitle { .recentCallsTitle {
margin-bottom: 32px; margin-bottom: 32px;
} }
.notice {
color: var(--secondary-content);
}

View file

@ -39,11 +39,11 @@ import { CallList } from "./CallList";
import { UserMenuContainer } from "../UserMenuContainer"; import { UserMenuContainer } from "../UserMenuContainer";
import { useModalTriggerState } from "../Modal"; import { useModalTriggerState } from "../Modal";
import { JoinExistingCallModal } from "./JoinExistingCallModal"; import { JoinExistingCallModal } from "./JoinExistingCallModal";
import { Title } from "../typography/Typography"; import { Caption, Title } from "../typography/Typography";
import { Form } from "../form/Form"; import { Form } from "../form/Form";
import { CallType, CallTypeDropdown } from "./CallTypeDropdown"; import { CallType, CallTypeDropdown } from "./CallTypeDropdown";
import { useOptInAnalytics } from "../settings/useSetting"; import { useOptInAnalytics } from "../settings/useSetting";
import { optInDescription } from "../analytics/AnalyticsOptInDescription"; import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
interface Props { interface Props {
client: MatrixClient; client: MatrixClient;
@ -54,7 +54,7 @@ export function RegisteredView({ client, isPasswordlessUser }: Props) {
const [callType, setCallType] = useState(CallType.Video); const [callType, setCallType] = useState(CallType.Video);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error>(); const [error, setError] = useState<Error>();
const [optInAnalytics, setOptInAnalytics] = useOptInAnalytics(); const [optInAnalytics] = useOptInAnalytics();
const history = useHistory(); const history = useHistory();
const { t } = useTranslation(); const { t } = useTranslation();
const { modalState, modalProps } = useModalTriggerState(); const { modalState, modalProps } = useModalTriggerState();
@ -144,15 +144,11 @@ export function RegisteredView({ client, isPasswordlessUser }: Props) {
{loading ? t("Loading…") : t("Go")} {loading ? t("Loading…") : t("Go")}
</Button> </Button>
</FieldRow> </FieldRow>
<InputField {optInAnalytics === null && (
id="optInAnalytics" <Caption className={styles.notice}>
type="checkbox" <AnalyticsNotice />
checked={optInAnalytics} </Caption>
description={optInDescription()} )}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setOptInAnalytics(event.target.checked)
}
/>
{error && ( {error && (
<FieldRow className={styles.fieldRow}> <FieldRow className={styles.fieldRow}>
<ErrorMessage error={error} /> <ErrorMessage error={error} />

View file

@ -45,3 +45,7 @@ limitations under the License.
display: none; display: none;
} }
} }
.notice {
color: var(--secondary-content);
}

View file

@ -39,15 +39,15 @@ import { CallType, CallTypeDropdown } from "./CallTypeDropdown";
import styles from "./UnauthenticatedView.module.css"; import styles from "./UnauthenticatedView.module.css";
import commonStyles from "./common.module.css"; import commonStyles from "./common.module.css";
import { generateRandomName } from "../auth/generateRandomName"; import { generateRandomName } from "../auth/generateRandomName";
import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
import { useOptInAnalytics } from "../settings/useSetting"; import { useOptInAnalytics } from "../settings/useSetting";
import { optInDescription } from "../analytics/AnalyticsOptInDescription";
export const UnauthenticatedView: FC = () => { export const UnauthenticatedView: FC = () => {
const { setClient } = useClient(); const { setClient } = useClient();
const [callType, setCallType] = useState(CallType.Video); const [callType, setCallType] = useState(CallType.Video);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error>(); const [error, setError] = useState<Error>();
const [optInAnalytics, setOptInAnalytics] = useOptInAnalytics(); const [optInAnalytics] = useOptInAnalytics();
const [privacyPolicyUrl, recaptchaKey, register] = const [privacyPolicyUrl, recaptchaKey, register] =
useInteractiveRegistration(); useInteractiveRegistration();
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey); const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
@ -155,16 +155,12 @@ export const UnauthenticatedView: FC = () => {
autoComplete="off" autoComplete="off"
/> />
</FieldRow> </FieldRow>
<InputField {optInAnalytics === null && (
id="optInAnalytics" <Caption className={styles.notice}>
type="checkbox" <AnalyticsNotice />
checked={optInAnalytics} </Caption>
description={optInDescription()} )}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => <Caption className={styles.notice}>
setOptInAnalytics(event.target.checked)
}
/>
<Caption>
<Trans> <Trans>
By clicking "Go", you agree to our{" "} By clicking "Go", you agree to our{" "}
<Link href={privacyPolicyUrl}>Terms and conditions</Link> <Link href={privacyPolicyUrl}>Terms and conditions</Link>

View file

@ -74,7 +74,7 @@ function flattenVoipEventRecursive(
); );
for (const [k, v] of Object.entries(obj)) { for (const [k, v] of Object.entries(obj)) {
if (["string", "number"].includes(typeof v)) { if (["string", "number", "boolean"].includes(typeof v)) {
flatObject[prefix + k] = v; flatObject[prefix + k] = v;
} else if (typeof v === "object") { } else if (typeof v === "object") {
flattenVoipEventRecursive( flattenVoipEventRecursive(
@ -182,6 +182,9 @@ export class OTelGroupCallMembership {
// XXX: anonymity // XXX: anonymity
span.setAttribute("matrix.call.target.userId", userId); span.setAttribute("matrix.call.target.userId", userId);
span.setAttribute("matrix.call.target.deviceId", deviceId); span.setAttribute("matrix.call.target.deviceId", deviceId);
const displayName =
this.groupCall.room.getMember(userId)?.name ?? "unknown";
span.setAttribute("matrix.call.target.displayName", displayName);
this.callsByCallId.set( this.callsByCallId.set(
call.callId, call.callId,
new OTelCall(userId, deviceId, call, span) new OTelCall(userId, deviceId, call, span)
@ -215,13 +218,19 @@ export class OTelGroupCallMembership {
const eventType = event.eventType as string; const eventType = event.eventType as string;
if (!eventType.startsWith("m.call")) return; if (!eventType.startsWith("m.call")) return;
const callTrackingInfo = this.callsByCallId.get(call.callId);
if (!callTrackingInfo) {
logger.error(`Got call send event for unknown call ID ${call.callId}`);
return;
}
if (event.type === "toDevice") { if (event.type === "toDevice") {
this.callMembershipSpan?.addEvent( callTrackingInfo.span.addEvent(
`matrix.sendToDeviceEvent_${event.eventType}`, `matrix.sendToDeviceEvent_${event.eventType}`,
flattenVoipEvent(event) flattenVoipEvent(event)
); );
} else if (event.type === "sendEvent") { } else if (event.type === "sendEvent") {
this.callMembershipSpan?.addEvent( callTrackingInfo.span.addEvent(
`matrix.sendToRoomEvent_${event.eventType}`, `matrix.sendToRoomEvent_${event.eventType}`,
flattenVoipEvent(event) flattenVoipEvent(event)
); );

View file

@ -27,6 +27,7 @@ import { useUrlParams } from "../UrlParams";
import { MediaHandlerProvider } from "../settings/useMediaHandler"; import { MediaHandlerProvider } from "../settings/useMediaHandler";
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser"; import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
import { translatedError } from "../TranslatedError"; import { translatedError } from "../TranslatedError";
import { useOptInAnalytics } from "../settings/useSetting";
export const RoomPage: FC = () => { export const RoomPage: FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -46,9 +47,15 @@ export const RoomPage: FC = () => {
const roomIdOrAlias = roomId ?? roomAlias; const roomIdOrAlias = roomId ?? roomAlias;
if (!roomIdOrAlias) throw translatedError("No room specified", t); if (!roomIdOrAlias) throw translatedError("No room specified", t);
const [optInAnalytics, setOptInAnalytics] = useOptInAnalytics();
const { registerPasswordlessUser } = useRegisterPasswordlessUser(); const { registerPasswordlessUser } = useRegisterPasswordlessUser();
const [isRegistering, setIsRegistering] = useState(false); const [isRegistering, setIsRegistering] = useState(false);
useEffect(() => {
// During the beta, opt into analytics by default
if (optInAnalytics === null) setOptInAnalytics(true);
}, [optInAnalytics, setOptInAnalytics]);
useEffect(() => { useEffect(() => {
// If we've finished loading, are not already authed and we've been given a display name as // If we've finished loading, are not already authed and we've been given a display name as
// a URL param, automatically register a passwordless user // a URL param, automatically register a passwordless user

View file

@ -20,7 +20,7 @@ limitations under the License.
} }
.tabContainer { .tabContainer {
margin: 27px 16px; padding: 27px 20px;
} }
.fieldRowText { .fieldRowText {
@ -33,5 +33,5 @@ The "Developer" item in the tab bar can be toggled.
Without a defined width activating the developer tab makes the tab container jump to the right. Without a defined width activating the developer tab makes the tab container jump to the right.
*/ */
.tabLabel { .tabLabel {
width: 80px; min-width: 80px;
} }

View file

@ -16,7 +16,7 @@ limitations under the License.
import React from "react"; import React from "react";
import { Item } from "@react-stately/collections"; import { Item } from "@react-stately/collections";
import { useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { Modal } from "../Modal"; import { Modal } from "../Modal";
import styles from "./SettingsModal.module.css"; import styles from "./SettingsModal.module.css";
@ -32,15 +32,14 @@ import {
useSpatialAudio, useSpatialAudio,
useShowInspector, useShowInspector,
useOptInAnalytics, useOptInAnalytics,
canEnableSpatialAudio,
useNewGrid, useNewGrid,
useDeveloperSettingsTab, useDeveloperSettingsTab,
} from "./useSetting"; } from "./useSetting";
import { FieldRow, InputField } from "../input/Input"; import { FieldRow, InputField } from "../input/Input";
import { Button } from "../button"; import { Button } from "../button";
import { useDownloadDebugLog } from "./submit-rageshake"; import { useDownloadDebugLog } from "./submit-rageshake";
import { Body } from "../typography/Typography"; import { Body, Caption } from "../typography/Typography";
import { optInDescription } from "../analytics/AnalyticsOptInDescription"; import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
interface Props { interface Props {
isOpen: boolean; isOpen: boolean;
@ -71,6 +70,17 @@ export const SettingsModal = (props: Props) => {
const downloadDebugLog = useDownloadDebugLog(); const downloadDebugLog = useDownloadDebugLog();
const optInDescription = (
<Caption>
<Trans>
<AnalyticsNotice />
<br />
You may withdraw consent by unchecking this box. If you are currently in
a call, this setting will take effect at the end of the call.
</Trans>
</Caption>
);
return ( return (
<Modal <Modal
title={t("Settings")} title={t("Settings")}
@ -122,16 +132,16 @@ export const SettingsModal = (props: Props) => {
label={t("Spatial audio")} label={t("Spatial audio")}
type="checkbox" type="checkbox"
checked={spatialAudio} checked={spatialAudio}
disabled={!canEnableSpatialAudio()} disabled={setSpatialAudio === null}
description={ description={
canEnableSpatialAudio() setSpatialAudio === null
? t( ? t("This feature is only supported on Firefox.")
: t(
"This will make a speaker's audio seem as if it is coming from where their tile is positioned on screen. (Experimental feature: this may impact the stability of audio.)" "This will make a speaker's audio seem as if it is coming from where their tile is positioned on screen. (Experimental feature: this may impact the stability of audio.)"
) )
: t("This feature is only supported on Firefox.")
} }
onChange={(event: React.ChangeEvent<HTMLInputElement>) => onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setSpatialAudio(event.target.checked) setSpatialAudio!(event.target.checked)
} }
/> />
</FieldRow> </FieldRow>
@ -187,7 +197,7 @@ export const SettingsModal = (props: Props) => {
id="optInAnalytics" id="optInAnalytics"
type="checkbox" type="checkbox"
checked={optInAnalytics} checked={optInAnalytics}
description={optInDescription()} description={optInDescription}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setOptInAnalytics(event.target.checked) setOptInAnalytics(event.target.checked)
} }

View file

@ -17,6 +17,11 @@ limitations under the License.
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import { useMemo, useState, useEffect, useCallback } from "react"; import { useMemo, useState, useEffect, useCallback } from "react";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
type Setting<T> = [T, (value: T) => void];
type DisableableSetting<T> = [T, ((value: T) => void) | null];
// Bus to notify other useSetting consumers when a setting is changed // Bus to notify other useSetting consumers when a setting is changed
export const settingsBus = new EventEmitter(); export const settingsBus = new EventEmitter();
@ -24,10 +29,7 @@ const getSettingKey = (name: string): string => {
return `matrix-setting-${name}`; return `matrix-setting-${name}`;
}; };
// Like useState, but reads from and persists the value to localStorage // Like useState, but reads from and persists the value to localStorage
const useSetting = <T>( const useSetting = <T>(name: string, defaultValue: T): Setting<T> => {
name: string,
defaultValue: T
): [T, (value: T) => void] => {
const key = useMemo(() => getSettingKey(name), [name]); const key = useMemo(() => getSettingKey(name), [name]);
const [value, setValue] = useState<T>(() => { const [value, setValue] = useState<T>(() => {
@ -65,7 +67,7 @@ export const setSetting = <T>(name: string, newValue: T) => {
settingsBus.emit(name, newValue); settingsBus.emit(name, newValue);
}; };
export const canEnableSpatialAudio = () => { const canEnableSpatialAudio = () => {
const { userAgent } = navigator; const { userAgent } = navigator;
// Spatial audio means routing audio through audio contexts. On Chrome, // Spatial audio means routing audio through audio contexts. On Chrome,
// this bypasses the AEC processor and so breaks echo cancellation. // this bypasses the AEC processor and so breaks echo cancellation.
@ -79,17 +81,27 @@ export const canEnableSpatialAudio = () => {
return userAgent.includes("Firefox"); return userAgent.includes("Firefox");
}; };
export const useSpatialAudio = (): [boolean, (val: boolean) => void] => { export const useSpatialAudio = (): DisableableSetting<boolean> => {
const settingVal = useSetting("spatial-audio", false); const settingVal = useSetting("spatial-audio", false);
if (canEnableSpatialAudio()) return settingVal; if (canEnableSpatialAudio()) return settingVal;
return [false, (_: boolean) => {}]; return [false, null];
}; };
export const useShowInspector = () => useSetting("show-inspector", false); export const useShowInspector = () => useSetting("show-inspector", false);
export const useOptInAnalytics = () => useSetting("opt-in-analytics", false);
// null = undecided
export const useOptInAnalytics = (): DisableableSetting<boolean | null> => {
const settingVal = useSetting<boolean | null>("opt-in-analytics", null);
if (PosthogAnalytics.instance.isEnabled()) return settingVal;
return [false, null];
};
export const useKeyboardShortcuts = () => export const useKeyboardShortcuts = () =>
useSetting("keyboard-shortcuts", true); useSetting("keyboard-shortcuts", true);
export const useNewGrid = () => useSetting("new-grid", false); export const useNewGrid = () => useSetting("new-grid", false);
export const useDeveloperSettingsTab = () => export const useDeveloperSettingsTab = () =>
useSetting("developer-settings-tab", false); useSetting("developer-settings-tab", false);

View file

@ -88,7 +88,9 @@ limitations under the License.
.tabContainer { .tabContainer {
width: 100%; width: 100%;
flex-direction: row; flex-direction: row;
margin: 27px 16px; padding: 27px 20px;
box-sizing: border-box;
overflow: hidden;
} }
.tabList { .tabList {