Merge pull request #213 from duckietm/Dev

Dev
This commit is contained in:
DuckieTM
2026-06-07 08:56:50 +02:00
committed by GitHub
47 changed files with 1877 additions and 973 deletions
+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';
+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>
@@ -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 } = useMessenger();
const { getFriend = null } = useFriends();
const { report = null } = useHelp();
const { settings, translateOutgoing } = useTranslation();
const messagesBox = useRef<HTMLDivElement>(null);
@@ -133,12 +135,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>
@@ -18,7 +18,8 @@ export const FurniEditorView: FC<{}> = () =>
items, total, page, loading, error, clearError,
selectedItem, setSelectedItem, furniDataEntry,
interactions,
searchItems, loadDetail, loadBySpriteId, updateItem, deleteItem, loadInteractions
searchItems, loadDetail, loadBySpriteId, updateItem, deleteItem, loadInteractions,
updateFurnidata, revertFurnidata, importText, importResult
} = useFurniEditor();
const isMod = useHasPermission('acc_catalogfurni');
@@ -155,6 +156,10 @@ export const FurniEditorView: FC<{}> = () =>
onUpdate={ updateItem }
onDelete={ deleteItem }
onBack={ handleBack }
onUpdateFurnidata={ updateFurnidata }
onRevertFurnidata={ revertFurnidata }
onImportText={ importText }
importResult={ importResult }
/>
}
@@ -1,4 +1,5 @@
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Button, Column, Flex, LayoutFurniIconImageView, Text } from '../../../common';
import { FurniDetail } from '../../../hooks/furni-editor';
@@ -11,6 +12,10 @@ interface FurniEditorEditViewProps
onUpdate: (id: number, fields: Record<string, unknown>) => void;
onDelete: (id: number) => void;
onBack: () => void;
onUpdateFurnidata: (id: number, name: string, description: string) => void;
onRevertFurnidata: (id: number) => void;
onImportText: (id: number) => void;
importResult: { found: boolean; name: string; description: string; classname: string; nonce: number } | null;
}
const FIELD_TIPS: Record<string, string> = {
@@ -21,9 +26,8 @@ const FIELD_TIPS: Record<string, string> = {
};
const PERM_GROUPS = [
{ label: 'Gameplay', keys: [ 'allowStack', 'allowWalk', 'allowSit', 'allowLay' ] },
{ label: 'Gameplay', keys: [ 'allowStack', 'allowWalk', 'allowSit', 'allowLay', 'allowInventoryStack' ] },
{ label: 'Trading', keys: [ 'allowGift', 'allowTrade', 'allowRecycle', 'allowMarketplaceSell' ] },
{ label: 'Inventory', keys: [ 'allowInventoryStack' ] },
];
interface SectionProps { title: string; children: React.ReactNode; defaultOpen?: boolean }
@@ -33,16 +37,16 @@ const Section: FC<SectionProps> = ({ title, children, defaultOpen = true }) =>
const [ open, setOpen ] = useState(defaultOpen);
return (
<div className="bg-white rounded border border-[#ccc]">
<div className="bg-[#ffffff] rounded-xl border border-slate-200 shadow-sm">
<button
type="button"
className="w-full flex items-center justify-between px-2 py-1.5 cursor-pointer hover:bg-[#f5f5f5] transition-colors"
className={ `w-full flex items-center justify-between px-3 py-2 cursor-pointer hover:bg-slate-50 transition-colors rounded-t-xl ${ open ? '' : 'rounded-b-xl' }` }
onClick={ () => setOpen(p => !p) }
>
<Text small bold variant="primary">{ title }</Text>
<span className="text-[10px] text-[#999]">{ open ? '▼' : '▶' }</span>
<Text className="text-[12px] font-semibold text-slate-700">{ title }</Text>
<span className="text-[11px] text-slate-400 transition-transform duration-200" style={ { transform: open ? 'rotate(0deg)' : 'rotate(-90deg)' } }></span>
</button>
{ open && <div className="px-2 pb-2">{ children }</div> }
{ open && <div className="px-3 pb-2.5 pt-0.5">{ children }</div> }
</div>
);
};
@@ -50,22 +54,74 @@ const Section: FC<SectionProps> = ({ title, children, defaultOpen = true }) =>
const Tip: FC<{ field: string }> = ({ field }) =>
{
const tip = FIELD_TIPS[field];
const ref = useRef<HTMLSpanElement>(null);
const [ pos, setPos ] = useState<{ left: number; top: number } | null>(null);
const show = useCallback(() =>
{
const r = ref.current?.getBoundingClientRect();
if(r) setPos({ left: r.left + (r.width / 2), top: r.top - 6 });
}, []);
const hide = useCallback(() => setPos(null), []);
if(!tip) return null;
return (
<span className="relative group ml-0.5 inline-flex">
<span className="w-3 h-3 rounded-full bg-[#1e7295] text-white text-[8px] flex items-center justify-center cursor-help font-bold">?</span>
<span className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-2 py-1 bg-[#333] text-white text-[10px] rounded whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity z-10">
{ tip }
</span>
<span
ref={ ref }
onMouseEnter={ show }
onMouseLeave={ hide }
className="ml-0.5 inline-flex w-3 h-3 rounded-full bg-[#1e7295] text-white text-[8px] items-center justify-center cursor-help font-bold align-middle"
>
?
{ pos && createPortal(
<span
style={ { position: 'fixed', left: pos.left, top: pos.top, transform: 'translate(-50%, -100%)', zIndex: 9999 } }
className="px-2 py-1 bg-[#333] text-white text-[10px] rounded w-44 whitespace-normal text-center leading-snug shadow-lg pointer-events-none"
>
{ tip }
</span>, document.body) }
</span>
);
};
const CopyValue: FC<{ value: string | number }> = ({ value }) =>
{
const [ copied, setCopied ] = useState(false);
const copy = useCallback(() =>
{
const text = String(value);
if(navigator.clipboard?.writeText) navigator.clipboard.writeText(text).then(() => setCopied(true)).catch(() => setCopied(true));
else setCopied(true);
}, [ value ]);
// Reset the "copied!" flag after 1s, with cleanup so the timer never fires after unmount.
useEffect(() =>
{
if(!copied) return;
const handle = window.setTimeout(() => setCopied(false), 1000);
return () => window.clearTimeout(handle);
}, [ copied ]);
return (
<div
role="button"
title="Click to copy"
onClick={ copy }
className={ `group relative cursor-pointer w-full px-3 py-1.5 text-sm font-mono rounded-lg border transition ${ copied ? 'border-primary/50 bg-primary/5 text-primary' : 'border-slate-200 bg-slate-50 text-slate-600 hover:border-slate-300 hover:bg-slate-100' }` }
>
<span className="block truncate pr-12">{ String(value) }</span>
<span className={ `absolute right-2 top-1/2 -translate-y-1/2 text-[9px] font-semibold uppercase tracking-wide pointer-events-none ${ copied ? 'text-primary' : 'text-slate-300 group-hover:text-slate-400' }` }>{ copied ? 'copied!' : 'copy' }</span>
</div>
);
};
export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
{
const { item, furniDataEntry, interactions, loading, onUpdate, onDelete, onBack } = props;
const { item, furniDataEntry, interactions, loading, onUpdate, onDelete, onBack, onUpdateFurnidata, onRevertFurnidata, onImportText, importResult } = props;
const saveRef = useRef<() => void>(null);
const [ form, setForm ] = useState({
@@ -91,6 +147,11 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
});
const [ showDeleteDialog, setShowDeleteDialog ] = useState(false);
const [ furniName, setFurniName ] = useState('');
const [ furniDescription, setFurniDescription ] = useState('');
const [ confirmFurnidata, setConfirmFurnidata ] = useState(false);
const [ importNote, setImportNote ] = useState('');
const appliedImportNonce = useRef(0);
useEffect(() =>
{
@@ -119,7 +180,11 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
});
setShowDeleteDialog(false);
}, [ item ]);
setFurniName(String(furniDataEntry?.name ?? ''));
setFurniDescription(String(furniDataEntry?.description ?? ''));
setConfirmFurnidata(false);
setImportNote('');
}, [ item, furniDataEntry ]);
const setField = useCallback((key: string, value: unknown) =>
{
@@ -166,6 +231,48 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
const isValid = useMemo(() => Object.keys(validation).length === 0, [ validation ]);
// Furnidata name editing only works when the furni has a matching furnidata
// entry: the server writer is edit-only and refuses classnames absent from
// furnidata (pets, custom items, …). furniDataEntry is the entry resolved by
// the server (by id); guard on it + a classname match so we never trigger the
// cryptic "Classname not found in furnidata" error on save.
const furnidataEditable = useMemo(() =>
{
if(!furniDataEntry) return false;
const cn = String((furniDataEntry as { classname?: unknown }).classname ?? '').trim().toLowerCase();
const itemCn = String(item?.itemName ?? '').trim().toLowerCase();
return cn ? (cn === itemCn) : true;
}, [ furniDataEntry, item ]);
// True only when the name/description actually differ from the stored furnidata
// entry. Used to gate the Save button: saving an unchanged value makes the
// server writer return false, which the handler misreports as "Classname not
// found in furnidata" — so we never let an unchanged save fire.
const furnidataDirty = useMemo(() =>
furniName !== String(furniDataEntry?.name ?? '') || furniDescription !== String(furniDataEntry?.description ?? ''),
[ furniName, furniDescription, furniDataEntry ]);
// Apply an "Import from Habbo" result into the editable fields (review then Save).
useEffect(() =>
{
if(!importResult || importResult.nonce === appliedImportNonce.current) return;
appliedImportNonce.current = importResult.nonce;
// Ignore a result that belongs to a different furni (user navigated away).
if(importResult.classname && importResult.classname.trim().toLowerCase() !== String(item?.itemName ?? '').trim().toLowerCase()) return;
if(importResult.found)
{
setFurniName(importResult.name);
setFurniDescription(importResult.description);
setImportNote('Imported from Habbo — review and Save');
}
else
{
setImportNote('Not found on Habbo for this classname');
}
}, [ importResult, item ]);
const handleSave = useCallback(() =>
{
if(!isValid) return;
@@ -207,54 +314,116 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
}, []);
const inputClass = (field?: string) =>
`w-full px-2 py-1 text-xs leading-normal rounded-sm border border-[#ccc] min-h-[calc(1.5em+0.5rem+2px)] ${ field && validation[field] ? 'border-red-500 bg-red-50' : '' }`;
const labelClass = 'text-[11px] font-bold text-[#333] mb-0 flex items-center gap-0.5';
`w-full px-3 py-1.5 text-sm leading-normal rounded-lg border border-slate-300 bg-[#ffffff] focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/15 transition${ field && validation[field] ? ' border-red-400 bg-red-50' : '' }`;
const labelClass = 'text-[11px] font-medium text-slate-500 mb-1 flex items-center gap-0.5';
return (
<Column gap={ 1 } className="h-full overflow-auto">
<Column gap={ 1 }>
{ /* Header */ }
<Flex gap={ 2 } alignItems="center" className="mb-1">
<Button variant="secondary" onClick={ handleBack }>Back</Button>
<div className="bg-[#e9ecef] rounded border border-[#ccc] flex items-center justify-center w-[48px] h-[48px]">
<LayoutFurniIconImageView productType={ item.type } productClassId={ item.spriteId } className="scale-150" />
<Flex alignItems="center" gap={ 2 } className="px-1">
<div className="shrink-0 w-14 h-14 rounded-xl bg-[#ffffff] border border-slate-200 shadow-sm flex items-center justify-center overflow-hidden">
<LayoutFurniIconImageView productType={ item.type } productClassId={ item.spriteId } className="scale-[1.6]" />
</div>
<Flex column gap={ 0 }>
<Flex alignItems="center" gap={ 1 }>
<Text bold className="text-[12px]">ID: { item.id }</Text>
<span className="text-[#999]">|</span>
<Text bold className="text-[12px]">Sprite: { item.spriteId }</Text>
<Flex column gap={ 0 } className="min-w-0 flex-1">
<Text bold className="truncate text-slate-800 text-[15px] leading-tight">{ furniName || form.publicName || form.itemName }</Text>
<Text className="truncate text-slate-400 text-[11px] font-mono">{ form.itemName }</Text>
<Flex alignItems="center" gap={ 1 } className="mt-1 flex-wrap">
<span className="inline-flex items-center gap-1 text-[10px] rounded-md border border-slate-200 bg-slate-50 pl-1.5 pr-2 py-0.5">
<span className="text-[8px] font-semibold uppercase tracking-wide text-slate-400">ID</span>
<span className="font-mono text-slate-600">{ item.id }</span>
</span>
<span className="inline-flex items-center gap-1 text-[10px] rounded-md border border-slate-200 bg-slate-50 pl-1.5 pr-2 py-0.5">
<span className="text-[8px] font-semibold uppercase tracking-wide text-slate-400">Sprite</span>
<span className="font-mono text-slate-600">{ item.spriteId }</span>
</span>
<span className={ `inline-flex items-center gap-1 text-[10px] rounded-md border px-2 py-0.5 ${ item.usageCount > 0 ? 'border-[#a7f3d0] bg-[#ecfdf5] text-[#047857]' : 'border-slate-200 bg-slate-50 text-slate-500' }` }>
<span className={ `w-1.5 h-1.5 rounded-full ${ item.usageCount > 0 ? 'bg-[#10b981]' : 'bg-slate-300' }` } />
{ item.usageCount } in use
</span>
{ isDirty && <span className="inline-flex items-center gap-1 text-[10px] font-medium text-amber-700 bg-amber-100 border border-amber-200 rounded-md px-2 py-0.5">
<span className="w-1.5 h-1.5 rounded-full bg-[#f59e0b]" />
Unsaved
</span> }
</Flex>
<Text small variant="gray">({ item.usageCount } in use)</Text>
</Flex>
{ isDirty && <span className="text-[10px] text-orange-500 font-bold ml-auto">Unsaved changes</span> }
<Button variant="secondary" onClick={ handleBack } className="shrink-0">Back</Button>
</Flex>
{ /* Primary edit surface: furnidata display name + description (server-authoritative, live) */ }
<div className="bg-[#ffffff] rounded-xl border border-slate-200 shadow-sm p-2.5">
<div className="flex items-center gap-2 mb-1.5">
<Text className="text-[12px] font-semibold text-slate-700">Display name &amp; description</Text>
{ furnidataEditable
? <span className="text-[9px] font-semibold text-primary bg-primary/10 rounded-md px-1.5 py-0.5">LIVE</span>
: <span className="text-[9px] font-semibold text-amber-700 bg-amber-100 rounded-md px-1.5 py-0.5">NO FURNIDATA</span> }
{ furnidataEditable && furnidataDirty &&
<span className="ml-auto text-[10px] text-amber-600 font-medium">Unsaved</span> }
</div>
{ furnidataEditable ? (
<>
<div className="grid grid-cols-2 gap-2">
<div>
<label className={ labelClass }>Display Name (furnidata)</label>
<input className={ inputClass() } value={ furniName } onChange={ e => setFurniName(e.target.value) } maxLength={ 256 } />
</div>
<div>
<label className={ labelClass }>Description</label>
<input className={ inputClass() } value={ furniDescription } onChange={ e => setFurniDescription(e.target.value) } maxLength={ 256 } />
</div>
</div>
<Flex gap={ 1 } className="mt-1.5" alignItems="center">
<Button variant="success" disabled={ loading || !furnidataDirty } onClick={ () => setConfirmFurnidata(true) }>Save name/desc</Button>
<Button variant="secondary" disabled={ loading } onClick={ () => onRevertFurnidata(item.id) }>Revert</Button>
<button
type="button"
disabled={ loading }
onClick={ () => onImportText(item.id) }
title="Fetch the official name &amp; description from Habbo"
className="ml-auto inline-flex items-center gap-1 text-[11px] font-medium px-2.5 py-1.5 rounded-lg border border-slate-300 bg-[#ffffff] text-slate-600 hover:bg-slate-50 hover:border-slate-400 disabled:opacity-50 transition"
>
<svg className="w-3.5 h-3.5" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M10 3v9" /><path d="m6.5 8.5 3.5 3.5 3.5-3.5" /><path d="M4 16h12" /></svg>
Import from Habbo
</button>
</Flex>
{ importNote &&
<Text className={ `mt-1 text-[10px] ${ importNote.startsWith('Not found') ? 'text-amber-600' : 'text-primary' }` }>{ importNote }</Text> }
</>
) : (
<div className="flex items-start gap-2 text-[11px] text-slate-500 bg-slate-50 border border-slate-200 rounded-lg px-2.5 py-2 leading-snug">
<span className="text-[#f59e0b] text-sm leading-none mt-px"></span>
<span>This furni has no matching <b>furnidata</b> entry (e.g. a pet or custom item), so its display name can&apos;t be edited here. Clients fall back to the DB <b>Public Name</b> below.</span>
</div>
) }
</div>
<Section title="Basic Info">
<div className="grid grid-cols-2 gap-2">
<div>
<label className={ labelClass }>Item Name</label>
<input className={ inputClass('itemName') } value={ form.itemName } onChange={ e => setField('itemName', e.target.value) } />
{ validation.itemName && <span className="text-[9px] text-red-500">{ validation.itemName }</span> }
<label className={ labelClass }>Classname</label>
<CopyValue value={ form.itemName } />
</div>
<div>
<label className={ labelClass }>Public Name</label>
<input className={ inputClass('publicName') } value={ form.publicName } onChange={ e => setField('publicName', e.target.value) } />
{ validation.publicName && <span className="text-[9px] text-red-500">{ validation.publicName }</span> }
<label className={ labelClass }>Public Name (DB fallback)</label>
<CopyValue value={ form.publicName } />
</div>
<div>
<label className={ labelClass }>Sprite ID</label>
<input type="number" className={ inputClass() } value={ form.spriteId } onChange={ e => setField('spriteId', Number(e.target.value)) } />
<CopyValue value={ form.spriteId } />
</div>
<div>
<label className={ labelClass }>Type</label>
<select className="w-full px-2 py-1 text-xs leading-normal rounded-sm border border-[#ccc] pr-8" value={ form.type } onChange={ e => setField('type', e.target.value) }>
<option value="s">Floor (s)</option>
<option value="i">Wall (i)</option>
</select>
<CopyValue value={ form.type === 's' ? 'Floor (s)' : 'Wall (i)' } />
</div>
</div>
</Section>
{ furniDataEntry &&
<Section title="FurniData.json" defaultOpen={ false }>
<Text className="text-[10px] text-slate-400 mb-1 block">Read-only how this furni resolves from the furnidata JSON (source of truth for the display name).</Text>
<pre className="text-[10px] leading-snug text-slate-600 bg-slate-50 border border-slate-200 rounded-lg p-2 overflow-auto max-h-52 whitespace-pre-wrap break-all font-mono">{ JSON.stringify(furniDataEntry, null, 2) }</pre>
</Section>
}
<Section title="Dimensions">
<div className="grid grid-cols-3 gap-2">
<div>
@@ -279,19 +448,24 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
<div className="flex flex-col gap-2">
{ PERM_GROUPS.map(group => (
<div key={ group.label }>
<Text className="text-[10px] font-bold text-[#666] uppercase tracking-wider mb-0.5 block">{ group.label }</Text>
<div className="grid grid-cols-4 gap-x-3 gap-y-1">
{ group.keys.map(key => (
<label key={ key } className="flex items-center gap-1 text-[11px] cursor-pointer">
<input
type="checkbox"
className="mt-1"
checked={ (form as any)[key] }
onChange={ e => setField(key, e.target.checked) }
/>
{ key.replace('allow', '') }
</label>
)) }
<Text className="text-[10px] font-semibold text-slate-400 uppercase tracking-wide mb-1.5 block">{ group.label }</Text>
<div className="flex flex-wrap gap-1.5">
{ group.keys.map(key => {
const on = (form as any)[key];
return (
<button
key={ key }
type="button"
onClick={ () => setField(key, !on) }
aria-pressed={ on }
title={ on ? 'Enabled — click to disable' : 'Disabled — click to enable' }
className={ `inline-flex items-center gap-1.5 text-[11px] px-2.5 py-1 rounded-lg border font-medium transition ${ on ? 'bg-[#1E7295] border-[#1E7295] text-[#ffffff] shadow-sm' : 'bg-slate-100 border-slate-200 text-slate-400 hover:bg-slate-200 hover:text-slate-600' }` }
>
<span className={ `inline-block w-2 h-2 rounded-full ring-1 ${ on ? 'bg-[#22c55e] ring-[#ffffff]/70' : 'bg-[#ef4444] ring-[#00000014]' }` } />
{ key.replace('allow', '') }
</button>
);
}) }
</div>
</div>
)) }
@@ -302,7 +476,7 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
<div className="grid grid-cols-3 gap-2">
<div className="col-span-2">
<label className={ labelClass }>Type<Tip field="interactionType" /></label>
<select className="w-full px-2 py-1 text-xs leading-normal rounded-sm border border-[#ccc] pr-8" value={ form.interactionType } onChange={ e => setField('interactionType', e.target.value) }>
<select className="w-full px-2 py-1 text-sm leading-normal rounded-sm border border-[#bbb] focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/40 pr-8" value={ form.interactionType } onChange={ e => setField('interactionType', e.target.value) }>
<option value="">none</option>
{ interactions.map(i => (
<option key={ i } value={ i }>{ i }</option>
@@ -320,19 +494,6 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
</div>
</Section>
{ furniDataEntry &&
<Section title="FurniData.json" defaultOpen={ false }>
<div className="grid grid-cols-2 gap-x-3 gap-y-0.5 text-[10px]">
{ Object.entries(furniDataEntry).map(([ key, value ]) => (
<div key={ key } className="flex justify-between bg-[#f5f5f5] px-2 py-0.5 rounded">
<span className="font-bold text-[#555]">{ key }</span>
<span className="text-[#333] truncate ml-1 max-w-[120px] text-right">{ String(value ?? '') }</span>
</div>
)) }
</div>
</Section>
}
{ /* Actions */ }
<Flex gap={ 1 } justifyContent="between" alignItems="center" className="mt-1">
<Flex gap={ 1 } alignItems="center">
@@ -352,8 +513,8 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
{ /* Delete Confirmation Dialog */ }
{ showDeleteDialog &&
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={ () => setShowDeleteDialog(false) }>
<div className="bg-white rounded-lg shadow-xl p-4 w-[320px]" onClick={ e => e.stopPropagation() }>
<div className="fixed inset-0 bg-[#00000080] flex items-center justify-center z-[60]" onClick={ () => setShowDeleteDialog(false) }>
<div className="bg-[#ffffff] rounded-lg shadow-xl p-4 w-[320px]" onClick={ e => e.stopPropagation() }>
<Text bold className="text-[14px] mb-2 block">Delete Item?</Text>
<Text small className="mb-3 block text-[#666]">
Are you sure you want to delete <strong>{ item.publicName || item.itemName }</strong> (ID: { item.id })?
@@ -366,6 +527,21 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
</div>
</div>
}
{ /* Furnidata Confirmation Dialog */ }
{ confirmFurnidata &&
<div className="fixed inset-0 bg-[#00000080] flex items-center justify-center z-[60]" onClick={ () => setConfirmFurnidata(false) }>
<div className="bg-[#ffffff] rounded-lg shadow-xl p-4 w-[320px]" onClick={ e => e.stopPropagation() }>
<Text bold className="text-[14px] mb-2 block">Apply furnidata change to ALL clients?</Text>
<div className="text-xs mb-1"><b>Name:</b> { String(furniDataEntry?.name ?? '') } { furniName }</div>
<div className="text-xs mb-3"><b>Desc:</b> { String(furniDataEntry?.description ?? '') } { furniDescription }</div>
<Flex gap={ 1 } justifyContent="end">
<Button variant="secondary" onClick={ () => setConfirmFurnidata(false) }>Cancel</Button>
<Button variant="success" onClick={ () => { onUpdateFurnidata(item.id, furniName, furniDescription); setConfirmFurnidata(false); } }>Confirm</Button>
</Flex>
</div>
</div>
}
</Column>
);
};
@@ -1,5 +1,5 @@
import { FC, useCallback, useEffect, useEffectEvent, useMemo, useState } from 'react';
import { Button, Column, Flex, LayoutFurniIconImageView, Text } from '../../../common';
import { FC, ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import { Column, Flex, LayoutFurniIconImageView, Text } from '../../../common';
import { FurniItem } from '../../../hooks/furni-editor';
interface FurniEditorSearchViewProps
@@ -8,20 +8,43 @@ interface FurniEditorSearchViewProps
total: number;
page: number;
loading: boolean;
onSearch: (query: string, type: string, page: number) => void;
onSearch: (query: string, type: string, page: number, sortField: string, sortDir: string) => void;
onSelect: (id: number) => void;
}
type SortField = 'id' | 'spriteId' | 'itemName' | 'publicName' | 'type' | 'interactionType';
type SortDir = 'asc' | 'desc';
const PAGE_SIZE = 20;
const COLUMNS: { field: SortField; label: string; align: 'left' | 'center' }[] = [
{ field: 'id', label: 'ID', align: 'left' },
{ field: 'spriteId', label: 'Sprite', align: 'left' },
{ field: 'itemName', label: 'Name', align: 'left' },
{ field: 'publicName', label: 'Public Name', align: 'left' },
{ field: 'type', label: 'Type', align: 'center' },
{ field: 'interactionType', label: 'Interaction', align: 'left' },
];
const SortArrow: FC<{ field: SortField; active: SortField; dir: SortDir }> = ({ field, active, dir }) =>
{
if(field !== active) return <span className="ml-0.5 opacity-30"></span>;
if(field !== active) return <span className="ml-1 text-slate-300"></span>;
return <span className="ml-0.5">{ dir === 'asc' ? '▲' : '▼' }</span>;
return <span className="ml-1 text-primary">{ dir === 'asc' ? '▲' : '▼' }</span>;
};
const PagBtn: FC<{ disabled?: boolean; onClick: () => void; children: ReactNode; title?: string }> = ({ disabled, onClick, children, title }) => (
<button
type="button"
title={ title }
disabled={ disabled }
onClick={ onClick }
className="w-7 h-7 flex items-center justify-center rounded-lg border border-slate-200 bg-[#ffffff] text-slate-600 text-sm leading-none hover:bg-slate-50 hover:border-slate-300 disabled:opacity-40 disabled:cursor-not-allowed transition"
>
{ children }
</button>
);
export const FurniEditorSearchView: FC<FurniEditorSearchViewProps> = props =>
{
const { items, total, page, loading, onSearch, onSelect } = props;
@@ -29,184 +52,198 @@ export const FurniEditorSearchView: FC<FurniEditorSearchViewProps> = props =>
const [ typeFilter, setTypeFilter ] = useState('');
const [ sortField, setSortField ] = useState<SortField>('id');
const [ sortDir, setSortDir ] = useState<SortDir>('asc');
const [ pageInput, setPageInput ] = useState('1');
const initialSearch = useEffectEvent(() => onSearch('', '', 1));
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const from = total === 0 ? 0 : ((page - 1) * PAGE_SIZE) + 1;
const to = Math.min(page * PAGE_SIZE, total);
// Latest filter/sort for the debounced query effect (avoids stale closure).
const stateRef = useRef({ typeFilter, sortField, sortDir });
stateRef.current = { typeFilter, sortField, sortDir };
// Initial fetch (once).
const didInit = useRef(false);
useEffect(() =>
{
initialSearch();
}, []);
if(didInit.current) return;
didInit.current = true;
onSearch('', '', 1, 'id', 'asc');
}, [ onSearch ]);
const handleSearch = useCallback(() =>
{
onSearch(query, typeFilter, 1);
}, [ query, typeFilter, onSearch ]);
// Keep the page input synced with the authoritative page from the server.
useEffect(() => { setPageInput(String(page)); }, [ page ]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) =>
// Debounced live search as the user types (skips the first render).
const firstQuery = useRef(true);
useEffect(() =>
{
if(e.key === 'Enter') handleSearch();
}, [ handleSearch ]);
if(firstQuery.current) { firstQuery.current = false; return; }
const handleSort = useCallback((field: SortField) =>
{
setSortDir(prev => (sortField === field ? (prev === 'asc' ? 'desc' : 'asc') : 'asc'));
setSortField(field);
}, [ sortField ]);
const handleTypeToggle = useCallback((type: string) =>
{
setTypeFilter(prev =>
const handle = window.setTimeout(() =>
{
const next = prev === type ? '' : type;
const s = stateRef.current;
onSearch(query, s.typeFilter, 1, s.sortField, s.sortDir);
}, 350);
onSearch(query, next, 1);
return next;
});
return () => window.clearTimeout(handle);
}, [ query, onSearch ]);
const sortedItems = useMemo(() =>
const applyType = useCallback((t: string) =>
{
const sorted = [ ...items ];
const next = typeFilter === t ? '' : t;
setTypeFilter(next);
onSearch(query, next, 1, sortField, sortDir);
}, [ typeFilter, query, sortField, sortDir, onSearch ]);
sorted.sort((a, b) =>
{
let va: string | number = a[sortField] ?? '';
let vb: string | number = b[sortField] ?? '';
const applySort = useCallback((field: SortField) =>
{
const nextDir: SortDir = (sortField === field && sortDir === 'asc') ? 'desc' : 'asc';
setSortField(field);
setSortDir(nextDir);
onSearch(query, typeFilter, 1, field, nextDir);
}, [ sortField, sortDir, query, typeFilter, onSearch ]);
if(typeof va === 'string') va = va.toLowerCase();
if(typeof vb === 'string') vb = vb.toLowerCase();
const goTo = useCallback((pg: number) =>
{
const clamped = Math.min(Math.max(1, pg || 1), totalPages);
onSearch(query, typeFilter, clamped, sortField, sortDir);
}, [ totalPages, query, typeFilter, sortField, sortDir, onSearch ]);
if(va < vb) return sortDir === 'asc' ? -1 : 1;
if(va > vb) return sortDir === 'asc' ? 1 : -1;
return 0;
});
return sorted;
}, [ items, sortField, sortDir ]);
const totalPages = Math.ceil(total / 20);
const inputClass = 'w-full pl-9 pr-8 py-2 text-sm leading-normal rounded-lg border border-slate-300 bg-[#ffffff] focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/15 transition';
return (
<Column gap={ 1 } className="h-full">
<Flex gap={ 1 } alignItems="end">
<Column gap={ 0 } className="flex-1">
<Text small bold>Search</Text>
<Column gap={ 2 } className="h-full">
{ /* Search + filters */ }
<Flex gap={ 2 } alignItems="center">
<div className="relative flex-1">
<span className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none text-slate-400">
<svg className="w-4 h-4" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="8.5" cy="8.5" r="5.5" /><line x1="12.7" y1="12.7" x2="18" y2="18" /></svg>
</span>
<input
type="text"
className="w-full px-2 py-1 text-xs leading-normal rounded-sm border border-[#ccc] min-h-[calc(1.5em+0.5rem+2px)]"
placeholder="ID, name or sprite ID..."
className={ inputClass }
placeholder="Search by ID, name or sprite ID"
value={ query }
onChange={ e => setQuery(e.target.value) }
onKeyDown={ handleKeyDown }
/>
</Column>
<Flex gap={ 1 }>
{ [ '', 's', 'i' ].map(t => (
{ query &&
<button
key={ t || 'all' }
className={ `px-2 py-1 text-[11px] rounded border cursor-pointer transition-colors ${
typeFilter === t
? 'bg-[#1e7295] text-white border-[#1e7295]'
: 'bg-white text-[#333] border-[#ccc] hover:bg-[#f0f0f0]'
}` }
onClick={ () => handleTypeToggle(t) }
>
{ t === '' ? 'All' : t === 's' ? 'Floor' : 'Wall' }
</button>
)) }
type="button"
onClick={ () => setQuery('') }
className="absolute right-2 top-1/2 -translate-y-1/2 w-5 h-5 flex items-center justify-center rounded-full text-[11px] text-slate-300 hover:text-slate-500 hover:bg-slate-100"
></button> }
</div>
<Flex gap={ 1 }>
{ [ '', 's', 'i' ].map(t =>
{
const on = typeFilter === t;
return (
<button
key={ t || 'all' }
type="button"
onClick={ () => applyType(t) }
className={ `px-3 py-1.5 text-[12px] font-medium rounded-lg border transition ${ on ? 'bg-[#1E7295] border-[#1E7295] text-[#ffffff] shadow-sm' : 'bg-slate-100 border-slate-200 text-slate-500 hover:bg-slate-200 hover:text-slate-600' }` }
>
{ t === '' ? 'All' : t === 's' ? 'Floor' : 'Wall' }
</button>
);
}) }
</Flex>
<Button variant="primary" disabled={ loading } onClick={ handleSearch }>
{ loading ? '...' : 'Search' }
</Button>
</Flex>
{ total > 0 &&
<Text small variant="gray" className="text-[10px]">
{ total } items found
{ /* Result count + activity */ }
<Flex alignItems="center" gap={ 2 } className="px-0.5">
<Text className="text-[11px] text-slate-500">
{ total > 0 ? `Showing ${ from }${ to } of ${ total.toLocaleString() }` : (loading ? 'Searching…' : 'No results') }
</Text>
}
{ loading && <span className="w-3 h-3 rounded-full border-2 border-slate-300 border-t-primary animate-spin" /> }
</Flex>
<Column gap={ 0 } className="flex-1 overflow-auto border border-[#ccc] rounded bg-white">
<table className="w-full text-xs">
{ /* Table */ }
<div className="flex-1 overflow-auto rounded-xl border border-slate-200 shadow-sm bg-[#ffffff]">
<table className="w-full text-xs border-collapse">
<thead>
<tr className="bg-[#e8e8e8] sticky top-0 select-none">
<th className="px-1 py-1 text-center w-[50px]"></th>
<th className="px-2 py-1 text-left cursor-pointer hover:bg-[#ddd]" onClick={ () => handleSort('id') }>
ID<SortArrow field="id" active={ sortField } dir={ sortDir } />
</th>
<th className="px-2 py-1 text-left cursor-pointer hover:bg-[#ddd]" onClick={ () => handleSort('spriteId') }>
Sprite<SortArrow field="spriteId" active={ sortField } dir={ sortDir } />
</th>
<th className="px-2 py-1 text-left cursor-pointer hover:bg-[#ddd]" onClick={ () => handleSort('itemName') }>
Name<SortArrow field="itemName" active={ sortField } dir={ sortDir } />
</th>
<th className="px-2 py-1 text-left cursor-pointer hover:bg-[#ddd]" onClick={ () => handleSort('publicName') }>
Public Name<SortArrow field="publicName" active={ sortField } dir={ sortDir } />
</th>
<th className="px-2 py-1 text-center cursor-pointer hover:bg-[#ddd]" onClick={ () => handleSort('type') }>
Type<SortArrow field="type" active={ sortField } dir={ sortDir } />
</th>
<th className="px-2 py-1 text-left cursor-pointer hover:bg-[#ddd]" onClick={ () => handleSort('interactionType') }>
Interaction<SortArrow field="interactionType" active={ sortField } dir={ sortDir } />
</th>
<tr className="sticky top-0 z-10 bg-slate-50 border-b border-slate-200 select-none">
<th className="px-2 py-2 w-[52px]"></th>
{ COLUMNS.map(c => (
<th
key={ c.field }
onClick={ () => applySort(c.field) }
className={ `px-2 py-2 text-[10px] font-semibold uppercase tracking-wide text-slate-500 cursor-pointer hover:text-slate-700 ${ c.align === 'center' ? 'text-center' : 'text-left' }` }
>
{ c.label }<SortArrow field={ c.field } active={ sortField } dir={ sortDir } />
</th>
)) }
</tr>
</thead>
<tbody>
{ sortedItems.map(item => (
{ items.map(item => (
<tr
key={ item.id }
className="cursor-pointer hover:bg-[#d4edfa] border-b border-[#eee] transition-colors"
onClick={ () => onSelect(item.id) }
className="group cursor-pointer border-b border-slate-100 last:border-0 hover:bg-slate-50 transition-colors"
>
<td className="px-1 py-1 text-center">
<LayoutFurniIconImageView productType={ item.type } productClassId={ item.spriteId } className="inline-block scale-125" />
<td className="px-2 py-1.5">
<div className="w-9 h-9 rounded-lg bg-slate-50 border border-slate-100 group-hover:border-slate-200 flex items-center justify-center overflow-hidden">
<LayoutFurniIconImageView productType={ item.type } productClassId={ item.spriteId } />
</div>
</td>
<td className="px-2 py-1 font-mono">{ item.id }</td>
<td className="px-2 py-1 font-mono">{ item.spriteId }</td>
<td className="px-2 py-1 truncate max-w-[120px]" title={ item.itemName }>{ item.itemName }</td>
<td className="px-2 py-1 truncate max-w-[120px]" title={ item.publicName }>{ item.publicName }</td>
<td className="px-2 py-1 text-center">
<span className={ `px-1.5 py-0.5 rounded text-white text-[10px] font-medium ${ item.type === 's' ? 'bg-[#1e7295]' : 'bg-[#6b7280]' }` }>
<td className="px-2 py-1.5 font-mono text-slate-500">{ item.id }</td>
<td className="px-2 py-1.5 font-mono text-slate-500">{ item.spriteId }</td>
<td className="px-2 py-1.5 text-slate-700 font-medium truncate max-w-[160px]" title={ item.itemName }>{ item.itemName }</td>
<td className="px-2 py-1.5 text-slate-500 truncate max-w-[150px]" title={ item.publicName }>{ item.publicName || '—' }</td>
<td className="px-2 py-1.5 text-center">
<span className={ `inline-block px-2 py-0.5 rounded-md text-[#ffffff] text-[10px] font-semibold ${ item.type === 's' ? 'bg-[#1E7295]' : 'bg-[#64748b]' }` }>
{ item.type === 's' ? 'Floor' : 'Wall' }
</span>
</td>
<td className="px-2 py-1 text-[10px]">{ item.interactionType || '-' }</td>
<td className="px-2 py-1.5">
{ item.interactionType
? <span className="inline-block px-1.5 py-0.5 rounded bg-slate-100 text-slate-500 text-[10px] font-mono">{ item.interactionType }</span>
: <span className="text-slate-300"></span> }
</td>
</tr>
)) }
{ items.length === 0 && loading &&
Array.from({ length: 8 }).map((_, i) => (
<tr key={ `sk-${ i }` } className="border-b border-slate-100">
<td className="px-2 py-1.5"><div className="w-9 h-9 rounded-lg bg-slate-100 animate-pulse" /></td>
{ COLUMNS.map(c => <td key={ c.field } className="px-2 py-1.5"><div className="h-3 rounded bg-slate-100 animate-pulse" /></td>) }
</tr>
)) }
{ items.length === 0 && !loading &&
<tr>
<td colSpan={ 7 } className="px-2 py-4 text-center text-[#999]">No items found</td>
</tr>
}
<td colSpan={ 7 } className="px-2 py-10 text-center">
<div className="text-slate-400 text-sm">No furni found</div>
<div className="text-slate-300 text-[11px] mt-0.5">Try a different search or filter</div>
</td>
</tr> }
</tbody>
</table>
</Column>
</div>
{ totalPages > 1 &&
<Flex gap={ 1 } justifyContent="between" alignItems="center">
<Text small variant="gray">
Page { page }/{ totalPages }
</Text>
<Flex gap={ 1 }>
<Button
variant="secondary"
disabled={ page <= 1 }
onClick={ () => onSearch(query, typeFilter, page - 1) }
>
Prev
</Button>
<Button
variant="secondary"
disabled={ page >= totalPages }
onClick={ () => onSearch(query, typeFilter, page + 1) }
>
Next
</Button>
{ /* Pagination */ }
<Flex justifyContent="between" alignItems="center" className="px-0.5">
<Text className="text-[11px] text-slate-400">{ total.toLocaleString() } items</Text>
<Flex alignItems="center" gap={ 1 }>
<PagBtn title="First" disabled={ page <= 1 } onClick={ () => goTo(1) }>«</PagBtn>
<PagBtn title="Previous" disabled={ page <= 1 } onClick={ () => goTo(page - 1) }></PagBtn>
<Flex alignItems="center" gap={ 1 } className="px-1 text-[11px] text-slate-500">
<input
type="text"
value={ pageInput }
onChange={ e => setPageInput(e.target.value.replace(/[^0-9]/g, '')) }
onKeyDown={ e => { if(e.key === 'Enter') goTo(Number(pageInput)); } }
className="w-12 px-1.5 py-1 text-center rounded-lg border border-slate-200 bg-[#ffffff] focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/15"
/>
<span className="text-slate-400">/ { totalPages.toLocaleString() }</span>
</Flex>
<PagBtn title="Next" disabled={ page >= totalPages } onClick={ () => goTo(page + 1) }></PagBtn>
<PagBtn title="Last" disabled={ page >= totalPages } onClick={ () => goTo(totalPages) }>»</PagBtn>
</Flex>
}
</Flex>
</Column>
);
};
+10 -10
View File
@@ -1,5 +1,5 @@
import { FC, Fragment, ReactNode } from 'react';
import { tokenIsMention } from '../room/widgets/chat/highlightMentions';
import { classifyMentionToken } from '../../api/mentions/mentionTokens';
interface MentionMessageViewProps
{
@@ -9,10 +9,11 @@ interface MentionMessageViewProps
}
/**
* Renders a mention's message text as React nodes, wrapping the token(s) that
* mention the local user or a room-broadcast alias in a `.mention-highlight`
* span. Pure text segmentation (no innerHTML) → no XSS risk from other users'
* chat content. Original spacing is preserved verbatim.
* Renders a mention's message text as React nodes, wrapping every @user token
* in a `.mention-tag` span (with the `.mention-tag--self` modifier when the
* token targets the local user or a broadcast alias). Pure text segmentation
* (no innerHTML) → no XSS risk from other users' chat content. Original spacing
* is preserved verbatim.
*/
export const MentionMessageView: FC<MentionMessageViewProps> = props =>
{
@@ -24,12 +25,11 @@ export const MentionMessageView: FC<MentionMessageViewProps> = props =>
{
if(segment.length === 0) return null;
if(/^\s+$/.test(segment) || !tokenIsMention(segment, ownUsername))
{
return <Fragment key={ index }>{ segment }</Fragment>;
}
const kind = (/^\s+$/.test(segment)) ? '' : classifyMentionToken(segment, ownUsername);
return <span key={ index } className="mention-highlight">{ segment }</span>;
if(!kind) return <Fragment key={ index }>{ segment }</Fragment>;
return <span key={ index } className={ (kind === 'self') ? 'mention-tag mention-tag--self' : 'mention-tag' }>{ segment }</span>;
});
return <span className={ className }>{ nodes }</span>;
+26 -34
View File
@@ -1,8 +1,8 @@
import { FC, MouseEvent } from 'react';
import { IMentionEntry, LocalizeText, MentionType } from '../../api';
import { Flex, Text } from '../../common';
import { FaArrowRight, FaTimes } from 'react-icons/fa';
import { formatMentionTime, IMentionEntry, LocalizeText, MentionType } from '../../api';
import { LayoutAvatarImageView } from '../../common';
import { MentionMessageView } from './MentionMessageView';
import { formatMentionTime } from './mentionsFormat';
interface MentionRowViewProps
{
@@ -28,39 +28,31 @@ export const MentionRowView: FC<MentionRowViewProps> = props =>
};
return (
<Flex pointer alignItems="center" className="group relative px-1 py-[3px] rounded hover:bg-black/5" gap={ 2 } onClick={ () => onOpen(mention) }>
<span
className={ `inline-block w-[8px] h-[8px] rounded-full shrink-0 ${ mention.read ? 'bg-transparent' : 'bg-[#1e7295]' }` }
title={ mention.read ? '' : LocalizeText('mentions.filter.unread') } />
<span
title={ typeTitle }
className={ `flex items-center justify-center shrink-0 w-[18px] h-[18px] rounded text-[10px] font-bold leading-none text-white ${ isRoom ? 'bg-[#d08a1e]' : 'bg-[#1e7295]' }` }>
{ isRoom ? '@' : '@' }
</span>
<Flex grow column className="min-w-0" gap={ 0 }>
<Flex alignItems="center" gap={ 1 } className="min-w-0">
<Text bold={ !mention.read } truncate variant="primary">{ mention.senderUsername }</Text>
{ (mention.roomName && mention.roomName.length > 0) &&
<Text small truncate variant="gray">· { mention.roomName }</Text> }
</Flex>
<MentionMessageView className="block truncate text-black text-sm" ownUsername={ ownUsername } text={ mention.message } />
</Flex>
<Flex alignItems="center" gap={ 1 } className="shrink-0">
<div className={ `mention-row ${ mention.read ? '' : 'is-unread' }` } onClick={ () => onOpen(mention) }>
{ !mention.read &&
<span className="mention-row-unread-dot" aria-hidden /> }
<div className="mention-row-avatar" title={ typeTitle }>
<LayoutAvatarImageView headOnly direction={ 2 } figure={ mention.senderFigure } />
<span className={ `mention-row-type ${ isRoom ? 'is-room' : 'is-direct' }` }>{ isRoom ? '' : '@' }</span>
</div>
<div className="mention-row-body">
<div className="mention-row-head">
<span className="mention-row-name">{ mention.senderUsername }</span>
{ (mention.roomName && (mention.roomName.length > 0)) &&
<span className="mention-row-room">· { mention.roomName }</span> }
</div>
<MentionMessageView className="mention-row-msg" ownUsername={ ownUsername } text={ mention.message } />
</div>
<div className="mention-row-meta">
{ (time.length > 0) &&
<Text small variant="gray" className="tabular-nums group-hover:hidden">{ time }</Text> }
<Flex alignItems="center" gap={ 1 } className="hidden group-hover:flex">
<span className="mention-row-time">{ time }</span> }
<div className="mention-row-actions">
{ onGoto &&
<span
title={ LocalizeText('mentions.action.goto') }
className="flex items-center justify-center w-[18px] h-[18px] rounded bg-black/10 hover:bg-black/20 text-[12px] leading-none"
onClick={ event => stop(event, () => onGoto(mention)) }></span> }
<button type="button" className="mention-row-action" title={ LocalizeText('mentions.action.goto') } onClick={ event => stop(event, () => onGoto(mention)) }><FaArrowRight /></button> }
{ onRemove &&
<span
title={ LocalizeText('mentions.action.remove') }
className="flex items-center justify-center w-[18px] h-[18px] rounded bg-black/10 hover:bg-red-500 hover:text-white text-[11px] leading-none"
onClick={ event => stop(event, () => onRemove(mention)) }></span> }
</Flex>
</Flex>
</Flex>
<button type="button" className="mention-row-action is-remove" title={ LocalizeText('mentions.action.remove') } onClick={ event => stop(event, () => onRemove(mention)) }><FaTimes /></button> }
</div>
</div>
</div>
);
};
@@ -1,63 +0,0 @@
import { CreateLinkEvent, MarkMentionsReadComposer } from '@nitrots/nitro-renderer';
import { FC, MouseEvent, useEffect } from 'react';
import { FaTimes } from 'react-icons/fa';
import { LocalizeText, SendMessageComposer } from '../../api';
import { LayoutAvatarImageView } from '../../common';
import { useExternalSnapshot } from '../../hooks/events/useExternalSnapshot';
import { markRead } from '../../hooks/mentions/mentionsStore';
import { dismissMentionToast, getMentionToasts, MentionToast, subscribeMentionToasts } from '../../hooks/mentions/mentionToastsStore';
// Quanto resta visibile un toast prima di nascondersi da solo (resta non-letto).
const AUTO_DISMISS_MS = 8000;
const MentionToastItemView: FC<{ toast: MentionToast }> = ({ toast }) =>
{
useEffect(() =>
{
const timer = window.setTimeout(() => dismissMentionToast(toast.mentionId), AUTO_DISMISS_MS);
return () => window.clearTimeout(timer);
}, [ toast.mentionId ]);
// Dismiss esplicito: segna letta (badge toolbar si aggiorna) + persiste sul server + chiude.
const onDismiss = (event: MouseEvent) =>
{
event.stopPropagation();
markRead(toast.mentionId);
SendMessageComposer(new MarkMentionsReadComposer(1, toast.mentionId));
dismissMentionToast(toast.mentionId);
};
const onOpen = () =>
{
CreateLinkEvent('mentions/toggle');
dismissMentionToast(toast.mentionId);
};
return (
<div className="mention-toast" onClick={ onOpen }>
<div className="mention-toast-avatar">
<LayoutAvatarImageView headOnly direction={ 2 } figure={ toast.senderFigure } />
</div>
<div className="mention-toast-body">
<div className="mention-toast-title">{ toast.senderUsername }</div>
<div className="mention-toast-message">{ toast.message }</div>
</div>
<button className="mention-toast-dismiss" title={ LocalizeText('generic.cancel') } type="button" onClick={ onDismiss }>
<FaTimes />
</button>
</div>
);
};
export const MentionToastsView: FC = () =>
{
const toasts = useExternalSnapshot(subscribeMentionToasts, getMentionToasts);
if(!toasts || !toasts.length) return null;
return (
<div className="mention-toasts">
{ toasts.map(toast => <MentionToastItemView key={ toast.mentionId } toast={ toast } />) }
</div>
);
};
+65 -37
View File
@@ -1,13 +1,12 @@
import { MarkMentionsReadComposer } from '@nitrots/nitro-renderer';
import { FC, useCallback, useMemo, useState } from 'react';
import { IMentionEntry, LocalizeText, MentionType, SendMessageComposer } from '../../api';
import { Button, Flex, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common';
import { useMentionsSnapshot } from '../../hooks';
import { MarkMentionsReadComposer, RequestMentionsComposer } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { FaSearch, FaSync } from 'react-icons/fa';
import { getMentionDateGroup, IMentionEntry, LocalizeText, MentionDateGroup, MentionType, SendMessageComposer } from '../../api';
import { Button, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../common';
import { useMentionActions, useMentionsSnapshot } from '../../hooks';
import { markAllRead } from '../../hooks/mentions/mentionsStore';
import { useUserDataSnapshot } from '../../hooks/session/useSessionSnapshots';
import { MentionRowView } from './MentionRowView';
import { getMentionDateGroup, MentionDateGroup } from './mentionsFormat';
import { useMentionActions } from './useMentionActions';
interface MentionsViewProps
{
@@ -41,6 +40,17 @@ const matchesFilter = (mention: IMentionEntry, filter: MentionFilter): boolean =
}
};
const matchesQuery = (mention: IMentionEntry, query: string): boolean =>
{
if(!query) return true;
const q = query.toLowerCase();
return ((mention.senderUsername || '').toLowerCase().includes(q)
|| (mention.roomName || '').toLowerCase().includes(q)
|| (mention.message || '').toLowerCase().includes(q));
};
export const MentionsView: FC<MentionsViewProps> = props =>
{
const { onClose } = props;
@@ -48,6 +58,15 @@ export const MentionsView: FC<MentionsViewProps> = props =>
const { userName: ownUsername = '' } = useUserDataSnapshot();
const { open, goto, remove } = useMentionActions();
const [ filter, setFilter ] = useState<MentionFilter>('all');
const [ query, setQuery ] = useState('');
// Re-request from the server: once on open, and on the manual refresh button.
const refresh = useCallback(() => SendMessageComposer(new RequestMentionsComposer()), []);
useEffect(() =>
{
refresh();
}, [ refresh ]);
const onMarkAll = useCallback(() =>
{
@@ -62,48 +81,57 @@ export const MentionsView: FC<MentionsViewProps> = props =>
for(const mention of mentions)
{
if(!matchesFilter(mention, filter)) continue;
if(!matchesQuery(mention, query)) continue;
buckets[getMentionDateGroup(mention.timestamp)].push(mention);
}
return GROUP_ORDER
.map(key => ({ key, items: buckets[key] }))
.filter(group => group.items.length > 0);
}, [ mentions, filter ]);
}, [ mentions, filter, query ]);
const hasAny = groups.length > 0;
const title = `${ LocalizeText('mentions.window.title') }${ (unreadCount > 0) ? ` (${ unreadCount })` : '' }`;
return (
<NitroCardView className="w-[360px] h-[440px] has-classic-scrollbar" theme="primary-slim" uniqueKey="mentions">
<NitroCardHeaderView headerText={ LocalizeText('mentions.window.title') } onCloseClick={ onClose } />
<NitroCardView className="mentions-window w-[360px] has-classic-scrollbar" theme="primary-slim" uniqueKey="mentions">
<NitroCardHeaderView headerText={ title } onCloseClick={ onClose } />
<NitroCardContentView gap={ 1 }>
<Flex alignItems="center" className="flex-wrap" gap={ 1 }>
{ FILTERS.map(({ key, label }) =>
{
const active = (filter === key);
const showCount = ((key === 'unread') && (unreadCount > 0));
<div className="mentions-search">
<FaSearch className="mentions-search-icon" />
<input
type="text"
value={ query }
placeholder={ LocalizeText('generic.search') }
onChange={ event => setQuery(event.target.value) } />
</div>
<div className="mentions-toolbar">
<div className="mentions-filters">
{ FILTERS.map(({ key, label }) =>
{
const active = (filter === key);
const showCount = ((key === 'unread') && (unreadCount > 0));
return (
<button
key={ key }
type="button"
onClick={ () => setFilter(key) }
className={ `px-2 py-[2px] rounded-full text-xs border transition-colors ${ active ? 'bg-[#1e7295] text-white border-[#1e7295]' : 'bg-black/5 text-black/70 border-transparent hover:bg-black/10' }` }>
{ LocalizeText(label) }{ showCount ? ` (${ unreadCount })` : '' }
</button>
);
}) }
</Flex>
<Flex grow column className="min-h-0 overflow-y-auto" gap={ 0 }>
return (
<button key={ key } type="button" className={ `mentions-filter ${ active ? 'is-active' : '' }` } onClick={ () => setFilter(key) }>
{ LocalizeText(label) }{ showCount ? ` ${ unreadCount }` : '' }
</button>
);
}) }
</div>
<button type="button" className="mentions-refresh" title="Aggiorna" onClick={ refresh }>
<FaSync />
</button>
</div>
<div className="mentions-list">
{ !hasAny &&
<Flex grow column center gap={ 2 } className="py-6 text-center">
<span className="flex items-center justify-center w-[44px] h-[44px] rounded-full bg-black/5 text-[#1e7295] text-[22px] font-bold">@</span>
<Text center variant="gray">{ LocalizeText('mentions.window.empty') }</Text>
</Flex> }
<div className="mentions-empty">
<span className="mentions-empty-glyph">@</span>
<span className="mentions-empty-text">{ LocalizeText('mentions.window.empty') }</span>
</div> }
{ hasAny && groups.map(group => (
<Flex key={ group.key } column gap={ 0 }>
<Text small bold variant="gray" className="px-1 pt-2 pb-[2px] uppercase tracking-wide">
{ LocalizeText(GROUP_LABEL[group.key]) }
</Text>
<div key={ group.key } className="mentions-group">
<div className="mentions-group-label">{ LocalizeText(GROUP_LABEL[group.key]) }</div>
{ group.items.map(mention => (
<MentionRowView
key={ mention.mentionId }
@@ -113,9 +141,9 @@ export const MentionsView: FC<MentionsViewProps> = props =>
onRemove={ remove }
ownUsername={ ownUsername } />
)) }
</Flex>
</div>
)) }
</Flex>
</div>
{ (unreadCount > 0) &&
<Button variant="primary" onClick={ onMarkAll }>{ LocalizeText('mentions.window.markall') }</Button> }
</NitroCardContentView>
-3
View File
@@ -1,6 +1,3 @@
export * from './MentionMessageView';
export * from './MentionRowView';
export * from './MentionsView';
export * from './MentionToastsView';
export * from './mentionsFormat';
export * from './useMentionActions';
@@ -1,7 +1,8 @@
import { NotificationBubbleItem, NotificationBubbleType } from '../../../../api';
import { MentionNotificationBubbleItem, NotificationBubbleItem, NotificationBubbleType } from '../../../../api';
import { NotificationBadgeReceivedBubbleView } from './NotificationBadgeReceivedBubbleView';
import { NotificationClubGiftBubbleView } from './NotificationClubGiftBubbleView';
import { NotificationDefaultBubbleView } from './NotificationDefaultBubbleView';
import { NotificationMentionBubbleView } from './NotificationMentionBubbleView';
export const GetBubbleLayout = (item: NotificationBubbleItem, onClose: () => void) =>
{
@@ -15,6 +16,8 @@ export const GetBubbleLayout = (item: NotificationBubbleItem, onClose: () => voi
return <NotificationBadgeReceivedBubbleView key={ item.id } { ...props } />;
case NotificationBubbleType.CLUBGIFT:
return <NotificationClubGiftBubbleView key={ item.id } { ...props } />;
case NotificationBubbleType.MENTION:
return <NotificationMentionBubbleView key={ item.id } item={ item as MentionNotificationBubbleItem } onClose={ onClose } />;
default:
return <NotificationDefaultBubbleView key={ item.id } { ...props } />;
}
@@ -0,0 +1,89 @@
import { CreateLinkEvent, MarkMentionsReadComposer } from '@nitrots/nitro-renderer';
import { FC, MouseEvent } from 'react';
import { FaTimes } from 'react-icons/fa';
import { formatMentionTime, LocalizeText, MentionNotificationBubbleItem, MentionType, SendMessageComposer } from '../../../../api';
import { Flex, LayoutAvatarImageView, LayoutNotificationBubbleView, LayoutNotificationBubbleViewProps, Text } from '../../../../common';
import { markRead } from '../../../../hooks/mentions/mentionsStore';
import { useUserDataSnapshot } from '../../../../hooks/session/useSessionSnapshots';
import { MentionMessageView } from '../../../mentions/MentionMessageView';
export interface NotificationMentionBubbleViewProps extends LayoutNotificationBubbleViewProps
{
item: MentionNotificationBubbleItem;
}
export const NotificationMentionBubbleView: FC<NotificationMentionBubbleViewProps> = props =>
{
const { item = null, onClose = null, ...rest } = props;
const { userName: ownUsername = '' } = useUserDataSnapshot();
const mention = item.mention;
const isRoom = (mention.mentionType === MentionType.ROOM);
const time = formatMentionTime(mention.timestamp);
const markReadOnServer = () =>
{
markRead(mention.mentionId);
SendMessageComposer(new MarkMentionsReadComposer(1, mention.mentionId));
};
// Whole-bubble click opens the mentions panel (and dismisses the bubble).
const open = () =>
{
CreateLinkEvent('mentions/toggle');
onClose();
};
const goto = () =>
{
markReadOnServer();
if(mention.roomId > 0) CreateLinkEvent(`navigator/goto/${ mention.roomId }`);
onClose();
};
const act = (event: MouseEvent, fn: () => void) =>
{
event.stopPropagation();
fn();
};
return (
<LayoutNotificationBubbleView
alignItems="start"
gap={ 2 }
classNames={ [ 'w-[330px]', 'max-w-[92vw]', 'cursor-pointer' ] }
onClick={ open }
onClose={ onClose }
{ ...rest }>
<div className="mention-toast-avatar">
<LayoutAvatarImageView headOnly direction={ 2 } figure={ mention.senderFigure } />
</div>
<Flex column gap={ 0 } className="min-w-0 flex-1">
<Flex alignItems="center" gap={ 1 } className="min-w-0">
<Text bold truncate variant="white">{ mention.senderUsername }</Text>
<span className={ `mention-toast-chip ${ isRoom ? 'is-room' : 'is-direct' }` }>
{ LocalizeText(isRoom ? 'mentions.type.room' : 'mentions.type.direct') }
</span>
<span className="mention-toast-spacer" />
{ (time.length > 0) &&
<span className="mention-toast-time">{ time }</span> }
<button type="button" className="mention-toast-dismiss" title={ LocalizeText('generic.cancel') } onClick={ event => act(event, onClose) }>
<FaTimes />
</button>
</Flex>
{ (mention.roomName && (mention.roomName.length > 0)) &&
<span className="mention-toast-room">· { mention.roomName }</span> }
<MentionMessageView className="mention-toast-message" ownUsername={ ownUsername } text={ mention.message } />
<Flex gap={ 1 } className="mention-toast-actions">
<button type="button" className="mention-toast-btn" onClick={ event => act(event, open) }>
{ LocalizeText('mentions.window.title') }
</button>
{ (mention.roomId > 0) &&
<button type="button" className="mention-toast-btn" onClick={ event => act(event, goto) }>
{ LocalizeText('mentions.action.goto') }
</button> }
</Flex>
</Flex>
</LayoutNotificationBubbleView>
);
};
@@ -18,6 +18,56 @@ const PICKUP_MODE_NONE: number = 0;
const PICKUP_MODE_EJECT: number = 1;
const PICKUP_MODE_FULL: number = 2;
function getValidRoomObjectDirection(roomObject: any, isPositive: boolean)
{
if(!roomObject || !roomObject.model) return 0;
let allowedDirections: number[] = [];
if(roomObject.type === 'monster_plant')
{
allowedDirections = roomObject.model.getValue('pet_allowed_directions');
}
else
{
allowedDirections = roomObject.model.getValue('furniture_allowed_directions');
}
let direction = roomObject.getDirection().x;
if(allowedDirections && allowedDirections.length)
{
let index = allowedDirections.indexOf(direction);
if(index < 0)
{
index = 0;
for(let i = 0; i < allowedDirections.length; i++)
{
if(direction <= allowedDirections[i]) break;
index++;
}
index = index % allowedDirections.length;
}
if(isPositive)
{
index = (index + 1) % allowedDirections.length;
}
else
{
index = (index - 1 + allowedDirections.length) % allowedDirections.length;
}
direction = allowedDirections[index];
}
return direction;
}
export const InfoStandWidgetFurniView: FC<InfoStandWidgetFurniViewProps> = props =>
{
const { avatarInfo = null, onClose = null } = props;
@@ -78,56 +128,6 @@ export const InfoStandWidgetFurniView: FC<InfoStandWidgetFurniViewProps> = props
SendMessageComposer(new UpdateFurniturePositionComposer(avatarInfo.id, newX, newY, Math.round(newZ * 10000), newDirection));
}, [ avatarInfo ]);
function getValidRoomObjectDirection(roomObject: any, isPositive: boolean)
{
if(!roomObject || !roomObject.model) return 0;
let allowedDirections: number[] = [];
if(roomObject.type === 'monster_plant')
{
allowedDirections = roomObject.model.getValue('pet_allowed_directions');
}
else
{
allowedDirections = roomObject.model.getValue('furniture_allowed_directions');
}
let direction = roomObject.getDirection().x;
if(allowedDirections && allowedDirections.length)
{
let index = allowedDirections.indexOf(direction);
if(index < 0)
{
index = 0;
for(let i = 0; i < allowedDirections.length; i++)
{
if(direction <= allowedDirections[i]) break;
index++;
}
index = index % allowedDirections.length;
}
if(isPositive)
{
index = (index + 1) % allowedDirections.length;
}
else
{
index = (index - 1 + allowedDirections.length) % allowedDirections.length;
}
direction = allowedDirections[index];
}
return direction;
}
const handleHeightChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) =>
{
let newZ = parseFloat(event.target.value);
@@ -421,7 +421,11 @@ export const InfoStandWidgetFurniView: FC<InfoStandWidgetFurniViewProps> = props
if(key === 'offsetX') value = String(x);
else if(key === 'offsetY') value = String(y);
else if(key === 'offsetZ') value = String(z);
else if(key === 'scale') { value = String(scale); hasScale = true; }
else if(key === 'scale')
{
value = String(scale);
hasScale = true;
}
clone[i] = value;
map.set(key, value);
@@ -638,6 +642,20 @@ export const InfoStandWidgetFurniView: FC<InfoStandWidgetFurniViewProps> = props
})() }</Text>
</div>
</div> }
{ isModerator &&
<button
className="w-full text-white text-xs bg-[#1e7295] hover:bg-[#1a617f] border border-[#ffffff33] rounded px-2 py-1 cursor-pointer transition-colors"
onClick={ () =>
{
const roomObject = GetRoomEngine().getRoomObject(roomSession.roomId, avatarInfo.id, avatarInfo.isWallItem ? RoomObjectCategory.WALL : RoomObjectCategory.FLOOR);
const typeId = roomObject?.model?.getValue(RoomObjectVariable.FURNITURE_TYPE_ID);
CreateLinkEvent('furni-editor/show');
if(typeId) window.dispatchEvent(new CustomEvent('furni-editor:open', { detail: { spriteId: typeId } }));
} }>
Edit Furni
</button> }
{ (!avatarInfo.isWallItem && canMove) &&
<>
<button
@@ -645,20 +663,6 @@ export const InfoStandWidgetFurniView: FC<InfoStandWidgetFurniViewProps> = props
onClick={ () => setDropdownOpen(!dropdownOpen) }>
{ dropdownOpen ? `${LocalizeText('widget.furni.present.close')} Buildtools` : `${LocalizeText('navigator.roomsettings.doormode.open')} Buildtools` }
</button>
{ isModerator &&
<button
className="w-full text-white text-xs bg-[#1e7295] hover:bg-[#1a617f] border border-[#ffffff33] rounded px-2 py-1 cursor-pointer transition-colors"
onClick={ () =>
{
const roomObject = GetRoomEngine().getRoomObject(roomSession.roomId, avatarInfo.id, avatarInfo.isWallItem ? RoomObjectCategory.WALL : RoomObjectCategory.FLOOR);
const typeId = roomObject?.model?.getValue(RoomObjectVariable.FURNITURE_TYPE_ID);
CreateLinkEvent('furni-editor/show');
if(typeId) window.dispatchEvent(new CustomEvent('furni-editor:open', { detail: { spriteId: typeId } }));
} }>
Edit Furni
</button> }
{ dropdownOpen &&
<div className="flex gap-[4px] w-full">
{ /* Left panel: position + rotation */ }
@@ -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
{
@@ -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 {
+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);
}
+11 -7
View File
@@ -536,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;
}
+47 -4
View File
@@ -1,4 +1,4 @@
import { FurniEditorBySpriteComposer, FurniEditorDeleteComposer, FurniEditorDetailComposer, FurniEditorDetailResultEvent, FurniEditorInteractionsComposer, FurniEditorInteractionsResultEvent, FurniEditorResultEvent, FurniEditorSearchComposer, FurniEditorSearchResultEvent, FurniEditorUpdateComposer } from '@nitrots/nitro-renderer';
import { FurniEditorBySpriteComposer, FurniEditorDeleteComposer, FurniEditorDetailComposer, FurniEditorDetailResultEvent, FurniEditorInteractionsComposer, FurniEditorInteractionsResultEvent, FurniEditorResultEvent, FurniEditorRevertFurnidataComposer, FurniEditorSearchComposer, FurniEditorSearchResultEvent, FurniEditorUpdateComposer, FurniEditorUpdateFurnidataComposer, FurniEditorImportTextComposer, FurniEditorImportTextResultEvent } from '@nitrots/nitro-renderer';
import { useCallback, useRef, useState } from 'react';
import { NotificationAlertType, SendMessageComposer } from '../../api';
import { useMessageEvent, useNotification } from '../../hooks';
@@ -61,6 +61,8 @@ export const useFurniEditor = () =>
const [ interactions, setInteractions ] = useState<string[]>([]);
const [ furniDataEntry, setFurniDataEntry ] = useState<Record<string, unknown> | null>(null);
const pendingActionRef = useRef<{ action: string; itemId: number } | null>(null);
const [ importResult, setImportResult ] = useState<{ found: boolean; name: string; description: string; classname: string; nonce: number } | null>(null);
const importNonceRef = useRef(0);
const { simpleAlert = null } = useNotification();
const clearError = useCallback(() => setError(null), []);
@@ -209,11 +211,11 @@ export const useFurniEditor = () =>
}
});
const searchItems = useCallback((query: string, type: string, pg: number) =>
const searchItems = useCallback((query: string, type: string, pg: number, sortField: string = 'id', sortDir: string = 'asc') =>
{
setLoading(true);
setError(null);
SendMessageComposer(new FurniEditorSearchComposer(query, type, pg));
SendMessageComposer(new FurniEditorSearchComposer(query, type, pg, sortField, sortDir));
}, []);
const loadDetail = useCallback((id: number) =>
@@ -246,6 +248,46 @@ export const useFurniEditor = () =>
SendMessageComposer(new FurniEditorDeleteComposer(id));
}, []);
const updateFurnidata = useCallback((id: number, name: string, description: string) =>
{
pendingActionRef.current = { action: 'update', itemId: id };
// Optimistic: the server now mirrors the furnidata display name into
// items_base.public_name, so reflect it immediately in the read-only
// "Public Name" field. The auto re-fetch that follows will agree (no flicker).
setSelectedItem(prev => (prev && prev.id === id ? { ...prev, publicName: name } : prev));
setLoading(true);
SendMessageComposer(new FurniEditorUpdateFurnidataComposer(id, JSON.stringify({ name, description })));
}, []);
const revertFurnidata = useCallback((id: number) =>
{
pendingActionRef.current = { action: 'update', itemId: id };
setLoading(true);
SendMessageComposer(new FurniEditorRevertFurnidataComposer(id));
}, []);
const importText = useCallback((id: number) =>
{
setLoading(true);
setError(null);
SendMessageComposer(new FurniEditorImportTextComposer(id));
}, []);
useMessageEvent(FurniEditorImportTextResultEvent, (event: FurniEditorImportTextResultEvent) =>
{
const parser = event.getParser();
setLoading(false);
importNonceRef.current += 1;
setImportResult({
found: parser.found,
name: parser.name,
description: parser.description,
classname: parser.classname,
nonce: importNonceRef.current
});
});
const loadInteractions = useCallback(() =>
{
SendMessageComposer(new FurniEditorInteractionsComposer());
@@ -255,6 +297,7 @@ export const useFurniEditor = () =>
items, total, page, loading, error, clearError,
selectedItem, setSelectedItem, catalogItems, furniDataEntry,
interactions,
searchItems, loadDetail, loadBySpriteId, updateItem, deleteItem, loadInteractions
searchItems, loadDetail, loadBySpriteId, updateItem, deleteItem, loadInteractions,
updateFurnidata, revertFurnidata, importText, importResult
};
};
+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 };
};
+1
View File
@@ -25,6 +25,7 @@ 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';