mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-22 08:13:52 +02:00
refactor store
This commit is contained in:
93
src/shared/utils/__tests__/cacheUtils.test.js
Normal file
93
src/shared/utils/__tests__/cacheUtils.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
241
src/shared/utils/__tests__/discordPresence.test.js
Normal file
241
src/shared/utils/__tests__/discordPresence.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
418
src/shared/utils/__tests__/entityTransforms.test.js
Normal file
418
src/shared/utils/__tests__/entityTransforms.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
211
src/shared/utils/__tests__/notificationTransforms.test.js
Normal file
211
src/shared/utils/__tests__/notificationTransforms.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
|
||||
67
src/shared/utils/cacheUtils.js
Normal file
67
src/shared/utils/cacheUtils.js
Normal 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 };
|
||||
}
|
||||
228
src/shared/utils/discordPresence.js
Normal file
228
src/shared/utils/discordPresence.js
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
532
src/shared/utils/entityTransforms.js
Normal file
532
src/shared/utils/entityTransforms.js
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 };
|
||||
|
||||
120
src/shared/utils/notificationTransforms.js
Normal file
120
src/shared/utils/notificationTransforms.js
Normal 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}`;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user