refactor store

This commit is contained in:
pa
2026-03-06 22:42:43 +09:00
parent e665b3815d
commit 8ddedb2d2d
29 changed files with 3269 additions and 888 deletions

View File

@@ -0,0 +1,93 @@
import { describe, expect, it, vi } from 'vitest';
import { evictMapCache } from '../cacheUtils';
describe('evictMapCache', () => {
it('does nothing when cache is under maxSize', () => {
const cache = new Map([
['a', 1],
['b', 2]
]);
const result = evictMapCache(cache, 5, () => false);
expect(result.deletedCount).toBe(0);
expect(cache.size).toBe(2);
});
it('evicts entries when cache exceeds maxSize', () => {
const cache = new Map();
for (let i = 0; i < 10; i++) {
cache.set(`key_${i}`, i);
}
const result = evictMapCache(cache, 5, () => false);
expect(result.deletedCount).toBe(5);
expect(cache.size).toBe(5);
});
it('retains entries matching isRetainedFn', () => {
const cache = new Map([
['keep_1', 'retained'],
['keep_2', 'retained'],
['evict_1', 'evictable'],
['evict_2', 'evictable'],
['evict_3', 'evictable']
]);
const result = evictMapCache(cache, 2, (_value, key) =>
key.startsWith('keep_')
);
// Should have evicted evictable entries but retained keep entries
expect(cache.has('keep_1')).toBe(true);
expect(cache.has('keep_2')).toBe(true);
expect(result.deletedCount).toBe(3);
});
it('uses custom sortFn for eviction order', () => {
const cache = new Map([
['old', { age: 1 }],
['new', { age: 100 }],
['medium', { age: 50 }]
]);
const result = evictMapCache(cache, 1, () => false, {
sortFn: (a, b) => a.value.age - b.value.age
});
// Should evict oldest first
expect(result.deletedCount).toBe(2);
expect(cache.has('new')).toBe(true);
expect(cache.has('old')).toBe(false);
expect(cache.has('medium')).toBe(false);
});
it('logs when logLabel is provided', () => {
const spy = vi.spyOn(console, 'log').mockImplementation(() => {});
const cache = new Map([
['a', 1],
['b', 2],
['c', 3]
]);
evictMapCache(cache, 1, () => false, { logLabel: 'Test cleanup' });
expect(spy).toHaveBeenCalledWith(
expect.stringContaining('Test cleanup')
);
spy.mockRestore();
});
it('does not evict retained entries even when all need eviction', () => {
const cache = new Map([
['a', 1],
['b', 2],
['c', 3]
]);
const result = evictMapCache(cache, 1, () => true);
// All entries are retained
expect(result.deletedCount).toBe(0);
expect(cache.size).toBe(3);
});
it('handles exact maxSize (no eviction needed)', () => {
const cache = new Map([
['a', 1],
['b', 2]
]);
const result = evictMapCache(cache, 2, () => false);
expect(result.deletedCount).toBe(0);
expect(cache.size).toBe(2);
});
});

View File

@@ -0,0 +1,241 @@
import { describe, expect, test, vi } from 'vitest';
import {
getPlatformLabel,
getStatusInfo,
getRpcWorldConfig,
isPopcornPalaceWorld
} from '../discordPresence';
import { ActivityType, StatusDisplayType } from '../../constants/discord';
const t = (key) => key;
describe('getPlatformLabel', () => {
test('returns VR label when game is running in VR', () => {
const result = getPlatformLabel('standalonewindows', true, false, t);
expect(result).toBe(' (view.settings.discord_presence.rpc.vr)');
});
test('returns desktop label when game is running in desktop mode', () => {
const result = getPlatformLabel('standalonewindows', true, true, t);
expect(result).toBe(' (view.settings.discord_presence.rpc.desktop)');
});
test('returns empty string for web platform', () => {
expect(getPlatformLabel('web', false, false, t)).toBe('');
});
test('returns (PC) for standalonewindows', () => {
expect(getPlatformLabel('standalonewindows', false, false, t)).toBe(
' (PC)'
);
});
test('returns (Android) for android', () => {
expect(getPlatformLabel('android', false, false, t)).toBe(' (Android)');
});
test('returns (iOS) for ios', () => {
expect(getPlatformLabel('ios', false, false, t)).toBe(' (iOS)');
});
test('returns platform name in parens for unknown platform', () => {
expect(getPlatformLabel('quest', false, false, t)).toBe(' (quest)');
});
test('returns empty string for empty/falsy platform when not game running', () => {
expect(getPlatformLabel('', false, false, t)).toBe('');
expect(getPlatformLabel(undefined, false, false, t)).toBe('');
});
});
describe('getStatusInfo', () => {
test('active status', () => {
const result = getStatusInfo('active', false, t);
expect(result).toEqual({
statusName: 'dialog.user.status.active',
statusImage: 'active',
hidePrivate: false
});
});
test('join me status', () => {
const result = getStatusInfo('join me', false, t);
expect(result).toEqual({
statusName: 'dialog.user.status.join_me',
statusImage: 'joinme',
hidePrivate: false
});
});
test('ask me status without hide invite', () => {
const result = getStatusInfo('ask me', false, t);
expect(result).toEqual({
statusName: 'dialog.user.status.ask_me',
statusImage: 'askme',
hidePrivate: false
});
});
test('ask me status with hide invite', () => {
const result = getStatusInfo('ask me', true, t);
expect(result).toEqual({
statusName: 'dialog.user.status.ask_me',
statusImage: 'askme',
hidePrivate: true
});
});
test('busy status always hides private', () => {
const result = getStatusInfo('busy', false, t);
expect(result).toEqual({
statusName: 'dialog.user.status.busy',
statusImage: 'busy',
hidePrivate: true
});
});
test('unknown status defaults to offline', () => {
const result = getStatusInfo('unknown', false, t);
expect(result).toEqual({
statusName: 'dialog.user.status.offline',
statusImage: 'offline',
hidePrivate: true
});
});
test('empty status defaults to offline', () => {
const result = getStatusInfo('', false, t);
expect(result).toEqual({
statusName: 'dialog.user.status.offline',
statusImage: 'offline',
hidePrivate: true
});
});
});
describe('getRpcWorldConfig', () => {
test('returns PyPyDance config for known PyPyDance world', () => {
const config = getRpcWorldConfig(
'wrld_f20326da-f1ac-45fc-a062-609723b097b1'
);
expect(config).toEqual({
activityType: ActivityType.Listening,
statusDisplayType: StatusDisplayType.Details,
appId: '784094509008551956',
bigIcon: 'pypy'
});
});
test('returns VR Dancing config', () => {
const config = getRpcWorldConfig(
'wrld_42377cf1-c54f-45ed-8996-5875b0573a83'
);
expect(config).toEqual({
activityType: ActivityType.Listening,
statusDisplayType: StatusDisplayType.Details,
appId: '846232616054030376',
bigIcon: 'vr_dancing'
});
});
test('returns ZuwaZuwa Dance config', () => {
const config = getRpcWorldConfig(
'wrld_52bdcdab-11cd-4325-9655-0fb120846945'
);
expect(config).toEqual({
activityType: ActivityType.Listening,
statusDisplayType: StatusDisplayType.Details,
appId: '939473404808007731',
bigIcon: 'zuwa_zuwa_dance'
});
});
test('returns LS Media config', () => {
const config = getRpcWorldConfig(
'wrld_74970324-58e8-4239-a17b-2c59dfdf00db'
);
expect(config).toEqual({
activityType: ActivityType.Watching,
statusDisplayType: StatusDisplayType.Details,
appId: '968292722391785512',
bigIcon: 'ls_media'
});
});
test('returns Popcorn Palace config', () => {
const config = getRpcWorldConfig(
'wrld_266523e8-9161-40da-acd0-6bd82e075833'
);
expect(config).toEqual({
activityType: ActivityType.Watching,
statusDisplayType: StatusDisplayType.Details,
appId: '1095440531821170820',
bigIcon: 'popcorn_palace'
});
});
test('returns null for unknown world', () => {
expect(getRpcWorldConfig('wrld_unknown')).toBeNull();
});
test('returns null for empty string', () => {
expect(getRpcWorldConfig('')).toBeNull();
});
test('returns a copy, not the original object', () => {
const a = getRpcWorldConfig(
'wrld_f20326da-f1ac-45fc-a062-609723b097b1'
);
const b = getRpcWorldConfig(
'wrld_f20326da-f1ac-45fc-a062-609723b097b1'
);
expect(a).toEqual(b);
expect(a).not.toBe(b);
});
test('covers all PyPyDance world IDs', () => {
const pypyIds = [
'wrld_f20326da-f1ac-45fc-a062-609723b097b1',
'wrld_10e5e467-fc65-42ed-8957-f02cace1398c',
'wrld_04899f23-e182-4a8d-b2c7-2c74c7c15534'
];
for (const id of pypyIds) {
const config = getRpcWorldConfig(id);
expect(config.appId).toBe('784094509008551956');
expect(config.bigIcon).toBe('pypy');
}
});
test('covers all LS Media world IDs', () => {
const lsIds = [
'wrld_74970324-58e8-4239-a17b-2c59dfdf00db',
'wrld_db9d878f-6e76-4776-8bf2-15bcdd7fc445',
'wrld_435bbf25-f34f-4b8b-82c6-cd809057eb8e',
'wrld_f767d1c8-b249-4ecc-a56f-614e433682c8'
];
for (const id of lsIds) {
const config = getRpcWorldConfig(id);
expect(config.appId).toBe('968292722391785512');
expect(config.bigIcon).toBe('ls_media');
}
});
});
describe('isPopcornPalaceWorld', () => {
test('returns true for Popcorn Palace worlds', () => {
expect(
isPopcornPalaceWorld('wrld_266523e8-9161-40da-acd0-6bd82e075833')
).toBe(true);
expect(
isPopcornPalaceWorld('wrld_27c7e6b2-d938-447e-a270-3d1a873e2cf3')
).toBe(true);
});
test('returns false for non-Popcorn Palace worlds', () => {
expect(
isPopcornPalaceWorld('wrld_f20326da-f1ac-45fc-a062-609723b097b1')
).toBe(false);
expect(isPopcornPalaceWorld('wrld_unknown')).toBe(false);
});
});

