diff --git a/public/configuration/UITexts_en.json5.example b/public/configuration/UITexts_en.json5.example index 4fd480a..188ec9f 100644 --- a/public/configuration/UITexts_en.json5.example +++ b/public/configuration/UITexts_en.json5.example @@ -640,8 +640,6 @@ 'wheel.extra': 'Extra spins: %count%', 'wheel.spin': 'SPIN', 'wheel.buy': 'Buy spin', - 'wheel.settings': 'Settings', - 'wheel.settings.title': 'Wheel of Fortune Settings', 'wheel.winners': 'Latest winners', 'wheel.winners.empty': 'No winners yet', 'wheel.win.title': 'You won!', @@ -716,7 +714,7 @@ 'memenu.settings.other.place.multiple.objects': "Place multiple objects", 'memenu.settings.other.skip.purchase.confirmation': "Skip purchase confirmation", 'memenu.settings.other.enable.chat.window': "Enable chat window", - 'memenu.settings.other.catalog.classic.style': "Catalog: classic style", + 'memenu.settings.other.catalog.classic.style': "New style", 'usersettings.open.title': "User settings", 'usersettings.open.subtitle': "Password & account", 'usersettings.themes.custom': "Custom theme", diff --git a/public/configuration/UITexts_it.json5.example b/public/configuration/UITexts_it.json5.example index c5d3b17..c22eb4a 100644 --- a/public/configuration/UITexts_it.json5.example +++ b/public/configuration/UITexts_it.json5.example @@ -640,8 +640,6 @@ 'wheel.extra': 'Giri extra: %count%', 'wheel.spin': 'GIRA', 'wheel.buy': 'Acquista giro', - 'wheel.settings': 'Configurações', - 'wheel.settings.title': 'Configuração de Sistema da Roleta', 'wheel.winners': 'Ultimi vincitori', 'wheel.winners.empty': 'Ancora nessun vincitore', 'wheel.win.title': 'Hai vinto!', @@ -716,7 +714,7 @@ 'memenu.settings.other.place.multiple.objects': "Posiziona più oggetti", 'memenu.settings.other.skip.purchase.confirmation': "Salta la conferma d'acquisto", 'memenu.settings.other.enable.chat.window': "Abilita finestra chat", - 'memenu.settings.other.catalog.classic.style': "Catalogo: stile classico", + 'memenu.settings.other.catalog.classic.style': "Nuovo stile", 'usersettings.open.title': "Impostazioni utente", 'usersettings.open.subtitle': "Password e account", 'usersettings.themes.custom': "Tema personalizzato", diff --git a/public/configuration/UITexts_nl.json5.example b/public/configuration/UITexts_nl.json5.example index ebd3616..1c34f63 100644 --- a/public/configuration/UITexts_nl.json5.example +++ b/public/configuration/UITexts_nl.json5.example @@ -716,7 +716,7 @@ 'memenu.settings.other.place.multiple.objects': "Meerdere objecten plaatsen", 'memenu.settings.other.skip.purchase.confirmation': "Aankoopbevestiging overslaan", 'memenu.settings.other.enable.chat.window': "Chatvenster inschakelen", - 'memenu.settings.other.catalog.classic.style': "Catalogus: klassieke stijl", + 'memenu.settings.other.catalog.classic.style': "Nieuwe stijl", 'usersettings.open.title': "Gebruikersinstellingen", 'usersettings.open.subtitle': "Wachtwoord & account", 'usersettings.themes.custom': "Aangepast thema", diff --git a/public/configuration/ui-config.example b/public/configuration/ui-config.example index b544b4a..9db01e2 100644 --- a/public/configuration/ui-config.example +++ b/public/configuration/ui-config.example @@ -27,6 +27,9 @@ "radio_ui.enabled": false, "mentions_ui.enabled": true, "mentions_ui.sound": true, + "mentions_ui.aliases.everyone": [ "all", "everyone" ], + "mentions_ui.aliases.friends": [ "friends" ], + "mentions_ui.aliases.room": [ "room" ], "guides.enabled": true, "housekeeping.enabled": true, "toolbar.hide.quests": true, 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/common/layout/LayoutRoomPreviewerView.tsx b/src/common/layout/LayoutRoomPreviewerView.tsx index bee10e8..fa8b7df 100644 --- a/src/common/layout/LayoutRoomPreviewerView.tsx +++ b/src/common/layout/LayoutRoomPreviewerView.tsx @@ -8,6 +8,14 @@ export const LayoutRoomPreviewerView: FC<{ { const { roomPreviewer = null, height = 0 } = props; const elementRef = useRef(null); + // Counter that disables further renders once Pixi throws in this + // previewer too many times in a row. The Pixi v8 null-texture bug + // (see src/pixiPatch.ts) is mostly absorbed at the prototype level, + // but any stray throw still cascades every animation frame. Allow + // a small number of consecutive failures so a transient bad frame + // self-recovers; permanently disable only if the previewer is truly + // wedged, which is what produces the "disabling further renders" + // log the user sees. const renderFailuresRef = useRef(0); const MAX_RENDER_FAILURES = 6; @@ -62,6 +70,8 @@ export const LayoutRoomPreviewerView: FC<{ canvas.height = 0; elementRef.current.style.backgroundImage = `url(${ base64 })`; + // A successful paint is the signal we've recovered from + // a transient bad frame; reset the failure counter. renderFailuresRef.current = 0; } catch(error) 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/catalog/views/page/common/CatalogGridOfferView.tsx b/src/components/catalog/views/page/common/CatalogGridOfferView.tsx index 60ab937..3926e42 100644 --- a/src/components/catalog/views/page/common/CatalogGridOfferView.tsx +++ b/src/components/catalog/views/page/common/CatalogGridOfferView.tsx @@ -40,7 +40,17 @@ export const CatalogGridOfferView: FC = props => if(className?.length) { - const param = (product.productType === ProductTypeEnum.WALL && product.extraParam?.length) ? `_${ product.extraParam }` : ''; + let param = ''; + + if(product.productType === ProductTypeEnum.WALL && product.extraParam?.length) + { + param = `_${ product.extraParam }`; + } + else if(product.productType === ProductTypeEnum.FLOOR && product.furnitureData?.hasIndexedColor && (product.furnitureData.colorIndex > 0)) + { + param = `_${ product.furnitureData.colorIndex }`; + } + const configuredIconUrl = GetConfigurationValue('furni.asset.icon.url', ''); if(configuredIconUrl?.length) @@ -104,6 +114,7 @@ export const CatalogGridOfferView: FC = props => return ( = props =>
{ GetConfigurationValue('catalog.headers') && } - +
{ currentOffer && diff --git a/src/components/catalog/views/page/widgets/CatalogViewProductWidgetView.tsx b/src/components/catalog/views/page/widgets/CatalogViewProductWidgetView.tsx index df4cc24..46c0184 100644 --- a/src/components/catalog/views/page/widgets/CatalogViewProductWidgetView.tsx +++ b/src/components/catalog/views/page/widgets/CatalogViewProductWidgetView.tsx @@ -19,7 +19,7 @@ export const CatalogViewProductWidgetView: FC<{}> = props => if(!product) return; roomPreviewer.reset(false); - roomPreviewer.updateObjectRoom('default', 'default', 'default'); + roomPreviewer.updateObjectRoom('111', '217', '1.1'); roomPreviewer.updateRoomWallsAndFloorVisibility(true, true); const populate = () => @@ -91,7 +91,7 @@ export const CatalogViewProductWidgetView: FC<{}> = props => return; } default: - roomPreviewer.updateObjectRoom('default', 'default', 'default'); + roomPreviewer.updateObjectRoom('101', '101', '1.1'); roomPreviewer.addWallItemIntoRoom(product.productClassId, new Vector3d(90), product.extraParam); return; } @@ -106,13 +106,6 @@ export const CatalogViewProductWidgetView: FC<{}> = props => }; populate(); - - // RoomPreviewer.addFurnitureIntoRoom / addAvatarIntoRoom flip - // _automaticStateChange to true, which makes the ticker advance - // the room object's state every AUTOMATIC_STATE_CHANGE_INTERVAL. - // In the catalog we want the preview to sit still until the - // user clicks the state button explicitly - turn it back off - // after populate() runs. roomPreviewer.setAutomaticStateChange(false); }, [ currentOffer, previewStuffData, roomPreviewer ]); @@ -132,11 +125,5 @@ export const CatalogViewProductWidgetView: FC<{}> = props => ); } - // Re-mount the previewer whenever the offer changes so the render - // latch / texture handle in LayoutRoomPreviewerView resets cleanly. - // Without this a single broken offer (e.g. blackhole's Pixi filter - // crash) latches the previewer permanently and every following - // offer paints nothing - the singleton roomPreviewer + 240px height - // keep the same component mounted otherwise. return ; }; 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 7cb0fa7..5d009ed 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, typingUserIds = [], sendTypingStatus = null } = useMessenger(); + const { getFriend = null } = useFriends(); const { report = null } = useHelp(); const { settings, translateOutgoing } = useTranslation(); const messagesBox = useRef(null); @@ -185,12 +187,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 9276c73..69b54f7 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/inventory/views/bot/InventoryBotView.tsx b/src/components/inventory/views/bot/InventoryBotView.tsx index 23bb76b..e4bd09d 100644 --- a/src/components/inventory/views/bot/InventoryBotView.tsx +++ b/src/components/inventory/views/bot/InventoryBotView.tsx @@ -1,4 +1,4 @@ -import { GetRoomEngine, IRoomSession, RoomObjectVariable, RoomPreviewer } from '@nitrots/nitro-renderer'; +import { IRoomSession, RoomPreviewer } from '@nitrots/nitro-renderer'; import { FC, useEffect, useState } from 'react'; import { IBotItem, LocalizeText, UnseenItemCategory, attemptBotPlacement } from '../../../../api'; import { LayoutRoomPreviewerView } from '../../../../common'; @@ -22,20 +22,9 @@ export const InventoryBotView: FC<{ if(!selectedBot || !roomPreviewer) return; const botData = selectedBot.botData; - - const roomEngine = GetRoomEngine(); - - let wallType = roomEngine.getRoomInstanceVariable(roomEngine.activeRoomId, RoomObjectVariable.ROOM_WALL_TYPE); - let floorType = roomEngine.getRoomInstanceVariable(roomEngine.activeRoomId, RoomObjectVariable.ROOM_FLOOR_TYPE); - let landscapeType = roomEngine.getRoomInstanceVariable(roomEngine.activeRoomId, RoomObjectVariable.ROOM_LANDSCAPE_TYPE); - - wallType = (wallType && wallType.length) ? wallType : '101'; - floorType = (floorType && floorType.length) ? floorType : '101'; - landscapeType = (landscapeType && landscapeType.length) ? landscapeType : '1.1'; - roomPreviewer.reset(false); roomPreviewer.updateRoomWallsAndFloorVisibility(true, true); - roomPreviewer.updateObjectRoom(floorType, wallType, landscapeType); + roomPreviewer.updateObjectRoom('111', '217', '1.1'); roomPreviewer.addAvatarIntoRoom(botData.figure, 0); }, [ roomPreviewer, selectedBot ]); diff --git a/src/components/inventory/views/furniture/InventoryFurnitureView.tsx b/src/components/inventory/views/furniture/InventoryFurnitureView.tsx index 58a049f..fd0f38e 100644 --- a/src/components/inventory/views/furniture/InventoryFurnitureView.tsx +++ b/src/components/inventory/views/furniture/InventoryFurnitureView.tsx @@ -1,5 +1,5 @@ import { InfiniteGrid } from '@layout/InfiniteGrid'; -import { GetRoomEngine, GetSessionDataManager, IRoomSession, RoomObjectVariable, RoomPreviewer, Vector3d } from '@nitrots/nitro-renderer'; +import { GetSessionDataManager, IRoomSession, RoomPreviewer, Vector3d } from '@nitrots/nitro-renderer'; import { FC, useEffect, useState } from 'react'; import { FaPowerOff, FaSyncAlt, FaTrashAlt } from 'react-icons/fa'; import { DispatchUiEvent, FurniCategory, GroupItem, LocalizeText, UnseenItemCategory, attemptItemPlacement } from '../../../../api'; @@ -53,24 +53,17 @@ export const InventoryFurnitureView: FC<{ const isRoomDecoration = (furnitureItem.category === FurniCategory.WALL_PAPER) || (furnitureItem.category === FurniCategory.FLOOR) || (furnitureItem.category === FurniCategory.LANDSCAPE); + let floorType = '111'; + let wallType = '217'; + let landscapeType = '1.1'; + if(isRoomDecoration) { - const roomEngine = GetRoomEngine(); - - let wallType = roomEngine.getRoomInstanceVariable(roomEngine.activeRoomId, RoomObjectVariable.ROOM_WALL_TYPE); - let floorType = roomEngine.getRoomInstanceVariable(roomEngine.activeRoomId, RoomObjectVariable.ROOM_FLOOR_TYPE); - let landscapeType = roomEngine.getRoomInstanceVariable(roomEngine.activeRoomId, RoomObjectVariable.ROOM_LANDSCAPE_TYPE); - - wallType = (wallType && wallType.length) ? wallType : '101'; - floorType = (floorType && floorType.length) ? floorType : '101'; - landscapeType = (landscapeType && landscapeType.length) ? landscapeType : '1.1'; - - roomPreviewer.updateRoomWallsAndFloorVisibility(true, true); - floorType = ((furnitureItem.category === FurniCategory.FLOOR) ? selectedItem.stuffData.getLegacyString() : floorType); wallType = ((furnitureItem.category === FurniCategory.WALL_PAPER) ? selectedItem.stuffData.getLegacyString() : wallType); landscapeType = ((furnitureItem.category === FurniCategory.LANDSCAPE) ? selectedItem.stuffData.getLegacyString() : landscapeType); + roomPreviewer.updateRoomWallsAndFloorVisibility(true, true); roomPreviewer.updateObjectRoom(floorType, wallType, landscapeType); if(furnitureItem.category === FurniCategory.LANDSCAPE) @@ -83,7 +76,7 @@ export const InventoryFurnitureView: FC<{ return; } - roomPreviewer.updateObjectRoom('default', 'default', 'default'); + roomPreviewer.updateObjectRoom(floorType, wallType, landscapeType); roomPreviewer.updateRoomWallsAndFloorVisibility(true, true); if(selectedItem.isWallItem) diff --git a/src/components/inventory/views/pet/InventoryPetView.tsx b/src/components/inventory/views/pet/InventoryPetView.tsx index e78c742..a684cc2 100644 --- a/src/components/inventory/views/pet/InventoryPetView.tsx +++ b/src/components/inventory/views/pet/InventoryPetView.tsx @@ -1,4 +1,4 @@ -import { DeletePetMessageComposer, GetRoomEngine, IRoomSession, RoomObjectVariable, RoomPreviewer } from '@nitrots/nitro-renderer'; +import { DeletePetMessageComposer, IRoomSession, RoomPreviewer } from '@nitrots/nitro-renderer'; import { FC, useEffect, useState } from 'react'; import { FaTrashAlt } from 'react-icons/fa'; import { IPetItem, LocalizeText, SendMessageComposer, UnseenItemCategory, attemptPetPlacement } from '../../../../api'; @@ -38,19 +38,10 @@ export const InventoryPetView: FC<{ if(!selectedPet || !roomPreviewer) return; const petData = selectedPet.petData; - const roomEngine = GetRoomEngine(); - - let wallType = roomEngine.getRoomInstanceVariable(roomEngine.activeRoomId, RoomObjectVariable.ROOM_WALL_TYPE); - let floorType = roomEngine.getRoomInstanceVariable(roomEngine.activeRoomId, RoomObjectVariable.ROOM_FLOOR_TYPE); - let landscapeType = roomEngine.getRoomInstanceVariable(roomEngine.activeRoomId, RoomObjectVariable.ROOM_LANDSCAPE_TYPE); - - wallType = (wallType && wallType.length) ? wallType : '101'; - floorType = (floorType && floorType.length) ? floorType : '101'; - landscapeType = (landscapeType && landscapeType.length) ? landscapeType : '1.1'; roomPreviewer.reset(false); roomPreviewer.updateRoomWallsAndFloorVisibility(true, true); - roomPreviewer.updateObjectRoom(floorType, wallType, landscapeType); + roomPreviewer.updateObjectRoom('111', '217', '1.1'); roomPreviewer.addPetIntoRoom(petData.figureString); }, [ roomPreviewer, selectedPet ]); 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 6846ed2..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/navigator/NavigatorView.tsx b/src/components/navigator/NavigatorView.tsx index 7137908..f2b163f 100644 --- a/src/components/navigator/NavigatorView.tsx +++ b/src/components/navigator/NavigatorView.tsx @@ -109,7 +109,7 @@ export const NavigatorView: FC<{}> = props => <> { isVisible && 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/ChatInputCommandSelectorView.tsx b/src/components/room/widgets/chat-input/ChatInputCommandSelectorView.tsx index f3458c5..5c88bda 100644 --- a/src/components/room/widgets/chat-input/ChatInputCommandSelectorView.tsx +++ b/src/components/room/widgets/chat-input/ChatInputCommandSelectorView.tsx @@ -62,7 +62,7 @@ export const ChatInputCommandSelectorView: FC : Command
-
+
{ commands.map((cmd, index) => { const isSelected = (index === selectedIndex); diff --git a/src/components/room/widgets/chat-input/ChatInputMentionSelectorView.tsx b/src/components/room/widgets/chat-input/ChatInputMentionSelectorView.tsx index 0c860ad..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 { @@ -99,7 +88,7 @@ export const ChatInputMentionSelectorView: FC @ Mention
-
+
{ suggestions.map((suggestion, index) => { const isSelected = (index === selectedIndex); 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/catalog/CatalogClassicView.css b/src/css/catalog/CatalogClassicView.css index f71e155..38c082c 100644 --- a/src/css/catalog/CatalogClassicView.css +++ b/src/css/catalog/CatalogClassicView.css @@ -151,10 +151,6 @@ display: none !important; } -/* Publish button: lives inside the catalog window, absolutely - positioned in the header just to the left of the close X. Renders - only when adminMode is true (see CatalogClassicView.tsx). Uses the - Habbo yellow buy-button skin so it matches the Koop button. */ .nitro-catalog-classic-window .nitro-catalog-classic-header-publish { position: absolute !important; top: 5px !important; @@ -179,17 +175,7 @@ 50% { box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65), inset 0 -2px 0 rgba(140, 75, 0, 0.35), 0 0 8px rgba(255, 200, 0, 0.75); } } -/* Catalog default-layout admin row (Pagina bewerken / Nieuwe - aanbieding / Aanbieding bewerken). These are inline text buttons - but the .habbo-swf-window button + .habbo-swf-window - button[class*="success"] global rules were dressing them up as - SWF skin buttons (one yellow!) and forcing min-height: 22px which - broke the layout. Reset and re-skin as compact pill chips. */ .nitro-catalog-classic-window .nitro-catalog-classic-default-admin { - /* Keep all admin buttons on one row so the product-view doesn't - get pushed down into the absolutely-positioned grid-shell. If - a future label makes them overflow, the row scrolls - horizontally instead of wrapping. */ flex-wrap: nowrap !important; align-items: center !important; gap: 6px !important; @@ -239,8 +225,6 @@ box-shadow: inset 0 1px 0 rgba(0, 0, 0, 0.08), 0 0 0 rgba(0, 0, 0, 0) !important; } -/* The "Nieuwe aanbieding" button uses text-success - give it the - habbo-yellow buy-button palette to mark it as the create action. */ .nitro-catalog-classic-window .nitro-catalog-classic-default-admin button.text-success, .nitro-catalog-classic-window .nitro-catalog-classic-default-admin button[class*="success"] { border-color: #8a5b00 !important; @@ -270,10 +254,6 @@ margin-left: 4px !important; } -/* Admin cog tab at the end of the tab strip - only renders when the - user is a mod, so leaving it visible at all times is safe. Style - it as a compact square that sits flush with the other tabs - instead of stretching to flex: 1 like a category tab. */ .nitro-catalog-classic-tabs-shell .nitro-card-tab-item.nitro-catalog-classic-admin-tab { flex: 0 0 auto !important; width: 32px !important; @@ -343,9 +323,6 @@ } .nitro-catalog-classic-tabs-shell { - /* Strip just tall enough to hold the 36px tab + 4px of breathing - room above. Trims the dead blue band between the header and - the tabs so the catalog body doesn't lose vertical space. */ flex: 0 0 40px; height: 40px; min-height: 40px; @@ -353,8 +330,6 @@ gap: 0; padding: 4px 6px 0 !important; align-items: flex-end; - /* Horizontal scroll so every category tab stays reachable when the - card is narrower than the total tab width. */ overflow-x: auto; overflow-y: hidden; flex-wrap: nowrap; @@ -365,8 +340,6 @@ border-bottom: 1px solid #c8c8bf; } -/* The tabs strip uses a slim 6px scrollbar - opt it out of the - 17px Habbo-sprite scrollbar applied to the rest of the catalog. */ .nitro-catalog-classic-tabs-shell::-webkit-scrollbar { width: 6px !important; height: 6px !important; @@ -397,18 +370,11 @@ gap: 4px; height: 36px; min-width: 0; - /* Equal-width tabs that share the strip exactly - the right - edge of the last tab now lines up with the right edge of the - catalog window, no trailing gap. */ flex: 1 1 0; max-width: none; padding: 6px 6px 7px; margin: 0 2px 0 0; flex-shrink: 1; - /* Classic Habbo tab: gray rounded-top rectangle with a 1px black - outline. ubuntu_tab3_*.png isn't shipped, so we draw the - habbo-look ourselves instead of border-image-slicing a missing - sprite. */ border: 1px solid #000 !important; border-bottom: 0 !important; border-radius: 6px 6px 0 0 !important; @@ -425,9 +391,6 @@ border-image-source: none !important; } -/* Bring the last tab flush with whatever follows (admin cog, edge), - instead of leaving the negative right margin tugging on empty - space. */ .nitro-catalog-classic-tabs-shell .nitro-card-tab-item:last-of-type { margin-right: 0; } @@ -442,9 +405,6 @@ line-height: 17px; } -/* Category icon that sits before the label. The blanket "hide every - img/svg inside a tab" rule is gone - we explicitly size the - classic tab icon and let everything else fall through. */ .nitro-catalog-classic-tab-icon { width: 18px; height: 18px; @@ -460,9 +420,6 @@ .nitro-catalog-classic-tabs-shell .nitro-card-tab-item-active { z-index: 2; - /* Active tab: the catalog-header habbo-blue with the cream catalog - body bleeding up into it. Drop the bottom border so the tab - "merges" with the panel below. */ background: linear-gradient(180deg, #4fb3ff 0%, var(--catalog-swf-blue) 100%) !important; color: #ffffff !important; @@ -490,10 +447,6 @@ .nitro-catalog-classic-stage { display: grid; - /* Sidebar pinned at 184px; the layout column takes the rest of - the stage row so the right edge of the right column lines up - with the right edge of the catalog window instead of leaving a - wide cream strip. */ grid-template-columns: 184px 1fr; gap: 8px; width: 100%; @@ -515,14 +468,9 @@ .nitro-catalog-classic-search-shell { position: relative; - /* Use flex so the input vertically centers inside the 24px shell - regardless of the input's own intrinsic baseline. */ display: flex; align-items: center; height: 24px; - /* Outer padding 0, negative horizontal margin bleeds the shell a - couple of pixels past its sidebar column on each side without - touching the grid template - cheap way to look ~4px wider. */ padding: 0; margin: -2px -1px 0 -1px; border: 1px solid #b7b7ae; @@ -530,10 +478,6 @@ background: #f7f7f2; } -/* Clear the magnifying-glass on the left and the X-clear button on the - right. The shell's own outer padding is essentially zero, so the - input claims the full sidebar column width minus just enough to - keep the icons from overlapping the text. */ .nitro-catalog-classic-search-shell input { flex: 1 1 auto; width: 100% !important; @@ -550,8 +494,6 @@ vertical-align: middle !important; } -/* The wrapping div the React component renders is 100% wide so the - input fills the shell instead of shrinking to content. */ .nitro-catalog-classic-search-shell > div { width: 100% !important; height: 100% !important; @@ -562,13 +504,10 @@ font-size: 11px !important; } -/* The search icon ships with absolute + left-2; nudge it tight to the - shell edge so the input can keep its left padding small. */ .nitro-catalog-classic-search-shell svg:first-child { left: 4px !important; } -/* X-clear button on the right edge - keep it tight too. */ .nitro-catalog-classic-search-shell button { right: 4px !important; } @@ -703,20 +642,11 @@ .nitro-catalog-classic-default-layout { position: relative; display: block !important; - /* Fill the layout container in both axes - the stage was - previously 552px wide and this column was pinned at 360px, but - now that the stage uses 1fr, hardcoding 360px would leave a - wide blank strip on the right of every default-layout catalog - page. */ width: 100%; height: 100%; min-height: 460px; } -/* The product-view, grid-shell, price-row and purchase-row inside - the default-layout were each pinned at 360px to match the old - stage width. Widen them in lockstep so they fill the new - 1fr-sized container. */ .nitro-catalog-classic-product-view, .nitro-catalog-classic-grid-shell, .nitro-catalog-classic-price-row, @@ -756,12 +686,6 @@ height: 100%; } -/* Default-3x3 layout: .offer-info is rendered but hidden via the - display: none rule below, so the panel reserved the full width - while only the 360px preview was visible (empty strip on the - right). Center the preview inside the panel instead so the gap - becomes symmetric padding on both sides. Color-grouping doesn't - render .offer-info so its panel keeps the existing layout. */ .nitro-catalog-classic-offer-panel:has(> .nitro-catalog-classic-offer-info) { justify-content: center; } @@ -774,13 +698,6 @@ background: #000; } -/* The default-3x3 layout puts the preview next to .offer-info inside - .offer-panel and needs the 360px column. Scope the pin to that - context so other layouts (color-grouping, etc.) can put the same - preview class inside a flex/grid column and let it track the - container width. Without this scoping the absolute-positioned - rotate/state buttons sit past the column's right edge and get - clipped by overflow: hidden. */ .nitro-catalog-classic-offer-panel > .nitro-catalog-classic-offer-preview { width: 360px; min-width: 360px; @@ -846,8 +763,6 @@ position: absolute; left: 0; top: 245px; - /* Stretch down to just above the price + purchase rows so the - grid soaks up any extra height the bigger window gives us. */ bottom: 68px; width: 360px; min-height: 0; @@ -855,20 +770,12 @@ overflow: auto; } -/* When the admin row is rendered above the product-view it adds - ~30px (22px button + flex gap) to the flex column, but the - grid-shell is absolutely positioned and doesn't shift on its own. - Push it (and the bottom-anchored price/purchase rows stay put) - down so the preview panel no longer bleeds into the grid. */ .nitro-catalog-classic-default-layout:has(.nitro-catalog-classic-default-admin) .nitro-catalog-classic-grid-shell { top: 280px !important; } .nitro-catalog-classic-grid { - /* Don't pin a fixed column track here - AutoGrid sets the inline - grid-template-columns from its columnMinWidth prop. The earlier - `repeat(6, 53px) !important` was clobbering that and freezing - the row at 6 tiles regardless of what the React layout passed. */ + grid-template-columns: repeat(6, 1fr) !important; grid-auto-rows: var(--nitro-grid-column-min-height, 70px); align-content: start; justify-content: start; @@ -943,30 +850,20 @@ } .nitro-catalog-classic-grid-offer-icon { - position: absolute; - left: 50%; - top: 20px; width: auto !important; height: auto !important; - max-width: 36px; - max-height: 36px; + image-rendering: pixelated; object-fit: contain; - transform: translate(-50%, -50%); pointer-events: none; } .nitro-catalog-classic-grid-price { - position: absolute; - left: 2px; - right: 2px; - top: 36px; - bottom: auto; display: flex; - flex-direction: column; + flex-direction: row; align-items: center; - justify-content: flex-start; - gap: 0; - min-height: 24px; + justify-content: center; + gap: 3px; + width: 100%; color: #000; font-size: 11px; font-weight: 700; @@ -1015,11 +912,15 @@ } .nitro-catalog-classic-grid-price.is-single-price { - height: 19px; + height: auto; + min-height: 0; } .nitro-catalog-classic-grid-price.is-multi-price { - height: 38px; + height: auto; + min-height: 0; + flex-wrap: wrap; + row-gap: 1px; } .nitro-catalog-classic-grid-price-entry { @@ -1452,8 +1353,8 @@ .nitro-catalog-classic-window .nitro-catalog-classic-grid-shell::-webkit-scrollbar, .nitro-catalog-classic-window .nitro-catalog-classic-grid::-webkit-scrollbar, .nitro-catalog-classic-window .nitro-catalog-classic-layout-container::-webkit-scrollbar { - width: 17px; - height: 17px; + width: 18px; + height: 18px; } .nitro-catalog-classic-window * { @@ -1462,116 +1363,120 @@ } .nitro-catalog-classic-window *::-webkit-scrollbar { - width: 17px !important; - height: 17px !important; - background-color: #e7e5d8 !important; + width: 18px !important; + height: 18px !important; + background-color: #bdbbb3 !important; } .nitro-catalog-classic-window *::-webkit-scrollbar-track { background-image: none !important; - background-color: #e7e5d8 !important; - box-shadow: inset 1px 0 0 #b9b6a5, inset -1px 0 0 #ffffff !important; + background-color: #bdbbb3 !important; + box-shadow: inset 1px 0 0 #000000 !important; border: 0 !important; } .nitro-catalog-classic-window *::-webkit-scrollbar-thumb { - min-height: 28px !important; - border: 1px solid #2a2a26 !important; - border-radius: 2px !important; + /* Grip: a single 2px #a0a0a0 stripe in an 8px-wide centered band, + repeated every 5px (2px stripe + 3px body gap). + Outline: 1px black border, then a 2px white inset frame inside it. */ + min-height: 24px !important; background: - url("data:image/svg+xml;utf8,") center center / 10px 9px no-repeat, - linear-gradient(180deg, #d6d6cc 0%, #b4b4aa 30%, #9a9a90 50%, #b4b4aa 70%, #d6d6cc 100%) !important; - background-color: #a8a89e !important; - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.55), - inset 0 -1px 0 rgba(255, 255, 255, 0.4) !important; + url("data:image/svg+xml;utf8,") center top / 8px 5px repeat-y, + #d9d9d9 !important; + border: 1px solid #000000 !important; + border-radius: 3px !important; + box-shadow: inset 0 0 0 2px #ffffff !important; } .nitro-catalog-classic-window *::-webkit-scrollbar-thumb:hover { background: - url("data:image/svg+xml;utf8,") center center / 10px 9px no-repeat, - linear-gradient(180deg, #e0e0d6 0%, #bebeb4 30%, #a4a49a 50%, #bebeb4 70%, #e0e0d6 100%) !important; - background-color: #b2b2a8 !important; + url("data:image/svg+xml;utf8,") center top / 8px 5px repeat-y, + #e3e3e3 !important; } .nitro-catalog-classic-window *::-webkit-scrollbar-thumb:active { background: - linear-gradient(180deg, #c6c6bc 0%, #a4a49a 30%, #8a8a82 50%, #a4a49a 70%, #c6c6bc 100%) !important; - background-color: #9a9a90 !important; - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.35), - inset 0 -1px 0 rgba(255, 255, 255, 0.25) !important; + url("data:image/svg+xml;utf8,") center top / 8px 5px repeat-y, + #bcbcbc !important; } +/* Arrow buttons: light grey cap with rounded OUTER corners (up button + rounded at the top, down button rounded at the bottom), 1px black + border, dark chevron via inline SVG. */ .nitro-catalog-classic-window *::-webkit-scrollbar-button:single-button:vertical:decrement { display: block !important; - width: 17px !important; - height: 16px !important; + width: 18px !important; + height: 18px !important; background: - url("data:image/svg+xml;utf8,") center center / 9px 6px no-repeat, - linear-gradient(180deg, #f3f1e6 0%, #d8d6c8 100%) !important; - background-color: #e7e5d8 !important; - border: 1px solid #0b2d3a !important; - border-radius: 2px !important; + url("data:image/svg+xml;utf8,") center center / 9px 6px no-repeat, + #d9d9d9 !important; + border: 1px solid #000000 !important; + border-radius: 3px 3px 0 0 !important; box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.7), - inset 0 -1px 0 rgba(0, 0, 0, 0.18) !important; + inset 0 1px 0 #ffffff, + inset 1px 0 0 rgba(255, 255, 255, 0.6) !important; } .nitro-catalog-classic-window *::-webkit-scrollbar-button:single-button:vertical:decrement:active { background: url("data:image/svg+xml;utf8,") center center / 9px 6px no-repeat, - linear-gradient(180deg, #c7c5b8 0%, #aeaca0 100%) !important; - background-color: #c7c5b8 !important; + #bcbcbc !important; } .nitro-catalog-classic-window *::-webkit-scrollbar-button:single-button:vertical:increment { display: block !important; - width: 17px !important; - height: 16px !important; + width: 18px !important; + height: 18px !important; background: - url("data:image/svg+xml;utf8,") center center / 9px 6px no-repeat, - linear-gradient(180deg, #f3f1e6 0%, #d8d6c8 100%) !important; - background-color: #e7e5d8 !important; - border: 1px solid #0b2d3a !important; - border-radius: 2px !important; + url("data:image/svg+xml;utf8,") center center / 9px 6px no-repeat, + #d9d9d9 !important; + border: 1px solid #000000 !important; + border-radius: 0 0 3px 3px !important; box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.7), - inset 0 -1px 0 rgba(0, 0, 0, 0.18) !important; + inset 0 1px 0 #ffffff, + inset 1px 0 0 rgba(255, 255, 255, 0.6) !important; } .nitro-catalog-classic-window *::-webkit-scrollbar-button:single-button:vertical:increment:active { background: url("data:image/svg+xml;utf8,") center center / 9px 6px no-repeat, - linear-gradient(180deg, #c7c5b8 0%, #aeaca0 100%) !important; - background-color: #c7c5b8 !important; + #bcbcbc !important; } .nitro-catalog-classic-window *::-webkit-scrollbar-button:single-button:horizontal:decrement { display: block !important; - width: 16px !important; - height: 17px !important; + width: 18px !important; + height: 18px !important; background: - url("data:image/svg+xml;utf8,") center center / 6px 9px no-repeat, - linear-gradient(180deg, #f3f1e6 0%, #d8d6c8 100%) !important; - background-color: #e7e5d8 !important; - border: 1px solid #0b2d3a !important; - border-radius: 2px !important; + url("data:image/svg+xml;utf8,") center center / 6px 9px no-repeat, + #d9d9d9 !important; + border: 1px solid #000000 !important; + border-radius: 3px 0 0 3px !important; box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.7), - inset 0 -1px 0 rgba(0, 0, 0, 0.18) !important; + inset 0 1px 0 #ffffff, + inset 1px 0 0 rgba(255, 255, 255, 0.6) !important; } +.nitro-catalog-classic-window *::-webkit-scrollbar-button:single-button:horizontal:decrement:active { + background: + url("data:image/svg+xml;utf8,") center center / 6px 9px no-repeat, + #bcbcbc !important; +} + .nitro-catalog-classic-window *::-webkit-scrollbar-button:single-button:horizontal:increment { display: block !important; - width: 16px !important; - height: 17px !important; + width: 18px !important; + height: 18px !important; background: - url("data:image/svg+xml;utf8,") center center / 6px 9px no-repeat, - linear-gradient(180deg, #f3f1e6 0%, #d8d6c8 100%) !important; - background-color: #e7e5d8 !important; - border: 1px solid #0b2d3a !important; - border-radius: 2px !important; + url("data:image/svg+xml;utf8,") center center / 6px 9px no-repeat, + #d9d9d9 !important; + border: 1px solid #000000 !important; + border-radius: 0 3px 3px 0 !important; box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.7), - inset 0 -1px 0 rgba(0, 0, 0, 0.18) !important; + inset 0 1px 0 #ffffff, + inset 1px 0 0 rgba(255, 255, 255, 0.6) !important; +} +.nitro-catalog-classic-window *::-webkit-scrollbar-button:single-button:horizontal:increment:active { + background: + url("data:image/svg+xml;utf8,") center center / 6px 9px no-repeat, + #bcbcbc !important; } .nitro-catalog-classic-breadcrumb { @@ -1604,12 +1509,6 @@ padding: 6px !important; } - /* Mobile: drop the per-page welcome row (image + localization - blurb shown when no offer is selected). On a narrow viewport - it eats most of the visible space and pushes the actual grid - off-screen. Hide it and also collapse the surrounding - product-view (otherwise its 240px height reservation stays - and leaves a blank strip above the grid). */ .nitro-catalog-classic-welcome { display: none !important; } diff --git a/src/css/chat/ChatInputMentionSelectorView.css b/src/css/chat/ChatInputMentionSelectorView.css index d9b0cc8..c52eb83 100644 --- a/src/css/chat/ChatInputMentionSelectorView.css +++ b/src/css/chat/ChatInputMentionSelectorView.css @@ -1,13 +1,3 @@ -/* ============================================================================ - Chat-bar @-mention autocomplete - Habbo style - ---------------------------------------------------------------------------- - Mirrors the NitroCard look (cream cardstock, habbo-blue header, black 2px - border, drop shadow) and the in-room infostand row chrome. The popover - appears above the chat input, anchored to its bottom-left and the same - width as the input, with the bottom corners flush so it visually merges - with the input edge. - ============================================================================ */ - .chat-input-mention-popover { position: absolute; bottom: 100%; @@ -186,32 +176,6 @@ color: #4a2b00; } -/* Habbo-style scrollbar, matching NitroCardContentView scroll chrome. */ -.chat-input-mention-popover-list::-webkit-scrollbar { - width: 8px; -} - -.chat-input-mention-popover-list::-webkit-scrollbar-track { - background: #d8d8cf; - border-left: 1px solid #000; -} - -.chat-input-mention-popover-list::-webkit-scrollbar-thumb { - background: #30728c; - border: 1px solid #000; - border-radius: 3px; -} - -.chat-input-mention-popover-list::-webkit-scrollbar-thumb:hover { - background: #3c88a6; -} - -/* ============================================================================ - :command popover - same Habbo NitroCard chrome as the @-mention picker. - Header is green to distinguish it visually from the mention popover, - which uses the standard habbo-blue. - ============================================================================ */ - .chat-input-command-popover { position: absolute; bottom: 100%; @@ -344,23 +308,4 @@ .chat-input-command-row.is-selected .chat-input-command-row-desc { color: #d9efde; -} - -.chat-input-command-popover-list::-webkit-scrollbar { - width: 8px; -} - -.chat-input-command-popover-list::-webkit-scrollbar-track { - background: #d8d8cf; - border-left: 1px solid #000; -} - -.chat-input-command-popover-list::-webkit-scrollbar-thumb { - background: #2f8d4a; - border: 1px solid #000; - border-radius: 3px; -} - -.chat-input-command-popover-list::-webkit-scrollbar-thumb:hover { - background: #3aa55b; -} +} \ No newline at end of file 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/common/ClassicScrollbar.css b/src/css/common/ClassicScrollbar.css new file mode 100644 index 0000000..61e2e09 --- /dev/null +++ b/src/css/common/ClassicScrollbar.css @@ -0,0 +1,127 @@ +.has-classic-scrollbar { + scrollbar-color: auto !important; + scrollbar-width: auto; +} + +.has-classic-scrollbar *::-webkit-scrollbar, +.has-classic-scrollbar::-webkit-scrollbar { + width: 18px !important; + height: 18px !important; + background-color: #bdbbb3 !important; +} + +.has-classic-scrollbar *::-webkit-scrollbar-track, +.has-classic-scrollbar::-webkit-scrollbar-track { + background-color: #bdbbb3 !important; + box-shadow: inset 1px 0 0 #000000 !important; + border: 0 !important; +} + +.has-classic-scrollbar *::-webkit-scrollbar-thumb, +.has-classic-scrollbar::-webkit-scrollbar-thumb { + min-height: 24px !important; + background: + url("data:image/svg+xml;utf8,") center top / 8px 5px repeat-y, + #d9d9d9 !important; + border: 1px solid #000000 !important; + border-radius: 3px !important; + box-shadow: inset 0 0 0 2px #ffffff !important; +} + +.has-classic-scrollbar *::-webkit-scrollbar-thumb:hover, +.has-classic-scrollbar::-webkit-scrollbar-thumb:hover { + background: + url("data:image/svg+xml;utf8,") center top / 8px 5px repeat-y, + #e3e3e3 !important; +} + +.has-classic-scrollbar *::-webkit-scrollbar-thumb:active, +.has-classic-scrollbar::-webkit-scrollbar-thumb:active { + background: + url("data:image/svg+xml;utf8,") center top / 8px 5px repeat-y, + #bcbcbc !important; +} + +.has-classic-scrollbar *::-webkit-scrollbar-button:single-button:vertical:decrement, +.has-classic-scrollbar::-webkit-scrollbar-button:single-button:vertical:decrement { + display: block !important; + width: 18px !important; + height: 18px !important; + background: + url("data:image/svg+xml;utf8,") center center / 9px 6px no-repeat, + #d9d9d9 !important; + border: 1px solid #000000 !important; + border-radius: 3px 3px 0 0 !important; + box-shadow: + inset 0 1px 0 #ffffff, + inset 1px 0 0 rgba(255, 255, 255, 0.6) !important; +} +.has-classic-scrollbar *::-webkit-scrollbar-button:single-button:vertical:decrement:active, +.has-classic-scrollbar::-webkit-scrollbar-button:single-button:vertical:decrement:active { + background: + url("data:image/svg+xml;utf8,") center center / 9px 6px no-repeat, + #bcbcbc !important; +} + +.has-classic-scrollbar *::-webkit-scrollbar-button:single-button:vertical:increment, +.has-classic-scrollbar::-webkit-scrollbar-button:single-button:vertical:increment { + display: block !important; + width: 18px !important; + height: 18px !important; + background: + url("data:image/svg+xml;utf8,") center center / 9px 6px no-repeat, + #d9d9d9 !important; + border: 1px solid #000000 !important; + border-radius: 0 0 3px 3px !important; + box-shadow: + inset 0 1px 0 #ffffff, + inset 1px 0 0 rgba(255, 255, 255, 0.6) !important; +} +.has-classic-scrollbar *::-webkit-scrollbar-button:single-button:vertical:increment:active, +.has-classic-scrollbar::-webkit-scrollbar-button:single-button:vertical:increment:active { + background: + url("data:image/svg+xml;utf8,") center center / 9px 6px no-repeat, + #bcbcbc !important; +} + +.has-classic-scrollbar *::-webkit-scrollbar-button:single-button:horizontal:decrement, +.has-classic-scrollbar::-webkit-scrollbar-button:single-button:horizontal:decrement { + display: block !important; + width: 18px !important; + height: 18px !important; + background: + url("data:image/svg+xml;utf8,") center center / 6px 9px no-repeat, + #d9d9d9 !important; + border: 1px solid #000000 !important; + border-radius: 3px 0 0 3px !important; + box-shadow: + inset 0 1px 0 #ffffff, + inset 1px 0 0 rgba(255, 255, 255, 0.6) !important; +} +.has-classic-scrollbar *::-webkit-scrollbar-button:single-button:horizontal:decrement:active, +.has-classic-scrollbar::-webkit-scrollbar-button:single-button:horizontal:decrement:active { + background: + url("data:image/svg+xml;utf8,") center center / 6px 9px no-repeat, + #bcbcbc !important; +} + +.has-classic-scrollbar *::-webkit-scrollbar-button:single-button:horizontal:increment, +.has-classic-scrollbar::-webkit-scrollbar-button:single-button:horizontal:increment { + display: block !important; + width: 18px !important; + height: 18px !important; + background: + url("data:image/svg+xml;utf8,") center center / 6px 9px no-repeat, + #d9d9d9 !important; + border: 1px solid #000000 !important; + border-radius: 0 3px 3px 0 !important; + box-shadow: + inset 0 1px 0 #ffffff, + inset 1px 0 0 rgba(255, 255, 255, 0.6) !important; +} +.has-classic-scrollbar *::-webkit-scrollbar-button:single-button:horizontal:increment:active, +.has-classic-scrollbar::-webkit-scrollbar-button:single-button:horizontal:increment:active { + background: + url("data:image/svg+xml;utf8,") center center / 6px 9px no-repeat, + #bcbcbc !important; +} diff --git a/src/css/friends/FriendsView.css b/src/css/friends/FriendsView.css index 16634d1..7f1404a 100644 --- a/src/css/friends/FriendsView.css +++ b/src/css/friends/FriendsView.css @@ -1,4 +1,3 @@ -/* ── Friends spritesheet icons ── */ .nitro-friends-spritesheet { background: url('@/assets/images/friends/friends-spritesheet.png') transparent no-repeat; @@ -537,14 +536,18 @@ } & .avatar-image { - position: absolute; - left: 50% !important; - top: -31px !important; - width: 90px !important; - height: 130px !important; + position: absolute !important; + inset: 0 !important; + width: 100% !important; + height: 100% !important; margin: 0 !important; - background-position: center -8px !important; - transform: translateX(-50%) !important; + /* head-only image at native size (no scaling -> never grainy), + centred in the tab. Tweak the 2nd value of background-position + to raise (smaller %) or lower (larger %) the face. */ + background-size: auto !important; + background-position: center 35% !important; + transform: none !important; + image-rendering: pixelated !important; } } 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/css/navigator/HabboNavigatorDesktop.css b/src/css/navigator/HabboNavigatorDesktop.css deleted file mode 100644 index dd75c44..0000000 --- a/src/css/navigator/HabboNavigatorDesktop.css +++ /dev/null @@ -1,242 +0,0 @@ -.habbo-navigator-desktop { - border: 1px solid #000; - border-radius: 7px; - background: #e9e9e1; - box-shadow: 0 4px 4px rgba(0, 0, 0, 0.35); - color: #111; - font-family: Ubuntu, Arial, sans-serif; -} - -.habbo-navigator-desktop .nitro-card-header-shell { - min-height: 32px; - max-height: 32px; - background: #418db0; - border-bottom: 1px solid #000; - border-radius: 6px 6px 0 0; -} - -.habbo-navigator-desktop .nitro-card-title { - color: #fff; - font-family: UbuntuCondensed, Ubuntu, Arial, sans-serif; - font-size: 18px; - font-weight: 700; - line-height: 1; - text-shadow: 1px 1px 0 #000; -} - -.habbo-navigator-desktop .nitro-card-close-button { - width: 19px; - height: 20px; - right: 6px; - border: 2px solid #000; - border-radius: 4px; - background: #c73a32; - box-shadow: inset 1px 1px 0 rgba(255, 255, 255, 0.35); -} - -.habbo-navigator-desktop .nitro-card-close-button::before, -.habbo-navigator-desktop .nitro-card-close-button::after { - content: ""; - position: absolute; - width: 10px; - height: 2px; - background: #fff; - box-shadow: 1px 1px 0 #64120f; -} - -.habbo-navigator-desktop .nitro-card-close-button::before { - transform: rotate(45deg); -} - -.habbo-navigator-desktop .nitro-card-close-button::after { - transform: rotate(-45deg); -} - -.habbo-navigator-desktop .nitro-card-tabs-shell { - justify-content: flex-start; - gap: 0; - min-height: 27px; - max-height: 27px; - padding: 4px 8px 0; - background: #e9e9e1; - border-bottom: 1px solid #b8b8ad; -} - -.habbo-navigator-desktop .nitro-card-tab-item { - min-height: 23px; - margin-right: -1px; - padding: 4px 12px 3px; - border: 1px solid #555; - border-bottom: 0; - border-radius: 6px 6px 0 0; - background-color: #d5d8cf; - background-image: url("../../assets/images/navigator/swf/tab_bg_unsel.png"); - background-repeat: repeat-x; - background-size: auto 100%; - color: #111; - font-size: 12px; - font-weight: 400; - line-height: 1; - box-shadow: inset 1px 1px 0 #fff; -} - -.habbo-navigator-desktop .nitro-card-tab-item:hover { - background-color: #e7e8df; - background-image: url("../../assets/images/navigator/swf/tab_bg_hilite.png"); -} - -.habbo-navigator-desktop .nitro-card-tab-item-active { - z-index: 2; - margin-bottom: -1px; - background-color: #f4f4ed; - background-image: url("../../assets/images/navigator/swf/tab_bg_sel.png"); - border-bottom: 1px solid #f4f4ed; - font-weight: 700; -} - -.habbo-navigator-desktop .habbo-navigator-desktop-content { - padding: 8px 9px 9px; - overflow: hidden; - background: #f4f4ed; - border-radius: 0 0 6px 6px; - color: #111; -} - -.habbo-navigator-desktop .habbo-navigator-desktop-content input, -.habbo-navigator-desktop .habbo-navigator-desktop-content select, -.habbo-navigator-desktop .habbo-navigator-desktop-content textarea { - height: 22px; - border: 1px solid #a0a49c; - border-radius: 3px; - background-color: #fff; - background-image: url("../../assets/images/navigator/swf/hdr_search.png"); - background-repeat: repeat-x; - color: #333; - font-size: 12px; - box-shadow: inset 1px 1px 0 rgba(0, 0, 0, 0.08); -} - -.habbo-navigator-desktop .habbo-navigator-desktop-content button, -.habbo-navigator-desktop .habbo-navigator-desktop-content .btn { - min-height: 22px; - border: 1px solid #3a3a3a; - border-radius: 4px; - background-color: #d6d6d1; - background-image: url("../../assets/images/navigator/swf/button.png"); - background-repeat: repeat-x; - background-size: auto 100%; - color: #111; - font-size: 12px; - font-weight: 700; - box-shadow: inset 1px 1px 0 #fff; -} - -.habbo-navigator-desktop .nitro-card-panel { - border: 1px solid #babdb4; - border-radius: 6px; - background: #efefe8; - box-shadow: inset 1px 1px 0 #fff; - overflow: hidden; -} - -.habbo-navigator-desktop .nitro-card-panel > .flex:first-child { - min-height: 28px; - padding: 5px 8px; - border-bottom: 1px solid #d3d5cd; - background: #efefe8; -} - -.habbo-navigator-desktop .navigator-grid { - padding: 0 4px 5px; - background: #fff; -} - -.habbo-navigator-desktop .navigator-item { - min-height: 28px; - margin: 2px 0; - border: 1px solid transparent; - border-radius: 5px; - background: #f5f5ef; - color: #111; -} - -.habbo-navigator-desktop .navigator-item:nth-child(even) { - background: #e7e8e0; -} - -.habbo-navigator-desktop .navigator-item:hover { - border-color: #777; - background: #fff; -} - -.habbo-navigator-desktop .nitro-navigator-search-saves-result { - width: 155px; - min-width: 155px; - height: 100%; - border: 1px solid #babdb4; - border-radius: 6px; - background: #efefe8; - padding: 4px; -} - -.habbo-navigator-desktop .nitro-navigator-search-saves-result > .flex:first-child { - min-height: 24px; - border: 1px solid #d58e00; - border-radius: 4px; - background: #f8a900; - box-shadow: inset 1px 1px 0 rgba(255, 255, 255, 0.45); -} - -.habbo-navigator-desktop .nitro-navigator-search-saves-result span { - color: #111; - font-weight: 700; -} - -.habbo-navigator-desktop .nitro-icon.icon-navigator-info { - width: 16px; - height: 16px; -} - -.habbo-navigator-desktop ::-webkit-scrollbar { - width: 17px !important; - height: 17px !important; -} - -.habbo-navigator-desktop ::-webkit-scrollbar-track { - border: 0 !important; - background-color: transparent !important; - background-image: url("../../assets/images/catalog/swf/ubuntu_scrollbar_track_v_17x2.png") !important; - background-repeat: repeat-y !important; - background-position: center top !important; -} - -.habbo-navigator-desktop ::-webkit-scrollbar-thumb { - min-height: 24px !important; - border: 0 !important; - border-radius: 0 !important; - background-color: transparent !important; - background-image: - url("../../assets/images/catalog/swf/ubuntu_scrollbar_thumb_v_grip_7x10.png"), - url("../../assets/images/catalog/swf/ubuntu_scrollbar_thumb_v_top_17x2.png"), - url("../../assets/images/catalog/swf/ubuntu_scrollbar_thumb_v_bottom_17x2.png"), - url("../../assets/images/catalog/swf/ubuntu_scrollbar_thumb_v_mid_17x1.png") !important; - background-repeat: repeat-y, no-repeat, no-repeat, repeat-y !important; - background-position: center center, center top, center bottom, center top !important; - background-size: 7px 10px, 17px 2px, 17px 2px, 17px 1px !important; - box-shadow: none !important; -} - -.habbo-navigator-desktop ::-webkit-scrollbar-button { - width: 17px !important; - height: 16px !important; - background-color: transparent !important; - border: 0 !important; -} - -.habbo-navigator-desktop ::-webkit-scrollbar-button:single-button:vertical:decrement { - background-image: url("../../assets/images/catalog/swf/ubuntu_scrollbar_up_17x16.png") !important; -} - -.habbo-navigator-desktop ::-webkit-scrollbar-button:single-button:vertical:increment { - background-image: url("../../assets/images/catalog/swf/ubuntu_scrollbar_down_17x16.png") !important; -} 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 91c4147..3b7af7d 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -25,8 +25,10 @@ import './css/emustats/EmuStatsView.css'; import './css/chat/Chats.css'; import './css/chat/ChatInputMentionSelectorView.css'; import './css/mentions/MentionToasts.css'; +import './css/mentions/MentionsPanel.css'; import './css/common/Buttons.css'; +import './css/common/ClassicScrollbar.css'; import './css/forms/form_select.css'; diff --git a/src/pixiPatch.ts b/src/pixiPatch.ts index bea0f24..8074720 100644 --- a/src/pixiPatch.ts +++ b/src/pixiPatch.ts @@ -65,7 +65,6 @@ const installPatch = (): void => if(!proto) continue; if(guardMethod(proto, 'break', name)) patched = true; - if(guardMethod(proto, 'checkAndUpdateTexture', name)) patched = true; }