Refactor settings to use observables

Also removing some unused settings along the way.
This commit is contained in:
Robin 2024-05-08 15:29:39 -04:00
parent 20602c122b
commit 41083c0f9e
11 changed files with 157 additions and 179 deletions

View File

@ -66,6 +66,7 @@
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#238eea0ef5c82d0a11b8d5cc5c04104d6c94c4c1", "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#238eea0ef5c82d0a11b8d5cc5c04104d6c94c4c1",
"matrix-widget-api": "^1.3.1", "matrix-widget-api": "^1.3.1",
"normalize.css": "^8.0.1", "normalize.css": "^8.0.1",
"observable-hooks": "^4.2.3",
"pako": "^2.0.4", "pako": "^2.0.4",
"postcss-preset-env": "^9.0.0", "postcss-preset-env": "^9.0.0",
"posthog-js": "^1.29.0", "posthog-js": "^1.29.0",

View File

@ -140,7 +140,6 @@
"feedback_tab_title": "Feedback", "feedback_tab_title": "Feedback",
"more_tab_title": "More", "more_tab_title": "More",
"opt_in_description": "<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.", "opt_in_description": "<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.",
"show_connection_stats_label": "Show connection stats",
"speaker_device_selection_label": "Speaker" "speaker_device_selection_label": "Speaker"
}, },
"star_rating_input_label_one": "{{count}} stars", "star_rating_input_label_one": "{{count}} stars",

View File