View File

@@ -0,0 +1,418 @@
import {
sanitizeUserJson,
sanitizeEntityJson,
computeTrustLevel,
computeUserPlatform,
computeDisabledContentSettings,
diffObjectProps,
createDefaultUserRef,
createDefaultWorldRef,
createDefaultAvatarRef,
createDefaultGroupRef,
createDefaultInstanceRef,
createDefaultFavoriteGroupRef,
createDefaultFavoriteCachedRef
} from '../entityTransforms';
describe('sanitizeUserJson', () => {
it('applies replaceBioSymbols to statusDescription, bio, note', () => {
const json = {
statusDescription: 'hello world',
bio: 'test bio',
note: 'test note'
};
sanitizeUserJson(json, '');
// replaceBioSymbols replaces Unicode look-alikes with ASCII
expect(json.statusDescription).toContain('?');
expect(json.bio).toContain('#');
expect(json.note).toContain('@');
});
it('removes emojis from statusDescription', () => {
const json = { statusDescription: 'hello 🎉 world' };
sanitizeUserJson(json, '');
// removeEmojis removes emoji then collapses whitespace
expect(json.statusDescription).toBe('hello world');
});
it('strips robot avatar URL', () => {
const robotUrl = 'https://example.com/robot.png';
const json = {
currentAvatarImageUrl: robotUrl,
currentAvatarThumbnailImageUrl: 'thumb.png'
};
sanitizeUserJson(json, robotUrl);
expect(json.currentAvatarImageUrl).toBeUndefined();
expect(json.currentAvatarThumbnailImageUrl).toBeUndefined();
});
it('keeps avatar URL when it does not match robot', () => {
const json = {
currentAvatarImageUrl: 'https://example.com/user.png',
currentAvatarThumbnailImageUrl: 'thumb.png'
};
sanitizeUserJson(json, 'https://example.com/robot.png');
expect(json.currentAvatarImageUrl).toBe('https://example.com/user.png');
});
it('handles missing fields gracefully', () => {
const json = { id: 'usr_123' };
expect(() => sanitizeUserJson(json, '')).not.toThrow();
});
});
describe('sanitizeEntityJson', () => {
it('applies replaceBioSymbols to specified fields', () => {
const json = {
name: 'hello',
description: 'test',
other: 'unchanged'
};
sanitizeEntityJson(json, ['name', 'description']);
expect(json.name).toContain('?');
expect(json.description).toContain('#');
expect(json.other).toContain(''); // not sanitized, still has Unicode
});
it('skips falsy fields', () => {
const json = { name: '', description: null };
expect(() =>
sanitizeEntityJson(json, ['name', 'description'])
).not.toThrow();
});
});
describe('computeTrustLevel', () => {
it('returns Visitor for empty tags', () => {
const result = computeTrustLevel([], '');
expect(result.trustLevel).toBe('Visitor');
expect(result.trustClass).toBe('x-tag-untrusted');
expect(result.trustColorKey).toBe('untrusted');
expect(result.trustSortNum).toBe(1);
});
it('returns Trusted User for veteran tags', () => {
const result = computeTrustLevel(['system_trust_veteran'], '');
expect(result.trustLevel).toBe('Trusted User');
expect(result.trustClass).toBe('x-tag-veteran');
expect(result.trustColorKey).toBe('veteran');
expect(result.trustSortNum).toBe(5);
});
it('returns Known User for trusted tags', () => {
const result = computeTrustLevel(['system_trust_trusted'], '');
expect(result.trustLevel).toBe('Known User');
expect(result.trustSortNum).toBe(4);
});
it('returns User for known tags', () => {
const result = computeTrustLevel(['system_trust_known'], '');
expect(result.trustLevel).toBe('User');
expect(result.trustSortNum).toBe(3);
});
it('returns New User for basic tags', () => {
const result = computeTrustLevel(['system_trust_basic'], '');
expect(result.trustLevel).toBe('New User');
expect(result.trustSortNum).toBe(2);
});
it('detects troll status', () => {
const result = computeTrustLevel(
['system_troll', 'system_trust_known'],
''
);
expect(result.isTroll).toBe(true);
expect(result.trustColorKey).toBe('troll');
expect(result.trustSortNum).toBeCloseTo(3.1); // 3 + 0.1
});
it('detects probable troll when not already troll', () => {
const result = computeTrustLevel(
['system_probable_troll', 'system_trust_basic'],
''
);
expect(result.isProbableTroll).toBe(true);
expect(result.isTroll).toBe(false);
expect(result.trustColorKey).toBe('troll');
});
it('probable troll is not set when already troll', () => {
const result = computeTrustLevel(
['system_troll', 'system_probable_troll'],
''
);
expect(result.isTroll).toBe(true);
expect(result.isProbableTroll).toBe(false);
});
it('detects moderator from developerType', () => {
const result = computeTrustLevel([], 'internal');
expect(result.isModerator).toBe(true);
expect(result.trustColorKey).toBe('vip');
expect(result.trustSortNum).toBeCloseTo(1.3); // 1 + 0.3
});
it('detects moderator from admin_moderator tag', () => {
const result = computeTrustLevel(
['admin_moderator', 'system_trust_veteran'],
''
);
expect(result.isModerator).toBe(true);
expect(result.trustColorKey).toBe('vip');
});
it('does not treat "none" developerType as moderator', () => {
const result = computeTrustLevel([], 'none');
expect(result.isModerator).toBe(false);
});
});
describe('computeUserPlatform', () => {
it('returns platform when valid', () => {
expect(computeUserPlatform('standalonewindows', 'android')).toBe(
'standalonewindows'
);
});
it('falls back to last_platform when platform is "offline"', () => {
expect(computeUserPlatform('offline', 'android')).toBe('android');
});
it('falls back to last_platform when platform is "web"', () => {
expect(computeUserPlatform('web', 'ios')).toBe('ios');
});
it('falls back to last_platform when platform is empty', () => {
expect(computeUserPlatform('', 'standalonewindows')).toBe(
'standalonewindows'
);
});
it('returns empty string when both are empty', () => {
expect(computeUserPlatform('', '')).toBe('');
});
});
describe('computeDisabledContentSettings', () => {
const settingsList = ['gore', 'nudity', 'violence'];
it('returns empty for null contentSettings', () => {
expect(computeDisabledContentSettings(null, settingsList)).toEqual([]);
});
it('returns empty for empty object', () => {
expect(computeDisabledContentSettings({}, settingsList)).toEqual([]);
});
it('returns disabled settings (false values)', () => {
const result = computeDisabledContentSettings(
{ gore: false, nudity: true, violence: false },
settingsList
);
expect(result).toEqual(['gore', 'violence']);
});
it('skips undefined settings', () => {
const result = computeDisabledContentSettings(
{ gore: true },
settingsList
);
expect(result).toEqual([]);
});
});
describe('diffObjectProps', () => {
const arraysMatch = (a, b) =>
a.length === b.length && a.every((v, i) => v === b[i]);
it('detects changed primitive props', () => {
const ref = { name: 'old', id: '1' };
const json = { name: 'new', id: '1' };
const result = diffObjectProps(ref, json, arraysMatch);
expect(result.hasPropChanged).toBe(true);
expect(result.changedProps.name).toEqual(['new', 'old']);
});
it('detects unchanged props', () => {
const ref = { name: 'same', id: '1' };
const json = { name: 'same', id: '1' };
const result = diffObjectProps(ref, json, arraysMatch);
expect(result.hasPropChanged).toBe(false);
});
it('detects changed arrays', () => {
const ref = { tags: ['a', 'b'] };
const json = { tags: ['a', 'c'] };
const result = diffObjectProps(ref, json, arraysMatch);
expect(result.hasPropChanged).toBe(true);
expect(result.changedProps.tags).toBeDefined();
});
it('ignores props only in json (not in ref)', () => {
const ref = { id: '1' };
const json = { id: '1', newProp: 'value' };
const result = diffObjectProps(ref, json, arraysMatch);
expect(result.hasPropChanged).toBe(false);
});
it('ignores props only in ref (not in json)', () => {
const ref = { id: '1', extra: 'value' };
const json = { id: '1' };
const result = diffObjectProps(ref, json, arraysMatch);
expect(result.hasPropChanged).toBe(false);
});
});
describe('createDefaultUserRef', () => {
it('creates object with defaults', () => {
const ref = createDefaultUserRef({});
expect(ref.id).toBe('');
expect(ref.displayName).toBe('');
expect(ref.tags).toEqual([]);
expect(ref.$trustLevel).toBe('Visitor');
expect(ref.$platform).toBe('');
});
it('spreads json over defaults', () => {
const ref = createDefaultUserRef({
id: 'usr_123',
displayName: 'Test'
});
expect(ref.id).toBe('usr_123');
expect(ref.displayName).toBe('Test');
expect(ref.bio).toBe('');
});
});
describe('createDefaultWorldRef', () => {
it('creates object with defaults', () => {
const ref = createDefaultWorldRef({});
expect(ref.id).toBe('');
expect(ref.name).toBe('');
expect(ref.capacity).toBe(0);
expect(ref.$isLabs).toBe(false);
});
it('spreads json over defaults', () => {
const ref = createDefaultWorldRef({
id: 'wrld_123',
name: 'Test World'
});
expect(ref.id).toBe('wrld_123');
expect(ref.name).toBe('Test World');
});
});
describe('createDefaultAvatarRef', () => {
it('creates object with defaults', () => {
const ref = createDefaultAvatarRef({});
expect(ref.id).toBe('');
expect(ref.name).toBe('');
expect(ref.version).toBe(0);
expect(ref.tags).toEqual([]);
});
it('spreads json over defaults', () => {
const ref = createDefaultAvatarRef({
id: 'avtr_123',
name: 'My Avatar'
});
expect(ref.id).toBe('avtr_123');
expect(ref.name).toBe('My Avatar');
});
});
describe('createDefaultGroupRef', () => {
it('creates object with defaults including myMember', () => {
const ref = createDefaultGroupRef({});
expect(ref.id).toBe('');
expect(ref.name).toBe('');
expect(ref.myMember).toBeDefined();
expect(ref.myMember.roleIds).toEqual([]);
expect(ref.roles).toEqual([]);
});
it('spreads json over defaults', () => {
const ref = createDefaultGroupRef({
id: 'grp_123',
name: 'Test Group'
});
expect(ref.id).toBe('grp_123');
});
});
describe('createDefaultInstanceRef', () => {
it('creates object with defaults', () => {
const ref = createDefaultInstanceRef({});
expect(ref.id).toBe('');
expect(ref.capacity).toBe(0);
expect(ref.hasCapacityForYou).toBe(true);
expect(ref.$fetchedAt).toBe('');
expect(ref.$disabledContentSettings).toEqual([]);
});
it('spreads json over defaults', () => {
const ref = createDefaultInstanceRef({
id: 'wrld_123:12345',
capacity: 40
});
expect(ref.id).toBe('wrld_123:12345');
expect(ref.capacity).toBe(40);
});
});
describe('createDefaultFavoriteGroupRef', () => {
it('creates object with defaults', () => {
const ref = createDefaultFavoriteGroupRef({});
expect(ref.id).toBe('');
expect(ref.name).toBe('');
expect(ref.displayName).toBe('');
expect(ref.type).toBe('');
expect(ref.visibility).toBe('');
expect(ref.tags).toEqual([]);
});
it('spreads json over defaults', () => {
const ref = createDefaultFavoriteGroupRef({
id: 'fvgrp_1',
name: 'group_0',
displayName: 'Group 1',
type: 'friend'
});
expect(ref.id).toBe('fvgrp_1');
expect(ref.name).toBe('group_0');
expect(ref.displayName).toBe('Group 1');
expect(ref.type).toBe('friend');
});
});
describe('createDefaultFavoriteCachedRef', () => {
it('creates object with defaults and computes $groupKey', () => {
const ref = createDefaultFavoriteCachedRef({});
expect(ref.id).toBe('');
expect(ref.type).toBe('');
expect(ref.favoriteId).toBe('');
expect(ref.tags).toEqual([]);
expect(ref.$groupKey).toBe(':undefined');
});
it('computes $groupKey from type and first tag', () => {
const ref = createDefaultFavoriteCachedRef({
id: 'fav_1',
type: 'friend',
favoriteId: 'usr_123',
tags: ['group_0']
});
expect(ref.$groupKey).toBe('friend:group_0');
expect(ref.favoriteId).toBe('usr_123');
});
it('handles multiple tags (uses first)', () => {
const ref = createDefaultFavoriteCachedRef({
type: 'world',
tags: ['worlds1', 'worlds2']
});
expect(ref.$groupKey).toBe('world:worlds1');
});
});

