Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 12 commits into from
Aug 22, 2022
13 changes: 12 additions & 1 deletion cypress/e2e/user-onboarding/user-onboarding-new.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,18 @@ describe("User Onboarding (new user)", () => {
cy.get(".mx_InviteDialog_editor input").type(bot1.getUserId());
cy.get(".mx_InviteDialog_buttonAndSpinner").click();
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);
});
});
Expand Down
45 changes: 16 additions & 29 deletions src/components/views/user-onboarding/UserOnboardingButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,62 +20,55 @@ import React, { useCallback } from "react";
import { Action } from "../../../dispatcher/actions";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { useSettingValue } from "../../../hooks/useSettings";
import { useUserOnboardingContext } from "../../../hooks/useUserOnboardingContext";
import { useUserOnboardingTasks } from "../../../hooks/useUserOnboardingTasks";
import { _t } from "../../../languageHandler";
import PosthogTrackers from "../../../PosthogTrackers";
import { UseCase } from "../../../settings/enums/UseCase";
import { SettingLevel } from "../../../settings/SettingLevel";
import SettingsStore from "../../../settings/SettingsStore";
import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton";
import ProgressBar from "../../views/elements/ProgressBar";
import Heading from "../../views/typography/Heading";
import { showUserOnboardingPage } from "./UserOnboardingPage";

function toPercentage(progress: number): string {
return (progress * 100).toFixed(0);
}

interface Props {
selected: boolean;
minimized: boolean;
}

