diff --git a/src/shared/utils/__tests__/avatarTransforms.test.js b/src/shared/utils/__tests__/avatarTransforms.test.js new file mode 100644 index 00000000..cf1e670d --- /dev/null +++ b/src/shared/utils/__tests__/avatarTransforms.test.js @@ -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'); + }); +}); diff --git a/src/shared/utils/__tests__/entityTransforms.test.js b/src/shared/utils/__tests__/entityTransforms.test.js index e8c803f5..cadbdefd 100644 --- a/src/shared/utils/__tests__/entityTransforms.test.js +++ b/src/shared/utils/__tests__/entityTransforms.test.js @@ -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({}); diff --git a/src/shared/utils/__tests__/groupTransforms.test.js b/src/shared/utils/__tests__/groupTransforms.test.js new file mode 100644 index 00000000..f8018df5 --- /dev/null +++ b/src/shared/utils/__tests__/groupTransforms.test.js @@ -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'); + }); +}); diff --git a/src/shared/utils/__tests__/instanceTransforms.test.js b/src/shared/utils/__tests__/instanceTransforms.test.js new file mode 100644 index 00000000..1056a0f9 --- /dev/null +++ b/src/shared/utils/__tests__/instanceTransforms.test.js @@ -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); + }); +}); diff --git a/src/shared/utils/__tests__/userTransforms.test.js b/src/shared/utils/__tests__/userTransforms.test.js new file mode 100644 index 00000000..4b02adc0 --- /dev/null +++ b/src/shared/utils/__tests__/userTransforms.test.js @@ -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(''); + }); +}); diff --git a/src/shared/utils/__tests__/worldTransforms.test.js b/src/shared/utils/__tests__/worldTransforms.test.js new file mode 100644 index 00000000..31776116 --- /dev/null +++ b/src/shared/utils/__tests__/worldTransforms.test.js @@ -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'); + }); +}); diff --git a/src/shared/utils/avatarTransforms.js b/src/shared/utils/avatarTransforms.js new file mode 100644 index 00000000..9ac26b06 --- /dev/null +++ b/src/shared/utils/avatarTransforms.js @@ -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 + }; +} diff --git a/src/shared/utils/entityTransforms.js b/src/shared/utils/entityTransforms.js index eb197867..15c7cd16 100644 --- a/src/shared/utils/entityTransforms.js +++ b/src/shared/utils/entityTransforms.js @@ -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 diff --git a/src/shared/utils/groupTransforms.js b/src/shared/utils/groupTransforms.js new file mode 100644 index 00000000..08f4e507 --- /dev/null +++ b/src/shared/utils/groupTransforms.js @@ -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 + }; +} diff --git a/src/shared/utils/index.js b/src/shared/utils/index.js index f844945b..0c4f3bc0 100644 --- a/src/shared/utils/index.js +++ b/src/shared/utils/index.js @@ -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'; diff --git a/src/shared/utils/instanceTransforms.js b/src/shared/utils/instanceTransforms.js new file mode 100644 index 00000000..21c13bb9 --- /dev/null +++ b/src/shared/utils/instanceTransforms.js @@ -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 + }; +} diff --git a/src/shared/utils/userTransforms.js b/src/shared/utils/userTransforms.js new file mode 100644 index 00000000..a713a868 --- /dev/null +++ b/src/shared/utils/userTransforms.js @@ -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 + }; +} diff --git a/src/shared/utils/worldTransforms.js b/src/shared/utils/worldTransforms.js new file mode 100644 index 00000000..c019b7e9 --- /dev/null +++ b/src/shared/utils/worldTransforms.js @@ -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 + }; +}