From afb8100300000513e26c0605a6ac66c266fcccde Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 31 May 2026 21:56:35 +0200 Subject: [PATCH 01/10] feat(mentions): client api types, store, snapshot + message hooks --- src/api/index.ts | 1 + src/api/mentions/IMentionEntry.ts | 12 +++ src/api/mentions/MentionType.ts | 5 ++ src/api/mentions/index.ts | 2 + src/hooks/index.ts | 1 + .../mentions/__tests__/mentionsStore.test.ts | 43 +++++++++++ src/hooks/mentions/index.ts | 2 + src/hooks/mentions/mentionsStore.ts | 43 +++++++++++ src/hooks/mentions/useMentionMessages.ts | 73 +++++++++++++++++++ src/hooks/mentions/useMentionsSnapshot.ts | 10 +++ 10 files changed, 192 insertions(+) create mode 100644 src/api/mentions/IMentionEntry.ts create mode 100644 src/api/mentions/MentionType.ts create mode 100644 src/api/mentions/index.ts create mode 100644 src/hooks/mentions/__tests__/mentionsStore.test.ts create mode 100644 src/hooks/mentions/index.ts create mode 100644 src/hooks/mentions/mentionsStore.ts create mode 100644 src/hooks/mentions/useMentionMessages.ts create mode 100644 src/hooks/mentions/useMentionsSnapshot.ts 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/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..324c8ac --- /dev/null +++ b/src/hooks/mentions/mentionsStore.ts @@ -0,0 +1,43 @@ +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 resetMentions = (): void => { mentions = []; emit(); }; diff --git a/src/hooks/mentions/useMentionMessages.ts b/src/hooks/mentions/useMentionMessages.ts new file mode 100644 index 0000000..11a7452 --- /dev/null +++ b/src/hooks/mentions/useMentionMessages.ts @@ -0,0 +1,73 @@ +import { MarkMentionsReadComposer, MentionReceivedEvent, MentionsListEvent, RequestMentionsComposer } from '@nitrots/nitro-renderer'; +import { useCallback, useEffect } from 'react'; +import { GetConfigurationValue, IMentionEntry, LocalizeText, NotificationBubbleType, PlaySound, SendMessageComposer, SoundNames } from '../../api'; +import { useMessageEvent } from '../events'; +import { useNotificationActions } from '../notification'; +import { addMention, setMentions } from './mentionsStore'; + +// MarkMentionsReadComposer is part of the mentions wire contract; it is sent by +// the UI layer (later phase) when the user opens / clears the mentions window. +void MarkMentionsReadComposer; + +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(SoundNames.MESSENGER_MESSAGE_RECEIVED); + + 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 }; +}; From c67c90d4c1822ebe6513eb510864d1edeec7cd66 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 31 May 2026 22:07:24 +0200 Subject: [PATCH 02/10] feat(mentions): inbox window, toolbar badge, chat-history tab, ui-config + i18n --- public/configuration/UITexts_it.json5.example | 9 +++ public/configuration/ui-config.example | 2 + src/components/MainView.tsx | 61 ++++++++++++++++++- .../chat-history/ChatHistoryView.tsx | 31 +++++++++- src/components/mentions/MentionRowView.tsx | 29 +++++++++ src/components/mentions/MentionsView.tsx | 43 +++++++++++++ src/components/mentions/index.ts | 3 + src/components/mentions/useMentionRowClick.ts | 20 ++++++ src/components/toolbar/ToolbarView.tsx | 16 ++++- src/hooks/mentions/useMentionMessages.ts | 6 +- src/nitro-renderer.mock.ts | 7 +++ 11 files changed, 216 insertions(+), 11 deletions(-) create mode 100644 src/components/mentions/MentionRowView.tsx create mode 100644 src/components/mentions/MentionsView.tsx create mode 100644 src/components/mentions/index.ts create mode 100644 src/components/mentions/useMentionRowClick.ts diff --git a/public/configuration/UITexts_it.json5.example b/public/configuration/UITexts_it.json5.example index 776900d..580f4f8 100644 --- a/public/configuration/UITexts_it.json5.example +++ b/public/configuration/UITexts_it.json5.example @@ -772,4 +772,13 @@ '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/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/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..dac3f07 100644 --- a/src/components/chat-history/ChatHistoryView.tsx +++ b/src/components/chat-history/ChatHistoryView.tsx @@ -1,15 +1,22 @@ 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 { NitroInput } from '../../layout'; +import { MentionRowView, useMentionRowClick } 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 } = useMentionsSnapshot(); + const onMentionRowClick = useMentionRowClick(); const { onClickChat } = useOnClickChat(); const elementRef = useRef(null); const isFirstRender = useRef(true); @@ -87,7 +94,25 @@ export const ChatHistoryView: FC<{}> = props => return ( setIsVisible(false)} /> + + setActiveTab(TAB_CHAT) }> + { LocalizeText('room.chathistory.button.text') } + + n + (m.read ? 0 : 1), 0) } isActive={ activeTab === TAB_MENTIONS } onClick={ () => 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 +144,8 @@ export const ChatHistoryView: FC<{}> = props => ))}
+ + ) }
); diff --git a/src/components/mentions/MentionRowView.tsx b/src/components/mentions/MentionRowView.tsx new file mode 100644 index 0000000..da3e56f --- /dev/null +++ b/src/components/mentions/MentionRowView.tsx @@ -0,0 +1,29 @@ +import { FC } from 'react'; +import { IMentionEntry } from '../../api'; +import { Flex, Text } from '../../common'; + +interface MentionRowViewProps +{ + mention: IMentionEntry; + onClick: (mention: IMentionEntry) => void; +} + +export const MentionRowView: FC = props => +{ + const { mention, onClick } = props; + + return ( + onClick(mention) }> + + + + { mention.senderUsername } + { (mention.roomName && mention.roomName.length > 0) && + { mention.roomName } } + + { mention.message } + + + ); +}; diff --git a/src/components/mentions/MentionsView.tsx b/src/components/mentions/MentionsView.tsx new file mode 100644 index 0000000..fdc58f1 --- /dev/null +++ b/src/components/mentions/MentionsView.tsx @@ -0,0 +1,43 @@ +import { MarkMentionsReadComposer } from '@nitrots/nitro-renderer'; +import { FC, useCallback } from 'react'; +import { LocalizeText, SendMessageComposer } from '../../api'; +import { Button, Flex, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common'; +import { useMentionsSnapshot } from '../../hooks'; +import { markAllRead } from '../../hooks/mentions/mentionsStore'; +import { MentionRowView } from './MentionRowView'; +import { useMentionRowClick } from './useMentionRowClick'; + +interface MentionsViewProps +{ + onClose: () => void; +} + +export const MentionsView: FC = props => +{ + const { onClose } = props; + const { mentions } = useMentionsSnapshot(); + const onRowClick = useMentionRowClick(); + + const onMarkAll = useCallback(() => + { + markAllRead(); + SendMessageComposer(new MarkMentionsReadComposer(0, 0)); + }, []); + + return ( + + + + + { (mentions.length === 0) + ? { LocalizeText('mentions.window.empty') } + : mentions.map(mention => ( + + )) } + + { (mentions.length > 0) && + } + + + ); +}; diff --git a/src/components/mentions/index.ts b/src/components/mentions/index.ts new file mode 100644 index 0000000..d49ca61 --- /dev/null +++ b/src/components/mentions/index.ts @@ -0,0 +1,3 @@ +export * from './MentionRowView'; +export * from './MentionsView'; +export * from './useMentionRowClick'; diff --git a/src/components/mentions/useMentionRowClick.ts b/src/components/mentions/useMentionRowClick.ts new file mode 100644 index 0000000..2f8ed85 --- /dev/null +++ b/src/components/mentions/useMentionRowClick.ts @@ -0,0 +1,20 @@ +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/toolbar/ToolbarView.tsx b/src/components/toolbar/ToolbarView.tsx index a2124fd..cc7d7c6 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/hooks/mentions/useMentionMessages.ts b/src/hooks/mentions/useMentionMessages.ts index 11a7452..b647c76 100644 --- a/src/hooks/mentions/useMentionMessages.ts +++ b/src/hooks/mentions/useMentionMessages.ts @@ -1,14 +1,10 @@ -import { MarkMentionsReadComposer, MentionReceivedEvent, MentionsListEvent, RequestMentionsComposer } from '@nitrots/nitro-renderer'; +import { MentionReceivedEvent, MentionsListEvent, RequestMentionsComposer } from '@nitrots/nitro-renderer'; import { useCallback, useEffect } from 'react'; import { GetConfigurationValue, IMentionEntry, LocalizeText, NotificationBubbleType, PlaySound, SendMessageComposer, SoundNames } from '../../api'; import { useMessageEvent } from '../events'; import { useNotificationActions } from '../notification'; import { addMention, setMentions } from './mentionsStore'; -// MarkMentionsReadComposer is part of the mentions wire contract; it is sent by -// the UI layer (later phase) when the user opens / clears the mentions window. -void MarkMentionsReadComposer; - export const useMentionMessages = (): void => { const { showSingleBubble } = useNotificationActions(); diff --git a/src/nitro-renderer.mock.ts b/src/nitro-renderer.mock.ts index d80b851..5cb58b9 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,8 @@ 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 DesktopViewComposer extends StubClass {} export class FurniturePlacePaintComposer extends StubClass {} export class GetGuestRoomMessageComposer extends StubClass {} From c1085aa4b190373b9580fbccdbd3484b1afb0a95 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 31 May 2026 22:14:23 +0200 Subject: [PATCH 03/10] fix(mentions): add en/nl translations for mention strings --- public/configuration/UITexts_en.json5.example | 9 +++++++++ public/configuration/UITexts_nl.json5.example | 9 +++++++++ src/components/chat-history/ChatHistoryView.tsx | 4 ++-- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/public/configuration/UITexts_en.json5.example b/public/configuration/UITexts_en.json5.example index 4b4fcdf..90c1aa1 100644 --- a/public/configuration/UITexts_en.json5.example +++ b/public/configuration/UITexts_en.json5.example @@ -772,4 +772,13 @@ '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_nl.json5.example b/public/configuration/UITexts_nl.json5.example index d3aa55a..8e76beb 100644 --- a/public/configuration/UITexts_nl.json5.example +++ b/public/configuration/UITexts_nl.json5.example @@ -774,4 +774,13 @@ '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 dac3f07..19d7611 100644 --- a/src/components/chat-history/ChatHistoryView.tsx +++ b/src/components/chat-history/ChatHistoryView.tsx @@ -15,7 +15,7 @@ export const ChatHistoryView: FC<{}> = props => const [searchText, setSearchText] = useState(''); const [activeTab, setActiveTab] = useState(TAB_CHAT); const { chatHistory = [] } = useChatHistory(); - const { mentions } = useMentionsSnapshot(); + const { mentions, unreadCount } = useMentionsSnapshot(); const onMentionRowClick = useMentionRowClick(); const { onClickChat } = useOnClickChat(); const elementRef = useRef(null); @@ -98,7 +98,7 @@ export const ChatHistoryView: FC<{}> = props => setActiveTab(TAB_CHAT) }> { LocalizeText('room.chathistory.button.text') } - n + (m.read ? 0 : 1), 0) } isActive={ activeTab === TAB_MENTIONS } onClick={ () => setActiveTab(TAB_MENTIONS) }> + setActiveTab(TAB_MENTIONS) }> { LocalizeText('mentions.tab.title') } From cdabdedfbf55ca336c1ecb5ee0f5211ee20efb44 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 31 May 2026 22:19:54 +0200 Subject: [PATCH 04/10] feat(mentions): dedicated toolbar icon sprite --- src/assets/images/toolbar/icons/mentions.png | Bin 0 -> 2252 bytes src/components/toolbar/ToolbarView.tsx | 4 ++-- src/css/icons/icons.css | 7 +++++++ 3 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 src/assets/images/toolbar/icons/mentions.png diff --git a/src/assets/images/toolbar/icons/mentions.png b/src/assets/images/toolbar/icons/mentions.png new file mode 100644 index 0000000000000000000000000000000000000000..ca7d31dfcab33b0ca34b92d23f9f0931f60e0005 GIT binary patch literal 2252 zcmV;-2s8JIP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D2w_P?K~#8N?VD{- zRM#2D^D!U$vEOd*U8A5@RM1^yfn`Bhc7f$xR8Yu>HIrzQRwUEfs$fPN6VX^-0>Y|M zUPV+?Y)n)PhNwhiCfZtKX{^(XVnl=#nIL{V&zBx%q`qeeBND0UbO1`FFKT}%*!U%I~yE^)9Siej4}2w`j=Rhu9PQzc32 zWfLI_bEBZ@4_4T_!3Nz8*`tQT+tTEAEnR4W@UU@QyKx5K62bO$#AYxUY?37PlfjLV zVbENa3im!Of~S{Df`UU^)4-89g%t^TlkIcB<{mlJTJx9X@$kd>1@QEX;-Cb-SRW&* zfbILRhHDNvt->dagXWDkczS7J(9_eD1;*)Nq(Fm$AedMUhBRErNRHMY_|q#|bMIMN^GrSzIs!j_+{5{jO)zhSE7vBO66{#YryA2REm}lM6+< z(zV+O39}}0Vhjh?V`FLMa!VGKW7rExye#(=#yL80b{;%AU#J^z{Wg~CxQE4aq7?iI zi1pc+T5(u^t;82aaUH7(W#rXzX?_$u{;W_p)-I#PK_NP}+Tm75K35j$@0bn==T-x z^Sv2dKDChf0v71&wk#O@q(Bp!SHvmtzWXR&_T6l!r%&Z;G0HKdti(ti#~5TxkBGIa z61aS7Q76!WOc*>fM-#cW|KCBE6&n>_<*PAD4(#lAC-SH|HxJWm!kKD2b-Xk$(k+mM z%cmA~0<~tsVCNi7cw!yhl+5{%=5AznGmnQn|WnQ72Gy20S{Mr!TIFQ`>5CS zmUjU!&6!Ret6k~6sJq*d!_`%bI)R$f;nByrs_Ch-dj+bqb3R!hAN3MU;mUCo#N)qO z?;U-WuS9e8w4h3$e>SGW4pyaG^F8Dwgn%K6d^wPKNY(V(AGP zKVX5bHKf7A53@C~yl93T%VG*wd{u9FPxB+CVu^{%p$BA+zy+#zz{9^~X`=mY@07v+ z(bsp_38mT7xXvSu6&B7%9%JL{1(_#sf%ZAz;qfd@^d0ivU?Nxc*p*`_*z&?CD9xG% z6~8e-Z&RA=KfNVc_IdnR4&!+0VT|+DHW|{d1$6>_V26QYnVM->O+9acuXd$!Wdfyr zy>~=fjNx#nErTng22}!Gct05ijyiQ=QRY;t5jEvz_*cE1E9Z}|TsAJoxgGkoZzKXK zZ&8nLu)@HR3|+W$APsVoCQ}VT&{wrA8X8u`!MW|TDQZ^4Kt<_Huo$U#STgPFEL;w? z^w!gt9{5g>)kQIURH}=|K&7zrH=Z6A?sV<9w+xs0{JgwB0QxEQXSxqRzm-(K;GpCM+2Zz$0 zDW7dkg6gtau<2WUii`6xyk`8ZK57%5Z@qzqwB~S>eG!N62zx zPYQ%jFpwi?_>BzYI`ncz1l(_Pge)tIsaHz4iQz?756$)W9 zZ?OeEw;NL6<#cYh*%DTRVNH^x*U2C=BF4j+cjBSH*%q{1c|VaIIXJZ%*uDpB?tRcy zz_Pj1;QG!)xYv|IabT4h%#jlyV$wLsPnt}zvS>Q2FO5?8>oOypSRY66&92#8OufL^ zm>1i|^ykuurj{=wuZ>9Nr%Z;LH;iy%T`b&dOqNF&TShHm;|)+z5-G=3OXu5huzN)` zEXkZA$76Hg$k8m0$K1PJzh#pIDR{s6I@?FhhHAUlrC=3m5KrYpZcfm{h~H`8ULtA_ z^(X{I!Jkh2e81Q5Ww^DUY*<5iG<;ZT9+u_#^fMj=JA{`RS{U)R-k-zBPJa1bJlx%r zI3#NpOy|VWwDWzm8zs=@c(}Ve(I@|{wL*!Vdw8bLZzo2xQ37p>gT7r>4)oh*aZd<) zac?#oFXW68s45ovTnQ8>D`&xsDKD|&bPxH6Gn9-HXrmeW>MRQNzn{rntm%5877Rvq z{_?wKxKnG9SzXAVsX}@&_Ggqp@5I2Z?Qu|So5Tte`irqYBZWA=(g?QbF}fK0Gg6F* ziQHZD*jbGI86$|om|Kkf87qvNv9}oeGv@cLV|Ow3XYRXZ6lZKN#{SIx7zFR6pRYG# a#r_AH;i9XxVk=w#0000 = props => { mentionsEnabled && - CreateLinkEvent('mentions/toggle') } className="tb-icon" /> + CreateLinkEvent('mentions/toggle') } className="tb-icon" /> { (mentionsUnread > 0) && } } @@ -432,7 +432,7 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => { mentionsEnabled && - CreateLinkEvent('mentions/toggle') } className="tb-icon" /> + CreateLinkEvent('mentions/toggle') } className="tb-icon" /> { (mentionsUnread > 0) && } } 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; From 22e28a31abae80f8fad4a511a762e13905f704a3 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 2 Jun 2026 14:23:29 +0200 Subject: [PATCH 05/10] feat(mentions): highlight own mentions inside room chat bubbles --- .../widgets/chat/ChatWidgetMessageView.tsx | 19 ++- .../widgets/chat/highlightMentions.test.ts | 120 ++++++++++++++ .../room/widgets/chat/highlightMentions.tsx | 152 ++++++++++++++++++ src/css/chat/Chats.css | 9 ++ 4 files changed, 296 insertions(+), 4 deletions(-) create mode 100644 src/components/room/widgets/chat/highlightMentions.test.ts create mode 100644 src/components/room/widgets/chat/highlightMentions.tsx 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..c61f9cc --- /dev/null +++ b/src/components/room/widgets/chat/highlightMentions.tsx @@ -0,0 +1,152 @@ +/** + * 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); +}; + +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/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; +} From 49d3bde50adf521efff8e31483a6da5bf65a597c Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 2 Jun 2026 14:34:13 +0200 Subject: [PATCH 06/10] =?UTF-8?q?feat(mentions):=20richer=20inbox=20?= =?UTF-8?q?=E2=80=94=20filters,=20date=20groups,=20type=20badge,=20relativ?= =?UTF-8?q?e=20time,=20per-row=20actions,=20highlighted=20preview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/configuration/UITexts_en.json5.example | 27 +++-- public/configuration/UITexts_it.json5.example | 27 +++-- public/configuration/UITexts_nl.json5.example | 27 +++-- .../chat-history/ChatHistoryView.tsx | 14 ++- .../mentions/MentionMessageView.tsx | 36 ++++++ src/components/mentions/MentionRowView.tsx | 55 +++++++-- src/components/mentions/MentionsView.tsx | 107 +++++++++++++++--- src/components/mentions/index.ts | 4 +- .../mentions/mentionsFormat.test.ts | 52 +++++++++ src/components/mentions/mentionsFormat.ts | 41 +++++++ src/components/mentions/useMentionActions.ts | 39 +++++++ src/components/mentions/useMentionRowClick.ts | 20 ---- .../room/widgets/chat/highlightMentions.tsx | 15 +++ src/hooks/mentions/mentionsStore.ts | 8 ++ 14 files changed, 399 insertions(+), 73 deletions(-) create mode 100644 src/components/mentions/MentionMessageView.tsx create mode 100644 src/components/mentions/mentionsFormat.test.ts create mode 100644 src/components/mentions/mentionsFormat.ts create mode 100644 src/components/mentions/useMentionActions.ts delete mode 100644 src/components/mentions/useMentionRowClick.ts 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(); }; From 2d126a7b9a48e3251e05611bb2e76be0fc5062aa Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 2 Jun 2026 14:44:22 +0200 Subject: [PATCH 07/10] feat(mentions): wire Remove action to server-side delete packet --- src/components/mentions/useMentionActions.ts | 10 +++++----- src/nitro-renderer.mock.ts | 1 + 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/components/mentions/useMentionActions.ts b/src/components/mentions/useMentionActions.ts index f2e56c3..21be9f9 100644 --- a/src/components/mentions/useMentionActions.ts +++ b/src/components/mentions/useMentionActions.ts @@ -1,4 +1,4 @@ -import { CreateLinkEvent, MarkMentionsReadComposer } from '@nitrots/nitro-renderer'; +import { CreateLinkEvent, DeleteMentionComposer, MarkMentionsReadComposer } from '@nitrots/nitro-renderer'; import { useMemo } from 'react'; import { IMentionEntry, SendMessageComposer } from '../../api'; import { markRead, removeMention } from '../../hooks/mentions/mentionsStore'; @@ -9,9 +9,8 @@ export interface MentionActions 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. */ + /** 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; } @@ -33,7 +32,8 @@ export const useMentionActions = (): MentionActions => useMemo(() => ({ }, remove: (mention) => { - if(!mention.read) SendMessageComposer(new MarkMentionsReadComposer(1, mention.mentionId)); + // Permanent server-side delete, then drop it from the local list. + SendMessageComposer(new DeleteMentionComposer(mention.mentionId)); removeMention(mention.mentionId); } }), []); diff --git a/src/nitro-renderer.mock.ts b/src/nitro-renderer.mock.ts index 5cb58b9..e96ba0f 100644 --- a/src/nitro-renderer.mock.ts +++ b/src/nitro-renderer.mock.ts @@ -384,6 +384,7 @@ 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 {} From c576c6185a1e44b40ba5142d5432837b4016f0e5 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 2 Jun 2026 15:02:07 +0200 Subject: [PATCH 08/10] feat(mentions): use dedicated mention chime sample --- src/hooks/mentions/useMentionMessages.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/hooks/mentions/useMentionMessages.ts b/src/hooks/mentions/useMentionMessages.ts index b647c76..192b828 100644 --- a/src/hooks/mentions/useMentionMessages.ts +++ b/src/hooks/mentions/useMentionMessages.ts @@ -1,10 +1,13 @@ import { MentionReceivedEvent, MentionsListEvent, RequestMentionsComposer } from '@nitrots/nitro-renderer'; import { useCallback, useEffect } from 'react'; -import { GetConfigurationValue, IMentionEntry, LocalizeText, NotificationBubbleType, PlaySound, SendMessageComposer, SoundNames } from '../../api'; +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(); @@ -46,7 +49,7 @@ export const useMentionMessages = (): void => addMention(entry); - if(GetConfigurationValue('mentions_ui.sound', true)) PlaySound(SoundNames.MESSENGER_MESSAGE_RECEIVED); + if(GetConfigurationValue('mentions_ui.sound', true)) PlaySound(MENTION_SOUND_SAMPLE); showSingleBubble( LocalizeText('mentions.notification', [ 'sender', 'room' ], [ entry.senderUsername, entry.roomName ]), From 0c694820f78c2bacae894b214957acf066fb6cc9 Mon Sep 17 00:00:00 2001 From: duckietm Date: Wed, 3 Jun 2026 07:46:28 +0200 Subject: [PATCH 09/10] =?UTF-8?q?=F0=9F=86=99=20Visual=20updates=20Group?= =?UTF-8?q?=20Forums?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../views/forums/GroupForumNewThreadView.tsx | 4 ++-- .../views/forums/GroupForumSettingsView.tsx | 4 ++-- .../views/forums/GroupForumThreadListView.tsx | 4 ++-- .../groups/views/forums/GroupForumThreadView.tsx | 16 +++++++--------- 4 files changed, 13 insertions(+), 15 deletions(-) diff --git a/src/components/groups/views/forums/GroupForumNewThreadView.tsx b/src/components/groups/views/forums/GroupForumNewThreadView.tsx index 986e046..95df643 100644 --- a/src/components/groups/views/forums/GroupForumNewThreadView.tsx +++ b/src/components/groups/views/forums/GroupForumNewThreadView.tsx @@ -58,8 +58,8 @@ export const GroupForumNewThreadView: FC = 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 && - - -