View File

@@ -1,7 +1,13 @@
import {
compareGameLogRows,
createJoinLeaveEntry,
createLocationEntry,
createPortalSpawnEntry,
createResourceLoadEntry,
gameLogSearchFilter,
getGameLogCreatedAtTs
getGameLogCreatedAtTs,
parseInventoryFromUrl,
parsePrintFromUrl
} from '../gameLog';
describe('gameLogSearchFilter', () => {
@@ -184,3 +190,159 @@ describe('compareGameLogRows', () => {
expect(compareGameLogRows(a, b)).toBe(0);
});
});
describe('createLocationEntry', () => {
test('creates entry with correct shape', () => {
const entry = createLocationEntry(
'2024-01-15T12:00:00Z',
'wrld_abc123~12345',
'wrld_abc123',
'Test World'
);
expect(entry).toEqual({
created_at: '2024-01-15T12:00:00Z',
type: 'Location',
location: 'wrld_abc123~12345',
worldId: 'wrld_abc123',
worldName: 'Test World',
groupName: '',
time: 0
});
});
});
describe('createJoinLeaveEntry', () => {
test('creates OnPlayerJoined entry with default time', () => {
const entry = createJoinLeaveEntry(
'OnPlayerJoined',
'2024-01-15T12:00:00Z',
'Alice',
'wrld_abc~123',
'usr_abc'
);
expect(entry).toEqual({
created_at: '2024-01-15T12:00:00Z',
type: 'OnPlayerJoined',
displayName: 'Alice',
location: 'wrld_abc~123',
userId: 'usr_abc',
time: 0
});
});
test('creates OnPlayerLeft entry with custom time', () => {
const entry = createJoinLeaveEntry(
'OnPlayerLeft',
'2024-01-15T12:30:00Z',
'Bob',
'wrld_xyz~456',
'usr_xyz',
1800000
);
expect(entry.type).toBe('OnPlayerLeft');
expect(entry.time).toBe(1800000);
});
});
describe('createPortalSpawnEntry', () => {
test('creates portal spawn entry with empty defaults', () => {
const entry = createPortalSpawnEntry(
'2024-01-15T12:00:00Z',
'wrld_abc~123'
);
expect(entry).toEqual({
created_at: '2024-01-15T12:00:00Z',
type: 'PortalSpawn',
location: 'wrld_abc~123',
displayName: '',
userId: '',
instanceId: '',
worldName: ''
});
});
});
describe('createResourceLoadEntry', () => {
test('maps resource-load-string to StringLoad', () => {
const entry = createResourceLoadEntry(
'resource-load-string',
'2024-01-15T12:00:00Z',
'https://cdn.example.com/res.json',
'wrld_abc~123'
);
expect(entry.type).toBe('StringLoad');
expect(entry.resourceUrl).toBe('https://cdn.example.com/res.json');
});
test('maps resource-load-image to ImageLoad', () => {
const entry = createResourceLoadEntry(
'resource-load-image',
'2024-01-15T12:00:00Z',
'https://cdn.example.com/img.png',
'wrld_abc~123'
);
expect(entry.type).toBe('ImageLoad');
});
});
describe('parseInventoryFromUrl', () => {
test('parses valid inventory URL', () => {
const url =
'https://api.vrchat.cloud/api/1/user/usr_032383a7-748c-4fb2-94e4-bcb928e5de6b/inventory/inv_75781d65-92fe-4a80-a1ff-27ee6e843b08';
const result = parseInventoryFromUrl(url);
expect(result).toEqual({
userId: 'usr_032383a7-748c-4fb2-94e4-bcb928e5de6b',
inventoryId: 'inv_75781d65-92fe-4a80-a1ff-27ee6e843b08'
});
});
test('returns null for non-inventory URL', () => {
expect(
parseInventoryFromUrl(
'https://api.vrchat.cloud/api/1/user/usr_abc/avatar'
)
).toBeNull();
});
test('returns null for invalid URL', () => {
expect(parseInventoryFromUrl('not a url')).toBeNull();
});
test('returns null for empty string', () => {
expect(parseInventoryFromUrl('')).toBeNull();
});
test('returns null if inventoryId length is wrong', () => {
expect(
parseInventoryFromUrl(
'https://api.vrchat.cloud/api/1/user/usr_abc/inventory/inv_short'
)
).toBeNull();
});
});
describe('parsePrintFromUrl', () => {
test('parses valid print URL', () => {
// printId is 41 chars: prnt_ (5) + UUID (36)
const printId = 'prnt_aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
const url = `https://api.vrchat.cloud/api/1/prints/${printId}`;
const result = parsePrintFromUrl(url);
expect(result).toBe(printId);
});
test('returns null for non-print URL', () => {
expect(
parsePrintFromUrl('https://api.vrchat.cloud/api/1/user/usr_abc')
).toBeNull();
});
test('returns null for invalid URL', () => {
expect(parsePrintFromUrl('not a url')).toBeNull();
});
test('returns null if printId has wrong length', () => {
expect(
parsePrintFromUrl('https://api.vrchat.cloud/api/1/prints/short')
).toBeNull();
});
});

