Rework how the onboarding notifications task works (#12839)

* Rework how the onboarding notifications task works

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Add test

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Michael Telatynski 2024-07-30 15:55:08 +01:00 committed by GitHub
parent dd20741b87
commit 02047243f0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 85 additions and 17 deletions

View file

@ -29,6 +29,7 @@ import {
IRoomTimelineData, IRoomTimelineData,
M_LOCATION, M_LOCATION,
EventType, EventType,
TypedEventEmitter,
} from "matrix-js-sdk/src/matrix"; } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { PermissionChanged as PermissionChangedEvent } from "@matrix-org/analytics-events/types/typescript/PermissionChanged"; import { PermissionChanged as PermissionChangedEvent } from "@matrix-org/analytics-events/types/typescript/PermissionChanged";
@ -103,7 +104,15 @@ const msgTypeHandlers: Record<string, (event: MatrixEvent) => string | null> = {
}, },
}; };
class NotifierClass { export const enum NotifierEvent {
NotificationHiddenChange = "notification_hidden_change",
}
interface EmittedEvents {
[NotifierEvent.NotificationHiddenChange]: (hidden: boolean) => void;
}
class NotifierClass extends TypedEventEmitter<keyof EmittedEvents, EmittedEvents> {
private notifsByRoom: Record<string, Notification[]> = {}; private notifsByRoom: Record<string, Notification[]> = {};
// A list of event IDs that we've received but need to wait until // A list of event IDs that we've received but need to wait until
@ -357,6 +366,7 @@ class NotifierClass {
if (persistent && global.localStorage) { if (persistent && global.localStorage) {
global.localStorage.setItem("notifications_hidden", String(hidden)); global.localStorage.setItem("notifications_hidden", String(hidden));
} }
this.emit(NotifierEvent.NotificationHiddenChange, hidden);
} }
public shouldShowPrompt(): boolean { public shouldShowPrompt(): boolean {

View file

@ -18,15 +18,17 @@ import { logger } from "matrix-js-sdk/src/logger";
import { ClientEvent, MatrixClient } from "matrix-js-sdk/src/matrix"; import { ClientEvent, MatrixClient } from "matrix-js-sdk/src/matrix";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Notifier } from "../Notifier"; import { Notifier, NotifierEvent } from "../Notifier";
import DMRoomMap from "../utils/DMRoomMap"; import DMRoomMap from "../utils/DMRoomMap";
import { useMatrixClientContext } from "../contexts/MatrixClientContext"; import { useMatrixClientContext } from "../contexts/MatrixClientContext";
import { useSettingValue } from "./useSettings";
import { useEventEmitter } from "./useEventEmitter";
export interface UserOnboardingContext { export interface UserOnboardingContext {
hasAvatar: boolean; hasAvatar: boolean;
hasDevices: boolean; hasDevices: boolean;
hasDmRooms: boolean; hasDmRooms: boolean;
hasNotificationsEnabled: boolean; showNotificationsPrompt: boolean;
} }
const USER_ONBOARDING_CONTEXT_INTERVAL = 5000; const USER_ONBOARDING_CONTEXT_INTERVAL = 5000;
@ -82,6 +84,18 @@ function useUserOnboardingContextValue<T>(defaultValue: T, callback: (cli: Matri
return value; return value;
} }
function useShowNotificationsPrompt(): boolean {
const [value, setValue] = useState<boolean>(Notifier.shouldShowPrompt());
useEventEmitter(Notifier, NotifierEvent.NotificationHiddenChange, () => {
setValue(Notifier.shouldShowPrompt());
});
const setting = useSettingValue("notificationsEnabled");
useEffect(() => {
setValue(Notifier.shouldShowPrompt());
}, [setting]);
return value;
}
export function useUserOnboardingContext(): UserOnboardingContext { export function useUserOnboardingContext(): UserOnboardingContext {
const hasAvatar = useUserOnboardingContextValue(false, async (cli) => { const hasAvatar = useUserOnboardingContextValue(false, async (cli) => {
const profile = await cli.getProfileInfo(cli.getUserId()!); const profile = await cli.getProfileInfo(cli.getUserId()!);
@ -96,12 +110,10 @@ export function useUserOnboardingContext(): UserOnboardingContext {
const dmRooms = DMRoomMap.shared().getUniqueRoomsWithIndividuals() ?? {}; const dmRooms = DMRoomMap.shared().getUniqueRoomsWithIndividuals() ?? {};
return Boolean(Object.keys(dmRooms).length); return Boolean(Object.keys(dmRooms).length);
}); });
const hasNotificationsEnabled = useUserOnboardingContextValue(false, async () => { const showNotificationsPrompt = useShowNotificationsPrompt();
return Notifier.isPossible();
});
return useMemo( return useMemo(
() => ({ hasAvatar, hasDevices, hasDmRooms, hasNotificationsEnabled }), () => ({ hasAvatar, hasDevices, hasDmRooms, showNotificationsPrompt }),
[hasAvatar, hasDevices, hasDmRooms, hasNotificationsEnabled], [hasAvatar, hasDevices, hasDmRooms, showNotificationsPrompt],
); );
} }

View file

@ -136,14 +136,18 @@ const tasks: UserOnboardingTask[] = [
id: "permission-notifications", id: "permission-notifications",
title: _t("onboarding|enable_notifications"), title: _t("onboarding|enable_notifications"),
description: _t("onboarding|enable_notifications_description"), description: _t("onboarding|enable_notifications_description"),
completed: (ctx: UserOnboardingContext) => ctx.hasNotificationsEnabled, completed: (ctx: UserOnboardingContext) => !ctx.showNotificationsPrompt,
action: { action: {
label: _t("onboarding|enable_notifications_action"), label: _t("onboarding|enable_notifications_action"),
onClick: (ev: ButtonEvent) => { onClick: (ev: ButtonEvent) => {
PosthogTrackers.trackInteraction("WebUserOnboardingTaskEnableNotifications", ev); PosthogTrackers.trackInteraction("WebUserOnboardingTaskEnableNotifications", ev);
Notifier.setEnabled(true); defaultDispatcher.dispatch({
action: Action.ViewUserSettings,
initialTabId: UserTab.Notifications,
});
Notifier.setPromptHidden(true);
}, },
hideOnComplete: true, hideOnComplete: !Notifier.isPossible(),
}, },
}, },
]; ];

