diff --git a/client/src/components/Notifications/InAppNotificationItem.tsx b/client/src/components/Notifications/InAppNotificationItem.tsx index 3c4eed44e..4d68ecf25 100644 --- a/client/src/components/Notifications/InAppNotificationItem.tsx +++ b/client/src/components/Notifications/InAppNotificationItem.tsx @@ -1,19 +1,12 @@ -import React, { useState } from 'react' -import { useNavigate } from 'react-router' +import React from 'react' import { User, Check, X, ArrowRight, Trash2, CheckCheck } from 'lucide-react' import { useTranslation } from '../../i18n' -import { useInAppNotificationStore, InAppNotification } from '../../store/inAppNotificationStore' +import type { InAppNotification } from '../../store/inAppNotificationStore' import { useSettingsStore } from '../../store/settingsStore' +import { compactTime, useNotificationItemActions } from './useNotificationItemActions' function relativeTime(dateStr: string, locale: string): string { - const diff = Date.now() - new Date(dateStr).getTime() - const minutes = Math.floor(diff / 60000) - if (minutes < 1) return locale === 'ar' ? 'الآن' : 'just now' - if (minutes < 60) return `${minutes}m` - const hours = Math.floor(minutes / 60) - if (hours < 24) return `${hours}h` - const days = Math.floor(hours / 24) - return `${days}d` + return compactTime(dateStr, locale === 'ar' ? 'الآن' : 'just now') } interface NotificationItemProps { @@ -23,28 +16,11 @@ interface NotificationItemProps { export default function InAppNotificationItem({ notification, onClose }: NotificationItemProps): React.ReactElement { const { t, locale } = useTranslation() - const navigate = useNavigate() const { settings } = useSettingsStore() const darkMode = settings.dark_mode const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) - const [responding, setResponding] = useState(false) - - const { markRead, markUnread, deleteNotification, respondToBoolean } = useInAppNotificationStore() - - const handleNavigate = async () => { - if (!notification.is_read) await markRead(notification.id) - if (notification.navigate_target) { - navigate(notification.navigate_target) - onClose?.() - } - } - - const handleRespond = async (response: 'positive' | 'negative') => { - if (responding || notification.response !== null) return - setResponding(true) - await respondToBoolean(notification.id, response) - setResponding(false) - } + const { responding, handleRespond, handleNavigate, markRead, deleteNotification } = + useNotificationItemActions(notification, onClose) const titleText = t(notification.title_key, notification.title_params) const bodyText = t(notification.text_key, notification.text_params) diff --git a/client/src/components/Notifications/useNotificationItemActions.test.ts b/client/src/components/Notifications/useNotificationItemActions.test.ts new file mode 100644 index 000000000..21d406d02 --- /dev/null +++ b/client/src/components/Notifications/useNotificationItemActions.test.ts @@ -0,0 +1,121 @@ +// FE-COMP-NOTIFITEM-ACTIONS-001 onwards: what one notification can do, behind both lists. +import { act, renderHook } from '@testing-library/react'; + +import { resetAllStores, seedStore } from '../../../tests/helpers/store'; +import { type InAppNotification, useInAppNotificationStore } from '../../store/inAppNotificationStore'; +import { compactTime, useNotificationItemActions } from './useNotificationItemActions'; + +const navigate = vi.hoisted(() => vi.fn()); +vi.mock('react-router', async () => { + const actual = await vi.importActual('react-router'); + return { ...actual, useNavigate: () => navigate }; +}); + +const markRead = vi.fn(); +const deleteNotification = vi.fn(); +const respondToBoolean = vi.fn(); + +function notification(over: Partial = {}): InAppNotification { + return { + id: 7, + type: 'navigate', + scope: 'user', + target: 1, + sender_id: null, + sender_username: null, + sender_avatar: null, + recipient_id: 1, + title_key: 't', + title_params: {}, + text_key: 'x', + text_params: {}, + positive_text_key: null, + negative_text_key: null, + response: null, + navigate_text_key: 'go', + navigate_target: '/trips/1', + is_read: false, + created_at: new Date().toISOString(), + ...over, + }; +} + +beforeEach(() => { + resetAllStores(); + navigate.mockReset(); + markRead.mockReset().mockResolvedValue(undefined); + deleteNotification.mockReset().mockResolvedValue(undefined); + respondToBoolean.mockReset().mockResolvedValue(undefined); + seedStore(useInAppNotificationStore, { markRead, deleteNotification, respondToBoolean }); +}); + +describe('compactTime', () => { + it('FE-COMP-NOTIFITEM-ACTIONS-001: minutes, hours and days, and the caller wording under a minute', () => { + const ago = (ms: number) => new Date(Date.now() - ms).toISOString(); + expect(compactTime(ago(10_000), 'now')).toBe('now'); + expect(compactTime(ago(5 * 60_000), 'now')).toBe('5m'); + expect(compactTime(ago(3 * 3_600_000), 'now')).toBe('3h'); + expect(compactTime(ago(2 * 86_400_000), 'now')).toBe('2d'); + }); +}); + +describe('useNotificationItemActions', () => { + it('FE-COMP-NOTIFITEM-ACTIONS-002: navigating marks an unread item read first, then follows and closes', async () => { + const onClose = vi.fn(); + const { result } = renderHook(() => useNotificationItemActions(notification(), onClose)); + await act(() => result.current.handleNavigate()); + expect(markRead).toHaveBeenCalledWith(7); + expect(navigate).toHaveBeenCalledWith('/trips/1'); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('FE-COMP-NOTIFITEM-ACTIONS-003: a read item is not marked again, and no target means no navigation', async () => { + const onClose = vi.fn(); + const read = renderHook(() => useNotificationItemActions(notification({ is_read: true }))); + await act(() => read.result.current.handleNavigate()); + expect(markRead).not.toHaveBeenCalled(); + expect(navigate).toHaveBeenCalledWith('/trips/1'); + + navigate.mockReset(); + const none = renderHook(() => useNotificationItemActions(notification({ navigate_target: null }), onClose)); + await act(() => none.result.current.handleNavigate()); + expect(navigate).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('FE-COMP-NOTIFITEM-ACTIONS-004: a question is answered once, and not at all after an answer', async () => { + let finish: () => void = () => {}; + respondToBoolean.mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }) + ); + const { result } = renderHook(() => useNotificationItemActions(notification({ type: 'boolean' }))); + act(() => { + void result.current.handleRespond('positive'); + }); + expect(result.current.responding).toBe(true); + act(() => { + void result.current.handleRespond('negative'); + }); + expect(respondToBoolean).toHaveBeenCalledTimes(1); + expect(respondToBoolean).toHaveBeenCalledWith(7, 'positive'); + await act(async () => finish()); + expect(result.current.responding).toBe(false); + + const answered = renderHook(() => + useNotificationItemActions(notification({ type: 'boolean', response: 'negative' })) + ); + await act(() => answered.result.current.handleRespond('positive')); + expect(respondToBoolean).toHaveBeenCalledTimes(1); + }); + + it('FE-COMP-NOTIFITEM-ACTIONS-005: hands the store mark read and delete through', () => { + const { result } = renderHook(() => useNotificationItemActions(notification())); + void result.current.markRead(7); + void result.current.deleteNotification(7); + expect(markRead).toHaveBeenCalledWith(7); + expect(deleteNotification).toHaveBeenCalledWith(7); + }); +}); diff --git a/client/src/components/Notifications/useNotificationItemActions.ts b/client/src/components/Notifications/useNotificationItemActions.ts new file mode 100644 index 000000000..038e259f5 --- /dev/null +++ b/client/src/components/Notifications/useNotificationItemActions.ts @@ -0,0 +1,42 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router'; + +import { type InAppNotification, useInAppNotificationStore } from '../../store/inAppNotificationStore'; + +/** Compact relative timestamp ("5m" / "3h" / "2d"); under a minute it reads `justNow`. */ +export function compactTime(dateStr: string, justNow: string): string { + const minutes = Math.floor((Date.now() - new Date(dateStr).getTime()) / 60000); + if (minutes < 1) return justNow; + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h`; + return `${Math.floor(hours / 24)}d`; +} + +/** + * What one in-app notification can do, behind both lists (the desktop bell's item and + * the phone row render their own markup over this): answer a yes or no question once, + * follow its link (marking it read first), mark it read and delete it. + */ +export function useNotificationItemActions(notification: InAppNotification, onClose?: () => void) { + const navigate = useNavigate(); + const [responding, setResponding] = useState(false); + const { markRead, deleteNotification, respondToBoolean } = useInAppNotificationStore(); + + const handleRespond = async (response: 'positive' | 'negative') => { + if (responding || notification.response !== null) return; + setResponding(true); + await respondToBoolean(notification.id, response); + setResponding(false); + }; + + const handleNavigate = async () => { + if (!notification.is_read) await markRead(notification.id); + if (notification.navigate_target) { + navigate(notification.navigate_target); + onClose?.(); + } + }; + + return { responding, handleRespond, handleNavigate, markRead, deleteNotification }; +} diff --git a/client/src/mobile/screens/notifications/MNotificationRow.tsx b/client/src/mobile/screens/notifications/MNotificationRow.tsx index 41e4a13ec..32378e3c1 100644 --- a/client/src/mobile/screens/notifications/MNotificationRow.tsx +++ b/client/src/mobile/screens/notifications/MNotificationRow.tsx @@ -1,20 +1,10 @@ -import { useState, type HTMLAttributes } from 'react' -import { useNavigate } from 'react-router' +import type { HTMLAttributes } from 'react' import { ArrowRight, Bell, Check, Trash2, X } from 'lucide-react' import { useTranslation } from '../../../i18n' -import { useInAppNotificationStore, InAppNotification } from '../../../store/inAppNotificationStore' +import type { InAppNotification } from '../../../store/inAppNotificationStore' +import { compactTime, useNotificationItemActions } from '../../../components/Notifications/useNotificationItemActions' import MChip from '../../components/MChip' -/** Compact relative timestamp ("5m" / "3h" / "2d"), locale-neutral like the desktop item. */ -function compactTime(dateStr: string, justNow: string): string { - const minutes = Math.floor((Date.now() - new Date(dateStr).getTime()) / 60000) - if (minutes < 1) return justNow - if (minutes < 60) return `${minutes}m` - const hours = Math.floor(minutes / 60) - if (hours < 24) return `${hours}h` - return `${Math.floor(hours / 24)}d` -} - interface MNotificationRowProps { notification: InAppNotification } @@ -26,25 +16,11 @@ interface MNotificationRowProps { */ export default function MNotificationRow({ notification }: MNotificationRowProps) { const { t } = useTranslation() - const navigate = useNavigate() - const [responding, setResponding] = useState(false) - const { markRead, deleteNotification, respondToBoolean } = useInAppNotificationStore() + const { handleRespond, handleNavigate, markRead, deleteNotification } = useNotificationItemActions(notification) const title = t(notification.title_key, notification.title_params) const body = t(notification.text_key, notification.text_params) - const handleRespond = async (response: 'positive' | 'negative') => { - if (responding || notification.response !== null) return - setResponding(true) - await respondToBoolean(notification.id, response) - setResponding(false) - } - - const handleNavigate = async () => { - if (!notification.is_read) await markRead(notification.id) - if (notification.navigate_target) navigate(notification.navigate_target) - } - // Tapping an unread row marks it read; a row that is already read carries no // action, so it neither takes focus nor claims a role. It stays a div because // the chips and the delete button inside it are buttons of their own.