View File

@@ -1,9 +1,21 @@
import { vi } from 'vitest';
// Mock transitive deps from location.js → stores → columns.jsx → i18n
vi.mock('../../../views/Feed/Feed.vue', () => ({
default: { template: '<div />' }
}));
vi.mock('../../../views/Feed/columns.jsx', () => ({ columns: [] }));
vi.mock('../../../plugin/router', () => ({
default: { push: vi.fn(), currentRoute: { value: {} } }
}));
import {
displayLocation,
parseLocation,
resolveRegion,
translateAccessType
} from '../locationParser';
import { getLocationText } from '../location';
import { accessTypeLocaleKeyMap } from '../../constants';
describe('Location Utils', () => {
@@ -508,4 +520,78 @@ describe('Location Utils', () => {
);
});
});
describe('getLocationText', () => {
const t = (key) => key;
const opts = (overrides = {}) => ({
hint: '',
worldName: undefined,
accessTypeLabel: 'Public',
t,
...overrides
});
test('returns offline label', () => {
const L = parseLocation('offline');
expect(getLocationText(L, opts())).toBe('location.offline');
});
test('returns private label', () => {
const L = parseLocation('private');
expect(getLocationText(L, opts())).toBe('location.private');
});
test('returns traveling label', () => {
const L = parseLocation('traveling');
expect(getLocationText(L, opts())).toBe('location.traveling');
});
test('returns hint with access type when instance exists', () => {
const L = parseLocation('wrld_12345:67890');
expect(getLocationText(L, opts({ hint: 'My World' }))).toBe(
'My World · Public'
);
});
test('returns hint alone when no instance', () => {
const L = parseLocation('wrld_12345');
expect(getLocationText(L, opts({ hint: 'My World' }))).toBe(
'My World'
);
});
test('returns world name with access type when cached', () => {
const L = parseLocation('wrld_12345:67890');
expect(getLocationText(L, opts({ worldName: 'Cool World' }))).toBe(
'Cool World · Public'
);
});
test('returns world name alone when no instance', () => {
const L = parseLocation('wrld_12345');
expect(getLocationText(L, opts({ worldName: 'Cool World' }))).toBe(
'Cool World'
);
});
test('falls back to worldId when no cached name', () => {
const L = parseLocation('wrld_12345:67890');
expect(getLocationText(L, opts())).toBe('wrld_12345 · Public');
});
test('returns empty string for empty location', () => {
const L = parseLocation('');
expect(getLocationText(L, opts())).toBe('');
});
test('hint takes priority over worldName', () => {
const L = parseLocation('wrld_12345:67890');
expect(
getLocationText(
L,
opts({ hint: 'Hint Text', worldName: 'World Name' })
)
).toBe('Hint Text · Public');
});
});
});

View File

@@ -0,0 +1,211 @@
import { describe, it, expect, vi } from 'vitest';
import {
sanitizeNotificationJson,
parseNotificationDetails,
createDefaultNotificationRef,
createDefaultNotificationV2Ref,
applyBoopLegacyHandling
} from '../notificationTransforms';
describe('sanitizeNotificationJson', () => {
it('should remove null and undefined values', () => {
const json = { id: '1', message: null, type: undefined, seen: false };
const result = sanitizeNotificationJson(json);
expect(result).not.toHaveProperty('message');
expect(result).not.toHaveProperty('type');
expect(result).toHaveProperty('id', '1');
expect(result).toHaveProperty('seen', false);
});
it('should apply replaceBioSymbols to message', () => {
// replaceBioSymbols replaces Unicode look-alikes with ASCII, not zero-width spaces
const json = { message: 'hello world' };
const result = sanitizeNotificationJson(json);
expect(result.message).toContain('?');
});
it('should apply replaceBioSymbols to title', () => {
const json = { title: 'hello world' };
const result = sanitizeNotificationJson(json);
expect(result.title).toContain('?');
});
it('should not touch other fields', () => {
const json = { id: 'abc', seen: true, details: { x: 1 } };
const result = sanitizeNotificationJson(json);
expect(result).toEqual({ id: 'abc', seen: true, details: { x: 1 } });
});
it('should mutate and return the same object', () => {
const json = { id: '1', bad: null };
const result = sanitizeNotificationJson(json);
expect(result).toBe(json);
});
});
describe('parseNotificationDetails', () => {
it('should return object details as-is', () => {
const details = { worldId: 'wrld_123' };
expect(parseNotificationDetails(details)).toBe(details);
});
it('should parse JSON string details', () => {
const details = '{"worldId":"wrld_123"}';
expect(parseNotificationDetails(details)).toEqual({
worldId: 'wrld_123'
});
});
it('should return empty object for "{}"', () => {
expect(parseNotificationDetails('{}')).toEqual({});
});
it('should return empty object for invalid JSON', () => {
const spy = vi.spyOn(console, 'log').mockImplementation(() => {});
expect(parseNotificationDetails('not json')).toEqual({});
spy.mockRestore();
});
it('should return parsed array for JSON array string (arrays are objects)', () => {
expect(parseNotificationDetails('[1,2]')).toEqual([1, 2]);
});
it('should return empty object for null', () => {
expect(parseNotificationDetails(null)).toEqual({});
});
it('should return empty object for undefined', () => {
expect(parseNotificationDetails(undefined)).toEqual({});
});
});
describe('createDefaultNotificationRef', () => {
it('should create a ref with all default fields', () => {
const ref = createDefaultNotificationRef({});
expect(ref).toEqual({
id: '',
senderUserId: '',
senderUsername: '',
type: '',
message: '',
details: {},
seen: false,
created_at: '',
$isExpired: false
});
});
it('should merge json over defaults', () => {
const ref = createDefaultNotificationRef({
id: 'noti_1',
type: 'friendRequest',
senderUserId: 'usr_abc'
});
expect(ref.id).toBe('noti_1');
expect(ref.type).toBe('friendRequest');
expect(ref.senderUserId).toBe('usr_abc');
expect(ref.message).toBe('');
});
it('should parse string details', () => {
const ref = createDefaultNotificationRef({
details: '{"worldId":"wrld_1"}'
});
expect(ref.details).toEqual({ worldId: 'wrld_1' });
});
it('should keep object details', () => {
const details = { worldId: 'wrld_1' };
const ref = createDefaultNotificationRef({ details });
expect(ref.details).toBe(details);
});
});
describe('createDefaultNotificationV2Ref', () => {
it('should create a ref with all default V2 fields', () => {
const ref = createDefaultNotificationV2Ref({});
expect(ref).toMatchObject({
id: '',
createdAt: '',
updatedAt: '',
expiresAt: '',
type: '',
link: '',
linkText: '',
message: '',
title: '',
imageUrl: '',
seen: false,
senderUserId: '',
senderUsername: '',
version: 2
});
expect(ref.data).toEqual({});
expect(ref.responses).toEqual([]);
expect(ref.details).toEqual({});
});
it('should merge json over defaults', () => {
const ref = createDefaultNotificationV2Ref({
id: 'noti_v2',
type: 'boop',
seen: true
});
expect(ref.id).toBe('noti_v2');
expect(ref.type).toBe('boop');
expect(ref.seen).toBe(true);
});
});
describe('applyBoopLegacyHandling', () => {
it('should not modify non-boop notifications', () => {
const ref = {
type: 'friendRequest',
title: 'Hello',
message: '',
imageUrl: ''
};
applyBoopLegacyHandling(ref, 'https://api.example.com');
expect(ref.title).toBe('Hello');
expect(ref.message).toBe('');
});
it('should not modify boop without title', () => {
const ref = {
type: 'boop',
title: '',
message: 'existing',
imageUrl: ''
};
applyBoopLegacyHandling(ref, 'https://api.example.com');
expect(ref.message).toBe('existing');
});
it('should handle default emoji boops', () => {
const ref = {
type: 'boop',
title: 'Boop!',
message: '',
imageUrl: '',
details: { emojiId: 'default_wave', emojiVersion: '1' }
};
applyBoopLegacyHandling(ref, 'https://api.example.com');
expect(ref.title).toBe('');
expect(ref.message).toBe('Boop! wave');
expect(ref.imageUrl).toBe('default_wave');
});
it('should handle custom emoji boops', () => {
const ref = {
type: 'boop',
title: 'Boop!',
message: '',
imageUrl: '',
details: { emojiId: 'emj_123', emojiVersion: '5' }
};
applyBoopLegacyHandling(ref, 'https://api.example.com');
expect(ref.title).toBe('');
expect(ref.message).toBe('Boop!');
expect(ref.imageUrl).toBe('https://api.example.com/file/emj_123/5');
});
});

