diff --git a/src/api/mentions/index.ts b/src/api/mentions/index.ts index 57433b6..d6edd8c 100644 --- a/src/api/mentions/index.ts +++ b/src/api/mentions/index.ts @@ -1,2 +1,4 @@ export * from './MentionType'; export * from './IMentionEntry'; +export * from './mentionTokens'; +export * from './mentionsFormat'; diff --git a/src/api/mentions/mentionTokens.test.ts b/src/api/mentions/mentionTokens.test.ts new file mode 100644 index 0000000..25ff678 --- /dev/null +++ b/src/api/mentions/mentionTokens.test.ts @@ -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); + }); +}); diff --git a/src/api/mentions/mentionTokens.ts b/src/api/mentions/mentionTokens.ts new file mode 100644 index 0000000..94224f7 --- /dev/null +++ b/src/api/mentions/mentionTokens.ts @@ -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 = [ + '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 = 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 = MENTION_ROOM_ALIASES +): boolean => (classifyMentionToken(token, ownUsername, aliases) === 'self'); diff --git a/src/components/mentions/mentionsFormat.test.ts b/src/api/mentions/mentionsFormat.test.ts similarity index 100% rename from src/components/mentions/mentionsFormat.test.ts rename to src/api/mentions/mentionsFormat.test.ts diff --git a/src/components/mentions/mentionsFormat.ts b/src/api/mentions/mentionsFormat.ts similarity index 100% rename from src/components/mentions/mentionsFormat.ts rename to src/api/mentions/mentionsFormat.ts diff --git a/src/api/notification/MentionNotificationBubbleItem.ts b/src/api/notification/MentionNotificationBubbleItem.ts new file mode 100644 index 0000000..ad5f701 --- /dev/null +++ b/src/api/notification/MentionNotificationBubbleItem.ts @@ -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; + } +} diff --git a/src/api/notification/NotificationBubbleType.ts b/src/api/notification/NotificationBubbleType.ts index 858573b..ab3a48a 100644 --- a/src/api/notification/NotificationBubbleType.ts +++ b/src/api/notification/NotificationBubbleType.ts @@ -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'; } diff --git a/src/api/notification/index.ts b/src/api/notification/index.ts index 23476d3..a631834 100644 --- a/src/api/notification/index.ts +++ b/src/api/notification/index.ts @@ -1,5 +1,6 @@ export * from './NotificationAlertItem'; export * from './NotificationAlertType'; +export * from './MentionNotificationBubbleItem'; export * from './NotificationBubbleItem'; export * from './NotificationBubbleType'; export * from './NotificationConfirmItem'; diff --git a/src/components/MainView.tsx b/src/components/MainView.tsx index 32eb535..caa04ab 100644 --- a/src/components/MainView.tsx +++ b/src/components/MainView.tsx @@ -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('radio_ui.enabled', false) && } { (GetConfigurationValue('mentions_ui.enabled', true) && mentionsVisible) && setMentionsVisible(false) } /> } - { GetConfigurationValue('mentions_ui.enabled', true) && - } ); diff --git a/src/components/badge-leaderboard/BadgeLeaderboardView.tsx b/src/components/badge-leaderboard/BadgeLeaderboardView.tsx index 7af23d0..fa31cc3 100644 --- a/src/components/badge-leaderboard/BadgeLeaderboardView.tsx +++ b/src/components/badge-leaderboard/BadgeLeaderboardView.tsx @@ -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 = 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 = props =>
{ entry.rank }
- +
{ entry.username } { entry.score } diff --git a/src/components/chat-history/ChatHistoryView.tsx b/src/components/chat-history/ChatHistoryView.tsx index 9fb6dd8..0a1283e 100644 --- a/src/components/chat-history/ChatHistoryView.tsx +++ b/src/components/chat-history/ChatHistoryView.tsx @@ -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'; diff --git a/src/components/friends/views/friends-bar/FriendsBarView.tsx b/src/components/friends/views/friends-bar/FriendsBarView.tsx index de51519..9234a07 100644 --- a/src/components/friends/views/friends-bar/FriendsBarView.tsx +++ b/src/components/friends/views/friends-bar/FriendsBarView.tsx @@ -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(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 ( }
{ - if(indexOffset > 0) setIndexOffset(indexOffset - 1); + if(canScrollLeft) setIndexOffset(safeOffset - 1); } } > @@ -94,10 +152,10 @@ export const FriendBarView: FC<{ onlineFriends: MessengerFriend[]; requestsCount
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); } } > diff --git a/src/components/friends/views/messenger/FriendsMessengerView.tsx b/src/components/friends/views/messenger/FriendsMessengerView.tsx index 0eaf484..b456b8b 100644 --- a/src/components/friends/views/messenger/FriendsMessengerView.tsx +++ b/src/components/friends/views/messenger/FriendsMessengerView.tsx @@ -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(null); @@ -133,12 +135,22 @@ export const FriendsMessengerView: FC<{}> = props =>
{ 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 ( ); diff --git a/src/components/friends/views/messenger/messenger-thread/FriendsMessengerThreadGroup.tsx b/src/components/friends/views/messenger/messenger-thread/FriendsMessengerThreadGroup.tsx index 97d74bf..aed920c 100644 --- a/src/components/friends/views/messenger/messenger-thread/FriendsMessengerThreadGroup.tsx +++ b/src/components/friends/views/messenger/messenger-thread/FriendsMessengerThreadGroup.tsx @@ -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 { ((group.type === MessengerGroupType.PRIVATE_CHAT) && !isOwnChat) && - } + } { (groupChatData && !isOwnChat) && } diff --git a/src/components/furni-editor/FurniEditorView.tsx b/src/components/furni-editor/FurniEditorView.tsx index 65206ba..c36ed09 100644 --- a/src/components/furni-editor/FurniEditorView.tsx +++ b/src/components/furni-editor/FurniEditorView.tsx @@ -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 } /> } diff --git a/src/components/furni-editor/views/FurniEditorEditView.tsx b/src/components/furni-editor/views/FurniEditorEditView.tsx index 0d9fe2d..5c7a86b 100644 --- a/src/components/furni-editor/views/FurniEditorEditView.tsx +++ b/src/components/furni-editor/views/FurniEditorEditView.tsx @@ -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) => 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 = { @@ -21,9 +26,8 @@ const FIELD_TIPS: Record = { }; 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 = ({ title, children, defaultOpen = true }) => const [ open, setOpen ] = useState(defaultOpen); return ( -
+
- { open &&
{ children }
} + { open &&
{ children }
}
); }; @@ -50,22 +54,74 @@ const Section: FC = ({ title, children, defaultOpen = true }) => const Tip: FC<{ field: string }> = ({ field }) => { const tip = FIELD_TIPS[field]; + const ref = useRef(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 ( - - ? - - { tip } - + + ? + { pos && createPortal( + + { tip } + , document.body) } ); }; +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 ( +
+ { String(value) } + { copied ? 'copied!' : 'copy' } +
+ ); +}; + export const FurniEditorEditView: FC = 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 = 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 = 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 = 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 = 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 ( - + { /* Header */ } - - -
- + +
+
- - - ID: { item.id } - | - Sprite: { item.spriteId } + + { furniName || form.publicName || form.itemName } + { form.itemName } + + + ID + { item.id } + + + Sprite + { item.spriteId } + + 0 ? 'border-[#a7f3d0] bg-[#ecfdf5] text-[#047857]' : 'border-slate-200 bg-slate-50 text-slate-500' }` }> + 0 ? 'bg-[#10b981]' : 'bg-slate-300' }` } /> + { item.usageCount } in use + + { isDirty && + + Unsaved + } - ({ item.usageCount } in use) - { isDirty && Unsaved changes } + + { /* Primary edit surface: furnidata display name + description (server-authoritative, live) */ } +
+
+ Display name & description + { furnidataEditable + ? LIVE + : NO FURNIDATA } + { furnidataEditable && furnidataDirty && + Unsaved } +
+ { furnidataEditable ? ( + <> +
+
+ + setFurniName(e.target.value) } maxLength={ 256 } /> +
+
+ + setFurniDescription(e.target.value) } maxLength={ 256 } /> +
+
+ + + + + + { importNote && + { importNote } } + + ) : ( +
+ + This furni has no matching furnidata entry (e.g. a pet or custom item), so its display name can't be edited here. Clients fall back to the DB Public Name below. +
+ ) } +
+
- - setField('itemName', e.target.value) } /> - { validation.itemName && { validation.itemName } } + +
- - setField('publicName', e.target.value) } /> - { validation.publicName && { validation.publicName } } + +
- setField('spriteId', Number(e.target.value)) } /> +
- +
+ { furniDataEntry && +
+ Read-only — how this furni resolves from the furnidata JSON (source of truth for the display name). +
{ JSON.stringify(furniDataEntry, null, 2) }
+
+ } +
@@ -279,19 +448,24 @@ export const FurniEditorEditView: FC = props =>
{ PERM_GROUPS.map(group => (
- { group.label } -
- { group.keys.map(key => ( - - )) } + { group.label } +
+ { group.keys.map(key => { + const on = (form as any)[key]; + return ( + + ); + }) }
)) } @@ -302,7 +476,7 @@ export const FurniEditorEditView: FC = props =>
- setField('interactionType', e.target.value) }> { interactions.map(i => ( @@ -320,19 +494,6 @@ export const FurniEditorEditView: FC = props =>
- { furniDataEntry && -
-
- { Object.entries(furniDataEntry).map(([ key, value ]) => ( -
- { key } - { String(value ?? '') } -
- )) } -
-
- } - { /* Actions */ } @@ -352,8 +513,8 @@ export const FurniEditorEditView: FC = props => { /* Delete Confirmation Dialog */ } { showDeleteDialog && -
setShowDeleteDialog(false) }> -
e.stopPropagation() }> +
setShowDeleteDialog(false) }> +
e.stopPropagation() }> Delete Item? Are you sure you want to delete { item.publicName || item.itemName } (ID: { item.id })? @@ -366,6 +527,21 @@ export const FurniEditorEditView: FC = props =>
} + + { /* Furnidata Confirmation Dialog */ } + { confirmFurnidata && +
setConfirmFurnidata(false) }> +
e.stopPropagation() }> + Apply furnidata change to ALL clients? +
Name: { String(furniDataEntry?.name ?? '') } → { furniName }
+
Desc: { String(furniDataEntry?.description ?? '') } → { furniDescription }
+ + + + +
+
+ } ); }; diff --git a/src/components/furni-editor/views/FurniEditorSearchView.tsx b/src/components/furni-editor/views/FurniEditorSearchView.tsx index a1eccfb..9164951 100644 --- a/src/components/furni-editor/views/FurniEditorSearchView.tsx +++ b/src/components/furni-editor/views/FurniEditorSearchView.tsx @@ -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 ; + if(field !== active) return ; - return { dir === 'asc' ? '▲' : '▼' }; + return { dir === 'asc' ? '▲' : '▼' }; }; +const PagBtn: FC<{ disabled?: boolean; onClick: () => void; children: ReactNode; title?: string }> = ({ disabled, onClick, children, title }) => ( + +); + export const FurniEditorSearchView: FC = props => { const { items, total, page, loading, onSearch, onSelect } = props; @@ -29,184 +52,198 @@ export const FurniEditorSearchView: FC = props => const [ typeFilter, setTypeFilter ] = useState(''); const [ sortField, setSortField ] = useState('id'); const [ sortDir, setSortDir ] = useState('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 ( - - - - Search + + { /* Search + filters */ } + +
+ + + setQuery(e.target.value) } - onKeyDown={ handleKeyDown } /> - - - { [ '', 's', 'i' ].map(t => ( + { query && - )) } + 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" + >✕ } +
+ + { [ '', 's', 'i' ].map(t => + { + const on = typeFilter === t; + + return ( + + ); + }) } -
- { total > 0 && - - { total } items found + { /* Result count + activity */ } + + + { total > 0 ? `Showing ${ from }–${ to } of ${ total.toLocaleString() }` : (loading ? 'Searching…' : 'No results') } - } + { loading && } + - - + { /* Table */ } +
+
- - - - - - - - + + + { COLUMNS.map(c => ( + + )) } - { sortedItems.map(item => ( + { items.map(item => ( onSelect(item.id) } + className="group cursor-pointer border-b border-slate-100 last:border-0 hover:bg-slate-50 transition-colors" > - - - - - - + + + + - + )) } + { items.length === 0 && loading && + Array.from({ length: 8 }).map((_, i) => ( + + + { COLUMNS.map(c => ) } + + )) } { items.length === 0 && !loading && - - - } + + }
handleSort('id') }> - ID - handleSort('spriteId') }> - Sprite - handleSort('itemName') }> - Name - handleSort('publicName') }> - Public Name - handleSort('type') }> - Type - handleSort('interactionType') }> - Interaction -
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 } +
- + +
+ +
{ item.id }{ item.spriteId }{ item.itemName }{ item.publicName } - + { item.id }{ item.spriteId }{ item.itemName }{ item.publicName || '—' } + { item.type === 's' ? 'Floor' : 'Wall' } { item.interactionType || '-' } + { item.interactionType + ? { item.interactionType } + : } +
No items found
+
No furni found
+
Try a different search or filter
+
-
+
- { totalPages > 1 && - - - Page { page }/{ totalPages } - - - - + { /* Pagination */ } + + { total.toLocaleString() } items + + goTo(1) }>« + goTo(page - 1) }>‹ + + 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" + /> + / { totalPages.toLocaleString() } + = totalPages } onClick={ () => goTo(page + 1) }>› + = totalPages } onClick={ () => goTo(totalPages) }>» - } + ); }; diff --git a/src/components/mentions/MentionMessageView.tsx b/src/components/mentions/MentionMessageView.tsx index 4f8d327..744b44e 100644 --- a/src/components/mentions/MentionMessageView.tsx +++ b/src/components/mentions/MentionMessageView.tsx @@ -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 = props => { @@ -24,12 +25,11 @@ export const MentionMessageView: FC = props => { if(segment.length === 0) return null; - if(/^\s+$/.test(segment) || !tokenIsMention(segment, ownUsername)) - { - return { segment }; - } + const kind = (/^\s+$/.test(segment)) ? '' : classifyMentionToken(segment, ownUsername); - return { segment }; + if(!kind) return { segment }; + + return { segment }; }); return { nodes }; diff --git a/src/components/mentions/MentionRowView.tsx b/src/components/mentions/MentionRowView.tsx index f943d1f..a3cf99b 100644 --- a/src/components/mentions/MentionRowView.tsx +++ b/src/components/mentions/MentionRowView.tsx @@ -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 = props => }; return ( - onOpen(mention) }> - - - { isRoom ? '@∗' : '@' } - - - - { mention.senderUsername } - { (mention.roomName && mention.roomName.length > 0) && - · { mention.roomName } } - - - - +
onOpen(mention) }> + { !mention.read && + } +
+ + { isRoom ? '∗' : '@' } +
+
+
+ { mention.senderUsername } + { (mention.roomName && (mention.roomName.length > 0)) && + · { mention.roomName } } +
+ +
+
{ (time.length > 0) && - { time } } - + { time } } +
{ onGoto && - stop(event, () => onGoto(mention)) }>→ } + } { onRemove && - stop(event, () => onRemove(mention)) }>✕ } - - - + } +
+
+
); }; diff --git a/src/components/mentions/MentionToastsView.tsx b/src/components/mentions/MentionToastsView.tsx deleted file mode 100644 index 6943986..0000000 --- a/src/components/mentions/MentionToastsView.tsx +++ /dev/null @@ -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 ( -
-
- -
-
-
{ toast.senderUsername }
-
{ toast.message }
-
- -
- ); -}; - -export const MentionToastsView: FC = () => -{ - const toasts = useExternalSnapshot(subscribeMentionToasts, getMentionToasts); - - if(!toasts || !toasts.length) return null; - - return ( -
- { toasts.map(toast => ) } -
- ); -}; diff --git a/src/components/mentions/MentionsView.tsx b/src/components/mentions/MentionsView.tsx index 3893005..b09da45 100644 --- a/src/components/mentions/MentionsView.tsx +++ b/src/components/mentions/MentionsView.tsx @@ -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 = props => { const { onClose } = props; @@ -48,6 +58,15 @@ export const MentionsView: FC = props => const { userName: ownUsername = '' } = useUserDataSnapshot(); const { open, goto, remove } = useMentionActions(); const [ filter, setFilter ] = useState('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 = 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 ( - - + + - - { FILTERS.map(({ key, label }) => - { - const active = (filter === key); - const showCount = ((key === 'unread') && (unreadCount > 0)); +
+ + setQuery(event.target.value) } /> +
+
+
+ { FILTERS.map(({ key, label }) => + { + const active = (filter === key); + const showCount = ((key === 'unread') && (unreadCount > 0)); - return ( - - ); - }) } - - + return ( + + ); + }) } +
+ +
+
{ !hasAny && - - @ - { LocalizeText('mentions.window.empty') } - } +
+ @ + { LocalizeText('mentions.window.empty') } +
} { hasAny && groups.map(group => ( - - - { LocalizeText(GROUP_LABEL[group.key]) } - +
+
{ LocalizeText(GROUP_LABEL[group.key]) }
{ group.items.map(mention => ( = props => onRemove={ remove } ownUsername={ ownUsername } /> )) } - +
)) } -
+
{ (unreadCount > 0) && }
diff --git a/src/components/mentions/index.ts b/src/components/mentions/index.ts index 8c125c1..ccdde36 100644 --- a/src/components/mentions/index.ts +++ b/src/components/mentions/index.ts @@ -1,6 +1,3 @@ export * from './MentionMessageView'; export * from './MentionRowView'; export * from './MentionsView'; -export * from './MentionToastsView'; -export * from './mentionsFormat'; -export * from './useMentionActions'; diff --git a/src/components/notification-center/views/bubble-layouts/GetBubbleLayout.tsx b/src/components/notification-center/views/bubble-layouts/GetBubbleLayout.tsx index f86f0f6..065629a 100644 --- a/src/components/notification-center/views/bubble-layouts/GetBubbleLayout.tsx +++ b/src/components/notification-center/views/bubble-layouts/GetBubbleLayout.tsx @@ -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 ; case NotificationBubbleType.CLUBGIFT: return ; + case NotificationBubbleType.MENTION: + return ; default: return ; } diff --git a/src/components/notification-center/views/bubble-layouts/NotificationMentionBubbleView.tsx b/src/components/notification-center/views/bubble-layouts/NotificationMentionBubbleView.tsx new file mode 100644 index 0000000..fb8006c --- /dev/null +++ b/src/components/notification-center/views/bubble-layouts/NotificationMentionBubbleView.tsx @@ -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 = 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 ( + +
+ +
+ + + { mention.senderUsername } + + { LocalizeText(isRoom ? 'mentions.type.room' : 'mentions.type.direct') } + + + { (time.length > 0) && + { time } } + + + { (mention.roomName && (mention.roomName.length > 0)) && + · { mention.roomName } } + + + + { (mention.roomId > 0) && + } + + +
+ ); +}; diff --git a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetFurniView.tsx b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetFurniView.tsx index 40b458f..76f9900 100644 --- a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetFurniView.tsx +++ b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetFurniView.tsx @@ -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 = props => { const { avatarInfo = null, onClose = null } = props; @@ -78,56 +128,6 @@ export const InfoStandWidgetFurniView: FC = 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) => { let newZ = parseFloat(event.target.value); @@ -421,7 +421,11 @@ export const InfoStandWidgetFurniView: FC = 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 = props })() }
} + { isModerator && + } { (!avatarInfo.isWallItem && canMove) && <> - { isModerator && - } { dropdownOpen &&
{ /* Left panel: position + rotation */ } diff --git a/src/components/room/widgets/chat-input/ChatInputMentionSelectorView.tsx b/src/components/room/widgets/chat-input/ChatInputMentionSelectorView.tsx index 1404185..8a67c8a 100644 --- a/src/components/room/widgets/chat-input/ChatInputMentionSelectorView.tsx +++ b/src/components/room/widgets/chat-input/ChatInputMentionSelectorView.tsx @@ -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 { diff --git a/src/components/room/widgets/chat-input/ChatInputView.tsx b/src/components/room/widgets/chat-input/ChatInputView.tsx index 551795a..34178e4 100644 --- a/src/components/room/widgets/chat-input/ChatInputView.tsx +++ b/src/components/room/widgets/chat-input/ChatInputView.tsx @@ -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 = { - everyone: 'mentions_ui.aliases.everyone', - friends: 'mentions_ui.aliases.friends', - room: 'mentions_ui.aliases.room' -}; - -const MENTION_ALIAS_DEFAULTS: Record = { - everyone: [ 'all', 'everyone', 'tutti' ], - friends: [ 'friends', 'amici' ], - room: [ 'room', 'stanza' ] -}; - -const MENTION_ALIAS_DESCRIPTION_KEY: Record = { - 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(''); @@ -56,129 +18,13 @@ export const ChatInputView: FC<{}> = props => const inputRef = useRef(null); const { isVisible: commandSelectorVisible, filteredCommands, selectedIndex, setSelectedIndex, moveUp, moveDown, selectCurrent, close: closeCommandSelector } = useChatCommandSelector(chatValue); - const roomUserList = useRoomUserListSnapshot(); - const [ mentionSelectedIndex, setMentionSelectedIndex ] = useState(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>(() => - { - const out: { key: string; scope: MentionAliasScope; description: string }[] = []; - const seen = new Set(); - - const scopes: MentionAliasScope[] = [ 'everyone', 'friends', 'room' ]; - for(const scope of scopes) - { - const list = sanitizeAliasList( - GetConfigurationValue(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(() => - { - 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.CHAT_INPUT_CONTENT, event => { @@ -492,12 +327,12 @@ export const ChatInputView: FC<{}> = props => onHover={ setSelectedIndex } newStyle={ newStyle } /> } - { mentionSelectorVisible && !commandSelectorVisible && + { mention.visible && !commandSelectorVisible && }
diff --git a/src/components/room/widgets/chat/highlightMentions.test.ts b/src/components/room/widgets/chat/highlightMentions.test.ts index 8c21557..d618b13 100644 --- a/src/components/room/widgets/chat/highlightMentions.test.ts +++ b/src/components/room/widgets/chat/highlightMentions.test.ts @@ -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 = ''; -const CLOSE = ''; +// A generic @user tag, and a self/alias mention (strong). +const TAG = (s: string) => `${ s }`; +const SELF = (s: string) => `${ s }`; 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('hi @Bob', 'Bob'); - expect(out).toBe(`hi ${ OPEN }@Bob${ CLOSE }`); + expect(out).toBe(`hi ${ SELF('@Bob') }`); }); it('leaves font-colour spans and line breaks intact', () => @@ -80,14 +86,14 @@ describe('highlightMentions', () => const html = 'hi @Bob
bye'; const out = highlightMentions(html, 'Bob'); - expect(out).toBe(`hi ${ OPEN }@Bob${ CLOSE }
bye`); + expect(out).toBe(`hi ${ SELF('@Bob') }
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`); - }); }); diff --git a/src/components/room/widgets/chat/highlightMentions.tsx b/src/components/room/widgets/chat/highlightMentions.tsx index 6c8620e..c7058eb 100644 --- a/src/components/room/widgets/chat/highlightMentions.tsx +++ b/src/components/room/widgets/chat/highlightMentions.tsx @@ -1,44 +1,9 @@ -export const MENTION_ROOM_ALIASES: ReadonlyArray = [ - '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): 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 = 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 = ''; -const HIGHLIGHT_CLOSE = ''; - -const highlightTextChunk = (chunk: string, ownUsernameLower: string, aliases: ReadonlySet): string => +const highlightTextChunk = (chunk: string, ownUsername: string, aliases: ReadonlyArray): 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 += `${ segment }`; } 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); diff --git a/src/css/badges/BadgeLeaderboardView.css b/src/css/badges/BadgeLeaderboardView.css index 0d87c0e..d3ca100 100644 --- a/src/css/badges/BadgeLeaderboardView.css +++ b/src/css/badges/BadgeLeaderboardView.css @@ -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 . 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 { diff --git a/src/css/chat/Chats.css b/src/css/chat/Chats.css index 43f093f..893cf07 100644 --- a/src/css/chat/Chats.css +++ b/src/css/chat/Chats.css @@ -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); +} diff --git a/src/css/friends/FriendsView.css b/src/css/friends/FriendsView.css index 8f7a265..81ee192 100644 --- a/src/css/friends/FriendsView.css +++ b/src/css/friends/FriendsView.css @@ -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; } } diff --git a/src/css/icons/icons.css b/src/css/icons/icons.css index 17a903f..50f390c 100644 --- a/src/css/icons/icons.css +++ b/src/css/icons/icons.css @@ -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; diff --git a/src/css/mentions/MentionToasts.css b/src/css/mentions/MentionToasts.css index 6895c89..a7ba0d6 100644 --- a/src/css/mentions/MentionToasts.css +++ b/src/css/mentions/MentionToasts.css @@ -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; } diff --git a/src/css/mentions/MentionsPanel.css b/src/css/mentions/MentionsPanel.css new file mode 100644 index 0000000..70c31f8 --- /dev/null +++ b/src/css/mentions/MentionsPanel.css @@ -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; +} diff --git a/src/hooks/furni-editor/useFurniEditor.ts b/src/hooks/furni-editor/useFurniEditor.ts index cb62d86..3cd8ec8 100644 --- a/src/hooks/furni-editor/useFurniEditor.ts +++ b/src/hooks/furni-editor/useFurniEditor.ts @@ -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([]); const [ furniDataEntry, setFurniDataEntry ] = useState | 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 }; }; diff --git a/src/hooks/mentions/index.ts b/src/hooks/mentions/index.ts index f8b9e5f..87ccb8f 100644 --- a/src/hooks/mentions/index.ts +++ b/src/hooks/mentions/index.ts @@ -1,3 +1,3 @@ export * from './useMentionsSnapshot'; export * from './useMentionMessages'; -export * from './useMentionAutocomplete'; +export * from './useMentionActions'; diff --git a/src/hooks/mentions/mentionToastsStore.ts b/src/hooks/mentions/mentionToastsStore.ts deleted file mode 100644 index 07c5fec..0000000 --- a/src/hooks/mentions/mentionToastsStore.ts +++ /dev/null @@ -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 => 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(); -}; diff --git a/src/components/mentions/useMentionActions.ts b/src/hooks/mentions/useMentionActions.ts similarity index 95% rename from src/components/mentions/useMentionActions.ts rename to src/hooks/mentions/useMentionActions.ts index 21be9f9..f336c4a 100644 --- a/src/components/mentions/useMentionActions.ts +++ b/src/hooks/mentions/useMentionActions.ts @@ -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 { diff --git a/src/hooks/mentions/useMentionAutocomplete.ts b/src/hooks/mentions/useMentionAutocomplete.ts deleted file mode 100644 index 6b2bbc1..0000000 --- a/src/hooks/mentions/useMentionAutocomplete.ts +++ /dev/null @@ -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 @ 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(() => - { - if(partial === null) return []; - - const query = partial.toLowerCase(); - const seen = new Set(); - 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 + ' ') - }; -}; diff --git a/src/hooks/mentions/useMentionMessages.ts b/src/hooks/mentions/useMentionMessages.ts index 09cf0dd..fee19d9 100644 --- a/src/hooks/mentions/useMentionMessages.ts +++ b/src/hooks/mentions/useMentionMessages.ts @@ -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/.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('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, onMentionsList); useMessageEvent(MentionReceivedEvent, onMentionReceived); diff --git a/src/hooks/notification/useNotification.ts b/src/hooks/notification/useNotification.ts index d7d3562..508fc78 100644 --- a/src/hooks/notification/useNotification.ts +++ b/src/hooks/notification/useNotification.ts @@ -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 = null) => { if(!options) options = new Map(); @@ -490,7 +500,7 @@ const useNotificationStore = () => useMessageEvent(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 diff --git a/src/hooks/rooms/widgets/index.ts b/src/hooks/rooms/widgets/index.ts index c0f96db..d576de0 100644 --- a/src/hooks/rooms/widgets/index.ts +++ b/src/hooks/rooms/widgets/index.ts @@ -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'; diff --git a/src/hooks/rooms/widgets/useChatMentions.helpers.test.ts b/src/hooks/rooms/widgets/useChatMentions.helpers.test.ts new file mode 100644 index 0000000..1b69187 --- /dev/null +++ b/src/hooks/rooms/widgets/useChatMentions.helpers.test.ts @@ -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); + }); +}); diff --git a/src/hooks/rooms/widgets/useChatMentions.helpers.ts b/src/hooks/rooms/widgets/useChatMentions.helpers.ts new file mode 100644 index 0000000..58e3f4f --- /dev/null +++ b/src/hooks/rooms/widgets/useChatMentions.helpers.ts @@ -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 = { + everyone: 'mentions_ui.aliases.everyone', + friends: 'mentions_ui.aliases.friends', + room: 'mentions_ui.aliases.room' +}; + +export const MENTION_ALIAS_DEFAULTS: Record = { + everyone: [ 'all', 'everyone', 'tutti' ], + friends: [ 'friends', 'amici' ], + room: [ 'room', 'stanza' ] +}; + +export const MENTION_ALIAS_DESCRIPTION_KEY: Record = { + 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, + aliases: ReadonlyArray, + 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; +}; diff --git a/src/hooks/rooms/widgets/useChatMentions.ts b/src/hooks/rooms/widgets/useChatMentions.ts new file mode 100644 index 0000000..5b52bb9 --- /dev/null +++ b/src/hooks/rooms/widgets/useChatMentions.ts @@ -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, + commandSelectorVisible: boolean +): ChatMentionsState => +{ + const roomUserList = useRoomUserListSnapshot(); + const [ selectedIndex, setSelectedIndex ] = useState(0); + + const mentionContext = useMemo(() => computeMentionContext(chatValue, commandSelectorVisible), [ chatValue, commandSelectorVisible ]); + + const aliases = useMemo(() => + { + const out: MentionAlias[] = []; + const seen = new Set(); + const scopes: MentionAliasScope[] = [ 'everyone', 'friends', 'room' ]; + + for(const scope of scopes) + { + const list = sanitizeAliasList(GetConfigurationValue(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(() => + { + 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 }; +}; diff --git a/src/index.tsx b/src/index.tsx index f6abedc..3b7af7d 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -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';