From c67c90d4c1822ebe6513eb510864d1edeec7cd66 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 31 May 2026 22:07:24 +0200 Subject: [PATCH] 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 {}