Merge branch 'Dev' into merge-duckie-main-2026-05-06

This commit is contained in:
DuckieTM
2026-05-25 18:51:48 +02:00
committed by GitHub
340 changed files with 18499 additions and 7335 deletions
+14 -4
View File
@@ -17,13 +17,20 @@ export const setAccessToken = (token: string | null | undefined, expiresAt?: num
window.localStorage.removeItem(EXPIRES_KEY);
}
}
catch {}
catch
{}
};
export const getAccessToken = (): string =>
{
try { return window.localStorage.getItem(STORAGE_KEY) ?? ''; }
catch { return ''; }
try
{
return window.localStorage.getItem(STORAGE_KEY) ?? '';
}
catch
{
return '';
}
};
export const getAccessTokenExpiresAt = (): number =>
@@ -35,7 +42,10 @@ export const getAccessTokenExpiresAt = (): number =>
const value = parseInt(raw, 10);
return Number.isFinite(value) ? value : 0;
}
catch { return 0; }
catch
{
return 0;
}
};
export const clearAccessToken = (): void =>
@@ -87,7 +87,7 @@ export class AvatarEditorThumbnailsHelper
AvatarFigurePartType.PET,
'ptl',
'ptr',
AvatarFigurePartType.MISC,
AvatarFigurePartType.MISC,
'mcl',
'mcr',
];
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import { dedupeBadges } from './dedupeBadges';
describe('dedupeBadges', () =>
{
it('returns an empty array for an empty input', () =>
{
expect(dedupeBadges([])).toEqual([]);
});
it('preserves unique badges in slot order', () =>
{
expect(dedupeBadges([ 'a', 'b', 'c' ])).toEqual([ 'a', 'b', 'c' ]);
});
it('replaces duplicate slots with empty strings to preserve slot indices', () =>
{
expect(dedupeBadges([ 'a', 'b', 'a', 'c' ])).toEqual([ 'a', 'b', '', 'c' ]);
});
it('normalizes falsy entries (null, undefined, "") to empty string', () =>
{
// server sometimes returns null/undefined for unused slots
const input = [ 'a', null as unknown as string, '', undefined as unknown as string, 'b' ];
expect(dedupeBadges(input)).toEqual([ 'a', '', '', '', 'b' ]);
});
it('only keeps the FIRST occurrence of each unique code', () =>
{
expect(dedupeBadges([ 'a', 'a', 'a' ])).toEqual([ 'a', '', '' ]);
});
it('is order-sensitive: identical multisets but different orderings yield different outputs', () =>
{
expect(dedupeBadges([ 'a', 'b', 'a' ])).toEqual([ 'a', 'b', '' ]);
expect(dedupeBadges([ 'b', 'a', 'a' ])).toEqual([ 'b', 'a', '' ]);
});
});
+21
View File
@@ -0,0 +1,21 @@
/**
* Strips duplicate badge codes from a server-supplied badge array,
* preserving slot indices: a duplicate is replaced by an empty string
* rather than shifted out, so badge[i] still corresponds to slot i.
*
* Empty / falsy entries are normalized to '' (some servers emit null
* inside the array for unused slots).
*/
export const dedupeBadges = (badges: ReadonlyArray<string>): string[] =>
{
const seen = new Set<string>();
return badges.map(code =>
{
if(!code || seen.has(code)) return '';
seen.add(code);
return code;
});
};
+1
View File
@@ -3,5 +3,6 @@ export * from './AvatarEditorColorSorter';
export * from './AvatarEditorPartSorter';
export * from './AvatarEditorThumbnailsHelper';
export * from './BuildPurchasableClothingFigure';
export * from './dedupeBadges';
export * from './IAvatarEditorCategory';
export * from './IAvatarEditorCategoryPartItem';
+26 -7
View File
@@ -31,8 +31,14 @@ export interface CustomBadgeError
const interpolate = (value: string): string =>
{
try { return GetConfiguration().interpolate(value); }
catch { return value; }
try
{
return GetConfiguration().interpolate(value);
}
catch
{
return value;
}
};
const getConfigUrl = (key: string, fallback: string): string =>
@@ -61,8 +67,14 @@ const parseJson = async <T>(response: Response): Promise<T> =>
{
const text = await response.text();
if(!text) return {} as T;
try { return JSON.parse(text) as T; }
catch { throw new Error('Invalid response from server.'); }
try
{
return JSON.parse(text) as T;
}
catch
{
throw new Error('Invalid response from server.');
}
};
const throwOnError = async (response: Response): Promise<void> =>
@@ -129,8 +141,14 @@ const injectTextsIntoLocalization = (texts: Record<string, string> | null | unde
{
if(!texts) return;
let manager: ReturnType<typeof GetLocalizationManager> | null = null;
try { manager = GetLocalizationManager(); }
catch { return; }
try
{
manager = GetLocalizationManager();
}
catch
{
return;
}
if(!manager || typeof manager.setValue !== 'function') return;
for(const key of Object.keys(texts))
{
@@ -152,7 +170,8 @@ export const ensureCustomBadgeTexts = (): Promise<void> =>
const payload = await parseJson<{ texts: Record<string, string> }>(response);
injectTextsIntoLocalization(payload.texts);
}
catch {}
catch
{}
})();
return customBadgeTextsLoadPromise;
};
+7
View File
@@ -6,6 +6,7 @@ export class CatalogNode implements ICatalogNode
private _depth: number = 0;
private _localization: string = '';
private _pageId: number = -1;
private _parentId: number = -1;
private _pageName: string = '';
private _iconId: number = 0;
private _children: ICatalogNode[];
@@ -21,6 +22,7 @@ export class CatalogNode implements ICatalogNode
this._parent = parent;
this._localization = node.localization;
this._pageId = node.pageId;
this._parentId = node.parentId;
this._pageName = node.pageName;
this._iconId = node.icon;
this._children = [];
@@ -82,6 +84,11 @@ export class CatalogNode implements ICatalogNode
return this._pageId;
}
public get parentId(): number
{
return this._parentId;
}
public get pageName(): string
{
return this._pageName;
+1 -1
View File
@@ -15,7 +15,7 @@ export class FurnitureOffer implements IPurchasableOffer
constructor(furniData: IFurnitureData)
{
this._furniData = furniData;
this._product = (new Product(this._furniData.type, this._furniData.id, this._furniData.customParams, 1, GetProductDataForLocalization(this._furniData.className), this._furniData) as IProduct);
this._product = (new Product(this._furniData.type, this._furniData.id, this._furniData.customParams, 1, GetProductDataForLocalization(this._furniData.className), this._furniData));
}
public activate(): void
+1
View File
@@ -10,6 +10,7 @@ export interface ICatalogNode
readonly isLeaf: boolean;
readonly localization: string;
readonly pageId: number;
readonly parentId: number;
readonly pageName: string;
readonly iconId: number;
readonly children: ICatalogNode[];
-14
View File
@@ -1,14 +0,0 @@
import { ClubGiftInfoParser, ClubOfferData, HabboGroupEntryData, MarketplaceConfigurationMessageParser } from '@nitrots/nitro-renderer';
import { CatalogPetPalette } from './CatalogPetPalette';
import { GiftWrappingConfiguration } from './GiftWrappingConfiguration';
export interface ICatalogOptions
{
groups?: HabboGroupEntryData[];
petPalettes?: CatalogPetPalette[];
clubOffers?: ClubOfferData[];
clubOffersByWindowId?: Record<number, ClubOfferData[]>;
clubGifts?: ClubGiftInfoParser;
giftConfiguration?: GiftWrappingConfiguration;
marketplaceConfiguration?: MarketplaceConfigurationMessageParser;
}
+1
View File
@@ -24,4 +24,5 @@ export interface IPurchasableOffer
products: IProduct[];
itemIds: string;
haveOffer: boolean;
clone?(): IPurchasableOffer;
}
-1
View File
@@ -10,7 +10,6 @@ export * from './FurnitureOffer';
export * from './GetImageIconUrlForProduct';
export * from './GiftWrappingConfiguration';
export * from './ICatalogNode';
export * from './ICatalogOptions';
export * from './ICatalogPage';
export * from './IMarketplaceSearchOptions';
export * from './IPageLocalization';
+1 -1
View File
@@ -9,5 +9,5 @@ export const GetGroupChatData = (extraData: string) =>
const figure = splitData[1];
const userId = parseInt(splitData[2]);
return ({ username: username, figure: figure, userId: userId } as IGroupChatData);
return ({ username: username, figure: figure, userId: userId });
};
+1
View File
@@ -26,6 +26,7 @@ export * from './purse';
export * from './room';
export * from './room/events';
export * from './room/widgets';
export * from './ui-settings';
export * from './user';
export * from './utils';
export * from './wired';
+127
View File
@@ -0,0 +1,127 @@
import { GetCommunication, IMessageComposer, IMessageEvent, MessageEvent } from '@nitrots/nitro-renderer';
import { QueryKey, useQuery, UseQueryOptions, UseQueryResult } from '@tanstack/react-query';
import { SendMessageComposer } from '../nitro/SendMessageComposer';
export interface NitroQueryConfig<TParser extends IMessageEvent, TData>
{
/**
* Stable key for caching/deduping. Convention:
* `['nitro', '<domain>', '<request>', ...args]`.
*/
key: QueryKey;
/**
* Factory for the request composer. Called once per query execution.
* `null` skips sending (useful when the server pushes the event
* unprompted — you only want subscription, not a request).
*/
request: (() => IMessageComposer<unknown[]>) | null;
/**
* The parser class to listen for as the response.
*/
parser: typeof MessageEvent;
/**
* Maps the parser event to the data the component cares about.
*/
select?: (event: TParser) => TData;
/**
* Optional predicate to ignore parser events that don't match this
* query (typically used as a correlation-key filter on a globally
* shared event stream — e.g. `e => e.getParser()?.roomId === roomId`).
* When the predicate returns false, the listener stays registered
* and keeps waiting; the timeout still applies.
*/
accept?: (event: TParser) => boolean;
/**
* Max time to wait for the response before rejecting (default 15s).
*/
timeoutMs?: number;
/**
* Forwarded to TanStack Query.
*/
enabled?: boolean;
staleTime?: number;
refetchOnMount?: boolean | 'always';
}
/**
* Wraps a Nitro composer/parser request-response pair as a TanStack Query
* `useQuery` call. The returned object is the standard TanStack result —
* `{ data, isLoading, isError, error, refetch, ... }`.
*
* Behavior:
* - On the first subscribe, registers the parser, sends the composer,
* resolves the Promise with the selected payload when the parser fires.
* - Default `staleTime` is the QueryClient default (30s).
* - Subsequent mounts within `staleTime` get the cached value immediately;
* the request is NOT re-sent.
* - Identical concurrent calls (same `key`) are deduped.
*/
export const useNitroQuery = <TParser extends IMessageEvent, TData = TParser>(
config: NitroQueryConfig<TParser, TData>
): UseQueryResult<TData> =>
{
const { key, request, parser, select, accept, timeoutMs = 15_000, enabled, staleTime, refetchOnMount } = config;
const options: UseQueryOptions<TData, Error, TData> = {
queryKey: key,
queryFn: () => awaitNitroResponse<TParser, TData>({ request, parser, select, accept, timeoutMs }),
enabled,
staleTime,
refetchOnMount
};
return useQuery(options);
};
/**
* Lower-level helper: send a composer (if any) and resolve with the next
* matching parser event. Exposed so `queryClient.fetchQuery({...})` callers
* can use the same plumbing imperatively.
*/
export const awaitNitroResponse = <TParser extends IMessageEvent, TData>(
config: Pick<NitroQueryConfig<TParser, TData>, 'request' | 'parser' | 'select' | 'accept' | 'timeoutMs'>
): Promise<TData> =>
new Promise<TData>((resolve, reject) =>
{
const { request, parser: ParserCtor, select, accept, timeoutMs = 15_000 } = config;
let settled = false;
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
let listener: IMessageEvent | undefined = undefined;
const cleanup = () =>
{
if(timeoutHandle !== null) clearTimeout(timeoutHandle);
if(listener) GetCommunication().removeMessageEvent(listener);
};
listener = new (ParserCtor as any)((event: TParser) =>
{
if(settled) return;
if(accept && !accept(event)) return;
settled = true;
cleanup();
try
{
resolve(select ? select(event) : (event as unknown as TData));
}
catch(err)
{
reject(err instanceof Error ? err : new Error(String(err)));
}
});
GetCommunication().registerMessageEvent(listener);
timeoutHandle = setTimeout(() =>
{
if(settled) return;
settled = true;
cleanup();
reject(new Error(`NitroQuery timed out after ${ timeoutMs }ms`));
}, timeoutMs);
if(request) SendMessageComposer(request());
});
+2
View File
@@ -0,0 +1,2 @@
export * from './createNitroQuery';
export * from './useNitroEventInvalidator';
@@ -0,0 +1,48 @@
import { IMessageEvent, MessageEvent } from '@nitrots/nitro-renderer';
import { QueryKey, useQueryClient } from '@tanstack/react-query';
import { useMessageEvent } from '../../hooks/events/useMessageEvent';
/**
* Invalidate a TanStack query slot every time the renderer pushes the
* matching parser event. Companion to useNitroQuery for the case where
* the server can push fresh data unprompted (e.g. ClubGiftInfoEvent
* fires both as the response to GetClubGiftInfo and again after the
* user claims a gift via SelectClubGiftComposer).
*
* Usage:
*
* const { data: clubGifts } = useNitroQuery({
* key: ['nitro', 'catalog', 'clubGifts'],
* request: () => new GetClubGiftInfo(),
* parser: ClubGiftInfoEvent,
* select: e => e.getParser(),
* });
*
* // re-fetch on every server push:
* useNitroEventInvalidator(ClubGiftInfoEvent, ['nitro', 'catalog', 'clubGifts']);
*
* Optional `accept` predicate filters out events that don't belong to
* this query slot — useful when the same parser is multiplexed across
* multiple correlated queries (mirrors useNitroQuery.accept).
*
* Implementation: the renderer push triggers `queryClient.invalidateQueries`,
* which marks the slot stale; the next subscriber render triggers a
* fresh fetch via useNitroQuery's queryFn. If nobody is currently
* subscribed, the invalidation is a no-op (TanStack drops stale entries
* with no active observers per its garbage-collection policy).
*/
export const useNitroEventInvalidator = <T extends IMessageEvent>(
eventType: typeof MessageEvent,
queryKey: QueryKey,
accept?: (event: T) => boolean
) =>
{
const queryClient = useQueryClient();
useMessageEvent<T>(eventType, event =>
{
if(accept && !accept(event)) return;
queryClient.invalidateQueries({ queryKey });
});
};
+2 -1
View File
@@ -20,10 +20,11 @@ export class AvatarInfoUser implements IAvatarInfo
public prefixFont: string = '';
public displayOrder: string = 'icon-prefix-name';
public achievementScore: number = 0;
public backgroundId: number = 0;
public backgroundId: number = 0;
public standId: number = 0;
public overlayId: number = 0;
public cardBackgroundId: number = 0;
public borderId: number = 0;
public webID: number = 0;
public xp: number = 0;
public userType: number = -1;
+2 -1
View File
@@ -190,10 +190,11 @@ export class AvatarInfoUtilities
userInfo.prefixEffect = userData.prefixEffect;
userInfo.prefixFont = userData.prefixFont;
userInfo.displayOrder = userData.displayOrder;
userInfo.backgroundId = userData.background;
userInfo.backgroundId = userData.background;
userInfo.standId = userData.stand;
userInfo.overlayId = userData.overlay;
userInfo.cardBackgroundId = userData.cardBackground ?? 0;
userInfo.borderId = (userData as any).borderId ?? 0;
userInfo.achievementScore = userData.activityPoints;
userInfo.webID = userData.webID;
userInfo.roomIndex = userData.roomIndex;
@@ -9,9 +9,11 @@ export class chooserSelectionVisualizer
{
if (this.animationFrameId !== null) return;
const animate = (time: number) => {
const animate = (time: number) =>
{
const elapsed = time / 1000; // Convert to seconds
this.activeFilters.forEach(filter => {
this.activeFilters.forEach(filter =>
{
filter.time = elapsed; // Update time uniform
});
this.animationFrameId = requestAnimationFrame(animate);
@@ -22,7 +24,8 @@ export class chooserSelectionVisualizer
private static stopAnimation(): void
{
if (this.animationFrameId !== null) {
if (this.animationFrameId !== null)
{
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
@@ -45,7 +48,7 @@ export class chooserSelectionVisualizer
for (const sprite of visualization.sprites)
{
if (sprite.blendMode === 1) continue;
if (sprite.blendMode === 'add') continue;
const existing = (sprite.filters || []).filter(f => !(f instanceof ChooserSelectionFilter));
sprite.filters = [...existing, filter];
}
@@ -69,7 +72,8 @@ export class chooserSelectionVisualizer
sprite.filters = (sprite.filters || []).filter(f => !(f instanceof ChooserSelectionFilter));
}
if (this.activeFilters.size === 0) {
if (this.activeFilters.size === 0)
{
this.stopAnimation();
}
}
+2 -2
View File
@@ -2,7 +2,7 @@ import { AvatarFigurePartType, GetAvatarRenderManager, IAvatarFigureContainer }
export class MannequinUtilities
{
public static MANNEQUIN_FIGURE = [ 'hd', 99999, [ 99998 ] ];
public static MANNEQUIN_FIGURE: [ string, number, number[] ] = [ 'hd', 99999, [ 99998 ] ];
public static MANNEQUIN_CLOTHING_PART_TYPES = [
AvatarFigurePartType.CHEST_ACCESSORY,
AvatarFigurePartType.COAT_CHEST,
@@ -33,6 +33,6 @@ export class MannequinUtilities
figureContainer.removePart(part);
}
figureContainer.updatePart((this.MANNEQUIN_FIGURE[0] as string), (this.MANNEQUIN_FIGURE[1] as number), (this.MANNEQUIN_FIGURE[2] as number[]));
figureContainer.updatePart((this.MANNEQUIN_FIGURE[0]), (this.MANNEQUIN_FIGURE[1]), (this.MANNEQUIN_FIGURE[2]));
};
}
+27 -67
View File
@@ -1,7 +1,14 @@
import { GetCommunication, UiSettingsDataEvent, UiSettingsLoadComposer, UiSettingsSaveComposer } from '@nitrots/nitro-renderer';
import { createContext, FC, PropsWithChildren, useCallback, useContext, useEffect, useRef, useState } from 'react';
import { createContext, FC, PropsWithChildren, useCallback, useContext, useEffect, useState } from 'react';
import { DEFAULT_UI_SETTINGS, IUiSettings } from './IUiSettings';
/**
* UI settings currently persist to localStorage only. The cross-device
* server-side sync (UiSettingsLoadComposer / UiSettingsSaveComposer /
* UiSettingsDataEvent) is a planned addition that requires both the
* renderer composer classes and the Arcturus packet handlers — none of
* which exist yet. Until those land, settings stay per-browser.
*/
const STORAGE_KEY = 'nitro.ui.settings';
interface IUiSettingsContext
@@ -18,8 +25,10 @@ interface IUiSettingsContext
const UiSettingsContext = createContext<IUiSettingsContext>({
settings: DEFAULT_UI_SETTINGS,
isCustomActive: false,
updateSettings: () => {},
resetSettings: () => {},
updateSettings: () =>
{},
resetSettings: () =>
{},
getHeaderStyle: () => ({}),
getTabsStyle: () => ({}),
getAccentColor: () => DEFAULT_UI_SETTINGS.headerColor
@@ -42,7 +51,8 @@ const loadSettings = (): IUiSettings =>
const stored = localStorage.getItem(STORAGE_KEY);
if(stored) return { ...DEFAULT_UI_SETTINGS, ...JSON.parse(stored) };
}
catch(e) {}
catch(e)
{}
return { ...DEFAULT_UI_SETTINGS };
};
@@ -53,61 +63,20 @@ const saveSettings = (settings: IUiSettings): void =>
{
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
}
catch(e) {}
catch(e)
{}
};
const sendComposer = (composer: any): void =>
{
try
{
GetCommunication()?.connection?.send(composer);
}
catch(e) {}
};
const ALL_CSS_VARS = [
'--ui-accent-color', '--ui-accent-dark',
'--ui-ctx-bg', '--ui-ctx-header-bg', '--ui-ctx-item-bg1', '--ui-ctx-item-bg2',
'--ui-btn-primary-bg', '--ui-btn-primary-border',
'--ui-dark-bg', '--ui-dark-border'
];
export const UiSettingsProvider: FC<PropsWithChildren> = ({ children }) =>
{
const [ settings, setSettings ] = useState<IUiSettings>(loadSettings);
const serverSaveTimerRef = useRef<ReturnType<typeof setTimeout>>(null);
// Carica dal server al mount e ascolta risposta
useEffect(() =>
{
sendComposer(new UiSettingsLoadComposer());
const connection = GetCommunication()?.connection;
if(!connection) return;
const handler = (event: any) =>
{
try
{
const parser = event.getParser();
const json = parser?.settingsJson;
if(json && json !== '{}')
{
const serverSettings = { ...DEFAULT_UI_SETTINGS, ...JSON.parse(json) };
setSettings(serverSettings);
saveSettings(serverSettings);
}
}
catch(e) {}
};
connection.addMessageEvent(new UiSettingsDataEvent(handler));
}, []);
const syncToServer = useCallback((settingsToSave: IUiSettings) =>
{
if(serverSaveTimerRef.current) clearTimeout(serverSaveTimerRef.current);
serverSaveTimerRef.current = setTimeout(() =>
{
sendComposer(new UiSettingsSaveComposer(JSON.stringify(settingsToSave)));
}, 1000);
}, []);
const updateSettings = useCallback((partial: Partial<IUiSettings>) =>
{
@@ -115,18 +84,16 @@ export const UiSettingsProvider: FC<PropsWithChildren> = ({ children }) =>
{
const updated = { ...prev, ...partial };
saveSettings(updated);
syncToServer(updated);
return updated;
});
}, [ syncToServer ]);
}, []);
const resetSettings = useCallback(() =>
{
setSettings({ ...DEFAULT_UI_SETTINGS });
saveSettings(DEFAULT_UI_SETTINGS);
syncToServer(DEFAULT_UI_SETTINGS);
}, [ syncToServer ]);
}, []);
const getHeaderStyle = useCallback((): React.CSSProperties =>
{
@@ -183,13 +150,6 @@ export const UiSettingsProvider: FC<PropsWithChildren> = ({ children }) =>
const isCustomActive = settings.colorMode !== 'default';
const ALL_CSS_VARS = [
'--ui-accent-color', '--ui-accent-dark',
'--ui-ctx-bg', '--ui-ctx-header-bg', '--ui-ctx-item-bg1', '--ui-ctx-item-bg2',
'--ui-btn-primary-bg', '--ui-btn-primary-border',
'--ui-dark-bg', '--ui-dark-border'
];
useEffect(() =>
{
const root = document.documentElement;
@@ -215,9 +175,9 @@ export const UiSettingsProvider: FC<PropsWithChildren> = ({ children }) =>
}, [ settings ]);
return (
<UiSettingsContext.Provider value={ { settings, isCustomActive, updateSettings, resetSettings, getHeaderStyle, getTabsStyle, getAccentColor } }>
<UiSettingsContext value={ { settings, isCustomActive, updateSettings, resetSettings, getHeaderStyle, getTabsStyle, getAccentColor } }>
{ children }
</UiSettingsContext.Provider>
</UiSettingsContext>
);
};
+2 -2
View File
@@ -13,7 +13,7 @@ export class ProductImageUtility
imageUrl = GetRoomEngine().getFurnitureFloorIconUrl(furniClassId);
break;
case FurnitureType.WALL:
const productCategory = this.getProductCategory(CatalogPageMessageProductData.I, furniClassId);
const productCategory = this.getProductCategory(productType, furniClassId);
if(productCategory === 1)
{
@@ -32,7 +32,7 @@ export class ProductImageUtility
}
}
break;
case FurnitureType.EFFECT:
case FurnitureType.EFFECT:
// fx_icon_furniClassId_png
break;
}
+8 -3
View File
@@ -53,8 +53,12 @@ export const SetRememberLogin = (data: RememberLoginData): void =>
{
if(!data?.token?.length && !data?.ssoTicket?.length) return;
try { window.localStorage.setItem(REMEMBER_LOGIN_KEY, JSON.stringify(data)); }
catch {}
try
{
window.localStorage.setItem(REMEMBER_LOGIN_KEY, JSON.stringify(data));
}
catch
{}
};
export const ClearRememberLogin = (): void =>
@@ -64,7 +68,8 @@ export const ClearRememberLogin = (): void =>
window.localStorage.removeItem(REMEMBER_LOGIN_KEY);
window.localStorage.removeItem(LEGACY_REMEMBER_LOGIN_KEY);
}
catch {}
catch
{}
};
export const StoreRememberLoginFromPayload = (payload: Record<string, unknown>, username?: string, ssoTicket?: string): void =>
+165
View File
@@ -0,0 +1,165 @@
import { describe, expect, it } from 'vitest';
import { ColorUtils } from './ColorUtils';
import { FixedSizeStack } from './FixedSizeStack';
import { LocalizeFormattedNumber } from './LocalizeFormattedNumber';
describe('LocalizeFormattedNumber', () =>
{
it('returns "0" for zero / NaN / null / undefined', () =>
{
expect(LocalizeFormattedNumber(0)).toBe('0');
expect(LocalizeFormattedNumber(NaN)).toBe('0');
expect(LocalizeFormattedNumber(null)).toBe('0');
expect(LocalizeFormattedNumber(undefined as unknown as number)).toBe('0');
});
it('keeps numbers under 1000 unchanged', () =>
{
expect(LocalizeFormattedNumber(42)).toBe('42');
expect(LocalizeFormattedNumber(999)).toBe('999');
});
it('inserts a thin space every 3 digits for >=1000', () =>
{
expect(LocalizeFormattedNumber(1000)).toBe('1 000');
expect(LocalizeFormattedNumber(1_234_567)).toBe('1 234 567');
expect(LocalizeFormattedNumber(10_000_000)).toBe('10 000 000');
});
});
describe('ColorUtils', () =>
{
describe('makeColorHex', () =>
{
it('prepends "#" to the given color string', () =>
{
expect(ColorUtils.makeColorHex('ff0000')).toBe('#ff0000');
expect(ColorUtils.makeColorHex('abc')).toBe('#abc');
});
});
describe('makeColorNumberHex', () =>
{
it('pads to 6 hex chars and prepends "#"', () =>
{
expect(ColorUtils.makeColorNumberHex(0xff0000)).toBe('#ff0000');
expect(ColorUtils.makeColorNumberHex(0x00ff00)).toBe('#00ff00');
expect(ColorUtils.makeColorNumberHex(0)).toBe('#000000');
});
it('pads short hex values with leading zeros', () =>
{
expect(ColorUtils.makeColorNumberHex(0xff)).toBe('#0000ff');
expect(ColorUtils.makeColorNumberHex(1)).toBe('#000001');
});
});
describe('convertFromHex', () =>
{
it('parses a "#"-prefixed hex string to a number', () =>
{
expect(ColorUtils.convertFromHex('#ff0000')).toBe(0xff0000);
expect(ColorUtils.convertFromHex('#000000')).toBe(0);
expect(ColorUtils.convertFromHex('#ffffff')).toBe(0xffffff);
});
it('also handles strings without the leading "#"', () =>
{
expect(ColorUtils.convertFromHex('00ff00')).toBe(0x00ff00);
});
});
describe('int_to_8BitVals / eight_bitVals_to_int', () =>
{
it('roundtrips: int -> [a,r,g,b] -> int', () =>
{
const original = 0x12345678;
const [ a, b, c, d ] = ColorUtils.int_to_8BitVals(original);
expect(a).toBe(0x12);
expect(b).toBe(0x34);
expect(c).toBe(0x56);
expect(d).toBe(0x78);
expect(ColorUtils.eight_bitVals_to_int(a, b, c, d)).toBe(original);
});
it('roundtrips zero', () =>
{
const parts = ColorUtils.int_to_8BitVals(0);
expect(parts).toEqual([ 0, 0, 0, 0 ]);
expect(ColorUtils.eight_bitVals_to_int(0, 0, 0, 0)).toBe(0);
});
});
describe('int2rgb', () =>
{
it('produces rgba(r,g,b,1) for an RGB integer', () =>
{
expect(ColorUtils.int2rgb(0xff0000)).toBe('rgba(255,0,0,1)');
expect(ColorUtils.int2rgb(0x00ff00)).toBe('rgba(0,255,0,1)');
expect(ColorUtils.int2rgb(0x0000ff)).toBe('rgba(0,0,255,1)');
});
it('returns black for 0', () =>
{
expect(ColorUtils.int2rgb(0)).toBe('rgba(0,0,0,1)');
});
});
});
describe('FixedSizeStack', () =>
{
it('grows up to maxSize then overwrites the oldest entry', () =>
{
const stack = new FixedSizeStack(3);
stack.addValue(10);
stack.addValue(20);
stack.addValue(30);
expect(stack.getMax()).toBe(30);
expect(stack.getMin()).toBe(10);
// Capacity hit — 40 overwrites 10
stack.addValue(40);
expect(stack.getMin()).toBe(20);
expect(stack.getMax()).toBe(40);
// 50 overwrites 20
stack.addValue(50);
expect(stack.getMin()).toBe(30);
expect(stack.getMax()).toBe(50);
});
it('reset clears all values', () =>
{
const stack = new FixedSizeStack(2);
stack.addValue(100);
stack.addValue(200);
expect(stack.getMax()).toBe(200);
stack.reset();
stack.addValue(7);
expect(stack.getMax()).toBe(7);
expect(stack.getMin()).toBe(7);
});
it('getMax with maxSize > inserted entries returns the inserted value', () =>
{
// FixedSizeStack iterates the whole maxSize window but the
// unfilled slots are `undefined` which fail `> currentMax`, so
// the inserted value wins.
const stack = new FixedSizeStack(5);
stack.addValue(42);
expect(stack.getMax()).toBe(42);
});
it('getMax on an empty stack returns Number.MIN_VALUE', () =>
{
const stack = new FixedSizeStack(3);
expect(stack.getMax()).toBe(Number.MIN_VALUE);
});
});
+194
View File
@@ -0,0 +1,194 @@
import { describe, expect, it } from 'vitest';
import { CloneObject } from './CloneObject';
import { ConvertSeconds } from './ConvertSeconds';
import { LocalizeShortNumber } from './LocalizeShortNumber';
import { GetWiredTimeLocale } from '../wired/GetWiredTimeLocale';
import { WiredDateToString } from '../wired/WiredDateToString';
import { getPrefixFontStyle, parsePrefixColors, PRESET_PREFIX_FONTS } from './PrefixUtils';
describe('ConvertSeconds', () =>
{
it('formats zero seconds as the dd:hh:mm:ss zero string', () =>
{
expect(ConvertSeconds(0)).toBe('00:00:00:00');
});
it('formats one minute correctly', () =>
{
expect(ConvertSeconds(60)).toBe('00:00:01:00');
});
it('formats one hour correctly', () =>
{
expect(ConvertSeconds(3600)).toBe('00:01:00:00');
});
it('formats one day correctly', () =>
{
expect(ConvertSeconds(86400)).toBe('01:00:00:00');
});
it('formats a mixed value (1d 2h 3m 4s)', () =>
{
expect(ConvertSeconds(86400 + 2 * 3600 + 3 * 60 + 4)).toBe('01:02:03:04');
});
it('pads single-digit components with a leading zero', () =>
{
expect(ConvertSeconds(9)).toBe('00:00:00:09');
});
});
describe('LocalizeShortNumber', () =>
{
it('returns "0" for zero, null, undefined, and NaN', () =>
{
expect(LocalizeShortNumber(0)).toBe('0');
expect(LocalizeShortNumber(NaN)).toBe('0');
expect(LocalizeShortNumber(null)).toBe('0');
expect(LocalizeShortNumber(undefined as unknown as number)).toBe('0');
});
it('keeps numbers safely under 1000 unchanged (returns as-is)', () =>
{
expect(LocalizeShortNumber(42)).toBe('42');
// Anything that rounds to >= 1.0K (i.e. >= 950) crosses into the K bucket
expect(LocalizeShortNumber(949)).toBe('949');
});
it('rounds 950..999 up into the K bucket (documented quirk)', () =>
{
expect(LocalizeShortNumber(950)).toBe('1K');
expect(LocalizeShortNumber(999)).toBe('1K');
});
it('uses K for thousands', () =>
{
expect(LocalizeShortNumber(1500)).toBe('1.5K');
expect(LocalizeShortNumber(12_345)).toBe('12.3K');
});
it('uses M for millions', () =>
{
expect(LocalizeShortNumber(2_500_000)).toBe('2.5M');
});
it('uses B for billions', () =>
{
expect(LocalizeShortNumber(3_700_000_000)).toBe('3.7B');
});
it('preserves the sign for negative values', () =>
{
expect(LocalizeShortNumber(-1500)).toBe('-1.5K');
expect(LocalizeShortNumber(-2_500_000)).toBe('-2.5M');
});
});
describe('CloneObject', () =>
{
it('returns primitives unchanged', () =>
{
expect(CloneObject(42)).toBe(42);
expect(CloneObject('hello')).toBe('hello');
expect(CloneObject(null)).toBe(null);
expect(CloneObject(undefined)).toBe(undefined);
});
it('returns a new object instance for object inputs', () =>
{
const original = { a: 1, b: 'two' };
const copy = CloneObject(original);
expect(copy).not.toBe(original);
expect(copy).toEqual(original);
});
it('preserves enumerable own keys', () =>
{
const original = { x: 1, y: 2, z: 3 };
const copy = CloneObject(original);
expect(copy.x).toBe(1);
expect(copy.y).toBe(2);
expect(copy.z).toBe(3);
});
});
describe('GetWiredTimeLocale', () =>
{
// The renderer encodes time as `value = seconds * 2` so even values
// are whole seconds, odd values are half-seconds.
it('returns "0" for value 0', () =>
{
expect(GetWiredTimeLocale(0)).toBe('0');
});
it('returns whole seconds for even values', () =>
{
expect(GetWiredTimeLocale(2)).toBe('1');
expect(GetWiredTimeLocale(10)).toBe('5');
expect(GetWiredTimeLocale(60)).toBe('30');
});
it('returns half-second formatting for odd values', () =>
{
expect(GetWiredTimeLocale(1)).toBe('0.5');
expect(GetWiredTimeLocale(3)).toBe('1.5');
expect(GetWiredTimeLocale(11)).toBe('5.5');
});
});
describe('WiredDateToString', () =>
{
it('zero-pads single-digit month / day / hour / minute', () =>
{
const d = new Date(2024, 0, 5, 7, 9); // Jan 5, 2024, 07:09
expect(WiredDateToString(d)).toBe('2024/01/05 07:09');
});
it('formats two-digit values without extra padding', () =>
{
const d = new Date(2024, 11, 31, 23, 59); // Dec 31, 2024, 23:59
expect(WiredDateToString(d)).toBe('2024/12/31 23:59');
});
});
describe('PrefixUtils.parsePrefixColors', () =>
{
it('returns an empty array when text or colors are empty', () =>
{
expect(parsePrefixColors('', '#fff')).toEqual([]);
expect(parsePrefixColors('abc', '')).toEqual([]);
});
it('maps each text character to the nth color', () =>
{
expect(parsePrefixColors('ab', '#f00,#0f0')).toEqual([ '#f00', '#0f0' ]);
});
it('reuses the last color when the text is longer than the color list', () =>
{
expect(parsePrefixColors('abcd', '#f00,#0f0')).toEqual([ '#f00', '#0f0', '#0f0', '#0f0' ]);
});
});
describe('PrefixUtils.getPrefixFontStyle', () =>
{
it('returns an empty object for the default (empty) font id', () =>
{
expect(getPrefixFontStyle('')).toEqual({});
});
it('returns a fontFamily for a known preset', () =>
{
const out = getPrefixFontStyle('pixel');
expect(out.fontFamily).toBe(PRESET_PREFIX_FONTS.find(p => p.id === 'pixel')?.family);
});
it('returns an empty object for an unknown font id', () =>
{
expect(getPrefixFontStyle('does-not-exist')).toEqual({});
});
});
+100
View File
@@ -0,0 +1,100 @@
import { describe, expect, it, vi } from 'vitest';
/**
* Mock LocalizeText (which transitively imports @nitrots/nitro-renderer)
* with a deterministic stub. The stub returns `key|amount` so each test
* can assert both the bucket FriendlyTime chose AND the value it computed.
*/
vi.mock('./LocalizeText', () => ({
LocalizeText: (key: string, _params?: string[], replacements?: string[]) =>
`${ key }|${ replacements?.[0] ?? '' }`
}));
import { FriendlyTime } from './FriendlyTime';
const MINUTE = 60;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
const MONTH = 30 * DAY;
const YEAR = 365 * DAY;
describe('FriendlyTime.format', () =>
{
it('uses the seconds bucket for small values', () =>
{
expect(FriendlyTime.format(5)).toBe('friendlytime.seconds|5');
expect(FriendlyTime.format(0)).toBe('friendlytime.seconds|0');
});
it('uses the minutes bucket once we cross 3 * 60s (default threshold)', () =>
{
expect(FriendlyTime.format(4 * MINUTE)).toBe('friendlytime.minutes|4');
expect(FriendlyTime.format(10 * MINUTE)).toBe('friendlytime.minutes|10');
});
it('uses the hours bucket above 3 * HOUR', () =>
{
expect(FriendlyTime.format(4 * HOUR)).toBe('friendlytime.hours|4');
});
it('uses the days bucket above 3 * DAY', () =>
{
expect(FriendlyTime.format(5 * DAY)).toBe('friendlytime.days|5');
});
it('uses the months bucket above 3 * MONTH', () =>
{
expect(FriendlyTime.format(4 * MONTH)).toBe('friendlytime.months|4');
});
it('uses the years bucket above 3 * YEAR', () =>
{
expect(FriendlyTime.format(4 * YEAR)).toBe('friendlytime.years|4');
});
it('rounds half-hours correctly inside the hours bucket', () =>
{
// 4.5 hours -> rounds to 5
expect(FriendlyTime.format((4 * HOUR) + (30 * MINUTE))).toBe('friendlytime.hours|5');
});
it('threshold=1 lets the larger bucket win sooner', () =>
{
// With default threshold=3, 90s would stay in "seconds"; with threshold=1
// it crosses into "minutes" (90s > 1*60s).
expect(FriendlyTime.format(90, '', 1)).toBe('friendlytime.minutes|2');
});
it('key suffix is appended to the bucket key', () =>
{
// Useful for plurals / variants ('s' for singular fallback, etc.)
expect(FriendlyTime.format(5, '.foo')).toBe('friendlytime.seconds.foo|5');
expect(FriendlyTime.format(4 * HOUR, '.foo')).toBe('friendlytime.hours.foo|4');
});
});
describe('FriendlyTime.shortFormat', () =>
{
it('uses the .short variant of each bucket', () =>
{
expect(FriendlyTime.shortFormat(5)).toBe('friendlytime.seconds.short|5');
expect(FriendlyTime.shortFormat(4 * MINUTE)).toBe('friendlytime.minutes.short|4');
expect(FriendlyTime.shortFormat(4 * HOUR)).toBe('friendlytime.hours.short|4');
expect(FriendlyTime.shortFormat(5 * DAY)).toBe('friendlytime.days.short|5');
expect(FriendlyTime.shortFormat(4 * MONTH)).toBe('friendlytime.months.short|4');
expect(FriendlyTime.shortFormat(4 * YEAR)).toBe('friendlytime.years.short|4');
});
it('respects the optional key suffix and threshold', () =>
{
expect(FriendlyTime.shortFormat(2 * MINUTE, '.bar', 1)).toBe('friendlytime.minutes.short.bar|2');
});
});
describe('FriendlyTime.getLocalization', () =>
{
it('formats an arbitrary key and amount with the (amount, AMOUNT) replacements', () =>
{
expect(FriendlyTime.getLocalization('whatever', 42)).toBe('whatever|42');
});
});
+4 -1
View File
@@ -1,4 +1,7 @@
let _youtubeEnabled = false;
export const getYoutubeRoomEnabled = () => _youtubeEnabled;
export const setYoutubeRoomEnabled = (enabled: boolean) => { _youtubeEnabled = enabled; };
export const setYoutubeRoomEnabled = (enabled: boolean) =>
{
_youtubeEnabled = enabled;
};