Reduce amount of requests done by the onboarding task list (#9194)

* Significantly reduce work of useUserOnboardingContext
* Wrap UserOnboardingButton to avoid unnecessary work when it's not shown
* Remove progress from user onboarding button
pull/28217/head
Janne Mareike Koschinski 2022-08-22 13:48:54 +02:00 committed by GitHub
parent e8eefeb937
commit 94d292a6ec
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 117 additions and 81 deletions

View File

@ -80,7 +80,18 @@ describe("User Onboarding (new user)", () => {
cy.get(".mx_InviteDialog_editor input").type(bot1.getUserId()); cy.get(".mx_InviteDialog_editor input").type(bot1.getUserId());
cy.get(".mx_InviteDialog_buttonAndSpinner").click(); cy.get(".mx_InviteDialog_buttonAndSpinner").click();
cy.get(".mx_InviteDialog_buttonAndSpinner").should("not.exist"); cy.get(".mx_InviteDialog_buttonAndSpinner").should("not.exist");
cy.get(".mx_SendMessageComposer").type("Hi!{enter}"); const message = "Hi!";
cy.get(".mx_SendMessageComposer").type(`${message}!{enter}`);
cy.contains(".mx_MTextBody.mx_EventTile_content", message);
cy.visit("/#/home");
cy.get('.mx_UserOnboardingPage').should('exist');
cy.get('.mx_UserOnboardingButton').should('exist');
cy.get('.mx_UserOnboardingList')
.should('exist')
.should(($list) => {
const list = $list.get(0);
expect(getComputedStyle(list).opacity).to.be.eq("1");
});
cy.get(".mx_ProgressBar").invoke("val").should("be.greaterThan", oldProgress); cy.get(".mx_ProgressBar").invoke("val").should("be.greaterThan", oldProgress);
}); });
}); });

View File

