mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
Merge branch 'duckietm:Dev' into Dev
This commit is contained in:
@@ -707,6 +707,24 @@
|
|||||||
'chatcmd.client.info': 'Client info',
|
'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
|
// Me-menu settings + User account settings window
|
||||||
// ------------------------------------------------------------------------
|
// ------------------------------------------------------------------------
|
||||||
'usersettings.tab.general': "General",
|
'usersettings.tab.general': "General",
|
||||||
|
|||||||
@@ -707,6 +707,24 @@
|
|||||||
'chatcmd.client.info': 'Info client',
|
'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
|
// Me-menu settings + User account settings window
|
||||||
// ------------------------------------------------------------------------
|
// ------------------------------------------------------------------------
|
||||||
'usersettings.tab.general': "Generale",
|
'usersettings.tab.general': "Generale",
|
||||||
|
|||||||
@@ -709,6 +709,24 @@
|
|||||||
'chatcmd.client.info': 'Client info',
|
'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
|
// Me-menu settings + User account settings window
|
||||||
// ------------------------------------------------------------------------
|
// ------------------------------------------------------------------------
|
||||||
'usersettings.tab.general': "Algemeen",
|
'usersettings.tab.general': "Algemeen",
|
||||||
|
|||||||
@@ -25,6 +25,8 @@
|
|||||||
"wired.action.mute.user.max.length": 100,
|
"wired.action.mute.user.max.length": 100,
|
||||||
"game.center.enabled": false,
|
"game.center.enabled": false,
|
||||||
"radio_ui.enabled": false,
|
"radio_ui.enabled": false,
|
||||||
|
"mentions_ui.enabled": true,
|
||||||
|
"mentions_ui.sound": true,
|
||||||
"guides.enabled": true,
|
"guides.enabled": true,
|
||||||
"housekeeping.enabled": true,
|
"housekeeping.enabled": true,
|
||||||
"toolbar.hide.quests": true,
|
"toolbar.hide.quests": true,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export * from './hc-center';
|
|||||||
export * from './help';
|
export * from './help';
|
||||||
export * from './housekeeping';
|
export * from './housekeeping';
|
||||||
export * from './inventory';
|
export * from './inventory';
|
||||||
|
export * from './mentions';
|
||||||
export * from './mod-tools';
|
export * from './mod-tools';
|
||||||
export * from './navigator';
|
export * from './navigator';
|
||||||
export * from './nitro';
|
export * from './nitro';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export class MentionType
|
||||||
|
{
|
||||||
|
public static DIRECT: number = 0;
|
||||||
|
public static ROOM: number = 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './MentionType';
|
||||||
|
export * from './IMentionEntry';
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
@@ -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 { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { FC, useEffect, useState } from 'react';
|
import { FC, useEffect, useState } from 'react';
|
||||||
import { GetConfigurationValue } from '../api';
|
import { GetConfigurationValue, SendMessageComposer } from '../api';
|
||||||
import { useNitroEventReducer } from '../hooks';
|
import { useMentionMessages, useNitroEventReducer } from '../hooks';
|
||||||
|
import { markAllRead } from '../hooks/mentions/mentionsStore';
|
||||||
import { AchievementsView } from './achievements/AchievementsView';
|
import { AchievementsView } from './achievements/AchievementsView';
|
||||||
import { AvatarEditorView } from './avatar-editor';
|
import { AvatarEditorView } from './avatar-editor';
|
||||||
import { BadgeCreatorView } from './badge-creator';
|
import { BadgeCreatorView } from './badge-creator';
|
||||||
@@ -47,11 +48,15 @@ import { UserAccountSettingsView } from './user-settings/UserAccountSettingsView
|
|||||||
import { UserSettingsView } from './user-settings/UserSettingsView';
|
import { UserSettingsView } from './user-settings/UserSettingsView';
|
||||||
import { WiredView } from './wired/WiredView';
|
import { WiredView } from './wired/WiredView';
|
||||||
import { WiredCreatorToolsView } from './wired-tools/WiredCreatorToolsView';
|
import { WiredCreatorToolsView } from './wired-tools/WiredCreatorToolsView';
|
||||||
|
import { MentionsView } from './mentions';
|
||||||
|
|
||||||
export const MainView: FC<{}> = props =>
|
export const MainView: FC<{}> = props =>
|
||||||
{
|
{
|
||||||
const [ isReady, setIsReady ] = useState(false);
|
const [ isReady, setIsReady ] = useState(false);
|
||||||
const [ localizationVersion, setLocalizationVersion ] = useState(0);
|
const [ localizationVersion, setLocalizationVersion ] = useState(0);
|
||||||
|
const [ mentionsVisible, setMentionsVisible ] = useState(false);
|
||||||
|
|
||||||
|
useMentionMessages();
|
||||||
|
|
||||||
// CREATED and ENDED can arrive out of order under flaky reconnects.
|
// CREATED and ENDED can arrive out of order under flaky reconnects.
|
||||||
// Treating them as two independent setters left landingViewVisible
|
// Treating them as two independent setters left landingViewVisible
|
||||||
@@ -124,6 +129,54 @@ export const MainView: FC<{}> = props =>
|
|||||||
return () => RemoveLinkEventTracker(linkTracker);
|
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(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
const refreshLocalization = () => setLocalizationVersion(value => (value + 1));
|
const refreshLocalization = () => setLocalizationVersion(value => (value + 1));
|
||||||
@@ -187,6 +240,8 @@ export const MainView: FC<{}> = props =>
|
|||||||
<FortuneWheelView />
|
<FortuneWheelView />
|
||||||
<SoundboardView />
|
<SoundboardView />
|
||||||
{ GetConfigurationValue<boolean>('radio_ui.enabled', false) && <RadioView /> }
|
{ GetConfigurationValue<boolean>('radio_ui.enabled', false) && <RadioView /> }
|
||||||
|
{ (GetConfigurationValue<boolean>('mentions_ui.enabled', true) && mentionsVisible) &&
|
||||||
|
<MentionsView onClose={ () => setMentionsVisible(false) } /> }
|
||||||
<ExternalPluginLoader />
|
<ExternalPluginLoader />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
|
import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
|
||||||
import { FC, useEffect, useMemo, useRef, useState } from 'react';
|
import { FC, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { ChatEntryType, LocalizeText } from '../../api';
|
import { ChatEntryType, LocalizeText } from '../../api';
|
||||||
import { Flex, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common';
|
import { Flex, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView, Text } from '../../common';
|
||||||
import { useChatHistory, useOnClickChat } from '../../hooks';
|
import { useChatHistory, useMentionsSnapshot, useOnClickChat } from '../../hooks';
|
||||||
|
import { useUserDataSnapshot } from '../../hooks/session/useSessionSnapshots';
|
||||||
import { NitroInput } from '../../layout';
|
import { NitroInput } from '../../layout';
|
||||||
|
import { MentionRowView, useMentionActions } from '../mentions';
|
||||||
|
|
||||||
|
const TAB_CHAT = 'chat';
|
||||||
|
const TAB_MENTIONS = 'mentions';
|
||||||
|
|
||||||
export const ChatHistoryView: FC<{}> = props =>
|
export const ChatHistoryView: FC<{}> = props =>
|
||||||
{
|
{
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
const [searchText, setSearchText] = useState<string>('');
|
const [searchText, setSearchText] = useState<string>('');
|
||||||
|
const [activeTab, setActiveTab] = useState<string>(TAB_CHAT);
|
||||||
const { chatHistory = [] } = useChatHistory();
|
const { chatHistory = [] } = useChatHistory();
|
||||||
|
const { mentions, unreadCount } = useMentionsSnapshot();
|
||||||
|
const { userName: ownMentionUsername = '' } = useUserDataSnapshot();
|
||||||
|
const { open: onMentionOpen, goto: onMentionGoto, remove: onMentionRemove } = useMentionActions();
|
||||||
const { onClickChat } = useOnClickChat();
|
const { onClickChat } = useOnClickChat();
|
||||||
const elementRef = useRef<HTMLDivElement>(null);
|
const elementRef = useRef<HTMLDivElement>(null);
|
||||||
const isFirstRender = useRef(true);
|
const isFirstRender = useRef(true);
|
||||||
@@ -87,7 +96,31 @@ export const ChatHistoryView: FC<{}> = props =>
|
|||||||
return (
|
return (
|
||||||
<NitroCardView className="w-[400px] h-[400px] bg-[#f0f0f0]" theme="primary-slim" uniqueKey="chat-history">
|
<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)} />
|
<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' }}>
|
<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)} />
|
<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' }}>
|
<div ref={elementRef} style={{ flex: 1, overflowY: 'auto', background: 'inherit' }}>
|
||||||
{filteredChatHistory.map((row, index) => (
|
{filteredChatHistory.map((row, index) => (
|
||||||
@@ -119,6 +152,8 @@ export const ChatHistoryView: FC<{}> = props =>
|
|||||||
</Flex>
|
</Flex>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
) }
|
||||||
</NitroCardContentView>
|
</NitroCardContentView>
|
||||||
</NitroCardView>
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { GetRoomEngine, RoomChatSettings, RoomObjectCategory } from '@nitrots/nitro-renderer';
|
||||||
import { FC, useEffect, useMemo, useRef, useState } from 'react';
|
import { FC, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { ChatBubbleMessage } from '../../../../api';
|
import { ChatBubbleMessage, GetConfigurationValue } from '../../../../api';
|
||||||
import { UserIdentityView } from '../../../../common';
|
import { UserIdentityView } from '../../../../common';
|
||||||
import { useOnClickChat } from '../../../../hooks';
|
import { useOnClickChat } from '../../../../hooks';
|
||||||
|
import { useUserDataSnapshot } from '../../../../hooks/session/useSessionSnapshots';
|
||||||
|
import { highlightMentions } from './highlightMentions';
|
||||||
|
|
||||||
interface ChatWidgetMessageViewProps
|
interface ChatWidgetMessageViewProps
|
||||||
{
|
{
|
||||||
@@ -21,6 +23,15 @@ export const ChatWidgetMessageView: FC<ChatWidgetMessageViewProps> = ({
|
|||||||
const [ isReady, setIsReady ] = useState(false);
|
const [ isReady, setIsReady ] = useState(false);
|
||||||
const elementRef = useRef<HTMLDivElement>(null);
|
const elementRef = useRef<HTMLDivElement>(null);
|
||||||
const { onClickChat } = useOnClickChat();
|
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(() =>
|
const getBubbleWidth = useMemo(() =>
|
||||||
{
|
{
|
||||||
@@ -112,16 +123,16 @@ export const ChatWidgetMessageView: FC<ChatWidgetMessageViewProps> = ({
|
|||||||
showColon={ true }
|
showColon={ true }
|
||||||
username={ chat.username } />
|
username={ chat.username } />
|
||||||
{ !chat.showTranslation &&
|
{ !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 &&
|
{ chat.showTranslation &&
|
||||||
<div className="mt-[2px] flex flex-col gap-[2px]" onClick={ onClickChat }>
|
<div className="mt-[2px] flex flex-col gap-[2px]" onClick={ onClickChat }>
|
||||||
<div className="flex items-start gap-1 leading-[1.1]">
|
<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="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>
|
||||||
<div className="flex items-start gap-1 leading-[1.1]">
|
<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="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> }
|
</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;
|
||||||
|
};
|
||||||
@@ -3,7 +3,7 @@ import { AnimatePresence, motion, Variants } from 'framer-motion';
|
|||||||
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { GetConfigurationValue, isHousekeepingEnabled, MessengerIconState, OpenMessengerChat, setYoutubeRoomEnabled, VisitDesktop } from '../../api';
|
import { GetConfigurationValue, isHousekeepingEnabled, MessengerIconState, OpenMessengerChat, setYoutubeRoomEnabled, VisitDesktop } from '../../api';
|
||||||
import { Flex, LayoutAvatarImageView, LayoutItemCountView } from '../../common';
|
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 { ToolbarItemView } from './ToolbarItemView';
|
||||||
import { ToolbarMeView } from './ToolbarMeView';
|
import { ToolbarMeView } from './ToolbarMeView';
|
||||||
import { YouTubePlayerView } from './YouTubePlayerView';
|
import { YouTubePlayerView } from './YouTubePlayerView';
|
||||||
@@ -42,6 +42,8 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
|
|||||||
const { getTotalUnseen = 0 } = useAchievements();
|
const { getTotalUnseen = 0 } = useAchievements();
|
||||||
const { requests = [] } = useFriends();
|
const { requests = [] } = useFriends();
|
||||||
const { iconState = MessengerIconState.HIDDEN } = useMessenger();
|
const { iconState = MessengerIconState.HIDDEN } = useMessenger();
|
||||||
|
const { unreadCount: mentionsUnread = 0 } = useMentionsSnapshot();
|
||||||
|
const mentionsEnabled = useMemo(() => GetConfigurationValue<boolean>('mentions_ui.enabled', true), []);
|
||||||
const { openMonitor, showToolbarButton } = useWiredTools();
|
const { openMonitor, showToolbarButton } = useWiredTools();
|
||||||
const { enabled: soundboardEnabled, reset: resetSoundboard } = useSoundboard();
|
const { enabled: soundboardEnabled, reset: resetSoundboard } = useSoundboard();
|
||||||
const isMod = useHasPermission('acc_supporttool');
|
const isMod = useHasPermission('acc_supporttool');
|
||||||
@@ -332,6 +334,12 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
|
|||||||
{ (requests.length > 0) &&
|
{ (requests.length > 0) &&
|
||||||
<LayoutItemCountView count={ requests.length } className="absolute -right-2 -top-1" /> }
|
<LayoutItemCountView count={ requests.length } className="absolute -right-2 -top-1" /> }
|
||||||
</motion.div>
|
</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)) &&
|
{ ((iconState === MessengerIconState.SHOW) || (iconState === MessengerIconState.UNREAD)) &&
|
||||||
<motion.div variants={ itemVariants }>
|
<motion.div variants={ itemVariants }>
|
||||||
<ToolbarItemView className={ `tb-icon ${ iconState === MessengerIconState.UNREAD ? 'is-unseen animate-pulse' : '' }` } icon="message" onClick={ () => OpenMessengerChat() } />
|
<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) &&
|
{ (requests.length > 0) &&
|
||||||
<LayoutItemCountView count={ requests.length } className="absolute -right-2 -top-1" /> }
|
<LayoutItemCountView count={ requests.length } className="absolute -right-2 -top-1" /> }
|
||||||
</motion.div>
|
</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>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
{ /* Mobile side tools — moved out of the bottom bar into a
|
{ /* Mobile side tools — moved out of the bottom bar into a
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -229,6 +229,13 @@
|
|||||||
height: 32px;
|
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 {
|
.nitro-icon.icon-wired-tools {
|
||||||
background-image: url("@/assets/images/wiredtools/wired_menu.png");
|
background-image: url("@/assets/images/wiredtools/wired_menu.png");
|
||||||
background-size: contain;
|
background-size: contain;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export * from './groups';
|
|||||||
export * from './help';
|
export * from './help';
|
||||||
export * from './housekeeping';
|
export * from './housekeeping';
|
||||||
export * from './inventory';
|
export * from './inventory';
|
||||||
|
export * from './mentions';
|
||||||
export * from './mod-tools';
|
export * from './mod-tools';
|
||||||
export * from './navigator';
|
export * from './navigator';
|
||||||
export * from './notification';
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './useMentionsSnapshot';
|
||||||
|
export * from './useMentionMessages';
|
||||||
@@ -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(); };
|
||||||
@@ -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());
|
||||||
|
}, []);
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
|
};
|
||||||
@@ -251,6 +251,11 @@ export class FlatAccessDeniedMessageEvent extends MessageEvent {}
|
|||||||
export class GenericErrorEvent extends MessageEvent {}
|
export class GenericErrorEvent extends MessageEvent {}
|
||||||
export class GetGuestRoomResultEvent 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
|
// Navigator event classes — MessageEvent subclasses needed by useNavigatorStore
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -377,6 +382,9 @@ export class FollowFriendMessageComposer extends StubClass {}
|
|||||||
export class GetUserEventCatsMessageComposer extends StubClass {}
|
export class GetUserEventCatsMessageComposer extends StubClass {}
|
||||||
export class GetUserFlatCatsMessageComposer extends StubClass {}
|
export class GetUserFlatCatsMessageComposer extends StubClass {}
|
||||||
export class NavigatorSearchComposer 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 DesktopViewComposer extends StubClass {}
|
||||||
export class FurniturePlacePaintComposer extends StubClass {}
|
export class FurniturePlacePaintComposer extends StubClass {}
|
||||||
export class GetGuestRoomMessageComposer extends StubClass {}
|
export class GetGuestRoomMessageComposer extends StubClass {}
|
||||||
|
|||||||
Reference in New Issue
Block a user