From 9f9330e6b133834f9cf421484faef1a5fcd744cd Mon Sep 17 00:00:00 2001 From: "xan.lol" Date: Sun, 9 Aug 2026 18:09:54 -0700 Subject: [PATCH] Impressions settings for in-app notification badges --- src/lib/metrics-display.ts | 7 ++ .../RunesSettings/ImpressionsSettings.tsx | 72 ++++++++++++++++++- src/state/persisted/__tests__/schema.test.ts | 12 ++++ src/state/persisted/schema.ts | 8 +++ src/state/persisted/util.ts | 4 ++ .../metrics-display-preference.tsx | 65 ++++++++++++++++- src/state/preferences/settings-sync.tsx | 3 + src/view/shell/bottom-bar/BottomBar.tsx | 28 +++++++- src/view/shell/bottom-bar/BottomBarWeb.tsx | 25 ++++++- src/view/shell/desktop/LeftNav.tsx | 28 +++++++- 10 files changed, 240 insertions(+), 12 deletions(-) diff --git a/src/lib/metrics-display.ts b/src/lib/metrics-display.ts index 0e62eab9c..7577f7162 100644 --- a/src/lib/metrics-display.ts +++ b/src/lib/metrics-display.ts @@ -14,6 +14,13 @@ export const countsMetricsDisplaySchema = z.preprocess( ) export type CountsMetricsDisplay = z.infer +const notificationDotDisplayValues = ['hidden', 'visible', 'exact'] as const + +export const notificationDotDisplaySchema = z.enum(notificationDotDisplayValues) +export type NotificationDotDisplay = z.infer< + typeof notificationDotDisplaySchema +> + const followedByMetricsDisplayValues = [ 'hidden', 'lite', diff --git a/src/screens/Settings/RunesSettings/ImpressionsSettings.tsx b/src/screens/Settings/RunesSettings/ImpressionsSettings.tsx index 91cb0f17a..8b3ebaf1b 100644 --- a/src/screens/Settings/RunesSettings/ImpressionsSettings.tsx +++ b/src/screens/Settings/RunesSettings/ImpressionsSettings.tsx @@ -2,21 +2,28 @@ import {type ComponentProps} from 'react' import {View} from 'react-native' import {Trans, useLingui} from '@lingui/react/macro' -import {type CountsMetricsDisplay} from '#/lib/metrics-display' import { + type CountsMetricsDisplay, + type NotificationDotDisplay, +} from '#/lib/metrics-display' +import { + useChatsTabBadgeDisplay, useFollowedByMetricsDisplay, useFollowersMetricsDisplay, useFollowingMetricsDisplay, useLikesMetricsDisplay, + useNotificationsTabBadgeDisplay, usePostsMetricsDisplay, useQuotesMetricsDisplay, useReplyMetricsDisplay, useRepostsMetricsDisplay, useSavesMetricsDisplay, + useSetChatsTabBadgeDisplay, useSetFollowedByMetricsDisplay, useSetFollowersMetricsDisplay, useSetFollowingMetricsDisplay, useSetLikesMetricsDisplay, + useSetNotificationsTabBadgeDisplay, useSetPostsMetricsDisplay, useSetQuotesMetricsDisplay, useSetReplyMetricsDisplay, @@ -35,6 +42,7 @@ import * as SettingsList from '#/screens/Settings/components/SettingsList' import {atoms as a, useBreakpoints} from '#/alf' import * as Toggle from '#/components/forms/Toggle' import * as ToggleButton from '#/components/forms/ToggleButton' +import {Bell_Stroke2_Corner0_Rounded as BellIcon} from '#/components/icons/Bell' import {Person_Stroke2_Corner0_Rounded as PersonIcon} from '#/components/icons/Person' import {Reply as ReplyIcon} from '#/components/icons/Reply' import {Text} from '#/components/Typography' @@ -67,6 +75,10 @@ export function RunesImpressionsSettingsScreen() { const setShowFollowsYouBadge = useSetShowFollowsYouBadge() const showFollowedByOnOwnProfile = useShowFollowedByOnOwnProfile() const setShowFollowedByOnOwnProfile = useSetShowFollowedByOnOwnProfile() + const notificationsTabBadgeDisplay = useNotificationsTabBadgeDisplay() + const setNotificationsTabBadgeDisplay = useSetNotificationsTabBadgeDisplay() + const chatsTabBadgeDisplay = useChatsTabBadgeDisplay() + const setChatsTabBadgeDisplay = useSetChatsTabBadgeDisplay() const labels = useMetricDisplayLabels() @@ -130,7 +142,6 @@ export function RunesImpressionsSettingsScreen() { labels={labels} onChange={setFollowedByMetricsDisplay} /> - Enable extra "Follows you" label + + + + ) } +function TabBadgeRow({ + name, + value, + onChange, +}: { + name: string + value: NotificationDotDisplay + onChange: (value: NotificationDotDisplay) => void +}) { + const {t: l} = useLingui() + + const handleChange = (values: string[]) => { + const next = values[0] as NotificationDotDisplay | undefined + if (next === 'hidden' || next === 'visible' || next === 'exact') { + onChange(next) + } + } + + return ( + + {name} + + + + Hidden + + + + + Dot + + + + + Number + + + + + ) +} + function ImpressionsSectionHeader({ icon, label, diff --git a/src/state/persisted/__tests__/schema.test.ts b/src/state/persisted/__tests__/schema.test.ts index 68d8b0e9b..1418e108e 100644 --- a/src/state/persisted/__tests__/schema.test.ts +++ b/src/state/persisted/__tests__/schema.test.ts @@ -48,6 +48,8 @@ describe('persisted schema helpers', () => { defaults.thumbnailFormat, ) expect(normalizeData(parsed!).downloadFormat).toBe(defaults.downloadFormat) + expect(normalizeData(parsed!).notificationsTabBadgeDisplay).toBe('exact') + expect(normalizeData(parsed!).chatsTabBadgeDisplay).toBe('exact') }) it('preserves external embed prefs when defaults object is empty', () => { @@ -100,4 +102,14 @@ describe('persisted schema helpers', () => { expect(parsed).toBeDefined() expect(parsed!.followedByMetricsDisplay).toBe('visible') }) + + it('migrates the notification dot display to both tab badges', () => { + const parsed = tryParse( + JSON.stringify({...partialState, notificationDotDisplay: 'visible'}), + ) + + const normalized = normalizeData(parsed!) + expect(normalized.notificationsTabBadgeDisplay).toBe('visible') + expect(normalized.chatsTabBadgeDisplay).toBe('visible') + }) }) diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index b2967c08f..0445363b0 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -5,6 +5,7 @@ import {DEFAULT_ALT_TEXT_AI_MODEL} from '#/lib/constants' import { countsMetricsDisplaySchema, followedByMetricsDisplaySchema, + notificationDotDisplaySchema, } from '#/lib/metrics-display' import {deviceLanguageCodes, deviceLocales} from '#/locale/deviceLocales' import {findSupportedAppLanguage} from '#/locale/helpers' @@ -216,6 +217,10 @@ const schema = z.object({ followingMetricsDisplay: countsMetricsDisplaySchema.optional(), followedByMetricsDisplay: followedByMetricsDisplaySchema.optional(), postsMetricsDisplay: countsMetricsDisplaySchema.optional(), + /** @deprecated Migrated to notificationsTabBadgeDisplay and chatsTabBadgeDisplay */ + notificationDotDisplay: notificationDotDisplaySchema.optional(), + notificationsTabBadgeDisplay: notificationDotDisplaySchema.optional(), + chatsTabBadgeDisplay: notificationDotDisplaySchema.optional(), showFollowsYouBadge: z.boolean().optional(), showFollowedByOnOwnProfile: z.boolean().optional(), hideSimilarAccountsRecomm: z.boolean().optional(), @@ -394,6 +399,9 @@ export const defaults: Schema = { followingMetricsDisplay: 'visible', followedByMetricsDisplay: 'visible', postsMetricsDisplay: 'visible', + notificationDotDisplay: 'exact', + notificationsTabBadgeDisplay: 'exact', + chatsTabBadgeDisplay: 'exact', showFollowsYouBadge: false, showFollowedByOnOwnProfile: false, hideSimilarAccountsRecomm: true, diff --git a/src/state/persisted/util.ts b/src/state/persisted/util.ts index f2c083faf..b9d9aa6d9 100644 --- a/src/state/persisted/util.ts +++ b/src/state/persisted/util.ts @@ -88,6 +88,10 @@ function migrateMetricsDisplayPrefs(data: Schema): Schema { data.followedByMetricsDisplay, data.disableFollowedByMetrics, ), + notificationsTabBadgeDisplay: + data.notificationsTabBadgeDisplay ?? data.notificationDotDisplay, + chatsTabBadgeDisplay: + data.chatsTabBadgeDisplay ?? data.notificationDotDisplay, } } diff --git a/src/state/preferences/metrics-display-preference.tsx b/src/state/preferences/metrics-display-preference.tsx index 0408e3efb..070d72281 100644 --- a/src/state/preferences/metrics-display-preference.tsx +++ b/src/state/preferences/metrics-display-preference.tsx @@ -12,6 +12,7 @@ import { type FollowedByMetricsDisplay, migrateCountsMetricsDisplay, migrateFollowedByMetricsDisplay, + type NotificationDotDisplay, } from '#/lib/metrics-display' import * as persisted from '#/state/persisted' @@ -147,6 +148,53 @@ function createFollowedByMetricsDisplayPreference() { return {Provider, useMetricsDisplay, useSetMetricsDisplay} } +function createNotificationDotDisplayPreference({ + displayKey, +}: { + displayKey: 'notificationsTabBadgeDisplay' | 'chatsTabBadgeDisplay' +}) { + const defaultDisplay: NotificationDotDisplay = 'exact' + const stateContext = createContext(defaultDisplay) + const setContext = createContext<(v: NotificationDotDisplay) => void>( + () => {}, + ) + + function Provider({children}: PropsWithChildren<{}>) { + const [state, setState] = useState( + () => persisted.get(displayKey) ?? defaultDisplay, + ) + + const setStateWrapped = useCallback((value: NotificationDotDisplay) => { + setState(value) + void persisted.write(displayKey, value) + }, []) + + useEffect(() => { + return persisted.onUpdate(displayKey, next => { + setState(next ?? defaultDisplay) + }) + }, []) + + return ( + + + {children} + + + ) + } + + function useDisplay() { + return useContext(stateContext) + } + + function useSetDisplay() { + return useContext(setContext) + } + + return {Provider, useDisplay, useSetDisplay} +} + const likes = createCountsMetricsDisplayPreference({ displayKey: 'likesMetricsDisplay', legacyDisableKey: 'disableLikesMetrics', @@ -180,6 +228,12 @@ const posts = createCountsMetricsDisplayPreference({ legacyDisableKey: 'disablePostsMetrics', }) const followedBy = createFollowedByMetricsDisplayPreference() +const notificationsTabBadge = createNotificationDotDisplayPreference({ + displayKey: 'notificationsTabBadgeDisplay', +}) +const chatsTabBadge = createNotificationDotDisplayPreference({ + displayKey: 'chatsTabBadgeDisplay', +}) export const useLikesMetricsDisplay = likes.useMetricsDisplay export const useSetLikesMetricsDisplay = likes.useSetMetricsDisplay @@ -199,6 +253,11 @@ export const usePostsMetricsDisplay = posts.useMetricsDisplay export const useSetPostsMetricsDisplay = posts.useSetMetricsDisplay export const useFollowedByMetricsDisplay = followedBy.useMetricsDisplay export const useSetFollowedByMetricsDisplay = followedBy.useSetMetricsDisplay +export const useNotificationsTabBadgeDisplay = notificationsTabBadge.useDisplay +export const useSetNotificationsTabBadgeDisplay = + notificationsTabBadge.useSetDisplay +export const useChatsTabBadgeDisplay = chatsTabBadge.useDisplay +export const useSetChatsTabBadgeDisplay = chatsTabBadge.useSetDisplay export function MetricsDisplayPreferencesProvider({ children, @@ -212,7 +271,11 @@ export function MetricsDisplayPreferencesProvider({ - {children} + + + {children} + + diff --git a/src/state/preferences/settings-sync.tsx b/src/state/preferences/settings-sync.tsx index 09f20ce9e..c01e7b730 100644 --- a/src/state/preferences/settings-sync.tsx +++ b/src/state/preferences/settings-sync.tsx @@ -67,6 +67,9 @@ export const SYNCED_PREFS_KEYS = [ 'followingMetricsDisplay', 'followedByMetricsDisplay', 'postsMetricsDisplay', + 'notificationDotDisplay', + 'notificationsTabBadgeDisplay', + 'chatsTabBadgeDisplay', 'showFollowsYouBadge', 'showFollowedByOnOwnProfile', 'hideSimilarAccountsRecomm', diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx index b1a0c6e68..bd2e3fe45 100644 --- a/src/view/shell/bottom-bar/BottomBar.tsx +++ b/src/view/shell/bottom-bar/BottomBar.tsx @@ -20,6 +20,10 @@ import {type SharedNavTab, TAB_TO_NAV_ITEM} from '#/lib/routes/tab-to-nav-item' import {emitSoftReset} from '#/state/events' import {useEnableSquareAvatars} from '#/state/preferences/enable-square-avatars' import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' +import { + useChatsTabBadgeDisplay, + useNotificationsTabBadgeDisplay, +} from '#/state/preferences/metrics-display-preference' import {useUnreadMessageCount} from '#/state/queries/messages/list-conversations' import {useUpdateAllRead} from '#/state/queries/messages/update-all-read' import {useUnreadNotifications} from '#/state/queries/notifications/unread' @@ -73,6 +77,8 @@ export function BottomBar({navigation}: BottomTabBarProps) { useNavigationTabState() const numUnreadNotifications = useUnreadNotifications() const numUnreadMessages = useUnreadMessageCount() + const notificationsTabBadgeDisplay = useNotificationsTabBadgeDisplay() + const chatsTabBadgeDisplay = useChatsTabBadgeDisplay() const aa = useAgeAssurance() const footerMinimalShellTransform = useMinimalShellFooterTransform() const {data: profile} = useProfileQuery({did: currentAccount?.did}) @@ -238,9 +244,17 @@ export function BottomBar({navigation}: BottomTabBarProps) { onPress={onPressMessages} onLongPress={onLongPressMessages} notificationCount={ - aa.flags.chatDisabled ? undefined : numUnreadMessages.numUnread + !aa.flags.chatDisabled && chatsTabBadgeDisplay === 'exact' + ? numUnreadMessages.numUnread + : undefined + } + hasNew={ + !aa.flags.chatDisabled && chatsTabBadgeDisplay !== 'hidden' + ? chatsTabBadgeDisplay === 'visible' + ? numUnreadMessages.count > 0 || numUnreadMessages.hasNew + : numUnreadMessages.hasNew + : false } - hasNew={aa.flags.chatDisabled ? false : numUnreadMessages.hasNew} accessible={true} accessibilityRole="tab" accessibilityLabel={l`Chat`} @@ -277,7 +291,15 @@ export function BottomBar({navigation}: BottomTabBarProps) { ) } onPress={onPressNotifications} - notificationCount={numUnreadNotifications} + notificationCount={ + notificationsTabBadgeDisplay === 'exact' + ? numUnreadNotifications + : undefined + } + hasNew={ + notificationsTabBadgeDisplay === 'visible' && + numUnreadNotifications !== '' + } accessible={true} accessibilityRole="tab" accessibilityLabel={l`Notifications`} diff --git a/src/view/shell/bottom-bar/BottomBarWeb.tsx b/src/view/shell/bottom-bar/BottomBarWeb.tsx index f289b5e90..6a4fde8a2 100644 --- a/src/view/shell/bottom-bar/BottomBarWeb.tsx +++ b/src/view/shell/bottom-bar/BottomBarWeb.tsx @@ -22,6 +22,10 @@ import {type CommonNavigatorParams} from '#/lib/routes/types' import {convertBskyAppUrlIfNeeded} from '#/lib/strings/url-helpers' import {emitSoftReset} from '#/state/events' import {useEnableSquareAvatars} from '#/state/preferences/enable-square-avatars' +import { + useChatsTabBadgeDisplay, + useNotificationsTabBadgeDisplay, +} from '#/state/preferences/metrics-display-preference' import {useUnreadMessageCount} from '#/state/queries/messages/list-conversations' import {useUnreadNotifications} from '#/state/queries/notifications/unread' import {useProfileQuery} from '#/state/queries/profile' @@ -79,6 +83,8 @@ export function BottomBarWeb() { const unreadMessageCount = useUnreadMessageCount() const notificationCountStr = useUnreadNotifications() + const notificationsTabBadgeDisplay = useNotificationsTabBadgeDisplay() + const chatsTabBadgeDisplay = useChatsTabBadgeDisplay() const aa = useAgeAssurance() const isLabeler = profile?.associated?.labeler @@ -150,12 +156,17 @@ export function BottomBarWeb() { href="/messages" navItem="chat" notificationCount={ - aa.flags.chatDisabled + aa.flags.chatDisabled || chatsTabBadgeDisplay !== 'exact' ? undefined : unreadMessageCount.numUnread } hasNew={ - aa.flags.chatDisabled ? false : unreadMessageCount.hasNew + aa.flags.chatDisabled || chatsTabBadgeDisplay === 'hidden' + ? false + : chatsTabBadgeDisplay === 'visible' + ? unreadMessageCount.count > 0 || + unreadMessageCount.hasNew + : unreadMessageCount.hasNew }> {({isActive}) => { const Icon = isActive ? MessageFilled : Message @@ -176,7 +187,15 @@ export function BottomBarWeb() { routeName="Notifications" href="/notifications" navItem="notifications" - notificationCount={notificationCountStr}> + notificationCount={ + notificationsTabBadgeDisplay === 'exact' + ? notificationCountStr + : undefined + } + hasNew={ + notificationsTabBadgeDisplay === 'visible' && + notificationCountStr !== '' + }> {({isActive}) => { const Icon = isActive ? BellFilled : Bell return ( diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx index 3210ff2db..361471fbb 100644 --- a/src/view/shell/desktop/LeftNav.tsx +++ b/src/view/shell/desktop/LeftNav.tsx @@ -19,6 +19,10 @@ import {emitSoftReset} from '#/state/events' import {useEnableSquareAvatars} from '#/state/preferences/enable-square-avatars' import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' import {useHideDisplayNames} from '#/state/preferences/hide-display-names' +import { + useChatsTabBadgeDisplay, + useNotificationsTabBadgeDisplay, +} from '#/state/preferences/metrics-display-preference' import {useFetchHandle} from '#/state/queries/handle' import {useUnreadMessageCount} from '#/state/queries/messages/list-conversations' import {useUnreadNotifications} from '#/state/queries/notifications/unread' @@ -685,6 +689,8 @@ export function DesktopLeftNav({routeName}: {routeName: string}) { useLayoutBreakpoints() const numUnreadNotifications = useUnreadNotifications() const numUnreadMessages = useUnreadMessageCount() + const notificationsTabBadgeDisplay = useNotificationsTabBadgeDisplay() + const chatsTabBadgeDisplay = useChatsTabBadgeDisplay() const leftNavMinimal = isMessagesRelatedScreen || leftNavMinimalBreakpoint @@ -756,7 +762,15 @@ export function DesktopLeftNav({routeName}: {routeName: string}) { href="/notifications" navItem="notifications" minimal={leftNavMinimal} - count={numUnreadNotifications} + count={ + notificationsTabBadgeDisplay === 'exact' + ? numUnreadNotifications + : undefined + } + hasNew={ + notificationsTabBadgeDisplay === 'visible' && + numUnreadNotifications !== '' + } icons={{ inactive: BellIcon, active: BellFilledIcon, @@ -768,9 +782,17 @@ export function DesktopLeftNav({routeName}: {routeName: string}) { navItem="chat" minimal={leftNavMinimal} count={ - aa.flags.chatDisabled ? undefined : numUnreadMessages.numUnread + !aa.flags.chatDisabled && chatsTabBadgeDisplay === 'exact' + ? numUnreadMessages.numUnread + : undefined + } + hasNew={ + !aa.flags.chatDisabled && chatsTabBadgeDisplay !== 'hidden' + ? chatsTabBadgeDisplay === 'visible' + ? numUnreadMessages.count > 0 || numUnreadMessages.hasNew + : numUnreadMessages.hasNew + : false } - hasNew={!aa.flags.chatDisabled && numUnreadMessages.hasNew} icons={{ inactive: MessageIcon, active: MessageFilledIcon, -- 2.51.2