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")