mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
Merge origin/main into main
Resolved 2 messenger conflicts: - FriendsMessengerView.tsx: union — kept local typingUserIds/sendTypingStatus from useMessenger() plus upstream's useFriends().getFriend. - FriendsView.css: kept local group-chips + typing-indicator styles (upstream empty there). Vitest 545/545 green. (typecheck TS2307 is the un-linked renderer, env-only.)
This commit is contained in:
@@ -640,8 +640,6 @@
|
||||
'wheel.extra': 'Extra spins: %count%',
|
||||
'wheel.spin': 'SPIN',
|
||||
'wheel.buy': 'Buy spin',
|
||||
'wheel.settings': 'Settings',
|
||||
'wheel.settings.title': 'Wheel of Fortune Settings',
|
||||
'wheel.winners': 'Latest winners',
|
||||
'wheel.winners.empty': 'No winners yet',
|
||||
'wheel.win.title': 'You won!',
|
||||
@@ -716,7 +714,7 @@
|
||||
'memenu.settings.other.place.multiple.objects': "Place multiple objects",
|
||||
'memenu.settings.other.skip.purchase.confirmation': "Skip purchase confirmation",
|
||||
'memenu.settings.other.enable.chat.window': "Enable chat window",
|
||||
'memenu.settings.other.catalog.classic.style': "Catalog: classic style",
|
||||
'memenu.settings.other.catalog.classic.style': "New style",
|
||||
'usersettings.open.title': "User settings",
|
||||
'usersettings.open.subtitle': "Password & account",
|
||||
'usersettings.themes.custom': "Custom theme",
|
||||
|
||||
@@ -640,8 +640,6 @@
|
||||
'wheel.extra': 'Giri extra: %count%',
|
||||
'wheel.spin': 'GIRA',
|
||||
'wheel.buy': 'Acquista giro',
|
||||
'wheel.settings': 'Configurações',
|
||||
'wheel.settings.title': 'Configuração de Sistema da Roleta',
|
||||
'wheel.winners': 'Ultimi vincitori',
|
||||
'wheel.winners.empty': 'Ancora nessun vincitore',
|
||||
'wheel.win.title': 'Hai vinto!',
|
||||
@@ -716,7 +714,7 @@
|
||||
'memenu.settings.other.place.multiple.objects': "Posiziona più oggetti",
|
||||
'memenu.settings.other.skip.purchase.confirmation': "Salta la conferma d'acquisto",
|
||||
'memenu.settings.other.enable.chat.window': "Abilita finestra chat",
|
||||
'memenu.settings.other.catalog.classic.style': "Catalogo: stile classico",
|
||||
'memenu.settings.other.catalog.classic.style': "Nuovo stile",
|
||||
'usersettings.open.title': "Impostazioni utente",
|
||||
'usersettings.open.subtitle': "Password e account",
|
||||
'usersettings.themes.custom': "Tema personalizzato",
|
||||
|
||||
@@ -716,7 +716,7 @@
|
||||
'memenu.settings.other.place.multiple.objects': "Meerdere objecten plaatsen",
|
||||
'memenu.settings.other.skip.purchase.confirmation': "Aankoopbevestiging overslaan",
|
||||
'memenu.settings.other.enable.chat.window': "Chatvenster inschakelen",
|
||||
'memenu.settings.other.catalog.classic.style': "Catalogus: klassieke stijl",
|
||||
'memenu.settings.other.catalog.classic.style': "Nieuwe stijl",
|
||||
'usersettings.open.title': "Gebruikersinstellingen",
|
||||
'usersettings.open.subtitle': "Wachtwoord & account",
|
||||
'usersettings.themes.custom': "Aangepast thema",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
export * from './MentionType';
|
||||
export * from './IMentionEntry';
|
||||
export * from './mentionTokens';
|
||||
export * from './mentionsFormat';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
// Shared @-mention token classification, used by both the chat-bubble
|
||||
// highlighter and the mentions panel so the two can't diverge.
|
||||
|
||||
export const MENTION_ROOM_ALIASES: ReadonlyArray<string> = [
|
||||
'all', 'everyone', 'tutti',
|
||||
'friends', 'amici',
|
||||
'room', 'stanza'
|
||||
];
|
||||
|
||||
const NON_NICK_CHARS = /[^A-Za-z0-9_]/g;
|
||||
|
||||
const normalizeToken = (token: string): string =>
|
||||
{
|
||||
if(!token || (token.length < 2) || (token.charAt(0) !== '@')) return '';
|
||||
|
||||
return token.substring(1).replace(NON_NICK_CHARS, '').toLowerCase();
|
||||
};
|
||||
|
||||
const normalizeNick = (value: string): string => (value || '').replace(NON_NICK_CHARS, '').toLowerCase();
|
||||
|
||||
// '' = not a mention; 'tag' = any @user (subtle chip); 'self' = the token
|
||||
// targets the viewer (own nick) or is a broadcast alias (strong highlight).
|
||||
export type MentionKind = '' | 'tag' | 'self';
|
||||
|
||||
export const classifyMentionToken = (
|
||||
token: string,
|
||||
ownUsername: string,
|
||||
aliases: ReadonlyArray<string> = MENTION_ROOM_ALIASES
|
||||
): MentionKind =>
|
||||
{
|
||||
const nick = normalizeToken(token);
|
||||
|
||||
if(!nick) return '';
|
||||
|
||||
const ownLower = normalizeNick(ownUsername);
|
||||
|
||||
if((ownLower && (nick === ownLower)) || aliases.some(alias => alias.toLowerCase() === nick)) return 'self';
|
||||
|
||||
return 'tag';
|
||||
};
|
||||
|
||||
/**
|
||||
* Back-compat boolean — true only when the token targets the viewer or a
|
||||
* broadcast alias (i.e. "I was mentioned"), not for generic @user tags.
|
||||
*/
|
||||
export const tokenIsMention = (
|
||||
token: string,
|
||||
ownUsername: string,
|
||||
aliases: ReadonlyArray<string> = MENTION_ROOM_ALIASES
|
||||
): boolean => (classifyMentionToken(token, ownUsername, aliases) === 'self');
|
||||
@@ -0,0 +1,25 @@
|
||||
import { IMentionEntry } from '../mentions';
|
||||
import { NotificationBubbleItem } from './NotificationBubbleItem';
|
||||
import { NotificationBubbleType } from './NotificationBubbleType';
|
||||
|
||||
/**
|
||||
* A notification bubble that carries a full mention entry, so the dedicated
|
||||
* mention bubble layout can render the sender's avatar (from the figure) and
|
||||
* the go-to-room action — data the plain NotificationBubbleItem can't hold.
|
||||
*/
|
||||
export class MentionNotificationBubbleItem extends NotificationBubbleItem
|
||||
{
|
||||
private _mention: IMentionEntry;
|
||||
|
||||
constructor(mention: IMentionEntry)
|
||||
{
|
||||
super(mention.message, NotificationBubbleType.MENTION, null, null, mention.senderUsername);
|
||||
|
||||
this._mention = mention;
|
||||
}
|
||||
|
||||
public get mention(): IMentionEntry
|
||||
{
|
||||
return this._mention;
|
||||
}
|
||||
}
|
||||
@@ -16,4 +16,5 @@ export class NotificationBubbleType
|
||||
public static BUYFURNI: string = 'buyfurni';
|
||||
public static VIP: string = 'vip';
|
||||
public static ROOMMESSAGESPOSTED: string = 'roommessagesposted';
|
||||
public static MENTION: string = 'mention';
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './NotificationAlertItem';
|
||||
export * from './NotificationAlertType';
|
||||
export * from './MentionNotificationBubbleItem';
|
||||
export * from './NotificationBubbleItem';
|
||||
export * from './NotificationBubbleType';
|
||||
export * from './NotificationConfirmItem';
|
||||
|
||||
@@ -8,6 +8,14 @@ export const LayoutRoomPreviewerView: FC<{
|
||||
{
|
||||
const { roomPreviewer = null, height = 0 } = props;
|
||||
const elementRef = useRef<HTMLDivElement>(null);
|
||||
// Counter that disables further renders once Pixi throws in this
|
||||
// previewer too many times in a row. The Pixi v8 null-texture bug
|
||||
// (see src/pixiPatch.ts) is mostly absorbed at the prototype level,
|
||||
// but any stray throw still cascades every animation frame. Allow
|
||||
// a small number of consecutive failures so a transient bad frame
|
||||
// self-recovers; permanently disable only if the previewer is truly
|
||||
// wedged, which is what produces the "disabling further renders"
|
||||
// log the user sees.
|
||||
const renderFailuresRef = useRef(0);
|
||||
const MAX_RENDER_FAILURES = 6;
|
||||
|
||||
@@ -62,6 +70,8 @@ export const LayoutRoomPreviewerView: FC<{
|
||||
canvas.height = 0;
|
||||
|
||||
elementRef.current.style.backgroundImage = `url(${ base64 })`;
|
||||
// A successful paint is the signal we've recovered from
|
||||
// a transient bad frame; reset the failure counter.
|
||||
renderFailuresRef.current = 0;
|
||||
}
|
||||
catch(error)
|
||||
|
||||
@@ -48,7 +48,7 @@ import { UserAccountSettingsView } from './user-settings/UserAccountSettingsView
|
||||
import { UserSettingsView } from './user-settings/UserSettingsView';
|
||||
import { WiredView } from './wired/WiredView';
|
||||
import { WiredCreatorToolsView } from './wired-tools/WiredCreatorToolsView';
|
||||
import { MentionsView, MentionToastsView } from './mentions';
|
||||
import { MentionsView } from './mentions';
|
||||
|
||||
export const MainView: FC<{}> = props =>
|
||||
{
|
||||
@@ -242,8 +242,6 @@ export const MainView: FC<{}> = props =>
|
||||
{ GetConfigurationValue<boolean>('radio_ui.enabled', false) && <RadioView /> }
|
||||
{ (GetConfigurationValue<boolean>('mentions_ui.enabled', true) && mentionsVisible) &&
|
||||
<MentionsView onClose={ () => setMentionsVisible(false) } /> }
|
||||
{ GetConfigurationValue<boolean>('mentions_ui.enabled', true) &&
|
||||
<MentionToastsView /> }
|
||||
<ExternalPluginLoader />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AddLinkEventTracker, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
|
||||
import { CSSProperties, FC, useEffect, useMemo, useState } from 'react';
|
||||
import { BadgeLeaderboardBoard, BadgeLeaderboardEntry, BadgeRarityKey, fetchBadgeLeaderboard, getCachedBadgeLeaderboard, LocalizeText } from '../../api';
|
||||
import { Column, DraggableWindow, DraggableWindowPosition, Flex, Text } from '../../common';
|
||||
import { Column, DraggableWindow, DraggableWindowPosition, Flex, LayoutAvatarImageView, Text } from '../../common';
|
||||
import {
|
||||
badgeEmblemAchievement,
|
||||
badgeEmblemCommon,
|
||||
@@ -40,12 +40,6 @@ const RARITY_ASSETS: Record<BadgeRarityKey, { frame: string; emblem: string }> =
|
||||
|
||||
const RARITY_ORDER: BadgeRarityKey[] = [ 'common', 'rare', 'epic', 'legendary', 'mythical', 'unique' ];
|
||||
const PAGE_SIZE = 10;
|
||||
const getAvatarHeadUrl = (figure: string): string =>
|
||||
{
|
||||
if(!figure) return '';
|
||||
|
||||
return `https://www.habbo.com/habbo-imaging/avatarimage?figure=${ encodeURIComponent(figure) }&direction=2&head_direction=2&gesture=sml&size=m&headonly=1`;
|
||||
};
|
||||
|
||||
export const BadgeLeaderboardView: FC<{}> = props =>
|
||||
{
|
||||
@@ -310,7 +304,7 @@ const LeaderboardRow: FC<LeaderboardRowProps> = props =>
|
||||
<div className={ `nitro-badge-leaderboard__row ${ isCurrentUser ? 'is-current-user' : '' } ${ ((rowIndex % 2) === 0) ? 'is-even' : 'is-odd' }` }>
|
||||
<div className={ `nitro-badge-leaderboard__rank ${ rankClassName }` }>{ entry.rank }</div>
|
||||
<div className="nitro-badge-leaderboard__avatar">
|
||||
<img className="nitro-badge-leaderboard__avatar-image" src={ getAvatarHeadUrl(entry.figure) } alt="" loading="lazy" />
|
||||
<LayoutAvatarImageView figure={ entry.figure } headOnly direction={ 2 } />
|
||||
</div>
|
||||
<Text className="nitro-badge-leaderboard__username" bold>{ entry.username }</Text>
|
||||
<Text className="nitro-badge-leaderboard__score" bold>{ entry.score }</Text>
|
||||
|
||||
@@ -40,7 +40,17 @@ export const CatalogGridOfferView: FC<CatalogGridOfferViewProps> = props =>
|
||||
|
||||
if(className?.length)
|
||||
{
|
||||
const param = (product.productType === ProductTypeEnum.WALL && product.extraParam?.length) ? `_${ product.extraParam }` : '';
|
||||
let param = '';
|
||||
|
||||
if(product.productType === ProductTypeEnum.WALL && product.extraParam?.length)
|
||||
{
|
||||
param = `_${ product.extraParam }`;
|
||||
}
|
||||
else if(product.productType === ProductTypeEnum.FLOOR && product.furnitureData?.hasIndexedColor && (product.furnitureData.colorIndex > 0))
|
||||
{
|
||||
param = `_${ product.furnitureData.colorIndex }`;
|
||||
}
|
||||
|
||||
const configuredIconUrl = GetConfigurationValue<string>('furni.asset.icon.url', '');
|
||||
|
||||
if(configuredIconUrl?.length)
|
||||
@@ -104,6 +114,7 @@ export const CatalogGridOfferView: FC<CatalogGridOfferViewProps> = props =>
|
||||
return (
|
||||
<LayoutGridItem
|
||||
className={ `group/tile relative ${ itemActive ? 'is-active' : '' }` }
|
||||
gap={ 1 }
|
||||
itemActive={ itemActive }
|
||||
itemCount={ ((offer.pricingModel === Offer.PRICING_MODEL_MULTI) ? product.productCount : 1) }
|
||||
itemUniqueNumber={ product.uniqueLimitedItemSeriesSize }
|
||||
|
||||
@@ -103,7 +103,7 @@ export const CatalogLayoutDefaultView: FC<CatalogLayoutProps> = props =>
|
||||
<div className="nitro-catalog-classic-grid-shell flex-1 overflow-auto min-h-0">
|
||||
{ GetConfigurationValue('catalog.headers') &&
|
||||
<CatalogHeaderView imageUrl={ currentPage.localization.getImage(0) } /> }
|
||||
<CatalogItemGridWidgetView className="nitro-catalog-classic-grid" columnCount={ 7 } columnMinHeight={ 70 } columnMinWidth={ 45 } />
|
||||
<CatalogItemGridWidgetView className="nitro-catalog-classic-grid" columnCount={ 6 } columnMinHeight={ 80 } columnMinWidth={ 55 } />
|
||||
</div>
|
||||
|
||||
{ currentOffer &&
|
||||
|
||||
@@ -19,7 +19,7 @@ export const CatalogViewProductWidgetView: FC<{}> = props =>
|
||||
if(!product) return;
|
||||
|
||||
roomPreviewer.reset(false);
|
||||
roomPreviewer.updateObjectRoom('default', 'default', 'default');
|
||||
roomPreviewer.updateObjectRoom('111', '217', '1.1');
|
||||
roomPreviewer.updateRoomWallsAndFloorVisibility(true, true);
|
||||
|
||||
const populate = () =>
|
||||
@@ -91,7 +91,7 @@ export const CatalogViewProductWidgetView: FC<{}> = props =>
|
||||
return;
|
||||
}
|
||||
default:
|
||||
roomPreviewer.updateObjectRoom('default', 'default', 'default');
|
||||
roomPreviewer.updateObjectRoom('101', '101', '1.1');
|
||||
roomPreviewer.addWallItemIntoRoom(product.productClassId, new Vector3d(90), product.extraParam);
|
||||
return;
|
||||
}
|
||||
@@ -106,13 +106,6 @@ export const CatalogViewProductWidgetView: FC<{}> = props =>
|
||||
};
|
||||
|
||||
populate();
|
||||
|
||||
// RoomPreviewer.addFurnitureIntoRoom / addAvatarIntoRoom flip
|
||||
// _automaticStateChange to true, which makes the ticker advance
|
||||
// the room object's state every AUTOMATIC_STATE_CHANGE_INTERVAL.
|
||||
// In the catalog we want the preview to sit still until the
|
||||
// user clicks the state button explicitly - turn it back off
|
||||
// after populate() runs.
|
||||
roomPreviewer.setAutomaticStateChange(false);
|
||||
}, [ currentOffer, previewStuffData, roomPreviewer ]);
|
||||
|
||||
@@ -132,11 +125,5 @@ export const CatalogViewProductWidgetView: FC<{}> = props =>
|
||||
);
|
||||
}
|
||||
|
||||
// Re-mount the previewer whenever the offer changes so the render
|
||||
// latch / texture handle in LayoutRoomPreviewerView resets cleanly.
|
||||
// Without this a single broken offer (e.g. blackhole's Pixi filter
|
||||
// crash) latches the previewer permanently and every following
|
||||
// offer paints nothing - the singleton roomPreviewer + 240px height
|
||||
// keep the same component mounted otherwise.
|
||||
return <LayoutRoomPreviewerView key={ currentOffer?.offerId } height={ 240 } roomPreviewer={ roomPreviewer } />;
|
||||
};
|
||||
|
||||
@@ -2,10 +2,10 @@ import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '
|
||||
import { FC, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ChatEntryType, LocalizeText } from '../../api';
|
||||
import { Flex, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView, Text } from '../../common';
|
||||
import { useChatHistory, useMentionsSnapshot, useOnClickChat } from '../../hooks';
|
||||
import { useChatHistory, useMentionActions, useMentionsSnapshot, useOnClickChat } from '../../hooks';
|
||||
import { useUserDataSnapshot } from '../../hooks/session/useSessionSnapshots';
|
||||
import { NitroInput } from '../../layout';
|
||||
import { MentionRowView, useMentionActions } from '../mentions';
|
||||
import { MentionRowView } from '../mentions';
|
||||
|
||||
const TAB_CHAT = 'chat';
|
||||
const TAB_MENTIONS = 'mentions';
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import { FC, useRef, useState } from 'react';
|
||||
import { FC, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { FaChevronLeft, FaChevronRight } from 'react-icons/fa';
|
||||
import { LocalizeText, MessengerFriend } from '../../../../api';
|
||||
import { FriendBarItemView } from './FriendBarItemView';
|
||||
import { motion, AnimatePresence, Variants } from 'framer-motion';
|
||||
|
||||
// Hard cap on simultaneously-shown friend chips. The effective count is
|
||||
// reduced below this when the bar would otherwise overflow its (clipped)
|
||||
// slot in the toolbar — see the width measurement below.
|
||||
const MAX_DISPLAY_COUNT = 3;
|
||||
|
||||
// Layout constants mirrored from FriendBarItemView / the flex gaps here, used
|
||||
// to compute how many friend chips fit in the available width. A "slot" is one
|
||||
// w-[132px] button plus the gap-[6px] that precedes it.
|
||||
const ITEM_SLOT = 138; // 132px chip + 6px gap (friend chip and search chip)
|
||||
const ARROWS_WIDTH = 52; // two w-[20px] arrows, each + 6px gap
|
||||
const REQUEST_SLOT = 120; // requests chip (only present when requestsCount > 0)
|
||||
const BASE_PAD = 8; // container px-[2px] + a little slack
|
||||
const RIGHT_SAFE = 24; // right inset (right-0/right-3) + pr-3 safety margin
|
||||
|
||||
// Mirrored from Toolbar to keep physics identical
|
||||
const containerVariants: Variants = {
|
||||
hidden: {},
|
||||
@@ -23,9 +35,55 @@ export const FriendBarView: FC<{ onlineFriends: MessengerFriend[]; requestsCount
|
||||
{
|
||||
const { onlineFriends = [], requestsCount = 0 } = props;
|
||||
const [ indexOffset, setIndexOffset ] = useState(0);
|
||||
const [ maxVisible, setMaxVisible ] = useState(MAX_DISPLAY_COUNT);
|
||||
const elementRef = useRef<HTMLDivElement>(null);
|
||||
const hasScrollableFriends = (onlineFriends.length > MAX_DISPLAY_COUNT);
|
||||
const visibleFriends = onlineFriends.slice(indexOffset, (indexOffset + MAX_DISPLAY_COUNT));
|
||||
|
||||
// Auto-fit the visible friend count to the room actually available between
|
||||
// the bar's left edge and the right side of the viewport. The bar lives in
|
||||
// a `overflow-x: clip` toolbar slot, so anything that doesn't fit would be
|
||||
// silently cut off (the scroll arrow / search button disappear). The bar's
|
||||
// left edge is stable (it sits after fixed-width toolbar icons), so growing
|
||||
// or shrinking the chip count never moves it — no measurement feedback loop.
|
||||
useLayoutEffect(() =>
|
||||
{
|
||||
const element = elementRef.current;
|
||||
|
||||
if(!element) return;
|
||||
|
||||
const measure = () =>
|
||||
{
|
||||
const left = element.getBoundingClientRect().left;
|
||||
const available = window.innerWidth - left - RIGHT_SAFE;
|
||||
const fixed = ARROWS_WIDTH + ITEM_SLOT /* search chip */ + BASE_PAD + ((requestsCount > 0) ? REQUEST_SLOT : 0);
|
||||
const fit = Math.floor((available - fixed) / ITEM_SLOT);
|
||||
const next = Math.max(1, Math.min(MAX_DISPLAY_COUNT, fit));
|
||||
|
||||
setMaxVisible(prev => ((prev === next) ? prev : next));
|
||||
};
|
||||
|
||||
measure();
|
||||
|
||||
const observer = new ResizeObserver(measure);
|
||||
|
||||
observer.observe(document.documentElement);
|
||||
window.addEventListener('resize', measure);
|
||||
|
||||
return () =>
|
||||
{
|
||||
observer.disconnect();
|
||||
window.removeEventListener('resize', measure);
|
||||
};
|
||||
}, [ requestsCount, onlineFriends.length ]);
|
||||
|
||||
// `safeOffset` is the offset clamped to the current list/fit. Every read
|
||||
// below uses it, so a stale `indexOffset` (after the list shrinks or the fit
|
||||
// grows) renders correctly and self-corrects on the next arrow click — no
|
||||
// write-back effect needed.
|
||||
const maxOffset = Math.max(0, (onlineFriends.length - maxVisible));
|
||||
const safeOffset = Math.min(indexOffset, maxOffset);
|
||||
const canScrollLeft = (safeOffset > 0);
|
||||
const canScrollRight = (safeOffset < maxOffset);
|
||||
const visibleFriends = onlineFriends.slice(safeOffset, (safeOffset + maxVisible));
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
@@ -44,10 +102,10 @@ export const FriendBarView: FC<{ onlineFriends: MessengerFriend[]; requestsCount
|
||||
</motion.div> }
|
||||
<motion.div variants={itemVariants}>
|
||||
<div
|
||||
className={ `flex h-[34px] w-[20px] items-center justify-center text-white/80 transition-all ${ (!hasScrollableFriends || (indexOffset <= 0)) ? 'opacity-30 cursor-not-allowed' : 'cursor-pointer hover:text-white active:scale-95' }` }
|
||||
className={ `flex h-[34px] w-[20px] items-center justify-center text-white/80 transition-all ${ !canScrollLeft ? 'opacity-30 cursor-not-allowed' : 'cursor-pointer hover:text-white active:scale-95' }` }
|
||||
onClick={ () =>
|
||||
{
|
||||
if(indexOffset > 0) setIndexOffset(indexOffset - 1);
|
||||
if(canScrollLeft) setIndexOffset(safeOffset - 1);
|
||||
} }
|
||||
>
|
||||
<FaChevronLeft className="text-white/70 text-sm drop-shadow-[1px_1px_0_#000]" />
|
||||
@@ -94,10 +152,10 @@ export const FriendBarView: FC<{ onlineFriends: MessengerFriend[]; requestsCount
|
||||
|
||||
<motion.div variants={itemVariants}>
|
||||
<div
|
||||
className={ `flex h-[34px] w-[20px] items-center justify-center text-white/80 transition-all ${ (!hasScrollableFriends || !((onlineFriends.length > MAX_DISPLAY_COUNT) && ((indexOffset + MAX_DISPLAY_COUNT) <= (onlineFriends.length - 1)))) ? 'opacity-30 cursor-not-allowed' : 'cursor-pointer hover:text-white active:scale-95' }` }
|
||||
className={ `flex h-[34px] w-[20px] items-center justify-center text-white/80 transition-all ${ !canScrollRight ? 'opacity-30 cursor-not-allowed' : 'cursor-pointer hover:text-white active:scale-95' }` }
|
||||
onClick={ () =>
|
||||
{
|
||||
if((onlineFriends.length > MAX_DISPLAY_COUNT) && ((indexOffset + MAX_DISPLAY_COUNT) <= (onlineFriends.length - 1))) setIndexOffset(indexOffset + 1);
|
||||
if(canScrollRight) setIndexOffset(safeOffset + 1);
|
||||
} }
|
||||
>
|
||||
<FaChevronRight className="text-white/70 text-sm drop-shadow-[1px_1px_0_#000]" />
|
||||
|
||||
@@ -3,7 +3,8 @@ import { FC, KeyboardEvent, useEffect, useRef, useState } from 'react';
|
||||
import { FaTimes } from 'react-icons/fa';
|
||||
import { GetUserProfile, LocalizeText, ReportType, SendMessageComposer } from '../../../../api';
|
||||
import { DraggableWindowPosition, LayoutAvatarImageView, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
|
||||
import { useHelp, useMessenger, useTranslation } from '../../../../hooks';
|
||||
import { useFriends, useHelp, useMessenger, useTranslation } from '../../../../hooks';
|
||||
import { resolveAvatarFigure } from '../friends-list/resolveAvatarFigure';
|
||||
import { FriendsMessengerThreadView } from './messenger-thread/FriendsMessengerThreadView';
|
||||
|
||||
export const FriendsMessengerView: FC<{}> = props =>
|
||||
@@ -12,6 +13,7 @@ export const FriendsMessengerView: FC<{}> = props =>
|
||||
const [ lastThreadId, setLastThreadId ] = useState(-1);
|
||||
const [ messageText, setMessageText ] = useState('');
|
||||
const { visibleThreads = [], activeThread = null, getMessageThread = null, sendMessage = null, setActiveThreadId = null, closeThread = null, typingUserIds = [], sendTypingStatus = null } = useMessenger();
|
||||
const { getFriend = null } = useFriends();
|
||||
const { report = null } = useHelp();
|
||||
const { settings, translateOutgoing } = useTranslation();
|
||||
const messagesBox = useRef<HTMLDivElement>(null);
|
||||
@@ -185,12 +187,22 @@ export const FriendsMessengerView: FC<{}> = props =>
|
||||
<div className="messenger-avatar-bar">
|
||||
{ visibleThreads && (visibleThreads.length > 0) && visibleThreads.map(thread =>
|
||||
{
|
||||
const isStaff = (thread.participant.id <= 0);
|
||||
// Read the live look from the friend list (same source the friends
|
||||
// list renders) so offline friends show their real avatar instead
|
||||
// of the standard/anonymous one; resolveAvatarFigure is the final
|
||||
// fallback when the look is genuinely missing.
|
||||
const liveFriend = isStaff ? null : getFriend(thread.participant.id);
|
||||
const figure = isStaff
|
||||
? (thread.participant.figure === 'ADM' ? 'ha-3409-1413-70.lg-285-89.ch-3032-1334-109.sh-3016-110.hd-185-1359.ca-3225-110-62.wa-3264-62-62.fa-1206-90.hr-3322-1403' : thread.participant.figure)
|
||||
: resolveAvatarFigure(liveFriend?.figure || thread.participant.figure, liveFriend?.gender ?? thread.participant.gender);
|
||||
|
||||
return (
|
||||
<button key={ thread.threadId } className={ 'messenger-avatar-tab' + ((activeThread === thread) ? ' active' : '') + (thread.unread ? ' unread' : '') } onClick={ event => setActiveThreadId(thread.threadId) }>
|
||||
<LayoutAvatarImageView
|
||||
figure={ thread.participant.id > 0 ? thread.participant.figure : thread.participant.figure === 'ADM' ? 'ha-3409-1413-70.lg-285-89.ch-3032-1334-109.sh-3016-110.hd-185-1359.ca-3225-110-62.wa-3264-62-62.fa-1206-90.hr-3322-1403' : thread.participant.figure }
|
||||
figure={ figure }
|
||||
headOnly={ true }
|
||||
direction={ thread.participant.id > 0 ? 2 : 3 }
|
||||
direction={ isStaff ? 3 : 2 }
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
|
||||
+4
-1
@@ -2,10 +2,13 @@ import { GetSessionDataManager } from '@nitrots/nitro-renderer';
|
||||
import { FC, useMemo } from 'react';
|
||||
import { GetGroupChatData, LocalizeText, MessengerGroupType, MessengerThread, MessengerThreadChat, MessengerThreadChatGroup } from '../../../../../api';
|
||||
import { Base, Flex, LayoutAvatarImageView } from '../../../../../common';
|
||||
import { useFriends } from '../../../../../hooks';
|
||||
import { resolveAvatarFigure } from '../../friends-list/resolveAvatarFigure';
|
||||
|
||||
export const FriendsMessengerThreadGroup: FC<{ thread: MessengerThread, group: MessengerThreadChatGroup }> = props =>
|
||||
{
|
||||
const { thread = null, group = null } = props;
|
||||
const { getFriend = null } = useFriends();
|
||||
|
||||
const groupChatData = useMemo(() => ((group.type === MessengerGroupType.GROUP_CHAT) && GetGroupChatData(group.chats[0].extraData)), [ group ]);
|
||||
|
||||
@@ -50,7 +53,7 @@ export const FriendsMessengerThreadGroup: FC<{ thread: MessengerThread, group: M
|
||||
<Flex fullWidth gap={ 2 } justifyContent={ isOwnChat ? 'end' : 'start' } className={ 'messenger-message-row ' + (isOwnChat ? 'own' : '') }>
|
||||
<Base shrink className="message-avatar">
|
||||
{ ((group.type === MessengerGroupType.PRIVATE_CHAT) && !isOwnChat) &&
|
||||
<LayoutAvatarImageView direction={ 2 } figure={ thread.participant.figure } headOnly={ true } /> }
|
||||
<LayoutAvatarImageView direction={ 2 } figure={ resolveAvatarFigure(getFriend?.(thread.participant.id)?.figure || thread.participant.figure, getFriend?.(thread.participant.id)?.gender ?? thread.participant.gender) } headOnly={ true } /> }
|
||||
{ (groupChatData && !isOwnChat) &&
|
||||
<LayoutAvatarImageView direction={ 2 } figure={ groupChatData.figure } headOnly={ true } /> }
|
||||
</Base>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { GetRoomEngine, IRoomSession, RoomObjectVariable, RoomPreviewer } from '@nitrots/nitro-renderer';
|
||||
import { IRoomSession, RoomPreviewer } from '@nitrots/nitro-renderer';
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { IBotItem, LocalizeText, UnseenItemCategory, attemptBotPlacement } from '../../../../api';
|
||||
import { LayoutRoomPreviewerView } from '../../../../common';
|
||||
@@ -22,20 +22,9 @@ export const InventoryBotView: FC<{
|
||||
if(!selectedBot || !roomPreviewer) return;
|
||||
|
||||
const botData = selectedBot.botData;
|
||||
|
||||
const roomEngine = GetRoomEngine();
|
||||
|
||||
let wallType = roomEngine.getRoomInstanceVariable<string>(roomEngine.activeRoomId, RoomObjectVariable.ROOM_WALL_TYPE);
|
||||
let floorType = roomEngine.getRoomInstanceVariable<string>(roomEngine.activeRoomId, RoomObjectVariable.ROOM_FLOOR_TYPE);
|
||||
let landscapeType = roomEngine.getRoomInstanceVariable<string>(roomEngine.activeRoomId, RoomObjectVariable.ROOM_LANDSCAPE_TYPE);
|
||||
|
||||
wallType = (wallType && wallType.length) ? wallType : '101';
|
||||
floorType = (floorType && floorType.length) ? floorType : '101';
|
||||
landscapeType = (landscapeType && landscapeType.length) ? landscapeType : '1.1';
|
||||
|
||||
roomPreviewer.reset(false);
|
||||
roomPreviewer.updateRoomWallsAndFloorVisibility(true, true);
|
||||
roomPreviewer.updateObjectRoom(floorType, wallType, landscapeType);
|
||||
roomPreviewer.updateObjectRoom('111', '217', '1.1');
|
||||
roomPreviewer.addAvatarIntoRoom(botData.figure, 0);
|
||||
}, [ roomPreviewer, selectedBot ]);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { InfiniteGrid } from '@layout/InfiniteGrid';
|
||||
import { GetRoomEngine, GetSessionDataManager, IRoomSession, RoomObjectVariable, RoomPreviewer, Vector3d } from '@nitrots/nitro-renderer';
|
||||
import { GetSessionDataManager, IRoomSession, RoomPreviewer, Vector3d } from '@nitrots/nitro-renderer';
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { FaPowerOff, FaSyncAlt, FaTrashAlt } from 'react-icons/fa';
|
||||
import { DispatchUiEvent, FurniCategory, GroupItem, LocalizeText, UnseenItemCategory, attemptItemPlacement } from '../../../../api';
|
||||
@@ -53,24 +53,17 @@ export const InventoryFurnitureView: FC<{
|
||||
|
||||
const isRoomDecoration = (furnitureItem.category === FurniCategory.WALL_PAPER) || (furnitureItem.category === FurniCategory.FLOOR) || (furnitureItem.category === FurniCategory.LANDSCAPE);
|
||||
|
||||
let floorType = '111';
|
||||
let wallType = '217';
|
||||
let landscapeType = '1.1';
|
||||
|
||||
if(isRoomDecoration)
|
||||
{
|
||||
const roomEngine = GetRoomEngine();
|
||||
|
||||
let wallType = roomEngine.getRoomInstanceVariable<string>(roomEngine.activeRoomId, RoomObjectVariable.ROOM_WALL_TYPE);
|
||||
let floorType = roomEngine.getRoomInstanceVariable<string>(roomEngine.activeRoomId, RoomObjectVariable.ROOM_FLOOR_TYPE);
|
||||
let landscapeType = roomEngine.getRoomInstanceVariable<string>(roomEngine.activeRoomId, RoomObjectVariable.ROOM_LANDSCAPE_TYPE);
|
||||
|
||||
wallType = (wallType && wallType.length) ? wallType : '101';
|
||||
floorType = (floorType && floorType.length) ? floorType : '101';
|
||||
landscapeType = (landscapeType && landscapeType.length) ? landscapeType : '1.1';
|
||||
|
||||
roomPreviewer.updateRoomWallsAndFloorVisibility(true, true);
|
||||
|
||||
floorType = ((furnitureItem.category === FurniCategory.FLOOR) ? selectedItem.stuffData.getLegacyString() : floorType);
|
||||
wallType = ((furnitureItem.category === FurniCategory.WALL_PAPER) ? selectedItem.stuffData.getLegacyString() : wallType);
|
||||
landscapeType = ((furnitureItem.category === FurniCategory.LANDSCAPE) ? selectedItem.stuffData.getLegacyString() : landscapeType);
|
||||
|
||||
roomPreviewer.updateRoomWallsAndFloorVisibility(true, true);
|
||||
roomPreviewer.updateObjectRoom(floorType, wallType, landscapeType);
|
||||
|
||||
if(furnitureItem.category === FurniCategory.LANDSCAPE)
|
||||
@@ -83,7 +76,7 @@ export const InventoryFurnitureView: FC<{
|
||||
return;
|
||||
}
|
||||
|
||||
roomPreviewer.updateObjectRoom('default', 'default', 'default');
|
||||
roomPreviewer.updateObjectRoom(floorType, wallType, landscapeType);
|
||||
roomPreviewer.updateRoomWallsAndFloorVisibility(true, true);
|
||||
|
||||
if(selectedItem.isWallItem)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DeletePetMessageComposer, GetRoomEngine, IRoomSession, RoomObjectVariable, RoomPreviewer } from '@nitrots/nitro-renderer';
|
||||
import { DeletePetMessageComposer, IRoomSession, RoomPreviewer } from '@nitrots/nitro-renderer';
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { FaTrashAlt } from 'react-icons/fa';
|
||||
import { IPetItem, LocalizeText, SendMessageComposer, UnseenItemCategory, attemptPetPlacement } from '../../../../api';
|
||||
@@ -38,19 +38,10 @@ export const InventoryPetView: FC<{
|
||||
if(!selectedPet || !roomPreviewer) return;
|
||||
|
||||
const petData = selectedPet.petData;
|
||||
const roomEngine = GetRoomEngine();
|
||||
|
||||
let wallType = roomEngine.getRoomInstanceVariable<string>(roomEngine.activeRoomId, RoomObjectVariable.ROOM_WALL_TYPE);
|
||||
let floorType = roomEngine.getRoomInstanceVariable<string>(roomEngine.activeRoomId, RoomObjectVariable.ROOM_FLOOR_TYPE);
|
||||
let landscapeType = roomEngine.getRoomInstanceVariable<string>(roomEngine.activeRoomId, RoomObjectVariable.ROOM_LANDSCAPE_TYPE);
|
||||
|
||||
wallType = (wallType && wallType.length) ? wallType : '101';
|
||||
floorType = (floorType && floorType.length) ? floorType : '101';
|
||||
landscapeType = (landscapeType && landscapeType.length) ? landscapeType : '1.1';
|
||||
|
||||
roomPreviewer.reset(false);
|
||||
roomPreviewer.updateRoomWallsAndFloorVisibility(true, true);
|
||||
roomPreviewer.updateObjectRoom(floorType, wallType, landscapeType);
|
||||
roomPreviewer.updateObjectRoom('111', '217', '1.1');
|
||||
roomPreviewer.addPetIntoRoom(petData.figureString);
|
||||
}, [ roomPreviewer, selectedPet ]);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FC, Fragment, ReactNode } from 'react';
|
||||
import { tokenIsMention } from '../room/widgets/chat/highlightMentions';
|
||||
import { classifyMentionToken } from '../../api/mentions/mentionTokens';
|
||||
|
||||
interface MentionMessageViewProps
|
||||
{
|
||||
@@ -9,10 +9,11 @@ interface MentionMessageViewProps
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a mention's message text as React nodes, wrapping the token(s) that
|
||||
* mention the local user or a room-broadcast alias in a `.mention-highlight`
|
||||
* span. Pure text segmentation (no innerHTML) → no XSS risk from other users'
|
||||
* chat content. Original spacing is preserved verbatim.
|
||||
* Renders a mention's message text as React nodes, wrapping every @user token
|
||||
* in a `.mention-tag` span (with the `.mention-tag--self` modifier when the
|
||||
* token targets the local user or a broadcast alias). Pure text segmentation
|
||||
* (no innerHTML) → no XSS risk from other users' chat content. Original spacing
|
||||
* is preserved verbatim.
|
||||
*/
|
||||
export const MentionMessageView: FC<MentionMessageViewProps> = props =>
|
||||
{
|
||||
@@ -24,12 +25,11 @@ export const MentionMessageView: FC<MentionMessageViewProps> = props =>
|
||||
{
|
||||
if(segment.length === 0) return null;
|
||||
|
||||
if(/^\s+$/.test(segment) || !tokenIsMention(segment, ownUsername))
|
||||
{
|
||||
return <Fragment key={ index }>{ segment }</Fragment>;
|
||||
}
|
||||
const kind = (/^\s+$/.test(segment)) ? '' : classifyMentionToken(segment, ownUsername);
|
||||
|
||||
return <span key={ index } className="mention-highlight">{ segment }</span>;
|
||||
if(!kind) return <Fragment key={ index }>{ segment }</Fragment>;
|
||||
|
||||
return <span key={ index } className={ (kind === 'self') ? 'mention-tag mention-tag--self' : 'mention-tag' }>{ segment }</span>;
|
||||
});
|
||||
|
||||
return <span className={ className }>{ nodes }</span>;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { FC, MouseEvent } from 'react';
|
||||
import { IMentionEntry, LocalizeText, MentionType } from '../../api';
|
||||
import { Flex, Text } from '../../common';
|
||||
import { FaArrowRight, FaTimes } from 'react-icons/fa';
|
||||
import { formatMentionTime, IMentionEntry, LocalizeText, MentionType } from '../../api';
|
||||
import { LayoutAvatarImageView } from '../../common';
|
||||
import { MentionMessageView } from './MentionMessageView';
|
||||
import { formatMentionTime } from './mentionsFormat';
|
||||
|
||||
interface MentionRowViewProps
|
||||
{
|
||||
@@ -28,39 +28,31 @@ export const MentionRowView: FC<MentionRowViewProps> = props =>
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex pointer alignItems="center" className="group relative px-1 py-[3px] rounded hover:bg-black/5" gap={ 2 } onClick={ () => onOpen(mention) }>
|
||||
<span
|
||||
className={ `inline-block w-[8px] h-[8px] rounded-full shrink-0 ${ mention.read ? 'bg-transparent' : 'bg-[#1e7295]' }` }
|
||||
title={ mention.read ? '' : LocalizeText('mentions.filter.unread') } />
|
||||
<span
|
||||
title={ typeTitle }
|
||||
className={ `flex items-center justify-center shrink-0 w-[18px] h-[18px] rounded text-[10px] font-bold leading-none text-white ${ isRoom ? 'bg-[#d08a1e]' : 'bg-[#1e7295]' }` }>
|
||||
{ isRoom ? '@∗' : '@' }
|
||||
</span>
|
||||
<Flex grow column className="min-w-0" gap={ 0 }>
|
||||
<Flex alignItems="center" gap={ 1 } className="min-w-0">
|
||||
<Text bold={ !mention.read } truncate variant="primary">{ mention.senderUsername }</Text>
|
||||
{ (mention.roomName && mention.roomName.length > 0) &&
|
||||
<Text small truncate variant="gray">· { mention.roomName }</Text> }
|
||||
</Flex>
|
||||
<MentionMessageView className="block truncate text-black text-sm" ownUsername={ ownUsername } text={ mention.message } />
|
||||
</Flex>
|
||||
<Flex alignItems="center" gap={ 1 } className="shrink-0">
|
||||
<div className={ `mention-row ${ mention.read ? '' : 'is-unread' }` } onClick={ () => onOpen(mention) }>
|
||||
{ !mention.read &&
|
||||
<span className="mention-row-unread-dot" aria-hidden /> }
|
||||
<div className="mention-row-avatar" title={ typeTitle }>
|
||||
<LayoutAvatarImageView headOnly direction={ 2 } figure={ mention.senderFigure } />
|
||||
<span className={ `mention-row-type ${ isRoom ? 'is-room' : 'is-direct' }` }>{ isRoom ? '∗' : '@' }</span>
|
||||
</div>
|
||||
<div className="mention-row-body">
|
||||
<div className="mention-row-head">
|
||||
<span className="mention-row-name">{ mention.senderUsername }</span>
|
||||
{ (mention.roomName && (mention.roomName.length > 0)) &&
|
||||
<span className="mention-row-room">· { mention.roomName }</span> }
|
||||
</div>
|
||||
<MentionMessageView className="mention-row-msg" ownUsername={ ownUsername } text={ mention.message } />
|
||||
</div>
|
||||
<div className="mention-row-meta">
|
||||
{ (time.length > 0) &&
|
||||
<Text small variant="gray" className="tabular-nums group-hover:hidden">{ time }</Text> }
|
||||
<Flex alignItems="center" gap={ 1 } className="hidden group-hover:flex">
|
||||
<span className="mention-row-time">{ time }</span> }
|
||||
<div className="mention-row-actions">
|
||||
{ onGoto &&
|
||||
<span
|
||||
title={ LocalizeText('mentions.action.goto') }
|
||||
className="flex items-center justify-center w-[18px] h-[18px] rounded bg-black/10 hover:bg-black/20 text-[12px] leading-none"
|
||||
onClick={ event => stop(event, () => onGoto(mention)) }>→</span> }
|
||||
<button type="button" className="mention-row-action" title={ LocalizeText('mentions.action.goto') } onClick={ event => stop(event, () => onGoto(mention)) }><FaArrowRight /></button> }
|
||||
{ onRemove &&
|
||||
<span
|
||||
title={ LocalizeText('mentions.action.remove') }
|
||||
className="flex items-center justify-center w-[18px] h-[18px] rounded bg-black/10 hover:bg-red-500 hover:text-white text-[11px] leading-none"
|
||||
onClick={ event => stop(event, () => onRemove(mention)) }>✕</span> }
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<button type="button" className="mention-row-action is-remove" title={ LocalizeText('mentions.action.remove') } onClick={ event => stop(event, () => onRemove(mention)) }><FaTimes /></button> }
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { CreateLinkEvent, MarkMentionsReadComposer } from '@nitrots/nitro-renderer';
|
||||
import { FC, MouseEvent, useEffect } from 'react';
|
||||
import { FaTimes } from 'react-icons/fa';
|
||||
import { LocalizeText, SendMessageComposer } from '../../api';
|
||||
import { LayoutAvatarImageView } from '../../common';
|
||||
import { useExternalSnapshot } from '../../hooks/events/useExternalSnapshot';
|
||||
import { markRead } from '../../hooks/mentions/mentionsStore';
|
||||
import { dismissMentionToast, getMentionToasts, MentionToast, subscribeMentionToasts } from '../../hooks/mentions/mentionToastsStore';
|
||||
|
||||
// Quanto resta visibile un toast prima di nascondersi da solo (resta non-letto).
|
||||
const AUTO_DISMISS_MS = 8000;
|
||||
|
||||
const MentionToastItemView: FC<{ toast: MentionToast }> = ({ toast }) =>
|
||||
{
|
||||
useEffect(() =>
|
||||
{
|
||||
const timer = window.setTimeout(() => dismissMentionToast(toast.mentionId), AUTO_DISMISS_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [ toast.mentionId ]);
|
||||
|
||||
// Dismiss esplicito: segna letta (badge toolbar si aggiorna) + persiste sul server + chiude.
|
||||
const onDismiss = (event: MouseEvent) =>
|
||||
{
|
||||
event.stopPropagation();
|
||||
markRead(toast.mentionId);
|
||||
SendMessageComposer(new MarkMentionsReadComposer(1, toast.mentionId));
|
||||
dismissMentionToast(toast.mentionId);
|
||||
};
|
||||
|
||||
const onOpen = () =>
|
||||
{
|
||||
CreateLinkEvent('mentions/toggle');
|
||||
dismissMentionToast(toast.mentionId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mention-toast" onClick={ onOpen }>
|
||||
<div className="mention-toast-avatar">
|
||||
<LayoutAvatarImageView headOnly direction={ 2 } figure={ toast.senderFigure } />
|
||||
</div>
|
||||
<div className="mention-toast-body">
|
||||
<div className="mention-toast-title">{ toast.senderUsername }</div>
|
||||
<div className="mention-toast-message">{ toast.message }</div>
|
||||
</div>
|
||||
<button className="mention-toast-dismiss" title={ LocalizeText('generic.cancel') } type="button" onClick={ onDismiss }>
|
||||
<FaTimes />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const MentionToastsView: FC = () =>
|
||||
{
|
||||
const toasts = useExternalSnapshot(subscribeMentionToasts, getMentionToasts);
|
||||
|
||||
if(!toasts || !toasts.length) return null;
|
||||
|
||||
return (
|
||||
<div className="mention-toasts">
|
||||
{ toasts.map(toast => <MentionToastItemView key={ toast.mentionId } toast={ toast } />) }
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,13 +1,12 @@
|
||||
import { MarkMentionsReadComposer } from '@nitrots/nitro-renderer';
|
||||
import { FC, useCallback, useMemo, useState } from 'react';
|
||||
import { IMentionEntry, LocalizeText, MentionType, SendMessageComposer } from '../../api';
|
||||
import { Button, Flex, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common';
|
||||
import { useMentionsSnapshot } from '../../hooks';
|
||||
import { MarkMentionsReadComposer, RequestMentionsComposer } from '@nitrots/nitro-renderer';
|
||||
import { FC, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { FaSearch, FaSync } from 'react-icons/fa';
|
||||
import { getMentionDateGroup, IMentionEntry, LocalizeText, MentionDateGroup, MentionType, SendMessageComposer } from '../../api';
|
||||
import { Button, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../common';
|
||||
import { useMentionActions, useMentionsSnapshot } from '../../hooks';
|
||||
import { markAllRead } from '../../hooks/mentions/mentionsStore';
|
||||
import { useUserDataSnapshot } from '../../hooks/session/useSessionSnapshots';
|
||||
import { MentionRowView } from './MentionRowView';
|
||||
import { getMentionDateGroup, MentionDateGroup } from './mentionsFormat';
|
||||
import { useMentionActions } from './useMentionActions';
|
||||
|
||||
interface MentionsViewProps
|
||||
{
|
||||
@@ -41,6 +40,17 @@ const matchesFilter = (mention: IMentionEntry, filter: MentionFilter): boolean =
|
||||
}
|
||||
};
|
||||
|
||||
const matchesQuery = (mention: IMentionEntry, query: string): boolean =>
|
||||
{
|
||||
if(!query) return true;
|
||||
|
||||
const q = query.toLowerCase();
|
||||
|
||||
return ((mention.senderUsername || '').toLowerCase().includes(q)
|
||||
|| (mention.roomName || '').toLowerCase().includes(q)
|
||||
|| (mention.message || '').toLowerCase().includes(q));
|
||||
};
|
||||
|
||||
export const MentionsView: FC<MentionsViewProps> = props =>
|
||||
{
|
||||
const { onClose } = props;
|
||||
@@ -48,6 +58,15 @@ export const MentionsView: FC<MentionsViewProps> = props =>
|
||||
const { userName: ownUsername = '' } = useUserDataSnapshot();
|
||||
const { open, goto, remove } = useMentionActions();
|
||||
const [ filter, setFilter ] = useState<MentionFilter>('all');
|
||||
const [ query, setQuery ] = useState('');
|
||||
|
||||
// Re-request from the server: once on open, and on the manual refresh button.
|
||||
const refresh = useCallback(() => SendMessageComposer(new RequestMentionsComposer()), []);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
refresh();
|
||||
}, [ refresh ]);
|
||||
|
||||
const onMarkAll = useCallback(() =>
|
||||
{
|
||||
@@ -62,48 +81,57 @@ export const MentionsView: FC<MentionsViewProps> = props =>
|
||||
for(const mention of mentions)
|
||||
{
|
||||
if(!matchesFilter(mention, filter)) continue;
|
||||
if(!matchesQuery(mention, query)) continue;
|
||||
buckets[getMentionDateGroup(mention.timestamp)].push(mention);
|
||||
}
|
||||
|
||||
return GROUP_ORDER
|
||||
.map(key => ({ key, items: buckets[key] }))
|
||||
.filter(group => group.items.length > 0);
|
||||
}, [ mentions, filter ]);
|
||||
}, [ mentions, filter, query ]);
|
||||
|
||||
const hasAny = groups.length > 0;
|
||||
const title = `${ LocalizeText('mentions.window.title') }${ (unreadCount > 0) ? ` (${ unreadCount })` : '' }`;
|
||||
|
||||
return (
|
||||
<NitroCardView className="w-[360px] h-[440px]" theme="primary-slim" uniqueKey="mentions">
|
||||
<NitroCardHeaderView headerText={ LocalizeText('mentions.window.title') } onCloseClick={ onClose } />
|
||||
<NitroCardView className="mentions-window w-[360px] has-classic-scrollbar" theme="primary-slim" uniqueKey="mentions">
|
||||
<NitroCardHeaderView headerText={ title } onCloseClick={ onClose } />
|
||||
<NitroCardContentView gap={ 1 }>
|
||||
<Flex alignItems="center" className="flex-wrap" gap={ 1 }>
|
||||
{ FILTERS.map(({ key, label }) =>
|
||||
{
|
||||
const active = (filter === key);
|
||||
const showCount = ((key === 'unread') && (unreadCount > 0));
|
||||
<div className="mentions-search">
|
||||
<FaSearch className="mentions-search-icon" />
|
||||
<input
|
||||
type="text"
|
||||
value={ query }
|
||||
placeholder={ LocalizeText('generic.search') }
|
||||
onChange={ event => setQuery(event.target.value) } />
|
||||
</div>
|
||||
<div className="mentions-toolbar">
|
||||
<div className="mentions-filters">
|
||||
{ FILTERS.map(({ key, label }) =>
|
||||
{
|
||||
const active = (filter === key);
|
||||
const showCount = ((key === 'unread') && (unreadCount > 0));
|
||||
|
||||
return (
|
||||
<button
|
||||
key={ key }
|
||||
type="button"
|
||||
onClick={ () => setFilter(key) }
|
||||
className={ `px-2 py-[2px] rounded-full text-xs border transition-colors ${ active ? 'bg-[#1e7295] text-white border-[#1e7295]' : 'bg-black/5 text-black/70 border-transparent hover:bg-black/10' }` }>
|
||||
{ LocalizeText(label) }{ showCount ? ` (${ unreadCount })` : '' }
|
||||
</button>
|
||||
);
|
||||
}) }
|
||||
</Flex>
|
||||
<Flex grow column className="min-h-0 overflow-y-auto" gap={ 0 }>
|
||||
return (
|
||||
<button key={ key } type="button" className={ `mentions-filter ${ active ? 'is-active' : '' }` } onClick={ () => setFilter(key) }>
|
||||
{ LocalizeText(label) }{ showCount ? ` ${ unreadCount }` : '' }
|
||||
</button>
|
||||
);
|
||||
}) }
|
||||
</div>
|
||||
<button type="button" className="mentions-refresh" title="Aggiorna" onClick={ refresh }>
|
||||
<FaSync />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mentions-list">
|
||||
{ !hasAny &&
|
||||
<Flex grow column center gap={ 2 } className="py-6 text-center">
|
||||
<span className="flex items-center justify-center w-[44px] h-[44px] rounded-full bg-black/5 text-[#1e7295] text-[22px] font-bold">@</span>
|
||||
<Text center variant="gray">{ LocalizeText('mentions.window.empty') }</Text>
|
||||
</Flex> }
|
||||
<div className="mentions-empty">
|
||||
<span className="mentions-empty-glyph">@</span>
|
||||
<span className="mentions-empty-text">{ LocalizeText('mentions.window.empty') }</span>
|
||||
</div> }
|
||||
{ hasAny && groups.map(group => (
|
||||
<Flex key={ group.key } column gap={ 0 }>
|
||||
<Text small bold variant="gray" className="px-1 pt-2 pb-[2px] uppercase tracking-wide">
|
||||
{ LocalizeText(GROUP_LABEL[group.key]) }
|
||||
</Text>
|
||||
<div key={ group.key } className="mentions-group">
|
||||
<div className="mentions-group-label">{ LocalizeText(GROUP_LABEL[group.key]) }</div>
|
||||
{ group.items.map(mention => (
|
||||
<MentionRowView
|
||||
key={ mention.mentionId }
|
||||
@@ -113,9 +141,9 @@ export const MentionsView: FC<MentionsViewProps> = props =>
|
||||
onRemove={ remove }
|
||||
ownUsername={ ownUsername } />
|
||||
)) }
|
||||
</Flex>
|
||||
</div>
|
||||
)) }
|
||||
</Flex>
|
||||
</div>
|
||||
{ (unreadCount > 0) &&
|
||||
<Button variant="primary" onClick={ onMarkAll }>{ LocalizeText('mentions.window.markall') }</Button> }
|
||||
</NitroCardContentView>
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
export * from './MentionMessageView';
|
||||
export * from './MentionRowView';
|
||||
export * from './MentionsView';
|
||||
export * from './MentionToastsView';
|
||||
export * from './mentionsFormat';
|
||||
export * from './useMentionActions';
|
||||
|
||||
@@ -109,7 +109,7 @@ export const NavigatorView: FC<{}> = props =>
|
||||
<>
|
||||
{ isVisible &&
|
||||
<NitroCard
|
||||
className={ `${ isOpenSavesSearches ? 'w-[600px] sm:min-w-[600px]' : 'w-navigator-w sm:min-w-navigator-w' } max-w-[calc(100vw-1rem)] h-navigator-h min-h-navigator-h` }
|
||||
className={ `${ isOpenSavesSearches ? 'w-[600px] sm:min-w-[600px]' : 'w-navigator-w sm:min-w-navigator-w' } max-w-[calc(100vw-1rem)] h-navigator-h min-h-navigator-h has-classic-scrollbar` }
|
||||
uniqueKey="navigator">
|
||||
<NitroCard.Header
|
||||
headerText={ LocalizeText(isCreatorOpen ? 'navigator.createroom.title' : 'navigator.title') }
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { NotificationBubbleItem, NotificationBubbleType } from '../../../../api';
|
||||
import { MentionNotificationBubbleItem, NotificationBubbleItem, NotificationBubbleType } from '../../../../api';
|
||||
import { NotificationBadgeReceivedBubbleView } from './NotificationBadgeReceivedBubbleView';
|
||||
import { NotificationClubGiftBubbleView } from './NotificationClubGiftBubbleView';
|
||||
import { NotificationDefaultBubbleView } from './NotificationDefaultBubbleView';
|
||||
import { NotificationMentionBubbleView } from './NotificationMentionBubbleView';
|
||||
|
||||
export const GetBubbleLayout = (item: NotificationBubbleItem, onClose: () => void) =>
|
||||
{
|
||||
@@ -15,6 +16,8 @@ export const GetBubbleLayout = (item: NotificationBubbleItem, onClose: () => voi
|
||||
return <NotificationBadgeReceivedBubbleView key={ item.id } { ...props } />;
|
||||
case NotificationBubbleType.CLUBGIFT:
|
||||
return <NotificationClubGiftBubbleView key={ item.id } { ...props } />;
|
||||
case NotificationBubbleType.MENTION:
|
||||
return <NotificationMentionBubbleView key={ item.id } item={ item as MentionNotificationBubbleItem } onClose={ onClose } />;
|
||||
default:
|
||||
return <NotificationDefaultBubbleView key={ item.id } { ...props } />;
|
||||
}
|
||||
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
import { CreateLinkEvent, MarkMentionsReadComposer } from '@nitrots/nitro-renderer';
|
||||
import { FC, MouseEvent } from 'react';
|
||||
import { FaTimes } from 'react-icons/fa';
|
||||
import { formatMentionTime, LocalizeText, MentionNotificationBubbleItem, MentionType, SendMessageComposer } from '../../../../api';
|
||||
import { Flex, LayoutAvatarImageView, LayoutNotificationBubbleView, LayoutNotificationBubbleViewProps, Text } from '../../../../common';
|
||||
import { markRead } from '../../../../hooks/mentions/mentionsStore';
|
||||
import { useUserDataSnapshot } from '../../../../hooks/session/useSessionSnapshots';
|
||||
import { MentionMessageView } from '../../../mentions/MentionMessageView';
|
||||
|
||||
export interface NotificationMentionBubbleViewProps extends LayoutNotificationBubbleViewProps
|
||||
{
|
||||
item: MentionNotificationBubbleItem;
|
||||
}
|
||||
|
||||
export const NotificationMentionBubbleView: FC<NotificationMentionBubbleViewProps> = props =>
|
||||
{
|
||||
const { item = null, onClose = null, ...rest } = props;
|
||||
const { userName: ownUsername = '' } = useUserDataSnapshot();
|
||||
|
||||
const mention = item.mention;
|
||||
const isRoom = (mention.mentionType === MentionType.ROOM);
|
||||
const time = formatMentionTime(mention.timestamp);
|
||||
|
||||
const markReadOnServer = () =>
|
||||
{
|
||||
markRead(mention.mentionId);
|
||||
SendMessageComposer(new MarkMentionsReadComposer(1, mention.mentionId));
|
||||
};
|
||||
|
||||
// Whole-bubble click opens the mentions panel (and dismisses the bubble).
|
||||
const open = () =>
|
||||
{
|
||||
CreateLinkEvent('mentions/toggle');
|
||||
onClose();
|
||||
};
|
||||
|
||||
const goto = () =>
|
||||
{
|
||||
markReadOnServer();
|
||||
if(mention.roomId > 0) CreateLinkEvent(`navigator/goto/${ mention.roomId }`);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const act = (event: MouseEvent, fn: () => void) =>
|
||||
{
|
||||
event.stopPropagation();
|
||||
fn();
|
||||
};
|
||||
|
||||
return (
|
||||
<LayoutNotificationBubbleView
|
||||
alignItems="start"
|
||||
gap={ 2 }
|
||||
classNames={ [ 'w-[330px]', 'max-w-[92vw]', 'cursor-pointer' ] }
|
||||
onClick={ open }
|
||||
onClose={ onClose }
|
||||
{ ...rest }>
|
||||
<div className="mention-toast-avatar">
|
||||
<LayoutAvatarImageView headOnly direction={ 2 } figure={ mention.senderFigure } />
|
||||
</div>
|
||||
<Flex column gap={ 0 } className="min-w-0 flex-1">
|
||||
<Flex alignItems="center" gap={ 1 } className="min-w-0">
|
||||
<Text bold truncate variant="white">{ mention.senderUsername }</Text>
|
||||
<span className={ `mention-toast-chip ${ isRoom ? 'is-room' : 'is-direct' }` }>
|
||||
{ LocalizeText(isRoom ? 'mentions.type.room' : 'mentions.type.direct') }
|
||||
</span>
|
||||
<span className="mention-toast-spacer" />
|
||||
{ (time.length > 0) &&
|
||||
<span className="mention-toast-time">{ time }</span> }
|
||||
<button type="button" className="mention-toast-dismiss" title={ LocalizeText('generic.cancel') } onClick={ event => act(event, onClose) }>
|
||||
<FaTimes />
|
||||
</button>
|
||||
</Flex>
|
||||
{ (mention.roomName && (mention.roomName.length > 0)) &&
|
||||
<span className="mention-toast-room">· { mention.roomName }</span> }
|
||||
<MentionMessageView className="mention-toast-message" ownUsername={ ownUsername } text={ mention.message } />
|
||||
<Flex gap={ 1 } className="mention-toast-actions">
|
||||
<button type="button" className="mention-toast-btn" onClick={ event => act(event, open) }>
|
||||
{ LocalizeText('mentions.window.title') }
|
||||
</button>
|
||||
{ (mention.roomId > 0) &&
|
||||
<button type="button" className="mention-toast-btn" onClick={ event => act(event, goto) }>
|
||||
{ LocalizeText('mentions.action.goto') }
|
||||
</button> }
|
||||
</Flex>
|
||||
</Flex>
|
||||
</LayoutNotificationBubbleView>
|
||||
);
|
||||
};
|
||||
@@ -18,6 +18,56 @@ const PICKUP_MODE_NONE: number = 0;
|
||||
const PICKUP_MODE_EJECT: number = 1;
|
||||
const PICKUP_MODE_FULL: number = 2;
|
||||
|
||||
function getValidRoomObjectDirection(roomObject: any, isPositive: boolean)
|
||||
{
|
||||
if(!roomObject || !roomObject.model) return 0;
|
||||
|
||||
let allowedDirections: number[] = [];
|
||||
|
||||
if(roomObject.type === 'monster_plant')
|
||||
{
|
||||
allowedDirections = roomObject.model.getValue('pet_allowed_directions');
|
||||
}
|
||||
else
|
||||
{
|
||||
allowedDirections = roomObject.model.getValue('furniture_allowed_directions');
|
||||
}
|
||||
|
||||
let direction = roomObject.getDirection().x;
|
||||
|
||||
if(allowedDirections && allowedDirections.length)
|
||||
{
|
||||
let index = allowedDirections.indexOf(direction);
|
||||
|
||||
if(index < 0)
|
||||
{
|
||||
index = 0;
|
||||
|
||||
for(let i = 0; i < allowedDirections.length; i++)
|
||||
{
|
||||
if(direction <= allowedDirections[i]) break;
|
||||
|
||||
index++;
|
||||
}
|
||||
|
||||
index = index % allowedDirections.length;
|
||||
}
|
||||
|
||||
if(isPositive)
|
||||
{
|
||||
index = (index + 1) % allowedDirections.length;
|
||||
}
|
||||
else
|
||||
{
|
||||
index = (index - 1 + allowedDirections.length) % allowedDirections.length;
|
||||
}
|
||||
|
||||
direction = allowedDirections[index];
|
||||
}
|
||||
|
||||
return direction;
|
||||
}
|
||||
|
||||
export const InfoStandWidgetFurniView: FC<InfoStandWidgetFurniViewProps> = props =>
|
||||
{
|
||||
const { avatarInfo = null, onClose = null } = props;
|
||||
@@ -78,56 +128,6 @@ export const InfoStandWidgetFurniView: FC<InfoStandWidgetFurniViewProps> = props
|
||||
SendMessageComposer(new UpdateFurniturePositionComposer(avatarInfo.id, newX, newY, Math.round(newZ * 10000), newDirection));
|
||||
}, [ avatarInfo ]);
|
||||
|
||||
function getValidRoomObjectDirection(roomObject: any, isPositive: boolean)
|
||||
{
|
||||
if(!roomObject || !roomObject.model) return 0;
|
||||
|
||||
let allowedDirections: number[] = [];
|
||||
|
||||
if(roomObject.type === 'monster_plant')
|
||||
{
|
||||
allowedDirections = roomObject.model.getValue('pet_allowed_directions');
|
||||
}
|
||||
else
|
||||
{
|
||||
allowedDirections = roomObject.model.getValue('furniture_allowed_directions');
|
||||
}
|
||||
|
||||
let direction = roomObject.getDirection().x;
|
||||
|
||||
if(allowedDirections && allowedDirections.length)
|
||||
{
|
||||
let index = allowedDirections.indexOf(direction);
|
||||
|
||||
if(index < 0)
|
||||
{
|
||||
index = 0;
|
||||
|
||||
for(let i = 0; i < allowedDirections.length; i++)
|
||||
{
|
||||
if(direction <= allowedDirections[i]) break;
|
||||
|
||||
index++;
|
||||
}
|
||||
|
||||
index = index % allowedDirections.length;
|
||||
}
|
||||
|
||||
if(isPositive)
|
||||
{
|
||||
index = (index + 1) % allowedDirections.length;
|
||||
}
|
||||
else
|
||||
{
|
||||
index = (index - 1 + allowedDirections.length) % allowedDirections.length;
|
||||
}
|
||||
|
||||
direction = allowedDirections[index];
|
||||
}
|
||||
|
||||
return direction;
|
||||
}
|
||||
|
||||
const handleHeightChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
{
|
||||
let newZ = parseFloat(event.target.value);
|
||||
@@ -421,7 +421,11 @@ export const InfoStandWidgetFurniView: FC<InfoStandWidgetFurniViewProps> = props
|
||||
if(key === 'offsetX') value = String(x);
|
||||
else if(key === 'offsetY') value = String(y);
|
||||
else if(key === 'offsetZ') value = String(z);
|
||||
else if(key === 'scale') { value = String(scale); hasScale = true; }
|
||||
else if(key === 'scale')
|
||||
{
|
||||
value = String(scale);
|
||||
hasScale = true;
|
||||
}
|
||||
|
||||
clone[i] = value;
|
||||
map.set(key, value);
|
||||
@@ -638,6 +642,20 @@ export const InfoStandWidgetFurniView: FC<InfoStandWidgetFurniViewProps> = props
|
||||
})() }</Text>
|
||||
</div>
|
||||
</div> }
|
||||
{ isModerator &&
|
||||
<button
|
||||
className="w-full text-white text-xs bg-[#1e7295] hover:bg-[#1a617f] border border-[#ffffff33] rounded px-2 py-1 cursor-pointer transition-colors"
|
||||
onClick={ () =>
|
||||
{
|
||||
const roomObject = GetRoomEngine().getRoomObject(roomSession.roomId, avatarInfo.id, avatarInfo.isWallItem ? RoomObjectCategory.WALL : RoomObjectCategory.FLOOR);
|
||||
const typeId = roomObject?.model?.getValue(RoomObjectVariable.FURNITURE_TYPE_ID);
|
||||
|
||||
CreateLinkEvent('furni-editor/show');
|
||||
|
||||
if(typeId) window.dispatchEvent(new CustomEvent('furni-editor:open', { detail: { spriteId: typeId } }));
|
||||
} }>
|
||||
Edit Furni
|
||||
</button> }
|
||||
{ (!avatarInfo.isWallItem && canMove) &&
|
||||
<>
|
||||
<button
|
||||
@@ -645,20 +663,6 @@ export const InfoStandWidgetFurniView: FC<InfoStandWidgetFurniViewProps> = props
|
||||
onClick={ () => setDropdownOpen(!dropdownOpen) }>
|
||||
{ dropdownOpen ? `${LocalizeText('widget.furni.present.close')} Buildtools` : `${LocalizeText('navigator.roomsettings.doormode.open')} Buildtools` }
|
||||
</button>
|
||||
{ isModerator &&
|
||||
<button
|
||||
className="w-full text-white text-xs bg-[#1e7295] hover:bg-[#1a617f] border border-[#ffffff33] rounded px-2 py-1 cursor-pointer transition-colors"
|
||||
onClick={ () =>
|
||||
{
|
||||
const roomObject = GetRoomEngine().getRoomObject(roomSession.roomId, avatarInfo.id, avatarInfo.isWallItem ? RoomObjectCategory.WALL : RoomObjectCategory.FLOOR);
|
||||
const typeId = roomObject?.model?.getValue(RoomObjectVariable.FURNITURE_TYPE_ID);
|
||||
|
||||
CreateLinkEvent('furni-editor/show');
|
||||
|
||||
if(typeId) window.dispatchEvent(new CustomEvent('furni-editor:open', { detail: { spriteId: typeId } }));
|
||||
} }>
|
||||
Edit Furni
|
||||
</button> }
|
||||
{ dropdownOpen &&
|
||||
<div className="flex gap-[4px] w-full">
|
||||
{ /* Left panel: position + rotation */ }
|
||||
|
||||
@@ -62,7 +62,7 @@ export const ChatInputCommandSelectorView: FC<ChatInputCommandSelectorViewProps>
|
||||
<span className="chat-input-command-popover-header-dot" aria-hidden />
|
||||
<span>: Command</span>
|
||||
</div>
|
||||
<div ref={ listRef } className="chat-input-command-popover-list">
|
||||
<div ref={ listRef } className="chat-input-command-popover-list has-classic-scrollbar">
|
||||
{ commands.map((cmd, index) =>
|
||||
{
|
||||
const isSelected = (index === selectedIndex);
|
||||
|
||||
@@ -1,17 +1,6 @@
|
||||
import { FC, useEffect, useRef } from 'react';
|
||||
import { LayoutAvatarImageView } from '../../../../common';
|
||||
|
||||
export type MentionSuggestionKind = 'user' | 'alias';
|
||||
|
||||
export interface MentionSuggestion
|
||||
{
|
||||
key: string;
|
||||
kind: MentionSuggestionKind;
|
||||
name: string;
|
||||
insertToken: string;
|
||||
figure?: string;
|
||||
description?: string;
|
||||
}
|
||||
import { MentionSuggestion } from '../../../../hooks/rooms/widgets/useChatMentions.helpers';
|
||||
|
||||
interface ChatInputMentionSelectorViewProps
|
||||
{
|
||||
@@ -99,7 +88,7 @@ export const ChatInputMentionSelectorView: FC<ChatInputMentionSelectorViewProps>
|
||||
<span className="chat-input-mention-popover-header-dot" aria-hidden />
|
||||
<span>@ Mention</span>
|
||||
</div>
|
||||
<div ref={ listRef } className="chat-input-mention-popover-list">
|
||||
<div ref={ listRef } className="chat-input-mention-popover-list has-classic-scrollbar">
|
||||
{ suggestions.map((suggestion, index) =>
|
||||
{
|
||||
const isSelected = (index === selectedIndex);
|
||||
|
||||
@@ -3,50 +3,12 @@ import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ChatMessageTypeEnum, GetClubMemberLevel, GetConfigurationValue, LocalizeText, RoomWidgetUpdateChatInputContentEvent } from '../../../../api';
|
||||
import { Text } from '../../../../common';
|
||||
import { useCatalogClassicStyle, useChatCommandSelector, useChatInputWidget, useRoom, useSessionInfo, useUiEvent } from '../../../../hooks';
|
||||
import { useRoomUserListSnapshot } from '../../../../hooks/session/useSessionSnapshots';
|
||||
import { useCatalogClassicStyle, useChatCommandSelector, useChatInputWidget, useChatMentions, useRoom, useSessionInfo, useUiEvent } from '../../../../hooks';
|
||||
import { ChatInputCommandSelectorView } from './ChatInputCommandSelectorView';
|
||||
import { ChatInputEmojiSelectorView } from './ChatInputEmojiSelectorView';
|
||||
import { ChatInputMentionSelectorView, MentionSuggestion } from './ChatInputMentionSelectorView';
|
||||
import { ChatInputMentionSelectorView } from './ChatInputMentionSelectorView';
|
||||
import { ChatInputStyleSelectorView } from './ChatInputStyleSelectorView';
|
||||
|
||||
const USER_TYPE_REAL_USER = 1;
|
||||
const MAX_MENTION_SUGGESTIONS = 8;
|
||||
|
||||
type MentionAliasScope = 'everyone' | 'friends' | 'room';
|
||||
|
||||
const MENTION_ALIAS_CONFIG_KEY: Record<MentionAliasScope, string> = {
|
||||
everyone: 'mentions_ui.aliases.everyone',
|
||||
friends: 'mentions_ui.aliases.friends',
|
||||
room: 'mentions_ui.aliases.room'
|
||||
};
|
||||
|
||||
const MENTION_ALIAS_DEFAULTS: Record<MentionAliasScope, string[]> = {
|
||||
everyone: [ 'all', 'everyone', 'tutti' ],
|
||||
friends: [ 'friends', 'amici' ],
|
||||
room: [ 'room', 'stanza' ]
|
||||
};
|
||||
|
||||
const MENTION_ALIAS_DESCRIPTION_KEY: Record<MentionAliasScope, string> = {
|
||||
everyone: 'mentions.alias.description.everyone',
|
||||
friends: 'mentions.alias.description.friends',
|
||||
room: 'mentions.alias.description.room'
|
||||
};
|
||||
|
||||
const sanitizeAliasList = (raw: unknown, fallback: string[]): string[] =>
|
||||
{
|
||||
if(!Array.isArray(raw)) return fallback;
|
||||
const out: string[] = [];
|
||||
for(const entry of raw)
|
||||
{
|
||||
if(typeof entry !== 'string') continue;
|
||||
const trimmed = entry.trim();
|
||||
if(!trimmed) continue;
|
||||
out.push(trimmed);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
export const ChatInputView: FC<{}> = props =>
|
||||
{
|
||||
const [ chatValue, setChatValue ] = useState<string>('');
|
||||
@@ -56,129 +18,13 @@ export const ChatInputView: FC<{}> = props =>
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const { isVisible: commandSelectorVisible, filteredCommands, selectedIndex, setSelectedIndex, moveUp, moveDown, selectCurrent, close: closeCommandSelector } = useChatCommandSelector(chatValue);
|
||||
|
||||
const roomUserList = useRoomUserListSnapshot();
|
||||
const [ mentionSelectedIndex, setMentionSelectedIndex ] = useState<number>(0);
|
||||
// The "New style" user-setting (memenu.settings.other.catalog.classic.style)
|
||||
// drives BOTH the catalog layout and the mention-picker chrome:
|
||||
// false (default) = Habbo old-school NitroCard cardstock look
|
||||
// true = flat minimalist gray look
|
||||
const [ newStyle ] = useCatalogClassicStyle();
|
||||
|
||||
const mentionContext = useMemo(() =>
|
||||
{
|
||||
if(!chatValue) return null;
|
||||
if(commandSelectorVisible) return null;
|
||||
|
||||
const caret = inputRef.current?.selectionStart ?? chatValue.length;
|
||||
const upToCaret = chatValue.slice(0, caret);
|
||||
const at = upToCaret.lastIndexOf('@');
|
||||
if(at < 0) return null;
|
||||
|
||||
if(at > 0 && !/\s/.test(upToCaret.charAt(at - 1))) return null;
|
||||
|
||||
const query = upToCaret.slice(at + 1);
|
||||
if(/\s/.test(query)) return null;
|
||||
|
||||
return { atIndex: at, replaceFrom: at, replaceTo: caret, query };
|
||||
}, [ chatValue, commandSelectorVisible ]);
|
||||
|
||||
const mentionAliases = useMemo<ReadonlyArray<{ key: string; scope: MentionAliasScope; description: string }>>(() =>
|
||||
{
|
||||
const out: { key: string; scope: MentionAliasScope; description: string }[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const scopes: MentionAliasScope[] = [ 'everyone', 'friends', 'room' ];
|
||||
for(const scope of scopes)
|
||||
{
|
||||
const list = sanitizeAliasList(
|
||||
GetConfigurationValue<unknown>(MENTION_ALIAS_CONFIG_KEY[scope], MENTION_ALIAS_DEFAULTS[scope]),
|
||||
MENTION_ALIAS_DEFAULTS[scope]
|
||||
);
|
||||
|
||||
for(const key of list)
|
||||
{
|
||||
const lower = key.toLowerCase();
|
||||
|
||||
if(seen.has(lower)) continue;
|
||||
seen.add(lower);
|
||||
|
||||
out.push({ key, scope, description: LocalizeText(MENTION_ALIAS_DESCRIPTION_KEY[scope]) });
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}, []);
|
||||
|
||||
const mentionSuggestions = useMemo<MentionSuggestion[]>(() =>
|
||||
{
|
||||
if(!mentionContext) return [];
|
||||
|
||||
const query = mentionContext.query.toLowerCase();
|
||||
const out: MentionSuggestion[] = [];
|
||||
|
||||
for(const user of roomUserList)
|
||||
{
|
||||
if(!user || user.type !== USER_TYPE_REAL_USER) continue;
|
||||
if(!user.name) continue;
|
||||
if(query.length > 0 && !user.name.toLowerCase().startsWith(query)) continue;
|
||||
|
||||
out.push({
|
||||
key: `user:${ user.webID }`,
|
||||
kind: 'user',
|
||||
name: user.name,
|
||||
insertToken: user.name,
|
||||
figure: user.figure || ''
|
||||
});
|
||||
|
||||
if(out.length >= MAX_MENTION_SUGGESTIONS) break;
|
||||
}
|
||||
|
||||
for(const alias of mentionAliases)
|
||||
{
|
||||
if(query.length > 0 && !alias.key.toLowerCase().startsWith(query)) continue;
|
||||
|
||||
out.push({
|
||||
key: `alias:${ alias.key }`,
|
||||
kind: 'alias',
|
||||
name: alias.key,
|
||||
insertToken: alias.key,
|
||||
description: alias.description
|
||||
});
|
||||
|
||||
if(out.length >= MAX_MENTION_SUGGESTIONS) break;
|
||||
}
|
||||
|
||||
return out;
|
||||
}, [ mentionContext, roomUserList, mentionAliases ]);
|
||||
|
||||
const mentionSelectorVisible = mentionSuggestions.length > 0;
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(mentionSelectedIndex >= mentionSuggestions.length) setMentionSelectedIndex(0);
|
||||
}, [ mentionSuggestions.length, mentionSelectedIndex ]);
|
||||
|
||||
const applyMentionSuggestion = useCallback((suggestion: MentionSuggestion) =>
|
||||
{
|
||||
if(!suggestion || !mentionContext) return;
|
||||
|
||||
const before = chatValue.slice(0, mentionContext.replaceFrom);
|
||||
const after = chatValue.slice(mentionContext.replaceTo);
|
||||
const inserted = `@${ suggestion.insertToken } `;
|
||||
const next = `${ before }${ inserted }${ after }`;
|
||||
|
||||
setChatValue(next);
|
||||
|
||||
requestAnimationFrame(() =>
|
||||
{
|
||||
if(!inputRef.current) return;
|
||||
const caret = before.length + inserted.length;
|
||||
inputRef.current.focus();
|
||||
inputRef.current.setSelectionRange(caret, caret);
|
||||
});
|
||||
|
||||
setMentionSelectedIndex(0);
|
||||
}, [ chatValue, mentionContext ]);
|
||||
const mention = useChatMentions(chatValue, setChatValue, inputRef, commandSelectorVisible);
|
||||
|
||||
const chatModeIdWhisper = useMemo(() => LocalizeText('widgets.chatinput.mode.whisper'), []);
|
||||
const chatModeIdShout = useMemo(() => LocalizeText('widgets.chatinput.mode.shout'), []);
|
||||
@@ -331,41 +177,30 @@ export const ChatInputView: FC<{}> = props =>
|
||||
}
|
||||
}
|
||||
|
||||
if(mentionSelectorVisible)
|
||||
if(mention.visible)
|
||||
{
|
||||
switch(event.key)
|
||||
{
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
setMentionSelectedIndex(prev => (prev <= 0) ? (mentionSuggestions.length - 1) : (prev - 1));
|
||||
mention.moveUp();
|
||||
return;
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
setMentionSelectedIndex(prev => (prev >= mentionSuggestions.length - 1) ? 0 : (prev + 1));
|
||||
mention.moveDown();
|
||||
return;
|
||||
case 'Tab':
|
||||
case 'NumpadEnter':
|
||||
case 'Enter': {
|
||||
const picked = mentionSuggestions[mentionSelectedIndex] ?? mentionSuggestions[0];
|
||||
|
||||
if(picked)
|
||||
case 'Enter':
|
||||
if(mention.applyCurrent())
|
||||
{
|
||||
event.preventDefault();
|
||||
applyMentionSuggestion(picked);
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'Escape':
|
||||
event.preventDefault();
|
||||
setMentionSelectedIndex(0);
|
||||
|
||||
if(mentionContext)
|
||||
{
|
||||
const before = chatValue.slice(0, mentionContext.replaceFrom);
|
||||
const after = chatValue.slice(mentionContext.replaceTo);
|
||||
setChatValue(before + after);
|
||||
}
|
||||
mention.cancel();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -395,7 +230,7 @@ export const ChatInputView: FC<{}> = props =>
|
||||
return;
|
||||
}
|
||||
|
||||
}, [ floodBlocked, inputRef, chatModeIdWhisper, anotherInputHasFocus, setInputFocus, checkSpecialKeywordForInput, sendChatValue, commandSelectorVisible, moveUp, moveDown, selectCurrent, closeCommandSelector, mentionSelectorVisible, mentionSuggestions, mentionSelectedIndex, applyMentionSuggestion, mentionContext, chatValue ]);
|
||||
}, [ floodBlocked, inputRef, chatModeIdWhisper, anotherInputHasFocus, setInputFocus, checkSpecialKeywordForInput, sendChatValue, commandSelectorVisible, moveUp, moveDown, selectCurrent, closeCommandSelector, mention, chatValue ]);
|
||||
|
||||
useUiEvent<RoomWidgetUpdateChatInputContentEvent>(RoomWidgetUpdateChatInputContentEvent.CHAT_INPUT_CONTENT, event =>
|
||||
{
|
||||
@@ -492,12 +327,12 @@ export const ChatInputView: FC<{}> = props =>
|
||||
onHover={ setSelectedIndex }
|
||||
newStyle={ newStyle }
|
||||
/> }
|
||||
{ mentionSelectorVisible && !commandSelectorVisible &&
|
||||
{ mention.visible && !commandSelectorVisible &&
|
||||
<ChatInputMentionSelectorView
|
||||
suggestions={ mentionSuggestions }
|
||||
selectedIndex={ mentionSelectedIndex }
|
||||
onSelect={ applyMentionSuggestion }
|
||||
onHover={ setMentionSelectedIndex }
|
||||
suggestions={ mention.suggestions }
|
||||
selectedIndex={ mention.selectedIndex }
|
||||
onSelect={ mention.apply }
|
||||
onHover={ mention.setSelectedIndex }
|
||||
newStyle={ newStyle }
|
||||
/> }
|
||||
<div className="flex-1 items-center input-sizer">
|
||||
|
||||
@@ -1,35 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { highlightMentions, MENTION_ROOM_ALIASES } from './highlightMentions';
|
||||
import { MENTION_ROOM_ALIASES } from '../../../../api/mentions/mentionTokens';
|
||||
import { highlightMentions } from './highlightMentions';
|
||||
|
||||
const OPEN = '<span class="mention-highlight">';
|
||||
const CLOSE = '</span>';
|
||||
// A generic @user tag, and a self/alias mention (strong).
|
||||
const TAG = (s: string) => `<span class="mention-tag">${ s }</span>`;
|
||||
const SELF = (s: string) => `<span class="mention-tag mention-tag--self">${ s }</span>`;
|
||||
|
||||
describe('highlightMentions', () =>
|
||||
{
|
||||
it('highlights the own-nick token', () =>
|
||||
it('marks the own-nick token as a self mention', () =>
|
||||
{
|
||||
const out = highlightMentions('hello @Bob how are you', 'Bob');
|
||||
|
||||
expect(out).toBe(`hello ${ OPEN }@Bob${ CLOSE } how are you`);
|
||||
expect(out).toBe(`hello ${ SELF('@Bob') } how are you`);
|
||||
});
|
||||
|
||||
it('highlights a room-broadcast alias token', () =>
|
||||
it('marks a room-broadcast alias token as a self mention', () =>
|
||||
{
|
||||
const out = highlightMentions('@all party time', 'Bob');
|
||||
|
||||
expect(out).toBe(`${ OPEN }@all${ CLOSE } party time`);
|
||||
expect(out).toBe(`${ SELF('@all') } party time`);
|
||||
});
|
||||
|
||||
it('highlights every configured room alias', () =>
|
||||
it('marks every configured room alias as a self mention', () =>
|
||||
{
|
||||
for(const alias of MENTION_ROOM_ALIASES)
|
||||
{
|
||||
const out = highlightMentions(`hey @${ alias }!`, 'Bob');
|
||||
|
||||
expect(out).toBe(`hey ${ OPEN }@${ alias }!${ CLOSE }`);
|
||||
expect(out).toBe(`hey ${ SELF(`@${ alias }!`) }`);
|
||||
}
|
||||
});
|
||||
|
||||
it('tags other users (not me, not an alias) as generic mentions', () =>
|
||||
{
|
||||
const out = highlightMentions('hi @Charlie and @Dave', 'Bob');
|
||||
|
||||
expect(out).toBe(`hi ${ TAG('@Charlie') } and ${ TAG('@Dave') }`);
|
||||
});
|
||||
|
||||
it('tags a cross-room user even when own username is empty', () =>
|
||||
{
|
||||
const out = highlightMentions('hi @Charlie', '');
|
||||
|
||||
expect(out).toBe(`hi ${ TAG('@Charlie') }`);
|
||||
});
|
||||
|
||||
it('leaves non-mention text untouched', () =>
|
||||
{
|
||||
const text = 'just a normal sentence with no at signs';
|
||||
@@ -37,42 +53,32 @@ describe('highlightMentions', () =>
|
||||
expect(highlightMentions(text, 'Bob')).toBe(text);
|
||||
});
|
||||
|
||||
it('returns the message unchanged when there is no mention of me or an alias', () =>
|
||||
{
|
||||
const text = 'hi @Charlie and @Dave';
|
||||
|
||||
// Neither @Charlie nor @Dave is the local user or a room alias.
|
||||
expect(highlightMentions(text, 'Bob')).toBe(text);
|
||||
});
|
||||
|
||||
it('matches a token with trailing punctuation (mirrors server stripping)', () =>
|
||||
it('keeps trailing punctuation inside the span (mirrors server stripping)', () =>
|
||||
{
|
||||
const out = highlightMentions('watch out @Bob! seriously', 'Bob');
|
||||
|
||||
// The original token text (including the `!`) is kept inside the span.
|
||||
expect(out).toBe(`watch out ${ OPEN }@Bob!${ CLOSE } seriously`);
|
||||
expect(out).toBe(`watch out ${ SELF('@Bob!') } seriously`);
|
||||
});
|
||||
|
||||
it('matches case-insensitively but preserves the original casing', () =>
|
||||
{
|
||||
const out = highlightMentions('yo @bOb whatup', 'BOB');
|
||||
|
||||
expect(out).toBe(`yo ${ OPEN }@bOb${ CLOSE } whatup`);
|
||||
expect(out).toBe(`yo ${ SELF('@bOb') } whatup`);
|
||||
});
|
||||
|
||||
it('preserves the original spacing verbatim', () =>
|
||||
{
|
||||
const out = highlightMentions('a @Bob\tb', 'Bob');
|
||||
|
||||
expect(out).toBe(`a ${ OPEN }@Bob${ CLOSE }\tb`);
|
||||
expect(out).toBe(`a ${ SELF('@Bob') }\tb`);
|
||||
});
|
||||
|
||||
it('does not highlight inside HTML tags produced by the formatter', () =>
|
||||
it('does not tag inside HTML tags produced by the formatter', () =>
|
||||
{
|
||||
// Formatter output: wired bold markup around a mention.
|
||||
const out = highlightMentions('<strong>hi @Bob</strong>', 'Bob');
|
||||
|
||||
expect(out).toBe(`<strong>hi ${ OPEN }@Bob${ CLOSE }</strong>`);
|
||||
expect(out).toBe(`<strong>hi ${ SELF('@Bob') }</strong>`);
|
||||
});
|
||||
|
||||
it('leaves font-colour spans and line breaks intact', () =>
|
||||
@@ -80,14 +86,14 @@ describe('highlightMentions', () =>
|
||||
const html = '<span style="color:red">hi @Bob</span><br />bye';
|
||||
const out = highlightMentions(html, 'Bob');
|
||||
|
||||
expect(out).toBe(`<span style="color:red">hi ${ OPEN }@Bob${ CLOSE }</span><br />bye`);
|
||||
expect(out).toBe(`<span style="color:red">hi ${ SELF('@Bob') }</span><br />bye`);
|
||||
});
|
||||
|
||||
it('highlights multiple distinct mentions in one message', () =>
|
||||
it('handles a self mention and a generic tag in one message', () =>
|
||||
{
|
||||
const out = highlightMentions('@Bob and @all listen', 'Bob');
|
||||
const out = highlightMentions('@Bob and @Charlie listen', 'Bob');
|
||||
|
||||
expect(out).toBe(`${ OPEN }@Bob${ CLOSE } and ${ OPEN }@all${ CLOSE } listen`);
|
||||
expect(out).toBe(`${ SELF('@Bob') } and ${ TAG('@Charlie') } listen`);
|
||||
});
|
||||
|
||||
it('ignores a bare @ with no nick', () =>
|
||||
@@ -103,18 +109,4 @@ describe('highlightMentions', () =>
|
||||
|
||||
expect(highlightMentions(text, 'Bob')).toBe(text);
|
||||
});
|
||||
|
||||
it('returns input verbatim when own username is empty and no alias matches', () =>
|
||||
{
|
||||
const text = 'hi @Charlie';
|
||||
|
||||
expect(highlightMentions(text, '')).toBe(text);
|
||||
});
|
||||
|
||||
it('still highlights aliases when own username is empty', () =>
|
||||
{
|
||||
const out = highlightMentions('@everyone hi', '');
|
||||
|
||||
expect(out).toBe(`${ OPEN }@everyone${ CLOSE } hi`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,44 +1,9 @@
|
||||
export const MENTION_ROOM_ALIASES: ReadonlyArray<string> = [
|
||||
'all', 'everyone', 'tutti',
|
||||
'friends', 'amici',
|
||||
'room', 'stanza'
|
||||
];
|
||||
import { classifyMentionToken, MENTION_ROOM_ALIASES } from '../../../../api/mentions/mentionTokens';
|
||||
|
||||
const NON_NICK_CHARS = /[^A-Za-z0-9_]/g;
|
||||
const TAG_CLASS = 'mention-tag';
|
||||
const SELF_CLASS = 'mention-tag mention-tag--self';
|
||||
|
||||
const normalizeToken = (token: string): string =>
|
||||
{
|
||||
if(!token || token.length < 2 || token.charAt(0) !== '@') return '';
|
||||
|
||||
return token.substring(1).replace(NON_NICK_CHARS, '').toLowerCase();
|
||||
};
|
||||
|
||||
|
||||
const isMentionToken = (token: string, ownUsernameLower: string, aliases: ReadonlySet<string>): boolean =>
|
||||
{
|
||||
const nick = normalizeToken(token);
|
||||
|
||||
if(!nick) return false;
|
||||
|
||||
if(ownUsernameLower && nick === ownUsernameLower) return true;
|
||||
|
||||
return aliases.has(nick);
|
||||
};
|
||||
|
||||
export const tokenIsMention = (
|
||||
token: string,
|
||||
ownUsername: string,
|
||||
aliases: ReadonlyArray<string> = MENTION_ROOM_ALIASES
|
||||
): boolean =>
|
||||
{
|
||||
const ownUsernameLower = (ownUsername || '').replace(NON_NICK_CHARS, '').toLowerCase();
|
||||
return isMentionToken(token, ownUsernameLower, new Set(aliases.map(a => a.toLowerCase())));
|
||||
};
|
||||
|
||||
const HIGHLIGHT_OPEN = '<span class="mention-highlight">';
|
||||
const HIGHLIGHT_CLOSE = '</span>';
|
||||
|
||||
const highlightTextChunk = (chunk: string, ownUsernameLower: string, aliases: ReadonlySet<string>): string =>
|
||||
const highlightTextChunk = (chunk: string, ownUsername: string, aliases: ReadonlyArray<string>): string =>
|
||||
{
|
||||
if(chunk.indexOf('@') < 0) return chunk;
|
||||
|
||||
@@ -50,18 +15,26 @@ const highlightTextChunk = (chunk: string, ownUsernameLower: string, aliases: Re
|
||||
{
|
||||
if(segment.length === 0) continue;
|
||||
|
||||
if(/^\s+$/.test(segment) || !isMentionToken(segment, ownUsernameLower, aliases))
|
||||
const kind = (/^\s+$/.test(segment)) ? '' : classifyMentionToken(segment, ownUsername, aliases);
|
||||
|
||||
if(!kind)
|
||||
{
|
||||
result += segment;
|
||||
continue;
|
||||
}
|
||||
|
||||
result += `${ HIGHLIGHT_OPEN }${ segment }${ HIGHLIGHT_CLOSE }`;
|
||||
result += `<span class="${ (kind === 'self') ? SELF_CLASS : TAG_CLASS }">${ segment }</span>`;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrap every @mention token in chat HTML with a `.mention-tag` span. Tokens
|
||||
* that target the viewer or a broadcast alias additionally get the
|
||||
* `.mention-tag--self` modifier so "I was mentioned" stands out. Walks around
|
||||
* formatter HTML (`<...>`) so tags inside markup are never touched.
|
||||
*/
|
||||
export const highlightMentions = (
|
||||
formattedHtml: string,
|
||||
ownUsername: string,
|
||||
@@ -70,11 +43,6 @@ export const highlightMentions = (
|
||||
{
|
||||
if(!formattedHtml || formattedHtml.indexOf('@') < 0) return formattedHtml;
|
||||
|
||||
const ownUsernameLower = (ownUsername || '').replace(NON_NICK_CHARS, '').toLowerCase();
|
||||
const aliasSet = new Set(aliases.map(a => a.toLowerCase()));
|
||||
|
||||
if(!ownUsernameLower && aliasSet.size === 0) return formattedHtml;
|
||||
|
||||
let result = '';
|
||||
let cursor = 0;
|
||||
|
||||
@@ -84,13 +52,13 @@ export const highlightMentions = (
|
||||
|
||||
if(tagStart < 0)
|
||||
{
|
||||
result += highlightTextChunk(formattedHtml.slice(cursor), ownUsernameLower, aliasSet);
|
||||
result += highlightTextChunk(formattedHtml.slice(cursor), ownUsername, aliases);
|
||||
break;
|
||||
}
|
||||
|
||||
if(tagStart > cursor)
|
||||
{
|
||||
result += highlightTextChunk(formattedHtml.slice(cursor, tagStart), ownUsernameLower, aliasSet);
|
||||
result += highlightTextChunk(formattedHtml.slice(cursor, tagStart), ownUsername, aliases);
|
||||
}
|
||||
|
||||
const tagEnd = formattedHtml.indexOf('>', tagStart);
|
||||
|
||||
@@ -258,30 +258,28 @@
|
||||
}
|
||||
|
||||
.nitro-badge-leaderboard__avatar {
|
||||
width: 54px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
overflow: hidden;
|
||||
margin: 0 auto;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.nitro-badge-leaderboard__avatar .avatar-image,
|
||||
.nitro-badge-leaderboard__avatar-image {
|
||||
display: block;
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
max-width: 54px;
|
||||
max-height: 62px;
|
||||
left: auto !important;
|
||||
right: auto !important;
|
||||
top: auto !important;
|
||||
bottom: auto !important;
|
||||
margin: 0 auto;
|
||||
/* The renderer-backed LayoutAvatarImageView (headOnly) draws the avatar head as
|
||||
an absolutely-positioned background div (`.avatar-image`), not an <img>. Frame
|
||||
it the same way the friends list does, scaled up for the larger row. */
|
||||
.nitro-badge-leaderboard__avatar .avatar-image {
|
||||
position: absolute !important;
|
||||
inset: 0 !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
margin: 0 !important;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 82px auto !important;
|
||||
background-position: -20px -24px !important;
|
||||
transform: none !important;
|
||||
image-rendering: pixelated;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.nitro-badge-leaderboard__username {
|
||||
|
||||
@@ -151,10 +151,6 @@
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Publish button: lives inside the catalog window, absolutely
|
||||
positioned in the header just to the left of the close X. Renders
|
||||
only when adminMode is true (see CatalogClassicView.tsx). Uses the
|
||||
Habbo yellow buy-button skin so it matches the Koop button. */
|
||||
.nitro-catalog-classic-window .nitro-catalog-classic-header-publish {
|
||||
position: absolute !important;
|
||||
top: 5px !important;
|
||||
@@ -179,17 +175,7 @@
|
||||
50% { box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65), inset 0 -2px 0 rgba(140, 75, 0, 0.35), 0 0 8px rgba(255, 200, 0, 0.75); }
|
||||
}
|
||||
|
||||
/* Catalog default-layout admin row (Pagina bewerken / Nieuwe
|
||||
aanbieding / Aanbieding bewerken). These are inline text buttons
|
||||
but the .habbo-swf-window button + .habbo-swf-window
|
||||
button[class*="success"] global rules were dressing them up as
|
||||
SWF skin buttons (one yellow!) and forcing min-height: 22px which
|
||||
broke the layout. Reset and re-skin as compact pill chips. */
|
||||
.nitro-catalog-classic-window .nitro-catalog-classic-default-admin {
|
||||
/* Keep all admin buttons on one row so the product-view doesn't
|
||||
get pushed down into the absolutely-positioned grid-shell. If
|
||||
a future label makes them overflow, the row scrolls
|
||||
horizontally instead of wrapping. */
|
||||
flex-wrap: nowrap !important;
|
||||
align-items: center !important;
|
||||
gap: 6px !important;
|
||||
@@ -239,8 +225,6 @@
|
||||
box-shadow: inset 0 1px 0 rgba(0, 0, 0, 0.08), 0 0 0 rgba(0, 0, 0, 0) !important;
|
||||
}
|
||||
|
||||
/* The "Nieuwe aanbieding" button uses text-success - give it the
|
||||
habbo-yellow buy-button palette to mark it as the create action. */
|
||||
.nitro-catalog-classic-window .nitro-catalog-classic-default-admin button.text-success,
|
||||
.nitro-catalog-classic-window .nitro-catalog-classic-default-admin button[class*="success"] {
|
||||
border-color: #8a5b00 !important;
|
||||
@@ -270,10 +254,6 @@
|
||||
margin-left: 4px !important;
|
||||
}
|
||||
|
||||
/* Admin cog tab at the end of the tab strip - only renders when the
|
||||
user is a mod, so leaving it visible at all times is safe. Style
|
||||
it as a compact square that sits flush with the other tabs
|
||||
instead of stretching to flex: 1 like a category tab. */
|
||||
.nitro-catalog-classic-tabs-shell .nitro-card-tab-item.nitro-catalog-classic-admin-tab {
|
||||
flex: 0 0 auto !important;
|
||||
width: 32px !important;
|
||||
@@ -343,9 +323,6 @@
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-tabs-shell {
|
||||
/* Strip just tall enough to hold the 36px tab + 4px of breathing
|
||||
room above. Trims the dead blue band between the header and
|
||||
the tabs so the catalog body doesn't lose vertical space. */
|
||||
flex: 0 0 40px;
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
@@ -353,8 +330,6 @@
|
||||
gap: 0;
|
||||
padding: 4px 6px 0 !important;
|
||||
align-items: flex-end;
|
||||
/* Horizontal scroll so every category tab stays reachable when the
|
||||
card is narrower than the total tab width. */
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
flex-wrap: nowrap;
|
||||
@@ -365,8 +340,6 @@
|
||||
border-bottom: 1px solid #c8c8bf;
|
||||
}
|
||||
|
||||
/* The tabs strip uses a slim 6px scrollbar - opt it out of the
|
||||
17px Habbo-sprite scrollbar applied to the rest of the catalog. */
|
||||
.nitro-catalog-classic-tabs-shell::-webkit-scrollbar {
|
||||
width: 6px !important;
|
||||
height: 6px !important;
|
||||
@@ -397,18 +370,11 @@
|
||||
gap: 4px;
|
||||
height: 36px;
|
||||
min-width: 0;
|
||||
/* Equal-width tabs that share the strip exactly - the right
|
||||
edge of the last tab now lines up with the right edge of the
|
||||
catalog window, no trailing gap. */
|
||||
flex: 1 1 0;
|
||||
max-width: none;
|
||||
padding: 6px 6px 7px;
|
||||
margin: 0 2px 0 0;
|
||||
flex-shrink: 1;
|
||||
/* Classic Habbo tab: gray rounded-top rectangle with a 1px black
|
||||
outline. ubuntu_tab3_*.png isn't shipped, so we draw the
|
||||
habbo-look ourselves instead of border-image-slicing a missing
|
||||
sprite. */
|
||||
border: 1px solid #000 !important;
|
||||
border-bottom: 0 !important;
|
||||
border-radius: 6px 6px 0 0 !important;
|
||||
@@ -425,9 +391,6 @@
|
||||
border-image-source: none !important;
|
||||
}
|
||||
|
||||
/* Bring the last tab flush with whatever follows (admin cog, edge),
|
||||
instead of leaving the negative right margin tugging on empty
|
||||
space. */
|
||||
.nitro-catalog-classic-tabs-shell .nitro-card-tab-item:last-of-type {
|
||||
margin-right: 0;
|
||||
}
|
||||
@@ -442,9 +405,6 @@
|
||||
line-height: 17px;
|
||||
}
|
||||
|
||||
/* Category icon that sits before the label. The blanket "hide every
|
||||
img/svg inside a tab" rule is gone - we explicitly size the
|
||||
classic tab icon and let everything else fall through. */
|
||||
.nitro-catalog-classic-tab-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
@@ -460,9 +420,6 @@
|
||||
|
||||
.nitro-catalog-classic-tabs-shell .nitro-card-tab-item-active {
|
||||
z-index: 2;
|
||||
/* Active tab: the catalog-header habbo-blue with the cream catalog
|
||||
body bleeding up into it. Drop the bottom border so the tab
|
||||
"merges" with the panel below. */
|
||||
background:
|
||||
linear-gradient(180deg, #4fb3ff 0%, var(--catalog-swf-blue) 100%) !important;
|
||||
color: #ffffff !important;
|
||||
@@ -490,10 +447,6 @@
|
||||
|
||||
.nitro-catalog-classic-stage {
|
||||
display: grid;
|
||||
/* Sidebar pinned at 184px; the layout column takes the rest of
|
||||
the stage row so the right edge of the right column lines up
|
||||
with the right edge of the catalog window instead of leaving a
|
||||
wide cream strip. */
|
||||
grid-template-columns: 184px 1fr;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
@@ -515,14 +468,9 @@
|
||||
|
||||
.nitro-catalog-classic-search-shell {
|
||||
position: relative;
|
||||
/* Use flex so the input vertically centers inside the 24px shell
|
||||
regardless of the input's own intrinsic baseline. */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
/* Outer padding 0, negative horizontal margin bleeds the shell a
|
||||
couple of pixels past its sidebar column on each side without
|
||||
touching the grid template - cheap way to look ~4px wider. */
|
||||
padding: 0;
|
||||
margin: -2px -1px 0 -1px;
|
||||
border: 1px solid #b7b7ae;
|
||||
@@ -530,10 +478,6 @@
|
||||
background: #f7f7f2;
|
||||
}
|
||||
|
||||
/* Clear the magnifying-glass on the left and the X-clear button on the
|
||||
right. The shell's own outer padding is essentially zero, so the
|
||||
input claims the full sidebar column width minus just enough to
|
||||
keep the icons from overlapping the text. */
|
||||
.nitro-catalog-classic-search-shell input {
|
||||
flex: 1 1 auto;
|
||||
width: 100% !important;
|
||||
@@ -550,8 +494,6 @@
|
||||
vertical-align: middle !important;
|
||||
}
|
||||
|
||||
/* The wrapping div the React component renders is 100% wide so the
|
||||
input fills the shell instead of shrinking to content. */
|
||||
.nitro-catalog-classic-search-shell > div {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
@@ -562,13 +504,10 @@
|
||||
font-size: 11px !important;
|
||||
}
|
||||
|
||||
/* The search icon ships with absolute + left-2; nudge it tight to the
|
||||
shell edge so the input can keep its left padding small. */
|
||||
.nitro-catalog-classic-search-shell svg:first-child {
|
||||
left: 4px !important;
|
||||
}
|
||||
|
||||
/* X-clear button on the right edge - keep it tight too. */
|
||||
.nitro-catalog-classic-search-shell button {
|
||||
right: 4px !important;
|
||||
}
|
||||
@@ -703,20 +642,11 @@
|
||||
.nitro-catalog-classic-default-layout {
|
||||
position: relative;
|
||||
display: block !important;
|
||||
/* Fill the layout container in both axes - the stage was
|
||||
previously 552px wide and this column was pinned at 360px, but
|
||||
now that the stage uses 1fr, hardcoding 360px would leave a
|
||||
wide blank strip on the right of every default-layout catalog
|
||||
page. */
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 460px;
|
||||
}
|
||||
|
||||
/* The product-view, grid-shell, price-row and purchase-row inside
|
||||
the default-layout were each pinned at 360px to match the old
|
||||
stage width. Widen them in lockstep so they fill the new
|
||||
1fr-sized container. */
|
||||
.nitro-catalog-classic-product-view,
|
||||
.nitro-catalog-classic-grid-shell,
|
||||
.nitro-catalog-classic-price-row,
|
||||
@@ -756,12 +686,6 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Default-3x3 layout: .offer-info is rendered but hidden via the
|
||||
display: none rule below, so the panel reserved the full width
|
||||
while only the 360px preview was visible (empty strip on the
|
||||
right). Center the preview inside the panel instead so the gap
|
||||
becomes symmetric padding on both sides. Color-grouping doesn't
|
||||
render .offer-info so its panel keeps the existing layout. */
|
||||
.nitro-catalog-classic-offer-panel:has(> .nitro-catalog-classic-offer-info) {
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -774,13 +698,6 @@
|
||||
background: #000;
|
||||
}
|
||||
|
||||
/* The default-3x3 layout puts the preview next to .offer-info inside
|
||||
.offer-panel and needs the 360px column. Scope the pin to that
|
||||
context so other layouts (color-grouping, etc.) can put the same
|
||||
preview class inside a flex/grid column and let it track the
|
||||
container width. Without this scoping the absolute-positioned
|
||||
rotate/state buttons sit past the column's right edge and get
|
||||
clipped by overflow: hidden. */
|
||||
.nitro-catalog-classic-offer-panel > .nitro-catalog-classic-offer-preview {
|
||||
width: 360px;
|
||||
min-width: 360px;
|
||||
@@ -846,8 +763,6 @@
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 245px;
|
||||
/* Stretch down to just above the price + purchase rows so the
|
||||
grid soaks up any extra height the bigger window gives us. */
|
||||
bottom: 68px;
|
||||
width: 360px;
|
||||
min-height: 0;
|
||||
@@ -855,20 +770,12 @@
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* When the admin row is rendered above the product-view it adds
|
||||
~30px (22px button + flex gap) to the flex column, but the
|
||||
grid-shell is absolutely positioned and doesn't shift on its own.
|
||||
Push it (and the bottom-anchored price/purchase rows stay put)
|
||||
down so the preview panel no longer bleeds into the grid. */
|
||||
.nitro-catalog-classic-default-layout:has(.nitro-catalog-classic-default-admin) .nitro-catalog-classic-grid-shell {
|
||||
top: 280px !important;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-grid {
|
||||
/* Don't pin a fixed column track here - AutoGrid sets the inline
|
||||
grid-template-columns from its columnMinWidth prop. The earlier
|
||||
`repeat(6, 53px) !important` was clobbering that and freezing
|
||||
the row at 6 tiles regardless of what the React layout passed. */
|
||||
grid-template-columns: repeat(6, 1fr) !important;
|
||||
grid-auto-rows: var(--nitro-grid-column-min-height, 70px);
|
||||
align-content: start;
|
||||
justify-content: start;
|
||||
@@ -943,30 +850,20 @@
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-grid-offer-icon {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 20px;
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
max-width: 36px;
|
||||
max-height: 36px;
|
||||
image-rendering: pixelated;
|
||||
object-fit: contain;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-grid-price {
|
||||
position: absolute;
|
||||
left: 2px;
|
||||
right: 2px;
|
||||
top: 36px;
|
||||
bottom: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0;
|
||||
min-height: 24px;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
width: 100%;
|
||||
color: #000;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
@@ -1015,11 +912,15 @@
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-grid-price.is-single-price {
|
||||
height: 19px;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-grid-price.is-multi-price {
|
||||
height: 38px;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
flex-wrap: wrap;
|
||||
row-gap: 1px;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-grid-price-entry {
|
||||
@@ -1452,8 +1353,8 @@
|
||||
.nitro-catalog-classic-window .nitro-catalog-classic-grid-shell::-webkit-scrollbar,
|
||||
.nitro-catalog-classic-window .nitro-catalog-classic-grid::-webkit-scrollbar,
|
||||
.nitro-catalog-classic-window .nitro-catalog-classic-layout-container::-webkit-scrollbar {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-window * {
|
||||
@@ -1462,116 +1363,120 @@
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-window *::-webkit-scrollbar {
|
||||
width: 17px !important;
|
||||
height: 17px !important;
|
||||
background-color: #e7e5d8 !important;
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
background-color: #bdbbb3 !important;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-window *::-webkit-scrollbar-track {
|
||||
background-image: none !important;
|
||||
background-color: #e7e5d8 !important;
|
||||
box-shadow: inset 1px 0 0 #b9b6a5, inset -1px 0 0 #ffffff !important;
|
||||
background-color: #bdbbb3 !important;
|
||||
box-shadow: inset 1px 0 0 #000000 !important;
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-window *::-webkit-scrollbar-thumb {
|
||||
min-height: 28px !important;
|
||||
border: 1px solid #2a2a26 !important;
|
||||
border-radius: 2px !important;
|
||||
/* Grip: a single 2px #a0a0a0 stripe in an 8px-wide centered band,
|
||||
repeated every 5px (2px stripe + 3px body gap).
|
||||
Outline: 1px black border, then a 2px white inset frame inside it. */
|
||||
min-height: 24px !important;
|
||||
background:
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='9'><line x1='1' y1='1' x2='9' y2='1' stroke='%23dcdcd2' stroke-width='1'/><line x1='1' y1='3' x2='9' y2='3' stroke='%235e5e58' stroke-width='1'/><line x1='1' y1='5' x2='9' y2='5' stroke='%23dcdcd2' stroke-width='1'/><line x1='1' y1='7' x2='9' y2='7' stroke='%235e5e58' stroke-width='1'/></svg>") center center / 10px 9px no-repeat,
|
||||
linear-gradient(180deg, #d6d6cc 0%, #b4b4aa 30%, #9a9a90 50%, #b4b4aa 70%, #d6d6cc 100%) !important;
|
||||
background-color: #a8a89e !important;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.55),
|
||||
inset 0 -1px 0 rgba(255, 255, 255, 0.4) !important;
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='8' height='5'><rect x='0' y='0' width='8' height='2' fill='%23a0a0a0'/></svg>") center top / 8px 5px repeat-y,
|
||||
#d9d9d9 !important;
|
||||
border: 1px solid #000000 !important;
|
||||
border-radius: 3px !important;
|
||||
box-shadow: inset 0 0 0 2px #ffffff !important;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-window *::-webkit-scrollbar-thumb:hover {
|
||||
background:
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='9'><line x1='1' y1='1' x2='9' y2='1' stroke='%23ececea' stroke-width='1'/><line x1='1' y1='3' x2='9' y2='3' stroke='%235e5e58' stroke-width='1'/><line x1='1' y1='5' x2='9' y2='5' stroke='%23ececea' stroke-width='1'/><line x1='1' y1='7' x2='9' y2='7' stroke='%235e5e58' stroke-width='1'/></svg>") center center / 10px 9px no-repeat,
|
||||
linear-gradient(180deg, #e0e0d6 0%, #bebeb4 30%, #a4a49a 50%, #bebeb4 70%, #e0e0d6 100%) !important;
|
||||
background-color: #b2b2a8 !important;
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='8' height='5'><rect x='0' y='0' width='8' height='2' fill='%23ababab'/></svg>") center top / 8px 5px repeat-y,
|
||||
#e3e3e3 !important;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-window *::-webkit-scrollbar-thumb:active {
|
||||
background:
|
||||
linear-gradient(180deg, #c6c6bc 0%, #a4a49a 30%, #8a8a82 50%, #a4a49a 70%, #c6c6bc 100%) !important;
|
||||
background-color: #9a9a90 !important;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.35),
|
||||
inset 0 -1px 0 rgba(255, 255, 255, 0.25) !important;
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='8' height='5'><rect x='0' y='0' width='8' height='2' fill='%23606060'/></svg>") center top / 8px 5px repeat-y,
|
||||
#bcbcbc !important;
|
||||
}
|
||||
|
||||
/* Arrow buttons: light grey cap with rounded OUTER corners (up button
|
||||
rounded at the top, down button rounded at the bottom), 1px black
|
||||
border, dark chevron via inline SVG. */
|
||||
.nitro-catalog-classic-window *::-webkit-scrollbar-button:single-button:vertical:decrement {
|
||||
display: block !important;
|
||||
width: 17px !important;
|
||||
height: 16px !important;
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
background:
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='9' height='6'><polygon points='4.5,0 9,6 0,6' fill='%23142536'/></svg>") center center / 9px 6px no-repeat,
|
||||
linear-gradient(180deg, #f3f1e6 0%, #d8d6c8 100%) !important;
|
||||
background-color: #e7e5d8 !important;
|
||||
border: 1px solid #0b2d3a !important;
|
||||
border-radius: 2px !important;
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='9' height='6'><polygon points='4.5,0 9,6 0,6' fill='%23222'/></svg>") center center / 9px 6px no-repeat,
|
||||
#d9d9d9 !important;
|
||||
border: 1px solid #000000 !important;
|
||||
border-radius: 3px 3px 0 0 !important;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.7),
|
||||
inset 0 -1px 0 rgba(0, 0, 0, 0.18) !important;
|
||||
inset 0 1px 0 #ffffff,
|
||||
inset 1px 0 0 rgba(255, 255, 255, 0.6) !important;
|
||||
}
|
||||
.nitro-catalog-classic-window *::-webkit-scrollbar-button:single-button:vertical:decrement:active {
|
||||
background:
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='9' height='6'><polygon points='4.5,0 9,6 0,6' fill='%23000'/></svg>") center center / 9px 6px no-repeat,
|
||||
linear-gradient(180deg, #c7c5b8 0%, #aeaca0 100%) !important;
|
||||
background-color: #c7c5b8 !important;
|
||||
#bcbcbc !important;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-window *::-webkit-scrollbar-button:single-button:vertical:increment {
|
||||
display: block !important;
|
||||
width: 17px !important;
|
||||
height: 16px !important;
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
background:
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='9' height='6'><polygon points='0,0 9,0 4.5,6' fill='%23142536'/></svg>") center center / 9px 6px no-repeat,
|
||||
linear-gradient(180deg, #f3f1e6 0%, #d8d6c8 100%) !important;
|
||||
background-color: #e7e5d8 !important;
|
||||
border: 1px solid #0b2d3a !important;
|
||||
border-radius: 2px !important;
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='9' height='6'><polygon points='0,0 9,0 4.5,6' fill='%23222'/></svg>") center center / 9px 6px no-repeat,
|
||||
#d9d9d9 !important;
|
||||
border: 1px solid #000000 !important;
|
||||
border-radius: 0 0 3px 3px !important;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.7),
|
||||
inset 0 -1px 0 rgba(0, 0, 0, 0.18) !important;
|
||||
inset 0 1px 0 #ffffff,
|
||||
inset 1px 0 0 rgba(255, 255, 255, 0.6) !important;
|
||||
}
|
||||
.nitro-catalog-classic-window *::-webkit-scrollbar-button:single-button:vertical:increment:active {
|
||||
background:
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='9' height='6'><polygon points='0,0 9,0 4.5,6' fill='%23000'/></svg>") center center / 9px 6px no-repeat,
|
||||
linear-gradient(180deg, #c7c5b8 0%, #aeaca0 100%) !important;
|
||||
background-color: #c7c5b8 !important;
|
||||
#bcbcbc !important;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-window *::-webkit-scrollbar-button:single-button:horizontal:decrement {
|
||||
display: block !important;
|
||||
width: 16px !important;
|
||||
height: 17px !important;
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
background:
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='6' height='9'><polygon points='0,4.5 6,0 6,9' fill='%23142536'/></svg>") center center / 6px 9px no-repeat,
|
||||
linear-gradient(180deg, #f3f1e6 0%, #d8d6c8 100%) !important;
|
||||
background-color: #e7e5d8 !important;
|
||||
border: 1px solid #0b2d3a !important;
|
||||
border-radius: 2px !important;
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='6' height='9'><polygon points='0,4.5 6,0 6,9' fill='%23222'/></svg>") center center / 6px 9px no-repeat,
|
||||
#d9d9d9 !important;
|
||||
border: 1px solid #000000 !important;
|
||||
border-radius: 3px 0 0 3px !important;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.7),
|
||||
inset 0 -1px 0 rgba(0, 0, 0, 0.18) !important;
|
||||
inset 0 1px 0 #ffffff,
|
||||
inset 1px 0 0 rgba(255, 255, 255, 0.6) !important;
|
||||
}
|
||||
.nitro-catalog-classic-window *::-webkit-scrollbar-button:single-button:horizontal:decrement:active {
|
||||
background:
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='6' height='9'><polygon points='0,4.5 6,0 6,9' fill='%23000'/></svg>") center center / 6px 9px no-repeat,
|
||||
#bcbcbc !important;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-window *::-webkit-scrollbar-button:single-button:horizontal:increment {
|
||||
display: block !important;
|
||||
width: 16px !important;
|
||||
height: 17px !important;
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
background:
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='6' height='9'><polygon points='6,4.5 0,0 0,9' fill='%23142536'/></svg>") center center / 6px 9px no-repeat,
|
||||
linear-gradient(180deg, #f3f1e6 0%, #d8d6c8 100%) !important;
|
||||
background-color: #e7e5d8 !important;
|
||||
border: 1px solid #0b2d3a !important;
|
||||
border-radius: 2px !important;
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='6' height='9'><polygon points='6,4.5 0,0 0,9' fill='%23222'/></svg>") center center / 6px 9px no-repeat,
|
||||
#d9d9d9 !important;
|
||||
border: 1px solid #000000 !important;
|
||||
border-radius: 0 3px 3px 0 !important;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.7),
|
||||
inset 0 -1px 0 rgba(0, 0, 0, 0.18) !important;
|
||||
inset 0 1px 0 #ffffff,
|
||||
inset 1px 0 0 rgba(255, 255, 255, 0.6) !important;
|
||||
}
|
||||
.nitro-catalog-classic-window *::-webkit-scrollbar-button:single-button:horizontal:increment:active {
|
||||
background:
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='6' height='9'><polygon points='6,4.5 0,0 0,9' fill='%23000'/></svg>") center center / 6px 9px no-repeat,
|
||||
#bcbcbc !important;
|
||||
}
|
||||
|
||||
.nitro-catalog-classic-breadcrumb {
|
||||
@@ -1604,12 +1509,6 @@
|
||||
padding: 6px !important;
|
||||
}
|
||||
|
||||
/* Mobile: drop the per-page welcome row (image + localization
|
||||
blurb shown when no offer is selected). On a narrow viewport
|
||||
it eats most of the visible space and pushes the actual grid
|
||||
off-screen. Hide it and also collapse the surrounding
|
||||
product-view (otherwise its 240px height reservation stays
|
||||
and leaves a blank strip above the grid). */
|
||||
.nitro-catalog-classic-welcome {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
/* ============================================================================
|
||||
Chat-bar @-mention autocomplete - Habbo style
|
||||
----------------------------------------------------------------------------
|
||||
Mirrors the NitroCard look (cream cardstock, habbo-blue header, black 2px
|
||||
border, drop shadow) and the in-room infostand row chrome. The popover
|
||||
appears above the chat input, anchored to its bottom-left and the same
|
||||
width as the input, with the bottom corners flush so it visually merges
|
||||
with the input edge.
|
||||
============================================================================ */
|
||||
|
||||
.chat-input-mention-popover {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
@@ -186,32 +176,6 @@
|
||||
color: #4a2b00;
|
||||
}
|
||||
|
||||
/* Habbo-style scrollbar, matching NitroCardContentView scroll chrome. */
|
||||
.chat-input-mention-popover-list::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.chat-input-mention-popover-list::-webkit-scrollbar-track {
|
||||
background: #d8d8cf;
|
||||
border-left: 1px solid #000;
|
||||
}
|
||||
|
||||
.chat-input-mention-popover-list::-webkit-scrollbar-thumb {
|
||||
background: #30728c;
|
||||
border: 1px solid #000;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.chat-input-mention-popover-list::-webkit-scrollbar-thumb:hover {
|
||||
background: #3c88a6;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
:command popover - same Habbo NitroCard chrome as the @-mention picker.
|
||||
Header is green to distinguish it visually from the mention popover,
|
||||
which uses the standard habbo-blue.
|
||||
============================================================================ */
|
||||
|
||||
.chat-input-command-popover {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
.has-classic-scrollbar {
|
||||
scrollbar-color: auto !important;
|
||||
scrollbar-width: auto;
|
||||
}
|
||||
|
||||
.has-classic-scrollbar *::-webkit-scrollbar,
|
||||
.has-classic-scrollbar::-webkit-scrollbar {
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
background-color: #bdbbb3 !important;
|
||||
}
|
||||
|
||||
.has-classic-scrollbar *::-webkit-scrollbar-track,
|
||||
.has-classic-scrollbar::-webkit-scrollbar-track {
|
||||
background-color: #bdbbb3 !important;
|
||||
box-shadow: inset 1px 0 0 #000000 !important;
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
.has-classic-scrollbar *::-webkit-scrollbar-thumb,
|
||||
.has-classic-scrollbar::-webkit-scrollbar-thumb {
|
||||
min-height: 24px !important;
|
||||
background:
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='8' height='5'><rect x='0' y='0' width='8' height='2' fill='%23a0a0a0'/></svg>") center top / 8px 5px repeat-y,
|
||||
#d9d9d9 !important;
|
||||
border: 1px solid #000000 !important;
|
||||
border-radius: 3px !important;
|
||||
box-shadow: inset 0 0 0 2px #ffffff !important;
|
||||
}
|
||||
|
||||
.has-classic-scrollbar *::-webkit-scrollbar-thumb:hover,
|
||||
.has-classic-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background:
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='8' height='5'><rect x='0' y='0' width='8' height='2' fill='%23ababab'/></svg>") center top / 8px 5px repeat-y,
|
||||
#e3e3e3 !important;
|
||||
}
|
||||
|
||||
.has-classic-scrollbar *::-webkit-scrollbar-thumb:active,
|
||||
.has-classic-scrollbar::-webkit-scrollbar-thumb:active {
|
||||
background:
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='8' height='5'><rect x='0' y='0' width='8' height='2' fill='%23606060'/></svg>") center top / 8px 5px repeat-y,
|
||||
#bcbcbc !important;
|
||||
}
|
||||
|
||||
.has-classic-scrollbar *::-webkit-scrollbar-button:single-button:vertical:decrement,
|
||||
.has-classic-scrollbar::-webkit-scrollbar-button:single-button:vertical:decrement {
|
||||
display: block !important;
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
background:
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='9' height='6'><polygon points='4.5,0 9,6 0,6' fill='%23222'/></svg>") center center / 9px 6px no-repeat,
|
||||
#d9d9d9 !important;
|
||||
border: 1px solid #000000 !important;
|
||||
border-radius: 3px 3px 0 0 !important;
|
||||
box-shadow:
|
||||
inset 0 1px 0 #ffffff,
|
||||
inset 1px 0 0 rgba(255, 255, 255, 0.6) !important;
|
||||
}
|
||||
.has-classic-scrollbar *::-webkit-scrollbar-button:single-button:vertical:decrement:active,
|
||||
.has-classic-scrollbar::-webkit-scrollbar-button:single-button:vertical:decrement:active {
|
||||
background:
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='9' height='6'><polygon points='4.5,0 9,6 0,6' fill='%23000'/></svg>") center center / 9px 6px no-repeat,
|
||||
#bcbcbc !important;
|
||||
}
|
||||
|
||||
.has-classic-scrollbar *::-webkit-scrollbar-button:single-button:vertical:increment,
|
||||
.has-classic-scrollbar::-webkit-scrollbar-button:single-button:vertical:increment {
|
||||
display: block !important;
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
background:
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='9' height='6'><polygon points='0,0 9,0 4.5,6' fill='%23222'/></svg>") center center / 9px 6px no-repeat,
|
||||
#d9d9d9 !important;
|
||||
border: 1px solid #000000 !important;
|
||||
border-radius: 0 0 3px 3px !important;
|
||||
box-shadow:
|
||||
inset 0 1px 0 #ffffff,
|
||||
inset 1px 0 0 rgba(255, 255, 255, 0.6) !important;
|
||||
}
|
||||
.has-classic-scrollbar *::-webkit-scrollbar-button:single-button:vertical:increment:active,
|
||||
.has-classic-scrollbar::-webkit-scrollbar-button:single-button:vertical:increment:active {
|
||||
background:
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='9' height='6'><polygon points='0,0 9,0 4.5,6' fill='%23000'/></svg>") center center / 9px 6px no-repeat,
|
||||
#bcbcbc !important;
|
||||
}
|
||||
|
||||
.has-classic-scrollbar *::-webkit-scrollbar-button:single-button:horizontal:decrement,
|
||||
.has-classic-scrollbar::-webkit-scrollbar-button:single-button:horizontal:decrement {
|
||||
display: block !important;
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
background:
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='6' height='9'><polygon points='0,4.5 6,0 6,9' fill='%23222'/></svg>") center center / 6px 9px no-repeat,
|
||||
#d9d9d9 !important;
|
||||
border: 1px solid #000000 !important;
|
||||
border-radius: 3px 0 0 3px !important;
|
||||
box-shadow:
|
||||
inset 0 1px 0 #ffffff,
|
||||
inset 1px 0 0 rgba(255, 255, 255, 0.6) !important;
|
||||
}
|
||||
.has-classic-scrollbar *::-webkit-scrollbar-button:single-button:horizontal:decrement:active,
|
||||
.has-classic-scrollbar::-webkit-scrollbar-button:single-button:horizontal:decrement:active {
|
||||
background:
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='6' height='9'><polygon points='0,4.5 6,0 6,9' fill='%23000'/></svg>") center center / 6px 9px no-repeat,
|
||||
#bcbcbc !important;
|
||||
}
|
||||
|
||||
.has-classic-scrollbar *::-webkit-scrollbar-button:single-button:horizontal:increment,
|
||||
.has-classic-scrollbar::-webkit-scrollbar-button:single-button:horizontal:increment {
|
||||
display: block !important;
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
background:
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='6' height='9'><polygon points='6,4.5 0,0 0,9' fill='%23222'/></svg>") center center / 6px 9px no-repeat,
|
||||
#d9d9d9 !important;
|
||||
border: 1px solid #000000 !important;
|
||||
border-radius: 0 3px 3px 0 !important;
|
||||
box-shadow:
|
||||
inset 0 1px 0 #ffffff,
|
||||
inset 1px 0 0 rgba(255, 255, 255, 0.6) !important;
|
||||
}
|
||||
.has-classic-scrollbar *::-webkit-scrollbar-button:single-button:horizontal:increment:active,
|
||||
.has-classic-scrollbar::-webkit-scrollbar-button:single-button:horizontal:increment:active {
|
||||
background:
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='6' height='9'><polygon points='6,4.5 0,0 0,9' fill='%23000'/></svg>") center center / 6px 9px no-repeat,
|
||||
#bcbcbc !important;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
.habbo-navigator-desktop {
|
||||
border: 1px solid #000;
|
||||
border-radius: 7px;
|
||||
background: #e9e9e1;
|
||||
box-shadow: 0 4px 4px rgba(0, 0, 0, 0.35);
|
||||
color: #111;
|
||||
font-family: Ubuntu, Arial, sans-serif;
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop .nitro-card-header-shell {
|
||||
min-height: 32px;
|
||||
max-height: 32px;
|
||||
background: #418db0;
|
||||
border-bottom: 1px solid #000;
|
||||
border-radius: 6px 6px 0 0;
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop .nitro-card-title {
|
||||
color: #fff;
|
||||
font-family: UbuntuCondensed, Ubuntu, Arial, sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
text-shadow: 1px 1px 0 #000;
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop .nitro-card-close-button {
|
||||
width: 19px;
|
||||
height: 20px;
|
||||
right: 6px;
|
||||
border: 2px solid #000;
|
||||
border-radius: 4px;
|
||||
background: #c73a32;
|
||||
box-shadow: inset 1px 1px 0 rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop .nitro-card-close-button::before,
|
||||
.habbo-navigator-desktop .nitro-card-close-button::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 2px;
|
||||
background: #fff;
|
||||
box-shadow: 1px 1px 0 #64120f;
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop .nitro-card-close-button::before {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop .nitro-card-close-button::after {
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop .nitro-card-tabs-shell {
|
||||
justify-content: flex-start;
|
||||
gap: 0;
|
||||
min-height: 27px;
|
||||
max-height: 27px;
|
||||
padding: 4px 8px 0;
|
||||
background: #e9e9e1;
|
||||
border-bottom: 1px solid #b8b8ad;
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop .nitro-card-tab-item {
|
||||
min-height: 23px;
|
||||
margin-right: -1px;
|
||||
padding: 4px 12px 3px;
|
||||
border: 1px solid #555;
|
||||
border-bottom: 0;
|
||||
border-radius: 6px 6px 0 0;
|
||||
background-color: #d5d8cf;
|
||||
background-image: url("../../assets/images/navigator/swf/tab_bg_unsel.png");
|
||||
background-repeat: repeat-x;
|
||||
background-size: auto 100%;
|
||||
color: #111;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
box-shadow: inset 1px 1px 0 #fff;
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop .nitro-card-tab-item:hover {
|
||||
background-color: #e7e8df;
|
||||
background-image: url("../../assets/images/navigator/swf/tab_bg_hilite.png");
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop .nitro-card-tab-item-active {
|
||||
z-index: 2;
|
||||
margin-bottom: -1px;
|
||||
background-color: #f4f4ed;
|
||||
background-image: url("../../assets/images/navigator/swf/tab_bg_sel.png");
|
||||
border-bottom: 1px solid #f4f4ed;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop .habbo-navigator-desktop-content {
|
||||
padding: 8px 9px 9px;
|
||||
overflow: hidden;
|
||||
background: #f4f4ed;
|
||||
border-radius: 0 0 6px 6px;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop .habbo-navigator-desktop-content input,
|
||||
.habbo-navigator-desktop .habbo-navigator-desktop-content select,
|
||||
.habbo-navigator-desktop .habbo-navigator-desktop-content textarea {
|
||||
height: 22px;
|
||||
border: 1px solid #a0a49c;
|
||||
border-radius: 3px;
|
||||
background-color: #fff;
|
||||
background-image: url("../../assets/images/navigator/swf/hdr_search.png");
|
||||
background-repeat: repeat-x;
|
||||
color: #333;
|
||||
font-size: 12px;
|
||||
box-shadow: inset 1px 1px 0 rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop .habbo-navigator-desktop-content button,
|
||||
.habbo-navigator-desktop .habbo-navigator-desktop-content .btn {
|
||||
min-height: 22px;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 4px;
|
||||
background-color: #d6d6d1;
|
||||
background-image: url("../../assets/images/navigator/swf/button.png");
|
||||
background-repeat: repeat-x;
|
||||
background-size: auto 100%;
|
||||
color: #111;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
box-shadow: inset 1px 1px 0 #fff;
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop .nitro-card-panel {
|
||||
border: 1px solid #babdb4;
|
||||
border-radius: 6px;
|
||||
background: #efefe8;
|
||||
box-shadow: inset 1px 1px 0 #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop .nitro-card-panel > .flex:first-child {
|
||||
min-height: 28px;
|
||||
padding: 5px 8px;
|
||||
border-bottom: 1px solid #d3d5cd;
|
||||
background: #efefe8;
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop .navigator-grid {
|
||||
padding: 0 4px 5px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop .navigator-item {
|
||||
min-height: 28px;
|
||||
margin: 2px 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 5px;
|
||||
background: #f5f5ef;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop .navigator-item:nth-child(even) {
|
||||
background: #e7e8e0;
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop .navigator-item:hover {
|
||||
border-color: #777;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop .nitro-navigator-search-saves-result {
|
||||
width: 155px;
|
||||
min-width: 155px;
|
||||
height: 100%;
|
||||
border: 1px solid #babdb4;
|
||||
border-radius: 6px;
|
||||
background: #efefe8;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop .nitro-navigator-search-saves-result > .flex:first-child {
|
||||
min-height: 24px;
|
||||
border: 1px solid #d58e00;
|
||||
border-radius: 4px;
|
||||
background: #f8a900;
|
||||
box-shadow: inset 1px 1px 0 rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop .nitro-navigator-search-saves-result span {
|
||||
color: #111;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop .nitro-icon.icon-navigator-info {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop ::-webkit-scrollbar {
|
||||
width: 17px !important;
|
||||
height: 17px !important;
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop ::-webkit-scrollbar-track {
|
||||
border: 0 !important;
|
||||
background-color: transparent !important;
|
||||
background-image: url("../../assets/images/catalog/swf/ubuntu_scrollbar_track_v_17x2.png") !important;
|
||||
background-repeat: repeat-y !important;
|
||||
background-position: center top !important;
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop ::-webkit-scrollbar-thumb {
|
||||
min-height: 24px !important;
|
||||
border: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
background-color: transparent !important;
|
||||
background-image:
|
||||
url("../../assets/images/catalog/swf/ubuntu_scrollbar_thumb_v_grip_7x10.png"),
|
||||
url("../../assets/images/catalog/swf/ubuntu_scrollbar_thumb_v_top_17x2.png"),
|
||||
url("../../assets/images/catalog/swf/ubuntu_scrollbar_thumb_v_bottom_17x2.png"),
|
||||
url("../../assets/images/catalog/swf/ubuntu_scrollbar_thumb_v_mid_17x1.png") !important;
|
||||
background-repeat: repeat-y, no-repeat, no-repeat, repeat-y !important;
|
||||
background-position: center center, center top, center bottom, center top !important;
|
||||
background-size: 7px 10px, 17px 2px, 17px 2px, 17px 1px !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop ::-webkit-scrollbar-button {
|
||||
width: 17px !important;
|
||||
height: 16px !important;
|
||||
background-color: transparent !important;
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop ::-webkit-scrollbar-button:single-button:vertical:decrement {
|
||||
background-image: url("../../assets/images/catalog/swf/ubuntu_scrollbar_up_17x16.png") !important;
|
||||
}
|
||||
|
||||
.habbo-navigator-desktop ::-webkit-scrollbar-button:single-button:vertical:increment {
|
||||
background-image: url("../../assets/images/catalog/swf/ubuntu_scrollbar_down_17x16.png") !important;
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
export * from './useMentionsSnapshot';
|
||||
export * from './useMentionMessages';
|
||||
export * from './useMentionAutocomplete';
|
||||
export * from './useMentionActions';
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { IMentionEntry } from '../../api';
|
||||
|
||||
// Toast laterali per le menzioni appena ricevute (avatar + messaggio + dismiss).
|
||||
// Separato da mentionsStore: i toast sono effimeri, le menzioni persistono nel pannello.
|
||||
export interface MentionToast
|
||||
{
|
||||
mentionId: number;
|
||||
senderId: number;
|
||||
senderUsername: string;
|
||||
senderFigure: string;
|
||||
message: string;
|
||||
roomName: string;
|
||||
}
|
||||
|
||||
const MAX_TOASTS = 4;
|
||||
|
||||
let toasts: MentionToast[] = [];
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
const emit = (): void =>
|
||||
{
|
||||
for(const listener of listeners) listener();
|
||||
};
|
||||
|
||||
export const subscribeMentionToasts = (callback: () => void): (() => void) =>
|
||||
{
|
||||
listeners.add(callback);
|
||||
return () => { listeners.delete(callback); };
|
||||
};
|
||||
|
||||
export const getMentionToasts = (): ReadonlyArray<MentionToast> => toasts;
|
||||
|
||||
export const pushMentionToast = (entry: IMentionEntry): void =>
|
||||
{
|
||||
toasts = [
|
||||
{
|
||||
mentionId: entry.mentionId,
|
||||
senderId: entry.senderId,
|
||||
senderUsername: entry.senderUsername,
|
||||
senderFigure: entry.senderFigure,
|
||||
message: entry.message,
|
||||
roomName: entry.roomName
|
||||
},
|
||||
...toasts.filter(toast => toast.mentionId !== entry.mentionId)
|
||||
].slice(0, MAX_TOASTS);
|
||||
|
||||
emit();
|
||||
};
|
||||
|
||||
export const dismissMentionToast = (mentionId: number): void =>
|
||||
{
|
||||
const next = toasts.filter(toast => toast.mentionId !== mentionId);
|
||||
|
||||
if(next.length === toasts.length) return;
|
||||
|
||||
toasts = next;
|
||||
emit();
|
||||
};
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { CreateLinkEvent, DeleteMentionComposer, MarkMentionsReadComposer } from '@nitrots/nitro-renderer';
|
||||
import { useMemo } from 'react';
|
||||
import { IMentionEntry, SendMessageComposer } from '../../api';
|
||||
import { markRead, removeMention } from '../../hooks/mentions/mentionsStore';
|
||||
import { markRead, removeMention } from './mentionsStore';
|
||||
|
||||
export interface MentionActions
|
||||
{
|
||||
@@ -1,89 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { MENTION_ROOM_ALIASES } from '../../components/room/widgets/chat/highlightMentions';
|
||||
import { useFriendsState } from '../friends/useFriends';
|
||||
import { useRoomUserListSnapshot } from '../session/useSessionSnapshots';
|
||||
|
||||
export interface MentionSuggestion
|
||||
{
|
||||
name: string;
|
||||
figure: string;
|
||||
isAlias: boolean;
|
||||
}
|
||||
|
||||
const MAX_SUGGESTIONS = 8;
|
||||
|
||||
// Trova il token @<parziale> che si sta digitando alla FINE del valore.
|
||||
// Restituisce il parziale (anche '' subito dopo @) oppure null se non si è in un @mention.
|
||||
const activeMentionPartial = (value: string): string | null =>
|
||||
{
|
||||
if(!value || value.indexOf('@') < 0) return null;
|
||||
|
||||
const match = /(?:^|\s)@([A-Za-z0-9_]*)$/.exec(value);
|
||||
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
|
||||
export interface MentionAutocompleteState
|
||||
{
|
||||
isVisible: boolean;
|
||||
suggestions: MentionSuggestion[];
|
||||
selectedIndex: number;
|
||||
setSelectedIndex: (index: number) => void;
|
||||
moveUp: () => void;
|
||||
moveDown: () => void;
|
||||
current: () => MentionSuggestion | null;
|
||||
// Inserisce il nome scelto sostituendo il parziale @... alla fine del valore.
|
||||
applyTo: (value: string, name: string) => string;
|
||||
}
|
||||
|
||||
export const useMentionAutocomplete = (chatValue: string): MentionAutocompleteState =>
|
||||
{
|
||||
const roomUsers = useRoomUserListSnapshot();
|
||||
const { onlineFriends } = useFriendsState();
|
||||
const [ selectedIndex, setSelectedIndex ] = useState(0);
|
||||
|
||||
const partial = useMemo(() => activeMentionPartial(chatValue), [ chatValue ]);
|
||||
|
||||
const suggestions = useMemo<MentionSuggestion[]>(() =>
|
||||
{
|
||||
if(partial === null) return [];
|
||||
|
||||
const query = partial.toLowerCase();
|
||||
const seen = new Set<string>();
|
||||
const out: MentionSuggestion[] = [];
|
||||
|
||||
const add = (name: string, figure: string, isAlias: boolean) =>
|
||||
{
|
||||
if(!name || out.length >= MAX_SUGGESTIONS) return;
|
||||
|
||||
const key = name.toLowerCase();
|
||||
|
||||
if(seen.has(key)) return;
|
||||
if(query && !key.startsWith(query)) return;
|
||||
|
||||
seen.add(key);
|
||||
out.push({ name, figure: figure || '', isAlias });
|
||||
};
|
||||
|
||||
for(const user of (roomUsers || [])) add(user?.name, (user as any)?.figure, false);
|
||||
for(const friend of (onlineFriends || [])) add(friend?.name, friend?.figure, false);
|
||||
for(const alias of MENTION_ROOM_ALIASES) add(alias, '', true);
|
||||
|
||||
return out;
|
||||
}, [ partial, roomUsers, onlineFriends ]);
|
||||
|
||||
useEffect(() => { setSelectedIndex(0); }, [ partial ]);
|
||||
|
||||
const isVisible = (partial !== null) && (suggestions.length > 0);
|
||||
|
||||
return {
|
||||
isVisible,
|
||||
suggestions,
|
||||
selectedIndex,
|
||||
setSelectedIndex,
|
||||
moveUp: () => setSelectedIndex(index => (index <= 0 ? suggestions.length - 1 : index - 1)),
|
||||
moveDown: () => setSelectedIndex(index => (index >= suggestions.length - 1 ? 0 : index + 1)),
|
||||
current: () => suggestions[selectedIndex] ?? null,
|
||||
applyTo: (value: string, name: string) => value.replace(/@([A-Za-z0-9_]*)$/, '@' + name + ' ')
|
||||
};
|
||||
};
|
||||
@@ -2,14 +2,16 @@ import { MentionReceivedEvent, MentionsListEvent, RequestMentionsComposer } from
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { GetConfigurationValue, IMentionEntry, PlaySound, SendMessageComposer } from '../../api';
|
||||
import { useMessageEvent } from '../events';
|
||||
import { useNotification } from '../notification/useNotification';
|
||||
import { addMention, setMentions } from './mentionsStore';
|
||||
import { pushMentionToast } from './mentionToastsStore';
|
||||
|
||||
// Dedicated mention chime served from nitro-assets/sounds/<sample>.mp3.
|
||||
const MENTION_SOUND_SAMPLE = 'mentions_notification';
|
||||
|
||||
export const useMentionMessages = (): void =>
|
||||
{
|
||||
const { showMentionBubble } = useNotification();
|
||||
|
||||
const onMentionsList = useCallback((event: MentionsListEvent) =>
|
||||
{
|
||||
const list = event.getParser().mentions;
|
||||
@@ -51,9 +53,10 @@ export const useMentionMessages = (): void =>
|
||||
|
||||
if(GetConfigurationValue<boolean>('mentions_ui.sound', true)) PlaySound(MENTION_SOUND_SAMPLE);
|
||||
|
||||
// Notifica laterale custom (avatar + messaggio + dismiss) invece del bubble generico.
|
||||
pushMentionToast(entry);
|
||||
}, []);
|
||||
// Surface it through the client's standard notification stream, using the
|
||||
// dedicated mention bubble layout (avatar + actions).
|
||||
showMentionBubble(entry);
|
||||
}, [ showMentionBubble ]);
|
||||
|
||||
useMessageEvent<MentionsListEvent>(MentionsListEvent, onMentionsList);
|
||||
useMessageEvent<MentionReceivedEvent>(MentionReceivedEvent, onMentionReceived);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AchievementNotificationMessageEvent, ActivityPointNotificationMessageEvent, BadgeReceivedEvent, ClubGiftNotificationEvent, ClubGiftSelectedEvent, ConnectionErrorEvent, GetLocalizationManager, GetRoomEngine, GetSessionDataManager, HabboBroadcastMessageEvent, HotelClosedAndOpensEvent, HotelClosesAndWillOpenAtEvent, HotelWillCloseInMinutesEvent, InfoFeedEnableMessageEvent, MaintenanceStatusMessageEvent, ModeratorCautionEvent, ModeratorMessageEvent, MOTDNotificationEvent, NotificationDialogMessageEvent, PetLevelNotificationEvent, PetReceivedMessageEvent, RespectReceivedEvent, RoomEnterEffect, RoomEnterEvent, SimpleAlertMessageEvent, UserBannedMessageEvent, Vector3d, WiredRewardResultMessageEvent } from '@nitrots/nitro-renderer';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useBetween } from 'use-between';
|
||||
import { GetConfigurationValue, LocalizeBadgeName, LocalizeText, NotificationAlertItem, NotificationAlertType, NotificationBubbleItem, NotificationBubbleType, NotificationConfirmItem, PlaySound, ProductImageUtility, TradingNotificationType } from '../../api';
|
||||
import { GetConfigurationValue, IMentionEntry, LocalizeBadgeName, LocalizeText, MentionNotificationBubbleItem, NotificationAlertItem, NotificationAlertType, NotificationBubbleItem, NotificationBubbleType, NotificationConfirmItem, PlaySound, ProductImageUtility, TradingNotificationType } from '../../api';
|
||||
import { useMessageEvent } from '../events';
|
||||
|
||||
const cleanText = (text: string) => (text && text.length) ? text.replace(/\\r/g, '\r') : '';
|
||||
@@ -86,6 +86,16 @@ const useNotificationStore = () =>
|
||||
});
|
||||
}, [ bubblesDisabled ]);
|
||||
|
||||
const showMentionBubble = useCallback((mention: IMentionEntry) =>
|
||||
{
|
||||
// Mentions always surface: they have their own `mentions_ui.enabled` gate
|
||||
// (checked in useMentionMessages) and are intentionally independent of the
|
||||
// generic info-feed toggle, so EVERY received mention shows a bubble.
|
||||
const item = new MentionNotificationBubbleItem(mention);
|
||||
|
||||
setBubbleAlerts(prevValue => [ item, ...prevValue ]);
|
||||
}, []);
|
||||
|
||||
const showNotification = (type: string, options: Map<string, string> = null) =>
|
||||
{
|
||||
if(!options) options = new Map();
|
||||
@@ -490,7 +500,7 @@ const useNotificationStore = () =>
|
||||
|
||||
useMessageEvent<RoomEnterEvent>(RoomEnterEvent, onRoomEnterEvent);
|
||||
|
||||
return { alerts, bubbleAlerts, confirms, simpleAlert, showNitroAlert, showTradeAlert, showConfirm, showSingleBubble, closeAlert, closeBubbleAlert, closeConfirm };
|
||||
return { alerts, bubbleAlerts, confirms, simpleAlert, showNitroAlert, showTradeAlert, showConfirm, showSingleBubble, showMentionBubble, closeAlert, closeBubbleAlert, closeConfirm };
|
||||
};
|
||||
|
||||
export const useNotificationState = () =>
|
||||
@@ -508,6 +518,7 @@ export const useNotificationActions = () =>
|
||||
showTradeAlert,
|
||||
showConfirm,
|
||||
showSingleBubble,
|
||||
showMentionBubble,
|
||||
closeAlert,
|
||||
closeBubbleAlert,
|
||||
closeConfirm
|
||||
@@ -519,6 +530,7 @@ export const useNotificationActions = () =>
|
||||
showTradeAlert,
|
||||
showConfirm,
|
||||
showSingleBubble,
|
||||
showMentionBubble,
|
||||
closeAlert,
|
||||
closeBubbleAlert,
|
||||
closeConfirm
|
||||
|
||||
@@ -2,6 +2,7 @@ export * from './furniture';
|
||||
export * from './useAvatarInfoWidget';
|
||||
export * from './useChatCommandSelector';
|
||||
export * from './useChatInputWidget';
|
||||
export * from './useChatMentions';
|
||||
export * from './useChatWidget';
|
||||
export * from './useDoorbellActions';
|
||||
export * from './useDoorbellState';
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildChatMentionSuggestions, computeMentionContext, MentionAlias } from './useChatMentions.helpers';
|
||||
|
||||
const ALIASES: MentionAlias[] = [
|
||||
{ key: 'all', scope: 'everyone', description: '' },
|
||||
{ key: 'room', scope: 'room', description: '' }
|
||||
];
|
||||
|
||||
const ROOM = [
|
||||
{ webID: 1, type: 1, name: 'tester', figure: 'a' },
|
||||
{ webID: 2, type: 1, name: 'alice', figure: 'b' },
|
||||
{ webID: 3, type: 1, name: 'bob', figure: 'c' },
|
||||
{ webID: 9, type: 2, name: 'petbot', figure: 'd' } // non-real user (pet/bot)
|
||||
];
|
||||
|
||||
describe('computeMentionContext', () =>
|
||||
{
|
||||
it('detects a trailing @query', () =>
|
||||
{
|
||||
expect(computeMentionContext('hi @al', false)).toEqual({ atIndex: 3, replaceFrom: 3, replaceTo: 6, query: 'al' });
|
||||
});
|
||||
|
||||
it('detects @ at the very start', () =>
|
||||
{
|
||||
expect(computeMentionContext('@al', false)).toEqual({ atIndex: 0, replaceFrom: 0, replaceTo: 3, query: 'al' });
|
||||
});
|
||||
|
||||
it('returns null when a command popover is open', () =>
|
||||
{
|
||||
expect(computeMentionContext('hi @al', true)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when @ is glued to a previous word (e.g. an email)', () =>
|
||||
{
|
||||
expect(computeMentionContext('mail me@al', false)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the @token is not at the end', () =>
|
||||
{
|
||||
expect(computeMentionContext('@al ready', false)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when there is no @', () =>
|
||||
{
|
||||
expect(computeMentionContext('plain text', false)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildChatMentionSuggestions', () =>
|
||||
{
|
||||
it('excludes the viewer by id and keeps the other real users + aliases', () =>
|
||||
{
|
||||
const names = buildChatMentionSuggestions('', ROOM, ALIASES, 1, 'tester').map(s => s.name);
|
||||
|
||||
expect(names).not.toContain('tester'); // own user (webID 1)
|
||||
expect(names).toContain('alice');
|
||||
expect(names).toContain('bob');
|
||||
expect(names).toContain('all');
|
||||
expect(names).toContain('room');
|
||||
});
|
||||
|
||||
it('excludes the viewer by name when the id does not line up', () =>
|
||||
{
|
||||
const names = buildChatMentionSuggestions('', ROOM, ALIASES, -1, 'TESTER').map(s => s.name);
|
||||
|
||||
expect(names).not.toContain('tester');
|
||||
});
|
||||
|
||||
it('skips non-real users (pets/bots)', () =>
|
||||
{
|
||||
const names = buildChatMentionSuggestions('', ROOM, [], 1, 'tester').map(s => s.name);
|
||||
|
||||
expect(names).not.toContain('petbot');
|
||||
});
|
||||
|
||||
it('prefix-filters users and aliases by the query', () =>
|
||||
{
|
||||
const names = buildChatMentionSuggestions('al', ROOM, ALIASES, 1, 'tester').map(s => s.name);
|
||||
|
||||
expect(names).toContain('alice');
|
||||
expect(names).toContain('all');
|
||||
expect(names).not.toContain('bob');
|
||||
expect(names).not.toContain('room');
|
||||
});
|
||||
|
||||
it('tags user vs alias kinds', () =>
|
||||
{
|
||||
const out = buildChatMentionSuggestions('', ROOM, ALIASES, 1, 'tester');
|
||||
|
||||
expect(out.find(s => s.name === 'alice')?.kind).toBe('user');
|
||||
expect(out.find(s => s.name === 'all')?.kind).toBe('alias');
|
||||
});
|
||||
|
||||
it('caps the total at max', () =>
|
||||
{
|
||||
const many = Array.from({ length: 20 }, (_, i) => ({ webID: 100 + i, type: 1, name: `u${ i }`, figure: '' }));
|
||||
const out = buildChatMentionSuggestions('', many, ALIASES, -1, 'me', 5);
|
||||
|
||||
expect(out).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
// Pure helpers for the chat-input @-mention autocomplete. Kept framework-free
|
||||
// so the suggestion building and the caret-context detection are unit-testable.
|
||||
|
||||
export type MentionSuggestionKind = 'user' | 'alias';
|
||||
|
||||
export interface MentionSuggestion
|
||||
{
|
||||
key: string;
|
||||
kind: MentionSuggestionKind;
|
||||
name: string;
|
||||
insertToken: string;
|
||||
figure?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export type MentionAliasScope = 'everyone' | 'friends' | 'room';
|
||||
|
||||
export interface MentionAlias
|
||||
{
|
||||
key: string;
|
||||
scope: MentionAliasScope;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface MentionContext
|
||||
{
|
||||
atIndex: number;
|
||||
replaceFrom: number;
|
||||
replaceTo: number;
|
||||
query: string;
|
||||
}
|
||||
|
||||
export const MAX_MENTION_SUGGESTIONS = 8;
|
||||
|
||||
const USER_TYPE_REAL_USER = 1;
|
||||
|
||||
export const MENTION_ALIAS_CONFIG_KEY: Record<MentionAliasScope, string> = {
|
||||
everyone: 'mentions_ui.aliases.everyone',
|
||||
friends: 'mentions_ui.aliases.friends',
|
||||
room: 'mentions_ui.aliases.room'
|
||||
};
|
||||
|
||||
export const MENTION_ALIAS_DEFAULTS: Record<MentionAliasScope, string[]> = {
|
||||
everyone: [ 'all', 'everyone', 'tutti' ],
|
||||
friends: [ 'friends', 'amici' ],
|
||||
room: [ 'room', 'stanza' ]
|
||||
};
|
||||
|
||||
export const MENTION_ALIAS_DESCRIPTION_KEY: Record<MentionAliasScope, string> = {
|
||||
everyone: 'mentions.alias.description.everyone',
|
||||
friends: 'mentions.alias.description.friends',
|
||||
room: 'mentions.alias.description.room'
|
||||
};
|
||||
|
||||
export const sanitizeAliasList = (raw: unknown, fallback: string[]): string[] =>
|
||||
{
|
||||
if(!Array.isArray(raw)) return fallback;
|
||||
|
||||
const out: string[] = [];
|
||||
|
||||
for(const entry of raw)
|
||||
{
|
||||
if(typeof entry !== 'string') continue;
|
||||
|
||||
const trimmed = entry.trim();
|
||||
|
||||
if(!trimmed) continue;
|
||||
|
||||
out.push(trimmed);
|
||||
}
|
||||
|
||||
return out;
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect an in-progress `@partial` token at the END of the input. Returns the
|
||||
* token bounds + the query (text after `@`), or null when not in a mention (no
|
||||
* trailing `@token`, `@` glued to a previous word, or a command popover open).
|
||||
* End-anchored (no caret read) so it stays a pure render-safe computation.
|
||||
*/
|
||||
export const computeMentionContext = (value: string, commandSelectorVisible: boolean): MentionContext | null =>
|
||||
{
|
||||
if(!value) return null;
|
||||
if(commandSelectorVisible) return null;
|
||||
|
||||
const match = /(?:^|\s)@([A-Za-z0-9_]*)$/.exec(value);
|
||||
|
||||
if(!match) return null;
|
||||
|
||||
const query = match[1];
|
||||
const atIndex = value.length - query.length - 1;
|
||||
|
||||
return { atIndex, replaceFrom: atIndex, replaceTo: value.length, query };
|
||||
};
|
||||
|
||||
interface MentionRoomUser
|
||||
{
|
||||
webID?: number;
|
||||
type?: number;
|
||||
name?: string;
|
||||
figure?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the suggestion list for the current query: real room users (minus the
|
||||
* viewer themselves — match by id, name as fallback) then the broadcast
|
||||
* aliases, prefix-filtered and capped.
|
||||
*/
|
||||
export const buildChatMentionSuggestions = (
|
||||
query: string,
|
||||
roomUserList: ReadonlyArray<MentionRoomUser>,
|
||||
aliases: ReadonlyArray<MentionAlias>,
|
||||
ownUserId: number,
|
||||
ownUsername: string,
|
||||
max: number = MAX_MENTION_SUGGESTIONS
|
||||
): MentionSuggestion[] =>
|
||||
{
|
||||
const q = (query || '').toLowerCase();
|
||||
const ownNameLower = (ownUsername || '').toLowerCase();
|
||||
const out: MentionSuggestion[] = [];
|
||||
|
||||
for(const user of (roomUserList || []))
|
||||
{
|
||||
if(out.length >= max) break;
|
||||
if(!user || (user.type !== USER_TYPE_REAL_USER)) continue;
|
||||
if(!user.name) continue;
|
||||
// You can't mention yourself — skip the own user (match by id, name as fallback).
|
||||
if((user.webID === ownUserId) || (ownNameLower && (user.name.toLowerCase() === ownNameLower))) continue;
|
||||
if((q.length > 0) && !user.name.toLowerCase().startsWith(q)) continue;
|
||||
|
||||
out.push({ key: `user:${ user.webID }`, kind: 'user', name: user.name, insertToken: user.name, figure: user.figure || '' });
|
||||
}
|
||||
|
||||
for(const alias of aliases)
|
||||
{
|
||||
if(out.length >= max) break;
|
||||
if((q.length > 0) && !alias.key.toLowerCase().startsWith(q)) continue;
|
||||
|
||||
out.push({ key: `alias:${ alias.key }`, kind: 'alias', name: alias.key, insertToken: alias.key, description: alias.description });
|
||||
}
|
||||
|
||||
return out;
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
import { GetSessionDataManager } from '@nitrots/nitro-renderer';
|
||||
import { RefObject, useCallback, useMemo, useState } from 'react';
|
||||
import { GetConfigurationValue, LocalizeText } from '../../../api';
|
||||
import { useRoomUserListSnapshot } from '../../session/useSessionSnapshots';
|
||||
import { buildChatMentionSuggestions, computeMentionContext, MentionAlias, MentionAliasScope, MentionSuggestion, MENTION_ALIAS_CONFIG_KEY, MENTION_ALIAS_DEFAULTS, MENTION_ALIAS_DESCRIPTION_KEY, sanitizeAliasList } from './useChatMentions.helpers';
|
||||
|
||||
export type { MentionSuggestion, MentionSuggestionKind } from './useChatMentions.helpers';
|
||||
|
||||
export interface ChatMentionsState
|
||||
{
|
||||
visible: boolean;
|
||||
suggestions: MentionSuggestion[];
|
||||
selectedIndex: number;
|
||||
setSelectedIndex: (index: number) => void;
|
||||
moveUp: () => void;
|
||||
moveDown: () => void;
|
||||
/** Apply the highlighted suggestion (or the first). Returns true if one was applied. */
|
||||
applyCurrent: () => boolean;
|
||||
apply: (suggestion: MentionSuggestion) => void;
|
||||
/** Escape: reset selection and strip the in-progress @query from the input. */
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat-input @-mention autocomplete. Owns the caret-context detection, the
|
||||
* config-driven broadcast aliases, the room-user suggestion list (minus the
|
||||
* viewer), keyboard navigation and the insert/cancel actions — so ChatInputView
|
||||
* just wires its keydown handler and the selector view to this hook.
|
||||
*/
|
||||
export const useChatMentions = (
|
||||
chatValue: string,
|
||||
setChatValue: (value: string) => void,
|
||||
inputRef: RefObject<HTMLInputElement>,
|
||||
commandSelectorVisible: boolean
|
||||
): ChatMentionsState =>
|
||||
{
|
||||
const roomUserList = useRoomUserListSnapshot();
|
||||
const [ selectedIndex, setSelectedIndex ] = useState(0);
|
||||
|
||||
const mentionContext = useMemo(() => computeMentionContext(chatValue, commandSelectorVisible), [ chatValue, commandSelectorVisible ]);
|
||||
|
||||
const aliases = useMemo<MentionAlias[]>(() =>
|
||||
{
|
||||
const out: MentionAlias[] = [];
|
||||
const seen = new Set<string>();
|
||||
const scopes: MentionAliasScope[] = [ 'everyone', 'friends', 'room' ];
|
||||
|
||||
for(const scope of scopes)
|
||||
{
|
||||
const list = sanitizeAliasList(GetConfigurationValue<unknown>(MENTION_ALIAS_CONFIG_KEY[scope], MENTION_ALIAS_DEFAULTS[scope]), MENTION_ALIAS_DEFAULTS[scope]);
|
||||
|
||||
for(const key of list)
|
||||
{
|
||||
const lower = key.toLowerCase();
|
||||
|
||||
if(seen.has(lower)) continue;
|
||||
|
||||
seen.add(lower);
|
||||
out.push({ key, scope, description: LocalizeText(MENTION_ALIAS_DESCRIPTION_KEY[scope]) });
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}, []);
|
||||
|
||||
const suggestions = useMemo<MentionSuggestion[]>(() =>
|
||||
{
|
||||
if(!mentionContext) return [];
|
||||
|
||||
const session = GetSessionDataManager();
|
||||
|
||||
return buildChatMentionSuggestions(mentionContext.query, roomUserList, aliases, session?.userId ?? -1, session?.userName || '');
|
||||
}, [ mentionContext, roomUserList, aliases ]);
|
||||
|
||||
const visible = (suggestions.length > 0);
|
||||
// Clamp the selection to the current list (derived, no effect) so a
|
||||
// shrinking list never leaves the highlight out of range.
|
||||
const safeIndex = (selectedIndex < suggestions.length) ? selectedIndex : 0;
|
||||
|
||||
const apply = useCallback((suggestion: MentionSuggestion) =>
|
||||
{
|
||||
if(!suggestion || !mentionContext) return;
|
||||
|
||||
const before = chatValue.slice(0, mentionContext.replaceFrom);
|
||||
const after = chatValue.slice(mentionContext.replaceTo);
|
||||
const inserted = `@${ suggestion.insertToken } `;
|
||||
|
||||
setChatValue(`${ before }${ inserted }${ after }`);
|
||||
|
||||
requestAnimationFrame(() =>
|
||||
{
|
||||
if(!inputRef.current) return;
|
||||
|
||||
const caret = before.length + inserted.length;
|
||||
|
||||
inputRef.current.focus();
|
||||
inputRef.current.setSelectionRange(caret, caret);
|
||||
});
|
||||
|
||||
setSelectedIndex(0);
|
||||
}, [ chatValue, mentionContext, setChatValue, inputRef ]);
|
||||
|
||||
const moveUp = useCallback(() => setSelectedIndex((safeIndex <= 0) ? (suggestions.length - 1) : (safeIndex - 1)), [ safeIndex, suggestions.length ]);
|
||||
const moveDown = useCallback(() => setSelectedIndex((safeIndex >= (suggestions.length - 1)) ? 0 : (safeIndex + 1)), [ safeIndex, suggestions.length ]);
|
||||
|
||||
const applyCurrent = useCallback(() =>
|
||||
{
|
||||
const picked = suggestions[safeIndex] ?? suggestions[0];
|
||||
|
||||
if(!picked) return false;
|
||||
|
||||
apply(picked);
|
||||
|
||||
return true;
|
||||
}, [ suggestions, safeIndex, apply ]);
|
||||
|
||||
const cancel = useCallback(() =>
|
||||
{
|
||||
setSelectedIndex(0);
|
||||
|
||||
if(!mentionContext) return;
|
||||
|
||||
const before = chatValue.slice(0, mentionContext.replaceFrom);
|
||||
const after = chatValue.slice(mentionContext.replaceTo);
|
||||
|
||||
setChatValue(before + after);
|
||||
}, [ mentionContext, chatValue, setChatValue ]);
|
||||
|
||||
return { visible, suggestions, selectedIndex: safeIndex, setSelectedIndex, moveUp, moveDown, applyCurrent, apply, cancel };
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -65,7 +65,6 @@ const installPatch = (): void =>
|
||||
if(!proto) continue;
|
||||
|
||||
if(guardMethod(proto, 'break', name)) patched = true;
|
||||
|
||||
if(guardMethod(proto, 'checkAndUpdateTexture', name)) patched = true;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user