From 076a1928e0244f71691634e9718a7123674a7ff6 Mon Sep 17 00:00:00 2001 From: yikZero Date: Thu, 13 Aug 2026 13:31:35 +0800 Subject: [PATCH] feat: improve notification settings and inbox --- .../components/src/composite/Tabs/TabBar.tsx | 2 +- .../HeaderNotificationIconButton.tsx | 4 + .../components/NotificationListView.tsx | 362 +++++++++--------- .../components/notificationListUtils.test.ts | 87 +++++ .../components/notificationListUtils.ts | 76 ++++ .../NotificationsTestButton.test.tsx | 184 +++++++++ .../components/NotificationsTestButton.tsx | 108 +++++- .../Notifications/ManageAccountActivity.tsx | 4 +- .../NotificationsSettings.test.tsx | 354 +++++++++++++++++ .../Notifications/NotificationsSettings.tsx | 133 ++++--- .../shared/src/locale/enum/translations.ts | 9 + packages/shared/src/locale/json/bn.json | 9 + packages/shared/src/locale/json/de.json | 9 + packages/shared/src/locale/json/en_US.json | 9 + packages/shared/src/locale/json/es.json | 9 + packages/shared/src/locale/json/fr_FR.json | 9 + packages/shared/src/locale/json/hi_IN.json | 9 + packages/shared/src/locale/json/id.json | 9 + packages/shared/src/locale/json/it_IT.json | 9 + packages/shared/src/locale/json/ja_JP.json | 9 + packages/shared/src/locale/json/ko_KR.json | 9 + packages/shared/src/locale/json/pt.json | 9 + packages/shared/src/locale/json/pt_BR.json | 9 + packages/shared/src/locale/json/ru.json | 9 + packages/shared/src/locale/json/th_TH.json | 9 + packages/shared/src/locale/json/uk_UA.json | 9 + packages/shared/src/locale/json/vi.json | 9 + packages/shared/src/locale/json/zh_CN.json | 9 + packages/shared/src/locale/json/zh_HK.json | 9 + packages/shared/src/locale/json/zh_TW.json | 9 + 30 files changed, 1247 insertions(+), 247 deletions(-) create mode 100644 packages/kit/src/views/Notifications/components/notificationListUtils.test.ts create mode 100644 packages/kit/src/views/Notifications/components/notificationListUtils.ts create mode 100644 packages/kit/src/views/Setting/components/NotificationsTestButton.test.tsx create mode 100644 packages/kit/src/views/Setting/pages/Notifications/NotificationsSettings.test.tsx diff --git a/packages/components/src/composite/Tabs/TabBar.tsx b/packages/components/src/composite/Tabs/TabBar.tsx index babc3dfba39a..94202ebe6133 100644 --- a/packages/components/src/composite/Tabs/TabBar.tsx +++ b/packages/components/src/composite/Tabs/TabBar.tsx @@ -49,7 +49,7 @@ type IReadonlySharedValue = { readonly value: T }; const TAB_HOVER_STYLE = { bg: '$bgHover' } as const; const TAB_PRESS_STYLE = { bg: '$bgActive' } as const; -const TAB_LIST_VIEW_STYLE = { flexShrink: 1 } as const; +const TAB_LIST_VIEW_STYLE = { flexGrow: 1, flexShrink: 1 } as const; const TAB_CONTENT_CONTAINER_STYLE = { pr: 16 } as const; const PILL_SCROLL_CONTENT_STYLE = { px: '$pagePadding', diff --git a/packages/kit/src/components/TabPageHeader/components/HeaderNotificationIconButton.tsx b/packages/kit/src/components/TabPageHeader/components/HeaderNotificationIconButton.tsx index cc294b9c81f3..71aa41647121 100644 --- a/packages/kit/src/components/TabPageHeader/components/HeaderNotificationIconButton.tsx +++ b/packages/kit/src/components/TabPageHeader/components/HeaderNotificationIconButton.tsx @@ -18,6 +18,8 @@ import { EModalNotificationsRoutes } from '@onekeyhq/shared/src/routes/notificat import useAppNavigation from '../../../hooks/useAppNavigation'; import { NotificationListViewPopover } from '../../../views/Notifications/components/NotificationListView'; +const NOTIFICATION_POPOVER_FADE_STYLE = { opacity: 0 } as const; + export interface IHeaderNotificationIconButtonProps { size?: IIconButtonProps['size']; iconSize?: IIconButtonProps['iconSize']; @@ -86,6 +88,8 @@ export function HeaderNotificationIconButton({ height: 592, px: 0, overflow: 'hidden', + enterStyle: NOTIFICATION_POPOVER_FADE_STYLE, + exitStyle: NOTIFICATION_POPOVER_FADE_STYLE, }} renderContent={ - + ); } @@ -167,7 +173,9 @@ function NotificationItem({ extras?.image && item.topicType !== ENotificationPushTopicTypes.system ) { - return ; + return ( + + ); } }, [extras?.image, item.icon, item.topicType]); return ( @@ -192,25 +200,15 @@ function NotificationItem({ /> ) : null} - - + + {title} - + {content} - + {formatDistanceToNow(new Date(createdAt))} @@ -221,7 +219,7 @@ function NotificationItem({ size="$16" borderColor="$neutral3" borderWidth={StyleSheet.hairlineWidth} - borderRadius={6} + borderRadius="$2" /> ) : null} @@ -231,14 +229,6 @@ function NotificationItem({ const NotificationItemMemo = memo(NotificationItem); -function markNotificationItemsRead( - notifications: INotificationPushMessageListItem[], -) { - return notifications.map((item) => - item.readed ? item : { ...item, readed: true }, - ); -} - function buildReadedMessageMap( notifications: INotificationPushMessageListItem[], ) { @@ -250,23 +240,15 @@ function buildReadedMessageMap( }, {}); } -function buildUnreadMap(notifications: INotificationPushMessageListItem[]) { - return notifications.reduce( - (acc, item) => { - if (!item.readed) { - if (item.topicType === ENotificationPushTopicTypes.accountActivity) { - acc[ENotificationPushTopicTypes.accountActivity] += 1; - } else if (item.topicType === ENotificationPushTopicTypes.system) { - acc[ENotificationPushTopicTypes.system] += 1; - } - } - return acc; - }, - { - [ENotificationPushTopicTypes.accountActivity]: 0, - [ENotificationPushTopicTypes.system]: 0, - }, - ); +function createNotificationListCache(): Record< + INotificationListTab, + INotificationPushMessageListItem[] +> { + return { + [ENotificationListTab.all]: [], + [ENotificationListTab.accountActivity]: [], + [ENotificationListTab.alertsAndUpdates]: [], + }; } function groupNotificationsByDate( @@ -363,19 +345,21 @@ function MaxAccountLimitWarning() { export function NotificationListView({ showPageHeader = true, containerStyle, + useFlashList = true, }: { showPageHeader?: boolean; containerStyle?: IYStackProps; + useFlashList?: boolean; }) { const { closePopover } = usePopoverContext(); const intl = useIntl(); const { bottom } = useSafeAreaInsets(); const navigation = useAppNavigation(); - const [ - { lastReceivedTime, firstTimeGuideOpened, badge }, - setNotificationsData, - ] = useNotificationsAtom(); - const [, setReadedMap] = useNotificationsReadedAtom(); + const [{ lastReceivedTime, firstTimeGuideOpened }, setNotificationsData] = + useNotificationsAtom(); + const [readedMap, setReadedMap] = useNotificationsReadedAtom(); + const readedMapRef = useRef(readedMap); + readedMapRef.current = readedMap; const isFirstTimeGuideOpened = useRef(false); const listRef = useRef>(null); @@ -401,22 +385,27 @@ export function NotificationListView({ } }, [closePopover, firstTimeGuideOpened, navigation, setNotificationsData]); - const tabs = useMemo( + const tabs = useMemo< + { + id: INotificationListTab; + name: string; + }[] + >( () => [ { - id: ENotificationPushTopicTypes.all, + id: ENotificationListTab.all, name: intl.formatMessage({ id: ETranslations.global_all }), }, { - id: ENotificationPushTopicTypes.accountActivity, + id: ENotificationListTab.accountActivity, name: intl.formatMessage({ id: ETranslations.notifications_notifications_account_activity_label, }), }, { - id: ENotificationPushTopicTypes.system, + id: ENotificationListTab.alertsAndUpdates, name: intl.formatMessage({ - id: ETranslations.global_system, + id: ETranslations.alerts_and_updates__action, }), }, ], @@ -428,77 +417,94 @@ export function NotificationListView({ return tabs.map((tab) => tab.name); }, [tabs]); const focusedTab = useSharedValue(tabs[0].name); + const [activeTabId, setActiveTabId] = useState( + ENotificationListTab.all, + ); + const activeTabIdRef = useRef(activeTabId); + activeTabIdRef.current = activeTabId; const [ shouldShowMaxAccountLimitWarning, setShouldShowMaxAccountLimitWarning, ] = useState(false); - const [unreadMap, setUnreadMap] = useState<{ - [key: string]: number; - }>({ - [ENotificationPushTopicTypes.accountActivity]: 0, - [ENotificationPushTopicTypes.system]: 0, - }); - - // Clear tab unread badges when global badge becomes 0 - useEffect(() => { - if (badge === 0) { - setUnreadMap({ - [ENotificationPushTopicTypes.accountActivity]: 0, - [ENotificationPushTopicTypes.system]: 0, - }); - } - }, [badge]); const [result, setResult] = useState([]); - const cacheListRef = useRef< - Record - >({ - [ENotificationPushTopicTypes.all]: [], - [ENotificationPushTopicTypes.accountActivity]: [], - [ENotificationPushTopicTypes.coinPriceAlert]: [], - [ENotificationPushTopicTypes.system]: [], - }); + const [isLoading, setIsLoading] = useState(true); + const cacheListRef = useRef(createNotificationListCache()); + const latestRequestIdRef = useRef(0); const messageListMutationVersionRef = useRef(0); - const { isLoading, run: reFetchList } = usePromiseResult( - async () => { - noop(lastReceivedTime); - const topicType = tabs.find((tab) => tab.name === focusedTab.value)?.id; - if (!topicType) return; + const fetchNotificationList = useCallback( + async (tabId: INotificationListTab) => { + const requestId = latestRequestIdRef.current + 1; + latestRequestIdRef.current = requestId; const requestMutationVersion = messageListMutationVersionRef.current; - const cacheList = cacheListRef.current[topicType]; - setShouldShowMaxAccountLimitWarning( - topicType !== ENotificationPushTopicTypes.system, - ); - setResult(cacheList); - void backgroundApiProxy.serviceNotification.refreshBadgeFromServer(); - const r = await backgroundApiProxy.serviceNotification.fetchMessageList( - !topicType || topicType === ENotificationPushTopicTypes.all - ? undefined - : [topicType], - ); - // Ignore responses from list requests started before mark-all-read completed. - if (requestMutationVersion !== messageListMutationVersionRef.current) { - return cacheListRef.current[topicType]; - } - if (topicType === ENotificationPushTopicTypes.all) { - setUnreadMap(buildUnreadMap(r)); + + const cachedList = cacheListRef.current[tabId]; + + if (activeTabIdRef.current === tabId) { + setShouldShowMaxAccountLimitWarning( + tabId === ENotificationListTab.all || + tabId === ENotificationListTab.accountActivity, + ); + setResult(cachedList); + setIsLoading(true); } - if ( - (cacheListRef.current[topicType]?.length || 0) === 0 && - r?.length > 0 - ) { - setResult(r); + + void backgroundApiProxy.serviceNotification.refreshBadgeFromServer(); + try { + const fetchedList = + await backgroundApiProxy.serviceNotification.fetchMessageList( + getNotificationListTopicTypes(tabId), + ); + if ( + !isNotificationListResponseCurrent({ + requestId, + latestRequestId: latestRequestIdRef.current, + requestMutationVersion, + currentMutationVersion: messageListMutationVersionRef.current, + requestTabId: tabId, + activeTabId: activeTabIdRef.current, + }) + ) { + return; + } + + const nextList = applyNotificationReadState( + fetchedList, + readedMapRef.current, + ); + cacheListRef.current[tabId] = nextList; + setResult(nextList); + } catch { + if ( + requestId === latestRequestIdRef.current && + tabId === activeTabIdRef.current + ) { + setResult(cacheListRef.current[tabId]); + } + } finally { + if ( + requestId === latestRequestIdRef.current && + tabId === activeTabIdRef.current + ) { + setIsLoading(false); + } } - cacheListRef.current[topicType] = r; - return r; - }, - [focusedTab.value, lastReceivedTime, tabs], - { - watchLoading: true, - checkIsFocused: false, }, + [], ); + useEffect(() => { + noop(lastReceivedTime); + void fetchNotificationList(activeTabId); + }, [activeTabId, fetchNotificationList, lastReceivedTime]); + + useEffect(() => { + const activeTab = tabs.find((tab) => tab.id === activeTabId); + if (activeTab) { + focusedTab.value = activeTab.name; + } + }, [activeTabId, focusedTab, tabs]); + const sectionsData = useMemo( () => groupNotificationsByDate(result), [result], @@ -511,17 +517,14 @@ export function NotificationListView({ const allCachedItems = Object.values(cacheListRef.current).flat(); const readedMessageMap = buildReadedMessageMap(allCachedItems); cacheListRef.current = { - [ENotificationPushTopicTypes.all]: markNotificationItemsRead( - cacheListRef.current[ENotificationPushTopicTypes.all], + [ENotificationListTab.all]: markNotificationItemsRead( + cacheListRef.current[ENotificationListTab.all], ), - [ENotificationPushTopicTypes.accountActivity]: markNotificationItemsRead( - cacheListRef.current[ENotificationPushTopicTypes.accountActivity], + [ENotificationListTab.accountActivity]: markNotificationItemsRead( + cacheListRef.current[ENotificationListTab.accountActivity], ), - [ENotificationPushTopicTypes.coinPriceAlert]: markNotificationItemsRead( - cacheListRef.current[ENotificationPushTopicTypes.coinPriceAlert], - ), - [ENotificationPushTopicTypes.system]: markNotificationItemsRead( - cacheListRef.current[ENotificationPushTopicTypes.system], + [ENotificationListTab.alertsAndUpdates]: markNotificationItemsRead( + cacheListRef.current[ENotificationListTab.alertsAndUpdates], ), }; setResult((prev) => markNotificationItemsRead(prev)); @@ -531,14 +534,39 @@ export function NotificationListView({ })); setNotificationsData((v) => ({ ...v, - badge: undefined, + badge: 0, })); - setUnreadMap({ - [ENotificationPushTopicTypes.accountActivity]: 0, - [ENotificationPushTopicTypes.system]: 0, - }); - void reFetchList(); - }, [reFetchList, setNotificationsData, setReadedMap, setUnreadMap]); + void fetchNotificationList(activeTabIdRef.current); + }, [fetchNotificationList, setNotificationsData, setReadedMap]); + + const handleNotificationItemRead = useCallback( + (item: INotificationPushMessageListItem) => { + if (item.readed || readedMapRef.current[item.msgId]) { + return; + } + messageListMutationVersionRef.current += 1; + cacheListRef.current = { + [ENotificationListTab.all]: markNotificationItemRead( + cacheListRef.current[ENotificationListTab.all], + item.msgId, + ), + [ENotificationListTab.accountActivity]: markNotificationItemRead( + cacheListRef.current[ENotificationListTab.accountActivity], + item.msgId, + ), + [ENotificationListTab.alertsAndUpdates]: markNotificationItemRead( + cacheListRef.current[ENotificationListTab.alertsAndUpdates], + item.msgId, + ), + }; + setResult((prev) => markNotificationItemRead(prev, item.msgId)); + setReadedMap((prev) => ({ + ...prev, + [item.msgId]: true, + })); + }, + [setReadedMap], + ); const { markAllReadTitle, @@ -586,20 +614,19 @@ export function NotificationListView({ ); useEffect(() => { - const fn = async () => { - const r = await reFetchList(); - setResult(r ?? []); + const fn = () => { + void fetchNotificationList(activeTabIdRef.current); }; appEventBus.on(EAppEventBusNames.UpdateNotificationBadge, fn); return () => { appEventBus.off(EAppEventBusNames.UpdateNotificationBadge, fn); }; - }, [reFetchList]); + }, [fetchNotificationList]); const contentView = useMemo(() => { return ( { - if (!item.readed) { - setUnreadMap((prev) => ({ - ...prev, - [item.topicType]: Math.max( - 0, - (prev[item.topicType] ?? 0) - 1, - ), - })); - } + handleNotificationItemRead(item); }, 100); } }} @@ -697,19 +716,27 @@ export function NotificationListView({ }, [ bottom, closePopover, + handleNotificationItemRead, intl, isLoading, isVersionCompatible, navigation, sectionsData, + useFlashList, ]); const handleTabPress = useCallback( (tabName: string) => { const tab = tabs.find((i) => i.name === tabName); if (tab) { + activeTabIdRef.current = tab.id; focusedTab.value = tab.name; - void reFetchList(); + setActiveTabId(tab.id); + setShouldShowMaxAccountLimitWarning( + tab.id === ENotificationListTab.all || + tab.id === ENotificationListTab.accountActivity, + ); + setResult(cacheListRef.current[tab.id]); setTimeout(() => { listRef.current?.scrollToIndex({ index: 0, @@ -718,38 +745,7 @@ export function NotificationListView({ }, 10); } }, - [focusedTab, reFetchList, tabs], - ); - - const handleRenderItem = useCallback( - (props: ITabBarItemProps) => { - const tabId = tabs.find((i) => i.name === props.name)?.id; - let unreadCount = 0; - if (tabId === ENotificationPushTopicTypes.all) { - unreadCount = tabs.reduce((acc, tab) => { - return acc + (unreadMap[tab.id as keyof typeof unreadMap] || 0); - }, 0); - } else { - unreadCount = unreadMap[tabId as keyof typeof unreadMap]; - } - return ( - - - {unreadCount > 0 ? ( - - ) : null} - - ); - }, - [unreadMap, tabs], + [focusedTab, tabs], ); return ( @@ -785,7 +781,7 @@ export function NotificationListView({ tabNames={tabTitles} onTabPress={handleTabPress} focusedTab={focusedTab} - renderItem={handleRenderItem} + scrollable containerStyle={{ bg: 'transparent' }} /> @@ -795,7 +791,7 @@ export function NotificationListView({ tabNames={tabTitles} onTabPress={handleTabPress} focusedTab={focusedTab} - renderItem={handleRenderItem} + scrollable tabItemStyle={{ h: 44, }} @@ -832,6 +828,8 @@ export function NotificationListViewPopover({