feat: Add a preference for desktop notification badge off/count/indicator (#11436)

This commit is contained in:
Tom Moor
2026-02-13 18:04:10 -05:00
committed by GitHub
parent 797c28a12e
commit 4f6ee1a00b
10 changed files with 140 additions and 27 deletions
+19 -7
View File
@@ -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
View File
@@ -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,
+54 -2
View File
@@ -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 && (
<>
+1 -1
View File
@@ -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
View File
@@ -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.
+12 -2
View File
@@ -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(),
}),
});
+5 -4
View File
@@ -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;
+2
View File
@@ -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
View File
@@ -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. */