diff --git a/html/src/app.js b/html/src/app.js index c116f1af..5480c343 100644 --- a/html/src/app.js +++ b/html/src/app.js @@ -13275,8 +13275,7 @@ speechSynthesis.getVoices(); if (this.directAccessWorld(input.trim())) { return true; } - var testUrl = input.substring(0, 15); - if (testUrl === 'https://vrchat.') { + if (input.startsWith('https://vrchat.')) { var url = new URL(input); var urlPath = url.pathname; if (urlPath.substring(5, 11) === '/user/') { @@ -13287,7 +13286,14 @@ speechSynthesis.getVoices(); var avatarId = urlPath.substring(13); this.showAvatarDialog(avatarId); return true; + } else if (urlPath.substring(5, 12) === '/group/') { + var groupId = urlPath.substring(12); + this.showGroupDialog(groupId); + return true; } + } else if (input.startsWith('https://vrc.group/')) { + // var shortCode = input.substring(18); + // resolve short code? A } else if ( input.substring(0, 4) === 'usr_' || /^[A-Za-z0-9]{10}$/g.test(input) @@ -13297,6 +13303,9 @@ speechSynthesis.getVoices(); } else if (input.substring(0, 5) === 'avtr_') { this.showAvatarDialog(input.trim()); return true; + } else if (input.substring(0, 4) === 'grp_') { + this.showGroupDialog(input.trim()); + return true; } return false; }; @@ -13695,6 +13704,20 @@ speechSynthesis.getVoices(); avatarName: '', fileCreatedAt: '' }, + representedGroup: { + bannerUrl: '', + description: '', + discriminator: '', + groupId: '', + iconUrl: '', + isRepresenting: false, + memberCount: 0, + memberVisibility: '', + name: '', + ownerId: '', + privacy: '', + shortCode: '' + }, joinCount: 0, timeSpent: 0, lastSeen: '', @@ -13923,6 +13946,20 @@ speechSynthesis.getVoices(); occupants: 0, friendCount: 0 }; + D.representedGroup = { + bannerUrl: '', + description: '', + discriminator: '', + groupId: '', + iconUrl: '', + isRepresenting: false, + memberCount: 0, + memberVisibility: '', + name: '', + ownerId: '', + privacy: '', + shortCode: '' + }; D.lastSeen = ''; D.joinCount = 0; D.timeSpent = 0; @@ -14070,6 +14107,7 @@ speechSynthesis.getVoices(); ); }); } + API.getRepresentedGroup({userId}); } return args; }); @@ -16716,6 +16754,22 @@ speechSynthesis.getVoices(); this.copyToClipboard(`https://vrchat.com/home/user/${userId}`); }; + $app.methods.copyGroupId = function (groupId) { + this.$message({ + message: 'Group ID copied to clipboard', + type: 'success' + }); + this.copyToClipboard(groupId); + }; + + $app.methods.copyGroupUrl = function (groupUrl) { + this.$message({ + message: 'Group URL copied to clipboard', + type: 'success' + }); + this.copyToClipboard(groupUrl); + }; + $app.methods.copyText = function (text) { this.$message({ message: 'Text copied to clipboard', @@ -20403,6 +20457,9 @@ speechSynthesis.getVoices(); case 'user': this.showUserDialog(commandArg); break; + case 'group': + this.showGroupDialog(commandArg); + break; case 'addavatardb': this.addAvatarProvider(input.replace('addavatardb/', '')); break; @@ -22278,11 +22335,64 @@ speechSynthesis.getVoices(); }; API.$on('GROUP', function (args) { - var group = args.json; - console.log(args); + args.ref = this.applyGroup(args.json); + }); + + API.$on('GROUP', function (args) { + console.log('group', args); + var group = args.ref; this.cachedGroups.set(group.id, group); }); + API.$on('GROUP', function (args) { + if ($app.groupDialog.visible && $app.groupDialog.id === args.ref.id) { + // update group dialog + } + if ( + $app.userDialog.visible && + ($app.userDialog.representedGroup.id === args.ref.id || + $app.userDialog.id === args.params.userId) + ) { + $app.userDialog.representedGroup = args.ref; + } + }); + + /* + params: { + userId: string + } + */ + API.getRepresentedGroup = function (params) { + return this.call(`users/${params.userId}/groups/represented`, { + method: 'GET' + }).then((json) => { + var args = { + json, + params + }; + this.$emit('GROUP:REPRESENTED', args); + return args; + }); + }; + + API.$on('GROUP:REPRESENTED', function (args) { + console.log('represented', args.json); + var json = args.json; + if (!json.id) { + // no group + return; + } + json.$memberId = json.id; + json.id = json.groupId; + this.$emit('GROUP', { + json, + params: { + groupId: json.id, + userId: args.params.userId + } + }); + }); + /* params: { userId: string @@ -22302,24 +22412,329 @@ speechSynthesis.getVoices(); }; API.$on('GROUP:LIST', function (args) { - console.log(args.json); - for (var i = 0; i < args.json.length; ++i) { - var group = args.json[i]; - this.cachedGroups.set(group.id, group); + console.log('groups', args.json); + for (var json of args.json) { + json.$memberId = json.id; + json.id = json.groupId; + this.$emit('GROUP', { + json, + params: { + groupId: json.id, + userId: args.params.userId + } + }); } }); + /* + params: { + groupId: string + } + */ + API.joinGroup = function (params) { + return this.call(`groups/${params.groupId}/join`, { + method: 'POST' + }).then((json) => { + var args = { + json, + params + }; + this.$emit('GROUP:JOIN', args); + return args; + }); + }; + + API.$on('GROUP:JOIN', function (args) { + console.log('join', args.json); + var json = { + $memberId: args.json.id, + id: args.json.groupId, + membershipStatus: args.json.membershipStatus, + myMember: { + isRepresenting: args.json.isRepresenting, + id: args.json.id, + roleIds: args.json.roleIds, + joinedAt: args.json.joinedAt, + membershipStatus: args.json.membershipStatus, + visibility: args.json.visibility, + isSubscribedToAnnouncements: + args.json.isSubscribedToAnnouncements + } + }; + var groupId = json.id; + this.$emit('GROUP', { + json, + params: { + groupId, + userId: args.params.userId + } + }); + if ($app.groupDialog.visible && $app.groupDialog.id === groupId) { + if (json.membershipStatus === 'member') { + $app.groupDialog.inGroup = true; + } + } + }); + + /* + params: { + groupId: string + } + */ + API.leaveGroup = function (params) { + return this.call(`groups/${params.groupId}/leave`, { + method: 'POST' + }).then((json) => { + var args = { + json, + params + }; + this.$emit('GROUP:LEAVE', args); + return args; + }); + }; + + API.$on('GROUP:LEAVE', function (args) { + console.log('leave', args); + var groupId = args.params.groupId; + if ($app.groupDialog.visible && $app.groupDialog.id === groupId) { + $app.groupDialog.inGroup = false; + } + }); + + /* + params: { + groupId: string + } + */ + API.cancelGroupRequest = function (params) { + return this.call(`groups/${params.groupId}/requests`, { + method: 'DELETE' + }).then((json) => { + var args = { + json, + params + }; + this.$emit('GROUP:CANCELJOINREQUEST', args); + return args; + }); + }; + + API.$on('GROUP:CANCELJOINREQUEST', function (args) { + console.log('CANCELJOINREQUEST', args); + var groupId = args.params.groupId; + if ($app.groupDialog.visible && $app.groupDialog.id === groupId) { + $app.groupDialog.ref.membershipStatus = 'inactive'; + } + }); + + /* + params: { + groupId: string + } + */ + API.getCachedGroup = function (params) { + return new Promise((resolve, reject) => { + var ref = this.cachedGroups.get(params.groupId); + if (typeof ref === 'undefined') { + this.getGroup(params).catch(reject).then(resolve); + } else { + resolve({ + cache: true, + json: ref, + params, + ref + }); + } + }); + }; + + API.applyGroup = function (json) { + var ref = this.cachedGroups.get(json.id); + if (typeof ref === 'undefined') { + ref = { + id: '', + name: '', + shortCode: '', + description: '', + bannerId: '', + bannerUrl: '', + createdAt: '', + discriminator: '', + galleries: [], + iconId: '', + iconUrl: '', + isVerified: false, + joinState: '', + languages: [], + links: [], + memberCount: 0, + memberCountSyncedAt: '', + membershipStatus: '', + onlineMemberCount: 0, + ownerId: '', + privacy: '', + rules: null, + tags: [], + // in group + initialRoleIds: [], + myMember: { + bannedAt: null, + groupId: '', + has2FA: false, + id: '', + isRepresenting: false, + isSubscribedToAnnouncements: false, + joinedAt: '', + managerNotes: '', + membershipStatus: '', + permissions: [], + roleIds: [], + userId: '', + visibility: '', + _created_at: '', + _id: '', + _updated_at: '' + }, + updatedAt: '', + // group list + $memberId: '', + groupId: '', + isRepresenting: false, + memberVisibility: false, + mutualGroup: false, + // VRCX + $languages: [], + ...json + }; + this.cachedGroups.set(ref.id, ref); + } else { + Object.assign(ref, json); + } + ref.$url = `https://vrc.group/${ref.shortCode}.${ref.discriminator}`; + this.applyGroupLanguage(ref); + return ref; + }; + + API.applyGroupLanguage = function (ref) { + ref.$languages = []; + var {languages} = ref; + if (!languages) { + return; + } + for (var language of languages) { + var value = subsetOfLanguages[language]; + if (typeof value === 'undefined') { + continue; + } + ref.$languages.push({ + key: language, + value + }); + } + }; + $app.data.groupDialog = { visible: false, loading: false, + treeData: [], id: '', - group: null + inGroup: false, + ref: {} }; $app.methods.showGroupDialog = function (groupId) { - // this.$nextTick(() => adjustDialogZ(this.$refs.groupDialog.$el)); - this.groupDialog.visible = true; - this.groupDialog.id = groupId; + if (!groupId) { + return; + } + this.$nextTick(() => adjustDialogZ(this.$refs.groupDialog.$el)); + var D = this.groupDialog; + D.visible = true; + D.loading = true; + D.id = groupId; + D.inGroup = false; + D.treeData = []; + API.getCachedGroup({ + groupId + }) + .catch((err) => { + D.loading = false; + D.visible = false; + this.$message({ + message: 'Failed to load group', + type: 'error' + }); + throw err; + }) + .then((args) => { + if (D.id === args.ref.id) { + D.loading = false; + D.ref = args.ref; + D.inGroup = args.ref.membershipStatus === 'member'; + if (args.cache) { + API.getGroup(args.params) + .catch((err) => { + throw err; + }) + .then((args1) => { + if (D.id === args1.ref.id) { + D.ref = args1.ref; + D.inGroup = + args.ref.membershipStatus === 'member'; + } + return args1; + }); + } + } + }); + }; + + $app.methods.groupDialogCommand = function (command) { + var D = this.groupDialog; + if (D.visible === false) { + return; + } + switch (command) { + case 'Refresh': + this.showGroupDialog(D.id); + break; + } + }; + + $app.methods.refreshGroupDialogTreeData = function () { + var D = this.groupDialog; + D.treeData = buildTreeData(D.ref); + }; + + $app.methods.joinGroup = function (groupId) { + return API.joinGroup({ + groupId + }).then((args) => { + if (args.json.membershipStatus === 'member') { + this.$message({ + message: 'Group joined', + type: 'success' + }); + } else if (args.json.membershipStatus === 'requested') { + this.$message({ + message: 'Group join request sent', + type: 'success' + }); + } + return args; + }); + }; + + $app.methods.leaveGroup = function (groupId) { + return API.leaveGroup({ + groupId + }); + }; + + $app.methods.cancelGroupRequest = function (groupId) { + return API.cancelGroupRequest({ + groupId + }); }; $app = new Vue($app); diff --git a/html/src/app.scss b/html/src/app.scss index 41f197c2..e4ab4151 100644 --- a/html/src/app.scss +++ b/html/src/app.scss @@ -100,7 +100,7 @@ .el-dialog__body { padding: 20px; - word-break: unset; + word-break: break-word; } .el-dialog__footer > .el-button + .el-button { diff --git a/html/src/index.pug b/html/src/index.pug index 453dd4b0..c6b42a2c 100644 --- a/html/src/index.pug +++ b/html/src/index.pug @@ -1694,6 +1694,19 @@ html span.name(v-else) Avatar Info .extra avatar-info(:imageurl="userDialog.ref.currentAvatarImageUrl" :userid="userDialog.id") + .x-friend-item(style="width:100%;cursor:default") + .detail + span.name Represented Group + .extra(v-if="userDialog.representedGroup.id") + div(style="display:inline-block;flex:none;margin-right:5px") + el-popover(placement="right" width="500px" trigger="click") + img.x-link(slot="reference" v-lazy="userDialog.representedGroup.iconUrl" style="flex:none;width:60px;height:60px;border-radius:4px;object-fit:cover") + img.x-link(v-lazy="userDialog.representedGroup.iconUrl" style="height:500px" @click="openExternalLink(userDialog.representedGroup.iconUrl)") + span(style="vertical-align:top;cursor:pointer" @click="showGroupDialog(userDialog.representedGroup.id)") + span(v-if="userDialog.representedGroup.ownerId === userDialog.id" style="margin-right:5px") 👑 + span(v-text="userDialog.representedGroup.name" style="margin-right:5px") + span ({{ userDialog.representedGroup.memberCount }}) + .extra(v-else) - .x-friend-item(style="width:100%;cursor:default") .detail span.name Bio @@ -1791,7 +1804,7 @@ html template(v-if="userGroups.mutualGroups.length > 0") span(style="font-weight:bold;font-size:16px") Mutual Groups span(style="color:#909399;font-size:12px;margin-left:5px") {{ userGroups.mutualGroups.length }} - .x-friend-list(v-if="userGroups.mutualGroups.length > 0" style="margin-top:10px;margin-bottom:15px;min-height:60px") + .x-friend-list(style="margin-top:10px;margin-bottom:15px;min-height:60px") .x-friend-item(v-for="group in userGroups.mutualGroups" :key="group.id" @click="showGroupDialog(group.id)" class="x-friend-item-border") .avatar img(v-lazy="group.iconUrl") @@ -1801,7 +1814,7 @@ html template(v-if="userGroups.remainingGroups.length > 0") span(style="font-weight:bold;font-size:16px") Groups span(style="color:#909399;font-size:12px;margin-left:5px") {{ userGroups.remainingGroups.length }} - .x-friend-list(v-if="userGroups.remainingGroups.length > 0" style="margin-top:10px;margin-bottom:15px;min-height:60px") + .x-friend-list(style="margin-top:10px;margin-bottom:15px;min-height:60px") .x-friend-item(v-for="group in userGroups.remainingGroups" :key="group.id" @click="showGroupDialog(group.id)" class="x-friend-item-border") .avatar img(v-lazy="group.iconUrl") @@ -2128,6 +2141,88 @@ html 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: group + el-dialog.x-dialog.x-world-dialog(:before-close="beforeDialogClose" @mousedown.native="dialogMouseDown" @mouseup.native="dialogMouseUp" ref="groupDialog" :visible.sync="groupDialog.visible" :show-close="false" width="770px") + div(v-loading="groupDialog.loading") + div(style="display:flex") + el-popover(placement="right" width="500px" trigger="click") + img.x-link(slot="reference" v-lazy="groupDialog.ref.iconUrl" style="flex:none;width:120px;height:120px;border-radius:4px") + img.x-link(v-lazy="groupDialog.ref.iconUrl" style="width:500px;height:500px" @click="openExternalLink(groupDialog.ref.iconUrl)") + div(style="flex:1;display:flex;align-items:center;margin-left:15px") + div(style="flex:1") + div + span(v-if="groupDialog.ref.ownerId === API.currentUser.id" style="margin-right:5px") 👑 + span.dialog-title(v-text="groupDialog.ref.name" style="margin-right:5px") + el-tooltip(v-for="item in groupDialog.ref.$languages" :key="item.key" placement="top") + template(#content) + span {{ item.value }} ({{ item.key }}) + span.flags(:class="languageClass(item.key)" style="display:inline-block;margin-right:5px") + div(style="margin-top:5px") + span.x-link(v-text="groupDialog.ref.ownerId" @click="showUserDialog(groupDialog.ref.ownerId)" style="color:#909399;font-family:monospace") + //- display-name(:userid="groupDialog.ref.ownerId" style="color:#909399;font-family:monospace") + div(style="margin-top:5px") + span(v-show="groupDialog.ref.name !== groupDialog.ref.description" v-text="groupDialog.ref.description" style="font-size:12px") + div(style="flex:none;margin-left:10px") + el-tooltip(v-if="groupDialog.inGroup" placement="top" content="Leave Group" :disabled="hideTooltips") + el-button(type="default" icon="el-icon-star-on" circle @click="leaveGroup(groupDialog.id)" style="margin-left:5px") + el-tooltip(v-else-if="groupDialog.ref.membershipStatus === 'requested'" placement="top" content="Cancel join request" :disabled="hideTooltips") + el-button(type="default" icon="el-icon-close" circle @click="cancelGroupRequest(groupDialog.id)" style="margin-left:5px") + el-tooltip(v-else-if="groupDialog.ref.joinState === 'request'" placement="top" content="Request to join" :disabled="hideTooltips") + el-button(type="default" icon="el-icon-message" circle @click="joinGroup(groupDialog.id)" style="margin-left:5px") + el-tooltip(v-else placement="top" content="Join Group" :disabled="hideTooltips") + el-button(type="default" icon="el-icon-star-off" circle @click="joinGroup(groupDialog.id)" style="margin-left:5px") + el-dropdown(trigger="click" @command="groupDialogCommand" size="small" style="margin-left:5px") + el-button(type="default" icon="el-icon-more" circle) + el-dropdown-menu(#default="dropdown") + el-dropdown-item(icon="el-icon-refresh" command="Refresh") Refresh + el-tabs + el-tab-pane(label="Info") + .x-friend-list(style="max-height:none") + .x-friend-item(style="width:100%;cursor:default") + .detail + span.name Links + div(v-if="groupDialog.ref.links && groupDialog.ref.links.length > 0" style="margin-top:5px") + el-tooltip(v-if="link" v-for="(link, index) in groupDialog.ref.links" :key="index") + template(#content) + span(v-text="link") + img(:src="getFaviconUrl(link)" style="width:16px;height:16px;vertical-align:middle;margin-right:5px;cursor:pointer" @click.stop="openExternalLink(link)") + .extra(v-else) - + .x-friend-item(style="width:100%;cursor:default") + .detail + span.name Rules + pre.extra(style="font-family:inherit;font-size:12px;white-space:pre-wrap;margin:0 0.5em 0 0") {{ groupDialog.ref.rules || '-' }} + .x-friend-item(style="width:350px;cursor:default") + .detail + span.name Group ID + span.extra {{ groupDialog.id }} + el-tooltip(placement="top" content="Copy to clipboard" :disabled="hideTooltips") + el-dropdown(trigger="click" @click.native.stop size="mini" style="margin-left:5px") + el-button(type="default" icon="el-icon-s-order" size="mini" circle) + el-dropdown-menu(#default="dropdown") + el-dropdown-item(@click.native="copyGroupId(groupDialog.id)") Copy ID + el-dropdown-item(@click.native="copyGroupUrl(groupDialog.ref.$url)") Copy URL + template(v-if="groupDialog.ref.membershipStatus === 'member'") + div(style="width:100%;display:flex") + .x-friend-item(style="cursor:default") + .detail + span.name Joined At + span.extra {{ groupDialog.ref.myMember.joinedAt | formatDate('long') }} + .x-friend-item(style="cursor:default") + .detail + span.name Announcements + span.extra {{ groupDialog.ref.myMember.isSubscribedToAnnouncements }} + .x-friend-item(style="cursor:default") + .detail + span.name Visibility + span.extra {{ groupDialog.ref.myMember.visibility }} + el-tab-pane(label="JSON") + el-button(type="default" @click="refreshGroupDialogTreeData()" size="mini" icon="el-icon-refresh" circle) + el-tree(:data="groupDialog.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(:before-close="beforeDialogClose" @mousedown.native="dialogMouseDown" @mouseup.native="dialogMouseUp" ref="favoriteDialog" :visible.sync="favoriteDialog.visible" title="Choose Group" width="300px") div(v-if="favoriteDialog.visible" v-loading="favoriteDialog.loading")