mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user