// Copyright(c) 2019-2021 pypy and individual contributors. // All rights reserved. // // This work is licensed under the terms of the MIT license. // For a copy, see . import Noty from 'noty'; import Vue from 'vue'; import VueLazyload from 'vue-lazyload'; import { DataTables } from 'vue-data-tables'; // eslint-disable-next-line no-unused-vars import ToggleSwitch from 'vuejs-toggle-switch'; import VSwatches from 'vue-swatches'; Vue.component('v-swatches', VSwatches); import '../node_modules/vue-swatches/dist/vue-swatches.css'; import ElementUI from 'element-ui'; import locale from 'element-ui/lib/locale/lang/en'; import sharedRepository from './repository/shared.js'; import configRepository from './repository/config.js'; import webApiService from './service/webapi.js'; import gameLogService from './service/gamelog.js'; speechSynthesis.getVoices(); (async function () { var $app = null; await CefSharp.BindObjectAsync( 'AppApi', 'WebApi', 'SharedVariable', 'VRCXStorage', 'SQLite', 'LogWatcher', 'Discord' ); await configRepository.init(); if (configRepository.getBool('migrate_config_20201101') === null) { var legacyConfigKeys = [ 'orderFriendGroup0', 'orderFriendGroup1', 'orderFriendGroup2', 'orderFriendGroup3', 'discordActive', 'discordInstance', 'openVR', 'openVRAlways', 'VRCX_hidePrivateFromFeed', 'VRCX_hideLoginsFromFeed', 'VRCX_hideDevicesFromFeed', 'VRCX_VIPNotifications', 'VRCX_minimalFeed', 'isDarkMode', 'VRCX_StartAtWindowsStartup', 'VRCX_StartAsMinimizedState', 'VRCX_CloseToTray', 'launchAsDesktop' ]; for (var _key of legacyConfigKeys) { configRepository.setBool(_key, VRCXStorage.Get(_key) === 'true'); } configRepository.setBool('migrate_config_20201101', true); } document.addEventListener('keyup', function (e) { if (e.ctrlKey) { if (e.key === 'I') { AppApi.ShowDevTools(); } else if (e.key === 'r') { location.reload(); } } }); VRCXStorage.GetArray = function (key) { try { var array = JSON.parse(this.Get(key)); if (Array.isArray(array)) { return array; } } catch (err) { console.error(err); } return []; }; VRCXStorage.SetArray = function (key, value) { this.Set(key, JSON.stringify(value)); }; VRCXStorage.GetObject = function (key) { try { var object = JSON.parse(this.Get(key)); if (object === Object(object)) { return object; } } catch (err) { console.error(err); } return {}; }; VRCXStorage.SetObject = function (key, value) { this.Set(key, JSON.stringify(value)); }; setInterval(function () { VRCXStorage.Flush(); }, 5 * 60 * 1000); Noty.overrideDefaults({ animation: { open: 'animate__animated animate__bounceInLeft', close: 'animate__animated animate__bounceOutLeft' }, layout: 'bottomLeft', theme: 'mint', timeout: 6000 }); Vue.use(ElementUI, { locale }); var removeFromArray = function (array, item) { var { length } = array; for (var i = 0; i < length; ++i) { if (array[i] === item) { array.splice(i, 1); return true; } } return false; }; var escapeTag = function (tag) { var s = String(tag); return s.replace(/["&'<>]/g, (c) => `&#${c.charCodeAt(0)};`); }; Vue.filter('escapeTag', escapeTag); var commaNumber = function (num) { var s = String(Number(num)); return s.replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1,'); }; Vue.filter('commaNumber', commaNumber); var formatDate = function (date, format) { var dt = new Date(date); if (isNaN(dt)) { return escapeTag(date); } var hours = dt.getHours(); var map = { 'YYYY': String(10000 + dt.getFullYear()).substr(-4), 'MM': String(101 + dt.getMonth()).substr(-2), 'DD': String(100 + dt.getDate()).substr(-2), 'HH24': String(100 + hours).substr(-2), 'HH': String(100 + (hours > 12 ? hours - 12 : hours)).substr(-2), 'MI': String(100 + dt.getMinutes()).substr(-2), 'SS': String(100 + dt.getSeconds()).substr(-2), 'AMPM': hours >= 12 ? 'PM' : 'AM' }; return format.replace(/YYYY|MM|DD|HH24|HH|MI|SS|AMPM/g, (c) => map[c] || c); }; Vue.filter('formatDate', formatDate); var textToHex = function (text) { var s = String(text); return s.split('').map((c) => c.charCodeAt(0).toString(16)).join(' '); }; Vue.filter('textToHex', textToHex); var timeToText = function (sec) { var n = Number(sec); if (isNaN(n)) { return escapeTag(sec); } n = Math.floor(n / 1000); var arr = []; if (n < 0) { n = -n; } if (n >= 86400) { arr.push(`${Math.floor(n / 86400)}d`); n %= 86400; } if (n >= 3600) { arr.push(`${Math.floor(n / 3600)}h`); n %= 3600; } if (n >= 60) { arr.push(`${Math.floor(n / 60)}m`); n %= 60; } if (n || arr.length === 0) { arr.push(`${n}s`); } return arr.join(' '); }; Vue.filter('timeToText', timeToText); Vue.use(VueLazyload, { preLoad: 1, observer: true, observerOptions: { rootMargin: '0px', threshold: 0.1 } }); Vue.use(DataTables); var uuidv4 = () => 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { var v = Math.random() * 16 | 0; if (c !== 'x') { v |= 8; } return v.toString(16); }); var $appDarkStyle = document.createElement('link'); $appDarkStyle.disabled = true; $appDarkStyle.rel = 'stylesheet'; $appDarkStyle.href = `app.dark.css?_=${Date.now()}`; document.head.appendChild($appDarkStyle); // // Languages // var subsetOfLanguages = { eng: 'English', kor: '한국어', rus: 'Русский', spa: 'Español', por: 'Português', zho: '中文', deu: 'Deutsch', jpn: '日本語', fra: 'Français', swe: 'Svenska', nld: 'Nederlands', pol: 'Polski', dan: 'Dansk', nor: 'Norsk', ita: 'Italiano', tha: 'ภาษาไทย', fin: 'Suomi', hun: 'Magyar', ces: 'Čeština', tur: 'Türkçe', ara: 'العربية' }; // vrchat to famfamfam var languageMappings = { eng: 'us', kor: 'kr', rus: 'ru', spa: 'es', por: 'pt', zho: 'cn', deu: 'de', jpn: 'jp', fra: 'fr', swe: 'se', nld: 'nl', pol: 'pl', dan: 'dk', nor: 'no', ita: 'it', tha: 'th', fin: 'fi', hun: 'hu', ces: 'cz', tur: 'tr', ara: 'ae' }; // // API // var API = {}; API.eventHandlers = new Map(); API.$emit = function (name, ...args) { if ($app.debug) { console.log(name, ...args); } var handlers = this.eventHandlers.get(name); if (typeof handlers === 'undefined') { return; } try { for (var handler of handlers) { handler.apply(this, args); } } catch (err) { console.error(err); } }; API.$on = function (name, handler) { var handlers = this.eventHandlers.get(name); if (typeof handlers === 'undefined') { handlers = []; this.eventHandlers.set(name, handlers); } handlers.push(handler); }; API.$off = function (name, handler) { var handlers = this.eventHandlers.get(name); if (typeof handlers === 'undefined') { return; } var { length } = handlers; for (var i = 0; i < length; ++i) { if (handlers[i] === handler) { if (length > 1) { handlers.splice(i, 1); } else { this.eventHandlers.delete(name); } break; } } }; API.pendingGetRequests = new Map(); API.call = function (endpoint, options) { var init = { url: `https://api.vrchat.cloud/api/1/${endpoint}`, method: 'GET', ...options }; var { params } = init; if (init.method === 'GET') { // transform body to url if (params === Object(params)) { var url = new URL(init.url); var { searchParams } = url; for (var key in params) { searchParams.set(key, params[key]); } init.url = url.toString(); } // merge requests var req = this.pendingGetRequests.get(init.url); if (typeof req !== 'undefined') { return req; } } else if (init.uploadImage) { } else { init.headers = { 'Content-Type': 'application/json;charset=utf-8', ...init.headers }; init.body = params === Object(params) ? JSON.stringify(params) : '{}'; } var req = webApiService.execute(init).catch((err) => { this.$throw(0, err); }).then((response) => { try { response.data = JSON.parse(response.data); if ($app.debug) { console.log(init, response.data); } return response; } catch (e) { } if (response.status === 200) { this.$throw(0, 'Invalid JSON response'); } this.$throw(response.status); return {}; }).then(({ data, status }) => { if (status === 200) { if (data.success === Object(data.success)) { new Noty({ type: 'success', text: escapeTag(data.success.message) }).show(); } return data; } if ((status === 401) && (data.error.message === '"Missing Credentials"') && ($app.isAutoLogin)) { if (endpoint.substring(0, 10) === 'auth/user?') { this.$emit('AUTOLOGIN'); } throw new Error('401: Missing Credentials'); } if (data.error === Object(data.error)) { this.$throw( data.error.status_code || status, data.error.message, data.error.data ); } else if (typeof data.error === 'string') { this.$throw( data.status_code || status, data.error ); } this.$throw(status, data); return data; }); if (init.method === 'GET') { req.finally(() => { this.pendingGetRequests.delete(init.url); }); this.pendingGetRequests.set(init.url, req); } return req; }; API.statusCodes = { 100: 'Continue', 101: 'Switching Protocols', 102: 'Processing', 103: 'Early Hints', 200: 'OK', 201: 'Created', 202: 'Accepted', 203: 'Non-Authoritative Information', 204: 'No Content', 205: 'Reset Content', 206: 'Partial Content', 207: 'Multi-Status', 208: 'Already Reported', 226: 'IM Used', 300: 'Multiple Choices', 301: 'Moved Permanently', 302: 'Found', 303: 'See Other', 304: 'Not Modified', 305: 'Use Proxy', 306: 'Switch Proxy', 307: 'Temporary Redirect', 308: 'Permanent Redirect', 400: 'Bad Request', 401: 'Unauthorized', 402: 'Payment Required', 403: 'Forbidden', 404: 'Not Found', 405: 'Method Not Allowed', 406: 'Not Acceptable', 407: 'Proxy Authentication Required', 408: 'Request Timeout', 409: 'Conflict', 410: 'Gone', 411: 'Length Required', 412: 'Precondition Failed', 413: 'Payload Too Large', 414: 'URI Too Long', 415: 'Unsupported Media Type', 416: 'Range Not Satisfiable', 417: 'Expectation Failed', 418: "I'm a teapot", 421: 'Misdirected Request', 422: 'Unprocessable Entity', 423: 'Locked', 424: 'Failed Dependency', 425: 'Too Early', 426: 'Upgrade Required', 428: 'Precondition Required', 429: 'Too Many Requests', 431: 'Request Header Fields Too Large', 451: 'Unavailable For Legal Reasons', 500: 'Internal Server Error', 501: 'Not Implemented', 502: 'Bad Gateway', 503: 'Service Unavailable', 504: 'Gateway Timeout', 505: 'HTTP Version Not Supported', 506: 'Variant Also Negotiates', 507: 'Insufficient Storage', 508: 'Loop Detected', 510: 'Not Extended', 511: 'Network Authentication Required', // CloudFlare Error 520: 'Web server returns an unknown error', 521: 'Web server is down', 522: 'Connection timed out', 523: 'Origin is unreachable', 524: 'A timeout occurred', 525: 'SSL handshake failed', 526: 'Invalid SSL certificate', 527: 'Railgun Listener to origin error' }; // FIXME : extra를 없애줘 API.$throw = function (code, error, extra) { var text = []; if (code > 0) { var status = this.statusCodes[code]; if (typeof status === 'undefined') { text.push(`${code}`); } else { text.push(`${code} ${status}`); } } if (typeof error !== 'undefined') { text.push(JSON.stringify(error)); } if (typeof extra !== 'undefined') { text.push(JSON.stringify(extra)); } text = text.map((s) => escapeTag(s)).join('
'); if (text.length) { new Noty({ type: 'error', text }).show(); } throw new Error(text); }; API.$bulk = function (options, args) { if ('handle' in options) { options.handle.call(this, args, options); } if (args.json.length > 0 && (options.params.offset += args.json.length, // eslint-disable-next-line no-nested-ternary options.N > 0 ? options.N > options.params.offset : options.N < 0 ? args.json.length : options.params.n === args.json.length)) { this.bulk(options); } else if ('done' in options) { options.done.call(this, true, options); } return args; }; API.bulk = function (options) { this[options.fn](options.params).catch((err) => { if ('done' in options) { options.done.call(this, false, options); } throw err; }).then((args) => this.$bulk(options, args)); }; // API: Config API.cachedConfig = {}; API.$on('CONFIG', function (args) { args.ref = this.applyConfig(args.json); }); API.applyConfig = function (json) { var ref = { clientApiKey: '', ...json }; this.cachedConfig = ref; return ref; }; API.getConfig = function () { return this.call('config', { method: 'GET' }).then((json) => { var args = { json }; this.$emit('CONFIG', args); return args; }); }; // API: Location API.parseLocation = function (tag) { tag = String(tag || ''); var ctx = { tag, isOffline: false, isPrivate: false, worldId: '', instanceId: '', instanceName: '', accessType: '', userId: null, hiddenId: null, privateId: null, friendsId: null, canRequestInvite: false }; if (tag === 'offline') { ctx.isOffline = true; } else if (tag === 'private') { ctx.isPrivate = true; } else if (tag.startsWith('local') === false) { var sep = tag.indexOf(':'); if (sep >= 0) { ctx.worldId = tag.substr(0, sep); ctx.instanceId = tag.substr(sep + 1); ctx.instanceId.split('~').forEach((s, i) => { if (i) { var A = s.indexOf('('); var Z = A >= 0 ? s.lastIndexOf(')') : -1; var key = Z >= 0 ? s.substr(0, A) : s; var value = A < Z ? s.substr(A + 1, Z - A - 1) : ''; if (key === 'hidden') { ctx.hiddenId = value; } else if (key === 'private') { ctx.privateId = value; } else if (key === 'friends') { ctx.friendsId = value; } else if (key === 'canRequestInvite') { ctx.canRequestInvite = true; } } else { ctx.instanceName = s; } }); ctx.accessType = 'public'; if (ctx.privateId !== null) { if (ctx.canRequestInvite) { // InvitePlus ctx.accessType = 'invite+'; } else { // InviteOnly ctx.accessType = 'invite'; } ctx.userId = ctx.privateId; } else if (ctx.friendsId !== null) { // FriendsOnly ctx.accessType = 'friends'; ctx.userId = ctx.friendsId; } else if (ctx.hiddenId !== null) { // FriendsOfGuests ctx.accessType = 'friends+'; ctx.userId = ctx.hiddenId; } } else { ctx.worldId = tag; } } return ctx; }; Vue.component('launch', { template: '', props: { location: String }, methods: { parse() { var L = API.parseLocation(this.location); this.$el.style.display = L.isOffline || L.isPrivate ? 'none' : ''; }, confirm() { API.$emit('SHOW_LAUNCH_DIALOG', this.location); } }, watch: { location() { this.parse(); } }, mounted() { this.parse(); } }); Vue.component('invite-yourself', { template: '', props: { location: String }, methods: { parse() { var L = API.parseLocation(this.location); this.$el.style.display = L.isOffline || L.isPrivate ? 'none' : ''; }, confirm() { var L = API.parseLocation(this.location); if (L.isOffline || L.isPrivate || L.worldId === '') { return; } API.getCachedWorld({ worldId: L.worldId }).then((args) => { if (API.currentUser.status === 'busy') { this.$message({ message: 'You can\'t invite yourself in \'Do Not Disturb\' mode', type: 'error' }); return; } API.sendInvite({ instanceId: L.tag, worldId: L.tag, worldName: args.ref.name }, API.currentUser.id).finally(() => { this.$message({ message: 'Invite sent to yourself', type: 'success' }); }); }); } }, watch: { location() { this.parse(); } }, mounted() { this.parse(); } }); Vue.component('location', { template: '{{ text }}', props: { location: String, hint: { type: String, default: '' }, link: { type: Boolean, default: true } }, data() { return { text: this.location }; }, methods: { parse() { var L = API.parseLocation(this.location); if (L.isOffline) { this.text = 'Offline'; } else if (L.isPrivate) { this.text = 'Private'; } else if (typeof this.hint === 'string' && this.hint !== '') { if (L.instanceId) { this.text = `${this.hint} #${L.instanceName} ${L.accessType}`; } else { this.text = this.hint; } } else if (L.worldId) { var ref = API.cachedWorlds.get(L.worldId); if (typeof ref === 'undefined') { API.getWorld({ worldId: L.worldId }).then((args) => { if (L.tag === this.location) { if (L.instanceId) { this.text = `${args.json.name} #${L.instanceName} ${L.accessType}`; } else { this.text = args.json.name; } } return args; }); } else if (L.instanceId) { this.text = `${ref.name} #${L.instanceName} ${L.accessType}`; } else { this.text = ref.name; } } }, showWorldDialog() { if (this.link) { API.$emit('SHOW_WORLD_DIALOG', this.location); } } }, watch: { location() { this.parse(); } }, created() { this.parse(); } }); // API: User // changeUserName: PUT users/${userId} {displayName: string, currentPassword: string} // changeUserEmail: PUT users/${userId} {email: string, currentPassword: string} // changePassword: PUT users/${userId} {password: string, currentPassword: string} // updateTOSAggreement: PUT users/${userId} {acceptedTOSVersion: number} // 2FA // removeTwoFactorAuth: DELETE auth/twofactorauth // getTwoFactorAuthpendingSecret: POST auth/twofactorauth/totp/pending -> { qrCodeDataUrl: string, secret: string } // verifyTwoFactorAuthPendingSecret: POST auth/twofactorauth/totp/pending/verify { code: string } -> { verified: bool, enabled: bool } // cancelVerifyTwoFactorAuthPendingSecret: DELETE auth/twofactorauth/totp/pending // getTwoFactorAuthOneTimePasswords: GET auth/user/twofactorauth/otp -> { otp: [ { code: string, used: bool } ] } // Account Link // merge: PUT auth/user/merge {mergeToken: string} // 링크됐다면 CurrentUser에 steamId, oculusId 값이 생기는듯 // 스팀 계정으로 로그인해도 steamId, steamDetails에 값이 생김 // Password Recovery // sendLink: PUT auth/password {email: string} // setNewPassword: PUT auth/password {emailToken: string, id: string, password: string} API.isLoggedIn = false; API.cachedUsers = new Map(); API.currentUser = {}; API.$on('LOGOUT', function () { webApiService.clearCookies(); this.isLoggedIn = false; }); API.$on('USER:CURRENT', function (args) { var { json } = args; args.ref = this.applyCurrentUser(json); this.applyUser({ id: json.id, username: json.username, displayName: json.displayName, bio: json.bio, bioLinks: json.bioLinks, currentAvatarImageUrl: json.currentAvatarImageUrl, currentAvatarThumbnailImageUrl: json.currentAvatarThumbnailImageUrl, status: json.status, statusDescription: json.statusDescription, state: json.state, tags: json.tags, developerType: json.developerType, last_login: json.last_login, last_platform: json.last_platform, date_joined: json.date_joined, allowAvatarCopying: json.allowAvatarCopying, isFriend: false, location: $app.lastLocation.location }); }); API.$on('USER:CURRENT:SAVE', function (args) { this.$emit('USER:CURRENT', args); }); API.$on('USER', function (args) { args.ref = this.applyUser(args.json); }); API.$on('USER:LIST', function (args) { for (var json of args.json) { this.$emit('USER', { json, params: { userId: json.id } }); } }); API.logout = function () { return this.call('logout', { method: 'PUT' }).finally(() => { this.$emit('LOGOUT'); }); }; /* params: { username: string, password: string } */ API.login = function (params) { var { username, password, saveCredentials } = params; username = encodeURIComponent(username); password = encodeURIComponent(password); var auth = btoa(`${username}:${password}`); if (saveCredentials) { delete params.saveCredentials; $app.saveCredentials = params; } return this.call(`auth/user?apiKey=${this.cachedConfig.clientApiKey}`, { method: 'GET', headers: { Authorization: `Basic ${auth}` } }).then((json) => { var args = { json, params, origin: true }; if (json.requiresTwoFactorAuth) { this.$emit('USER:2FA', args); } else { this.$emit('USER:CURRENT', args); } return args; }); }; /* params: { steamTicket: string } */ API.loginWithSteam = function (params) { return this.call(`auth/steam?apiKey=${this.cachedConfig.clientApiKey}`, { method: 'POST', params }).then((json) => { var args = { json, params, origin: true }; if (json.requiresTwoFactorAuth) { this.$emit('USER:2FA', args); } else { this.$emit('USER:CURRENT', args); } return args; }); }; /* params: { code: string } */ API.verifyOTP = function (params) { return this.call('auth/twofactorauth/otp/verify', { method: 'POST', params }).then((json) => { var args = { json, params }; this.$emit('OTP', args); return args; }); }; /* params: { code: string } */ API.verifyTOTP = function (params) { return this.call('auth/twofactorauth/totp/verify', { method: 'POST', params }).then((json) => { var args = { json, params }; this.$emit('TOTP', args); return args; }); }; API.applyUserTrustLevel = function (ref) { ref.$isModerator = ref.developerType && ref.developerType !== 'none'; ref.$isTroll = false; var { tags } = ref; if (tags.includes('admin_moderator')) { ref.$isModerator = true; } if (tags.includes('system_troll') || tags.includes('system_probable_troll')) { ref.$isTroll = true; } if (tags.includes('system_legend')) { ref.$trustLevel = 'Legendary User'; ref.$trustClass = 'x-tag-legendary'; } else if (tags.includes('system_trust_legend')) { ref.$trustLevel = 'Veteran User'; ref.$trustClass = 'x-tag-legend'; } else if (tags.includes('system_trust_veteran')) { ref.$trustLevel = 'Trusted User'; ref.$trustClass = 'x-tag-veteran'; } else if (tags.includes('system_trust_trusted')) { ref.$trustLevel = 'Known User'; ref.$trustClass = 'x-tag-trusted'; } else if (tags.includes('system_trust_known')) { ref.$trustLevel = 'User'; ref.$trustClass = 'x-tag-known'; } else if (tags.includes('system_trust_basic')) { ref.$trustLevel = 'New User'; ref.$trustClass = 'x-tag-basic'; } else { ref.$trustLevel = 'Visitor'; ref.$trustClass = 'x-tag-untrusted'; } if (ref.$isModerator) { ref.$trustLevel = 'VRChat Team'; ref.$trustClass = 'x-tag-vip'; } else if (ref.$isTroll) { ref.$trustLevel = 'Nuisance'; ref.$trustClass = 'x-tag-troll'; } }; // FIXME: it may performance issue. review here API.applyUserLanguage = function (ref) { ref.$languages = []; var { tags } = ref; for (var tag of tags) { if (tag.startsWith('language_') === false) { continue; } var key = tag.substr(9); var value = subsetOfLanguages[key]; if (typeof value === 'undefined') { continue; } ref.$languages.push({ key, value }); } }; API.applyCurrentUser = function (json) { var ref = this.currentUser; if (this.isLoggedIn) { Object.assign(ref, json); if (ref.homeLocation !== ref.$homeLocation.tag) { ref.$homeLocation = this.parseLocation(ref.homeLocation); } ref.$isVRCPlus = ref.tags.includes('system_supporter'); this.applyUserTrustLevel(ref); this.applyUserLanguage(ref); } else { ref = { id: '', username: '', displayName: '', userIcon: '', bio: '', bioLinks: [], pastDisplayNames: [], friends: [], currentAvatarImageUrl: '', currentAvatarThumbnailImageUrl: '', currentAvatar: '', homeLocation: '', twoFactorAuthEnabled: false, status: '', statusDescription: '', state: '', tags: [], developerType: '', last_login: '', last_platform: '', date_joined: '', allowAvatarCopying: false, onlineFriends: [], activeFriends: [], offlineFriends: [], // VRCX $homeLocation: {}, $isVRCPlus: false, $isModerator: false, $isTroll: false, $trustLevel: 'Visitor', $trustClass: 'x-tag-untrusted', $languages: [], // ...json }; ref.$homeLocation = this.parseLocation(ref.homeLocation); ref.$isVRCPlus = ref.tags.includes('system_supporter'); this.applyUserTrustLevel(ref); this.applyUserLanguage(ref); this.currentUser = ref; this.isLoggedIn = true; this.$emit('LOGIN', { json, ref }); } sharedRepository.setString('current_user_status', ref.status); return ref; }; API.getCurrentUser = function () { return this.call(`auth/user?apiKey=${this.cachedConfig.clientApiKey}`, { method: 'GET' }).then((json) => { var args = { json, origin: true }; if (json.requiresTwoFactorAuth) { this.$emit('USER:2FA', args); } else { this.$emit('USER:CURRENT', args); } return args; }); }; var userUpdateQueue = []; var userUpdateTimer = null; var queueUserUpdate = function (ctx) { userUpdateQueue.push(ctx); if (userUpdateTimer !== null) { return; } userUpdateTimer = setTimeout(function () { userUpdateTimer = null; var { length } = userUpdateQueue; for (var i = 0; i < length; ++i) { API.$emit('USER:UPDATE', userUpdateQueue[i]); } userUpdateQueue.length = 0; }, 1); }; API.applyUser = function (json) { var ref = this.cachedUsers.get(json.id); // some missing variables on currentUser if (json.id === API.currentUser.id) { json.status = API.currentUser.status; json.statusDescription = API.currentUser.statusDescription; json.state = API.currentUser.state; json.last_login = API.currentUser.last_login; if ($app.lastLocation.location) { json.location = $app.lastLocation.location; json.$location_at = $app.lastLocation.date; } json.$online_for = API.currentUser.$online_for; json.$offline_for = API.currentUser.$offline_for; } if (typeof ref === 'undefined') { ref = { id: '', username: '', displayName: '', userIcon: '', bio: '', bioLinks: [], currentAvatarImageUrl: '', currentAvatarThumbnailImageUrl: '', status: '', statusDescription: '', state: '', tags: [], developerType: '', last_login: '', last_platform: '', date_joined: '', allowAvatarCopying: false, isFriend: false, location: '', worldId: '', instanceId: '', // VRCX $location: {}, $location_at: Date.now(), $online_for: Date.now(), $offline_for: '', $isVRCPlus: false, $isModerator: false, $isTroll: false, $trustLevel: 'Visitor', $trustClass: 'x-tag-untrusted', $languages: [], // ...json }; ref.$location = this.parseLocation(ref.location); ref.$isVRCPlus = ref.tags.includes('system_supporter'); this.applyUserTrustLevel(ref); this.applyUserLanguage(ref); this.cachedUsers.set(ref.id, ref); } else { var props = {}; for (var prop in ref) { if (ref[prop] !== Object(ref[prop])) { props[prop] = true; } } var $ref = { ...ref }; Object.assign(ref, json); if (ref.location !== ref.$location.tag) { ref.$location = this.parseLocation(ref.location); } ref.$isVRCPlus = ref.tags.includes('system_supporter'); this.applyUserTrustLevel(ref); this.applyUserLanguage(ref); for (var prop in ref) { if (ref[prop] !== Object(ref[prop])) { props[prop] = true; } } var has = false; for (var prop in props) { var asis = $ref[prop]; var tobe = ref[prop]; if (asis === tobe) { delete props[prop]; } else { has = true; props[prop] = [ tobe, asis ]; } } // FIXME // if the status is offline, just ignore status and statusDescription only. if (has && (ref.status !== 'offline' && $ref.status !== 'offline')) { if (props.location) { var ts = Date.now(); props.location.push(ts - ref.$location_at); ref.$location_at = ts; } queueUserUpdate({ ref, props }); } } return ref; }; /* params: { userId: string } */ API.getUser = function (params) { return this.call(`users/${params.userId}`, { method: 'GET' }).then((json) => { var args = { json, params }; this.$emit('USER', args); return args; }); }; /* params: { userId: string } */ API.getCachedUser = function (params) { return new Promise((resolve, reject) => { var ref = this.cachedUsers.get(params.userId); if (typeof ref === 'undefined') { this.getUser(params).catch(reject).then(resolve); } else { resolve({ cache: true, json: ref, params, ref }); } }); }; /* params: { n: number, offset: number, search: string, sort: string ('nuisanceFactor', 'created', '_created_at', 'last_login'), order: string ('ascending', 'descending') } */ API.getUsers = function (params) { return this.call('users', { method: 'GET', params }).then((json) => { var args = { json, params }; this.$emit('USER:LIST', args); return args; }); }; /* params: { status: string ('active', 'offline', 'busy', 'ask me', 'join me'), statusDescription: string } */ API.saveCurrentUser = function (params) { return this.call(`users/${this.currentUser.id}`, { method: 'PUT', params }).then((json) => { var args = { json, params }; this.$emit('USER:CURRENT:SAVE', args); return args; }); }; /* params: { tags: array[string] } */ API.addUserTags = function (params) { return this.call(`users/${this.currentUser.id}/addTags`, { method: 'POST', params }).then((json) => { var args = { json, params }; this.$emit('USER:CURRENT:SAVE', args); return args; }); }; /* params: { tags: array[string] } */ API.removeUserTags = function (params) { return this.call(`users/${this.currentUser.id}/removeTags`, { method: 'POST', params }).then((json) => { var args = { json, params }; this.$emit('USER:CURRENT:SAVE', args); return args; }); }; // API: World API.cachedWorlds = new Map(); API.$on('WORLD', function (args) { args.ref = this.applyWorld(args.json); }); API.$on('WORLD:LIST', function (args) { for (var json of args.json) { this.$emit('WORLD', { json, params: { worldId: json.id } }); } }); API.applyWorld = function (json) { var ref = this.cachedWorlds.get(json.id); if (typeof ref === 'undefined') { ref = { id: '', name: '', description: '', authorId: '', authorName: '', capacity: 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: [], // VRCX $isLabs: false, // ...json }; this.cachedWorlds.set(ref.id, ref); } else { Object.assign(ref, json); } ref.$isLabs = ref.tags.includes('system_labs'); return ref; }; /* params: { worldId: string } */ API.getWorld = function (params) { return this.call(`worlds/${params.worldId}`, { method: 'GET' }).then((json) => { var args = { json, params }; this.$emit('WORLD', args); return args; }); }; /* params: { worldId: string } */ API.getCachedWorld = function (params) { return new Promise((resolve, reject) => { var ref = this.cachedWorlds.get(params.worldId); if (typeof ref === 'undefined') { this.getWorld(params).catch(reject).then(resolve); } else { resolve({ cache: true, json: ref, params, ref }); } }); }; /* params: { n: number, offset: number, search: string, userId: string, user: string ('me','friend') sort: string ('popularity','heat','trust','shuffle','favorites','reportScore','reportCount','publicationDate','labsPublicationDate','created','_created_at','updated','_updated_at','order'), order: string ('ascending','descending'), releaseStatus: string ('public','private','hidden','all'), featured: boolean }, option: string */ API.getWorlds = function (params, option) { var endpoint = 'worlds'; if (typeof option !== 'undefined') { endpoint = `worlds/${option}`; } return this.call(endpoint, { method: 'GET', params }).then((json) => { var args = { json, params }; this.$emit('WORLD:LIST', args); return args; }); }; // API: Friend API.friends200 = new Set(); API.friends404 = new Map(); API.isFriendsLoading = false; API.$on('LOGIN', function () { this.friends200.clear(); this.friends404.clear(); this.isFriendsLoading = false; }); API.$on('USER', function (args) { this.friends200.add(args.ref.id); this.friends404.delete(args.ref.id); }); API.$on('FRIEND:LIST', function (args) { for (var json of args.json) { this.$emit('USER', { json, params: { userId: json.id } }); } }); API.isAllFriendsRetrived = function (flag) { if (flag) { for (var id of this.currentUser.friends) { if (this.friends200.has(id) === false) { var n = this.friends404.get(id) || 0; if (n < 2) { this.friends404.set(id, n + 1); } } } } else { for (var id of this.currentUser.friends) { if (this.friends200.has(id) === false || this.friends404.get(id) < 2) { return false; } } } return true; }; API.refreshFriends = function () { var params = { n: 50, offset: 0, offline: false }; var N = this.currentUser.onlineFriends.length; if (N === 0) { N = this.currentUser.friends.length; if (N === 0 || this.isAllFriendsRetrived(false)) { return; } params.offline = true; } if (this.isFriendsLoading) { return; } this.isFriendsLoading = true; this.bulk({ fn: 'getFriends', N, params, done(ok, options) { if (this.isAllFriendsRetrived(params.offline)) { this.isFriendsLoading = false; return; } var { length } = this.currentUser.friends; options.N = length - params.offset; if (options.N <= 0) { options.N = length; } params.offset = 0; params.offline = true; this.bulk(options); } }); }; /* params: { n: number, offset: number, offline: boolean } */ API.getFriends = function (params) { return this.call('auth/user/friends', { method: 'GET', params }).then((json) => { var args = { json, params }; this.$emit('FRIEND:LIST', args); return args; }); }; /* params: { userId: string } */ API.deleteFriend = function (params) { return this.call(`auth/user/friends/${params.userId}`, { method: 'DELETE' }).then((json) => { var args = { json, params }; this.$emit('FRIEND:DELETE', args); return args; }); }; /* params: { userId: string } */ API.sendFriendRequest = function (params) { return this.call(`user/${params.userId}/friendRequest`, { method: 'POST' }).then((json) => { var args = { json, params }; this.$emit('FRIEND:REQUEST', args); return args; }); }; /* params: { userId: string } */ API.cancelFriendRequest = function (params) { return this.call(`user/${params.userId}/friendRequest`, { method: 'DELETE' }).then((json) => { var args = { json, params }; this.$emit('FRIEND:REQUEST:CANCEL', args); return args; }); }; /* params: { userId: string } */ API.getFriendStatus = function (params) { return this.call(`user/${params.userId}/friendStatus`, { method: 'GET' }).then((json) => { var args = { json, params }; this.$emit('FRIEND:STATUS', args); return args; }); }; // API: Avatar API.cachedAvatars = new Map(); API.$on('AVATAR', function (args) { args.ref = this.applyAvatar(args.json); }); API.$on('AVATAR:LIST', function (args) { for (var json of args.json) { this.$emit('AVATAR', { json, params: { avatarId: json.id } }); } }); API.$on('AVATAR:SAVE', function (args) { var { json } = args; this.$emit('AVATAR', { json, params: { avatarId: json.id } }); }); API.$on('AVATAR:SELECT', function (args) { this.$emit('USER:CURRENT', args); }); API.$on('AVATAR:DELETE', function (args) { var { json } = args; this.cachedAvatars.delete(json._id); if ($app.userDialog.id === json.authorId) { var map = new Map(); for (var ref of this.cachedAvatars.values()) { if (ref.authorId === json.authorId) { map.set(ref.id, ref); } } var array = Array.from(map.values()); $app.setUserDialogAvatars(array); } }); API.applyAvatar = function (json) { var ref = this.cachedAvatars.get(json.id); if (typeof ref === 'undefined') { ref = { id: '', name: '', description: '', authorId: '', authorName: '', tags: [], assetUrl: '', assetUrlObject: {}, imageUrl: '', thumbnailImageUrl: '', releaseStatus: '', version: 0, unityPackages: [], unityPackageUrl: '', unityPackageUrlObject: {}, created_at: '', updated_at: '', ...json }; this.cachedAvatars.set(ref.id, ref); } else { Object.assign(ref, json); } return ref; }; /* params: { avatarId: string } */ API.getAvatar = function (params) { return this.call(`avatars/${params.avatarId}`, { method: 'GET' }).then((json) => { var args = { json, params }; this.$emit('AVATAR', args); return args; }); }; /* params: { avatarId: string } */ API.getCachedAvatar = function (params) { return new Promise((resolve, reject) => { var ref = this.cachedAvatars.get(params.avatarId); if (typeof ref === 'undefined') { this.getAvatar(params).catch(reject).then(resolve); } else { resolve({ cache: true, json: ref, params, ref }); } }); }; /* params: { n: number, offset: number, search: string, userId: string, user: string ('me','friends') sort: string ('created','updated','order','_created_at','_updated_at'), order: string ('ascending','descending'), releaseStatus: string ('public','private','hidden','all'), featured: boolean } */ API.getAvatars = function (params) { return this.call('avatars', { method: 'GET', params }).then((json) => { var args = { json, params }; this.$emit('AVATAR:LIST', args); return args; }); }; /* params: { id: string releaseStatus: string ('public','private'), } */ API.saveAvatar = function (params) { return this.call(`avatars/${params.id}`, { method: 'PUT', params }).then((json) => { var args = { json, params }; this.$emit('AVATAR:SAVE', args); return args; }); }; /* params: { avatarId: string } */ API.selectAvatar = function (params) { return this.call(`avatars/${params.avatarId}/select`, { method: 'PUT', params }).then((json) => { var args = { json, params }; this.$emit('AVATAR:SELECT', args); return args; }); }; /* params: { avatarId: string } */ API.selectFallbackAvatar = function (params) { return this.call(`avatars/${params.avatarId}/selectfallback`, { method: 'PUT', params }).then((json) => { var args = { json, params }; this.$emit('AVATAR:SELECT', args); return args; }); }; /* params: { avatarId: string } */ API.deleteAvatar = function (params) { return this.call(`avatars/${params.avatarId}`, { method: 'DELETE', params }).then((json) => { var args = { json, params }; this.$emit('AVATAR:DELETE', args); return args; }); }; // API: Notification API.cachedNotifications = new Map(); API.isNotificationsLoading = false; API.$on('LOGIN', function () { this.cachedNotifications.clear(); this.isNotificationsLoading = false; }); API.$on('NOTIFICATION', function (args) { args.ref = this.applyNotification(args.json); }); API.$on('NOTIFICATION:LIST', function (args) { for (var json of args.json) { this.$emit('NOTIFICATION', { json, params: { notificationId: json.id } }); } }); API.$on('NOTIFICATION:ACCEPT', function (args) { var ref = this.cachedNotifications.get(args.params.notificationId); if (typeof ref === 'undefined' || ref.$isDeleted) { return; } args.ref = ref; ref.$isDeleted = true; this.$emit('NOTIFICATION:@DELETE', { ref, params: { notificationId: ref.id } }); this.$emit('FRIEND:ADD', { params: { userId: ref.senderUserId } }); }); API.$on('NOTIFICATION:HIDE', function (args) { var ref = this.cachedNotifications.get(args.params.notificationId); if (typeof ref === 'undefined' && ref.$isDeleted) { return; } args.ref = ref; ref.$isDeleted = true; this.$emit('NOTIFICATION:@DELETE', { ref, params: { notificationId: ref.id } }); }); API.applyNotification = function (json) { var ref = this.cachedNotifications.get(json.id); if (typeof ref === 'undefined') { ref = { id: '', senderUserId: '', senderUsername: '', type: '', message: '', details: {}, seen: false, created_at: '', // VRCX $isDeleted: false, $isExpired: false, // ...json }; this.cachedNotifications.set(ref.id, ref); } else { Object.assign(ref, json); ref.$isExpired = false; } if (ref.details !== Object(ref.details)) { var details = {}; if (ref.details !== '{}') { try { var object = JSON.parse(ref.details); if (object === Object(object)) { details = object; } } catch (err) { } } ref.details = details; } return ref; }; API.expireNotifications = function () { for (var ref of this.cachedNotifications.values()) { ref.$isExpired = true; } }; API.deleteExpiredNotifcations = function () { for (var ref of this.cachedNotifications.values()) { if (ref.$isDeleted || ref.$isExpired === false) { continue; } ref.$isDeleted = true; this.$emit('NOTIFICATION:@DELETE', { ref, params: { notificationId: ref.id } }); } }; API.refreshNotifications = function () { // NOTE : 캐시 때문에 after=~ 로는 갱신이 안됨. 그래서 첨부터 불러옴 if (this.isNotificationsLoading) { return; } this.isNotificationsLoading = true; this.expireNotifications(); this.bulk({ fn: 'getNotifications', N: -1, params: { n: 50, offset: 0 }, done(ok) { if (ok) { this.deleteExpiredNotifcations(); } this.isNotificationsLoading = false; } }); }; /* params: { n: number, offset: number, sent: boolean, type: string, after: string (ISO8601 or 'five_minutes_ago') } */ API.getNotifications = function (params) { return this.call('auth/user/notifications', { method: 'GET', params }).then((json) => { var args = { json, params }; this.$emit('NOTIFICATION:LIST', args); return args; }); }; API.clearNotifications = function () { return this.call('auth/user/notifications/clear', { method: 'PUT' }).then((json) => { var args = { json }; // FIXME: NOTIFICATION:CLEAR 핸들링 this.$emit('NOTIFICATION:CLEAR', args); return args; }); }; /* params: { receiverUserId: string, type: string, message: string, seen: boolean, details: json-string } */ API.sendInvite = function (params, receiverUserId) { return this.call(`invite/${receiverUserId}`, { method: 'POST', params }).then((json) => { var args = { json, params }; this.$emit('NOTIFICATION:INVITE:SEND', args); return args; }); }; API.sendInvitePhoto = function (params, receiverUserId) { return this.call(`invite/${receiverUserId}/photo`, { uploadImage: true, postData: JSON.stringify(params), imageData: $app.uploadImage }).then((json) => { var args = { json, params }; this.$emit('NOTIFICATION:INVITE:PHOTO:SEND', args); return args; }); }; API.sendRequestInvite = function (params, receiverUserId) { return this.call(`requestInvite/${receiverUserId}`, { method: 'POST', params }).then((json) => { var args = { json, params }; this.$emit('NOTIFICATION:REQUESTINVITE:SEND', args); return args; }); }; API.sendRequestInvitePhoto = function (params, receiverUserId) { return this.call(`requestInvite/${receiverUserId}/photo`, { uploadImage: true, postData: JSON.stringify(params), imageData: $app.uploadImage }).then((json) => { var args = { json, params }; this.$emit('NOTIFICATION:REQUESTINVITE:PHOTO:SEND', args); return args; }); }; API.sendInviteResponse = function (params, inviteID) { return this.call(`invite/${inviteID}/response`, { method: 'POST', params }).then((json) => { var args = { json, params, inviteID }; this.$emit('INVITE:RESPONSE:SEND', args); return args; }); }; API.sendInviteResponsePhoto = function (params, inviteID) { return this.call(`invite/${inviteID}/response/photo`, { uploadImage: true, postData: JSON.stringify(params), imageData: $app.uploadImage }).then((json) => { var args = { json, params, inviteID }; this.$emit('INVITE:RESPONSE:PHOTO:SEND', args); return args; }); }; /* params: { notificationId: string } */ API.acceptNotification = function (params) { return this.call(`auth/user/notifications/${params.notificationId}/accept`, { method: 'PUT' }).then((json) => { var args = { json, params }; this.$emit('NOTIFICATION:ACCEPT', args); return args; }); }; /* params: { notificationId: string } */ API.hideNotification = function (params) { return this.call(`auth/user/notifications/${params.notificationId}/hide`, { method: 'PUT' }).then((json) => { var args = { json, params }; this.$emit('NOTIFICATION:HIDE', args); return args; }); }; API.getFriendRequest = function (userId) { for (var ref of this.cachedNotifications.values()) { if (ref.$isDeleted === false && ref.type === 'friendRequest' && ref.senderUserId === userId) { return ref.id; } } return ''; }; API.parseInviteLocation = function (ref) { try { var L = API.parseLocation(ref.details.worldId); if (L.worldId && L.instanceId) { return `${ref.details.worldName} #${L.instanceName} ${L.accessType}`; } return ref.message || ref.details.worldId || ref.details.worldName; } catch (err) { return ''; } }; // API: PlayerModeration API.cachedPlayerModerations = new Map(); API.isPlayerModerationsLoading = false; API.$on('LOGIN', function () { this.cachedPlayerModerations.clear(); $app.playerModerationTable.lastRunLength = 0; this.isPlayerModerationsLoading = false; this.refreshPlayerModerations(); }); API.$on('PLAYER-MODERATION', function (args) { args.ref = this.applyPlayerModeration(args.json); }); API.$on('PLAYER-MODERATION:LIST', function (args) { for (var json of args.json) { this.$emit('PLAYER-MODERATION', { json, params: { playerModerationId: json.id } }); } }); API.$on('PLAYER-MODERATION:SEND', function (args) { var ref = { json: args.json, params: { playerModerationId: args.json.id } }; this.$emit('PLAYER-MODERATION', ref); this.$emit('PLAYER-MODERATION:@SEND', ref); }); API.$on('PLAYER-MODERATION:DELETE', function (args) { var { type, moderated } = args.params; var userId = this.currentUser.id; for (var ref of this.cachedPlayerModerations.values()) { if (ref.$isDeleted === false && ref.type === type && ref.targetUserId === moderated && ref.sourceUserId === userId) { ref.$isDeleted = true; this.$emit('PLAYER-MODERATION:@DELETE', { ref, params: { playerModerationId: ref.id } }); } } }); API.applyPlayerModeration = function (json) { var ref = this.cachedPlayerModerations.get(json.id); if (typeof ref === 'undefined') { ref = { id: '', type: '', sourceUserId: '', sourceDisplayName: '', targetUserId: '', targetDisplayName: '', created: '', // VRCX $isDeleted: false, $isExpired: false, // ...json }; this.cachedPlayerModerations.set(ref.id, ref); } else { Object.assign(ref, json); ref.$isExpired = false; } return ref; }; API.expirePlayerModerations = function () { for (var ref of this.cachedPlayerModerations.values()) { ref.$isExpired = true; } }; API.deleteExpiredPlayerModerations = function () { for (var ref of this.cachedPlayerModerations.values()) { if (ref.$isDeleted || ref.$isExpired === false) { continue; } ref.$isDeleted = true; this.$emit('PLAYER-MODERATION:@DELETE', { ref, params: { playerModerationId: ref.id } }); } }; API.refreshPlayerModerations = function () { if (this.isPlayerModerationsLoading) { return; } this.isPlayerModerationsLoading = true; this.expirePlayerModerations(); Promise.all([ this.getPlayerModerations(), //this.getPlayerModerationsAgainstMe(); ]).finally(() => { this.isPlayerModerationsLoading = false; }).then(() => { this.deleteExpiredPlayerModerations(); if (($app.playerModerationTable.data.length !== $app.playerModerationTable.lastRunLength) && ($app.playerModerationTable.lastRunLength > 0)) { $app.notifyMenu('moderation'); } $app.playerModerationTable.lastRunLength = $app.playerModerationTable.data.length; }); }; API.getPlayerModerations = function () { return this.call('auth/user/playermoderations', { method: 'GET' }).then((json) => { var args = { json }; this.$emit('PLAYER-MODERATION:LIST', args); return args; }); }; API.getPlayerModerationsAgainstMe = function () { return this.call('auth/user/playermoderated', { method: 'GET' }).then((json) => { var args = { json }; this.$emit('PLAYER-MODERATION:LIST', args); return args; }); }; /* params: { moderated: string, type: string } */ // old-way: POST auth/user/blocks {blocked:userId} API.sendPlayerModeration = function (params) { return this.call('auth/user/playermoderations', { method: 'POST', params }).then((json) => { var args = { json, params }; this.$emit('PLAYER-MODERATION:SEND', args); return args; }); }; /* params: { moderated: string, type: string } */ // old-way: PUT auth/user/unblocks {blocked:userId} API.deletePlayerModeration = function (params) { return this.call('auth/user/unplayermoderate', { method: 'PUT', params }).then((json) => { var args = { json, params }; this.$emit('PLAYER-MODERATION:DELETE', args); return args; }); }; // API: Favorite API.cachedFavorites = new Map(); API.cachedFavoritesByObjectId = new Map(); API.cachedFavoriteGroups = new Map(); API.cachedFavoriteGroupsByTypeName = new Map(); API.favoriteFriendGroups = []; API.favoriteWorldGroups = []; API.favoriteAvatarGroups = []; API.isFavoriteLoading = false; API.isFavoriteGroupLoading = false; API.$on('LOGIN', function () { this.cachedFavorites.clear(); this.cachedFavoritesByObjectId.clear(); this.cachedFavoriteGroups.clear(); this.cachedFavoriteGroupsByTypeName.clear(); this.favoriteFriendGroups = []; this.favoriteWorldGroups = []; this.favoriteAvatarGroups = []; this.isFavoriteLoading = false; this.isFavoriteGroupLoading = false; this.refreshFavorites(); }); API.$on('FAVORITE', function (args) { var ref = this.applyFavorite(args.json); if (ref.$isDeleted) { return; } args.ref = ref; }); API.$on('FAVORITE:@DELETE', function (args) { var { ref } = args; if (ref.$groupRef !== null) { --ref.$groupRef.count; } }); API.$on('FAVORITE:LIST', function (args) { for (var json of args.json) { this.$emit('FAVORITE', { json, params: { favoriteId: json.id } }); } }); API.$on('FAVORITE:ADD', function (args) { this.$emit('FAVORITE', { json: args.json, params: { favoriteId: args.json.id } }); }); API.$on('FAVORITE:DELETE', function (args) { var ref = this.cachedFavoritesByObjectId.get(args.params.objectId); if (typeof ref === 'undefined') { return; } // 애초에 $isDeleted인데 여기로 올 수 가 있나..? this.cachedFavoritesByObjectId.delete(args.params.objectId); if (ref.$isDeleted) { return; } args.ref = ref; ref.$isDeleted = true; API.$emit('FAVORITE:@DELETE', { ref, params: { favoriteId: ref.id } }); }); API.$on('FAVORITE:GROUP', function (args) { var ref = this.applyFavoriteGroup(args.json); if (ref.$isDeleted) { return; } args.ref = ref; if (ref.$groupRef !== null) { ref.$groupRef.displayName = ref.displayName; } }); API.$on('FAVORITE:GROUP:LIST', function (args) { for (var json of args.json) { this.$emit('FAVORITE:GROUP', { json, params: { favoriteGroupId: json.id } }); } }); API.$on('FAVORITE:GROUP:SAVE', function (args) { this.$emit('FAVORITE:GROUP', { json: args.json, params: { favoriteGroupId: args.json.id } }); }); API.$on('FAVORITE:GROUP:CLEAR', function (args) { var key = `${args.params.type}:${args.params.group}`; for (var ref of this.cachedFavorites.values()) { if (ref.$isDeleted || ref.$groupKey !== key) { continue; } this.cachedFavoritesByObjectId.delete(ref.favoriteId); ref.$isDeleted = true; API.$emit('FAVORITE:@DELETE', { ref, params: { favoriteId: ref.id } }); } }); API.$on('FAVORITE:FRIEND:LIST', function (args) { for (var json of args.json) { this.$emit('USER', { json, params: { userId: json.id } }); } }); API.$on('FAVORITE:WORLD:LIST', function (args) { for (var json of args.json) { if (json.id === '???') { // FIXME // json.favoriteId로 따로 불러와야 하나? // 근데 ???가 많으면 과다 요청이 될듯 continue; } this.$emit('WORLD', { json, params: { worldId: json.id } }); } }); API.$on('FAVORITE:AVATAR:LIST', function (args) { for (var json of args.json) { if (json.releaseStatus === 'hidden') { // NOTE: 얘는 또 더미 데이터로 옴 continue; } this.$emit('AVATAR', { json, params: { avatarId: json.id } }); } }); API.applyFavorite = function (json) { var ref = this.cachedFavorites.get(json.id); if (typeof ref === 'undefined') { ref = { id: '', type: '', favoriteId: '', tags: [], // VRCX $isDeleted: false, $isExpired: false, $groupKey: '', $groupRef: null, // ...json }; this.cachedFavorites.set(ref.id, ref); this.cachedFavoritesByObjectId.set(ref.favoriteId, ref); } else { Object.assign(ref, json); ref.$isExpired = false; } ref.$groupKey = `${ref.type}:${String(ref.tags[0])}`; if (ref.$isDeleted === false && ref.$groupRef === null) { var group = this.cachedFavoriteGroupsByTypeName.get(ref.$groupKey); if (typeof group !== 'undefined') { ref.$groupRef = group; ++group.count; } } return ref; }; API.expireFavorites = function () { for (var ref of this.cachedFavorites.values()) { ref.$isExpired = true; } }; API.deleteExpiredFavorites = function () { for (var ref of this.cachedFavorites.values()) { if (ref.$isDeleted || ref.$isExpired === false) { continue; } ref.$isDeleted = true; this.$emit('FAVORITE:@DELETE', { ref, params: { favoriteId: ref.id } }); } }; API.refreshFavoriteItems = function () { var types = { // 'friend': [0, 'getFavoriteFriends'], 'world': [0, 'getFavoriteWorlds'], 'avatar': [0, 'getFavoriteAvatars'] }; var tags = []; for (var ref of this.cachedFavorites.values()) { if (ref.$isDeleted) { continue; } var type = types[ref.type]; if (typeof type === 'undefined') { continue; } if ((ref.type === 'avatar') && (!tags.includes(ref.tags[0]))) { tags.push(ref.tags[0]); } ++type[0]; } for (var type in types) { var [N, fn] = types[type]; if (N > 0) { if (type === 'avatar') { for (var tag of tags) { this.bulk({ fn, N, params: { n: 50, offset: 0, tag } }); } } else { this.bulk({ fn, N, params: { n: 50, offset: 0 } }); } } } }; API.refreshFavorites = function () { if (this.isFavoriteLoading) { return; } this.isFavoriteLoading = true; this.expireFavorites(); this.bulk({ fn: 'getFavorites', N: -1, params: { n: 50, offset: 0 }, done(ok) { if (ok) { this.deleteExpiredFavorites(); } this.refreshFavoriteItems(); this.refreshFavoriteGroups(); this.isFavoriteLoading = false; } }); }; API.applyFavoriteGroup = function (json) { var ref = this.cachedFavoriteGroups.get(json.id); if (typeof ref === 'undefined') { ref = { id: '', ownerId: '', ownerDisplayName: '', name: '', displayName: '', type: '', visibility: '', tags: [], // VRCX $isDeleted: false, $isExpired: false, $groupRef: null, // ...json }; this.cachedFavoriteGroups.set(ref.id, ref); } else { Object.assign(ref, json); ref.$isExpired = false; } return ref; }; API.buildFavoriteGroups = function () { // 96 = ['group_0', 'group_1', 'group_2'] x 32 this.favoriteFriendGroups = []; for (var i = 0; i < 3; ++i) { this.favoriteFriendGroups.push({ assign: false, key: `friend:group_${i}`, type: 'friend', name: `group_${i}`, displayName: `Group ${i + 1}`, capacity: 32, count: 0 }); } // 128 = ['worlds1', 'worlds2', 'worlds3', 'worlds4'] x 32 this.favoriteWorldGroups = []; for (var i = 0; i < 4; ++i) { this.favoriteWorldGroups.push({ assign: false, key: `world:worlds${i + 1}`, type: 'world', name: `worlds${i + 1}`, displayName: `Group ${i + 1}`, capacity: 32, count: 0 }); } // 100 = ['avatars1'] x 25 // Favorite Avatars (0/25) // VRC+ Group 1 (0/25) // VRC+ Group 2 (0/25) // VRC+ Group 3 (0/25) var avatarGroupNames = [ 'Favorite Avatars', 'VRC+ Group 1', 'VRC+ Group 2', 'VRC+ Group 3' ]; this.favoriteAvatarGroups = []; for (var i = 0; i < 4; ++i) { this.favoriteAvatarGroups.push({ assign: false, key: `avatar:avatars${i + 1}`, type: 'avatar', name: `avatars${i + 1}`, displayName: avatarGroupNames[i], capacity: 25, count: 0 }); } var types = { 'friend': this.favoriteFriendGroups, 'world': this.favoriteWorldGroups, 'avatar': this.favoriteAvatarGroups }; var assigns = new Set(); // assign the same name first for (var ref of this.cachedFavoriteGroups.values()) { if (ref.$isDeleted) { continue; } var groups = types[ref.type]; if (typeof groups === 'undefined') { continue; } for (var group of groups) { if (group.assign === false && group.name === ref.name) { group.assign = true; if (ref.type !== 'avatar') { group.displayName = ref.displayName; } ref.$groupRef = group; assigns.add(ref.id); break; } } } // assign the rest // FIXME // The order (cachedFavoriteGroups) is very important. It should be // processed in the order in which the server responded. But since we // used Map(), the order would be a mess. So we need something to solve // this. for (var ref of this.cachedFavoriteGroups.values()) { if (ref.$isDeleted || assigns.has(ref.id)) { continue; } var groups = types[ref.type]; if (typeof groups === 'undefined') { continue; } for (var group of groups) { if (group.assign === false) { group.assign = true; group.key = `${group.type}:${ref.name}`; group.name = ref.name; if (ref.type !== 'avatar') { group.displayName = ref.displayName; } ref.$groupRef = group; assigns.add(ref.id); break; } } } // update favorites this.cachedFavoriteGroupsByTypeName.clear(); for (var type in types) { for (var group of types[type]) { this.cachedFavoriteGroupsByTypeName.set(group.key, group); } } for (var ref of this.cachedFavorites.values()) { ref.$groupRef = null; if (ref.$isDeleted) { continue; } var group = this.cachedFavoriteGroupsByTypeName.get(ref.$groupKey); if (typeof group === 'undefined') { continue; } ref.$groupRef = group; ++group.count; } }; API.expireFavoriteGroups = function () { for (var ref of this.cachedFavoriteGroups.values()) { ref.$isExpired = true; } }; API.deleteExpiredFavoriteGroups = function () { for (var ref of this.cachedFavoriteGroups.values()) { if (ref.$isDeleted || ref.$isExpired === false) { continue; } ref.$isDeleted = true; this.$emit('FAVORITE:GROUP:@DELETE', { ref, params: { favoriteGroupId: ref.id } }); } }; API.refreshFavoriteGroups = function () { if (this.isFavoriteGroupLoading) { return; } this.isFavoriteGroupLoading = true; this.expireFavoriteGroups(); this.bulk({ fn: 'getFavoriteGroups', N: -1, params: { n: 50, offset: 0 }, done(ok) { if (ok) { this.deleteExpiredFavoriteGroups(); this.buildFavoriteGroups(); } this.isFavoriteGroupLoading = false; } }); }; /* params: { n: number, offset: number, type: string, tag: string } */ API.getFavorites = function (params) { return this.call('favorites', { method: 'GET', params }).then((json) => { var args = { json, params }; this.$emit('FAVORITE:LIST', args); return args; }); }; /* params: { type: string, favoriteId: string (objectId), tags: string } */ API.addFavorite = function (params) { return this.call('favorites', { method: 'POST', params }).then((json) => { var args = { json, params }; this.$emit('FAVORITE:ADD', args); return args; }); }; /* params: { objectId: string } */ API.deleteFavorite = function (params) { return this.call(`favorites/${params.objectId}`, { method: 'DELETE' }).then((json) => { var args = { json, params }; this.$emit('FAVORITE:DELETE', args); return args; }); }; /* params: { n: number, offset: number, type: string } */ API.getFavoriteGroups = function (params) { return this.call('favorite/groups', { method: 'GET', params }).then((json) => { var args = { json, params }; this.$emit('FAVORITE:GROUP:LIST', args); return args; }); }; /* params: { type: string, group: string (name), displayName: string, visibility: string } */ API.saveFavoriteGroup = function (params) { return this.call(`favorite/group/${params.type}/${params.group}/${this.currentUser.id}`, { method: 'PUT', params }).then((json) => { var args = { json, params }; this.$emit('FAVORITE:GROUP:SAVE', args); return args; }); }; /* params: { type: string, group: string (name) } */ API.clearFavoriteGroup = function (params) { return this.call(`favorite/group/${params.type}/${params.group}/${this.currentUser.id}`, { method: 'DELETE', params }).then((json) => { var args = { json, params }; this.$emit('FAVORITE:GROUP:CLEAR', args); return args; }); }; /* params: { n: number, offset: number } */ API.getFavoriteFriends = function (params) { return this.call('auth/user/friends/favorite', { method: 'GET', params }).then((json) => { var args = { json, params }; this.$emit('FAVORITE:FRIEND:LIST', args); return args; }); }; /* params: { n: number, offset: number } */ API.getFavoriteWorlds = function (params) { return this.call('worlds/favorites', { method: 'GET', params }).then((json) => { var args = { json, params }; this.$emit('FAVORITE:WORLD:LIST', args); return args; }); }; /* params: { n: number, offset: number } */ API.getFavoriteAvatars = function (params) { return this.call('avatars/favorites', { method: 'GET', params }).then((json) => { var args = { json, params }; this.$emit('FAVORITE:AVATAR:LIST', args); return args; }); }; // API: WebSocket API.webSocket = null; API.$on('LOGOUT', function () { this.closeWebSocket(); }); API.$on('USER:CURRENT', function () { if (this.webSocket === null) { this.getAuth(); } }); API.$on('AUTH', function (args) { if (args.json.ok) { this.connectWebSocket(args.json.token); } }); API.$on('PIPELINE', function (args) { var { type, content } = args.json; switch (type) { case 'notification': this.$emit('NOTIFICATION', { json: content, params: { notificationId: content.id } }); break; case 'friend-add': this.$emit('USER', { json: content.user, params: { userId: content.userId } }); this.$emit('FRIEND:ADD', { params: { userId: content.userId } }); break; case 'friend-delete': this.$emit('FRIEND:DELETE', { params: { userId: content.userId } }); break; case 'friend-online': if (content.location !== 'private') { this.$emit('WORLD', { json: content.world, params: { worldId: content.world.id } }); } this.$emit('USER', { json: { location: content.location, ...content.user }, params: { userId: content.userId } }); this.$emit('FRIEND:STATE', { json: { state: 'online' }, params: { userId: content.userId } }); break; case 'friend-active': this.$emit('USER', { json: content.user, params: { userId: content.userId } }); this.$emit('FRIEND:STATE', { json: { state: 'active' }, params: { userId: content.userId } }); break; case 'friend-offline': this.$emit('FRIEND:STATE', { json: { state: 'offline' }, params: { userId: content.userId } }); break; case 'friend-update': this.$emit('USER', { json: content.user, params: { userId: content.userId } }); break; case 'friend-location': if (content.location !== 'private') { this.$emit('WORLD', { json: content.world, params: { worldId: content.world.id } }); } if (content.userId === this.currentUser.id) { this.$emit('USER', { json: content.user, params: { userId: content.userId } }); } else { this.$emit('USER', { json: { location: content.location, ...content.user }, params: { userId: content.userId } }); } break; case 'user-update': this.$emit('USER:CURRENT', { json: content.user, params: { userId: content.userId } }); break; case 'user-location': if (content.world === Object(content.world)) { this.$emit('WORLD', { json: content.world, params: { worldId: content.world.id } }); } this.$emit('USER', { json: { id: content.userId, location: content.location }, params: { userId: content.userId } }); break; default: break; } }); API.getAuth = function () { return this.call('auth', { method: 'GET' }).then((json) => { var args = { json }; this.$emit('AUTH', args); return args; }); }; API.connectWebSocket = function (token) { if (this.webSocket === null) { var socket = new WebSocket(`wss://pipeline.vrchat.cloud/?auth=${token}`); socket.onclose = () => { if (this.webSocket === socket) { this.webSocket = null; } try { socket.close(); } catch (err) { } }; socket.onerror = socket.onclose; socket.onmessage = ({ data }) => { try { var json = JSON.parse(data); json.content = JSON.parse(json.content); this.$emit('PIPELINE', { json }); } catch (err) { console.error(err); } }; this.webSocket = socket; } }; API.closeWebSocket = function () { var socket = this.webSocket; if (socket === null) { return; } this.webSocket = null; try { socket.close(); } catch (err) { } }; // API: Visit API.getVisits = function () { return this.call('visits', { method: 'GET' }).then((json) => { var args = { json }; this.$emit('VISITS', args); return args; }); }; // API var extractFileId = (s) => { var match = String(s).match(/file_[0-9A-Za-z-]+/); return match ? match[0] : ''; }; var buildTreeData = (json) => { var node = []; for (var key in json) { var value = json[key]; if (Array.isArray(value)) { node.push({ children: value.map((val, idx) => { if (val === Object(val)) { return { children: buildTreeData(val), key: idx }; } return { key: idx, value: val }; }), key }); } else if (value === Object(value)) { node.push({ children: buildTreeData(value), key }); } else { node.push({ key, value: String(value) }); } } node.sort(function (a, b) { var A = String(a.key).toUpperCase(); var B = String(b.key).toUpperCase(); if (A < B) { return -1; } if (A > B) { return 1; } return 0; }); return node; }; // Misc var $timers = []; Vue.component('timer', { template: '', props: { epoch: { type: Number, default() { return Date.now(); } } }, data() { return { text: '' }; }, methods: { update() { this.text = timeToText(Date.now() - this.epoch); } }, watch: { date() { this.update(); } }, mounted() { $timers.push(this); this.update(); }, destroyed() { removeFromArray($timers, this); } }); setInterval(function () { for (var $timer of $timers) { $timer.update(); } }, 5000); // Countdown timer var $countDownTimers = []; Vue.component('countdown-timer', { template: '', props: { datetime: { type: String, default() { return ''; } }, hours: { type: Number, default() { return 1; } } }, data() { return { text: '' }; }, methods: { update() { var epoch = new Date(this.datetime).getTime() + (1000 * 60 * 60 * this.hours) - Date.now(); if (epoch >= 0) { this.text = timeToText(epoch); } else { this.text = ''; } } }, watch: { date() { this.update(); } }, mounted() { $countDownTimers.push(this); this.update(); }, destroyed() { removeFromArray($countDownTimers, this); } }); setInterval(function () { for (var $countDownTimer of $countDownTimers) { $countDownTimer.update(); } }, 5000); // initialise var $app = { data: { API, nextCurrentUserRefresh: 0, nextFriendsRefresh: 0, isGameRunning: false, isGameNoVR: false, appVersion: 'VRCX 2021.03.08', latestAppVersion: '', ossDialog: false, exportFriendsListDialog: false, exportFriendsListContent: '' }, computed: {}, methods: {}, watch: {}, el: '#x-app', mounted() { this.checkAppVersion(); API.$on('SHOW_WORLD_DIALOG', (tag) => this.showWorldDialog(tag)); API.$on('SHOW_LAUNCH_DIALOG', (tag) => this.showLaunchDialog(tag)); this.updateLoop(); this.updateGameLogLoop(); this.$nextTick(function () { this.$el.style.display = ''; this.loginForm.loading = true; API.getConfig().catch((err) => { this.loginForm.loading = false; throw err; }).then((args) => { API.getCurrentUser().finally(() => { this.loginForm.loading = false; }); return args; }); }); } }; $app.methods.openExternalLink = function (link) { this.$confirm(`${link}`, 'Open External Link', { confirmButtonText: 'Confirm', cancelButtonText: 'Cancel', type: 'info', callback: (action) => { if (action === 'confirm') { AppApi.OpenLink(link); } } }); }; $app.methods.languageClass = function (language) { var style = {}; var mapping = languageMappings[language]; if (typeof mapping !== 'undefined') { style[mapping] = true; } return style; }; $app.methods.checkAppVersion = async function () { var response = await webApiService.execute({ url: 'https://api.github.com/repos/pypy-vrc/VRCX/releases/latest', method: 'GET', headers: { 'User-Agent': 'VRCX' } }); var json = JSON.parse(response.data); if (json === Object(json) && json.name && json.published_at) { this.latestAppVersion = `${json.name} (${formatDate(json.published_at, 'YYYY-MM-DD HH24:MI:SS')})`; if (json.name > this.appVersion) { new Noty({ type: 'info', text: `Update available!!
${this.latestAppVersion}`, timeout: 60000, callbacks: { onClick: () => AppApi.OpenLink('https://github.com/pypy-vrc/VRCX/releases') } }).show(); this.notifyMenu('settings'); } } else { this.latestAppVersion = 'Error occured'; } }; $app.methods.updateLoop = function () { try { if (API.isLoggedIn === true) { if (--this.nextCurrentUserRefresh <= 0) { this.nextCurrentUserRefresh = 120; // 1min API.getCurrentUser().catch((err1) => { throw err1; }); } if (--this.nextFriendsRefresh <= 0) { this.nextFriendsRefresh = 7200; // 1hour API.refreshFriends(); } AppApi.CheckGameRunning().then(([isGameRunning, isGameNoVR]) => { if (isGameRunning !== this.isGameRunning) { this.isGameRunning = isGameRunning; Discord.SetTimestamps(Date.now(), 0); } this.isGameNoVR = isGameNoVR; this.updateDiscord(); this.updateOpenVR(); }); } } catch (err) { console.error(err); } setTimeout(() => this.updateLoop(), 500); }; $app.data.debug = false; $app.data.sharedFeed = { gameLog: { wrist: [], noty: [], lastEntryDate: '' }, feedTable: { wrist: [], noty: [], lastEntryDate: '' }, notificationTable: { wrist: [], noty: [], lastEntryDate: '' }, friendLogTable: { wrist: [], noty: [], lastEntryDate: '' }, playerModerationTable: { wrist: [], noty: [], lastEntryDate: '' }, pendingUpdate: false }; $app.data.appInit = false; $app.data.notyInit = false; API.$on('LOGIN', function (args) { sharedRepository.setArray('wristFeed', []); sharedRepository.setArray('notyFeed', []); setTimeout(function () { $app.appInit = true; $app.updateSharedFeed(true); $app.notyInit = true; sharedRepository.setBool('VRInit', true); }, 10000); }); $app.methods.updateSharedFeed = function (forceUpdate) { if (!this.appInit) { return; } this.updateSharedFeedGameLog(forceUpdate); this.updateSharedFeedFeedTable(forceUpdate); this.updateSharedFeedNotificationTable(forceUpdate); this.updateSharedFeedFriendLogTable(forceUpdate); //this.updateSharedFeedPlayerModerationTable(forceUpdate); var feeds = this.sharedFeed; if (!feeds.pendingUpdate) { return; } var wristFeed = []; wristFeed = wristFeed.concat(feeds.gameLog.wrist, feeds.feedTable.wrist, feeds.notificationTable.wrist, feeds.friendLogTable.wrist, feeds.playerModerationTable.wrist); var notyFeed = []; notyFeed = notyFeed.concat(feeds.gameLog.noty, feeds.feedTable.noty, feeds.notificationTable.noty, feeds.friendLogTable.noty, feeds.playerModerationTable.noty); // OnPlayerJoining var locationBias = Date.now() - 30000; //30 seconds if ((this.isGameRunning) && (this.lastLocation.date < locationBias) && ((this.sharedFeedFilters.wrist.OnPlayerJoining === 'Friends') || (this.sharedFeedFilters.wrist.OnPlayerJoining === 'VIP') || (this.sharedFeedFilters.noty.OnPlayerJoining === 'Friends') || (this.sharedFeedFilters.noty.OnPlayerJoining === 'VIP'))) { var joiningMap = []; var bias = new Date(Date.now() - 120000).toJSON(); //2 minutes var feedTable = this.feedTable.data; for (var i = feedTable.length - 1; i > -1; i--) { var ctx = feedTable[i]; if ((ctx.type === 'GPS') && (ctx.location[0] === this.lastLocation.location)) { if (joiningMap[ctx.displayName]) { continue; } var joining = true; var playersInInstance = this.lastLocation.playerList; if (!playersInInstance.includes(ctx.displayName)) { var onPlayerJoining = { ...ctx, type: 'OnPlayerJoining' }; if ((this.sharedFeedFilters.wrist.OnPlayerJoining === 'Friends') || ((this.sharedFeedFilters.wrist.OnPlayerJoining === 'VIP') && (ctx.isFavorite))) { wristFeed.push(onPlayerJoining); i++; } if ((this.sharedFeedFilters.noty.OnPlayerJoining === 'Friends') || ((this.sharedFeedFilters.noty.OnPlayerJoining === 'VIP') && (ctx.isFavorite))) { notyFeed.push(onPlayerJoining); } } joiningMap[ctx.displayName] = ctx.created_at; } if (ctx.created_at < bias) { break; } } } wristFeed.sort(function (a, b) { if (a.created_at < b.created_at) { return 1; } if (a.created_at > b.created_at) { return -1; } return 0; }); wristFeed.splice(20); notyFeed.sort(function (a, b) { if (a.created_at < b.created_at) { return 1; } if (a.created_at > b.created_at) { return -1; } return 0; }); notyFeed.splice(5); sharedRepository.setArray('wristFeed', wristFeed); sharedRepository.setArray('notyFeed', notyFeed); this.playNoty(notyFeed); feeds.pendingUpdate = false; }; $app.methods.updateSharedFeedGameLog = function (forceUpdate) { // Location, OnPlayerJoined, OnPlayerLeft var { data } = this.gameLogTable; var i = data.length; if (i > 0) { if ((data[i - 1].created_at === this.sharedFeed.gameLog.lastEntryDate) && (forceUpdate === false)) { return; } this.sharedFeed.gameLog.lastEntryDate = data[i - 1].created_at; } else { return; } var bias = new Date(Date.now() - 86400000).toJSON(); //24 hours var wristArr = []; var notyArr = []; var w = 0; var n = 0; var wristFilter = this.sharedFeedFilters.wrist; var notyFilter = this.sharedFeedFilters.noty; var playerCountIndex = 0; var playerList = []; var friendList = []; for (var i = data.length - 1; i > -1; i--) { var ctx = data[i]; if (ctx.created_at < bias) { break; } if (ctx.type === 'Notification') { continue; } if ((playerCountIndex === 0) && (ctx.type === 'Location')) { playerCountIndex = i; } if (((ctx.type === 'OnPlayerJoined') || (ctx.type === 'OnPlayerLeft')) && (ctx.data === API.currentUser.displayName)) { continue; } // on Location change remove OnPlayerJoined if ((ctx.type === 'Location') && (this.hideOnPlayerJoined)) { var locationBias = new Date(Date.parse(ctx.created_at) + 15000).toJSON(); //15 seconds for (var k = w - 1; k > -1; k--) { var feedItem = wristArr[k]; if ((feedItem.created_at > locationBias) || (feedItem.type === 'Location')) { break; } if (feedItem.type === 'OnPlayerJoined') { wristArr.splice(k, 1); w--; } } for (var k = n - 1; k > -1; k--) { var feedItem = notyArr[k]; if (feedItem.created_at > locationBias) { break; } if (feedItem.type === 'OnPlayerJoined') { notyArr.splice(k, 1); n--; } } } var isFriend = false; var isFavorite = false; for (var ref of API.cachedUsers.values()) { if (ref.displayName === ctx.data) { isFriend = this.friends.has(ref.id); isFavorite = API.cachedFavoritesByObjectId.has(ref.id); break; } } if ((w < 20) && (wristFilter[ctx.type]) && ((wristFilter[ctx.type] === 'On') || (wristFilter[ctx.type] === 'Everyone') || ((wristFilter[ctx.type] === 'Friends') && (isFriend)) || ((wristFilter[ctx.type] === 'VIP') && (isFavorite)))) { wristArr.push({ ...ctx, isFriend, isFavorite }); ++w; } if ((n < 5) && (notyFilter[ctx.type]) && ((notyFilter[ctx.type] === 'On') || (notyFilter[ctx.type] === 'Everyone') || ((notyFilter[ctx.type] === 'Friends') && (isFriend)) || ((notyFilter[ctx.type] === 'VIP') && (isFavorite)))) { notyArr.push({ ...ctx, isFriend, isFavorite }); ++n; } } // instance player list for (var i = playerCountIndex + 1; i < data.length; i++) { var ctx = data[i]; if (ctx.type === 'OnPlayerJoined') { playerList.push(ctx.data); var isFriend = false; for (var ref of API.cachedUsers.values()) { if (ref.displayName === ctx.data) { isFriend = this.friends.has(ref.id); break; } } if (isFriend) { friendList.push(ctx.data); } } if (ctx.type === 'OnPlayerLeft') { var index = playerList.indexOf(ctx.data); if (index > -1) { playerList.splice(index, 1); } var index = friendList.indexOf(ctx.data); if (index > -1) { friendList.splice(index, 1); } } } if (this.isGameRunning) { this.lastLocation.playerList = playerList; this.lastLocation.friendList = friendList; sharedRepository.setObject('last_location', this.lastLocation); } this.sharedFeed.gameLog.wrist = wristArr; this.sharedFeed.gameLog.noty = notyArr; this.sharedFeed.pendingUpdate = true; }; $app.methods.updateSharedFeedFeedTable = function (forceUpdate) { // GPS, Online, Offline, Status, Avatar var { data } = this.feedTable; var i = data.length; if (i > 0) { if ((data[i - 1].created_at === this.sharedFeed.feedTable.lastEntryDate) && (forceUpdate === false)) { return; } this.sharedFeed.feedTable.lastEntryDate = data[i - 1].created_at; } else { return; } var bias = new Date(Date.now() - 86400000).toJSON(); //24 hours var wristArr = []; var notyArr = []; var w = 0; var n = 0; var wristFilter = this.sharedFeedFilters.wrist; var notyFilter = this.sharedFeedFilters.noty; for (var i = data.length - 1; i > -1; i--) { var ctx = data[i]; if (ctx.created_at < bias) { break; } if (ctx.type === 'Avatar') { continue; } // hide private worlds from feeds if ((this.hidePrivateFromFeed) && (ctx.type === 'GPS') && (ctx.location[0] === 'private')) { continue; } var isFriend = this.friends.has(ctx.userId); var isFavorite = API.cachedFavoritesByObjectId.has(ctx.userId); if ((w < 20) && (wristFilter[ctx.type]) && ((wristFilter[ctx.type] === 'Friends') || ((wristFilter[ctx.type] === 'VIP') && (isFavorite)))) { wristArr.push({ ...ctx, isFriend, isFavorite }); ++w; } if ((n < 5) && (notyFilter[ctx.type]) && ((notyFilter[ctx.type] === 'Friends') || ((notyFilter[ctx.type] === 'VIP') && (isFavorite)))) { notyArr.push({ ...ctx, isFriend, isFavorite }); ++n; } } this.sharedFeed.feedTable.wrist = wristArr; this.sharedFeed.feedTable.noty = notyArr; this.sharedFeed.pendingUpdate = true; }; $app.methods.updateSharedFeedNotificationTable = function (forceUpdate) { // invite, requestInvite, requestInviteResponse, inviteResponse, friendRequest var { data } = this.notificationTable; var i = data.length; if (i > 0) { if ((data[i - 1].created_at === this.sharedFeed.notificationTable.lastEntryDate) && (forceUpdate === false)) { return; } this.sharedFeed.notificationTable.lastEntryDate = data[i - 1].created_at; } else { return; } var bias = new Date(Date.now() - 86400000).toJSON(); //24 hours var wristArr = []; var notyArr = []; var w = 0; var n = 0; var wristFilter = this.sharedFeedFilters.wrist; var notyFilter = this.sharedFeedFilters.noty; for (var i = data.length - 1; i > -1; i--) { var ctx = data[i]; if (ctx.created_at < bias) { break; } if (ctx.senderUserId === API.currentUser.id) { continue; } var isFriend = this.friends.has(ctx.senderUserId); var isFavorite = API.cachedFavoritesByObjectId.has(ctx.senderUserId); if ((w < 20) && (wristFilter[ctx.type]) && ((wristFilter[ctx.type] === 'On') || (wristFilter[ctx.type] === 'Friends') || ((wristFilter[ctx.type] === 'VIP') && (isFavorite)))) { wristArr.push({ ...ctx, isFriend, isFavorite }); ++w; } if ((n < 5) && (notyFilter[ctx.type]) && ((notyFilter[ctx.type] === 'On') || (notyFilter[ctx.type] === 'Friends') || ((notyFilter[ctx.type] === 'VIP') && (isFavorite)))) { notyArr.push({ ...ctx, isFriend, isFavorite }); ++n; } } this.sharedFeed.notificationTable.wrist = wristArr; this.sharedFeed.notificationTable.noty = notyArr; this.sharedFeed.pendingUpdate = true; }; $app.methods.updateSharedFeedFriendLogTable = function (forceUpdate) { // TrustLevel, Friend, FriendRequest, Unfriend, DisplayName var { data } = this.friendLogTable; var i = data.length; if (i > 0) { if ((data[i - 1].created_at === this.sharedFeed.friendLogTable.lastEntryDate) && (forceUpdate === false)) { return; } this.sharedFeed.friendLogTable.lastEntryDate = data[i - 1].created_at; } else { return; } var bias = new Date(Date.now() - 86400000).toJSON(); //24 hours var wristArr = []; var notyArr = []; var w = 0; var n = 0; var wristFilter = this.sharedFeedFilters.wrist; var notyFilter = this.sharedFeedFilters.noty; for (var i = data.length - 1; i > -1; i--) { var ctx = data[i]; if (ctx.created_at < bias) { break; } if (ctx.type === 'FriendRequest') { continue; } var isFriend = this.friends.has(ctx.userId); var isFavorite = API.cachedFavoritesByObjectId.has(ctx.userId); if ((w < 20) && (wristFilter[ctx.type]) && ((wristFilter[ctx.type] === 'On') || (wristFilter[ctx.type] === 'Friends') || ((wristFilter[ctx.type] === 'VIP') && (isFavorite)))) { wristArr.push({ ...ctx, isFriend, isFavorite }); ++w; } if ((n < 5) && (notyFilter[ctx.type]) && ((notyFilter[ctx.type] === 'On') || (notyFilter[ctx.type] === 'Friends') || ((notyFilter[ctx.type] === 'VIP') && (isFavorite)))) { notyArr.push({ ...ctx, isFriend, isFavorite }); ++n; } } this.sharedFeed.friendLogTable.wrist = wristArr; this.sharedFeed.friendLogTable.noty = notyArr; this.sharedFeed.pendingUpdate = true; }; $app.methods.updateSharedFeedPlayerModerationTable = function (forceUpdate) { // showAvatar, hideAvatar, block, mute, unmute var { data } = this.playerModerationTable; var i = data.length; if (i > 0) { if ((data[i - 1].created === this.sharedFeed.playerModerationTable.lastEntryDate) && (forceUpdate === false)) { return; } this.sharedFeed.playerModerationTable.lastEntryDate = data[i - 1].created; } else { return; } var bias = new Date(Date.now() - 86400000).toJSON(); //24 hours var wristArr = []; var notyArr = []; var w = 0; var n = 0; var wristFilter = this.sharedFeedFilters.wrist; var notyFilter = this.sharedFeedFilters.noty; for (var i = data.length - 1; i > -1; i--) { var ctx = data[i]; if (ctx.created < bias) { break; } if (ctx.sourceUserId === API.currentUser.id) { continue; } var isFriend = this.friends.has(ctx.sourceUserId); var isFavorite = API.cachedFavoritesByObjectId.has(ctx.sourceUserId); if ((w < 20) && (wristFilter[ctx.type]) && (wristFilter[ctx.type] === 'On')) { wristArr.push({ ...ctx, created_at: ctx.created, isFriend, isFavorite }); ++w; } if ((n < 5) && (notyFilter[ctx.type]) && (notyFilter[ctx.type] === 'On')) { notyArr.push({ ...ctx, created_at: ctx.created, isFriend, isFavorite }); ++n; } } this.sharedFeed.playerModerationTable.wrist = wristArr; this.sharedFeed.playerModerationTable.noty = notyArr; this.sharedFeed.pendingUpdate = true; }; $app.data.notyMap = []; $app.methods.playNoty = async function (notyFeed) { var notyToPlay = []; notyFeed.forEach((feed) => { var displayName = ''; if (feed.displayName) { displayName = feed.displayName; } else if (feed.senderUsername) { displayName = feed.senderUsername; } else if (feed.sourceDisplayName) { displayName = feed.sourceDisplayName; } else if (feed.data) { displayName = feed.data; } else { console.error('missing displayName'); } if ((displayName) && (!this.notyMap[displayName]) || (this.notyMap[displayName] < feed.created_at)) { this.notyMap[displayName] = feed.created_at; notyToPlay.push(feed); } }); // disable notifications when busy if ((this.currentUserStatus === 'busy') || (!this.notyInit)) { return; } var bias = new Date(Date.now() - 60000).toJSON(); var noty = {}; var messageList = ['inviteMessage', 'requestMessage', 'responseMessage']; for (var i = 0; i < notyToPlay.length; i++) { noty = notyToPlay[i]; if (noty.created_at < bias) { continue; } var message = ''; for (i = 0; i < messageList.length; i++) { if (typeof noty.details !== 'undefined' && typeof noty.details[messageList[i]] !== 'undefined') { message = noty.details[messageList[i]]; } } if (message) { message = `, ${message}`; } if ((this.notificationTTS) && (this.isGameRunning)) { this.playNotyTTS(noty, message); } if ((this.desktopToast === 'Always') || ((this.desktopToast === 'Game Closed') && (!this.isGameRunning)) || ((this.desktopToast === 'Desktop Mode') && (this.isGameNoVR) && (this.isGameRunning))) { this.displayDesktopToast(noty, message); } } }; $app.methods.playNotyTTS = async function (noty, message) { switch (noty.type) { case 'OnPlayerJoined': this.speak(`${noty.data} has joined`); break; case 'OnPlayerLeft': this.speak(`${noty.data} has left`); break; case 'OnPlayerJoining': this.speak(`${noty.displayName} is joining`); break; case 'GPS': this.speak(`${noty.displayName} is in ${await this.displayLocation(noty.location[0])}`); break; case 'Online': this.speak(`${noty.displayName} has logged in`); break; case 'Offline': this.speak(`${noty.displayName} has logged out`); break; case 'Status': this.speak(`${noty.displayName} status is now ${noty.status[0].status} ${noty.status[0].statusDescription}`); break; case 'invite': this.speak(`${noty.senderUsername} has invited you to ${noty.details.worldName}${message}`); break; case 'requestInvite': this.speak(`${noty.senderUsername} has requested an invite${message}`); break; case 'inviteResponse': this.speak(`${noty.senderUsername} has responded to your invite${message}`); break; case 'requestInviteResponse': this.speak(`${noty.senderUsername} has responded to your invite request${message}`); break; case 'friendRequest': this.speak(`${noty.senderUsername} has sent you a friend request`); break; case 'Friend': this.speak(`${noty.displayName} is now your friend`); break; case 'Unfriend': this.speak(`${noty.displayName} is no longer your friend`); break; case 'TrustLevel': this.speak(`${noty.displayName} trust level is now ${noty.trustLevel}`); break; case 'DisplayName': this.speak(`${noty.previousDisplayName} changed their name to ${noty.displayName}`); break; case 'showAvatar': this.speak(`${noty.sourceDisplayName} has shown your avatar`); break; case 'hideAvatar': this.speak(`${noty.sourceDisplayName} has hidden your avatar`); break; case 'block': this.speak(`${noty.sourceDisplayName} has blocked you`); break; case 'mute': this.speak(`${noty.sourceDisplayName} has muted you`); break; case 'unmute': this.speak(`${noty.sourceDisplayName} has unmuted you`); break; default: break; } }; $app.methods.displayDesktopToast = async function (noty, message) { var imageURL = ''; var userId = ''; if (noty.userId) { userId = noty.userId; } else if (noty.senderUserId) { userId = noty.senderUserId; } else if (noty.sourceUserId) { userId = noty.sourceUserId; } else if (noty.data) { for (var ref of API.cachedUsers.values()) { if (ref.displayName === noty.data) { userId = ref.id; break; } } } if ((noty.details) && (noty.details.imageUrl)) { imageURL = noty.details.imageUrl; } else if (userId) { imageURL = await API.getCachedUser({ userId: userId }).catch((err) => { throw err; }).then((args) => { if ((this.displayVRCPlusIconsAsAvatar) && (args.json.userIcon)) { return args.json.userIcon; } return args.json.currentAvatarThumbnailImageUrl; }); } switch (noty.type) { case 'OnPlayerJoined': AppApi.DesktopNotification(noty.data, 'has joined', imageURL); break; case 'OnPlayerLeft': AppApi.DesktopNotification(noty.data, 'has left', imageURL); break; case 'OnPlayerJoining': AppApi.DesktopNotification(noty.displayName, 'is joining', imageURL); break; case 'GPS': AppApi.DesktopNotification(noty.displayName, `is in ${await this.displayLocation(noty.location[0])}`, imageURL); break; case 'Online': AppApi.DesktopNotification(noty.displayName, 'has logged in', imageURL); break; case 'Offline': AppApi.DesktopNotification(noty.displayName, 'has logged out', imageURL); break; case 'Status': AppApi.DesktopNotification(noty.displayName, `status is now ${noty.status[0].status} ${noty.status[0].statusDescription}`, imageURL); break; case 'invite': AppApi.DesktopNotification(noty.senderUsername, `has invited you to ${noty.details.worldName}${message}`, imageURL); break; case 'requestInvite': AppApi.DesktopNotification(noty.senderUsername, `has requested an invite${message}`, imageURL); break; case 'inviteResponse': AppApi.DesktopNotification(noty.senderUsername, `has responded to your invite${message}`, imageURL); break; case 'requestInviteResponse': AppApi.DesktopNotification(noty.senderUsername, `has responded to your invite request${message}`, imageURL); break; case 'friendRequest': AppApi.DesktopNotification(noty.senderUsername, 'has sent you a friend request', imageURL); break; case 'Friend': AppApi.DesktopNotification(noty.displayName, 'is now your friend', imageURL); break; case 'Unfriend': AppApi.DesktopNotification(noty.displayName, 'is no longer your friend', imageURL); break; case 'TrustLevel': AppApi.DesktopNotification(noty.displayName, `trust level is now ${noty.trustLevel}`, imageURL); break; case 'DisplayName': AppApi.DesktopNotification(noty.previousDisplayName, `changed their name to ${noty.displayName}`, imageURL); break; case 'showAvatar': AppApi.DesktopNotification(noty.sourceDisplayName, `has shown your avatar`, imageURL); break; case 'hideAvatar': AppApi.DesktopNotification(noty.sourceDisplayName, `has hidden your avatar`, imageURL); break; case 'block': AppApi.DesktopNotification(noty.sourceDisplayName, `has blocked you`, imageURL); break; case 'mute': AppApi.DesktopNotification(noty.sourceDisplayName, `has muted you`, imageURL); break; case 'unmute': AppApi.DesktopNotification(noty.sourceDisplayName, `has unmuted you`, imageURL); break; default: break; } }; $app.methods.displayLocation = async function (location) { var text = ''; var L = API.parseLocation(location); if (L.isOffline) { text = 'Offline'; } else if (L.isPrivate) { text = 'Private'; } else if (L.worldId) { var ref = API.cachedWorlds.get(L.worldId); if (typeof ref === 'undefined') { await API.getWorld({ worldId: L.worldId }).then((args) => { if (L.tag === location) { if (L.instanceId) { text = `${args.json.name} ${L.accessType}`; } else { text = args.json.name; } } }); } else if (L.instanceId) { text = `${ref.name} ${L.accessType}`; } else { text = ref.name; } } return text; }; $app.methods.notifyMenu = function (index) { var { menu } = this.$refs; if (menu.activeIndex !== index) { var item = menu.items[index]; if (item) { item.$el.classList.add('notify'); } } }; $app.methods.selectMenu = function (index) { // NOTE // 툴팁이 쌓여서 느려지기 때문에 날려줌. // 근데 이 방법이 안전한지는 모르겠음 document.querySelectorAll('[role="tooltip"]').forEach((node) => { node.remove(); }); var item = this.$refs.menu.items[index]; if (item) { item.$el.classList.remove('notify'); } }; $app.methods.promptTOTP = function () { this.$prompt('Enter a numeric code from your authenticator app', 'Two-factor Authentication', { distinguishCancelAndClose: true, cancelButtonText: 'Use OTP', confirmButtonText: 'Verify', inputPlaceholder: 'Code', inputPattern: /^[0-9]{6}$/, inputErrorMessage: 'Invalid Code', callback: (action, instance) => { if (action === 'confirm') { API.verifyTOTP({ code: instance.inputValue }).catch((err) => { this.promptTOTP(); throw err; }).then((args) => { API.getCurrentUser(); return args; }); } else if (action === 'cancel') { this.promptOTP(); } } }); }; $app.methods.promptOTP = function () { this.$prompt('Enter one of your saved recovery codes', 'Two-factor Authentication', { distinguishCancelAndClose: true, cancelButtonText: 'Use TOTP', confirmButtonText: 'Verify', inputPlaceholder: 'Code', inputPattern: /^[a-z0-9]{4}-[a-z0-9]{4}$/, inputErrorMessage: 'Invalid Code', callback: (action, instance) => { if (action === 'confirm') { API.verifyOTP({ code: instance.inputValue }).catch((err) => { this.promptOTP(); throw err; }).then((args) => { API.getCurrentUser(); return args; }); } else if (action === 'cancel') { this.promptTOTP(); } } }); }; $app.methods.showExportFriendsListDialog = function () { var { friends } = API.currentUser; if (Array.isArray(friends) === false) { return; } var lines = [ 'UserID,DisplayName,Memo' ]; var _ = function (str) { if (/[\x00-\x1f,"]/.test(str) === true) { str = `"${str.replace(/"/g, '""')}"`; } return str; }; for (var userId of friends) { var ref = this.friends.get(userId); var name = (typeof ref !== 'undefined' && ref.name) || ''; var memo = (typeof ref !== 'undefined' && ref.memo) || ''; lines.push(`${_(userId)},${_(name)},${_(memo)}`); } this.exportFriendsListContent = lines.join('\n'); this.exportFriendsListDialog = true; }; API.$on('USER:2FA', function () { $app.promptTOTP(); }); API.$on('LOGOUT', function () { new Noty({ type: 'success', text: `See you again, ${escapeTag(this.currentUser.displayName)}!` }).show(); }); API.$on('LOGIN', function (args) { new Noty({ type: 'success', text: `Hello there, ${escapeTag(args.ref.displayName)}!` }).show(); $app.$refs.menu.activeIndex = 'feed'; $app.resetGameLog(); }); API.$on('LOGIN', function (args) { $app.updateStoredUser(args.ref); }); API.$on('LOGOUT', function () { $app.updateStoredUser(this.currentUser); }); $app.methods.updateStoredUser = function (currentUser) { var savedCredentialsArray = {}; if (configRepository.getString('savedCredentials') !== null) { var savedCredentialsArray = JSON.parse(configRepository.getString('savedCredentials')); } if (this.saveCredentials) { var credentialsToSave = { user: currentUser, loginParmas: this.saveCredentials }; savedCredentialsArray[currentUser.username] = credentialsToSave; delete this.saveCredentials; } else if (typeof savedCredentialsArray[currentUser.username] !== 'undefined') { savedCredentialsArray[currentUser.username].user = currentUser; } this.loginForm.savedCredentials = savedCredentialsArray; var jsonCredentialsArray = JSON.stringify(savedCredentialsArray); configRepository.setString('savedCredentials', jsonCredentialsArray); this.loginForm.lastUserLoggedIn = currentUser.username; configRepository.setString('lastUserLoggedIn', currentUser.username); }; $app.methods.relogin = function (loginParmas) { this.loginForm.loading = true; return API.getConfig().catch((err) => { this.loginForm.loading = false; throw err; }).then(() => { API.login({ username: loginParmas.username, password: loginParmas.password }).catch((err2) => { API.logout(); throw err2; }).finally(() => { this.loginForm.loading = false; }); }); }; $app.methods.deleteSavedLogin = function (username) { var savedCredentialsArray = JSON.parse(configRepository.getString('savedCredentials')); delete savedCredentialsArray[username]; $app.loginForm.savedCredentials = savedCredentialsArray; var jsonCredentialsArray = JSON.stringify(savedCredentialsArray); configRepository.setString('savedCredentials', jsonCredentialsArray); new Noty({ type: 'success', text: 'Account removed.' }).show(); }; API.$on('AUTOLOGIN', function () { var user = $app.loginForm.savedCredentials[$app.loginForm.lastUserLoggedIn]; if (typeof user !== 'undefined') { $app.relogin({ username: user.loginParmas.username, password: user.loginParmas.password }).then(() => { new Noty({ type: 'success', text: 'Automatically logged in.' }).show(); }); } }); $app.data.loginForm = { loading: true, username: '', password: '', saveCredentials: false, savedCredentials: ((configRepository.getString('lastUserLoggedIn') !== null) ? JSON.parse(configRepository.getString('savedCredentials')) : {}), lastUserLoggedIn: configRepository.getString('lastUserLoggedIn'), rules: { username: [ { required: true, trigger: 'blur' } ], password: [ { required: true, trigger: 'blur' } ] } }; $app.methods.login = function () { this.$refs.loginForm.validate((valid) => { if (valid && !this.loginForm.loading) { this.loginForm.loading = true; API.getConfig().catch((err) => { this.loginForm.loading = false; throw err; }).then((args) => { API.login({ username: this.loginForm.username, password: this.loginForm.password, saveCredentials: this.loginForm.saveCredentials }).finally(() => { this.loginForm.username = ''; this.loginForm.password = ''; this.loginForm.loading = false; }); return args; }); } }); }; $app.methods.loginWithSteam = function () { if (!this.loginForm.loading) { this.loginForm.loading = true; AppApi.LoginWithSteam().catch((err) => { this.loginForm.loading = false; throw err; }).then((steamTicket) => { if (steamTicket) { API.getConfig().catch((err) => { this.loginForm.loading = false; throw err; }).then((args) => { API.loginWithSteam({ steamTicket }).finally(() => { this.loginForm.loading = false; }); return args; }); } else { this.loginForm.loading = false; this.$message({ message: 'It only works when VRChat is running.', type: 'error' }); } }); } }; $app.methods.loadMemo = function (id) { var key = `memo_${id}`; return VRCXStorage.Get(key); }; $app.methods.saveMemo = function (id, memo) { var key = `memo_${id}`; if (memo) { VRCXStorage.Set(key, String(memo)); } else { VRCXStorage.Remove(key); } var ref = this.friends.get(id); if (ref) { ref.memo = String(memo || ''); } }; // App: Friends $app.data.friends = new Map(); $app.data.pendingActiveFriends = new Set(); $app.data.friendsNo = 0; $app.data.isFriendsGroupMe = true; $app.data.isFriendsGroup0 = true; $app.data.isFriendsGroup1 = true; $app.data.isFriendsGroup2 = true; $app.data.isFriendsGroup3 = false; $app.data.friendsGroup0_ = []; $app.data.friendsGroup1_ = []; $app.data.friendsGroup2_ = []; $app.data.friendsGroup3_ = []; $app.data.friendsGroupA_ = []; $app.data.friendsGroupB_ = []; $app.data.friendsGroupC_ = []; $app.data.friendsGroupD_ = []; $app.data.sortFriendsGroup0 = false; $app.data.sortFriendsGroup1 = false; $app.data.sortFriendsGroup2 = false; $app.data.sortFriendsGroup3 = false; $app.data.orderFriendsGroup0 = configRepository.getBool('orderFriendGroup0'); $app.data.orderFriendsGroup1 = configRepository.getBool('orderFriendGroup1'); $app.data.orderFriendsGroup2 = configRepository.getBool('orderFriendGroup2'); $app.data.orderFriendsGroup3 = configRepository.getBool('orderFriendGroup3'); var saveOrderFriendGroup = function () { configRepository.setBool('orderFriendGroup0', this.orderFriendsGroup0); configRepository.setBool('orderFriendGroup1', this.orderFriendsGroup1); configRepository.setBool('orderFriendGroup2', this.orderFriendsGroup2); configRepository.setBool('orderFriendGroup3', this.orderFriendsGroup3); }; $app.watch.orderFriendsGroup0 = saveOrderFriendGroup; $app.watch.orderFriendsGroup1 = saveOrderFriendGroup; $app.watch.orderFriendsGroup2 = saveOrderFriendGroup; $app.watch.orderFriendsGroup3 = saveOrderFriendGroup; $app.methods.fetchActiveFriend = function (userId) { this.pendingActiveFriends.add(userId); // FIXME: handle error return API.getUser({ userId }).then((args) => { this.pendingActiveFriends.delete(userId); return args; }); }; $app.methods.checkActiveFriends = function () { if (Array.isArray(API.currentUser.activeFriends) === false) { return; } for (var userId of API.currentUser.activeFriends) { if (this.pendingActiveFriends.has(userId)) { continue; } var user = API.cachedUsers.get(userId); if (typeof user !== 'undefined' && user.status !== 'offline') { continue; } if (this.pendingActiveFriends.size >= 5) { break; } this.fetchActiveFriend(userId); } }; API.$on('LOGIN', function () { $app.friends.clear(); $app.pendingActiveFriends.clear(); $app.friendsNo = 0; $app.isFriendsGroup0 = true; $app.isFriendsGroup1 = true; $app.isFriendsGroup2 = true; $app.isFriendsGroup3 = false; $app.friendsGroup0_ = []; $app.friendsGroup1_ = []; $app.friendsGroup2_ = []; $app.friendsGroup3_ = []; $app.friendsGroupA_ = []; $app.friendsGroupB_ = []; $app.friendsGroupC_ = []; $app.friendsGroupD_ = []; $app.sortFriendsGroup0 = false; $app.sortFriendsGroup1 = false; $app.sortFriendsGroup2 = false; $app.sortFriendsGroup3 = false; }); API.$on('USER:CURRENT', function (args) { // initFriendship()이 LOGIN에서 처리되기 때문에 // USER:CURRENT에서 처리를 함 $app.refreshFriends(args.ref, args.origin); }); API.$on('USER', function (args) { $app.updateFriend(args.ref.id); }); API.$on('FRIEND:ADD', function (args) { $app.addFriend(args.params.userId); }); API.$on('FRIEND:DELETE', function (args) { $app.deleteFriend(args.params.userId); }); API.$on('FRIEND:STATE', function (args) { $app.updateFriend(args.params.userId, args.json.state); }); API.$on('FAVORITE', function (args) { $app.updateFriend(args.ref.favoriteId); }); API.$on('FAVORITE:@DELETE', function (args) { $app.updateFriend(args.ref.favoriteId); }); $app.methods.refreshFriends = function (ref, origin) { var map = new Map(); for (var id of ref.friends) { map.set(id, 'offline'); } for (var id of ref.offlineFriends) { map.set(id, 'offline'); } for (var id of ref.activeFriends) { map.set(id, 'active'); } for (var id of ref.onlineFriends) { map.set(id, 'online'); } for (var [id, state] of map) { if (this.friends.has(id)) { this.updateFriend(id, state, origin); } else { this.addFriend(id, state); } } for (var id of this.friends.keys()) { if (map.has(id) === false) { this.deleteFriend(id); } } }; $app.methods.addFriend = function (id, state) { if (this.friends.has(id)) { return; } var ref = API.cachedUsers.get(id); var isVIP = API.cachedFavoritesByObjectId.has(id); var ctx = { id, state: state || 'offline', isVIP, ref, name: '', no: ++this.friendsNo, memo: this.loadMemo(id) }; if (typeof ref === 'undefined') { ref = this.friendLog[id]; if (typeof ref !== 'undefined' && ref.displayName) { ctx.name = ref.displayName; } } else { ctx.name = ref.name; } this.friends.set(id, ctx); if (ctx.state === 'online') { if (ctx.isVIP) { this.sortFriendsGroup0 = true; this.friendsGroup0_.push(ctx); this.friendsGroupA_.unshift(ctx); } else { this.sortFriendsGroup1 = true; this.friendsGroup1_.push(ctx); this.friendsGroupB_.unshift(ctx); } } else if (ctx.state === 'active') { this.sortFriendsGroup2 = true; this.friendsGroup2_.push(ctx); this.friendsGroupC_.unshift(ctx); } else { this.sortFriendsGroup3 = true; this.friendsGroup3_.push(ctx); this.friendsGroupD_.unshift(ctx); } }; $app.methods.deleteFriend = function (id) { var ctx = this.friends.get(id); if (typeof ctx === 'undefined') { return; } this.friends.delete(id); if (ctx.state === 'online') { if (ctx.isVIP) { removeFromArray(this.friendsGroup0_, ctx); removeFromArray(this.friendsGroupA_, ctx); } else { removeFromArray(this.friendsGroup1_, ctx); removeFromArray(this.friendsGroupB_, ctx); } } else if (ctx.state === 'active') { removeFromArray(this.friendsGroup2_, ctx); removeFromArray(this.friendsGroupC_, ctx); } else { removeFromArray(this.friendsGroup3_, ctx); removeFromArray(this.friendsGroupD_, ctx); } }; $app.methods.updateFriend = function (id, state, origin) { var ctx = this.friends.get(id); if (typeof ctx === 'undefined') { return; } var ref = API.cachedUsers.get(id); var isVIP = API.cachedFavoritesByObjectId.has(id); if (typeof state === 'undefined' || ctx.state === state) { // this is should be: undefined -> user if (ctx.ref !== ref) { ctx.ref = ref; // NOTE // AddFriend (CurrentUser) 이후, // 서버에서 오는 순서라고 보면 될 듯. if (ctx.state === 'online') { if (ctx.isVIP) { removeFromArray(this.friendsGroupA_, ctx); this.friendsGroupA_.push(ctx); } else { removeFromArray(this.friendsGroupB_, ctx); this.friendsGroupB_.push(ctx); } } else if (ctx.state === 'active') { removeFromArray(this.friendsGroupC_, ctx); this.friendsGroupC_.push(ctx); } else { removeFromArray(this.friendsGroupD_, ctx); this.friendsGroupD_.push(ctx); } } if (ctx.isVIP !== isVIP) { ctx.isVIP = isVIP; if (ctx.state === 'online') { if (ctx.isVIP) { removeFromArray(this.friendsGroup1_, ctx); removeFromArray(this.friendsGroupB_, ctx); this.sortFriendsGroup0 = true; this.friendsGroup0_.push(ctx); this.friendsGroupA_.unshift(ctx); } else { removeFromArray(this.friendsGroup0_, ctx); removeFromArray(this.friendsGroupA_, ctx); this.sortFriendsGroup1 = true; this.friendsGroup1_.push(ctx); this.friendsGroupB_.unshift(ctx); } } } if (typeof ref !== 'undefined' && ctx.name !== ref.displayName) { ctx.name = ref.displayName; if (ctx.state === 'online') { if (ctx.isVIP) { this.sortFriendsGroup0 = true; } else { this.sortFriendsGroup1 = true; } } else if (ctx.state === 'active') { this.sortFriendsGroup2 = true; } else { this.sortFriendsGroup3 = true; } } // FIXME: 도배 가능성 있음 if (origin && ctx.state !== 'online' && typeof ref !== 'undefined' && ref.location !== '' && ref.location !== 'offline') { API.getUser({ userId: id }); } } else { if (ctx.state === 'online') { if (ctx.isVIP) { removeFromArray(this.friendsGroup0_, ctx); removeFromArray(this.friendsGroupA_, ctx); } else { removeFromArray(this.friendsGroup1_, ctx); removeFromArray(this.friendsGroupB_, ctx); } } else if (ctx.state === 'active') { removeFromArray(this.friendsGroup2_, ctx); removeFromArray(this.friendsGroupC_, ctx); } else { removeFromArray(this.friendsGroup3_, ctx); removeFromArray(this.friendsGroupD_, ctx); } // changing property triggers Vue // so, we need compare and set if (ctx.state !== state) { ctx.state = state; } if (ctx.isVIP !== isVIP) { ctx.isVIP = isVIP; } if (typeof ref !== 'undefined') { if (ctx.ref !== ref) { ctx.ref = ref; } if (ctx.name !== ref.displayName) { ctx.name = ref.displayName; } } if (ctx.state === 'online') { if (ctx.isVIP) { this.sortFriendsGroup0 = true; this.friendsGroup0_.push(ctx); this.friendsGroupA_.unshift(ctx); } else { this.sortFriendsGroup1 = true; this.friendsGroup1_.push(ctx); this.friendsGroupB_.unshift(ctx); } } else if (ctx.state === 'active') { this.sortFriendsGroup2 = true; this.friendsGroup2_.push(ctx); this.friendsGroupC_.unshift(ctx); } else { this.sortFriendsGroup3 = true; this.friendsGroup3_.push(ctx); this.friendsGroupD_.unshift(ctx); } if (typeof ctx.ref !== 'undefined') { if ((ctx.ref.$offline_for === '') && ((ctx.state === 'offline') && ctx.ref.state === '') || (((ctx.state === 'offline') || (ctx.state === 'active')) && ((ctx.ref.state === 'online')))) { ctx.ref.$online_for = ''; ctx.ref.$offline_for = Date.now(); } if (ctx.state === 'online') { ctx.ref.$location_at = Date.now(); ctx.ref.$online_for = Date.now(); ctx.ref.$offline_for = ''; } } } }; // ascending var compareByName = function (a, b) { var A = String(a.name).toUpperCase(); var B = String(b.name).toUpperCase(); if (A < B) { return -1; } if (A > B) { return 1; } return 0; }; // descending var compareByUpdatedAt = function (a, b) { var A = String(a.updated_at).toUpperCase(); var B = String(b.updated_at).toUpperCase(); if (A < B) { return 1; } if (A > B) { return -1; } return 0; }; // ascending var compareByDisplayName = function (a, b) { var A = String(a.displayName).toUpperCase(); var B = String(b.displayName).toUpperCase(); if (A < B) { return -1; } if (A > B) { return 1; } return 0; }; // VIP friends $app.computed.friendsGroup0 = function () { if (this.orderFriendsGroup0) { return this.friendsGroupA_; } if (this.sortFriendsGroup0) { this.sortFriendsGroup0 = false; this.friendsGroup0_.sort(compareByName); } return this.friendsGroup0_; }; // Online friends $app.computed.friendsGroup1 = function () { if (this.orderFriendsGroup1) { return this.friendsGroupB_; } if (this.sortFriendsGroup1) { this.sortFriendsGroup1 = false; this.friendsGroup1_.sort(compareByName); } return this.friendsGroup1_; }; // Active friends $app.computed.friendsGroup2 = function () { if (this.orderFriendsGroup2) { return this.friendsGroupC_; } if (this.sortFriendsGroup2) { this.sortFriendsGroup2 = false; this.friendsGroup2_.sort(compareByName); } return this.friendsGroup2_; }; // Offline friends $app.computed.friendsGroup3 = function () { if (this.orderFriendsGroup3) { return this.friendsGroupD_; } if (this.sortFriendsGroup3) { this.sortFriendsGroup3 = false; this.friendsGroup3_.sort(compareByName); } return this.friendsGroup3_; }; $app.methods.userStatusClass = function (user) { var style = {}; if (typeof user !== 'undefined') { if (user.location === 'offline') { // Offline style.offline = true; } else if (user.status === 'active') { // Online style.active = true; } else if (user.status === 'join me') { // Join Me style.joinme = true; } else if (user.status === 'ask me') { // Ask Me style.askme = true; } else if (user.status === 'busy') { // Do Not Disturb style.busy = true; } } return style; }; $app.methods.confirmDeleteFriend = function (id) { this.$confirm('Continue? Unfriend', 'Confirm', { confirmButtonText: 'Confirm', cancelButtonText: 'Cancel', type: 'info', callback: (action) => { if (action === 'confirm') { API.deleteFriend({ userId: id }); } } }); }; // App: Quick Search $app.data.quickSearch = ''; $app.data.quickSearchItems = []; $app.methods.quickSearchRemoteMethod = function (query) { var results = []; if (query) { var QUERY = query.toUpperCase(); for (var ctx of this.friends.values()) { if (typeof ctx.ref === 'undefined') { continue; } var NAME = ctx.name.toUpperCase(); var match = NAME.includes(QUERY); if (!match) { var uname = String(ctx.ref.username); match = uname.toUpperCase().includes(QUERY) && !uname.startsWith('steam_'); } if (!match && ctx.memo) { match = String(ctx.memo).toUpperCase().includes(QUERY); } if (match) { results.push({ value: ctx.id, label: ctx.name, ref: ctx.ref, NAME }); } } results.sort(function (a, b) { var A = a.NAME.startsWith(QUERY); var B = b.NAME.startsWith(QUERY); if (A !== B) { if (A) { return -1; } if (B) { return 1; } } if (a.NAME < b.NAME) { return -1; } if (a.NAME > b.NAME) { return 1; } return 0; }); results.push({ value: `search:${query}`, label: query }); } this.quickSearchItems = results; }; $app.methods.quickSearchChange = function (value) { if (value) { if (value.startsWith('search:')) { this.searchText = value.substr(7); this.search(); this.$refs.menu.activeIndex = 'search'; } else { this.showUserDialog(value); } } }; // NOTE: 그냥 열고 닫고 했을때 changed 이벤트 발생이 안되기 때문에 넣음 $app.methods.quickSearchVisibleChange = function (value) { if (value) { this.quickSearch = ''; } }; // App: Feed $app.data.feedTable = { data: [], filters: [ { prop: 'type', value: [], filterFn: (row, filter) => filter.value.some((v) => v === row.type) }, { prop: 'displayName', value: '' }, { prop: 'userId', value: false, filterFn: (row, filter) => !filter.value || API.cachedFavoritesByObjectId.has(row.userId) } ], tableProps: { stripe: true, size: 'mini', defaultSort: { prop: 'created_at', order: 'descending' } }, pageSize: 10, paginationProps: { small: true, layout: 'sizes,prev,pager,next,total', pageSizes: [ 10, 25, 50, 100 ] } }; API.$on('LOGIN', function (args) { $app.feedTable.data = VRCXStorage.GetArray(`${args.ref.id}_feedTable`); $app.sweepFeed(); }); API.$on('USER:UPDATE', function (args) { var { ref, props } = args; if ($app.friends.has(ref.id) === false) { return; } if (props.location) { if (props.location[0] === 'offline') { $app.addFeed('Offline', ref, { location: props.location[1], time: props.location[2] }); } else if (props.location[1] === 'offline') { $app.addFeed('Online', ref, { location: props.location[0] }); } else { $app.addFeed('GPS', ref, { location: [ props.location[0], props.location[1] ], time: props.location[2] }); } } if (props.currentAvatarImageUrl || props.currentAvatarThumbnailImageUrl) { $app.addFeed('Avatar', ref, { avatar: [ { currentAvatarImageUrl: props.currentAvatarImageUrl ? props.currentAvatarImageUrl[0] : ref.currentAvatarImageUrl, currentAvatarThumbnailImageUrl: props.currentAvatarThumbnailImageUrl ? props.currentAvatarThumbnailImageUrl[0] : ref.currentAvatarThumbnailImageUrl }, { currentAvatarImageUrl: props.currentAvatarImageUrl ? props.currentAvatarImageUrl[1] : ref.currentAvatarImageUrl, currentAvatarThumbnailImageUrl: props.currentAvatarThumbnailImageUrl ? props.currentAvatarThumbnailImageUrl[1] : ref.currentAvatarThumbnailImageUrl } ] }); } if (props.status || props.statusDescription) { $app.addFeed('Status', ref, { status: [ { status: props.status ? props.status[0] : ref.status, statusDescription: props.statusDescription ? props.statusDescription[0] : ref.statusDescription }, { status: props.status ? props.status[1] : ref.status, statusDescription: props.statusDescription ? props.statusDescription[1] : ref.statusDescription } ] }); } }); var saveFeedTimer = null; $app.methods.saveFeed = function () { if (saveFeedTimer !== null) { return; } saveFeedTimer = setTimeout(() => { saveFeedTimer = null; VRCXStorage.SetArray(`${API.currentUser.id}_feedTable`, this.feedTable.data); }, 1); }; $app.methods.addFeed = function (type, ref, extra) { this.feedTable.data.push({ created_at: new Date().toJSON(), type, userId: ref.id, displayName: ref.displayName, ...extra }); this.sweepFeed(); this.saveFeed(); this.updateSharedFeed(false); this.notifyMenu('feed'); }; $app.methods.clearFeed = function () { // FIXME: 메시지 수정 this.$confirm('Continue? Clear Feed', 'Confirm', { confirmButtonText: 'Confirm', cancelButtonText: 'Cancel', type: 'info', callback: (action) => { if (action === 'confirm') { // 필터된 데이터만 삭제 하려면.. 허어 var T = this.feedTable; T.data = T.data.filter((row) => !T.filters.every((filter) => { if (filter.value) { if (!Array.isArray(filter.value)) { if (filter.filterFn) { return filter.filterFn(row, filter); } return String(row[filter.prop]).toUpperCase().includes(String(filter.value).toUpperCase()); } if (filter.value.length) { if (filter.filterFn) { return filter.filterFn(row, filter); } var prop = String(row[filter.prop]).toUpperCase(); return filter.value.some((v) => prop.includes(String(v).toUpperCase())); } } return true; })); } } }); }; $app.methods.sweepFeed = function () { var { data } = this.feedTable; // 로그는 3일까지만 남김 var limit = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toJSON(); var i = 0; var j = data.length; while (i < j && data[i].created_at < limit) { ++i; } if (i === j) { this.feedTable.data = []; } else if (i) { data.splice(0, i); } }; // App: gameLog $app.data.lastLocation = { date: 0, location: '', name: '', playerList: [], friendList: [] }; $app.data.lastLocation$ = {}; $app.data.discordActive = configRepository.getBool('discordActive'); $app.data.discordInstance = configRepository.getBool('discordInstance'); var saveDiscordOption = function () { configRepository.setBool('discordActive', this.discordActive); configRepository.setBool('discordInstance', this.discordInstance); }; $app.watch.discordActive = saveDiscordOption; $app.watch.discordInstance = saveDiscordOption; $app.data.gameLogTable = { data: [], lastEntryDate: '', filters: [ { prop: 'type', value: [], filterFn: (row, filter) => filter.value.some((v) => v === row.type) }, { prop: 'data', value: '' }, { prop: 'data', value: true, filterFn: (row, filter) => row.data !== API.currentUser.displayName } ], tableProps: { stripe: true, size: 'mini', defaultSort: { prop: 'created_at', order: 'descending' } }, pageSize: 10, paginationProps: { small: true, layout: 'sizes,prev,pager,next,total', pageSizes: [ 10, 25, 50, 100 ] } }; $app.methods.resetGameLog = async function () { await gameLogService.reset(); this.gameLogTable.data = []; this.lastLocation = { date: 0, location: '', name: '', playerList: [], friendList: [] }; }; $app.methods.updateGameLogLoop = async function () { try { if (API.isLoggedIn === true) { await this.updateGameLog(); this.sweepGameLog(); var length = this.gameLogTable.data.length; if (length > 0) { if (this.gameLogTable.data[length - 1].created_at !== this.gameLogTable.lastEntryDate) { this.notifyMenu('gameLog'); } this.gameLogTable.lastEntryDate = this.gameLogTable.data[length - 1].created_at; } this.updateSharedFeed(false); } } catch (err) { console.error(err); } setTimeout(() => this.updateGameLogLoop(), 500); }; $app.methods.updateGameLog = async function () { for (var gameLog of await gameLogService.poll()) { var tableData = null; switch (gameLog.type) { case 'location': if (this.isGameRunning) { this.lastLocation = { date: Date.parse(gameLog.dt), location: gameLog.location, name: gameLog.worldName, playerList: [], friendList: [] }; } tableData = { created_at: gameLog.dt, type: 'Location', data: [gameLog.location, gameLog.worldName] }; break; case 'player-joined': tableData = { created_at: gameLog.dt, type: 'OnPlayerJoined', data: gameLog.userDisplayName }; break; case 'player-left': tableData = { created_at: gameLog.dt, type: 'OnPlayerLeft', data: gameLog.userDisplayName }; break; case 'notification': tableData = { created_at: gameLog.dt, type: 'Notification', data: gameLog.json }; break; default: break; } if (tableData !== null) { this.gameLogTable.data.push(tableData); } } }; $app.methods.sweepGameLog = function () { var { data } = this.gameLogTable; // 로그는 7일까지만 남김 var limit = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toJSON(); var i = 0; var j = data.length; while (i < j && data[i].created_at < limit) { ++i; } if (i === j) { this.gameLogTable.data = []; } else if (i) { data.splice(0, i); } }; $app.methods.updateDiscord = function () { var ref = API.cachedUsers.get(API.currentUser.id); if (typeof ref !== 'undefined') { var myLocation = this.lastLocation.location; if (ref.location !== myLocation) { API.applyUser({ id: ref.id, location: myLocation }); } } if (this.isGameRunning === false || this.lastLocation.location === '') { Discord.SetActive(false); return; } if (this.lastLocation.location !== this.lastLocation$.tag) { var L = API.parseLocation(this.lastLocation.location); L.worldName = L.worldId; this.lastLocation$ = L; if (L.worldId) { var ref = API.cachedWorlds.get(L.worldId); if (ref) { L.worldName = ref.name; } else { API.getWorld({ worldId: L.worldId }).then((args) => { L.worldName = args.ref.name; return args; }); } } } // NOTE // 글자 수가 짧으면 업데이트가 안된다.. var LL = this.lastLocation$; if (LL.worldName.length < 2) { LL.worldName += '\uFFA0'.repeat(2 - LL.worldName.length); } if (this.discordInstance) { Discord.SetText(LL.worldName, `#${LL.instanceName} ${LL.accessType}`); } else { Discord.SetText(LL.worldName, ''); } Discord.SetActive(this.discordActive); }; $app.methods.lookupUser = async function (name) { for (var ref of API.cachedUsers.values()) { if (ref.displayName === name) { this.showUserDialog(ref.id); return; } } this.searchText = name; await this.searchUser(); for (var ref of this.searchUserResults) { if (ref.displayName === name) { this.searchText = ''; this.clearSearch(); this.showUserDialog(ref.id); return; } } this.$refs.searchTab.currentName = '0'; this.$refs.menu.activeIndex = 'search'; }; // App: Search $app.data.searchText = ''; $app.data.searchUserResults = []; $app.data.searchUserParams = {}; $app.data.searchWorldResults = []; $app.data.searchWorldOption = ''; $app.data.searchWorldParams = {}; $app.data.searchAvatarResults = []; $app.data.searchAvatarParams = {}; $app.data.isSearchUserLoading = false; $app.data.isSearchWorldLoading = false; $app.data.isSearchAvatarLoading = false; API.$on('LOGIN', function () { $app.searchText = ''; $app.searchUserResults = []; $app.searchUserParams = {}; $app.searchWorldResults = []; $app.searchWorldOption = ''; $app.searchWorldParams = {}; $app.searchAvatarResults = []; $app.searchAvatarParams = {}; $app.isSearchUserLoading = false; $app.isSearchWorldLoading = false; $app.isSearchAvatarLoading = false; }); $app.methods.clearSearch = function () { this.searchUserResults = []; this.searchWorldResults = []; this.searchAvatarResults = []; }; $app.methods.search = function () { this.searchUser(); this.searchWorld({}); }; $app.methods.searchUser = async function () { this.searchUserParams = { n: 10, offset: 0, search: this.searchText }; await this.moreSearchUser(); }; $app.methods.moreSearchUser = async function (go) { var params = this.searchUserParams; if (go) { params.offset += params.n * go; if (params.offset < 0) { params.offset = 0; } } this.isSearchUserLoading = true; await API.getUsers(params).finally(() => { this.isSearchUserLoading = false; }).then((args) => { var map = new Map(); for (var json of args.json) { var ref = API.cachedUsers.get(json.id); if (typeof ref !== 'undefined') { map.set(ref.id, ref); } } this.searchUserResults = Array.from(map.values()); return args; }); }; $app.methods.searchWorld = function (ref) { this.searchWorldOption = ''; var params = { n: 10, offset: 0 }; switch (ref.sortHeading) { case 'featured': params.sort = 'order'; params.featured = 'true'; break; case 'trending': params.sort = 'popularity'; params.featured = 'false'; break; case 'updated': params.sort = 'updated'; break; case 'created': params.sort = 'created'; break; case 'publication': params.sort = 'publicationDate'; break; case 'shuffle': params.sort = 'shuffle'; break; case 'active': this.searchWorldOption = 'active'; break; case 'recent': this.searchWorldOption = 'recent'; break; case 'favorite': this.searchWorldOption = 'favorites'; break; case 'labs': params.sort = 'labsPublicationDate'; break; case 'heat': params.sort = 'heat'; params.featured = 'false'; break; default: params.sort = 'popularity'; params.search = this.searchText; break; } params.order = ref.sortOrder || 'descending'; if (ref.sortOwnership === 'mine') { params.user = 'me'; params.releaseStatus = 'all'; } if (ref.tag) { params.tag = ref.tag; } // TODO: option.platform this.searchWorldParams = params; this.moreSearchWorld(); }; $app.methods.moreSearchWorld = function (go) { var params = this.searchWorldParams; if (go) { params.offset += params.n * go; if (params.offset < 0) { params.offset = 0; } } this.isSearchWorldLoading = true; API.getWorlds(params, this.searchWorldOption).finally(() => { this.isSearchWorldLoading = false; }).then((args) => { var map = new Map(); for (var json of args.json) { var ref = API.cachedWorlds.get(json.id); if (typeof ref !== 'undefined') { map.set(ref.id, ref); } } this.searchWorldResults = Array.from(map.values()); return args; }); }; $app.methods.searchAvatar = function (option) { var params = { n: 10, offset: 0 }; switch (option) { case 'updated': params.sort = 'updated'; break; case 'created': params.sort = 'created'; break; case 'mine': params.user = 'me'; params.releaseStatus = 'all'; break; default: params.sort = 'popularity'; params.search = this.searchText; break; } params.order = 'descending'; // TODO: option.platform this.searchAvatarParams = params; this.moreSearchAvatar(); }; $app.methods.moreSearchAvatar = function (go) { var params = this.searchAvatarParams; if (go) { params.offset += params.n * go; if (params.offset < 0) { params.offset = 0; } } this.isSearchAvatarLoading = true; API.getAvatars(params).finally(() => { this.isSearchAvatarLoading = false; }).then((args) => { var map = new Map(); for (var json of args.json) { var ref = API.cachedAvatars.get(json.id); if (typeof ref !== 'undefined') { map.set(ref.id, ref); } } this.searchAvatarResults = Array.from(map.values()); return args; }); }; // App: Favorite $app.data.favoriteObjects = new Map(); $app.data.favoriteFriends_ = []; $app.data.favoriteWorlds_ = []; $app.data.favoriteAvatars_ = []; $app.data.sortFavoriteFriends = false; $app.data.sortFavoriteWorlds = false; $app.data.sortFavoriteAvatars = false; API.$on('LOGIN', function () { $app.favoriteObjects.clear(); $app.favoriteFriends_ = []; $app.favoriteWorlds_ = []; $app.favoriteAvatars_ = []; $app.sortFavoriteFriends = false; $app.sortFavoriteWorlds = false; $app.sortFavoriteAvatars = false; }); API.$on('FAVORITE', function (args) { $app.applyFavorite(args.ref.type, args.ref.favoriteId); }); API.$on('FAVORITE:@DELETE', function (args) { $app.applyFavorite(args.ref.type, args.ref.favoriteId); }); API.$on('USER', function (args) { $app.applyFavorite('friend', args.ref.id); }); API.$on('WORLD', function (args) { $app.applyFavorite('world', args.ref.id); }); API.$on('AVATAR', function (args) { $app.applyFavorite('avatar', args.ref.id); }); $app.methods.applyFavorite = function (type, objectId) { var favorite = API.cachedFavoritesByObjectId.get(objectId); var ctx = this.favoriteObjects.get(objectId); if (typeof favorite !== 'undefined') { var isTypeChanged = false; if (typeof ctx === 'undefined') { ctx = { id: objectId, type, groupKey: favorite.$groupKey, ref: null, name: '' }; this.favoriteObjects.set(objectId, ctx); if (type === 'friend') { var ref = API.cachedUsers.get(objectId); if (typeof ref === 'undefined') { ref = this.friendLog[objectId]; if (typeof ref !== 'undefined' && ref.displayName) { ctx.name = ref.displayName; } } else { ctx.ref = ref; ctx.name = ref.displayName; } } else if (type === 'world') { var ref = API.cachedWorlds.get(objectId); if (typeof ref !== 'undefined') { ctx.ref = ref; ctx.name = ref.name; } } else if (type === 'avatar') { var ref = API.cachedAvatars.get(objectId); if (typeof ref !== 'undefined') { ctx.ref = ref; ctx.name = ref.name; } } isTypeChanged = true; } else { if (ctx.type !== type) { // WTF??? isTypeChanged = true; if (type === 'friend') { removeFromArray(this.favoriteFriends_, ctx); } else if (type === 'world') { removeFromArray(this.favoriteWorlds_, ctx); } else if (type === 'avatar') { removeFromArray(this.favoriteAvatars_, ctx); } } if (type === 'friend') { var ref = API.cachedUsers.get(objectId); if (typeof ref !== 'undefined') { if (ctx.ref !== ref) { ctx.ref = ref; } if (ctx.name !== ref.displayName) { ctx.name = ref.displayName; this.sortFavoriteFriends = true; } } } else if (type === 'world') { var ref = API.cachedWorlds.get(objectId); if (typeof ref !== 'undefined') { if (ctx.ref !== ref) { ctx.ref = ref; } if (ctx.name !== ref.name) { ctx.name = ref.name; this.sortFavoriteWorlds = true; } } } else if (type === 'avatar') { var ref = API.cachedAvatars.get(objectId); if (typeof ref !== 'undefined') { if (ctx.ref !== ref) { ctx.ref = ref; } if (ctx.name !== ref.name) { ctx.name = ref.name; this.sortFavoriteAvatars = true; } } } } if (isTypeChanged) { if (type === 'friend') { this.favoriteFriends_.push(ctx); this.sortFavoriteFriends = true; } else if (type === 'world') { this.favoriteWorlds_.push(ctx); this.sortFavoriteWorlds = true; } else if (type === 'avatar') { this.favoriteAvatars_.push(ctx); this.sortFavoriteAvatars = true; } } } else if (typeof ctx !== 'undefined') { this.favoriteObjects.delete(objectId); if (type === 'friend') { removeFromArray(this.favoriteFriends_, ctx); } else if (type === 'world') { removeFromArray(this.favoriteWorlds_, ctx); } else if (type === 'avatar') { removeFromArray(this.favoriteAvatars_, ctx); } } }; $app.methods.deleteFavorite = function (objectId) { // FIXME: 메시지 수정 this.$confirm('Continue? Delete Favorite', 'Confirm', { confirmButtonText: 'Confirm', cancelButtonText: 'Cancel', type: 'info', callback: (action) => { if (action === 'confirm') { API.deleteFavorite({ objectId }); } } }); }; $app.methods.changeFavoriteGroupName = function (ctx) { this.$prompt('Enter a new name', 'Change Group Name', { distinguishCancelAndClose: true, cancelButtonText: 'Cancel', confirmButtonText: 'Change', inputPlaceholder: 'Name', inputValue: ctx.displayName, inputPattern: /\S+/, inputErrorMessage: 'Name is required', callback: (action, instance) => { if (action === 'confirm') { API.saveFavoriteGroup({ type: ctx.type, group: ctx.name, displayName: instance.inputValue }).then((args) => { this.$message('Group updated!'); return args; }); } } }); }; $app.methods.clearFavoriteGroup = function (ctx) { // FIXME: 메시지 수정 this.$confirm('Continue? Clear Group', 'Confirm', { confirmButtonText: 'Confirm', cancelButtonText: 'Cancel', type: 'info', callback: (action) => { if (action === 'confirm') { API.clearFavoriteGroup({ type: ctx.type, group: ctx.name }); } } }); }; $app.computed.favoriteFriends = function () { if (this.sortFavoriteFriends) { this.sortFavoriteFriends = false; this.favoriteFriends_.sort(compareByName); } return this.favoriteFriends_; }; $app.computed.favoriteWorlds = function () { if (this.sortFavoriteWorlds) { this.sortFavoriteWorlds = false; this.favoriteWorlds_.sort(compareByName); } return this.favoriteWorlds_; }; $app.computed.favoriteAvatars = function () { if (this.sortFavoriteAvatars) { this.sortFavoriteAvatars = false; this.favoriteAvatars_.sort(compareByName); } return this.favoriteAvatars_; }; // App: friendLog $app.data.friendLog = {}; $app.data.friendLogTable = { data: [], filters: [ { prop: 'type', value: [], filterFn: (row, filter) => filter.value.some((v) => v === row.type) }, { prop: 'displayName', value: '' } ], tableProps: { stripe: true, size: 'mini', defaultSort: { prop: 'created_at', order: 'descending' } }, pageSize: 10, paginationProps: { small: true, layout: 'sizes,prev,pager,next,total', pageSizes: [ 10, 25, 50, 100 ] } }; API.$on('LOGIN', function (args) { $app.initFriendship(args.ref); }); API.$on('USER:CURRENT', function (args) { $app.updateFriendships(args.ref); }); API.$on('USER', function (args) { $app.updateFriendship(args.ref); }); API.$on('FRIEND:ADD', function (args) { $app.addFriendship(args.params.userId); }); API.$on('FRIEND:DELETE', function (args) { $app.deleteFriendship(args.params.userId); }); API.$on('FRIEND:REQUEST', function (args) { var ref = this.cachedUsers.get(args.params.userId); if (typeof ref === 'undefined') { return; } $app.friendLogTable.data.push({ created_at: new Date().toJSON(), type: 'FriendRequest', userId: ref.id, displayName: ref.displayName }); $app.saveFriendLog(); }); API.$on('FRIEND:REQUEST:CANCEL', function (args) { var ref = this.cachedUsers.get(args.params.userId); if (typeof ref === 'undefined') { return; } $app.friendLogTable.data.push({ created_at: new Date().toJSON(), type: 'CancelFriendRequst', userId: ref.id, displayName: ref.displayName }); $app.saveFriendLog(); }); var saveFriendLogTimer = null; $app.methods.saveFriendLog = function () { if (saveFriendLogTimer !== null) { return; } this.updateSharedFeed(true); saveFriendLogTimer = setTimeout(() => { saveFriendLogTimer = null; VRCXStorage.SetObject(`${API.currentUser.id}_friendLog`, this.friendLog); VRCXStorage.SetArray(`${API.currentUser.id}_friendLogTable`, this.friendLogTable.data); VRCXStorage.Set(`${API.currentUser.id}_friendLogUpdatedAt`, new Date().toJSON()); }, 1); }; $app.methods.initFriendship = function (ref) { if (VRCXStorage.Get(`${ref.id}_friendLogUpdatedAt`)) { this.friendLog = VRCXStorage.GetObject(`${ref.id}_friendLog`); this.friendLogTable.data = VRCXStorage.GetArray(`${ref.id}_friendLogTable`); } else { var friendLog = {}; for (var id of ref.friends) { // DO NOT set displayName, // it's flag about it's new friend var ctx = { id }; var user = API.cachedUsers.get(id); if (typeof user !== 'undefined') { ctx.displayName = user.displayName; ctx.trustLevel = user.$trustLevel; } friendLog[id] = ctx; } this.friendLog = friendLog; this.friendLogTable.data = []; this.saveFriendLog(); } }; $app.methods.addFriendship = function (id) { if (typeof this.friendLog[id] !== 'undefined') { return; } var ctx = { id, displayName: null, trustLevel: null }; Vue.set(this.friendLog, id, ctx); var ref = API.cachedUsers.get(id); if (typeof ref !== 'undefined') { ctx.displayName = ref.displayName; ctx.trustLevel = ref.$trustLevel; this.friendLogTable.data.push({ created_at: new Date().toJSON(), type: 'Friend', userId: ref.id, displayName: ctx.displayName }); } this.saveFriendLog(); this.notifyMenu('friendLog'); }; $app.methods.deleteFriendship = function (id) { var ctx = this.friendLog[id]; if (typeof ctx === 'undefined') { return; } Vue.delete(this.friendLog, id); this.friendLogTable.data.push({ created_at: new Date().toJSON(), type: 'Unfriend', userId: id, displayName: ctx.displayName }); this.saveFriendLog(); this.notifyMenu('friendLog'); }; $app.methods.updateFriendships = function (ref) { var set = new Set(); for (var id of ref.friends) { set.add(id); this.addFriendship(id); } for (var id in this.friendLog) { if (set.has(id) === false) { this.deleteFriendship(id); } } }; $app.methods.updateFriendship = function (ref) { var ctx = this.friendLog[ref.id]; if (typeof ctx === 'undefined') { return; } if (ctx.displayName !== ref.displayName) { if (ctx.displayName) { this.friendLogTable.data.push({ created_at: new Date().toJSON(), type: 'DisplayName', userId: ref.id, displayName: ref.displayName, previousDisplayName: ctx.displayName }); } else if (ctx.displayName === null) { this.friendLogTable.data.push({ created_at: new Date().toJSON(), type: 'Friend', userId: ref.id, displayName: ref.displayName }); } ctx.displayName = ref.displayName; this.saveFriendLog(); this.notifyMenu('friendLog'); } if (ref.$trustLevel && ctx.trustLevel !== ref.$trustLevel) { if (ctx.trustLevel) { this.friendLogTable.data.push({ created_at: new Date().toJSON(), type: 'TrustLevel', userId: ref.id, displayName: ref.displayName, trustLevel: ref.$trustLevel, previousTrustLevel: ctx.trustLevel }); } ctx.trustLevel = ref.$trustLevel; this.saveFriendLog(); this.notifyMenu('friendLog'); } }; $app.methods.deleteFriendLog = function (row) { // FIXME: 메시지 수정 this.$confirm('Continue? Delete Log', 'Confirm', { confirmButtonText: 'Confirm', cancelButtonText: 'Cancel', type: 'info', callback: (action) => { if (action === 'confirm' && removeFromArray(this.friendLogTable.data, row)) { this.saveFriendLog(); } } }); }; // App: Moderation $app.data.playerModerationTable = { data: [], lastRunLength: 0, filters: [ { prop: 'type', value: [], filterFn: (row, filter) => filter.value.some((v) => v === row.type) }, { prop: [ 'sourceDisplayName', 'targetDisplayName' ], value: '' } ], tableProps: { stripe: true, size: 'mini', defaultSort: { prop: 'created', order: 'descending' } }, pageSize: 10, paginationProps: { small: true, layout: 'sizes,prev,pager,next,total', pageSizes: [ 10, 25, 50, 100 ] } }; API.$on('LOGIN', function () { $app.playerModerationTable.data = []; }); API.$on('PLAYER-MODERATION', function (args) { var { ref } = args; var array = $app.playerModerationTable.data; var { length } = array; for (var i = 0; i < length; ++i) { if (array[i].id === ref.id) { if (ref.$isDeleted) { array.splice(i, 1); } else { Vue.set(array, i, ref); } return; } } if (ref.$isDeleted === false) { $app.playerModerationTable.data.push(ref); } }); API.$on('PLAYER-MODERATION:@DELETE', function (args) { var { ref } = args; var array = $app.playerModerationTable.data; var { length } = array; for (var i = 0; i < length; ++i) { if (array[i].id === ref.id) { array.splice(i, 1); return; } } }); $app.methods.deletePlayerModeration = function (row) { // FIXME: 메시지 수정 this.$confirm('Continue? Delete Moderation', 'Confirm', { confirmButtonText: 'Confirm', cancelButtonText: 'Cancel', type: 'info', callback: (action) => { if (action === 'confirm') { API.deletePlayerModeration({ moderated: row.targetUserId, type: row.type }); } } }); }; // App: Notification $app.data.notificationTable = { data: [], filters: [ { prop: 'type', value: [], filterFn: (row, filter) => filter.value.some((v) => v === row.type) }, { prop: 'senderUsername', value: '' } ], tableProps: { stripe: true, size: 'mini', defaultSort: { prop: 'created_at', order: 'descending' } }, pageSize: 10, paginationProps: { small: true, layout: 'sizes,prev,pager,next,total', pageSizes: [ 10, 25, 50, 100 ] } }; API.$on('LOGIN', function () { $app.notificationTable.data = []; }); API.$on('NOTIFICATION', function (args) { var { ref } = args; var array = $app.notificationTable.data; var { length } = array; for (var i = 0; i < length; ++i) { if (array[i].id === ref.id) { if (ref.$isDeleted) { array.splice(i, 1); } else { Vue.set(array, i, ref); } return; } } if (ref.$isDeleted === false) { $app.notificationTable.data.push(ref); $app.notifyMenu('notification'); } $app.updateSharedFeed(true); }); API.$on('NOTIFICATION:@DELETE', function (args) { var { ref } = args; var array = $app.notificationTable.data; var { length } = array; for (var i = 0; i < length; ++i) { if (array[i].id === ref.id) { array.splice(i, 1); return; } } }); $app.methods.acceptNotification = function (row) { // FIXME: 메시지 수정 this.$confirm('Continue? Accept Friend Request', 'Confirm', { confirmButtonText: 'Confirm', cancelButtonText: 'Cancel', type: 'info', callback: (action) => { if (action === 'confirm') { API.acceptNotification({ notificationId: row.id }); } } }); }; $app.methods.hideNotification = function (row) { // FIXME: 메시지 수정 this.$confirm('Continue? Delete Notification', 'Confirm', { confirmButtonText: 'Confirm', cancelButtonText: 'Cancel', type: 'info', callback: (action) => { if (action === 'confirm') { API.hideNotification({ notificationId: row.id }); } } }); }; // App: Profile + Settings $app.data.configTreeData = []; $app.data.currentUserTreeData = []; $app.data.pastDisplayNameTable = { data: [], tableProps: { stripe: true, size: 'mini', defaultSort: { prop: 'updated_at', order: 'descending' } }, layout: 'table' }; $app.data.VRCPlusIconsTable = {}; $app.data.inviteMessageTable = { visible: false, data: [], tableProps: { stripe: true, size: 'mini' }, layout: 'table' }; $app.data.inviteResponseMessageTable = { visible: false, data: [], tableProps: { stripe: true, size: 'mini' }, layout: 'table' }; $app.data.inviteRequestMessageTable = { visible: false, data: [], tableProps: { stripe: true, size: 'mini' }, layout: 'table' }; $app.data.inviteRequestResponseMessageTable = { visible: false, data: [], tableProps: { stripe: true, size: 'mini' }, layout: 'table' }; $app.data.visits = 0; $app.data.openVR = configRepository.getBool('openVR'); $app.data.openVRAlways = configRepository.getBool('openVRAlways'); $app.data.overlaybutton = configRepository.getBool('VRCX_overlaybutton'); $app.data.hidePrivateFromFeed = configRepository.getBool('VRCX_hidePrivateFromFeed'); $app.data.hideOnPlayerJoined = configRepository.getBool('VRCX_hideOnPlayerJoined'); $app.data.hideDevicesFromFeed = configRepository.getBool('VRCX_hideDevicesFromFeed'); $app.data.overlayNotifications = configRepository.getBool('VRCX_overlayNotifications'); $app.data.desktopToast = configRepository.getString('VRCX_desktopToast'); $app.data.minimalFeed = configRepository.getBool('VRCX_minimalFeed'); $app.data.displayVRCPlusIconsAsAvatar = configRepository.getBool('displayVRCPlusIconsAsAvatar'); $app.data.notificationTTS = configRepository.getBool('VRCX_notificationTTS'); $app.data.notificationTTSVoice = configRepository.getString('VRCX_notificationTTSVoice'); $app.data.notificationTimeout = configRepository.getString('VRCX_notificationTimeout'); var saveOpenVROption = function () { configRepository.setBool('openVR', this.openVR); configRepository.setBool('openVRAlways', this.openVRAlways); configRepository.setBool('VRCX_overlaybutton', this.overlaybutton); configRepository.setBool('VRCX_hidePrivateFromFeed', this.hidePrivateFromFeed); configRepository.setBool('VRCX_hideOnPlayerJoined', this.hideOnPlayerJoined); configRepository.setBool('VRCX_hideDevicesFromFeed', this.hideDevicesFromFeed); configRepository.setBool('VRCX_overlayNotifications', this.overlayNotifications); configRepository.setString('VRCX_desktopToast', this.desktopToast); configRepository.setBool('VRCX_minimalFeed', this.minimalFeed); configRepository.setBool('displayVRCPlusIconsAsAvatar', this.displayVRCPlusIconsAsAvatar); this.updateVRConfigVars(); }; $app.data.TTSvoices = speechSynthesis.getVoices(); var saveNotificationTTS = function () { configRepository.setBool('VRCX_notificationTTS', this.notificationTTS); speechSynthesis.cancel(); if (this.notificationTTS) { this.speak('Notification text-to-speech enabled'); } this.updateVRConfigVars(); }; $app.watch.openVR = saveOpenVROption; $app.watch.openVRAlways = saveOpenVROption; $app.watch.overlaybutton = saveOpenVROption; $app.watch.hidePrivateFromFeed = saveOpenVROption; $app.watch.hideOnPlayerJoined = saveOpenVROption; $app.watch.hideDevicesFromFeed = saveOpenVROption; $app.watch.overlayNotifications = saveOpenVROption; $app.watch.desktopToast = saveOpenVROption; $app.watch.minimalFeed = saveOpenVROption; $app.watch.displayVRCPlusIconsAsAvatar = saveOpenVROption; $app.watch.notificationTTS = saveNotificationTTS; $app.data.isDarkMode = configRepository.getBool('isDarkMode'); $appDarkStyle.disabled = $app.data.isDarkMode === false; $app.watch.isDarkMode = function () { configRepository.setBool('isDarkMode', this.isDarkMode); $appDarkStyle.disabled = this.isDarkMode === false; this.updateVRConfigVars(); }; $app.data.isStartAtWindowsStartup = configRepository.getBool('VRCX_StartAtWindowsStartup'); $app.data.isStartAsMinimizedState = (VRCXStorage.Get('VRCX_StartAsMinimizedState') === 'true'); $app.data.isCloseToTray = configRepository.getBool('VRCX_CloseToTray'); $app.data.isAutoLogin = configRepository.getBool('VRCX_AutoLogin'); var saveVRCXWindowOption = function () { configRepository.setBool('VRCX_StartAtWindowsStartup', this.isStartAtWindowsStartup); VRCXStorage.Set('VRCX_StartAsMinimizedState', this.isStartAsMinimizedState.toString()); configRepository.setBool('VRCX_CloseToTray', this.isCloseToTray); AppApi.SetStartup(this.isStartAtWindowsStartup); configRepository.setBool('VRCX_AutoLogin', this.isAutoLogin); }; $app.watch.isStartAtWindowsStartup = saveVRCXWindowOption; $app.watch.isStartAsMinimizedState = saveVRCXWindowOption; $app.watch.isCloseToTray = saveVRCXWindowOption; $app.watch.isAutoLogin = saveVRCXWindowOption; // setting defaults if (configRepository.getBool('displayVRCPlusIconsAsAvatar') === null) { $app.data.displayVRCPlusIconsAsAvatar = true; configRepository.setBool('displayVRCPlusIconsAsAvatar', $app.data.displayVRCPlusIconsAsAvatar); } if (!configRepository.getString('VRCX_notificationPosition')) { $app.data.notificationPosition = 'topCenter'; configRepository.setString('VRCX_notificationPosition', $app.data.notificationPosition); } if (!configRepository.getString('VRCX_notificationTimeout')) { $app.data.notificationTimeout = 3000; configRepository.setString('VRCX_notificationTimeout', $app.data.notificationTimeout); } if (!configRepository.getString('VRCX_notificationTTSVoice')) { $app.data.notificationTTSVoice = '0'; configRepository.setString('VRCX_notificationTTSVoice', $app.data.notificationTTSVoice); } if (!configRepository.getString('VRCX_desktopToast')) { $app.data.desktopToast = 'Never'; configRepository.setString('VRCX_desktopToast', $app.data.desktopToast); } if (!configRepository.getString('sharedFeedFilters')) { var sharedFeedFilters = { noty: {}, wrist: {} }; sharedFeedFilters.noty.Location = 'Off'; sharedFeedFilters.noty.OnPlayerJoined = 'VIP'; sharedFeedFilters.noty.OnPlayerLeft = 'VIP'; sharedFeedFilters.noty.OnPlayerJoining = 'Off'; sharedFeedFilters.noty.Online = 'VIP'; sharedFeedFilters.noty.Offline = 'VIP'; sharedFeedFilters.noty.GPS = 'Off'; sharedFeedFilters.noty.Status = 'Off'; sharedFeedFilters.noty.invite = 'Friends'; sharedFeedFilters.noty.requestInvite = 'Friends'; sharedFeedFilters.noty.inviteResponse = 'Friends'; sharedFeedFilters.noty.requestInviteResponse = 'Friends'; sharedFeedFilters.noty.friendRequest = 'On'; sharedFeedFilters.noty.Friend = 'On'; sharedFeedFilters.noty.Unfriend = 'On'; sharedFeedFilters.noty.DisplayName = 'VIP'; sharedFeedFilters.noty.TrustLevel = 'VIP'; sharedFeedFilters.noty.showAvatar = 'On'; sharedFeedFilters.noty.hideAvatar = 'On'; sharedFeedFilters.noty.block = 'On'; sharedFeedFilters.noty.mute = 'On'; sharedFeedFilters.noty.unmute = 'On'; sharedFeedFilters.wrist.Location = 'On'; sharedFeedFilters.wrist.OnPlayerJoined = 'Everyone'; sharedFeedFilters.wrist.OnPlayerLeft = 'Everyone'; sharedFeedFilters.wrist.OnPlayerJoining = 'Friends'; sharedFeedFilters.wrist.Online = 'Friends'; sharedFeedFilters.wrist.Offline = 'Friends'; sharedFeedFilters.wrist.GPS = 'Friends'; sharedFeedFilters.wrist.Status = 'Friends'; sharedFeedFilters.wrist.invite = 'Friends'; sharedFeedFilters.wrist.requestInvite = 'Friends'; sharedFeedFilters.wrist.inviteResponse = 'Friends'; sharedFeedFilters.wrist.requestInviteResponse = 'Friends'; sharedFeedFilters.wrist.friendRequest = 'On'; sharedFeedFilters.wrist.Friend = 'On'; sharedFeedFilters.wrist.Unfriend = 'On'; sharedFeedFilters.wrist.DisplayName = 'Friends'; sharedFeedFilters.wrist.TrustLevel = 'Friends'; sharedFeedFilters.wrist.showAvatar = 'On'; sharedFeedFilters.wrist.hideAvatar = 'On'; sharedFeedFilters.wrist.block = 'On'; sharedFeedFilters.wrist.mute = 'On'; sharedFeedFilters.wrist.unmute = 'On'; configRepository.setString('sharedFeedFilters', JSON.stringify(sharedFeedFilters)); } $app.data.sharedFeedFilters = JSON.parse(configRepository.getString('sharedFeedFilters')); var toggleSwitchLayout = { backgroundColor: 'white', selectedBackgroundColor: '#409eff', selectedColor: 'white', color: '#409eff', borderColor: '#409eff', fontWeight: 'bold', fontFamily: '"Noto Sans JP", "Noto Sans KR", "Meiryo UI", "Malgun Gothic", "Segoe UI", "sans-serif"' }; $app.data.toggleSwitchOptionsEveryone = { layout: toggleSwitchLayout, size: { height: 1.5, width: 15, padding: 0.1, fontSize: 0.75 }, items: { labels: [{ name: 'Off' }, { name: 'VIP' }, { name: 'Friends' }, { name: 'Everyone' }] } }; $app.data.toggleSwitchOptionsFriends = { layout: toggleSwitchLayout, size: { height: 1.5, width: 11.25, padding: 0.1, fontSize: 0.75 }, items: { labels: [{ name: 'Off' }, { name: 'VIP' }, { name: 'Friends' }] } }; $app.data.toggleSwitchOptionsOn = { layout: toggleSwitchLayout, size: { height: 1.5, width: 7.5, padding: 0.1, fontSize: 0.75 }, items: { labels: [{ name: 'Off' }, { name: 'On' }] } }; $app.data.desktopToastToggleSwitchOption = { layout: toggleSwitchLayout, size: { height: 1.5, width: 22, padding: 0.1, fontSize: 0.75 }, items: { labels: [{ name: 'Never' }, { name: 'Desktop Mode' }, { name: 'Game Closed' }, { name: 'Always' }] } }; if (!configRepository.getString('VRCX_trustColor')) { var trustColor = { untrusted: '#CCCCCC', basic: '#1778FF', known: '#2BCF5C', trusted: '#FF7B42', veteran: '#B18FFF', legend: '#FFD000', legendary: '#FF69B4', vip: '#FF2626', troll: '#782F2F' }; configRepository.setString('VRCX_trustColor', JSON.stringify(trustColor)); } $app.data.trustColor = JSON.parse(configRepository.getString('VRCX_trustColor')); $app.data.trustColorSwatches = ['#CCCCCC', '#1778FF', '#2BCF5C', '#FF7B42', '#B18FFF', '#FFD000', '#FF69B4', '#ABCDEF', '#8143E6', '#B52626', '#FF2626', '#782F2F']; $app.methods.updatetrustColor = function () { var trustColor = $app.trustColor; if (trustColor) { configRepository.setString('VRCX_trustColor', JSON.stringify(trustColor)); } else { trustColor = JSON.parse(configRepository.getString('VRCX_trustColor')); $app.trustColor = trustColor; } if (document.getElementById('trustColor') !== null) { document.getElementById('trustColor').outerHTML = ''; } var style = document.createElement('style'); style.id = 'trustColor'; style.type = 'text/css'; var newCSS = ''; for (var rank in trustColor) { newCSS += `.x-tag-${rank} { color: ${trustColor[rank]} !important; border-color: ${trustColor[rank]} !important; } `; } style.innerHTML = newCSS; document.getElementsByTagName('head')[0].appendChild(style); }; $app.methods.updatetrustColor(); $app.watch['trustColor.untrusted'] = $app.methods.updatetrustColor; $app.watch['trustColor.basic'] = $app.methods.updatetrustColor; $app.watch['trustColor.known'] = $app.methods.updatetrustColor; $app.watch['trustColor.trusted'] = $app.methods.updatetrustColor; $app.watch['trustColor.veteran'] = $app.methods.updatetrustColor; $app.watch['trustColor.legend'] = $app.methods.updatetrustColor; $app.watch['trustColor.legendary'] = $app.methods.updatetrustColor; $app.watch['trustColor.vip'] = $app.methods.updatetrustColor; $app.watch['trustColor.troll'] = $app.methods.updatetrustColor; $app.methods.saveSharedFeedFilters = function () { this.notyFeedFiltersDialog.visible = false; this.wristFeedFiltersDialog.visible = false; configRepository.setString('sharedFeedFilters', JSON.stringify(this.sharedFeedFilters)); this.updateVRConfigVars(); }; $app.methods.cancelSharedFeedFilters = function () { this.notyFeedFiltersDialog.visible = false; this.wristFeedFiltersDialog.visible = false; this.sharedFeedFilters = JSON.parse(configRepository.getString('sharedFeedFilters')); }; $app.data.notificationPosition = configRepository.getString('VRCX_notificationPosition'); $app.methods.changeNotificationPosition = function () { configRepository.setString('VRCX_notificationPosition', this.notificationPosition); this.updateVRConfigVars(); }; sharedRepository.setBool('is_game_running', false); var isGameRunningStateChange = function () { sharedRepository.setBool('is_game_running', this.isGameRunning); this.lastLocation = { date: 0, location: '', name: '', playerList: [], friendList: [] }; if (this.isGameRunning) { API.currentUser.$online_for = Date.now(); API.currentUser.$offline_for = ''; } else { API.currentUser.$online_for = ''; API.currentUser.$offline_for = Date.now(); } }; $app.watch.isGameRunning = isGameRunningStateChange; sharedRepository.setBool('is_Game_No_VR', false); var isGameNoVRStateChange = function () { sharedRepository.setBool('is_Game_No_VR', this.isGameNoVR); }; $app.watch.isGameNoVR = isGameNoVRStateChange; var lastLocationStateChange = function () { sharedRepository.setObject('last_location', $app.lastLocation); }; $app.watch['lastLocation.location'] = lastLocationStateChange; $app.methods.updateVRConfigVars = function () { if (configRepository.getBool('isDarkMode')) { var notificationTheme = 'sunset'; } else { var notificationTheme = 'relax'; } var VRConfigVars = { notificationTTS: this.notificationTTS, notificationTTSVoice: this.notificationTTSVoice, overlayNotifications: this.overlayNotifications, desktopToast: this.desktopToast, hideDevicesFromFeed: this.hideDevicesFromFeed, minimalFeed: this.minimalFeed, displayVRCPlusIconsAsAvatar: this.displayVRCPlusIconsAsAvatar, notificationPosition: this.notificationPosition, notificationTimeout: this.notificationTimeout, notificationTheme }; sharedRepository.setObject('VRConfigVars', VRConfigVars); this.updateSharedFeed(true); }; API.$on('LOGIN', function () { $app.updateVRConfigVars(); }); API.$on('LOGIN', function () { $app.currentUserTreeData = []; $app.pastDisplayNameTable.data = []; }); API.$on('USER:CURRENT', function (args) { if (args.ref.pastDisplayNames) { $app.pastDisplayNameTable.data = args.ref.pastDisplayNames; } }); API.$on('VISITS', function (args) { $app.visits = args.json; }); $app.methods.logout = function () { this.$confirm('Continue? Logout', 'Confirm', { confirmButtonText: 'Confirm', cancelButtonText: 'Cancel', type: 'info', callback: (action) => { if (action === 'confirm') { API.logout(); } } }); }; $app.methods.resetHome = function () { this.$confirm('Continue? Reset Home', 'Confirm', { confirmButtonText: 'Confirm', cancelButtonText: 'Cancel', type: 'info', callback: (action) => { if (action === 'confirm') { API.saveCurrentUser({ homeLocation: '' }).then((args) => { this.$message({ message: 'Home world has been reset', type: 'success' }); return args; }); } } }); }; $app.methods.updateOpenVR = function () { if (this.openVR && this.isGameNoVR === false && (this.isGameRunning || this.openVRAlways)) { AppApi.StartVR(); } else { AppApi.StopVR(); } }; $app.methods.changeTTSVoice = function (index) { this.notificationTTSVoice = index; configRepository.setString('VRCX_notificationTTSVoice', this.notificationTTSVoice); var voices = speechSynthesis.getVoices(); var voiceName = voices[index].name; speechSynthesis.cancel(); this.speak(voiceName); this.updateVRConfigVars(); }; $app.methods.speak = function (text) { var tts = new SpeechSynthesisUtterance(); var voices = speechSynthesis.getVoices(); tts.voice = voices[this.notificationTTSVoice]; tts.text = text; speechSynthesis.speak(tts); }; $app.methods.refreshConfigTreeData = function () { this.configTreeData = buildTreeData(API.cachedConfig); }; $app.methods.refreshCurrentUserTreeData = function () { this.currentUserTreeData = buildTreeData(API.currentUser); }; $app.methods.promptUserDialog = function () { this.$prompt('Enter a User ID (UUID)', 'Direct Access', { distinguishCancelAndClose: true, confirmButtonText: 'OK', cancelButtonText: 'Cancel', inputPattern: /\S+/, inputErrorMessage: 'User ID is required', callback: (action, instance) => { if (action === 'confirm' && instance.inputValue) { this.showUserDialog(instance.inputValue); } } }); }; $app.methods.promptWorldDialog = function () { this.$prompt('Enter a World ID (UUID)', 'Direct Access', { distinguishCancelAndClose: true, confirmButtonText: 'OK', cancelButtonText: 'Cancel', inputPattern: /\S+/, inputErrorMessage: 'World ID is required', callback: (action, instance) => { if (action === 'confirm' && instance.inputValue) { this.showWorldDialog(instance.inputValue); } } }); }; $app.methods.promptAvatarDialog = function () { this.$prompt('Enter a Avatar ID (UUID)', 'Direct Access', { distinguishCancelAndClose: true, confirmButtonText: 'OK', cancelButtonText: 'Cancel', inputPattern: /\S+/, inputErrorMessage: 'Avatar ID is required', callback: (action, instance) => { if (action === 'confirm' && instance.inputValue) { this.showAvatarDialog(instance.inputValue); } } }); }; $app.methods.promptNotificationTimeout = function () { this.$prompt('Enter amount of seconds', 'Notification Timeout', { distinguishCancelAndClose: true, confirmButtonText: 'OK', cancelButtonText: 'Cancel', inputValue: this.notificationTimeout / 1000, inputPattern: /\d+$/, inputErrorMessage: 'Valid number is required', callback: (action, instance) => { if (action === 'confirm' && instance.inputValue && !isNaN(instance.inputValue)) { this.notificationTimeout = Math.trunc(Number(instance.inputValue) * 1000); configRepository.setString('VRCX_notificationTimeout', this.notificationTimeout); } } }); }; $app.methods.promptRenameAvatar = function (avatar) { this.$prompt('Enter avatar name', 'Rename Avatar', { distinguishCancelAndClose: true, confirmButtonText: 'OK', cancelButtonText: 'Cancel', inputValue: avatar.ref.name, inputErrorMessage: 'Valid name is required', callback: (action, instance) => { if (action === 'confirm' && instance.inputValue !== avatar.ref.name) { API.saveAvatar({ id: avatar.id, name: instance.inputValue }).then((args) => { this.$message({ message: 'Avatar renamed', type: 'success' }); return args; }); } } }); }; $app.methods.promptChangeDescription = function (avatar) { this.$prompt('Enter avatar description', 'Change Description', { distinguishCancelAndClose: true, confirmButtonText: 'OK', cancelButtonText: 'Cancel', inputValue: avatar.ref.description, inputErrorMessage: 'Valid description is required', callback: (action, instance) => { if (action === 'confirm' && instance.inputValue !== avatar.ref.description) { API.saveAvatar({ id: avatar.id, description: instance.inputValue }).then((args) => { this.$message({ message: 'Avatar description changed', type: 'success' }); return args; }); } } }); }; // App: Dialog var adjustDialogZ = (el) => { var z = 0; document.querySelectorAll('.v-modal,.el-dialog__wrapper').forEach((v) => { var _z = Number(v.style.zIndex) || 0; if (_z && _z > z && v !== el) { z = _z; } }); if (z) { el.style.zIndex = z + 1; } }; // App: User Dialog $app.data.userDialog = { visible: false, loading: false, id: '', ref: {}, friend: {}, isFriend: false, incomingRequest: false, outgoingRequest: false, isBlock: false, isMute: false, isHideAvatar: false, isFavorite: false, $location: {}, users: [], instance: {}, worlds: [], avatars: [], isWorldsLoading: false, isAvatarsLoading: false, worldSorting: 'update', avatarSorting: 'update', avatarReleaseStatus: 'all', treeData: [], memo: '' }; $app.watch['userDialog.memo'] = function () { var D = this.userDialog; this.saveMemo(D.id, D.memo); }; $app.methods.getFaviconUrl = function (resource) { try { var url = new URL(resource); return `https://www.google.com/s2/favicons?domain=${url.origin}`; } catch (err) { return ''; } }; API.$on('LOGOUT', function () { $app.userDialog.visible = false; }); API.$on('USER', function (args) { var { ref } = args; var D = $app.userDialog; if (D.visible === false || D.id !== ref.id) { return; } D.ref = ref; $app.applyUserDialogLocation(); }); API.$on('WORLD', function (args) { var D = $app.userDialog; if (D.visible === false || D.$location.worldId !== args.ref.id) { return; } $app.applyUserDialogLocation(); }); API.$on('FRIEND:STATUS', function (args) { var D = $app.userDialog; if (D.visible === false || D.id !== args.params.userId) { return; } var { json } = args; D.isFriend = json.isFriend; D.incomingRequest = json.incomingRequest; D.outgoingRequest = json.outgoingRequest; }); API.$on('FRIEND:REQUEST', function (args) { var D = $app.userDialog; if (D.visible === false || D.id !== args.params.userId) { return; } if (args.json.success) { D.isFriend = true; } else { D.outgoingRequest = true; } }); API.$on('FRIEND:REQUEST:CANCEL', function (args) { var D = $app.userDialog; if (D.visible === false || D.id !== args.params.userId) { return; } D.outgoingRequest = false; }); API.$on('NOTIFICATION', function (args) { var { ref } = args; var D = $app.userDialog; if (D.visible === false || ref.$isDeleted || ref.type !== 'friendRequest' || ref.senderUserId !== D.id) { return; } D.incomingRequest = true; }); API.$on('NOTIFICATION:ACCEPT', function (args) { var { ref } = args; var D = $app.userDialog; // 얘는 @DELETE가 오고나서 ACCEPT가 옴 // 따라서 $isDeleted라면 ref가 undefined가 됨 if (D.visible === false || typeof ref === 'undefined' || ref.type !== 'friendRequest' || ref.senderUserId !== D.id) { return; } D.isFriend = true; }); API.$on('NOTIFICATION:@DELETE', function (args) { var { ref } = args; var D = $app.userDialog; if (D.visible === false || ref.type !== 'friendRequest' || ref.senderUserId !== D.id) { return; } D.incomingRequest = false; }); API.$on('FRIEND:DELETE', function (args) { var D = $app.userDialog; if (D.visible === false || D.id !== args.params.userId) { return; } D.isFriend = false; }); API.$on('PLAYER-MODERATION:@SEND', function (args) { var { ref } = args; var D = $app.userDialog; if (D.visible === false || ref.$isDeleted || ref.targetUserId !== D.id && ref.sourceUserId !== this.currentUser.id) { return; } if (ref.type === 'block') { D.isBlock = true; } else if (ref.type === 'mute') { D.isMute = true; } else if (ref.type === 'hideAvatar') { D.isHideAvatar = true; } $app.$message({ message: 'User moderated', type: 'success' }); }); API.$on('PLAYER-MODERATION:@DELETE', function (args) { var { ref } = args; var D = $app.userDialog; if (D.visible === false || ref.targetUserId !== D.id || ref.sourceUserId !== this.currentUser.id) { return; } if (ref.type === 'block') { D.isBlock = false; } else if (ref.type === 'mute') { D.isMute = false; } else if (ref.type === 'hideAvatar') { D.isHideAvatar = false; } }); API.$on('FAVORITE', function (args) { var { ref } = args; var D = $app.userDialog; if (D.visible === false || ref.$isDeleted || ref.favoriteId !== D.id) { return; } D.isFavorite = true; }); API.$on('FAVORITE:@DELETE', function (args) { var D = $app.userDialog; if (D.visible === false || D.id !== args.ref.favoriteId) { return; } D.isFavorite = false; }); $app.methods.showUserDialog = function (userId) { this.$nextTick(() => adjustDialogZ(this.$refs.userDialog.$el)); var D = this.userDialog; D.id = userId; D.treeData = []; D.memo = this.loadMemo(userId); D.visible = true; D.loading = true; API.getCachedUser({ userId }).catch((err) => { D.loading = false; D.visible = false; throw err; }).then((args) => { if (args.ref.id === D.id) { D.loading = false; D.ref = args.ref; D.friend = this.friends.get(D.id); D.isFriend = Boolean(D.friend); D.incomingRequest = false; D.outgoingRequest = false; D.isBlock = false; D.isMute = false; D.isHideAvatar = false; for (var ref of API.cachedPlayerModerations.values()) { if (ref.$isDeleted === false && ref.targetUserId === D.id && ref.sourceUserId === API.currentUser.id) { if (ref.type === 'block') { D.isBlock = true; } else if (ref.type === 'mute') { D.isMute = true; } else if (ref.type === 'hideAvatar') { D.isHideAvatar = true; } } } D.isFavorite = API.cachedFavoritesByObjectId.has(D.id); this.applyUserDialogLocation(); var worlds = []; for (var ref of API.cachedWorlds.values()) { if (ref.authorId === D.id) { worlds.push(ref); } } this.setUserDialogWorlds(worlds); var avatars = []; for (var ref of API.cachedAvatars.values()) { if (ref.authorId === D.id) { avatars.push(ref); } } this.setUserDialogAvatars(avatars); D.avatars = avatars; D.isWorldsLoading = false; D.isAvatarsLoading = false; API.getFriendStatus({ userId: D.id }); if (args.cache) { API.getUser(args.params); } } return args; }); }; $app.methods.applyUserDialogLocation = function () { var D = this.userDialog; var L = API.parseLocation(D.ref.location); D.$location = L; if (L.userId) { var ref = API.cachedUsers.get(L.userId); if (typeof ref === 'undefined') { API.getUser({ userId: L.userId }).then((args) => { Vue.set(L, 'user', args.ref); return args; }); } else { L.user = ref; } } var users = []; if (L.isOffline === false) { for (var { ref } of this.friends.values()) { if (typeof ref !== 'undefined' && ref.location === L.tag) { users.push(ref); } } } if (this.isGameRunning && this.lastLocation.location === L.tag) { var ref = API.currentUser; var playersInInstance = this.lastLocation.playerList; if (playersInInstance.includes(ref.displayName)) { users.push(ref); } var friendsInInstance = this.lastLocation.friendList; for (var i = 0; i < friendsInInstance.length; i++) { var addUser = true; var player = friendsInInstance[i]; for (var k = 0; k < users.length; k++) { var user = users[k]; if (user.displayName === player) { addUser = false; break; } } if (addUser) { for (var ref of API.cachedUsers.values()) { if (ref.displayName === player) { users.push(ref); break; } } } } } users.sort(compareByDisplayName); D.users = users; D.instance = {}; if (L.worldId) { var applyInstance = function (instances) { for (var [id, occupants] of instances) { if (id === L.instanceId) { D.instance = { id, occupants }; break; } } }; var ref = API.cachedWorlds.get(L.worldId); if (typeof ref === 'undefined') { API.getWorld({ worldId: L.worldId }).then((args) => { if (args.ref.id === L.worldId) { applyInstance(args.ref.instances); } return true; }); } else { applyInstance(ref.instances); } } }; $app.methods.setUserDialogWorlds = function (array) { var D = this.userDialog; if (D.worldSorting === 'update') { array.sort(compareByUpdatedAt); } else { array.sort(compareByName); } D.worlds = array; }; $app.methods.setUserDialogAvatars = function (array) { var D = this.userDialog; if (D.avatarSorting === 'update') { array.sort(compareByUpdatedAt); } else { array.sort(compareByName); } D.avatars = array; }; $app.methods.refreshUserDialogWorlds = function () { var D = this.userDialog; if (D.isWorldsLoading) { return; } D.isWorldsLoading = true; var params = { n: 50, offset: 0, sort: 'updated', order: 'descending', // user: 'friends', userId: D.id, releaseStatus: 'public' }; if (params.userId === API.currentUser.id) { params.user = 'me'; params.releaseStatus = 'all'; } var map = new Map(); for (var ref of API.cachedWorlds.values()) { if (ref.authorId === D.id) { map.set(ref.id, ref); } } API.bulk({ fn: 'getWorlds', N: -1, params, handle: (args) => { for (var json of args.json) { var $ref = API.cachedWorlds.get(json.id); if (typeof $ref !== 'undefined') { map.set($ref.id, $ref); } } }, done: () => { if (D.id === params.userId) { var array = Array.from(map.values()); this.setUserDialogWorlds(array); } D.isWorldsLoading = false; } }); }; $app.methods.refreshUserDialogAvatars = function () { var D = this.userDialog; if (D.isAvatarsLoading) { return; } D.isAvatarsLoading = true; var params = { n: 50, offset: 0, sort: 'updated', order: 'descending', // user: 'friends', userId: D.id, releaseStatus: 'public' }; if (params.userId === API.currentUser.id) { params.user = 'me'; params.releaseStatus = 'all'; } var map = new Map(); for (var ref of API.cachedAvatars.values()) { if (ref.authorId === D.id) { map.set(ref.id, ref); } } API.bulk({ fn: 'getAvatars', N: -1, params, handle: (args) => { for (var json of args.json) { var $ref = API.cachedAvatars.get(json.id); if (typeof $ref !== 'undefined') { map.set($ref.id, $ref); } } }, done: () => { if (D.id === params.userId) { var array = Array.from(map.values()); this.setUserDialogAvatars(array); } D.isAvatarsLoading = false; } }); }; var performUserDialogCommand = (command, userId) => { switch (command) { case 'Delete Favorite': API.deleteFavorite({ objectId: userId }); break; case 'Accept Friend Request': var key = API.getFriendRequest(userId); if (key === '') { API.sendFriendRequest({ userId }); } else { API.acceptNotification({ notificationId: key }); } break; case 'Decline Friend Request': var key = API.getFriendRequest(userId); if (key === '') { API.cancelFriendRequest({ userId }); } else { API.hideNotification({ notificationId: key }); } break; case 'Cancel Friend Request': API.cancelFriendRequest({ userId }); break; case 'Send Friend Request': API.sendFriendRequest({ userId }); break; case 'Unblock': API.deletePlayerModeration({ moderated: userId, type: 'block' }); break; case 'Block': API.sendPlayerModeration({ moderated: userId, type: 'block' }); break; case 'Unmute': API.deletePlayerModeration({ moderated: userId, type: 'mute' }); break; case 'Mute': API.sendPlayerModeration({ moderated: userId, type: 'mute' }); break; case 'Show Avatar': API.deletePlayerModeration({ moderated: userId, type: 'hideAvatar' }); break; case 'Hide Avatar': API.sendPlayerModeration({ moderated: userId, type: 'hideAvatar' }); break; case 'Unfriend': API.deleteFriend({ userId }); break; default: break; } }; $app.methods.userDialogCommand = function (command) { var D = this.userDialog; if (D.visible === false) { return; } if (command === 'Add Favorite') { this.showFavoriteDialog('friend', D.id); } else if (command === 'Edit Social Status') { this.showSocialStatusDialog(); } else if (command === 'Edit Language') { this.showLanguageDialog(); } else if (command === 'Edit Bio') { this.showBioDialog(); } else if (command === 'Logout') { this.logout(); } else if (command === 'Request Invite') { API.sendRequestInvite({ platform: 'standalonewindows' }, D.id).then((args) => { this.$message('Request invite sent'); return args; }); } else if (command === 'Invite Message') { var L = API.parseLocation(this.lastLocation.location); API.getCachedWorld({ worldId: L.worldId }).then((args) => { this.showSendInviteDialog({ instanceId: this.lastLocation.location, worldId: this.lastLocation.location, worldName: args.ref.name }, D.id); }); } else if (command === 'Request Invite Message') { this.showSendInviteRequestDialog({ platform: 'standalonewindows' }, D.id); } else if (command === 'Invite') { var L = API.parseLocation(this.lastLocation.location); API.getCachedWorld({ worldId: L.worldId }).then((args) => { API.sendInvite({ instanceId: this.lastLocation.location, worldId: this.lastLocation.location, worldName: args.ref.name }, D.id).then((_args) => { this.$message('Invite sent'); return _args; }); }); } else if (command === 'Show Avatar Details') { var { currentAvatarImageUrl } = D.ref; var id = extractFileId(currentAvatarImageUrl); if (id) { API.call(`file/${id}`).then(({ ownerId }) => { for (var ref of API.cachedAvatars.values()) { if (ref.imageUrl === currentAvatarImageUrl) { this.showAvatarDialog(ref.id); return; } } D.loading = true; var params = { n: 50, offset: 0, sort: 'updated', order: 'descending', // user: 'friends', userId: ownerId, releaseStatus: 'public' }; if (params.userId === API.currentUser.id) { params.user = 'me'; params.releaseStatus = 'all'; } API.bulk({ fn: 'getAvatars', N: -1, params, done: () => { D.loading = false; for (var ref2 of API.cachedAvatars.values()) { if (ref2.imageUrl === currentAvatarImageUrl) { this.showAvatarDialog(ref2.id); return; } } if (ownerId === D.id) { this.$message({ message: 'It\'s personal (own) avatar', type: 'warning' }); return; } this.showUserDialog(ownerId); } }); }); } else { this.$message({ message: 'Sorry, the author is unknown', type: 'error' }); } } else if (command === 'Show Fallback Avatar Details') { var { fallbackAvatar } = D.ref; if (fallbackAvatar) { this.showAvatarDialog(fallbackAvatar); } else { this.$message({ message: 'No fallback avatar set', type: 'error' }); } } else { this.$confirm(`Continue? ${command}`, 'Confirm', { confirmButtonText: 'Confirm', cancelButtonText: 'Cancel', type: 'info', callback: (action) => { if (action === 'confirm') { performUserDialogCommand(command, D.id); } } }); } }; $app.methods.refreshUserDialogTreeData = function () { var D = this.userDialog; D.treeData = buildTreeData(D.ref); }; $app.methods.changeUserDialogWorldSorting = function () { var D = this.userDialog; this.setUserDialogWorlds(D.worlds); }; $app.methods.changeUserDialogAvatarSorting = function () { var D = this.userDialog; this.setUserDialogAvatars(D.avatars); }; $app.computed.userDialogAvatars = function () { var { avatars, avatarReleaseStatus } = this.userDialog; if (avatarReleaseStatus === 'public' || avatarReleaseStatus === 'private') { return avatars.filter((avatar) => avatar.releaseStatus === avatarReleaseStatus); } return avatars; }; // App: World Dialog $app.data.worldDialog = { visible: false, loading: false, id: '', $location: {}, ref: {}, isFavorite: false, rooms: [], treeData: [], fileCreatedAt: '', fileSize: '' }; API.$on('LOGOUT', function () { $app.worldDialog.visible = false; }); API.$on('WORLD', function (args) { var { ref } = args; var D = $app.worldDialog; if (D.visible === false || D.id !== ref.id) { return; } D.ref = ref; if (D.fileSize === 'Loading') { var id = extractFileId(ref.assetUrl); if (id) { this.call(`file/${id}`).then(function (json) { var ctx = json.versions[json.versions.length - 1]; D.fileCreatedAt = ctx.created_at; D.fileSize = `${(ctx.file.sizeInBytes / 1048576).toFixed(2)} MiB`; }); } } $app.applyWorldDialogInstances(); }); API.$on('FAVORITE', function (args) { var { ref } = args; var D = $app.worldDialog; if (D.visible === false || ref.$isDeleted || ref.favoriteId !== D.id) { return; } D.isFavorite = true; }); API.$on('FAVORITE:@DELETE', function (args) { var D = $app.worldDialog; if (D.visible === false || D.id !== args.ref.favoriteId) { return; } D.isFavorite = false; }); $app.methods.showWorldDialog = function (tag) { this.$nextTick(() => adjustDialogZ(this.$refs.worldDialog.$el)); var D = this.worldDialog; var L = API.parseLocation(tag); if (L.worldId === '') { return; } D.id = L.worldId; D.$location = L; D.treeData = []; D.fileCreatedAt = ''; D.fileSize = 'Loading'; D.visible = true; D.loading = true; API.getCachedWorld({ worldId: L.worldId }).catch((err) => { D.loading = false; D.visible = false; throw err; }).then((args) => { if (D.id === args.ref.id) { D.loading = false; D.ref = args.ref; D.isFavorite = API.cachedFavoritesByObjectId.has(D.id); D.rooms = []; this.applyWorldDialogInstances(); if (args.cache) { API.getWorld(args.params); } } return args; }); }; $app.methods.applyWorldDialogInstances = function () { var D = this.worldDialog; var instances = {}; for (var [id, occupants] of D.ref.instances) { instances[id] = { id, occupants, users: [] }; } var { instanceId } = D.$location; if (instanceId && typeof instances[instanceId] === 'undefined') { instances[instanceId] = { id: instanceId, occupants: 0, users: [] }; } for (var { ref } of this.friends.values()) { if (typeof ref === 'undefined' || typeof ref.$location === 'undefined' || ref.$location.worldId !== D.id) { continue; } var { instanceId } = ref.$location; var instance = instances[instanceId]; if (typeof instance === 'undefined') { instance = { id: instanceId, occupants: 0, users: [] }; instances[instanceId] = instance; } instance.users.push(ref); } if (this.isGameRunning) { var lastLocation$ = API.parseLocation(this.lastLocation.location); if (lastLocation$.worldId === D.id) { var instance = instances[lastLocation$.instanceId]; if (typeof instance === 'undefined') { instance = { id: lastLocation$.instanceId, occupants: 1, users: [] }; instances[instance.id] = instance; } var ref = API.currentUser; var playersInInstance = this.lastLocation.playerList; if (playersInInstance.includes(ref.displayName)) { instance.users.push(ref); } var friendsInInstance = this.lastLocation.friendList; for (var i = 0; i < friendsInInstance.length; i++) { var addUser = true; var player = friendsInInstance[i]; for (var k = 0; k < instance.users.length; k++) { var user = instance.users[k]; if (user.displayName === player) { addUser = false; break; } } if (addUser) { for (var ref of API.cachedUsers.values()) { if (ref.displayName === player) { instance.users.push(ref); break; } } } } } } var rooms = []; for (var instance of Object.values(instances)) { // due to references on callback of API.getUser() // this should be block scope variable const L = API.parseLocation(`${D.id}:${instance.id}`); instance.location = L.tag; instance.$location = L; if (L.userId) { var ref = API.cachedUsers.get(L.userId); if (typeof ref === 'undefined') { API.getUser({ userId: L.userId }).then((args) => { Vue.set(L, 'user', args.ref); return args; }); } else { L.user = ref; } } instance.users.sort(compareByDisplayName); rooms.push(instance); } // sort by more friends, occupants rooms.sort(function (a, b) { return b.users.length - a.users.length || b.occupants - a.occupants; }); D.rooms = rooms; }; $app.methods.worldDialogCommand = function (command) { var D = this.worldDialog; if (D.visible === false) { return; } if (command === 'Refresh') { D.loading = true; API.getWorld({ worldId: D.id }).catch((err) => { D.loading = false; D.visible = false; throw err; }).then((args) => { if (D.id === args.ref.id) { D.loading = false; D.ref = args.ref; D.isFavorite = API.cachedFavoritesByObjectId.has(D.id); D.rooms = []; this.applyWorldDialogInstances(); if (args.cache) { API.getWorld(args.params); } } return args; }); } else if (command === 'New Instance') { this.showNewInstanceDialog(D.$location.tag); } else if (command === 'Add Favorite') { this.showFavoriteDialog('world', D.id); } else { this.$confirm(`Continue? ${command}`, 'Confirm', { confirmButtonText: 'Confirm', cancelButtonText: 'Cancel', type: 'info', callback: (action) => { if (action !== 'confirm') { return; } switch (command) { case 'Delete Favorite': API.deleteFavorite({ objectId: D.id }); break; case 'Make Home': API.saveCurrentUser({ homeLocation: D.id }).then((args) => { this.$message({ message: 'Home world updated', type: 'success' }); return args; }); break; case 'Reset Home': API.saveCurrentUser({ homeLocation: '' }).then((args) => { this.$message({ message: 'Home world has been reset', type: 'success' }); return args; }); break; default: break; } } }); } }; $app.methods.refreshWorldDialogTreeData = function () { var D = this.worldDialog; D.treeData = buildTreeData(D.ref); }; $app.computed.worldDialogPlatform = function () { var { ref } = this.worldDialog; var platforms = []; if (ref.unityPackages) { for (var unityPackage of ref.unityPackages) { var platform = 'PC'; if (unityPackage.platform === 'standalonewindows') { platform = 'PC'; } else if (unityPackage.platform === 'android') { platform = 'Quest'; } else if (unityPackage.platform) { ({ platform } = unityPackage); } platforms.push(`${platform}/${unityPackage.unityVersion}`); } } return platforms.join(', '); }; // App: Avatar Dialog $app.data.avatarDialog = { visible: false, loading: false, id: '', ref: {}, isFavorite: false, treeData: [], fileCreatedAt: '', fileSize: '' }; API.$on('LOGOUT', function () { $app.avatarDialog.visible = false; }); API.$on('AVATAR', function (args) { var D = $app.avatarDialog; if (D.visible === false || D.id !== args.ref.id) { return; } D.ref = args.ref; if (D.fileSize === 'Loading') { var id = extractFileId(args.ref.assetUrl); if (id) { this.call(`file/${id}`).then((json) => { var ref = json.versions[json.versions.length - 1]; D.fileCreatedAt = ref.created_at; D.fileSize = `${(ref.file.sizeInBytes / 1048576).toFixed(2)} MiB`; }); } } }); API.$on('FAVORITE', function (args) { var { ref } = args; var D = $app.avatarDialog; if (D.visible === false || ref.$isDeleted || ref.favoriteId !== D.id) { return; } D.isFavorite = true; }); API.$on('FAVORITE:@DELETE', function (args) { var D = $app.avatarDialog; if (D.visible === false || D.id !== args.ref.favoriteId) { return; } D.isFavorite = false; }); $app.methods.showAvatarDialog = function (avatarId) { this.$nextTick(() => adjustDialogZ(this.$refs.avatarDialog.$el)); var D = this.avatarDialog; D.id = avatarId; D.treeData = []; D.fileCreatedAt = ''; D.fileSize = 'Loading'; D.visible = true; D.loading = true; API.getCachedAvatar({ avatarId }).catch((err) => { D.loading = false; D.visible = false; throw err; }).then((args) => { if (D.id === args.ref.id) { D.loading = false; D.ref = args.ref; D.isFavorite = API.cachedFavoritesByObjectId.has(D.ref.id); if (args.cache) { API.getAvatar(args.params); } } return args; }); }; $app.methods.avatarDialogCommand = function (command) { var D = this.avatarDialog; if (D.visible === false) { return; } switch (command) { case 'Rename': this.promptRenameAvatar(D); break; case 'Change Description': this.promptChangeDescription(D); break; case 'Add Favorite': this.showFavoriteDialog('avatar', D.id); break; default: this.$confirm(`Continue? ${command}`, 'Confirm', { confirmButtonText: 'Confirm', cancelButtonText: 'Cancel', type: 'info', callback: (action) => { if (action !== 'confirm') { return; } switch (command) { case 'Delete Favorite': API.deleteFavorite({ objectId: D.id }); break; case 'Select Avatar': API.selectAvatar({ avatarId: D.id }).then((args) => { this.$message({ message: 'Avatar changed', type: 'success' }); return args; }); break; case 'Select Fallback Avatar': API.selectFallbackAvatar({ avatarId: D.id }).then((args) => { this.$message({ message: 'Fallback avatar changed', type: 'success' }); return args; }); break; case 'Make Public': API.saveAvatar({ id: D.id, releaseStatus: 'public' }).then((args) => { this.$message({ message: 'Avatar updated to public', type: 'success' }); return args; }); break; case 'Make Private': API.saveAvatar({ id: D.id, releaseStatus: 'private' }).then((args) => { this.$message({ message: 'Avatar updated to private', type: 'success' }); return args; }); break; case 'Delete': API.deleteAvatar({ avatarId: D.id }).then((args) => { this.$message({ message: 'Avatar deleted', type: 'success' }); D.visible = false; return args; }); break; default: break; } } }); break; } }; $app.methods.showAvatarAuthorDialog = function (refUserId, currentAvatarImageUrl) { var id = extractFileId(currentAvatarImageUrl); if (id) { API.call(`file/${id}`).then(({ ownerId }) => { for (var ref of API.cachedAvatars.values()) { if (ref.imageUrl === currentAvatarImageUrl) { this.showAvatarDialog(ref.id); return; } } var params = { n: 50, offset: 0, sort: 'updated', order: 'descending', // user: 'friends', userId: ownerId, releaseStatus: 'public' }; if (params.userId === API.currentUser.id) { params.user = 'me'; params.releaseStatus = 'all'; } API.bulk({ fn: 'getAvatars', N: -1, params, done: () => { for (var ref2 of API.cachedAvatars.values()) { if (ref2.imageUrl === currentAvatarImageUrl) { this.showAvatarDialog(ref2.id); return; } } if (ownerId === refUserId) { this.$message({ message: 'It\'s personal (own) avatar', type: 'warning' }); return; } this.showUserDialog(ownerId); } }); }); } else { this.$message({ message: 'Sorry, the author is unknown', type: 'error' }); } }; $app.methods.refreshAvatarDialogTreeData = function () { var D = this.avatarDialog; D.treeData = buildTreeData(D.ref); }; $app.computed.avatarDialogPlatform = function () { var { ref } = this.avatarDialog; var platforms = []; if (ref.unityPackages) { for (var unityPackage of ref.unityPackages) { var platform = 'PC'; if (unityPackage.platform === 'standalonewindows') { platform = 'PC'; } else if (unityPackage.platform === 'android') { platform = 'Quest'; } else if (unityPackage.platform) { ({ platform } = unityPackage); } platforms.push(`${platform}/${unityPackage.unityVersion}`); } } return platforms.join(', '); }; // App: Favorite Dialog $app.data.favoriteDialog = { visible: false, loading: false, type: '', objectId: '', groups: [] }; API.$on('LOGOUT', function () { $app.favoriteDialog.visible = false; }); $app.methods.addFavorite = function (group) { var D = this.favoriteDialog; D.loading = true; API.addFavorite({ type: D.type, favoriteId: D.objectId, tags: group.name }).finally(() => { D.loading = false; }).then((args) => { D.visible = false; return args; }); }; $app.methods.showFavoriteDialog = function (type, objectId) { this.$nextTick(() => adjustDialogZ(this.$refs.favoriteDialog.$el)); var D = this.favoriteDialog; D.type = type; D.objectId = objectId; if (type === 'friend') { D.groups = API.favoriteFriendGroups; D.visible = true; } else if (type === 'world') { D.groups = API.favoriteWorldGroups; D.visible = true; } else if (type === 'avatar') { D.groups = API.favoriteAvatarGroups; D.visible = true; } }; // App: Invite Dialog $app.data.inviteDialog = { visible: false, loading: false, worldId: '', worldName: '', userIds: [] }; API.$on('LOGOUT', function () { $app.inviteDialog.visible = false; }); $app.methods.sendInvite = function () { this.$confirm('Continue? Invite', 'Confirm', { confirmButtonText: 'Confirm', cancelButtonText: 'Cancel', type: 'info', callback: (action) => { var D = this.inviteDialog; if (action !== 'confirm' || D.loading === true) { return; } if (this.API.currentUser.status === 'busy' && D.userIds.includes(this.API.currentUser.id) === true) { this.$message({ message: 'You can\'t invite yourself in \'Do Not Disturb\' mode', type: 'error' }); return; } D.loading = true; var inviteLoop = () => { if (D.userIds.length > 0) { var receiverUserId = D.userIds.shift(); API.sendInvite({ instanceId: D.worldId, worldId: D.worldId, worldName: D.worldName }, receiverUserId).finally(inviteLoop); } else { D.loading = false; D.visible = false; this.$message({ message: 'Invite sent', type: 'success' }); } }; inviteLoop(); } }); }; $app.methods.showInviteDialog = function (tag) { this.$nextTick(() => adjustDialogZ(this.$refs.inviteDialog.$el)); var L = API.parseLocation(tag); if (L.isOffline || L.isPrivate || L.worldId === '') { return; } API.getCachedWorld({ worldId: L.worldId }).then((args) => { var D = this.inviteDialog; D.userIds = []; D.worldId = L.tag; D.worldName = args.ref.name; D.visible = true; }); }; // App: Social Status Dialog $app.data.socialStatusDialog = { visible: false, loading: false, status: '', statusDescription: '' }; API.$on('LOGOUT', function () { $app.socialStatusDialog.visible = false; }); $app.methods.saveSocialStatus = function () { var D = this.socialStatusDialog; if (D.loading) { return; } D.loading = true; API.saveCurrentUser({ status: D.status, statusDescription: D.statusDescription }).finally(() => { D.loading = false; }).then((args) => { D.visible = false; this.$message({ message: 'Status updated', type: 'success' }); return args; }); }; $app.methods.showSocialStatusDialog = function () { this.$nextTick(() => adjustDialogZ(this.$refs.socialStatusDialog.$el)); var D = this.socialStatusDialog; D.status = API.currentUser.status; D.statusDescription = API.currentUser.statusDescription; D.visible = true; }; // App: Language Dialog $app.data.languageDialog = { visible: false, loading: false, languageChoice: false, languageValue: '', languages: (function () { var data = []; for (var key in subsetOfLanguages) { var value = subsetOfLanguages[key]; data.push({ key, value }); } return data; }()) }; API.$on('LOGOUT', function () { $app.languageDialog.visible = false; }); $app.methods.addUserLanguage = function (language) { if (language !== String(language)) { return; } var D = this.languageDialog; D.loading = true; API.addUserTags({ tags: [`language_${language}`] }).finally(function () { D.loading = false; }); }; $app.methods.removeUserLanguage = function (language) { if (language !== String(language)) { return; } var D = this.languageDialog; D.loading = true; API.removeUserTags({ tags: [`language_${language}`] }).finally(function () { D.loading = false; }); }; $app.methods.showLanguageDialog = function () { this.$nextTick(() => adjustDialogZ(this.$refs.languageDialog.$el)); var D = this.languageDialog; D.visible = true; }; // App: Bio Dialog $app.data.bioDialog = { visible: false, loading: false, bio: '', bioLinks: [] }; API.$on('LOGOUT', function () { $app.bioDialog.visible = false; }); $app.methods.saveBio = function () { var D = this.bioDialog; if (D.loading) { return; } D.loading = true; API.saveCurrentUser({ bio: D.bio, bioLinks: D.bioLinks }).finally(() => { D.loading = false; }).then((args) => { D.visible = false; this.$message({ message: 'Bio updated', type: 'success' }); return args; }); }; $app.methods.showBioDialog = function () { this.$nextTick(() => adjustDialogZ(this.$refs.bioDialog.$el)); var D = this.bioDialog; D.bio = API.currentUser.bio; D.bioLinks = API.currentUser.bioLinks.slice(); D.visible = true; }; // App: New Instance Dialog $app.data.newInstanceDialog = { visible: false, loading: false, worldId: '', instanceId: '', accessType: '', location: '', url: '' }; API.$on('LOGOUT', function () { $app.newInstanceDialog.visible = false; }); $app.methods.buildInstance = function () { var D = this.newInstanceDialog; var tags = []; tags.push((99999 * Math.random() + 1).toFixed(0)); if (D.accessType !== 'public') { if (D.accessType === 'friends+') { tags.push(`~hidden(${API.currentUser.id})`); } else if (D.accessType === 'friends') { tags.push(`~friends(${API.currentUser.id})`); } else { tags.push(`~private(${API.currentUser.id})`); } // NOTE : crypto.getRandomValues()를 쓰면 안전한 대신 무겁겠지.. /* var nonce = []; for (var i = 0; i < 10; ++i) { nonce.push(Math.random().toString(16).substr(2).toUpperCase()); } nonce = nonce.join('').substr(0, 64); */ tags.push(`~nonce(${uuidv4()})`); if (D.accessType === 'invite+') { tags.push('~canRequestInvite'); } } D.instanceId = tags.join(''); }; var getLaunchURL = function (worldId, instanceId) { if (instanceId) { return `https://vrchat.net/launch?worldId=${encodeURIComponent(worldId)}&instanceId=${encodeURIComponent(instanceId)}`; } return `https://vrchat.net/launch?worldId=${encodeURIComponent(worldId)}`; }; var updateLocationURL = function () { var D = this.newInstanceDialog; if (D.instanceId) { D.location = `${D.worldId}:${D.instanceId}`; } else { D.location = D.worldId; } D.url = getLaunchURL(D.worldId, D.instanceId); }; $app.watch['newInstanceDialog.worldId'] = updateLocationURL; $app.watch['newInstanceDialog.instanceId'] = updateLocationURL; $app.methods.showNewInstanceDialog = function (tag) { this.$nextTick(() => adjustDialogZ(this.$refs.newInstanceDialog.$el)); var L = API.parseLocation(tag); if (L.isOffline || L.isPrivate || L.worldId === '') { return; } var D = this.newInstanceDialog; D.worldId = L.worldId; D.accessType = 'public'; this.buildInstance(); D.visible = true; }; $app.methods.makeHome = function (tag) { this.$confirm('Continue? Make Home', 'Confirm', { confirmButtonText: 'Confirm', cancelButtonText: 'Cancel', type: 'info', callback: (action) => { if (action !== 'confirm') { return; } API.saveCurrentUser({ homeLocation: tag }).then((args) => { this.$message({ message: 'Home world updated', type: 'success' }); return args; }); } }); }; // App: Launch Options $app.data.launchArguments = VRCXStorage.Get('launchArguments'); // App: Launch Options Dialog $app.data.launchOptionsDialog = { visible: false, arguments: '' }; API.$on('LOGOUT', function () { $app.launchOptionsDialog.visible = false; }); $app.methods.updateLaunchOptions = function () { var D = this.launchOptionsDialog; D.visible = false; var args = String(D.arguments).replace(/\s+/g, ' ').trim(); this.launchArguments = args; VRCXStorage.Set('launchArguments', args); this.$message({ message: 'updated', type: 'success' }); }; $app.methods.showLaunchOptions = function () { this.$nextTick(() => adjustDialogZ(this.$refs.launchOptionsDialog.$el)); var D = this.launchOptionsDialog; D.arguments = this.launchArguments; D.visible = true; }; // App: Notification position $app.data.notificationPositionDialog = { visible: false }; $app.methods.showNotificationPositionDialog = function () { this.$nextTick(() => adjustDialogZ(this.$refs.notificationPositionDialog.$el)); this.notificationPositionDialog.visible = true; }; // App: Noty feed filters $app.data.notyFeedFiltersDialog = { visible: false }; $app.methods.showNotyFeedFiltersDialog = function () { this.$nextTick(() => adjustDialogZ(this.$refs.notyFeedFiltersDialog.$el)); this.notyFeedFiltersDialog.visible = true; }; // App: Wrist feed filters $app.data.wristFeedFiltersDialog = { visible: false }; $app.methods.showWristFeedFiltersDialog = function () { this.$nextTick(() => adjustDialogZ(this.$refs.wristFeedFiltersDialog.$el)); this.wristFeedFiltersDialog.visible = true; }; // App: Launch Dialog $app.data.launchDialog = { visible: false, loading: false, desktop: configRepository.getBool('launchAsDesktop'), location: '', url: '' }; $app.watch['launchDialog.desktop'] = function () { configRepository.setBool('launchAsDesktop', this.launchDialog.desktop); }; API.$on('LOGOUT', function () { $app.launchDialog.visible = false; }); $app.methods.showLaunchDialog = function (tag) { this.$nextTick(() => adjustDialogZ(this.$refs.launchDialog.$el)); var L = API.parseLocation(tag); if (L.isOffline || L.isPrivate || L.worldId === '') { return; } var D = this.launchDialog; if (L.instanceId) { D.location = `${L.worldId}:${L.instanceId}`; } else { D.location = L.worldId; } D.url = getLaunchURL(L.worldId, L.instanceId); D.visible = true; }; $app.methods.locationToLaunchArg = function (location) { return `vrchat://launch?id=${location}`; }; $app.methods.launchGame = function (...args) { var D = this.launchDialog; if (this.launchArguments) { args.push(this.launchArguments); } if (D.desktop === true) { args.push('--no-vr'); } AppApi.StartGame(args.join(' ')); D.visible = false; }; // App: VRCPlus Icons API.$on('LOGIN', function () { $app.VRCPlusIconsTable = {}; }); $app.methods.displayVRCPlusIconsTable = function () { var params = { n: 50, tag: 'icon' }; API.refreshVRCPlusIconsTableData(params); }; API.refreshVRCPlusIconsTableData = function (params) { return this.call('files', { method: 'GET', params }).then((json) => { var args = { json, params }; this.$emit('VRCPLUSICON:LIST', args); return args; }); }; API.$on('VRCPLUSICON:LIST', function (args) { $app.VRCPlusIconsTable = args.json; }); $app.methods.setVRCPlusIcon = function (userIcon) { if (userIcon !== '') { userIcon = `https://api.vrchat.cloud/api/1/file/${userIcon}/1`; } API.setVRCPlusIcon({ userIcon }).then((args) => { this.$message({ message: 'Icon changed', type: 'success' }); return args; }); }; API.setVRCPlusIcon = function (params) { return this.call(`users/${this.currentUser.id}`, { method: 'PUT', params }).then((json) => { var args = { json, params }; this.$emit('USER:CURRENT:SAVE', args); return args; }); }; $app.methods.deleteVRCPlusIcon = function (userIcon) { API.deleteVRCPlusIcon(userIcon).then((args) => { API.$emit('VRCPLUSICON:DELETE', args); return args; }); }; API.$on('VRCPLUSICON:DELETE', function (args) { var array = $app.VRCPlusIconsTable; var { length } = array; for (var i = 0; i < length; ++i) { if (args.userIcon === array[i].id) { array.splice(i, 1); break; } } }); API.deleteVRCPlusIcon = function (userIcon) { return this.call(`file/${userIcon}`, { method: 'DELETE' }).then((json) => { var args = { json, userIcon }; return args; }); }; $app.methods.compareCurrentVRCPlusIcon = function (userIcon) { try { var url = new URL(API.currentUser.userIcon); var pathArray = url.pathname.split('/'); var currentUserIcon = pathArray[4]; if (userIcon === currentUserIcon) { return true; } } catch (err) { } return false; }; $app.methods.onFileChangeVRCPlusIcon = function (e) { var clearFile = function () { if (document.querySelector('#VRCPlusIconUploadButton')) { document.querySelector('#VRCPlusIconUploadButton').value = ''; } }; var files = e.target.files || e.dataTransfer.files; if (!files.length) { return; } if (files[0].size >= 10485760) { //10MB $app.$message({ message: 'File size too large', type: 'error' }); clearFile(); return; } if (!files[0].type.match(/image.*/)) { $app.$message({ message: 'File isn\'t an image', type: 'error' }); clearFile(); return; } var r = new FileReader(); r.onload = function () { var base64Body = btoa(r.result); API.uploadVRCPlusIcon(base64Body).then((args) => { $app.$message({ message: 'Icon uploaded', type: 'success' }); return args; }); }; r.readAsBinaryString(files[0]); clearFile(); }; $app.methods.displayVRCPlusIconUpload = function () { document.getElementById('VRCPlusIconUploadButton').click(); }; API.uploadVRCPlusIcon = function (params) { return this.call('icon', { uploadImage: true, imageData: params }).then((json) => { var args = { json, params }; this.$emit('VRCPLUSICON:ADD', args); return args; }); }; API.$on('VRCPLUSICON:ADD', function (args) { if (Object.keys($app.VRCPlusIconsTable).length !== 0) { $app.VRCPlusIconsTable.push(args.json); } }); $app.data.uploadImage = ''; $app.methods.inviteImageUpload = function (e) { var files = e.target.files || e.dataTransfer.files; if (!files.length) { return; } if (files[0].size >= 10485760) { //10MB $app.$message({ message: 'File size too large', type: 'error' }); this.clearInviteImageUpload(); return; } if (!files[0].type.match(/image.png/)) { $app.$message({ message: 'File isn\'t a png', type: 'error' }); this.clearInviteImageUpload(); return; } var r = new FileReader(); r.onload = function () { $app.uploadImage = btoa(r.result); }; r.readAsBinaryString(files[0]); }; $app.methods.clearInviteImageUpload = function () { var buttonList = document.querySelectorAll('.inviteImageUploadButton'); buttonList.forEach(button => button.value = ''); this.uploadImage = ''; }; $app.methods.userOnlineFor = function (ctx) { if ((ctx.ref.state === 'online') && (ctx.ref.$online_for)) { return timeToText(Date.now() - ctx.ref.$online_for); } else if (ctx.ref.$offline_for) { return timeToText(Date.now() - ctx.ref.$offline_for); } return '-'; }; // App: Invite Messages API.$on('LOGIN', function () { $app.inviteMessageTable.data = []; $app.inviteResponseMessageTable.data = []; $app.inviteRequestMessageTable.data = []; $app.inviteRequestResponseMessageTable.data = []; $app.inviteMessageTable.visible = false; $app.inviteResponseMessageTable.visible = false; $app.inviteRequestMessageTable.visible = false; $app.inviteRequestResponseMessageTable.visible = false; }); $app.methods.refreshInviteMessageTable = function (messageType) { API.refreshInviteMessageTableData(messageType); } API.refreshInviteMessageTableData = function (messageType) { return this.call(`message/${this.currentUser.id}/${messageType}`, { method: 'GET' }).then((json) => { var args = { json, messageType }; this.$emit(`INVITE:${messageType.toUpperCase()}`, args); return args; }); }; API.$on('INVITE:MESSAGE', function (args) { $app.inviteMessageTable.data = args.json; }); API.$on('INVITE:RESPONSE', function (args) { $app.inviteResponseMessageTable.data = args.json; }); API.$on('INVITE:REQUEST', function (args) { $app.inviteRequestMessageTable.data = args.json; }); API.$on('INVITE:REQUESTRESPONSE', function (args) { $app.inviteRequestResponseMessageTable.data = args.json; }); API.editInviteMessage = function (params, messageType, slot) { return this.call(`message/${this.currentUser.id}/${messageType}/${slot}`, { method: 'PUT', params }).then((json) => { var args = { json, params, messageType, slot }; return args; }); }; // App: Edit Invite Message Dialog $app.data.editInviteMessageDialog = { visible: false, inviteMessage: {}, messageType: '', newMessage: '' }; $app.methods.showEditInviteMessageDialog = function (messageType, inviteMessage) { this.$nextTick(() => adjustDialogZ(this.$refs.editInviteMessageDialog.$el)); var D = this.editInviteMessageDialog; D.newMessage = inviteMessage.message; D.visible = true; D.inviteMessage = inviteMessage; D.messageType = messageType; }; $app.methods.saveEditInviteMessage = function () { var D = this.editInviteMessageDialog; D.visible = false; if (D.inviteMessage.message !== D.newMessage) { var slot = D.inviteMessage.slot; var messageType = D.messageType; var params = { message: D.newMessage }; API.editInviteMessage(params, messageType, slot).catch((err) => { throw err; }).then((args) => { API.$emit(`INVITE:${messageType.toUpperCase()}`, args); if (args.json[slot].message === D.inviteMessage.message) { this.$message({ message: 'VRChat API didn\'t update message, try again', type: 'error' }); throw new Error('VRChat API didn\'t update message, try again'); } else { this.$message('Invite message updated'); } return args; }); } }; $app.methods.cancelEditInviteMessage = function () { this.editInviteMessageDialog.visible = false; }; // App: Edit and Send Invite Response Message Dialog $app.data.editAndSendInviteResponseDialog = { visible: false, inviteMessage: {}, messageType: '', newMessage: '' }; $app.methods.showEditAndSendInviteResponseDialog = function (messageType, inviteMessage) { this.$nextTick(() => adjustDialogZ(this.$refs.editAndSendInviteResponseDialog.$el)); this.editAndSendInviteResponseDialog = { newMessage: inviteMessage.message, visible: true, messageType, inviteMessage }; }; $app.methods.saveEditAndSendInviteResponse = async function () { var D = this.editAndSendInviteResponseDialog; D.visible = false; var messageType = D.messageType; var slot = D.inviteMessage.slot; if (D.inviteMessage.message !== D.newMessage) { var params = { message: D.newMessage }; await API.editInviteMessage(params, messageType, slot).catch((err) => { throw err; }).then((args) => { API.$emit(`INVITE:${messageType.toUpperCase()}`, args); if (args.json[slot].message === D.inviteMessage.message) { this.$message({ message: 'VRChat API didn\'t update message, try again', type: 'error' }); throw new Error('VRChat API didn\'t update message, try again'); } else { this.$message('Invite message updated'); } return args; }); } var I = this.sendInviteResponseDialog; var params = { responseSlot: slot, rsvp: true }; if ($app.uploadImage) { API.sendInviteResponsePhoto(params, I.invite.id).catch((err) => { throw err; }).then((args) => { API.hideNotification({ notificationId: I.invite.id }); this.$message({ message: 'Invite response message sent', type: 'success' }); this.sendInviteResponseDialogVisible = false; this.sendInviteRequestResponseDialogVisible = false; return args; }); } else { API.sendInviteResponse(params, I.invite.id).catch((err) => { throw err; }).then((args) => { API.hideNotification({ notificationId: I.invite.id }); this.$message({ message: 'Invite response message sent', type: 'success' }); this.sendInviteResponseDialogVisible = false; this.sendInviteRequestResponseDialogVisible = false; return args; }); } }; $app.methods.cancelEditAndSendInviteResponse = function () { this.editAndSendInviteResponseDialog.visible = false; }; $app.data.sendInviteResponseDialog = { message: '', messageSlot: 0, invite: {} }; $app.data.sendInviteResponseDialogVisible = false; $app.data.sendInviteResponseConfirmDialog = { visible: false }; API.$on('LOGIN', function () { $app.sendInviteResponseDialogVisible = false; $app.sendInviteResponseConfirmDialog.visible = false; }); $app.methods.showSendInviteResponseDialog = function (invite) { this.sendInviteResponseDialog = { invite }; API.refreshInviteMessageTableData('response'); this.$nextTick(() => adjustDialogZ(this.$refs.sendInviteResponseDialog.$el)); this.clearInviteImageUpload(); this.sendInviteResponseDialogVisible = true; }; $app.methods.showSendInviteResponseConfirmDialog = function (val) { if (this.editAndSendInviteResponseDialog.visible === true || val === null) { return; } this.$nextTick(() => adjustDialogZ(this.$refs.sendInviteResponseConfirmDialog.$el)); this.sendInviteResponseConfirmDialog.visible = true; this.sendInviteResponseDialog.messageSlot = val.slot; }; $app.methods.cancelSendInviteResponse = function () { this.sendInviteResponseDialogVisible = false; }; $app.methods.cancelInviteResponseConfirm = function () { this.sendInviteResponseConfirmDialog.visible = false; }; $app.methods.sendInviteResponseConfirm = function () { var D = this.sendInviteResponseDialog; var params = { responseSlot: D.messageSlot, rsvp: true }; if ($app.uploadImage) { API.sendInviteResponsePhoto(params, D.invite.id, D.messageType).catch((err) => { throw err; }).then((args) => { API.hideNotification({ notificationId: D.invite.id }); this.$message({ message: 'Invite response photo message sent', type: 'success' }); return args; }); } else { API.sendInviteResponse(params, D.invite.id, D.messageType).catch((err) => { throw err; }).then((args) => { API.hideNotification({ notificationId: D.invite.id }); this.$message({ message: 'Invite response message sent', type: 'success' }); return args; }); } this.sendInviteResponseDialogVisible = false; this.sendInviteRequestResponseDialogVisible = false; this.sendInviteResponseConfirmDialog.visible = false; }; // App: Invite Request Response Message Dialog $app.data.sendInviteRequestResponseDialogVisible = false; $app.methods.cancelSendInviteRequestResponse = function () { this.sendInviteRequestResponseDialogVisible = false; }; API.$on('LOGIN', function () { $app.sendInviteRequestResponseDialogVisible = false; $app.showSendInviteResponseConfirmDialog.visible = false; }); $app.methods.showSendInviteRequestResponseDialog = function (invite) { this.sendInviteResponseDialog = { invite }; API.refreshInviteMessageTableData('requestResponse'); this.$nextTick(() => adjustDialogZ(this.$refs.sendInviteRequestResponseDialog.$el)); this.clearInviteImageUpload(); this.sendInviteRequestResponseDialogVisible = true; }; // App: Invite Message Dialog $app.data.editAndSendInviteDialog = { visible: false, messageType: '', newMessage: '', inviteMessage: {} }; $app.methods.showEditAndSendInviteDialog = function (messageType, inviteMessage) { this.$nextTick(() => adjustDialogZ(this.$refs.editAndSendInviteDialog.$el)); this.editAndSendInviteDialog = { newMessage: inviteMessage.message, visible: true, messageType, inviteMessage }; }; $app.methods.saveEditAndSendInvite = async function () { var D = this.editAndSendInviteDialog; D.visible = false; var messageType = D.messageType; var slot = D.inviteMessage.slot; if (D.inviteMessage.message !== D.newMessage) { var params = { message: D.newMessage }; await API.editInviteMessage(params, messageType, slot).catch((err) => { throw err; }).then((args) => { API.$emit(`INVITE:${messageType.toUpperCase()}`, args); if (args.json[slot].message === D.inviteMessage.message) { this.$message({ message: 'VRChat API didn\'t update message, try again', type: 'error' }); throw new Error('VRChat API didn\'t update message, try again'); } else { this.$message('Invite message updated'); } return args; }); } var I = this.sendInviteDialog; var J = this.inviteDialog; if (J.visible) { if (this.API.currentUser.status === 'busy' && J.userIds.includes(this.API.currentUser.id) === true) { this.$message({ message: 'You can\'t invite yourself in \'Do Not Disturb\' mode', type: 'error' }); return; } var inviteLoop = () => { if (J.userIds.length > 0) { var receiverUserId = J.userIds.shift(); if ($app.uploadImage) { API.sendInvitePhoto({ instanceId: J.worldId, worldId: J.worldId, worldName: J.worldName, messageSlot: slot }, receiverUserId).finally(inviteLoop); } else { API.sendInvite({ instanceId: J.worldId, worldId: J.worldId, worldName: J.worldName, messageSlot: slot }, receiverUserId).finally(inviteLoop); } } else { J.loading = false; J.visible = false; this.$message({ message: 'Invite message sent', type: 'success' }); } }; inviteLoop(); } else { if (I.messageType === 'invite') { I.params.messageSlot = slot; if ($app.uploadImage) { API.sendInvitePhoto(I.params, I.userId).catch((err) => { throw err; }).then((args) => { this.$message({ message: 'Invite photo message sent', type: 'success' }); return args; }); } else { API.sendInvite(I.params, I.userId).catch((err) => { throw err; }).then((args) => { this.$message({ message: 'Invite message sent', type: 'success' }); return args; }); } } else if (I.messageType === 'requestInvite') { I.params.requestSlot = slot; if ($app.uploadImage) { API.sendRequestInvitePhoto(I.params, I.userId).catch((err) => { this.clearInviteImageUpload(); throw err; }).then((args) => { this.$message({ message: 'Request invite photo message sent', type: 'success' }); return args; }); } else { API.sendRequestInvite(I.params, I.userId).catch((err) => { throw err; }).then((args) => { this.$message({ message: 'Request invite message sent', type: 'success' }); return args; }); } } } this.sendInviteDialogVisible = false; this.sendInviteRequestDialogVisible = false; }; $app.methods.cancelEditAndSendInvite = function () { this.editAndSendInviteDialog.visible = false; }; $app.data.sendInviteDialog = { message: '', messageSlot: 0, userId: '', messageType: '', params: {} }; $app.data.sendInviteDialogVisible = false; $app.data.sendInviteConfirmDialog = { visible: false }; API.$on('LOGIN', function () { $app.sendInviteDialogVisible = false; $app.sendInviteConfirmDialog.visible = false; }); $app.methods.showSendInviteDialog = function (params, userId) { this.sendInviteDialog = { params, userId, messageType: 'invite' }; API.refreshInviteMessageTableData('message'); this.$nextTick(() => adjustDialogZ(this.$refs.sendInviteDialog.$el)); this.clearInviteImageUpload(); this.sendInviteDialogVisible = true; }; $app.methods.showSendInviteConfirmDialog = function (val) { if (this.editAndSendInviteDialog.visible === true || val === null) { return; } this.$nextTick(() => adjustDialogZ(this.$refs.sendInviteConfirmDialog.$el)); this.sendInviteConfirmDialog.visible = true; this.sendInviteDialog.messageSlot = val.slot; }; $app.methods.cancelSendInvite = function () { this.sendInviteDialogVisible = false; }; $app.methods.cancelInviteConfirm = function () { this.sendInviteConfirmDialog.visible = false; }; $app.methods.sendInviteConfirm = function () { var D = this.sendInviteDialog; var J = this.inviteDialog; if (J.visible) { if (this.API.currentUser.status === 'busy' && J.userIds.includes(this.API.currentUser.id) === true) { this.$message({ message: 'You can\'t invite yourself in \'Do Not Disturb\' mode', type: 'error' }); return; } var inviteLoop = () => { if (J.userIds.length > 0) { var receiverUserId = J.userIds.shift(); if ($app.uploadImage) { API.sendInvitePhoto({ instanceId: J.worldId, worldId: J.worldId, worldName: J.worldName, messageSlot: D.messageSlot }, receiverUserId).finally(inviteLoop); } else { API.sendInvite({ instanceId: J.worldId, worldId: J.worldId, worldName: J.worldName, messageSlot: D.messageSlot }, receiverUserId).finally(inviteLoop); } } else { J.loading = false; J.visible = false; this.$message({ message: 'Invite message sent', type: 'success' }); } }; inviteLoop(); } else { if (D.messageType === 'invite') { D.params.messageSlot = D.messageSlot; if ($app.uploadImage) { API.sendInvitePhoto(D.params, D.userId).catch((err) => { throw err; }).then((args) => { this.$message({ message: 'Invite photo message sent', type: 'success' }); return args; }); } else { API.sendInvite(D.params, D.userId).catch((err) => { throw err; }).then((args) => { this.$message({ message: 'Invite message sent', type: 'success' }); return args; }); } } else if (D.messageType === 'requestInvite') { D.params.requestSlot = D.messageSlot; if ($app.uploadImage) { API.sendRequestInvitePhoto(D.params, D.userId).catch((err) => { this.clearInviteImageUpload(); throw err; }).then((args) => { this.$message({ message: 'Request invite photo message sent', type: 'success' }); return args; }); } else { API.sendRequestInvite(D.params, D.userId).catch((err) => { throw err; }).then((args) => { this.$message({ message: 'Request invite message sent', type: 'success' }); return args; }); } } } this.sendInviteDialogVisible = false; this.sendInviteRequestDialogVisible = false; this.sendInviteConfirmDialog.visible = false; }; // App: Invite Request Message Dialog $app.data.sendInviteRequestDialogVisible = false; $app.methods.cancelSendInviteRequest = function () { this.sendInviteRequestDialogVisible = false; }; API.$on('LOGIN', function () { $app.sendInviteRequestDialogVisible = false; $app.showSendInviteConfirmDialog.visible = false; }); $app.methods.showSendInviteRequestDialog = function (params, userId) { this.sendInviteDialog = { params, userId, messageType: 'requestInvite' }; API.refreshInviteMessageTableData('request'); this.$nextTick(() => adjustDialogZ(this.$refs.sendInviteRequestDialog.$el)); this.clearInviteImageUpload(); this.sendInviteRequestDialogVisible = true; }; $app = new Vue($app); window.$app = $app; }());