export function UserOnboardingButton({ selected, minimized }: Props) {
const context = useUserOnboardingContext();
const [completedTasks, waitingTasks] = useUserOnboardingTasks(context);

const completed = completedTasks.length;
const waiting = waitingTasks.length;
const total = completed + waiting;
const useCase = useSettingValue<UseCase | null>("FTUE.useCaseSelection");
const visible = useSettingValue<boolean>("FTUE.userOnboardingButton");

let progress = 1;
if (context && waiting) {
progress = completed / total;
if (!visible || minimized || !showUserOnboardingPage(useCase)) {
return null;
}

return (
<UserOnboardingButtonInternal selected={selected} minimized={minimized} />
);
}

function UserOnboardingButtonInternal({ selected, minimized }: Props) {
const onDismiss = useCallback((ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();

PosthogTrackers.trackInteraction("WebRoomListUserOnboardingIgnoreButton", ev);
SettingsStore.setValue("FTUE.userOnboardingButton", null, SettingLevel.ACCOUNT, false);
}, []);

const onClick = useCallback((ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();

PosthogTrackers.trackInteraction("WebRoomListUserOnboardingButton", ev);
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 (
<AccessibleButton
className={classNames("mx_UserOnboardingButton", {
"mx_UserOnboardingButton_selected": selected,
"mx_UserOnboardingButton_minimized": minimized,
"mx_UserOnboardingButton_completed": !waiting || !context,
})}
onClick={onClick}>
{ !minimized && (
Expand All @@ -84,17 +77,11 @@ export function UserOnboardingButton({ selected, minimized }: Props) {
<Heading size="h4" className="mx_Heading_h4">
{ _t("Welcome") }
</Heading>
{ context && !completed && (
<div className="mx_UserOnboardingButton_percentage">
{ toPercentage(progress) }%
</div>
) }
<AccessibleButton
className="mx_UserOnboardingButton_close"
onClick={onDismiss}
/>
</div>
<ProgressBar value={completed} max={total} animated />
</>
) }
</AccessibleButton>
Expand Down
119 changes: 86 additions & 33 deletions src/hooks/useUserOnboardingContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,54 +15,107 @@ limitations under the License.
*/

import { logger } from "matrix-js-sdk/src/logger";
import { ClientEvent, IMyDevice, Room } from "matrix-js-sdk/src/matrix";
import { useCallback, useEffect, useState } from "react";
import { ClientEvent } from "matrix-js-sdk/src/matrix";
import { useCallback, useEffect, useRef, useState } from "react";

import { MatrixClientPeg } from "../MatrixClientPeg";
import { Notifier } from "../Notifier";
import DMRoomMap from "../utils/DMRoomMap";
import { useEventEmitter } from "./useEventEmitter";

export interface UserOnboardingContext {
avatar: string | null;
myDevice: string;
devices: IMyDevice[];
dmRooms: {[userId: string]: Room};
hasAvatar: boolean;
hasDevices: boolean;
hasDmRooms: boolean;
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 that’s 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 [], R>(value: (...values: T) => R): (...values: T) => R {
const ref = useRef(value);
ref.current = value;
return useCallback(
(...values: T) => ref.current(...values),
[],
);
justjanne marked this conversation as resolved.
Show resolved Hide resolved
}

export function useUserOnboardingContext(): UserOnboardingContext | null {
const [context, setContext] = useState<UserOnboardingContext | null>(null);

const cli = MatrixClientPeg.get();
const handler = useCallback(async () => {
try {
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);
const handler = useRefOf(
useCallback(async () => {
justjanne marked this conversation as resolved.
Show resolved Hide resolved
try {
let hasAvatar = context?.hasAvatar;
if (!hasAvatar) {
const profile = await cli.getProfileInfo(cli.getUserId());
hasAvatar = Boolean(profile?.avatar_url);
}

let hasDevices = context?.hasDevices;
if (!hasDevices) {
const myDevice = cli.getDeviceId();
const devices = await cli.getDevices();
hasDevices = Boolean(devices.devices.find(device => device.device_id !== myDevice));
}
justjanne marked this conversation as resolved.
Show resolved Hide resolved

let hasDmRooms = context?.hasDmRooms;
if (!hasDmRooms) {
const dmRooms = DMRoomMap.shared().getUniqueRoomsWithIndividuals() ?? {};
hasDmRooms = Boolean(Object.keys(dmRooms).length);
}

let hasNotificationsEnabled = context?.hasNotificationsEnabled;
if (!hasNotificationsEnabled) {
hasNotificationsEnabled = Notifier.isPossible();
}

if (hasAvatar !== context?.hasAvatar
|| hasDevices !== context?.hasDevices
|| hasDmRooms !== context?.hasDmRooms
|| hasNotificationsEnabled !== context?.hasNotificationsEnabled) {
setContext({ hasAvatar, hasDevices, hasDmRooms, hasNotificationsEnabled });
}
} catch (e) {
logger.warn("Could not load context for user onboarding task list: ", e);
setContext(null);
}
}, [context, cli]),
);

useEffect(() => {
const handle = setInterval(handler, 2000);
handler();
let handle: number | null = null;
let enabled = true;
const repeater = async () => {
if (handle !== null) {
clearTimeout(handle);
handle = null;
}
await handler();
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 () => {
if (handle) {
clearInterval(handle);
enabled = false;
cli.off(ClientEvent.AccountData, repeater);
if (handle !== null) {
clearTimeout(handle);
handle = null;
}
};
}, [handler]);
}, [cli, handler]);

return context;
}
16 changes: 6 additions & 10 deletions src/hooks/useUserOnboardingTasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,6 @@ interface InternalUserOnboardingTask extends UserOnboardingTask {
completed: (ctx: UserOnboardingContext) => boolean;
}

const hasOpenDMs = (ctx: UserOnboardingContext) => Boolean(Object.entries(ctx.dmRooms).length);

const onClickStartDm = (ev: ButtonEvent) => {
PosthogTrackers.trackInteraction("WebUserOnboardingTaskSendDm", ev);
defaultDispatcher.dispatch({ action: 'view_create_chat' });
Expand All @@ -64,7 +62,7 @@ const tasks: InternalUserOnboardingTask[] = [
id: "find-friends",
title: _t("Find and invite your friends"),
description: _t("It’s what you’re here for, so lets get to it"),
completed: hasOpenDMs,
completed: (ctx: UserOnboardingContext) => ctx.hasDmRooms,
relevant: [UseCase.PersonalMessaging, UseCase.Skip],
action: {
label: _t("Find friends"),
Expand All @@ -75,7 +73,7 @@ const tasks: InternalUserOnboardingTask[] = [
id: "find-coworkers",
title: _t("Find and invite your co-workers"),
description: _t("Get stuff done by finding your teammates"),
completed: hasOpenDMs,
completed: (ctx: UserOnboardingContext) => ctx.hasDmRooms,
relevant: [UseCase.WorkMessaging],
action: {
label: _t("Find people"),
Expand All @@ -86,7 +84,7 @@ const tasks: InternalUserOnboardingTask[] = [
id: "find-community-members",
title: _t("Find and invite your community members"),
description: _t("Get stuff done by finding your teammates"),
completed: hasOpenDMs,
completed: (ctx: UserOnboardingContext) => ctx.hasDmRooms,
relevant: [UseCase.CommunityMessaging],
action: {
label: _t("Find people"),
Expand All @@ -97,9 +95,7 @@ const tasks: InternalUserOnboardingTask[] = [
id: "download-apps",
title: _t("Download Element"),
description: _t("Don’t miss a thing by taking Element with you"),
completed: (ctx: UserOnboardingContext) => {
return Boolean(ctx.devices.filter(it => it.device_id !== ctx.myDevice).length);
},
completed: (ctx: UserOnboardingContext) => ctx.hasDevices,
action: {
label: _t("Download apps"),
onClick: (ev: ButtonEvent) => {
Expand All @@ -112,7 +108,7 @@ const tasks: InternalUserOnboardingTask[] = [
id: "setup-profile",
title: _t("Set up your profile"),
description: _t("Make sure people know it’s really you"),
completed: (info: UserOnboardingContext) => Boolean(info.avatar),
completed: (ctx: UserOnboardingContext) => ctx.hasAvatar,
action: {
label: _t("Your profile"),
onClick: (ev: ButtonEvent) => {
Expand All @@ -128,7 +124,7 @@ const tasks: InternalUserOnboardingTask[] = [
id: "permission-notifications",
title: _t("Turn on notifications"),
description: _t("Don’t miss a reply or important message"),
completed: () => Notifier.isPossible(),
completed: (ctx: UserOnboardingContext) => ctx.hasNotificationsEnabled,
action: {
label: _t("Enable notifications"),
onClick: (ev: ButtonEvent) => {
Expand Down