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
@@ -640,8 +640,6 @@
'wheel.extra': 'Extra spins: %count%',
'wheel.spin': 'SPIN',
'wheel.buy': 'Buy spin',
'wheel.settings': 'Settings',
'wheel.settings.title': 'Wheel of Fortune Settings',
'wheel.winners': 'Latest winners',
'wheel.winners.empty': 'No winners yet',
'wheel.win.title': 'You won!',
@@ -716,7 +714,7 @@
'memenu.settings.other.place.multiple.objects': "Place multiple objects",
'memenu.settings.other.skip.purchase.confirmation': "Skip purchase confirmation",
'memenu.settings.other.enable.chat.window': "Enable chat window",
'memenu.settings.other.catalog.classic.style': "Catalog: classic style",
'memenu.settings.other.catalog.classic.style': "New style",
'usersettings.open.title': "User settings",
'usersettings.open.subtitle': "Password & account",
'usersettings.themes.custom': "Custom theme",
@@ -640,8 +640,6 @@
'wheel.extra': 'Giri extra: %count%',
'wheel.spin': 'GIRA',
'wheel.buy': 'Acquista giro',
'wheel.settings': 'Configurações',
'wheel.settings.title': 'Configuração de Sistema da Roleta',
'wheel.winners': 'Ultimi vincitori',
'wheel.winners.empty': 'Ancora nessun vincitore',
'wheel.win.title': 'Hai vinto!',
@@ -716,7 +714,7 @@
'memenu.settings.other.place.multiple.objects': "Posiziona più oggetti",
'memenu.settings.other.skip.purchase.confirmation': "Salta la conferma d'acquisto",
'memenu.settings.other.enable.chat.window': "Abilita finestra chat",
'memenu.settings.other.catalog.classic.style': "Catalogo: stile classico",
'memenu.settings.other.catalog.classic.style': "Nuovo stile",
'usersettings.open.title': "Impostazioni utente",
'usersettings.open.subtitle': "Password e account",
'usersettings.themes.custom': "Tema personalizzato",
@@ -716,7 +716,7 @@
'memenu.settings.other.place.multiple.objects': "Meerdere objecten plaatsen",
'memenu.settings.other.skip.purchase.confirmation': "Aankoopbevestiging overslaan",
'memenu.settings.other.enable.chat.window': "Chatvenster inschakelen",
'memenu.settings.other.catalog.classic.style': "Catalogus: klassieke stijl",
'memenu.settings.other.catalog.classic.style': "Nieuwe stijl",
'usersettings.open.title': "Gebruikersinstellingen",
'usersettings.open.subtitle': "Wachtwoord & account",
'usersettings.themes.custom': "Aangepast thema",
+3
View File
@@ -27,6 +27,9 @@
"radio_ui.enabled": false,
"mentions_ui.enabled": true,
"mentions_ui.sound": true,
"mentions_ui.aliases.everyone": [ "all", "everyone" ],
"mentions_ui.aliases.friends": [ "friends" ],
"mentions_ui.aliases.room": [ "room" ],
"guides.enabled": true,
"housekeeping.enabled": true,
"toolbar.hide.quests": true,
+2
View File
@@ -1,2 +1,4 @@
export * from './MentionType';
export * from './IMentionEntry';
export * from './mentionTokens';
export * from './mentionsFormat';
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import { classifyMentionToken, MENTION_ROOM_ALIASES, tokenIsMention } from './mentionTokens';
describe('classifyMentionToken', () =>
{
it('returns "self" for the own nick', () =>
{
expect(classifyMentionToken('@Bob', 'Bob')).toBe('self');
});
it('returns "self" for a broadcast alias', () =>
{
expect(classifyMentionToken('@all', 'Bob')).toBe('self');
for(const alias of MENTION_ROOM_ALIASES)
{
expect(classifyMentionToken(`@${ alias }`, 'Bob')).toBe('self');
}
});
it('returns "tag" for any other @user', () =>
{
expect(classifyMentionToken('@Charlie', 'Bob')).toBe('tag');
});
it('matches the own nick case-insensitively', () =>
{
expect(classifyMentionToken('@bOb', 'BOB')).toBe('self');
});
it('returns "" for non-mentions and a bare @', () =>
{
expect(classifyMentionToken('@', 'Bob')).toBe('');
expect(classifyMentionToken('nothing', 'Bob')).toBe('');
});
it('still tags others when the own username is empty', () =>
{
expect(classifyMentionToken('@Charlie', '')).toBe('tag');
});
});
describe('tokenIsMention', () =>
{
it('is true only for self/alias mentions', () =>
{
expect(tokenIsMention('@Bob', 'Bob')).toBe(true);
expect(tokenIsMention('@everyone', 'Bob')).toBe(true);
expect(tokenIsMention('@Charlie', 'Bob')).toBe(false);
});
});
+50
View File
@@ -0,0 +1,50 @@
// Shared @-mention token classification, used by both the chat-bubble
// highlighter and the mentions panel so the two can't diverge.
export const MENTION_ROOM_ALIASES: ReadonlyArray<string> = [
'all', 'everyone', 'tutti',
'friends', 'amici',
'room', 'stanza'
];
const NON_NICK_CHARS = /[^A-Za-z0-9_]/g;
const normalizeToken = (token: string): string =>
{
if(!token || (token.length < 2) || (token.charAt(0) !== '@')) return '';
return token.substring(1).replace(NON_NICK_CHARS, '').toLowerCase();
};
const normalizeNick = (value: string): string => (value || '').replace(NON_NICK_CHARS, '').toLowerCase();
// '' = not a mention; 'tag' = any @user (subtle chip); 'self' = the token
// targets the viewer (own nick) or is a broadcast alias (strong highlight).
export type MentionKind = '' | 'tag' | 'self';
export const classifyMentionToken = (
token: string,
ownUsername: string,
aliases: ReadonlyArray<string> = MENTION_ROOM_ALIASES
): MentionKind =>
{
const nick = normalizeToken(token);
if(!nick) return '';
const ownLower = normalizeNick(ownUsername);
if((ownLower && (nick === ownLower)) || aliases.some(alias => alias.toLowerCase() === nick)) return 'self';
return 'tag';
};
/**
* Back-compat boolean — true only when the token targets the viewer or a
* broadcast alias (i.e. "I was mentioned"), not for generic @user tags.
*/
export const tokenIsMention = (
token: string,
ownUsername: string,
aliases: ReadonlyArray<string> = MENTION_ROOM_ALIASES
): boolean => (classifyMentionToken(token, ownUsername, aliases) === 'self');
@@ -0,0 +1,25 @@
import { IMentionEntry } from '../mentions';
import { NotificationBubbleItem } from './NotificationBubbleItem';
import { NotificationBubbleType } from './NotificationBubbleType';
/**
* A notification bubble that carries a full mention entry, so the dedicated
* mention bubble layout can render the sender's avatar (from the figure) and
* the go-to-room action — data the plain NotificationBubbleItem can't hold.
*/
export class MentionNotificationBubbleItem extends NotificationBubbleItem
{
private _mention: IMentionEntry;
constructor(mention: IMentionEntry)
{
super(mention.message, NotificationBubbleType.MENTION, null, null, mention.senderUsername);
this._mention = mention;
}
public get mention(): IMentionEntry
{
return this._mention;
}
}
@@ -16,4 +16,5 @@ export class NotificationBubbleType
public static BUYFURNI: string = 'buyfurni';
public static VIP: string = 'vip';
public static ROOMMESSAGESPOSTED: string = 'roommessagesposted';
public static MENTION: string = 'mention';
}
+1
View File
@@ -1,5 +1,6 @@
export * from './NotificationAlertItem';
export * from './NotificationAlertType';
export * from './MentionNotificationBubbleItem';
export * from './NotificationBubbleItem';
export * from './NotificationBubbleType';
export * from './NotificationConfirmItem';
@@ -8,6 +8,14 @@ export const LayoutRoomPreviewerView: FC<{
{
const { roomPreviewer = null, height = 0 } = props;
const elementRef = useRef<HTMLDivElement>(null);
// Counter that disables further renders once Pixi throws in this
// previewer too many times in a row. The Pixi v8 null-texture bug
// (see src/pixiPatch.ts) is mostly absorbed at the prototype level,
// but any stray throw still cascades every animation frame. Allow
// a small number of consecutive failures so a transient bad frame
// self-recovers; permanently disable only if the previewer is truly
// wedged, which is what produces the "disabling further renders"
// log the user sees.
const renderFailuresRef = useRef(0);
const MAX_RENDER_FAILURES = 6;
@@ -62,6 +70,8 @@ export const LayoutRoomPreviewerView: FC<{
canvas.height = 0;
elementRef.current.style.backgroundImage = `url(${ base64 })`;
// A successful paint is the signal we've recovered from
// a transient bad frame; reset the failure counter.
renderFailuresRef.current = 0;
}
catch(error)
+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>
);
};
+57 -29
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 }>
<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 key={ key } type="button" className={ `mentions-filter ${ active ? 'is-active' : '' }` } onClick={ () => setFilter(key) }>
{ LocalizeText(label) }{ showCount ? ` ${ unreadCount }` : '' }
</button>
);
}) }
</Flex>
<Flex grow column className="min-h-0 overflow-y-auto" gap={ 0 }>
</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 -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,13 +642,6 @@ export const InfoStandWidgetFurniView: FC<InfoStandWidgetFurniViewProps> = props
})() }</Text>
</div>
</div> }
{ (!avatarInfo.isWallItem && canMove) &&
<>
<button
className="w-full text-white text-xs bg-[#2a2a3a] hover:bg-[#3a3a4a] border border-[#ffffff33] rounded px-2 py-1 cursor-pointer transition-colors"
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"
@@ -659,6 +656,13 @@ export const InfoStandWidgetFurniView: FC<InfoStandWidgetFurniViewProps> = props
} }>
Edit Furni
</button> }
{ (!avatarInfo.isWallItem && canMove) &&
<>
<button
className="w-full text-white text-xs bg-[#2a2a3a] hover:bg-[#3a3a4a] border border-[#ffffff33] rounded px-2 py-1 cursor-pointer transition-colors"
onClick={ () => setDropdownOpen(!dropdownOpen) }>
{ dropdownOpen ? `${LocalizeText('widget.furni.present.close')} Buildtools` : `${LocalizeText('navigator.roomsettings.doormode.open')} Buildtools` }
</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);
+17 -19
View File
@@ -258,30 +258,28 @@
}
.nitro-badge-leaderboard__avatar {
width: 54px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
overflow: visible;
position: relative;
width: 40px;
height: 40px;
overflow: hidden;
margin: 0 auto;
align-self: center;
}
.nitro-badge-leaderboard__avatar .avatar-image,
.nitro-badge-leaderboard__avatar-image {
display: block;
width: auto !important;
height: auto !important;
max-width: 54px;
max-height: 62px;
left: auto !important;
right: auto !important;
top: auto !important;
bottom: auto !important;
margin: 0 auto;
/* The renderer-backed LayoutAvatarImageView (headOnly) draws the avatar head as
an absolutely-positioned background div (`.avatar-image`), not an <img>. Frame
it the same way the friends list does, scaled up for the larger row. */
.nitro-badge-leaderboard__avatar .avatar-image {
position: absolute !important;
inset: 0 !important;
width: 100% !important;
height: 100% !important;
margin: 0 !important;
background-repeat: no-repeat;
background-size: 82px auto !important;
background-position: -20px -24px !important;
transform: none !important;
image-rendering: pixelated;
object-fit: contain;
}
.nitro-badge-leaderboard__username {
+80 -181
View File
@@ -151,10 +151,6 @@
display: none !important;
}
/* Publish button: lives inside the catalog window, absolutely
positioned in the header just to the left of the close X. Renders
only when adminMode is true (see CatalogClassicView.tsx). Uses the
Habbo yellow buy-button skin so it matches the Koop button. */
.nitro-catalog-classic-window .nitro-catalog-classic-header-publish {
position: absolute !important;
top: 5px !important;
@@ -179,17 +175,7 @@
50% { box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65), inset 0 -2px 0 rgba(140, 75, 0, 0.35), 0 0 8px rgba(255, 200, 0, 0.75); }
}
/* Catalog default-layout admin row (Pagina bewerken / Nieuwe
aanbieding / Aanbieding bewerken). These are inline text buttons
but the .habbo-swf-window button + .habbo-swf-window
button[class*="success"] global rules were dressing them up as
SWF skin buttons (one yellow!) and forcing min-height: 22px which
broke the layout. Reset and re-skin as compact pill chips. */
.nitro-catalog-classic-window .nitro-catalog-classic-default-admin {
/* Keep all admin buttons on one row so the product-view doesn't
get pushed down into the absolutely-positioned grid-shell. If
a future label makes them overflow, the row scrolls
horizontally instead of wrapping. */
flex-wrap: nowrap !important;
align-items: center !important;
gap: 6px !important;
@@ -239,8 +225,6 @@
box-shadow: inset 0 1px 0 rgba(0, 0, 0, 0.08), 0 0 0 rgba(0, 0, 0, 0) !important;
}
/* The "Nieuwe aanbieding" button uses text-success - give it the
habbo-yellow buy-button palette to mark it as the create action. */
.nitro-catalog-classic-window .nitro-catalog-classic-default-admin button.text-success,
.nitro-catalog-classic-window .nitro-catalog-classic-default-admin button[class*="success"] {
border-color: #8a5b00 !important;
@@ -270,10 +254,6 @@
margin-left: 4px !important;
}
/* Admin cog tab at the end of the tab strip - only renders when the
user is a mod, so leaving it visible at all times is safe. Style
it as a compact square that sits flush with the other tabs
instead of stretching to flex: 1 like a category tab. */
.nitro-catalog-classic-tabs-shell .nitro-card-tab-item.nitro-catalog-classic-admin-tab {
flex: 0 0 auto !important;
width: 32px !important;
@@ -343,9 +323,6 @@
}
.nitro-catalog-classic-tabs-shell {
/* Strip just tall enough to hold the 36px tab + 4px of breathing
room above. Trims the dead blue band between the header and
the tabs so the catalog body doesn't lose vertical space. */
flex: 0 0 40px;
height: 40px;
min-height: 40px;
@@ -353,8 +330,6 @@
gap: 0;
padding: 4px 6px 0 !important;
align-items: flex-end;
/* Horizontal scroll so every category tab stays reachable when the
card is narrower than the total tab width. */
overflow-x: auto;
overflow-y: hidden;
flex-wrap: nowrap;
@@ -365,8 +340,6 @@
border-bottom: 1px solid #c8c8bf;
}
/* The tabs strip uses a slim 6px scrollbar - opt it out of the
17px Habbo-sprite scrollbar applied to the rest of the catalog. */
.nitro-catalog-classic-tabs-shell::-webkit-scrollbar {
width: 6px !important;
height: 6px !important;
@@ -397,18 +370,11 @@
gap: 4px;
height: 36px;
min-width: 0;
/* Equal-width tabs that share the strip exactly - the right
edge of the last tab now lines up with the right edge of the
catalog window, no trailing gap. */
flex: 1 1 0;
max-width: none;
padding: 6px 6px 7px;
margin: 0 2px 0 0;
flex-shrink: 1;
/* Classic Habbo tab: gray rounded-top rectangle with a 1px black
outline. ubuntu_tab3_*.png isn't shipped, so we draw the
habbo-look ourselves instead of border-image-slicing a missing
sprite. */
border: 1px solid #000 !important;
border-bottom: 0 !important;
border-radius: 6px 6px 0 0 !important;
@@ -425,9 +391,6 @@
border-image-source: none !important;
}
/* Bring the last tab flush with whatever follows (admin cog, edge),
instead of leaving the negative right margin tugging on empty
space. */
.nitro-catalog-classic-tabs-shell .nitro-card-tab-item:last-of-type {
margin-right: 0;
}
@@ -442,9 +405,6 @@
line-height: 17px;
}
/* Category icon that sits before the label. The blanket "hide every
img/svg inside a tab" rule is gone - we explicitly size the
classic tab icon and let everything else fall through. */
.nitro-catalog-classic-tab-icon {
width: 18px;
height: 18px;
@@ -460,9 +420,6 @@
.nitro-catalog-classic-tabs-shell .nitro-card-tab-item-active {
z-index: 2;
/* Active tab: the catalog-header habbo-blue with the cream catalog
body bleeding up into it. Drop the bottom border so the tab
"merges" with the panel below. */
background:
linear-gradient(180deg, #4fb3ff 0%, var(--catalog-swf-blue) 100%) !important;
color: #ffffff !important;
@@ -490,10 +447,6 @@
.nitro-catalog-classic-stage {
display: grid;
/* Sidebar pinned at 184px; the layout column takes the rest of
the stage row so the right edge of the right column lines up
with the right edge of the catalog window instead of leaving a
wide cream strip. */
grid-template-columns: 184px 1fr;
gap: 8px;
width: 100%;
@@ -515,14 +468,9 @@
.nitro-catalog-classic-search-shell {
position: relative;
/* Use flex so the input vertically centers inside the 24px shell
regardless of the input's own intrinsic baseline. */
display: flex;
align-items: center;
height: 24px;
/* Outer padding 0, negative horizontal margin bleeds the shell a
couple of pixels past its sidebar column on each side without
touching the grid template - cheap way to look ~4px wider. */
padding: 0;
margin: -2px -1px 0 -1px;
border: 1px solid #b7b7ae;
@@ -530,10 +478,6 @@
background: #f7f7f2;
}
/* Clear the magnifying-glass on the left and the X-clear button on the
right. The shell's own outer padding is essentially zero, so the
input claims the full sidebar column width minus just enough to
keep the icons from overlapping the text. */
.nitro-catalog-classic-search-shell input {
flex: 1 1 auto;
width: 100% !important;
@@ -550,8 +494,6 @@
vertical-align: middle !important;
}
/* The wrapping div the React component renders is 100% wide so the
input fills the shell instead of shrinking to content. */
.nitro-catalog-classic-search-shell > div {
width: 100% !important;
height: 100% !important;
@@ -562,13 +504,10 @@
font-size: 11px !important;
}
/* The search icon ships with absolute + left-2; nudge it tight to the
shell edge so the input can keep its left padding small. */
.nitro-catalog-classic-search-shell svg:first-child {
left: 4px !important;
}
/* X-clear button on the right edge - keep it tight too. */
.nitro-catalog-classic-search-shell button {
right: 4px !important;
}
@@ -703,20 +642,11 @@
.nitro-catalog-classic-default-layout {
position: relative;
display: block !important;
/* Fill the layout container in both axes - the stage was
previously 552px wide and this column was pinned at 360px, but
now that the stage uses 1fr, hardcoding 360px would leave a
wide blank strip on the right of every default-layout catalog
page. */
width: 100%;
height: 100%;
min-height: 460px;
}
/* The product-view, grid-shell, price-row and purchase-row inside
the default-layout were each pinned at 360px to match the old
stage width. Widen them in lockstep so they fill the new
1fr-sized container. */
.nitro-catalog-classic-product-view,
.nitro-catalog-classic-grid-shell,
.nitro-catalog-classic-price-row,
@@ -756,12 +686,6 @@
height: 100%;
}
/* Default-3x3 layout: .offer-info is rendered but hidden via the
display: none rule below, so the panel reserved the full width
while only the 360px preview was visible (empty strip on the
right). Center the preview inside the panel instead so the gap
becomes symmetric padding on both sides. Color-grouping doesn't
render .offer-info so its panel keeps the existing layout. */
.nitro-catalog-classic-offer-panel:has(> .nitro-catalog-classic-offer-info) {
justify-content: center;
}
@@ -774,13 +698,6 @@
background: #000;
}
/* The default-3x3 layout puts the preview next to .offer-info inside
.offer-panel and needs the 360px column. Scope the pin to that
context so other layouts (color-grouping, etc.) can put the same
preview class inside a flex/grid column and let it track the
container width. Without this scoping the absolute-positioned
rotate/state buttons sit past the column's right edge and get
clipped by overflow: hidden. */
.nitro-catalog-classic-offer-panel > .nitro-catalog-classic-offer-preview {
width: 360px;
min-width: 360px;
@@ -846,8 +763,6 @@
position: absolute;
left: 0;
top: 245px;
/* Stretch down to just above the price + purchase rows so the
grid soaks up any extra height the bigger window gives us. */
bottom: 68px;
width: 360px;
min-height: 0;
@@ -855,20 +770,12 @@
overflow: auto;
}
/* When the admin row is rendered above the product-view it adds
~30px (22px button + flex gap) to the flex column, but the
grid-shell is absolutely positioned and doesn't shift on its own.
Push it (and the bottom-anchored price/purchase rows stay put)
down so the preview panel no longer bleeds into the grid. */
.nitro-catalog-classic-default-layout:has(.nitro-catalog-classic-default-admin) .nitro-catalog-classic-grid-shell {
top: 280px !important;
}
.nitro-catalog-classic-grid {
/* Don't pin a fixed column track here - AutoGrid sets the inline
grid-template-columns from its columnMinWidth prop. The earlier
`repeat(6, 53px) !important` was clobbering that and freezing
the row at 6 tiles regardless of what the React layout passed. */
grid-template-columns: repeat(6, 1fr) !important;
grid-auto-rows: var(--nitro-grid-column-min-height, 70px);
align-content: start;
justify-content: start;
@@ -943,30 +850,20 @@
}
.nitro-catalog-classic-grid-offer-icon {
position: absolute;
left: 50%;
top: 20px;
width: auto !important;
height: auto !important;
max-width: 36px;
max-height: 36px;
image-rendering: pixelated;
object-fit: contain;
transform: translate(-50%, -50%);
pointer-events: none;
}
.nitro-catalog-classic-grid-price {
position: absolute;
left: 2px;
right: 2px;
top: 36px;
bottom: auto;
display: flex;
flex-direction: column;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 0;
min-height: 24px;
justify-content: center;
gap: 3px;
width: 100%;
color: #000;
font-size: 11px;
font-weight: 700;
@@ -1015,11 +912,15 @@
}
.nitro-catalog-classic-grid-price.is-single-price {
height: 19px;
height: auto;
min-height: 0;
}
.nitro-catalog-classic-grid-price.is-multi-price {
height: 38px;
height: auto;
min-height: 0;
flex-wrap: wrap;
row-gap: 1px;
}
.nitro-catalog-classic-grid-price-entry {
@@ -1452,8 +1353,8 @@
.nitro-catalog-classic-window .nitro-catalog-classic-grid-shell::-webkit-scrollbar,
.nitro-catalog-classic-window .nitro-catalog-classic-grid::-webkit-scrollbar,
.nitro-catalog-classic-window .nitro-catalog-classic-layout-container::-webkit-scrollbar {
width: 17px;
height: 17px;
width: 18px;
height: 18px;
}
.nitro-catalog-classic-window * {
@@ -1462,116 +1363,120 @@
}
.nitro-catalog-classic-window *::-webkit-scrollbar {
width: 17px !important;
height: 17px !important;
background-color: #e7e5d8 !important;
width: 18px !important;
height: 18px !important;
background-color: #bdbbb3 !important;
}
.nitro-catalog-classic-window *::-webkit-scrollbar-track {
background-image: none !important;
background-color: #e7e5d8 !important;
box-shadow: inset 1px 0 0 #b9b6a5, inset -1px 0 0 #ffffff !important;
background-color: #bdbbb3 !important;
box-shadow: inset 1px 0 0 #000000 !important;
border: 0 !important;
}
.nitro-catalog-classic-window *::-webkit-scrollbar-thumb {
min-height: 28px !important;
border: 1px solid #2a2a26 !important;
border-radius: 2px !important;
/* Grip: a single 2px #a0a0a0 stripe in an 8px-wide centered band,
repeated every 5px (2px stripe + 3px body gap).
Outline: 1px black border, then a 2px white inset frame inside it. */
min-height: 24px !important;
background:
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='9'><line x1='1' y1='1' x2='9' y2='1' stroke='%23dcdcd2' stroke-width='1'/><line x1='1' y1='3' x2='9' y2='3' stroke='%235e5e58' stroke-width='1'/><line x1='1' y1='5' x2='9' y2='5' stroke='%23dcdcd2' stroke-width='1'/><line x1='1' y1='7' x2='9' y2='7' stroke='%235e5e58' stroke-width='1'/></svg>") center center / 10px 9px no-repeat,
linear-gradient(180deg, #d6d6cc 0%, #b4b4aa 30%, #9a9a90 50%, #b4b4aa 70%, #d6d6cc 100%) !important;
background-color: #a8a89e !important;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.55),
inset 0 -1px 0 rgba(255, 255, 255, 0.4) !important;
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='8' height='5'><rect x='0' y='0' width='8' height='2' fill='%23a0a0a0'/></svg>") center top / 8px 5px repeat-y,
#d9d9d9 !important;
border: 1px solid #000000 !important;
border-radius: 3px !important;
box-shadow: inset 0 0 0 2px #ffffff !important;
}
.nitro-catalog-classic-window *::-webkit-scrollbar-thumb:hover {
background:
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='9'><line x1='1' y1='1' x2='9' y2='1' stroke='%23ececea' stroke-width='1'/><line x1='1' y1='3' x2='9' y2='3' stroke='%235e5e58' stroke-width='1'/><line x1='1' y1='5' x2='9' y2='5' stroke='%23ececea' stroke-width='1'/><line x1='1' y1='7' x2='9' y2='7' stroke='%235e5e58' stroke-width='1'/></svg>") center center / 10px 9px no-repeat,
linear-gradient(180deg, #e0e0d6 0%, #bebeb4 30%, #a4a49a 50%, #bebeb4 70%, #e0e0d6 100%) !important;
background-color: #b2b2a8 !important;
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='8' height='5'><rect x='0' y='0' width='8' height='2' fill='%23ababab'/></svg>") center top / 8px 5px repeat-y,
#e3e3e3 !important;
}
.nitro-catalog-classic-window *::-webkit-scrollbar-thumb:active {
background:
linear-gradient(180deg, #c6c6bc 0%, #a4a49a 30%, #8a8a82 50%, #a4a49a 70%, #c6c6bc 100%) !important;
background-color: #9a9a90 !important;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.35),
inset 0 -1px 0 rgba(255, 255, 255, 0.25) !important;
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='8' height='5'><rect x='0' y='0' width='8' height='2' fill='%23606060'/></svg>") center top / 8px 5px repeat-y,
#bcbcbc !important;
}
/* Arrow buttons: light grey cap with rounded OUTER corners (up button
rounded at the top, down button rounded at the bottom), 1px black
border, dark chevron via inline SVG. */
.nitro-catalog-classic-window *::-webkit-scrollbar-button:single-button:vertical:decrement {
display: block !important;
width: 17px !important;
height: 16px !important;
width: 18px !important;
height: 18px !important;
background:
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='9' height='6'><polygon points='4.5,0 9,6 0,6' fill='%23142536'/></svg>") center center / 9px 6px no-repeat,
linear-gradient(180deg, #f3f1e6 0%, #d8d6c8 100%) !important;
background-color: #e7e5d8 !important;
border: 1px solid #0b2d3a !important;
border-radius: 2px !important;
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='9' height='6'><polygon points='4.5,0 9,6 0,6' fill='%23222'/></svg>") center center / 9px 6px no-repeat,
#d9d9d9 !important;
border: 1px solid #000000 !important;
border-radius: 3px 3px 0 0 !important;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.7),
inset 0 -1px 0 rgba(0, 0, 0, 0.18) !important;
inset 0 1px 0 #ffffff,
inset 1px 0 0 rgba(255, 255, 255, 0.6) !important;
}
.nitro-catalog-classic-window *::-webkit-scrollbar-button:single-button:vertical:decrement:active {
background:
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='9' height='6'><polygon points='4.5,0 9,6 0,6' fill='%23000'/></svg>") center center / 9px 6px no-repeat,
linear-gradient(180deg, #c7c5b8 0%, #aeaca0 100%) !important;
background-color: #c7c5b8 !important;
#bcbcbc !important;
}
.nitro-catalog-classic-window *::-webkit-scrollbar-button:single-button:vertical:increment {
display: block !important;
width: 17px !important;
height: 16px !important;
width: 18px !important;
height: 18px !important;
background:
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='9' height='6'><polygon points='0,0 9,0 4.5,6' fill='%23142536'/></svg>") center center / 9px 6px no-repeat,
linear-gradient(180deg, #f3f1e6 0%, #d8d6c8 100%) !important;
background-color: #e7e5d8 !important;
border: 1px solid #0b2d3a !important;
border-radius: 2px !important;
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='9' height='6'><polygon points='0,0 9,0 4.5,6' fill='%23222'/></svg>") center center / 9px 6px no-repeat,
#d9d9d9 !important;
border: 1px solid #000000 !important;
border-radius: 0 0 3px 3px !important;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.7),
inset 0 -1px 0 rgba(0, 0, 0, 0.18) !important;
inset 0 1px 0 #ffffff,
inset 1px 0 0 rgba(255, 255, 255, 0.6) !important;
}
.nitro-catalog-classic-window *::-webkit-scrollbar-button:single-button:vertical:increment:active {
background:
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='9' height='6'><polygon points='0,0 9,0 4.5,6' fill='%23000'/></svg>") center center / 9px 6px no-repeat,
linear-gradient(180deg, #c7c5b8 0%, #aeaca0 100%) !important;
background-color: #c7c5b8 !important;
#bcbcbc !important;
}
.nitro-catalog-classic-window *::-webkit-scrollbar-button:single-button:horizontal:decrement {
display: block !important;
width: 16px !important;
height: 17px !important;
width: 18px !important;
height: 18px !important;
background:
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='6' height='9'><polygon points='0,4.5 6,0 6,9' fill='%23142536'/></svg>") center center / 6px 9px no-repeat,
linear-gradient(180deg, #f3f1e6 0%, #d8d6c8 100%) !important;
background-color: #e7e5d8 !important;
border: 1px solid #0b2d3a !important;
border-radius: 2px !important;
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='6' height='9'><polygon points='0,4.5 6,0 6,9' fill='%23222'/></svg>") center center / 6px 9px no-repeat,
#d9d9d9 !important;
border: 1px solid #000000 !important;
border-radius: 3px 0 0 3px !important;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.7),
inset 0 -1px 0 rgba(0, 0, 0, 0.18) !important;
inset 0 1px 0 #ffffff,
inset 1px 0 0 rgba(255, 255, 255, 0.6) !important;
}
.nitro-catalog-classic-window *::-webkit-scrollbar-button:single-button:horizontal:decrement:active {
background:
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='6' height='9'><polygon points='0,4.5 6,0 6,9' fill='%23000'/></svg>") center center / 6px 9px no-repeat,
#bcbcbc !important;
}
.nitro-catalog-classic-window *::-webkit-scrollbar-button:single-button:horizontal:increment {
display: block !important;
width: 16px !important;
height: 17px !important;
width: 18px !important;
height: 18px !important;
background:
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='6' height='9'><polygon points='6,4.5 0,0 0,9' fill='%23142536'/></svg>") center center / 6px 9px no-repeat,
linear-gradient(180deg, #f3f1e6 0%, #d8d6c8 100%) !important;
background-color: #e7e5d8 !important;
border: 1px solid #0b2d3a !important;
border-radius: 2px !important;
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='6' height='9'><polygon points='6,4.5 0,0 0,9' fill='%23222'/></svg>") center center / 6px 9px no-repeat,
#d9d9d9 !important;
border: 1px solid #000000 !important;
border-radius: 0 3px 3px 0 !important;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.7),
inset 0 -1px 0 rgba(0, 0, 0, 0.18) !important;
inset 0 1px 0 #ffffff,
inset 1px 0 0 rgba(255, 255, 255, 0.6) !important;
}
.nitro-catalog-classic-window *::-webkit-scrollbar-button:single-button:horizontal:increment:active {
background:
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='6' height='9'><polygon points='6,4.5 0,0 0,9' fill='%23000'/></svg>") center center / 6px 9px no-repeat,
#bcbcbc !important;
}
.nitro-catalog-classic-breadcrumb {
@@ -1604,12 +1509,6 @@
padding: 6px !important;
}
/* Mobile: drop the per-page welcome row (image + localization
blurb shown when no offer is selected). On a narrow viewport
it eats most of the visible space and pushes the actual grid
off-screen. Hide it and also collapse the surrounding
product-view (otherwise its 240px height reservation stays
and leaves a blank strip above the grid). */
.nitro-catalog-classic-welcome {
display: none !important;
}
@@ -1,13 +1,3 @@
/* ============================================================================
Chat-bar @-mention autocomplete - Habbo style
----------------------------------------------------------------------------
Mirrors the NitroCard look (cream cardstock, habbo-blue header, black 2px
border, drop shadow) and the in-room infostand row chrome. The popover
appears above the chat input, anchored to its bottom-left and the same
width as the input, with the bottom corners flush so it visually merges
with the input edge.
============================================================================ */
.chat-input-mention-popover {
position: absolute;
bottom: 100%;
@@ -186,32 +176,6 @@
color: #4a2b00;
}
/* Habbo-style scrollbar, matching NitroCardContentView scroll chrome. */
.chat-input-mention-popover-list::-webkit-scrollbar {
width: 8px;
}
.chat-input-mention-popover-list::-webkit-scrollbar-track {
background: #d8d8cf;
border-left: 1px solid #000;
}
.chat-input-mention-popover-list::-webkit-scrollbar-thumb {
background: #30728c;
border: 1px solid #000;
border-radius: 3px;
}
.chat-input-mention-popover-list::-webkit-scrollbar-thumb:hover {
background: #3c88a6;
}
/* ============================================================================
:command popover - same Habbo NitroCard chrome as the @-mention picker.
Header is green to distinguish it visually from the mention popover,
which uses the standard habbo-blue.
============================================================================ */
.chat-input-command-popover {
position: absolute;
bottom: 100%;
@@ -345,22 +309,3 @@
.chat-input-command-row.is-selected .chat-input-command-row-desc {
color: #d9efde;
}
.chat-input-command-popover-list::-webkit-scrollbar {
width: 8px;
}
.chat-input-command-popover-list::-webkit-scrollbar-track {
background: #d8d8cf;
border-left: 1px solid #000;
}
.chat-input-command-popover-list::-webkit-scrollbar-thumb {
background: #2f8d4a;
border: 1px solid #000;
border-radius: 3px;
}
.chat-input-command-popover-list::-webkit-scrollbar-thumb:hover {
background: #3aa55b;
}
+20
View File
@@ -1811,3 +1811,23 @@
}
}
}
/* @-mention tags inside chat bubbles and the mentions panel.
`.mention-tag` styles ANY @user as a chip (colour inherits so it stays
readable on both light and dark bubble backgrounds); `.mention-tag--self`
marks a mention of the viewer / a broadcast alias with a gold highlight so
"you were mentioned" stands out. */
.mention-tag {
display: inline;
font-weight: 700;
border-radius: 4px;
padding: 0 3px;
background: rgba(30, 114, 149, 0.18);
box-shadow: inset 0 0 0 1px rgba(30, 114, 149, 0.40);
}
.mention-tag.mention-tag--self {
color: #4a3300;
background: #ffd24d;
box-shadow: inset 0 0 0 1px rgba(150, 105, 0, 0.55);
}
+127
View File
@@ -0,0 +1,127 @@
.has-classic-scrollbar {
scrollbar-color: auto !important;
scrollbar-width: auto;
}
.has-classic-scrollbar *::-webkit-scrollbar,
.has-classic-scrollbar::-webkit-scrollbar {
width: 18px !important;
height: 18px !important;
background-color: #bdbbb3 !important;
}
.has-classic-scrollbar *::-webkit-scrollbar-track,
.has-classic-scrollbar::-webkit-scrollbar-track {
background-color: #bdbbb3 !important;
box-shadow: inset 1px 0 0 #000000 !important;
border: 0 !important;
}
.has-classic-scrollbar *::-webkit-scrollbar-thumb,
.has-classic-scrollbar::-webkit-scrollbar-thumb {
min-height: 24px !important;
background:
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='8' height='5'><rect x='0' y='0' width='8' height='2' fill='%23a0a0a0'/></svg>") center top / 8px 5px repeat-y,
#d9d9d9 !important;
border: 1px solid #000000 !important;
border-radius: 3px !important;
box-shadow: inset 0 0 0 2px #ffffff !important;
}
.has-classic-scrollbar *::-webkit-scrollbar-thumb:hover,
.has-classic-scrollbar::-webkit-scrollbar-thumb:hover {
background:
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='8' height='5'><rect x='0' y='0' width='8' height='2' fill='%23ababab'/></svg>") center top / 8px 5px repeat-y,
#e3e3e3 !important;
}
.has-classic-scrollbar *::-webkit-scrollbar-thumb:active,
.has-classic-scrollbar::-webkit-scrollbar-thumb:active {
background:
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='8' height='5'><rect x='0' y='0' width='8' height='2' fill='%23606060'/></svg>") center top / 8px 5px repeat-y,
#bcbcbc !important;
}
.has-classic-scrollbar *::-webkit-scrollbar-button:single-button:vertical:decrement,
.has-classic-scrollbar::-webkit-scrollbar-button:single-button:vertical:decrement {
display: block !important;
width: 18px !important;
height: 18px !important;
background:
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='9' height='6'><polygon points='4.5,0 9,6 0,6' fill='%23222'/></svg>") center center / 9px 6px no-repeat,
#d9d9d9 !important;
border: 1px solid #000000 !important;
border-radius: 3px 3px 0 0 !important;
box-shadow:
inset 0 1px 0 #ffffff,
inset 1px 0 0 rgba(255, 255, 255, 0.6) !important;
}
.has-classic-scrollbar *::-webkit-scrollbar-button:single-button:vertical:decrement:active,
.has-classic-scrollbar::-webkit-scrollbar-button:single-button:vertical:decrement:active {
background:
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='9' height='6'><polygon points='4.5,0 9,6 0,6' fill='%23000'/></svg>") center center / 9px 6px no-repeat,
#bcbcbc !important;
}
.has-classic-scrollbar *::-webkit-scrollbar-button:single-button:vertical:increment,
.has-classic-scrollbar::-webkit-scrollbar-button:single-button:vertical:increment {
display: block !important;
width: 18px !important;
height: 18px !important;
background:
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='9' height='6'><polygon points='0,0 9,0 4.5,6' fill='%23222'/></svg>") center center / 9px 6px no-repeat,
#d9d9d9 !important;
border: 1px solid #000000 !important;
border-radius: 0 0 3px 3px !important;
box-shadow:
inset 0 1px 0 #ffffff,
inset 1px 0 0 rgba(255, 255, 255, 0.6) !important;
}
.has-classic-scrollbar *::-webkit-scrollbar-button:single-button:vertical:increment:active,
.has-classic-scrollbar::-webkit-scrollbar-button:single-button:vertical:increment:active {
background:
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='9' height='6'><polygon points='0,0 9,0 4.5,6' fill='%23000'/></svg>") center center / 9px 6px no-repeat,
#bcbcbc !important;
}
.has-classic-scrollbar *::-webkit-scrollbar-button:single-button:horizontal:decrement,
.has-classic-scrollbar::-webkit-scrollbar-button:single-button:horizontal:decrement {
display: block !important;
width: 18px !important;
height: 18px !important;
background:
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='6' height='9'><polygon points='0,4.5 6,0 6,9' fill='%23222'/></svg>") center center / 6px 9px no-repeat,
#d9d9d9 !important;
border: 1px solid #000000 !important;
border-radius: 3px 0 0 3px !important;
box-shadow:
inset 0 1px 0 #ffffff,
inset 1px 0 0 rgba(255, 255, 255, 0.6) !important;
}
.has-classic-scrollbar *::-webkit-scrollbar-button:single-button:horizontal:decrement:active,
.has-classic-scrollbar::-webkit-scrollbar-button:single-button:horizontal:decrement:active {
background:
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='6' height='9'><polygon points='0,4.5 6,0 6,9' fill='%23000'/></svg>") center center / 6px 9px no-repeat,
#bcbcbc !important;
}
.has-classic-scrollbar *::-webkit-scrollbar-button:single-button:horizontal:increment,
.has-classic-scrollbar::-webkit-scrollbar-button:single-button:horizontal:increment {
display: block !important;
width: 18px !important;
height: 18px !important;
background:
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='6' height='9'><polygon points='6,4.5 0,0 0,9' fill='%23222'/></svg>") center center / 6px 9px no-repeat,
#d9d9d9 !important;
border: 1px solid #000000 !important;
border-radius: 0 3px 3px 0 !important;
box-shadow:
inset 0 1px 0 #ffffff,
inset 1px 0 0 rgba(255, 255, 255, 0.6) !important;
}
.has-classic-scrollbar *::-webkit-scrollbar-button:single-button:horizontal:increment:active,
.has-classic-scrollbar::-webkit-scrollbar-button:single-button:horizontal:increment:active {
background:
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='6' height='9'><polygon points='6,4.5 0,0 0,9' fill='%23000'/></svg>") center center / 6px 9px no-repeat,
#bcbcbc !important;
}
+11 -8
View File
@@ -1,4 +1,3 @@
/* ── Friends spritesheet icons ── */
.nitro-friends-spritesheet {
background: url('@/assets/images/friends/friends-spritesheet.png') transparent no-repeat;
@@ -537,14 +536,18 @@
}
& .avatar-image {
position: absolute;
left: 50% !important;
top: -31px !important;
width: 90px !important;
height: 130px !important;
position: absolute !important;
inset: 0 !important;
width: 100% !important;
height: 100% !important;
margin: 0 !important;
background-position: center -8px !important;
transform: translateX(-50%) !important;
/* head-only image at native size (no scaling -> never grainy),
centred in the tab. Tweak the 2nd value of background-position
to raise (smaller %) or lower (larger %) the face. */
background-size: auto !important;
background-position: center 35% !important;
transform: none !important;
image-rendering: pixelated !important;
}
}
+7
View File
@@ -36,6 +36,13 @@
height: 36px;
}
.nitro-icon.icon-mentions {
background-image: url("@/assets/images/toolbar/icons/mentions.png");
background-size: contain;
width: 36px;
height: 32px;
}
.nitro-icon.icon-buildersclub {
background-image: url("@/assets/images/toolbar/icons/buildersclub.png");
background-size: contain;
+84 -66
View File
@@ -1,72 +1,88 @@
.mention-toasts {
position: fixed;
top: 130px;
right: 12px;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 8px;
max-width: 320px;
pointer-events: none;
}
.mention-toast {
pointer-events: auto;
display: flex;
align-items: center;
gap: 8px;
width: 300px;
padding: 8px 10px;
background: #2b2f3a;
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.12);
border-left: 3px solid #1e7295;
border-radius: 8px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.42);
cursor: pointer;
animation: mention-toast-in 0.22s ease;
}
@keyframes mention-toast-in {
from { opacity: 0; transform: translateX(22px); }
to { opacity: 1; transform: translateX(0); }
}
/* Mention notification bubble rendered through the client's standard
notification stream (LayoutNotificationBubbleView, NotificationMentionBubbleView),
so it inherits the default chrome/position. Only the mention-specific bits
live here: the avatar head crop, type chip, time, room, message clamp and
action buttons. */
.mention-toast-avatar {
position: relative;
width: 44px;
height: 44px;
width: 32px;
height: 36px;
flex-shrink: 0;
overflow: hidden;
border-radius: 6px;
background: rgba(0, 0, 0, 0.25);
}
/* ricetta testa headOnly: l'avatar-image riempie il box (inset-0) e si croppa
sulla testa via background-position (come l'avatar-testa della toolbar),
invece di scalare il corpo. */
/* Exact friends-list head crop a matched set: 32x36 box + 66px width-scale +
-16/-21 offset (deterministic, starts above the hairline) so the full head
shows and isn't clipped. */
.mention-toast-avatar .avatar-image {
position: absolute !important;
inset: 0 !important;
width: 100% !important;
height: 100% !important;
background-size: auto !important;
background-position: -23px -32px !important;
background-size: 66px auto !important;
background-position: -16px -21px !important;
}
.mention-toast-body {
flex: 1 1 auto;
min-width: 0;
}
.mention-toast-title {
.mention-toast-chip {
flex: none;
padding: 1px 6px;
border-radius: 8px;
font-size: 9px;
font-weight: 700;
font-size: 12px;
line-height: 1.2;
color: #6cb6e0;
line-height: 1.4;
text-transform: uppercase;
letter-spacing: 0.4px;
color: #fff;
}
.mention-toast-chip.is-direct { background: #1e7295; }
.mention-toast-chip.is-room { background: #d08a1e; }
.mention-toast-spacer {
flex: 1 1 auto;
}
.mention-toast-time {
flex: none;
font-size: 11px;
color: #aeb3bd;
font-variant-numeric: tabular-nums;
}
.mention-toast-dismiss {
flex: none;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.10);
color: #c9ccd3;
font-size: 9px;
line-height: 1;
cursor: pointer;
}
.mention-toast-dismiss:hover {
background: rgba(255, 255, 255, 0.20);
color: #fff;
}
.mention-toast-room {
margin-top: 1px;
font-size: 11px;
color: #aeb3bd;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mention-toast-message {
margin-top: 3px;
font-size: 12px;
line-height: 1.3;
color: #e6e8ec;
@@ -78,24 +94,26 @@
word-break: break-word;
}
.mention-toast-dismiss {
flex-shrink: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
color: #c9ccd3;
cursor: pointer;
font-size: 10px;
line-height: 1;
.mention-toast-actions {
margin-top: 6px;
}
.mention-toast-dismiss:hover {
background: rgba(255, 255, 255, 0.18);
.mention-toast-btn {
padding: 3px 10px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 7px;
background: rgba(255, 255, 255, 0.08);
color: #eef0f4;
font-size: 11px;
font-weight: 700;
line-height: 1.2;
cursor: pointer;
transition: background 0.12s ease, border-color 0.12s ease;
}
.mention-toast-btn:hover {
background: #1e7295;
border-color: #2a90bd;
color: #fff;
}
+323
View File
@@ -0,0 +1,323 @@
/* ============================================================================
Mentions panel search bar, list rows, avatar framing.
The avatar tile mirrors the @-autocomplete tile (bordered cream chrome +
`background-size: auto` / `-22px -32px` head crop) so the head is framed the
same proven way instead of a finicky bare crop.
============================================================================ */
.mentions-search {
display: flex;
align-items: center;
gap: 6px;
height: 28px;
padding: 0 8px;
border: 1px solid #b9b6aa;
border-radius: 8px;
background: #fbfbf6;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.08);
}
.mentions-search-icon {
flex: none;
color: #8a8a82;
font-size: 12px;
}
.mentions-search input {
flex: 1 1 auto;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
font-size: 13px;
color: #2c2c2c;
}
.mention-row {
position: relative;
display: flex;
align-items: center;
gap: 8px;
padding: 5px 6px 5px 11px;
border-radius: 8px;
border: 1px solid transparent;
cursor: pointer;
transition: background 0.08s linear, border-color 0.08s linear;
}
.mention-row-unread-dot {
position: absolute;
left: 2px;
top: 50%;
transform: translateY(-50%);
width: 5px;
height: 5px;
border-radius: 50%;
background: #1e7295;
}
.mention-row:hover {
background: rgba(48, 114, 140, 0.10);
border-color: rgba(48, 114, 140, 0.25);
}
.mention-row.is-unread {
background: rgba(255, 210, 77, 0.16);
}
.mention-row.is-unread:hover {
background: rgba(255, 210, 77, 0.24);
}
.mention-row-avatar {
position: relative;
width: 32px;
height: 36px;
flex-shrink: 0;
border-radius: 8px;
border: 1px solid rgba(0, 0, 0, 0.28);
background: #e3e0d6;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65), inset 0 -1px 0 rgba(0, 0, 0, 0.12);
overflow: hidden;
}
/* Exact friends-list head crop a matched set: 32x36 box + 66px width-scale +
-16/-21 offset. The explicit 66px width is deterministic (not native-size
dependent) and the -21 vertical offset starts above the hairline, so the full
head shows and isn't clipped. */
.mention-row-avatar .avatar-image {
position: absolute !important;
inset: 0 !important;
width: 100% !important;
height: 100% !important;
margin: 0 !important;
background-repeat: no-repeat;
background-size: 66px auto !important;
background-position: -16px -21px !important;
transform: none !important;
}
.mention-row-type {
position: absolute;
right: -3px;
bottom: -3px;
min-width: 16px;
height: 16px;
padding: 0 2px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
border: 2px solid #f2f2eb;
font-size: 9px;
font-weight: 700;
line-height: 1;
color: #fff;
}
.mention-row-type.is-direct { background: #1e7295; }
.mention-row-type.is-room { background: #d08a1e; }
.mention-row-body {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.mention-row-head {
display: flex;
align-items: baseline;
gap: 5px;
min-width: 0;
}
.mention-row-name {
font-weight: 700;
font-size: 13px;
color: #232323;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 0 1 auto;
}
.mention-row-room {
font-size: 11px;
color: #7a7a72;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1 1 auto;
min-width: 0;
}
.mention-row-msg {
display: block;
font-size: 12px;
color: #4a4a45;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mention-row-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: center;
gap: 4px;
flex-shrink: 0;
min-width: 38px;
}
.mention-row-time {
font-size: 11px;
color: #8a8a82;
font-variant-numeric: tabular-nums;
}
.mention-row-actions {
display: none;
gap: 4px;
}
.mention-row:hover .mention-row-time { display: none; }
.mention-row:hover .mention-row-actions { display: flex; }
.mention-row-action {
width: 22px;
height: 22px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
border: 1px solid rgba(0, 0, 0, 0.12);
background: rgba(0, 0, 0, 0.05);
font-size: 10px;
line-height: 1;
color: #4a4a45;
cursor: pointer;
transition: background 0.1s, color 0.1s, border-color 0.1s;
}
.mention-row-action:hover { background: #1e7295; color: #fff; border-color: #185b76; }
.mention-row-action.is-remove:hover { background: #d64545; color: #fff; border-color: #b53636; }
.mentions-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 28px 12px;
text-align: center;
color: #7a7a72;
}
.mentions-empty-glyph {
width: 46px;
height: 46px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(30, 114, 149, 0.10);
color: #1e7295;
font-size: 22px;
font-weight: 700;
}
.mentions-empty-text {
font-size: 13px;
color: #6f6f67;
}
/* --- toolbar: filter chips + refresh --- */
.mentions-toolbar {
display: flex;
align-items: center;
gap: 6px;
}
.mentions-filters {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.mentions-filter {
padding: 2px 9px;
border-radius: 9999px;
border: 1px solid #b9b6aa;
background: #f3f1ea;
color: #5a5a52;
font-size: 11px;
font-weight: 700;
line-height: 1.5;
cursor: pointer;
transition: background 0.1s, color 0.1s, border-color 0.1s;
}
.mentions-filter:hover {
background: #e9e6dc;
}
.mentions-filter.is-active {
background: #1e7295;
border-color: #185b76;
color: #fff;
}
.mentions-refresh {
flex: none;
width: 26px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 7px;
border: 1px solid #b9b6aa;
background: #f3f1ea;
color: #6a6a62;
font-size: 11px;
cursor: pointer;
transition: background 0.1s, color 0.1s;
}
.mentions-refresh:hover {
background: #e9e6dc;
color: #1e7295;
}
.mentions-refresh:active {
transform: translateY(1px);
}
/* --- list + date groups --- */
.mentions-list {
display: flex;
flex-direction: column;
gap: 1px;
min-height: 96px;
max-height: 340px;
overflow-y: auto;
}
.mentions-group {
display: flex;
flex-direction: column;
gap: 1px;
}
.mentions-group-label {
padding: 8px 4px 2px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.6px;
text-transform: uppercase;
color: #9a978c;
}
-242
View File
@@ -1,242 +0,0 @@
.habbo-navigator-desktop {
border: 1px solid #000;
border-radius: 7px;
background: #e9e9e1;
box-shadow: 0 4px 4px rgba(0, 0, 0, 0.35);
color: #111;
font-family: Ubuntu, Arial, sans-serif;
}
.habbo-navigator-desktop .nitro-card-header-shell {
min-height: 32px;
max-height: 32px;
background: #418db0;
border-bottom: 1px solid #000;
border-radius: 6px 6px 0 0;
}
.habbo-navigator-desktop .nitro-card-title {
color: #fff;
font-family: UbuntuCondensed, Ubuntu, Arial, sans-serif;
font-size: 18px;
font-weight: 700;
line-height: 1;
text-shadow: 1px 1px 0 #000;
}
.habbo-navigator-desktop .nitro-card-close-button {
width: 19px;
height: 20px;
right: 6px;
border: 2px solid #000;
border-radius: 4px;
background: #c73a32;
box-shadow: inset 1px 1px 0 rgba(255, 255, 255, 0.35);
}
.habbo-navigator-desktop .nitro-card-close-button::before,
.habbo-navigator-desktop .nitro-card-close-button::after {
content: "";
position: absolute;
width: 10px;
height: 2px;
background: #fff;
box-shadow: 1px 1px 0 #64120f;
}
.habbo-navigator-desktop .nitro-card-close-button::before {
transform: rotate(45deg);
}
.habbo-navigator-desktop .nitro-card-close-button::after {
transform: rotate(-45deg);
}
.habbo-navigator-desktop .nitro-card-tabs-shell {
justify-content: flex-start;
gap: 0;
min-height: 27px;
max-height: 27px;
padding: 4px 8px 0;
background: #e9e9e1;
border-bottom: 1px solid #b8b8ad;
}
.habbo-navigator-desktop .nitro-card-tab-item {
min-height: 23px;
margin-right: -1px;
padding: 4px 12px 3px;
border: 1px solid #555;
border-bottom: 0;
border-radius: 6px 6px 0 0;
background-color: #d5d8cf;
background-image: url("../../assets/images/navigator/swf/tab_bg_unsel.png");
background-repeat: repeat-x;
background-size: auto 100%;
color: #111;
font-size: 12px;
font-weight: 400;
line-height: 1;
box-shadow: inset 1px 1px 0 #fff;
}
.habbo-navigator-desktop .nitro-card-tab-item:hover {
background-color: #e7e8df;
background-image: url("../../assets/images/navigator/swf/tab_bg_hilite.png");
}
.habbo-navigator-desktop .nitro-card-tab-item-active {
z-index: 2;
margin-bottom: -1px;
background-color: #f4f4ed;
background-image: url("../../assets/images/navigator/swf/tab_bg_sel.png");
border-bottom: 1px solid #f4f4ed;
font-weight: 700;
}
.habbo-navigator-desktop .habbo-navigator-desktop-content {
padding: 8px 9px 9px;
overflow: hidden;
background: #f4f4ed;
border-radius: 0 0 6px 6px;
color: #111;
}
.habbo-navigator-desktop .habbo-navigator-desktop-content input,
.habbo-navigator-desktop .habbo-navigator-desktop-content select,
.habbo-navigator-desktop .habbo-navigator-desktop-content textarea {
height: 22px;
border: 1px solid #a0a49c;
border-radius: 3px;
background-color: #fff;
background-image: url("../../assets/images/navigator/swf/hdr_search.png");
background-repeat: repeat-x;
color: #333;
font-size: 12px;
box-shadow: inset 1px 1px 0 rgba(0, 0, 0, 0.08);
}
.habbo-navigator-desktop .habbo-navigator-desktop-content button,
.habbo-navigator-desktop .habbo-navigator-desktop-content .btn {
min-height: 22px;
border: 1px solid #3a3a3a;
border-radius: 4px;
background-color: #d6d6d1;
background-image: url("../../assets/images/navigator/swf/button.png");
background-repeat: repeat-x;
background-size: auto 100%;
color: #111;
font-size: 12px;
font-weight: 700;
box-shadow: inset 1px 1px 0 #fff;
}
.habbo-navigator-desktop .nitro-card-panel {
border: 1px solid #babdb4;
border-radius: 6px;
background: #efefe8;
box-shadow: inset 1px 1px 0 #fff;
overflow: hidden;
}
.habbo-navigator-desktop .nitro-card-panel > .flex:first-child {
min-height: 28px;
padding: 5px 8px;
border-bottom: 1px solid #d3d5cd;
background: #efefe8;
}
.habbo-navigator-desktop .navigator-grid {
padding: 0 4px 5px;
background: #fff;
}
.habbo-navigator-desktop .navigator-item {
min-height: 28px;
margin: 2px 0;
border: 1px solid transparent;
border-radius: 5px;
background: #f5f5ef;
color: #111;
}
.habbo-navigator-desktop .navigator-item:nth-child(even) {
background: #e7e8e0;
}
.habbo-navigator-desktop .navigator-item:hover {
border-color: #777;
background: #fff;
}
.habbo-navigator-desktop .nitro-navigator-search-saves-result {
width: 155px;
min-width: 155px;
height: 100%;
border: 1px solid #babdb4;
border-radius: 6px;
background: #efefe8;
padding: 4px;
}
.habbo-navigator-desktop .nitro-navigator-search-saves-result > .flex:first-child {
min-height: 24px;
border: 1px solid #d58e00;
border-radius: 4px;
background: #f8a900;
box-shadow: inset 1px 1px 0 rgba(255, 255, 255, 0.45);
}
.habbo-navigator-desktop .nitro-navigator-search-saves-result span {
color: #111;
font-weight: 700;
}
.habbo-navigator-desktop .nitro-icon.icon-navigator-info {
width: 16px;
height: 16px;
}
.habbo-navigator-desktop ::-webkit-scrollbar {
width: 17px !important;
height: 17px !important;
}
.habbo-navigator-desktop ::-webkit-scrollbar-track {
border: 0 !important;
background-color: transparent !important;
background-image: url("../../assets/images/catalog/swf/ubuntu_scrollbar_track_v_17x2.png") !important;
background-repeat: repeat-y !important;
background-position: center top !important;
}
.habbo-navigator-desktop ::-webkit-scrollbar-thumb {
min-height: 24px !important;
border: 0 !important;
border-radius: 0 !important;
background-color: transparent !important;
background-image:
url("../../assets/images/catalog/swf/ubuntu_scrollbar_thumb_v_grip_7x10.png"),
url("../../assets/images/catalog/swf/ubuntu_scrollbar_thumb_v_top_17x2.png"),
url("../../assets/images/catalog/swf/ubuntu_scrollbar_thumb_v_bottom_17x2.png"),
url("../../assets/images/catalog/swf/ubuntu_scrollbar_thumb_v_mid_17x1.png") !important;
background-repeat: repeat-y, no-repeat, no-repeat, repeat-y !important;
background-position: center center, center top, center bottom, center top !important;
background-size: 7px 10px, 17px 2px, 17px 2px, 17px 1px !important;
box-shadow: none !important;
}
.habbo-navigator-desktop ::-webkit-scrollbar-button {
width: 17px !important;
height: 16px !important;
background-color: transparent !important;
border: 0 !important;
}
.habbo-navigator-desktop ::-webkit-scrollbar-button:single-button:vertical:decrement {
background-image: url("../../assets/images/catalog/swf/ubuntu_scrollbar_up_17x16.png") !important;
}
.habbo-navigator-desktop ::-webkit-scrollbar-button:single-button:vertical:increment {
background-image: url("../../assets/images/catalog/swf/ubuntu_scrollbar_down_17x16.png") !important;
}
+1 -1
View File
@@ -1,3 +1,3 @@
export * from './useMentionsSnapshot';
export * from './useMentionMessages';
export * from './useMentionAutocomplete';
export * from './useMentionActions';
-58
View File
@@ -1,58 +0,0 @@
import { IMentionEntry } from '../../api';
// Toast laterali per le menzioni appena ricevute (avatar + messaggio + dismiss).
// Separato da mentionsStore: i toast sono effimeri, le menzioni persistono nel pannello.
export interface MentionToast
{
mentionId: number;
senderId: number;
senderUsername: string;
senderFigure: string;
message: string;
roomName: string;
}
const MAX_TOASTS = 4;
let toasts: MentionToast[] = [];
const listeners = new Set<() => void>();
const emit = (): void =>
{
for(const listener of listeners) listener();
};
export const subscribeMentionToasts = (callback: () => void): (() => void) =>
{
listeners.add(callback);
return () => { listeners.delete(callback); };
};
export const getMentionToasts = (): ReadonlyArray<MentionToast> => toasts;
export const pushMentionToast = (entry: IMentionEntry): void =>
{
toasts = [
{
mentionId: entry.mentionId,
senderId: entry.senderId,
senderUsername: entry.senderUsername,
senderFigure: entry.senderFigure,
message: entry.message,
roomName: entry.roomName
},
...toasts.filter(toast => toast.mentionId !== entry.mentionId)
].slice(0, MAX_TOASTS);
emit();
};
export const dismissMentionToast = (mentionId: number): void =>
{
const next = toasts.filter(toast => toast.mentionId !== mentionId);
if(next.length === toasts.length) return;
toasts = next;
emit();
};
@@ -1,7 +1,7 @@
import { CreateLinkEvent, DeleteMentionComposer, MarkMentionsReadComposer } from '@nitrots/nitro-renderer';
import { useMemo } from 'react';
import { IMentionEntry, SendMessageComposer } from '../../api';
import { markRead, removeMention } from '../../hooks/mentions/mentionsStore';
import { markRead, removeMention } from './mentionsStore';
export interface MentionActions
{
@@ -1,89 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { MENTION_ROOM_ALIASES } from '../../components/room/widgets/chat/highlightMentions';
import { useFriendsState } from '../friends/useFriends';
import { useRoomUserListSnapshot } from '../session/useSessionSnapshots';
export interface MentionSuggestion
{
name: string;
figure: string;
isAlias: boolean;
}
const MAX_SUGGESTIONS = 8;
// Trova il token @<parziale> che si sta digitando alla FINE del valore.
// Restituisce il parziale (anche '' subito dopo @) oppure null se non si è in un @mention.
const activeMentionPartial = (value: string): string | null =>
{
if(!value || value.indexOf('@') < 0) return null;
const match = /(?:^|\s)@([A-Za-z0-9_]*)$/.exec(value);
return match ? match[1] : null;
};
export interface MentionAutocompleteState
{
isVisible: boolean;
suggestions: MentionSuggestion[];
selectedIndex: number;
setSelectedIndex: (index: number) => void;
moveUp: () => void;
moveDown: () => void;
current: () => MentionSuggestion | null;
// Inserisce il nome scelto sostituendo il parziale @... alla fine del valore.
applyTo: (value: string, name: string) => string;
}
export const useMentionAutocomplete = (chatValue: string): MentionAutocompleteState =>
{
const roomUsers = useRoomUserListSnapshot();
const { onlineFriends } = useFriendsState();
const [ selectedIndex, setSelectedIndex ] = useState(0);
const partial = useMemo(() => activeMentionPartial(chatValue), [ chatValue ]);
const suggestions = useMemo<MentionSuggestion[]>(() =>
{
if(partial === null) return [];
const query = partial.toLowerCase();
const seen = new Set<string>();
const out: MentionSuggestion[] = [];
const add = (name: string, figure: string, isAlias: boolean) =>
{
if(!name || out.length >= MAX_SUGGESTIONS) return;
const key = name.toLowerCase();
if(seen.has(key)) return;
if(query && !key.startsWith(query)) return;
seen.add(key);
out.push({ name, figure: figure || '', isAlias });
};
for(const user of (roomUsers || [])) add(user?.name, (user as any)?.figure, false);
for(const friend of (onlineFriends || [])) add(friend?.name, friend?.figure, false);
for(const alias of MENTION_ROOM_ALIASES) add(alias, '', true);
return out;
}, [ partial, roomUsers, onlineFriends ]);
useEffect(() => { setSelectedIndex(0); }, [ partial ]);
const isVisible = (partial !== null) && (suggestions.length > 0);
return {
isVisible,
suggestions,
selectedIndex,
setSelectedIndex,
moveUp: () => setSelectedIndex(index => (index <= 0 ? suggestions.length - 1 : index - 1)),
moveDown: () => setSelectedIndex(index => (index >= suggestions.length - 1 ? 0 : index + 1)),
current: () => suggestions[selectedIndex] ?? null,
applyTo: (value: string, name: string) => value.replace(/@([A-Za-z0-9_]*)$/, '@' + name + ' ')
};
};
+7 -4
View File
@@ -2,14 +2,16 @@ import { MentionReceivedEvent, MentionsListEvent, RequestMentionsComposer } from
import { useCallback, useEffect } from 'react';
import { GetConfigurationValue, IMentionEntry, PlaySound, SendMessageComposer } from '../../api';
import { useMessageEvent } from '../events';
import { useNotification } from '../notification/useNotification';
import { addMention, setMentions } from './mentionsStore';
import { pushMentionToast } from './mentionToastsStore';
// Dedicated mention chime served from nitro-assets/sounds/<sample>.mp3.
const MENTION_SOUND_SAMPLE = 'mentions_notification';
export const useMentionMessages = (): void =>
{
const { showMentionBubble } = useNotification();
const onMentionsList = useCallback((event: MentionsListEvent) =>
{
const list = event.getParser().mentions;
@@ -51,9 +53,10 @@ export const useMentionMessages = (): void =>
if(GetConfigurationValue<boolean>('mentions_ui.sound', true)) PlaySound(MENTION_SOUND_SAMPLE);
// Notifica laterale custom (avatar + messaggio + dismiss) invece del bubble generico.
pushMentionToast(entry);
}, []);
// Surface it through the client's standard notification stream, using the
// dedicated mention bubble layout (avatar + actions).
showMentionBubble(entry);
}, [ showMentionBubble ]);
useMessageEvent<MentionsListEvent>(MentionsListEvent, onMentionsList);
useMessageEvent<MentionReceivedEvent>(MentionReceivedEvent, onMentionReceived);
+14 -2
View File
@@ -1,7 +1,7 @@
import { AchievementNotificationMessageEvent, ActivityPointNotificationMessageEvent, BadgeReceivedEvent, ClubGiftNotificationEvent, ClubGiftSelectedEvent, ConnectionErrorEvent, GetLocalizationManager, GetRoomEngine, GetSessionDataManager, HabboBroadcastMessageEvent, HotelClosedAndOpensEvent, HotelClosesAndWillOpenAtEvent, HotelWillCloseInMinutesEvent, InfoFeedEnableMessageEvent, MaintenanceStatusMessageEvent, ModeratorCautionEvent, ModeratorMessageEvent, MOTDNotificationEvent, NotificationDialogMessageEvent, PetLevelNotificationEvent, PetReceivedMessageEvent, RespectReceivedEvent, RoomEnterEffect, RoomEnterEvent, SimpleAlertMessageEvent, UserBannedMessageEvent, Vector3d, WiredRewardResultMessageEvent } from '@nitrots/nitro-renderer';
import { useCallback, useState } from 'react';
import { useBetween } from 'use-between';
import { GetConfigurationValue, LocalizeBadgeName, LocalizeText, NotificationAlertItem, NotificationAlertType, NotificationBubbleItem, NotificationBubbleType, NotificationConfirmItem, PlaySound, ProductImageUtility, TradingNotificationType } from '../../api';
import { GetConfigurationValue, IMentionEntry, LocalizeBadgeName, LocalizeText, MentionNotificationBubbleItem, NotificationAlertItem, NotificationAlertType, NotificationBubbleItem, NotificationBubbleType, NotificationConfirmItem, PlaySound, ProductImageUtility, TradingNotificationType } from '../../api';
import { useMessageEvent } from '../events';
const cleanText = (text: string) => (text && text.length) ? text.replace(/\\r/g, '\r') : '';
@@ -86,6 +86,16 @@ const useNotificationStore = () =>
});
}, [ bubblesDisabled ]);
const showMentionBubble = useCallback((mention: IMentionEntry) =>
{
// Mentions always surface: they have their own `mentions_ui.enabled` gate
// (checked in useMentionMessages) and are intentionally independent of the
// generic info-feed toggle, so EVERY received mention shows a bubble.
const item = new MentionNotificationBubbleItem(mention);
setBubbleAlerts(prevValue => [ item, ...prevValue ]);
}, []);
const showNotification = (type: string, options: Map<string, string> = null) =>
{
if(!options) options = new Map();
@@ -490,7 +500,7 @@ const useNotificationStore = () =>
useMessageEvent<RoomEnterEvent>(RoomEnterEvent, onRoomEnterEvent);
return { alerts, bubbleAlerts, confirms, simpleAlert, showNitroAlert, showTradeAlert, showConfirm, showSingleBubble, closeAlert, closeBubbleAlert, closeConfirm };
return { alerts, bubbleAlerts, confirms, simpleAlert, showNitroAlert, showTradeAlert, showConfirm, showSingleBubble, showMentionBubble, closeAlert, closeBubbleAlert, closeConfirm };
};
export const useNotificationState = () =>
@@ -508,6 +518,7 @@ export const useNotificationActions = () =>
showTradeAlert,
showConfirm,
showSingleBubble,
showMentionBubble,
closeAlert,
closeBubbleAlert,
closeConfirm
@@ -519,6 +530,7 @@ export const useNotificationActions = () =>
showTradeAlert,
showConfirm,
showSingleBubble,
showMentionBubble,
closeAlert,
closeBubbleAlert,
closeConfirm
+1
View File
@@ -2,6 +2,7 @@ export * from './furniture';
export * from './useAvatarInfoWidget';
export * from './useChatCommandSelector';
export * from './useChatInputWidget';
export * from './useChatMentions';
export * from './useChatWidget';
export * from './useDoorbellActions';
export * from './useDoorbellState';
@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest';
import { buildChatMentionSuggestions, computeMentionContext, MentionAlias } from './useChatMentions.helpers';
const ALIASES: MentionAlias[] = [
{ key: 'all', scope: 'everyone', description: '' },
{ key: 'room', scope: 'room', description: '' }
];
const ROOM = [
{ webID: 1, type: 1, name: 'tester', figure: 'a' },
{ webID: 2, type: 1, name: 'alice', figure: 'b' },
{ webID: 3, type: 1, name: 'bob', figure: 'c' },
{ webID: 9, type: 2, name: 'petbot', figure: 'd' } // non-real user (pet/bot)
];
describe('computeMentionContext', () =>
{
it('detects a trailing @query', () =>
{
expect(computeMentionContext('hi @al', false)).toEqual({ atIndex: 3, replaceFrom: 3, replaceTo: 6, query: 'al' });
});
it('detects @ at the very start', () =>
{
expect(computeMentionContext('@al', false)).toEqual({ atIndex: 0, replaceFrom: 0, replaceTo: 3, query: 'al' });
});
it('returns null when a command popover is open', () =>
{
expect(computeMentionContext('hi @al', true)).toBeNull();
});
it('returns null when @ is glued to a previous word (e.g. an email)', () =>
{
expect(computeMentionContext('mail me@al', false)).toBeNull();
});
it('returns null when the @token is not at the end', () =>
{
expect(computeMentionContext('@al ready', false)).toBeNull();
});
it('returns null when there is no @', () =>
{
expect(computeMentionContext('plain text', false)).toBeNull();
});
});
describe('buildChatMentionSuggestions', () =>
{
it('excludes the viewer by id and keeps the other real users + aliases', () =>
{
const names = buildChatMentionSuggestions('', ROOM, ALIASES, 1, 'tester').map(s => s.name);
expect(names).not.toContain('tester'); // own user (webID 1)
expect(names).toContain('alice');
expect(names).toContain('bob');
expect(names).toContain('all');
expect(names).toContain('room');
});
it('excludes the viewer by name when the id does not line up', () =>
{
const names = buildChatMentionSuggestions('', ROOM, ALIASES, -1, 'TESTER').map(s => s.name);
expect(names).not.toContain('tester');
});
it('skips non-real users (pets/bots)', () =>
{
const names = buildChatMentionSuggestions('', ROOM, [], 1, 'tester').map(s => s.name);
expect(names).not.toContain('petbot');
});
it('prefix-filters users and aliases by the query', () =>
{
const names = buildChatMentionSuggestions('al', ROOM, ALIASES, 1, 'tester').map(s => s.name);
expect(names).toContain('alice');
expect(names).toContain('all');
expect(names).not.toContain('bob');
expect(names).not.toContain('room');
});
it('tags user vs alias kinds', () =>
{
const out = buildChatMentionSuggestions('', ROOM, ALIASES, 1, 'tester');
expect(out.find(s => s.name === 'alice')?.kind).toBe('user');
expect(out.find(s => s.name === 'all')?.kind).toBe('alias');
});
it('caps the total at max', () =>
{
const many = Array.from({ length: 20 }, (_, i) => ({ webID: 100 + i, type: 1, name: `u${ i }`, figure: '' }));
const out = buildChatMentionSuggestions('', many, ALIASES, -1, 'me', 5);
expect(out).toHaveLength(5);
});
});
@@ -0,0 +1,143 @@
// Pure helpers for the chat-input @-mention autocomplete. Kept framework-free
// so the suggestion building and the caret-context detection are unit-testable.
export type MentionSuggestionKind = 'user' | 'alias';
export interface MentionSuggestion
{
key: string;
kind: MentionSuggestionKind;
name: string;
insertToken: string;
figure?: string;
description?: string;
}
export type MentionAliasScope = 'everyone' | 'friends' | 'room';
export interface MentionAlias
{
key: string;
scope: MentionAliasScope;
description: string;
}
export interface MentionContext
{
atIndex: number;
replaceFrom: number;
replaceTo: number;
query: string;
}
export const MAX_MENTION_SUGGESTIONS = 8;
const USER_TYPE_REAL_USER = 1;
export const MENTION_ALIAS_CONFIG_KEY: Record<MentionAliasScope, string> = {
everyone: 'mentions_ui.aliases.everyone',
friends: 'mentions_ui.aliases.friends',
room: 'mentions_ui.aliases.room'
};
export const MENTION_ALIAS_DEFAULTS: Record<MentionAliasScope, string[]> = {
everyone: [ 'all', 'everyone', 'tutti' ],
friends: [ 'friends', 'amici' ],
room: [ 'room', 'stanza' ]
};
export const MENTION_ALIAS_DESCRIPTION_KEY: Record<MentionAliasScope, string> = {
everyone: 'mentions.alias.description.everyone',
friends: 'mentions.alias.description.friends',
room: 'mentions.alias.description.room'
};
export 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;
};
/**
* Detect an in-progress `@partial` token at the END of the input. Returns the
* token bounds + the query (text after `@`), or null when not in a mention (no
* trailing `@token`, `@` glued to a previous word, or a command popover open).
* End-anchored (no caret read) so it stays a pure render-safe computation.
*/
export const computeMentionContext = (value: string, commandSelectorVisible: boolean): MentionContext | null =>
{
if(!value) return null;
if(commandSelectorVisible) return null;
const match = /(?:^|\s)@([A-Za-z0-9_]*)$/.exec(value);
if(!match) return null;
const query = match[1];
const atIndex = value.length - query.length - 1;
return { atIndex, replaceFrom: atIndex, replaceTo: value.length, query };
};
interface MentionRoomUser
{
webID?: number;
type?: number;
name?: string;
figure?: string;
}
/**
* Build the suggestion list for the current query: real room users (minus the
* viewer themselves match by id, name as fallback) then the broadcast
* aliases, prefix-filtered and capped.
*/
export const buildChatMentionSuggestions = (
query: string,
roomUserList: ReadonlyArray<MentionRoomUser>,
aliases: ReadonlyArray<MentionAlias>,
ownUserId: number,
ownUsername: string,
max: number = MAX_MENTION_SUGGESTIONS
): MentionSuggestion[] =>
{
const q = (query || '').toLowerCase();
const ownNameLower = (ownUsername || '').toLowerCase();
const out: MentionSuggestion[] = [];
for(const user of (roomUserList || []))
{
if(out.length >= max) break;
if(!user || (user.type !== USER_TYPE_REAL_USER)) continue;
if(!user.name) continue;
// You can't mention yourself — skip the own user (match by id, name as fallback).
if((user.webID === ownUserId) || (ownNameLower && (user.name.toLowerCase() === ownNameLower))) continue;
if((q.length > 0) && !user.name.toLowerCase().startsWith(q)) continue;
out.push({ key: `user:${ user.webID }`, kind: 'user', name: user.name, insertToken: user.name, figure: user.figure || '' });
}
for(const alias of aliases)
{
if(out.length >= max) break;
if((q.length > 0) && !alias.key.toLowerCase().startsWith(q)) continue;
out.push({ key: `alias:${ alias.key }`, kind: 'alias', name: alias.key, insertToken: alias.key, description: alias.description });
}
return out;
};
+130
View File
@@ -0,0 +1,130 @@
import { GetSessionDataManager } from '@nitrots/nitro-renderer';
import { RefObject, useCallback, useMemo, useState } from 'react';
import { GetConfigurationValue, LocalizeText } from '../../../api';
import { useRoomUserListSnapshot } from '../../session/useSessionSnapshots';
import { buildChatMentionSuggestions, computeMentionContext, MentionAlias, MentionAliasScope, MentionSuggestion, MENTION_ALIAS_CONFIG_KEY, MENTION_ALIAS_DEFAULTS, MENTION_ALIAS_DESCRIPTION_KEY, sanitizeAliasList } from './useChatMentions.helpers';
export type { MentionSuggestion, MentionSuggestionKind } from './useChatMentions.helpers';
export interface ChatMentionsState
{
visible: boolean;
suggestions: MentionSuggestion[];
selectedIndex: number;
setSelectedIndex: (index: number) => void;
moveUp: () => void;
moveDown: () => void;
/** Apply the highlighted suggestion (or the first). Returns true if one was applied. */
applyCurrent: () => boolean;
apply: (suggestion: MentionSuggestion) => void;
/** Escape: reset selection and strip the in-progress @query from the input. */
cancel: () => void;
}
/**
* Chat-input @-mention autocomplete. Owns the caret-context detection, the
* config-driven broadcast aliases, the room-user suggestion list (minus the
* viewer), keyboard navigation and the insert/cancel actions so ChatInputView
* just wires its keydown handler and the selector view to this hook.
*/
export const useChatMentions = (
chatValue: string,
setChatValue: (value: string) => void,
inputRef: RefObject<HTMLInputElement>,
commandSelectorVisible: boolean
): ChatMentionsState =>
{
const roomUserList = useRoomUserListSnapshot();
const [ selectedIndex, setSelectedIndex ] = useState(0);
const mentionContext = useMemo(() => computeMentionContext(chatValue, commandSelectorVisible), [ chatValue, commandSelectorVisible ]);
const aliases = useMemo<MentionAlias[]>(() =>
{
const out: MentionAlias[] = [];
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 suggestions = useMemo<MentionSuggestion[]>(() =>
{
if(!mentionContext) return [];
const session = GetSessionDataManager();
return buildChatMentionSuggestions(mentionContext.query, roomUserList, aliases, session?.userId ?? -1, session?.userName || '');
}, [ mentionContext, roomUserList, aliases ]);
const visible = (suggestions.length > 0);
// Clamp the selection to the current list (derived, no effect) so a
// shrinking list never leaves the highlight out of range.
const safeIndex = (selectedIndex < suggestions.length) ? selectedIndex : 0;
const apply = useCallback((suggestion: MentionSuggestion) =>
{
if(!suggestion || !mentionContext) return;
const before = chatValue.slice(0, mentionContext.replaceFrom);
const after = chatValue.slice(mentionContext.replaceTo);
const inserted = `@${ suggestion.insertToken } `;
setChatValue(`${ before }${ inserted }${ after }`);
requestAnimationFrame(() =>
{
if(!inputRef.current) return;
const caret = before.length + inserted.length;
inputRef.current.focus();
inputRef.current.setSelectionRange(caret, caret);
});
setSelectedIndex(0);
}, [ chatValue, mentionContext, setChatValue, inputRef ]);
const moveUp = useCallback(() => setSelectedIndex((safeIndex <= 0) ? (suggestions.length - 1) : (safeIndex - 1)), [ safeIndex, suggestions.length ]);
const moveDown = useCallback(() => setSelectedIndex((safeIndex >= (suggestions.length - 1)) ? 0 : (safeIndex + 1)), [ safeIndex, suggestions.length ]);
const applyCurrent = useCallback(() =>
{
const picked = suggestions[safeIndex] ?? suggestions[0];
if(!picked) return false;
apply(picked);
return true;
}, [ suggestions, safeIndex, apply ]);
const cancel = useCallback(() =>
{
setSelectedIndex(0);
if(!mentionContext) return;
const before = chatValue.slice(0, mentionContext.replaceFrom);
const after = chatValue.slice(mentionContext.replaceTo);
setChatValue(before + after);
}, [ mentionContext, chatValue, setChatValue ]);
return { visible, suggestions, selectedIndex: safeIndex, setSelectedIndex, moveUp, moveDown, applyCurrent, apply, cancel };
};
+2
View File
@@ -25,8 +25,10 @@ import './css/emustats/EmuStatsView.css';
import './css/chat/Chats.css';
import './css/chat/ChatInputMentionSelectorView.css';
import './css/mentions/MentionToasts.css';
import './css/mentions/MentionsPanel.css';
import './css/common/Buttons.css';
import './css/common/ClassicScrollbar.css';
import './css/forms/form_select.css';
-1
View File
@@ -65,7 +65,6 @@ const installPatch = (): void =>
if(!proto) continue;
if(guardMethod(proto, 'break', name)) patched = true;
if(guardMethod(proto, 'checkAndUpdateTexture', name)) patched = true;
}