diff --git a/package-lock.json b/package-lock.json index fe368561..1bf7d1df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,8 +30,8 @@ "@tailwindcss/vite": "^4.2.1", "@tanstack/react-query": "^5.90.21", "@tanstack/vue-table": "^8.21.3", - "@tanstack/vue-virtual": "^3.13.19", - "@types/node": "^25.3.3", + "@tanstack/vue-virtual": "^3.13.21", + "@types/node": "^24.12.0", "@vee-validate/zod": "^4.15.1", "@vitejs/plugin-vue": "^6.0.4", "@vitejs/plugin-vue-jsx": "^5.1.4", @@ -188,7 +188,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -708,7 +707,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -749,7 +747,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -1359,6 +1356,7 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, + "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -1380,6 +1378,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -1396,6 +1395,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -1410,6 +1410,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 10.0.0" } @@ -4139,9 +4140,9 @@ } }, "node_modules/@tanstack/virtual-core": { - "version": "3.13.19", - "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.19.tgz", - "integrity": "sha512-/BMP7kNhzKOd7wnDeB8NrIRNLwkf5AhCYCvtfZV2GXWbBieFm/el0n6LOAXlTi6ZwHICSNnQcIxRCWHrLzDY+g==", + "version": "3.13.21", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.21.tgz", + "integrity": "sha512-ww+fmLHyCbPSf7JNbWZP3g7wl6SdNo3ah5Aiw+0e9FDErkVHLKprYUrwTm7dF646FtEkN/KkAKPYezxpmvOjxw==", "dev": true, "license": "MIT", "funding": { @@ -4227,13 +4228,13 @@ } }, "node_modules/@tanstack/vue-virtual": { - "version": "3.13.19", - "resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.19.tgz", - "integrity": "sha512-07Fp1TYuIziB4zIDA/moeDKHODePy3K1fN4c4VIAGnkxo1+uOvBJP7m54CoxKiQX6Q9a1dZnznrwOg9C86yvvA==", + "version": "3.13.21", + "resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.21.tgz", + "integrity": "sha512-zneUNdQTcUhoDl6+ek+/O4S9gSZRAc2q7VLscZ4WZnFfZcHc3M7OyVCfSDC3hGuwFqzfL8Cx5bZF6zbGCYwXmw==", "dev": true, "license": "MIT", "dependencies": { - "@tanstack/virtual-core": "3.13.19" + "@tanstack/virtual-core": "3.13.21" }, "funding": { "type": "github", @@ -4352,13 +4353,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.3.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz", - "integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==", + "version": "24.12.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", + "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "undici-types": "~7.16.0" } }, "node_modules/@types/plist": { @@ -4950,7 +4951,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4984,7 +4984,6 @@ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5541,7 +5540,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6192,7 +6190,8 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/cross-env": { "version": "10.1.0", @@ -6539,7 +6538,6 @@ "integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", @@ -7006,6 +7004,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -7026,6 +7025,7 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -7057,8 +7057,7 @@ "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/embla-carousel-reactive-utils": { "version": "8.6.0", @@ -7091,6 +7090,17 @@ "dev": true, "license": "MIT" }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -7279,7 +7289,6 @@ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7340,7 +7349,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -9804,6 +9812,7 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -10416,7 +10425,6 @@ "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vue/devtools-api": "^7.7.7" }, @@ -10497,6 +10505,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -10514,6 +10523,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -10534,7 +10544,6 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -10875,6 +10884,7 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -10889,6 +10899,7 @@ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -10910,6 +10921,7 @@ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -11553,7 +11565,6 @@ "integrity": "sha512-/BUbeOwPGruiBOm0YQQ6ZMcLIZ6tf/W+Jcm7dxZyAX0tK3WP9/sq7/NAWBxPIxVahdGjCJoGwej0Gdrv0DxlQQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "events": "^3.3.0", "graphology-utils": "^2.5.2" @@ -12042,6 +12053,7 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -12177,7 +12189,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -12351,9 +12362,9 @@ } }, "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "dev": true, "license": "MIT" }, @@ -12568,7 +12579,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -12662,7 +12672,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -12676,7 +12685,6 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", @@ -12777,7 +12785,6 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz", "integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.29", "@vue/compiler-sfc": "3.5.29", @@ -13504,7 +13511,6 @@ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index f6960c7a..bdedd4d8 100644 --- a/package.json +++ b/package.json @@ -49,8 +49,8 @@ "@tailwindcss/vite": "^4.2.1", "@tanstack/react-query": "^5.90.21", "@tanstack/vue-table": "^8.21.3", - "@tanstack/vue-virtual": "^3.13.19", - "@types/node": "^25.3.3", + "@tanstack/vue-virtual": "^3.13.21", + "@types/node": "^24.12.0", "@vee-validate/zod": "^4.15.1", "@vitejs/plugin-vue": "^6.0.4", "@vitejs/plugin-vue-jsx": "^5.1.4", diff --git a/src/components/Location.vue b/src/components/Location.vue index f64533eb..999615d2 100644 --- a/src/components/Location.vue +++ b/src/components/Location.vue @@ -35,7 +35,14 @@ import { storeToRefs } from 'pinia'; import { useI18n } from 'vue-i18n'; - import { getGroupName, getWorldName, parseLocation, resolveRegion, translateAccessType } from '../shared/utils'; + import { + getGroupName, + getLocationText, + getWorldName, + parseLocation, + resolveRegion, + translateAccessType + } from '../shared/utils'; import { useAppearanceSettingsStore, useGroupStore, @@ -229,41 +236,27 @@ */ function setText(L) { const accessTypeLabel = getAccessTypeLabel(L.accessTypeName); + const cachedRef = L.worldId ? cachedWorlds.get(L.worldId) : undefined; + const worldName = typeof cachedRef !== 'undefined' ? cachedRef.name : undefined; - if (L.isOffline) { - text.value = t('location.offline'); - } else if (L.isPrivate) { - text.value = t('location.private'); - } else if (L.isTraveling) { - text.value = t('location.traveling'); - } else if (typeof props.hint === 'string' && props.hint !== '') { - if (L.instanceId) { - text.value = `${props.hint} · ${accessTypeLabel}`; - } else { - text.value = props.hint; - } - } else if (L.worldId) { - if (L.instanceId) { - text.value = `${L.worldId} · ${accessTypeLabel}`; - } else { - text.value = L.worldId; - } - const ref = cachedWorlds.get(L.worldId); - if (typeof ref === 'undefined') { - getWorldName(L.worldId).then((name) => { - if (!isDisposed && name && currentInstanceId() === L.tag) { - if (L.instanceId) { - text.value = `${name} · ${getAccessTypeLabel(L.accessTypeName)}`; - } else { - text.value = name; - } - } - }); - } else if (L.instanceId) { - text.value = `${ref.name} · ${accessTypeLabel}`; - } else { - text.value = ref.name; - } + text.value = getLocationText(L, { + hint: props.hint, + worldName, + accessTypeLabel, + t + }); + + if (L.worldId && typeof cachedRef === 'undefined') { + getWorldName(L.worldId).then((name) => { + if (!isDisposed && name && currentInstanceId() === L.tag) { + text.value = getLocationText(L, { + hint: props.hint, + worldName: name, + accessTypeLabel: getAccessTypeLabel(L.accessTypeName), + t + }); + } + }); } } diff --git a/src/components/navMenuUtils.js b/src/components/navMenuUtils.js index 5b050726..3b7f10b4 100644 --- a/src/components/navMenuUtils.js +++ b/src/components/navMenuUtils.js @@ -179,5 +179,8 @@ export function isEntryNotified(entry, notifiedMenus) { targets.push(lastSegment); } } + if (!Array.isArray(notifiedMenus)) { + return false; + } return targets.some((key) => notifiedMenus.includes(key)); } diff --git a/src/shared/utils/__tests__/cacheUtils.test.js b/src/shared/utils/__tests__/cacheUtils.test.js new file mode 100644 index 00000000..a88fa9b2 --- /dev/null +++ b/src/shared/utils/__tests__/cacheUtils.test.js @@ -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); + }); +}); diff --git a/src/shared/utils/__tests__/discordPresence.test.js b/src/shared/utils/__tests__/discordPresence.test.js new file mode 100644 index 00000000..c0b1b4e3 --- /dev/null +++ b/src/shared/utils/__tests__/discordPresence.test.js @@ -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); + }); +}); diff --git a/src/shared/utils/__tests__/entityTransforms.test.js b/src/shared/utils/__tests__/entityTransforms.test.js new file mode 100644 index 00000000..e8c803f5 --- /dev/null +++ b/src/shared/utils/__tests__/entityTransforms.test.js @@ -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'); + }); +}); diff --git a/src/shared/utils/__tests__/gameLog.test.js b/src/shared/utils/__tests__/gameLog.test.js index 900848cd..ddd23def 100644 --- a/src/shared/utils/__tests__/gameLog.test.js +++ b/src/shared/utils/__tests__/gameLog.test.js @@ -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(); + }); +}); diff --git a/src/shared/utils/__tests__/location.test.js b/src/shared/utils/__tests__/location.test.js index 143046e7..8ee6c1c7 100644 --- a/src/shared/utils/__tests__/location.test.js +++ b/src/shared/utils/__tests__/location.test.js @@ -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: '
' } +})); +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'); + }); + }); }); diff --git a/src/shared/utils/__tests__/notificationTransforms.test.js b/src/shared/utils/__tests__/notificationTransforms.test.js new file mode 100644 index 00000000..7e124ee0 --- /dev/null +++ b/src/shared/utils/__tests__/notificationTransforms.test.js @@ -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'); + }); +}); diff --git a/src/shared/utils/base/string.js b/src/shared/utils/base/string.js index 2a01f951..6ff75b83 100644 --- a/src/shared/utils/base/string.js +++ b/src/shared/utils/base/string.js @@ -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 }; diff --git a/src/shared/utils/cacheUtils.js b/src/shared/utils/cacheUtils.js new file mode 100644 index 00000000..16a4466a --- /dev/null +++ b/src/shared/utils/cacheUtils.js @@ -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 }; +} diff --git a/src/shared/utils/discordPresence.js b/src/shared/utils/discordPresence.js new file mode 100644 index 00000000..a0c3e2d5 --- /dev/null +++ b/src/shared/utils/discordPresence.js @@ -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 + }; + } +} diff --git a/src/shared/utils/entityTransforms.js b/src/shared/utils/entityTransforms.js new file mode 100644 index 00000000..eb197867 --- /dev/null +++ b/src/shared/utils/entityTransforms.js @@ -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; +} diff --git a/src/shared/utils/gameLog.js b/src/shared/utils/gameLog.js index a3695735..1a824301 100644 --- a/src/shared/utils/gameLog.js +++ b/src/shared/utils/gameLog.js @@ -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; +} diff --git a/src/shared/utils/index.js b/src/shared/utils/index.js index 24d003c5..f844945b 100644 --- a/src/shared/utils/index.js +++ b/src/shared/utils/index.js @@ -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'; diff --git a/src/shared/utils/location.js b/src/shared/utils/location.js index 0c5ee56d..b33786f9 100644 --- a/src/shared/utils/location.js +++ b/src/shared/utils/location.js @@ -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 }; diff --git a/src/shared/utils/notificationTransforms.js b/src/shared/utils/notificationTransforms.js new file mode 100644 index 00000000..7125dc29 --- /dev/null +++ b/src/shared/utils/notificationTransforms.js @@ -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}`; + } +} diff --git a/src/shared/utils/user.js b/src/shared/utils/user.js index de2a7240..ac7f55cf 100644 --- a/src/shared/utils/user.js +++ b/src/shared/utils/user.js @@ -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 diff --git a/src/stores/avatar.js b/src/stores/avatar.js index 40b531b6..04a88b26 100644 --- a/src/stores/avatar.js +++ b/src/stores/avatar.js @@ -5,17 +5,19 @@ import { useI18n } from 'vue-i18n'; import { checkVRChatCache, + createDefaultAvatarRef, extractFileId, getAvailablePlatforms, getBundleDateSize, getPlatformInfo, replaceBioSymbols, + sanitizeEntityJson, storeAvatarImage } from '../shared/utils'; import { avatarRequest, miscRequest } from '../api'; -import { patchAvatarFromEvent } from '../query'; import { AppDebug } from '../service/appConfig'; import { database } from '../service/database'; +import { patchAvatarFromEvent } from '../query'; import { processBulk } from '../service/request'; import { useAdvancedSettingsStore } from './settings/advanced'; import { useAvatarProviderStore } from './avatarProvider'; @@ -92,40 +94,10 @@ export const useAvatarStore = defineStore('Avatar', () => { * @returns {object} ref */ function applyAvatar(json) { - json.name = replaceBioSymbols(json.name); - json.description = replaceBioSymbols(json.description); + sanitizeEntityJson(json, ['name', 'description']); let ref = cachedAvatars.get(json.id); if (typeof ref === 'undefined') { - ref = { - 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 - }; + ref = createDefaultAvatarRef(json); cachedAvatars.set(ref.id, ref); } else { const { unityPackages } = ref; @@ -178,6 +150,7 @@ export const useAvatarStore = defineStore('Avatar', () => { /** * * @param {string} avatarId + * @param options * @returns */ function showAvatarDialog(avatarId, options = {}) { diff --git a/src/stores/favorite.js b/src/stores/favorite.js index 38443100..7ea51607 100644 --- a/src/stores/favorite.js +++ b/src/stores/favorite.js @@ -5,6 +5,8 @@ import { useI18n } from 'vue-i18n'; import { compareByName, + createDefaultFavoriteCachedRef, + createDefaultFavoriteGroupRef, removeFromArray, replaceReactiveObject } from '../shared/utils'; @@ -209,6 +211,11 @@ export const useFavoriteStore = defineStore('Favorite', () => { return favoriteGroup.length; }); + /** + * + * @param list + * @param selectionRef + */ function syncFavoriteSelection(list, selectionRef) { if (!Array.isArray(list)) { selectionRef.value = []; @@ -255,6 +262,9 @@ export const useFavoriteStore = defineStore('Favorite', () => { { flush: 'sync' } ); + /** + * + */ function getCachedFavoriteGroupsByTypeName() { const group = {}; @@ -274,10 +284,18 @@ export const useFavoriteStore = defineStore('Favorite', () => { return group; } + /** + * + * @param objectId + */ function getCachedFavoritesByObjectId(objectId) { return cachedFavoritesByObjectId.get(objectId); } + /** + * + * @param args + */ function handleFavoriteAdd(args) { handleFavorite({ json: args.json, @@ -310,6 +328,10 @@ export const useFavoriteStore = defineStore('Favorite', () => { updateFavoriteDialog(args.params.objectId); } + /** + * + * @param args + */ function handleFavorite(args) { args.ref = applyFavoriteCached(args.json); applyFavorite(args.ref.type, args.ref.favoriteId); @@ -329,6 +351,10 @@ export const useFavoriteStore = defineStore('Favorite', () => { } } + /** + * + * @param objectId + */ function handleFavoriteDelete(objectId) { const ref = getCachedFavoritesByObjectId(objectId); if (typeof ref === 'undefined') { @@ -337,10 +363,18 @@ export const useFavoriteStore = defineStore('Favorite', () => { handleFavoriteAtDelete(ref); } + /** + * + * @param args + */ function handleFavoriteGroup(args) { args.ref = applyFavoriteGroup(args.json); } + /** + * + * @param args + */ function handleFavoriteGroupClear(args) { const key = `${args.params.type}:${args.params.group}`; for (const ref of cachedFavorites.values()) { @@ -351,6 +385,10 @@ export const useFavoriteStore = defineStore('Favorite', () => { } } + /** + * + * @param args + */ function handleFavoriteWorldList(args) { for (const json of args.json) { if (json.id === '???') { @@ -360,6 +398,10 @@ export const useFavoriteStore = defineStore('Favorite', () => { } } + /** + * + * @param args + */ function handleFavoriteAvatarList(args) { for (const json of args.json) { if (json.releaseStatus === 'hidden') { @@ -369,6 +411,10 @@ export const useFavoriteStore = defineStore('Favorite', () => { } } + /** + * + * @param ref + */ function handleFavoriteAtDelete(ref) { const favorite = state.favoriteObjects.get(ref.favoriteId); removeFromArray(state.favoriteFriends_, favorite); @@ -536,6 +582,9 @@ export const useFavoriteStore = defineStore('Favorite', () => { } } + /** + * + */ function refreshFavoriteGroups() { if (isFavoriteGroupLoading.value) { return; @@ -567,6 +616,9 @@ export const useFavoriteStore = defineStore('Favorite', () => { }); } + /** + * + */ function buildFavoriteGroups() { let group; let groups; @@ -683,6 +735,9 @@ export const useFavoriteStore = defineStore('Favorite', () => { countFavoriteGroups(); } + /** + * + */ function countFavoriteGroups() { const cachedFavoriteGroups = getCachedFavoriteGroupsByTypeName(); for (const key in cachedFavoriteGroups) { @@ -764,17 +819,7 @@ export const useFavoriteStore = defineStore('Favorite', () => { function applyFavoriteGroup(json) { let ref = cachedFavoriteGroups.value[json.id]; if (typeof ref === 'undefined') { - ref = { - id: '', - ownerId: '', - ownerDisplayName: '', - name: '', - displayName: '', - type: '', - visibility: '', - tags: [], - ...json - }; + ref = createDefaultFavoriteGroupRef(json); cachedFavoriteGroups.value[ref.id] = ref; } else { Object.assign(ref, json); @@ -790,19 +835,9 @@ export const useFavoriteStore = defineStore('Favorite', () => { function applyFavoriteCached(json) { let ref = cachedFavorites.get(json.id); if (typeof ref === 'undefined') { - ref = { - id: '', - type: '', - favoriteId: '', - tags: [], - // VRCX - $groupKey: '', - // - ...json - }; + ref = createDefaultFavoriteCachedRef(json); cachedFavorites.set(ref.id, ref); cachedFavoritesByObjectId.set(ref.favoriteId, ref); - ref.$groupKey = `${ref.type}:${String(ref.tags[0])}`; if ( ref.type === 'friend' && (!generalSettingsStore.localFavoriteFriendsGroups.some( @@ -893,14 +928,23 @@ export const useFavoriteStore = defineStore('Favorite', () => { } } + /** + * + */ function showWorldImportDialog() { worldImportDialogVisible.value = true; } + /** + * + */ function showAvatarImportDialog() { avatarImportDialogVisible.value = true; } + /** + * + */ function showFriendImportDialog() { friendImportDialogVisible.value = true; } @@ -1016,6 +1060,10 @@ export const useFavoriteStore = defineStore('Favorite', () => { return false; } + /** + * + * @param objectId + */ function updateFavoriteDialog(objectId) { const D = favoriteDialog.value; if (!D.visible || D.objectId !== objectId) { @@ -1108,6 +1156,9 @@ export const useFavoriteStore = defineStore('Favorite', () => { }); } + /** + * + */ function sortLocalAvatarFavorites() { if (!appearanceSettingsStore.sortFavorites) { for (let i = 0; i < localAvatarFavoriteGroups.value.length; ++i) { @@ -1294,6 +1345,9 @@ export const useFavoriteStore = defineStore('Favorite', () => { }); } + /** + * + */ function sortLocalWorldFavorites() { if (!appearanceSettingsStore.sortFavorites) { for (let i = 0; i < localWorldFavoriteGroups.value.length; ++i) { @@ -1718,6 +1772,11 @@ export const useFavoriteStore = defineStore('Favorite', () => { }); } + /** + * + * @param type + * @param objectId + */ function showFavoriteDialog(type, objectId) { const D = favoriteDialog.value; D.type = type; @@ -1726,12 +1785,18 @@ export const useFavoriteStore = defineStore('Favorite', () => { updateFavoriteDialog(objectId); } + /** + * + */ async function saveSortFavoritesOption() { getLocalWorldFavorites(); getLocalFriendFavorites(); appearanceSettingsStore.setSortFavorites(); } + /** + * + */ async function initFavorites() { refreshFavorites(); getLocalWorldFavorites(); @@ -1739,6 +1804,11 @@ export const useFavoriteStore = defineStore('Favorite', () => { getLocalFriendFavorites(); } + /** + * + * @param a + * @param b + */ function compareByFavoriteSortOrder(a, b) { const indexA = favoritesSortOrder.value.indexOf(a.id); const indexB = favoritesSortOrder.value.indexOf(b.id); diff --git a/src/stores/gameLog/index.js b/src/stores/gameLog/index.js index d185e5d5..a229d3c3 100644 --- a/src/stores/gameLog/index.js +++ b/src/stores/gameLog/index.js @@ -8,11 +8,17 @@ import dayjs from 'dayjs'; import { compareGameLogRows, + createJoinLeaveEntry, + createLocationEntry, + createPortalSpawnEntry, + createResourceLoadEntry, findUserByDisplayName, formatSeconds, gameLogSearchFilter, getGroupName, + parseInventoryFromUrl, parseLocation, + parsePrintFromUrl, replaceBioSymbols } from '../../shared/utils'; import { AppDebug } from '../../service/appConfig'; @@ -131,6 +137,9 @@ export const useGameLogStore = defineStore('GameLog', () => { { flush: 'sync' } ); + /** + * + */ async function init() { gameLogTable.value.filter = JSON.parse( await configRepository.getString('VRCX_gameLogTableFilters', '[]') @@ -143,6 +152,10 @@ export const useGameLogStore = defineStore('GameLog', () => { init(); + /** + * + * @param entry + */ function insertGameLogSorted(entry) { const arr = gameLogTableData.value; if (arr.length === 0) { @@ -170,6 +183,9 @@ export const useGameLogStore = defineStore('GameLog', () => { gameLogTableData.value = [...arr, entry]; } + /** + * + */ function clearNowPlaying() { nowPlaying.value = { url: '', @@ -186,6 +202,10 @@ export const useGameLogStore = defineStore('GameLog', () => { vrStore.updateVrNowPlaying(); } + /** + * + * @param data + */ function setNowPlaying(data) { const ctx = structuredClone(data); if (nowPlaying.value.url !== ctx.videoUrl) { @@ -257,6 +277,9 @@ export const useGameLogStore = defineStore('GameLog', () => { advancedSettingsStore }); + /** + * + */ function updateNowPlaying() { const np = nowPlaying.value; if (!nowPlaying.value.playing) { @@ -275,6 +298,9 @@ export const useGameLogStore = defineStore('GameLog', () => { workerTimers.setTimeout(() => updateNowPlaying(), 1000); } + /** + * + */ async function tryLoadPlayerList() { // TODO: make this work again if (!gameStore.isGameRunning) { @@ -355,6 +381,10 @@ export const useGameLogStore = defineStore('GameLog', () => { } } + /** + * + * @param row + */ function gameLogIsFriend(row) { if (typeof row.isFriend !== 'undefined') { return row.isFriend; @@ -365,6 +395,10 @@ export const useGameLogStore = defineStore('GameLog', () => { return friendStore.friends.has(row.userId); } + /** + * + * @param row + */ function gameLogIsFavorite(row) { if (typeof row.isFavorite !== 'undefined') { return row.isFavorite; @@ -375,6 +409,9 @@ export const useGameLogStore = defineStore('GameLog', () => { return friendStore.localFavoriteFriends.has(row.userId); } + /** + * + */ async function gameLogTableLookup() { await configRepository.setString( 'VRCX_gameLogTableFilters', @@ -416,6 +453,10 @@ export const useGameLogStore = defineStore('GameLog', () => { } } + /** + * + * @param entry + */ function addGameLog(entry) { entry.isFriend = gameLogIsFriend(entry); entry.isFavorite = gameLogIsFavorite(entry); @@ -456,6 +497,10 @@ export const useGameLogStore = defineStore('GameLog', () => { uiStore.notifyMenu('game-log'); } + /** + * + * @param input + */ async function addGamelogLocationToDatabase(input) { const groupName = await getGroupName(input.location); const entry = { @@ -465,10 +510,17 @@ export const useGameLogStore = defineStore('GameLog', () => { database.addGamelogLocationToDatabase(entry); } + /** + * + * @param row + */ function gameLogSearch(row) { return gameLogSearchFilter(row, gameLogTable.value.search); } + /** + * + */ function sweepGameLog() { const j = gameLogTableData.value.length; if (j > vrcxStore.maxTableSize + 50) { @@ -476,6 +528,11 @@ export const useGameLogStore = defineStore('GameLog', () => { } } + /** + * + * @param gameLog + * @param location + */ function addGameLogEntry(gameLog, location) { let entry = undefined; if (advancedSettingsStore.gameLogDisabled) { @@ -543,15 +600,12 @@ export const useGameLogStore = defineStore('GameLog', () => { gameLog.dt ); const L = parseLocation(gameLog.location); - entry = { - created_at: gameLog.dt, - type: 'Location', - location: gameLog.location, - worldId: L.worldId, - worldName, - groupName: '', - time: 0 - }; + entry = createLocationEntry( + gameLog.dt, + gameLog.location, + L.worldId, + worldName + ); getGroupName(gameLog.location).then((groupName) => { entry.groupName = groupName; }); @@ -595,14 +649,13 @@ export const useGameLogStore = defineStore('GameLog', () => { } vrStore.updateVRLastLocation(); instanceStore.getCurrentInstanceUserList(); - entry = { - created_at: gameLog.dt, - type: 'OnPlayerJoined', - displayName: gameLog.displayName, + entry = createJoinLeaveEntry( + 'OnPlayerJoined', + gameLog.dt, + gameLog.displayName, location, - userId, - time: 0 - }; + userId + ); database.addGamelogJoinLeaveToDatabase(entry); break; case 'player-left': @@ -617,29 +670,21 @@ export const useGameLogStore = defineStore('GameLog', () => { photonStore.photonLobbyAvatars.delete(userId); vrStore.updateVRLastLocation(); instanceStore.getCurrentInstanceUserList(); - entry = { - created_at: gameLog.dt, - type: 'OnPlayerLeft', - displayName: gameLog.displayName, + entry = createJoinLeaveEntry( + 'OnPlayerLeft', + gameLog.dt, + gameLog.displayName, location, userId, time - }; + ); database.addGamelogJoinLeaveToDatabase(entry); break; case 'portal-spawn': if (vrcxStore.ipcEnabled && gameStore.isGameRunning) { break; } - entry = { - created_at: gameLog.dt, - type: 'PortalSpawn', - location, - displayName: '', - userId: '', - instanceId: '', - worldName: '' - }; + entry = createPortalSpawnEntry(gameLog.dt, location); database.addGamelogPortalSpawnToDatabase(entry); break; case 'video-play': @@ -665,15 +710,12 @@ export const useGameLogStore = defineStore('GameLog', () => { break; } lastResourceloadUrl.value = gameLog.resourceUrl; - entry = { - created_at: gameLog.dt, - type: - gameLog.type === 'resource-load-string' - ? 'StringLoad' - : 'ImageLoad', - resourceUrl: gameLog.resourceUrl, + entry = createResourceLoadEntry( + gameLog.type, + gameLog.dt, + gameLog.resourceUrl, location - }; + ); database.addGamelogResourceLoadToDatabase(entry); break; case 'screenshot': @@ -711,42 +753,18 @@ export const useGameLogStore = defineStore('GameLog', () => { // } if (advancedSettingsStore.saveInstanceEmoji) { - try { - // https://api.vrchat.cloud/api/1/user/usr_032383a7-748c-4fb2-94e4-bcb928e5de6b/inventory/inv_75781d65-92fe-4a80-a1ff-27ee6e843b08 - const url = new URL(gameLog.url); - if ( - url.pathname.substring(0, 12) === '/api/1/user/' && - url.pathname.includes('/inventory/inv_') - ) { - const pathArray = url.pathname.split('/'); - const userId = pathArray[4]; - const inventoryId = pathArray[6]; - if (userId && inventoryId.length === 40) { - galleryStore.queueCheckInstanceInventory( - inventoryId, - userId - ); - } - } - } catch (err) { - console.error(err); + const inv = parseInventoryFromUrl(gameLog.url); + if (inv) { + galleryStore.queueCheckInstanceInventory( + inv.inventoryId, + inv.userId + ); } } if (advancedSettingsStore.saveInstancePrints) { - try { - let printId = ''; - const url1 = new URL(gameLog.url); - if ( - url1.pathname.substring(0, 14) === '/api/1/prints/' - ) { - const pathArray = url1.pathname.split('/'); - printId = pathArray[4]; - } - if (printId && printId.length === 41) { - galleryStore.queueSavePrintToFile(printId); - } - } catch (err) { - console.error(err); + const printId = parsePrintFromUrl(gameLog.url); + if (printId) { + galleryStore.queueSavePrintToFile(printId); } } break; @@ -902,12 +920,19 @@ export const useGameLogStore = defineStore('GameLog', () => { } } + /** + * + */ async function getGameLogTable() { await database.initTables(); const dateTill = await database.getLastDateGameLogDatabase(); updateGameLog(dateTill); } + /** + * + * @param dateTill + */ async function updateGameLog(dateTill) { await gameLogService.setDateTill(dateTill); await new Promise((resolve) => { @@ -923,6 +948,10 @@ export const useGameLogStore = defineStore('GameLog', () => { } // use in C# + /** + * + * @param json + */ function addGameLogEvent(json) { const rawLogs = JSON.parse(json); const gameLog = gameLogService.parseRawGameLog( @@ -941,6 +970,9 @@ export const useGameLogStore = defineStore('GameLog', () => { addGameLogEntry(gameLog, locationStore.lastLocation.location); } + /** + * + */ async function disableGameLogDialog() { if (gameStore.isGameRunning) { toast.error(t('message.gamelog.vrchat_must_be_closed')); @@ -962,6 +994,9 @@ export const useGameLogStore = defineStore('GameLog', () => { } } + /** + * + */ async function initGameLogTable() { gameLogTable.value.loading = true; const rows = await database.lookupGameLogDatabase( diff --git a/src/stores/group.js b/src/stores/group.js index 13ca1e8c..ba313e0c 100644 --- a/src/stores/group.js +++ b/src/stores/group.js @@ -3,20 +3,22 @@ import { defineStore } from 'pinia'; import { toast } from 'vue-sonner'; import { useI18n } from 'vue-i18n'; +import { + convertFileUrlToImageUrl, + createDefaultGroupRef, + hasGroupPermission, + replaceBioSymbols, + sanitizeEntityJson +} from '../shared/utils'; import { groupRequest, instanceRequest, userRequest, worldRequest } from '../api'; -import { patchGroupFromEvent } from '../query'; -import { - convertFileUrlToImageUrl, - hasGroupPermission, - replaceBioSymbols -} from '../shared/utils'; import { database } from '../service/database'; import { groupDialogFilterOptions } from '../shared/constants/'; +import { patchGroupFromEvent } from '../query'; import { useGameStore } from './game'; import { useInstanceStore } from './instance'; import { useModalStore } from './modal'; @@ -128,6 +130,11 @@ export const useGroupStore = defineStore('Group', () => { { flush: 'sync' } ); + /** + * + * @param groupId + * @param options + */ function showGroupDialog(groupId, options = {}) { if (!groupId) { return; @@ -232,6 +239,11 @@ export const useGroupStore = defineStore('Group', () => { ); } + /** + * + * @param ref + * @param message + */ function groupChange(ref, message) { if (!currentUserGroupsInit.value) { return; @@ -260,6 +272,9 @@ export const useGroupStore = defineStore('Group', () => { workerTimers.setTimeout(saveCurrentUserGroups, 100); } + /** + * + */ function saveCurrentUserGroups() { if (!currentUserGroupsInit.value) { return; @@ -284,10 +299,10 @@ export const useGroupStore = defineStore('Group', () => { /** * * @param {object} ref - * @param {array} oldRoles - * @param {array} newRoles - * @param {array} oldRoleIds - * @param {array} newRoleIds + * @param {Array} oldRoles + * @param {Array} newRoles + * @param {Array} oldRoleIds + * @param {Array} newRoleIds */ function groupRoleChange(ref, oldRoles, newRoles, oldRoleIds, newRoleIds) { // check for removed/added roleIds @@ -401,7 +416,7 @@ export const useGroupStore = defineStore('Group', () => { /** * * @param {{ groupId: string }} params - * @return { Promise<{posts: any, params}> } + * @returns { Promise<{posts: any, params}> } */ async function getAllGroupPosts(params) { const n = 100; @@ -442,6 +457,10 @@ export const useGroupStore = defineStore('Group', () => { return returnArgs; } + /** + * + * @param groupId + */ function getGroupDialogGroup(groupId) { const D = groupDialog.value; D.isGetGroupDialogGroupLoading = false; @@ -497,32 +516,38 @@ export const useGroupStore = defineStore('Group', () => { }); } }); - groupRequest.getCachedGroupCalendar(groupId).then((args) => { - if (groupDialog.value.id === args.params.groupId) { - D.calendar = args.json.results; - for (const event of D.calendar) { - applyGroupEvent(event); - // fetch again for isFollowing - groupRequest - .getCachedGroupCalendarEvent({ - groupId, - eventId: event.id - }) - .then((args) => { - Object.assign( - event, - applyGroupEvent(args.json) - ); - }); + groupRequest + .getCachedGroupCalendar(groupId) + .then((args) => { + if (groupDialog.value.id === args.params.groupId) { + D.calendar = args.json.results; + for (const event of D.calendar) { + applyGroupEvent(event); + // fetch again for isFollowing + groupRequest + .getCachedGroupCalendarEvent({ + groupId, + eventId: event.id + }) + .then((args) => { + Object.assign( + event, + applyGroupEvent(args.json) + ); + }); + } } - } - }); + }); } nextTick(() => (D.isGetGroupDialogGroupLoading = false)); return args; }); } + /** + * + * @param event + */ function applyGroupEvent(event) { return { userInterest: { @@ -536,6 +561,9 @@ export const useGroupStore = defineStore('Group', () => { }; } + /** + * + */ async function updateInGameGroupOrder() { inGameGroupOrder.value = []; try { @@ -551,6 +579,11 @@ export const useGroupStore = defineStore('Group', () => { } } + /** + * + * @param a + * @param b + */ function sortGroupInstancesByInGame(a, b) { const aIndex = inGameGroupOrder.value.indexOf(a?.group?.id); const bIndex = inGameGroupOrder.value.indexOf(b?.group?.id); @@ -566,6 +599,10 @@ export const useGroupStore = defineStore('Group', () => { return aIndex - bIndex; } + /** + * + * @param groupId + */ function leaveGroup(groupId) { groupRequest .leaveGroup({ @@ -590,6 +627,10 @@ export const useGroupStore = defineStore('Group', () => { }); } + /** + * + * @param groupId + */ function leaveGroupPrompt(groupId) { modalStore .confirm({ @@ -603,6 +644,9 @@ export const useGroupStore = defineStore('Group', () => { .catch(() => {}); } + /** + * + */ function updateGroupPostSearch() { const D = groupDialog.value; const search = D.postsSearch.toLowerCase(); @@ -620,6 +664,11 @@ export const useGroupStore = defineStore('Group', () => { }); } + /** + * + * @param groupId + * @param visibility + */ function setGroupVisibility(groupId, visibility) { return groupRequest .setGroupMemberProps(userStore.currentUser.id, groupId, { @@ -632,6 +681,11 @@ export const useGroupStore = defineStore('Group', () => { }); } + /** + * + * @param groupId + * @param subscribe + */ function setGroupSubscription(groupId, subscribe) { return groupRequest .setGroupMemberProps(userStore.currentUser.id, groupId, { @@ -651,73 +705,9 @@ export const useGroupStore = defineStore('Group', () => { */ function applyGroup(json) { let ref = cachedGroups.get(json.id); - if (json.rules) { - json.rules = replaceBioSymbols(json.rules); - } - if (json.name) { - json.name = replaceBioSymbols(json.name); - } - if (json.description) { - json.description = replaceBioSymbols(json.description); - } + sanitizeEntityJson(json, ['rules', 'name', 'description']); if (typeof ref === 'undefined') { - ref = { - 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 - }; + ref = createDefaultGroupRef(json); cachedGroups.set(ref.id, ref); } else { if (currentUserGroups.has(ref.id)) { @@ -796,6 +786,10 @@ export const useGroupStore = defineStore('Group', () => { return ref; } + /** + * + * @param args + */ function handleGroupRepresented(args) { const D = userStore.userDialog; const json = args.json; @@ -819,6 +813,10 @@ export const useGroupStore = defineStore('Group', () => { applyGroup(json); } + /** + * + * @param args + */ function handleGroupList(args) { for (const json of args.json) { json.$memberId = json.id; @@ -827,6 +825,10 @@ export const useGroupStore = defineStore('Group', () => { } } + /** + * + * @param args + */ function handleGroupMemberProps(args) { if (args.userId === userStore.currentUser.id) { const json = args.json; @@ -873,6 +875,10 @@ export const useGroupStore = defineStore('Group', () => { } } + /** + * + * @param args + */ function handleGroupPermissions(args) { if (args.params.userId !== userStore.currentUser.id) { return; @@ -919,10 +925,18 @@ export const useGroupStore = defineStore('Group', () => { updateGroupPostSearch(); } + /** + * + * @param args + */ function handleGroupMember(args) { args.ref = applyGroupMember(args.json); } + /** + * + * @param args + */ async function handleGroupUserInstances(args) { groupInstances.value = []; for (const json of args.json.instances) { @@ -990,6 +1004,10 @@ export const useGroupStore = defineStore('Group', () => { return json; } + /** + * + * @param ref + */ function applyGroupLanguage(ref) { ref.$languages = []; const { languages } = ref; @@ -1008,6 +1026,11 @@ export const useGroupStore = defineStore('Group', () => { } } + /** + * + * @param userId + * @param groups + */ async function loadCurrentUserGroups(userId, groups) { const savedGroups = JSON.parse( await configRepository.getString( @@ -1063,6 +1086,9 @@ export const useGroupStore = defineStore('Group', () => { getCurrentUserGroups(); } + /** + * + */ async function getCurrentUserGroups() { const args = await groupRequest.getGroups({ userId: userStore.currentUser.id @@ -1082,6 +1108,9 @@ export const useGroupStore = defineStore('Group', () => { saveCurrentUserGroups(); } + /** + * + */ function getCurrentUserRepresentedGroup() { return groupRequest .getRepresentedGroup({ @@ -1093,6 +1122,9 @@ export const useGroupStore = defineStore('Group', () => { }); } + /** + * + */ async function initUserGroups() { updateInGameGroupOrder(); loadCurrentUserGroups( @@ -1101,6 +1133,10 @@ export const useGroupStore = defineStore('Group', () => { ); } + /** + * + * @param userId + */ function showModerateGroupDialog(userId) { const D = moderateGroupDialog.value; D.userId = userId; @@ -1108,6 +1144,11 @@ export const useGroupStore = defineStore('Group', () => { D.visible = true; } + /** + * + * @param groupId + * @param userId + */ function showGroupMemberModerationDialog(groupId, userId = '') { const D = groupMemberModeration.value; D.id = groupId; diff --git a/src/stores/instance.js b/src/stores/instance.js index c1335001..4d92127a 100644 --- a/src/stores/instance.js +++ b/src/stores/instance.js @@ -8,8 +8,11 @@ import { compareByDisplayName, compareById, compareByLocationAt, + computeDisabledContentSettings, + createDefaultInstanceRef, debounce, displayLocation, + evictMapCache, getAvailablePlatforms, getBundleDateSize, getGroupName, @@ -25,12 +28,12 @@ import { userRequest, worldRequest } from '../api'; -import { patchInstanceFromEvent } from '../query'; import { accessTypeLocaleKeyMap, instanceContentSettings } from '../shared/constants'; import { database } from '../service/database'; +import { patchInstanceFromEvent } from '../query'; import { resolveRef } from '../shared/utils/resolveRef'; import { useAppearanceSettingsStore } from './settings/appearance'; import { useFriendStore } from './friend'; @@ -66,30 +69,26 @@ export const useInstanceStore = defineStore('Instance', () => { let cachedInstances = new Map(); + /** + * + */ function cleanInstanceCache() { - const maxSize = 200; - if (cachedInstances.size <= maxSize) { - return; - } - const removable = []; - cachedInstances.forEach((ref, id) => { - if ( - [...friendStore.friends.values()].some( - (f) => f.$location?.tag === id - ) - ) { - return; + const friendLocationTags = new Set( + [...friendStore.friends.values()] + .map((f) => f.$location?.tag) + .filter(Boolean) + ); + evictMapCache( + cachedInstances, + 200, + (_value, key) => friendLocationTags.has(key), + { + sortFn: (a, b) => + (Date.parse(a.value.$fetchedAt) || 0) - + (Date.parse(b.value.$fetchedAt) || 0), + logLabel: 'Instance cache cleanup' } - removable.push({ - id, - fetchedAt: Date.parse(ref.$fetchedAt) || 0 - }); - }); - removable.sort((a, b) => a.fetchedAt - b.fetchedAt); - const overBy = cachedInstances.size - maxSize; - for (let i = 0; i < overBy && i < removable.length; i++) { - cachedInstances.delete(removable[i].id); - } + ); } const lastInstanceApplied = ref(''); @@ -183,6 +182,9 @@ export const useInstanceStore = defineStore('Instance', () => { { flush: 'sync' } ); + /** + * + */ async function getInstanceJoinHistory() { try { const data = await database.getInstanceJoinHistory(); @@ -195,6 +197,11 @@ export const useInstanceStore = defineStore('Instance', () => { } } + /** + * + * @param location + * @param dateTime + */ function addInstanceJoinHistory(location, dateTime) { if (!location || !dateTime) { return; @@ -208,11 +215,18 @@ export const useInstanceStore = defineStore('Instance', () => { instanceJoinHistory.set(location, epoch); } + /** + * + */ function hidePreviousInstancesDialogs() { previousInstancesInfoDialog.value.visible = false; previousInstancesListDialog.value.visible = false; } + /** + * + * @param input + */ function resolveUserRef(input) { return resolveRef(input, { emptyDefault: { id: '', displayName: '' }, @@ -222,6 +236,10 @@ export const useInstanceStore = defineStore('Instance', () => { }); } + /** + * + * @param input + */ function resolveWorldRef(input) { return resolveRef(input, { emptyDefault: { id: '', name: '' }, @@ -231,6 +249,10 @@ export const useInstanceStore = defineStore('Instance', () => { }); } + /** + * + * @param input + */ function resolveGroupRef(input) { return resolveRef(input, { emptyDefault: { id: '', name: '' }, @@ -240,6 +262,10 @@ export const useInstanceStore = defineStore('Instance', () => { }); } + /** + * + * @param accessTypeNameRaw + */ function translateAccessType(accessTypeNameRaw) { const key = accessTypeLocaleKeyMap[accessTypeNameRaw]; if (!key) { @@ -255,6 +281,11 @@ export const useInstanceStore = defineStore('Instance', () => { return t(key); } + /** + * + * @param instanceId + * @param worldNameOverride + */ function formatPreviousInstancesInfoLabel( instanceId, worldNameOverride = '' @@ -275,6 +306,10 @@ export const useInstanceStore = defineStore('Instance', () => { return `${baseLabel} · ${accessTypeLabel}`; } + /** + * + * @param instanceId + */ function showPreviousInstancesInfoDialog(instanceId) { previousInstancesInfoDialog.value.visible = true; previousInstancesInfoDialog.value.instanceId = instanceId; @@ -308,6 +343,11 @@ export const useInstanceStore = defineStore('Instance', () => { } } + /** + * + * @param variant + * @param targetRef + */ async function showPreviousInstancesListDialog(variant, targetRef) { previousInstancesListDialog.value.variant = variant; let resolved = null; @@ -335,6 +375,9 @@ export const useInstanceStore = defineStore('Instance', () => { }); } + /** + * + */ function updateCurrentInstanceWorld() { let L; let instanceId = locationStore.lastLocation.location; @@ -472,53 +515,7 @@ export const useInstanceStore = defineStore('Instance', () => { } let ref = cachedInstances.get(json.id); if (typeof ref === 'undefined') { - ref = { - 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 - }; + ref = createDefaultInstanceRef(json); cachedInstances.set(ref.id, ref); cleanInstanceCache(); } else { @@ -535,18 +532,10 @@ export const useInstanceStore = defineStore('Instance', () => { return args; }); } - ref.$disabledContentSettings = []; - if (json.contentSettings && Object.keys(json.contentSettings).length) { - for (const setting of instanceContentSettings) { - if ( - typeof json.contentSettings[setting] === 'undefined' || - json.contentSettings[setting] === true - ) { - continue; - } - ref.$disabledContentSettings.push(setting); - } - } + ref.$disabledContentSettings = computeDisabledContentSettings( + json.contentSettings, + instanceContentSettings + ); if (ref.displayName) { ref.displayName = replaceBioSymbols(ref.displayName); } @@ -578,6 +567,10 @@ export const useInstanceStore = defineStore('Instance', () => { return ref; } + /** + * + * @param location + */ async function getInstanceName(location) { let instanceName = ''; @@ -701,10 +694,16 @@ export const useInstanceStore = defineStore('Instance', () => { } } + /** + * + */ function applyWorldDialogInstances() { debounce(applyWorldDialogInstancesDebounced, 100)(); } + /** + * + */ function applyWorldDialogInstancesDebounced() { let ref; let instance; @@ -1079,6 +1078,9 @@ export const useInstanceStore = defineStore('Instance', () => { D.instances = rooms; } + /** + * + */ function removeAllQueuedInstances() { queuedInstances.forEach((ref) => { toast.info(`Removed instance ${ref.$worldName} from queue`); @@ -1233,6 +1235,9 @@ export const useInstanceStore = defineStore('Instance', () => { // workerTimers.setTimeout(this.instanceQueueTimeout, 3600000); } + /** + * + */ function getCurrentInstanceUserList() { if (!watchState.isFriendsLoaded) { return; @@ -1250,6 +1255,9 @@ export const useInstanceStore = defineStore('Instance', () => { } } + /** + * + */ function updatePlayerListExecute() { try { updatePlayerListDebounce(); @@ -1260,6 +1268,9 @@ export const useInstanceStore = defineStore('Instance', () => { state.updatePlayerListPending = false; } + /** + * + */ function updatePlayerListDebounce() { const users = []; const pushUser = function (ref) { diff --git a/src/stores/notification/index.js b/src/stores/notification/index.js index d0bbfd0c..306946bb 100644 --- a/src/stores/notification/index.js +++ b/src/stores/notification/index.js @@ -7,14 +7,18 @@ import Noty from 'noty'; import dayjs from 'dayjs'; import { + applyBoopLegacyHandling, checkCanInvite, + createDefaultNotificationRef, + createDefaultNotificationV2Ref, escapeTag, executeWithBackoff, findUserByDisplayName, getUserMemo, parseLocation, + parseNotificationDetails, removeFromArray, - replaceBioSymbols + sanitizeNotificationJson } from '../../shared/utils'; import { friendRequest, @@ -163,6 +167,9 @@ export const useNotificationStore = defineStore('Notification', () => { { flush: 'sync' } ); + /** + * + */ async function init() { notificationTable.value.filters[0].value = JSON.parse( await configRepository.getString( @@ -174,6 +181,10 @@ export const useNotificationStore = defineStore('Notification', () => { init(); + /** + * + * @param args + */ function handleNotification(args) { args.ref = applyNotification(args.json); const { ref } = args; @@ -234,6 +245,10 @@ export const useNotificationStore = defineStore('Notification', () => { D.incomingRequest = true; } + /** + * + * @param notificationId + */ function handleNotificationHide(notificationId) { const ref = notificationTable.value.data.find( (n) => n.id === notificationId @@ -259,6 +274,10 @@ export const useNotificationStore = defineStore('Notification', () => { }); } + /** + * + * @param args + */ function handlePipelineNotification(args) { const ref = args.json; if ( @@ -373,6 +392,10 @@ export const useNotificationStore = defineStore('Notification', () => { }); } + /** + * + * @param notificationId + */ function handleNotificationSee(notificationId) { removeFromArray(unseenNotifications.value, notificationId); if (unseenNotifications.value.length === 0) { @@ -392,6 +415,9 @@ export const useNotificationStore = defineStore('Notification', () => { let seeProcessing = false; const SEE_CONCURRENCY = 2; + /** + * + */ async function processSeeQueue() { if (seeProcessing) return; seeProcessing = true; @@ -443,7 +469,7 @@ export const useNotificationStore = defineStore('Notification', () => { /** * Queue a notification to be marked as seen. * @param {string} notificationId - * @param {number} [version=1] + * @param {number} [version] */ function queueMarkAsSeen(notificationId, version = 1) { if (seenIds.has(notificationId)) return; @@ -452,6 +478,10 @@ export const useNotificationStore = defineStore('Notification', () => { processSeeQueue(); } + /** + * + * @param args + */ function handleNotificationAccept(args) { let ref; const array = notificationTable.value.data; @@ -490,6 +520,10 @@ export const useNotificationStore = defineStore('Notification', () => { D.isFriend = true; } + /** + * + * @param args + */ function handleNotificationExpire(args) { const { ref } = args; const D = userStore.userDialog; @@ -509,10 +543,7 @@ export const useNotificationStore = defineStore('Notification', () => { * @returns {object} */ function applyNotification(data) { - const json = { ...data }; - if (json.message) { - json.message = replaceBioSymbols(json.message); - } + const json = sanitizeNotificationJson({ ...data }); let ref; const array = notificationTable.value.data; for (let i = array.length - 1; i >= 0; i--) { @@ -521,102 +552,37 @@ export const useNotificationStore = defineStore('Notification', () => { break; } } - // delete any null in json - for (const key in json) { - if (json[key] === null) { - delete json[key]; - } - } if (typeof ref === 'undefined') { - ref = { - id: '', - senderUserId: '', - senderUsername: '', - type: '', - message: '', - details: {}, - seen: false, - created_at: '', - // VRCX - $isExpired: false, - // - ...json - }; + ref = createDefaultNotificationRef(json); } else { Object.assign(ref, json); ref.$isExpired = false; } - if (ref.details !== Object(ref.details)) { - let details = {}; - if (ref.details !== '{}') { - try { - const object = JSON.parse(ref.details); - if (object === Object(object)) { - details = object; - } - } catch (err) { - console.log(err); - } - } - ref.details = details; - } + ref.details = parseNotificationDetails(ref.details); return ref; } + /** + * + * @param data + */ function applyNotificationV2(data) { - const json = { ...data }; - // delete any null in 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); - } + const json = sanitizeNotificationJson({ ...data }); let ref = notificationTable.value.data.find((n) => n.id === json.id); if (typeof ref === 'undefined') { - ref = { - id: '', - createdAt: '', - updatedAt: '', - expiresAt: '', - type: '', - link: '', - linkText: '', - message: '', - title: '', - imageUrl: '', - seen: false, - senderUserId: '', - senderUsername: '', - data: {}, - responses: [], - details: {}, - version: 2, - ...json - }; + ref = createDefaultNotificationV2Ref(json); } else { Object.assign(ref, json); } ref.created_at = ref.createdAt; // for table - // legacy handling of boops - if (ref.type === 'boop' && ref.title) { - 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 = `${AppDebug.endpointDomain}/file/${ref.details.emojiId}/${ref.details.emojiVersion}`; - } - } + applyBoopLegacyHandling(ref, AppDebug.endpointDomain); return ref; } + /** + * + * @param args + */ function handleNotificationV2(args) { const ref = applyNotificationV2(args.json); if (ref.seen) { @@ -645,6 +611,10 @@ export const useNotificationStore = defineStore('Notification', () => { sharedFeedStore.addEntry(ref); } + /** + * + * @param args + */ function handleNotificationV2Update(args) { const notificationId = args.params.notificationId; const json = { ...args.json }; @@ -663,6 +633,10 @@ export const useNotificationStore = defineStore('Notification', () => { } } + /** + * + * @param notificationId + */ function handleNotificationV2Hide(notificationId) { database.expireNotificationV2(notificationId); const ref = notificationTable.value.data.find( @@ -674,6 +648,9 @@ export const useNotificationStore = defineStore('Notification', () => { } } + /** + * + */ function expireFriendRequestNotifications() { const array = notificationTable.value.data; for (let i = array.length - 1; i >= 0; i--) { @@ -813,6 +790,10 @@ export const useNotificationStore = defineStore('Notification', () => { } } + /** + * + * @param noty + */ function playNoty(noty) { if ( userStore.currentUser.status === 'busy' || @@ -1014,6 +995,10 @@ export const useNotificationStore = defineStore('Notification', () => { return ''; } + /** + * + * @param gamelog + */ function queueGameLogNoty(gamelog) { const noty = structuredClone(gamelog); let bias; @@ -1090,6 +1075,10 @@ export const useNotificationStore = defineStore('Notification', () => { } } + /** + * + * @param feed + */ function queueFeedNoty(feed) { const noty = { ...feed }; if (noty.type === 'Avatar') { @@ -1116,6 +1105,10 @@ export const useNotificationStore = defineStore('Notification', () => { } } + /** + * + * @param noty + */ function queueFriendLogNoty(noty) { if (noty.type === 'FriendRequest') { return; @@ -1133,6 +1126,10 @@ export const useNotificationStore = defineStore('Notification', () => { } } + /** + * + * @param noty + */ function queueModerationNoty(noty) { noty.isFriend = false; noty.isFavorite = false; @@ -1146,6 +1143,9 @@ export const useNotificationStore = defineStore('Notification', () => { } } + /** + * + */ async function initNotifications() { notificationInitStatus.value = false; let tableData = await database.getNotificationsV2(); @@ -1161,6 +1161,9 @@ export const useNotificationStore = defineStore('Notification', () => { refreshNotifications(); } + /** + * + */ function testNotification() { playNoty({ type: 'Event', @@ -1169,6 +1172,10 @@ export const useNotificationStore = defineStore('Notification', () => { }); } + /** + * + * @param row + */ function acceptFriendRequestNotification(row) { modalStore .confirm({ @@ -1193,6 +1200,10 @@ export const useNotificationStore = defineStore('Notification', () => { .catch(() => {}); } + /** + * + * @param row + */ async function hideNotification(row) { if (row.type === 'ignoredFriendRequest') { await friendRequest.deleteHiddenFriendRequest( @@ -1211,6 +1222,10 @@ export const useNotificationStore = defineStore('Notification', () => { } } + /** + * + * @param row + */ function hideNotificationPrompt(row) { modalStore .confirm({ @@ -1223,6 +1238,10 @@ export const useNotificationStore = defineStore('Notification', () => { .catch(() => {}); } + /** + * + * @param row + */ function acceptRequestInvite(row) { modalStore .confirm({ @@ -1268,6 +1287,12 @@ export const useNotificationStore = defineStore('Notification', () => { .catch(() => {}); } + /** + * + * @param notificationId + * @param responses + * @param responseType + */ function sendNotificationResponse(notificationId, responses, responseType) { if (!Array.isArray(responses) || responses.length === 0) return; let responseData = ''; @@ -1295,6 +1320,10 @@ export const useNotificationStore = defineStore('Notification', () => { }); } + /** + * + * @param row + */ function deleteNotificationLog(row) { const idx = notificationTable.value.data.findIndex( (e) => e.id === row.id @@ -1314,6 +1343,10 @@ export const useNotificationStore = defineStore('Notification', () => { } } + /** + * + * @param row + */ function deleteNotificationLogPrompt(row) { modalStore .confirm({ @@ -1327,6 +1360,10 @@ export const useNotificationStore = defineStore('Notification', () => { .catch(() => {}); } + /** + * + * @param notification + */ function isNotificationExpired(notification) { if (notification.$isExpired !== undefined) { return notification.$isExpired; @@ -1338,6 +1375,10 @@ export const useNotificationStore = defineStore('Notification', () => { return expiresAt.isValid() && dayjs().isSameOrAfter(expiresAt); } + /** + * + * @param link + */ function openNotificationLink(link) { if (!link) { return; diff --git a/src/stores/settings/appearance.js b/src/stores/settings/appearance.js index 33216218..51d79c86 100644 --- a/src/stores/settings/appearance.js +++ b/src/stores/settings/appearance.js @@ -20,8 +20,8 @@ import { getThemeMode, updateTrustColorClasses } from '../../shared/utils/base/ui'; +import { computeTrustLevel, getNameColour } from '../../shared/utils'; import { database } from '../../service/database'; -import { getNameColour } from '../../shared/utils'; import { languageCodes } from '../../localization'; import { loadLocalizedStrings } from '../../plugin'; import { useFeedStore } from '../feed'; @@ -127,6 +127,9 @@ export const useAppearanceSettingsStore = defineStore( : fallback; }; + /** + * + */ async function initAppearanceSettings() { const { initThemeMode, isDarkMode: initDarkMode } = await getThemeMode(configRepository); @@ -410,6 +413,10 @@ export const useAppearanceSettingsStore = defineStore( updateTrustColorClasses(trustColor.value); } + /** + * + * @param customFunc + */ async function userColourInit(customFunc) { let dictObject = null; if (typeof customFunc === 'function') { @@ -440,55 +447,13 @@ export const useAppearanceSettingsStore = defineStore( * @param {object} ref */ function applyUserTrustLevel(ref) { - ref.$isModerator = - ref.developerType && ref.developerType !== 'none'; - ref.$isTroll = false; - ref.$isProbableTroll = false; - let trustColorTemp = ''; - const { tags } = ref; - if (tags.includes('admin_moderator')) { - ref.$isModerator = true; - } - if (tags.includes('system_troll')) { - ref.$isTroll = true; - } - if (tags.includes('system_probable_troll') && !ref.$isTroll) { - ref.$isProbableTroll = true; - } - if (tags.includes('system_trust_veteran')) { - ref.$trustLevel = 'Trusted User'; - ref.$trustClass = 'x-tag-veteran'; - trustColorTemp = 'veteran'; - ref.$trustSortNum = 5; - } else if (tags.includes('system_trust_trusted')) { - ref.$trustLevel = 'Known User'; - ref.$trustClass = 'x-tag-trusted'; - trustColorTemp = 'trusted'; - ref.$trustSortNum = 4; - } else if (tags.includes('system_trust_known')) { - ref.$trustLevel = 'User'; - ref.$trustClass = 'x-tag-known'; - trustColorTemp = 'known'; - ref.$trustSortNum = 3; - } else if (tags.includes('system_trust_basic')) { - ref.$trustLevel = 'New User'; - ref.$trustClass = 'x-tag-basic'; - trustColorTemp = 'basic'; - ref.$trustSortNum = 2; - } else { - ref.$trustLevel = 'Visitor'; - ref.$trustClass = 'x-tag-untrusted'; - trustColorTemp = 'untrusted'; - ref.$trustSortNum = 1; - } - if (ref.$isTroll || ref.$isProbableTroll) { - trustColorTemp = 'troll'; - ref.$trustSortNum += 0.1; - } - if (ref.$isModerator) { - trustColorTemp = 'vip'; - ref.$trustSortNum += 0.3; - } + const trust = computeTrustLevel(ref.tags, ref.developerType); + ref.$isModerator = trust.isModerator; + ref.$isTroll = trust.isTroll; + ref.$isProbableTroll = trust.isProbableTroll; + ref.$trustLevel = trust.trustLevel; + ref.$trustClass = trust.trustClass; + ref.$trustSortNum = trust.trustSortNum; if (randomUserColours.value && watchState.isFriendsLoaded) { if (!ref.$userColour) { getNameColour(ref.id).then((colour) => { @@ -496,7 +461,7 @@ export const useAppearanceSettingsStore = defineStore( }); } } else { - ref.$userColour = trustColor.value[trustColorTemp]; + ref.$userColour = trustColor.value[trust.trustColorKey]; } } @@ -525,6 +490,9 @@ export const useAppearanceSettingsStore = defineStore( updateTrustColor(undefined, undefined); } + /** + * + */ function toggleThemeMode() { const nextMode = isDarkMode.value ? 'light' @@ -532,12 +500,20 @@ export const useAppearanceSettingsStore = defineStore( setThemeMode(nextMode); } + /** + * + * @param value + */ function normalizeAppFontFamily(value) { return APP_FONT_FAMILIES.includes(value) ? value : APP_FONT_DEFAULT_KEY; } + /** + * + * @param value + */ function setAppFontFamily(value) { const normalized = normalizeAppFontFamily(value); appFontFamily.value = normalized; @@ -545,6 +521,9 @@ export const useAppearanceSettingsStore = defineStore( applyAppFontFamily(normalized); } + /** + * + */ function setDisplayVRCPlusIconsAsAvatar() { displayVRCPlusIconsAsAvatar.value = !displayVRCPlusIconsAsAvatar.value; @@ -553,6 +532,9 @@ export const useAppearanceSettingsStore = defineStore( displayVRCPlusIconsAsAvatar.value ); } + /** + * + */ function setNotificationIconDot() { notificationIconDot.value = !notificationIconDot.value; configRepository.setBool( @@ -561,10 +543,16 @@ export const useAppearanceSettingsStore = defineStore( ); uiStore.updateTrayIconNotify(); } + /** + * + */ function setHideNicknames() { hideNicknames.value = !hideNicknames.value; configRepository.setBool('VRCX_hideNicknames', hideNicknames.value); } + /** + * + */ function setShowInstanceIdInLocation() { showInstanceIdInLocation.value = !showInstanceIdInLocation.value; configRepository.setBool( @@ -572,6 +560,9 @@ export const useAppearanceSettingsStore = defineStore( showInstanceIdInLocation.value ); } + /** + * + */ function setIsAgeGatedInstancesVisible() { isAgeGatedInstancesVisible.value = !isAgeGatedInstancesVisible.value; @@ -580,10 +571,16 @@ export const useAppearanceSettingsStore = defineStore( isAgeGatedInstancesVisible.value ); } + /** + * + */ function setSortFavorites() { sortFavorites.value = !sortFavorites.value; configRepository.setBool('VRCX_sortFavorites', sortFavorites.value); } + /** + * + */ function setInstanceUsersSortAlphabetical() { instanceUsersSortAlphabetical.value = !instanceUsersSortAlphabetical.value; @@ -593,6 +590,10 @@ export const useAppearanceSettingsStore = defineStore( ); } + /** + * + * @param size + */ function setTablePageSize(size) { const processedSize = clampInt(size, 1, MAX_TABLE_PAGE_SIZE); tablePageSize.value = processedSize; @@ -601,6 +602,10 @@ export const useAppearanceSettingsStore = defineStore( return processedSize; } + /** + * + * @param input + */ function normalizeTablePageSizes(input) { const values = ( Array.isArray(input) ? input : DEFAULT_TABLE_PAGE_SIZES @@ -629,10 +634,16 @@ export const useAppearanceSettingsStore = defineStore( setTablePageSize(tablePageSizes.value[0]); } } + /** + * + */ function setDtHour12() { dtHour12.value = !dtHour12.value; configRepository.setBool('VRCX_dtHour12', dtHour12.value); } + /** + * + */ function setDtIsoFormat() { dtIsoFormat.value = !dtIsoFormat.value; configRepository.setBool('VRCX_dtIsoFormat', dtIsoFormat.value); @@ -668,13 +679,24 @@ export const useAppearanceSettingsStore = defineStore( JSON.stringify(methods) ); } + /** + * + * @param collapsed + */ function setNavCollapsed(collapsed) { isNavCollapsed.value = collapsed; configRepository.setBool('VRCX_navIsCollapsed', collapsed); } + /** + * + */ function toggleNavCollapsed() { setNavCollapsed(!isNavCollapsed.value); } + /** + * + * @param widthOrArray + */ function setNavWidth(widthOrArray) { let width = null; if (Array.isArray(widthOrArray) && widthOrArray.length) { @@ -692,6 +714,9 @@ export const useAppearanceSettingsStore = defineStore( }); } } + /** + * + */ function setIsSidebarGroupByInstance() { isSidebarGroupByInstance.value = !isSidebarGroupByInstance.value; configRepository.setBool( @@ -699,6 +724,9 @@ export const useAppearanceSettingsStore = defineStore( isSidebarGroupByInstance.value ); } + /** + * + */ function setIsHideFriendsInSameInstance() { isHideFriendsInSameInstance.value = !isHideFriendsInSameInstance.value; @@ -707,6 +735,9 @@ export const useAppearanceSettingsStore = defineStore( isHideFriendsInSameInstance.value ); } + /** + * + */ function setIsSidebarDivideByFriendGroup() { isSidebarDivideByFriendGroup.value = !isSidebarDivideByFriendGroup.value; @@ -735,18 +766,30 @@ export const useAppearanceSettingsStore = defineStore( JSON.stringify(value) ); } + /** + * + */ function setHideUserNotes() { hideUserNotes.value = !hideUserNotes.value; configRepository.setBool('VRCX_hideUserNotes', hideUserNotes.value); } + /** + * + */ function setHideUserMemos() { hideUserMemos.value = !hideUserMemos.value; configRepository.setBool('VRCX_hideUserMemos', hideUserMemos.value); } + /** + * + */ function setHideUnfriends() { hideUnfriends.value = !hideUnfriends.value; configRepository.setBool('VRCX_hideUnfriends', hideUnfriends.value); } + /** + * + */ function setRandomUserColours() { randomUserColours.value = !randomUserColours.value; configRepository.setBool( @@ -754,6 +797,10 @@ export const useAppearanceSettingsStore = defineStore( randomUserColours.value ); } + /** + * + * @param value + */ function normalizeTableDensity(value) { if ( value === 'compact' || @@ -765,6 +812,10 @@ export const useAppearanceSettingsStore = defineStore( return 'standard'; } + /** + * + * @param density + */ function setTableDensity(density) { const normalized = normalizeTableDensity(density); tableDensity.value = normalized; @@ -772,6 +823,9 @@ export const useAppearanceSettingsStore = defineStore( configRepository.setString('VRCX_tableDensity', tableDensity.value); } + /** + * + */ function toggleStripedDataTable() { isDataTableStriped.value = !isDataTableStriped.value; configRepository.setBool( @@ -781,6 +835,9 @@ export const useAppearanceSettingsStore = defineStore( } // FIXME: this is nasty, there should be a better way of doing this + /** + * + */ function applyPointerHoverClass() { const classList = document.documentElement.classList; classList.remove('force-pointer-on-hover'); @@ -790,6 +847,9 @@ export const useAppearanceSettingsStore = defineStore( } } + /** + * + */ function togglePointerOnHover() { showPointerOnHover.value = !showPointerOnHover.value; configRepository.setBool( @@ -811,6 +871,9 @@ export const useAppearanceSettingsStore = defineStore( ); } + /** + * + */ function handleSaveSidebarSortOrder() { if (sidebarSortMethod1.value === sidebarSortMethod2.value) { sidebarSortMethod2.value = ''; @@ -835,6 +898,9 @@ export const useAppearanceSettingsStore = defineStore( setSidebarSortMethods(sidebarSortMethods); } + /** + * + */ async function mergeOldSortMethodsSettings() { const orderFriendsGroupPrivate = await configRepository.getBool( 'orderFriendGroupPrivate' @@ -897,6 +963,9 @@ export const useAppearanceSettingsStore = defineStore( return n; }; + /** + * + */ function showTableLimitsDialog() { tableLimitsDialog.value.maxTableSize = Number( vrcxStore.maxTableSize ?? 500 @@ -907,10 +976,16 @@ export const useAppearanceSettingsStore = defineStore( tableLimitsDialog.value.visible = true; } + /** + * + */ function closeTableLimitsDialog() { tableLimitsDialog.value.visible = false; } + /** + * + */ async function saveTableLimitsDialog() { const nextMaxTableSize = clampLimit( tableLimitsDialog.value.maxTableSize, @@ -949,6 +1024,9 @@ export const useAppearanceSettingsStore = defineStore( tableLimitsDialog.value.visible = false; } + /** + * + */ async function tryInitUserColours() { if (!randomUserColours.value) { return; @@ -958,6 +1036,10 @@ export const useAppearanceSettingsStore = defineStore( await userColourInit(); } + /** + * + * @param density + */ function applyTableDensity(density) { const classList = document.documentElement.classList; classList.remove('is-compact-table', 'is-comfortable-table'); diff --git a/src/stores/settings/discordPresence.js b/src/stores/settings/discordPresence.js index 89acae51..894e3d58 100644 --- a/src/stores/settings/discordPresence.js +++ b/src/stores/settings/discordPresence.js @@ -9,6 +9,12 @@ import { isRpcWorld, parseLocation } from '../../shared/utils'; +import { + getPlatformLabel, + getRpcWorldConfig, + getStatusInfo, + isPopcornPalaceWorld +} from '../../shared/utils/discordPresence'; import { ActivityType, StatusDisplayType @@ -59,14 +65,23 @@ export const useDiscordPresenceSettingsStore = defineStore( const discordWorldIntegration = ref(true); const discordWorldNameAsDiscordStatus = ref(false); + /** + * + */ function setDiscordActive() { discordActive.value = !discordActive.value; configRepository.setBool('discordActive', discordActive.value); } + /** + * + */ function setDiscordInstance() { discordInstance.value = !discordInstance.value; configRepository.setBool('discordInstance', discordInstance.value); } + /** + * + */ function setDiscordHideInvite() { discordHideInvite.value = !discordHideInvite.value; configRepository.setBool( @@ -74,6 +89,9 @@ export const useDiscordPresenceSettingsStore = defineStore( discordHideInvite.value ); } + /** + * + */ function setDiscordJoinButton() { discordJoinButton.value = !discordJoinButton.value; configRepository.setBool( @@ -81,6 +99,9 @@ export const useDiscordPresenceSettingsStore = defineStore( discordJoinButton.value ); } + /** + * + */ function setDiscordHideImage() { discordHideImage.value = !discordHideImage.value; configRepository.setBool( @@ -88,6 +109,9 @@ export const useDiscordPresenceSettingsStore = defineStore( discordHideImage.value ); } + /** + * + */ function setDiscordShowPlatform() { discordShowPlatform.value = !discordShowPlatform.value; configRepository.setBool( @@ -95,6 +119,9 @@ export const useDiscordPresenceSettingsStore = defineStore( discordShowPlatform.value ); } + /** + * + */ function setDiscordWorldIntegration() { discordWorldIntegration.value = !discordWorldIntegration.value; configRepository.setBool( @@ -102,6 +129,9 @@ export const useDiscordPresenceSettingsStore = defineStore( discordWorldIntegration.value ); } + /** + * + */ function setDiscordWorldNameAsDiscordStatus() { discordWorldNameAsDiscordStatus.value = !discordWorldNameAsDiscordStatus.value; @@ -111,6 +141,9 @@ export const useDiscordPresenceSettingsStore = defineStore( ); } + /** + * + */ async function initDiscordPresenceSettings() { const [ discordActiveConfig, @@ -148,6 +181,9 @@ export const useDiscordPresenceSettingsStore = defineStore( initDiscordPresenceSettings(); + /** + * + */ async function updateDiscord() { let currentLocation = locationStore.lastLocation.location; let startTime = locationStore.lastLocation.date; @@ -204,27 +240,12 @@ export const useDiscordPresenceSettingsStore = defineStore( let platform = ''; if (discordShowPlatform.value) { - if (gameStore.isGameRunning) { - platform = gameStore.isGameNoVR - ? ` (${t('view.settings.discord_presence.rpc.desktop')})` - : ` (${t('view.settings.discord_presence.rpc.vr')})`; - } else { - switch (userStore.currentUser.presence.platform) { - case 'web': - break; - case 'standalonewindows': - platform = ` (PC)`; - break; - case 'android': - platform = ` (Android)`; - break; - case 'ios': - platform = ` (iOS)`; - break; - default: - platform = ` (${userStore.currentUser.presence.platform})`; - } - } + platform = getPlatformLabel( + userStore.currentUser.presence.platform, + gameStore.isGameRunning, + gameStore.isGameNoVR, + t + ); } state.lastLocationDetails.groupAccessType = L.groupAccessType; if (L.groupAccessType) { @@ -281,34 +302,14 @@ export const useDiscordPresenceSettingsStore = defineStore( ) { hidePrivate = true; } - let statusName = ''; - let statusImage = ''; - switch (userStore.currentUser.status) { - case 'active': - statusName = t('dialog.user.status.active'); - statusImage = 'active'; - break; - case 'join me': - statusName = t('dialog.user.status.join_me'); - statusImage = 'joinme'; - break; - case 'ask me': - statusName = t('dialog.user.status.ask_me'); - statusImage = 'askme'; - if (discordHideInvite.value) { - hidePrivate = true; - } - break; - case 'busy': - statusName = t('dialog.user.status.busy'); - statusImage = 'busy'; - hidePrivate = true; - break; - default: - statusName = t('dialog.user.status.offline'); - statusImage = 'offline'; - hidePrivate = true; - break; + const statusInfo = getStatusInfo( + userStore.currentUser.status, + discordHideInvite.value, + t + ); + const { statusName, statusImage } = statusInfo; + if (statusInfo.hidePrivate) { + hidePrivate = true; } let details = state.lastLocationDetails.worldName; let stateText = state.lastLocationDetails.accessName; @@ -345,74 +346,23 @@ export const useDiscordPresenceSettingsStore = defineStore( buttonUrl = ''; } - if ( + const rpcConfig = isRpcWorld(state.lastLocationDetails.tag) && discordWorldIntegration.value - ) { - // custom world rpc + ? getRpcWorldConfig(state.lastLocationDetails.worldId) + : null; + + if (rpcConfig) { + activityType = rpcConfig.activityType; + statusDisplayType = rpcConfig.statusDisplayType; + appId = rpcConfig.appId; + bigIcon = rpcConfig.bigIcon; if ( - state.lastLocationDetails.worldId === - 'wrld_f20326da-f1ac-45fc-a062-609723b097b1' || - state.lastLocationDetails.worldId === - 'wrld_10e5e467-fc65-42ed-8957-f02cace1398c' || - state.lastLocationDetails.worldId === - 'wrld_04899f23-e182-4a8d-b2c7-2c74c7c15534' + isPopcornPalaceWorld(state.lastLocationDetails.worldId) && + !discordHideImage.value && + gameLogStore.nowPlaying.thumbnailUrl ) { - activityType = ActivityType.Listening; - statusDisplayType = StatusDisplayType.Details; - appId = '784094509008551956'; - bigIcon = 'pypy'; - } else if ( - state.lastLocationDetails.worldId === - 'wrld_42377cf1-c54f-45ed-8996-5875b0573a83' || - state.lastLocationDetails.worldId === - 'wrld_dd6d2888-dbdc-47c2-bc98-3d631b2acd7c' - ) { - activityType = ActivityType.Listening; - statusDisplayType = StatusDisplayType.Details; - appId = '846232616054030376'; - bigIcon = 'vr_dancing'; - } else if ( - state.lastLocationDetails.worldId === - 'wrld_52bdcdab-11cd-4325-9655-0fb120846945' || - state.lastLocationDetails.worldId === - 'wrld_2d40da63-8f1f-4011-8a9e-414eb8530acd' - ) { - activityType = ActivityType.Listening; - statusDisplayType = StatusDisplayType.Details; - appId = '939473404808007731'; - bigIcon = 'zuwa_zuwa_dance'; - } else if ( - state.lastLocationDetails.worldId === - 'wrld_74970324-58e8-4239-a17b-2c59dfdf00db' || - state.lastLocationDetails.worldId === - 'wrld_db9d878f-6e76-4776-8bf2-15bcdd7fc445' || - state.lastLocationDetails.worldId === - 'wrld_435bbf25-f34f-4b8b-82c6-cd809057eb8e' || - state.lastLocationDetails.worldId === - 'wrld_f767d1c8-b249-4ecc-a56f-614e433682c8' - ) { - activityType = ActivityType.Watching; - statusDisplayType = StatusDisplayType.Details; - appId = '968292722391785512'; - bigIcon = 'ls_media'; - } else if ( - state.lastLocationDetails.worldId === - 'wrld_266523e8-9161-40da-acd0-6bd82e075833' || - state.lastLocationDetails.worldId === - 'wrld_27c7e6b2-d938-447e-a270-3d1a873e2cf3' - ) { - activityType = ActivityType.Watching; - statusDisplayType = StatusDisplayType.Details; - appId = '1095440531821170820'; - if ( - !discordHideImage.value && - gameLogStore.nowPlaying.thumbnailUrl - ) { - bigIcon = gameLogStore.nowPlaying.thumbnailUrl; - } else { - bigIcon = 'popcorn_palace'; - } + bigIcon = gameLogStore.nowPlaying.thumbnailUrl; } if (gameLogStore.nowPlaying.name) { details = gameLogStore.nowPlaying.name; @@ -476,12 +426,20 @@ export const useDiscordPresenceSettingsStore = defineStore( ); } + /** + * + * @param active + */ async function setIsDiscordActive(active) { if (active !== state.isDiscordActive) { state.isDiscordActive = await Discord.SetActive(active); } } + /** + * + * @param configLabel + */ async function saveDiscordOption(configLabel = '') { state.lastLocationDetails.tag = ''; updateLoopStore.nextDiscordUpdate = 3; diff --git a/src/stores/user.js b/src/stores/user.js index 8e98453d..20bff090 100644 --- a/src/stores/user.js +++ b/src/stores/user.js @@ -12,6 +12,10 @@ import { compareByLocationAt, compareByName, compareByUpdatedAt, + computeUserPlatform, + createDefaultUserRef, + diffObjectProps, + evictMapCache, extractFileId, findUserByDisplayName, getAllUserMemos, @@ -20,8 +24,8 @@ import { getWorldName, isRealInstance, parseLocation, - removeEmojis, - replaceBioSymbols + replaceBioSymbols, + sanitizeUserJson } from '../shared/utils'; import { avatarRequest, @@ -29,10 +33,10 @@ import { instanceRequest, userRequest } from '../api'; -import { patchUserFromEvent } from '../query'; import { processBulk, request } from '../service/request'; import { AppDebug } from '../service/appConfig'; import { database } from '../service/database'; +import { patchUserFromEvent } from '../query'; import { useAppearanceSettingsStore } from './settings/appearance'; import { useAuthStore } from './auth'; import { useAvatarStore } from './avatar'; @@ -344,6 +348,10 @@ export const useUserStore = defineStore('User', () => { { flush: 'sync' } ); + /** + * + * @param args + */ function handleConfig(args) { const authStore = useAuthStore(); const ref = { @@ -419,143 +427,18 @@ export const useUserStore = defineStore('User', () => { } const robotUrl = `${AppDebug.endpointDomain}/file/file_0e8c4e32-7444-44ea-ade4-313c010d4bae/1/file`; - - /** - * - * @param {Map