New User Onboarding Task List (#9083)
* Improve type of AccessibleButton to accurately represent available props * Update analytics events
This commit is contained in:
parent
45f6c32eb6
commit
1e4c336fed
32 changed files with 1261 additions and 22 deletions
55
src/hooks/useAnimation.ts
Normal file
55
src/hooks/useAnimation.ts
Normal file
|
@ -0,0 +1,55 @@
|
|||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
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 { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
import SettingsStore from "../settings/SettingsStore";
|
||||
|
||||
const debuglog = (...args: any[]) => {
|
||||
if (SettingsStore.getValue("debug_animation")) {
|
||||
logger.log.call(console, "Animation debuglog:", ...args);
|
||||
}
|
||||
};
|
||||
|
||||
export function useAnimation(enabled: boolean, callback: (timestamp: DOMHighResTimeStamp) => boolean) {
|
||||
const handle = useRef<number | null>(null);
|
||||
|
||||
const handler = useCallback(
|
||||
(timestamp: DOMHighResTimeStamp) => {
|
||||
if (callback(timestamp)) {
|
||||
handle.current = requestAnimationFrame(handler);
|
||||
} else {
|
||||
debuglog("Finished animation!");
|
||||
}
|
||||
},
|
||||
[callback],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
debuglog("Started animation!");
|
||||
if (enabled) {
|
||||
handle.current = requestAnimationFrame(handler);
|
||||
}
|
||||
return () => {
|
||||
if (handle.current) {
|
||||
debuglog("Aborted animation!");
|
||||
cancelAnimationFrame(handle.current);
|
||||
handle.current = null;
|
||||
}
|
||||
};
|
||||
}, [enabled, handler]);
|
||||
}
|
25
src/hooks/useIsInitialSyncComplete.ts
Normal file
25
src/hooks/useIsInitialSyncComplete.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
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 { ClientEvent } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { MatrixClientPeg } from "../MatrixClientPeg";
|
||||
import { useEventEmitterState } from "./useEventEmitter";
|
||||
|
||||
export function useInitialSyncComplete(): boolean {
|
||||
const cli = MatrixClientPeg.get();
|
||||
return useEventEmitterState(cli, ClientEvent.Sync, () => cli.isInitialSyncComplete());
|
||||
}
|
85
src/hooks/useSmoothAnimation.ts
Normal file
85
src/hooks/useSmoothAnimation.ts
Normal file
|
@ -0,0 +1,85 @@
|
|||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
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 { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import SettingsStore from "../settings/SettingsStore";
|
||||
import { useAnimation } from "./useAnimation";
|
||||
|
||||
const debuglog = (...args: any[]) => {
|
||||
if (SettingsStore.getValue("debug_animation")) {
|
||||
logger.log.call(console, "Animation debuglog:", ...args);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility function to smoothly animate to a certain target value
|
||||
* @param initialValue Initial value to be used as initial starting point
|
||||
* @param targetValue Desired value to animate to (can be changed repeatedly to whatever is current at that time)
|
||||
* @param duration Duration that each animation should take
|
||||
* @param enabled Whether the animation should run or not
|
||||
*/
|
||||
export function useSmoothAnimation(
|
||||
initialValue: number,
|
||||
targetValue: number,
|
||||
duration: number,
|
||||
enabled: boolean,
|
||||
): number {
|
||||
const state = useRef<{ timestamp: DOMHighResTimeStamp | null, value: number }>({
|
||||
timestamp: null,
|
||||
value: initialValue,
|
||||
});
|
||||
const [currentValue, setCurrentValue] = useState<number>(initialValue);
|
||||
const [currentStepSize, setCurrentStepSize] = useState<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
const totalDelta = targetValue - state.current.value;
|
||||
setCurrentStepSize(totalDelta / duration);
|
||||
state.current = { ...state.current, timestamp: null };
|
||||
}, [duration, targetValue]);
|
||||
|
||||
const update = useCallback(
|
||||
(timestamp: DOMHighResTimeStamp): boolean => {
|
||||
if (!state.current.timestamp) {
|
||||
state.current = { ...state.current, timestamp };
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Math.abs(currentStepSize) < Number.EPSILON) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const timeDelta = timestamp - state.current.timestamp;
|
||||
const valueDelta = currentStepSize * timeDelta;
|
||||
const maxValueDelta = targetValue - state.current.value;
|
||||
const clampedValueDelta = Math.sign(valueDelta) * Math.min(Math.abs(maxValueDelta), Math.abs(valueDelta));
|
||||
const value = state.current.value + clampedValueDelta;
|
||||
|
||||
debuglog(`Animating to ${targetValue} at ${value} timeDelta=${timeDelta}, valueDelta=${valueDelta}`);
|
||||
|
||||
setCurrentValue(value);
|
||||
state.current = { value, timestamp };
|
||||
|
||||
return Math.abs(maxValueDelta) > Number.EPSILON;
|
||||
},
|
||||
[currentStepSize, targetValue],
|
||||
);
|
||||
|
||||
useAnimation(enabled, update);
|
||||
|
||||
return currentValue;
|
||||
}
|
62
src/hooks/useUserOnboardingContext.ts
Normal file
62
src/hooks/useUserOnboardingContext.ts
Normal file
|
@ -0,0 +1,62 @@
|
|||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
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, useEffect, useState } from "react";
|
||||
import { ClientEvent, IMyDevice, Room } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { MatrixClientPeg } from "../MatrixClientPeg";
|
||||
import DMRoomMap from "../utils/DMRoomMap";
|
||||
import { useEventEmitter } from "./useEventEmitter";
|
||||
|
||||
export interface UserOnboardingContext {
|
||||
avatar: string | null;
|
||||
myDevice: string;
|
||||
devices: IMyDevice[];
|
||||
dmRooms: {[userId: string]: Room};
|
||||
}
|
||||
|
||||
export function useUserOnboardingContext(): UserOnboardingContext | null {
|
||||
const [context, setContext] = useState<UserOnboardingContext | null>(null);
|
||||
|
||||
const cli = MatrixClientPeg.get();
|
||||
const handler = useCallback(async () => {
|
||||
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,
|
||||
});
|
||||
}, [cli]);
|
||||
|
||||
useEventEmitter(cli, ClientEvent.AccountData, handler);
|
||||
useEffect(() => {
|
||||
const handle = setInterval(handler, 2000);
|
||||
handler();
|
||||
return () => {
|
||||
if (handle) {
|
||||
clearInterval(handle);
|
||||
}
|
||||
};
|
||||
}, [handler]);
|
||||
|
||||
return context;
|
||||
}
|
150
src/hooks/useUserOnboardingTasks.ts
Normal file
150
src/hooks/useUserOnboardingTasks.ts
Normal file
|
@ -0,0 +1,150 @@
|
|||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
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 { useMemo } from "react";
|
||||
|
||||
import { UserTab } from "../components/views/dialogs/UserTab";
|
||||
import { ButtonEvent } from "../components/views/elements/AccessibleButton";
|
||||
import { Action } from "../dispatcher/actions";
|
||||
import defaultDispatcher from "../dispatcher/dispatcher";
|
||||
import { _t } from "../languageHandler";
|
||||
import { Notifier } from "../Notifier";
|
||||
import PosthogTrackers from "../PosthogTrackers";
|
||||
import { UseCase } from "../settings/enums/UseCase";
|
||||
import { useSettingValue } from "./useSettings";
|
||||
import { UserOnboardingContext, useUserOnboardingContext } from "./useUserOnboardingContext";
|
||||
|
||||
export interface UserOnboardingTask {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
relevant?: UseCase[];
|
||||
action?: {
|
||||
label: string;
|
||||
onClick?: (ev?: ButtonEvent) => void;
|
||||
href?: string;
|
||||
hideOnComplete?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
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' });
|
||||
};
|
||||
|
||||
const tasks: InternalUserOnboardingTask[] = [
|
||||
{
|
||||
id: "create-account",
|
||||
title: _t("Create account"),
|
||||
description: _t("You made it!"),
|
||||
completed: () => true,
|
||||
},
|
||||
{
|
||||
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,
|
||||
relevant: [UseCase.PersonalMessaging, UseCase.Skip],
|
||||
action: {
|
||||
label: _t("Find friends"),
|
||||
onClick: onClickStartDm,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "find-coworkers",
|
||||
title: _t("Find and invite your co-workers"),
|
||||
description: _t("Get stuff done by finding your teammates"),
|
||||
completed: hasOpenDMs,
|
||||
relevant: [UseCase.WorkMessaging],
|
||||
action: {
|
||||
label: _t("Find people"),
|
||||
onClick: onClickStartDm,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "find-community-members",
|
||||
title: _t("Find and invite your community members"),
|
||||
description: _t("Get stuff done by finding your teammates"),
|
||||
completed: hasOpenDMs,
|
||||
relevant: [UseCase.CommunityMessaging],
|
||||
action: {
|
||||
label: _t("Find people"),
|
||||
onClick: onClickStartDm,
|
||||
},
|
||||
},
|
||||
{
|
||||
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);
|
||||
},
|
||||
action: {
|
||||
label: _t("Download apps"),
|
||||
href: "https://element.io/get-started#download",
|
||||
onClick: (ev: ButtonEvent) => {
|
||||
PosthogTrackers.trackInteraction("WebUserOnboardingTaskDownloadApps", ev);
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
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),
|
||||
action: {
|
||||
label: _t("Your profile"),
|
||||
onClick: (ev: ButtonEvent) => {
|
||||
PosthogTrackers.trackInteraction("WebUserOnboardingTaskSetupProfile", ev);
|
||||
defaultDispatcher.dispatch({
|
||||
action: Action.ViewUserSettings,
|
||||
initialTabId: UserTab.General,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "permission-notifications",
|
||||
title: _t("Turn on notifications"),
|
||||
description: _t("Don’t miss a reply or important message"),
|
||||
completed: () => Notifier.isPossible(),
|
||||
action: {
|
||||
label: _t("Enable notifications"),
|
||||
onClick: (ev: ButtonEvent) => {
|
||||
PosthogTrackers.trackInteraction("WebUserOnboardingTaskEnableNotifications", ev);
|
||||
Notifier.setEnabled(true);
|
||||
},
|
||||
hideOnComplete: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function useUserOnboardingTasks(): [UserOnboardingTask[], UserOnboardingTask[]] {
|
||||
const useCase = useSettingValue<UseCase | null>("FTUE.useCaseSelection") ?? UseCase.Skip;
|
||||
const relevantTasks = useMemo(
|
||||
() => tasks.filter(it => !it.relevant || it.relevant.includes(useCase)),
|
||||
[useCase],
|
||||
);
|
||||
const onboardingInfo = useUserOnboardingContext();
|
||||
const completedTasks = relevantTasks.filter(it => onboardingInfo && it.completed(onboardingInfo));
|
||||
return [completedTasks, relevantTasks.filter(it => !completedTasks.includes(it))];
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue