Refine mobile avatar widgets and login flow

This commit is contained in:
Lorenzune
2026-05-07 21:19:15 +02:00
parent 851d82f93f
commit 57b83c1097
24 changed files with 654 additions and 166 deletions
+31 -13
View File
@@ -195,11 +195,15 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
const [ localeError, setLocaleError ] = useState('');
const [ loginViewConfig, setLoginViewConfig ] = useState<Record<string, unknown>>(() => GetConfigurationValue<Record<string, unknown>>('loginview', {}));
const submitTimeRef = useRef(0);
const preloadedLoginImagesRef = useRef<Set<string>>(new Set());
const configuredLoginImages: Record<string, string> = (loginViewConfig?.['images'] as Record<string, string>) ?? {};
const loginImages: Record<string, string> = { ...getDefaultLoginImages(), ...configuredLoginImages };
const configuredLoginWidgets: Record<string, unknown> = (loginViewConfig?.['widgets'] as Record<string, unknown>) ?? {};
const configuredLoginImages = useMemo<Record<string, string>>(() =>
(loginViewConfig?.['images'] as Record<string, string>) ?? {}, [ loginViewConfig ]);
const loginImages = useMemo<Record<string, string>>(() =>
({ ...getDefaultLoginImages(), ...configuredLoginImages }), [ configuredLoginImages ]);
const configuredLoginWidgets = useMemo<Record<string, unknown>>(() =>
(loginViewConfig?.['widgets'] as Record<string, unknown>) ?? {}, [ loginViewConfig ]);
const loginWidgetSlots = useMemo(() =>
{
return Object.entries(configuredLoginWidgets)
@@ -311,18 +315,32 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
if(!loginImageUrls.length) return;
let cancelled = false;
let remaining = 0;
loginImageUrls.forEach(url =>
{
const image = new Image();
image.onload = image.onerror = () =>
loginImageUrls
.filter(url =>
{
if(!cancelled) setLoginImagesVersion(version => version + 1);
};
if(preloadedLoginImagesRef.current.has(url)) return false;
image.src = url;
});
preloadedLoginImagesRef.current.add(url);
return true;
})
.forEach(url =>
{
remaining++;
const image = new Image();
image.onload = image.onerror = () =>
{
remaining--;
if(!cancelled && remaining <= 0) setLoginImagesVersion(version => version + 1);
};
image.src = url;
});
return () =>
{
@@ -27,7 +27,9 @@ export const NewsWindow: FC<NewsWindowProps> = ({ newsUrl }) =>
{
if(!newsUrl) { setFailed(true); return; }
let cancelled = false;
fetch(newsUrl, { credentials: 'omit' })
const controller = new AbortController();
fetch(newsUrl, { credentials: 'omit', signal: controller.signal })
.then(async r =>
{
if(!r.ok) throw new Error('status ' + r.status);
@@ -42,7 +44,11 @@ export const NewsWindow: FC<NewsWindowProps> = ({ newsUrl }) =>
setItems(list);
})
.catch(() => { if(!cancelled) setFailed(true); });
return () => { cancelled = true; };
return () =>
{
cancelled = true;
controller.abort();
};
}, [ newsUrl ]);
useEffect(() =>
+71 -1
View File
@@ -1,4 +1,4 @@
import { GetRenderer, RoomSession } from '@nitrots/nitro-renderer';
import { GetEventDispatcher, GetRenderer, RoomObjectMouseEvent, RoomObjectTileMouseEvent, RoomSession } from '@nitrots/nitro-renderer';
import { AnimatePresence, motion } from 'framer-motion';
import { FC, useEffect, useRef } from 'react';
import { DispatchMouseEvent, DispatchTouchEvent } from '../../api';
@@ -30,6 +30,68 @@ export const RoomView: FC<{}> = (props) =>
canvas.ontouchend = (event) => DispatchTouchEvent(event);
canvas.ontouchcancel = (event) => DispatchTouchEvent(event);
let touchStartX = 0;
let touchStartY = 0;
let touchMoved = false;
let lastTileTap: { x: number; y: number; time: number } = null;
const isMobileTouch = () => window.matchMedia('(pointer: coarse), (hover: none)').matches;
const onTouchStart = (event: TouchEvent) =>
{
const touch = event.touches[0];
if(!touch || !isMobileTouch()) return;
touchStartX = touch.clientX;
touchStartY = touch.clientY;
touchMoved = false;
};
const onTouchMove = (event: TouchEvent) =>
{
const touch = event.touches[0];
if(!touch || !isMobileTouch()) return;
if(Math.abs(touch.clientX - touchStartX) > 8 || Math.abs(touch.clientY - touchStartY) > 8) touchMoved = true;
};
const onTouchEnd = (event: TouchEvent) =>
{
const touch = event.changedTouches[0];
if(!touch || touchMoved || !isMobileTouch()) return;
lastTileTap = { x: touch.clientX, y: touch.clientY, time: Date.now() };
};
const showTouchFeedback = () =>
{
if(!lastTileTap || ((Date.now() - lastTileTap.time) > 250)) return;
const feedback = document.createElement('div');
feedback.className = 'nitro-room-touch-feedback';
feedback.style.left = `${ lastTileTap.x }px`;
feedback.style.top = `${ lastTileTap.y }px`;
document.body.appendChild(feedback);
window.setTimeout(() => feedback.remove(), 420);
lastTileTap = null;
};
const onTileClick = (event: RoomObjectMouseEvent) =>
{
if(event instanceof RoomObjectTileMouseEvent) window.setTimeout(showTouchFeedback, 0);
};
canvas.addEventListener('touchstart', onTouchStart, { passive: true });
canvas.addEventListener('touchmove', onTouchMove, { passive: true });
canvas.addEventListener('touchend', onTouchEnd, { passive: true });
GetEventDispatcher().addEventListener(RoomObjectMouseEvent.CLICK, onTileClick);
const element = elementRef.current;
if(!element) return;
@@ -37,6 +99,14 @@ export const RoomView: FC<{}> = (props) =>
canvas.classList.add('bg-black');
element.appendChild(canvas);
return () =>
{
canvas.removeEventListener('touchstart', onTouchStart);
canvas.removeEventListener('touchmove', onTouchMove);
canvas.removeEventListener('touchend', onTouchEnd);
GetEventDispatcher().removeEventListener(RoomObjectMouseEvent.CLICK, onTileClick);
};
}, [roomSession]);
return (
@@ -1,7 +1,7 @@
import { AddLinkEventTracker, GetRoomEngine, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker, RoomEngineEvent, RoomEnterEffect, RoomSessionDanceEvent } from '@nitrots/nitro-renderer';
import { AddLinkEventTracker, GetRoomEngine, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker, RoomEngineEvent, RoomEngineObjectEvent, RoomEnterEffect, RoomSessionDanceEvent } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { AvatarInfoFurni, AvatarInfoPet, AvatarInfoRentableBot, AvatarInfoUser, GetConfigurationValue, RoomWidgetUpdateRentableBotChatEvent } from '../../../../api';
import { Column } from '../../../../common';
import { Column, LayoutFurniIconImageView } from '../../../../common';
import { useAvatarInfoWidget, useNitroEvent, useRoom, useUiEvent } from '../../../../hooks';
import { AvatarInfoPetTrainingPanelView } from './AvatarInfoPetTrainingPanelView';
import { AvatarInfoRentableBotChatView } from './AvatarInfoRentableBotChatView';
@@ -27,6 +27,9 @@ export const AvatarInfoWidgetView: FC<{}> = props =>
const BLOCK_ROTATE_WINDOW_MS = 500;
const [ isGameMode, setGameMode ] = useState(false);
const [ isDancing, setIsDancing ] = useState(false);
const [ isTouchLayout, setIsTouchLayout ] = useState(false);
const [ mobileFurniDetailsOpen, setMobileFurniDetailsOpen ] = useState(false);
const [ mobileUserDetailsOpen, setMobileUserDetailsOpen ] = useState(false);
const [ rentableBotChatEvent, setRentableBotChatEvent ] = useState<RoomWidgetUpdateRentableBotChatEvent>(null);
const { avatarInfo = null, setAvatarInfo = null, activeNameBubble = null, setActiveNameBubble = null, nameBubbles = [], removeNameBubble = null, productBubbles = [], confirmingProduct = null, updateConfirmingProduct = null, removeProductBubble = null, isDecorating = false, setIsDecorating = null } = useAvatarInfoWidget();
const { roomSession = null } = useRoom();
@@ -56,6 +59,17 @@ export const AvatarInfoWidgetView: FC<{}> = props =>
if(!isGameMode) setGameMode(true);
});
useEffect(() =>
{
const query = window.matchMedia('(pointer: coarse), (hover: none)');
const updateTouchLayout = () => setIsTouchLayout(query.matches);
updateTouchLayout();
query.addEventListener('change', updateTouchLayout);
return () => query.removeEventListener('change', updateTouchLayout);
}, []);
useNitroEvent<RoomSessionDanceEvent>(RoomSessionDanceEvent.RSDE_DANCE, event =>
{
if(event.roomIndex !== roomSession.ownRoomIndex) return;
@@ -65,6 +79,13 @@ export const AvatarInfoWidgetView: FC<{}> = props =>
useUiEvent<RoomWidgetUpdateRentableBotChatEvent>(RoomWidgetUpdateRentableBotChatEvent.UPDATE_CHAT, event => setRentableBotChatEvent(event));
useNitroEvent<RoomEngineObjectEvent>(RoomEngineObjectEvent.REQUEST_MANIPULATION, event =>
{
if(event.category !== avatarInfo?.category || event.objectId !== avatarInfo?.id) return;
setMobileFurniDetailsOpen(false);
});
useEffect(() =>
{
const linkTracker: ILinkEventTracker = {
@@ -100,6 +121,19 @@ export const AvatarInfoWidgetView: FC<{}> = props =>
return () => RemoveLinkEventTracker(linkTracker);
}, [ roomSession, setActiveNameBubble, setAvatarInfo ]);
useEffect(() =>
{
if(avatarInfo?.type !== AvatarInfoFurni.FURNI)
{
setMobileFurniDetailsOpen(false);
}
if(avatarInfo?.type !== AvatarInfoUser.OWN_USER && avatarInfo?.type !== AvatarInfoUser.PEER)
{
setMobileUserDetailsOpen(false);
}
}, [ avatarInfo ]);
const getMenuView = () =>
{
if(!roomSession || isGameMode) return null;
@@ -120,6 +154,9 @@ export const AvatarInfoWidgetView: FC<{}> = props =>
case AvatarInfoUser.OWN_USER:
case AvatarInfoUser.PEER: {
const info = (avatarInfo as AvatarInfoUser);
if(isTouchLayout && !mobileUserDetailsOpen) return null;
if(GetConfigurationValue('user.tags.enabled')) GetSessionDataManager().getUserTags(info.roomIndex);
if(info.isSpectatorMode) return null;
@@ -156,9 +193,41 @@ export const AvatarInfoWidgetView: FC<{}> = props =>
switch(avatarInfo.type)
{
case AvatarInfoFurni.FURNI:
if(isTouchLayout && !isDecorating)
{
const info = (avatarInfo as AvatarInfoFurni);
if(!mobileFurniDetailsOpen)
{
return (
<button className="nitro-mobile-furni-infostand-trigger" type="button" onClick={ () => setMobileFurniDetailsOpen(true) }>
<LayoutFurniIconImageView productType={ info.productType } productClassId={ info.spriteId } />
</button>
);
}
}
return <InfoStandWidgetFurniView avatarInfo={ (avatarInfo as AvatarInfoFurni) } onClose={ () => setAvatarInfo(null) } />;
case AvatarInfoUser.OWN_USER:
case AvatarInfoUser.PEER:
if(isTouchLayout)
{
const info = (avatarInfo as AvatarInfoUser);
const figure = encodeURIComponent(info.figure || '');
const avatarHeadUrl = `https://www.habbo.com/habbo-imaging/avatarimage?figure=${ figure }&direction=2&head_direction=2&gesture=sml&size=m&headonly=1`;
if(!mobileUserDetailsOpen)
{
return (
<button className="nitro-mobile-furni-infostand-trigger nitro-mobile-user-infostand-trigger" type="button" onClick={ () => setMobileUserDetailsOpen(true) }>
<div className="nitro-mobile-user-infostand-avatar">
<img className="nitro-mobile-user-infostand-avatar-image" src={ avatarHeadUrl } alt={ info.name } draggable={ false } />
</div>
</button>
);
}
}
return <InfoStandWidgetUserView avatarInfo={ (avatarInfo as AvatarInfoUser) } setAvatarInfo={ setAvatarInfo } onClose={ () => setAvatarInfo(null) } />;
case AvatarInfoUser.BOT:
return <InfoStandWidgetBotView avatarInfo={ (avatarInfo as AvatarInfoUser) } onClose={ () => setAvatarInfo(null) } />;
@@ -1,6 +1,6 @@
import { CrackableDataType, CreateLinkEvent, FurnitureFloorUpdateEvent, GetRoomEngine, GetSessionDataManager, GetSoundManager, GroupInformationComposer, GroupInformationEvent, NowPlayingEvent, RoomControllerLevel, RoomObjectCategory, RoomObjectOperationType, RoomObjectVariable, RoomWidgetEnumItemExtradataParameter, RoomWidgetFurniInfoUsagePolicyEnum, SetObjectDataMessageComposer, SongInfoReceivedEvent, StringDataType, UpdateFurniturePositionComposer } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { FaCrosshairs, FaRulerVertical, FaTimes } from 'react-icons/fa';
import { FaCrosshairs, FaTimes } from 'react-icons/fa';
import { GrFormNextLink, GrRotateLeft, GrRotateRight } from 'react-icons/gr';
import { AvatarInfoFurni, GetGroupInformation, LocalizeText, SendMessageComposer } from '../../../../../api';
import { Button, Column, Flex, LayoutBadgeImageView, LayoutLimitedEditionCompactPlateView, LayoutRarityLevelView, LayoutRoomObjectImageView, Text, UserProfileIconView } from '../../../../../common';
@@ -487,17 +487,23 @@ export const InfoStandWidgetFurniView: FC<InfoStandWidgetFurniViewProps> = props
<div className="absolute inset-e-0">
<LayoutRarityLevelView level={ avatarInfo.stuffData.rarityLevel } />
</div> }
<Flex center fullWidth>
<LayoutRoomObjectImageView category={ avatarInfo.category } objectId={ avatarInfo.id } roomId={ roomSession.roomId } />
<Flex center fullWidth className="min-h-[74px] max-h-[86px] overflow-hidden">
<LayoutRoomObjectImageView
category={ avatarInfo.category }
objectId={ avatarInfo.id }
roomId={ roomSession.roomId }
style={ {
maxWidth: 120,
maxHeight: 82,
backgroundSize: 'contain',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat'
} } />
</Flex>
</Flex>
<hr className="m-0 bg-[#0003] border-0 opacity-[.5] h-px" />
</div>
}
<div className="flex flex-col gap-1">
<Text fullWidth small textBreak wrap variant="white">{ avatarInfo.description }</Text>
<hr className="m-0 bg-[#0003] border-0 opacity-[.5] h-px" />
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1">
{ showOwnerProfileIcon && <UserProfileIconView userId={ avatarInfo.ownerId } /> }
@@ -551,13 +557,9 @@ export const InfoStandWidgetFurniView: FC<InfoStandWidgetFurniViewProps> = props
{ (itemLocation.x > -1) &&
<>
<hr className="m-0 bg-[#0003] border-0 opacity-[.5] h-px" />
<div className="flex items-center gap-1">
<div className="flex items-center gap-1 min-w-0">
<FaCrosshairs className="fa-icon shrink-0" />
<Text small wrap variant="white">X: { itemLocation.x } · Y: { itemLocation.y }</Text>
</div>
<div className="flex items-center gap-1">
<FaRulerVertical className="fa-icon shrink-0" />
<Text small wrap variant="white">{ LocalizeText('stack.magic.tile.height.label') }: { itemLocation.z < 0.01 ? 0 : itemLocation.z }</Text>
<Text small textBreak variant="white">X: { itemLocation.x } · Y: { itemLocation.y } · H: { itemLocation.z < 0.01 ? 0 : itemLocation.z }</Text>
</div>
</> }
{ godMode &&
@@ -208,7 +208,7 @@ export const AvatarInfoWidgetAvatarView: FC<AvatarInfoWidgetAvatarViewProps> = p
}, [ avatarInfo ]);
return (
<ContextMenuView category={ RoomObjectCategory.UNIT } collapsable={ true } objectId={ avatarInfo.roomIndex } userType={ avatarInfo.userType } onClose={ onClose }>
<ContextMenuView category={ RoomObjectCategory.UNIT } classNames={ [ 'nitro-avatar-action-menu' ] } collapsable={ true } objectId={ avatarInfo.roomIndex } userType={ avatarInfo.userType } onClose={ onClose }>
<ContextMenuHeaderView className="cursor-pointer" onClick={ event => GetUserProfile(avatarInfo.webID) } dangerouslySetInnerHTML={ { __html: `${ avatarInfo.name }` } }></ContextMenuHeaderView>
{ (mode === MODE_NORMAL) &&
<>
@@ -58,6 +58,9 @@ export const AvatarInfoWidgetOwnAvatarView: FC<AvatarInfoWidgetOwnAvatarViewProp
case 'avatar_effect':
CreateLinkEvent('avatar-effects/show');
break;
case 'customize_nick':
CreateLinkEvent('customize/show');
break;
case 'expressions':
hideMenu = false;
setMode(MODE_EXPRESSIONS);
@@ -122,7 +125,7 @@ export const AvatarInfoWidgetOwnAvatarView: FC<AvatarInfoWidgetOwnAvatarViewProp
const isRidingHorse = IsRidingHorse();
return (
<ContextMenuView category={ RoomObjectCategory.UNIT } collapsable={ true } objectId={ avatarInfo.roomIndex } userType={ avatarInfo.userType } onClose={ onClose }>
<ContextMenuView category={ RoomObjectCategory.UNIT } classNames={ [ 'nitro-avatar-action-menu' ] } collapsable={ true } objectId={ avatarInfo.roomIndex } userType={ avatarInfo.userType } onClose={ onClose }>
<ContextMenuHeaderView className="cursor-pointer" onClick={ event => GetUserProfile(avatarInfo.webID) }>
{ avatarInfo.name }
@@ -143,6 +146,9 @@ export const AvatarInfoWidgetOwnAvatarView: FC<AvatarInfoWidgetOwnAvatarViewProp
<ContextMenuListItemView onClick={ event => processAction('avatar_effect') }>
{ LocalizeText('product.type.effect') }
</ContextMenuListItemView>
<ContextMenuListItemView onClick={ event => processAction('customize_nick') }>
Nick Custom
</ContextMenuListItemView>
{ (HasHabboClub() && !isRidingHorse) &&
<ContextMenuListItemView onClick={ event => processAction('dance_menu') }>
<FaChevronRight className="right fa-icon" />
@@ -12,7 +12,7 @@ export const ContextMenuCaretView: FC<CaretViewProps> = props =>
const getClassNames = useMemo(() =>
{
const newClassNames: string[] = [ 'menu-footer' ];
const newClassNames: string[] = [ 'menu-footer nitro-context-menu-footer' ];
if(classNames.length) newClassNames.push(...classNames);
@@ -7,7 +7,7 @@ export const ContextMenuHeaderView: FC<FlexProps> = props =>
const getClassNames = useMemo(() =>
{
const newClassNames: string[] = [ 'bg-[#3d5f6e] text-[#fff] min-w-[117px] h-[25px] max-h-[25px] text-[16px] mb-[2px]', 'p-1' ];
const newClassNames: string[] = [ 'nitro-context-menu-header', 'bg-[#3d5f6e] text-[#fff] min-w-[117px] h-[25px] max-h-[25px] text-[16px] mb-[2px]', 'p-1' ];
if(classNames.length) newClassNames.push(...classNames);
@@ -19,7 +19,7 @@ export const ContextMenuListItemView: FC<ContextMenuListItemViewProps> = props =
const getClassNames = useMemo(() =>
{
const newClassNames: string[] = [ 'relative mb-[2px] p-[3px] overflow-hidden', 'h-[24px] max-h-[24px] p-[3px] bg-[repeating-linear-gradient(#131e25,#131e25_50%,#0d171d_50%,#0d171d_100%)] cursor-pointer' ];
const newClassNames: string[] = [ 'nitro-context-menu-item', 'relative mb-[2px] p-[3px] overflow-hidden', 'h-[24px] max-h-[24px] p-[3px] bg-[repeating-linear-gradient(#131e25,#131e25_50%,#0d171d_50%,#0d171d_100%)] cursor-pointer' ];
if(disabled) newClassNames.push('disabled');
@@ -75,6 +75,7 @@ export const ContextMenuView: FC<ContextMenuViewProps> = ({
const getClassNames = useMemo(() => {
const classes = [
'nitro-context-menu',
'p-[2px]!',
'bg-[#1c323f]',
'border-2',
+31 -11
View File
@@ -25,6 +25,7 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
const { isInRoom } = props;
const [ isMeExpanded, setMeExpanded ] = useState(false);
const [ isToolbarOpen, setIsToolbarOpen ] = useState(false);
const [ isTouchLayout, setIsTouchLayout ] = useState(false);
const [ useGuideTool, setUseGuideTool ] = useState(false);
const [ youtubeEnabled, setYoutubeEnabled ] = useState(false);
const { userFigure = null } = useSessionInfo();
@@ -36,6 +37,14 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
const isMod = GetSessionDataManager().isModerator;
const hasDesktopUnifiedShell = (isInRoom && isToolbarOpen);
const showDesktopShell = (isToolbarOpen || !isInRoom);
const desktopToolbarFrameClasses = isTouchLayout ? '' : 'md:left-1/2 md:right-auto md:h-[52px] md:w-[420px] md:-translate-x-1/2 md:items-center md:px-[6px] md:py-[4px] lg:w-[460px]';
const desktopToolbarOpenClasses = isTouchLayout ? '' : 'md:rounded-none md:border-0 md:bg-transparent md:shadow-none';
const desktopToggleClasses = isTouchLayout ? '' : 'md:mb-0';
const desktopToggleIconClasses = isTouchLayout ? '' : (isToolbarOpen ? 'md:-rotate-90' : 'md:rotate-90');
const desktopChatInputClasses = isTouchLayout ? '' : 'md:px-0';
const mobileOnlyClasses = isTouchLayout ? '' : 'md:hidden';
const desktopBlockClasses = isTouchLayout ? 'hidden' : 'hidden md:block';
const desktopFlexClasses = isTouchLayout ? 'hidden' : 'hidden md:flex';
useMessageEvent<YouTubeRoomSettingsEvent>(YouTubeRoomSettingsEvent, event =>
{
@@ -53,6 +62,17 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
}
}, [ isInRoom ]);
useEffect(() =>
{
const query = window.matchMedia('(pointer: coarse), (hover: none)');
const updateTouchLayout = () => setIsTouchLayout(query.matches);
updateTouchLayout();
query.addEventListener('change', updateTouchLayout);
return () => query.removeEventListener('change', updateTouchLayout);
}, []);
const openYouTubePlayer = () => window.dispatchEvent(new CustomEvent('youtube:toggle'));
useMessageEvent<PerkAllowancesMessageEvent>(PerkAllowancesMessageEvent, event =>
@@ -103,13 +123,13 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
{ youtubeEnabled && <YouTubePlayerView /> }
{ isInRoom &&
<div className={ `fixed bottom-0 left-0 right-0 z-40 flex h-[52px] items-end px-0 pt-[2px] pb-0 pointer-events-none md:left-1/2 md:right-auto md:h-[52px] md:w-[420px] md:-translate-x-1/2 md:items-center md:px-[6px] md:py-[4px] lg:w-[460px] ${ isToolbarOpen ? (hasDesktopUnifiedShell ? 'md:rounded-none md:border-0 md:bg-transparent md:shadow-none rounded-t-[12px] border border-b-0 border-white/8 bg-[rgba(10,10,12,0.58)] shadow-[0_-6px_18px_rgba(0,0,0,0.18)]' : 'rounded-t-[12px] border border-b-0 border-white/8 bg-[rgba(10,10,12,0.58)] shadow-[0_-6px_18px_rgba(0,0,0,0.18)]') : 'border-0 bg-transparent shadow-none md:border-0 md:bg-transparent md:shadow-none' }` }>
<div className={ `fixed bottom-0 left-0 right-0 z-40 flex h-[52px] items-end px-0 pt-[2px] pb-0 pointer-events-none ${ desktopToolbarFrameClasses } ${ isToolbarOpen ? (hasDesktopUnifiedShell ? `${ desktopToolbarOpenClasses } rounded-t-[12px] border border-b-0 border-white/8 bg-[rgba(10,10,12,0.58)] shadow-[0_-6px_18px_rgba(0,0,0,0.18)]` : 'rounded-t-[12px] border border-b-0 border-white/8 bg-[rgba(10,10,12,0.58)] shadow-[0_-6px_18px_rgba(0,0,0,0.18)]') : `border-0 bg-transparent shadow-none ${ desktopToolbarOpenClasses }` }` }>
<motion.div
className="tb-toggle pointer-events-auto mr-2 mb-[4px] flex-shrink-0 md:mb-0"
className={ `tb-toggle pointer-events-auto mr-2 mb-[4px] flex-shrink-0 ${ desktopToggleClasses }` }
onClick={ () => setIsToolbarOpen(value => !value) }
whileTap={ { scale: 0.9 } }>
<svg
className={ `h-3.5 w-3.5 text-white/70 transition-transform duration-300 ${ isToolbarOpen ? 'rotate-180 md:-rotate-90' : 'rotate-0 md:rotate-90' }` }
className={ `h-3.5 w-3.5 text-white/70 transition-transform duration-300 ${ isToolbarOpen ? 'rotate-180' : 'rotate-0' } ${ desktopToggleIconClasses }` }
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
@@ -119,9 +139,9 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
<Flex
alignItems="center"
justifyContent="center"
className="pointer-events-auto h-full w-full min-w-0 flex-1 px-[6px] md:px-0"
className={ `pointer-events-auto h-full w-full min-w-0 flex-1 px-[6px] ${ desktopChatInputClasses }` }
id="toolbar-chat-input-container" />
<div className="pointer-events-auto relative mr-[6px] shrink-0 md:hidden">
<div className={ `pointer-events-auto relative mr-[6px] shrink-0 ${ mobileOnlyClasses }` }>
<ToolbarItemView icon="friendall" onClick={ () => CreateLinkEvent('friends/toggle') } className="tb-icon" />
{ (requests.length > 0) &&
<LayoutItemCountView count={ requests.length } className="absolute -right-1 top-0" /> }
@@ -138,7 +158,7 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
animate={ { opacity: 1, y: 0 } }
exit={ { opacity: 0, y: 8 } }
transition={ { type: 'spring', stiffness: 260, damping: 26 } }
className="pointer-events-none fixed bottom-0 left-0 right-0 z-[39] hidden h-[52px] rounded-t-[12px] border border-b-0 border-white/8 bg-[rgba(10,10,12,0.58)] shadow-[0_-6px_18px_rgba(0,0,0,0.18)] md:block" /> }
className={ `pointer-events-none fixed bottom-0 left-0 right-0 z-[39] h-[52px] rounded-t-[12px] border border-b-0 border-white/8 bg-[rgba(10,10,12,0.58)] shadow-[0_-6px_18px_rgba(0,0,0,0.18)] ${ desktopBlockClasses }` } /> }
<motion.div
key="left-nav"
@@ -146,7 +166,7 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
animate={ { opacity: 1, x: 0, y: 0 } }
exit={ { opacity: 0, x: isInRoom ? -10 : 0, y: isInRoom ? 0 : 8 } }
transition={ { type: 'spring', stiffness: 300, damping: 28 } }
className="fixed bottom-0 left-0 z-40 hidden h-[52px] max-w-[calc(50vw-242px)] items-center overflow-visible pl-3 pointer-events-auto md:flex">
className={ `fixed bottom-0 left-0 z-40 h-[52px] max-w-[calc(50vw-242px)] items-center overflow-visible pl-3 pointer-events-auto ${ desktopFlexClasses }` }>
<motion.div variants={ containerVariants } initial="hidden" animate="visible" exit="exit" className={ `tb-open-shell flex h-[52px] max-w-full items-center gap-2 overflow-visible px-[8px] pt-[10px] pb-[2px] ${ showDesktopShell ? 'bg-transparent' : 'rounded-t-[10px] border border-b-0 border-white/8 bg-[rgba(10,10,12,0.58)] shadow-[0_-6px_18px_rgba(0,0,0,0.18)]' }` }>
<motion.div variants={ itemVariants }>
{ isInRoom
@@ -218,7 +238,7 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
animate={ { opacity: 1, x: 0 } }
exit={ { opacity: 0, x: 10 } }
transition={ { type: 'spring', stiffness: 300, damping: 28 } }
className={ `fixed bottom-0 z-40 hidden h-[52px] max-w-[calc(50vw-242px)] items-center overflow-visible pr-3 pointer-events-auto md:flex ${ isInRoom ? 'right-0' : 'right-3' }` }>
className={ `fixed bottom-0 z-40 h-[52px] max-w-[calc(50vw-242px)] items-center overflow-visible pr-3 pointer-events-auto ${ desktopFlexClasses } ${ isInRoom ? 'right-0' : 'right-3' }` }>
<motion.div variants={ containerVariants } initial="hidden" animate="visible" exit="exit" className="tb-open-shell flex h-[52px] max-w-full items-center gap-3 overflow-visible bg-transparent px-[8px] pt-[10px] pb-[2px]">
<motion.div variants={ itemVariants } className="relative">
<ToolbarItemView icon="friendall" onClick={ () => CreateLinkEvent('friends/toggle') } className="tb-icon" />
@@ -229,8 +249,8 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
<motion.div variants={ itemVariants }>
<ToolbarItemView className={ `tb-icon ${ iconState === MessengerIconState.UNREAD ? 'is-unseen animate-pulse' : '' }` } icon="message" onClick={ () => OpenMessengerChat() } />
</motion.div> }
<div className="mx-1 hidden h-5 w-[1px] bg-white/20 md:block" />
<div className="hidden h-full shrink-0 md:block" id="toolbar-friend-bar-container-desktop" />
<div className={ `mx-1 h-5 w-[1px] bg-white/20 ${ desktopBlockClasses }` } />
<div className={ `h-full shrink-0 ${ desktopBlockClasses }` } id="toolbar-friend-bar-container-desktop" />
</motion.div>
</motion.div>
@@ -240,7 +260,7 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
animate={ { opacity: 1, y: 0 } }
exit={ { opacity: 0, y: 8 } }
transition={ { type: 'spring', stiffness: 300, damping: 28 } }
className={ `fixed left-1/2 z-40 flex w-[95vw] -translate-x-1/2 items-center overflow-visible pointer-events-auto md:hidden ${ isInRoom ? 'bottom-[52px] rounded-t-[12px] border border-b-0 border-white/8 bg-[rgba(10,10,12,0.58)] px-[6px] py-[4px] shadow-[0_-6px_18px_rgba(0,0,0,0.18)]' : 'bottom-0' }` }>
className={ `fixed left-1/2 z-40 flex w-[95vw] -translate-x-1/2 items-center overflow-visible pointer-events-auto ${ mobileOnlyClasses } ${ isInRoom ? 'bottom-[52px] rounded-t-[12px] border border-b-0 border-white/8 bg-[rgba(10,10,12,0.58)] px-[6px] py-[4px] shadow-[0_-6px_18px_rgba(0,0,0,0.18)]' : 'bottom-0' }` }>
<motion.div variants={ containerVariants } initial="hidden" animate="visible" exit="exit" className="tb-bar-scroll flex h-full min-w-0 flex-1 items-center gap-2 overflow-x-auto overflow-y-visible px-1">
<motion.div variants={ itemVariants }>
{ isInRoom
@@ -166,7 +166,7 @@ export const useAvailableUserSources = (trigger: Triggerable, userSources: Wired
if(!trigger) return;
const intervalId = window.setInterval(refreshStackSources, 100);
const intervalId = window.setInterval(refreshStackSources, 1000);
return () => window.clearInterval(intervalId);
}, [ refreshStackSources, trigger ]);