diff --git a/public/configuration/UITexts_en.json5.example b/public/configuration/UITexts_en.json5.example index 4b4fcdf..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", diff --git a/public/configuration/UITexts_it.json5.example b/public/configuration/UITexts_it.json5.example index 776900d..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", diff --git a/public/configuration/UITexts_nl.json5.example b/public/configuration/UITexts_nl.json5.example index d3aa55a..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", diff --git a/public/configuration/ui-config.example b/public/configuration/ui-config.example index 65eff4c..b544b4a 100644 --- a/public/configuration/ui-config.example +++ b/public/configuration/ui-config.example @@ -25,6 +25,8 @@ "wired.action.mute.user.max.length": 100, "game.center.enabled": false, "radio_ui.enabled": false, + "mentions_ui.enabled": true, + "mentions_ui.sound": true, "guides.enabled": true, "housekeeping.enabled": true, "toolbar.hide.quests": true, diff --git a/src/api/index.ts b/src/api/index.ts index ae86f5d..75a8126 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -17,6 +17,7 @@ export * from './hc-center'; export * from './help'; export * from './housekeeping'; export * from './inventory'; +export * from './mentions'; export * from './mod-tools'; export * from './navigator'; export * from './nitro'; diff --git a/src/api/mentions/IMentionEntry.ts b/src/api/mentions/IMentionEntry.ts new file mode 100644 index 0000000..c8b89b3 --- /dev/null +++ b/src/api/mentions/IMentionEntry.ts @@ -0,0 +1,12 @@ +export interface IMentionEntry +{ + mentionId: number; + senderId: number; + senderUsername: string; + roomId: number; + roomName: string; + message: string; + mentionType: number; + timestamp: number; + read: boolean; +} diff --git a/src/api/mentions/MentionType.ts b/src/api/mentions/MentionType.ts new file mode 100644 index 0000000..1755931 --- /dev/null +++ b/src/api/mentions/MentionType.ts @@ -0,0 +1,5 @@ +export class MentionType +{ + public static DIRECT: number = 0; + public static ROOM: number = 1; +} diff --git a/src/api/mentions/index.ts b/src/api/mentions/index.ts new file mode 100644 index 0000000..57433b6 --- /dev/null +++ b/src/api/mentions/index.ts @@ -0,0 +1,2 @@ +export * from './MentionType'; +export * from './IMentionEntry'; diff --git a/src/assets/images/toolbar/icons/mentions.png b/src/assets/images/toolbar/icons/mentions.png new file mode 100644 index 0000000..ca7d31d Binary files /dev/null and b/src/assets/images/toolbar/icons/mentions.png differ diff --git a/src/components/MainView.tsx b/src/components/MainView.tsx index 3c763a5..caa04ab 100644 --- a/src/components/MainView.tsx +++ b/src/components/MainView.tsx @@ -1,8 +1,9 @@ -import { AddLinkEventTracker, GetCommunication, GetRoomSessionManager, HabboWebTools, ILinkEventTracker, RemoveLinkEventTracker, RoomSessionEvent } from '@nitrots/nitro-renderer'; +import { AddLinkEventTracker, GetCommunication, GetRoomSessionManager, HabboWebTools, ILinkEventTracker, MarkMentionsReadComposer, RemoveLinkEventTracker, RoomSessionEvent } from '@nitrots/nitro-renderer'; import { AnimatePresence, motion } from 'framer-motion'; import { FC, useEffect, useState } from 'react'; -import { GetConfigurationValue } from '../api'; -import { useNitroEventReducer } from '../hooks'; +import { GetConfigurationValue, SendMessageComposer } from '../api'; +import { useMentionMessages, useNitroEventReducer } from '../hooks'; +import { markAllRead } from '../hooks/mentions/mentionsStore'; import { AchievementsView } from './achievements/AchievementsView'; import { AvatarEditorView } from './avatar-editor'; import { BadgeCreatorView } from './badge-creator'; @@ -47,11 +48,15 @@ import { UserAccountSettingsView } from './user-settings/UserAccountSettingsView import { UserSettingsView } from './user-settings/UserSettingsView'; import { WiredView } from './wired/WiredView'; import { WiredCreatorToolsView } from './wired-tools/WiredCreatorToolsView'; +import { MentionsView } from './mentions'; export const MainView: FC<{}> = props => { const [ isReady, setIsReady ] = useState(false); const [ localizationVersion, setLocalizationVersion ] = useState(0); + const [ mentionsVisible, setMentionsVisible ] = useState(false); + + useMentionMessages(); // CREATED and ENDED can arrive out of order under flaky reconnects. // Treating them as two independent setters left landingViewVisible @@ -124,6 +129,54 @@ export const MainView: FC<{}> = props => return () => RemoveLinkEventTracker(linkTracker); }, []); + useEffect(() => + { + // Opening the inbox clears the unread badge both locally and + // server-side so the toolbar count resets immediately. + const clearMentionsBadge = () => + { + markAllRead(); + SendMessageComposer(new MarkMentionsReadComposer(0, 0)); + }; + + const linkTracker: ILinkEventTracker = { + linkReceived: (url: string) => + { + const parts = url.split('/'); + + if(parts.length < 2) return; + + switch(parts[1]) + { + case 'show': + setMentionsVisible(true); + clearMentionsBadge(); + return; + case 'hide': + setMentionsVisible(false); + return; + case 'toggle': + setMentionsVisible(prevValue => + { + if(prevValue) return false; + + // Side-effect-free in the updater: defer the + // badge-clear to a microtask so React's + // double-invoke (StrictMode) can't fire it twice. + queueMicrotask(clearMentionsBadge); + return true; + }); + return; + } + }, + eventUrlPrefix: 'mentions/' + }; + + AddLinkEventTracker(linkTracker); + + return () => RemoveLinkEventTracker(linkTracker); + }, []); + useEffect(() => { const refreshLocalization = () => setLocalizationVersion(value => (value + 1)); @@ -187,6 +240,8 @@ export const MainView: FC<{}> = props => { GetConfigurationValue('radio_ui.enabled', false) && } + { (GetConfigurationValue('mentions_ui.enabled', true) && mentionsVisible) && + setMentionsVisible(false) } /> } ); diff --git a/src/components/chat-history/ChatHistoryView.tsx b/src/components/chat-history/ChatHistoryView.tsx index c86bbf8..9fb6dd8 100644 --- a/src/components/chat-history/ChatHistoryView.tsx +++ b/src/components/chat-history/ChatHistoryView.tsx @@ -1,15 +1,24 @@ import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; import { FC, useEffect, useMemo, useRef, useState } from 'react'; import { ChatEntryType, LocalizeText } from '../../api'; -import { Flex, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common'; -import { useChatHistory, useOnClickChat } from '../../hooks'; +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, useMentionActions } from '../mentions'; + +const TAB_CHAT = 'chat'; +const TAB_MENTIONS = 'mentions'; export const ChatHistoryView: FC<{}> = props => { const [isVisible, setIsVisible] = useState(false); const [searchText, setSearchText] = useState(''); + const [activeTab, setActiveTab] = useState(TAB_CHAT); const { chatHistory = [] } = useChatHistory(); + const { mentions, unreadCount } = useMentionsSnapshot(); + const { userName: ownMentionUsername = '' } = useUserDataSnapshot(); + const { open: onMentionOpen, goto: onMentionGoto, remove: onMentionRemove } = useMentionActions(); const { onClickChat } = useOnClickChat(); const elementRef = useRef(null); const isFirstRender = useRef(true); @@ -87,7 +96,31 @@ export const ChatHistoryView: FC<{}> = props => return ( setIsVisible(false)} /> + + setActiveTab(TAB_CHAT) }> + { LocalizeText('room.chathistory.button.text') } + + setActiveTab(TAB_MENTIONS) }> + { LocalizeText('mentions.tab.title') } + + + { activeTab === TAB_MENTIONS ? ( +
+ { (mentions.length === 0) + ? { LocalizeText('mentions.window.empty') } + : mentions.map(mention => ( + + )) } +
+ ) : ( + <> setSearchText(event.target.value)} />
{filteredChatHistory.map((row, index) => ( @@ -119,6 +152,8 @@ export const ChatHistoryView: FC<{}> = props => ))}
+ + ) }
); 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 new file mode 100644 index 0000000..f943d1f --- /dev/null +++ b/src/components/mentions/MentionRowView.tsx @@ -0,0 +1,66 @@ +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; + ownUsername: string; + onOpen: (mention: IMentionEntry) => void; + onGoto?: (mention: IMentionEntry) => void; + onRemove?: (mention: IMentionEntry) => void; +} + +export const MentionRowView: FC = 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 ( + onOpen(mention) }> + + + { isRoom ? '@∗' : '@' } + + + + { mention.senderUsername } + { (mention.roomName && mention.roomName.length > 0) && + · { mention.roomName } } + + + + + { (time.length > 0) && + { time } } + + { onGoto && + stop(event, () => onGoto(mention)) }>→ } + { onRemove && + stop(event, () => onRemove(mention)) }>✕ } + + + + ); +}; diff --git a/src/components/mentions/MentionsView.tsx b/src/components/mentions/MentionsView.tsx new file mode 100644 index 0000000..6846ed2 --- /dev/null +++ b/src/components/mentions/MentionsView.tsx @@ -0,0 +1,124 @@ +import { MarkMentionsReadComposer } from '@nitrots/nitro-renderer'; +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 { 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, unreadCount } = useMentionsSnapshot(); + const { userName: ownUsername = '' } = useUserDataSnapshot(); + const { open, goto, remove } = useMentionActions(); + const [ filter, setFilter ] = useState('all'); + + const onMarkAll = useCallback(() => + { + markAllRead(); + 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 ( + + + + + { FILTERS.map(({ key, label }) => + { + const active = (filter === key); + const showCount = ((key === 'unread') && (unreadCount > 0)); + + return ( + + ); + }) } + + + { !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 new file mode 100644 index 0000000..5eaab12 --- /dev/null +++ b/src/components/mentions/index.ts @@ -0,0 +1,5 @@ +export * from './MentionMessageView'; +export * from './MentionRowView'; +export * from './MentionsView'; +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..21be9f9 --- /dev/null +++ b/src/components/mentions/useMentionActions.ts @@ -0,0 +1,39 @@ +import { CreateLinkEvent, DeleteMentionComposer, 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; + /** Permanently delete the mention server-side (DeleteMentionComposer) and + * drop it from the local list, so it does not reappear after a relog. */ + 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) => + { + // Permanent server-side delete, then drop it from the local list. + SendMessageComposer(new DeleteMentionComposer(mention.mentionId)); + removeMention(mention.mentionId); + } +}), []); diff --git a/src/components/room/widgets/chat/ChatWidgetMessageView.tsx b/src/components/room/widgets/chat/ChatWidgetMessageView.tsx index c791599..39093fc 100644 --- a/src/components/room/widgets/chat/ChatWidgetMessageView.tsx +++ b/src/components/room/widgets/chat/ChatWidgetMessageView.tsx @@ -1,8 +1,10 @@ import { GetRoomEngine, RoomChatSettings, RoomObjectCategory } from '@nitrots/nitro-renderer'; import { FC, useEffect, useMemo, useRef, useState } from 'react'; -import { ChatBubbleMessage } from '../../../../api'; +import { ChatBubbleMessage, GetConfigurationValue } from '../../../../api'; import { UserIdentityView } from '../../../../common'; import { useOnClickChat } from '../../../../hooks'; +import { useUserDataSnapshot } from '../../../../hooks/session/useSessionSnapshots'; +import { highlightMentions } from './highlightMentions'; interface ChatWidgetMessageViewProps { @@ -21,6 +23,15 @@ export const ChatWidgetMessageView: FC = ({ const [ isReady, setIsReady ] = useState(false); const elementRef = useRef(null); const { onClickChat } = useOnClickChat(); + const { userName: ownUsername = '' } = useUserDataSnapshot(); + + const mentionsHighlightOn = GetConfigurationValue('mentions_ui.enabled', true); + + const highlight = (html: string): string => (mentionsHighlightOn ? highlightMentions(html, ownUsername) : html); + + const formattedText = useMemo(() => highlight(`${ chat.formattedText }`), [ chat.formattedText, ownUsername, mentionsHighlightOn ]); + const originalFormattedText = useMemo(() => highlight(`${ chat.originalFormattedText || chat.formattedText }`), [ chat.originalFormattedText, chat.formattedText, ownUsername, mentionsHighlightOn ]); + const translatedFormattedText = useMemo(() => highlight(`${ chat.translatedFormattedText || chat.formattedText }`), [ chat.translatedFormattedText, chat.formattedText, ownUsername, mentionsHighlightOn ]); const getBubbleWidth = useMemo(() => { @@ -112,16 +123,16 @@ export const ChatWidgetMessageView: FC = ({ showColon={ true } username={ chat.username } /> { !chat.showTranslation && - } + } { chat.showTranslation &&
original: - +
translate: - +
} diff --git a/src/components/room/widgets/chat/highlightMentions.test.ts b/src/components/room/widgets/chat/highlightMentions.test.ts new file mode 100644 index 0000000..8c21557 --- /dev/null +++ b/src/components/room/widgets/chat/highlightMentions.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest'; +import { highlightMentions, MENTION_ROOM_ALIASES } from './highlightMentions'; + +const OPEN = ''; +const CLOSE = ''; + +describe('highlightMentions', () => +{ + it('highlights the own-nick token', () => + { + const out = highlightMentions('hello @Bob how are you', 'Bob'); + + expect(out).toBe(`hello ${ OPEN }@Bob${ CLOSE } how are you`); + }); + + it('highlights a room-broadcast alias token', () => + { + const out = highlightMentions('@all party time', 'Bob'); + + expect(out).toBe(`${ OPEN }@all${ CLOSE } party time`); + }); + + it('highlights every configured room alias', () => + { + for(const alias of MENTION_ROOM_ALIASES) + { + const out = highlightMentions(`hey @${ alias }!`, 'Bob'); + + expect(out).toBe(`hey ${ OPEN }@${ alias }!${ CLOSE }`); + } + }); + + it('leaves non-mention text untouched', () => + { + const text = 'just a normal sentence with no at signs'; + + expect(highlightMentions(text, 'Bob')).toBe(text); + }); + + it('returns the message unchanged when there is no mention of me or an alias', () => + { + const text = 'hi @Charlie and @Dave'; + + // Neither @Charlie nor @Dave is the local user or a room alias. + expect(highlightMentions(text, 'Bob')).toBe(text); + }); + + it('matches a token with trailing punctuation (mirrors server stripping)', () => + { + const out = highlightMentions('watch out @Bob! seriously', 'Bob'); + + // The original token text (including the `!`) is kept inside the span. + expect(out).toBe(`watch out ${ OPEN }@Bob!${ CLOSE } seriously`); + }); + + it('matches case-insensitively but preserves the original casing', () => + { + const out = highlightMentions('yo @bOb whatup', 'BOB'); + + expect(out).toBe(`yo ${ OPEN }@bOb${ CLOSE } whatup`); + }); + + it('preserves the original spacing verbatim', () => + { + const out = highlightMentions('a @Bob\tb', 'Bob'); + + expect(out).toBe(`a ${ OPEN }@Bob${ CLOSE }\tb`); + }); + + it('does not highlight inside HTML tags produced by the formatter', () => + { + // Formatter output: wired bold markup around a mention. + const out = highlightMentions('hi @Bob', 'Bob'); + + expect(out).toBe(`hi ${ OPEN }@Bob${ CLOSE }`); + }); + + it('leaves font-colour spans and line breaks intact', () => + { + const html = 'hi @Bob
bye'; + const out = highlightMentions(html, 'Bob'); + + expect(out).toBe(`hi ${ OPEN }@Bob${ CLOSE }
bye`); + }); + + it('highlights multiple distinct mentions in one message', () => + { + const out = highlightMentions('@Bob and @all listen', 'Bob'); + + expect(out).toBe(`${ OPEN }@Bob${ CLOSE } and ${ OPEN }@all${ CLOSE } listen`); + }); + + it('ignores a bare @ with no nick', () => + { + const text = 'email me @ home'; + + expect(highlightMentions(text, 'Bob')).toBe(text); + }); + + it('returns input verbatim when there is no @ at all (fast path)', () => + { + const text = 'plain message'; + + expect(highlightMentions(text, 'Bob')).toBe(text); + }); + + it('returns input verbatim when own username is empty and no alias matches', () => + { + const text = 'hi @Charlie'; + + expect(highlightMentions(text, '')).toBe(text); + }); + + it('still highlights aliases when own username is empty', () => + { + const out = highlightMentions('@everyone hi', ''); + + expect(out).toBe(`${ OPEN }@everyone${ CLOSE } hi`); + }); +}); diff --git a/src/components/room/widgets/chat/highlightMentions.tsx b/src/components/room/widgets/chat/highlightMentions.tsx new file mode 100644 index 0000000..15d32dd --- /dev/null +++ b/src/components/room/widgets/chat/highlightMentions.tsx @@ -0,0 +1,167 @@ +/** + * Cosmetic-only mention highlighting for in-room chat bubbles. + * + * The bubble text is rendered through {@link RoomChatFormatter}, which emits + * an HTML string (wired markup ``/``/``, font-colour + * ``, `
`, plus HTML-entity-encoded special characters) and + * is injected via `dangerouslySetInnerHTML`. We therefore operate on the + * already-formatted HTML string and wrap mention tokens that appear in the + * TEXT regions (never inside a ``), returning a new HTML string. This + * keeps every existing formatting behaviour intact and is purely visual — it + * does not touch `chat.text`, parsing, chat history, or any wire payload. + * + * Token detection mirrors the server's `MentionManager.process` exactly: + * - split on whitespace + * - a candidate token has length >= 2 and starts with `@` + * - strip the `@`, remove every char that is not [A-Za-z0-9_], lowercase + * - match against the local username or a room-broadcast alias + * + * This means `@Bob!`, `@bob,` etc. all match the nick `Bob` (case-insensitive) + * just like the server, while the highlighted span keeps the original token + * text (`@` + original casing + trailing punctuation) verbatim. + */ + +// Mirror of `mentions.room.aliases` default in Arcturus +// (com.eu.habbo.habbohotel.mentions.MentionManager#roomAliases). +export const MENTION_ROOM_ALIASES: ReadonlyArray = [ + 'amici', 'friends', 'all', 'everyone', 'tutti', 'room', 'stanza' +]; + +const NON_NICK_CHARS = /[^A-Za-z0-9_]/g; + +/** + * Normalise a raw `@token` the same way the server does: drop the leading `@`, + * strip any non-nick characters (trailing punctuation, etc.), lowercase. + * Returns an empty string when nothing usable remains. + */ +const normalizeToken = (token: string): string => +{ + if(!token || token.length < 2 || token.charAt(0) !== '@') return ''; + + return token.substring(1).replace(NON_NICK_CHARS, '').toLowerCase(); +}; + +/** + * Whether the given raw whitespace-delimited token mentions the local user + * or a room-broadcast alias. + */ +const isMentionToken = (token: string, ownUsernameLower: string, aliases: ReadonlySet): boolean => +{ + const nick = normalizeToken(token); + + if(!nick) return false; + + if(ownUsernameLower && nick === ownUsernameLower) return true; + + 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 = ''; + +/** + * Wrap mention tokens in a single text chunk (no HTML tags inside it). + * Whitespace runs between tokens are preserved verbatim by re-using the + * original substrings around each match. + */ +const highlightTextChunk = (chunk: string, ownUsernameLower: string, aliases: ReadonlySet): string => +{ + if(chunk.indexOf('@') < 0) return chunk; + + // Split into alternating [whitespace, token, whitespace, token, ...] + // segments so the exact original spacing is rebuilt unchanged. + const segments = chunk.split(/(\s+)/); + + let result = ''; + + for(const segment of segments) + { + if(segment.length === 0) continue; + + // Whitespace runs and non-mention tokens pass through untouched. + if(/^\s+$/.test(segment) || !isMentionToken(segment, ownUsernameLower, aliases)) + { + result += segment; + continue; + } + + result += `${ HIGHLIGHT_OPEN }${ segment }${ HIGHLIGHT_CLOSE }`; + } + + return result; +}; + +/** + * Take the formatted bubble HTML and return new HTML where every mention + * token (own nick or room alias) in the text regions is wrapped in + * ``. HTML tags are passed through + * untouched so existing markup keeps working. + * + * Returns the input unchanged when there is no `@`, no own username, and no + * possibility of a match (fast path), or when nothing matches. + */ +export const highlightMentions = ( + formattedHtml: string, + ownUsername: string, + aliases: ReadonlyArray = MENTION_ROOM_ALIASES +): string => +{ + if(!formattedHtml || formattedHtml.indexOf('@') < 0) return formattedHtml; + + const ownUsernameLower = (ownUsername || '').replace(NON_NICK_CHARS, '').toLowerCase(); + const aliasSet = new Set(aliases.map(a => a.toLowerCase())); + + // Nothing could ever match → return verbatim. + if(!ownUsernameLower && aliasSet.size === 0) return formattedHtml; + + // Walk the string, only highlighting inside text regions (outside `<...>`). + let result = ''; + let cursor = 0; + + while(cursor < formattedHtml.length) + { + const tagStart = formattedHtml.indexOf('<', cursor); + + if(tagStart < 0) + { + result += highlightTextChunk(formattedHtml.slice(cursor), ownUsernameLower, aliasSet); + break; + } + + // Text region before the next tag. + if(tagStart > cursor) + { + result += highlightTextChunk(formattedHtml.slice(cursor, tagStart), ownUsernameLower, aliasSet); + } + + const tagEnd = formattedHtml.indexOf('>', tagStart); + + if(tagEnd < 0) + { + // Malformed trailing `<` with no closing `>` — emit the rest verbatim. + result += formattedHtml.slice(tagStart); + break; + } + + // Emit the tag (including the angle brackets) untouched. + result += formattedHtml.slice(tagStart, tagEnd + 1); + cursor = tagEnd + 1; + } + + return result; +}; diff --git a/src/components/toolbar/ToolbarView.tsx b/src/components/toolbar/ToolbarView.tsx index a2124fd..e8ad69a 100644 --- a/src/components/toolbar/ToolbarView.tsx +++ b/src/components/toolbar/ToolbarView.tsx @@ -3,7 +3,7 @@ import { AnimatePresence, motion, Variants } from 'framer-motion'; import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { GetConfigurationValue, isHousekeepingEnabled, MessengerIconState, OpenMessengerChat, setYoutubeRoomEnabled, VisitDesktop } from '../../api'; import { Flex, LayoutAvatarImageView, LayoutItemCountView } from '../../common'; -import { useAchievements, useFriends, useHasPermission, useInventoryUnseenTracker, useMessageEvent, useMessenger, useModTools, useNitroEvent, useSessionInfo, useSoundboard, useWiredTools } from '../../hooks'; +import { useAchievements, useFriends, useHasPermission, useInventoryUnseenTracker, useMentionsSnapshot, useMessageEvent, useMessenger, useModTools, useNitroEvent, useSessionInfo, useSoundboard, useWiredTools } from '../../hooks'; import { ToolbarItemView } from './ToolbarItemView'; import { ToolbarMeView } from './ToolbarMeView'; import { YouTubePlayerView } from './YouTubePlayerView'; @@ -42,6 +42,8 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => const { getTotalUnseen = 0 } = useAchievements(); const { requests = [] } = useFriends(); const { iconState = MessengerIconState.HIDDEN } = useMessenger(); + const { unreadCount: mentionsUnread = 0 } = useMentionsSnapshot(); + const mentionsEnabled = useMemo(() => GetConfigurationValue('mentions_ui.enabled', true), []); const { openMonitor, showToolbarButton } = useWiredTools(); const { enabled: soundboardEnabled, reset: resetSoundboard } = useSoundboard(); const isMod = useHasPermission('acc_supporttool'); @@ -332,6 +334,12 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => { (requests.length > 0) && } + { mentionsEnabled && + + CreateLinkEvent('mentions/toggle') } className="tb-icon" /> + { (mentionsUnread > 0) && + } + } { ((iconState === MessengerIconState.SHOW) || (iconState === MessengerIconState.UNREAD)) && OpenMessengerChat() } /> @@ -422,6 +430,12 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => { (requests.length > 0) && } + { mentionsEnabled && + + CreateLinkEvent('mentions/toggle') } className="tb-icon" /> + { (mentionsUnread > 0) && + } + } { /* Mobile side tools — moved out of the bottom bar into a diff --git a/src/css/chat/Chats.css b/src/css/chat/Chats.css index 13187d2..8dd0096 100644 --- a/src/css/chat/Chats.css +++ b/src/css/chat/Chats.css @@ -2396,3 +2396,12 @@ } } + +/* Mention highlight inside chat bubbles (cosmetic) */ +.mention-highlight { + font-weight: 700; + color: #1e7295; + background-color: rgba(30, 114, 149, 0.16); + border-radius: 3px; + padding: 0 2px; +} diff --git a/src/css/icons/icons.css b/src/css/icons/icons.css index 17a903f..7245da9 100644 --- a/src/css/icons/icons.css +++ b/src/css/icons/icons.css @@ -229,6 +229,13 @@ height: 32px; } +.nitro-icon.icon-mentions { + background-image: url("@/assets/images/toolbar/icons/mentions.png"); + background-size: contain; + width: 36px; + height: 32px; +} + .nitro-icon.icon-wired-tools { background-image: url("@/assets/images/wiredtools/wired_menu.png"); background-size: contain; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 46a0287..87643b2 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -11,6 +11,7 @@ export * from './groups'; export * from './help'; export * from './housekeeping'; export * from './inventory'; +export * from './mentions'; export * from './mod-tools'; export * from './navigator'; export * from './notification'; diff --git a/src/hooks/mentions/__tests__/mentionsStore.test.ts b/src/hooks/mentions/__tests__/mentionsStore.test.ts new file mode 100644 index 0000000..cc3557d --- /dev/null +++ b/src/hooks/mentions/__tests__/mentionsStore.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, beforeEach } from 'vitest'; +import { addMention, setMentions, markAllRead, markRead, getMentionsSnapshot, getUnreadCount, resetMentions } from '../mentionsStore'; +import { IMentionEntry } from '../../../api/mentions'; + +const make = (id: number, read = false): IMentionEntry => ({ + mentionId: id, senderId: 1, senderUsername: 'Bob', roomId: 9, roomName: 'R', + message: '@me hi', mentionType: 0, timestamp: 0, read +}); + +describe('mentionsStore', () => +{ + beforeEach(() => resetMentions()); + + it('adds newest-first and counts unread', () => + { + addMention(make(1)); + addMention(make(2)); + expect(getMentionsSnapshot()[0].mentionId).toBe(2); + expect(getUnreadCount()).toBe(2); + }); + + it('setMentions replaces and recomputes unread', () => + { + setMentions([make(1, true), make(2, false)]); + expect(getMentionsSnapshot()).toHaveLength(2); + expect(getUnreadCount()).toBe(1); + }); + + it('markAllRead zeroes unread', () => + { + setMentions([make(1), make(2)]); + markAllRead(); + expect(getUnreadCount()).toBe(0); + }); + + it('markRead clears a single entry', () => + { + setMentions([make(1), make(2)]); + markRead(1); + expect(getUnreadCount()).toBe(1); + expect(getMentionsSnapshot().find(m => m.mentionId === 1)!.read).toBe(true); + }); +}); diff --git a/src/hooks/mentions/index.ts b/src/hooks/mentions/index.ts new file mode 100644 index 0000000..3486da8 --- /dev/null +++ b/src/hooks/mentions/index.ts @@ -0,0 +1,2 @@ +export * from './useMentionsSnapshot'; +export * from './useMentionMessages'; diff --git a/src/hooks/mentions/mentionsStore.ts b/src/hooks/mentions/mentionsStore.ts new file mode 100644 index 0000000..23d2b17 --- /dev/null +++ b/src/hooks/mentions/mentionsStore.ts @@ -0,0 +1,51 @@ +import { IMentionEntry } from '../../api/mentions'; + +let mentions: IMentionEntry[] = []; +const listeners = new Set<() => void>(); + +const emit = () => { for(const l of listeners) l(); }; + +export const subscribeMentions = (onChange: () => void): (() => void) => +{ + listeners.add(onChange); + return () => { listeners.delete(onChange); }; +}; + +export const getMentionsSnapshot = (): ReadonlyArray => mentions; + +export const getUnreadCount = (): number => mentions.reduce((n, m) => n + (m.read ? 0 : 1), 0); + +export const setMentions = (list: IMentionEntry[]): void => +{ + mentions = [...list].sort((a, b) => b.mentionId - a.mentionId); + emit(); +}; + +export const addMention = (entry: IMentionEntry): void => +{ + if(mentions.some(m => m.mentionId === entry.mentionId && entry.mentionId !== 0)) return; + mentions = [entry, ...mentions]; + emit(); +}; + +export const markRead = (mentionId: number): void => +{ + mentions = mentions.map(m => m.mentionId === mentionId ? { ...m, read: true } : m); + emit(); +}; + +export const markAllRead = (): void => +{ + mentions = mentions.map(m => m.read ? m : { ...m, read: true }); + 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(); }; diff --git a/src/hooks/mentions/useMentionMessages.ts b/src/hooks/mentions/useMentionMessages.ts new file mode 100644 index 0000000..192b828 --- /dev/null +++ b/src/hooks/mentions/useMentionMessages.ts @@ -0,0 +1,72 @@ +import { MentionReceivedEvent, MentionsListEvent, RequestMentionsComposer } from '@nitrots/nitro-renderer'; +import { useCallback, useEffect } from 'react'; +import { GetConfigurationValue, IMentionEntry, LocalizeText, NotificationBubbleType, PlaySound, SendMessageComposer } from '../../api'; +import { useMessageEvent } from '../events'; +import { useNotificationActions } from '../notification'; +import { addMention, setMentions } from './mentionsStore'; + +// Dedicated mention chime served from nitro-assets/sounds/.mp3. +const MENTION_SOUND_SAMPLE = 'mentions_notification'; + +export const useMentionMessages = (): void => +{ + const { showSingleBubble } = useNotificationActions(); + + const onMentionsList = useCallback((event: MentionsListEvent) => + { + const list = event.getParser().mentions; + + setMentions(list.map(m => ({ + mentionId: m.mentionId, + senderId: m.senderId, + senderUsername: m.senderUsername, + roomId: m.roomId, + roomName: m.roomName, + message: m.message, + mentionType: m.mentionType, + timestamp: m.timestamp, + read: m.read + }))); + }, []); + + const onMentionReceived = useCallback((event: MentionReceivedEvent) => + { + if(!GetConfigurationValue('mentions_ui.enabled', true)) return; + + const m = event.getParser().mention; + + const entry: IMentionEntry = { + mentionId: m.mentionId, + senderId: m.senderId, + senderUsername: m.senderUsername, + roomId: m.roomId, + roomName: m.roomName, + message: m.message, + mentionType: m.mentionType, + timestamp: m.timestamp, + read: false + }; + + addMention(entry); + + if(GetConfigurationValue('mentions_ui.sound', true)) PlaySound(MENTION_SOUND_SAMPLE); + + showSingleBubble( + LocalizeText('mentions.notification', [ 'sender', 'room' ], [ entry.senderUsername, entry.roomName ]), + NotificationBubbleType.INFO, + null, + 'mentions/toggle', + entry.senderUsername + ); + }, [ showSingleBubble ]); + + useMessageEvent(MentionsListEvent, onMentionsList); + useMessageEvent(MentionReceivedEvent, onMentionReceived); + + useEffect(() => + { + if(!GetConfigurationValue('mentions_ui.enabled', true)) return; + + SendMessageComposer(new RequestMentionsComposer()); + }, []); +}; diff --git a/src/hooks/mentions/useMentionsSnapshot.ts b/src/hooks/mentions/useMentionsSnapshot.ts new file mode 100644 index 0000000..e5b6e77 --- /dev/null +++ b/src/hooks/mentions/useMentionsSnapshot.ts @@ -0,0 +1,10 @@ +import { IMentionEntry } from '../../api/mentions'; +import { useExternalSnapshot } from '../events/useExternalSnapshot'; +import { getMentionsSnapshot, getUnreadCount, subscribeMentions } from './mentionsStore'; + +export const useMentionsSnapshot = (): { mentions: ReadonlyArray; unreadCount: number } => +{ + const mentions = useExternalSnapshot(subscribeMentions, getMentionsSnapshot); + const unreadCount = useExternalSnapshot(subscribeMentions, getUnreadCount); + return { mentions, unreadCount }; +}; diff --git a/src/nitro-renderer.mock.ts b/src/nitro-renderer.mock.ts index d80b851..e96ba0f 100644 --- a/src/nitro-renderer.mock.ts +++ b/src/nitro-renderer.mock.ts @@ -251,6 +251,11 @@ export class FlatAccessDeniedMessageEvent extends MessageEvent {} export class GenericErrorEvent extends MessageEvent {} export class GetGuestRoomResultEvent extends MessageEvent {} +// Mentions system — incoming events extend MessageEvent (they expose +// getParser()); the request/mark composers are symbol-only constructors. +export class MentionReceivedEvent extends MessageEvent {} +export class MentionsListEvent extends MessageEvent {} + // --------------------------------------------------------------------------- // Navigator event classes — MessageEvent subclasses needed by useNavigatorStore // --------------------------------------------------------------------------- @@ -377,6 +382,9 @@ export class FollowFriendMessageComposer extends StubClass {} export class GetUserEventCatsMessageComposer extends StubClass {} export class GetUserFlatCatsMessageComposer extends StubClass {} export class NavigatorSearchComposer extends StubClass {} +export class RequestMentionsComposer extends StubClass {} +export class MarkMentionsReadComposer extends StubClass {} +export class DeleteMentionComposer extends StubClass {} export class DesktopViewComposer extends StubClass {} export class FurniturePlacePaintComposer extends StubClass {} export class GetGuestRoomMessageComposer extends StubClass {}