@ -20,62 +20,55 @@ import React, { useCallback } from "react";
import { Action } from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
import defaultDispatcher from "../../../dispatcher/dispatcher"; import defaultDispatcher from "../../../dispatcher/dispatcher";
import { useSettingValue } from "../../../hooks/useSettings"; import { useSettingValue } from "../../../hooks/useSettings";
import { useUserOnboardingContext } from "../../../hooks/useUserOnboardingContext";
import { useUserOnboardingTasks } from "../../../hooks/useUserOnboardingTasks";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import PosthogTrackers from "../../../PosthogTrackers"; import PosthogTrackers from "../../../PosthogTrackers";
import { UseCase } from "../../../settings/enums/UseCase"; import { UseCase } from "../../../settings/enums/UseCase";
import { SettingLevel } from "../../../settings/SettingLevel"; import { SettingLevel } from "../../../settings/SettingLevel";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton"; import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton";
import ProgressBar from "../../views/elements/ProgressBar";
import Heading from "../../views/typography/Heading"; import Heading from "../../views/typography/Heading";
import { showUserOnboardingPage } from "./UserOnboardingPage"; import { showUserOnboardingPage } from "./UserOnboardingPage";
function toPercentage(progress: number): string {
return (progress * 100).toFixed(0);
}
interface Props { interface Props {
selected: boolean; selected: boolean;
minimized: boolean; minimized: boolean;
} }
export function UserOnboardingButton({ selected, minimized }: Props) { export function UserOnboardingButton({ selected, minimized }: Props) {
const context = useUserOnboardingContext(); const useCase = useSettingValue<UseCase | null>("FTUE.useCaseSelection");
const [completedTasks, waitingTasks] = useUserOnboardingTasks(context); const visible = useSettingValue<boolean>("FTUE.userOnboardingButton");
const completed = completedTasks.length; if (!visible || minimized || !showUserOnboardingPage(useCase)) {
const waiting = waitingTasks.length; return null;
const total = completed + waiting;
let progress = 1;
if (context && waiting) {
progress = completed / total;
} }
return (
<UserOnboardingButtonInternal selected={selected} minimized={minimized} />
);
}
function UserOnboardingButtonInternal({ selected, minimized }: Props) {
const onDismiss = useCallback((ev: ButtonEvent) => { const onDismiss = useCallback((ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
PosthogTrackers.trackInteraction("WebRoomListUserOnboardingIgnoreButton", ev); PosthogTrackers.trackInteraction("WebRoomListUserOnboardingIgnoreButton", ev);
SettingsStore.setValue("FTUE.userOnboardingButton", null, SettingLevel.ACCOUNT, false); SettingsStore.setValue("FTUE.userOnboardingButton", null, SettingLevel.ACCOUNT, false);
}, []); }, []);
const onClick = useCallback((ev: ButtonEvent) => { const onClick = useCallback((ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
PosthogTrackers.trackInteraction("WebRoomListUserOnboardingButton", ev); PosthogTrackers.trackInteraction("WebRoomListUserOnboardingButton", ev);
defaultDispatcher.fire(Action.ViewHomePage); defaultDispatcher.fire(Action.ViewHomePage);
}, []); }, []);
const useCase = useSettingValue<UseCase | null>("FTUE.useCaseSelection");
const visible = useSettingValue<boolean>("FTUE.userOnboardingButton");
if (!visible || minimized || !showUserOnboardingPage(useCase)) {
return null;
}
return ( return (
<AccessibleButton <AccessibleButton
className={classNames("mx_UserOnboardingButton", { className={classNames("mx_UserOnboardingButton", {
"mx_UserOnboardingButton_selected": selected, "mx_UserOnboardingButton_selected": selected,
"mx_UserOnboardingButton_minimized": minimized, "mx_UserOnboardingButton_minimized": minimized,
"mx_UserOnboardingButton_completed": !waiting || !context,
})} })}
onClick={onClick}> onClick={onClick}>
{ !minimized && ( { !minimized && (
@ -84,17 +77,11 @@ export function UserOnboardingButton({ selected, minimized }: Props) {
<Heading size="h4" className="mx_Heading_h4"> <Heading size="h4" className="mx_Heading_h4">
{ _t("Welcome") } { _t("Welcome") }
</Heading> </Heading>
{ context && !completed && (
<div className="mx_UserOnboardingButton_percentage">
{ toPercentage(progress) }%
</div>
) }
<AccessibleButton <AccessibleButton
className="mx_UserOnboardingButton_close" className="mx_UserOnboardingButton_close"
onClick={onDismiss} onClick={onDismiss}
/> />
</div> </div>
<ProgressBar value={completed} max={total} animated />
</> </>
) } ) }
</AccessibleButton> </AccessibleButton>

View File

@ -15,54 +15,96 @@ limitations under the License.
*/ */
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { ClientEvent, IMyDevice, Room } from "matrix-js-sdk/src/matrix"; import { ClientEvent, MatrixClient } from "matrix-js-sdk/src/matrix";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { MatrixClientPeg } from "../MatrixClientPeg"; import { MatrixClientPeg } from "../MatrixClientPeg";
import { Notifier } from "../Notifier";
import DMRoomMap from "../utils/DMRoomMap"; import DMRoomMap from "../utils/DMRoomMap";
import { useEventEmitter } from "./useEventEmitter";
export interface UserOnboardingContext { export interface UserOnboardingContext {
avatar: string | null; hasAvatar: boolean;
myDevice: string; hasDevices: boolean;
devices: IMyDevice[]; hasDmRooms: boolean;
dmRooms: {[userId: string]: Room}; hasNotificationsEnabled: boolean;
}
const USER_ONBOARDING_CONTEXT_INTERVAL = 5000;
/**
* Returns a persistent, non-changing reference to a function
* This function proxies all its calls to the current value of the given input callback
*
* This allows you to use the current value of e.g., a state in a callback thats used by e.g., a useEventEmitter or
* similar hook without re-registering the hook when the state changes
* @param value changing callback
*/
function useRefOf<T extends any[], R>(value: (...values: T) => R): (...values: T) => R {
const ref = useRef(value);
ref.current = value;
return useCallback(
(...values: T) => ref.current(...values),
[],
);
}
function useUserOnboardingContextValue<T>(defaultValue: T, callback: (cli: MatrixClient) => Promise<T>): T {
const [value, setValue] = useState<T>(defaultValue);
const cli = MatrixClientPeg.get();
const handler = useRefOf(callback);
useEffect(() => {
if (value) {
return;
}
let handle: number | null = null;
let enabled = true;
const repeater = async () => {
if (handle !== null) {
clearTimeout(handle);
handle = null;
}
setValue(await handler(cli));
if (enabled) {
handle = setTimeout(repeater, USER_ONBOARDING_CONTEXT_INTERVAL);
}
};
repeater().catch(err => logger.warn("could not update user onboarding context", err));
cli.on(ClientEvent.AccountData, repeater);
return () => {
enabled = false;
cli.off(ClientEvent.AccountData, repeater);
if (handle !== null) {
clearTimeout(handle);
handle = null;
}
};
}, [cli, handler, value]);
return value;
} }
export function useUserOnboardingContext(): UserOnboardingContext | null { export function useUserOnboardingContext(): UserOnboardingContext | null {
const [context, setContext] = useState<UserOnboardingContext | null>(null); const hasAvatar = useUserOnboardingContextValue(false, async (cli) => {
const profile = await cli.getProfileInfo(cli.getUserId());
return Boolean(profile?.avatar_url);
});
const hasDevices = useUserOnboardingContextValue(false, async (cli) => {
const myDevice = cli.getDeviceId();
const devices = await cli.getDevices();
return Boolean(devices.devices.find(device => device.device_id !== myDevice));
});
const hasDmRooms = useUserOnboardingContextValue(false, async () => {
const dmRooms = DMRoomMap.shared().getUniqueRoomsWithIndividuals() ?? {};
return Boolean(Object.keys(dmRooms).length);
});
const hasNotificationsEnabled = useUserOnboardingContextValue(false, async () => {
return Notifier.isPossible();
});
const cli = MatrixClientPeg.get(); return useMemo(
const handler = useCallback(async () => { () => ({ hasAvatar, hasDevices, hasDmRooms, hasNotificationsEnabled }),
try { [hasAvatar, hasDevices, hasDmRooms, hasNotificationsEnabled],
const profile = await cli.getProfileInfo(cli.getUserId()); );
const myDevice = cli.getDeviceId();
const devices = await cli.getDevices();
const dmRooms = DMRoomMap.shared().getUniqueRoomsWithIndividuals() ?? {};
setContext({
avatar: profile?.avatar_url ?? null,
myDevice,
devices: devices.devices,
dmRooms: dmRooms,
});
} catch (e) {
logger.warn("Could not load context for user onboarding task list: ", e);
setContext(null);
}
}, [cli]);
useEventEmitter(cli, ClientEvent.AccountData, handler);
useEffect(() => {
const handle = setInterval(handler, 2000);
handler();
return () => {
if (handle) {
clearInterval(handle);
}
};
}, [handler]);
return context;
} }

View File

@ -47,8 +47,6 @@ interface InternalUserOnboardingTask extends UserOnboardingTask {
completed: (ctx: UserOnboardingContext) => boolean; completed: (ctx: UserOnboardingContext) => boolean;
} }
const hasOpenDMs = (ctx: UserOnboardingContext) => Boolean(Object.entries(ctx.dmRooms).length);
const onClickStartDm = (ev: ButtonEvent) => { const onClickStartDm = (ev: ButtonEvent) => {
PosthogTrackers.trackInteraction("WebUserOnboardingTaskSendDm", ev); PosthogTrackers.trackInteraction("WebUserOnboardingTaskSendDm", ev);
defaultDispatcher.dispatch({ action: 'view_create_chat' }); defaultDispatcher.dispatch({ action: 'view_create_chat' });
@ -65,7 +63,7 @@ const tasks: InternalUserOnboardingTask[] = [
id: "find-friends", id: "find-friends",
title: _t("Find and invite your friends"), title: _t("Find and invite your friends"),
description: _t("Its what youre here for, so lets get to it"), description: _t("Its what youre here for, so lets get to it"),
completed: hasOpenDMs, completed: (ctx: UserOnboardingContext) => ctx.hasDmRooms,
relevant: [UseCase.PersonalMessaging, UseCase.Skip], relevant: [UseCase.PersonalMessaging, UseCase.Skip],
action: { action: {
label: _t("Find friends"), label: _t("Find friends"),
@ -76,7 +74,7 @@ const tasks: InternalUserOnboardingTask[] = [
id: "find-coworkers", id: "find-coworkers",
title: _t("Find and invite your co-workers"), title: _t("Find and invite your co-workers"),
description: _t("Get stuff done by finding your teammates"), description: _t("Get stuff done by finding your teammates"),
completed: hasOpenDMs, completed: (ctx: UserOnboardingContext) => ctx.hasDmRooms,
relevant: [UseCase.WorkMessaging], relevant: [UseCase.WorkMessaging],
action: { action: {
label: _t("Find people"), label: _t("Find people"),
@ -87,7 +85,7 @@ const tasks: InternalUserOnboardingTask[] = [
id: "find-community-members", id: "find-community-members",
title: _t("Find and invite your community members"), title: _t("Find and invite your community members"),
description: _t("Get stuff done by finding your teammates"), description: _t("Get stuff done by finding your teammates"),
completed: hasOpenDMs, completed: (ctx: UserOnboardingContext) => ctx.hasDmRooms,
relevant: [UseCase.CommunityMessaging], relevant: [UseCase.CommunityMessaging],
action: { action: {
label: _t("Find people"), label: _t("Find people"),
@ -102,9 +100,7 @@ const tasks: InternalUserOnboardingTask[] = [
description: () => _t("Dont miss a thing by taking %(brand)s with you", { description: () => _t("Dont miss a thing by taking %(brand)s with you", {
brand: SdkConfig.get("brand"), brand: SdkConfig.get("brand"),
}), }),
completed: (ctx: UserOnboardingContext) => { completed: (ctx: UserOnboardingContext) => ctx.hasDevices,
return Boolean(ctx.devices.filter(it => it.device_id !== ctx.myDevice).length);
},
action: { action: {
label: _t("Download apps"), label: _t("Download apps"),
onClick: (ev: ButtonEvent) => { onClick: (ev: ButtonEvent) => {
@ -117,7 +113,7 @@ const tasks: InternalUserOnboardingTask[] = [
id: "setup-profile", id: "setup-profile",
title: _t("Set up your profile"), title: _t("Set up your profile"),
description: _t("Make sure people know its really you"), description: _t("Make sure people know its really you"),
completed: (info: UserOnboardingContext) => Boolean(info.avatar), completed: (ctx: UserOnboardingContext) => ctx.hasAvatar,
action: { action: {
label: _t("Your profile"), label: _t("Your profile"),
onClick: (ev: ButtonEvent) => { onClick: (ev: ButtonEvent) => {
@ -133,7 +129,7 @@ const tasks: InternalUserOnboardingTask[] = [
id: "permission-notifications", id: "permission-notifications",
title: _t("Turn on notifications"), title: _t("Turn on notifications"),
description: _t("Dont miss a reply or important message"), description: _t("Dont miss a reply or important message"),
completed: () => Notifier.isPossible(), completed: (ctx: UserOnboardingContext) => ctx.hasNotificationsEnabled,
action: { action: {
label: _t("Enable notifications"), label: _t("Enable notifications"),
onClick: (ev: ButtonEvent) => { onClick: (ev: ButtonEvent) => {