feat(mentions): inbox window, toolbar badge, chat-history tab, ui-config + i18n

This commit is contained in:
simoleo89
2026-05-31 22:07:24 +02:00
committed by simoleo89
parent afb8100300
commit c67c90d4c1
11 changed files with 216 additions and 11 deletions
@@ -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%',
}
+2
View File
@@ -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,
+58 -3
View File
@@ -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 =>
<FortuneWheelView />
<SoundboardView />
{ GetConfigurationValue<boolean>('radio_ui.enabled', false) && <RadioView /> }
{ (GetConfigurationValue<boolean>('mentions_ui.enabled', true) && mentionsVisible) &&
<MentionsView onClose={ () => setMentionsVisible(false) } /> }
<ExternalPluginLoader />
</>
);
@@ -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<string>('');
const [activeTab, setActiveTab] = useState<string>(TAB_CHAT);
const { chatHistory = [] } = useChatHistory();
const { mentions } = useMentionsSnapshot();
const onMentionRowClick = useMentionRowClick();
const { onClickChat } = useOnClickChat();
const elementRef = useRef<HTMLDivElement>(null);
const isFirstRender = useRef(true);
@@ -87,7 +94,25 @@ export const ChatHistoryView: FC<{}> = props =>
return (
<NitroCardView className="w-[400px] h-[400px] bg-[#f0f0f0]" theme="primary-slim" uniqueKey="chat-history">
<NitroCardHeaderView headerText={LocalizeText('room.chathistory.button.text')} onCloseClick={event => setIsVisible(false)} />
<NitroCardTabsView>
<NitroCardTabsItemView isActive={ activeTab === TAB_CHAT } onClick={ () => setActiveTab(TAB_CHAT) }>
{ LocalizeText('room.chathistory.button.text') }
</NitroCardTabsItemView>
<NitroCardTabsItemView count={ mentions.reduce((n, m) => n + (m.read ? 0 : 1), 0) } isActive={ activeTab === TAB_MENTIONS } onClick={ () => setActiveTab(TAB_MENTIONS) }>
{ LocalizeText('mentions.tab.title') }
</NitroCardTabsItemView>
</NitroCardTabsView>
<NitroCardContentView className="h-full bg-[#f0f0f0] bg-[url('@/assets/images/chat/chathistory_background.png')] bg-repeat bg-auto" gap={2} overflow="hidden" style={{ height: 'calc(100% - 40px)', display: 'flex', flexDirection: 'column' }}>
{ activeTab === TAB_MENTIONS ? (
<div style={{ flex: 1, overflowY: 'auto', background: 'inherit' }}>
{ (mentions.length === 0)
? <Text center variant="gray">{ LocalizeText('mentions.window.empty') }</Text>
: mentions.map(mention => (
<MentionRowView key={ mention.mentionId } mention={ mention } onClick={ onMentionRowClick } />
)) }
</div>
) : (
<>
<NitroInput placeholder={LocalizeText('generic.search')} type="text" value={searchText} onChange={event => setSearchText(event.target.value)} />
<div ref={elementRef} style={{ flex: 1, overflowY: 'auto', background: 'inherit' }}>
{filteredChatHistory.map((row, index) => (
@@ -119,6 +144,8 @@ export const ChatHistoryView: FC<{}> = props =>
</Flex>
))}
</div>
</>
) }
</NitroCardContentView>
</NitroCardView>
);
@@ -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<MentionRowViewProps> = props =>
{
const { mention, onClick } = props;
return (
<Flex pointer alignItems="center" className="p-1 hover:bg-black/5" gap={ 2 } onClick={ () => onClick(mention) }>
<span
className={ `inline-block w-[8px] h-[8px] rounded-full shrink-0 ${ mention.read ? 'bg-transparent' : 'bg-[#1e7295]' }` } />
<Flex grow column className="min-w-0" gap={ 0 }>
<Flex alignItems="center" gap={ 1 }>
<Text bold={ !mention.read } truncate variant="primary">{ mention.senderUsername }</Text>
{ (mention.roomName && mention.roomName.length > 0) &&
<Text small truncate variant="gray">{ mention.roomName }</Text> }
</Flex>
<Text truncate variant="black">{ mention.message }</Text>
</Flex>
</Flex>
);
};
+43
View File
@@ -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<MentionsViewProps> = props =>
{
const { onClose } = props;
const { mentions } = useMentionsSnapshot();
const onRowClick = useMentionRowClick();
const onMarkAll = useCallback(() =>
{
markAllRead();
SendMessageComposer(new MarkMentionsReadComposer(0, 0));
}, []);
return (
<NitroCardView className="w-[340px] h-[420px]" theme="primary-slim" uniqueKey="mentions">
<NitroCardHeaderView headerText={ LocalizeText('mentions.window.title') } onCloseClick={ onClose } />
<NitroCardContentView gap={ 1 }>
<Flex grow column className="min-h-0 overflow-y-auto" gap={ 0 }>
{ (mentions.length === 0)
? <Text center variant="gray">{ LocalizeText('mentions.window.empty') }</Text>
: mentions.map(mention => (
<MentionRowView key={ mention.mentionId } mention={ mention } onClick={ onRowClick } />
)) }
</Flex>
{ (mentions.length > 0) &&
<Button variant="primary" onClick={ onMarkAll }>{ LocalizeText('mentions.window.markall') }</Button> }
</NitroCardContentView>
</NitroCardView>
);
};
+3
View File
@@ -0,0 +1,3 @@
export * from './MentionRowView';
export * from './MentionsView';
export * from './useMentionRowClick';
@@ -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 }`);
}, []);
};
+15 -1
View File
@@ -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<boolean>('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) &&
<LayoutItemCountView count={ requests.length } className="absolute -right-2 -top-1" /> }
</motion.div>
{ mentionsEnabled &&
<motion.div variants={ itemVariants } className="relative">
<ToolbarItemView icon="message" onClick={ () => CreateLinkEvent('mentions/toggle') } className="tb-icon" />
{ (mentionsUnread > 0) &&
<LayoutItemCountView count={ mentionsUnread } className="absolute -right-2 -top-1" /> }
</motion.div> }
{ ((iconState === MessengerIconState.SHOW) || (iconState === MessengerIconState.UNREAD)) &&
<motion.div variants={ itemVariants }>
<ToolbarItemView className={ `tb-icon ${ iconState === MessengerIconState.UNREAD ? 'is-unseen animate-pulse' : '' }` } icon="message" onClick={ () => OpenMessengerChat() } />
@@ -422,6 +430,12 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
{ (requests.length > 0) &&
<LayoutItemCountView count={ requests.length } className="absolute -right-2 -top-1" /> }
</motion.div>
{ mentionsEnabled &&
<motion.div variants={ itemVariants } className="relative">
<ToolbarItemView icon="message" onClick={ () => CreateLinkEvent('mentions/toggle') } className="tb-icon" />
{ (mentionsUnread > 0) &&
<LayoutItemCountView count={ mentionsUnread } className="absolute -right-2 -top-1" /> }
</motion.div> }
</motion.div>
</motion.div>
{ /* Mobile side tools — moved out of the bottom bar into a
+1 -5
View File
@@ -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();
+7
View File
@@ -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 {}