Merge pull request #193 from duckietm/Dev

Dev
This commit is contained in:
DuckieTM
2026-06-03 14:18:15 +02:00
committed by GitHub
31 changed files with 1054 additions and 10 deletions
@@ -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",
@@ -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",
@@ -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",
+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,
+1
View File
@@ -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';
+12
View File
@@ -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;
}
+5
View File
@@ -0,0 +1,5 @@
export class MentionType
{
public static DIRECT: number = 0;
public static ROOM: number = 1;
}
+2
View File
@@ -0,0 +1,2 @@
export * from './MentionType';
export * from './IMentionEntry';
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

+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,24 @@
import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
import { FC, useEffect, useMemo, useRef, useState } from 'react';
import { ChatEntryType, LocalizeText } from '../../api';
import { Flex, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common';
import { useChatHistory, useOnClickChat } from '../../hooks';
import { Flex, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView, Text } from '../../common';
import { useChatHistory, useMentionsSnapshot, useOnClickChat } from '../../hooks';
import { useUserDataSnapshot } from '../../hooks/session/useSessionSnapshots';
import { NitroInput } from '../../layout';
import { MentionRowView, useMentionActions } from '../mentions';
const TAB_CHAT = 'chat';
const TAB_MENTIONS = 'mentions';
export const ChatHistoryView: FC<{}> = props =>
{
const [isVisible, setIsVisible] = useState(false);
const [searchText, setSearchText] = useState<string>('');
const [activeTab, setActiveTab] = useState<string>(TAB_CHAT);
const { chatHistory = [] } = useChatHistory();
const { mentions, unreadCount } = useMentionsSnapshot();
const { userName: ownMentionUsername = '' } = useUserDataSnapshot();
const { open: onMentionOpen, goto: onMentionGoto, remove: onMentionRemove } = useMentionActions();
const { onClickChat } = useOnClickChat();
const elementRef = useRef<HTMLDivElement>(null);
const isFirstRender = useRef(true);
@@ -87,7 +96,31 @@ 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={ unreadCount } 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 }
onGoto={ onMentionGoto }
onOpen={ onMentionOpen }
onRemove={ onMentionRemove }
ownUsername={ ownMentionUsername } />
)) }
</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 +152,8 @@ export const ChatHistoryView: FC<{}> = props =>
</Flex>
))}
</div>
</>
) }
</NitroCardContentView>
</NitroCardView>
);
@@ -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<MentionMessageViewProps> = props =>
{
const { text, ownUsername, className } = props;
if(!text) return <span className={ className } />;
const nodes: ReactNode[] = text.split(/(\s+)/).map((segment, index) =>
{
if(segment.length === 0) return null;
if(/^\s+$/.test(segment) || !tokenIsMention(segment, ownUsername))
{
return <Fragment key={ index }>{ segment }</Fragment>;
}
return <span key={ index } className="mention-highlight">{ segment }</span>;
});
return <span className={ className }>{ nodes }</span>;
};
@@ -0,0 +1,66 @@
import { FC, MouseEvent } from 'react';
import { IMentionEntry, LocalizeText, MentionType } from '../../api';
import { Flex, Text } from '../../common';
import { MentionMessageView } from './MentionMessageView';
import { formatMentionTime } from './mentionsFormat';
interface MentionRowViewProps
{
mention: IMentionEntry;
ownUsername: string;
onOpen: (mention: IMentionEntry) => void;
onGoto?: (mention: IMentionEntry) => void;
onRemove?: (mention: IMentionEntry) => void;
}
export const MentionRowView: FC<MentionRowViewProps> = 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 (
<Flex pointer alignItems="center" className="group relative px-1 py-[3px] rounded hover:bg-black/5" gap={ 2 } onClick={ () => onOpen(mention) }>
<span
className={ `inline-block w-[8px] h-[8px] rounded-full shrink-0 ${ mention.read ? 'bg-transparent' : 'bg-[#1e7295]' }` }
title={ mention.read ? '' : LocalizeText('mentions.filter.unread') } />
<span
title={ typeTitle }
className={ `flex items-center justify-center shrink-0 w-[18px] h-[18px] rounded text-[10px] font-bold leading-none text-white ${ isRoom ? 'bg-[#d08a1e]' : 'bg-[#1e7295]' }` }>
{ isRoom ? '@' : '@' }
</span>
<Flex grow column className="min-w-0" gap={ 0 }>
<Flex alignItems="center" gap={ 1 } className="min-w-0">
<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>
<MentionMessageView className="block truncate text-black text-sm" ownUsername={ ownUsername } text={ mention.message } />
</Flex>
<Flex alignItems="center" gap={ 1 } className="shrink-0">
{ (time.length > 0) &&
<Text small variant="gray" className="tabular-nums group-hover:hidden">{ time }</Text> }
<Flex alignItems="center" gap={ 1 } className="hidden group-hover:flex">
{ onGoto &&
<span
title={ LocalizeText('mentions.action.goto') }
className="flex items-center justify-center w-[18px] h-[18px] rounded bg-black/10 hover:bg-black/20 text-[12px] leading-none"
onClick={ event => stop(event, () => onGoto(mention)) }></span> }
{ onRemove &&
<span
title={ LocalizeText('mentions.action.remove') }
className="flex items-center justify-center w-[18px] h-[18px] rounded bg-black/10 hover:bg-red-500 hover:text-white text-[11px] leading-none"
onClick={ event => stop(event, () => onRemove(mention)) }></span> }
</Flex>
</Flex>
</Flex>
);
};
+124
View File
@@ -0,0 +1,124 @@
import { MarkMentionsReadComposer } from '@nitrots/nitro-renderer';
import { FC, useCallback, useMemo, useState } from 'react';
import { IMentionEntry, LocalizeText, MentionType, SendMessageComposer } from '../../api';
import { Button, Flex, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common';
import { useMentionsSnapshot } from '../../hooks';
import { markAllRead } from '../../hooks/mentions/mentionsStore';
import { useUserDataSnapshot } from '../../hooks/session/useSessionSnapshots';
import { MentionRowView } from './MentionRowView';
import { getMentionDateGroup, MentionDateGroup } from './mentionsFormat';
import { useMentionActions } from './useMentionActions';
interface MentionsViewProps
{
onClose: () => void;
}
type MentionFilter = 'all' | 'unread' | 'direct' | 'room';
const FILTERS: ReadonlyArray<{ key: MentionFilter; label: string }> = [
{ key: 'all', label: 'mentions.filter.all' },
{ key: 'unread', label: 'mentions.filter.unread' },
{ key: 'direct', label: 'mentions.filter.direct' },
{ key: 'room', label: 'mentions.filter.room' }
];
const GROUP_ORDER: ReadonlyArray<MentionDateGroup> = [ 'today', 'yesterday', 'older' ];
const GROUP_LABEL: Record<MentionDateGroup, string> = {
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<MentionsViewProps> = props =>
{
const { onClose } = props;
const { mentions, unreadCount } = useMentionsSnapshot();
const { userName: ownUsername = '' } = useUserDataSnapshot();
const { open, goto, remove } = useMentionActions();
const [ filter, setFilter ] = useState<MentionFilter>('all');
const onMarkAll = useCallback(() =>
{
markAllRead();
SendMessageComposer(new MarkMentionsReadComposer(0, 0));
}, []);
const groups = useMemo(() =>
{
const buckets: Record<MentionDateGroup, IMentionEntry[]> = { 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 (
<NitroCardView className="w-[360px] h-[440px]" theme="primary-slim" uniqueKey="mentions">
<NitroCardHeaderView headerText={ LocalizeText('mentions.window.title') } onCloseClick={ onClose } />
<NitroCardContentView gap={ 1 }>
<Flex alignItems="center" className="flex-wrap" gap={ 1 }>
{ FILTERS.map(({ key, label }) =>
{
const active = (filter === key);
const showCount = ((key === 'unread') && (unreadCount > 0));
return (
<button
key={ key }
type="button"
onClick={ () => setFilter(key) }
className={ `px-2 py-[2px] rounded-full text-xs border transition-colors ${ active ? 'bg-[#1e7295] text-white border-[#1e7295]' : 'bg-black/5 text-black/70 border-transparent hover:bg-black/10' }` }>
{ LocalizeText(label) }{ showCount ? ` (${ unreadCount })` : '' }
</button>
);
}) }
</Flex>
<Flex grow column className="min-h-0 overflow-y-auto" gap={ 0 }>
{ !hasAny &&
<Flex grow column center gap={ 2 } className="py-6 text-center">
<span className="flex items-center justify-center w-[44px] h-[44px] rounded-full bg-black/5 text-[#1e7295] text-[22px] font-bold">@</span>
<Text center variant="gray">{ LocalizeText('mentions.window.empty') }</Text>
</Flex> }
{ hasAny && groups.map(group => (
<Flex key={ group.key } column gap={ 0 }>
<Text small bold variant="gray" className="px-1 pt-2 pb-[2px] uppercase tracking-wide">
{ LocalizeText(GROUP_LABEL[group.key]) }
</Text>
{ group.items.map(mention => (
<MentionRowView
key={ mention.mentionId }
mention={ mention }
onGoto={ goto }
onOpen={ open }
onRemove={ remove }
ownUsername={ ownUsername } />
)) }
</Flex>
)) }
</Flex>
{ (unreadCount > 0) &&
<Button variant="primary" onClick={ onMarkAll }>{ LocalizeText('mentions.window.markall') }</Button> }
</NitroCardContentView>
</NitroCardView>
);
};
+5
View File
@@ -0,0 +1,5 @@
export * from './MentionMessageView';
export * from './MentionRowView';
export * from './MentionsView';
export * from './mentionsFormat';
export * from './useMentionActions';
@@ -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('');
});
});
+41
View File
@@ -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()) }`;
};
@@ -0,0 +1,39 @@
import { CreateLinkEvent, DeleteMentionComposer, MarkMentionsReadComposer } from '@nitrots/nitro-renderer';
import { useMemo } from 'react';
import { IMentionEntry, SendMessageComposer } from '../../api';
import { markRead, removeMention } from '../../hooks/mentions/mentionsStore';
export interface MentionActions
{
/** Row click: mark the mention as read (no navigation). */
open: (mention: IMentionEntry) => void;
/** Explicit "go to room" action: mark read, then jump to the origin room. */
goto: (mention: IMentionEntry) => void;
/** Permanently delete the mention server-side (DeleteMentionComposer) and
* drop it from the local list, so it does not reappear after a relog. */
remove: (mention: IMentionEntry) => void;
}
const markReadOnServer = (mention: IMentionEntry): void =>
{
if(mention.read) return;
markRead(mention.mentionId);
SendMessageComposer(new MarkMentionsReadComposer(1, mention.mentionId));
};
// Shared action handlers used by both MentionsView and the chat-history
// "Menzioni" tab so behaviour can't diverge.
export const useMentionActions = (): MentionActions => useMemo(() => ({
open: (mention) => markReadOnServer(mention),
goto: (mention) =>
{
markReadOnServer(mention);
if(mention.roomId > 0) CreateLinkEvent(`navigator/goto/${ mention.roomId }`);
},
remove: (mention) =>
{
// Permanent server-side delete, then drop it from the local list.
SendMessageComposer(new DeleteMentionComposer(mention.mentionId));
removeMention(mention.mentionId);
}
}), []);
@@ -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<ChatWidgetMessageViewProps> = ({
const [ isReady, setIsReady ] = useState(false);
const elementRef = useRef<HTMLDivElement>(null);
const { onClickChat } = useOnClickChat();
const { userName: ownUsername = '' } = useUserDataSnapshot();
const mentionsHighlightOn = GetConfigurationValue<boolean>('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<ChatWidgetMessageViewProps> = ({
showColon={ true }
username={ chat.username } />
{ !chat.showTranslation &&
<span className={ `${ messageClassName } align-middle` } dangerouslySetInnerHTML={ { __html: `${ chat.formattedText }` } } onClick={ onClickChat } /> }
<span className={ `${ messageClassName } align-middle` } dangerouslySetInnerHTML={ { __html: formattedText } } onClick={ onClickChat } /> }
{ chat.showTranslation &&
<div className="mt-[2px] flex flex-col gap-[2px]" onClick={ onClickChat }>
<div className="flex items-start gap-1 leading-[1.1]">
<span className="inline-block min-w-[52px] font-bold" style={ { opacity: 0.75 } }>original:</span>
<span className={ messageClassName } dangerouslySetInnerHTML={ { __html: `${ chat.originalFormattedText || chat.formattedText }` } } />
<span className={ messageClassName } dangerouslySetInnerHTML={ { __html: originalFormattedText } } />
</div>
<div className="flex items-start gap-1 leading-[1.1]">
<span className="inline-block min-w-[52px] font-bold" style={ { opacity: 0.75 } }>translate:</span>
<span className={ messageClassName } dangerouslySetInnerHTML={ { __html: `${ chat.translatedFormattedText || chat.formattedText }` } } />
<span className={ messageClassName } dangerouslySetInnerHTML={ { __html: translatedFormattedText } } />
</div>
</div> }
</div>
@@ -0,0 +1,120 @@
import { describe, expect, it } from 'vitest';
import { highlightMentions, MENTION_ROOM_ALIASES } from './highlightMentions';
const OPEN = '<span class="mention-highlight">';
const CLOSE = '</span>';
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('<strong>hi @Bob</strong>', 'Bob');
expect(out).toBe(`<strong>hi ${ OPEN }@Bob${ CLOSE }</strong>`);
});
it('leaves font-colour spans and line breaks intact', () =>
{
const html = '<span style="color:red">hi @Bob</span><br />bye';
const out = highlightMentions(html, 'Bob');
expect(out).toBe(`<span style="color:red">hi ${ OPEN }@Bob${ CLOSE }</span><br />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`);
});
});
@@ -0,0 +1,167 @@
/**
* Cosmetic-only mention highlighting for in-room chat bubbles.
*
* The bubble text is rendered through {@link RoomChatFormatter}, which emits
* an HTML string (wired markup `<strong>`/`<em>`/`<u>`, font-colour
* `<span style>`, `<br />`, 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 `<tag>`), 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<string> = [
'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<string>): boolean =>
{
const nick = normalizeToken(token);
if(!nick) return false;
if(ownUsernameLower && nick === ownUsernameLower) return true;
return aliases.has(nick);
};
/**
* Public predicate: does this raw whitespace-delimited token mention the given
* user or a room-broadcast alias? Mirrors the server's detection. Reusable by
* UI that renders mention previews as React nodes (e.g. the mentions box).
*/
export const tokenIsMention = (
token: string,
ownUsername: string,
aliases: ReadonlyArray<string> = 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 = '<span class="mention-highlight">';
const HIGHLIGHT_CLOSE = '</span>';
/**
* 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>): 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
* `<span class="mention-highlight">…</span>`. 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<string> = 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;
};
+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="mentions" 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="mentions" 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
+9
View File
@@ -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;
}
+7
View File
@@ -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;
+1
View File
@@ -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';
@@ -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);
});
});
+2
View File
@@ -0,0 +1,2 @@
export * from './useMentionsSnapshot';
export * from './useMentionMessages';
+51
View File
@@ -0,0 +1,51 @@
import { IMentionEntry } from '../../api/mentions';
let mentions: IMentionEntry[] = [];
const listeners = new Set<() => void>();
const emit = () => { for(const l of listeners) l(); };
export const subscribeMentions = (onChange: () => void): (() => void) =>
{
listeners.add(onChange);
return () => { listeners.delete(onChange); };
};
export const getMentionsSnapshot = (): ReadonlyArray<IMentionEntry> => mentions;
export const getUnreadCount = (): number => mentions.reduce((n, m) => n + (m.read ? 0 : 1), 0);
export const setMentions = (list: IMentionEntry[]): void =>
{
mentions = [...list].sort((a, b) => b.mentionId - a.mentionId);
emit();
};
export const addMention = (entry: IMentionEntry): void =>
{
if(mentions.some(m => m.mentionId === entry.mentionId && entry.mentionId !== 0)) return;
mentions = [entry, ...mentions];
emit();
};
export const markRead = (mentionId: number): void =>
{
mentions = mentions.map(m => m.mentionId === mentionId ? { ...m, read: true } : m);
emit();
};
export const markAllRead = (): void =>
{
mentions = mentions.map(m => m.read ? m : { ...m, read: true });
emit();
};
export const removeMention = (mentionId: number): void =>
{
const next = mentions.filter(m => m.mentionId !== mentionId);
if(next.length === mentions.length) return;
mentions = next;
emit();
};
export const resetMentions = (): void => { mentions = []; emit(); };
+72
View File
@@ -0,0 +1,72 @@
import { MentionReceivedEvent, MentionsListEvent, RequestMentionsComposer } from '@nitrots/nitro-renderer';
import { useCallback, useEffect } from 'react';
import { GetConfigurationValue, IMentionEntry, LocalizeText, NotificationBubbleType, PlaySound, SendMessageComposer } from '../../api';
import { useMessageEvent } from '../events';
import { useNotificationActions } from '../notification';
import { addMention, setMentions } from './mentionsStore';
// Dedicated mention chime served from nitro-assets/sounds/<sample>.mp3.
const MENTION_SOUND_SAMPLE = 'mentions_notification';
export const useMentionMessages = (): void =>
{
const { showSingleBubble } = useNotificationActions();
const onMentionsList = useCallback((event: MentionsListEvent) =>
{
const list = event.getParser().mentions;
setMentions(list.map(m => ({
mentionId: m.mentionId,
senderId: m.senderId,
senderUsername: m.senderUsername,
roomId: m.roomId,
roomName: m.roomName,
message: m.message,
mentionType: m.mentionType,
timestamp: m.timestamp,
read: m.read
})));
}, []);
const onMentionReceived = useCallback((event: MentionReceivedEvent) =>
{
if(!GetConfigurationValue<boolean>('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<boolean>('mentions_ui.sound', true)) PlaySound(MENTION_SOUND_SAMPLE);
showSingleBubble(
LocalizeText('mentions.notification', [ 'sender', 'room' ], [ entry.senderUsername, entry.roomName ]),
NotificationBubbleType.INFO,
null,
'mentions/toggle',
entry.senderUsername
);
}, [ showSingleBubble ]);
useMessageEvent<MentionsListEvent>(MentionsListEvent, onMentionsList);
useMessageEvent<MentionReceivedEvent>(MentionReceivedEvent, onMentionReceived);
useEffect(() =>
{
if(!GetConfigurationValue<boolean>('mentions_ui.enabled', true)) return;
SendMessageComposer(new RequestMentionsComposer());
}, []);
};
+10
View File
@@ -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<IMentionEntry>; unreadCount: number } =>
{
const mentions = useExternalSnapshot(subscribeMentions, getMentionsSnapshot);
const unreadCount = useExternalSnapshot(subscribeMentions, getUnreadCount);
return { mentions, unreadCount };
};
+8
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,9 @@ export class FollowFriendMessageComposer extends StubClass {}
export class GetUserEventCatsMessageComposer extends StubClass {}
export class GetUserFlatCatsMessageComposer extends StubClass {}
export class NavigatorSearchComposer extends StubClass {}
export class RequestMentionsComposer extends StubClass {}
export class MarkMentionsReadComposer extends StubClass {}
export class DeleteMentionComposer extends StubClass {}
export class DesktopViewComposer extends StubClass {}
export class FurniturePlacePaintComposer extends StubClass {}
export class GetGuestRoomMessageComposer extends StubClass {}