diff --git a/public/configuration/UITexts_en.json5.example b/public/configuration/UITexts_en.json5.example index 90c1aa1..3bfbe4b 100644 --- a/public/configuration/UITexts_en.json5.example +++ b/public/configuration/UITexts_en.json5.example @@ -707,6 +707,24 @@ 'chatcmd.client.info': 'Client info', // ------------------------------------------------------------------------ + // Mentions + // ------------------------------------------------------------------------ + 'mentions.window.title': 'Mentions', + 'mentions.window.empty': 'No mentions', + 'mentions.window.markall': 'Mark all as read', + 'mentions.tab.title': 'Mentions', + 'mentions.notification': '%sender% mentioned you in %room%', + 'mentions.filter.all': 'All', + 'mentions.filter.unread': 'Unread', + 'mentions.filter.direct': 'Direct', + 'mentions.filter.room': 'Room', + 'mentions.group.today': 'Today', + 'mentions.group.yesterday': 'Yesterday', + 'mentions.group.older': 'Earlier', + 'mentions.type.direct': 'Direct mention', + 'mentions.type.room': 'Room mention', + 'mentions.action.goto': 'Go to room', + 'mentions.action.remove': 'Remove', // Me-menu settings + User account settings window // ------------------------------------------------------------------------ 'usersettings.tab.general': "General", @@ -772,13 +790,4 @@ 'usersettings.success.password': "Password updated successfully.", 'usersettings.success.email': "Email updated successfully.", 'usersettings.success.username': "Username updated. Please log in again with your new name.", - - // ------------------------------------------------------------------------ - // Mentions - // ------------------------------------------------------------------------ - 'mentions.window.title': 'Mentions', - 'mentions.window.empty': 'No mentions', - 'mentions.window.markall': 'Mark all as read', - 'mentions.tab.title': 'Mentions', - 'mentions.notification': '%sender% mentioned you in %room%', } diff --git a/public/configuration/UITexts_it.json5.example b/public/configuration/UITexts_it.json5.example index 580f4f8..593498e 100644 --- a/public/configuration/UITexts_it.json5.example +++ b/public/configuration/UITexts_it.json5.example @@ -707,6 +707,24 @@ 'chatcmd.client.info': 'Info client', // ------------------------------------------------------------------------ + // Mentions + // ------------------------------------------------------------------------ + 'mentions.window.title': 'Menzioni', + 'mentions.window.empty': 'Nessuna menzione', + 'mentions.window.markall': 'Segna tutte come lette', + 'mentions.tab.title': 'Menzioni', + 'mentions.notification': '%sender% ti ha menzionato in %room%', + 'mentions.filter.all': 'Tutte', + 'mentions.filter.unread': 'Non lette', + 'mentions.filter.direct': 'Dirette', + 'mentions.filter.room': 'Stanza', + 'mentions.group.today': 'Oggi', + 'mentions.group.yesterday': 'Ieri', + 'mentions.group.older': 'Precedenti', + 'mentions.type.direct': 'Menzione diretta', + 'mentions.type.room': 'Menzione di stanza', + 'mentions.action.goto': 'Vai alla stanza', + 'mentions.action.remove': 'Rimuovi', // Me-menu settings + User account settings window // ------------------------------------------------------------------------ 'usersettings.tab.general': "Generale", @@ -772,13 +790,4 @@ 'usersettings.success.password': "Password aggiornata con successo.", 'usersettings.success.email': "Email aggiornata con successo.", 'usersettings.success.username': "Nome utente aggiornato. Accedi di nuovo con il tuo nuovo nome.", - - // ------------------------------------------------------------------------ - // Mentions - // ------------------------------------------------------------------------ - 'mentions.window.title': 'Menzioni', - 'mentions.window.empty': 'Nessuna menzione', - 'mentions.window.markall': 'Segna tutte come lette', - 'mentions.tab.title': 'Menzioni', - 'mentions.notification': '%sender% ti ha menzionato in %room%', } diff --git a/public/configuration/UITexts_nl.json5.example b/public/configuration/UITexts_nl.json5.example index 8e76beb..62f963f 100644 --- a/public/configuration/UITexts_nl.json5.example +++ b/public/configuration/UITexts_nl.json5.example @@ -709,6 +709,24 @@ 'chatcmd.client.info': 'Client info', // ------------------------------------------------------------------------ + // Mentions + // ------------------------------------------------------------------------ + 'mentions.window.title': 'Vermeldingen', + 'mentions.window.empty': 'Geen vermeldingen', + 'mentions.window.markall': 'Alles als gelezen markeren', + 'mentions.tab.title': 'Vermeldingen', + 'mentions.notification': '%sender% heeft je genoemd in %room%', + 'mentions.filter.all': 'Alle', + 'mentions.filter.unread': 'Ongelezen', + 'mentions.filter.direct': 'Direct', + 'mentions.filter.room': 'Kamer', + 'mentions.group.today': 'Vandaag', + 'mentions.group.yesterday': 'Gisteren', + 'mentions.group.older': 'Eerder', + 'mentions.type.direct': 'Directe vermelding', + 'mentions.type.room': 'Kamervermelding', + 'mentions.action.goto': 'Ga naar kamer', + 'mentions.action.remove': 'Verwijderen', // Me-menu settings + User account settings window // ------------------------------------------------------------------------ 'usersettings.tab.general': "Algemeen", @@ -774,13 +792,4 @@ 'usersettings.success.password': "Wachtwoord succesvol bijgewerkt.", 'usersettings.success.email': "E-mail succesvol bijgewerkt.", 'usersettings.success.username': "Gebruikersnaam bijgewerkt. Log opnieuw in met je nieuwe naam.", - - // ------------------------------------------------------------------------ - // Mentions - // ------------------------------------------------------------------------ - 'mentions.window.title': 'Vermeldingen', - 'mentions.window.empty': 'Geen vermeldingen', - 'mentions.window.markall': 'Alles als gelezen markeren', - 'mentions.tab.title': 'Vermeldingen', - 'mentions.notification': '%sender% heeft je genoemd in %room%', } diff --git a/src/components/chat-history/ChatHistoryView.tsx b/src/components/chat-history/ChatHistoryView.tsx index 19d7611..9fb6dd8 100644 --- a/src/components/chat-history/ChatHistoryView.tsx +++ b/src/components/chat-history/ChatHistoryView.tsx @@ -3,8 +3,9 @@ import { FC, useEffect, useMemo, useRef, useState } from 'react'; import { ChatEntryType, LocalizeText } from '../../api'; import { Flex, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView, Text } from '../../common'; import { useChatHistory, useMentionsSnapshot, useOnClickChat } from '../../hooks'; +import { useUserDataSnapshot } from '../../hooks/session/useSessionSnapshots'; import { NitroInput } from '../../layout'; -import { MentionRowView, useMentionRowClick } from '../mentions'; +import { MentionRowView, useMentionActions } from '../mentions'; const TAB_CHAT = 'chat'; const TAB_MENTIONS = 'mentions'; @@ -16,7 +17,8 @@ export const ChatHistoryView: FC<{}> = props => const [activeTab, setActiveTab] = useState(TAB_CHAT); const { chatHistory = [] } = useChatHistory(); const { mentions, unreadCount } = useMentionsSnapshot(); - const onMentionRowClick = useMentionRowClick(); + const { userName: ownMentionUsername = '' } = useUserDataSnapshot(); + const { open: onMentionOpen, goto: onMentionGoto, remove: onMentionRemove } = useMentionActions(); const { onClickChat } = useOnClickChat(); const elementRef = useRef(null); const isFirstRender = useRef(true); @@ -108,7 +110,13 @@ export const ChatHistoryView: FC<{}> = props => { (mentions.length === 0) ? { LocalizeText('mentions.window.empty') } : mentions.map(mention => ( - + )) } ) : ( diff --git a/src/components/mentions/MentionMessageView.tsx b/src/components/mentions/MentionMessageView.tsx new file mode 100644 index 0000000..4f8d327 --- /dev/null +++ b/src/components/mentions/MentionMessageView.tsx @@ -0,0 +1,36 @@ +import { FC, Fragment, ReactNode } from 'react'; +import { tokenIsMention } from '../room/widgets/chat/highlightMentions'; + +interface MentionMessageViewProps +{ + text: string; + ownUsername: string; + className?: string; +} + +/** + * Renders a mention's message text as React nodes, wrapping the token(s) that + * mention the local user or a room-broadcast alias in a `.mention-highlight` + * span. Pure text segmentation (no innerHTML) → no XSS risk from other users' + * chat content. Original spacing is preserved verbatim. + */ +export const MentionMessageView: FC = props => +{ + const { text, ownUsername, className } = props; + + if(!text) return ; + + const nodes: ReactNode[] = text.split(/(\s+)/).map((segment, index) => + { + if(segment.length === 0) return null; + + if(/^\s+$/.test(segment) || !tokenIsMention(segment, ownUsername)) + { + return { segment }; + } + + return { segment }; + }); + + return { nodes }; +}; diff --git a/src/components/mentions/MentionRowView.tsx b/src/components/mentions/MentionRowView.tsx index da3e56f..f943d1f 100644 --- a/src/components/mentions/MentionRowView.tsx +++ b/src/components/mentions/MentionRowView.tsx @@ -1,28 +1,65 @@ -import { FC } from 'react'; -import { IMentionEntry } from '../../api'; +import { FC, MouseEvent } from 'react'; +import { IMentionEntry, LocalizeText, MentionType } from '../../api'; import { Flex, Text } from '../../common'; +import { MentionMessageView } from './MentionMessageView'; +import { formatMentionTime } from './mentionsFormat'; interface MentionRowViewProps { mention: IMentionEntry; - onClick: (mention: IMentionEntry) => void; + ownUsername: string; + onOpen: (mention: IMentionEntry) => void; + onGoto?: (mention: IMentionEntry) => void; + onRemove?: (mention: IMentionEntry) => void; } export const MentionRowView: FC = props => { - const { mention, onClick } = props; + const { mention, ownUsername, onOpen, onGoto = null, onRemove = null } = props; + + const isRoom = (mention.mentionType === MentionType.ROOM); + const typeTitle = LocalizeText(isRoom ? 'mentions.type.room' : 'mentions.type.direct'); + const time = formatMentionTime(mention.timestamp); + + const stop = (event: MouseEvent, action: () => void) => + { + event.stopPropagation(); + action(); + }; return ( - onClick(mention) }> + onOpen(mention) }> + className={ `inline-block w-[8px] h-[8px] rounded-full shrink-0 ${ mention.read ? 'bg-transparent' : 'bg-[#1e7295]' }` } + title={ mention.read ? '' : LocalizeText('mentions.filter.unread') } /> + + { isRoom ? '@∗' : '@' } + - + { mention.senderUsername } { (mention.roomName && mention.roomName.length > 0) && - { mention.roomName } } + · { mention.roomName } } + + + + + { (time.length > 0) && + { time } } + + { onGoto && + stop(event, () => onGoto(mention)) }>→ } + { onRemove && + stop(event, () => onRemove(mention)) }>✕ } - { mention.message } ); diff --git a/src/components/mentions/MentionsView.tsx b/src/components/mentions/MentionsView.tsx index fdc58f1..6846ed2 100644 --- a/src/components/mentions/MentionsView.tsx +++ b/src/components/mentions/MentionsView.tsx @@ -1,22 +1,53 @@ import { MarkMentionsReadComposer } from '@nitrots/nitro-renderer'; -import { FC, useCallback } from 'react'; -import { LocalizeText, SendMessageComposer } from '../../api'; +import { FC, useCallback, useMemo, useState } from 'react'; +import { IMentionEntry, LocalizeText, MentionType, SendMessageComposer } from '../../api'; import { Button, Flex, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common'; import { useMentionsSnapshot } from '../../hooks'; import { markAllRead } from '../../hooks/mentions/mentionsStore'; +import { useUserDataSnapshot } from '../../hooks/session/useSessionSnapshots'; import { MentionRowView } from './MentionRowView'; -import { useMentionRowClick } from './useMentionRowClick'; +import { getMentionDateGroup, MentionDateGroup } from './mentionsFormat'; +import { useMentionActions } from './useMentionActions'; interface MentionsViewProps { onClose: () => void; } +type MentionFilter = 'all' | 'unread' | 'direct' | 'room'; + +const FILTERS: ReadonlyArray<{ key: MentionFilter; label: string }> = [ + { key: 'all', label: 'mentions.filter.all' }, + { key: 'unread', label: 'mentions.filter.unread' }, + { key: 'direct', label: 'mentions.filter.direct' }, + { key: 'room', label: 'mentions.filter.room' } +]; + +const GROUP_ORDER: ReadonlyArray = [ 'today', 'yesterday', 'older' ]; +const GROUP_LABEL: Record = { + today: 'mentions.group.today', + yesterday: 'mentions.group.yesterday', + older: 'mentions.group.older' +}; + +const matchesFilter = (mention: IMentionEntry, filter: MentionFilter): boolean => +{ + switch(filter) + { + case 'unread': return !mention.read; + case 'direct': return mention.mentionType === MentionType.DIRECT; + case 'room': return mention.mentionType === MentionType.ROOM; + default: return true; + } +}; + export const MentionsView: FC = props => { const { onClose } = props; - const { mentions } = useMentionsSnapshot(); - const onRowClick = useMentionRowClick(); + const { mentions, unreadCount } = useMentionsSnapshot(); + const { userName: ownUsername = '' } = useUserDataSnapshot(); + const { open, goto, remove } = useMentionActions(); + const [ filter, setFilter ] = useState('all'); const onMarkAll = useCallback(() => { @@ -24,18 +55,68 @@ export const MentionsView: FC = props => SendMessageComposer(new MarkMentionsReadComposer(0, 0)); }, []); + const groups = useMemo(() => + { + const buckets: Record = { today: [], yesterday: [], older: [] }; + + for(const mention of mentions) + { + if(!matchesFilter(mention, filter)) continue; + buckets[getMentionDateGroup(mention.timestamp)].push(mention); + } + + return GROUP_ORDER + .map(key => ({ key, items: buckets[key] })) + .filter(group => group.items.length > 0); + }, [ mentions, filter ]); + + const hasAny = groups.length > 0; + return ( - + - - { (mentions.length === 0) - ? { LocalizeText('mentions.window.empty') } - : mentions.map(mention => ( - - )) } + + { FILTERS.map(({ key, label }) => + { + const active = (filter === key); + const showCount = ((key === 'unread') && (unreadCount > 0)); + + return ( + + ); + }) } - { (mentions.length > 0) && + + { !hasAny && + + @ + { LocalizeText('mentions.window.empty') } + } + { hasAny && groups.map(group => ( + + + { LocalizeText(GROUP_LABEL[group.key]) } + + { group.items.map(mention => ( + + )) } + + )) } + + { (unreadCount > 0) && } diff --git a/src/components/mentions/index.ts b/src/components/mentions/index.ts index d49ca61..5eaab12 100644 --- a/src/components/mentions/index.ts +++ b/src/components/mentions/index.ts @@ -1,3 +1,5 @@ +export * from './MentionMessageView'; export * from './MentionRowView'; export * from './MentionsView'; -export * from './useMentionRowClick'; +export * from './mentionsFormat'; +export * from './useMentionActions'; diff --git a/src/components/mentions/mentionsFormat.test.ts b/src/components/mentions/mentionsFormat.test.ts new file mode 100644 index 0000000..4319c81 --- /dev/null +++ b/src/components/mentions/mentionsFormat.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { formatMentionTime, getMentionDateGroup } from './mentionsFormat'; + +// Fixed reference "now": 2026-06-02 14:30 local time. +const NOW = new Date(2026, 5, 2, 14, 30, 0); +const at = (y: number, mo: number, d: number, h = 12, mi = 0): number => Math.floor(new Date(y, mo, d, h, mi, 0).getTime() / 1000); + +describe('getMentionDateGroup', () => +{ + it('buckets same-day as today', () => + { + expect(getMentionDateGroup(at(2026, 5, 2, 9, 15), NOW)).toBe('today'); + }); + + it('buckets previous day as yesterday', () => + { + expect(getMentionDateGroup(at(2026, 5, 1, 23, 59), NOW)).toBe('yesterday'); + }); + + it('buckets two+ days ago as older', () => + { + expect(getMentionDateGroup(at(2026, 4, 28, 10, 0), NOW)).toBe('older'); + }); + + it('treats missing/zero timestamp as older', () => + { + expect(getMentionDateGroup(0, NOW)).toBe('older'); + }); +}); + +describe('formatMentionTime', () => +{ + it('shows HH:MM (zero-padded) for today', () => + { + expect(formatMentionTime(at(2026, 5, 2, 9, 5), NOW)).toBe('09:05'); + }); + + it('shows HH:MM for yesterday', () => + { + expect(formatMentionTime(at(2026, 5, 1, 18, 45), NOW)).toBe('18:45'); + }); + + it('shows DD-MM for older entries', () => + { + expect(formatMentionTime(at(2026, 4, 28, 10, 0), NOW)).toBe('28-05'); + }); + + it('returns empty string for missing timestamp', () => + { + expect(formatMentionTime(0, NOW)).toBe(''); + }); +}); diff --git a/src/components/mentions/mentionsFormat.ts b/src/components/mentions/mentionsFormat.ts new file mode 100644 index 0000000..1c0f14a --- /dev/null +++ b/src/components/mentions/mentionsFormat.ts @@ -0,0 +1,41 @@ +// Date/time helpers for the mentions box. Kept framework-free and pure so they +// are unit-testable. Timestamps are unix SECONDS (as carried on the wire). + +export type MentionDateGroup = 'today' | 'yesterday' | 'older'; + +const DAY_MS = 86_400_000; + +const startOfDay = (d: Date): number => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); + +const pad = (n: number): string => (n < 10 ? `0${ n }` : `${ n }`); + +/** + * Bucket a mention timestamp into today / yesterday / older relative to `now`. + */ +export const getMentionDateGroup = (timestampSeconds: number, now: Date = new Date()): MentionDateGroup => +{ + if(!timestampSeconds || timestampSeconds <= 0) return 'older'; + + const ts = timestampSeconds * 1000; + const todayStart = startOfDay(now); + + if(ts >= todayStart) return 'today'; + if(ts >= (todayStart - DAY_MS)) return 'yesterday'; + + return 'older'; +}; + +/** + * Compact per-row time label: HH:MM for today/yesterday (the section header + * disambiguates the day), DD-MM for older entries. Empty string when unknown. + */ +export const formatMentionTime = (timestampSeconds: number, now: Date = new Date()): string => +{ + if(!timestampSeconds || timestampSeconds <= 0) return ''; + + const d = new Date(timestampSeconds * 1000); + + if(getMentionDateGroup(timestampSeconds, now) === 'older') return `${ pad(d.getDate()) }-${ pad(d.getMonth() + 1) }`; + + return `${ pad(d.getHours()) }:${ pad(d.getMinutes()) }`; +}; diff --git a/src/components/mentions/useMentionActions.ts b/src/components/mentions/useMentionActions.ts new file mode 100644 index 0000000..f2e56c3 --- /dev/null +++ b/src/components/mentions/useMentionActions.ts @@ -0,0 +1,39 @@ +import { CreateLinkEvent, MarkMentionsReadComposer } from '@nitrots/nitro-renderer'; +import { useMemo } from 'react'; +import { IMentionEntry, SendMessageComposer } from '../../api'; +import { markRead, removeMention } from '../../hooks/mentions/mentionsStore'; + +export interface MentionActions +{ + /** Row click: mark the mention as read (no navigation). */ + open: (mention: IMentionEntry) => void; + /** Explicit "go to room" action: mark read, then jump to the origin room. */ + goto: (mention: IMentionEntry) => void; + /** Remove from the list (client-side). Marks read on the server so it does + * not reappear as unread after a relog. A true server-side delete packet + * is a follow-up. */ + remove: (mention: IMentionEntry) => void; +} + +const markReadOnServer = (mention: IMentionEntry): void => +{ + if(mention.read) return; + markRead(mention.mentionId); + SendMessageComposer(new MarkMentionsReadComposer(1, mention.mentionId)); +}; + +// Shared action handlers used by both MentionsView and the chat-history +// "Menzioni" tab so behaviour can't diverge. +export const useMentionActions = (): MentionActions => useMemo(() => ({ + open: (mention) => markReadOnServer(mention), + goto: (mention) => + { + markReadOnServer(mention); + if(mention.roomId > 0) CreateLinkEvent(`navigator/goto/${ mention.roomId }`); + }, + remove: (mention) => + { + if(!mention.read) SendMessageComposer(new MarkMentionsReadComposer(1, mention.mentionId)); + removeMention(mention.mentionId); + } +}), []); diff --git a/src/components/mentions/useMentionRowClick.ts b/src/components/mentions/useMentionRowClick.ts deleted file mode 100644 index 2f8ed85..0000000 --- a/src/components/mentions/useMentionRowClick.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { CreateLinkEvent, MarkMentionsReadComposer } from '@nitrots/nitro-renderer'; -import { useCallback } from 'react'; -import { IMentionEntry, SendMessageComposer } from '../../api'; -import { markRead } from '../../hooks/mentions/mentionsStore'; - -// Shared row-click handler used by both MentionsView and the chat-history -// "Menzioni" tab so the mark-read + room-navigation behaviour can't diverge. -export const useMentionRowClick = (): ((mention: IMentionEntry) => void) => -{ - return useCallback((mention: IMentionEntry) => - { - if(!mention.read) - { - markRead(mention.mentionId); - SendMessageComposer(new MarkMentionsReadComposer(1, mention.mentionId)); - } - - if(mention.roomId > 0) CreateLinkEvent(`navigator/goto/${ mention.roomId }`); - }, []); -}; diff --git a/src/components/room/widgets/chat/highlightMentions.tsx b/src/components/room/widgets/chat/highlightMentions.tsx index c61f9cc..15d32dd 100644 --- a/src/components/room/widgets/chat/highlightMentions.tsx +++ b/src/components/room/widgets/chat/highlightMentions.tsx @@ -56,6 +56,21 @@ const isMentionToken = (token: string, ownUsernameLower: string, aliases: Readon return aliases.has(nick); }; +/** + * Public predicate: does this raw whitespace-delimited token mention the given + * user or a room-broadcast alias? Mirrors the server's detection. Reusable by + * UI that renders mention previews as React nodes (e.g. the mentions box). + */ +export const tokenIsMention = ( + token: string, + ownUsername: string, + aliases: ReadonlyArray = MENTION_ROOM_ALIASES +): boolean => +{ + const ownUsernameLower = (ownUsername || '').replace(NON_NICK_CHARS, '').toLowerCase(); + return isMentionToken(token, ownUsernameLower, new Set(aliases.map(a => a.toLowerCase()))); +}; + const HIGHLIGHT_OPEN = ''; const HIGHLIGHT_CLOSE = ''; diff --git a/src/hooks/mentions/mentionsStore.ts b/src/hooks/mentions/mentionsStore.ts index 324c8ac..23d2b17 100644 --- a/src/hooks/mentions/mentionsStore.ts +++ b/src/hooks/mentions/mentionsStore.ts @@ -40,4 +40,12 @@ export const markAllRead = (): void => emit(); }; +export const removeMention = (mentionId: number): void => +{ + const next = mentions.filter(m => m.mentionId !== mentionId); + if(next.length === mentions.length) return; + mentions = next; + emit(); +}; + export const resetMentions = (): void => { mentions = []; emit(); };