Merge origin/main into main

Resolved 2 messenger conflicts:
- FriendsMessengerView.tsx: union — kept local typingUserIds/sendTypingStatus
  from useMessenger() plus upstream's useFriends().getFriend.
- FriendsView.css: kept local group-chips + typing-indicator styles (upstream
  empty there).
Vitest 545/545 green. (typecheck TS2307 is the un-linked renderer, env-only.)
This commit is contained in:
simoleo89
2026-06-07 11:50:32 +02:00
61 changed files with 1664 additions and 1313 deletions
+1 -3
View File
@@ -48,7 +48,7 @@ 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, MentionToastsView } from './mentions';
import { MentionsView } from './mentions';
export const MainView: FC<{}> = props =>
{
@@ -242,8 +242,6 @@ export const MainView: FC<{}> = props =>
{ GetConfigurationValue<boolean>('radio_ui.enabled', false) && <RadioView /> }
{ (GetConfigurationValue<boolean>('mentions_ui.enabled', true) && mentionsVisible) &&
<MentionsView onClose={ () => setMentionsVisible(false) } /> }
{ GetConfigurationValue<boolean>('mentions_ui.enabled', true) &&
<MentionToastsView /> }
<ExternalPluginLoader />
</>
);
@@ -1,7 +1,7 @@
import { AddLinkEventTracker, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
import { CSSProperties, FC, useEffect, useMemo, useState } from 'react';
import { BadgeLeaderboardBoard, BadgeLeaderboardEntry, BadgeRarityKey, fetchBadgeLeaderboard, getCachedBadgeLeaderboard, LocalizeText } from '../../api';
import { Column, DraggableWindow, DraggableWindowPosition, Flex, Text } from '../../common';
import { Column, DraggableWindow, DraggableWindowPosition, Flex, LayoutAvatarImageView, Text } from '../../common';
import {
badgeEmblemAchievement,
badgeEmblemCommon,
@@ -40,12 +40,6 @@ const RARITY_ASSETS: Record<BadgeRarityKey, { frame: string; emblem: string }> =
const RARITY_ORDER: BadgeRarityKey[] = [ 'common', 'rare', 'epic', 'legendary', 'mythical', 'unique' ];
const PAGE_SIZE = 10;
const getAvatarHeadUrl = (figure: string): string =>
{
if(!figure) return '';
return `https://www.habbo.com/habbo-imaging/avatarimage?figure=${ encodeURIComponent(figure) }&direction=2&head_direction=2&gesture=sml&size=m&headonly=1`;
};
export const BadgeLeaderboardView: FC<{}> = props =>
{
@@ -310,7 +304,7 @@ const LeaderboardRow: FC<LeaderboardRowProps> = props =>
<div className={ `nitro-badge-leaderboard__row ${ isCurrentUser ? 'is-current-user' : '' } ${ ((rowIndex % 2) === 0) ? 'is-even' : 'is-odd' }` }>
<div className={ `nitro-badge-leaderboard__rank ${ rankClassName }` }>{ entry.rank }</div>
<div className="nitro-badge-leaderboard__avatar">
<img className="nitro-badge-leaderboard__avatar-image" src={ getAvatarHeadUrl(entry.figure) } alt="" loading="lazy" />
<LayoutAvatarImageView figure={ entry.figure } headOnly direction={ 2 } />
</div>
<Text className="nitro-badge-leaderboard__username" bold>{ entry.username }</Text>
<Text className="nitro-badge-leaderboard__score" bold>{ entry.score }</Text>
@@ -40,7 +40,17 @@ export const CatalogGridOfferView: FC<CatalogGridOfferViewProps> = props =>
if(className?.length)
{
const param = (product.productType === ProductTypeEnum.WALL && product.extraParam?.length) ? `_${ product.extraParam }` : '';
let param = '';
if(product.productType === ProductTypeEnum.WALL && product.extraParam?.length)
{
param = `_${ product.extraParam }`;
}
else if(product.productType === ProductTypeEnum.FLOOR && product.furnitureData?.hasIndexedColor && (product.furnitureData.colorIndex > 0))
{
param = `_${ product.furnitureData.colorIndex }`;
}
const configuredIconUrl = GetConfigurationValue<string>('furni.asset.icon.url', '');
if(configuredIconUrl?.length)
@@ -104,6 +114,7 @@ export const CatalogGridOfferView: FC<CatalogGridOfferViewProps> = props =>
return (
<LayoutGridItem
className={ `group/tile relative ${ itemActive ? 'is-active' : '' }` }
gap={ 1 }
itemActive={ itemActive }
itemCount={ ((offer.pricingModel === Offer.PRICING_MODEL_MULTI) ? product.productCount : 1) }
itemUniqueNumber={ product.uniqueLimitedItemSeriesSize }
@@ -103,7 +103,7 @@ export const CatalogLayoutDefaultView: FC<CatalogLayoutProps> = props =>
<div className="nitro-catalog-classic-grid-shell flex-1 overflow-auto min-h-0">
{ GetConfigurationValue('catalog.headers') &&
<CatalogHeaderView imageUrl={ currentPage.localization.getImage(0) } /> }
<CatalogItemGridWidgetView className="nitro-catalog-classic-grid" columnCount={ 7 } columnMinHeight={ 70 } columnMinWidth={ 45 } />
<CatalogItemGridWidgetView className="nitro-catalog-classic-grid" columnCount={ 6 } columnMinHeight={ 80 } columnMinWidth={ 55 } />
</div>
{ currentOffer &&
@@ -19,7 +19,7 @@ export const CatalogViewProductWidgetView: FC<{}> = props =>
if(!product) return;
roomPreviewer.reset(false);
roomPreviewer.updateObjectRoom('default', 'default', 'default');
roomPreviewer.updateObjectRoom('111', '217', '1.1');
roomPreviewer.updateRoomWallsAndFloorVisibility(true, true);
const populate = () =>
@@ -91,7 +91,7 @@ export const CatalogViewProductWidgetView: FC<{}> = props =>
return;
}
default:
roomPreviewer.updateObjectRoom('default', 'default', 'default');
roomPreviewer.updateObjectRoom('101', '101', '1.1');
roomPreviewer.addWallItemIntoRoom(product.productClassId, new Vector3d(90), product.extraParam);
return;
}
@@ -106,13 +106,6 @@ export const CatalogViewProductWidgetView: FC<{}> = props =>
};
populate();
// RoomPreviewer.addFurnitureIntoRoom / addAvatarIntoRoom flip
// _automaticStateChange to true, which makes the ticker advance
// the room object's state every AUTOMATIC_STATE_CHANGE_INTERVAL.
// In the catalog we want the preview to sit still until the
// user clicks the state button explicitly - turn it back off
// after populate() runs.
roomPreviewer.setAutomaticStateChange(false);
}, [ currentOffer, previewStuffData, roomPreviewer ]);
@@ -132,11 +125,5 @@ export const CatalogViewProductWidgetView: FC<{}> = props =>
);
}
// Re-mount the previewer whenever the offer changes so the render
// latch / texture handle in LayoutRoomPreviewerView resets cleanly.
// Without this a single broken offer (e.g. blackhole's Pixi filter
// crash) latches the previewer permanently and every following
// offer paints nothing - the singleton roomPreviewer + 240px height
// keep the same component mounted otherwise.
return <LayoutRoomPreviewerView key={ currentOffer?.offerId } height={ 240 } roomPreviewer={ roomPreviewer } />;
};
@@ -2,10 +2,10 @@ import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '
import { FC, useEffect, useMemo, useRef, useState } from 'react';
import { ChatEntryType, LocalizeText } from '../../api';
import { Flex, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView, Text } from '../../common';
import { useChatHistory, useMentionsSnapshot, useOnClickChat } from '../../hooks';
import { useChatHistory, useMentionActions, useMentionsSnapshot, useOnClickChat } from '../../hooks';
import { useUserDataSnapshot } from '../../hooks/session/useSessionSnapshots';
import { NitroInput } from '../../layout';
import { MentionRowView, useMentionActions } from '../mentions';
import { MentionRowView } from '../mentions';
const TAB_CHAT = 'chat';
const TAB_MENTIONS = 'mentions';
@@ -1,11 +1,23 @@
import { FC, useRef, useState } from 'react';
import { FC, useLayoutEffect, useRef, useState } from 'react';
import { FaChevronLeft, FaChevronRight } from 'react-icons/fa';
import { LocalizeText, MessengerFriend } from '../../../../api';
import { FriendBarItemView } from './FriendBarItemView';
import { motion, AnimatePresence, Variants } from 'framer-motion';
// Hard cap on simultaneously-shown friend chips. The effective count is
// reduced below this when the bar would otherwise overflow its (clipped)
// slot in the toolbar — see the width measurement below.
const MAX_DISPLAY_COUNT = 3;
// Layout constants mirrored from FriendBarItemView / the flex gaps here, used
// to compute how many friend chips fit in the available width. A "slot" is one
// w-[132px] button plus the gap-[6px] that precedes it.
const ITEM_SLOT = 138; // 132px chip + 6px gap (friend chip and search chip)
const ARROWS_WIDTH = 52; // two w-[20px] arrows, each + 6px gap
const REQUEST_SLOT = 120; // requests chip (only present when requestsCount > 0)
const BASE_PAD = 8; // container px-[2px] + a little slack
const RIGHT_SAFE = 24; // right inset (right-0/right-3) + pr-3 safety margin
// Mirrored from Toolbar to keep physics identical
const containerVariants: Variants = {
hidden: {},
@@ -23,9 +35,55 @@ export const FriendBarView: FC<{ onlineFriends: MessengerFriend[]; requestsCount
{
const { onlineFriends = [], requestsCount = 0 } = props;
const [ indexOffset, setIndexOffset ] = useState(0);
const [ maxVisible, setMaxVisible ] = useState(MAX_DISPLAY_COUNT);
const elementRef = useRef<HTMLDivElement>(null);
const hasScrollableFriends = (onlineFriends.length > MAX_DISPLAY_COUNT);
const visibleFriends = onlineFriends.slice(indexOffset, (indexOffset + MAX_DISPLAY_COUNT));
// Auto-fit the visible friend count to the room actually available between
// the bar's left edge and the right side of the viewport. The bar lives in
// a `overflow-x: clip` toolbar slot, so anything that doesn't fit would be
// silently cut off (the scroll arrow / search button disappear). The bar's
// left edge is stable (it sits after fixed-width toolbar icons), so growing
// or shrinking the chip count never moves it — no measurement feedback loop.
useLayoutEffect(() =>
{
const element = elementRef.current;
if(!element) return;
const measure = () =>
{
const left = element.getBoundingClientRect().left;
const available = window.innerWidth - left - RIGHT_SAFE;
const fixed = ARROWS_WIDTH + ITEM_SLOT /* search chip */ + BASE_PAD + ((requestsCount > 0) ? REQUEST_SLOT : 0);
const fit = Math.floor((available - fixed) / ITEM_SLOT);
const next = Math.max(1, Math.min(MAX_DISPLAY_COUNT, fit));
setMaxVisible(prev => ((prev === next) ? prev : next));
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(document.documentElement);
window.addEventListener('resize', measure);
return () =>
{
observer.disconnect();
window.removeEventListener('resize', measure);
};
}, [ requestsCount, onlineFriends.length ]);
// `safeOffset` is the offset clamped to the current list/fit. Every read
// below uses it, so a stale `indexOffset` (after the list shrinks or the fit
// grows) renders correctly and self-corrects on the next arrow click — no
// write-back effect needed.
const maxOffset = Math.max(0, (onlineFriends.length - maxVisible));
const safeOffset = Math.min(indexOffset, maxOffset);
const canScrollLeft = (safeOffset > 0);
const canScrollRight = (safeOffset < maxOffset);
const visibleFriends = onlineFriends.slice(safeOffset, (safeOffset + maxVisible));
return (
<motion.div
@@ -44,10 +102,10 @@ export const FriendBarView: FC<{ onlineFriends: MessengerFriend[]; requestsCount
</motion.div> }
<motion.div variants={itemVariants}>
<div
className={ `flex h-[34px] w-[20px] items-center justify-center text-white/80 transition-all ${ (!hasScrollableFriends || (indexOffset <= 0)) ? 'opacity-30 cursor-not-allowed' : 'cursor-pointer hover:text-white active:scale-95' }` }
className={ `flex h-[34px] w-[20px] items-center justify-center text-white/80 transition-all ${ !canScrollLeft ? 'opacity-30 cursor-not-allowed' : 'cursor-pointer hover:text-white active:scale-95' }` }
onClick={ () =>
{
if(indexOffset > 0) setIndexOffset(indexOffset - 1);
if(canScrollLeft) setIndexOffset(safeOffset - 1);
} }
>
<FaChevronLeft className="text-white/70 text-sm drop-shadow-[1px_1px_0_#000]" />
@@ -94,10 +152,10 @@ export const FriendBarView: FC<{ onlineFriends: MessengerFriend[]; requestsCount
<motion.div variants={itemVariants}>
<div
className={ `flex h-[34px] w-[20px] items-center justify-center text-white/80 transition-all ${ (!hasScrollableFriends || !((onlineFriends.length > MAX_DISPLAY_COUNT) && ((indexOffset + MAX_DISPLAY_COUNT) <= (onlineFriends.length - 1)))) ? 'opacity-30 cursor-not-allowed' : 'cursor-pointer hover:text-white active:scale-95' }` }
className={ `flex h-[34px] w-[20px] items-center justify-center text-white/80 transition-all ${ !canScrollRight ? 'opacity-30 cursor-not-allowed' : 'cursor-pointer hover:text-white active:scale-95' }` }
onClick={ () =>
{
if((onlineFriends.length > MAX_DISPLAY_COUNT) && ((indexOffset + MAX_DISPLAY_COUNT) <= (onlineFriends.length - 1))) setIndexOffset(indexOffset + 1);
if(canScrollRight) setIndexOffset(safeOffset + 1);
} }
>
<FaChevronRight className="text-white/70 text-sm drop-shadow-[1px_1px_0_#000]" />
@@ -3,7 +3,8 @@ import { FC, KeyboardEvent, useEffect, useRef, useState } from 'react';
import { FaTimes } from 'react-icons/fa';
import { GetUserProfile, LocalizeText, ReportType, SendMessageComposer } from '../../../../api';
import { DraggableWindowPosition, LayoutAvatarImageView, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
import { useHelp, useMessenger, useTranslation } from '../../../../hooks';
import { useFriends, useHelp, useMessenger, useTranslation } from '../../../../hooks';
import { resolveAvatarFigure } from '../friends-list/resolveAvatarFigure';
import { FriendsMessengerThreadView } from './messenger-thread/FriendsMessengerThreadView';
export const FriendsMessengerView: FC<{}> = props =>
@@ -12,6 +13,7 @@ export const FriendsMessengerView: FC<{}> = props =>
const [ lastThreadId, setLastThreadId ] = useState(-1);
const [ messageText, setMessageText ] = useState('');
const { visibleThreads = [], activeThread = null, getMessageThread = null, sendMessage = null, setActiveThreadId = null, closeThread = null, typingUserIds = [], sendTypingStatus = null } = useMessenger();
const { getFriend = null } = useFriends();
const { report = null } = useHelp();
const { settings, translateOutgoing } = useTranslation();
const messagesBox = useRef<HTMLDivElement>(null);
@@ -185,12 +187,22 @@ export const FriendsMessengerView: FC<{}> = props =>
<div className="messenger-avatar-bar">
{ visibleThreads && (visibleThreads.length > 0) && visibleThreads.map(thread =>
{
const isStaff = (thread.participant.id <= 0);
// Read the live look from the friend list (same source the friends
// list renders) so offline friends show their real avatar instead
// of the standard/anonymous one; resolveAvatarFigure is the final
// fallback when the look is genuinely missing.
const liveFriend = isStaff ? null : getFriend(thread.participant.id);
const figure = isStaff
? (thread.participant.figure === 'ADM' ? 'ha-3409-1413-70.lg-285-89.ch-3032-1334-109.sh-3016-110.hd-185-1359.ca-3225-110-62.wa-3264-62-62.fa-1206-90.hr-3322-1403' : thread.participant.figure)
: resolveAvatarFigure(liveFriend?.figure || thread.participant.figure, liveFriend?.gender ?? thread.participant.gender);
return (
<button key={ thread.threadId } className={ 'messenger-avatar-tab' + ((activeThread === thread) ? ' active' : '') + (thread.unread ? ' unread' : '') } onClick={ event => setActiveThreadId(thread.threadId) }>
<LayoutAvatarImageView
figure={ thread.participant.id > 0 ? thread.participant.figure : thread.participant.figure === 'ADM' ? 'ha-3409-1413-70.lg-285-89.ch-3032-1334-109.sh-3016-110.hd-185-1359.ca-3225-110-62.wa-3264-62-62.fa-1206-90.hr-3322-1403' : thread.participant.figure }
figure={ figure }
headOnly={ true }
direction={ thread.participant.id > 0 ? 2 : 3 }
direction={ isStaff ? 3 : 2 }
/>
</button>
);
@@ -2,10 +2,13 @@ import { GetSessionDataManager } from '@nitrots/nitro-renderer';
import { FC, useMemo } from 'react';
import { GetGroupChatData, LocalizeText, MessengerGroupType, MessengerThread, MessengerThreadChat, MessengerThreadChatGroup } from '../../../../../api';
import { Base, Flex, LayoutAvatarImageView } from '../../../../../common';
import { useFriends } from '../../../../../hooks';
import { resolveAvatarFigure } from '../../friends-list/resolveAvatarFigure';
export const FriendsMessengerThreadGroup: FC<{ thread: MessengerThread, group: MessengerThreadChatGroup }> = props =>
{
const { thread = null, group = null } = props;
const { getFriend = null } = useFriends();
const groupChatData = useMemo(() => ((group.type === MessengerGroupType.GROUP_CHAT) && GetGroupChatData(group.chats[0].extraData)), [ group ]);
@@ -50,7 +53,7 @@ export const FriendsMessengerThreadGroup: FC<{ thread: MessengerThread, group: M
<Flex fullWidth gap={ 2 } justifyContent={ isOwnChat ? 'end' : 'start' } className={ 'messenger-message-row ' + (isOwnChat ? 'own' : '') }>
<Base shrink className="message-avatar">
{ ((group.type === MessengerGroupType.PRIVATE_CHAT) && !isOwnChat) &&
<LayoutAvatarImageView direction={ 2 } figure={ thread.participant.figure } headOnly={ true } /> }
<LayoutAvatarImageView direction={ 2 } figure={ resolveAvatarFigure(getFriend?.(thread.participant.id)?.figure || thread.participant.figure, getFriend?.(thread.participant.id)?.gender ?? thread.participant.gender) } headOnly={ true } /> }
{ (groupChatData && !isOwnChat) &&
<LayoutAvatarImageView direction={ 2 } figure={ groupChatData.figure } headOnly={ true } /> }
</Base>
@@ -1,4 +1,4 @@
import { GetRoomEngine, IRoomSession, RoomObjectVariable, RoomPreviewer } from '@nitrots/nitro-renderer';
import { IRoomSession, RoomPreviewer } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { IBotItem, LocalizeText, UnseenItemCategory, attemptBotPlacement } from '../../../../api';
import { LayoutRoomPreviewerView } from '../../../../common';
@@ -22,20 +22,9 @@ export const InventoryBotView: FC<{
if(!selectedBot || !roomPreviewer) return;
const botData = selectedBot.botData;
const roomEngine = GetRoomEngine();
let wallType = roomEngine.getRoomInstanceVariable<string>(roomEngine.activeRoomId, RoomObjectVariable.ROOM_WALL_TYPE);
let floorType = roomEngine.getRoomInstanceVariable<string>(roomEngine.activeRoomId, RoomObjectVariable.ROOM_FLOOR_TYPE);
let landscapeType = roomEngine.getRoomInstanceVariable<string>(roomEngine.activeRoomId, RoomObjectVariable.ROOM_LANDSCAPE_TYPE);
wallType = (wallType && wallType.length) ? wallType : '101';
floorType = (floorType && floorType.length) ? floorType : '101';
landscapeType = (landscapeType && landscapeType.length) ? landscapeType : '1.1';
roomPreviewer.reset(false);
roomPreviewer.updateRoomWallsAndFloorVisibility(true, true);
roomPreviewer.updateObjectRoom(floorType, wallType, landscapeType);
roomPreviewer.updateObjectRoom('111', '217', '1.1');
roomPreviewer.addAvatarIntoRoom(botData.figure, 0);
}, [ roomPreviewer, selectedBot ]);
@@ -1,5 +1,5 @@
import { InfiniteGrid } from '@layout/InfiniteGrid';
import { GetRoomEngine, GetSessionDataManager, IRoomSession, RoomObjectVariable, RoomPreviewer, Vector3d } from '@nitrots/nitro-renderer';
import { GetSessionDataManager, IRoomSession, RoomPreviewer, Vector3d } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { FaPowerOff, FaSyncAlt, FaTrashAlt } from 'react-icons/fa';
import { DispatchUiEvent, FurniCategory, GroupItem, LocalizeText, UnseenItemCategory, attemptItemPlacement } from '../../../../api';
@@ -53,24 +53,17 @@ export const InventoryFurnitureView: FC<{
const isRoomDecoration = (furnitureItem.category === FurniCategory.WALL_PAPER) || (furnitureItem.category === FurniCategory.FLOOR) || (furnitureItem.category === FurniCategory.LANDSCAPE);
let floorType = '111';
let wallType = '217';
let landscapeType = '1.1';
if(isRoomDecoration)
{
const roomEngine = GetRoomEngine();
let wallType = roomEngine.getRoomInstanceVariable<string>(roomEngine.activeRoomId, RoomObjectVariable.ROOM_WALL_TYPE);
let floorType = roomEngine.getRoomInstanceVariable<string>(roomEngine.activeRoomId, RoomObjectVariable.ROOM_FLOOR_TYPE);
let landscapeType = roomEngine.getRoomInstanceVariable<string>(roomEngine.activeRoomId, RoomObjectVariable.ROOM_LANDSCAPE_TYPE);
wallType = (wallType && wallType.length) ? wallType : '101';
floorType = (floorType && floorType.length) ? floorType : '101';
landscapeType = (landscapeType && landscapeType.length) ? landscapeType : '1.1';
roomPreviewer.updateRoomWallsAndFloorVisibility(true, true);
floorType = ((furnitureItem.category === FurniCategory.FLOOR) ? selectedItem.stuffData.getLegacyString() : floorType);
wallType = ((furnitureItem.category === FurniCategory.WALL_PAPER) ? selectedItem.stuffData.getLegacyString() : wallType);
landscapeType = ((furnitureItem.category === FurniCategory.LANDSCAPE) ? selectedItem.stuffData.getLegacyString() : landscapeType);
roomPreviewer.updateRoomWallsAndFloorVisibility(true, true);
roomPreviewer.updateObjectRoom(floorType, wallType, landscapeType);
if(furnitureItem.category === FurniCategory.LANDSCAPE)
@@ -83,7 +76,7 @@ export const InventoryFurnitureView: FC<{
return;
}
roomPreviewer.updateObjectRoom('default', 'default', 'default');
roomPreviewer.updateObjectRoom(floorType, wallType, landscapeType);
roomPreviewer.updateRoomWallsAndFloorVisibility(true, true);
if(selectedItem.isWallItem)
@@ -1,4 +1,4 @@
import { DeletePetMessageComposer, GetRoomEngine, IRoomSession, RoomObjectVariable, RoomPreviewer } from '@nitrots/nitro-renderer';
import { DeletePetMessageComposer, IRoomSession, RoomPreviewer } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { FaTrashAlt } from 'react-icons/fa';
import { IPetItem, LocalizeText, SendMessageComposer, UnseenItemCategory, attemptPetPlacement } from '../../../../api';
@@ -38,19 +38,10 @@ export const InventoryPetView: FC<{
if(!selectedPet || !roomPreviewer) return;
const petData = selectedPet.petData;
const roomEngine = GetRoomEngine();
let wallType = roomEngine.getRoomInstanceVariable<string>(roomEngine.activeRoomId, RoomObjectVariable.ROOM_WALL_TYPE);
let floorType = roomEngine.getRoomInstanceVariable<string>(roomEngine.activeRoomId, RoomObjectVariable.ROOM_FLOOR_TYPE);
let landscapeType = roomEngine.getRoomInstanceVariable<string>(roomEngine.activeRoomId, RoomObjectVariable.ROOM_LANDSCAPE_TYPE);
wallType = (wallType && wallType.length) ? wallType : '101';
floorType = (floorType && floorType.length) ? floorType : '101';
landscapeType = (landscapeType && landscapeType.length) ? landscapeType : '1.1';
roomPreviewer.reset(false);
roomPreviewer.updateRoomWallsAndFloorVisibility(true, true);
roomPreviewer.updateObjectRoom(floorType, wallType, landscapeType);
roomPreviewer.updateObjectRoom('111', '217', '1.1');
roomPreviewer.addPetIntoRoom(petData.figureString);
}, [ roomPreviewer, selectedPet ]);
+10 -10
View File
@@ -1,5 +1,5 @@
import { FC, Fragment, ReactNode } from 'react';
import { tokenIsMention } from '../room/widgets/chat/highlightMentions';
import { classifyMentionToken } from '../../api/mentions/mentionTokens';
interface MentionMessageViewProps
{
@@ -9,10 +9,11 @@ interface MentionMessageViewProps
}
/**
* 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.
* Renders a mention's message text as React nodes, wrapping every @user token
* in a `.mention-tag` span (with the `.mention-tag--self` modifier when the
* token targets the local user or a broadcast alias). Pure text segmentation
* (no innerHTML) → no XSS risk from other users' chat content. Original spacing
* is preserved verbatim.
*/
export const MentionMessageView: FC<MentionMessageViewProps> = props =>
{
@@ -24,12 +25,11 @@ export const MentionMessageView: FC<MentionMessageViewProps> = props =>
{
if(segment.length === 0) return null;
if(/^\s+$/.test(segment) || !tokenIsMention(segment, ownUsername))
{
return <Fragment key={ index }>{ segment }</Fragment>;
}
const kind = (/^\s+$/.test(segment)) ? '' : classifyMentionToken(segment, ownUsername);
return <span key={ index } className="mention-highlight">{ segment }</span>;
if(!kind) return <Fragment key={ index }>{ segment }</Fragment>;
return <span key={ index } className={ (kind === 'self') ? 'mention-tag mention-tag--self' : 'mention-tag' }>{ segment }</span>;
});
return <span className={ className }>{ nodes }</span>;
+26 -34
View File
@@ -1,8 +1,8 @@
import { FC, MouseEvent } from 'react';
import { IMentionEntry, LocalizeText, MentionType } from '../../api';
import { Flex, Text } from '../../common';
import { FaArrowRight, FaTimes } from 'react-icons/fa';
import { formatMentionTime, IMentionEntry, LocalizeText, MentionType } from '../../api';
import { LayoutAvatarImageView } from '../../common';
import { MentionMessageView } from './MentionMessageView';
import { formatMentionTime } from './mentionsFormat';
interface MentionRowViewProps
{
@@ -28,39 +28,31 @@ export const MentionRowView: FC<MentionRowViewProps> = props =>
};
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">
<div className={ `mention-row ${ mention.read ? '' : 'is-unread' }` } onClick={ () => onOpen(mention) }>
{ !mention.read &&
<span className="mention-row-unread-dot" aria-hidden /> }
<div className="mention-row-avatar" title={ typeTitle }>
<LayoutAvatarImageView headOnly direction={ 2 } figure={ mention.senderFigure } />
<span className={ `mention-row-type ${ isRoom ? 'is-room' : 'is-direct' }` }>{ isRoom ? '' : '@' }</span>
</div>
<div className="mention-row-body">
<div className="mention-row-head">
<span className="mention-row-name">{ mention.senderUsername }</span>
{ (mention.roomName && (mention.roomName.length > 0)) &&
<span className="mention-row-room">· { mention.roomName }</span> }
</div>
<MentionMessageView className="mention-row-msg" ownUsername={ ownUsername } text={ mention.message } />
</div>
<div className="mention-row-meta">
{ (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">
<span className="mention-row-time">{ time }</span> }
<div className="mention-row-actions">
{ 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> }
<button type="button" className="mention-row-action" title={ LocalizeText('mentions.action.goto') } onClick={ event => stop(event, () => onGoto(mention)) }><FaArrowRight /></button> }
{ 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>
<button type="button" className="mention-row-action is-remove" title={ LocalizeText('mentions.action.remove') } onClick={ event => stop(event, () => onRemove(mention)) }><FaTimes /></button> }
</div>
</div>
</div>
);
};
@@ -1,63 +0,0 @@
import { CreateLinkEvent, MarkMentionsReadComposer } from '@nitrots/nitro-renderer';
import { FC, MouseEvent, useEffect } from 'react';
import { FaTimes } from 'react-icons/fa';
import { LocalizeText, SendMessageComposer } from '../../api';
import { LayoutAvatarImageView } from '../../common';
import { useExternalSnapshot } from '../../hooks/events/useExternalSnapshot';
import { markRead } from '../../hooks/mentions/mentionsStore';
import { dismissMentionToast, getMentionToasts, MentionToast, subscribeMentionToasts } from '../../hooks/mentions/mentionToastsStore';
// Quanto resta visibile un toast prima di nascondersi da solo (resta non-letto).
const AUTO_DISMISS_MS = 8000;
const MentionToastItemView: FC<{ toast: MentionToast }> = ({ toast }) =>
{
useEffect(() =>
{
const timer = window.setTimeout(() => dismissMentionToast(toast.mentionId), AUTO_DISMISS_MS);
return () => window.clearTimeout(timer);
}, [ toast.mentionId ]);
// Dismiss esplicito: segna letta (badge toolbar si aggiorna) + persiste sul server + chiude.
const onDismiss = (event: MouseEvent) =>
{
event.stopPropagation();
markRead(toast.mentionId);
SendMessageComposer(new MarkMentionsReadComposer(1, toast.mentionId));
dismissMentionToast(toast.mentionId);
};
const onOpen = () =>
{
CreateLinkEvent('mentions/toggle');
dismissMentionToast(toast.mentionId);
};
return (
<div className="mention-toast" onClick={ onOpen }>
<div className="mention-toast-avatar">
<LayoutAvatarImageView headOnly direction={ 2 } figure={ toast.senderFigure } />
</div>
<div className="mention-toast-body">
<div className="mention-toast-title">{ toast.senderUsername }</div>
<div className="mention-toast-message">{ toast.message }</div>
</div>
<button className="mention-toast-dismiss" title={ LocalizeText('generic.cancel') } type="button" onClick={ onDismiss }>
<FaTimes />
</button>
</div>
);
};
export const MentionToastsView: FC = () =>
{
const toasts = useExternalSnapshot(subscribeMentionToasts, getMentionToasts);
if(!toasts || !toasts.length) return null;
return (
<div className="mention-toasts">
{ toasts.map(toast => <MentionToastItemView key={ toast.mentionId } toast={ toast } />) }
</div>
);
};
+65 -37
View File
@@ -1,13 +1,12 @@
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 { MarkMentionsReadComposer, RequestMentionsComposer } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { FaSearch, FaSync } from 'react-icons/fa';
import { getMentionDateGroup, IMentionEntry, LocalizeText, MentionDateGroup, MentionType, SendMessageComposer } from '../../api';
import { Button, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../common';
import { useMentionActions, 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
{
@@ -41,6 +40,17 @@ const matchesFilter = (mention: IMentionEntry, filter: MentionFilter): boolean =
}
};
const matchesQuery = (mention: IMentionEntry, query: string): boolean =>
{
if(!query) return true;
const q = query.toLowerCase();
return ((mention.senderUsername || '').toLowerCase().includes(q)
|| (mention.roomName || '').toLowerCase().includes(q)
|| (mention.message || '').toLowerCase().includes(q));
};
export const MentionsView: FC<MentionsViewProps> = props =>
{
const { onClose } = props;
@@ -48,6 +58,15 @@ export const MentionsView: FC<MentionsViewProps> = props =>
const { userName: ownUsername = '' } = useUserDataSnapshot();
const { open, goto, remove } = useMentionActions();
const [ filter, setFilter ] = useState<MentionFilter>('all');
const [ query, setQuery ] = useState('');
// Re-request from the server: once on open, and on the manual refresh button.
const refresh = useCallback(() => SendMessageComposer(new RequestMentionsComposer()), []);
useEffect(() =>
{
refresh();
}, [ refresh ]);
const onMarkAll = useCallback(() =>
{
@@ -62,48 +81,57 @@ export const MentionsView: FC<MentionsViewProps> = props =>
for(const mention of mentions)
{
if(!matchesFilter(mention, filter)) continue;
if(!matchesQuery(mention, query)) continue;
buckets[getMentionDateGroup(mention.timestamp)].push(mention);
}
return GROUP_ORDER
.map(key => ({ key, items: buckets[key] }))
.filter(group => group.items.length > 0);
}, [ mentions, filter ]);
}, [ mentions, filter, query ]);
const hasAny = groups.length > 0;
const title = `${ LocalizeText('mentions.window.title') }${ (unreadCount > 0) ? ` (${ unreadCount })` : '' }`;
return (
<NitroCardView className="w-[360px] h-[440px]" theme="primary-slim" uniqueKey="mentions">
<NitroCardHeaderView headerText={ LocalizeText('mentions.window.title') } onCloseClick={ onClose } />
<NitroCardView className="mentions-window w-[360px] has-classic-scrollbar" theme="primary-slim" uniqueKey="mentions">
<NitroCardHeaderView headerText={ 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));
<div className="mentions-search">
<FaSearch className="mentions-search-icon" />
<input
type="text"
value={ query }
placeholder={ LocalizeText('generic.search') }
onChange={ event => setQuery(event.target.value) } />
</div>
<div className="mentions-toolbar">
<div className="mentions-filters">
{ 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 }>
return (
<button key={ key } type="button" className={ `mentions-filter ${ active ? 'is-active' : '' }` } onClick={ () => setFilter(key) }>
{ LocalizeText(label) }{ showCount ? ` ${ unreadCount }` : '' }
</button>
);
}) }
</div>
<button type="button" className="mentions-refresh" title="Aggiorna" onClick={ refresh }>
<FaSync />
</button>
</div>
<div className="mentions-list">
{ !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> }
<div className="mentions-empty">
<span className="mentions-empty-glyph">@</span>
<span className="mentions-empty-text">{ LocalizeText('mentions.window.empty') }</span>
</div> }
{ 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>
<div key={ group.key } className="mentions-group">
<div className="mentions-group-label">{ LocalizeText(GROUP_LABEL[group.key]) }</div>
{ group.items.map(mention => (
<MentionRowView
key={ mention.mentionId }
@@ -113,9 +141,9 @@ export const MentionsView: FC<MentionsViewProps> = props =>
onRemove={ remove }
ownUsername={ ownUsername } />
)) }
</Flex>
</div>
)) }
</Flex>
</div>
{ (unreadCount > 0) &&
<Button variant="primary" onClick={ onMarkAll }>{ LocalizeText('mentions.window.markall') }</Button> }
</NitroCardContentView>
-3
View File
@@ -1,6 +1,3 @@
export * from './MentionMessageView';
export * from './MentionRowView';
export * from './MentionsView';
export * from './MentionToastsView';
export * from './mentionsFormat';
export * from './useMentionActions';
@@ -1,52 +0,0 @@
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
@@ -1,41 +0,0 @@
// 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()) }`;
};
@@ -1,39 +0,0 @@
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 -1
View File
@@ -109,7 +109,7 @@ export const NavigatorView: FC<{}> = props =>
<>
{ isVisible &&
<NitroCard
className={ `${ isOpenSavesSearches ? 'w-[600px] sm:min-w-[600px]' : 'w-navigator-w sm:min-w-navigator-w' } max-w-[calc(100vw-1rem)] h-navigator-h min-h-navigator-h` }
className={ `${ isOpenSavesSearches ? 'w-[600px] sm:min-w-[600px]' : 'w-navigator-w sm:min-w-navigator-w' } max-w-[calc(100vw-1rem)] h-navigator-h min-h-navigator-h has-classic-scrollbar` }
uniqueKey="navigator">
<NitroCard.Header
headerText={ LocalizeText(isCreatorOpen ? 'navigator.createroom.title' : 'navigator.title') }
@@ -1,7 +1,8 @@
import { NotificationBubbleItem, NotificationBubbleType } from '../../../../api';
import { MentionNotificationBubbleItem, NotificationBubbleItem, NotificationBubbleType } from '../../../../api';
import { NotificationBadgeReceivedBubbleView } from './NotificationBadgeReceivedBubbleView';
import { NotificationClubGiftBubbleView } from './NotificationClubGiftBubbleView';
import { NotificationDefaultBubbleView } from './NotificationDefaultBubbleView';
import { NotificationMentionBubbleView } from './NotificationMentionBubbleView';
export const GetBubbleLayout = (item: NotificationBubbleItem, onClose: () => void) =>
{
@@ -15,6 +16,8 @@ export const GetBubbleLayout = (item: NotificationBubbleItem, onClose: () => voi
return <NotificationBadgeReceivedBubbleView key={ item.id } { ...props } />;
case NotificationBubbleType.CLUBGIFT:
return <NotificationClubGiftBubbleView key={ item.id } { ...props } />;
case NotificationBubbleType.MENTION:
return <NotificationMentionBubbleView key={ item.id } item={ item as MentionNotificationBubbleItem } onClose={ onClose } />;
default:
return <NotificationDefaultBubbleView key={ item.id } { ...props } />;
}
@@ -0,0 +1,89 @@
import { CreateLinkEvent, MarkMentionsReadComposer } from '@nitrots/nitro-renderer';
import { FC, MouseEvent } from 'react';
import { FaTimes } from 'react-icons/fa';
import { formatMentionTime, LocalizeText, MentionNotificationBubbleItem, MentionType, SendMessageComposer } from '../../../../api';
import { Flex, LayoutAvatarImageView, LayoutNotificationBubbleView, LayoutNotificationBubbleViewProps, Text } from '../../../../common';
import { markRead } from '../../../../hooks/mentions/mentionsStore';
import { useUserDataSnapshot } from '../../../../hooks/session/useSessionSnapshots';
import { MentionMessageView } from '../../../mentions/MentionMessageView';
export interface NotificationMentionBubbleViewProps extends LayoutNotificationBubbleViewProps
{
item: MentionNotificationBubbleItem;
}
export const NotificationMentionBubbleView: FC<NotificationMentionBubbleViewProps> = props =>
{
const { item = null, onClose = null, ...rest } = props;
const { userName: ownUsername = '' } = useUserDataSnapshot();
const mention = item.mention;
const isRoom = (mention.mentionType === MentionType.ROOM);
const time = formatMentionTime(mention.timestamp);
const markReadOnServer = () =>
{
markRead(mention.mentionId);
SendMessageComposer(new MarkMentionsReadComposer(1, mention.mentionId));
};
// Whole-bubble click opens the mentions panel (and dismisses the bubble).
const open = () =>
{
CreateLinkEvent('mentions/toggle');
onClose();
};
const goto = () =>
{
markReadOnServer();
if(mention.roomId > 0) CreateLinkEvent(`navigator/goto/${ mention.roomId }`);
onClose();
};
const act = (event: MouseEvent, fn: () => void) =>
{
event.stopPropagation();
fn();
};
return (
<LayoutNotificationBubbleView
alignItems="start"
gap={ 2 }
classNames={ [ 'w-[330px]', 'max-w-[92vw]', 'cursor-pointer' ] }
onClick={ open }
onClose={ onClose }
{ ...rest }>
<div className="mention-toast-avatar">
<LayoutAvatarImageView headOnly direction={ 2 } figure={ mention.senderFigure } />
</div>
<Flex column gap={ 0 } className="min-w-0 flex-1">
<Flex alignItems="center" gap={ 1 } className="min-w-0">
<Text bold truncate variant="white">{ mention.senderUsername }</Text>
<span className={ `mention-toast-chip ${ isRoom ? 'is-room' : 'is-direct' }` }>
{ LocalizeText(isRoom ? 'mentions.type.room' : 'mentions.type.direct') }
</span>
<span className="mention-toast-spacer" />
{ (time.length > 0) &&
<span className="mention-toast-time">{ time }</span> }
<button type="button" className="mention-toast-dismiss" title={ LocalizeText('generic.cancel') } onClick={ event => act(event, onClose) }>
<FaTimes />
</button>
</Flex>
{ (mention.roomName && (mention.roomName.length > 0)) &&
<span className="mention-toast-room">· { mention.roomName }</span> }
<MentionMessageView className="mention-toast-message" ownUsername={ ownUsername } text={ mention.message } />
<Flex gap={ 1 } className="mention-toast-actions">
<button type="button" className="mention-toast-btn" onClick={ event => act(event, open) }>
{ LocalizeText('mentions.window.title') }
</button>
{ (mention.roomId > 0) &&
<button type="button" className="mention-toast-btn" onClick={ event => act(event, goto) }>
{ LocalizeText('mentions.action.goto') }
</button> }
</Flex>
</Flex>
</LayoutNotificationBubbleView>
);
};
@@ -18,6 +18,56 @@ const PICKUP_MODE_NONE: number = 0;
const PICKUP_MODE_EJECT: number = 1;
const PICKUP_MODE_FULL: number = 2;
function getValidRoomObjectDirection(roomObject: any, isPositive: boolean)
{
if(!roomObject || !roomObject.model) return 0;
let allowedDirections: number[] = [];
if(roomObject.type === 'monster_plant')
{
allowedDirections = roomObject.model.getValue('pet_allowed_directions');
}
else
{
allowedDirections = roomObject.model.getValue('furniture_allowed_directions');
}
let direction = roomObject.getDirection().x;
if(allowedDirections && allowedDirections.length)
{
let index = allowedDirections.indexOf(direction);
if(index < 0)
{
index = 0;
for(let i = 0; i < allowedDirections.length; i++)
{
if(direction <= allowedDirections[i]) break;
index++;
}
index = index % allowedDirections.length;
}
if(isPositive)
{
index = (index + 1) % allowedDirections.length;
}
else
{
index = (index - 1 + allowedDirections.length) % allowedDirections.length;
}
direction = allowedDirections[index];
}
return direction;
}
export const InfoStandWidgetFurniView: FC<InfoStandWidgetFurniViewProps> = props =>
{
const { avatarInfo = null, onClose = null } = props;
@@ -78,56 +128,6 @@ export const InfoStandWidgetFurniView: FC<InfoStandWidgetFurniViewProps> = props
SendMessageComposer(new UpdateFurniturePositionComposer(avatarInfo.id, newX, newY, Math.round(newZ * 10000), newDirection));
}, [ avatarInfo ]);
function getValidRoomObjectDirection(roomObject: any, isPositive: boolean)
{
if(!roomObject || !roomObject.model) return 0;
let allowedDirections: number[] = [];
if(roomObject.type === 'monster_plant')
{
allowedDirections = roomObject.model.getValue('pet_allowed_directions');
}
else
{
allowedDirections = roomObject.model.getValue('furniture_allowed_directions');
}
let direction = roomObject.getDirection().x;
if(allowedDirections && allowedDirections.length)
{
let index = allowedDirections.indexOf(direction);
if(index < 0)
{
index = 0;
for(let i = 0; i < allowedDirections.length; i++)
{
if(direction <= allowedDirections[i]) break;
index++;
}
index = index % allowedDirections.length;
}
if(isPositive)
{
index = (index + 1) % allowedDirections.length;
}
else
{
index = (index - 1 + allowedDirections.length) % allowedDirections.length;
}
direction = allowedDirections[index];
}
return direction;
}
const handleHeightChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) =>
{
let newZ = parseFloat(event.target.value);
@@ -421,7 +421,11 @@ export const InfoStandWidgetFurniView: FC<InfoStandWidgetFurniViewProps> = props
if(key === 'offsetX') value = String(x);
else if(key === 'offsetY') value = String(y);
else if(key === 'offsetZ') value = String(z);
else if(key === 'scale') { value = String(scale); hasScale = true; }
else if(key === 'scale')
{
value = String(scale);
hasScale = true;
}
clone[i] = value;
map.set(key, value);
@@ -638,6 +642,20 @@ export const InfoStandWidgetFurniView: FC<InfoStandWidgetFurniViewProps> = props
})() }</Text>
</div>
</div> }
{ isModerator &&
<button
className="w-full text-white text-xs bg-[#1e7295] hover:bg-[#1a617f] border border-[#ffffff33] rounded px-2 py-1 cursor-pointer transition-colors"
onClick={ () =>
{
const roomObject = GetRoomEngine().getRoomObject(roomSession.roomId, avatarInfo.id, avatarInfo.isWallItem ? RoomObjectCategory.WALL : RoomObjectCategory.FLOOR);
const typeId = roomObject?.model?.getValue(RoomObjectVariable.FURNITURE_TYPE_ID);
CreateLinkEvent('furni-editor/show');
if(typeId) window.dispatchEvent(new CustomEvent('furni-editor:open', { detail: { spriteId: typeId } }));
} }>
Edit Furni
</button> }
{ (!avatarInfo.isWallItem && canMove) &&
<>
<button
@@ -645,20 +663,6 @@ export const InfoStandWidgetFurniView: FC<InfoStandWidgetFurniViewProps> = props
onClick={ () => setDropdownOpen(!dropdownOpen) }>
{ dropdownOpen ? `${LocalizeText('widget.furni.present.close')} Buildtools` : `${LocalizeText('navigator.roomsettings.doormode.open')} Buildtools` }
</button>
{ isModerator &&
<button
className="w-full text-white text-xs bg-[#1e7295] hover:bg-[#1a617f] border border-[#ffffff33] rounded px-2 py-1 cursor-pointer transition-colors"
onClick={ () =>
{
const roomObject = GetRoomEngine().getRoomObject(roomSession.roomId, avatarInfo.id, avatarInfo.isWallItem ? RoomObjectCategory.WALL : RoomObjectCategory.FLOOR);
const typeId = roomObject?.model?.getValue(RoomObjectVariable.FURNITURE_TYPE_ID);
CreateLinkEvent('furni-editor/show');
if(typeId) window.dispatchEvent(new CustomEvent('furni-editor:open', { detail: { spriteId: typeId } }));
} }>
Edit Furni
</button> }
{ dropdownOpen &&
<div className="flex gap-[4px] w-full">
{ /* Left panel: position + rotation */ }
@@ -62,7 +62,7 @@ export const ChatInputCommandSelectorView: FC<ChatInputCommandSelectorViewProps>
<span className="chat-input-command-popover-header-dot" aria-hidden />
<span>: Command</span>
</div>
<div ref={ listRef } className="chat-input-command-popover-list">
<div ref={ listRef } className="chat-input-command-popover-list has-classic-scrollbar">
{ commands.map((cmd, index) =>
{
const isSelected = (index === selectedIndex);
@@ -1,17 +1,6 @@
import { FC, useEffect, useRef } from 'react';
import { LayoutAvatarImageView } from '../../../../common';
export type MentionSuggestionKind = 'user' | 'alias';
export interface MentionSuggestion
{
key: string;
kind: MentionSuggestionKind;
name: string;
insertToken: string;
figure?: string;
description?: string;
}
import { MentionSuggestion } from '../../../../hooks/rooms/widgets/useChatMentions.helpers';
interface ChatInputMentionSelectorViewProps
{
@@ -99,7 +88,7 @@ export const ChatInputMentionSelectorView: FC<ChatInputMentionSelectorViewProps>
<span className="chat-input-mention-popover-header-dot" aria-hidden />
<span>@ Mention</span>
</div>
<div ref={ listRef } className="chat-input-mention-popover-list">
<div ref={ listRef } className="chat-input-mention-popover-list has-classic-scrollbar">
{ suggestions.map((suggestion, index) =>
{
const isSelected = (index === selectedIndex);
@@ -3,50 +3,12 @@ import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { ChatMessageTypeEnum, GetClubMemberLevel, GetConfigurationValue, LocalizeText, RoomWidgetUpdateChatInputContentEvent } from '../../../../api';
import { Text } from '../../../../common';
import { useCatalogClassicStyle, useChatCommandSelector, useChatInputWidget, useRoom, useSessionInfo, useUiEvent } from '../../../../hooks';
import { useRoomUserListSnapshot } from '../../../../hooks/session/useSessionSnapshots';
import { useCatalogClassicStyle, useChatCommandSelector, useChatInputWidget, useChatMentions, useRoom, useSessionInfo, useUiEvent } from '../../../../hooks';
import { ChatInputCommandSelectorView } from './ChatInputCommandSelectorView';
import { ChatInputEmojiSelectorView } from './ChatInputEmojiSelectorView';
import { ChatInputMentionSelectorView, MentionSuggestion } from './ChatInputMentionSelectorView';
import { ChatInputMentionSelectorView } from './ChatInputMentionSelectorView';
import { ChatInputStyleSelectorView } from './ChatInputStyleSelectorView';
const USER_TYPE_REAL_USER = 1;
const MAX_MENTION_SUGGESTIONS = 8;
type MentionAliasScope = 'everyone' | 'friends' | 'room';
const MENTION_ALIAS_CONFIG_KEY: Record<MentionAliasScope, string> = {
everyone: 'mentions_ui.aliases.everyone',
friends: 'mentions_ui.aliases.friends',
room: 'mentions_ui.aliases.room'
};
const MENTION_ALIAS_DEFAULTS: Record<MentionAliasScope, string[]> = {
everyone: [ 'all', 'everyone', 'tutti' ],
friends: [ 'friends', 'amici' ],
room: [ 'room', 'stanza' ]
};
const MENTION_ALIAS_DESCRIPTION_KEY: Record<MentionAliasScope, string> = {
everyone: 'mentions.alias.description.everyone',
friends: 'mentions.alias.description.friends',
room: 'mentions.alias.description.room'
};
const sanitizeAliasList = (raw: unknown, fallback: string[]): string[] =>
{
if(!Array.isArray(raw)) return fallback;
const out: string[] = [];
for(const entry of raw)
{
if(typeof entry !== 'string') continue;
const trimmed = entry.trim();
if(!trimmed) continue;
out.push(trimmed);
}
return out;
};
export const ChatInputView: FC<{}> = props =>
{
const [ chatValue, setChatValue ] = useState<string>('');
@@ -56,129 +18,13 @@ export const ChatInputView: FC<{}> = props =>
const inputRef = useRef<HTMLInputElement>(null);
const { isVisible: commandSelectorVisible, filteredCommands, selectedIndex, setSelectedIndex, moveUp, moveDown, selectCurrent, close: closeCommandSelector } = useChatCommandSelector(chatValue);
const roomUserList = useRoomUserListSnapshot();
const [ mentionSelectedIndex, setMentionSelectedIndex ] = useState<number>(0);
// The "New style" user-setting (memenu.settings.other.catalog.classic.style)
// drives BOTH the catalog layout and the mention-picker chrome:
// false (default) = Habbo old-school NitroCard cardstock look
// true = flat minimalist gray look
const [ newStyle ] = useCatalogClassicStyle();
const mentionContext = useMemo(() =>
{
if(!chatValue) return null;
if(commandSelectorVisible) return null;
const caret = inputRef.current?.selectionStart ?? chatValue.length;
const upToCaret = chatValue.slice(0, caret);
const at = upToCaret.lastIndexOf('@');
if(at < 0) return null;
if(at > 0 && !/\s/.test(upToCaret.charAt(at - 1))) return null;
const query = upToCaret.slice(at + 1);
if(/\s/.test(query)) return null;
return { atIndex: at, replaceFrom: at, replaceTo: caret, query };
}, [ chatValue, commandSelectorVisible ]);
const mentionAliases = useMemo<ReadonlyArray<{ key: string; scope: MentionAliasScope; description: string }>>(() =>
{
const out: { key: string; scope: MentionAliasScope; description: string }[] = [];
const seen = new Set<string>();
const scopes: MentionAliasScope[] = [ 'everyone', 'friends', 'room' ];
for(const scope of scopes)
{
const list = sanitizeAliasList(
GetConfigurationValue<unknown>(MENTION_ALIAS_CONFIG_KEY[scope], MENTION_ALIAS_DEFAULTS[scope]),
MENTION_ALIAS_DEFAULTS[scope]
);
for(const key of list)
{
const lower = key.toLowerCase();
if(seen.has(lower)) continue;
seen.add(lower);
out.push({ key, scope, description: LocalizeText(MENTION_ALIAS_DESCRIPTION_KEY[scope]) });
}
}
return out;
}, []);
const mentionSuggestions = useMemo<MentionSuggestion[]>(() =>
{
if(!mentionContext) return [];
const query = mentionContext.query.toLowerCase();
const out: MentionSuggestion[] = [];
for(const user of roomUserList)
{
if(!user || user.type !== USER_TYPE_REAL_USER) continue;
if(!user.name) continue;
if(query.length > 0 && !user.name.toLowerCase().startsWith(query)) continue;
out.push({
key: `user:${ user.webID }`,
kind: 'user',
name: user.name,
insertToken: user.name,
figure: user.figure || ''
});
if(out.length >= MAX_MENTION_SUGGESTIONS) break;
}
for(const alias of mentionAliases)
{
if(query.length > 0 && !alias.key.toLowerCase().startsWith(query)) continue;
out.push({
key: `alias:${ alias.key }`,
kind: 'alias',
name: alias.key,
insertToken: alias.key,
description: alias.description
});
if(out.length >= MAX_MENTION_SUGGESTIONS) break;
}
return out;
}, [ mentionContext, roomUserList, mentionAliases ]);
const mentionSelectorVisible = mentionSuggestions.length > 0;
useEffect(() =>
{
if(mentionSelectedIndex >= mentionSuggestions.length) setMentionSelectedIndex(0);
}, [ mentionSuggestions.length, mentionSelectedIndex ]);
const applyMentionSuggestion = useCallback((suggestion: MentionSuggestion) =>
{
if(!suggestion || !mentionContext) return;
const before = chatValue.slice(0, mentionContext.replaceFrom);
const after = chatValue.slice(mentionContext.replaceTo);
const inserted = `@${ suggestion.insertToken } `;
const next = `${ before }${ inserted }${ after }`;
setChatValue(next);
requestAnimationFrame(() =>
{
if(!inputRef.current) return;
const caret = before.length + inserted.length;
inputRef.current.focus();
inputRef.current.setSelectionRange(caret, caret);
});
setMentionSelectedIndex(0);
}, [ chatValue, mentionContext ]);
const mention = useChatMentions(chatValue, setChatValue, inputRef, commandSelectorVisible);
const chatModeIdWhisper = useMemo(() => LocalizeText('widgets.chatinput.mode.whisper'), []);
const chatModeIdShout = useMemo(() => LocalizeText('widgets.chatinput.mode.shout'), []);
@@ -331,41 +177,30 @@ export const ChatInputView: FC<{}> = props =>
}
}
if(mentionSelectorVisible)
if(mention.visible)
{
switch(event.key)
{
case 'ArrowUp':
event.preventDefault();
setMentionSelectedIndex(prev => (prev <= 0) ? (mentionSuggestions.length - 1) : (prev - 1));
mention.moveUp();
return;
case 'ArrowDown':
event.preventDefault();
setMentionSelectedIndex(prev => (prev >= mentionSuggestions.length - 1) ? 0 : (prev + 1));
mention.moveDown();
return;
case 'Tab':
case 'NumpadEnter':
case 'Enter': {
const picked = mentionSuggestions[mentionSelectedIndex] ?? mentionSuggestions[0];
if(picked)
case 'Enter':
if(mention.applyCurrent())
{
event.preventDefault();
applyMentionSuggestion(picked);
return;
}
break;
}
case 'Escape':
event.preventDefault();
setMentionSelectedIndex(0);
if(mentionContext)
{
const before = chatValue.slice(0, mentionContext.replaceFrom);
const after = chatValue.slice(mentionContext.replaceTo);
setChatValue(before + after);
}
mention.cancel();
return;
}
}
@@ -395,7 +230,7 @@ export const ChatInputView: FC<{}> = props =>
return;
}
}, [ floodBlocked, inputRef, chatModeIdWhisper, anotherInputHasFocus, setInputFocus, checkSpecialKeywordForInput, sendChatValue, commandSelectorVisible, moveUp, moveDown, selectCurrent, closeCommandSelector, mentionSelectorVisible, mentionSuggestions, mentionSelectedIndex, applyMentionSuggestion, mentionContext, chatValue ]);
}, [ floodBlocked, inputRef, chatModeIdWhisper, anotherInputHasFocus, setInputFocus, checkSpecialKeywordForInput, sendChatValue, commandSelectorVisible, moveUp, moveDown, selectCurrent, closeCommandSelector, mention, chatValue ]);
useUiEvent<RoomWidgetUpdateChatInputContentEvent>(RoomWidgetUpdateChatInputContentEvent.CHAT_INPUT_CONTENT, event =>
{
@@ -492,12 +327,12 @@ export const ChatInputView: FC<{}> = props =>
onHover={ setSelectedIndex }
newStyle={ newStyle }
/> }
{ mentionSelectorVisible && !commandSelectorVisible &&
{ mention.visible && !commandSelectorVisible &&
<ChatInputMentionSelectorView
suggestions={ mentionSuggestions }
selectedIndex={ mentionSelectedIndex }
onSelect={ applyMentionSuggestion }
onHover={ setMentionSelectedIndex }
suggestions={ mention.suggestions }
selectedIndex={ mention.selectedIndex }
onSelect={ mention.apply }
onHover={ mention.setSelectedIndex }
newStyle={ newStyle }
/> }
<div className="flex-1 items-center input-sizer">
@@ -1,35 +1,51 @@
import { describe, expect, it } from 'vitest';
import { highlightMentions, MENTION_ROOM_ALIASES } from './highlightMentions';
import { MENTION_ROOM_ALIASES } from '../../../../api/mentions/mentionTokens';
import { highlightMentions } from './highlightMentions';
const OPEN = '<span class="mention-highlight">';
const CLOSE = '</span>';
// A generic @user tag, and a self/alias mention (strong).
const TAG = (s: string) => `<span class="mention-tag">${ s }</span>`;
const SELF = (s: string) => `<span class="mention-tag mention-tag--self">${ s }</span>`;
describe('highlightMentions', () =>
{
it('highlights the own-nick token', () =>
it('marks the own-nick token as a self mention', () =>
{
const out = highlightMentions('hello @Bob how are you', 'Bob');
expect(out).toBe(`hello ${ OPEN }@Bob${ CLOSE } how are you`);
expect(out).toBe(`hello ${ SELF('@Bob') } how are you`);
});
it('highlights a room-broadcast alias token', () =>
it('marks a room-broadcast alias token as a self mention', () =>
{
const out = highlightMentions('@all party time', 'Bob');
expect(out).toBe(`${ OPEN }@all${ CLOSE } party time`);
expect(out).toBe(`${ SELF('@all') } party time`);
});
it('highlights every configured room alias', () =>
it('marks every configured room alias as a self mention', () =>
{
for(const alias of MENTION_ROOM_ALIASES)
{
const out = highlightMentions(`hey @${ alias }!`, 'Bob');
expect(out).toBe(`hey ${ OPEN }@${ alias }!${ CLOSE }`);
expect(out).toBe(`hey ${ SELF(`@${ alias }!`) }`);
}
});
it('tags other users (not me, not an alias) as generic mentions', () =>
{
const out = highlightMentions('hi @Charlie and @Dave', 'Bob');
expect(out).toBe(`hi ${ TAG('@Charlie') } and ${ TAG('@Dave') }`);
});
it('tags a cross-room user even when own username is empty', () =>
{
const out = highlightMentions('hi @Charlie', '');
expect(out).toBe(`hi ${ TAG('@Charlie') }`);
});
it('leaves non-mention text untouched', () =>
{
const text = 'just a normal sentence with no at signs';
@@ -37,42 +53,32 @@ describe('highlightMentions', () =>
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)', () =>
it('keeps trailing punctuation inside the span (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`);
expect(out).toBe(`watch out ${ SELF('@Bob!') } 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`);
expect(out).toBe(`yo ${ SELF('@bOb') } whatup`);
});
it('preserves the original spacing verbatim', () =>
{
const out = highlightMentions('a @Bob\tb', 'Bob');
expect(out).toBe(`a ${ OPEN }@Bob${ CLOSE }\tb`);
expect(out).toBe(`a ${ SELF('@Bob') }\tb`);
});
it('does not highlight inside HTML tags produced by the formatter', () =>
it('does not tag 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>`);
expect(out).toBe(`<strong>hi ${ SELF('@Bob') }</strong>`);
});
it('leaves font-colour spans and line breaks intact', () =>
@@ -80,14 +86,14 @@ describe('highlightMentions', () =>
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`);
expect(out).toBe(`<span style="color:red">hi ${ SELF('@Bob') }</span><br />bye`);
});
it('highlights multiple distinct mentions in one message', () =>
it('handles a self mention and a generic tag in one message', () =>
{
const out = highlightMentions('@Bob and @all listen', 'Bob');
const out = highlightMentions('@Bob and @Charlie listen', 'Bob');
expect(out).toBe(`${ OPEN }@Bob${ CLOSE } and ${ OPEN }@all${ CLOSE } listen`);
expect(out).toBe(`${ SELF('@Bob') } and ${ TAG('@Charlie') } listen`);
});
it('ignores a bare @ with no nick', () =>
@@ -103,18 +109,4 @@ describe('highlightMentions', () =>
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`);
});
});
@@ -1,44 +1,9 @@
export const MENTION_ROOM_ALIASES: ReadonlyArray<string> = [
'all', 'everyone', 'tutti',
'friends', 'amici',
'room', 'stanza'
];
import { classifyMentionToken, MENTION_ROOM_ALIASES } from '../../../../api/mentions/mentionTokens';
const NON_NICK_CHARS = /[^A-Za-z0-9_]/g;
const TAG_CLASS = 'mention-tag';
const SELF_CLASS = 'mention-tag mention-tag--self';
const normalizeToken = (token: string): string =>
{
if(!token || token.length < 2 || token.charAt(0) !== '@') return '';
return token.substring(1).replace(NON_NICK_CHARS, '').toLowerCase();
};
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);
};
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>';
const highlightTextChunk = (chunk: string, ownUsernameLower: string, aliases: ReadonlySet<string>): string =>
const highlightTextChunk = (chunk: string, ownUsername: string, aliases: ReadonlyArray<string>): string =>
{
if(chunk.indexOf('@') < 0) return chunk;
@@ -50,18 +15,26 @@ const highlightTextChunk = (chunk: string, ownUsernameLower: string, aliases: Re
{
if(segment.length === 0) continue;
if(/^\s+$/.test(segment) || !isMentionToken(segment, ownUsernameLower, aliases))
const kind = (/^\s+$/.test(segment)) ? '' : classifyMentionToken(segment, ownUsername, aliases);
if(!kind)
{
result += segment;
continue;
}
result += `${ HIGHLIGHT_OPEN }${ segment }${ HIGHLIGHT_CLOSE }`;
result += `<span class="${ (kind === 'self') ? SELF_CLASS : TAG_CLASS }">${ segment }</span>`;
}
return result;
};
/**
* Wrap every @mention token in chat HTML with a `.mention-tag` span. Tokens
* that target the viewer or a broadcast alias additionally get the
* `.mention-tag--self` modifier so "I was mentioned" stands out. Walks around
* formatter HTML (`<...>`) so tags inside markup are never touched.
*/
export const highlightMentions = (
formattedHtml: string,
ownUsername: string,
@@ -70,11 +43,6 @@ export const highlightMentions = (
{
if(!formattedHtml || formattedHtml.indexOf('@') < 0) return formattedHtml;
const ownUsernameLower = (ownUsername || '').replace(NON_NICK_CHARS, '').toLowerCase();
const aliasSet = new Set(aliases.map(a => a.toLowerCase()));
if(!ownUsernameLower && aliasSet.size === 0) return formattedHtml;
let result = '';
let cursor = 0;
@@ -84,13 +52,13 @@ export const highlightMentions = (
if(tagStart < 0)
{
result += highlightTextChunk(formattedHtml.slice(cursor), ownUsernameLower, aliasSet);
result += highlightTextChunk(formattedHtml.slice(cursor), ownUsername, aliases);
break;
}
if(tagStart > cursor)
{
result += highlightTextChunk(formattedHtml.slice(cursor, tagStart), ownUsernameLower, aliasSet);
result += highlightTextChunk(formattedHtml.slice(cursor, tagStart), ownUsername, aliases);
}
const tagEnd = formattedHtml.indexOf('>', tagStart);