refactor utils

This commit is contained in:
pa
2026-03-06 22:53:46 +09:00
parent 8ddedb2d2d
commit 318f0b141c
13 changed files with 820 additions and 814 deletions

View File

@@ -0,0 +1,20 @@
import { createDefaultAvatarRef } from '../avatarTransforms';
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');
});
});

View File

@@ -1,66 +1,9 @@
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 = {
@@ -82,286 +25,6 @@ describe('sanitizeEntityJson', () => {
});
});
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({});

View File

@@ -0,0 +1,20 @@
import { createDefaultGroupRef } from '../groupTransforms';
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');
});
});

View File

@@ -0,0 +1,52 @@
import {
computeDisabledContentSettings,
createDefaultInstanceRef
} from '../instanceTransforms';
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('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);
});
});

View File

@@ -0,0 +1,229 @@
import {
sanitizeUserJson,
computeTrustLevel,
computeUserPlatform,
diffObjectProps,
createDefaultUserRef
} from '../userTransforms';
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('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('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('');
});
});

View File

@@ -0,0 +1,20 @@
import { createDefaultWorldRef } from '../worldTransforms';
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');
});
});

View File

@@ -0,0 +1,37 @@
/**
* 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
};
}

View File

@@ -1,31 +1,4 @@
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;
}
import { replaceBioSymbols } from './base/string';
/**
* Sanitize arbitrary entity JSON fields via replaceBioSymbols.
@@ -42,455 +15,6 @@ export function sanitizeEntityJson(json, fields) {
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

View File

@@ -0,0 +1,64 @@
/**
* 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
};
}

View File

@@ -25,6 +25,11 @@ export * from './throttle';
export * from './retry';
export * from './gameLog';
export * from './entityTransforms';
export * from './userTransforms';
export * from './worldTransforms';
export * from './avatarTransforms';
export * from './groupTransforms';
export * from './instanceTransforms';
export * from './cacheUtils';
export * from './notificationTransforms';
export * from './discordPresence';

View File

@@ -0,0 +1,77 @@
/**
* 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;
}
/**
* 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
};
}

View File

@@ -0,0 +1,247 @@
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;
}
/**
* 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 || '';
}
/**
* 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
};
}

View File

@@ -0,0 +1,48 @@
/**
* 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
};
}