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 543aeec..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)); @@ -186,7 +239,9 @@ 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/customize/CustomizeNickIconView.tsx b/src/components/customize/CustomizeNickIconView.tsx index 4aae464..6485527 100644 --- a/src/components/customize/CustomizeNickIconView.tsx +++ b/src/components/customize/CustomizeNickIconView.tsx @@ -1,4 +1,4 @@ -import { AddLinkEventTracker, ILinkEventTracker, PurchaseCatalogPrefixComposer, PurchaseNickIconComposer, PurchasePrefixComposer, RemoveLinkEventTracker, RequestNickIconsComposer, SetActiveNickIconComposer, SetActivePrefixComposer, SetDisplayOrderComposer, UserNickIconsEvent } from '@nitrots/nitro-renderer'; +import { AddLinkEventTracker, CustomPrefixPurchaseFailedEvent, ILinkEventTracker, PurchaseCatalogPrefixComposer, PurchaseNickIconComposer, PurchasePrefixComposer, RemoveLinkEventTracker, RequestNickIconsComposer, SetActiveNickIconComposer, SetActivePrefixComposer, SetDisplayOrderComposer, UserNickIconsEvent } from '@nitrots/nitro-renderer'; import data from '@emoji-mart/data'; import Picker from '@emoji-mart/react'; import { FC, useEffect, useMemo, useState } from 'react'; @@ -66,6 +66,12 @@ export const CustomizeNickIconView: FC<{}> = () => const [ customPrefixFont, setCustomPrefixFont ] = useState(''); const [ showEmojiPicker, setShowEmojiPicker ] = useState(false); + useMessageEvent(CustomPrefixPurchaseFailedEvent, () => + { + setIsLoading(false); + setIsVisible(false); + }); + useMessageEvent(UserNickIconsEvent, event => { const parser = event.getParser(); @@ -298,7 +304,7 @@ export const CustomizeNickIconView: FC<{}> = () =>
Live preview -
+
= () => Premium fonts add an extra price on top of the custom prefix.
}
-
+
= props = return ( - - « { LocalizeText('groupforum.view.back') } + + { LocalizeText('groupforum.view.back') } diff --git a/src/components/groups/views/forums/GroupForumSettingsView.tsx b/src/components/groups/views/forums/GroupForumSettingsView.tsx index 4c78732..5915b3e 100644 --- a/src/components/groups/views/forums/GroupForumSettingsView.tsx +++ b/src/components/groups/views/forums/GroupForumSettingsView.tsx @@ -58,8 +58,8 @@ export const GroupForumSettingsView: FC = props => return ( - - « { LocalizeText('groupforum.view.back') } + + { LocalizeText('groupforum.view.back') } { LocalizeText('groupforum.settings.window_title') } diff --git a/src/components/groups/views/forums/GroupForumThreadListView.tsx b/src/components/groups/views/forums/GroupForumThreadListView.tsx index bc2aa25..14a7615 100644 --- a/src/components/groups/views/forums/GroupForumThreadListView.tsx +++ b/src/components/groups/views/forums/GroupForumThreadListView.tsx @@ -92,8 +92,8 @@ export const GroupForumThreadListView: FC = props - - « { LocalizeText('groupforum.view.back') } + + { LocalizeText('groupforum.view.back') } diff --git a/src/components/groups/views/forums/GroupForumThreadView.tsx b/src/components/groups/views/forums/GroupForumThreadView.tsx index eabbf60..2895549 100644 --- a/src/components/groups/views/forums/GroupForumThreadView.tsx +++ b/src/components/groups/views/forums/GroupForumThreadView.tsx @@ -203,19 +203,19 @@ export const GroupForumThreadView: FC = props => - - « { LocalizeText('groupforum.view.back') } + + { LocalizeText('groupforum.view.back') } { canModerate && - - - + ); + }) } + + + { !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 {}