View file

@ -1652,8 +1652,8 @@
"download_brand_desktop": "Download %(brand)s Desktop", "download_brand_desktop": "Download %(brand)s Desktop",
"download_f_droid": "Get it on F-Droid", "download_f_droid": "Get it on F-Droid",
"download_google_play": "Get it on Google Play", "download_google_play": "Get it on Google Play",
"enable_notifications": "Turn on notifications", "enable_notifications": "Turn on desktop notifications",
"enable_notifications_action": "Enable notifications", "enable_notifications_action": "Open settings",
"enable_notifications_description": "Dont miss a reply or important message", "enable_notifications_description": "Dont miss a reply or important message",
"explore_rooms": "Explore Public Rooms", "explore_rooms": "Explore Public Rooms",
"find_community_members": "Find and invite your community members", "find_community_members": "Find and invite your community members",

View file

@ -20,9 +20,11 @@ import GenericToast from "../components/views/toasts/GenericToast";
import ToastStore from "../stores/ToastStore"; import ToastStore from "../stores/ToastStore";
import { MatrixClientPeg } from "../MatrixClientPeg"; import { MatrixClientPeg } from "../MatrixClientPeg";
import { getLocalNotificationAccountDataEventType } from "../utils/notifications"; import { getLocalNotificationAccountDataEventType } from "../utils/notifications";
import SettingsStore from "../settings/SettingsStore";
import { SettingLevel } from "../settings/SettingLevel";
const onAccept = (): void => { const onAccept = async (): Promise<void> => {
Notifier.setEnabled(true); await SettingsStore.setValue("notificationsEnabled", null, SettingLevel.DEVICE, true);
const cli = MatrixClientPeg.safeGet(); const cli = MatrixClientPeg.safeGet();
const eventType = getLocalNotificationAccountDataEventType(cli.deviceId!); const eventType = getLocalNotificationAccountDataEventType(cli.deviceId!);
cli.setAccountData(eventType, { cli.setAccountData(eventType, {

View file

@ -14,9 +14,16 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from "react";
import { renderHook } from "@testing-library/react-hooks"; import { renderHook } from "@testing-library/react-hooks";
import { waitFor } from "@testing-library/react";
import { useUserOnboardingTasks } from "../../src/hooks/useUserOnboardingTasks"; import { useUserOnboardingTasks } from "../../src/hooks/useUserOnboardingTasks";
import { useUserOnboardingContext } from "../../src/hooks/useUserOnboardingContext";
import { stubClient } from "../test-utils";
import MatrixClientContext from "../../src/contexts/MatrixClientContext";
import DMRoomMap from "../../src/utils/DMRoomMap";
import PlatformPeg from "../../src/PlatformPeg";
describe("useUserOnboardingTasks", () => { describe("useUserOnboardingTasks", () => {
it.each([ it.each([
@ -25,7 +32,7 @@ describe("useUserOnboardingTasks", () => {
hasAvatar: false, hasAvatar: false,
hasDevices: false, hasDevices: false,
hasDmRooms: false, hasDmRooms: false,
hasNotificationsEnabled: false, showNotificationsPrompt: false,
}, },
}, },
{ {
@ -33,7 +40,7 @@ describe("useUserOnboardingTasks", () => {
hasAvatar: true, hasAvatar: true,
hasDevices: false, hasDevices: false,
hasDmRooms: false, hasDmRooms: false,
hasNotificationsEnabled: true, showNotificationsPrompt: true,
}, },
}, },
])("sequence should stay static", async ({ context }) => { ])("sequence should stay static", async ({ context }) => {
@ -46,4 +53,37 @@ describe("useUserOnboardingTasks", () => {
expect(result.current[3].id).toBe("setup-profile"); expect(result.current[3].id).toBe("setup-profile");
expect(result.current[4].id).toBe("permission-notifications"); expect(result.current[4].id).toBe("permission-notifications");
}); });
it("should mark desktop notifications task completed on click", async () => {
jest.spyOn(PlatformPeg, "get").mockReturnValue({
supportsNotifications: jest.fn().mockReturnValue(true),
maySendNotifications: jest.fn().mockReturnValue(false),
} as any);
const cli = stubClient();
cli.pushRules = {
global: {
override: [
{
rule_id: ".m.rule.master",
enabled: false,
actions: [],
default: true,
},
],
},
};
DMRoomMap.makeShared(cli);
const context = renderHook(() => useUserOnboardingContext(), {
wrapper: (props) => {
return <MatrixClientContext.Provider value={cli}>{props.children}</MatrixClientContext.Provider>;
},
});
const { result, rerender } = renderHook(() => useUserOnboardingTasks(context.result.current));
expect(result.current[4].id).toBe("permission-notifications");
await waitFor(() => expect(result.current[4].completed).toBe(false));
result.current[4].action!.onClick!({ type: "click" } as any);
rerender();
await waitFor(() => expect(result.current[4].completed).toBe(true));
});
}); });