@ -20,7 +20,6 @@ import { MatrixClient } from "matrix-js-sdk";
import { Buffer } from "buffer"; import { Buffer } from "buffer";
import { widget } from "../widget"; import { widget } from "../widget";
import { getSetting, setSetting, getSettingKey } from "../settings/useSetting";
import { import {
CallEndedTracker, CallEndedTracker,
CallStartedTracker, CallStartedTracker,
@ -35,7 +34,7 @@ import {
} from "./PosthogEvents"; } from "./PosthogEvents";
import { Config } from "../config/Config"; import { Config } from "../config/Config";
import { getUrlParams } from "../UrlParams"; import { getUrlParams } from "../UrlParams";
import { localStorageBus } from "../useLocalStorage"; import { optInAnalytics } from "../settings/settings";
/* Posthog analytics tracking. /* Posthog analytics tracking.
* *
@ -131,7 +130,7 @@ export class PosthogAnalytics {
const { analyticsID } = getUrlParams(); const { analyticsID } = getUrlParams();
// if the embedding platform (element web) already got approval to communicating with posthog // if the embedding platform (element web) already got approval to communicating with posthog
// element call can also send events to posthog // element call can also send events to posthog
setSetting("opt-in-analytics", Boolean(analyticsID)); optInAnalytics.setValue(Boolean(analyticsID));
} }
this.posthog.init(posthogConfig.project_api_key, { this.posthog.init(posthogConfig.project_api_key, {
@ -151,9 +150,7 @@ export class PosthogAnalytics {
); );
this.enabled = false; this.enabled = false;
} }
this.startListeningToSettingsChanges(); this.startListeningToSettingsChanges(); // Triggers maybeIdentifyUser
const optInAnalytics = getSetting("opt-in-analytics", false);
this.updateAnonymityAndIdentifyUser(optInAnalytics);
} }
private sanitizeProperties = ( private sanitizeProperties = (
@ -336,8 +333,7 @@ export class PosthogAnalytics {
} }
public onLoginStatusChanged(): void { public onLoginStatusChanged(): void {
const optInAnalytics = getSetting("opt-in-analytics", false); this.maybeIdentifyUser();
this.updateAnonymityAndIdentifyUser(optInAnalytics);
} }
private updateSuperProperties(): void { private updateSuperProperties(): void {
@ -360,20 +356,12 @@ export class PosthogAnalytics {
return this.eventSignup.getSignupEndTime() > new Date(0); return this.eventSignup.getSignupEndTime() > new Date(0);
} }
private async updateAnonymityAndIdentifyUser( private async maybeIdentifyUser(): Promise<void> {
pseudonymousOptIn: boolean,
): Promise<void> {
// Update this.anonymity based on the user's analytics opt-in settings
const anonymity = pseudonymousOptIn
? Anonymity.Pseudonymous
: Anonymity.Disabled;
this.setAnonymity(anonymity);
// We may not yet have a Matrix client at this point, if not, bail. This should get // We may not yet have a Matrix client at this point, if not, bail. This should get
// triggered again by onLoginStatusChanged once we do have a client. // triggered again by onLoginStatusChanged once we do have a client.
if (!window.matrixclient) return; if (!window.matrixclient) return;
if (anonymity === Anonymity.Pseudonymous) { if (this.anonymity === Anonymity.Pseudonymous) {
this.setRegistrationType( this.setRegistrationType(
window.matrixclient.isGuest() || window.passwordlessUser window.matrixclient.isGuest() || window.passwordlessUser
? RegistrationType.Guest ? RegistrationType.Guest
@ -389,7 +377,7 @@ export class PosthogAnalytics {
} }
} }
if (anonymity !== Anonymity.Disabled) { if (this.anonymity !== Anonymity.Disabled) {
this.updateSuperProperties(); this.updateSuperProperties();
} }
} }
@ -419,8 +407,9 @@ export class PosthogAnalytics {
// * When the user changes their preferences on this device // * When the user changes their preferences on this device
// Note that for new accounts, pseudonymousAnalyticsOptIn won't be set, so updateAnonymityFromSettings // Note that for new accounts, pseudonymousAnalyticsOptIn won't be set, so updateAnonymityFromSettings
// won't be called (i.e. this.anonymity will be left as the default, until the setting changes) // won't be called (i.e. this.anonymity will be left as the default, until the setting changes)
localStorageBus.on(getSettingKey("opt-in-analytics"), (optInAnalytics) => { optInAnalytics.value.subscribe((optIn) => {
this.updateAnonymityAndIdentifyUser(optInAnalytics); this.setAnonymity(optIn ? Anonymity.Pseudonymous : Anonymity.Disabled);
this.maybeIdentifyUser();
}); });
} }

View File

@ -38,9 +38,12 @@ import { UserMenuContainer } from "../UserMenuContainer";
import { JoinExistingCallModal } from "./JoinExistingCallModal"; import { JoinExistingCallModal } from "./JoinExistingCallModal";
import { Caption } from "../typography/Typography"; import { Caption } from "../typography/Typography";
import { Form } from "../form/Form"; import { Form } from "../form/Form";
import { useOptInAnalytics } from "../settings/useSetting";
import { AnalyticsNotice } from "../analytics/AnalyticsNotice"; import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
import { E2eeType } from "../e2ee/e2eeType"; import { E2eeType } from "../e2ee/e2eeType";
import {
useSetting,
optInAnalytics as optInAnalyticsSetting,
} from "../settings/settings";
interface Props { interface Props {
client: MatrixClient; client: MatrixClient;
@ -49,7 +52,7 @@ interface Props {
export const RegisteredView: FC<Props> = ({ client }) => { export const RegisteredView: FC<Props> = ({ client }) => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error>(); const [error, setError] = useState<Error>();
const [optInAnalytics] = useOptInAnalytics(); const [optInAnalytics] = useSetting(optInAnalyticsSetting);
const history = useHistory(); const history = useHistory();
const { t } = useTranslation(); const { t } = useTranslation();
const [joinExistingCallModalOpen, setJoinExistingCallModalOpen] = const [joinExistingCallModalOpen, setJoinExistingCallModalOpen] =

View File

@ -41,15 +41,18 @@ 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 { AnalyticsNotice } from "../analytics/AnalyticsNotice";
import { useOptInAnalytics } from "../settings/useSetting";
import { Config } from "../config/Config"; import { Config } from "../config/Config";
import { E2eeType } from "../e2ee/e2eeType"; import { E2eeType } from "../e2ee/e2eeType";
import {
useSetting,
optInAnalytics as optInAnalyticsSetting,
} from "../settings/settings";
export const UnauthenticatedView: FC = () => { export const UnauthenticatedView: FC = () => {
const { setClient } = useClient(); const { setClient } = useClient();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error>(); const [error, setError] = useState<Error>();
const [optInAnalytics] = useOptInAnalytics(); const [optInAnalytics] = useSetting(optInAnalyticsSetting);
const { recaptchaKey, register } = useInteractiveRegistration(); const { recaptchaKey, register } = useInteractiveRegistration();
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey); const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);

View File

@ -29,11 +29,12 @@ import { Observable } from "rxjs";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { import {
useSetting,
audioInput as audioInputSetting,
audioOutput as audioOutputSetting,
videoInput as videoInputSetting,
isFirefox, isFirefox,
useAudioInput, } from "../settings/settings";
useAudioOutput,
useVideoInput,
} from "../settings/useSetting";
export interface MediaDevice { export interface MediaDevice {
available: MediaDeviceInfo[]; available: MediaDeviceInfo[];
@ -145,43 +146,36 @@ export const MediaDevicesProvider: FC<Props> = ({ children }) => {
// for ouput devices because the selector wont be shown on FF. // for ouput devices because the selector wont be shown on FF.
const useOutputNames = usingNames && !isFirefox(); const useOutputNames = usingNames && !isFirefox();
const [audioInputSetting, setAudioInputSetting] = useAudioInput(); const [storedAudioInput, setStoredAudioInput] = useSetting(audioInputSetting);
const [audioOutputSetting, setAudioOutputSetting] = useAudioOutput(); const [storedAudioOutput, setStoredAudioOutput] =
const [videoInputSetting, setVideoInputSetting] = useVideoInput(); useSetting(audioOutputSetting);
const [storedVideoInput, setStoredVideoInput] = useSetting(videoInputSetting);
const audioInput = useMediaDevice( const audioInput = useMediaDevice("audioinput", storedAudioInput, usingNames);
"audioinput",
audioInputSetting,
usingNames,
);
const audioOutput = useMediaDevice( const audioOutput = useMediaDevice(
"audiooutput", "audiooutput",
audioOutputSetting, storedAudioOutput,
useOutputNames, useOutputNames,
alwaysUseDefaultAudio, alwaysUseDefaultAudio,
); );
const videoInput = useMediaDevice( const videoInput = useMediaDevice("videoinput", storedVideoInput, usingNames);
"videoinput",
videoInputSetting,
usingNames,
);
useEffect(() => { useEffect(() => {
if (audioInput.selectedId !== undefined) if (audioInput.selectedId !== undefined)
setAudioInputSetting(audioInput.selectedId); setStoredAudioInput(audioInput.selectedId);
}, [setAudioInputSetting, audioInput.selectedId]); }, [setStoredAudioInput, audioInput.selectedId]);
useEffect(() => { useEffect(() => {
// Skip setting state for ff output. Redundent since it is set to always return 'undefined' // Skip setting state for ff output. Redundent since it is set to always return 'undefined'
// but makes it clear while debugging that this is not happening on FF. + perf ;) // but makes it clear while debugging that this is not happening on FF. + perf ;)
if (audioOutput.selectedId !== undefined && !isFirefox()) if (audioOutput.selectedId !== undefined && !isFirefox())
setAudioOutputSetting(audioOutput.selectedId); setStoredAudioOutput(audioOutput.selectedId);
}, [setAudioOutputSetting, audioOutput.selectedId]); }, [setStoredAudioOutput, audioOutput.selectedId]);
useEffect(() => { useEffect(() => {
if (videoInput.selectedId !== undefined) if (videoInput.selectedId !== undefined)
setVideoInputSetting(videoInput.selectedId); setStoredVideoInput(videoInput.selectedId);
}, [setVideoInputSetting, videoInput.selectedId]); }, [setStoredVideoInput, videoInput.selectedId]);
const startUsingDeviceNames = useCallback( const startUsingDeviceNames = useCallback(
() => setNumCallersUsingNames((n) => n + 1), () => setNumCallersUsingNames((n) => n + 1),

View File

@ -26,7 +26,6 @@ import { GroupCallLoader } from "./GroupCallLoader";
import { GroupCallView } from "./GroupCallView"; import { GroupCallView } from "./GroupCallView";
import { useRoomIdentifier, useUrlParams } from "../UrlParams"; import { useRoomIdentifier, useUrlParams } from "../UrlParams";
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser"; import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
import { useOptInAnalytics } from "../settings/useSetting";
import { HomePage } from "../home/HomePage"; import { HomePage } from "../home/HomePage";
import { platform } from "../Platform"; import { platform } from "../Platform";
import { AppSelectionModal } from "./AppSelectionModal"; import { AppSelectionModal } from "./AppSelectionModal";
@ -36,6 +35,10 @@ import { LobbyView } from "./LobbyView";
import { E2eeType } from "../e2ee/e2eeType"; import { E2eeType } from "../e2ee/e2eeType";
import { useProfile } from "../profile/useProfile"; import { useProfile } from "../profile/useProfile";
import { useMuteStates } from "./MuteStates"; import { useMuteStates } from "./MuteStates";
import {
useSetting,
optInAnalytics as optInAnalyticsSetting,
} from "../settings/settings";
export const RoomPage: FC = () => { export const RoomPage: FC = () => {
const { const {
@ -80,7 +83,7 @@ export const RoomPage: FC = () => {
registerPasswordlessUser, registerPasswordlessUser,
]); ]);
const [optInAnalytics, setOptInAnalytics] = useOptInAnalytics(); const [optInAnalytics, setOptInAnalytics] = useSetting(optInAnalyticsSetting);
useEffect(() => { useEffect(() => {
// During the beta, opt into analytics by default // During the beta, opt into analytics by default
if (optInAnalytics === null && setOptInAnalytics) setOptInAnalytics(true); if (optInAnalytics === null && setOptInAnalytics) setOptInAnalytics(true);

View File

@ -29,12 +29,6 @@ import OverflowIcon from "../icons/Overflow.svg?react";
import UserIcon from "../icons/User.svg?react"; import UserIcon from "../icons/User.svg?react";
import FeedbackIcon from "../icons/Feedback.svg?react"; import FeedbackIcon from "../icons/Feedback.svg?react";
import { SelectInput } from "../input/SelectInput"; import { SelectInput } from "../input/SelectInput";
import {
useOptInAnalytics,
useDeveloperSettingsTab,
useShowConnectionStats,
isFirefox,
} from "./useSetting";
import { FieldRow, InputField } from "../input/Input"; import { FieldRow, InputField } from "../input/Input";
import { Body, Caption } from "../typography/Typography"; import { Body, Caption } from "../typography/Typography";
import { AnalyticsNotice } from "../analytics/AnalyticsNotice"; import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
@ -46,6 +40,12 @@ import {
useMediaDeviceNames, useMediaDeviceNames,
} from "../livekit/MediaDevicesContext"; } from "../livekit/MediaDevicesContext";
import { widget } from "../widget"; import { widget } from "../widget";
import {
useSetting,
optInAnalytics as optInAnalyticsSetting,
developerSettingsTab as developerSettingsTabSetting,
isFirefox,
} from "./settings";
type SettingsTab = type SettingsTab =
| "audio" | "audio"
@ -76,11 +76,10 @@ export const SettingsModal: FC<Props> = ({
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [optInAnalytics, setOptInAnalytics] = useOptInAnalytics(); const [optInAnalytics, setOptInAnalytics] = useSetting(optInAnalyticsSetting);
const [developerSettingsTab, setDeveloperSettingsTab] = const [developerSettingsTab, setDeveloperSettingsTab] = useSetting(
useDeveloperSettingsTab(); developerSettingsTabSetting,
const [showConnectionStats, setShowConnectionStats] = );
useShowConnectionStats();
// Generate a `SelectInput` with a list of devices for a given device kind. // Generate a `SelectInput` with a list of devices for a given device kind.
const generateDeviceSelection = ( const generateDeviceSelection = (
@ -245,18 +244,6 @@ export const SettingsModal: FC<Props> = ({
})} })}
</Body> </Body>
</FieldRow> </FieldRow>
<FieldRow>
<InputField
id="showConnectionStats"
name="connection-stats"
label={t("settings.show_connection_stats_label")}
type="checkbox"
checked={showConnectionStats}
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
setShowConnectionStats(e.target.checked)
}
/>
</FieldRow>
</TabItem> </TabItem>
); );

98
src/settings/settings.ts Normal file
View File

@ -0,0 +1,98 @@
/*
Copyright 2024 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 { logger } from "matrix-js-sdk/src/logger";
import { BehaviorSubject, Observable } from "rxjs";
import { useObservableEagerState } from "observable-hooks";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
export class Setting<T> {
public constructor(key: string, defaultValue: T) {
this.key = `matrix-setting-${key}`;
const storedValue = localStorage.getItem(this.key);
let initialValue = defaultValue;
if (storedValue !== null) {
try {
initialValue = JSON.parse(storedValue);
} catch (e) {
logger.warn(`Invalid value stored for setting ${key}: ${storedValue}`);
}
}
this._value = new BehaviorSubject(initialValue);
this.value = this._value;
}
private readonly key: string;
private readonly _value: BehaviorSubject<T>;
public readonly value: Observable<T>;
public readonly setValue = (value: T): void => {
this._value.next(value);
localStorage.setItem(this.key, JSON.stringify(value));
};
}
/**
* React hook that returns a settings's current value and a setter.
*/
export function useSetting<T>(setting: Setting<T>): [T, (value: T) => void] {
return [useObservableEagerState(setting.value), setting.setValue];
}
// TODO: This doesn't belong here
export const isFirefox = (): boolean => {
const { userAgent } = navigator;
return userAgent.includes("Firefox");
};
// null = undecided
export const optInAnalytics = new Setting<boolean | null>(
"opt-in-analytics",
null,
);
// TODO: This setting can be disabled. Work out an approach to disableable
// settings thats works for Observables in addition to React.
export const useOptInAnalytics = (): [
boolean | null,
((value: boolean | null) => void) | null,
] => {
const setting = useSetting(optInAnalytics);
if (PosthogAnalytics.instance.isEnabled()) return setting;
return [false, null];
};
export const developerSettingsTab = new Setting(
"developer-settings-tab",
false,
);
export const audioInput = new Setting<string | undefined>(
"audio-input",
undefined,
);
export const audioOutput = new Setting<string | undefined>(
"audio-output",
undefined,
);
export const videoInput = new Setting<string | undefined>(
"video-input",
undefined,
);

View File

@ -1,104 +0,0 @@
/*
Copyright 2022 - 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 { useCallback, useMemo } from "react";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import {
getLocalStorageItem,
setLocalStorageItem,
useLocalStorage,
} from "../useLocalStorage";
type Setting<T> = [T, (value: T) => void];
type DisableableSetting<T> = [T, ((value: T) => void) | null];
export const getSettingKey = (name: string): string => {
return `matrix-setting-${name}`;
};
// Like useState, but reads from and persists the value to localStorage
export const useSetting = <T>(name: string, defaultValue: T): Setting<T> => {
const key = useMemo(() => getSettingKey(name), [name]);
const [item, setItem] = useLocalStorage(key);
const value = useMemo(
() => (item == null ? defaultValue : JSON.parse(item)),
[item, defaultValue],
);
const setValue = useCallback(
(value: T) => {
setItem(JSON.stringify(value));
},
[setItem],
);
return [value, setValue];
};
export const getSetting = <T>(name: string, defaultValue: T): T => {
const item = getLocalStorageItem(getSettingKey(name));
return item === null ? defaultValue : JSON.parse(item);
};
export const setSetting = <T>(name: string, newValue: T): void =>
setLocalStorageItem(getSettingKey(name), JSON.stringify(newValue));
export const isFirefox = (): boolean => {
const { userAgent } = navigator;
return userAgent.includes("Firefox");
};
const canEnableSpatialAudio = (): boolean => {
// Spatial audio means routing audio through audio contexts. On Chrome,
// this bypasses the AEC processor and so breaks echo cancellation.
// We only allow spatial audio to be enabled on Firefox which we know
// passes audio context audio through the AEC algorithm.
// https://bugs.chromium.org/p/chromium/issues/detail?id=687574 is the
// chrome bug for this: once this is fixed and the updated version is deployed
// widely enough, we can allow spatial audio everywhere. It's currently in a
// chrome flag, so we could enable this in Electron if we enabled the chrome flag
// in the Electron wrapper.
return isFirefox();
};
export const useSpatialAudio = (): DisableableSetting<boolean> => {
const settingVal = useSetting("spatial-audio", false);
if (canEnableSpatialAudio()) return settingVal;
return [false, null];
};
// 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 useDeveloperSettingsTab = (): Setting<boolean> =>
useSetting("developer-settings-tab", false);
export const useShowConnectionStats = (): Setting<boolean> =>
useSetting("show-connection-stats", false);
export const useAudioInput = (): Setting<string | undefined> =>
useSetting<string | undefined>("audio-input", undefined);
export const useAudioOutput = (): Setting<string | undefined> =>
useSetting<string | undefined>("audio-output", undefined);
export const useVideoInput = (): Setting<string | undefined> =>
useSetting<string | undefined>("video-input", undefined);

View File

@ -7104,6 +7104,11 @@ object.values@^1.1.7:
define-properties "^1.2.0" define-properties "^1.2.0"
es-abstract "^1.22.1" es-abstract "^1.22.1"
observable-hooks@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/observable-hooks/-/observable-hooks-4.2.3.tgz#69e3353caafd7887ad9030bd440b053304e8d2d1"
integrity sha512-d6fYTIU+9sg1V+CT0GhgoE/ntjIqcy9DGaYGE6ELGVP4ojaWIEsaLvL/05hLOM+AL7aySN4DCTLvj6dDF9T8XA==
oidc-client-ts@^3.0.1: oidc-client-ts@^3.0.1:
version "3.0.1" version "3.0.1"
resolved "https://registry.yarnpkg.com/oidc-client-ts/-/oidc-client-ts-3.0.1.tgz#be264fb87c89f74f73863646431c32cd06f5ceb7" resolved "https://registry.yarnpkg.com/oidc-client-ts/-/oidc-client-ts-3.0.1.tgz#be264fb87c89f74f73863646431c32cd06f5ceb7"