diff --git a/Content-Gamedata/news/news.json b/Content-Gamedata/news/news.json new file mode 100644 index 0000000..7d804a1 --- /dev/null +++ b/Content-Gamedata/news/news.json @@ -0,0 +1,20 @@ +{ + "news": [ + { + "id": 1, + "title": "Welcome to Nitro local mode", + "body": "This news card is loaded from public/configuration/news.json while yarn start is running.", + "image": "${image.library.url}web_promo_small/spromo_Canal_Bundle.png", + "link": "", + "linkText": "Read more" + }, + { + "id": 2, + "title": "Local development ready", + "body": "API and socket use localhost:2096. Remote assets and gamedata stay available so you do not need to copy the full client folder locally.", + "image": "${image.library.url}web_promo_small/spromo_hween12_vampire.png", + "link": "", + "linkText": "Ok" + } + ] +} diff --git a/public/configuration/bootstrap.js b/public/configuration/bootstrap.js index 13c1d8f..997602f 100644 --- a/public/configuration/bootstrap.js +++ b/public/configuration/bootstrap.js @@ -1,4 +1,17 @@ (() => { + const API_BASE = "http://localhost:2096"; + + const ensureMobileViewport = () => { + let viewport = document.querySelector('meta[name="viewport"]'); + if(!viewport) { + viewport = document.createElement("meta"); + viewport.name = "viewport"; + document.head.appendChild(viewport); + } + viewport.content = "width=device-width, initial-scale=1, viewport-fit=cover"; + }; + + ensureMobileViewport(); const FALLBACK_API_BASE = ""; const getBase = () => { diff --git a/public/configuration/renderer-config.example b/public/configuration/renderer-config.example index a5a0241..f8d94bf 100644 --- a/public/configuration/renderer-config.example +++ b/public/configuration/renderer-config.example @@ -58,6 +58,8 @@ "login.server_key.endpoint": "${api.url}/api/auth/server-key", "login.sso-token.endpoint": "${api.url}/api/auth/sso-token", "login.refresh.endpoint": "${api.url}/api/auth/refresh", + "login.health.method": "GET", + "login.news.url": "${asset.url}/news/news.json", "badges.custom.list.endpoint": "${api.url}/api/badges/custom", "badges.custom.create.endpoint": "${api.url}/api/badges/custom", "badges.custom.update.endpoint": "${api.url}/api/badges/custom/%badgeId%", diff --git a/scripts/write-asset-loader.mjs b/scripts/write-asset-loader.mjs index d70ec98..4b082fc 100644 --- a/scripts/write-asset-loader.mjs +++ b/scripts/write-asset-loader.mjs @@ -215,6 +215,11 @@ const ASSET_LOADER_JS = `(() => { return new URL(".", source); }; + const getDeployBase = () => { + try { return new URL("..", getBase()); } + catch { return new URL("/", location.href); } + }; + const withCacheBust = (url) => { url.searchParams.set("v", Date.now().toString(36)); return url; @@ -242,9 +247,14 @@ const ASSET_LOADER_JS = `(() => { const resolveAssetCandidates = (path) => { const base = getBase(); + const deploy = getDeployBase(); const normalized = path.replace(/^\\.\\//, ""); const file = normalized.split("/").pop(); + const relative = normalized.replace(/^\\//, ""); const urls = [ + new URL("src/assets/" + file, deploy), + new URL("assets/" + file, deploy), + new URL(relative, deploy), new URL("./src/assets/" + file, base), new URL("./assets/" + file, base), new URL("/src/assets/" + file, base.origin), @@ -376,7 +386,10 @@ const ASSET_LOADER_JS = `(() => { const fetchManifest = async () => { const base = getBase(); + const deploy = getDeployBase(); const candidates = [ + new URL(".vite/manifest.json", deploy), + new URL("manifest.json", deploy), new URL(".vite/manifest.json", base.origin + "/"), new URL("manifest.json", base.origin + "/"), new URL(".vite/manifest.json", base), @@ -392,7 +405,11 @@ const ASSET_LOADER_JS = `(() => { const json = await response.json(); if(json && typeof json === "object") { debug("loader: manifest from " + candidate.href); - return { manifest: json, base: new URL(".", candidate.href) }; + let manifestBase = new URL(".", candidate.href); + if(/\\/\\.vite\\/manifest\\.json$/.test(candidate.pathname)) { + manifestBase = new URL("..", manifestBase); + } + return { manifest: json, base: manifestBase }; } } catch {} } @@ -418,18 +435,24 @@ const ASSET_LOADER_JS = `(() => { const resolveManifestPath = (manifestBase, file) => { if(/^https?:\\/\\//i.test(file)) return file; if(file.startsWith("/")) return file; - return new URL(file, manifestBase.origin + "/").pathname; + return new URL(file, manifestBase).pathname; }; const isLoaderUrl = (href) => /(?:^|\\/)bootstrap\\.js(?:$|\\?|#)/i.test(href) || /(?:^|\\/)asset-loader\\.js(?:$|\\?|#)/i.test(href); const fetchEntryFromIndexHtml = async () => { const base = getBase(); + const deploy = getDeployBase(); const candidates = [ + new URL("index.html", deploy), + new URL("./", deploy), new URL("/index.html", base.origin + "/"), new URL("/", base.origin + "/") ]; + const seen = new Set(); for(const candidate of candidates) { + if(seen.has(candidate.href)) continue; + seen.add(candidate.href); try { const response = await fetch(withCacheBust(new URL(candidate.href)), { cache: "no-store" }); if(!response.ok) continue; diff --git a/src/App.tsx b/src/App.tsx index 7722748..20b1f54 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,23 @@ import { useMessageEvent, useNitroEvent } from './hooks'; NitroVersion.UI_VERSION = GetUIVersion(); +const getViewportDimensions = () => +{ + const viewport = window.visualViewport; + const width = Math.max(1, Math.floor(viewport?.width ?? window.innerWidth)); + const height = Math.max(1, Math.floor(viewport?.height ?? window.innerHeight)); + + return { width, height }; +}; + +const syncViewportCssVars = () => +{ + const { width, height } = getViewportDimensions(); + + document.documentElement.style.setProperty('--nitro-app-width', `${ width }px`); + document.documentElement.style.setProperty('--nitro-app-height', `${ height }px`); +}; + const preloadUrl = async (url: string): Promise => { if(!url) return; @@ -268,6 +285,25 @@ export const App: FC<{}> = props => return warmupPromiseRef.current; }, [ startRenderer ]); + useEffect(() => + { + syncViewportCssVars(); + + const handleViewportResize = () => syncViewportCssVars(); + const viewport = window.visualViewport; + + window.addEventListener('resize', handleViewportResize); + viewport?.addEventListener('resize', handleViewportResize); + viewport?.addEventListener('scroll', handleViewportResize); + + return () => + { + window.removeEventListener('resize', handleViewportResize); + viewport?.removeEventListener('resize', handleViewportResize); + viewport?.removeEventListener('scroll', handleViewportResize); + }; + }, []); + useEffect(() => { const prepare = async (width: number, height: number) => @@ -370,7 +406,9 @@ export const App: FC<{}> = props => } }; - prepare(window.innerWidth, window.innerHeight); + const { width, height } = getViewportDimensions(); + + prepare(width, height); return () => { @@ -380,7 +418,7 @@ export const App: FC<{}> = props => }, [ prepareTrigger, startWarmup, startRenderer, tryRememberLogin, applySsoTicket, rotateRememberLogin ]); return ( - + { !isReady && !showLogin && 0 } message={ errorMessage } homeUrl={ homeUrl } /> } { !isReady && showLogin && } diff --git a/src/api/room/widgets/AvatarInfoFurni.ts b/src/api/room/widgets/AvatarInfoFurni.ts index 133139e..44e55d1 100644 --- a/src/api/room/widgets/AvatarInfoFurni.ts +++ b/src/api/room/widgets/AvatarInfoFurni.ts @@ -36,6 +36,8 @@ export class AvatarInfoFurni implements IAvatarInfo public allowLay: boolean = false; public allowWalk: boolean = false; public teleportTargetId: number = 0; + public spriteId: number = -1; + public productType: string = 's'; constructor(public readonly type: string) {} diff --git a/src/api/room/widgets/AvatarInfoUtilities.ts b/src/api/room/widgets/AvatarInfoUtilities.ts index a2d0b45..ffd8b8a 100644 --- a/src/api/room/widgets/AvatarInfoUtilities.ts +++ b/src/api/room/widgets/AvatarInfoUtilities.ts @@ -118,6 +118,8 @@ export class AvatarInfoUtilities { furniInfo.name = furnitureData.name; furniInfo.description = furnitureData.description; + furniInfo.spriteId = furnitureData.id; + furniInfo.productType = ((category === RoomObjectCategory.WALL) ? 'i' : 's'); furniInfo.purchaseOfferId = furnitureData.purchaseOfferId; furniInfo.purchaseCouldBeUsedForBuyout = furnitureData.purchaseCouldBeUsedForBuyout; furniInfo.rentOfferId = furnitureData.rentOfferId; diff --git a/src/api/utils/RoomChatFormatter.ts b/src/api/utils/RoomChatFormatter.ts index 949de56..4a35a6e 100644 --- a/src/api/utils/RoomChatFormatter.ts +++ b/src/api/utils/RoomChatFormatter.ts @@ -80,11 +80,97 @@ const applyWiredTextMarkup = (content: string) => return result; }; +const FONT_NAMED_COLORS = new Set([ + 'red', 'green', 'blue', 'yellow', 'white', 'black', + 'orange', 'cyan', 'brown', 'purple', 'pink', 'magenta', + 'violet', 'gray', 'grey', 'lime', 'teal', 'gold', + 'silver', 'navy', 'maroon', 'olive', 'indigo' +]); + +export const sanitizeFontColor = (raw: string | null | undefined): string | null => +{ + if(!raw) return null; + if(raw.length > 20) return null; + + const value = raw.trim().toLowerCase(); + + if(/^#([0-9a-f]{3}|[0-9a-f]{6})$/.test(value)) return value; + if(FONT_NAMED_COLORS.has(value)) return value; + + return null; +}; + +export type FontSegment = { color: string | null; text: string }; + +const FONT_COLOR_ATTR = /color\s*=\s*(?:"([^"]{1,32})"|'([^']{1,32})'|([^\s"'>]{1,32}))/i; + +export const parseFontSegments = (input: string): FontSegment[] => +{ + if(!input) return []; + + const pattern = /]{0,200}?)>([\s\S]{0,200}?)<\/font>/gi; + const segments: FontSegment[] = []; + + let lastIndex = 0; + let match: RegExpExecArray | null; + + while((match = pattern.exec(input)) !== null) + { + if(match.index > lastIndex) + { + segments.push({ color: null, text: input.slice(lastIndex, match.index) }); + } + + const colorMatch = FONT_COLOR_ATTR.exec(match[1] || ''); + const rawColor = colorMatch ? (colorMatch[1] || colorMatch[2] || colorMatch[3]) : null; + const color = sanitizeFontColor(rawColor); + + segments.push({ color, text: match[2] }); + lastIndex = pattern.lastIndex; + } + + if(lastIndex < input.length) + { + segments.push({ color: null, text: input.slice(lastIndex) }); + } + + return segments; +}; + +const applyFontMarkup = (content: string) => +{ + const fontPattern = /<font\b([^&]{0,200}?)>([\s\S]{0,4000}?)<\/font>/gi; + const colorAttr = /color\s*=\s*(?:"([^"]{1,32})"|'([^']{1,32})'|([^\s"'>]{1,32}))/i; + + let previous = ''; + let next = content; + let guard = 0; + + while((previous !== next) && (guard < 20)) + { + previous = next; + next = next.replace(fontPattern, (_match, attrs: string, inner: string) => + { + const colorMatch = colorAttr.exec(attrs || ''); + const rawColor = colorMatch ? (colorMatch[1] || colorMatch[2] || colorMatch[3]) : null; + const color = sanitizeFontColor(rawColor); + + if(!color) return inner; + + return `${ inner }`; + }); + guard++; + } + + return next; +}; + export const RoomChatFormatter = (content: string) => { let result = ''; content = encodeHTML(content); + content = applyFontMarkup(content); content = applyWiredTextMarkup(content); //content = (joypixels.shortnameToUnicode(content) as string) diff --git a/src/bootstrap.ts b/src/bootstrap.ts index 5bedd6b..ecff7b4 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -1,5 +1,20 @@ import { configFileUrl, getClientMode, installSecureFetch } from './secure-assets'; +const ensureMobileViewport = () => +{ + let viewport = document.querySelector('meta[name="viewport"]'); + + if(!viewport) + { + viewport = document.createElement('meta'); + viewport.name = 'viewport'; + document.head.appendChild(viewport); + } + + viewport.content = 'width=device-width, initial-scale=1, viewport-fit=cover'; +}; + +ensureMobileViewport(); installSecureFetch(); const setBootDebug = (message: string) => diff --git a/src/common/UserIdentityView.tsx b/src/common/UserIdentityView.tsx index fac5e9a..019eb21 100644 --- a/src/common/UserIdentityView.tsx +++ b/src/common/UserIdentityView.tsx @@ -1,6 +1,23 @@ -import { FC, useMemo } from 'react'; +import { FC, Fragment, ReactNode, useMemo } from 'react'; import { GetNickIconUrl } from '../assets/images/user_custom/nick_icons'; -import { PREFIX_EFFECT_KEYFRAMES, getPrefixEffectStyle, getPrefixFontStyle, parsePrefixColors } from '../api'; +import { PREFIX_EFFECT_KEYFRAMES, getPrefixEffectStyle, getPrefixFontStyle, parseFontSegments, parsePrefixColors } from '../api'; + +const renderInlineFontMarkup = (text: string): ReactNode => +{ + if(!text) return text; + if(text.indexOf(' + { + if(segment.color) return { segment.text }; + + return { segment.text }; + }); +}; interface UserIdentityViewProps { @@ -87,7 +104,7 @@ export const UserIdentityView: FC = ({ ); case 'name': - return { username }{ showColon ? ':' : '' }{ showColon ? ' ' : '' }; + return { renderInlineFontMarkup(username) }{ showColon ? ':' : '' }{ showColon ? ' ' : '' }; default: return null; } diff --git a/src/components/login/LoginView.tsx b/src/components/login/LoginView.tsx index cf88503..adb8dbb 100644 --- a/src/components/login/LoginView.tsx +++ b/src/components/login/LoginView.tsx @@ -1,6 +1,6 @@ -import { GetConfiguration } from '@nitrots/nitro-renderer'; +import { AvatarScaleType, AvatarSetType, GetAvatarRenderManager, GetConfiguration, IAvatarImage } from '@nitrots/nitro-renderer'; import { FC, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { ClearRememberLogin, GetConfigurationValue, GetOptionalConfigurationValue, GetRememberLogin, StoreRememberLoginFromPayload } from '../../api'; +import { ClearRememberLogin, GetConfigurationValue, GetRememberLogin, StoreRememberLoginFromPayload } from '../../api'; import { configFileUrl } from '../../secure-assets'; import flagBr from '../../assets/images/flag_icon/flag_icon_br.png'; import flagDe from '../../assets/images/flag_icon/flag_icon_de.png'; @@ -195,25 +195,19 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa const [ localeApplying, setLocaleApplying ] = useState(false); const [ localeError, setLocaleError ] = useState(''); const [ loginViewConfig, setLoginViewConfig ] = useState>(() => GetConfigurationValue>('loginview', {})); - const [ , setLocalizationVersion ] = useState(0); const submitTimeRef = useRef(0); + const preloadedLoginImagesRef = useRef>(new Set()); - useEffect(() => - { - const refreshLocalization = () => setLocalizationVersion(value => (value + 1)); - window.addEventListener('nitro-localization-updated', refreshLocalization); - return () => window.removeEventListener('nitro-localization-updated', refreshLocalization); - }, []); - + const configuredLoginImages = useMemo>(() => + (loginViewConfig?.['images'] as Record) ?? {}, [ loginViewConfig ]); const loginImages = useMemo>(() => - { - const configured = (loginViewConfig?.['images'] as Record) ?? {}; - return { ...getDefaultLoginImages(), ...configured }; - }, [ loginViewConfig ]); + ({ ...getDefaultLoginImages(), ...configuredLoginImages }), [ configuredLoginImages ]); + const configuredLoginWidgets = useMemo>(() => + (loginViewConfig?.['widgets'] as Record) ?? {}, [ loginViewConfig ]); + const loginWidgetSlots = useMemo(() => { - const configuredLoginWidgets = (loginViewConfig?.['widgets'] as Record) ?? {}; return Object.entries(configuredLoginWidgets) .filter(([ key, value ]) => key.startsWith('slot.') && key.endsWith('.widget') && typeof value === 'string' && value.length > 0) .map(([ key, value ]) => @@ -225,7 +219,7 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa }) .filter(slot => slot.slotNum > 0) .sort((a, b) => a.slotNum - b.slotNum); - }, [ loginViewConfig ]); + }, [ configuredLoginWidgets ]); const backgroundColor = (loginImages['background.colour'] || GetConfigurationValue('login_background.colour', '#6eadc8')); const background = interpolate(loginImages['background'] || GetConfigurationValue('login_background', '')); @@ -234,11 +228,15 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa const left = interpolate(loginImages['left'] || GetConfigurationValue('login_left', '')); const rightRepeat = interpolate(loginImages['right.repeat'] || GetConfigurationValue('login_right.repeat', '')); const right = interpolate(loginImages['right'] || GetConfigurationValue('login_right', '')); + const widgetImageUrls = useMemo(() => loginWidgetSlots + .map(slot => typeof slot.conf.image === 'string' ? interpolate(slot.conf.image) : '') + .filter(Boolean), [ loginWidgetSlots ]); + const loginImageUrls = useMemo(() => [ background, sun, drape, left, rightRepeat, right, ...widgetImageUrls ].filter(Boolean), [ background, sun, drape, left, rightRepeat, right, widgetImageUrls ]); + const [ loginImagesVersion, setLoginImagesVersion ] = useState(0); const loginUrl = GetConfigurationValue('login.endpoint', '/api/auth/login'); const registerUrl = GetConfigurationValue('login.register.endpoint', '/api/auth/register'); const forgotUrl = GetConfigurationValue('login.forgot.endpoint', '/api/auth/forgot-password'); - const configuredNewsUrl = interpolate(GetOptionalConfigurationValue('login.news.url', '')); - const newsUrl = configuredNewsUrl || configFileUrl('news.json'); + const newsUrl = interpolate(GetConfigurationValue('login.news.url', '')); const turnstileSiteKey = GetConfigurationValue('login.turnstile.sitekey', ''); const rawTurnstileEnabled = GetConfigurationValue('login.turnstile.enabled', false); const turnstileEnabled = (rawTurnstileEnabled === true @@ -314,6 +312,44 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa } }, [ localeApplying, selectedLocale ]); + useEffect(() => + { + if(!loginImageUrls.length) return; + + let cancelled = false; + let remaining = 0; + + loginImageUrls + .filter(url => + { + if(preloadedLoginImagesRef.current.has(url)) return false; + + 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 () => + { + cancelled = true; + }; + }, [ loginImageUrls ]); + useEffect(() => { if(!info) return; @@ -367,7 +403,7 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa }, []); const healthUrl = GetConfigurationValue('login.health.endpoint', ''); - const healthMethodRaw = GetOptionalConfigurationValue('login.health.method', 'GET'); + const healthMethodRaw = GetConfigurationValue('login.health.method', 'GET'); const healthMethod = (healthMethodRaw || 'GET').toUpperCase(); const checkServerReachable = useCallback(async (): Promise => { @@ -481,7 +517,6 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa const checkEmailUrl = GetConfigurationValue('login.check-email.endpoint', '/api/auth/check-email'); const checkUsernameUrl = GetConfigurationValue('login.check-username.endpoint', '/api/auth/check-username'); - const imagingUrl = GetOptionalConfigurationValue('login.register.imaging.url', ''); const interpretAvailability = (ok: boolean, status: number, payload: Record): { available: boolean; error?: string } => { const isTrue = (v: unknown) => v === true || v === 'true' || v === 1 || v === '1'; @@ -629,6 +664,9 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa { left ? : null } { rightRepeat ?
: null } { right ? : null } + { loginWidgetSlots.length > 0 &&
@@ -769,7 +807,6 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa onCheckEmail={ checkEmailAvailable } onCheckUsername={ checkUsernameAvailable } onCheckServer={ checkServerReachable } - imagingUrl={ imagingUrl } submitting={ submitting } error={ error } info={ info } @@ -807,7 +844,6 @@ interface RegisterDialogProps extends DialogSharedProps onCheckEmail: (email: string) => Promise<{ available: boolean; error?: string }>; onCheckUsername: (username: string) => Promise<{ available: boolean; error?: string }>; onCheckServer: () => Promise; - imagingUrl: string; } type RegisterStep = 'credentials' | 'avatar'; @@ -868,60 +904,160 @@ const buildFigureString = (selection: FigureSelection): string => return parts.join('.'); }; -const buildImagingUrl = (template: string, figure: string, gender: GenderKey): string => - template - .replace(/\{figure\}/g, encodeURIComponent(figure)) - .replace(/\{gender\}/g, gender) - .replace(/\{direction\}/g, '2'); - const HEAD_ONLY_PARTS = new Set([ 'hr', 'hd' ]); -const buildPartPreviewUrl = ( - template: string, - setType: string, - selection: FigureSelection, - gender: GenderKey -): string => +const buildPartPreviewFigure = (setType: string, selection: FigureSelection, gender: GenderKey): string => { const defaults = FALLBACK_DEFAULTS[gender]; const partSel = selection[setType] ?? defaults[setType]; const tail = (partSel.colors && partSel.colors.length) ? `-${ partSel.colors.join('-') }` : ''; - const isHeadOnly = HEAD_ONLY_PARTS.has(setType); + const hd = defaults.hd; + const head = `hd-${ hd.partId }-${ hd.colors.join('-') }`; + const part = `${ setType }-${ partSel.partId }${ tail }`; - let parts: string[]; - if(isHeadOnly) + return setType === 'hd' ? part : `${ head }.${ part }`; +}; + +const AVATAR_PREVIEW_CACHE = new Map(); +const AVATAR_PREVIEW_CACHE_MAX = 200; + +const AVATAR_PREVIEW_MAX_ATTEMPTS = 4; +const AVATAR_PREVIEW_TIMEOUT_MS = 8000; + +const renderAvatarPreview = (figure: string, gender: GenderKey, setType: string): Promise => +{ + if(!figure) return Promise.resolve(''); + + const cacheKey = `${ gender }|${ setType }|${ figure }`; + const cached = AVATAR_PREVIEW_CACHE.get(cacheKey); + if(cached) return Promise.resolve(cached); + + return new Promise(resolve => { - const hd = defaults.hd; - const pieces = new Map(); - pieces.set('hd', `hd-${ hd.partId }-${ hd.colors.join('-') }`); - pieces.set(setType, `${ setType }-${ partSel.partId }${ tail }`); - parts = Array.from(pieces.values()); - } - else + let avatarImage: IAvatarImage | null = null; + let resolved = false; + let attempts = 0; + let timer: number | null = null; + + const finish = (url: string) => + { + if(resolved) return; + resolved = true; + if(timer !== null) window.clearTimeout(timer); + try { avatarImage?.dispose(); } catch {} + avatarImage = null; + if(url) + { + AVATAR_PREVIEW_CACHE.set(cacheKey, url); + if(AVATAR_PREVIEW_CACHE.size > AVATAR_PREVIEW_CACHE_MAX) + { + const firstKey = AVATAR_PREVIEW_CACHE.keys().next().value; + if(firstKey) AVATAR_PREVIEW_CACHE.delete(firstKey); + } + } + resolve(url); + }; + + timer = window.setTimeout(() => finish(''), AVATAR_PREVIEW_TIMEOUT_MS); + + const attempt = () => + { + if(resolved) return; + if(attempts >= AVATAR_PREVIEW_MAX_ATTEMPTS) { finish(''); return; } + attempts++; + + try { avatarImage?.dispose(); } catch {} + avatarImage = null; + + try + { + avatarImage = GetAvatarRenderManager().createAvatarImage(figure, AvatarScaleType.LARGE, gender, { + resetFigure: () => attempt(), + dispose: () => {}, + disposed: false + }); + } + catch + { + finish(''); + return; + } + + if(!avatarImage) { finish(''); return; } + + if(avatarImage.isPlaceholder()) return; + + try + { + const url = avatarImage.processAsImageUrl(setType); + if(url) finish(url); + } + catch + { + finish(''); + } + }; + + attempt(); + }); +}; + +const useAvatarPreview = (figure: string, gender: GenderKey, setType: string): string => +{ + const [ url, setUrl ] = useState(() => + AVATAR_PREVIEW_CACHE.get(`${ gender }|${ setType }|${ figure }`) ?? ''); + + useEffect(() => { - const hd = defaults.hd; - parts = [ - `hd-${ hd.partId }-${ hd.colors.join('-') }`, - `${ setType }-${ partSel.partId }${ tail }` - ]; - } + const cacheKey = `${ gender }|${ setType }|${ figure }`; + const cached = AVATAR_PREVIEW_CACHE.get(cacheKey); + if(cached) + { + setUrl(cached); + return; + } - const figure = parts.join('.'); - let url = template - .replace(/\{figure\}/g, encodeURIComponent(figure)) - .replace(/\{gender\}/g, gender) - .replace(/\{direction\}/g, '2'); - - url = url.replace(/size=l/, 'size=s').replace(/size=m/, 'size=s'); - if(!/size=/.test(url)) url += (url.includes('?') ? '&' : '?') + 'size=s'; - if(isHeadOnly && !/headonly=/.test(url)) url += '&headonly=1'; + let cancelled = false; + setUrl(''); + renderAvatarPreview(figure, gender, setType).then(result => + { + if(!cancelled) setUrl(result); + }); + return () => { cancelled = true; }; + }, [ figure, gender, setType ]); return url; }; +interface AvatarPartRowProps +{ + setType: string; + selection: FigureSelection; + gender: GenderKey; + onPrev: () => void; + onNext: () => void; +} + +const AvatarPartRow: FC = ({ setType, selection, gender, onPrev, onNext }) => +{ + const figure = useMemo(() => buildPartPreviewFigure(setType, selection, gender), [ setType, selection, gender ]); + const previewSetType = HEAD_ONLY_PARTS.has(setType) ? AvatarSetType.HEAD : AvatarSetType.FULL; + const url = useAvatarPreview(figure, gender, previewSetType); + + return ( +
+ +
+ { url && { { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; } } /> } +
+ +
+ ); +}; + const RegisterDialog: FC = props => { - const { onCancel, onSubmit, onCheckEmail, onCheckUsername, onCheckServer, imagingUrl, submitting, error, info, turnstileEnabled, turnstileSiteKey } = props; + const { onCancel, onSubmit, onCheckEmail, onCheckUsername, onCheckServer, submitting, error, info, turnstileEnabled, turnstileSiteKey } = props; const [ step, setStep ] = useState('credentials'); const [ email, setEmail ] = useState(''); @@ -1207,7 +1343,7 @@ const RegisterDialog: FC = props => }; const figure = buildFigureString(selection); - const previewSrc = buildImagingUrl(imagingUrl, figure, gender); + const previewSrc = useAvatarPreview(figure, gender, AvatarSetType.FULL); const handleAvatarSubmit = async (event: FormEvent) => { @@ -1345,24 +1481,20 @@ const RegisterDialog: FC = props =>
- { PART_ROWS.map(setType => { - const partPreviewSrc = buildPartPreviewUrl(imagingUrl, setType, selection, gender); - return ( -
- -
- { { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; } } /> -
- -
- ); - }) } + { PART_ROWS.map(setType => ( + cyclePart(setType, -1) } + onNext={ () => cyclePart(setType, 1) } + /> + )) }
- Habbo preview { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; } } /> + { previewSrc && Habbo preview { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; } } /> }
diff --git a/src/components/login/components/NewsWindow.tsx b/src/components/login/components/NewsWindow.tsx index d643731..bd31947 100644 --- a/src/components/login/components/NewsWindow.tsx +++ b/src/components/login/components/NewsWindow.tsx @@ -47,7 +47,9 @@ export const NewsWindow: FC = ({ 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); @@ -62,7 +64,11 @@ export const NewsWindow: FC = ({ newsUrl }) => setItems(rawList.map((raw, idx) => normalizeNewsItem(raw, idx + 1))); }) .catch(() => { if(!cancelled) setFailed(true); }); - return () => { cancelled = true; }; + return () => + { + cancelled = true; + controller.abort(); + }; }, [ newsUrl ]); useEffect(() => diff --git a/src/components/room/RoomView.tsx b/src/components/room/RoomView.tsx index e2a8764..5b06db7 100644 --- a/src/components/room/RoomView.tsx +++ b/src/components/room/RoomView.tsx @@ -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 ( diff --git a/src/components/room/widgets/avatar-info/AvatarInfoWidgetView.tsx b/src/components/room/widgets/avatar-info/AvatarInfoWidgetView.tsx index fd81f96..da25e4d 100644 --- a/src/components/room/widgets/avatar-info/AvatarInfoWidgetView.tsx +++ b/src/components/room/widgets/avatar-info/AvatarInfoWidgetView.tsx @@ -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(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.RSDE_DANCE, event => { if(event.roomIndex !== roomSession.ownRoomIndex) return; @@ -65,6 +79,13 @@ export const AvatarInfoWidgetView: FC<{}> = props => useUiEvent(RoomWidgetUpdateRentableBotChatEvent.UPDATE_CHAT, event => setRentableBotChatEvent(event)); + useNitroEvent(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 ( + + ); + } + } + return 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 ( + + ); + } + } + return setAvatarInfo(null) } />; case AvatarInfoUser.BOT: return setAvatarInfo(null) } />; diff --git a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetFurniView.tsx b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetFurniView.tsx index f396c28..970cf2f 100644 --- a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetFurniView.tsx +++ b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetFurniView.tsx @@ -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 = props
} - - + +
} -
- { avatarInfo.description } -
-
{ showOwnerProfileIcon && } @@ -551,13 +557,9 @@ export const InfoStandWidgetFurniView: FC = props { (itemLocation.x > -1) && <>
-
+
- X: { itemLocation.x } · Y: { itemLocation.y } -
-
- - { LocalizeText('stack.magic.tile.height.label') }: { itemLocation.z < 0.01 ? 0 : itemLocation.z } + X: { itemLocation.x } · Y: { itemLocation.y } · H: { itemLocation.z < 0.01 ? 0 : itemLocation.z }
} { godMode && diff --git a/src/components/room/widgets/avatar-info/menu/AvatarInfoWidgetAvatarView.tsx b/src/components/room/widgets/avatar-info/menu/AvatarInfoWidgetAvatarView.tsx index 626f5b4..582b9a1 100644 --- a/src/components/room/widgets/avatar-info/menu/AvatarInfoWidgetAvatarView.tsx +++ b/src/components/room/widgets/avatar-info/menu/AvatarInfoWidgetAvatarView.tsx @@ -208,7 +208,7 @@ export const AvatarInfoWidgetAvatarView: FC = p }, [ avatarInfo ]); return ( - + GetUserProfile(avatarInfo.webID) } dangerouslySetInnerHTML={ { __html: `${ avatarInfo.name }` } }> { (mode === MODE_NORMAL) && <> diff --git a/src/components/room/widgets/avatar-info/menu/AvatarInfoWidgetOwnAvatarView.tsx b/src/components/room/widgets/avatar-info/menu/AvatarInfoWidgetOwnAvatarView.tsx index 1ad76e2..b3a8ab3 100644 --- a/src/components/room/widgets/avatar-info/menu/AvatarInfoWidgetOwnAvatarView.tsx +++ b/src/components/room/widgets/avatar-info/menu/AvatarInfoWidgetOwnAvatarView.tsx @@ -58,6 +58,9 @@ export const AvatarInfoWidgetOwnAvatarView: FC + GetUserProfile(avatarInfo.webID) }> { avatarInfo.name } @@ -143,6 +146,9 @@ export const AvatarInfoWidgetOwnAvatarView: FC processAction('avatar_effect') }> { LocalizeText('product.type.effect') } + processAction('customize_nick') }> + Nick Custom + { (HasHabboClub() && !isRidingHorse) && processAction('dance_menu') }> diff --git a/src/components/room/widgets/context-menu/ContextMenuCaretView.tsx b/src/components/room/widgets/context-menu/ContextMenuCaretView.tsx index 0167f58..67b1678 100644 --- a/src/components/room/widgets/context-menu/ContextMenuCaretView.tsx +++ b/src/components/room/widgets/context-menu/ContextMenuCaretView.tsx @@ -12,7 +12,7 @@ export const ContextMenuCaretView: FC = props => const getClassNames = useMemo(() => { - const newClassNames: string[] = [ 'menu-footer' ]; + const newClassNames: string[] = [ 'menu-footer nitro-context-menu-footer' ]; if(classNames.length) newClassNames.push(...classNames); diff --git a/src/components/room/widgets/context-menu/ContextMenuHeaderView.tsx b/src/components/room/widgets/context-menu/ContextMenuHeaderView.tsx index a0513cf..7902bd5 100644 --- a/src/components/room/widgets/context-menu/ContextMenuHeaderView.tsx +++ b/src/components/room/widgets/context-menu/ContextMenuHeaderView.tsx @@ -7,7 +7,7 @@ export const ContextMenuHeaderView: FC = 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); diff --git a/src/components/room/widgets/context-menu/ContextMenuListItemView.tsx b/src/components/room/widgets/context-menu/ContextMenuListItemView.tsx index 0a1eacc..b7997cc 100644 --- a/src/components/room/widgets/context-menu/ContextMenuListItemView.tsx +++ b/src/components/room/widgets/context-menu/ContextMenuListItemView.tsx @@ -19,7 +19,7 @@ export const ContextMenuListItemView: FC = 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'); diff --git a/src/components/room/widgets/context-menu/ContextMenuView.tsx b/src/components/room/widgets/context-menu/ContextMenuView.tsx index be14f76..f7bc10b 100644 --- a/src/components/room/widgets/context-menu/ContextMenuView.tsx +++ b/src/components/room/widgets/context-menu/ContextMenuView.tsx @@ -75,6 +75,7 @@ export const ContextMenuView: FC = ({ const getClassNames = useMemo(() => { const classes = [ + 'nitro-context-menu', 'p-[2px]!', 'bg-[#1c323f]', 'border-2', diff --git a/src/components/toolbar/ToolbarView.tsx b/src/components/toolbar/ToolbarView.tsx index 4d822c6..c06e5e9 100644 --- a/src/components/toolbar/ToolbarView.tsx +++ b/src/components/toolbar/ToolbarView.tsx @@ -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, 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, event => @@ -103,13 +123,13 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => { youtubeEnabled && } { isInRoom && -
+
setIsToolbarOpen(value => !value) } whileTap={ { scale: 0.9 } }> @@ -119,9 +139,9 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => -
+
CreateLinkEvent('friends/toggle') } className="tb-icon" /> { (requests.length > 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 }` } /> } = 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 }` }> { 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' }` }> CreateLinkEvent('friends/toggle') } className="tb-icon" /> @@ -229,8 +249,8 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => OpenMessengerChat() } /> } -
-
+
+
@@ -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' }` }> { isInRoom diff --git a/src/components/wired/views/WiredSourcesSelector.tsx b/src/components/wired/views/WiredSourcesSelector.tsx index 74cefcf..b37adac 100644 --- a/src/components/wired/views/WiredSourcesSelector.tsx +++ b/src/components/wired/views/WiredSourcesSelector.tsx @@ -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 ]); diff --git a/src/css/index.css b/src/css/index.css index 8875e5d..5896b1d 100644 --- a/src/css/index.css +++ b/src/css/index.css @@ -45,18 +45,70 @@ body { padding: 0; width: 100%; height: 100%; + min-height: 100%; overflow: hidden; background-color: #000; -webkit-user-select: none; user-select: none; + overscroll-behavior: none; scrollbar-width: thin; scrollbar-color: #6d7b84 #c8d0d4; } +#root { + width: var(--nitro-app-width, 100vw); + height: var(--nitro-app-height, 100vh); + min-height: var(--nitro-app-height, 100vh); + overflow: hidden; +} + +.nitro-app-root { + position: fixed; + inset: 0; + width: var(--nitro-app-width, 100vw) !important; + height: var(--nitro-app-height, 100vh) !important; + min-height: var(--nitro-app-height, 100vh) !important; + overflow: hidden !important; + overscroll-behavior: none; +} + +@supports (height: 100dvh) { + html, + body, + #root, + .nitro-app-root { + height: var(--nitro-app-height, 100dvh) !important; + min-height: var(--nitro-app-height, 100dvh) !important; + } +} + .image-rendering-pixelated { image-rendering: pixelated; } +.nitro-room-touch-feedback { + position: fixed; + z-index: 35; + width: 34px; + height: 18px; + margin-left: -17px; + margin-top: -9px; + border-radius: 50%; + pointer-events: none; + border: 2px solid rgba(255, 255, 255, 0.9); + background: rgba(255, 255, 255, 0.18); + transform: scale(0.35); + opacity: 0.9; + animation: nitroRoomTouchFeedback 0.42s ease-out forwards; +} + +@keyframes nitroRoomTouchFeedback { + to { + transform: scale(1.15); + opacity: 0; + } +} + *, *:focus, *:hover { diff --git a/src/css/login/LoginView.css b/src/css/login/LoginView.css index b251f13..d08e028 100644 --- a/src/css/login/LoginView.css +++ b/src/css/login/LoginView.css @@ -708,6 +708,22 @@ } } +@media (min-width: 600px) and (max-width: 1100px) { + .nitro-login-view .login-stack { + right: 16px; + width: auto; + max-width: min(540px, calc(100vw - 32px)); + display: grid; + grid-template-columns: 1fr 1fr; + column-gap: 12px; + row-gap: 14px; + } + + .nitro-login-view .login-stack > .nitro-login-card:nth-child(3) { + grid-column: 1 / -1; + } +} + /* ─── Login News Window (Habbo flavour) ─── */ .nitro-login-view .login-news-stack { @@ -1065,6 +1081,13 @@ } } +@media (max-width: 1100px) { + .nitro-login-view .login-news-stack { + left: 24px; + top: 45%; + } +} + @media (max-width: 900px) { .nitro-login-view .login-news-stack { display: none; diff --git a/src/css/room/RoomWidgets.css b/src/css/room/RoomWidgets.css index c588a5e..eabbfb6 100644 --- a/src/css/room/RoomWidgets.css +++ b/src/css/room/RoomWidgets.css @@ -249,3 +249,127 @@ } } } + +.nitro-mobile-furni-infostand-trigger { + pointer-events: auto; + display: flex; + align-items: center; + justify-content: center; + width: 42px; + height: 42px; + padding: 2px; + border: 1px solid rgba(0, 0, 0, 0.55); + border-radius: 8px; + background: rgba(27, 40, 52, 0.92); + box-shadow: + inset 1px 1px 0 rgba(255, 255, 255, 0.25), + 0 4px 10px rgba(0, 0, 0, 0.24); +} + +.nitro-mobile-furni-infostand-trigger .object-preview { + width: 32px; + height: 32px; + background-position: center; + background-repeat: no-repeat; + background-size: contain; + image-rendering: pixelated; +} + +.nitro-mobile-user-infostand-avatar { + position: absolute; + top: 50%; + left: 50%; + width: auto; + height: auto; + max-width: 54px; + max-height: 62px; + display: flex; + align-items: center; + justify-content: center; + transform: translate(-50%, -50%); + transform-origin: center center; +} + +.nitro-mobile-user-infostand-avatar-image { + display: block; + width: auto !important; + height: auto !important; + max-width: 54px; + max-height: 62px; + object-fit: contain; + image-rendering: pixelated; + margin: 0; + pointer-events: none; +} + +.nitro-mobile-user-infostand-trigger { + position: relative; + width: 42px; + height: 42px; + padding: 0; + overflow: hidden; +} + +.nitro-avatar-action-menu { + min-width: 132px; + padding: 4px !important; + border: 1px solid rgba(255, 255, 255, 0.08) !important; + border-radius: 12px; + background: rgba(10, 10, 12, 0.58) !important; + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.2); + overflow: hidden; +} + +.nitro-avatar-action-menu .nitro-context-menu-header { + min-width: 0; + height: 28px; + max-height: 28px; + margin-bottom: 4px; + padding: 0 10px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 9px; + background: rgba(10, 10, 12, 0.58); + color: #fff; + font-size: 14px; + font-weight: 700; + text-shadow: none; +} + +.nitro-avatar-action-menu .nitro-context-menu-item { + min-height: 26px; + max-height: none; + margin-bottom: 2px; + padding: 0 10px; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: #fff; + font-size: 13px; + line-height: 1; + text-decoration: none; + text-shadow: none; +} + +.nitro-avatar-action-menu .nitro-context-menu-item:hover { + background: rgba(18, 18, 22, 0.72); + border-color: rgba(255, 255, 255, 0.08); +} + +.nitro-avatar-action-menu .nitro-context-menu-item.disabled { + opacity: 0.55; +} + +.nitro-avatar-action-menu .nitro-context-menu-item .right.fa-icon, +.nitro-avatar-action-menu .nitro-context-menu-item .left.fa-icon { + font-size: 12px; +} + +.nitro-avatar-action-menu .nitro-context-menu-footer { + height: 20px; + margin-top: 2px; + border-radius: 8px; + background: rgba(10, 10, 12, 0.58); + color: #fff; +} diff --git a/src/hooks/rooms/useRoom.ts b/src/hooks/rooms/useRoom.ts index c72a7b1..97cbf8b 100644 --- a/src/hooks/rooms/useRoom.ts +++ b/src/hooks/rooms/useRoom.ts @@ -4,6 +4,16 @@ import { useBetween } from 'use-between'; import { CanManipulateFurniture, DispatchUiEvent, GetRoomSession, IsFurnitureSelectionDisabled, ProcessRoomObjectOperation, RoomWidgetUpdateBackgroundColorPreviewEvent, RoomWidgetUpdateRoomObjectEvent, SetActiveRoomId, StartRoomSession } from '../../api'; import { useMessageEvent, useNitroEvent, useUiEvent } from '../events'; +const getViewportSize = () => +{ + const viewport = window.visualViewport; + + return { + width: Math.max(1, Math.floor(viewport?.width ?? window.innerWidth)), + height: Math.max(1, Math.floor(viewport?.height ?? window.innerHeight)) + }; +}; + const useRoomState = () => { const [roomSession, setRoomSession] = useState(null); @@ -215,8 +225,7 @@ const useRoomState = () => const roomEngine = GetRoomEngine(); const roomId = roomSession.roomId; const canvasId = 1; - const width = Math.floor(window.innerWidth); - const height = Math.floor(window.innerHeight); + const { width, height } = getViewportSize(); const renderer = GetRenderer(); if (renderer) renderer.resize(width, height); @@ -266,10 +275,9 @@ const useRoomState = () => SetActiveRoomId(roomSession.roomId); - const resize = (event: UIEvent) => + const resize = () => { - const newWidth = Math.floor(window.innerWidth); - const newHeight = Math.floor(window.innerHeight); + const { width: newWidth, height: newHeight } = getViewportSize(); const offsetX = canvas.screenOffsetX - (newWidth - canvas.width) / 2; const offsetY = canvas.screenOffsetY - (newHeight - canvas.height) / 2; @@ -284,7 +292,11 @@ const useRoomState = () => canvas.screenOffsetY = ~~offsetY; }; + const viewport = window.visualViewport; + window.addEventListener('resize', resize); + viewport?.addEventListener('resize', resize); + viewport?.addEventListener('scroll', resize); return () => { @@ -293,6 +305,8 @@ const useRoomState = () => setOriginalRoomBackgroundColor(0); window.removeEventListener('resize', resize); + viewport?.removeEventListener('resize', resize); + viewport?.removeEventListener('scroll', resize); }; }, [roomSession]); diff --git a/src/secure-assets.ts b/src/secure-assets.ts index 990f2f2..f8ea08c 100644 --- a/src/secure-assets.ts +++ b/src/secure-assets.ts @@ -110,6 +110,7 @@ const textDecoder = new TextDecoder(); let secureSessionPromise: Promise = null; let installed = false; const secureResponseCache = new Map>(); +const SECURE_RESPONSE_CACHE_LIMIT = 128; let secureSessionCreatedAt = 0; const SECURE_SESSION_TTL_MS = 5 * 60 * 1000; const REKEY_ENDPOINTS = new Set([ @@ -366,6 +367,22 @@ const cloneCachedResponse = async (responsePromise: Promise): Promise< return response.clone(); }; +const cacheSecureResponse = (cacheKey: string, responsePromise: Promise): void => +{ + secureResponseCache.set(cacheKey, responsePromise); + + responsePromise.catch(() => secureResponseCache.delete(cacheKey)); + + while(secureResponseCache.size > SECURE_RESPONSE_CACHE_LIMIT) + { + const oldestKey = secureResponseCache.keys().next().value; + + if(!oldestKey) break; + + secureResponseCache.delete(oldestKey); + } +}; + const normalizeSecureCacheKey = (requestUrl: string): string => { try @@ -518,7 +535,7 @@ export const installSecureFetch = (): void => return response; })(); - if(cacheKey) secureResponseCache.set(cacheKey, responsePromise); + if(cacheKey) cacheSecureResponse(cacheKey, responsePromise); return cloneCachedResponse(responsePromise); } diff --git a/yarn.lock b/yarn.lock index 872d873..81e8375 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17,9 +17,9 @@ picocolors "^1.1.1" "@babel/compat-data@^7.28.6": - version "7.29.0" - resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.29.0.tgz#00d03e8c0ac24dd9be942c5370990cbe1f17d88d" - integrity sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg== + version "7.29.3" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.29.3.tgz#e3f5347f0589596c91d227ccb6a541d37fb1307b" + integrity sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg== "@babel/core@^7.24.4": version "7.29.0" @@ -110,9 +110,9 @@ "@babel/types" "^7.29.0" "@babel/parser@^7.24.4", "@babel/parser@^7.28.6", "@babel/parser@^7.29.0": - version "7.29.2" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.2.tgz#58bd50b9a7951d134988a1ae177a35ef9a703ba1" - integrity sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA== + version "7.29.3" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.3.tgz#116f70a77958307fceac27747573032f8a62f88e" + integrity sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA== dependencies: "@babel/types" "^7.29.0" @@ -856,9 +856,9 @@ integrity sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q== "@tybys/wasm-util@^0.10.1": - version "0.10.1" - resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz#ecddd3205cf1e2d5274649ff0eedd2991ed7f414" - integrity sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg== + version "0.10.2" + resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.2.tgz#12b3a1b33db1f9cad4ddff1f604ab7dd00bf464e" + integrity sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg== dependencies: tslib "^2.4.0" @@ -901,100 +901,100 @@ resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11" integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw== -"@typescript-eslint/eslint-plugin@8.59.1", "@typescript-eslint/eslint-plugin@^8.59.1": - version "8.59.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz#781bc6f9002982cfaf75a185240e24ad7276628a" - integrity sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag== +"@typescript-eslint/eslint-plugin@8.59.2", "@typescript-eslint/eslint-plugin@^8.59.1": + version "8.59.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz#f37b2c189a0177141fe3de3b08f2a83991bfdbfa" + integrity sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ== dependencies: "@eslint-community/regexpp" "^4.12.2" - "@typescript-eslint/scope-manager" "8.59.1" - "@typescript-eslint/type-utils" "8.59.1" - "@typescript-eslint/utils" "8.59.1" - "@typescript-eslint/visitor-keys" "8.59.1" + "@typescript-eslint/scope-manager" "8.59.2" + "@typescript-eslint/type-utils" "8.59.2" + "@typescript-eslint/utils" "8.59.2" + "@typescript-eslint/visitor-keys" "8.59.2" ignore "^7.0.5" natural-compare "^1.4.0" ts-api-utils "^2.5.0" -"@typescript-eslint/parser@8.59.1", "@typescript-eslint/parser@^8.59.1": - version "8.59.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.59.1.tgz#835d20a62350659a082a1ae2a60b822c40488905" - integrity sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA== +"@typescript-eslint/parser@8.59.2", "@typescript-eslint/parser@^8.59.1": + version "8.59.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.59.2.tgz#e2fd0084baa5dd0c24cd789af1c72cbc3a7a1c62" + integrity sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ== dependencies: - "@typescript-eslint/scope-manager" "8.59.1" - "@typescript-eslint/types" "8.59.1" - "@typescript-eslint/typescript-estree" "8.59.1" - "@typescript-eslint/visitor-keys" "8.59.1" + "@typescript-eslint/scope-manager" "8.59.2" + "@typescript-eslint/types" "8.59.2" + "@typescript-eslint/typescript-estree" "8.59.2" + "@typescript-eslint/visitor-keys" "8.59.2" debug "^4.4.3" -"@typescript-eslint/project-service@8.59.1": - version "8.59.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.59.1.tgz#49efe87c37ef84262f23df8bf62fdc56698ca6fe" - integrity sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg== +"@typescript-eslint/project-service@8.59.2": + version "8.59.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.59.2.tgz#f8b8cbf8692e3a51c2c394acf8cf6900f7e755af" + integrity sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw== dependencies: - "@typescript-eslint/tsconfig-utils" "^8.59.1" - "@typescript-eslint/types" "^8.59.1" + "@typescript-eslint/tsconfig-utils" "^8.59.2" + "@typescript-eslint/types" "^8.59.2" debug "^4.4.3" -"@typescript-eslint/scope-manager@8.59.1": - version "8.59.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz#ed90d054fc3db2d0c81464db3a953a94fb85bb58" - integrity sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg== +"@typescript-eslint/scope-manager@8.59.2": + version "8.59.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.59.2.tgz#63cbd0af2e3180949d6be81122cc555bc71e736d" + integrity sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg== dependencies: - "@typescript-eslint/types" "8.59.1" - "@typescript-eslint/visitor-keys" "8.59.1" + "@typescript-eslint/types" "8.59.2" + "@typescript-eslint/visitor-keys" "8.59.2" -"@typescript-eslint/tsconfig-utils@8.59.1", "@typescript-eslint/tsconfig-utils@^8.59.1": - version "8.59.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.1.tgz#ba2a779a444f1d5cb92a606f9b209d239fd4cab1" - integrity sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA== +"@typescript-eslint/tsconfig-utils@8.59.2", "@typescript-eslint/tsconfig-utils@^8.59.2": + version "8.59.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.2.tgz#6e92bc412083753185a79c9f1431e78169d9232f" + integrity sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw== -"@typescript-eslint/type-utils@8.59.1": - version "8.59.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.59.1.tgz#9c83d3f2ed9187a815e8120f72c08317e513e409" - integrity sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w== +"@typescript-eslint/type-utils@8.59.2": + version "8.59.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.59.2.tgz#a60a1192a804fa472a92c41656853ac6a9ba7176" + integrity sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ== dependencies: - "@typescript-eslint/types" "8.59.1" - "@typescript-eslint/typescript-estree" "8.59.1" - "@typescript-eslint/utils" "8.59.1" + "@typescript-eslint/types" "8.59.2" + "@typescript-eslint/typescript-estree" "8.59.2" + "@typescript-eslint/utils" "8.59.2" debug "^4.4.3" ts-api-utils "^2.5.0" -"@typescript-eslint/types@8.59.1", "@typescript-eslint/types@^8.59.1": - version "8.59.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.59.1.tgz#c1d014d3f03a97e0113a8899fc9d4e45a7fb0ca9" - integrity sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A== +"@typescript-eslint/types@8.59.2", "@typescript-eslint/types@^8.59.2": + version "8.59.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.59.2.tgz#01caabcd7e4715c33ad5e11cab260829714d6b9c" + integrity sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q== -"@typescript-eslint/typescript-estree@8.59.1": - version "8.59.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz#4391fadf98a22c869c5b6522dbf4e491e53e351a" - integrity sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g== +"@typescript-eslint/typescript-estree@8.59.2": + version "8.59.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.2.tgz#6a217ef65b18dbd12c718fc86a675d1d7a1414cc" + integrity sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg== dependencies: - "@typescript-eslint/project-service" "8.59.1" - "@typescript-eslint/tsconfig-utils" "8.59.1" - "@typescript-eslint/types" "8.59.1" - "@typescript-eslint/visitor-keys" "8.59.1" + "@typescript-eslint/project-service" "8.59.2" + "@typescript-eslint/tsconfig-utils" "8.59.2" + "@typescript-eslint/types" "8.59.2" + "@typescript-eslint/visitor-keys" "8.59.2" debug "^4.4.3" minimatch "^10.2.2" semver "^7.7.3" tinyglobby "^0.2.15" ts-api-utils "^2.5.0" -"@typescript-eslint/utils@8.59.1": - version "8.59.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.59.1.tgz#cf6204d69701bbbc5b150f98c18aeef0a42c10bd" - integrity sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA== +"@typescript-eslint/utils@8.59.2": + version "8.59.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.59.2.tgz#ff619a6a3075f4017fa91b8610b752a8ca3366aa" + integrity sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q== dependencies: "@eslint-community/eslint-utils" "^4.9.1" - "@typescript-eslint/scope-manager" "8.59.1" - "@typescript-eslint/types" "8.59.1" - "@typescript-eslint/typescript-estree" "8.59.1" + "@typescript-eslint/scope-manager" "8.59.2" + "@typescript-eslint/types" "8.59.2" + "@typescript-eslint/typescript-estree" "8.59.2" -"@typescript-eslint/visitor-keys@8.59.1": - version "8.59.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz#b5cba576287a3eeb0b400b62813189abcc3f976a" - integrity sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg== +"@typescript-eslint/visitor-keys@8.59.2": + version "8.59.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz#5ccc486913cd347883d69158836b1189a660bfe6" + integrity sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA== dependencies: - "@typescript-eslint/types" "8.59.1" + "@typescript-eslint/types" "8.59.2" eslint-visitor-keys "^5.0.0" "@vitejs/plugin-react@^6.0.1": @@ -1132,9 +1132,9 @@ balanced-match@^4.0.2: integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA== baseline-browser-mapping@^2.10.12: - version "2.10.23" - resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.23.tgz#3a1a55d1a691a8c8d74688af7f1fd17eac23c184" - integrity sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g== + version "2.10.27" + resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz#fee941c2a0b42cdf83c6427e4c830b1d0bdab2c3" + integrity sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA== brace-expansion@^1.1.7: version "1.1.14" @@ -1309,9 +1309,9 @@ doctrine@^2.1.0: esutils "^2.0.2" dompurify@^3.4.1: - version "3.4.1" - resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.4.1.tgz#521d04483ac12631b2aedf434a5f5390933b8789" - integrity sha512-JahakDAIg1gyOm7dlgWSDjV4n7Ip2PKR55NIT6jrMfIgLFgWo81vdr1/QGqWtFNRqXP9UV71oVePtjqS2ebnPw== + version "3.4.2" + resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.4.2.tgz#f0ff81be682c485505097ba8195a058d8f575218" + integrity sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA== optionalDependencies: "@types/trusted-types" "^2.0.7" @@ -1325,9 +1325,9 @@ dunder-proto@^1.0.0, dunder-proto@^1.0.1: gopd "^1.2.0" electron-to-chromium@^1.5.328: - version "1.5.344" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz#6437cc08a7d9b914a98120e182f37793c9eaffd4" - integrity sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg== + version "1.5.351" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.351.tgz#7314fbb5b4835a1869feaec09665541b6a84cd37" + integrity sha512-9D7Iqx8RImSvCnOsj86rCH6eQjZFQoM04Jn6HnZVM0Nu/G58/gmKYQ1d12MZTbjQbQSTGI8nwEy07ErsA2slLA== emoji-mart@^5.6.0: version "5.6.0" @@ -1538,9 +1538,9 @@ eslint-visitor-keys@^5.0.0, eslint-visitor-keys@^5.0.1: integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA== eslint@^10.2.1: - version "10.2.1" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.2.1.tgz#224b2a6caeb34473eddcf918762363e2e063222a" - integrity sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q== + version "10.3.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.3.0.tgz#ed5b810eb8e0191bf24bddcf9cdb45b974e0a16d" + integrity sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw== dependencies: "@eslint-community/eslint-utils" "^4.8.0" "@eslint-community/regexpp" "^4.12.2" @@ -1801,7 +1801,7 @@ has-tostringtag@^1.0.2: dependencies: has-symbols "^1.0.3" -hasown@^2.0.2: +hasown@^2.0.2, hasown@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.3.tgz#5e5c2b15b60370a4c7930c383dfb76bf17bc403c" integrity sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg== @@ -1890,11 +1890,11 @@ is-callable@^1.2.7: integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA== is-core-module@^2.16.1: - version "2.16.1" - resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.1.tgz#2a98801a849f43e2add644fbb6bc6229b19a4ef4" - integrity sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w== + version "2.16.2" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.2.tgz#3e07450a8080ebce3fbf0cac494f4d2ab324e082" + integrity sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA== dependencies: - hasown "^2.0.2" + hasown "^2.0.3" is-data-view@^1.0.1, is-data-view@^1.0.2: version "1.0.2" @@ -2050,9 +2050,9 @@ iterator.prototype@^1.1.5: set-function-name "^2.0.2" jiti@^2.6.1: - version "2.6.1" - resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.6.1.tgz#178ef2fc9a1a594248c20627cd820187a4d78d92" - integrity sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ== + version "2.7.0" + resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.7.0.tgz#974228f2f4ca2bc21885a1797b45fea68e950c64" + integrity sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ== "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" @@ -2263,9 +2263,9 @@ ms@^2.1.3: integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== nanoid@^3.3.11: - version "3.3.11" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" - integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== + version "3.3.12" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.12.tgz#ab3d912e217a6d0a514f00a72a16543a28982c05" + integrity sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ== natural-compare@^1.4.0: version "1.4.0" @@ -2430,9 +2430,9 @@ postcss-selector-parser@^7.0.0: util-deprecate "^1.0.2" postcss@^8.5.10, postcss@^8.5.12, postcss@^8.5.6: - version "8.5.12" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.12.tgz#cd0c0f667f7cb0521e2313234ea6e707a9ec1ddb" - integrity sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA== + version "8.5.14" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.14.tgz#a66c2d7808fadf69ebb5b84a03f8bafd76c4919c" + integrity sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg== dependencies: nanoid "^3.3.11" picocolors "^1.1.1" @@ -2884,14 +2884,14 @@ typed-array-length@^1.0.7: reflect.getprototypeof "^1.0.6" typescript-eslint@^8.59.1: - version "8.59.1" - resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.59.1.tgz#244a9fcbf27057ebbc2281d408239f1861b55b78" - integrity sha512-xqDcFVBmlrltH64lklOVp1wYxgJr6LVdg3NamBgH2OOQDLFdTKfIZXF5PfghrnXQKXZGTQs8tr1vL7fJvq8CTQ== + version "8.59.2" + resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.59.2.tgz#e24b4f7232e20112e40572dba162a829a738ce98" + integrity sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ== dependencies: - "@typescript-eslint/eslint-plugin" "8.59.1" - "@typescript-eslint/parser" "8.59.1" - "@typescript-eslint/typescript-estree" "8.59.1" - "@typescript-eslint/utils" "8.59.1" + "@typescript-eslint/eslint-plugin" "8.59.2" + "@typescript-eslint/parser" "8.59.2" + "@typescript-eslint/typescript-estree" "8.59.2" + "@typescript-eslint/utils" "8.59.2" typescript@^6.0.3: version "6.0.3" @@ -3047,6 +3047,6 @@ yocto-queue@^0.1.0: integrity sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ== "zod@^3.25.0 || ^4.0.0": - version "4.3.6" - resolved "https://registry.yarnpkg.com/zod/-/zod-4.3.6.tgz#89c56e0aa7d2b05107d894412227087885ab112a" - integrity sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg== + version "4.4.3" + resolved "https://registry.yarnpkg.com/zod/-/zod-4.4.3.tgz#b680f172885d18bbebf21a834ea25e55a1bbf356" + integrity sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==