mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
feat: Add a preference for desktop notification badge off/count/indicator (#11436)
This commit is contained in:
@@ -4,8 +4,10 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import { NotificationBadgeType, UserPreference } from "@shared/types";
|
||||
import Notification, { type NotificationFilter } from "~/models/Notification";
|
||||
import { markNotificationsAsRead } from "~/actions/definitions/notifications";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import NotificationMenu from "~/menus/NotificationMenu";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
@@ -34,6 +36,7 @@ function Notifications(
|
||||
ref: React.RefObject<HTMLDivElement>
|
||||
) {
|
||||
const { notifications } = useStores();
|
||||
const user = useCurrentUser();
|
||||
const { t } = useTranslation();
|
||||
const [filter, setFilter] = React.useState<NotificationFilter>("all");
|
||||
|
||||
@@ -61,25 +64,34 @@ function Notifications(
|
||||
);
|
||||
}, [notifications.active, filter]);
|
||||
|
||||
const badgeType = user.getPreference(UserPreference.NotificationBadge);
|
||||
const unreadCount = notifications.approximateUnreadCount;
|
||||
|
||||
// Update the notification count in the dock icon, if possible.
|
||||
React.useEffect(() => {
|
||||
// Account for old versions of the desktop app that don't have the
|
||||
// setNotificationCount method on the bridge.
|
||||
if (Desktop.bridge && "setNotificationCount" in Desktop.bridge) {
|
||||
void Desktop.bridge.setNotificationCount(
|
||||
notifications.approximateUnreadCount
|
||||
);
|
||||
if (badgeType === NotificationBadgeType.Disabled || unreadCount === 0) {
|
||||
void Desktop.bridge.setNotificationCount(0);
|
||||
} else if (badgeType === NotificationBadgeType.Count) {
|
||||
void Desktop.bridge.setNotificationCount(unreadCount);
|
||||
} else {
|
||||
void Desktop.bridge.setNotificationCount("・");
|
||||
}
|
||||
}
|
||||
|
||||
// PWA badging
|
||||
if ("setAppBadge" in navigator) {
|
||||
if (notifications.approximateUnreadCount) {
|
||||
void navigator.setAppBadge(notifications.approximateUnreadCount);
|
||||
if (unreadCount > 0 && badgeType !== NotificationBadgeType.Disabled) {
|
||||
void navigator.setAppBadge(
|
||||
badgeType === NotificationBadgeType.Count ? unreadCount : undefined
|
||||
);
|
||||
} else {
|
||||
void navigator.clearAppBadge();
|
||||
}
|
||||
}
|
||||
}, [notifications.approximateUnreadCount]);
|
||||
}, [unreadCount, badgeType]);
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
@@ -105,7 +117,7 @@ function Notifications(
|
||||
short
|
||||
nude
|
||||
/>
|
||||
{notifications.approximateUnreadCount > 0 && (
|
||||
{unreadCount > 0 && (
|
||||
<Tooltip content={t("Mark all as read")}>
|
||||
<Button
|
||||
action={markNotificationsAsRead}
|
||||
|
||||
+12
-5
@@ -231,10 +231,14 @@ class User extends ParanoidModel implements Searchable {
|
||||
* @param key The UserPreference key to retrieve
|
||||
* @returns The value
|
||||
*/
|
||||
getPreference(key: UserPreference, defaultValue = false): boolean {
|
||||
return (
|
||||
this.preferences?.[key] ?? UserPreferenceDefaults[key] ?? defaultValue
|
||||
);
|
||||
getPreference<K extends UserPreference>(
|
||||
key: K,
|
||||
defaultValue?: UserPreferences[K]
|
||||
): NonNullable<UserPreferences[K]> {
|
||||
return (this.preferences?.[key] ??
|
||||
UserPreferenceDefaults[key] ??
|
||||
defaultValue ??
|
||||
false) as NonNullable<UserPreferences[K]>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -243,7 +247,10 @@ class User extends ParanoidModel implements Searchable {
|
||||
* @param key The UserPreference key to retrieve
|
||||
* @param value The value to set
|
||||
*/
|
||||
setPreference(key: UserPreference, value: boolean) {
|
||||
setPreference<K extends UserPreference>(
|
||||
key: K,
|
||||
value: NonNullable<UserPreferences[K]>
|
||||
) {
|
||||
this.preferences = {
|
||||
...this.preferences,
|
||||
[key]: value,
|
||||
|
||||
@@ -4,7 +4,11 @@ import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { languageOptions as availableLanguages } from "@shared/i18n";
|
||||
import { TeamPreference, UserPreference } from "@shared/types";
|
||||
import {
|
||||
NotificationBadgeType,
|
||||
TeamPreference,
|
||||
UserPreference,
|
||||
} from "@shared/types";
|
||||
import { Theme } from "~/stores/UiStore";
|
||||
import Button from "~/components/Button";
|
||||
import Heading from "~/components/Heading";
|
||||
@@ -95,6 +99,39 @@ function Preferences() {
|
||||
[user, t]
|
||||
);
|
||||
|
||||
const notificationBadgeOptions: Option[] = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
type: "item",
|
||||
label: t("Disabled"),
|
||||
value: NotificationBadgeType.Disabled,
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
label: t("Unread count"),
|
||||
value: NotificationBadgeType.Count,
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
label: t("Unread indicator"),
|
||||
value: NotificationBadgeType.Indicator,
|
||||
},
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const handleNotificationBadgeChange = React.useCallback(
|
||||
async (value: string) => {
|
||||
user.setPreference(
|
||||
UserPreference.NotificationBadge,
|
||||
value as NotificationBadgeType
|
||||
);
|
||||
await user.save();
|
||||
toast.success(t("Preferences saved"));
|
||||
},
|
||||
[user, t]
|
||||
);
|
||||
|
||||
const handleLanguageChange = React.useCallback(
|
||||
async (language: string) => {
|
||||
await user.save({ language });
|
||||
@@ -230,7 +267,6 @@ function Preferences() {
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
border={false}
|
||||
name={UserPreference.EnableSmartText}
|
||||
label={t("Smart text replacements")}
|
||||
description={t(
|
||||
@@ -244,6 +280,22 @@ function Preferences() {
|
||||
onChange={handleEnableSmartTextChange}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
border={false}
|
||||
name={UserPreference.NotificationBadge}
|
||||
label={t("Notification badge")}
|
||||
description={t(
|
||||
"Choose how unread notifications are indicated on the app icon."
|
||||
)}
|
||||
>
|
||||
<InputSelect
|
||||
options={notificationBadgeOptions}
|
||||
value={user.getPreference(UserPreference.NotificationBadge)}
|
||||
onChange={handleNotificationBadgeChange}
|
||||
label={t("Notification badge")}
|
||||
hideLabel
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
{can.delete && (
|
||||
<>
|
||||
|
||||
Vendored
+1
-1
@@ -63,7 +63,7 @@ declare global {
|
||||
/**
|
||||
* Set the badge on the app icon.
|
||||
*/
|
||||
setNotificationCount: (count: number) => Promise<void>;
|
||||
setNotificationCount: (count: number | string) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Registers a callback to be called when the window is focused.
|
||||
|
||||
+10
-5
@@ -411,7 +411,10 @@ class User extends ParanoidModel<
|
||||
* @param value Sets the preference value
|
||||
* @returns The current user preferences
|
||||
*/
|
||||
public setPreference = (preference: UserPreference, value: boolean) => {
|
||||
public setPreference = <K extends UserPreference>(
|
||||
preference: K,
|
||||
value: NonNullable<UserPreferences[K]>
|
||||
) => {
|
||||
if (!this.preferences) {
|
||||
this.preferences = {};
|
||||
}
|
||||
@@ -428,10 +431,12 @@ class User extends ParanoidModel<
|
||||
* @param preference The user preference to retrieve
|
||||
* @returns The preference value if set, else the default value.
|
||||
*/
|
||||
public getPreference = (preference: UserPreference) =>
|
||||
this.preferences?.[preference] ??
|
||||
UserPreferenceDefaults[preference] ??
|
||||
false;
|
||||
public getPreference = <K extends UserPreference>(
|
||||
preference: K
|
||||
): NonNullable<UserPreferences[K]> =>
|
||||
(this.preferences?.[preference] ??
|
||||
UserPreferenceDefaults[preference] ??
|
||||
false) as NonNullable<UserPreferences[K]>;
|
||||
|
||||
/**
|
||||
* Returns the user's active groups.
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { z } from "zod";
|
||||
import { NotificationEventType, UserPreference, UserRole } from "@shared/types";
|
||||
import {
|
||||
NotificationBadgeType,
|
||||
NotificationEventType,
|
||||
UserPreference,
|
||||
UserRole,
|
||||
} from "@shared/types";
|
||||
import { locales } from "@shared/utils/date";
|
||||
import User from "@server/models/User";
|
||||
import { zodEnumFromObjectKeys, zodTimezone } from "@server/utils/zod";
|
||||
@@ -90,7 +95,12 @@ export const UsersUpdateSchema = BaseSchema.extend({
|
||||
name: z.string().optional(),
|
||||
avatarUrl: z.string().nullish(),
|
||||
language: zodEnumFromObjectKeys(locales).optional(),
|
||||
preferences: z.record(z.nativeEnum(UserPreference), z.boolean()).optional(),
|
||||
preferences: z
|
||||
.record(
|
||||
z.nativeEnum(UserPreference),
|
||||
z.union([z.boolean(), z.nativeEnum(NotificationBadgeType)])
|
||||
)
|
||||
.optional(),
|
||||
timezone: zodTimezone().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Router from "koa-router";
|
||||
import type { WhereOptions } from "sequelize";
|
||||
import { Op, Sequelize } from "sequelize";
|
||||
import type { UserPreference } from "@shared/types";
|
||||
import type { UserPreferences } from "@shared/types";
|
||||
import { UserRole } from "@shared/types";
|
||||
import { UserRoleHelper } from "@shared/utils/UserRoleHelper";
|
||||
import { settingsPath } from "@shared/utils/routeHelpers";
|
||||
@@ -332,9 +332,10 @@ router.post(
|
||||
user.language = language;
|
||||
}
|
||||
if (preferences) {
|
||||
for (const key of Object.keys(preferences) as Array<UserPreference>) {
|
||||
user.setPreference(key, preferences[key] as boolean);
|
||||
}
|
||||
user.preferences = {
|
||||
...user.preferences,
|
||||
...(preferences as UserPreferences),
|
||||
};
|
||||
}
|
||||
if (timezone) {
|
||||
user.timezone = timezone;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
TeamPreference,
|
||||
UserPreference,
|
||||
EmailDisplay,
|
||||
NotificationBadgeType,
|
||||
} from "./types";
|
||||
|
||||
export const MAX_AVATAR_DISPLAY = 6;
|
||||
@@ -42,4 +43,5 @@ export const UserPreferenceDefaults: UserPreferences = {
|
||||
[UserPreference.CodeBlockLineNumers]: true,
|
||||
[UserPreference.SortCommentsByOrderInDocument]: true,
|
||||
[UserPreference.EnableSmartText]: true,
|
||||
[UserPreference.NotificationBadge]: NotificationBadgeType.Count,
|
||||
};
|
||||
|
||||
@@ -1243,6 +1243,8 @@
|
||||
"Manage when and where you receive email notifications.": "Manage when and where you receive email notifications.",
|
||||
"The email integration is currently disabled. Please set the associated environment variables and restart the server to enable notifications.": "The email integration is currently disabled. Please set the associated environment variables and restart the server to enable notifications.",
|
||||
"Preferences saved": "Preferences saved",
|
||||
"Unread count": "Unread count",
|
||||
"Unread indicator": "Unread indicator",
|
||||
"Delete account": "Delete account",
|
||||
"Manage settings that affect your personal experience.": "Manage settings that affect your personal experience.",
|
||||
"Language": "Language",
|
||||
@@ -1257,6 +1259,8 @@
|
||||
"Automatically return to the document you were last viewing when the app is re-opened.": "Automatically return to the document you were last viewing when the app is re-opened.",
|
||||
"Smart text replacements": "Smart text replacements",
|
||||
"Auto-format text by replacing shortcuts with symbols, dashes, smart quotes, and other typographical elements.": "Auto-format text by replacing shortcuts with symbols, dashes, smart quotes, and other typographical elements.",
|
||||
"Notification badge": "Notification badge",
|
||||
"Choose how unread notifications are indicated on the app icon.": "Choose how unread notifications are indicated on the app icon.",
|
||||
"You may delete your account at any time, note that this is unrecoverable": "You may delete your account at any time, note that this is unrecoverable",
|
||||
"Profile saved": "Profile saved",
|
||||
"Profile picture updated": "Profile picture updated",
|
||||
|
||||
+21
-1
@@ -271,9 +271,29 @@ export enum UserPreference {
|
||||
SortCommentsByOrderInDocument = "sortCommentsByOrderInDocument",
|
||||
/** Whether smart text replacements should be enabled. */
|
||||
EnableSmartText = "enableSmartText",
|
||||
/** The style of notification badge to display. */
|
||||
NotificationBadge = "notificationBadge",
|
||||
}
|
||||
|
||||
export type UserPreferences = { [key in UserPreference]?: boolean };
|
||||
export enum NotificationBadgeType {
|
||||
/** Do not show a notification badge. */
|
||||
Disabled = "disabled",
|
||||
/** Show the unread notification count. */
|
||||
Count = "count",
|
||||
/** Show an unread indicator dot. */
|
||||
Indicator = "indicator",
|
||||
}
|
||||
|
||||
export type UserPreferences = {
|
||||
[UserPreference.RememberLastPath]?: boolean;
|
||||
[UserPreference.UseCursorPointer]?: boolean;
|
||||
[UserPreference.CodeBlockLineNumers]?: boolean;
|
||||
[UserPreference.SeamlessEdit]?: boolean;
|
||||
[UserPreference.FullWidthDocuments]?: boolean;
|
||||
[UserPreference.SortCommentsByOrderInDocument]?: boolean;
|
||||
[UserPreference.EnableSmartText]?: boolean;
|
||||
[UserPreference.NotificationBadge]?: NotificationBadgeType;
|
||||
};
|
||||
|
||||
export type SourceMetadata = {
|
||||
/** The original source file name. */
|
||||
|
||||
Reference in New Issue
Block a user