View File

@@ -145,6 +145,23 @@ function replaceBioSymbols(text) {
return newText.replace(/ {1,}/g, ' ').trimRight();
}
/**
* @param {string} text
* @returns {string}
*/
function removeEmojis(text) {
if (!text) {
return '';
}
return text
.replace(
/([\u2700-\u27BF]|[\uE000-\uF8FF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDFFF]|[\u2011-\u26FF]|\uD83E[\uDD10-\uDDFF])/g,
''
)
.replace(/\s+/g, ' ')
.trim();
}
export {
escapeTag,
escapeTagRecursive,
@@ -152,5 +169,6 @@ export {
commaNumber,
localeIncludes,
changeLogRemoveLinks,
replaceBioSymbols
replaceBioSymbols,
removeEmojis
};

View File

@@ -0,0 +1,67 @@
/**
* Evict entries from a Map cache when it exceeds maxSize.
* Entries matching isRetainedFn are kept; the rest are evicted oldest-first
* (or by the provided sortFn).
* @param {Map} cache - The cache Map to evict from
* @param {number} maxSize - Maximum allowed size
* @param {(value: any, key: string) => boolean} isRetainedFn - Return true to keep the entry
* @param {object} [opts] - Options
* @param {(a: {key: string, value: any}, b: {key: string, value: any}) => number} [opts.sortFn] -
* Custom sort for eviction order (entries sorted ascending; first entries evicted first).
* If not provided, entries are evicted in insertion order.
* @param {string} [opts.logLabel] - Label for console.log output
* @returns {{ deletedCount: number }}
*/
export function evictMapCache(cache, maxSize, isRetainedFn, opts = {}) {
if (cache.size <= maxSize) {
return { deletedCount: 0 };
}
const { sortFn, logLabel } = opts;
const overBy = cache.size - maxSize;
if (sortFn) {
// Collect removable entries, sort, then evict
const removable = [];
for (const [key, value] of cache) {
if (isRetainedFn(value, key)) {
continue;
}
removable.push({ key, value });
}
removable.sort(sortFn);
const toDelete = Math.min(overBy, removable.length);
for (let i = 0; i < toDelete; i++) {
cache.delete(removable[i].key);
}
if (logLabel) {
console.log(
`${logLabel}: Deleted ${toDelete}. Current cache size: ${cache.size}`
);
}
return { deletedCount: toDelete };
}
// Default: evict in insertion order (skip retained entries)
let deletedCount = 0;
const keysToDelete = [];
for (const [key, value] of cache) {
if (isRetainedFn(value, key)) {
continue;
}
if (deletedCount >= overBy) {
break;
}
keysToDelete.push(key);
deletedCount++;
}
for (const key of keysToDelete) {
cache.delete(key);
}
if (logLabel) {
console.log(
`${logLabel}: Deleted ${deletedCount}. Current cache size: ${cache.size}`
);
}
return { deletedCount };
}

View File

@@ -0,0 +1,228 @@
import { ActivityType, StatusDisplayType } from '../constants/discord';
/**
* RPC world configuration table.
* Maps worldId → { activityType, statusDisplayType, appId, bigIcon }.
*/
const RPC_WORLD_CONFIGS = new Map([
// PyPyDance
[
'wrld_f20326da-f1ac-45fc-a062-609723b097b1',
{
activityType: ActivityType.Listening,
statusDisplayType: StatusDisplayType.Details,
appId: '784094509008551956',
bigIcon: 'pypy'
}
],
[
'wrld_10e5e467-fc65-42ed-8957-f02cace1398c',
{
activityType: ActivityType.Listening,
statusDisplayType: StatusDisplayType.Details,
appId: '784094509008551956',
bigIcon: 'pypy'
}
],
[
'wrld_04899f23-e182-4a8d-b2c7-2c74c7c15534',
{
activityType: ActivityType.Listening,
statusDisplayType: StatusDisplayType.Details,
appId: '784094509008551956',
bigIcon: 'pypy'
}
],
// VR Dancing
[
'wrld_42377cf1-c54f-45ed-8996-5875b0573a83',
{
activityType: ActivityType.Listening,
statusDisplayType: StatusDisplayType.Details,
appId: '846232616054030376',
bigIcon: 'vr_dancing'
}
],
[
'wrld_dd6d2888-dbdc-47c2-bc98-3d631b2acd7c',
{
activityType: ActivityType.Listening,
statusDisplayType: StatusDisplayType.Details,
appId: '846232616054030376',
bigIcon: 'vr_dancing'
}
],
// ZuwaZuwa Dance
[
'wrld_52bdcdab-11cd-4325-9655-0fb120846945',
{
activityType: ActivityType.Listening,
statusDisplayType: StatusDisplayType.Details,
appId: '939473404808007731',
bigIcon: 'zuwa_zuwa_dance'
}
],
[
'wrld_2d40da63-8f1f-4011-8a9e-414eb8530acd',
{
activityType: ActivityType.Listening,
statusDisplayType: StatusDisplayType.Details,
appId: '939473404808007731',
bigIcon: 'zuwa_zuwa_dance'
}
],
// LS Media
[
'wrld_74970324-58e8-4239-a17b-2c59dfdf00db',
{
activityType: ActivityType.Watching,
statusDisplayType: StatusDisplayType.Details,
appId: '968292722391785512',
bigIcon: 'ls_media'
}
],
[
'wrld_db9d878f-6e76-4776-8bf2-15bcdd7fc445',
{
activityType: ActivityType.Watching,
statusDisplayType: StatusDisplayType.Details,
appId: '968292722391785512',
bigIcon: 'ls_media'
}
],
[
'wrld_435bbf25-f34f-4b8b-82c6-cd809057eb8e',
{
activityType: ActivityType.Watching,
statusDisplayType: StatusDisplayType.Details,
appId: '968292722391785512',
bigIcon: 'ls_media'
}
],
[
'wrld_f767d1c8-b249-4ecc-a56f-614e433682c8',
{
activityType: ActivityType.Watching,
statusDisplayType: StatusDisplayType.Details,
appId: '968292722391785512',
bigIcon: 'ls_media'
}
],
// Popcorn Palace
[
'wrld_266523e8-9161-40da-acd0-6bd82e075833',
{
activityType: ActivityType.Watching,
statusDisplayType: StatusDisplayType.Details,
appId: '1095440531821170820',
bigIcon: 'popcorn_palace'
}
],
[
'wrld_27c7e6b2-d938-447e-a270-3d1a873e2cf3',
{
activityType: ActivityType.Watching,
statusDisplayType: StatusDisplayType.Details,
appId: '1095440531821170820',
bigIcon: 'popcorn_palace'
}
]
]);
/** Set of Popcorn Palace world IDs (big icon can be overridden by thumbnail) */
const POPCORN_PALACE_WORLD_IDS = new Set([
'wrld_266523e8-9161-40da-acd0-6bd82e075833',
'wrld_27c7e6b2-d938-447e-a270-3d1a873e2cf3'
]);
/**
* Get custom world rpc configuration for a specific world ID.
* @param {string} worldId
* @returns {{ activityType: number, statusDisplayType: number, appId: string, bigIcon: string } | null}
*/
export function getRpcWorldConfig(worldId) {
const config = RPC_WORLD_CONFIGS.get(worldId);
if (!config) {
return null;
}
return { ...config };
}
/**
* Check if a world ID is a Popcorn Palace world.
* @param {string} worldId
* @returns {boolean}
*/
export function isPopcornPalaceWorld(worldId) {
return POPCORN_PALACE_WORLD_IDS.has(worldId);
}
/**
* Get the platform display label for Discord RPC.
* @param {string} platform - VRC platform string (e.g. 'standalonewindows', 'android')
* @param {boolean} isGameRunning
* @param {boolean} isGameNoVR
* @param {Function} t - i18n translate function
* @returns {string} Platform label string (e.g. ' (VR)', ' (PC)'), or empty string
*/
export function getPlatformLabel(platform, isGameRunning, isGameNoVR, t) {
if (isGameRunning) {
return isGameNoVR
? ` (${t('view.settings.discord_presence.rpc.desktop')})`
: ` (${t('view.settings.discord_presence.rpc.vr')})`;
}
switch (platform) {
case 'web':
return '';
case 'standalonewindows':
return ` (PC)`;
case 'android':
return ` (Android)`;
case 'ios':
return ` (iOS)`;
default:
return platform ? ` (${platform})` : '';
}
}
/**
* Get Discord status info from VRC user status.
* @param {string} status - VRC user status ('active', 'join me', 'ask me', 'busy')
* @param {boolean} discordHideInvite - Whether invite-hiding is enabled
* @param {Function} t - i18n translate function
* @returns {{ statusName: string, statusImage: string, hidePrivate: boolean }}
*/
export function getStatusInfo(status, discordHideInvite, t) {
switch (status) {
case 'active':
return {
statusName: t('dialog.user.status.active'),
statusImage: 'active',
hidePrivate: false
};
case 'join me':
return {
statusName: t('dialog.user.status.join_me'),
statusImage: 'joinme',
hidePrivate: false
};
case 'ask me':
return {
statusName: t('dialog.user.status.ask_me'),
statusImage: 'askme',
hidePrivate: discordHideInvite
};
case 'busy':
return {
statusName: t('dialog.user.status.busy'),
statusImage: 'busy',
hidePrivate: true
};
default:
return {
statusName: t('dialog.user.status.offline'),
statusImage: 'offline',
hidePrivate: true
};
}
}

View File

@@ -0,0 +1,532 @@
import { removeEmojis, replaceBioSymbols } from './base/string';
/**
* Sanitize user JSON fields before applying to cache.
* Applies replaceBioSymbols to statusDescription, bio, note;
* removeEmojis to statusDescription;
* strips robot avatar URL.
* @param {object} json - Raw user API response
* @param {string} robotUrl - The robot/default avatar URL to strip
* @returns {object} The mutated json (same reference)
*/
export function sanitizeUserJson(json, robotUrl) {
if (json.statusDescription) {
json.statusDescription = replaceBioSymbols(json.statusDescription);
json.statusDescription = removeEmojis(json.statusDescription);
}
if (json.bio) {
json.bio = replaceBioSymbols(json.bio);
}
if (json.note) {
json.note = replaceBioSymbols(json.note);
}
if (robotUrl && json.currentAvatarImageUrl === robotUrl) {
delete json.currentAvatarImageUrl;
delete json.currentAvatarThumbnailImageUrl;
}
return json;
}
/**
* Sanitize arbitrary entity JSON fields via replaceBioSymbols.
* @param {object} json - Raw API response
* @param {string[]} fields - Field names to sanitize
* @returns {object} The mutated json
*/
export function sanitizeEntityJson(json, fields) {
for (const field of fields) {
if (json[field]) {
json[field] = replaceBioSymbols(json[field]);
}
}
return json;
}
/**
* Compute trust level, moderator status, and troll status from user tags.
* Pure function — no store dependencies.
* @param {string[]} tags - User tags array
* @param {string} developerType - User's developerType field
* @returns {{
* trustLevel: string,
* trustClass: string,
* trustSortNum: number,
* isModerator: boolean,
* isTroll: boolean,
* isProbableTroll: boolean,
* trustColorKey: string
* }}
*/
export function computeTrustLevel(tags, developerType) {
let isModerator = Boolean(developerType) && developerType !== 'none';
let isTroll = false;
let isProbableTroll = false;
let trustLevel = 'Visitor';
let trustClass = 'x-tag-untrusted';
let trustColorKey = 'untrusted';
let trustSortNum = 1;
if (tags.includes('admin_moderator')) {
isModerator = true;
}
if (tags.includes('system_troll')) {
isTroll = true;
}
if (tags.includes('system_probable_troll') && !isTroll) {
isProbableTroll = true;
}
if (tags.includes('system_trust_veteran')) {
trustLevel = 'Trusted User';
trustClass = 'x-tag-veteran';
trustColorKey = 'veteran';
trustSortNum = 5;
} else if (tags.includes('system_trust_trusted')) {
trustLevel = 'Known User';
trustClass = 'x-tag-trusted';
trustColorKey = 'trusted';
trustSortNum = 4;
} else if (tags.includes('system_trust_known')) {
trustLevel = 'User';
trustClass = 'x-tag-known';
trustColorKey = 'known';
trustSortNum = 3;
} else if (tags.includes('system_trust_basic')) {
trustLevel = 'New User';
trustClass = 'x-tag-basic';
trustColorKey = 'basic';
trustSortNum = 2;
}
if (isTroll || isProbableTroll) {
trustColorKey = 'troll';
trustSortNum += 0.1;
}
if (isModerator) {
trustColorKey = 'vip';
trustSortNum += 0.3;
}
return {
trustLevel,
trustClass,
trustSortNum,
isModerator,
isTroll,
isProbableTroll,
trustColorKey
};
}
/**
* Determine the effective user platform.
* @param {string} platform - Current platform
* @param {string} lastPlatform - Last known platform
* @returns {string} Resolved platform
*/
export function computeUserPlatform(platform, lastPlatform) {
if (platform && platform !== 'offline' && platform !== 'web') {
return platform;
}
return lastPlatform || '';
}
/**
* Compute which content settings are disabled for an instance.
* @param {object} contentSettings - The instance's contentSettings object
* @param {string[]} settingsList - List of all possible content setting keys
* @returns {string[]} Array of disabled setting keys
*/
export function computeDisabledContentSettings(contentSettings, settingsList) {
const disabled = [];
if (!contentSettings || Object.keys(contentSettings).length === 0) {
return disabled;
}
for (const setting of settingsList) {
if (
typeof contentSettings[setting] === 'undefined' ||
contentSettings[setting] === true
) {
continue;
}
disabled.push(setting);
}
return disabled;
}
/**
* Detect which properties changed between an existing ref and incoming JSON.
* Compares primitives directly; arrays via arraysMatchFn.
* @param {object} ref - The existing cached object
* @param {object} json - The incoming update
* @param {(a: any[], b: any[]) => boolean} arraysMatchFn - Function to compare arrays
* @returns {{ hasPropChanged: boolean, changedProps: object }}
*/
export function diffObjectProps(ref, json, arraysMatchFn) {
const changedProps = {};
let hasPropChanged = false;
// Only compare primitive values
for (const prop in ref) {
if (typeof json[prop] === 'undefined') {
continue;
}
if (ref[prop] === null || typeof ref[prop] !== 'object') {
changedProps[prop] = true;
}
}
// Check json props against ref (including array comparison)
for (const prop in json) {
if (typeof ref[prop] === 'undefined') {
continue;
}
if (Array.isArray(json[prop]) && Array.isArray(ref[prop])) {
if (!arraysMatchFn(json[prop], ref[prop])) {
changedProps[prop] = true;
}
} else if (json[prop] === null || typeof json[prop] !== 'object') {
changedProps[prop] = true;
}
}
// Resolve actual changes
for (const prop in changedProps) {
const asIs = ref[prop];
const toBe = json[prop];
if (asIs === toBe) {
delete changedProps[prop];
} else {
hasPropChanged = true;
changedProps[prop] = [toBe, asIs];
}
}
return { hasPropChanged, changedProps };
}
/**
* Create a default user ref object with all expected fields.
* Returns a plain object (caller wraps in reactive() if needed).
* @param {object} json - API response to merge
* @returns {object} Default user object with json spread on top
*/
export function createDefaultUserRef(json) {
return {
ageVerificationStatus: '',
ageVerified: false,
allowAvatarCopying: false,
badges: [],
bio: '',
bioLinks: [],
currentAvatarImageUrl: '',
currentAvatarTags: [],
currentAvatarThumbnailImageUrl: '',
date_joined: '',
developerType: '',
discordId: '',
displayName: '',
friendKey: '',
friendRequestStatus: '',
id: '',
instanceId: '',
isFriend: false,
last_activity: '',
last_login: '',
last_mobile: null,
last_platform: '',
location: '',
platform: '',
note: null,
profilePicOverride: '',
profilePicOverrideThumbnail: '',
pronouns: '',
state: '',
status: '',
statusDescription: '',
tags: [],
travelingToInstance: '',
travelingToLocation: '',
travelingToWorld: '',
userIcon: '',
worldId: '',
// only in bulk request
fallbackAvatar: '',
// VRCX
$location: {},
$location_at: Date.now(),
$online_for: Date.now(),
$travelingToTime: Date.now(),
$offline_for: null,
$active_for: Date.now(),
$isVRCPlus: false,
$isModerator: false,
$isTroll: false,
$isProbableTroll: false,
$trustLevel: 'Visitor',
$trustClass: 'x-tag-untrusted',
$userColour: '',
$trustSortNum: 1,
$languages: [],
$joinCount: 0,
$timeSpent: 0,
$lastSeen: '',
$mutualCount: 0,
$nickName: '',
$previousLocation: '',
$customTag: '',
$customTagColour: '',
$friendNumber: 0,
$platform: '',
$moderations: {},
//
...json
};
}
/**
* Create a default world ref object.
* @param {object} json - API response to merge
* @returns {object}
*/
export function createDefaultWorldRef(json) {
return {
id: '',
name: '',
description: '',
defaultContentSettings: {},
authorId: '',
authorName: '',
capacity: 0,
recommendedCapacity: 0,
tags: [],
releaseStatus: '',
imageUrl: '',
thumbnailImageUrl: '',
assetUrl: '',
assetUrlObject: {},
pluginUrl: '',
pluginUrlObject: {},
unityPackageUrl: '',
unityPackageUrlObject: {},
unityPackages: [],
version: 0,
favorites: 0,
created_at: '',
updated_at: '',
publicationDate: '',
labsPublicationDate: '',
visits: 0,
popularity: 0,
heat: 0,
publicOccupants: 0,
privateOccupants: 0,
occupants: 0,
instances: [],
featured: false,
organization: '',
previewYoutubeId: '',
// VRCX
$isLabs: false,
//
...json
};
}
/**
* Create a default avatar ref object.
* @param {object} json - API response to merge
* @returns {object}
*/
export function createDefaultAvatarRef(json) {
return {
acknowledgements: '',
authorId: '',
authorName: '',
created_at: '',
description: '',
featured: false,
highestPrice: null,
id: '',
imageUrl: '',
listingDate: null,
lock: false,
lowestPrice: null,
name: '',
pendingUpload: false,
performance: {},
productId: null,
publishedListings: [],
releaseStatus: '',
searchable: false,
styles: [],
tags: [],
thumbnailImageUrl: '',
unityPackageUrl: '',
unityPackageUrlObject: {},
unityPackages: [],
updated_at: '',
version: 0,
...json
};
}
/**
* Create a default group ref object.
* @param {object} json - API response to merge
* @returns {object}
*/
export function createDefaultGroupRef(json) {
return {
id: '',
name: '',
shortCode: '',
description: '',
bannerId: '',
bannerUrl: '',
createdAt: '',
discriminator: '',
galleries: [],
iconId: '',
iconUrl: '',
isVerified: false,
joinState: '',
languages: [],
links: [],
memberCount: 0,
memberCountSyncedAt: '',
membershipStatus: '',
onlineMemberCount: 0,
ownerId: '',
privacy: '',
rules: null,
tags: [],
// in group
initialRoleIds: [],
myMember: {
bannedAt: null,
groupId: '',
has2FA: false,
id: '',
isRepresenting: false,
isSubscribedToAnnouncements: false,
joinedAt: '',
managerNotes: '',
membershipStatus: '',
permissions: [],
roleIds: [],
userId: '',
visibility: '',
_created_at: '',
_id: '',
_updated_at: ''
},
updatedAt: '',
// includeRoles: true
roles: [],
// group list
$memberId: '',
groupId: '',
isRepresenting: false,
memberVisibility: false,
mutualGroup: false,
// VRCX
$languages: [],
...json
};
}
/**
* Create a default instance ref object.
* @param {object} json - API response to merge
* @returns {object}
*/
export function createDefaultInstanceRef(json) {
return {
id: '',
location: '',
instanceId: '',
name: '',
worldId: '',
type: '',
ownerId: '',
tags: [],
active: false,
full: false,
n_users: 0,
hasCapacityForYou: true, // not present depending on endpoint
capacity: 0,
recommendedCapacity: 0,
userCount: 0,
queueEnabled: false, // only present with group instance type
queueSize: 0, // only present when queuing is enabled
platforms: {},
gameServerVersion: 0,
hardClose: null, // boolean or null
closedAt: null, // string or null
secureName: '',
shortName: '',
world: {},
users: [], // only present when you're the owner
clientNumber: '',
contentSettings: {},
photonRegion: '',
region: '',
canRequestInvite: false,
permanent: false,
private: '', // part of instance tag
hidden: '', // part of instance tag
nonce: '', // only present when you're the owner
strict: false, // deprecated
displayName: null,
groupAccessType: null, // only present with group instance type
roleRestricted: false, // only present with group instance type
instancePersistenceEnabled: null,
playerPersistenceEnabled: null,
ageGate: null,
// VRCX
$fetchedAt: '',
$disabledContentSettings: [],
...json
};
}
/**
* Build a default favorite group ref from JSON data.
* @param {object} json
* @returns {object}
*/
export function createDefaultFavoriteGroupRef(json) {
return {
id: '',
ownerId: '',
ownerDisplayName: '',
name: '',
displayName: '',
type: '',
visibility: '',
tags: [],
...json
};
}
/**
* Build a default cached favorite ref from JSON data.
* Computes $groupKey from type and first tag.
* @param {object} json
* @returns {object}
*/
export function createDefaultFavoriteCachedRef(json) {
const ref = {
id: '',
type: '',
favoriteId: '',
tags: [],
// VRCX
$groupKey: '',
//
...json
};
ref.$groupKey = `${ref.type}:${String(ref.tags[0])}`;
return ref;
}

View File

@@ -76,7 +76,6 @@ function gameLogSearchFilter(row, searchQuery) {
/**
* Extract a millisecond timestamp from a game log row.
* Handles numeric (seconds or millis), ISO string, and dayjs-parseable formats.
*
* @param {object} row
* @returns {number} millisecond timestamp, or 0 if unparseable
*/
@@ -105,7 +104,6 @@ function getGameLogCreatedAtTs(row) {
* Primary key: created_at timestamp (newest first).
* Secondary: rowId (highest first).
* Tertiary: uid string (reverse lexicographic).
*
* @param {object} a
* @param {object} b
* @returns {number} negative if a should come first, positive if b first
@@ -129,3 +127,136 @@ function compareGameLogRows(a, b) {
}
export { gameLogSearchFilter, getGameLogCreatedAtTs, compareGameLogRows };
/**
* Create a Location game log entry.
* @param {string} dt
* @param {string} location
* @param {string} worldId
* @param {string} worldName
* @returns {object}
*/
export function createLocationEntry(dt, location, worldId, worldName) {
return {
created_at: dt,
type: 'Location',
location,
worldId,
worldName,
groupName: '',
time: 0
};
}
/**
* Create a player join or leave game log entry.
* @param {'OnPlayerJoined'|'OnPlayerLeft'} type
* @param {string} dt
* @param {string} displayName
* @param {string} location
* @param {string} userId
* @param {number} [time]
* @returns {object}
*/
export function createJoinLeaveEntry(
type,
dt,
displayName,
location,
userId,
time = 0
) {
return {
created_at: dt,
type,
displayName,
location,
userId,
time
};
}
/**
* Create a PortalSpawn game log entry.
* @param {string} dt
* @param {string} location
* @returns {object}
*/
export function createPortalSpawnEntry(dt, location) {
return {
created_at: dt,
type: 'PortalSpawn',
location,
displayName: '',
userId: '',
instanceId: '',
worldName: ''
};
}
/**
* Create a resource load game log entry.
* @param {string} rawType - 'resource-load-string' or 'resource-load-image'
* @param {string} dt
* @param {string} resourceUrl
* @param {string} location
* @returns {object}
*/
export function createResourceLoadEntry(rawType, dt, resourceUrl, location) {
return {
created_at: dt,
type: rawType === 'resource-load-string' ? 'StringLoad' : 'ImageLoad',
resourceUrl,
location
};
}
/**
* Parse an API request URL for inventory info.
* Matches: /api/1/user/{userId}/inventory/{inventoryId}
* @example
* // https://api.vrchat.cloud/api/1/user/usr_032383a7-748c-4fb2-94e4-bcb928e5de6b/inventory/inv_75781d65-92fe-4a80-a1ff-27ee6e843b08
* @param {string} url
* @returns {{ userId: string, inventoryId: string } | null}
*/
export function parseInventoryFromUrl(url) {
try {
const parsed = new URL(url);
if (
parsed.pathname.substring(0, 12) === '/api/1/user/' &&
parsed.pathname.includes('/inventory/inv_')
) {
const pathArray = parsed.pathname.split('/');
const userId = pathArray[4];
const inventoryId = pathArray[6];
if (userId && inventoryId && inventoryId.length === 40) {
return { userId, inventoryId };
}
}
} catch {
// invalid URL
}
return null;
}
/**
* Parse an API request URL for print info.
* Matches: /api/1/prints/{printId}
* @param {string} url
* @returns {string|null} printId or null
*/
export function parsePrintFromUrl(url) {
try {
const parsed = new URL(url);
if (parsed.pathname.substring(0, 14) === '/api/1/prints/') {
const pathArray = parsed.pathname.split('/');
const printId = pathArray[4];
if (printId && printId.length === 41) {
return printId;
}
}
} catch {
// invalid URL
}
return null;
}

View File

@@ -24,3 +24,7 @@ export * from './memos';
export * from './throttle';
export * from './retry';
export * from './gameLog';
export * from './entityTransforms';
export * from './cacheUtils';
export * from './notificationTransforms';
export * from './discordPresence';

View File

@@ -37,3 +37,36 @@ function getFriendsLocations(friendsArr) {
}
export { getFriendsLocations };
/**
* Get the display text for a location — synchronous, pure function.
* Does NOT handle async world name lookups (those stay in the component).
* @param {object} L - Parsed location object from parseLocation()
* @param {object} options
* @param {string} [options.hint] - Hint string (e.g. from props)
* @param {string|undefined} [options.worldName] - Cached world name, if available
* @param {string} options.accessTypeLabel - Translated access type label
* @param {Function} options.t - i18n translate function
* @returns {string} Display text for the location
*/
function getLocationText(L, { hint, worldName, accessTypeLabel, t }) {
if (L.isOffline) {
return t('location.offline');
}
if (L.isPrivate) {
return t('location.private');
}
if (L.isTraveling) {
return t('location.traveling');
}
if (typeof hint === 'string' && hint !== '') {
return L.instanceId ? `${hint} · ${accessTypeLabel}` : hint;
}
if (L.worldId) {
const name = worldName || L.worldId;
return L.instanceId ? `${name} · ${accessTypeLabel}` : name;
}
return '';
}
export { getLocationText };

View File

@@ -0,0 +1,120 @@
import { replaceBioSymbols } from './base/string';
/**
* Remove null/undefined keys from a notification JSON object
* and sanitize message/title fields with replaceBioSymbols.
* @param {object} json - notification data (mutated in place)
* @returns {object} the same json reference
*/
export function sanitizeNotificationJson(json) {
for (const key in json) {
if (json[key] === null || typeof json[key] === 'undefined') {
delete json[key];
}
}
if (json.message) {
json.message = replaceBioSymbols(json.message);
}
if (json.title) {
json.title = replaceBioSymbols(json.title);
}
return json;
}
/**
* Parse a notification's details field from string to object if needed.
* @param {*} details - raw details value
* @returns {object} parsed details object
*/
export function parseNotificationDetails(details) {
if (details === Object(details)) {
return details;
}
if (details !== '{}' && typeof details === 'string') {
try {
const object = JSON.parse(details);
if (object === Object(object)) {
return object;
}
} catch (err) {
console.log(err);
}
}
return {};
}
/**
* Build a default V1 notification ref from JSON data.
* Does NOT perform cache lookup — caller is responsible for
* checking existing refs and merging.
* @param {object} json - sanitized notification JSON
* @returns {object} default notification ref
*/
export function createDefaultNotificationRef(json) {
const ref = {
id: '',
senderUserId: '',
senderUsername: '',
type: '',
message: '',
details: {},
seen: false,
created_at: '',
// VRCX
$isExpired: false,
//
...json
};
ref.details = parseNotificationDetails(ref.details);
return ref;
}
/**
* Build a default V2 notification ref from JSON data.
* Handles boop legacy formatting.
* @param {object} json - sanitized notification JSON
* @param {string} endpointDomain - API endpoint domain for emoji URLs
* @returns {object} default notification V2 ref
*/
export function createDefaultNotificationV2Ref(json) {
return {
id: '',
createdAt: '',
updatedAt: '',
expiresAt: '',
type: '',
link: '',
linkText: '',
message: '',
title: '',
imageUrl: '',
seen: false,
senderUserId: '',
senderUsername: '',
data: {},
responses: [],
details: {},
version: 2,
...json
};
}
/**
* Apply legacy boop formatting to a V2 notification ref.
* Mutates the ref in place.
* @param {object} ref - notification V2 ref
* @param {string} endpointDomain - API endpoint domain for emoji URLs
*/
export function applyBoopLegacyHandling(ref, endpointDomain) {
if (ref.type !== 'boop' || !ref.title) {
return;
}
ref.message = ref.title;
ref.title = '';
if (ref.details?.emojiId?.startsWith('default_')) {
ref.imageUrl = ref.details.emojiId;
ref.message += ` ${ref.details.emojiId.replace('default_', '')}`;
} else {
ref.imageUrl = `${endpointDomain}/file/${ref.details.emojiId}/${ref.details.emojiVersion}`;
}
}

View File

@@ -2,6 +2,7 @@ import { useAppearanceSettingsStore, useUserStore } from '../../stores';
import { HueToHex } from './base/ui';
import { convertFileUrlToImageUrl } from './common';
import { languageMappings } from '../constants';
import { removeEmojis } from './base/string';
import { timeToText } from './base/format';
/**
@@ -46,24 +47,6 @@ async function getNameColour(userId) {
return HueToHex(hue);
}
/**
*
* @param {string} text
* @returns
*/
function removeEmojis(text) {
if (!text) {
return '';
}
return text
.replace(
/([\u2700-\u27BF]|[\uE000-\uF8FF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDFFF]|[\u2011-\u26FF]|\uD83E[\uDD10-\uDDFF])/g,
''
)
.replace(/\s+/g, ' ')
.trim();
}
/**
*
* @param {object} user