diff --git a/html/src/app.dark.scss b/html/src/app.dark.scss index 25c3fd14..2490cb41 100644 --- a/html/src/app.dark.scss +++ b/html/src/app.dark.scss @@ -8,318 +8,318 @@ For a copy, see . */ ::-webkit-scrollbar-track { - background: rgba(255, 255, 255, 0.05); - border-radius: 16px; + background: rgba(255, 255, 255, 0.05); + border-radius: 16px; } ::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.2); - border-radius: 16px; + background: rgba(255, 255, 255, 0.2); + border-radius: 16px; } .el-loading-mask { - background-color: rgba(0, 0, 0, 0.6); + background-color: rgba(0, 0, 0, 0.6); } .el-input__inner { - background-color: #444444; - border: #333333; + background-color: #444444; + border: #333333; } .el-table td, .el-table th.is-leaf { - background-color: #292929; - border-bottom: 1px solid #5f5f5f; + background-color: #292929; + border-bottom: 1px solid #5f5f5f; } .el-table--border::after, .el-table--group::after, .el-table::before { - background-color: #5f5f5f; + background-color: #5f5f5f; } .el-table--striped .el-table__body tr.el-table__row--striped td { - background-color: #202020; + background-color: #202020; } .el-table--enable-row-hover .el-table__body tr:hover>td { - background-color: #323232; + background-color: #323232; } .el-pagination .btn-next, .el-pagination .btn-prev { - background-color: #333333; - color: #bbbbbb; + background-color: #333333; + color: #bbbbbb; } .el-pagination button:disabled { - background-color: #333333; - color: #101010; + background-color: #333333; + color: #101010; } .el-dialog, .el-pager li { - background-color: #333333; + background-color: #333333; } .el-pager li { - color: #bbbbbb; + color: #bbbbbb; } .el-table { - color: #ffffff; + color: #ffffff; } .el-pagination__total { - color: #bbbbbb; + color: #bbbbbb; } .el-tag--plain.el-tag--info { - background-color: #333333; + background-color: #333333; } .el-tag--plain.el-tag--success { - background-color: #333333; + background-color: #333333; } .el-tag--plain.el-tag--success { - background-color: #333333; + background-color: #333333; } .el-button { - color: #c5cad6; + color: #c5cad6; } .el-button:not(.el-button--text) { - background-color: #353535; - border-color: #404040; + background-color: #353535; + border-color: #404040; } .el-button:not(.el-button--text):focus, .el-button:not(.el-button--text):hover { - color: #000000; - border-color: #656565; - background-color: #737373; + color: #000000; + border-color: #656565; + background-color: #737373; } .el-tabs__item { - color: #c2c4ca; + color: #c2c4ca; } .el-tabs--card>.el-tabs__header { - border-bottom-color: #5f5f5f; + border-bottom-color: #5f5f5f; } .el-dropdown-menu { - background-color: #353535; - border-color: #404040; + background-color: #353535; + border-color: #404040; } .el-dropdown-menu__item--divided:before { - background-color: #404040; + background-color: #404040; } .el-dropdown-menu__item { - color: #d4d4d4; + color: #d4d4d4; } .el-dropdown-menu__item:focus, .el-dropdown-menu__item:not(.is-disabled):hover { - background-color: #444444; - color: #66b1ff; + background-color: #444444; + color: #66b1ff; } .el-popper[x-placement^=bottom] .popper__arrow::after { - border-bottom-color: #333333; + border-bottom-color: #333333; } .el-popper[x-placement^=bottom] .popper__arrow { - border-bottom-color: #404040; + border-bottom-color: #404040; } .el-message-box { - background-color: #333333; - border-color: #5f5f5f; + background-color: #333333; + border-color: #5f5f5f; } .el-tree { - background: #202020; - color: #bbbbbb; + background: #202020; + color: #bbbbbb; } .el-menu-item:focus, .el-menu-item:hover { - background-color: #505050; + background-color: #505050; } .el-tabs--card>.el-tabs__header .el-tabs__item { - border-left-color: #5f5f5f; + border-left-color: #5f5f5f; } .el-tabs--card>.el-tabs__header .el-tabs__item.is-active { - border-left-color: #5f5f5f; + border-left-color: #5f5f5f; } .el-tabs--card>.el-tabs__header .el-tabs__nav { - border-color: #5f5f5f; + border-color: #5f5f5f; } .el-collapse-item__header { - background-color: inherit; - color: #d0d0d0; - border-bottom-color: #5f5f5f; + background-color: inherit; + color: #d0d0d0; + border-bottom-color: #5f5f5f; } .el-collapse-item__wrap { - background-color: #333333; - border-bottom-color: #5f5f5f; + background-color: #333333; + border-bottom-color: #5f5f5f; } .el-message-box__title { - color: #909090; + color: #909090; } .el-dialog__title { - color: #909090; + color: #909090; } .el-message-box__content { - color: #a5a7ad; + color: #a5a7ad; } .el-input__inner { - color: #ffffff; + color: #ffffff; } .el-collapse-item__content { - color: #848484; + color: #848484; } .el-switch__core { - background-color: #212121; - border-color: #5f5f5f; + background-color: #212121; + border-color: #5f5f5f; } .el-popover { - background-color: #333333; - border-color: #5f5f5f; + background-color: #333333; + border-color: #5f5f5f; } .el-popper[x-placement^=right] .popper__arrow::after { - border-right-color: #5f5f5f; + border-right-color: #5f5f5f; } .el-popper[x-placement^=right] .popper__arrow { - border-right-color: #5f5f5f; + border-right-color: #5f5f5f; } .el-switch__label { - color: #a0a0a0; + color: #a0a0a0; } .el-table, .el-table__expanded-cell { - background-color: inherit; + background-color: inherit; } .el-tree-node__content:hover { - background-color: #272727; + background-color: #272727; } .el-tree-node:focus>.el-tree-node__content { - background-color: #333333; + background-color: #333333; } .el-select-dropdown { - background-color: #353535; + background-color: #353535; } .el-select-dropdown.is-multiple .el-select-dropdown__item.selected { - background-color: #404040; + background-color: #404040; } .el-select-dropdown.is-multiple .el-select-dropdown__item.selected.hover { - background-color: #404040; + background-color: #404040; } .el-select-dropdown__item.hover, .el-select-dropdown__item:hover { - background-color: #3e3e3e; + background-color: #3e3e3e; } .el-tag.el-tag--info { - background-color: #404040; - border-color: #252525; + background-color: #404040; + border-color: #252525; } .el-table__expanded-cell:hover { - background-color: #323232 !important; + background-color: #323232 !important; } .el-tabs--card>.el-tabs__header .el-tabs__item.is-active { - border-bottom-color: #9c9c9c; + border-bottom-color: #9c9c9c; } .el-dialog__body { - color: #ffffff; + color: #ffffff; } .x-app { - background-color: #101010; + background-color: #101010; } .x-container { - background: #222; + background: #222; } .x-login-container { - background-color: #101010; + background-color: #101010; } .x-aside-container { - background-color: #171717; + background-color: #171717; } .x-friend-list>.x-friend-group { - color: #ffffff; + color: #ffffff; } .x-friend-item:hover { - background: #3e3e3e; + background: #3e3e3e; } .x-friend-item>.avatar.active::after, .x-friend-item>.avatar.joinme::after, .x-friend-item>.avatar.askme::after, .x-friend-item>.avatar.busy::after { - border: 2px solid #000; + border: 2px solid #000; } .x-friend-item>.detail>.name { - color: #ffffff; + color: #ffffff; } .x-friend-item>.detail>.extra { - color: #c7c7c7; + color: #c7c7c7; } .x-friend-item>.detail>.name.x-tag-veteran { - color: rgb(177, 143, 255); + color: rgb(177, 143, 255); } .el-tag.x-tag-veteran { - border-color: rgb(177, 143, 255); - color: rgb(177, 143, 255); + border-color: rgb(177, 143, 255); + color: rgb(177, 143, 255); } .x-friend-item>.detail>.name.x-tag-legendary { - color: rgb(255, 255, 255); + color: rgb(255, 255, 255); } .el-tag.x-tag-legendary { - border-color: rgb(255, 255, 255); - color: rgb(255, 255, 255); + border-color: rgb(255, 255, 255); + color: rgb(255, 255, 255); } .x-user-dialog .el-textarea__inner { - color: #ffffff; + color: #ffffff; } html, body { - background-color: #101010; + background-color: #101010; } body, input, textarea, select, button { - color: #ffffff; + color: #ffffff; } .x-login-container p { - color: #dddddd; + color: #dddddd; } \ No newline at end of file diff --git a/html/src/app.js b/html/src/app.js index 32ce5d41..407537ff 100644 --- a/html/src/app.js +++ b/html/src/app.js @@ -12,6883 +12,6883 @@ import ElementUI from 'element-ui'; import locale from 'element-ui/lib/locale/lang/en'; CefSharp.BindObjectAsync( - 'VRCX', - 'VRCXStorage', - 'SQLite', - 'LogWatcher', - 'Discord' + 'VRCX', + 'VRCXStorage', + 'SQLite', + 'LogWatcher', + 'Discord' ).then(function () { - document.addEventListener('keyup', function (e) { - if (e.ctrlKey) { - if (e.shiftKey && e.code === 'KeyI') { - VRCX.ShowDevTools(); - } else if (e.code === 'KeyR') { - location.reload(); - } - } - }); - - VRCXStorage.GetBool = function (key) { - return this.Get(key) === 'true'; - }; - - VRCXStorage.SetBool = function (key, value) { - this.Set(key, value - ? 'true' - : 'false'); - }; - - VRCXStorage.GetInt = function (key) { - return parseInt(this.Get(key), 10) || 0; - }; - - VRCXStorage.SetInt = function (key, value) { - this.Set(key, String(value)); - }; - - VRCXStorage.GetFloat = function (key) { - return parseFloat(this.Get(key), 10) || 0.0; - }; - - VRCXStorage.SetFloat = function (key, value) { - this.Set(key, String(value)); - }; - - 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: 'animated bounceInLeft', - close: 'animated 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) { - // console.log(name, ...args); - var handlers = this.eventHandlers.get(name); - if (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 (handlers === undefined) { - handlers = []; - this.eventHandlers.set(name, handlers); - } - handlers.push(handler); - }; - - API.$off = function (name, handler) { - var handlers = this.eventHandlers.get(name); - if (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 resource = `https://api.vrchat.cloud/api/1/${endpoint}`; - var init = { - method: 'GET', - mode: 'cors', - credentials: 'include', - cache: 'no-cache', - redirect: 'follow', - referrerPolicy: 'no-referrer', - ...options - }; - var { params } = init; - var isGetRequest = init.method === 'GET'; - if (isGetRequest) { - // transform body to url - if (params === Object(params)) { - var url = new URL(resource); - var { searchParams } = url; - for (var key in params) { - searchParams.set(key, params[key]); - } - resource = url.toString(); - } - // merge requests - var req = this.pendingGetRequests.get(resource); - if (req !== undefined) { - return req; - } - } else { - init.headers = { - 'Content-Type': 'application/json;charset=utf-8', - ...init.headers - }; - init.body = params === Object(params) - ? JSON.stringify(params) - : '{}'; - } - var req = fetch(resource, init).catch((err) => { - this.$throw(0, err); - }).then((res) => res.json().catch(() => { - if (res.ok) { - this.$throw(0, 'Invalid JSON response'); - } - this.$throw(res.status); - }).then((json) => { - if (res.ok) { - if (json.success === Object(json.success)) { - new Noty({ - type: 'success', - text: escapeTag(json.success.message) - }).show(); - } - return json; - } - if (json === Object(json)) { - if (json.error === Object(json.error)) { - this.$throw( - json.error.status_code || res.status, - json.error.message, - json.error.data - ); - } else if (typeof json.error === 'string') { - this.$throw( - json.status_code || res.status, - json.error - ); - } - } - this.$throw(res.status, json); - return json; - })); - if (isGetRequest) { - req.finally(() => { - this.pendingGetRequests.delete(resource); - }); - this.pendingGetRequests.set(resource, 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 (status === undefined) { - text.push(`${code}`); - } else { - text.push(`${code} ${status}`); - } - } - if (error !== undefined) { - text.push(JSON.stringify(error)); - } - if (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 (options.handle !== undefined) { - 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 (options.done !== undefined) { - options.done.call(this, true, options); - } - return args; - }; - - API.bulk = function (options) { - this[options.fn](options.params).catch((err) => { - if (options.done !== undefined) { - 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('location', { - template: '{{ text }}', - props: { - location: String, - 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 (L.worldId) { - var ref = API.cachedWorlds.get(L.worldId); - if (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 () { - VRCX.DeleteAllCookies(); - this.isLoggedIn = false; - }); - - API.$on('USER:CURRENT', function (args) { - args.ref = this.applyCurrentUser(args.json); - }); - - 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 auth = `${params.username}:${params.password}`; - auth = encodeURIComponent(auth); - auth = auth.replace(/%([0-9A-F]{2})/g, (_, s) => String.fromCharCode(parseInt(s, 16))); - auth = auth.replace('%', '%25'); - auth = btoa(auth); - 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 (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); - } - this.applyUserTrustLevel(ref); - this.applyUserLanguage(ref); - } else { - ref = { - id: '', - username: '', - displayName: '', - bio: '', - bioLinks: [], - pastDisplayNames: [], - friends: [], - currentAvatarImageUrl: '', - currentAvatarThumbnailImageUrl: '', - currentAvatar: '', - homeLocation: '', - twoFactorAuthEnabled: false, - status: '', - statusDescription: '', - state: '', - tags: [], - developerType: '', - last_login: '', - last_platform: '', - allowAvatarCopying: false, - friendKey: '', - onlineFriends: [], - activeFriends: [], - offlineFriends: [], - // VRCX - $homeLocation: {}, - $isModerator: false, - $isTroll: false, - $trustLevel: 'Visitor', - $trustClass: 'x-tag-untrusted', - $languages: [], - // - ...json - }; - ref.$homeLocation = this.parseLocation(ref.homeLocation); - this.applyUserTrustLevel(ref); - this.applyUserLanguage(ref); - this.currentUser = ref; - this.isLoggedIn = true; - this.$emit('LOGIN', { - json, - ref - }); - } - VRCXStorage.SetObject('currentUser', ref); - 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); - if (ref === undefined) { - ref = { - id: '', - username: '', - displayName: '', - bio: '', - bioLinks: [], - currentAvatarImageUrl: '', - currentAvatarThumbnailImageUrl: '', - status: '', - statusDescription: '', - state: '', - tags: [], - developerType: '', - last_login: '', - last_platform: '', - allowAvatarCopying: false, - isFriend: false, - friendKey: '', - location: '', - worldId: '', - instanceId: '', - // VRCX - $location: {}, - $location_at: Date.now(), - $isModerator: false, - $isTroll: false, - $trustLevel: 'Visitor', - $trustClass: 'x-tag-untrusted', - $languages: [], - // - ...json - }; - ref.$location = this.parseLocation(ref.location); - 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); - } - 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 (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 (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 (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 (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: 100, - 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:SELECT', function (args) { - this.$emit('USER:CURRENT', args); - }); - - API.applyAvatar = function (json) { - var ref = this.cachedAvatars.get(json.id); - if (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 (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: { - 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; - }); - }; - - // 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 (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 (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 (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: 100, - 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.sendNotification = function (params) { - return this.call(`user/${params.receiverUserId}/notification`, { - method: 'POST', - params - }).then((json) => { - var args = { - json, - params - }; - this.$emit('NOTIFICATION: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(); - this.isPlayerModerationsLoading = false; - }); - - 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) { - this.$emit('PLAYER-MODERATION', { - json: args.json, - params: { - playerModerationId: args.json.id - } - }); - }); - - API.$on('PLAYER-MODERATION:DELETE', function (args) { - var { type, moderated } = args.param; - 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 (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(); - }); - }; - - 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 (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 (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 (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'] - }; - for (var ref of this.cachedFavorites.values()) { - if (ref.$isDeleted) { - continue; - } - var type = types[ref.type]; - if (type === undefined) { - continue; - } - ++type[0]; - } - for (var type in types) { - var [N, fn] = types[type]; - if (N > 0) { - this.bulk({ - fn, - N, - params: { - n: 100, - offset: 0 - } - }); - } - } - }; - - API.refreshFavorites = function () { - if (this.isFavoriteLoading) { - return; - } - this.isFavoriteLoading = true; - this.expireFavorites(); - this.bulk({ - fn: 'getFavorites', - N: -1, - params: { - n: 100, - 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 (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 - }); - } - // 16 = ['avatars1'] x 16 - this.favoriteAvatarGroups = []; - for (var i = 0; i < 1; ++i) { - this.favoriteAvatarGroups.push({ - assign: false, - key: `avatar:avatars${i + 1}`, - type: 'avatar', - name: `avatars${i + 1}`, - displayName: `Group ${i + 1}`, - capacity: 16, - 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 (groups === undefined) { - continue; - } - for (var group of groups) { - if (group.assign === false && - group.name === ref.name) { - group.assign = true; - 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 (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; - 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 (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: 100, - 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': - // it seems to only come when a friend is in a private world :/ - 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); - - // initialise - - var $app = { - data: { - API, - VRCX, - nextRefresh: 0, - isGameRunning: false, - appVersion: 'VRCX 2020.04.07', - latestAppVersion: '', - ossDialog: false - }, - computed: {}, - methods: {}, - watch: {}, - el: '#x-app', - mounted() { - LogWatcher.Reset().then(() => { - API.$on('SHOW_WORLD_DIALOG', (tag) => this.showWorldDialog(tag)); - API.$on('SHOW_LAUNCH_DIALOG', (tag) => this.showLaunchDialog(tag)); - setInterval(() => this.update(), 1000); - this.update(); - 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; - }); - }); - }); - this.checkAppVersion(); - } - }; - - $app.methods.openExternalLink = function (link) { - this.$confirm(`${link}`, 'Open External Link', { - confirmButtonText: 'Confirm', - cancelButtonText: 'Cancel', - type: 'info', - callback: (action) => { - if (action === 'confirm') { - VRCX.OpenLink(link); - } - } - }); - }; - - $app.methods.languageClass = function (language) { - var style = {}; - var mapping = languageMappings[language]; - if (mapping !== undefined) { - style[mapping] = true; - } - return style; - }; - - $app.methods.checkAppVersion = function () { - var url = 'https://api.github.com/repos/pypy-vrc/VRCX/releases/latest'; - fetch(url).then((res) => res.json()).then((json) => { - 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: () => VRCX.OpenLink('https://github.com/pypy-vrc/VRCX/releases') - } - }).show(); - this.notifyMenu('more'); - } - } else { - this.latestAppVersion = 'Error occured'; - } - }); - }; - - $app.methods.update = function () { - if (API.isLoggedIn === false) { - return; - } - if (--this.nextRefresh <= 0) { - this.nextRefresh = 60; - API.getCurrentUser().catch((err1) => { - if (err1.status_code === 401) { - API.getConfig().then((args) => { - API.login({ - username: this.loginForm.username, - password: this.loginForm.password - }).catch((err2) => { - if (err2.status_code === 401) { - API.logout(); - } - throw err2; - }); - return args; - }); - } - throw err1; - }); - } - this.checkActiveFriends(); - this.refreshGameLog(); - VRCX.IsGameRunning().then((running) => { - if (running !== this.isGameRunning) { - this.isGameRunning = running; - Discord.SetTimestamps(Date.now(), 0); - } - this.updateDiscord(); - this.updateOpenVR(); - }); - }; - - $app.methods.updateSharedFeed = function () { - var arr = []; - // FIXME - // 여러 개 켠다면 gameLogTable의 데이터가 시간순이 아닐 수도 있음 - var { data } = this.gameLogTable; - var i = data.length; - var j = 0; - while (j < 25) { - if (i <= 0) { - break; - } - var ctx = data[--i]; - // Location, OnPlayerJoined, OnPlayerLeft - if (ctx.type) { - // FIXME: 이거 존나 느릴거 같은데 - 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; - } - } - arr.push({ - ...ctx, - isFriend, - isFavorite - }); - } else { - arr.push(ctx); - } - ++j; - } - var { data } = this.feedTable; - var i = data.length; - var j = 0; - while (j < 25) { - if (i <= 0) { - break; - } - var ctx = data[--i]; - // GPS, Online, Offline, Status, Avatar - if (ctx.type !== 'Avatar') { - arr.push({ - ...ctx, - isFriend: this.friends.has(ctx.userId), - isFavorite: API.cachedFavoritesByObjectId.has(ctx.userId) - }); - ++j; - } - } - arr.sort(function (a, b) { - if (a.created_at < b.created_at) { - return 1; - } - if (a.created_at > b.created_at) { - return -1; - } - return 0; - }); - if (arr.length > 25) { - arr.length = 25; - } - VRCXStorage.SetArray('sharedFeeds', arr); - }; - - $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(); - } - } - }); - }; - - 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.data.loginForm = { - loading: true, - username: '', - password: '', - 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 - }).finally(() => { - this.loginForm.loading = false; - }); - return args; - }); - } - }); - }; - - $app.methods.loginWithSteam = function () { - if (!this.loginForm.loading) { - this.loginForm.loading = true; - VRCX.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.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 = VRCXStorage.GetBool('orderFriendGroup0'); - $app.data.orderFriendsGroup1 = VRCXStorage.GetBool('orderFriendGroup1'); - $app.data.orderFriendsGroup2 = VRCXStorage.GetBool('orderFriendGroup2'); - $app.data.orderFriendsGroup3 = VRCXStorage.GetBool('orderFriendGroup3'); - var saveOrderFriendGroup = function () { - VRCXStorage.SetBool('orderFriendGroup0', this.orderFriendsGroup0); - VRCXStorage.SetBool('orderFriendGroup1', this.orderFriendsGroup1); - VRCXStorage.SetBool('orderFriendGroup2', this.orderFriendsGroup2); - VRCXStorage.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 (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); - } - } - // called from API.login(), API.loginWithSteam(), API.getCurrentUser() - if (origin) { - API.refreshFriends(); - } - }; - - $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 (ref === undefined) { - ref = this.friendLog[id]; - if (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 (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 (ctx === undefined) { - return; - } - var ref = API.cachedUsers.get(id); - var isVIP = API.cachedFavoritesByObjectId.has(id); - if (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 (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' && - 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 (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); - } - } - }; - - // 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 (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 (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; - }); - if (results.length > 4) { - results.length = 4; - } - 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.currentAvatarThumbnailImageUrl) { - $app.addFeed('Avatar', ref, { - avatar: props.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.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 = ''; - $app.data.lastLocation$ = {}; - $app.data.discordActive = VRCXStorage.GetBool('discordActive'); - $app.data.discordInstance = VRCXStorage.GetBool('discordInstance'); - var saveDiscordOption = function () { - VRCXStorage.SetBool('discordActive', this.discordActive); - VRCXStorage.SetBool('discordInstance', this.discordInstance); - }; - $app.watch.discordActive = saveDiscordOption; - $app.watch.discordInstance = saveDiscordOption; - - $app.data.gameLogTable = { - data: [], - filters: [ - { - prop: 'type', - value: [], - filterFn: (row, filter) => filter.value.some((v) => v === row.type) - }, - { - prop: 'data', - 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 - ] - } - }; - - $app.methods.resetGameLog = function () { - LogWatcher.Reset().then(() => { - this.gameLogTable.data = []; - }); - }; - - $app.methods.refreshGameLog = function () { - LogWatcher.Get().then((logs) => { - if (logs.length) { - var { data } = this.gameLogTable; - for (var log of logs) { - var ctx = { - created_at: String(log[0]), - type: String(log[1]), - data: String(log[2]) - }; - if (ctx.type === 'Location') { - var tag = ctx.data; - if (tag.endsWith(':')) { - tag = tag.substr(0, tag.length - 1); - ctx.data = tag; - } - this.lastLocation = tag; - } - data.push(ctx); - } - this.sweepGameLog(); - // sweepGameLog로 기록이 삭제되면 - // 아무 것도 없는데 알림이 떠서 이상함 - if (data.length) { - this.notifyMenu('gameLog'); - } - } - this.updateSharedFeed(); - }); - }; - - $app.methods.sweepGameLog = function () { - var { data } = this.gameLogTable; - // 로그는 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.gameLogTable.data = []; - } else if (i) { - data.splice(0, i); - } - }; - - $app.methods.updateDiscord = function () { - if (this.isGameRunning === false || - this.lastLocation === '') { - Discord.SetActive(false); - return; - } - if (this.lastLocation !== this.lastLocation$.tag) { - var L = API.parseLocation(this.lastLocation); - 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 = function (name) { - for (var ref of API.cachedUsers.values()) { - if (ref.displayName === name) { - this.showUserDialog(ref.id); - return; - } - } - this.searchText = name; - this.search(); - this.$refs.menu.activeIndex = 'search'; - this.$refs.searchTab.currentName = '0'; - }; - - // 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 = function () { - this.searchUserParams = { - n: 10, - offset: 0, - search: this.searchText - }; - this.moreSearchUser(); - }; - - $app.methods.moreSearchUser = function (go) { - var params = this.searchUserParams; - if (go) { - params.offset += params.n * go; - if (params.offset < 0) { - params.offset = 0; - } - } - this.isSearchUserLoading = true; - 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 (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 (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 (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 (favorite !== undefined) { - var isTypeChanged = false; - if (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 (ref === undefined) { - ref = this.friendLog[objectId]; - if (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 (ref !== undefined) { - ctx.ref = ref; - ctx.name = ref.name; - } - } else if (type === 'avatar') { - var ref = API.cachedAvatars.get(objectId); - if (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 (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 (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 (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 (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 (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 (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; - } - 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 (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 (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 (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 (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 (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: [], - 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); - $app.notifyMenu('moderation'); - } - }); - - 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'); - } - }); - - 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: More - - $app.data.userLanguageVisible = 0; - $app.data.userLanguageSelected = ''; - $app.data.userLanguages = (function () { - var data = []; - for (var key in subsetOfLanguages) { - var value = subsetOfLanguages[key]; - data.push({ - key, - value - }); - } - return data; - }()); - $app.data.currentUserTreeData = []; - $app.data.pastDisplayNameTable = { - data: [], - tableProps: { - stripe: true, - size: 'mini', - defaultSort: { - prop: 'updated_at', - order: 'descending' - } - }, - pageSize: 10, - paginationProps: { - small: true, - layout: 'sizes,prev,pager,next,total', - pageSizes: [ - 10, - 25, - 50, - 100 - ] - } - }; - $app.data.visits = 0; - $app.data.openVR = VRCXStorage.GetBool('openVR'); - $app.data.openVRAlways = VRCXStorage.GetBool('openVRAlways'); - var saveOpenVROption = function () { - VRCXStorage.SetBool('openVR', this.openVR); - VRCXStorage.SetBool('openVRAlways', this.openVRAlways); - }; - $app.watch.openVR = saveOpenVROption; - $app.watch.openVRAlways = saveOpenVROption; - $app.data.isDarkMode = VRCXStorage.GetBool('isDarkMode'); - $appDarkStyle.disabled = $app.data.isDarkMode === false; - $app.watch.isDarkMode = function () { - VRCXStorage.SetBool('isDarkMode', this.isDarkMode); - $appDarkStyle.disabled = this.isDarkMode === false; - }; - $app.data.isStartAtWindowsStartup = VRCXStorage.GetBool('VRCX_StartAtWindowsStartup'); - $app.data.isStartAsMinimizedState = VRCXStorage.GetBool('VRCX_StartAsMinimizedState'); - $app.data.isCloseToTray = VRCXStorage.GetBool('VRCX_CloseToTray'); - var saveVRCXWindowOption = function () { - VRCXStorage.SetBool('VRCX_StartAtWindowsStartup', this.isStartAtWindowsStartup); - VRCXStorage.SetBool('VRCX_StartAsMinimizedState', this.isStartAsMinimizedState); - VRCXStorage.SetBool('VRCX_CloseToTray', this.isCloseToTray); - VRCX.SetStartup(this.isStartAtWindowsStartup); - }; - $app.watch.isStartAtWindowsStartup = saveVRCXWindowOption; - $app.watch.isStartAsMinimizedState = saveVRCXWindowOption; - $app.watch.isCloseToTray = saveVRCXWindowOption; - - 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.addUserLanguage = function (language) { - if (language !== String(language)) { - return; - } - API.addUserTags({ - tags: [`language_${language}`] - }); - }; - - $app.methods.removeUserLanguage = function (language) { - if (language !== String(language)) { - return; - } - API.removeUserTags({ - tags: [`language_${language}`] - }); - }; - - $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.isGameRunning || this.openVRAlways)) { - VRCX.StartVR(); - } else { - VRCX.StopVR(); - } - }; - - $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: 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', - - 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 || - 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', 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; - } - }); - - 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 (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 (ref !== undefined && - ref.location === L.tag) { - users.push(ref); - } - } - } - if (this.isGameRunning && - this.lastLocation === L.tag) { - users.push(API.currentUser); - } - 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 (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: 100, - 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 ($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: 100, - 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 ($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 === 'Message') { - this.$prompt('Enter a message', 'Send Message', { - distinguishCancelAndClose: true, - confirmButtonText: 'Send', - cancelButtonText: 'Cancel', - inputPattern: /\S+/, - inputErrorMessage: 'Message is required', - callback: (action, instance) => { - if (action === 'confirm' && - instance.inputValue) { - API.sendNotification({ - receiverUserId: D.id, - type: 'message', - message: instance.inputValue, - seen: false, - details: '{}' - }).then((args) => { - this.$message('Message sent'); - return args; - }); - } - } - }); - } else if (command === 'Show Avatar Author') { - 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: 100, - 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 { - 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: 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 && - instances[instanceId] === undefined) { - instances[instanceId] = { - id: instanceId, - occupants: 0, - users: [] - }; - } - for (var { ref } of this.friends.values()) { - if (ref === undefined || - ref.$location === undefined || - ref.$location.worldId !== D.id) { - continue; - } - var { instanceId } = ref.$location; - var instance = instances[instanceId]; - if (instance === undefined) { - instance = { - id: instanceId, - occupants: 0, - users: [] - }; - instances[instanceId] = instance; - } - instance.users.push(ref); - } - if (this.isGameRunning) { - var lastLocation$ = API.parseLocation(this.lastLocation); - if (lastLocation$.worldId === D.id) { - var instance = instances[lastLocation$.instanceId]; - if (instance === undefined) { - instance = { - id: lastLocation$.instanceId, - occupants: 1, - users: [] - }; - instances[instance.id] = instance; - } - instance.users.push(API.currentUser); - } - } - 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 (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 === '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; - } - if (command === 'Add Favorite') { - this.showFavoriteDialog('avatar', 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 'Select Avatar': - API.selectAvatar({ - avatarId: D.id - }); - break; - default: - break; - } - } - }); - } - }; - - $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 params = { - receiverUserId: '', - type: 'invite', - message: 'This is a generated invite', - seen: false, - details: { - worldId: D.worldId, - worldName: D.worldName - } - }; - var inviteLoop = () => { - if (D.userIds.length > 0) { - params.receiverUserId = D.userIds.shift(); - API.sendNotification(params).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: 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 Dialog - - $app.data.launchDialog = { - visible: false, - loading: false, - desktop: VRCXStorage.GetBool('launchAsDesktop'), - location: '', - url: '' - }; - - $app.watch['launchDialog.desktop'] = function () { - VRCXStorage.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.launchGame = function () { - var D = this.launchDialog; - VRCX.StartGame(D.location, D.desktop); - D.visible = false; - }; - - $app = new Vue($app); - window.$app = $app; + document.addEventListener('keyup', function (e) { + if (e.ctrlKey) { + if (e.shiftKey && e.code === 'KeyI') { + VRCX.ShowDevTools(); + } else if (e.code === 'KeyR') { + location.reload(); + } + } + }); + + VRCXStorage.GetBool = function (key) { + return this.Get(key) === 'true'; + }; + + VRCXStorage.SetBool = function (key, value) { + this.Set(key, value + ? 'true' + : 'false'); + }; + + VRCXStorage.GetInt = function (key) { + return parseInt(this.Get(key), 10) || 0; + }; + + VRCXStorage.SetInt = function (key, value) { + this.Set(key, String(value)); + }; + + VRCXStorage.GetFloat = function (key) { + return parseFloat(this.Get(key), 10) || 0.0; + }; + + VRCXStorage.SetFloat = function (key, value) { + this.Set(key, String(value)); + }; + + 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: 'animated bounceInLeft', + close: 'animated 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) { + // console.log(name, ...args); + var handlers = this.eventHandlers.get(name); + if (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 (handlers === undefined) { + handlers = []; + this.eventHandlers.set(name, handlers); + } + handlers.push(handler); + }; + + API.$off = function (name, handler) { + var handlers = this.eventHandlers.get(name); + if (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 resource = `https://api.vrchat.cloud/api/1/${endpoint}`; + var init = { + method: 'GET', + mode: 'cors', + credentials: 'include', + cache: 'no-cache', + redirect: 'follow', + referrerPolicy: 'no-referrer', + ...options + }; + var { params } = init; + var isGetRequest = init.method === 'GET'; + if (isGetRequest) { + // transform body to url + if (params === Object(params)) { + var url = new URL(resource); + var { searchParams } = url; + for (var key in params) { + searchParams.set(key, params[key]); + } + resource = url.toString(); + } + // merge requests + var req = this.pendingGetRequests.get(resource); + if (req !== undefined) { + return req; + } + } else { + init.headers = { + 'Content-Type': 'application/json;charset=utf-8', + ...init.headers + }; + init.body = params === Object(params) + ? JSON.stringify(params) + : '{}'; + } + var req = fetch(resource, init).catch((err) => { + this.$throw(0, err); + }).then((res) => res.json().catch(() => { + if (res.ok) { + this.$throw(0, 'Invalid JSON response'); + } + this.$throw(res.status); + }).then((json) => { + if (res.ok) { + if (json.success === Object(json.success)) { + new Noty({ + type: 'success', + text: escapeTag(json.success.message) + }).show(); + } + return json; + } + if (json === Object(json)) { + if (json.error === Object(json.error)) { + this.$throw( + json.error.status_code || res.status, + json.error.message, + json.error.data + ); + } else if (typeof json.error === 'string') { + this.$throw( + json.status_code || res.status, + json.error + ); + } + } + this.$throw(res.status, json); + return json; + })); + if (isGetRequest) { + req.finally(() => { + this.pendingGetRequests.delete(resource); + }); + this.pendingGetRequests.set(resource, 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 (status === undefined) { + text.push(`${code}`); + } else { + text.push(`${code} ${status}`); + } + } + if (error !== undefined) { + text.push(JSON.stringify(error)); + } + if (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 (options.handle !== undefined) { + 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 (options.done !== undefined) { + options.done.call(this, true, options); + } + return args; + }; + + API.bulk = function (options) { + this[options.fn](options.params).catch((err) => { + if (options.done !== undefined) { + 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('location', { + template: '{{ text }}', + props: { + location: String, + 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 (L.worldId) { + var ref = API.cachedWorlds.get(L.worldId); + if (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 () { + VRCX.DeleteAllCookies(); + this.isLoggedIn = false; + }); + + API.$on('USER:CURRENT', function (args) { + args.ref = this.applyCurrentUser(args.json); + }); + + 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 auth = `${params.username}:${params.password}`; + auth = encodeURIComponent(auth); + auth = auth.replace(/%([0-9A-F]{2})/g, (_, s) => String.fromCharCode(parseInt(s, 16))); + auth = auth.replace('%', '%25'); + auth = btoa(auth); + 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 (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); + } + this.applyUserTrustLevel(ref); + this.applyUserLanguage(ref); + } else { + ref = { + id: '', + username: '', + displayName: '', + bio: '', + bioLinks: [], + pastDisplayNames: [], + friends: [], + currentAvatarImageUrl: '', + currentAvatarThumbnailImageUrl: '', + currentAvatar: '', + homeLocation: '', + twoFactorAuthEnabled: false, + status: '', + statusDescription: '', + state: '', + tags: [], + developerType: '', + last_login: '', + last_platform: '', + allowAvatarCopying: false, + friendKey: '', + onlineFriends: [], + activeFriends: [], + offlineFriends: [], + // VRCX + $homeLocation: {}, + $isModerator: false, + $isTroll: false, + $trustLevel: 'Visitor', + $trustClass: 'x-tag-untrusted', + $languages: [], + // + ...json + }; + ref.$homeLocation = this.parseLocation(ref.homeLocation); + this.applyUserTrustLevel(ref); + this.applyUserLanguage(ref); + this.currentUser = ref; + this.isLoggedIn = true; + this.$emit('LOGIN', { + json, + ref + }); + } + VRCXStorage.SetObject('currentUser', ref); + 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); + if (ref === undefined) { + ref = { + id: '', + username: '', + displayName: '', + bio: '', + bioLinks: [], + currentAvatarImageUrl: '', + currentAvatarThumbnailImageUrl: '', + status: '', + statusDescription: '', + state: '', + tags: [], + developerType: '', + last_login: '', + last_platform: '', + allowAvatarCopying: false, + isFriend: false, + friendKey: '', + location: '', + worldId: '', + instanceId: '', + // VRCX + $location: {}, + $location_at: Date.now(), + $isModerator: false, + $isTroll: false, + $trustLevel: 'Visitor', + $trustClass: 'x-tag-untrusted', + $languages: [], + // + ...json + }; + ref.$location = this.parseLocation(ref.location); + 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); + } + 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 (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 (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 (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 (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: 100, + 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:SELECT', function (args) { + this.$emit('USER:CURRENT', args); + }); + + API.applyAvatar = function (json) { + var ref = this.cachedAvatars.get(json.id); + if (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 (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: { + 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; + }); + }; + + // 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 (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 (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 (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: 100, + 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.sendNotification = function (params) { + return this.call(`user/${params.receiverUserId}/notification`, { + method: 'POST', + params + }).then((json) => { + var args = { + json, + params + }; + this.$emit('NOTIFICATION: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(); + this.isPlayerModerationsLoading = false; + }); + + 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) { + this.$emit('PLAYER-MODERATION', { + json: args.json, + params: { + playerModerationId: args.json.id + } + }); + }); + + API.$on('PLAYER-MODERATION:DELETE', function (args) { + var { type, moderated } = args.param; + 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 (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(); + }); + }; + + 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 (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 (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 (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'] + }; + for (var ref of this.cachedFavorites.values()) { + if (ref.$isDeleted) { + continue; + } + var type = types[ref.type]; + if (type === undefined) { + continue; + } + ++type[0]; + } + for (var type in types) { + var [N, fn] = types[type]; + if (N > 0) { + this.bulk({ + fn, + N, + params: { + n: 100, + offset: 0 + } + }); + } + } + }; + + API.refreshFavorites = function () { + if (this.isFavoriteLoading) { + return; + } + this.isFavoriteLoading = true; + this.expireFavorites(); + this.bulk({ + fn: 'getFavorites', + N: -1, + params: { + n: 100, + 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 (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 + }); + } + // 16 = ['avatars1'] x 16 + this.favoriteAvatarGroups = []; + for (var i = 0; i < 1; ++i) { + this.favoriteAvatarGroups.push({ + assign: false, + key: `avatar:avatars${i + 1}`, + type: 'avatar', + name: `avatars${i + 1}`, + displayName: `Group ${i + 1}`, + capacity: 16, + 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 (groups === undefined) { + continue; + } + for (var group of groups) { + if (group.assign === false && + group.name === ref.name) { + group.assign = true; + 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 (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; + 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 (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: 100, + 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': + // it seems to only come when a friend is in a private world :/ + 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); + + // initialise + + var $app = { + data: { + API, + VRCX, + nextRefresh: 0, + isGameRunning: false, + appVersion: 'VRCX 2020.04.07', + latestAppVersion: '', + ossDialog: false + }, + computed: {}, + methods: {}, + watch: {}, + el: '#x-app', + mounted() { + LogWatcher.Reset().then(() => { + API.$on('SHOW_WORLD_DIALOG', (tag) => this.showWorldDialog(tag)); + API.$on('SHOW_LAUNCH_DIALOG', (tag) => this.showLaunchDialog(tag)); + setInterval(() => this.update(), 1000); + this.update(); + 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; + }); + }); + }); + this.checkAppVersion(); + } + }; + + $app.methods.openExternalLink = function (link) { + this.$confirm(`${link}`, 'Open External Link', { + confirmButtonText: 'Confirm', + cancelButtonText: 'Cancel', + type: 'info', + callback: (action) => { + if (action === 'confirm') { + VRCX.OpenLink(link); + } + } + }); + }; + + $app.methods.languageClass = function (language) { + var style = {}; + var mapping = languageMappings[language]; + if (mapping !== undefined) { + style[mapping] = true; + } + return style; + }; + + $app.methods.checkAppVersion = function () { + var url = 'https://api.github.com/repos/pypy-vrc/VRCX/releases/latest'; + fetch(url).then((res) => res.json()).then((json) => { + 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: () => VRCX.OpenLink('https://github.com/pypy-vrc/VRCX/releases') + } + }).show(); + this.notifyMenu('more'); + } + } else { + this.latestAppVersion = 'Error occured'; + } + }); + }; + + $app.methods.update = function () { + if (API.isLoggedIn === false) { + return; + } + if (--this.nextRefresh <= 0) { + this.nextRefresh = 60; + API.getCurrentUser().catch((err1) => { + if (err1.status_code === 401) { + API.getConfig().then((args) => { + API.login({ + username: this.loginForm.username, + password: this.loginForm.password + }).catch((err2) => { + if (err2.status_code === 401) { + API.logout(); + } + throw err2; + }); + return args; + }); + } + throw err1; + }); + } + this.checkActiveFriends(); + this.refreshGameLog(); + VRCX.IsGameRunning().then((running) => { + if (running !== this.isGameRunning) { + this.isGameRunning = running; + Discord.SetTimestamps(Date.now(), 0); + } + this.updateDiscord(); + this.updateOpenVR(); + }); + }; + + $app.methods.updateSharedFeed = function () { + var arr = []; + // FIXME + // 여러 개 켠다면 gameLogTable의 데이터가 시간순이 아닐 수도 있음 + var { data } = this.gameLogTable; + var i = data.length; + var j = 0; + while (j < 25) { + if (i <= 0) { + break; + } + var ctx = data[--i]; + // Location, OnPlayerJoined, OnPlayerLeft + if (ctx.type) { + // FIXME: 이거 존나 느릴거 같은데 + 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; + } + } + arr.push({ + ...ctx, + isFriend, + isFavorite + }); + } else { + arr.push(ctx); + } + ++j; + } + var { data } = this.feedTable; + var i = data.length; + var j = 0; + while (j < 25) { + if (i <= 0) { + break; + } + var ctx = data[--i]; + // GPS, Online, Offline, Status, Avatar + if (ctx.type !== 'Avatar') { + arr.push({ + ...ctx, + isFriend: this.friends.has(ctx.userId), + isFavorite: API.cachedFavoritesByObjectId.has(ctx.userId) + }); + ++j; + } + } + arr.sort(function (a, b) { + if (a.created_at < b.created_at) { + return 1; + } + if (a.created_at > b.created_at) { + return -1; + } + return 0; + }); + if (arr.length > 25) { + arr.length = 25; + } + VRCXStorage.SetArray('sharedFeeds', arr); + }; + + $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(); + } + } + }); + }; + + 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.data.loginForm = { + loading: true, + username: '', + password: '', + 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 + }).finally(() => { + this.loginForm.loading = false; + }); + return args; + }); + } + }); + }; + + $app.methods.loginWithSteam = function () { + if (!this.loginForm.loading) { + this.loginForm.loading = true; + VRCX.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.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 = VRCXStorage.GetBool('orderFriendGroup0'); + $app.data.orderFriendsGroup1 = VRCXStorage.GetBool('orderFriendGroup1'); + $app.data.orderFriendsGroup2 = VRCXStorage.GetBool('orderFriendGroup2'); + $app.data.orderFriendsGroup3 = VRCXStorage.GetBool('orderFriendGroup3'); + var saveOrderFriendGroup = function () { + VRCXStorage.SetBool('orderFriendGroup0', this.orderFriendsGroup0); + VRCXStorage.SetBool('orderFriendGroup1', this.orderFriendsGroup1); + VRCXStorage.SetBool('orderFriendGroup2', this.orderFriendsGroup2); + VRCXStorage.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 (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); + } + } + // called from API.login(), API.loginWithSteam(), API.getCurrentUser() + if (origin) { + API.refreshFriends(); + } + }; + + $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 (ref === undefined) { + ref = this.friendLog[id]; + if (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 (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 (ctx === undefined) { + return; + } + var ref = API.cachedUsers.get(id); + var isVIP = API.cachedFavoritesByObjectId.has(id); + if (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 (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' && + 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 (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); + } + } + }; + + // 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 (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 (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; + }); + if (results.length > 4) { + results.length = 4; + } + 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.currentAvatarThumbnailImageUrl) { + $app.addFeed('Avatar', ref, { + avatar: props.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.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 = ''; + $app.data.lastLocation$ = {}; + $app.data.discordActive = VRCXStorage.GetBool('discordActive'); + $app.data.discordInstance = VRCXStorage.GetBool('discordInstance'); + var saveDiscordOption = function () { + VRCXStorage.SetBool('discordActive', this.discordActive); + VRCXStorage.SetBool('discordInstance', this.discordInstance); + }; + $app.watch.discordActive = saveDiscordOption; + $app.watch.discordInstance = saveDiscordOption; + + $app.data.gameLogTable = { + data: [], + filters: [ + { + prop: 'type', + value: [], + filterFn: (row, filter) => filter.value.some((v) => v === row.type) + }, + { + prop: 'data', + 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 + ] + } + }; + + $app.methods.resetGameLog = function () { + LogWatcher.Reset().then(() => { + this.gameLogTable.data = []; + }); + }; + + $app.methods.refreshGameLog = function () { + LogWatcher.Get().then((logs) => { + if (logs.length) { + var { data } = this.gameLogTable; + for (var log of logs) { + var ctx = { + created_at: String(log[0]), + type: String(log[1]), + data: String(log[2]) + }; + if (ctx.type === 'Location') { + var tag = ctx.data; + if (tag.endsWith(':')) { + tag = tag.substr(0, tag.length - 1); + ctx.data = tag; + } + this.lastLocation = tag; + } + data.push(ctx); + } + this.sweepGameLog(); + // sweepGameLog로 기록이 삭제되면 + // 아무 것도 없는데 알림이 떠서 이상함 + if (data.length) { + this.notifyMenu('gameLog'); + } + } + this.updateSharedFeed(); + }); + }; + + $app.methods.sweepGameLog = function () { + var { data } = this.gameLogTable; + // 로그는 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.gameLogTable.data = []; + } else if (i) { + data.splice(0, i); + } + }; + + $app.methods.updateDiscord = function () { + if (this.isGameRunning === false || + this.lastLocation === '') { + Discord.SetActive(false); + return; + } + if (this.lastLocation !== this.lastLocation$.tag) { + var L = API.parseLocation(this.lastLocation); + 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 = function (name) { + for (var ref of API.cachedUsers.values()) { + if (ref.displayName === name) { + this.showUserDialog(ref.id); + return; + } + } + this.searchText = name; + this.search(); + this.$refs.menu.activeIndex = 'search'; + this.$refs.searchTab.currentName = '0'; + }; + + // 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 = function () { + this.searchUserParams = { + n: 10, + offset: 0, + search: this.searchText + }; + this.moreSearchUser(); + }; + + $app.methods.moreSearchUser = function (go) { + var params = this.searchUserParams; + if (go) { + params.offset += params.n * go; + if (params.offset < 0) { + params.offset = 0; + } + } + this.isSearchUserLoading = true; + 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 (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 (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 (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 (favorite !== undefined) { + var isTypeChanged = false; + if (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 (ref === undefined) { + ref = this.friendLog[objectId]; + if (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 (ref !== undefined) { + ctx.ref = ref; + ctx.name = ref.name; + } + } else if (type === 'avatar') { + var ref = API.cachedAvatars.get(objectId); + if (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 (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 (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 (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 (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 (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 (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; + } + 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 (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 (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 (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 (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 (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: [], + 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); + $app.notifyMenu('moderation'); + } + }); + + 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'); + } + }); + + 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: More + + $app.data.userLanguageVisible = 0; + $app.data.userLanguageSelected = ''; + $app.data.userLanguages = (function () { + var data = []; + for (var key in subsetOfLanguages) { + var value = subsetOfLanguages[key]; + data.push({ + key, + value + }); + } + return data; + }()); + $app.data.currentUserTreeData = []; + $app.data.pastDisplayNameTable = { + data: [], + tableProps: { + stripe: true, + size: 'mini', + defaultSort: { + prop: 'updated_at', + order: 'descending' + } + }, + pageSize: 10, + paginationProps: { + small: true, + layout: 'sizes,prev,pager,next,total', + pageSizes: [ + 10, + 25, + 50, + 100 + ] + } + }; + $app.data.visits = 0; + $app.data.openVR = VRCXStorage.GetBool('openVR'); + $app.data.openVRAlways = VRCXStorage.GetBool('openVRAlways'); + var saveOpenVROption = function () { + VRCXStorage.SetBool('openVR', this.openVR); + VRCXStorage.SetBool('openVRAlways', this.openVRAlways); + }; + $app.watch.openVR = saveOpenVROption; + $app.watch.openVRAlways = saveOpenVROption; + $app.data.isDarkMode = VRCXStorage.GetBool('isDarkMode'); + $appDarkStyle.disabled = $app.data.isDarkMode === false; + $app.watch.isDarkMode = function () { + VRCXStorage.SetBool('isDarkMode', this.isDarkMode); + $appDarkStyle.disabled = this.isDarkMode === false; + }; + $app.data.isStartAtWindowsStartup = VRCXStorage.GetBool('VRCX_StartAtWindowsStartup'); + $app.data.isStartAsMinimizedState = VRCXStorage.GetBool('VRCX_StartAsMinimizedState'); + $app.data.isCloseToTray = VRCXStorage.GetBool('VRCX_CloseToTray'); + var saveVRCXWindowOption = function () { + VRCXStorage.SetBool('VRCX_StartAtWindowsStartup', this.isStartAtWindowsStartup); + VRCXStorage.SetBool('VRCX_StartAsMinimizedState', this.isStartAsMinimizedState); + VRCXStorage.SetBool('VRCX_CloseToTray', this.isCloseToTray); + VRCX.SetStartup(this.isStartAtWindowsStartup); + }; + $app.watch.isStartAtWindowsStartup = saveVRCXWindowOption; + $app.watch.isStartAsMinimizedState = saveVRCXWindowOption; + $app.watch.isCloseToTray = saveVRCXWindowOption; + + 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.addUserLanguage = function (language) { + if (language !== String(language)) { + return; + } + API.addUserTags({ + tags: [`language_${language}`] + }); + }; + + $app.methods.removeUserLanguage = function (language) { + if (language !== String(language)) { + return; + } + API.removeUserTags({ + tags: [`language_${language}`] + }); + }; + + $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.isGameRunning || this.openVRAlways)) { + VRCX.StartVR(); + } else { + VRCX.StopVR(); + } + }; + + $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: 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', + + 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 || + 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', 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; + } + }); + + 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 (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 (ref !== undefined && + ref.location === L.tag) { + users.push(ref); + } + } + } + if (this.isGameRunning && + this.lastLocation === L.tag) { + users.push(API.currentUser); + } + 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 (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: 100, + 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 ($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: 100, + 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 ($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 === 'Message') { + this.$prompt('Enter a message', 'Send Message', { + distinguishCancelAndClose: true, + confirmButtonText: 'Send', + cancelButtonText: 'Cancel', + inputPattern: /\S+/, + inputErrorMessage: 'Message is required', + callback: (action, instance) => { + if (action === 'confirm' && + instance.inputValue) { + API.sendNotification({ + receiverUserId: D.id, + type: 'message', + message: instance.inputValue, + seen: false, + details: '{}' + }).then((args) => { + this.$message('Message sent'); + return args; + }); + } + } + }); + } else if (command === 'Show Avatar Author') { + 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: 100, + 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 { + 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: 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 && + instances[instanceId] === undefined) { + instances[instanceId] = { + id: instanceId, + occupants: 0, + users: [] + }; + } + for (var { ref } of this.friends.values()) { + if (ref === undefined || + ref.$location === undefined || + ref.$location.worldId !== D.id) { + continue; + } + var { instanceId } = ref.$location; + var instance = instances[instanceId]; + if (instance === undefined) { + instance = { + id: instanceId, + occupants: 0, + users: [] + }; + instances[instanceId] = instance; + } + instance.users.push(ref); + } + if (this.isGameRunning) { + var lastLocation$ = API.parseLocation(this.lastLocation); + if (lastLocation$.worldId === D.id) { + var instance = instances[lastLocation$.instanceId]; + if (instance === undefined) { + instance = { + id: lastLocation$.instanceId, + occupants: 1, + users: [] + }; + instances[instance.id] = instance; + } + instance.users.push(API.currentUser); + } + } + 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 (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 === '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; + } + if (command === 'Add Favorite') { + this.showFavoriteDialog('avatar', 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 'Select Avatar': + API.selectAvatar({ + avatarId: D.id + }); + break; + default: + break; + } + } + }); + } + }; + + $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 params = { + receiverUserId: '', + type: 'invite', + message: 'This is a generated invite', + seen: false, + details: { + worldId: D.worldId, + worldName: D.worldName + } + }; + var inviteLoop = () => { + if (D.userIds.length > 0) { + params.receiverUserId = D.userIds.shift(); + API.sendNotification(params).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: 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 Dialog + + $app.data.launchDialog = { + visible: false, + loading: false, + desktop: VRCXStorage.GetBool('launchAsDesktop'), + location: '', + url: '' + }; + + $app.watch['launchDialog.desktop'] = function () { + VRCXStorage.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.launchGame = function () { + var D = this.launchDialog; + VRCX.StartGame(D.location, D.desktop); + D.visible = false; + }; + + $app = new Vue($app); + window.$app = $app; }); diff --git a/html/src/app.scss b/html/src/app.scss index 4782c72f..abb7a02a 100644 --- a/html/src/app.scss +++ b/html/src/app.scss @@ -15,491 +15,491 @@ For a copy, see . @import '~famfamfam-flags/dist/sprite/famfamfam-flags.min.css'; .color-palettes { - background: #409EFF; - background: #67C23A; - background: #E6A23C; - background: #F56C6C; - background: #909399; - background: #FD9200; - background: #E6E6E6; - background: #C0C4CC; + background: #409EFF; + background: #67C23A; + background: #E6A23C; + background: #F56C6C; + background: #909399; + background: #FD9200; + background: #E6E6E6; + background: #C0C4CC; } .noty_layout { - word-break: break-all; + word-break: break-all; } .noty_theme__mint.noty_bar { - margin: 4px 0; - overflow: hidden; - border-radius: 2px; - position: relative; + margin: 4px 0; + overflow: hidden; + border-radius: 2px; + position: relative; } .noty_theme__mint.noty_bar .noty_body { - padding: 10px; - font-size: 14px; + padding: 10px; + font-size: 14px; } .noty_theme__mint.noty_bar .noty_buttons { - padding: 10px; + padding: 10px; } .noty_theme__mint.noty_type__alert, .noty_theme__mint.noty_type__notification { - background-color: #fff; - border-bottom: 1px solid #D1D1D1; - color: #2F2F2F; + background-color: #fff; + border-bottom: 1px solid #D1D1D1; + color: #2F2F2F; } .noty_theme__mint.noty_type__warning { - background-color: #FFAE42; - border-bottom: 1px solid #E89F3C; - color: #fff; + background-color: #FFAE42; + border-bottom: 1px solid #E89F3C; + color: #fff; } .noty_theme__mint.noty_type__error { - background-color: #DE636F; - border-bottom: 1px solid #CA5A65; - color: #fff; + background-color: #DE636F; + border-bottom: 1px solid #CA5A65; + color: #fff; } .noty_theme__mint.noty_type__info, .noty_theme__mint.noty_type__information { - background-color: #7F7EFF; - border-bottom: 1px solid #7473E8; - color: #fff; + background-color: #7F7EFF; + border-bottom: 1px solid #7473E8; + color: #fff; } .noty_theme__mint.noty_type__success { - background-color: #AFC765; - border-bottom: 1px solid #A0B55C; - color: #fff; + background-color: #AFC765; + border-bottom: 1px solid #A0B55C; + color: #fff; } .el-table+.pagination-bar { - margin-top: 15px; + margin-top: 15px; } .el-dialog__body { - padding: 20px; + padding: 20px; } .el-dialog__footer>.el-button+.el-button { - margin-left: 5px; + margin-left: 5px; } ::-webkit-scrollbar { - width: 8px; - height: 8px; + width: 8px; + height: 8px; } ::-webkit-scrollbar-track { - background: rgba(0, 0, 0, 0.05); - border-radius: 16px; + background: rgba(0, 0, 0, 0.05); + border-radius: 16px; } ::-webkit-scrollbar-thumb { - background: rgba(0, 0, 0, 0.2); - border-radius: 16px; + background: rgba(0, 0, 0, 0.2); + border-radius: 16px; } body, input, textarea, select, button { - font-family: 'Noto Sans JP', 'Noto Sans KR', 'Meiryo UI', 'Malgun Gothic', 'Segoe UI', sans-serif; - line-height: normal; + font-family: 'Noto Sans JP', 'Noto Sans KR', 'Meiryo UI', 'Malgun Gothic', 'Segoe UI', sans-serif; + line-height: normal; } a { - color: #409eff; + color: #409eff; } .x-link { - cursor: pointer; + cursor: pointer; } .x-link:hover { - text-decoration: underline; + text-decoration: underline; } .x-ellipsis { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .x-app { - display: flex; - position: absolute; - width: 100%; - height: 100%; - overflow: hidden auto; + display: flex; + position: absolute; + width: 100%; + height: 100%; + overflow: hidden auto; } .x-container { - flex: 1; - padding: 10px; - overflow: hidden auto; - background: #fff; - position: relative; + flex: 1; + padding: 10px; + overflow: hidden auto; + background: #fff; + position: relative; } .x-login-container { - display: flex; - position: absolute; - width: 100%; - height: 100%; - background: #fff; - /* modal 시작이 2000이라서 */ - z-index: 1999; + display: flex; + position: absolute; + width: 100%; + height: 100%; + background: #fff; + /* modal 시작이 2000이라서 */ + z-index: 1999; } .x-menu-container { - flex: none; - overflow: hidden auto; - background: #383838; + flex: none; + overflow: hidden auto; + background: #383838; } .x-menu-container>.el-menu { - background: 0; - border: 0; + background: 0; + border: 0; } .el-menu-item.is-active::before { - position: absolute; - content: ''; - left: 1px; - top: 4px; - width: 2px; - height: 48px; - background: #DCDFE6; + position: absolute; + content: ''; + left: 1px; + top: 4px; + width: 2px; + height: 48px; + background: #DCDFE6; } .el-menu-item.notify::after { - position: absolute; - content: ''; - right: 4px; - top: 4px; - width: 4px; - height: 4px; - background: #EBEEF5; - border-radius: 50% + position: absolute; + content: ''; + right: 4px; + top: 4px; + width: 4px; + height: 4px; + background: #EBEEF5; + border-radius: 50% } .x-aside-container { - flex: none; - width: 236px; - display: flex; - flex-direction: column; - background: #f8f8f8; + flex: none; + width: 236px; + display: flex; + flex-direction: column; + background: #f8f8f8; } .el-popper.x-quick-search { - min-width: 0 !important; - width: 225px; + min-width: 0 !important; + width: 225px; } .el-popper.x-quick-search .el-select-dropdown__item { - padding: 0 10px; - width: 100%; - height: auto; - font-size: 12px; - line-height: normal; + padding: 0 10px; + width: 100%; + height: auto; + font-size: 12px; + line-height: normal; } .x-friend-list { - overflow: hidden auto; - padding: 0 10px; + overflow: hidden auto; + padding: 0 10px; } .x-friend-group>.el-icon-arrow-right { - transition: transform .3s; + transition: transform .3s; } .x-friend-group>.el-icon-arrow-right.rotate { - transform: rotate(90deg); + transform: rotate(90deg); } .x-aside-container>.x-friend-list { - flex: 1; + flex: 1; } .x-dialog .x-friend-list { - display: flex; - align-items: flex-start; - flex-wrap: wrap; - max-height: 150px; + display: flex; + align-items: flex-start; + flex-wrap: wrap; + max-height: 150px; } .x-friend-list>.x-friend-group { - padding: 20px 0 5px; - font-weight: bold; - font-size: 12px; + padding: 20px 0 5px; + font-weight: bold; + font-size: 12px; } .x-friend-item { - display: flex; - align-items: center; - padding: 5px; - cursor: pointer; - font-size: 12px; - box-sizing: border-box; + display: flex; + align-items: center; + padding: 5px; + cursor: pointer; + font-size: 12px; + box-sizing: border-box; } .x-friend-item:hover { - background: #f0f0f0; - border-radius: 2px; + background: #f0f0f0; + border-radius: 2px; } .x-aside-container>.x-friend-list>.x-friend-item:hover { - background: #fff; - border-radius: 2px; + background: #fff; + border-radius: 2px; } .el-select-dropdown__item .x-friend-item:hover { - background: none; - border-radius: 0; + background: none; + border-radius: 0; } .x-dialog .x-friend-item { - width: 175px; + width: 175px; } .x-friend-item>.avatar { - flex: none; - width: 40px; - height: 40px; - margin-right: 8px; - display: inline-block; - position: relative; + flex: none; + width: 40px; + height: 40px; + margin-right: 8px; + display: inline-block; + position: relative; } .x-friend-item>img.avatar { - width: 50px; - height: 37.5px; - margin-left: 5px; - margin-right: 0; - border-radius: 2px; + width: 50px; + height: 37.5px; + margin-left: 5px; + margin-right: 0; + border-radius: 2px; } .x-friend-item>.avatar>img { - width: 100%; - height: 100%; - border-radius: 40%; - object-fit: cover; + width: 100%; + height: 100%; + border-radius: 40%; + object-fit: cover; } .x-friend-item>.avatar.offline>img { - filter: grayscale(1); + filter: grayscale(1); } .x-friend-item:hover>.avatar.offline>img { - filter: none; + filter: none; } .x-friend-item>.avatar.active::after, .x-friend-item>.avatar.joinme::after, .x-friend-item>.avatar.askme::after, .x-friend-item>.avatar.busy::after { - content: ''; - position: absolute; - right: 0; - bottom: 0; - width: 8px; - height: 8px; - border-radius: 50%; - border: 2px solid #fff; - background: #909399; + content: ''; + position: absolute; + right: 0; + bottom: 0; + width: 8px; + height: 8px; + border-radius: 50%; + border: 2px solid #fff; + background: #909399; } .x-friend-item>.avatar.active::after { - background: #67C23A; + background: #67C23A; } .x-friend-item>.avatar.joinme::after { - background: #409EFF; + background: #409EFF; } .x-friend-item>.avatar.askme::after { - background: #FD9200; + background: #FD9200; } .x-friend-item>.avatar.busy::after { - background: #F56C6C; + background: #F56C6C; } .x-friend-item.offline>.avatar::after { - display: none; + display: none; } .x-friend-item>.detail { - flex: 1; - overflow: hidden; + flex: 1; + overflow: hidden; } .x-friend-item>.detail>.name, .x-friend-item>.detail>.extra { - display: block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .x-friend-item>.detail>.name { - color: #303133; - font-weight: bold; + color: #303133; + font-weight: bold; } .x-friend-item>.detail>.extra { - color: #606266; - font-weight: normal; + color: #606266; + font-weight: normal; } .x-dialog>.el-dialog { - margin-bottom: 10px; - max-width: 100%; + margin-bottom: 10px; + max-width: 100%; } .x-user-dialog>.el-dialog>.el-dialog__header, .x-world-dialog>.el-dialog>.el-dialog__header, .x-avatar-dialog>.el-dialog>.el-dialog__header { - padding: 0; - display: none; + padding: 0; + display: none; } .x-user-dialog>.el-dialog>.el-dialog__body, .x-world-dialog>.el-dialog>.el-dialog__body, .x-avatar-dialog>.el-dialog>.el-dialog__body { - padding: 20px; + padding: 20px; } .el-popper.hex { - font-family: monospace; - text-align: center; - min-width: auto; - padding: 10px; + font-family: monospace; + text-align: center; + min-width: auto; + padding: 10px; } i.x-user-status { - display: inline-block; - width: 10px; - height: 10px; - border-radius: 50%; - background: gray; + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + background: gray; } i.x-user-status.active { - background: #67C23A; + background: #67C23A; } i.x-user-status.joinme { - background: #409EFF; + background: #409EFF; } i.x-user-status.askme { - background: #FD9200; + background: #FD9200; } i.x-user-status.busy { - background: #F56C6C; + background: #F56C6C; } .x-friend-item>.detail>.name.x-tag-untrusted { - color: rgb(204, 204, 204); + color: rgb(204, 204, 204); } .el-tag.x-tag-untrusted { - border-color: rgb(204, 204, 204); - color: rgb(204, 204, 204); + border-color: rgb(204, 204, 204); + color: rgb(204, 204, 204); } .x-friend-item>.detail>.name.x-tag-basic { - color: rgb(23, 120, 255); + color: rgb(23, 120, 255); } .el-tag.x-tag-basic { - border-color: rgb(23, 120, 255); - color: rgb(23, 120, 255); + border-color: rgb(23, 120, 255); + color: rgb(23, 120, 255); } .x-friend-item>.detail>.name.x-tag-known { - color: rgb(43, 207, 92); + color: rgb(43, 207, 92); } .el-tag.x-tag-known { - border-color: rgb(43, 207, 92); - color: rgb(43, 207, 92); + border-color: rgb(43, 207, 92); + color: rgb(43, 207, 92); } .x-friend-item>.detail>.name.x-tag-trusted { - color: rgb(255, 123, 66); + color: rgb(255, 123, 66); } .el-tag.x-tag-trusted { - border-color: rgb(255, 123, 66); - color: rgb(255, 123, 66); + border-color: rgb(255, 123, 66); + color: rgb(255, 123, 66); } .x-friend-item>.detail>.name.x-tag-veteran { - color: rgb(129, 67, 230); + color: rgb(129, 67, 230); } .el-tag.x-tag-veteran { - border-color: rgb(129, 67, 230); - color: rgb(129, 67, 230); + border-color: rgb(129, 67, 230); + color: rgb(129, 67, 230); } .x-friend-item>.detail>.name.x-tag-legend { - /*color: rgb(255, 255, 0);*/ - color: rgb(255, 208, 0); + /*color: rgb(255, 255, 0);*/ + color: rgb(255, 208, 0); } .el-tag.x-tag-legend { - /*border-color: rgb(255, 255, 0); - color: rgb(255, 255, 0);*/ - border-color: rgb(255, 208, 0); - color: rgb(255, 208, 0); + /*border-color: rgb(255, 255, 0); + color: rgb(255, 255, 0);*/ + border-color: rgb(255, 208, 0); + color: rgb(255, 208, 0); } .x-friend-item>.detail>.name.x-tag-legendary { - color: rgb(0, 0, 0); + color: rgb(0, 0, 0); } .el-tag.x-tag-legendary { - border-color: rgb(0, 0, 0); - color: rgb(0, 0, 0); + border-color: rgb(0, 0, 0); + color: rgb(0, 0, 0); } .x-friend-item>.detail>.name.x-tag-vip { - color: rgb(181, 38, 38); + color: rgb(181, 38, 38); } .el-tag.x-tag-vip { - border-color: rgb(181, 38, 38); - color: rgb(181, 38, 38); + border-color: rgb(181, 38, 38); + color: rgb(181, 38, 38); } .x-friend-item>.detail>.name.x-tag-troll { - color: rgb(120, 47, 47); + color: rgb(120, 47, 47); } .el-tag.x-tag-troll { - border-color: rgb(120, 47, 47); - color: rgb(120, 47, 47); + border-color: rgb(120, 47, 47); + color: rgb(120, 47, 47); } .x-friend-item>.detail>.name.x-tag-friend { - /*color: rgb(255, 255, 0);*/ - color: rgb(255, 208, 0); + /*color: rgb(255, 255, 0);*/ + color: rgb(255, 208, 0); } .el-tag.x-tag-friend { - /*border-color: rgb(255, 255, 0); - color: rgb(255, 255, 0);*/ - border-color: rgb(255, 208, 0); - color: rgb(255, 208, 0); + /*border-color: rgb(255, 255, 0); + color: rgb(255, 255, 0);*/ + border-color: rgb(255, 208, 0); + color: rgb(255, 208, 0); } .el-tree-node { - white-space: normal; + white-space: normal; } .el-tree-node__content { - height: auto; + height: auto; } .x-user-dialog .el-textarea__inner { - background: none; - border: 0; - border-radius: 2px; - padding: 0; + background: none; + border: 0; + border-radius: 2px; + padding: 0; } \ No newline at end of file diff --git a/html/src/index.pug b/html/src/index.pug index 78f78b0f..ab175773 100644 --- a/html/src/index.pug +++ b/html/src/index.pug @@ -1,1316 +1,1316 @@ doctype html html - head - meta(http-equiv="Content-Type" content="text/html;charset=utf-8") - meta(http-equiv="Cache-Control" content="no-cache") - meta(http-equiv="referrer" content="no-referrer") - meta(http-equiv="viewport" content="width=device-width,initial-scale=1,user-scalable=no") - title VRCX - link(rel="dns-prefetch" href="https://fonts.gstatic.com") - link(rel="preconnect" href="https://api.vrchat.cloud") - link(rel="preconnect" href="https://d348imysud55la.cloudfront.net") - link(rel="stylesheet" href="https://fonts.googleapis.com/css?family=Noto+Sans+JP|Noto+Sans+KR&display=swap") - link(rel="stylesheet" href="app.css") - body - .x-app#x-app(style="display:none") + head + meta(http-equiv="Content-Type" content="text/html;charset=utf-8") + meta(http-equiv="Cache-Control" content="no-cache") + meta(http-equiv="referrer" content="no-referrer") + meta(http-equiv="viewport" content="width=device-width,initial-scale=1,user-scalable=no") + title VRCX + link(rel="dns-prefetch" href="https://fonts.gstatic.com") + link(rel="preconnect" href="https://api.vrchat.cloud") + link(rel="preconnect" href="https://d348imysud55la.cloudfront.net") + link(rel="stylesheet" href="https://fonts.googleapis.com/css?family=Noto+Sans+JP|Noto+Sans+KR&display=swap") + link(rel="stylesheet" href="app.css") + body + .x-app#x-app(style="display:none") - //- login - .x-login-container(v-show="!API.isLoggedIn") - div(style="width:300px;margin:auto") - el-form(ref="loginForm" :model="loginForm" :rules="loginForm.rules" v-loading="loginForm.loading" @submit.native.prevent="login()") - el-form-item(label="Username or Email" prop="username" required) - el-input(v-model="loginForm.username" name="username" placeholder="Username or Email" clearable) - el-form-item(label="Password" prop="password" required) - el-input(type="password" v-model="loginForm.password" name="password" placeholder="Password" clearable show-password) - el-form-item(style="margin-top:35px") - el-button(native-type="submit" type="primary" :loading="loginForm.loading" style="width:100%") Login - el-form-item - el-button(:loading="loginForm.loading" style="width:100%" @click="loginWithSteam()") Login with Steam - div(style="text-align:center;font-size:12px") - p © 2019-2020 #[a(href="https://github.com/pypy-vrc" target="_blank") pypy] (mina#5656) - p VRCX is an assistant application for provide information about manage friendship. this application uses unofficial VRChat API (VRCSDK). - p VRCX isn't endorsed by VRChat and doesn't reflect the views or opinions of VRChat or anyone officially involved in producing or managing VRChat. VRChat is trademark of VRChat Inc. VRChat © VRChat Inc. - p pypy is not responsible for any problems caused by VRCX. Use at your own risk! + //- login + .x-login-container(v-show="!API.isLoggedIn") + div(style="width:300px;margin:auto") + el-form(ref="loginForm" :model="loginForm" :rules="loginForm.rules" v-loading="loginForm.loading" @submit.native.prevent="login()") + el-form-item(label="Username or Email" prop="username" required) + el-input(v-model="loginForm.username" name="username" placeholder="Username or Email" clearable) + el-form-item(label="Password" prop="password" required) + el-input(type="password" v-model="loginForm.password" name="password" placeholder="Password" clearable show-password) + el-form-item(style="margin-top:35px") + el-button(native-type="submit" type="primary" :loading="loginForm.loading" style="width:100%") Login + el-form-item + el-button(:loading="loginForm.loading" style="width:100%" @click="loginWithSteam()") Login with Steam + div(style="text-align:center;font-size:12px") + p © 2019-2020 #[a(href="https://github.com/pypy-vrc" target="_blank") pypy] (mina#5656) + p VRCX is an assistant application for provide information about manage friendship. this application uses unofficial VRChat API (VRCSDK). + p VRCX isn't endorsed by VRChat and doesn't reflect the views or opinions of VRChat or anyone officially involved in producing or managing VRChat. VRChat is trademark of VRChat Inc. VRChat © VRChat Inc. + p pypy is not responsible for any problems caused by VRCX. Use at your own risk! - //- menu - .x-menu-container - el-menu(ref="menu" collapse @select="selectMenu") - mixin menuitem(index, name, icon) - el-menu-item(index=index) - i(class=icon) - template(#title) - span= name - +menuitem('feed', 'Feed', 'el-icon-news') - +menuitem('gameLog', 'Game Log', 'el-icon-s-data') - +menuitem('search', 'Search', 'el-icon-search') - +menuitem('favorite', 'Favorite', 'el-icon-star-off') - +menuitem('friendLog', 'Friend Log', 'el-icon-notebook-2') - +menuitem('moderation', 'Moderation', 'el-icon-finished') - +menuitem('notification', 'Notification', 'el-icon-bell') - +menuitem('more', 'More', 'el-icon-s-tools') + //- menu + .x-menu-container + el-menu(ref="menu" collapse @select="selectMenu") + mixin menuitem(index, name, icon) + el-menu-item(index=index) + i(class=icon) + template(#title) + span= name + +menuitem('feed', 'Feed', 'el-icon-news') + +menuitem('gameLog', 'Game Log', 'el-icon-s-data') + +menuitem('search', 'Search', 'el-icon-search') + +menuitem('favorite', 'Favorite', 'el-icon-star-off') + +menuitem('friendLog', 'Friend Log', 'el-icon-notebook-2') + +menuitem('moderation', 'Moderation', 'el-icon-finished') + +menuitem('notification', 'Notification', 'el-icon-bell') + +menuitem('more', 'More', 'el-icon-s-tools') - //- feed - .x-container(v-show="$refs.menu && $refs.menu.activeIndex === 'feed'") - data-tables(v-bind="feedTable") - template(#tool) - div(style="margin:0 0 10px;display:flex;align-items:center") - div(style="flex:none;margin-right:10px") - el-switch(v-model="feedTable.filters[2].value" active-color="#13ce66") - el-select(v-model="feedTable.filters[0].value" multiple clearable collapse-tags style="flex:1" placeholder="Filter") - el-option(v-once v-for="type in ['GPS', 'Online', 'Offline', 'Status', 'Avatar']" :key="type" :label="type" :value="type") - el-input(v-model="feedTable.filters[1].value" placeholder="Search" style="flex:none;width:150px;margin:0 10px") - el-button(type="default" @click="clearFeed()" icon="el-icon-delete" circle style="flex:none") - el-table-column(type="expand") - template(v-once #default="scope") - div(style="position:relative;font-size:14px") - template(v-if="scope.row.type === 'GPS'") - location(:location="scope.row.location[1]") - el-tag(type="info" effect="plain" size="mini" style="margin-left:5px") {{ scope.row.time | timeToText }} - br - span - i.el-icon-right - location(:location="scope.row.location[0]") - template(v-else-if="scope.row.type === 'Offline'") - location(:location="scope.row.location") - el-tag(type="info" effect="plain" size="mini" style="margin-left:5px") {{ scope.row.time | timeToText }} - template(v-else-if="scope.row.type === 'Online'") - location(:location="scope.row.location") - template(v-else-if="scope.row.type === 'Avatar'") - el-popover(placement="right" width="500px" trigger="click") - img.x-link(slot="reference" v-lazy="scope.row.avatar[1]" style="flex:none;width:160px;height:120px;border-radius:4px") - img(v-lazy="scope.row.avatar[1]" style="width:500px;height:375px") - span(style="position:relative;top:-50px;margin:0 5px") - i.el-icon-right - el-popover(placement="right" width="500px" trigger="click") - img.x-link(slot="reference" v-lazy="scope.row.avatar[0]" style="flex:none;width:160px;height:120px;border-radius:4px") - img(v-lazy="scope.row.avatar[0]" style="width:500px;height:375px") - template(v-else-if="scope.row.type === 'Status'") - el-tooltip(placement="top") - template(#content) - span(v-if="scope.row.status[1].status === 'active'") Online - span(v-else-if="scope.row.status[1].status === 'join me'") Join Me - span(v-else-if="scope.row.status[1].status === 'ask me'") Ask Me - span(v-else-if="scope.row.status[1].status === 'busy'") Do Not Disturb - span(v-else) Offline - i.x-user-status(:class="userStatusClass(scope.row.status[1])") - span(v-text="scope.row.status[1].statusDescription") - br - span - i.el-icon-right - el-tooltip(placement="top") - template(#content) - span(v-if="scope.row.status[0].status === 'active'") Online - span(v-else-if="scope.row.status[0].status === 'join me'") Join Me - span(v-else-if="scope.row.status[0].status === 'ask me'") Ask Me - span(v-else-if="scope.row.status[0].status === 'busy'") Do Not Disturb - span(v-else) Offline - i.x-user-status(:class="userStatusClass(scope.row.status[0])") - span(v-text="scope.row.status[0].statusDescription") - el-table-column(label="Time" prop="created_at" sortable="custom" width="80") - template(v-once #default="scope") - el-tooltip(placement="right") - template(#content) - span {{ scope.row.created_at | formatDate('YYYY-MM-DD HH24:MI:SS') }} - span {{ scope.row.created_at | formatDate('HH24:MI') }} - el-table-column(label="Type" prop="type" width="100") - el-table-column(label="User" prop="displayName") - template(v-once #default="scope") - span.x-link(v-text="scope.row.displayName" @click="showUserDialog(scope.row.userId)") - el-table-column(label="Detail") - template(v-once #default="scope") - template(v-if="scope.row.type === 'GPS'") - location(:location="scope.row.location[0]") - template(v-else-if="scope.row.type === 'Offline' || scope.row.type === 'Online'") - location(:location="scope.row.location") - template(v-else-if="scope.row.type === 'Status'") - el-tooltip(placement="top") - template(#content) - span(v-if="scope.row.status[0].status === 'active'") Online - span(v-else-if="scope.row.status[0].status === 'join me'") Join Me - span(v-else-if="scope.row.status[0].status === 'ask me'") Ask Me - span(v-else-if="scope.row.status[0].status === 'busy'") Do Not Disturb - span(v-else) Offline - i.x-user-status(:class="userStatusClass(scope.row.status[0])") - span(v-text="scope.row.status[0].statusDescription") + //- feed + .x-container(v-show="$refs.menu && $refs.menu.activeIndex === 'feed'") + data-tables(v-bind="feedTable") + template(#tool) + div(style="margin:0 0 10px;display:flex;align-items:center") + div(style="flex:none;margin-right:10px") + el-switch(v-model="feedTable.filters[2].value" active-color="#13ce66") + el-select(v-model="feedTable.filters[0].value" multiple clearable collapse-tags style="flex:1" placeholder="Filter") + el-option(v-once v-for="type in ['GPS', 'Online', 'Offline', 'Status', 'Avatar']" :key="type" :label="type" :value="type") + el-input(v-model="feedTable.filters[1].value" placeholder="Search" style="flex:none;width:150px;margin:0 10px") + el-button(type="default" @click="clearFeed()" icon="el-icon-delete" circle style="flex:none") + el-table-column(type="expand") + template(v-once #default="scope") + div(style="position:relative;font-size:14px") + template(v-if="scope.row.type === 'GPS'") + location(:location="scope.row.location[1]") + el-tag(type="info" effect="plain" size="mini" style="margin-left:5px") {{ scope.row.time | timeToText }} + br + span + i.el-icon-right + location(:location="scope.row.location[0]") + template(v-else-if="scope.row.type === 'Offline'") + location(:location="scope.row.location") + el-tag(type="info" effect="plain" size="mini" style="margin-left:5px") {{ scope.row.time | timeToText }} + template(v-else-if="scope.row.type === 'Online'") + location(:location="scope.row.location") + template(v-else-if="scope.row.type === 'Avatar'") + el-popover(placement="right" width="500px" trigger="click") + img.x-link(slot="reference" v-lazy="scope.row.avatar[1]" style="flex:none;width:160px;height:120px;border-radius:4px") + img(v-lazy="scope.row.avatar[1]" style="width:500px;height:375px") + span(style="position:relative;top:-50px;margin:0 5px") + i.el-icon-right + el-popover(placement="right" width="500px" trigger="click") + img.x-link(slot="reference" v-lazy="scope.row.avatar[0]" style="flex:none;width:160px;height:120px;border-radius:4px") + img(v-lazy="scope.row.avatar[0]" style="width:500px;height:375px") + template(v-else-if="scope.row.type === 'Status'") + el-tooltip(placement="top") + template(#content) + span(v-if="scope.row.status[1].status === 'active'") Online + span(v-else-if="scope.row.status[1].status === 'join me'") Join Me + span(v-else-if="scope.row.status[1].status === 'ask me'") Ask Me + span(v-else-if="scope.row.status[1].status === 'busy'") Do Not Disturb + span(v-else) Offline + i.x-user-status(:class="userStatusClass(scope.row.status[1])") + span(v-text="scope.row.status[1].statusDescription") + br + span + i.el-icon-right + el-tooltip(placement="top") + template(#content) + span(v-if="scope.row.status[0].status === 'active'") Online + span(v-else-if="scope.row.status[0].status === 'join me'") Join Me + span(v-else-if="scope.row.status[0].status === 'ask me'") Ask Me + span(v-else-if="scope.row.status[0].status === 'busy'") Do Not Disturb + span(v-else) Offline + i.x-user-status(:class="userStatusClass(scope.row.status[0])") + span(v-text="scope.row.status[0].statusDescription") + el-table-column(label="Time" prop="created_at" sortable="custom" width="80") + template(v-once #default="scope") + el-tooltip(placement="right") + template(#content) + span {{ scope.row.created_at | formatDate('YYYY-MM-DD HH24:MI:SS') }} + span {{ scope.row.created_at | formatDate('HH24:MI') }} + el-table-column(label="Type" prop="type" width="100") + el-table-column(label="User" prop="displayName") + template(v-once #default="scope") + span.x-link(v-text="scope.row.displayName" @click="showUserDialog(scope.row.userId)") + el-table-column(label="Detail") + template(v-once #default="scope") + template(v-if="scope.row.type === 'GPS'") + location(:location="scope.row.location[0]") + template(v-else-if="scope.row.type === 'Offline' || scope.row.type === 'Online'") + location(:location="scope.row.location") + template(v-else-if="scope.row.type === 'Status'") + el-tooltip(placement="top") + template(#content) + span(v-if="scope.row.status[0].status === 'active'") Online + span(v-else-if="scope.row.status[0].status === 'join me'") Join Me + span(v-else-if="scope.row.status[0].status === 'ask me'") Ask Me + span(v-else-if="scope.row.status[0].status === 'busy'") Do Not Disturb + span(v-else) Offline + i.x-user-status(:class="userStatusClass(scope.row.status[0])") + span(v-text="scope.row.status[0].statusDescription") - //- gameLog - .x-container(v-show="$refs.menu && $refs.menu.activeIndex === 'gameLog'") - data-tables(v-bind="gameLogTable") - template(#tool) - div(style="margin:0 0 10px;display:flex;align-items:center") - el-select(v-model="gameLogTable.filters[0].value" multiple clearable collapse-tags style="flex:1" placeholder="Filter") - el-option(v-once v-for="type in ['Location', 'OnPlayerJoined', 'OnPlayerLeft']" :key="type" :label="type" :value="type") - el-input(v-model="gameLogTable.filters[1].value" placeholder="Search" style="flex:none;width:150px;margin:0 10px") - el-button(type="default" @click="resetGameLog()" icon="el-icon-refresh" circle style="flex:none") - el-table-column(label="Time" prop="created_at" sortable="custom" width="80") - template(v-once #default="scope") - el-tooltip(placement="right") - template(#content) - span {{ scope.row.created_at | formatDate('YYYY-MM-DD HH24:MI:SS') }} - span {{ scope.row.created_at | formatDate('HH24:MI') }} - el-table-column(label="Type" prop="type" width="120") - el-table-column(label="Detail" prop="data") - template(v-once #default="scope") - location(v-if="scope.row.type === 'Location'" :location="scope.row.data") - span.x-link(v-else v-text="scope.row.data" @click="lookupUser(scope.row.data)") + //- gameLog + .x-container(v-show="$refs.menu && $refs.menu.activeIndex === 'gameLog'") + data-tables(v-bind="gameLogTable") + template(#tool) + div(style="margin:0 0 10px;display:flex;align-items:center") + el-select(v-model="gameLogTable.filters[0].value" multiple clearable collapse-tags style="flex:1" placeholder="Filter") + el-option(v-once v-for="type in ['Location', 'OnPlayerJoined', 'OnPlayerLeft']" :key="type" :label="type" :value="type") + el-input(v-model="gameLogTable.filters[1].value" placeholder="Search" style="flex:none;width:150px;margin:0 10px") + el-button(type="default" @click="resetGameLog()" icon="el-icon-refresh" circle style="flex:none") + el-table-column(label="Time" prop="created_at" sortable="custom" width="80") + template(v-once #default="scope") + el-tooltip(placement="right") + template(#content) + span {{ scope.row.created_at | formatDate('YYYY-MM-DD HH24:MI:SS') }} + span {{ scope.row.created_at | formatDate('HH24:MI') }} + el-table-column(label="Type" prop="type" width="120") + el-table-column(label="Detail" prop="data") + template(v-once #default="scope") + location(v-if="scope.row.type === 'Location'" :location="scope.row.data") + span.x-link(v-else v-text="scope.row.data" @click="lookupUser(scope.row.data)") - //- search - .x-container(v-show="$refs.menu && $refs.menu.activeIndex === 'search'") - div(style="margin:0 0 10px;display:flex;align-items:center") - el-input(v-model="searchText" clearable placeholder="Search" @keyup.native.13="search()" style="flex:1") - el-button(type="default" @click="clearSearch()" icon="el-icon-delete" circle style="flex:none;margin-left:10px") - el-tabs(ref="searchTab" type="card" style="margin-top:15px") - el-tab-pane(label="User" v-loading="isSearchUserLoading" style="min-height:60px") - .x-friend-list - .x-friend-item(v-for="user in searchUserResults" :key="user.id" @click="showUserDialog(user.id)") - template(v-once) - .avatar - img(v-lazy="user.currentAvatarThumbnailImageUrl") - .detail - span.name(v-text="user.displayName" :class="user.trustClass") - span.extra(v-text="user.username" style="font-family:monospace") - el-button-group(style="margin-top:15px") - el-button(v-if="searchUserParams.offset" @click="moreSearchUser(-1)" icon="el-icon-back" size="small") Prev - el-button(v-if="searchUserResults.length" @click="moreSearchUser(1)" icon="el-icon-right" size="small") Next - el-tab-pane(label="World" v-loading="isSearchWorldLoading" style="min-height:60px") - el-dropdown(@command="(row) => searchWorld(row)" size="small" trigger="click" style="margin-bottom:15px") - el-button(size="small") Search by Category #[i.el-icon-arrow-down.el-icon--right] - el-dropdown-menu(#default="dropdown") - el-dropdown-item(v-for="row in API.cachedConfig.dynamicWorldRows" :key="row.index" v-text="row.name" :command="row") - .x-friend-list - .x-friend-item(v-for="world in searchWorldResults" :key="world.id" @click="showWorldDialog(world.id)") - template(v-once) - .avatar - img(v-lazy="world.thumbnailImageUrl") - .detail - span.name(v-text="world.name") - span.extra(v-if="world.occupants") {{ world.authorName }} ({{ world.occupants }}) - span.extra(v-else v-text="world.authorName") - el-button-group(style="margin-top:15px") - el-button(v-if="searchWorldParams.offset" @click="moreSearchWorld(-1)" icon="el-icon-back" size="small") Prev - el-button(v-if="searchWorldResults.length" @click="moreSearchWorld(1)" icon="el-icon-right" size="small") Next - el-tab-pane(label="Avatar" v-loading="isSearchAvatarLoading" style="min-height:60px") - el-dropdown(@command="(command) => searchAvatar(command)" size="small" trigger="click" style="margin-bottom:15px") - el-button(size="small") Search by Category #[i.el-icon-arrow-down.el-icon--right] - el-dropdown-menu(#default="dropdown") - el-dropdown-item(command="updated") Updated Recently - el-dropdown-item(command="created") New - el-dropdown-item(command="mine") Mine - span(style="margin-left:10px;font-size:12px;color:#909399") Avatar search is not possible. - .x-friend-list - .x-friend-item(v-for="avatar in searchAvatarResults" :key="avatar.id" @click="showAvatarDialog(avatar.id)") - template(v-once) - .avatar - img(v-lazy="avatar.thumbnailImageUrl") - .detail - span.name(v-text="avatar.name") - span.extra(v-text="avatar.authorName") - el-button-group(style="margin-top:15px") - el-button(v-if="searchAvatarParams.offset" @click="moreSearchAvatar(-1)" icon="el-icon-back" size="small") Prev - el-button(v-if="searchAvatarResults.length" @click="moreSearchAvatar(1)" icon="el-icon-right" size="small") Next + //- search + .x-container(v-show="$refs.menu && $refs.menu.activeIndex === 'search'") + div(style="margin:0 0 10px;display:flex;align-items:center") + el-input(v-model="searchText" clearable placeholder="Search" @keyup.native.13="search()" style="flex:1") + el-button(type="default" @click="clearSearch()" icon="el-icon-delete" circle style="flex:none;margin-left:10px") + el-tabs(ref="searchTab" type="card" style="margin-top:15px") + el-tab-pane(label="User" v-loading="isSearchUserLoading" style="min-height:60px") + .x-friend-list + .x-friend-item(v-for="user in searchUserResults" :key="user.id" @click="showUserDialog(user.id)") + template(v-once) + .avatar + img(v-lazy="user.currentAvatarThumbnailImageUrl") + .detail + span.name(v-text="user.displayName" :class="user.trustClass") + span.extra(v-text="user.username" style="font-family:monospace") + el-button-group(style="margin-top:15px") + el-button(v-if="searchUserParams.offset" @click="moreSearchUser(-1)" icon="el-icon-back" size="small") Prev + el-button(v-if="searchUserResults.length" @click="moreSearchUser(1)" icon="el-icon-right" size="small") Next + el-tab-pane(label="World" v-loading="isSearchWorldLoading" style="min-height:60px") + el-dropdown(@command="(row) => searchWorld(row)" size="small" trigger="click" style="margin-bottom:15px") + el-button(size="small") Search by Category #[i.el-icon-arrow-down.el-icon--right] + el-dropdown-menu(#default="dropdown") + el-dropdown-item(v-for="row in API.cachedConfig.dynamicWorldRows" :key="row.index" v-text="row.name" :command="row") + .x-friend-list + .x-friend-item(v-for="world in searchWorldResults" :key="world.id" @click="showWorldDialog(world.id)") + template(v-once) + .avatar + img(v-lazy="world.thumbnailImageUrl") + .detail + span.name(v-text="world.name") + span.extra(v-if="world.occupants") {{ world.authorName }} ({{ world.occupants }}) + span.extra(v-else v-text="world.authorName") + el-button-group(style="margin-top:15px") + el-button(v-if="searchWorldParams.offset" @click="moreSearchWorld(-1)" icon="el-icon-back" size="small") Prev + el-button(v-if="searchWorldResults.length" @click="moreSearchWorld(1)" icon="el-icon-right" size="small") Next + el-tab-pane(label="Avatar" v-loading="isSearchAvatarLoading" style="min-height:60px") + el-dropdown(@command="(command) => searchAvatar(command)" size="small" trigger="click" style="margin-bottom:15px") + el-button(size="small") Search by Category #[i.el-icon-arrow-down.el-icon--right] + el-dropdown-menu(#default="dropdown") + el-dropdown-item(command="updated") Updated Recently + el-dropdown-item(command="created") New + el-dropdown-item(command="mine") Mine + span(style="margin-left:10px;font-size:12px;color:#909399") Avatar search is not possible. + .x-friend-list + .x-friend-item(v-for="avatar in searchAvatarResults" :key="avatar.id" @click="showAvatarDialog(avatar.id)") + template(v-once) + .avatar + img(v-lazy="avatar.thumbnailImageUrl") + .detail + span.name(v-text="avatar.name") + span.extra(v-text="avatar.authorName") + el-button-group(style="margin-top:15px") + el-button(v-if="searchAvatarParams.offset" @click="moreSearchAvatar(-1)" icon="el-icon-back" size="small") Prev + el-button(v-if="searchAvatarResults.length" @click="moreSearchAvatar(1)" icon="el-icon-right" size="small") Next - //- favorite - .x-container(v-show="$refs.menu && $refs.menu.activeIndex === 'favorite'") - el-button(type="default" :loading="API.isFavoriteLoading" @click="API.refreshFavorites()" size="small" icon="el-icon-refresh" circle style="position:relative;float:right;z-index:1") - el-tabs(type="card" v-loading="API.isFavoriteLoading") - el-tab-pane(label="Friend") - el-collapse(style="border:0") - el-collapse-item(v-for="group in API.favoriteFriendGroups" :key="group.name") - template(slot="title") - span(v-text="group.displayName" style="font-weight:bold;font-size:14px;margin-left:10px") - span(style="color:#909399;font-size:12px;margin-left:10px") {{ group.count }}/{{ group.capacity }} - el-button(@click.stop="changeFavoriteGroupName(group)" size="mini" icon="el-icon-edit" circle style="margin-left:10px") - el-button(@click.stop="clearFavoriteGroup(group)" size="mini" icon="el-icon-delete" circle style="margin-left:5px") - .x-friend-list(v-if="group.count" style="margin-top:10px") - .x-friend-item(v-for="favorite in favoriteFriends" v-if="favorite.groupKey === group.key" :key="favorite.id" @click="showUserDialog(favorite.id)") - template(v-if="favorite.ref") - .avatar(:class="userStatusClass(favorite.ref)") - img(v-lazy="favorite.ref.currentAvatarThumbnailImageUrl") - .detail - span.name(v-text="favorite.ref.displayName" :class="favorite.ref.$trustClass") - location.extra(v-if="favorite.ref.location !== 'offline'" :location="favorite.ref.location" :link="false") - span(v-else v-text="favorite.ref.statusDescription") - template(v-else) - span(v-text="favorite.name || favorite.id") - el-button(type="text" icon="el-icon-close" size="mini" @click.stop="deleteFavorite(favorite.id)" style="margin-left:5px") - el-tab-pane(label="World") - el-collapse(style="border:0") - el-collapse-item(v-for="group in API.favoriteWorldGroups" :key="group.name") - template(slot="title") - span(v-text="group.displayName" style="font-weight:bold;font-size:14px;margin-left:10px") - span(style="color:#909399;font-size:12px;margin-left:10px") {{ group.count }}/{{ group.capacity }} - el-button(@click.stop="changeFavoriteGroupName(group)" size="mini" icon="el-icon-edit" circle style="margin-left:10px") - el-button(@click.stop="clearFavoriteGroup(group)" size="mini" icon="el-icon-delete" circle style="margin-left:5px") - .x-friend-list(v-if="group.count" style="margin-top:10px") - .x-friend-item(v-for="favorite in favoriteWorlds" v-if="favorite.groupKey === group.key" :key="favorite.id" @click="showWorldDialog(favorite.id)") - template(v-if="favorite.ref") - .avatar - img(v-lazy="favorite.ref.thumbnailImageUrl") - .detail - span.name(v-text="favorite.ref.name") - span.extra(v-if="favorite.ref.occupants") {{ favorite.ref.authorName }} ({{ favorite.ref.occupants }}) - span.extra(v-else v-text="favorite.ref.authorName") - template(v-else) - span(v-text="favorite.name || favorite.id") - el-button(type="text" icon="el-icon-close" size="mini" @click.stop="deleteFavorite(favorite.id)" style="margin-left:5px") - el-tab-pane(label="Avatar") - el-collapse(style="border:0") - el-collapse-item(v-for="group in API.favoriteAvatarGroups" :key="group.name") - template(slot="title") - span(v-text="group.displayName" style="font-weight:bold;font-size:14px;margin-left:10px") - span(style="color:#909399;font-size:12px;margin-left:10px") {{ group.count }}/{{ group.capacity }} - el-button(@click.stop="changeFavoriteGroupName(group)" size="mini" icon="el-icon-edit" circle style="margin-left:10px") - el-button(@click.stop="clearFavoriteGroup(group)" size="mini" icon="el-icon-delete" circle style="margin-left:5px") - .x-friend-list(v-if="group.count" style="margin-top:10px") - .x-friend-item(v-for="favorite in favoriteAvatars" v-if="favorite.groupKey === group.key" :key="favorite.id" @click="showAvatarDialog(favorite.id)") - template(v-if="favorite.ref") - .avatar - img(v-lazy="favorite.ref.thumbnailImageUrl") - .detail - span.name(v-text="favorite.ref.name") - span.extra(v-text="favorite.ref.authorName") - template(v-else) - span(v-text="favorite.name || favorite.id") - el-button(type="text" icon="el-icon-close" size="mini" @click.stop="deleteFavorite(favorite.id)" style="margin-left:5px") + //- favorite + .x-container(v-show="$refs.menu && $refs.menu.activeIndex === 'favorite'") + el-button(type="default" :loading="API.isFavoriteLoading" @click="API.refreshFavorites()" size="small" icon="el-icon-refresh" circle style="position:relative;float:right;z-index:1") + el-tabs(type="card" v-loading="API.isFavoriteLoading") + el-tab-pane(label="Friend") + el-collapse(style="border:0") + el-collapse-item(v-for="group in API.favoriteFriendGroups" :key="group.name") + template(slot="title") + span(v-text="group.displayName" style="font-weight:bold;font-size:14px;margin-left:10px") + span(style="color:#909399;font-size:12px;margin-left:10px") {{ group.count }}/{{ group.capacity }} + el-button(@click.stop="changeFavoriteGroupName(group)" size="mini" icon="el-icon-edit" circle style="margin-left:10px") + el-button(@click.stop="clearFavoriteGroup(group)" size="mini" icon="el-icon-delete" circle style="margin-left:5px") + .x-friend-list(v-if="group.count" style="margin-top:10px") + .x-friend-item(v-for="favorite in favoriteFriends" v-if="favorite.groupKey === group.key" :key="favorite.id" @click="showUserDialog(favorite.id)") + template(v-if="favorite.ref") + .avatar(:class="userStatusClass(favorite.ref)") + img(v-lazy="favorite.ref.currentAvatarThumbnailImageUrl") + .detail + span.name(v-text="favorite.ref.displayName" :class="favorite.ref.$trustClass") + location.extra(v-if="favorite.ref.location !== 'offline'" :location="favorite.ref.location" :link="false") + span(v-else v-text="favorite.ref.statusDescription") + template(v-else) + span(v-text="favorite.name || favorite.id") + el-button(type="text" icon="el-icon-close" size="mini" @click.stop="deleteFavorite(favorite.id)" style="margin-left:5px") + el-tab-pane(label="World") + el-collapse(style="border:0") + el-collapse-item(v-for="group in API.favoriteWorldGroups" :key="group.name") + template(slot="title") + span(v-text="group.displayName" style="font-weight:bold;font-size:14px;margin-left:10px") + span(style="color:#909399;font-size:12px;margin-left:10px") {{ group.count }}/{{ group.capacity }} + el-button(@click.stop="changeFavoriteGroupName(group)" size="mini" icon="el-icon-edit" circle style="margin-left:10px") + el-button(@click.stop="clearFavoriteGroup(group)" size="mini" icon="el-icon-delete" circle style="margin-left:5px") + .x-friend-list(v-if="group.count" style="margin-top:10px") + .x-friend-item(v-for="favorite in favoriteWorlds" v-if="favorite.groupKey === group.key" :key="favorite.id" @click="showWorldDialog(favorite.id)") + template(v-if="favorite.ref") + .avatar + img(v-lazy="favorite.ref.thumbnailImageUrl") + .detail + span.name(v-text="favorite.ref.name") + span.extra(v-if="favorite.ref.occupants") {{ favorite.ref.authorName }} ({{ favorite.ref.occupants }}) + span.extra(v-else v-text="favorite.ref.authorName") + template(v-else) + span(v-text="favorite.name || favorite.id") + el-button(type="text" icon="el-icon-close" size="mini" @click.stop="deleteFavorite(favorite.id)" style="margin-left:5px") + el-tab-pane(label="Avatar") + el-collapse(style="border:0") + el-collapse-item(v-for="group in API.favoriteAvatarGroups" :key="group.name") + template(slot="title") + span(v-text="group.displayName" style="font-weight:bold;font-size:14px;margin-left:10px") + span(style="color:#909399;font-size:12px;margin-left:10px") {{ group.count }}/{{ group.capacity }} + el-button(@click.stop="changeFavoriteGroupName(group)" size="mini" icon="el-icon-edit" circle style="margin-left:10px") + el-button(@click.stop="clearFavoriteGroup(group)" size="mini" icon="el-icon-delete" circle style="margin-left:5px") + .x-friend-list(v-if="group.count" style="margin-top:10px") + .x-friend-item(v-for="favorite in favoriteAvatars" v-if="favorite.groupKey === group.key" :key="favorite.id" @click="showAvatarDialog(favorite.id)") + template(v-if="favorite.ref") + .avatar + img(v-lazy="favorite.ref.thumbnailImageUrl") + .detail + span.name(v-text="favorite.ref.name") + span.extra(v-text="favorite.ref.authorName") + template(v-else) + span(v-text="favorite.name || favorite.id") + el-button(type="text" icon="el-icon-close" size="mini" @click.stop="deleteFavorite(favorite.id)" style="margin-left:5px") - //- friendLog - .x-container(v-show="$refs.menu && $refs.menu.activeIndex === 'friendLog'") - data-tables(v-bind="friendLogTable") - template(#tool) - div(style="margin:0 0 10px;display:flex;align-items:center") - el-select(v-model="friendLogTable.filters[0].value" multiple clearable collapse-tags style="flex:1" placeholder="Filter") - el-option(v-once v-for="type in ['Friend', 'Unfriend', 'FriendRequest', 'CancelFriendRequest', 'DisplayName', 'TrustLevel']" :key="type" :label="type" :value="type") - el-input(v-model="friendLogTable.filters[1].value" placeholder="Search" style="flex:none;width:150px;margin-left:10px") - el-table-column(label="Date" prop="created_at" sortable="custom" width="120") - template(v-once #default="scope") - el-tooltip(placement="right") - template(#content) - span {{ scope.row.created_at | formatDate('YYYY-MM-DD HH24:MI:SS') }} - span {{ scope.row.created_at | formatDate('MM-DD HH24:MI') }} - el-table-column(label="Type" prop="type" width="150") - el-table-column(label="User" prop="displayName") - template(v-once #default="scope") - span(v-if="scope.row.type === 'DisplayName'") {{ scope.row.previousDisplayName }} #[i.el-icon-right] - |   - span.x-link(v-text="scope.row.displayName || scope.row.userId" @click="showUserDialog(scope.row.userId)") - template(v-if="scope.row.type === 'TrustLevel'") - br - span ({{ scope.row.previousTrustLevel }} #[i.el-icon-right] {{ scope.row.trustLevel }}) - el-table-column(label="Action" width="80" align="right") - template(v-once #default="scope") - el-button(type="text" icon="el-icon-close" size="mini" @click="deleteFriendLog(scope.row)") + //- friendLog + .x-container(v-show="$refs.menu && $refs.menu.activeIndex === 'friendLog'") + data-tables(v-bind="friendLogTable") + template(#tool) + div(style="margin:0 0 10px;display:flex;align-items:center") + el-select(v-model="friendLogTable.filters[0].value" multiple clearable collapse-tags style="flex:1" placeholder="Filter") + el-option(v-once v-for="type in ['Friend', 'Unfriend', 'FriendRequest', 'CancelFriendRequest', 'DisplayName', 'TrustLevel']" :key="type" :label="type" :value="type") + el-input(v-model="friendLogTable.filters[1].value" placeholder="Search" style="flex:none;width:150px;margin-left:10px") + el-table-column(label="Date" prop="created_at" sortable="custom" width="120") + template(v-once #default="scope") + el-tooltip(placement="right") + template(#content) + span {{ scope.row.created_at | formatDate('YYYY-MM-DD HH24:MI:SS') }} + span {{ scope.row.created_at | formatDate('MM-DD HH24:MI') }} + el-table-column(label="Type" prop="type" width="150") + el-table-column(label="User" prop="displayName") + template(v-once #default="scope") + span(v-if="scope.row.type === 'DisplayName'") {{ scope.row.previousDisplayName }} #[i.el-icon-right] + |   + span.x-link(v-text="scope.row.displayName || scope.row.userId" @click="showUserDialog(scope.row.userId)") + template(v-if="scope.row.type === 'TrustLevel'") + br + span ({{ scope.row.previousTrustLevel }} #[i.el-icon-right] {{ scope.row.trustLevel }}) + el-table-column(label="Action" width="80" align="right") + template(v-once #default="scope") + el-button(type="text" icon="el-icon-close" size="mini" @click="deleteFriendLog(scope.row)") - //- moderation - .x-container(v-show="$refs.menu && $refs.menu.activeIndex === 'moderation'") - data-tables(v-bind="playerModerationTable" v-loading="API.isPlayerModerationsLoading") - template(#tool) - div(style="margin:0 0 10px;display:flex;align-items:center") - el-select(v-model="playerModerationTable.filters[0].value" multiple clearable collapse-tags style="flex:1" placeholder="Filter") - el-option(v-once v-for="type in ['block', 'mute', 'unmute', 'hideAvatar', 'showAvatar']" :key="type" :label="type" :value="type") - el-input(v-model="playerModerationTable.filters[1].value" placeholder="Search" style="flex:none;width:150px;margin:0 10px") - el-button(type="default" :loading="API.isPlayerModerationsLoading" @click="API.refreshPlayerModerations()" icon="el-icon-refresh" circle style="flex:none") - el-table-column(label="Date" prop="created" sortable="custom" width="120") - template(v-once #default="scope") - el-tooltip(placement="right") - template(#content) - span {{ scope.row.created | formatDate('YYYY-MM-DD HH24:MI:SS') }} - span {{ scope.row.created | formatDate('MM-DD HH24:MI') }} - el-table-column(label="Type" prop="type" width="120") - el-table-column(label="Source" prop="sourceDisplayName") - template(v-once #default="scope") - span.x-link(v-text="scope.row.sourceDisplayName" @click="showUserDialog(scope.row.sourceUserId)") - el-table-column(label="Target" prop="targetDisplayName") - template(v-once #default="scope") - span.x-link(v-text="scope.row.targetDisplayName" @click="showUserDialog(scope.row.targetUserId)") - el-table-column(label="Action" width="80" align="right") - template(v-once #default="scope") - el-button(v-if="scope.row.sourceUserId === API.currentUser.id" type="text" icon="el-icon-close" size="mini" @click="deletePlayerModeration(scope.row)") + //- moderation + .x-container(v-show="$refs.menu && $refs.menu.activeIndex === 'moderation'") + data-tables(v-bind="playerModerationTable" v-loading="API.isPlayerModerationsLoading") + template(#tool) + div(style="margin:0 0 10px;display:flex;align-items:center") + el-select(v-model="playerModerationTable.filters[0].value" multiple clearable collapse-tags style="flex:1" placeholder="Filter") + el-option(v-once v-for="type in ['block', 'mute', 'unmute', 'hideAvatar', 'showAvatar']" :key="type" :label="type" :value="type") + el-input(v-model="playerModerationTable.filters[1].value" placeholder="Search" style="flex:none;width:150px;margin:0 10px") + el-button(type="default" :loading="API.isPlayerModerationsLoading" @click="API.refreshPlayerModerations()" icon="el-icon-refresh" circle style="flex:none") + el-table-column(label="Date" prop="created" sortable="custom" width="120") + template(v-once #default="scope") + el-tooltip(placement="right") + template(#content) + span {{ scope.row.created | formatDate('YYYY-MM-DD HH24:MI:SS') }} + span {{ scope.row.created | formatDate('MM-DD HH24:MI') }} + el-table-column(label="Type" prop="type" width="120") + el-table-column(label="Source" prop="sourceDisplayName") + template(v-once #default="scope") + span.x-link(v-text="scope.row.sourceDisplayName" @click="showUserDialog(scope.row.sourceUserId)") + el-table-column(label="Target" prop="targetDisplayName") + template(v-once #default="scope") + span.x-link(v-text="scope.row.targetDisplayName" @click="showUserDialog(scope.row.targetUserId)") + el-table-column(label="Action" width="80" align="right") + template(v-once #default="scope") + el-button(v-if="scope.row.sourceUserId === API.currentUser.id" type="text" icon="el-icon-close" size="mini" @click="deletePlayerModeration(scope.row)") - //- notification - .x-container(v-show="$refs.menu && $refs.menu.activeIndex === 'notification'" v-loading="API.isNotificationsLoading") - data-tables(v-bind="notificationTable") - template(#tool) - div(style="margin:0 0 10px;display:flex;align-items:center") - el-select(v-model="notificationTable.filters[0].value" multiple clearable collapse-tags style="flex:1" placeholder="Filter") - el-option(v-once v-for="type in ['requestInvite', 'invite', 'friendRequest', 'message']" :key="type" :label="type" :value="type") - el-input(v-model="notificationTable.filters[1].value" placeholder="Search" style="flex:none;width:150px;margin:0 10px") - el-button(type="default" :loading="API.isNotificationsLoading" @click="API.refreshNotifications()" icon="el-icon-refresh" circle style="flex:none") - el-table-column(label="Date" prop="created_at" sortable="custom" width="120") - template(v-once #default="scope") - el-tooltip(placement="right") - template(#content) - span {{ scope.row.created_at | formatDate('YYYY-MM-DD HH24:MI:SS') }} - span {{ scope.row.created_at | formatDate('MM-DD HH24:MI') }} - el-table-column(label="Type" prop="type" width="120") - template(v-once #default="scope") - el-tooltip(placement="top" v-if="scope.row.type === 'invite'") - template(#content) - span(v-text="API.parseInviteLocation(scope.row)") - span.x-link(v-text="scope.row.type" @click="showWorldDialog(scope.row.details.worldId)") - span(v-else v-text="scope.row.type") - el-table-column(label="User" prop="senderUsername") - template(v-once #default="scope") - span.x-link(v-text="scope.row.senderUsername" @click="showUserDialog(scope.row.senderUserId)") - el-table-column(label="Action" width="80" align="right") - template(v-once #default="scope") - el-button(v-if="scope.row.type === 'friendRequest'" type="text" icon="el-icon-check" size="mini" @click="acceptNotification(scope.row)") - el-button(type="text" icon="el-icon-close" size="mini" @click="hideNotification(scope.row)") + //- notification + .x-container(v-show="$refs.menu && $refs.menu.activeIndex === 'notification'" v-loading="API.isNotificationsLoading") + data-tables(v-bind="notificationTable") + template(#tool) + div(style="margin:0 0 10px;display:flex;align-items:center") + el-select(v-model="notificationTable.filters[0].value" multiple clearable collapse-tags style="flex:1" placeholder="Filter") + el-option(v-once v-for="type in ['requestInvite', 'invite', 'friendRequest', 'message']" :key="type" :label="type" :value="type") + el-input(v-model="notificationTable.filters[1].value" placeholder="Search" style="flex:none;width:150px;margin:0 10px") + el-button(type="default" :loading="API.isNotificationsLoading" @click="API.refreshNotifications()" icon="el-icon-refresh" circle style="flex:none") + el-table-column(label="Date" prop="created_at" sortable="custom" width="120") + template(v-once #default="scope") + el-tooltip(placement="right") + template(#content) + span {{ scope.row.created_at | formatDate('YYYY-MM-DD HH24:MI:SS') }} + span {{ scope.row.created_at | formatDate('MM-DD HH24:MI') }} + el-table-column(label="Type" prop="type" width="120") + template(v-once #default="scope") + el-tooltip(placement="top" v-if="scope.row.type === 'invite'") + template(#content) + span(v-text="API.parseInviteLocation(scope.row)") + span.x-link(v-text="scope.row.type" @click="showWorldDialog(scope.row.details.worldId)") + span(v-else v-text="scope.row.type") + el-table-column(label="User" prop="senderUsername") + template(v-once #default="scope") + span.x-link(v-text="scope.row.senderUsername" @click="showUserDialog(scope.row.senderUserId)") + el-table-column(label="Action" width="80" align="right") + template(v-once #default="scope") + el-button(v-if="scope.row.type === 'friendRequest'" type="text" icon="el-icon-check" size="mini" @click="acceptNotification(scope.row)") + el-button(type="text" icon="el-icon-close" size="mini" @click="hideNotification(scope.row)") - //- more - .x-container(v-show="$refs.menu && $refs.menu.activeIndex === 'more'") - div - span(style="font-weight:bold") VRCX - .x-friend-list(style="margin-top:10px") - .x-friend-item - .detail - span.name Version - span.extra(v-text="appVersion") - .x-friend-item(@click="checkAppVersion()") - .detail - span.name Latest Version - span.extra(v-if="latestAppVersion" v-text="latestAppVersion") - span.extra(v-else) Click to refresh - .x-friend-item(@click="openExternalLink('https://github.com/pypy-vrc/VRCX')") - .detail - span.name Repository URL - span.extra https://github.com/pypy-vrc/VRCX - div(style="margin-top:30px") - span(style="font-weight:bold") Direct Access - div(style="margin-top:5px") - el-button-group - el-button(size="small" @click="promptUserDialog()") User - el-button(size="small" @click="promptWorldDialog()") World - el-button(size="small" @click="promptAvatarDialog()") Avatar - div(style="margin-top:30px") - span(style="font-weight:bold") My Profile - .x-friend-list(style="margin-top:10px") - .x-friend-item(@click="showUserDialog(API.currentUser.id)") - .avatar - img(v-lazy="API.currentUser.currentAvatarThumbnailImageUrl") - .detail - span.name(v-text="API.currentUser.displayName") - span.extra(v-text="API.currentUser.username") - .x-friend-item(@click="showSocialStatusDialog()") - .detail - span.name #[i.el-icon-edit] Social Status - span.extra - el-tooltip(placement="top") - template(#content) - span(v-if="API.currentUser.status === 'active'") Online - span(v-else-if="API.currentUser.status === 'join me'") Join Me - span(v-else-if="API.currentUser.status === 'ask me'") Ask Me - span(v-else-if="API.currentUser.status === 'busy'") Do Not Disturb - span(v-else) Offline - i.x-user-status(:class="userStatusClass(API.currentUser)") - span(v-text="API.currentUser.statusDescription") - .x-friend-item(@click="showBioDialog()") - .detail - span.name #[i.el-icon-edit] Bio - pre.extra(style="font-family:inherit;font-size:12px;white-space:pre-wrap;margin:0 0.5em 0 0") {{ API.currentUser.bio || '-' }} - div(style="margin-top:5px") - el-tooltip(v-for="(link, index) in API.currentUser.bioLinks" :key="index") - template(#content) - span(v-text="link") - img(:src="getFaviconUrl(link)" style="width:16px;height:16px;vertical-align:middle;margin-right:5px" @click.stop="openExternalLink(link)") - .x-friend-item - .detail - span.name Languages - span.extra - div(style="margin:5px 0") - el-tag(v-for="item in API.currentUser.$languages" :key="item.key" size="small" type="info" effect="plain" closable @close="removeUserLanguage(item.key)" style="margin-right:5px") - span.famfamfam-flags(:class="languageClass(item.key)" style="display:inline-block;margin-right:5px") - | {{ item.value }} ({{ item.key }}) - div(v-if="userLanguageVisible") - el-select(v-model="userLanguageSelected" size="mini") - el-option(v-for="item in userLanguages" :key="item.key" :value="item.key" :label="item.value") - span.famfamfam-flags(:class="languageClass(item.key)" style="display:inline-block;margin-right:5px") - | {{ item.value }} ({{ item.key }}) - el-button(@click="userLanguageVisible=0; addUserLanguage(userLanguageSelected)" size="mini") Ok - el-button(@click="userLanguageVisible=0" size="mini" style="margin-left:0") Cancel - div(v-else) - el-button(@click="userLanguageSelected='';userLanguageVisible=1" size="mini") Add Language - .x-friend-item - .detail - span.name Last Login - span.extra {{ API.currentUser.last_login | formatDate('YYYY-MM-DD HH24:MI:SS') }} - .x-friend-item - .detail - span.name Two-Factor Auth (2FA) - span.extra {{ API.currentUser.twoFactorAuthEnabled ? 'Enabled' : 'Disabled' }} - .x-friend-item(@click="showUserDialog(API.currentUser.id)") - .detail - span.name User ID - span.extra(v-text="API.currentUser.id") - .x-friend-item(@click="showAvatarDialog(API.currentUser.currentAvatar)") - .detail - span.name Avatar ID - span.extra(v-text="API.currentUser.currentAvatar") - .x-friend-item(v-if="API.currentUser.homeLocation" @click="showWorldDialog(API.currentUser.homeLocation)") - .detail - span.name Home Location - span.extra - location(:location="API.currentUser.homeLocation" :link="false") - el-button(@click.stop="resetHome()" size="mini" icon="el-icon-delete" circle style="margin-left:5px") - div(style="margin-top:10px") - el-button-group - el-button(size="small" icon="el-icon-switch-button" @click="logout()") Logout - div(style="margin-top:30px") - span(style="font-weight:bold") Past Display Names - data-tables(v-bind="pastDisplayNameTable" style="margin-top:5px") - el-table-column(label="Date" prop="updated_at" sortable="custom") - template(v-once #default="scope") - span {{ scope.row.updated_at | formatDate('YYYY-MM-DD HH24:MI:SS') }} - el-table-column(label="Name" prop="displayName") - div(style="margin-top:30px") - span(style="font-weight:bold") JSON - el-button(type="default" @click="refreshCurrentUserTreeData()" size="mini" icon="el-icon-refresh" circle style="margin-left:5px") - el-button(type="default" @click="currentUserTreeData = []" size="mini" icon="el-icon-delete" circle style="margin-left:0") - el-tree(:data="currentUserTreeData" style="margin-top:5px;font-size:12px") - template(#default="scope") - span - span(v-text="scope.data.key" style="font-weight:bold;margin-right:5px") - span(v-if="!scope.data.children" v-text="scope.data.value") - div(style="margin-top:30px") - span(style="font-weight:bold") Game Info - .x-friend-list(style="margin-top:10px") - .x-friend-item - .detail(@click="API.getVisits()") - span.name Online Users - span.extra(v-if="visits") {{visits}} users online. - span.extra(v-else) Click to refresh - .x-friend-item - .detail - span.name SDK Unity Version - span.extra(v-text="API.cachedConfig.sdkUnityVersion") - .template(v-if="API.cachedConfig.downloadUrls") - .x-friend-item - .detail - span.name VRCSDK2 URL - span.extra(v-text="API.cachedConfig.downloadUrls.sdk2") - .x-friend-item - .detail - span.name VRCSDK3 URL - span.extra(v-text="API.cachedConfig.downloadUrls.sdk3") - div(style="margin-top:30px") - span(style="font-weight:bold") Friends Sort Option - div(style="font-size:12px;margin-top:5px") - span(style="display:inline-block;min-width:150px") VIP - el-switch(v-model="orderFriendsGroup0" inactive-text="by name" active-text="by state") - div(style="font-size:12px;margin-top:5px") - span(style="display:inline-block;min-width:150px") ONLINE - el-switch(v-model="orderFriendsGroup1" inactive-text="by name" active-text="by state") - div(style="font-size:12px;margin-top:5px") - span(style="display:inline-block;min-width:150px") ACTIVE - el-switch(v-model="orderFriendsGroup2" inactive-text="by name" active-text="by state") - div(style="font-size:12px;margin-top:5px") - span(style="display:inline-block;min-width:150px") OFFLINE - el-switch(v-model="orderFriendsGroup3" inactive-text="by name" active-text="by state") - div(style="margin-top:30px") - span(style="font-weight:bold") Dark Mode - div(style="font-size:12px;margin-top:5px") - span(style="display:inline-block;min-width:150px") Enable - el-switch(v-model="isDarkMode") - div(style="margin-top:30px") - span(style="font-weight:bold") Discord Presence - div(style="font-size:12px;margin-top:5px") - span * Only works when VRChat is running. - div(style="font-size:12px;margin-top:5px") - span(style="display:inline-block;min-width:150px") Enable - el-switch(v-model="discordActive") - div(style="font-size:12px;margin-top:5px") - span(style="display:inline-block;min-width:150px") Instance details - el-switch(v-model="discordInstance") - div(style="margin-top:30px") - span(style="font-weight:bold") SteamVR Overlay - div(style="font-size:12px;margin-top:5px") - span * It runs automatically when VRChat is running. - br - span Vive or Other Controller: Grab Button - br - span Oculus Controller: B Button - br - div(style="font-size:12px;margin-top:5px") - span(style="display:inline-block;min-width:150px") Enable - el-switch(v-model="openVR") - div(style="font-size:12px;margin-top:5px") - span(style="display:inline-block;min-width:150px") Force Run - el-switch(v-model="openVRAlways") - div(style="margin-top:30px") - span(style="font-weight:bold") Window - div(style="font-size:12px;margin-top:5px") - span(style="display:inline-block;min-width:150px") Start at Windows startup - el-switch(v-model="isStartAtWindowsStartup") - div(style="font-size:12px;margin-top:5px") - span(style="display:inline-block;min-width:150px") Start as minimized state - el-switch(v-model="isStartAsMinimizedState") - div(style="font-size:12px;margin-top:5px") - span(style="display:inline-block;min-width:150px") Close to tray - el-switch(v-model="isCloseToTray") - div(style="margin-top:45px;border-top:1px solid #eee;padding-top:30px") - span(style="font-weight:bold") Legal Notice - div(style="margin-top:5px;font-size:12px") - p © 2019-2020 #[a(href="https://github.com/pypy-vrc" target="_blank") pypy] (mina#5656) - p VRCX is an assistant application for provide information about manage friendship. this application uses unofficial VRChat API (VRCSDK). - p VRCX isn't endorsed by VRChat and doesn't reflect the views or opinions of VRChat or anyone officially involved in producing or managing VRChat. VRChat is trademark of VRChat Inc. VRChat © VRChat Inc. - p pypy is not responsible for any problems caused by VRCX. Use at your own risk! - div(style="margin-top:5px;font-size:12px") - el-button(@click="ossDialog = true" size="small") Open Source Software Notice + //- more + .x-container(v-show="$refs.menu && $refs.menu.activeIndex === 'more'") + div + span(style="font-weight:bold") VRCX + .x-friend-list(style="margin-top:10px") + .x-friend-item + .detail + span.name Version + span.extra(v-text="appVersion") + .x-friend-item(@click="checkAppVersion()") + .detail + span.name Latest Version + span.extra(v-if="latestAppVersion" v-text="latestAppVersion") + span.extra(v-else) Click to refresh + .x-friend-item(@click="openExternalLink('https://github.com/pypy-vrc/VRCX')") + .detail + span.name Repository URL + span.extra https://github.com/pypy-vrc/VRCX + div(style="margin-top:30px") + span(style="font-weight:bold") Direct Access + div(style="margin-top:5px") + el-button-group + el-button(size="small" @click="promptUserDialog()") User + el-button(size="small" @click="promptWorldDialog()") World + el-button(size="small" @click="promptAvatarDialog()") Avatar + div(style="margin-top:30px") + span(style="font-weight:bold") My Profile + .x-friend-list(style="margin-top:10px") + .x-friend-item(@click="showUserDialog(API.currentUser.id)") + .avatar + img(v-lazy="API.currentUser.currentAvatarThumbnailImageUrl") + .detail + span.name(v-text="API.currentUser.displayName") + span.extra(v-text="API.currentUser.username") + .x-friend-item(@click="showSocialStatusDialog()") + .detail + span.name #[i.el-icon-edit] Social Status + span.extra + el-tooltip(placement="top") + template(#content) + span(v-if="API.currentUser.status === 'active'") Online + span(v-else-if="API.currentUser.status === 'join me'") Join Me + span(v-else-if="API.currentUser.status === 'ask me'") Ask Me + span(v-else-if="API.currentUser.status === 'busy'") Do Not Disturb + span(v-else) Offline + i.x-user-status(:class="userStatusClass(API.currentUser)") + span(v-text="API.currentUser.statusDescription") + .x-friend-item(@click="showBioDialog()") + .detail + span.name #[i.el-icon-edit] Bio + pre.extra(style="font-family:inherit;font-size:12px;white-space:pre-wrap;margin:0 0.5em 0 0") {{ API.currentUser.bio || '-' }} + div(style="margin-top:5px") + el-tooltip(v-for="(link, index) in API.currentUser.bioLinks" :key="index") + template(#content) + span(v-text="link") + img(:src="getFaviconUrl(link)" style="width:16px;height:16px;vertical-align:middle;margin-right:5px" @click.stop="openExternalLink(link)") + .x-friend-item + .detail + span.name Languages + span.extra + div(style="margin:5px 0") + el-tag(v-for="item in API.currentUser.$languages" :key="item.key" size="small" type="info" effect="plain" closable @close="removeUserLanguage(item.key)" style="margin-right:5px") + span.famfamfam-flags(:class="languageClass(item.key)" style="display:inline-block;margin-right:5px") + | {{ item.value }} ({{ item.key }}) + div(v-if="userLanguageVisible") + el-select(v-model="userLanguageSelected" size="mini") + el-option(v-for="item in userLanguages" :key="item.key" :value="item.key" :label="item.value") + span.famfamfam-flags(:class="languageClass(item.key)" style="display:inline-block;margin-right:5px") + | {{ item.value }} ({{ item.key }}) + el-button(@click="userLanguageVisible=0; addUserLanguage(userLanguageSelected)" size="mini") Ok + el-button(@click="userLanguageVisible=0" size="mini" style="margin-left:0") Cancel + div(v-else) + el-button(@click="userLanguageSelected='';userLanguageVisible=1" size="mini") Add Language + .x-friend-item + .detail + span.name Last Login + span.extra {{ API.currentUser.last_login | formatDate('YYYY-MM-DD HH24:MI:SS') }} + .x-friend-item + .detail + span.name Two-Factor Auth (2FA) + span.extra {{ API.currentUser.twoFactorAuthEnabled ? 'Enabled' : 'Disabled' }} + .x-friend-item(@click="showUserDialog(API.currentUser.id)") + .detail + span.name User ID + span.extra(v-text="API.currentUser.id") + .x-friend-item(@click="showAvatarDialog(API.currentUser.currentAvatar)") + .detail + span.name Avatar ID + span.extra(v-text="API.currentUser.currentAvatar") + .x-friend-item(v-if="API.currentUser.homeLocation" @click="showWorldDialog(API.currentUser.homeLocation)") + .detail + span.name Home Location + span.extra + location(:location="API.currentUser.homeLocation" :link="false") + el-button(@click.stop="resetHome()" size="mini" icon="el-icon-delete" circle style="margin-left:5px") + div(style="margin-top:10px") + el-button-group + el-button(size="small" icon="el-icon-switch-button" @click="logout()") Logout + div(style="margin-top:30px") + span(style="font-weight:bold") Past Display Names + data-tables(v-bind="pastDisplayNameTable" style="margin-top:5px") + el-table-column(label="Date" prop="updated_at" sortable="custom") + template(v-once #default="scope") + span {{ scope.row.updated_at | formatDate('YYYY-MM-DD HH24:MI:SS') }} + el-table-column(label="Name" prop="displayName") + div(style="margin-top:30px") + span(style="font-weight:bold") JSON + el-button(type="default" @click="refreshCurrentUserTreeData()" size="mini" icon="el-icon-refresh" circle style="margin-left:5px") + el-button(type="default" @click="currentUserTreeData = []" size="mini" icon="el-icon-delete" circle style="margin-left:0") + el-tree(:data="currentUserTreeData" style="margin-top:5px;font-size:12px") + template(#default="scope") + span + span(v-text="scope.data.key" style="font-weight:bold;margin-right:5px") + span(v-if="!scope.data.children" v-text="scope.data.value") + div(style="margin-top:30px") + span(style="font-weight:bold") Game Info + .x-friend-list(style="margin-top:10px") + .x-friend-item + .detail(@click="API.getVisits()") + span.name Online Users + span.extra(v-if="visits") {{visits}} users online. + span.extra(v-else) Click to refresh + .x-friend-item + .detail + span.name SDK Unity Version + span.extra(v-text="API.cachedConfig.sdkUnityVersion") + .template(v-if="API.cachedConfig.downloadUrls") + .x-friend-item + .detail + span.name VRCSDK2 URL + span.extra(v-text="API.cachedConfig.downloadUrls.sdk2") + .x-friend-item + .detail + span.name VRCSDK3 URL + span.extra(v-text="API.cachedConfig.downloadUrls.sdk3") + div(style="margin-top:30px") + span(style="font-weight:bold") Friends Sort Option + div(style="font-size:12px;margin-top:5px") + span(style="display:inline-block;min-width:150px") VIP + el-switch(v-model="orderFriendsGroup0" inactive-text="by name" active-text="by state") + div(style="font-size:12px;margin-top:5px") + span(style="display:inline-block;min-width:150px") ONLINE + el-switch(v-model="orderFriendsGroup1" inactive-text="by name" active-text="by state") + div(style="font-size:12px;margin-top:5px") + span(style="display:inline-block;min-width:150px") ACTIVE + el-switch(v-model="orderFriendsGroup2" inactive-text="by name" active-text="by state") + div(style="font-size:12px;margin-top:5px") + span(style="display:inline-block;min-width:150px") OFFLINE + el-switch(v-model="orderFriendsGroup3" inactive-text="by name" active-text="by state") + div(style="margin-top:30px") + span(style="font-weight:bold") Dark Mode + div(style="font-size:12px;margin-top:5px") + span(style="display:inline-block;min-width:150px") Enable + el-switch(v-model="isDarkMode") + div(style="margin-top:30px") + span(style="font-weight:bold") Discord Presence + div(style="font-size:12px;margin-top:5px") + span * Only works when VRChat is running. + div(style="font-size:12px;margin-top:5px") + span(style="display:inline-block;min-width:150px") Enable + el-switch(v-model="discordActive") + div(style="font-size:12px;margin-top:5px") + span(style="display:inline-block;min-width:150px") Instance details + el-switch(v-model="discordInstance") + div(style="margin-top:30px") + span(style="font-weight:bold") SteamVR Overlay + div(style="font-size:12px;margin-top:5px") + span * It runs automatically when VRChat is running. + br + span Vive or Other Controller: Grab Button + br + span Oculus Controller: B Button + br + div(style="font-size:12px;margin-top:5px") + span(style="display:inline-block;min-width:150px") Enable + el-switch(v-model="openVR") + div(style="font-size:12px;margin-top:5px") + span(style="display:inline-block;min-width:150px") Force Run + el-switch(v-model="openVRAlways") + div(style="margin-top:30px") + span(style="font-weight:bold") Window + div(style="font-size:12px;margin-top:5px") + span(style="display:inline-block;min-width:150px") Start at Windows startup + el-switch(v-model="isStartAtWindowsStartup") + div(style="font-size:12px;margin-top:5px") + span(style="display:inline-block;min-width:150px") Start as minimized state + el-switch(v-model="isStartAsMinimizedState") + div(style="font-size:12px;margin-top:5px") + span(style="display:inline-block;min-width:150px") Close to tray + el-switch(v-model="isCloseToTray") + div(style="margin-top:45px;border-top:1px solid #eee;padding-top:30px") + span(style="font-weight:bold") Legal Notice + div(style="margin-top:5px;font-size:12px") + p © 2019-2020 #[a(href="https://github.com/pypy-vrc" target="_blank") pypy] (mina#5656) + p VRCX is an assistant application for provide information about manage friendship. this application uses unofficial VRChat API (VRCSDK). + p VRCX isn't endorsed by VRChat and doesn't reflect the views or opinions of VRChat or anyone officially involved in producing or managing VRChat. VRChat is trademark of VRChat Inc. VRChat © VRChat Inc. + p pypy is not responsible for any problems caused by VRCX. Use at your own risk! + div(style="margin-top:5px;font-size:12px") + el-button(@click="ossDialog = true" size="small") Open Source Software Notice - //- friends - .x-aside-container - el-select(v-model="quickSearch" clearable placeholder="Search" filterable remote :remote-method="quickSearchRemoteMethod" popper-class="x-quick-search" @change="quickSearchChange" @visible-change="quickSearchVisibleChange" style="flex:none;padding:10px") - el-option(v-for="item in quickSearchItems" :key="item.value" :value="item.value" :label="item.label") - .x-friend-item - template(v-if="item.ref") - .detail - span.name(v-text="item.ref.displayName" :class="item.ref.$trustClass") - location.extra(:location="item.ref.location" :link="false") - img.avatar(v-lazy="item.ref.currentAvatarThumbnailImageUrl") - span(v-else) Search More: #[span(v-text="item.label" style="font-weight:bold")] - .x-friend-list(style="padding-bottom:10px") - .x-friend-group(v-show="friendsGroup0.length") - i.el-icon-arrow-right(:class="{ rotate: isFriendsGroup0 }") - span.x-link(@click="isFriendsGroup0 = !isFriendsGroup0" style="margin-left:5px") VIP―{{ friendsGroup0.length }} - div(v-show="isFriendsGroup0") - .x-friend-item(v-for="friend in friendsGroup0" :key="friend.id" @click="showUserDialog(friend.id)") - template(v-if="friend.ref") - .avatar(:class="userStatusClass(friend.ref)") - img(v-lazy="friend.ref.currentAvatarThumbnailImageUrl") - .detail - span.name(v-if="friend.memo" :class="friend.ref.$trustClass") {{ friend.ref.displayName }} ({{ friend.memo }}) - span.name(v-else v-text="friend.ref.displayName" :class="friend.ref.$trustClass") - location.extra(:location="friend.ref.location" :link="false") - template(v-else) - span(v-text="friend.name || friend.id") - el-button(type="text" icon="el-icon-close" size="mini" @click.stop="confirmDeleteFriend(friend.id)" style="margin-left:5px") - .x-friend-group(v-show="friendsGroup1.length") - i.el-icon-arrow-right(:class="{ rotate: isFriendsGroup1 }") - span.x-link(@click="isFriendsGroup1 = !isFriendsGroup1" style="margin-left:5px") ONLINE―{{ friendsGroup1.length }} - div(v-show="isFriendsGroup1") - .x-friend-item(v-for="friend in friendsGroup1" :key="friend.id" @click="showUserDialog(friend.id)") - template(v-if="friend.ref") - .avatar(:class="userStatusClass(friend.ref)") - img(v-lazy="friend.ref.currentAvatarThumbnailImageUrl") - .detail - span.name(v-if="friend.memo" :class="friend.ref.$trustClass") {{ friend.ref.displayName }} ({{ friend.memo }}) - span.name(v-else v-text="friend.ref.displayName" :class="friend.ref.$trustClass") - location.extra(:location="friend.ref.location" :link="false") - template(v-else) - span(v-text="friend.name || friend.id") - el-button(type="text" icon="el-icon-close" size="mini" @click.stop="confirmDeleteFriend(friend.id)" style="margin-left:5px") - .x-friend-group(v-show="friendsGroup2.length") - i.el-icon-arrow-right(:class="{ rotate: isFriendsGroup2 }") - span.x-link(@click="isFriendsGroup2 = !isFriendsGroup2" style="margin-left:5px") ACTIVE―{{ friendsGroup2.length }} - div(v-show="isFriendsGroup2") - .x-friend-item(v-for="friend in friendsGroup2" :key="friend.id" @click="showUserDialog(friend.id)") - template(v-if="friend.ref") - .avatar - img(v-lazy="friend.ref.currentAvatarThumbnailImageUrl") - .detail - span.name(v-if="friend.memo" :class="friend.ref.$trustClass") {{ friend.ref.displayName }} ({{ friend.memo }}) - span.name(v-else v-text="friend.ref.displayName" :class="friend.ref.$trustClass") - span.extra(v-text="friend.ref.statusDescription" :link="false") - template(v-else) - span(v-text="friend.name || friend.id") - el-button(type="text" icon="el-icon-close" size="mini" @click.stop="confirmDeleteFriend(friend.id)" style="margin-left:5px") - .x-friend-group(v-show="friendsGroup3.length") - i.el-icon-arrow-right(:class="{ rotate: isFriendsGroup3 }") - span.x-link(@click="isFriendsGroup3 = !isFriendsGroup3" style="margin-left:5px") OFFLINE―{{ friendsGroup3.length }} - div(v-show="isFriendsGroup3") - .x-friend-item(v-for="friend in friendsGroup3" :key="friend.id" @click="showUserDialog(friend.id)") - template(v-if="friend.ref") - .avatar - img(v-lazy="friend.ref.currentAvatarThumbnailImageUrl") - .detail - span.name(v-if="friend.memo" :class="friend.ref.$trustClass") {{ friend.ref.displayName }} ({{ friend.memo }}) - span.name(v-else v-text="friend.ref.displayName" :class="friend.ref.$trustClass") - span.extra(v-text="friend.ref.statusDescription") - template(v-else) - span(v-text="friend.name || friend.id") - el-button(type="text" icon="el-icon-close" size="mini" @click.stop="confirmDeleteFriend(friend.id)" style="margin-left:5px") + //- friends + .x-aside-container + el-select(v-model="quickSearch" clearable placeholder="Search" filterable remote :remote-method="quickSearchRemoteMethod" popper-class="x-quick-search" @change="quickSearchChange" @visible-change="quickSearchVisibleChange" style="flex:none;padding:10px") + el-option(v-for="item in quickSearchItems" :key="item.value" :value="item.value" :label="item.label") + .x-friend-item + template(v-if="item.ref") + .detail + span.name(v-text="item.ref.displayName" :class="item.ref.$trustClass") + location.extra(:location="item.ref.location" :link="false") + img.avatar(v-lazy="item.ref.currentAvatarThumbnailImageUrl") + span(v-else) Search More: #[span(v-text="item.label" style="font-weight:bold")] + .x-friend-list(style="padding-bottom:10px") + .x-friend-group(v-show="friendsGroup0.length") + i.el-icon-arrow-right(:class="{ rotate: isFriendsGroup0 }") + span.x-link(@click="isFriendsGroup0 = !isFriendsGroup0" style="margin-left:5px") VIP―{{ friendsGroup0.length }} + div(v-show="isFriendsGroup0") + .x-friend-item(v-for="friend in friendsGroup0" :key="friend.id" @click="showUserDialog(friend.id)") + template(v-if="friend.ref") + .avatar(:class="userStatusClass(friend.ref)") + img(v-lazy="friend.ref.currentAvatarThumbnailImageUrl") + .detail + span.name(v-if="friend.memo" :class="friend.ref.$trustClass") {{ friend.ref.displayName }} ({{ friend.memo }}) + span.name(v-else v-text="friend.ref.displayName" :class="friend.ref.$trustClass") + location.extra(:location="friend.ref.location" :link="false") + template(v-else) + span(v-text="friend.name || friend.id") + el-button(type="text" icon="el-icon-close" size="mini" @click.stop="confirmDeleteFriend(friend.id)" style="margin-left:5px") + .x-friend-group(v-show="friendsGroup1.length") + i.el-icon-arrow-right(:class="{ rotate: isFriendsGroup1 }") + span.x-link(@click="isFriendsGroup1 = !isFriendsGroup1" style="margin-left:5px") ONLINE―{{ friendsGroup1.length }} + div(v-show="isFriendsGroup1") + .x-friend-item(v-for="friend in friendsGroup1" :key="friend.id" @click="showUserDialog(friend.id)") + template(v-if="friend.ref") + .avatar(:class="userStatusClass(friend.ref)") + img(v-lazy="friend.ref.currentAvatarThumbnailImageUrl") + .detail + span.name(v-if="friend.memo" :class="friend.ref.$trustClass") {{ friend.ref.displayName }} ({{ friend.memo }}) + span.name(v-else v-text="friend.ref.displayName" :class="friend.ref.$trustClass") + location.extra(:location="friend.ref.location" :link="false") + template(v-else) + span(v-text="friend.name || friend.id") + el-button(type="text" icon="el-icon-close" size="mini" @click.stop="confirmDeleteFriend(friend.id)" style="margin-left:5px") + .x-friend-group(v-show="friendsGroup2.length") + i.el-icon-arrow-right(:class="{ rotate: isFriendsGroup2 }") + span.x-link(@click="isFriendsGroup2 = !isFriendsGroup2" style="margin-left:5px") ACTIVE―{{ friendsGroup2.length }} + div(v-show="isFriendsGroup2") + .x-friend-item(v-for="friend in friendsGroup2" :key="friend.id" @click="showUserDialog(friend.id)") + template(v-if="friend.ref") + .avatar + img(v-lazy="friend.ref.currentAvatarThumbnailImageUrl") + .detail + span.name(v-if="friend.memo" :class="friend.ref.$trustClass") {{ friend.ref.displayName }} ({{ friend.memo }}) + span.name(v-else v-text="friend.ref.displayName" :class="friend.ref.$trustClass") + span.extra(v-text="friend.ref.statusDescription" :link="false") + template(v-else) + span(v-text="friend.name || friend.id") + el-button(type="text" icon="el-icon-close" size="mini" @click.stop="confirmDeleteFriend(friend.id)" style="margin-left:5px") + .x-friend-group(v-show="friendsGroup3.length") + i.el-icon-arrow-right(:class="{ rotate: isFriendsGroup3 }") + span.x-link(@click="isFriendsGroup3 = !isFriendsGroup3" style="margin-left:5px") OFFLINE―{{ friendsGroup3.length }} + div(v-show="isFriendsGroup3") + .x-friend-item(v-for="friend in friendsGroup3" :key="friend.id" @click="showUserDialog(friend.id)") + template(v-if="friend.ref") + .avatar + img(v-lazy="friend.ref.currentAvatarThumbnailImageUrl") + .detail + span.name(v-if="friend.memo" :class="friend.ref.$trustClass") {{ friend.ref.displayName }} ({{ friend.memo }}) + span.name(v-else v-text="friend.ref.displayName" :class="friend.ref.$trustClass") + span.extra(v-text="friend.ref.statusDescription") + template(v-else) + span(v-text="friend.name || friend.id") + el-button(type="text" icon="el-icon-close" size="mini" @click.stop="confirmDeleteFriend(friend.id)" style="margin-left:5px") - //- dialog: user - el-dialog.x-dialog.x-user-dialog(ref="userDialog" :visible.sync="userDialog.visible" :show-close="false" width="600px") - div(v-loading="userDialog.loading") - div(style="display:flex") - el-popover(placement="right" width="500px" trigger="click") - img.x-link(slot="reference" v-lazy="userDialog.ref.currentAvatarThumbnailImageUrl" style="flex:none;width:160px;height:120px;border-radius:4px") - img(v-lazy="userDialog.ref.currentAvatarThumbnailImageUrl" style="width:500px;height:375px") - div(style="flex:1;display:flex;align-items:center;margin-left:15px") - div(style="flex:1") - div - el-tooltip(v-if="userDialog.ref.status" placement="top") - template(#content) - span(v-if="userDialog.ref.location === 'offline'") Offline - span(v-else-if="userDialog.ref.status === 'active'") Online - span(v-else-if="userDialog.ref.status === 'join me'") Join Me - span(v-else-if="userDialog.ref.status === 'ask me'") Ask Me - span(v-else-if="userDialog.ref.status === 'busy'") Do Not Disturb - span(v-else) Offline - i.x-user-status(:class="userStatusClass(userDialog.ref)") - span(v-text="userDialog.ref.displayName" style="margin-left:5px;font-weight:bold") - el-popover(placement="top" trigger="click") - span(slot="reference" v-text="userDialog.ref.username" style="margin-left:5px;color:#909399;font-family:monospace;font-size:12px;cursor:pointer") - span(style="display:block;text-align:center;font-family:monospace") {{ userDialog.ref.username | textToHex }} - el-tooltip(v-for="item in userDialog.ref.$languages" :key="item.key" placement="top") - template(#content) - span {{ item.value }} ({{ item.key }}) - span.famfamfam-flags(:class="languageClass(item.key)" style="display:inline-block;margin-left:5px") - div(style="margin-top:5px") - el-tag.name(type="info" effect="plain" size="mini" :class="userDialog.ref.$trustClass" v-text="userDialog.ref.$trustLevel") - el-tag.x-tag-friend(v-if="userDialog.isFriend && userDialog.friend" type="info" effect="plain" size="mini" style="margin-left:5px") Friend No.{{userDialog.friend.no}} - div(style="margin-top:5px") - span(v-text="userDialog.ref.statusDescription" style="font-size:12px") - div(style="flex:none;margin-left:10px") - el-button(v-if="userDialog.isFavorite" @click="userDialogCommand('Delete Favorite')" type="warning" icon="el-icon-star-on" circle) - el-button(v-else type="default" @click="userDialogCommand('Add Favorite')" icon="el-icon-star-off" circle) - el-dropdown(trigger="click" @command="userDialogCommand" size="small") - el-button(:type="(userDialog.incomingRequest || userDialog.outgoingRequest) ? 'success' : (userDialog.isBlock || userDialog.isMute || userDialog.isHideAvatar) ? 'danger' : 'default'" icon="el-icon-more" circle) - el-dropdown-menu(#default="dropdown") - template(v-if="userDialog.isFriend") - el-dropdown-item(icon="el-icon-message" command="Message") Message - template(v-else-if="userDialog.incomingRequest") - el-dropdown-item(icon="el-icon-check" command="Accept Friend Request") Accept Friend Request - el-dropdown-item(icon="el-icon-close" command="Decline Friend Request") Decline Friend Request - el-dropdown-item(v-else-if="userDialog.outgoingRequest" icon="el-icon-close" command="Cancel Friend Request") Cancel Friend Request - el-dropdown-item(v-else icon="el-icon-plus" command="Send Friend Request") Send Friend Request - el-dropdown-item(icon="el-icon-s-custom" command="Show Avatar Author" divided) Show Avatar Author - el-dropdown-item(v-if="userDialog.isBlock" icon="el-icon-circle-check" command="Unblock" divided style="color:#F56C6C") Unblock - el-dropdown-item(v-else icon="el-icon-circle-close" command="Block" divided) Block - el-dropdown-item(v-if="userDialog.isMute" icon="el-icon-microphone" command="Unmute" style="color:#F56C6C") Unmute - el-dropdown-item(v-else icon="el-icon-turn-off-microphone" command="Mute") Mute - el-dropdown-item(v-if="userDialog.isHideAvatar" icon="el-icon-user-solid" command="Show Avatar" style="color:#F56C6C") Show Avatar - el-dropdown-item(v-else icon="el-icon-user" command="Hide Avatar") Hide Avatar - template(v-if="userDialog.isFriend") - el-dropdown-item(icon="el-icon-delete" command="Unfriend" divided) Unfriend - el-tabs - el-tab-pane(label="Info") - div(v-if="userDialog.ref.location" style="display:flex;flex-direction:column;margin-bottom:10px;padding-bottom:10px;border-bottom:1px solid #eee") - div(style="flex:none") - i.el-icon-position - location(:location="userDialog.ref.location") - template(#default v-if="userDialog.instance.occupants") ({{ userDialog.instance.occupants }}) - launch(:location="userDialog.ref.location" style="margin-left:5px") - .x-friend-list(style="flex:1;margin-top:10px") - .x-friend-item(v-if="userDialog.$location.userId" @click="showUserDialog(userDialog.$location.userId)") - template(v-if="userDialog.$location.user") - .avatar(:class="userStatusClass(userDialog.$location.user)") - img(v-lazy="userDialog.$location.user.currentAvatarThumbnailImageUrl") - .detail - span.name(v-text="userDialog.$location.user.displayName" :class="userDialog.$location.user.$trustClass") - span.extra Instance Creator - span(v-else v-text="userDialog.$location.userId") - .x-friend-item(v-for="user in userDialog.users" :key="user.id" @click="showUserDialog(user.id)") - .avatar(:class="userStatusClass(user)") - img(v-lazy="user.currentAvatarThumbnailImageUrl") - .detail - span.name(v-text="user.displayName" :class="user.$trustClass") - span.extra - timer(:epoch="user.$location_at") - .x-friend-list(style="max-height:none") - .x-friend-item(style="width:100%") - .detail - span.name Note - el-input.extra(v-model="userDialog.memo" type="textarea" :rows="2" placeholder="Click to add a note" size="mini" resize="none") - .x-friend-item(style="width:100%") - .detail - span.name Bio - pre.extra(style="font-family:inherit;font-size:12px;white-space:pre-wrap;margin:0 0.5em 0 0") {{ userDialog.ref.bio || '-' }} - div(style="margin-top:5px") - el-tooltip(v-for="(link, index) in userDialog.ref.bioLinks" :key="index") - template(#content) - span(v-text="link") - img(:src="getFaviconUrl(link)" style="width:16px;height:16px;vertical-align:middle;margin-right:5px" @click.stop="openExternalLink(link)") - .x-friend-item - .detail - span.name Avatar Copying - span.extra(v-if="userDialog.ref.allowAvatarCopying" style="color:#67C23A") Allow - span.extra(v-else style="color:#F56C6C") Deny - .x-friend-item - .detail - span.name Last Login - span.extra {{ userDialog.ref.last_login | formatDate('YYYY-MM-DD HH24:MI:SS') || '-' }} - .x-friend-item - .detail - span.name Last Platform - span.extra(v-text="userDialog.ref.last_platform") - el-tab-pane(label="Worlds") - el-button(type="default" :loading="userDialog.isWorldsLoading" @click="refreshUserDialogWorlds()" size="mini" icon="el-icon-refresh" circle) - span(style="margin-left:5px") Total {{ userDialog.worlds.length }} - el-radio-group(v-model="userDialog.worldSorting" size="mini" style="margin-left:30px" @change="changeUserDialogWorldSorting") - el-radio(label="name") by name - el-radio(label="update") by update - .x-friend-list(v-loading="userDialog.isWorldsLoading" style="margin-top:10px;min-height:60px") - .x-friend-item(v-for="world in userDialog.worlds" :key="world.id" @click="showWorldDialog(world.id)") - .avatar - img(v-lazy="world.thumbnailImageUrl") - .detail - span.name(v-text="world.name") - span.extra(v-if="world.occupants") ({{ world.occupants }}) - el-tab-pane(label="Avatars") - el-button(type="default" :loading="userDialog.isAvatarsLoading" @click="refreshUserDialogAvatars()" size="mini" icon="el-icon-refresh" circle) - span(style="margin-left:5px") Total {{ userDialog.avatars.length }} - el-radio-group(v-model="userDialog.avatarSorting" size="mini" style="margin-left:30px" @change="changeUserDialogAvatarSorting") - el-radio(label="name") by name - el-radio(label="update") by update - .x-friend-list(v-loading="userDialog.isAvatarsLoading" style="margin-top:10px;min-height:60px") - .x-friend-item(v-for="avatar in userDialog.avatars" :key="avatar.id" @click="showAvatarDialog(avatar.id)") - .avatar - img(v-lazy="avatar.thumbnailImageUrl") - .detail - span.name(v-text="avatar.name") - el-tab-pane(label="JSON") - el-button(type="default" @click="refreshUserDialogTreeData()" size="mini" icon="el-icon-refresh" circle) - el-tree(:data="userDialog.treeData" style="margin-top:5px;font-size:12px") - template(#default="scope") - span - span(v-text="scope.data.key" style="font-weight:bold;margin-right:5px") - span(v-if="!scope.data.children" v-text="scope.data.value") + //- dialog: user + el-dialog.x-dialog.x-user-dialog(ref="userDialog" :visible.sync="userDialog.visible" :show-close="false" width="600px") + div(v-loading="userDialog.loading") + div(style="display:flex") + el-popover(placement="right" width="500px" trigger="click") + img.x-link(slot="reference" v-lazy="userDialog.ref.currentAvatarThumbnailImageUrl" style="flex:none;width:160px;height:120px;border-radius:4px") + img(v-lazy="userDialog.ref.currentAvatarThumbnailImageUrl" style="width:500px;height:375px") + div(style="flex:1;display:flex;align-items:center;margin-left:15px") + div(style="flex:1") + div + el-tooltip(v-if="userDialog.ref.status" placement="top") + template(#content) + span(v-if="userDialog.ref.location === 'offline'") Offline + span(v-else-if="userDialog.ref.status === 'active'") Online + span(v-else-if="userDialog.ref.status === 'join me'") Join Me + span(v-else-if="userDialog.ref.status === 'ask me'") Ask Me + span(v-else-if="userDialog.ref.status === 'busy'") Do Not Disturb + span(v-else) Offline + i.x-user-status(:class="userStatusClass(userDialog.ref)") + span(v-text="userDialog.ref.displayName" style="margin-left:5px;font-weight:bold") + el-popover(placement="top" trigger="click") + span(slot="reference" v-text="userDialog.ref.username" style="margin-left:5px;color:#909399;font-family:monospace;font-size:12px;cursor:pointer") + span(style="display:block;text-align:center;font-family:monospace") {{ userDialog.ref.username | textToHex }} + el-tooltip(v-for="item in userDialog.ref.$languages" :key="item.key" placement="top") + template(#content) + span {{ item.value }} ({{ item.key }}) + span.famfamfam-flags(:class="languageClass(item.key)" style="display:inline-block;margin-left:5px") + div(style="margin-top:5px") + el-tag.name(type="info" effect="plain" size="mini" :class="userDialog.ref.$trustClass" v-text="userDialog.ref.$trustLevel") + el-tag.x-tag-friend(v-if="userDialog.isFriend && userDialog.friend" type="info" effect="plain" size="mini" style="margin-left:5px") Friend No.{{userDialog.friend.no}} + div(style="margin-top:5px") + span(v-text="userDialog.ref.statusDescription" style="font-size:12px") + div(style="flex:none;margin-left:10px") + el-button(v-if="userDialog.isFavorite" @click="userDialogCommand('Delete Favorite')" type="warning" icon="el-icon-star-on" circle) + el-button(v-else type="default" @click="userDialogCommand('Add Favorite')" icon="el-icon-star-off" circle) + el-dropdown(trigger="click" @command="userDialogCommand" size="small") + el-button(:type="(userDialog.incomingRequest || userDialog.outgoingRequest) ? 'success' : (userDialog.isBlock || userDialog.isMute || userDialog.isHideAvatar) ? 'danger' : 'default'" icon="el-icon-more" circle) + el-dropdown-menu(#default="dropdown") + template(v-if="userDialog.isFriend") + el-dropdown-item(icon="el-icon-message" command="Message") Message + template(v-else-if="userDialog.incomingRequest") + el-dropdown-item(icon="el-icon-check" command="Accept Friend Request") Accept Friend Request + el-dropdown-item(icon="el-icon-close" command="Decline Friend Request") Decline Friend Request + el-dropdown-item(v-else-if="userDialog.outgoingRequest" icon="el-icon-close" command="Cancel Friend Request") Cancel Friend Request + el-dropdown-item(v-else icon="el-icon-plus" command="Send Friend Request") Send Friend Request + el-dropdown-item(icon="el-icon-s-custom" command="Show Avatar Author" divided) Show Avatar Author + el-dropdown-item(v-if="userDialog.isBlock" icon="el-icon-circle-check" command="Unblock" divided style="color:#F56C6C") Unblock + el-dropdown-item(v-else icon="el-icon-circle-close" command="Block" divided) Block + el-dropdown-item(v-if="userDialog.isMute" icon="el-icon-microphone" command="Unmute" style="color:#F56C6C") Unmute + el-dropdown-item(v-else icon="el-icon-turn-off-microphone" command="Mute") Mute + el-dropdown-item(v-if="userDialog.isHideAvatar" icon="el-icon-user-solid" command="Show Avatar" style="color:#F56C6C") Show Avatar + el-dropdown-item(v-else icon="el-icon-user" command="Hide Avatar") Hide Avatar + template(v-if="userDialog.isFriend") + el-dropdown-item(icon="el-icon-delete" command="Unfriend" divided) Unfriend + el-tabs + el-tab-pane(label="Info") + div(v-if="userDialog.ref.location" style="display:flex;flex-direction:column;margin-bottom:10px;padding-bottom:10px;border-bottom:1px solid #eee") + div(style="flex:none") + i.el-icon-position + location(:location="userDialog.ref.location") + template(#default v-if="userDialog.instance.occupants") ({{ userDialog.instance.occupants }}) + launch(:location="userDialog.ref.location" style="margin-left:5px") + .x-friend-list(style="flex:1;margin-top:10px") + .x-friend-item(v-if="userDialog.$location.userId" @click="showUserDialog(userDialog.$location.userId)") + template(v-if="userDialog.$location.user") + .avatar(:class="userStatusClass(userDialog.$location.user)") + img(v-lazy="userDialog.$location.user.currentAvatarThumbnailImageUrl") + .detail + span.name(v-text="userDialog.$location.user.displayName" :class="userDialog.$location.user.$trustClass") + span.extra Instance Creator + span(v-else v-text="userDialog.$location.userId") + .x-friend-item(v-for="user in userDialog.users" :key="user.id" @click="showUserDialog(user.id)") + .avatar(:class="userStatusClass(user)") + img(v-lazy="user.currentAvatarThumbnailImageUrl") + .detail + span.name(v-text="user.displayName" :class="user.$trustClass") + span.extra + timer(:epoch="user.$location_at") + .x-friend-list(style="max-height:none") + .x-friend-item(style="width:100%") + .detail + span.name Note + el-input.extra(v-model="userDialog.memo" type="textarea" :rows="2" placeholder="Click to add a note" size="mini" resize="none") + .x-friend-item(style="width:100%") + .detail + span.name Bio + pre.extra(style="font-family:inherit;font-size:12px;white-space:pre-wrap;margin:0 0.5em 0 0") {{ userDialog.ref.bio || '-' }} + div(style="margin-top:5px") + el-tooltip(v-for="(link, index) in userDialog.ref.bioLinks" :key="index") + template(#content) + span(v-text="link") + img(:src="getFaviconUrl(link)" style="width:16px;height:16px;vertical-align:middle;margin-right:5px" @click.stop="openExternalLink(link)") + .x-friend-item + .detail + span.name Avatar Copying + span.extra(v-if="userDialog.ref.allowAvatarCopying" style="color:#67C23A") Allow + span.extra(v-else style="color:#F56C6C") Deny + .x-friend-item + .detail + span.name Last Login + span.extra {{ userDialog.ref.last_login | formatDate('YYYY-MM-DD HH24:MI:SS') || '-' }} + .x-friend-item + .detail + span.name Last Platform + span.extra(v-text="userDialog.ref.last_platform") + el-tab-pane(label="Worlds") + el-button(type="default" :loading="userDialog.isWorldsLoading" @click="refreshUserDialogWorlds()" size="mini" icon="el-icon-refresh" circle) + span(style="margin-left:5px") Total {{ userDialog.worlds.length }} + el-radio-group(v-model="userDialog.worldSorting" size="mini" style="margin-left:30px" @change="changeUserDialogWorldSorting") + el-radio(label="name") by name + el-radio(label="update") by update + .x-friend-list(v-loading="userDialog.isWorldsLoading" style="margin-top:10px;min-height:60px") + .x-friend-item(v-for="world in userDialog.worlds" :key="world.id" @click="showWorldDialog(world.id)") + .avatar + img(v-lazy="world.thumbnailImageUrl") + .detail + span.name(v-text="world.name") + span.extra(v-if="world.occupants") ({{ world.occupants }}) + el-tab-pane(label="Avatars") + el-button(type="default" :loading="userDialog.isAvatarsLoading" @click="refreshUserDialogAvatars()" size="mini" icon="el-icon-refresh" circle) + span(style="margin-left:5px") Total {{ userDialog.avatars.length }} + el-radio-group(v-model="userDialog.avatarSorting" size="mini" style="margin-left:30px" @change="changeUserDialogAvatarSorting") + el-radio(label="name") by name + el-radio(label="update") by update + .x-friend-list(v-loading="userDialog.isAvatarsLoading" style="margin-top:10px;min-height:60px") + .x-friend-item(v-for="avatar in userDialog.avatars" :key="avatar.id" @click="showAvatarDialog(avatar.id)") + .avatar + img(v-lazy="avatar.thumbnailImageUrl") + .detail + span.name(v-text="avatar.name") + el-tab-pane(label="JSON") + el-button(type="default" @click="refreshUserDialogTreeData()" size="mini" icon="el-icon-refresh" circle) + el-tree(:data="userDialog.treeData" style="margin-top:5px;font-size:12px") + template(#default="scope") + span + span(v-text="scope.data.key" style="font-weight:bold;margin-right:5px") + span(v-if="!scope.data.children" v-text="scope.data.value") - //- dialog: world - el-dialog.x-dialog.x-world-dialog(ref="worldDialog" :visible.sync="worldDialog.visible" :show-close="false" width="600px") - div(v-loading="worldDialog.loading") - div(style="display:flex") - el-popover(placement="right" width="500px" trigger="click") - img.x-link(slot="reference" v-lazy="worldDialog.ref.thumbnailImageUrl" style="flex:none;width:160px;height:120px;border-radius:4px") - img(v-lazy="worldDialog.ref.thumbnailImageUrl" style="width:500px;height:375px") - div(style="flex:1;display:flex;align-items:center;margin-left:15px") - div(style="flex:1") - div - i.el-icon-s-home(v-show="API.currentUser.$homeLocation && API.currentUser.$homeLocation.worldId === worldDialog.id") - span(v-text="worldDialog.ref.name" style="font-weight:bold") - div(style="margin-top:5px") - span.x-link(v-text="worldDialog.ref.authorName" @click="showUserDialog(worldDialog.ref.authorId)" style="color:#909399;font-family:monospace") - div(style="margin-top:5px") - el-tag(v-if="worldDialog.ref.$isLabs" type="primary" effect="plain" size="mini") Labs - el-tag(v-else-if="worldDialog.ref.releaseStatus === 'public'" type="success" effect="plain" size="mini") Public - el-tag(v-else type="danger" effect="plain" size="mini") Private - el-tag(type="info" effect="plain" size="mini" v-text="worldDialog.fileSize" style="margin-left:5px") - div(style="margin-top:5px") - span(v-show="worldDialog.ref.name !== worldDialog.ref.description" v-text="worldDialog.ref.description" style="font-size:12px") - div(style="flex:none;margin-left:10px") - el-button(v-if="worldDialog.isFavorite" type="warning" icon="el-icon-star-on" circle @click="worldDialogCommand('Delete Favorite')") - el-button(v-else type="default" icon="el-icon-star-off" circle @click="worldDialogCommand('Add Favorite')") - el-dropdown(trigger="click" @command="worldDialogCommand" size="small") - el-button(type="default" icon="el-icon-more" circle) - el-dropdown-menu(#default="dropdown") - el-dropdown-item(icon="el-icon-s-flag" command="New Instance") New Instance - el-dropdown-item(v-if="API.currentUser.$homeLocation && API.currentUser.$homeLocation.worldId === worldDialog.id" icon="el-icon-magic-stick" command="Reset Home" divided) Reset Home - el-dropdown-item(v-else icon="el-icon-s-home" command="Make Home" divided) Make Home - el-tabs - el-tab-pane(label="Instances") - div. - #[i.el-icon-user] Public {{ worldDialog.ref.publicOccupants | commaNumber }} - #[i.el-icon-user-solid(style="margin-left:10px")] Private {{ worldDialog.ref.privateOccupants | commaNumber }} - #[i.el-icon-check(style="margin-left:10px")] Capacity {{ worldDialog.ref.capacity | commaNumber }} - div(v-for="room in worldDialog.rooms" :key="room.id") - div(style="margin:5px 0") - i.el-icon-position - span.x-link(@click="showLaunchDialog(room.$location.tag)"). - \#{{ room.$location.instanceName }} {{ room.$location.accessType }} #[template(v-if="room.occupants") ({{ room.occupants }})] - .x-friend-list(style="margin:10px 0" v-if="room.$location.userId || room.users.length") - .x-friend-item(v-if="room.$location.userId" @click="showUserDialog(room.$location.userId)") - template(v-if="room.$location.user") - .avatar(:class="userStatusClass(room.$location.user)") - img(v-lazy="room.$location.user.currentAvatarThumbnailImageUrl") - .detail - span.name(v-text="room.$location.user.displayName" :class="room.$location.user.$trustClass") - span.extra Instance Creator - span(v-else v-text="room.$location.userId") - .x-friend-item(v-for="user in room.users" :key="user.id" @click="showUserDialog(user.id)") - .avatar(:class="userStatusClass(user)") - img(v-lazy="user.currentAvatarThumbnailImageUrl") - .detail - span.name(v-text="user.displayName" :class="user.$trustClass") - span.extra - timer(:epoch="user.$location_at") - el-tab-pane(label="Info") - .x-friend-list(style="max-height:none") - .x-friend-item - .detail - span.name Players - span.extra {{ worldDialog.ref.occupants | commaNumber }} - .x-friend-item - .detail - span.name Favorites - span.extra {{ worldDialog.ref.favorites | commaNumber }} - .x-friend-item - .detail - span.name Visits - span.extra {{ worldDialog.ref.visits | commaNumber }} - .x-friend-item - .detail - span.name Capacity - span.extra(v-text="worldDialog.ref.capacity") - .x-friend-item - .detail - span.name Heat - span.extra {{ worldDialog.ref.heat | commaNumber }} {{ '🔥'.repeat(worldDialog.ref.heat) }} - .x-friend-item - .detail - span.name Popularity - span.extra {{ worldDialog.ref.popularity | commaNumber }} {{ '💖'.repeat(worldDialog.ref.popularity) }} - .x-friend-item - .detail - span.name Created - span.extra {{ worldDialog.ref.created_at | formatDate('YYYY-MM-DD HH24:MI:SS') || '-' }} - .x-friend-item - .detail - span.name Last Updated - span.extra {{ worldDialog.fileCreatedAt | formatDate('YYYY-MM-DD HH24:MI:SS') || '-' }} - .x-friend-item - .detail - span.name Version - span.extra(v-text="worldDialog.ref.version") - .x-friend-item(style="width:100%") - .detail - span.name Platform - span.extra(v-text="worldDialogPlatform") - el-tab-pane(label="JSON") - el-button(type="default" @click="refreshWorldDialogTreeData()" size="mini" icon="el-icon-refresh" circle) - el-tree(:data="worldDialog.treeData" style="margin-top:5px;font-size:12px") - template(#default="scope") - span - span(v-text="scope.data.key" style="font-weight:bold;margin-right:5px") - span(v-if="!scope.data.children" v-text="scope.data.value") + //- dialog: world + el-dialog.x-dialog.x-world-dialog(ref="worldDialog" :visible.sync="worldDialog.visible" :show-close="false" width="600px") + div(v-loading="worldDialog.loading") + div(style="display:flex") + el-popover(placement="right" width="500px" trigger="click") + img.x-link(slot="reference" v-lazy="worldDialog.ref.thumbnailImageUrl" style="flex:none;width:160px;height:120px;border-radius:4px") + img(v-lazy="worldDialog.ref.thumbnailImageUrl" style="width:500px;height:375px") + div(style="flex:1;display:flex;align-items:center;margin-left:15px") + div(style="flex:1") + div + i.el-icon-s-home(v-show="API.currentUser.$homeLocation && API.currentUser.$homeLocation.worldId === worldDialog.id") + span(v-text="worldDialog.ref.name" style="font-weight:bold") + div(style="margin-top:5px") + span.x-link(v-text="worldDialog.ref.authorName" @click="showUserDialog(worldDialog.ref.authorId)" style="color:#909399;font-family:monospace") + div(style="margin-top:5px") + el-tag(v-if="worldDialog.ref.$isLabs" type="primary" effect="plain" size="mini") Labs + el-tag(v-else-if="worldDialog.ref.releaseStatus === 'public'" type="success" effect="plain" size="mini") Public + el-tag(v-else type="danger" effect="plain" size="mini") Private + el-tag(type="info" effect="plain" size="mini" v-text="worldDialog.fileSize" style="margin-left:5px") + div(style="margin-top:5px") + span(v-show="worldDialog.ref.name !== worldDialog.ref.description" v-text="worldDialog.ref.description" style="font-size:12px") + div(style="flex:none;margin-left:10px") + el-button(v-if="worldDialog.isFavorite" type="warning" icon="el-icon-star-on" circle @click="worldDialogCommand('Delete Favorite')") + el-button(v-else type="default" icon="el-icon-star-off" circle @click="worldDialogCommand('Add Favorite')") + el-dropdown(trigger="click" @command="worldDialogCommand" size="small") + el-button(type="default" icon="el-icon-more" circle) + el-dropdown-menu(#default="dropdown") + el-dropdown-item(icon="el-icon-s-flag" command="New Instance") New Instance + el-dropdown-item(v-if="API.currentUser.$homeLocation && API.currentUser.$homeLocation.worldId === worldDialog.id" icon="el-icon-magic-stick" command="Reset Home" divided) Reset Home + el-dropdown-item(v-else icon="el-icon-s-home" command="Make Home" divided) Make Home + el-tabs + el-tab-pane(label="Instances") + div. + #[i.el-icon-user] Public {{ worldDialog.ref.publicOccupants | commaNumber }} + #[i.el-icon-user-solid(style="margin-left:10px")] Private {{ worldDialog.ref.privateOccupants | commaNumber }} + #[i.el-icon-check(style="margin-left:10px")] Capacity {{ worldDialog.ref.capacity | commaNumber }} + div(v-for="room in worldDialog.rooms" :key="room.id") + div(style="margin:5px 0") + i.el-icon-position + span.x-link(@click="showLaunchDialog(room.$location.tag)"). + \#{{ room.$location.instanceName }} {{ room.$location.accessType }} #[template(v-if="room.occupants") ({{ room.occupants }})] + .x-friend-list(style="margin:10px 0" v-if="room.$location.userId || room.users.length") + .x-friend-item(v-if="room.$location.userId" @click="showUserDialog(room.$location.userId)") + template(v-if="room.$location.user") + .avatar(:class="userStatusClass(room.$location.user)") + img(v-lazy="room.$location.user.currentAvatarThumbnailImageUrl") + .detail + span.name(v-text="room.$location.user.displayName" :class="room.$location.user.$trustClass") + span.extra Instance Creator + span(v-else v-text="room.$location.userId") + .x-friend-item(v-for="user in room.users" :key="user.id" @click="showUserDialog(user.id)") + .avatar(:class="userStatusClass(user)") + img(v-lazy="user.currentAvatarThumbnailImageUrl") + .detail + span.name(v-text="user.displayName" :class="user.$trustClass") + span.extra + timer(:epoch="user.$location_at") + el-tab-pane(label="Info") + .x-friend-list(style="max-height:none") + .x-friend-item + .detail + span.name Players + span.extra {{ worldDialog.ref.occupants | commaNumber }} + .x-friend-item + .detail + span.name Favorites + span.extra {{ worldDialog.ref.favorites | commaNumber }} + .x-friend-item + .detail + span.name Visits + span.extra {{ worldDialog.ref.visits | commaNumber }} + .x-friend-item + .detail + span.name Capacity + span.extra(v-text="worldDialog.ref.capacity") + .x-friend-item + .detail + span.name Heat + span.extra {{ worldDialog.ref.heat | commaNumber }} {{ '🔥'.repeat(worldDialog.ref.heat) }} + .x-friend-item + .detail + span.name Popularity + span.extra {{ worldDialog.ref.popularity | commaNumber }} {{ '💖'.repeat(worldDialog.ref.popularity) }} + .x-friend-item + .detail + span.name Created + span.extra {{ worldDialog.ref.created_at | formatDate('YYYY-MM-DD HH24:MI:SS') || '-' }} + .x-friend-item + .detail + span.name Last Updated + span.extra {{ worldDialog.fileCreatedAt | formatDate('YYYY-MM-DD HH24:MI:SS') || '-' }} + .x-friend-item + .detail + span.name Version + span.extra(v-text="worldDialog.ref.version") + .x-friend-item(style="width:100%") + .detail + span.name Platform + span.extra(v-text="worldDialogPlatform") + el-tab-pane(label="JSON") + el-button(type="default" @click="refreshWorldDialogTreeData()" size="mini" icon="el-icon-refresh" circle) + el-tree(:data="worldDialog.treeData" style="margin-top:5px;font-size:12px") + template(#default="scope") + span + span(v-text="scope.data.key" style="font-weight:bold;margin-right:5px") + span(v-if="!scope.data.children" v-text="scope.data.value") - //- dialog: avatar - el-dialog.x-dialog.x-avatar-dialog(ref="avatarDialog" :visible.sync="avatarDialog.visible" :show-close="false" width="600px") - div(v-loading="avatarDialog.loading") - div(style="display:flex") - el-popover(placement="right" width="500px" trigger="click") - img.x-link(slot="reference" v-lazy="avatarDialog.ref.thumbnailImageUrl" style="flex:none;width:160px;height:120px;border-radius:4px") - img(v-lazy="avatarDialog.ref.thumbnailImageUrl" style="width:500px;height:375px") - div(style="flex:1;display:flex;align-items:center;margin-left:15px") - div(style="flex:1") - div - span(v-text="avatarDialog.ref.name" style="font-weight:bold") - div(style="margin-top:5px") - span.x-link(v-text="avatarDialog.ref.authorName" @click="showUserDialog(avatarDialog.ref.authorId)" style="color:#909399;font-family:monospace") - div(style="margin-top:5px") - el-tag(v-if="avatarDialog.ref.releaseStatus === 'public'" type="success" effect="plain" size="mini") Public - el-tag(v-else type="danger" effect="plain" size="mini") Private - el-tag(type="info" effect="plain" size="mini" v-text="avatarDialog.fileSize" style="margin-left:5px") - div(style="margin-top:5px") - span(v-show="avatarDialog.ref.name !== avatarDialog.ref.description" v-text="avatarDialog.ref.description" style="font-size:12px") - div(style="flex:none;margin-left:10px") - el-button(v-if="avatarDialog.isFavorite" type="warning" icon="el-icon-star-on" circle @click="avatarDialogCommand('Delete Favorite')") - el-button(v-else type="default" icon="el-icon-star-off" circle @click="avatarDialogCommand('Add Favorite')") - el-dropdown(trigger="click" @command="avatarDialogCommand" size="small") - el-button(type="default" icon="el-icon-more" circle) - el-dropdown-menu(#default="dropdown") - el-dropdown-item(icon="el-icon-check" command="Select Avatar") Select Avatar - el-tabs - el-tab-pane(label="Info") - .x-friend-list - .x-friend-item - .detail - span.name Created - span.extra {{ avatarDialog.ref.created_at | formatDate('YYYY-MM-DD HH24:MI:SS') || '-' }} - .x-friend-item - .detail - span.name Last Updated - span.extra {{ avatarDialog.fileCreatedAt | formatDate('YYYY-MM-DD HH24:MI:SS') || '-' }} - .x-friend-item - .detail - span.name Version - span.extra(v-text="avatarDialog.ref.version") - .x-friend-item(style="width:100%") - .detail - span.name Platform - span.extra(v-text="avatarDialogPlatform") - el-tab-pane(label="JSON") - el-button(type="default" @click="refreshAvatarDialogTreeData()" size="mini" icon="el-icon-refresh" circle) - el-tree(:data="avatarDialog.treeData" style="margin-top:5px;font-size:12px") - template(#default="scope") - span - span(v-text="scope.data.key" style="font-weight:bold;margin-right:5px") - span(v-if="!scope.data.children" v-text="scope.data.value") + //- dialog: avatar + el-dialog.x-dialog.x-avatar-dialog(ref="avatarDialog" :visible.sync="avatarDialog.visible" :show-close="false" width="600px") + div(v-loading="avatarDialog.loading") + div(style="display:flex") + el-popover(placement="right" width="500px" trigger="click") + img.x-link(slot="reference" v-lazy="avatarDialog.ref.thumbnailImageUrl" style="flex:none;width:160px;height:120px;border-radius:4px") + img(v-lazy="avatarDialog.ref.thumbnailImageUrl" style="width:500px;height:375px") + div(style="flex:1;display:flex;align-items:center;margin-left:15px") + div(style="flex:1") + div + span(v-text="avatarDialog.ref.name" style="font-weight:bold") + div(style="margin-top:5px") + span.x-link(v-text="avatarDialog.ref.authorName" @click="showUserDialog(avatarDialog.ref.authorId)" style="color:#909399;font-family:monospace") + div(style="margin-top:5px") + el-tag(v-if="avatarDialog.ref.releaseStatus === 'public'" type="success" effect="plain" size="mini") Public + el-tag(v-else type="danger" effect="plain" size="mini") Private + el-tag(type="info" effect="plain" size="mini" v-text="avatarDialog.fileSize" style="margin-left:5px") + div(style="margin-top:5px") + span(v-show="avatarDialog.ref.name !== avatarDialog.ref.description" v-text="avatarDialog.ref.description" style="font-size:12px") + div(style="flex:none;margin-left:10px") + el-button(v-if="avatarDialog.isFavorite" type="warning" icon="el-icon-star-on" circle @click="avatarDialogCommand('Delete Favorite')") + el-button(v-else type="default" icon="el-icon-star-off" circle @click="avatarDialogCommand('Add Favorite')") + el-dropdown(trigger="click" @command="avatarDialogCommand" size="small") + el-button(type="default" icon="el-icon-more" circle) + el-dropdown-menu(#default="dropdown") + el-dropdown-item(icon="el-icon-check" command="Select Avatar") Select Avatar + el-tabs + el-tab-pane(label="Info") + .x-friend-list + .x-friend-item + .detail + span.name Created + span.extra {{ avatarDialog.ref.created_at | formatDate('YYYY-MM-DD HH24:MI:SS') || '-' }} + .x-friend-item + .detail + span.name Last Updated + span.extra {{ avatarDialog.fileCreatedAt | formatDate('YYYY-MM-DD HH24:MI:SS') || '-' }} + .x-friend-item + .detail + span.name Version + span.extra(v-text="avatarDialog.ref.version") + .x-friend-item(style="width:100%") + .detail + span.name Platform + span.extra(v-text="avatarDialogPlatform") + el-tab-pane(label="JSON") + el-button(type="default" @click="refreshAvatarDialogTreeData()" size="mini" icon="el-icon-refresh" circle) + el-tree(:data="avatarDialog.treeData" style="margin-top:5px;font-size:12px") + template(#default="scope") + span + span(v-text="scope.data.key" style="font-weight:bold;margin-right:5px") + span(v-if="!scope.data.children" v-text="scope.data.value") - //- dialog: favorite - el-dialog.x-dialog(ref="favoriteDialog" :visible.sync="favoriteDialog.visible" title="Choose Group" width="250px") - div(v-loading="favoriteDialog.loading") - el-button(v-for="group in favoriteDialog.groups" :key="group.name" style="display:block;width:100%;margin:10px 0" @click="addFavorite(group)" :disabled="group.count >= group.capacity") {{ group.displayName }} ({{ group.count }} / {{ group.capacity }}) + //- dialog: favorite + el-dialog.x-dialog(ref="favoriteDialog" :visible.sync="favoriteDialog.visible" title="Choose Group" width="250px") + div(v-loading="favoriteDialog.loading") + el-button(v-for="group in favoriteDialog.groups" :key="group.name" style="display:block;width:100%;margin:10px 0" @click="addFavorite(group)" :disabled="group.count >= group.capacity") {{ group.displayName }} ({{ group.count }} / {{ group.capacity }}) - //- dialog: invite - el-dialog.x-dialog(ref="inviteDialog" :visible.sync="inviteDialog.visible" title="Invite" width="450px") - div(v-loading="inviteDialog.loading") - location(:location="inviteDialog.worldId" :link="false") - el-select(v-model="inviteDialog.userIds" multiple clearable placeholder="Choose Friends" filterable :disabled="inviteDialog.loading" style="width:100%;margin-top:15px") - el-option-group(v-if="API.currentUser" label="ME") - el-option.x-friend-item(:label="API.currentUser.displayName" :value="API.currentUser.id" style="height:auto") - .avatar(:class="userStatusClass(API.currentUser)") - img(v-lazy="API.currentUser.currentAvatarThumbnailImageUrl") - .detail - span.name(v-text="API.currentUser.displayName") - el-option-group(v-if="friendsGroup0.length" label="VIP") - el-option.x-friend-item(v-for="friend in friendsGroup0" :key="friend.id" :label="friend.name" :value="friend.id" style="height:auto") - template(v-if="friend.ref") - .avatar(:class="userStatusClass(friend.ref)") - img(v-lazy="friend.ref.currentAvatarThumbnailImageUrl") - .detail - span.name(v-text="friend.ref.displayName" :class="friend.ref.$trustClass") - span(v-else v-text="friend.id") - el-option-group(v-if="friendsGroup1.length" label="ONLINE") - el-option.x-friend-item(v-for="friend in friendsGroup1" :key="friend.id" :label="friend.name" :value="friend.id" style="height:auto") - template(v-if="friend.ref") - .avatar(:class="userStatusClass(friend.ref)") - img(v-lazy="friend.ref.currentAvatarThumbnailImageUrl") - .detail - span.name(v-text="friend.ref.displayName" :class="friend.ref.$trustClass") - span(v-else v-text="friend.id") - el-option-group(v-if="friendsGroup2.length" label="ACTIVE") - el-option.x-friend-item(v-for="friend in friendsGroup2" :key="friend.id" :label="friend.name" :value="friend.id" style="height:auto") - template(v-if="friend.ref") - .avatar - img(v-lazy="friend.ref.currentAvatarThumbnailImageUrl") - .detail - span.name(v-text="friend.ref.displayName" :class="friend.ref.$trustClass") - span(v-else v-text="friend.id") - template(#footer) - el-button(type="primary" size="small" :disabled="inviteDialog.loading || !inviteDialog.userIds.length" @click="sendInvite()") Invite + //- dialog: invite + el-dialog.x-dialog(ref="inviteDialog" :visible.sync="inviteDialog.visible" title="Invite" width="450px") + div(v-loading="inviteDialog.loading") + location(:location="inviteDialog.worldId" :link="false") + el-select(v-model="inviteDialog.userIds" multiple clearable placeholder="Choose Friends" filterable :disabled="inviteDialog.loading" style="width:100%;margin-top:15px") + el-option-group(v-if="API.currentUser" label="ME") + el-option.x-friend-item(:label="API.currentUser.displayName" :value="API.currentUser.id" style="height:auto") + .avatar(:class="userStatusClass(API.currentUser)") + img(v-lazy="API.currentUser.currentAvatarThumbnailImageUrl") + .detail + span.name(v-text="API.currentUser.displayName") + el-option-group(v-if="friendsGroup0.length" label="VIP") + el-option.x-friend-item(v-for="friend in friendsGroup0" :key="friend.id" :label="friend.name" :value="friend.id" style="height:auto") + template(v-if="friend.ref") + .avatar(:class="userStatusClass(friend.ref)") + img(v-lazy="friend.ref.currentAvatarThumbnailImageUrl") + .detail + span.name(v-text="friend.ref.displayName" :class="friend.ref.$trustClass") + span(v-else v-text="friend.id") + el-option-group(v-if="friendsGroup1.length" label="ONLINE") + el-option.x-friend-item(v-for="friend in friendsGroup1" :key="friend.id" :label="friend.name" :value="friend.id" style="height:auto") + template(v-if="friend.ref") + .avatar(:class="userStatusClass(friend.ref)") + img(v-lazy="friend.ref.currentAvatarThumbnailImageUrl") + .detail + span.name(v-text="friend.ref.displayName" :class="friend.ref.$trustClass") + span(v-else v-text="friend.id") + el-option-group(v-if="friendsGroup2.length" label="ACTIVE") + el-option.x-friend-item(v-for="friend in friendsGroup2" :key="friend.id" :label="friend.name" :value="friend.id" style="height:auto") + template(v-if="friend.ref") + .avatar + img(v-lazy="friend.ref.currentAvatarThumbnailImageUrl") + .detail + span.name(v-text="friend.ref.displayName" :class="friend.ref.$trustClass") + span(v-else v-text="friend.id") + template(#footer) + el-button(type="primary" size="small" :disabled="inviteDialog.loading || !inviteDialog.userIds.length" @click="sendInvite()") Invite - //- dialog: social status - el-dialog.x-dialog(ref="socialStatusDialog" :visible.sync="socialStatusDialog.visible" title="Social Status" width="400px") - el-row(v-loading="socialStatusDialog.loading") - el-col(:span="9") - el-select(v-model="socialStatusDialog.status") - el-option(label="Online" value="active"). - #[i.x-user-status.active] Online - el-option(label="Join Me" value="join me"). - #[i.x-user-status.joinme] Join Me - el-option(label="Ask Me" value="ask me"). - #[i.x-user-status.askme] Ask Me - el-option(label="Do Not Disturb" value="busy"). - #[i.x-user-status.busy] Do Not Disturb - el-option(label="Offline" value="offline"). - #[i.x-user-status.offline] Offline - el-col(:span="1")   - el-col(:span="14") - el-input(v-model="socialStatusDialog.statusDescription" placeholder="Status") - template(#footer) - el-button(type="primary" size="small" :disabled="socialStatusDialog.loading" @click="saveSocialStatus") Update + //- dialog: social status + el-dialog.x-dialog(ref="socialStatusDialog" :visible.sync="socialStatusDialog.visible" title="Social Status" width="400px") + el-row(v-loading="socialStatusDialog.loading") + el-col(:span="9") + el-select(v-model="socialStatusDialog.status") + el-option(label="Online" value="active"). + #[i.x-user-status.active] Online + el-option(label="Join Me" value="join me"). + #[i.x-user-status.joinme] Join Me + el-option(label="Ask Me" value="ask me"). + #[i.x-user-status.askme] Ask Me + el-option(label="Do Not Disturb" value="busy"). + #[i.x-user-status.busy] Do Not Disturb + el-option(label="Offline" value="offline"). + #[i.x-user-status.offline] Offline + el-col(:span="1")   + el-col(:span="14") + el-input(v-model="socialStatusDialog.statusDescription" placeholder="Status") + template(#footer) + el-button(type="primary" size="small" :disabled="socialStatusDialog.loading" @click="saveSocialStatus") Update - //- dialog: bio - el-dialog.x-dialog(ref="bioDialog" :visible.sync="bioDialog.visible" title="Bio" width="400px") - el-input(type="textarea" v-model="bioDialog.bio" size="mini" maxlength="512" show-word-limit :autosize="{ minRows:2, maxRows:5 }" placeholder="Please input a bio") - el-input(v-for="(link, index) in bioDialog.bioLinks" :key="index" :value="link" v-model="bioDialog.bioLinks[index]" size="small" style="margin-top:5px") - img(slot="prepend" :src="getFaviconUrl(link)" style="width:16px;height:16px") - el-button(slot="append" icon="el-icon-delete" @click="bioDialog.bioLinks.splice(index, 1)") - el-button(@click="bioDialog.bioLinks.push('')" size="mini" style="margin-top:5px") Add Link - template(#footer) - el-button(type="primary" size="small" :disabled="bioDialog.loading" @click="saveBio") Update + //- dialog: bio + el-dialog.x-dialog(ref="bioDialog" :visible.sync="bioDialog.visible" title="Bio" width="400px") + el-input(type="textarea" v-model="bioDialog.bio" size="mini" maxlength="512" show-word-limit :autosize="{ minRows:2, maxRows:5 }" placeholder="Please input a bio") + el-input(v-for="(link, index) in bioDialog.bioLinks" :key="index" :value="link" v-model="bioDialog.bioLinks[index]" size="small" style="margin-top:5px") + img(slot="prepend" :src="getFaviconUrl(link)" style="width:16px;height:16px") + el-button(slot="append" icon="el-icon-delete" @click="bioDialog.bioLinks.splice(index, 1)") + el-button(@click="bioDialog.bioLinks.push('')" size="mini" style="margin-top:5px") Add Link + template(#footer) + el-button(type="primary" size="small" :disabled="bioDialog.loading" @click="saveBio") Update - //- dialog: new instance - el-dialog.x-dialog(ref="newInstanceDialog" :visible.sync="newInstanceDialog.visible" title="New Instance" width="600px") - el-form(:model="newInstanceDialog" label-width="100px") - el-form-item(label="Access Type") - el-radio-group(v-model="newInstanceDialog.accessType" size="mini" @change="buildInstance") - el-radio-button(label="public") - el-radio-button(label="friends+") - el-radio-button(label="friends") - el-radio-button(label="invite+") - el-radio-button(label="invite") - el-form-item(label="World ID") - el-input(v-model="newInstanceDialog.worldId" size="mini" @click.native="$event.target.tagName === 'INPUT' && $event.target.select()") - el-form-item(label="Instance ID") - el-input(v-model="newInstanceDialog.instanceId" size="mini" @click.native="$event.target.tagName === 'INPUT' && $event.target.select()") - el-form-item(label="Location") - el-input(v-model="newInstanceDialog.location" size="mini" readonly @click.native="$event.target.tagName === 'INPUT' && $event.target.select()") - el-form-item(label="URL") - el-input(ref="wtf" v-model="newInstanceDialog.url" size="mini" readonly @click.native="$event.target.tagName === 'INPUT' && $event.target.select()") - template(#footer) - el-button(size="small" @click="makeHome(newInstanceDialog.location)") Make Home - el-button(size="small" @click="showInviteDialog(newInstanceDialog.location)") Invite - el-button(type="primary" size="small" @click="showLaunchDialog(newInstanceDialog.location)") Launch + //- dialog: new instance + el-dialog.x-dialog(ref="newInstanceDialog" :visible.sync="newInstanceDialog.visible" title="New Instance" width="600px") + el-form(:model="newInstanceDialog" label-width="100px") + el-form-item(label="Access Type") + el-radio-group(v-model="newInstanceDialog.accessType" size="mini" @change="buildInstance") + el-radio-button(label="public") + el-radio-button(label="friends+") + el-radio-button(label="friends") + el-radio-button(label="invite+") + el-radio-button(label="invite") + el-form-item(label="World ID") + el-input(v-model="newInstanceDialog.worldId" size="mini" @click.native="$event.target.tagName === 'INPUT' && $event.target.select()") + el-form-item(label="Instance ID") + el-input(v-model="newInstanceDialog.instanceId" size="mini" @click.native="$event.target.tagName === 'INPUT' && $event.target.select()") + el-form-item(label="Location") + el-input(v-model="newInstanceDialog.location" size="mini" readonly @click.native="$event.target.tagName === 'INPUT' && $event.target.select()") + el-form-item(label="URL") + el-input(ref="wtf" v-model="newInstanceDialog.url" size="mini" readonly @click.native="$event.target.tagName === 'INPUT' && $event.target.select()") + template(#footer) + el-button(size="small" @click="makeHome(newInstanceDialog.location)") Make Home + el-button(size="small" @click="showInviteDialog(newInstanceDialog.location)") Invite + el-button(type="primary" size="small" @click="showLaunchDialog(newInstanceDialog.location)") Launch - //- dialog: launch - el-dialog.x-dialog(ref="launchDialog" :visible.sync="launchDialog.visible" title="Launch" width="400px") - div #[span(v-text="launchDialog.url" style="word-break:break-all;font-size:12px")] - template(#footer) - el-checkbox(v-model="launchDialog.desktop" style="float:left;margin-top:5px") Start as Desktop (No VR) - el-button(size="small" @click="showInviteDialog(launchDialog.location)") Invite - el-button(type="primary" size="small" @click="launchGame()") Launch + //- dialog: launch + el-dialog.x-dialog(ref="launchDialog" :visible.sync="launchDialog.visible" title="Launch" width="400px") + div #[span(v-text="launchDialog.url" style="word-break:break-all;font-size:12px")] + template(#footer) + el-checkbox(v-model="launchDialog.desktop" style="float:left;margin-top:5px") Start as Desktop (No VR) + el-button(size="small" @click="showInviteDialog(launchDialog.location)") Invite + el-button(type="primary" size="small" @click="launchGame()") Launch - //- dialog: open source software notice - el-dialog.x-dialog(:visible.sync="ossDialog" title="Open Source Software Notice" width="650px") - div(style="height:350px;overflow:hidden scroll;word-break:break-all") - div - span VRCX is based on open source software. It was possible because of their contribution. - div(style="margin-top:15px") - p(style="font-weight:bold") animate.css - pre(style="font-size:12px;white-space:pre-line"). - The MIT License (MIT) - - Copyright (c) 2019 Daniel Eden - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. - div(style="margin-top:15px") - p(style="font-weight:bold") CefSharp - pre(style="font-size:12px;white-space:pre-line"). - // Copyright © The CefSharp Authors. All rights reserved. - // - // Redistribution and use in source and binary forms, with or without - // modification, are permitted provided that the following conditions are - // met: - // - // * Redistributions of source code must retain the above copyright - // notice, this list of conditions and the following disclaimer. - // - // * Redistributions in binary form must reproduce the above - // copyright notice, this list of conditions and the following disclaimer - // in the documentation and/or other materials provided with the - // distribution. - // - // * Neither the name of Google Inc. nor the name Chromium Embedded - // Framework nor the name CefSharp nor the names of its contributors - // may be used to endorse or promote products derived from this software - // without specific prior written permission. - // - // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - // "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - // LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - // A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - // OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - // SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - // LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - // DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - // THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - // (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - div(style="margin-top:15px") - p(style="font-weight:bold") DiscordRichPresence - pre(style="font-size:12px;white-space:pre-line"). - MIT License - - Copyright (c) 2018 Lachee - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. - div(style="margin-top:15px") - p(style="font-weight:bold") element - pre(style="font-size:12px;white-space:pre-line"). - The MIT License (MIT) - - Copyright (c) 2016-present ElemeFE - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. - div(style="margin-top:15px") - p(style="font-weight:bold") Newtonsoft.Json - pre(style="font-size:12px;white-space:pre-line"). - The MIT License (MIT) - - Copyright (c) 2007 James Newton-King - - Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - div(style="margin-top:15px") - p(style="font-weight:bold") normalize - pre(style="font-size:12px;white-space:pre-line"). - The MIT License (MIT) - - Copyright © Nicolas Gallagher and Jonathan Neal - - Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - div(style="margin-top:15px") - p(style="font-weight:bold") noty - pre(style="font-size:12px;white-space:pre-line"). - Copyright (c) 2012 Nedim Arabacı - - Permission is hereby granted, free of charge, to any person obtaining - a copy of this software and associated documentation files (the - "Software"), to deal in the Software without restriction, including - without limitation the rights to use, copy, modify, merge, publish, - distribute, sublicense, and/or sell copies of the Software, and to - permit persons to whom the Software is furnished to do so, subject to - the following conditions: - - The above copyright notice and this permission notice shall be - included in all copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF - MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND - NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE - LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION - OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - div(style="margin-top:15px") - p(style="font-weight:bold") OpenVR SDK - pre(style="font-size:12px;white-space:pre-line"). - Copyright (c) 2015, Valve Corporation - All rights reserved. - - Redistribution and use in source and binary forms, with or without modification, - are permitted provided that the following conditions are met: - - 1. Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - - 2. Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation and/or - other materials provided with the distribution. - - 3. Neither the name of the copyright holder nor the names of its contributors - may be used to endorse or promote products derived from this software without - specific prior written permission. - - THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND - ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED - WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR - ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON - ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - div(style="margin-top:15px") - p(style="font-weight:bold") SharpDX - pre(style="font-size:12px;white-space:pre-line"). - Copyright (c) 2010-2014 SharpDX - Alexandre Mutel - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in - all copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - THE SOFTWARE. - div(style="margin-top:15px") - p(style="font-weight:bold") vue - pre(style="font-size:12px;white-space:pre-line"). - The MIT License (MIT) - - Copyright (c) 2013-present, Yuxi (Evan) You - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in - all copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - THE SOFTWARE. - div(style="margin-top:15px") - p(style="font-weight:bold") vue-data-tables - pre(style="font-size:12px;white-space:pre-line"). - The MIT License (MIT) - - Copyright (c) 2018 Leon Zhang - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. - div(style="margin-top:15px") - p(style="font-weight:bold") vue-lazyload - pre(style="font-size:12px;white-space:pre-line"). - The MIT License (MIT) - - Copyright (c) 2016 Awe - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. - script(src="app.js") + //- dialog: open source software notice + el-dialog.x-dialog(:visible.sync="ossDialog" title="Open Source Software Notice" width="650px") + div(style="height:350px;overflow:hidden scroll;word-break:break-all") + div + span VRCX is based on open source software. It was possible because of their contribution. + div(style="margin-top:15px") + p(style="font-weight:bold") animate.css + pre(style="font-size:12px;white-space:pre-line"). + The MIT License (MIT) + + Copyright (c) 2019 Daniel Eden + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + div(style="margin-top:15px") + p(style="font-weight:bold") CefSharp + pre(style="font-size:12px;white-space:pre-line"). + // Copyright © The CefSharp Authors. All rights reserved. + // + // Redistribution and use in source and binary forms, with or without + // modification, are permitted provided that the following conditions are + // met: + // + // * Redistributions of source code must retain the above copyright + // notice, this list of conditions and the following disclaimer. + // + // * Redistributions in binary form must reproduce the above + // copyright notice, this list of conditions and the following disclaimer + // in the documentation and/or other materials provided with the + // distribution. + // + // * Neither the name of Google Inc. nor the name Chromium Embedded + // Framework nor the name CefSharp nor the names of its contributors + // may be used to endorse or promote products derived from this software + // without specific prior written permission. + // + // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + // "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + // LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + // A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + // OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + // SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + // LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + // DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + // THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + // (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + div(style="margin-top:15px") + p(style="font-weight:bold") DiscordRichPresence + pre(style="font-size:12px;white-space:pre-line"). + MIT License + + Copyright (c) 2018 Lachee + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + div(style="margin-top:15px") + p(style="font-weight:bold") element + pre(style="font-size:12px;white-space:pre-line"). + The MIT License (MIT) + + Copyright (c) 2016-present ElemeFE + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + div(style="margin-top:15px") + p(style="font-weight:bold") Newtonsoft.Json + pre(style="font-size:12px;white-space:pre-line"). + The MIT License (MIT) + + Copyright (c) 2007 James Newton-King + + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + div(style="margin-top:15px") + p(style="font-weight:bold") normalize + pre(style="font-size:12px;white-space:pre-line"). + The MIT License (MIT) + + Copyright © Nicolas Gallagher and Jonathan Neal + + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + div(style="margin-top:15px") + p(style="font-weight:bold") noty + pre(style="font-size:12px;white-space:pre-line"). + Copyright (c) 2012 Nedim Arabacı + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + div(style="margin-top:15px") + p(style="font-weight:bold") OpenVR SDK + pre(style="font-size:12px;white-space:pre-line"). + Copyright (c) 2015, Valve Corporation + All rights reserved. + + Redistribution and use in source and binary forms, with or without modification, + are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation and/or + other materials provided with the distribution. + + 3. Neither the name of the copyright holder nor the names of its contributors + may be used to endorse or promote products derived from this software without + specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + div(style="margin-top:15px") + p(style="font-weight:bold") SharpDX + pre(style="font-size:12px;white-space:pre-line"). + Copyright (c) 2010-2014 SharpDX - Alexandre Mutel + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + div(style="margin-top:15px") + p(style="font-weight:bold") vue + pre(style="font-size:12px;white-space:pre-line"). + The MIT License (MIT) + + Copyright (c) 2013-present, Yuxi (Evan) You + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + div(style="margin-top:15px") + p(style="font-weight:bold") vue-data-tables + pre(style="font-size:12px;white-space:pre-line"). + The MIT License (MIT) + + Copyright (c) 2018 Leon Zhang + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + div(style="margin-top:15px") + p(style="font-weight:bold") vue-lazyload + pre(style="font-size:12px;white-space:pre-line"). + The MIT License (MIT) + + Copyright (c) 2016 Awe + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + script(src="app.js") diff --git a/html/src/vr.js b/html/src/vr.js index 1e874d35..28558c78 100644 --- a/html/src/vr.js +++ b/html/src/vr.js @@ -10,763 +10,763 @@ import ElementUI from 'element-ui'; import locale from 'element-ui/lib/locale/lang/en'; CefSharp.BindObjectAsync( - 'VRCX', - 'VRCXStorage', - 'SQLite' + 'VRCX', + 'VRCXStorage', + 'SQLite' ).then(function () { - VRCXStorage.GetBool = function (key) { - return this.Get(key) === 'true'; - }; + VRCXStorage.GetBool = function (key) { + return this.Get(key) === 'true'; + }; - VRCXStorage.SetBool = function (key, value) { - this.Set(key, value - ? 'true' - : 'false'); - }; + VRCXStorage.SetBool = function (key, value) { + this.Set(key, value + ? 'true' + : 'false'); + }; - VRCXStorage.GetInt = function (key) { - return parseInt(this.Get(key), 10) || 0; - }; + VRCXStorage.GetInt = function (key) { + return parseInt(this.Get(key), 10) || 0; + }; - VRCXStorage.SetInt = function (key, value) { - this.Set(key, String(value)); - }; + VRCXStorage.SetInt = function (key, value) { + this.Set(key, String(value)); + }; - VRCXStorage.GetFloat = function (key) { - return parseFloat(this.Get(key), 10) || 0.0; - }; + VRCXStorage.GetFloat = function (key) { + return parseFloat(this.Get(key), 10) || 0.0; + }; - VRCXStorage.SetFloat = function (key, value) { - this.Set(key, String(value)); - }; + VRCXStorage.SetFloat = function (key, value) { + this.Set(key, String(value)); + }; - 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.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.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.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)); - }; + VRCXStorage.SetObject = function (key, value) { + this.Set(key, JSON.stringify(value)); + }; - Noty.overrideDefaults({ - animation: { - open: 'animated fadeIn', - close: 'animated zoomOut' - }, - layout: 'topCenter', - theme: 'relax', - timeout: 6000 - }); + Noty.overrideDefaults({ + animation: { + open: 'animated fadeIn', + close: 'animated zoomOut' + }, + layout: 'topCenter', + theme: 'relax', + timeout: 6000 + }); - Vue.use(ElementUI, { - locale - }); + Vue.use(ElementUI, { + locale + }); - var escapeTag = (s) => String(s).replace(/["&'<>]/gu, (c) => `&#${c.charCodeAt(0)};`); - Vue.filter('escapeTag', escapeTag); + var escapeTag = (s) => String(s).replace(/["&'<>]/gu, (c) => `&#${c.charCodeAt(0)};`); + Vue.filter('escapeTag', escapeTag); - var commaNumber = (n) => String(Number(n) || 0).replace(/(\d)(?=(\d{3})+(?!\d))/gu, '$1,'); - Vue.filter('commaNumber', commaNumber); + var commaNumber = (n) => String(Number(n) || 0).replace(/(\d)(?=(\d{3})+(?!\d))/gu, '$1,'); + Vue.filter('commaNumber', commaNumber); - var formatDate = (s, format) => { - var dt = new Date(s); - if (isNaN(dt)) { - return escapeTag(s); - } - 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/gu, (c) => map[c] || c); - }; - Vue.filter('formatDate', formatDate); + var formatDate = (s, format) => { + var dt = new Date(s); + if (isNaN(dt)) { + return escapeTag(s); + } + 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/gu, (c) => map[c] || c); + }; + Vue.filter('formatDate', formatDate); - var textToHex = (s) => String(s).split('').map((c) => c.charCodeAt(0).toString(16)).join(' '); - Vue.filter('textToHex', textToHex); + var textToHex = (s) => String(s).split('').map((c) => c.charCodeAt(0).toString(16)).join(' '); + Vue.filter('textToHex', textToHex); - var timeToText = (t) => { - var sec = Number(t); - if (isNaN(sec)) { - return escapeTag(t); - } - sec = Math.floor(sec / 1000); - var arr = []; - if (sec < 0) { - sec = -sec; - } - if (sec >= 86400) { - arr.push(`${Math.floor(sec / 86400)}d`); - sec %= 86400; - } - if (sec >= 3600) { - arr.push(`${Math.floor(sec / 3600)}h`); - sec %= 3600; - } - if (sec >= 60) { - arr.push(`${Math.floor(sec / 60)}m`); - sec %= 60; - } - if (sec || - !arr.length) { - arr.push(`${sec}s`); - } - return arr.join(' '); - }; - Vue.filter('timeToText', timeToText); + var timeToText = (t) => { + var sec = Number(t); + if (isNaN(sec)) { + return escapeTag(t); + } + sec = Math.floor(sec / 1000); + var arr = []; + if (sec < 0) { + sec = -sec; + } + if (sec >= 86400) { + arr.push(`${Math.floor(sec / 86400)}d`); + sec %= 86400; + } + if (sec >= 3600) { + arr.push(`${Math.floor(sec / 3600)}h`); + sec %= 3600; + } + if (sec >= 60) { + arr.push(`${Math.floor(sec / 60)}m`); + sec %= 60; + } + if (sec || + !arr.length) { + arr.push(`${sec}s`); + } + return arr.join(' '); + }; + Vue.filter('timeToText', timeToText); - ELEMENT.locale(ELEMENT.lang.en); + ELEMENT.locale(ELEMENT.lang.en); - // - // API - // + // + // API + // - var API = {}; + var API = {}; - API.eventHandlers = new Map(); + API.eventHandlers = new Map(); - API.$emit = function (name, ...args) { - // console.log(name, ...args); - var handlers = this.eventHandlers.get(name); - if (handlers === undefined) { - return; - } - try { - for (var handler of handlers) { - handler.apply(this, args); - } - } catch (err) { - console.error(err); - } - }; + API.$emit = function (name, ...args) { + // console.log(name, ...args); + var handlers = this.eventHandlers.get(name); + if (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 (handlers === undefined) { - handlers = []; - this.eventHandlers.set(name, handlers); - } - handlers.push(handler); - }; + API.$on = function (name, handler) { + var handlers = this.eventHandlers.get(name); + if (handlers === undefined) { + handlers = []; + this.eventHandlers.set(name, handlers); + } + handlers.push(handler); + }; - API.$off = function (name, handler) { - var handlers = this.eventHandlers.get(name); - if (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.$off = function (name, handler) { + var handlers = this.eventHandlers.get(name); + if (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.pendingGetRequests = new Map(); - API.call = function (endpoint, options) { - var resource = `https://api.vrchat.cloud/api/1/${endpoint}`; - var init = { - method: 'GET', - mode: 'cors', - credentials: 'include', - cache: 'no-cache', - redirect: 'follow', - referrerPolicy: 'no-referrer', - ...options - }; - var { params } = init; - var isGetRequest = init.method === 'GET'; - if (isGetRequest) { - // transform body to url - if (params === Object(params)) { - var url = new URL(resource); - var { searchParams } = url; - for (var key in params) { - searchParams.set(key, params[key]); - } - resource = url.toString(); - } - // merge requests - var req = this.pendingGetRequests.get(resource); - if (req !== undefined) { - return req; - } - } else { - init.headers = { - 'Content-Type': 'application/json;charset=utf-8', - ...init.headers - }; - init.body = params === Object(params) - ? JSON.stringify(params) - : '{}'; - } - var req = fetch(resource, init).catch((err) => { - this.$throw(0, err); - }).then((res) => res.json().catch(() => { - if (res.ok) { - this.$throw(0, 'Invalid JSON response'); - } - this.$throw(res.status); - }).then((json) => { - if (res.ok) { - if (json.success === Object(json.success)) { - new Noty({ - type: 'success', - text: escapeTag(json.success.message) - }).show(); - } - return json; - } - if (json === Object(json)) { - if (json.error === Object(json.error)) { - this.$throw( - json.error.status_code || res.status, - json.error.message, - json.error.data - ); - } else if (typeof json.error === 'string') { - this.$throw( - json.status_code || res.status, - json.error - ); - } - } - this.$throw(res.status, json); - return json; - })); - if (isGetRequest) { - req.finally(() => { - this.pendingGetRequests.delete(resource); - }); - this.pendingGetRequests.set(resource, req); - } - return req; - }; + API.call = function (endpoint, options) { + var resource = `https://api.vrchat.cloud/api/1/${endpoint}`; + var init = { + method: 'GET', + mode: 'cors', + credentials: 'include', + cache: 'no-cache', + redirect: 'follow', + referrerPolicy: 'no-referrer', + ...options + }; + var { params } = init; + var isGetRequest = init.method === 'GET'; + if (isGetRequest) { + // transform body to url + if (params === Object(params)) { + var url = new URL(resource); + var { searchParams } = url; + for (var key in params) { + searchParams.set(key, params[key]); + } + resource = url.toString(); + } + // merge requests + var req = this.pendingGetRequests.get(resource); + if (req !== undefined) { + return req; + } + } else { + init.headers = { + 'Content-Type': 'application/json;charset=utf-8', + ...init.headers + }; + init.body = params === Object(params) + ? JSON.stringify(params) + : '{}'; + } + var req = fetch(resource, init).catch((err) => { + this.$throw(0, err); + }).then((res) => res.json().catch(() => { + if (res.ok) { + this.$throw(0, 'Invalid JSON response'); + } + this.$throw(res.status); + }).then((json) => { + if (res.ok) { + if (json.success === Object(json.success)) { + new Noty({ + type: 'success', + text: escapeTag(json.success.message) + }).show(); + } + return json; + } + if (json === Object(json)) { + if (json.error === Object(json.error)) { + this.$throw( + json.error.status_code || res.status, + json.error.message, + json.error.data + ); + } else if (typeof json.error === 'string') { + this.$throw( + json.status_code || res.status, + json.error + ); + } + } + this.$throw(res.status, json); + return json; + })); + if (isGetRequest) { + req.finally(() => { + this.pendingGetRequests.delete(resource); + }); + this.pendingGetRequests.set(resource, 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' - }; + 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' + }; - API.$throw = function (code, error) { - var text = []; - if (code > 0) { - var status = this.statusCodes[code]; - if (status === undefined) { - text.push(`${code}`); - } else { - text.push(`${code} ${status}`); - } - } - if (error !== undefined) { - text.push(JSON.stringify(error)); - } - text = text.map((s) => escapeTag(s)).join('
'); - if (text.length) { - new Noty({ - type: 'error', - text - }).show(); - } - throw new Error(text); - }; + API.$throw = function (code, error) { + var text = []; + if (code > 0) { + var status = this.statusCodes[code]; + if (status === undefined) { + text.push(`${code}`); + } else { + text.push(`${code} ${status}`); + } + } + if (error !== undefined) { + text.push(JSON.stringify(error)); + } + text = text.map((s) => escapeTag(s)).join('
'); + if (text.length) { + new Noty({ + type: 'error', + text + }).show(); + } + throw new Error(text); + }; - // API: Config + // API: Config - API.cachedConfig = {}; + API.cachedConfig = {}; - API.$on('CONFIG', function (args) { - args.ref = this.applyConfig(args.json); - }); + 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.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 = { - ref: null, - json - }; - this.$emit('CONFIG', args); - return args; - }); - }; + API.getConfig = function () { + return this.call('config', { + method: 'GET' + }).then((json) => { + var args = { + ref: null, + json + }; + this.$emit('CONFIG', args); + return args; + }); + }; - // API: Location + // 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; - }; + 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('location', { - template: '{{ text }}', - props: { - location: String - }, - 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 (L.worldId) { - var ref = API.cachedWorlds.get(L.worldId); - if (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; - } - } - } - }, - watch: { - location() { - this.parse(); - } - }, - created() { - this.parse(); - } - }); + Vue.component('location', { + template: '{{ text }}', + props: { + location: String + }, + 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 (L.worldId) { + var ref = API.cachedWorlds.get(L.worldId); + if (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; + } + } + } + }, + watch: { + location() { + this.parse(); + } + }, + created() { + this.parse(); + } + }); - // API: World + // API: World - API.cachedWorlds = new Map(); + API.cachedWorlds = new Map(); - API.$on('WORLD', function (args) { - args.ref = this.applyWorld(args.json); - }); + API.$on('WORLD', function (args) { + args.ref = this.applyWorld(args.json); + }); - API.applyWorld = function (json) { - var ref = this.cachedWorlds.get(json.id); - if (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; - }; + API.applyWorld = function (json) { + var ref = this.cachedWorlds.get(json.id); + if (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 = { - ref: null, - json, - params - }; - this.$emit('WORLD', args); - return args; - }); - }; + /* + params: { + worldId: string + } + */ + API.getWorld = function (params) { + return this.call(`worlds/${params.worldId}`, { + method: 'GET' + }).then((json) => { + var args = { + ref: null, + json, + params + }; + this.$emit('WORLD', args); + return args; + }); + }; - var $app = { - data: { - API, - VRCX, - // 1 = 대시보드랑 손목에 보이는거 - // 2 = 항상 화면에 보이는 거 - appType: location.href.substr(-1), - currentTime: new Date().toJSON(), - cpuUsage: 0, - feeds: [], - devices: [] - }, - computed: {}, - methods: {}, - watch: {}, - el: '#x-app', - mounted() { - // https://media.discordapp.net/attachments/581757976625283083/611170278218924033/unknown.png - // 현재 날짜 시간 - // 컨트롤러 배터리 상황 - // -- - // OO is in Let's Just H!!!!! [GPS] - // OO has logged in [Online] -> TODO: location - // OO has logged out [Offline] -> TODO: location - // OO has joined [OnPlayerJoined] - // OO has left [OnPlayerLeft] - // [Moderation] - // OO has blocked you - // OO has muted you - // OO has hidden you - // -- - API.getConfig().catch((err) => { - // FIXME: 어케 복구하냐 이건 - throw err; - }).then((args) => { - setInterval(() => this.update(), 1000); - this.update(); - this.$nextTick(function () { - if (this.appType === '1') { - this.$el.style.display = ''; - } - }); - return args; - }); - } - }; + var $app = { + data: { + API, + VRCX, + // 1 = 대시보드랑 손목에 보이는거 + // 2 = 항상 화면에 보이는 거 + appType: location.href.substr(-1), + currentTime: new Date().toJSON(), + cpuUsage: 0, + feeds: [], + devices: [] + }, + computed: {}, + methods: {}, + watch: {}, + el: '#x-app', + mounted() { + // https://media.discordapp.net/attachments/581757976625283083/611170278218924033/unknown.png + // 현재 날짜 시간 + // 컨트롤러 배터리 상황 + // -- + // OO is in Let's Just H!!!!! [GPS] + // OO has logged in [Online] -> TODO: location + // OO has logged out [Offline] -> TODO: location + // OO has joined [OnPlayerJoined] + // OO has left [OnPlayerLeft] + // [Moderation] + // OO has blocked you + // OO has muted you + // OO has hidden you + // -- + API.getConfig().catch((err) => { + // FIXME: 어케 복구하냐 이건 + throw err; + }).then((args) => { + setInterval(() => this.update(), 1000); + this.update(); + this.$nextTick(function () { + if (this.appType === '1') { + this.$el.style.display = ''; + } + }); + return args; + }); + } + }; - $app.methods.update = function () { - this.currentTime = new Date().toJSON(); - this.currentUser = VRCXStorage.GetObject('currentUser') || {}; - VRCX.CpuUsage().then((cpuUsage) => { - this.cpuUsage = cpuUsage.toFixed(2); - }); - VRCX.GetVRDevices().then((devices) => { - devices.forEach((device) => { - device[2] = parseInt(device[2], 10); - }); - this.devices = devices; - }); - this.updateSharedFeed(); - }; + $app.methods.update = function () { + this.currentTime = new Date().toJSON(); + this.currentUser = VRCXStorage.GetObject('currentUser') || {}; + VRCX.CpuUsage().then((cpuUsage) => { + this.cpuUsage = cpuUsage.toFixed(2); + }); + VRCX.GetVRDevices().then((devices) => { + devices.forEach((device) => { + device[2] = parseInt(device[2], 10); + }); + this.devices = devices; + }); + this.updateSharedFeed(); + }; - $app.methods.updateSharedFeed = function () { - // TODO: block mute hideAvatar unfriend - var _feeds = this.feeds; - this.feeds = VRCXStorage.GetArray('sharedFeeds'); - if (this.appType === '2') { - var map = {}; - _feeds.forEach((feed) => { - if (feed.isFavorite) { - if (feed.type === 'OnPlayerJoined' || - feed.type === 'OnPlayerLeft') { - if (!map[feed.data] || - map[feed.data] < feed.created_at) { - map[feed.data] = feed.created_at; - } - } else if (feed.type === 'Online' || - feed.type === 'Offline') { - if (!map[feed.displayName] || - map[feed.displayName] < feed.created_at) { - map[feed.displayName] = feed.created_at; - } - } - } - }); - // disable notification on busy - if (this.currentUser.status === 'busy') { - return; - } - var notys = []; - this.feeds.forEach((feed) => { - if (feed.isFavorite) { - if (feed.type === 'Online' || - feed.type === 'Offline') { - if (!map[feed.displayName] || - map[feed.displayName] < feed.created_at) { - map[feed.displayName] = feed.created_at; - notys.push(feed); - } - } else if (feed.type === 'OnPlayerJoined' || - feed.type === 'OnPlayerLeft') { - if (!map[feed.data] || - map[feed.data] < feed.created_at) { - map[feed.data] = feed.created_at; - notys.push(feed); - } - } - } - }); - var bias = new Date(Date.now() - 60000).toJSON(); - notys.forEach((noty) => { - if (noty.created_at > bias) { - if (noty.type === 'OnPlayerJoined') { - new Noty({ - type: 'alert', - text: `${noty.data} has joined` - }).show(); - } else if (noty.type === 'OnPlayerLeft') { - new Noty({ - type: 'alert', - text: `${noty.data} has left` - }).show(); - } else if (noty.type === 'Online') { - new Noty({ - type: 'alert', - text: `${noty.displayName} has logged in` - }).show(); - } else if (noty.type === 'Offline') { - new Noty({ - type: 'alert', - text: `${noty.displayName} has logged out` - }).show(); - } - } - }); - } - }; + $app.methods.updateSharedFeed = function () { + // TODO: block mute hideAvatar unfriend + var _feeds = this.feeds; + this.feeds = VRCXStorage.GetArray('sharedFeeds'); + if (this.appType === '2') { + var map = {}; + _feeds.forEach((feed) => { + if (feed.isFavorite) { + if (feed.type === 'OnPlayerJoined' || + feed.type === 'OnPlayerLeft') { + if (!map[feed.data] || + map[feed.data] < feed.created_at) { + map[feed.data] = feed.created_at; + } + } else if (feed.type === 'Online' || + feed.type === 'Offline') { + if (!map[feed.displayName] || + map[feed.displayName] < feed.created_at) { + map[feed.displayName] = feed.created_at; + } + } + } + }); + // disable notification on busy + if (this.currentUser.status === 'busy') { + return; + } + var notys = []; + this.feeds.forEach((feed) => { + if (feed.isFavorite) { + if (feed.type === 'Online' || + feed.type === 'Offline') { + if (!map[feed.displayName] || + map[feed.displayName] < feed.created_at) { + map[feed.displayName] = feed.created_at; + notys.push(feed); + } + } else if (feed.type === 'OnPlayerJoined' || + feed.type === 'OnPlayerLeft') { + if (!map[feed.data] || + map[feed.data] < feed.created_at) { + map[feed.data] = feed.created_at; + notys.push(feed); + } + } + } + }); + var bias = new Date(Date.now() - 60000).toJSON(); + notys.forEach((noty) => { + if (noty.created_at > bias) { + if (noty.type === 'OnPlayerJoined') { + new Noty({ + type: 'alert', + text: `${noty.data} has joined` + }).show(); + } else if (noty.type === 'OnPlayerLeft') { + new Noty({ + type: 'alert', + text: `${noty.data} has left` + }).show(); + } else if (noty.type === 'Online') { + new Noty({ + type: 'alert', + text: `${noty.displayName} has logged in` + }).show(); + } else if (noty.type === 'Offline') { + new Noty({ + type: 'alert', + text: `${noty.displayName} has logged out` + }).show(); + } + } + }); + } + }; - $app.methods.userStatusClass = function (user) { - var style = {}; - if (user) { - if (user.location === 'offline') { - style.offline = true; - } else if (user.status === 'active') { - style.active = true; - } else if (user.status === 'join me') { - style.joinme = true; - } else if (user.status === 'busy') { - style.busy = true; - } - } - return style; - }; + $app.methods.userStatusClass = function (user) { + var style = {}; + if (user) { + if (user.location === 'offline') { + style.offline = true; + } else if (user.status === 'active') { + style.active = true; + } else if (user.status === 'join me') { + style.joinme = true; + } else if (user.status === 'busy') { + style.busy = true; + } + } + return style; + }; - $app = new Vue($app); - window.$app = $app; + $app = new Vue($app); + window.$app = $app; }); diff --git a/html/src/vr.pug b/html/src/vr.pug index 682de743..a9e17740 100644 --- a/html/src/vr.pug +++ b/html/src/vr.pug @@ -1,89 +1,89 @@ doctype html html - head - meta(http-equiv="Content-Type" content="text/html;charset=utf-8") - meta(http-equiv="Cache-Control" content="no-cache") - meta(http-equiv="referrer" content="no-referrer") - meta(http-equiv="viewport" content="width=device-width,initial-scale=1,user-scalable=no") - title VRCXVR - link(rel="dns-prefetch" href="https://fonts.gstatic.com") - link(rel="preconnect" href="https://api.vrchat.cloud") - link(rel="preconnect" href="https://d348imysud55la.cloudfront.net") - link(rel="stylesheet" href="https://fonts.googleapis.com/css?family=Noto+Sans+JP|Noto+Sans+KR&display=swap") - link(rel="stylesheet" href="vr.css") - body - .x-app#x-app(style="display:none" :class="{ 'x-app-type': appType === '1' }") - .x-container(style="flex:1") - .x-friend-list(ref="list" style="color:#aaa") - template(v-for="feed in feeds") - .x-friend-item(v-if="feed.type === 'GPS'" :class="{ friend: feed.isFriend, favorite: feed.isFavorite }") - .detail - span.extra - span.time {{ feed.created_at | formatDate('HH:MI') }} - | #[span.name(v-text="feed.displayName")] is in #[location(:location="feed.location[0]")] - div(v-else-if="feed.type === 'Offline'" class="x-friend-item" :class="{ friend: feed.isFriend, favorite: feed.isFavorite }") - .detail - span.extra - span.time {{ feed.created_at | formatDate('HH:MI') }} - | #[span.name(v-text="feed.displayName")] has logged out - div(v-else-if="feed.type === 'Online'" class="x-friend-item" :class="{ friend: feed.isFriend, favorite: feed.isFavorite }") - .detail - span.extra - span.time {{ feed.created_at | formatDate('HH:MI') }} - | #[span.name(v-text="feed.displayName")] has logged in - div(v-else-if="feed.type === 'Status'" class="x-friend-item" :class="{ friend: feed.isFriend, favorite: feed.isFavorite }") - .detail - span.extra - span.time {{ feed.created_at | formatDate('HH:MI') }} - | #[span.name(v-text="feed.displayName")] is #[i.x-user-status(:class="userStatusClass(feed.status[0])")] {{feed.status[0].statusDescription}} - div(v-else-if="feed.type === 'OnPlayerJoined'" class="x-friend-item" :class="{ friend: feed.isFriend, favorite: feed.isFavorite }") - .detail - span.extra - span.time {{ feed.created_at | formatDate('HH:MI') }} - | #[span.name(v-text="feed.data")] has joined - div(v-else-if="feed.type === 'OnPlayerLeft'" class="x-friend-item" :class="{ friend: feed.isFriend, favorite: feed.isFavorite }") - .detail - span.extra - span.time {{ feed.created_at | formatDate('HH:MI') }} - | #[span.name(v-text="feed.data")] has left - div(v-else-if="feed.type === 'Location'" class="x-friend-item" :class="{ friend: feed.isFriend, favorite: feed.isFavorite }") - .detail - span.extra - span.time {{ feed.created_at | formatDate('HH:MI') }} - location(:location="feed.data") - .x-container - div(style="display:flex;flex-direction:row") - template(v-if="devices.length") - div(v-for="device in devices" style="flex:none;text-align:center;width:64px") - template(v-if="device[0] === 'tracker'") - img(v-if="device[1] !== 'connected'" src="images/tracker_status_off.png" style="width:32px;height:32px") - img(v-else-if="device[2] < 20" src="images/tracker_status_ready_low.png" style="width:32px;height:32px") - img(v-else src="images/tracker_status_ready.png" style="width:32px;height:32px") - template(v-else-if="device[0] === 'leftController'") - img(v-if="device[1] !== 'connected'" src="images/controller_status_off.png" style="width:32px;height:32px") - img(v-else-if="device[2] < 20" src="images/controller_status_ready_low.png" style="width:32px;height:32px") - img(v-else src="images/controller_status_ready.png" style="width:32px;height:32px") - template(v-else-if="device[0] === 'rightController'") - img(v-if="device[1] !== 'connected'" src="images/controller_status_off.png" style="width:32px;height:32px") - img(v-else-if="device[2] < 20" src="images/controller_status_ready_low.png" style="width:32px;height:32px") - img(v-else src="images/controller_status_ready.png" style="width:32px;height:32px") - template(v-else-if="device[0] === 'controller'") - img(v-if="device[1] !== 'connected'" src="images/controller_status_off.png" style="width:32px;height:32px") - img(v-else-if="device[2] < 20" src="images/controller_status_ready_low.png" style="width:32px;height:32px") - img(v-else src="images/controller_status_ready.png" style="width:32px;height:32px") - template(v-else-if="device[0] === 'base'") - img(v-if="device[1] !== 'connected'" src="images/base_status_off.png" style="width:32px;height:32px") - img(v-else-if="device[2] < 20" src="images/base_status_ready_low.png" style="width:32px;height:32px") - img(v-else src="images/base_status_ready.png" style="width:32px;height:32px") - template(v-else) - img(v-if="device[1] !== 'connected'" src="images/other_status_off.png" style="width:32px;height:32px") - img(v-else-if="device[2] < 20" src="images/other_status_ready_low.png" style="width:32px;height:32px") - img(v-else src="images/other_status_ready.png" style="width:32px;height:32px") - br - span {{ device[2] }}% - div(v-else) - span No SteamVR Devices - .x-container - span(style="float:right") {{ currentTime | formatDate('YYYY-MM-DD HH:MI:SS AMPM') }} - span CPU {{ cpuUsage }}% - script(src="vr.js") + head + meta(http-equiv="Content-Type" content="text/html;charset=utf-8") + meta(http-equiv="Cache-Control" content="no-cache") + meta(http-equiv="referrer" content="no-referrer") + meta(http-equiv="viewport" content="width=device-width,initial-scale=1,user-scalable=no") + title VRCXVR + link(rel="dns-prefetch" href="https://fonts.gstatic.com") + link(rel="preconnect" href="https://api.vrchat.cloud") + link(rel="preconnect" href="https://d348imysud55la.cloudfront.net") + link(rel="stylesheet" href="https://fonts.googleapis.com/css?family=Noto+Sans+JP|Noto+Sans+KR&display=swap") + link(rel="stylesheet" href="vr.css") + body + .x-app#x-app(style="display:none" :class="{ 'x-app-type': appType === '1' }") + .x-container(style="flex:1") + .x-friend-list(ref="list" style="color:#aaa") + template(v-for="feed in feeds") + .x-friend-item(v-if="feed.type === 'GPS'" :class="{ friend: feed.isFriend, favorite: feed.isFavorite }") + .detail + span.extra + span.time {{ feed.created_at | formatDate('HH:MI') }} + | #[span.name(v-text="feed.displayName")] is in #[location(:location="feed.location[0]")] + div(v-else-if="feed.type === 'Offline'" class="x-friend-item" :class="{ friend: feed.isFriend, favorite: feed.isFavorite }") + .detail + span.extra + span.time {{ feed.created_at | formatDate('HH:MI') }} + | #[span.name(v-text="feed.displayName")] has logged out + div(v-else-if="feed.type === 'Online'" class="x-friend-item" :class="{ friend: feed.isFriend, favorite: feed.isFavorite }") + .detail + span.extra + span.time {{ feed.created_at | formatDate('HH:MI') }} + | #[span.name(v-text="feed.displayName")] has logged in + div(v-else-if="feed.type === 'Status'" class="x-friend-item" :class="{ friend: feed.isFriend, favorite: feed.isFavorite }") + .detail + span.extra + span.time {{ feed.created_at | formatDate('HH:MI') }} + | #[span.name(v-text="feed.displayName")] is #[i.x-user-status(:class="userStatusClass(feed.status[0])")] {{feed.status[0].statusDescription}} + div(v-else-if="feed.type === 'OnPlayerJoined'" class="x-friend-item" :class="{ friend: feed.isFriend, favorite: feed.isFavorite }") + .detail + span.extra + span.time {{ feed.created_at | formatDate('HH:MI') }} + | #[span.name(v-text="feed.data")] has joined + div(v-else-if="feed.type === 'OnPlayerLeft'" class="x-friend-item" :class="{ friend: feed.isFriend, favorite: feed.isFavorite }") + .detail + span.extra + span.time {{ feed.created_at | formatDate('HH:MI') }} + | #[span.name(v-text="feed.data")] has left + div(v-else-if="feed.type === 'Location'" class="x-friend-item" :class="{ friend: feed.isFriend, favorite: feed.isFavorite }") + .detail + span.extra + span.time {{ feed.created_at | formatDate('HH:MI') }} + location(:location="feed.data") + .x-container + div(style="display:flex;flex-direction:row") + template(v-if="devices.length") + div(v-for="device in devices" style="flex:none;text-align:center;width:64px") + template(v-if="device[0] === 'tracker'") + img(v-if="device[1] !== 'connected'" src="images/tracker_status_off.png" style="width:32px;height:32px") + img(v-else-if="device[2] < 20" src="images/tracker_status_ready_low.png" style="width:32px;height:32px") + img(v-else src="images/tracker_status_ready.png" style="width:32px;height:32px") + template(v-else-if="device[0] === 'leftController'") + img(v-if="device[1] !== 'connected'" src="images/controller_status_off.png" style="width:32px;height:32px") + img(v-else-if="device[2] < 20" src="images/controller_status_ready_low.png" style="width:32px;height:32px") + img(v-else src="images/controller_status_ready.png" style="width:32px;height:32px") + template(v-else-if="device[0] === 'rightController'") + img(v-if="device[1] !== 'connected'" src="images/controller_status_off.png" style="width:32px;height:32px") + img(v-else-if="device[2] < 20" src="images/controller_status_ready_low.png" style="width:32px;height:32px") + img(v-else src="images/controller_status_ready.png" style="width:32px;height:32px") + template(v-else-if="device[0] === 'controller'") + img(v-if="device[1] !== 'connected'" src="images/controller_status_off.png" style="width:32px;height:32px") + img(v-else-if="device[2] < 20" src="images/controller_status_ready_low.png" style="width:32px;height:32px") + img(v-else src="images/controller_status_ready.png" style="width:32px;height:32px") + template(v-else-if="device[0] === 'base'") + img(v-if="device[1] !== 'connected'" src="images/base_status_off.png" style="width:32px;height:32px") + img(v-else-if="device[2] < 20" src="images/base_status_ready_low.png" style="width:32px;height:32px") + img(v-else src="images/base_status_ready.png" style="width:32px;height:32px") + template(v-else) + img(v-if="device[1] !== 'connected'" src="images/other_status_off.png" style="width:32px;height:32px") + img(v-else-if="device[2] < 20" src="images/other_status_ready_low.png" style="width:32px;height:32px") + img(v-else src="images/other_status_ready.png" style="width:32px;height:32px") + br + span {{ device[2] }}% + div(v-else) + span No SteamVR Devices + .x-container + span(style="float:right") {{ currentTime | formatDate('YYYY-MM-DD HH:MI:SS AMPM') }} + span CPU {{ cpuUsage }}% + script(src="vr.js") diff --git a/html/src/vr.scss b/html/src/vr.scss index d6bcae9e..c3e3f275 100644 --- a/html/src/vr.scss +++ b/html/src/vr.scss @@ -12,216 +12,216 @@ For a copy, see . @import '~element-ui/lib/theme-chalk/index.css'; /* - 마지노선인듯 - 화면 24px -> 나나 32 - 손등 18px -> 나나 24 + 마지노선인듯 + 화면 24px -> 나나 32 + 손등 18px -> 나나 24 */ .noty_body { - display: block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .noty_layout { - max-width: none; - width: 80% !important; + max-width: none; + width: 80% !important; } .noty_theme__relax.noty_bar { - margin: 4px 0; - overflow: hidden; - border-radius: 2px; - position: relative; + margin: 4px 0; + overflow: hidden; + border-radius: 2px; + position: relative; } .noty_theme__relax.noty_bar .noty_body { - padding: 5px 10px 10px; - font-size: 24px; - text-align: center; + padding: 5px 10px 10px; + font-size: 24px; + text-align: center; } .noty_theme__relax.noty_bar .noty_buttons { - border-top: 1px solid #e7e7e7; - padding: 5px 10px; + border-top: 1px solid #e7e7e7; + padding: 5px 10px; } .noty_theme__relax.noty_type__alert, .noty_theme__relax.noty_type__notification { - background-color: #fff; - border: 1px solid #dedede; - color: #444; + background-color: #fff; + border: 1px solid #dedede; + color: #444; } .noty_theme__relax.noty_type__warning { - background-color: #FFEAA8; - border: 1px solid #FFC237; - color: #826200; + background-color: #FFEAA8; + border: 1px solid #FFC237; + color: #826200; } .noty_theme__relax.noty_type__warning .noty_buttons { - border-color: #dfaa30; + border-color: #dfaa30; } .noty_theme__relax.noty_type__error { - background-color: #FF8181; - border: 1px solid #e25353; - color: #FFF; + background-color: #FF8181; + border: 1px solid #e25353; + color: #FFF; } .noty_theme__relax.noty_type__error .noty_buttons { - border-color: darkred; + border-color: darkred; } .noty_theme__relax.noty_type__info, .noty_theme__relax.noty_type__information { - background-color: #78C5E7; - border: 1px solid #3badd6; - color: #FFF; + background-color: #78C5E7; + border: 1px solid #3badd6; + color: #FFF; } .noty_theme__relax.noty_type__info .noty_buttons, .noty_theme__relax.noty_type__information .noty_buttons { - border-color: #0B90C4; + border-color: #0B90C4; } .noty_theme__relax.noty_type__success { - background-color: #BCF5BC; - border: 1px solid #7cdd77; - color: darkgreen; + background-color: #BCF5BC; + border: 1px solid #7cdd77; + color: darkgreen; } .noty_theme__relax.noty_type__success .noty_buttons { - border-color: #50C24E; + border-color: #50C24E; } ::-webkit-scrollbar { - width: 8px; - height: 8px; + width: 8px; + height: 8px; } ::-webkit-scrollbar-track { - background: rgba(0, 0, 0, 0.1); - border-radius: 16px; + background: rgba(0, 0, 0, 0.1); + border-radius: 16px; } ::-webkit-scrollbar-thumb { - background: rgba(0, 0, 0, 0.25); - border-radius: 16px; + background: rgba(0, 0, 0, 0.25); + border-radius: 16px; } body, input, textarea, select, button { - font-family: 'Noto Sans JP', 'Noto Sans KR', 'Meiryo UI', 'Malgun Gothic', 'Segoe UI', sans-serif; - line-height: normal; + font-family: 'Noto Sans JP', 'Noto Sans KR', 'Meiryo UI', 'Malgun Gothic', 'Segoe UI', sans-serif; + line-height: normal; } .x-app { - display: flex; - flex-direction: column; - position: absolute; - width: 100%; - height: 100%; - overflow: hidden; + display: flex; + flex-direction: column; + position: absolute; + width: 100%; + height: 100%; + overflow: hidden; } .x-app-type { - background: #1f1f1f; - color: #fff; + background: #1f1f1f; + color: #fff; } .x-container { - flex: none; - padding: 10px; - overflow: hidden auto; - position: relative; + flex: none; + padding: 10px; + overflow: hidden auto; + position: relative; } .x-friend-list { - overflow: hidden auto; - padding: 0 10px; + overflow: hidden auto; + padding: 0 10px; } .x-friend-item { - display: flex; - align-items: center; - font-size: 18px; - box-sizing: border-box; + display: flex; + align-items: center; + font-size: 18px; + box-sizing: border-box; } .x-friend-item .time { - margin-right: 5px; + margin-right: 5px; } .x-friend-item .name { - font-weight: bold; + font-weight: bold; } .x-friend-item.friend .name { - color: #fff; + color: #fff; } .x-friend-item.favorite .name { - color: #ff0; + color: #ff0; } .x-friend-item>.avatar { - flex: none; - width: 40px; - height: 40px; - margin-right: 8px; - display: inline-block; - position: relative; + flex: none; + width: 40px; + height: 40px; + margin-right: 8px; + display: inline-block; + position: relative; } .x-friend-item>img.avatar { - width: 50px; - height: 37.5px; - margin-left: 5px; - margin-right: 0; - border-radius: 2px; + width: 50px; + height: 37.5px; + margin-left: 5px; + margin-right: 0; + border-radius: 2px; } .x-friend-item>.avatar>img { - width: 100%; - height: 100%; - border-radius: 40%; - object-fit: cover; + width: 100%; + height: 100%; + border-radius: 40%; + object-fit: cover; } .x-friend-item>.detail { - flex: 1; - overflow: hidden; + flex: 1; + overflow: hidden; } .x-friend-item>.detail>.name, .x-friend-item>.detail>.extra { - display: block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .x-friend-item>.detail>.name { - font-weight: bold; + font-weight: bold; } .x-friend-item>.detail>.extra { - font-weight: normal; + font-weight: normal; } i.x-user-status { - display: inline-block; - width: 14px; - height: 14px; - border-radius: 50%; - background: gray; + display: inline-block; + width: 14px; + height: 14px; + border-radius: 50%; + background: gray; } i.x-user-status.active { - background: #67C23A; + background: #67C23A; } i.x-user-status.joinme { - background: #409EFF; + background: #409EFF; } i.x-user-status.busy { - background: #F56C6C; + background: #F56C6C; } \ No newline at end of file