refactor(notifications): share the notification row actions between desktop and phone

Answering a question once, following a link after marking it read and the compact timestamp live in useNotificationItemActions. The desktop item keeps closing its panel after a navigation through the optional onClose.
This commit is contained in:
Maurice
2026-10-09 10:25:54 +02:00
parent a7bd26722a
commit cf73779070
4 changed files with 173 additions and 58 deletions
@@ -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)
@@ -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<typeof import('react-router')>('react-router');
return { ...actual, useNavigate: () => navigate };
});
const markRead = vi.fn();
const deleteNotification = vi.fn();
const respondToBoolean = vi.fn();
function notification(over: Partial<InAppNotification> = {}): 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<void>((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);
});
});
@@ -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 };
}
@@ -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.