refactor: dialogs (#1224)

* refactor: dialogs

* fix: storeAvatarImage

* FriendLog.vue

* FriendLog.vue

* FriendLog.vue

* GameLog.vue

* fix: next day button jumping to the wrong date

* sync master

* fix: launchGame

* Notification.vue

* Feed.vue

* Search.vue

* Profile.vue

* PlayerList.vue

* Login.vue

* utils

* update dialog

* del gameLog.pug

* fix

* fix: group role cannot be displayed currently

* fix: "Hide Friends in Same Instance" hides players in unrelated private instances (#1210)

* fix

* fix: "Hide Friends in Same Instance" does not work when "Split Favorite Friends" is enabled

* fix Notification.vue message

* fix: deleteFavoriteNoConfirm

* fix: feed status style

* fix: infinite loading when deleting note

* fix: private players will not be hidden when 'Hide Friends in Same Instance', and 'Hide Friends in Same Instance' will not work when 'Split Favorite Friends'
This commit is contained in:
pa
2025-05-14 19:01:15 +09:00
committed by GitHub
parent 5ca028b30a
commit e792ed481b
130 changed files with 14208 additions and 10462 deletions

View File

@@ -116,6 +116,7 @@
<script>
import dayjs from 'dayjs';
import { parseLocation } from '../../../composables/instance/utils';
import database from '../../../service/database';
import utils from '../../../classes/utils';
import configRepository from '../../../service/config';
@@ -379,7 +380,7 @@
const timeString = utils.timeToText(param.data, true);
const color = param.color;
const name = param.name;
const location = utils.parseLocation(instanceData.location);
const location = parseLocation(instanceData.location);
return `
<div style="display: flex; align-items: center;">

View File

@@ -1,11 +1,5 @@
<template>
<el-dialog
:before-close="beforeDialogClose"
:visible.sync="isDialogVisible"
:title="$t('dialog.avatar_export.header')"
width="650px"
@mousedown.native="dialogMouseDown"
@mouseup.native="dialogMouseUp">
<safe-dialog :visible.sync="isDialogVisible" :title="$t('dialog.avatar_export.header')" width="650px">
<el-checkbox-group
v-model="exportSelectedOptions"
style="margin-bottom: 10px"
@@ -82,13 +76,13 @@
readonly
style="margin-top: 15px"
@click.native="handleCopyAvatarExportData"></el-input>
</el-dialog>
</safe-dialog>
</template>
<script>
export default {
name: 'AvatarExportDialog',
inject: ['API', 'beforeDialogClose', 'dialogMouseDown', 'dialogMouseUp'],
inject: ['API'],
props: {
avatarExportDialogVisible: Boolean,
favoriteAvatars: Array,

View File

@@ -1,12 +1,9 @@
<template>
<el-dialog
<safe-dialog
ref="avatarImportDialog"
:before-close="beforeDialogClose"
:visible.sync="isVisible"
:title="$t('dialog.avatar_import.header')"
width="650px"
@mousedown.native="dialogMouseDown"
@mouseup.native="dialogMouseUp">
width="650px">
<div style="display: flex; align-items: center; justify-content: space-between">
<div style="font-size: 12px">{{ $t('dialog.avatar_import.description') }}</div>
<div style="display: flex; align-items: center">
@@ -171,7 +168,7 @@
</template>
</el-table-column>
</data-tables>
</el-dialog>
</safe-dialog>
</template>
<script>
@@ -180,16 +177,7 @@
export default {
name: 'AvatarImportDialog',
inject: [
'API',
'beforeDialogClose',
'dialogMouseDown',
'dialogMouseUp',
'adjustDialogZ',
'showFullscreenImageDialog',
'showUserDialog',
'showAvatarDialog'
],
inject: ['API', 'adjustDialogZ', 'showFullscreenImageDialog', 'showUserDialog', 'showAvatarDialog'],
props: {
getLocalAvatarFavoriteGroupLength: Function,
localAvatarFavoriteGroups: Array,

View File

@@ -1,13 +1,10 @@
<template>
<el-dialog
:before-close="beforeDialogClose"
<safe-dialog
:visible.sync="isDialogVisible"
class="x-dialog"
:title="$t('dialog.friend_export.header')"
width="650px"
destroy-on-close
@mousedown.native="dialogMouseDown"
@mouseup.native="dialogMouseUp">
destroy-on-close>
<el-dropdown trigger="click" size="small" @click.native.stop>
<el-button size="mini">
<span v-if="friendExportFavoriteGroup">
@@ -42,13 +39,13 @@
readonly
style="margin-top: 15px"
@click.native="handleCopyFriendExportData"></el-input>
</el-dialog>
</safe-dialog>
</template>
<script>
export default {
name: 'FriendExportDialog',
inject: ['API', 'beforeDialogClose', 'dialogMouseDown', 'dialogMouseUp'],
inject: ['API'],
props: {
friendExportDialogVisible: Boolean,
favoriteFriends: Array

View File

@@ -1,12 +1,9 @@
<template>
<el-dialog
<safe-dialog
ref="friendImportDialog"
:before-close="beforeDialogClose"
:visible.sync="isVisible"
:title="$t('dialog.friend_import.header')"
width="650px"
@mousedown.native="dialogMouseDown"
@mouseup.native="dialogMouseUp">
width="650px">
<div style="display: flex; align-items: center; justify-content: space-between">
<div style="font-size: 12px">{{ $t('dialog.friend_import.description') }}</div>
<div style="display: flex; align-items: center">
@@ -122,7 +119,7 @@
</template>
</el-table-column>
</data-tables>
</el-dialog>
</safe-dialog>
</template>
<script>
@@ -131,17 +128,7 @@
export default {
name: 'FriendImportDialog',
inject: [
'API',
'userImage',
'userImageFull',
'showFullscreenImageDialog',
'showUserDialog',
'beforeDialogClose',
'dialogMouseDown',
'dialogMouseUp',
'adjustDialogZ'
],
inject: ['API', 'userImage', 'userImageFull', 'showFullscreenImageDialog', 'showUserDialog', 'adjustDialogZ'],
props: {
friendImportDialogVisible: {
type: Boolean,

View File

@@ -1,11 +1,5 @@
<template>
<el-dialog
:before-close="beforeDialogClose"
:visible.sync="isDialogVisible"
:title="$t('dialog.world_export.header')"
width="650px"
@mousedown.native="dialogMouseDown"
@mouseup.native="dialogMouseUp">
<safe-dialog :visible.sync="isDialogVisible" :title="$t('dialog.world_export.header')" width="650px">
<el-checkbox-group
v-model="exportSelectedOptions"
style="margin-bottom: 10px"
@@ -84,13 +78,13 @@
readonly
style="margin-top: 15px"
@click.native="handleCopyWorldExportData"></el-input>
</el-dialog>
</safe-dialog>
</template>
<script>
export default {
name: 'WorldExportDialog',
inject: ['API', 'beforeDialogClose', 'dialogMouseDown', 'dialogMouseUp'],
inject: ['API'],
props: {
favoriteWorlds: Array,
worldExportDialogVisible: Boolean,

View File

@@ -1,14 +1,11 @@
<template>
<el-dialog
<safe-dialog
ref="worldImportDialog"
:before-close="beforeDialogClose"
:visible.sync="isVisible"
:title="$t('dialog.world_import.header')"
width="650px"
top="10vh"
class="x-dialog"
@mousedown.native="dialogMouseDown"
@mouseup.native="dialogMouseUp">
class="x-dialog">
<div style="display: flex; align-items: center; justify-content: space-between">
<div style="font-size: 12px">{{ $t('dialog.world_import.description') }}</div>
<div style="display: flex; align-items: center">
@@ -176,7 +173,7 @@
</template>
</el-table-column>
</data-tables>
</el-dialog>
</safe-dialog>
</template>
<script>
@@ -185,16 +182,7 @@
export default {
name: 'WorldImportDialog',
inject: [
'API',
'beforeDialogClose',
'dialogMouseDown',
'dialogMouseUp',
'showFullscreenImageDialog',
'showUserDialog',
'adjustDialogZ',
'showWorldDialog'
],
inject: ['API', 'showFullscreenImageDialog', 'showUserDialog', 'adjustDialogZ', 'showWorldDialog'],
props: {
worldImportDialogVisible: Boolean,
worldImportDialogInput: String,

475
src/views/Feed/Feed.vue Normal file
View File

@@ -0,0 +1,475 @@
<template>
<div v-show="menuActiveIndex === 'feed'" class="x-container feed">
<div style="margin: 0 0 10px; display: flex; align-items: center">
<div style="flex: none; margin-right: 10px; display: flex; align-items: center">
<el-tooltip
placement="bottom"
:content="t('view.feed.favorites_only_tooltip')"
:disabled="hideTooltips">
<el-switch v-model="feedTable.vip" active-color="#13ce66" @change="feedTableLookup"></el-switch>
</el-tooltip>
</div>
<el-select
v-model="feedTable.filter"
multiple
clearable
style="flex: 1; height: 40px"
:placeholder="t('view.feed.filter_placeholder')"
@change="feedTableLookup">
<el-option
v-for="type in ['GPS', 'Online', 'Offline', 'Status', 'Avatar', 'Bio']"
:key="type"
:label="t('view.feed.filters.' + type)"
:value="type"></el-option>
</el-select>
<el-input
v-model="feedTable.search"
:placeholder="t('view.feed.search_placeholder')"
clearable
style="flex: none; width: 150px; margin: 0 10px"
@keyup.native.13="feedTableLookup"
@change="feedTableLookup"></el-input>
</div>
<data-tables v-loading="feedTable.loading" v-bind="feedTable" lazy>
<el-table-column type="expand" width="20">
<template #default="scope">
<div style="position: relative; font-size: 14px">
<template v-if="scope.row.type === 'GPS'">
<location
v-if="scope.row.previousLocation"
:location="scope.row.previousLocation"
style="display: inline-block"></location>
<el-tag type="info" effect="plain" size="mini" style="margin-left: 5px">{{
timeToText(scope.row.time)
}}</el-tag>
<br />
<span style="margin-right: 5px">
<i class="el-icon-right"></i>
</span>
<location
v-if="scope.row.location"
:location="scope.row.location"
:hint="scope.row.worldName"
:grouphint="scope.row.groupName"></location>
</template>
<template v-else-if="scope.row.type === 'Offline'">
<template v-if="scope.row.location">
<location
:location="scope.row.location"
:hint="scope.row.worldName"
:grouphint="scope.row.groupName"></location>
<el-tag type="info" effect="plain" size="mini" style="margin-left: 5px">{{
timeToText(scope.row.time)
}}</el-tag>
</template>
</template>
<template v-else-if="scope.row.type === 'Online'">
<location
v-if="scope.row.location"
:location="scope.row.location"
:hint="scope.row.worldName"
:grouphint="scope.row.groupName"></location>
</template>
<template v-else-if="scope.row.type === 'Avatar'">
<div style="display: flex; align-items: center">
<el-popover placement="right" width="500px" trigger="click">
<div
slot="reference"
style="display: inline-block; vertical-align: top; width: 160px">
<template v-if="scope.row.previousCurrentAvatarThumbnailImageUrl">
<img
v-lazy="scope.row.previousCurrentAvatarThumbnailImageUrl"
class="x-link"
style="flex: none; width: 160px; height: 120px; border-radius: 4px" />
<br />
<avatar-info
:imageurl="scope.row.previousCurrentAvatarThumbnailImageUrl"
:userid="scope.row.userId"
:hintownerid="scope.row.previousOwnerId"
:hintavatarname="scope.row.previousAvatarName"
:avatartags="scope.row.previousCurrentAvatarTags"></avatar-info>
</template>
</div>
<img
v-lazy="scope.row.previousCurrentAvatarImageUrl"
class="x-link"
style="width: 500px; height: 375px"
@click="showFullscreenImageDialog(scope.row.previousCurrentAvatarImageUrl)" />
</el-popover>
<span style="position: relative; margin: 0 10px">
<i class="el-icon-right"></i>
</span>
<el-popover placement="right" width="500px" trigger="click">
<div
slot="reference"
style="display: inline-block; vertical-align: top; width: 160px">
<template v-if="scope.row.currentAvatarThumbnailImageUrl">
<img
v-lazy="scope.row.currentAvatarThumbnailImageUrl"
class="x-link"
style="flex: none; width: 160px; height: 120px; border-radius: 4px" />
<br />
<avatar-info
:imageurl="scope.row.currentAvatarThumbnailImageUrl"
:userid="scope.row.userId"
:hintownerid="scope.row.ownerId"
:hintavatarname="scope.row.avatarName"
:avatartags="scope.row.currentAvatarTags"></avatar-info>
</template>
</div>
<img
v-lazy="scope.row.currentAvatarImageUrl"
class="x-link"
style="width: 500px; height: 375px"
@click="showFullscreenImageDialog(scope.row.currentAvatarImageUrl)" />
</el-popover>
</div>
</template>
<template v-else-if="scope.row.type === 'Status'">
<el-tooltip placement="top">
<template #content>
<span v-if="scope.row.previousStatus === 'active'">{{
t('dialog.user.status.active')
}}</span>
<span v-else-if="scope.row.previousStatus === 'join me'">{{
t('dialog.user.status.join_me')
}}</span>
<span v-else-if="scope.row.previousStatus === 'ask me'">{{
t('dialog.user.status.ask_me')
}}</span>
<span v-else-if="scope.row.previousStatus === 'busy'">{{
t('dialog.user.status.busy')
}}</span>
<span v-else>{{ t('dialog.user.status.offline') }}</span>
</template>
<i class="x-user-status" :class="statusClass(scope.row.previousStatus)"></i>
</el-tooltip>
<span style="margin-left: 5px" v-text="scope.row.previousStatusDescription"></span>
<br />
<span>
<i class="el-icon-right"></i>
</span>
<el-tooltip placement="top">
<template #content>
<span v-if="scope.row.status === 'active'">{{
t('dialog.user.status.active')
}}</span>
<span v-else-if="scope.row.status === 'join me'">{{
t('dialog.user.status.join_me')
}}</span>
<span v-else-if="scope.row.status === 'ask me'">{{
t('dialog.user.status.ask_me')
}}</span>
<span v-else-if="scope.row.status === 'busy'">{{
t('dialog.user.status.busy')
}}</span>
<span v-else>{{ t('dialog.user.status.offline') }}</span>
</template>
<i
class="x-user-status"
:class="statusClass(scope.row.status)"
style="margin: 0 5px"></i>
</el-tooltip>
<span v-text="scope.row.statusDescription"></span>
</template>
<template v-else-if="scope.row.type === 'Bio'">
<pre
style="
font-family: inherit;
font-size: 12px;
white-space: pre-wrap;
line-height: 25px;
line-height: 22px;
"
v-html="formatDifference(scope.row.previousBio, scope.row.bio)"></pre>
</template>
</div>
</template>
</el-table-column>
<el-table-column :label="t('table.feed.date')" prop="created_at" sortable="custom" width="120">
<template #default="scope">
<el-tooltip placement="right">
<template #content>
<span>{{ scope.row.created_at | formatDate('long') }}</span>
</template>
<span>{{ scope.row.created_at | formatDate('short') }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="t('table.feed.type')" prop="type" width="80">
<template #default="scope">
<span class="x-link" v-text="t('view.feed.filters.' + scope.row.type)"></span>
</template>
</el-table-column>
<el-table-column :label="t('table.feed.user')" prop="displayName" width="180">
<template #default="scope">
<span
class="x-link"
style="padding-right: 10px"
@click="showUserDialog(scope.row.userId)"
v-text="scope.row.displayName"></span>
</template>
</el-table-column>
<el-table-column :label="t('table.feed.detail')">
<template #default="scope">
<template v-if="scope.row.type === 'GPS'">
<location
v-if="scope.row.location"
:location="scope.row.location"
:hint="scope.row.worldName"
:grouphint="scope.row.groupName"></location>
</template>
<template v-else-if="scope.row.type === 'Offline' || scope.row.type === 'Online'">
<location
v-if="scope.row.location"
:location="scope.row.location"
:hint="scope.row.worldName"
:grouphint="scope.row.groupName"></location>
</template>
<template v-else-if="scope.row.type === 'Status'">
<template v-if="scope.row.statusDescription === scope.row.previousStatusDescription">
<el-tooltip placement="top">
<template #content>
<span v-if="scope.row.previousStatus === 'active'">{{
t('dialog.user.status.active')
}}</span>
<span v-else-if="scope.row.previousStatus === 'join me'">{{
t('dialog.user.status.join_me')
}}</span>
<span v-else-if="scope.row.previousStatus === 'ask me'">{{
t('dialog.user.status.ask_me')
}}</span>
<span v-else-if="scope.row.previousStatus === 'busy'">{{
t('dialog.user.status.busy')
}}</span>
<span v-else>{{ t('dialog.user.status.offline') }}</span>
</template>
<i class="x-user-status" :class="statusClass(scope.row.previousStatus)"></i>
</el-tooltip>
<span style="margin: 0 5px">
<i class="el-icon-right"></i>
</span>
<el-tooltip placement="top">
<template #content>
<span v-if="scope.row.status === 'active'">{{
t('dialog.user.status.active')
}}</span>
<span v-else-if="scope.row.status === 'join me'">{{
t('dialog.user.status.join_me')
}}</span>
<span v-else-if="scope.row.status === 'ask me'">{{
t('dialog.user.status.ask_me')
}}</span>
<span v-else-if="scope.row.status === 'busy'">{{
t('dialog.user.status.busy')
}}</span>
<span v-else>{{ t('dialog.user.status.offline') }}</span>
</template>
<i class="x-user-status" :class="statusClass(scope.row.status)"></i>
</el-tooltip>
</template>
<template v-else>
<el-tooltip placement="top">
<template #content>
<span v-if="scope.row.status === 'active'">{{
t('dialog.user.status.active')
}}</span>
<span v-else-if="scope.row.status === 'join me'">{{
t('dialog.user.status.join_me')
}}</span>
<span v-else-if="scope.row.status === 'ask me'">{{
t('dialog.user.status.ask_me')
}}</span>
<span v-else-if="scope.row.status === 'busy'">{{
t('dialog.user.status.busy')
}}</span>
<span v-else>{{ t('dialog.user.status.offline') }}</span>
</template>
<i
class="x-user-status"
:class="statusClass(scope.row.status)"
style="margin-right: 3px"></i>
</el-tooltip>
<span v-text="scope.row.statusDescription"></span>
</template>
</template>
<template v-else-if="scope.row.type === 'Avatar'">
<avatar-info
:imageurl="scope.row.currentAvatarImageUrl"
:userid="scope.row.userId"
:hintownerid="scope.row.ownerId"
:hintavatarname="scope.row.avatarName"
:avatartags="scope.row.currentAvatarTags"></avatar-info>
</template>
<template v-else-if="scope.row.type === 'Bio'">
<span v-text="scope.row.bio"></span>
</template>
</template>
</el-table-column>
</data-tables>
</div>
</template>
<script>
export default {
name: 'FeedTab'
};
</script>
<script setup>
import { inject } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import utils from '../../classes/utils';
import Location from '../../components/Location.vue';
const { t } = useI18n();
const showFullscreenImageDialog = inject('showFullscreenImageDialog');
const statusClass = inject('statusClass');
const showUserDialog = inject('showUserDialog');
defineProps({
menuActiveIndex: {
type: String,
default: 'feed'
},
hideTooltips: {
type: Boolean,
default: false
},
feedTable: {
type: Object,
default: () => ({})
}
});
const emit = defineEmits(['feedTableLookup']);
/**
* Function that format the differences between two strings with HTML tags
* markerStartTag and markerEndTag are optional, if emitted, the differences will be highlighted with yellow and underlined.
* @param {*} s1
* @param {*} s2
* @param {*} markerStartTag
* @param {*} markerEndTag
* @returns An array that contains both the string 1 and string 2, which the differences are formatted with HTML tags
*/
//function getWordDifferences
function formatDifference(
oldString,
newString,
markerAddition = '<span class="x-text-added">{{text}}</span>',
markerDeletion = '<span class="x-text-removed">{{text}}</span>'
) {
[oldString, newString] = [oldString, newString].map((s) =>
s
.replaceAll(/&/g, '&amp;')
.replaceAll(/</g, '&lt;')
.replaceAll(/>/g, '&gt;')
.replaceAll(/"/g, '&quot;')
.replaceAll(/'/g, '&#039;')
.replaceAll(/\n/g, '<br>')
);
const oldWords = oldString.split(/\s+/).flatMap((word) => word.split(/(<br>)/));
const newWords = newString.split(/\s+/).flatMap((word) => word.split(/(<br>)/));
function findLongestMatch(oldStart, oldEnd, newStart, newEnd) {
let bestOldStart = oldStart;
let bestNewStart = newStart;
let bestSize = 0;
const lookup = new Map();
for (let i = oldStart; i < oldEnd; i++) {
const word = oldWords[i];
if (!lookup.has(word)) lookup.set(word, []);
lookup.get(word).push(i);
}
for (let j = newStart; j < newEnd; j++) {
const word = newWords[j];
if (!lookup.has(word)) continue;
for (const i of lookup.get(word)) {
let size = 0;
while (i + size < oldEnd && j + size < newEnd && oldWords[i + size] === newWords[j + size]) {
size++;
}
if (size > bestSize) {
bestOldStart = i;
bestNewStart = j;
bestSize = size;
}
}
}
return {
oldStart: bestOldStart,
newStart: bestNewStart,
size: bestSize
};
}
function buildDiff(oldStart, oldEnd, newStart, newEnd) {
const result = [];
const match = findLongestMatch(oldStart, oldEnd, newStart, newEnd);
if (match.size > 0) {
// Handle differences before the match
if (oldStart < match.oldStart || newStart < match.newStart) {
result.push(...buildDiff(oldStart, match.oldStart, newStart, match.newStart));
}
// Add the matched words
result.push(oldWords.slice(match.oldStart, match.oldStart + match.size).join(' '));
// Handle differences after the match
if (match.oldStart + match.size < oldEnd || match.newStart + match.size < newEnd) {
result.push(...buildDiff(match.oldStart + match.size, oldEnd, match.newStart + match.size, newEnd));
}
} else {
function build(words, start, end, pattern) {
let r = [];
let ts = words
.slice(start, end)
.filter((w) => w.length > 0)
.join(' ')
.split('<br>');
for (let i = 0; i < ts.length; i++) {
if (i > 0) r.push('<br>');
if (ts[i].length < 1) continue;
r.push(pattern.replace('{{text}}', ts[i]));
}
return r;
}
// Add deletions
if (oldStart < oldEnd) result.push(...build(oldWords, oldStart, oldEnd, markerDeletion));
// Add insertions
if (newStart < newEnd) result.push(...build(newWords, newStart, newEnd, markerAddition));
}
return result;
}
return buildDiff(0, oldWords.length, 0, newWords.length)
.join(' ')
.replace(/<br>[ ]+<br>/g, '<br><br>')
.replace(/<br> /g, '<br>');
}
function feedTableLookup() {
emit('feedTableLookup');
}
function timeToText(time) {
return utils.timeToText(time);
}
</script>

View File

@@ -270,9 +270,11 @@
</template>
<script>
import removeConfusables, { removeWhitespace } from '../../service/confusables';
import utils from '../../classes/utils';
import { friendRequest, userRequest } from '../../api';
import utils from '../../classes/utils';
import { languageClass as _languageClass } from '../../composables/user/utils';
import removeConfusables, { removeWhitespace } from '../../service/confusables';
import { getFaviconUrl as _getFaviconUrl } from '../../composables/shared/utils';
export default {
name: 'FriendListTab',
@@ -282,8 +284,7 @@
'showFullscreenImageDialog',
'showUserDialog',
'statusClass',
'openExternalLink',
'languageClass'
'openExternalLink'
],
props: {
friends: {
@@ -322,7 +323,7 @@
friendsListLoading: false,
friendsListLoadingProgress: '',
friendsListSearchFilterVIP: false,
// emm
// TODO
friendsListBulkUnfriendForceUpdate: 0
};
},
@@ -336,6 +337,9 @@
}
},
methods: {
languageClass(key) {
return _languageClass(key);
},
friendsListSearchChange() {
this.friendsListLoading = true;
let query = '';
@@ -505,7 +509,7 @@
return utils.timeToText(val);
},
getFaviconUrl(link) {
return utils.getFaviconUrl(link);
return _getFaviconUrl(link);
}
}
};

View File

@@ -0,0 +1,140 @@
<template>
<div v-if="menuActiveIndex === 'friendLog'" class="x-container">
<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
style="flex: 1"
:placeholder="t('view.friend_log.filter_placeholder')"
@change="saveTableFilters">
<el-option
v-for="type in [
'Friend',
'Unfriend',
'FriendRequest',
'CancelFriendRequest',
'DisplayName',
'TrustLevel'
]"
:key="type"
:label="t('view.friend_log.filters.' + type)"
:value="type" />
</el-select>
<el-input
v-model="friendLogTable.filters[1].value"
:placeholder="t('view.friend_log.search_placeholder')"
style="flex: none; width: 150px; margin-left: 10px" />
</div>
</template>
<el-table-column :label="t('table.friendLog.date')" prop="created_at" sortable="custom" width="200">
<template #default="scope">
<el-tooltip placement="right">
<template #content>
<span>{{ scope.row.created_at | formatDate('long') }}</span>
</template>
<span>{{ scope.row.created_at | formatDate('short') }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="t('table.friendLog.type')" prop="type" width="150">
<template #default="scope">
<span v-text="t('view.friend_log.filters.' + scope.row.type)"></span>
</template>
</el-table-column>
<el-table-column :label="t('table.friendLog.user')" prop="displayName">
<template #default="scope">
<span v-if="scope.row.type === 'DisplayName'">
{{ scope.row.previousDisplayName }} <i class="el-icon-right"></i>&nbsp;
</span>
<span
class="x-link"
style="padding-right: 10px"
@click="showUserDialog(scope.row.userId)"
v-text="scope.row.displayName || scope.row.userId"></span>
<template v-if="scope.row.type === 'TrustLevel'">
<span>
({{ scope.row.previousTrustLevel }} <i class="el-icon-right"></i>
{{ scope.row.trustLevel }})</span
>
</template>
</template>
</el-table-column>
<el-table-column :label="t('table.friendLog.action')" width="80" align="right">
<template #default="scope">
<el-button
v-if="shiftHeld"
style="color: #f56c6c"
type="text"
icon="el-icon-close"
size="mini"
@click="deleteFriendLog(scope.row)"></el-button>
<el-button
v-else
type="text"
icon="el-icon-delete"
size="mini"
@click="deleteFriendLogPrompt(scope.row)"></el-button>
</template>
</el-table-column>
</data-tables>
</div>
</template>
<script>
export default {
name: 'FriendLogTab'
};
</script>
<script setup>
import { getCurrentInstance, inject } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import utils from '../../classes/utils';
import configRepository from '../../service/config';
import database from '../../service/database';
const { t } = useI18n();
const { proxy } = getCurrentInstance();
const { $confirm } = proxy;
const showUserDialog = inject('showUserDialog');
const props = defineProps({
menuActiveIndex: {
type: String,
default: ''
},
friendLogTable: {
type: Object,
default: () => ({})
},
shiftHeld: { type: Boolean, default: false }
});
function saveTableFilters() {
configRepository.setString('VRCX_friendLogTableFilters', JSON.stringify(props.friendLogTable.filters[0].value));
}
function deleteFriendLogPrompt(row) {
$confirm('Continue? Delete Log', 'Confirm', {
confirmButtonText: 'Confirm',
cancelButtonText: 'Cancel',
type: 'info',
callback: (action) => {
if (action === 'confirm') {
deleteFriendLog(row);
}
}
});
}
function deleteFriendLog(row) {
utils.removeFromArray(props.friendLogTable.data, row);
database.deleteFriendLogHistory(row.rowId);
}
</script>

View File

@@ -0,0 +1,278 @@
<template>
<div v-show="menuActiveIndex === 'gameLog'" class="x-container">
<data-tables v-loading="gameLogTable.loading" v-bind="gameLogTable" lazy>
<template #tool>
<div style="margin: 0 0 10px; display: flex; align-items: center">
<div style="flex: none; margin-right: 10px; display: flex; align-items: center">
<el-tooltip
placement="bottom"
:content="t('view.feed.favorites_only_tooltip')"
:disabled="hideTooltips">
<el-switch
v-model="gameLogTable.vip"
active-color="#13ce66"
@change="gameLogTableLookup"></el-switch>
</el-tooltip>
</div>
<el-select
v-model="gameLogTable.filter"
multiple
clearable
style="flex: 1"
:placeholder="t('view.game_log.filter_placeholder')"
@change="gameLogTableLookup">
<el-option
v-for="type in [
'Location',
'OnPlayerJoined',
'OnPlayerLeft',
'VideoPlay',
'Event',
'External',
'StringLoad',
'ImageLoad'
]"
:key="type"
:label="t('view.game_log.filters.' + type)"
:value="type"></el-option>
</el-select>
<el-input
v-model="gameLogTable.search"
:placeholder="t('view.game_log.search_placeholder')"
clearable
style="flex: none; width: 150px; margin: 0 10px"
@keyup.native.enter="gameLogTableLookup"
@change="gameLogTableLookup"></el-input>
</div>
</template>
<el-table-column :label="t('table.gameLog.date')" prop="created_at" sortable="custom" width="120">
<template #default="scope">
<el-tooltip placement="right">
<template #content>
<span>{{ scope.row.created_at | formatDate('long') }}</span>
</template>
<span>{{ scope.row.created_at | formatDate('short') }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="t('table.gameLog.type')" prop="type" width="120">
<template #default="scope">
<el-tooltip placement="right" :open-delay="500" :disabled="hideTooltips">
<template #content>
<span>{{ t('view.game_log.filters.' + scope.row.type) }}</span>
</template>
<span
v-if="scope.row.location && scope.row.type !== 'Location'"
class="x-link"
@click="showWorldDialog(scope.row.location)"
v-text="t('view.game_log.filters.' + scope.row.type)"></span>
<span v-else v-text="t('view.game_log.filters.' + scope.row.type)"></span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="t('table.gameLog.icon')" prop="isFriend" width="70" align="center">
<template #default="scope">
<template v-if="gameLogIsFriend(scope.row)">
<el-tooltip v-if="gameLogIsFavorite(scope.row)" placement="top" content="Favorite">
<span>⭐</span>
</el-tooltip>
<el-tooltip v-else placement="top" content="Friend">
<span>💚</span>
</el-tooltip>
</template>
</template>
</el-table-column>
<el-table-column :label="t('table.gameLog.user')" prop="displayName" width="180">
<template #default="scope">
<span
v-if="scope.row.displayName"
class="x-link"
style="padding-right: 10px"
@click="lookupUser(scope.row)"
v-text="scope.row.displayName"></span>
</template>
</el-table-column>
<el-table-column :label="t('table.gameLog.detail')" prop="data">
<template #default="scope">
<location
v-if="scope.row.type === 'Location'"
:location="scope.row.location"
:hint="scope.row.worldName"
:grouphint="scope.row.groupName"></location>
<location
v-else-if="scope.row.type === 'PortalSpawn'"
:location="scope.row.instanceId"
:hint="scope.row.worldName"
:grouphint="scope.row.groupName"></location>
<template v-else-if="scope.row.type === 'Event'">
<span v-text="scope.row.data"></span>
</template>
<template v-else-if="scope.row.type === 'External'">
<span v-text="scope.row.message"></span>
</template>
<template v-else-if="scope.row.type === 'VideoPlay'">
<span v-if="scope.row.videoId" style="margin-right: 5px">{{ scope.row.videoId }}:</span>
<span v-if="scope.row.videoId === 'LSMedia'" v-text="scope.row.videoName"></span>
<span
v-else-if="scope.row.videoName"
class="x-link"
@click="openExternalLink(scope.row.videoUrl)"
v-text="scope.row.videoName"></span>
<span
v-else
class="x-link"
@click="openExternalLink(scope.row.videoUrl)"
v-text="scope.row.videoUrl"></span>
</template>
<template v-else-if="scope.row.type === 'ImageLoad'">
<span
class="x-link"
@click="openExternalLink(scope.row.resourceUrl)"
v-text="scope.row.resourceUrl"></span>
</template>
<template v-else-if="scope.row.type === 'StringLoad'">
<span
class="x-link"
@click="openExternalLink(scope.row.resourceUrl)"
v-text="scope.row.resourceUrl"></span>
</template>
<template
v-else-if="
scope.row.type === 'Notification' ||
scope.row.type === 'OnPlayerJoined' ||
scope.row.type === 'OnPlayerLeft'
">
</template>
<span v-else class="x-link" v-text="scope.row.data"></span>
</template>
</el-table-column>
<el-table-column :label="t('table.gameLog.action')" width="80" align="right">
<template #default="scope">
<template
v-if="
scope.row.type !== 'OnPlayerJoined' &&
scope.row.type !== 'OnPlayerLeft' &&
scope.row.type !== 'Location' &&
scope.row.type !== 'PortalSpawn'
">
<el-button
v-if="shiftHeld"
style="color: #f56c6c"
type="text"
icon="el-icon-close"
size="mini"
@click="deleteGameLogEntry(scope.row)"></el-button>
<el-button
v-else
type="text"
icon="el-icon-delete"
size="mini"
@click="deleteGameLogEntryPrompt(scope.row)"></el-button>
</template>
<el-tooltip placement="top" :content="t('dialog.previous_instances.info')" :disabled="hideTooltips">
<el-button
v-if="scope.row.type === 'Location'"
type="text"
icon="el-icon-s-data"
size="mini"
@click="showPreviousInstancesInfoDialog(scope.row.location)"></el-button>
</el-tooltip>
</template>
</el-table-column>
</data-tables>
</div>
</template>
<script>
export default {
name: 'GameLogTab'
};
</script>
<script setup>
import { inject, getCurrentInstance } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import utils from '../../classes/utils';
import database from '../../service/database';
import Location from '../../components/Location.vue';
const { t } = useI18n();
const { $confirm } = getCurrentInstance().proxy;
const showWorldDialog = inject('showWorldDialog');
const openExternalLink = inject('openExternalLink');
const showPreviousInstancesInfoDialog = inject('showPreviousInstancesInfoDialog');
const props = defineProps({
menuActiveIndex: {
type: String,
required: true
},
gameLogTable: {
type: Object,
required: true
},
hideTooltips: {
type: Boolean,
default: false
},
shiftHeld: {
type: Boolean,
default: false
}
});
const emit = defineEmits([
'gameLogTableLookup',
'gameLogIsFriend',
'gameLogIsFavorite',
'lookupUser',
'updateGameLogSessionTable',
'updateSharedFeed'
]);
function gameLogTableLookup() {
emit('gameLogTableLookup');
}
function gameLogIsFriend(row) {
emit('gameLogIsFriend', row);
}
function gameLogIsFavorite(row) {
emit('gameLogIsFavorite', row);
}
function lookupUser(ref) {
emit('lookupUser', ref);
}
function deleteGameLogEntry(row) {
utils.removeFromArray(props.gameLogTable.data, row);
database.deleteGameLogEntry(row);
console.log('deleteGameLogEntry', row);
database.getGamelogDatabase().then((data) => {
emit('updateGameLogSessionTable', data);
emit('updateSharedFeed', true);
});
}
function deleteGameLogEntryPrompt(row) {
$confirm('Continue? Delete Log', 'Confirm', {
confirmButtonText: 'Confirm',
cancelButtonText: 'Cancel',
type: 'info',
callback: (action) => {
if (action === 'confirm') {
deleteGameLogEntry(row);
}
}
});
}
</script>

227
src/views/Login/Login.vue Normal file
View File

@@ -0,0 +1,227 @@
<template>
<div v-loading="loginForm.loading" class="x-login-container">
<div class="x-login">
<div style="position: fixed; top: 0; left: 0; margin: 5px">
<el-tooltip placement="top" :content="t('view.login.updater')" :disabled="hideTooltips">
<el-button
type="default"
size="mini"
icon="el-icon-download"
circle
@click="showVRCXUpdateDialog"></el-button>
</el-tooltip>
<el-tooltip placement="top" :content="t('view.login.proxy_settings')" :disabled="hideTooltips">
<el-button
type="default"
size="mini"
icon="el-icon-connection"
style="margin-left: 5px"
circle
@click="promptProxySettings"></el-button>
</el-tooltip>
</div>
<div class="x-login-form-container">
<div>
<h2 style="font-weight: bold; text-align: center; margin: 0">{{ t('view.login.login') }}</h2>
<el-form
ref="loginFormRef"
:model="loginForm"
:rules="loginForm.rules"
@submit.native.prevent="login()">
<el-form-item :label="t('view.login.field.username')" prop="username" required>
<el-input
v-model="loginForm.username"
name="username"
:placeholder="t('view.login.field.username')"
clearable></el-input>
</el-form-item>
<el-form-item
:label="t('view.login.field.password')"
prop="password"
required
style="margin-top: 10px">
<el-input
v-model="loginForm.password"
type="password"
name="password"
:placeholder="t('view.login.field.password')"
clearable
show-password></el-input>
</el-form-item>
<el-checkbox v-model="loginForm.saveCredentials" style="margin-top: 15px">{{
t('view.login.field.saveCredentials')
}}</el-checkbox>
<el-checkbox
v-model="enableCustomEndpoint"
style="margin-top: 10px"
@change="toggleCustomEndpoint"
>{{ t('view.login.field.devEndpoint') }}</el-checkbox
>
<el-form-item
v-if="enableCustomEndpoint"
:label="t('view.login.field.endpoint')"
prop="endpoint"
style="margin-top: 10px">
<el-input
v-model="loginForm.endpoint"
name="endpoint"
:placeholder="API.endpointDomainVrchat"
clearable></el-input>
</el-form-item>
<el-form-item
v-if="enableCustomEndpoint"
:label="t('view.login.field.websocket')"
prop="websocket"
style="margin-top: 10px">
<el-input
v-model="loginForm.websocket"
name="websocket"
:placeholder="API.websocketDomainVrchat"
clearable></el-input>
</el-form-item>
<el-form-item style="margin-top: 15px">
<el-button native-type="submit" type="primary" style="width: 100%">{{
t('view.login.login')
}}</el-button>
</el-form-item>
</el-form>
<el-button
type="primary"
style="width: 100%"
@click="openExternalLink('https://vrchat.com/register')"
>{{ t('view.login.register') }}</el-button
>
</div>
<hr v-if="Object.keys(loginForm.savedCredentials).length !== 0" class="x-vertical-divider" />
<div v-if="Object.keys(loginForm.savedCredentials).length !== 0">
<h2 style="font-weight: bold; text-align: center; margin: 0">
{{ t('view.login.savedAccounts') }}
</h2>
<div class="x-scroll-wrapper" style="margin-top: 10px">
<div class="x-saved-account-list">
<div
v-for="user in loginForm.savedCredentials"
:key="user.user.id"
class="x-friend-item"
@click="relogin(user)">
<div class="avatar">
<img v-lazy="userImage(user.user)" />
</div>
<div class="detail">
<span class="name" v-text="user.user.displayName"></span>
<span class="extra" v-text="user.user.username"></span>
<span class="extra" v-text="user.loginParmas.endpoint"></span>
</div>
<el-button
type="default"
size="mini"
icon="el-icon-delete"
style="margin-left: 10px"
circle
@click.stop="deleteSavedLogin(user.user.id)"></el-button>
</div>
</div>
</div>
</div>
</div>
<div class="x-legal-notice-container">
<div style="text-align: center; font-size: 12px">
<p>
<a class="x-link" @click="openExternalLink('https://vrchat.com/home/password')">{{
t('view.login.forgotPassword')
}}</a>
</p>
<p>
&copy; 2019-2025
<a class="x-link" @click="openExternalLink('https://github.com/pypy-vrc')">pypy</a> &amp;
<a class="x-link" @click="openExternalLink('https://github.com/Natsumi-sama')">Natsumi</a>
</p>
<p>{{ t('view.settings.general.legal_notice.info') }}</p>
<p>{{ t('view.settings.general.legal_notice.disclaimer1') }}</p>
<p>{{ t('view.settings.general.legal_notice.disclaimer2') }}</p>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'LoginPage'
};
</script>
<script setup>
import { inject, onBeforeUnmount, ref } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
const { t } = useI18n();
const API = inject('API');
const openExternalLink = inject('openExternalLink');
const userImage = inject('userImage');
defineProps({
hideTooltips: {
type: Boolean,
default: false
},
loginForm: {
type: Object,
default: () => ({})
},
enableCustomEndpoint: {
type: Boolean,
default: false
}
});
const emit = defineEmits([
'showVRCXUpdateDialog',
'promptProxySettings',
'toggleCustomEndpoint',
'deleteSavedLogin',
'relogin',
'login'
]);
const loginFormRef = ref(null);
function showVRCXUpdateDialog() {
emit('showVRCXUpdateDialog');
}
function promptProxySettings() {
emit('promptProxySettings');
}
function toggleCustomEndpoint(...args) {
emit('toggleCustomEndpoint', args);
}
function deleteSavedLogin(userId) {
emit('deleteSavedLogin', userId);
}
function relogin(user) {
emit('relogin', user);
}
function login() {
if (loginFormRef.value) {
loginFormRef.value.validate((valid) => {
valid && emit('login');
});
}
}
onBeforeUnmount(() => {
// Because v-if actually it is not required
if (loginFormRef.value) {
loginFormRef.value.resetFields();
}
});
</script>

View File

@@ -0,0 +1,670 @@
<template>
<div v-if="menuActiveIndex === 'notification'" v-loading="API.isNotificationsLoading" class="x-container">
<data-tables v-bind="notificationTable" ref="notificationTableRef" class="notification-table">
<template #tool>
<div style="margin: 0 0 10px; display: flex; align-items: center">
<el-select
v-model="notificationTable.filters[0].value"
multiple
clearable
style="flex: 1"
:placeholder="t('view.notification.filter_placeholder')"
@change="saveTableFilters">
<el-option
v-for="type in [
'requestInvite',
'invite',
'requestInviteResponse',
'inviteResponse',
'friendRequest',
'ignoredFriendRequest',
'message',
'boop',
'groupChange',
'group.announcement',
'group.informative',
'group.invite',
'group.joinRequest',
'group.transfer',
'group.queueReady',
'moderation.warning.group',
'moderation.report.closed',
'instance.closed'
]"
:key="type"
:label="t('view.notification.filters.' + type)"
:value="type" />
</el-select>
<el-input
v-model="notificationTable.filters[1].value"
:placeholder="t('view.notification.search_placeholder')"
style="flex: none; width: 150px; margin: 0 10px" />
<el-tooltip
placement="bottom"
:content="t('view.notification.refresh_tooltip')"
:disabled="hideTooltips">
<el-button
type="default"
:loading="API.isNotificationsLoading"
icon="el-icon-refresh"
circle
style="flex: none"
@click="API.refreshNotifications()" />
</el-tooltip>
</div>
</template>
<el-table-column :label="t('table.notification.date')" prop="created_at" sortable="custom" width="120">
<template #default="scope">
<el-tooltip placement="right">
<template #content>
<span>{{ scope.row.created_at | formatDate('long') }}</span>
</template>
<span>{{ scope.row.created_at | formatDate('short') }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="t('table.notification.type')" prop="type" width="180">
<template #default="scope">
<span
v-if="scope.row.type === 'invite'"
class="x-link"
@click="showWorldDialog(scope.row.details.worldId)"
v-text="t('view.notification.filters.' + scope.row.type)"></span>
<el-tooltip
v-else-if="scope.row.type === 'group.queueReady' || scope.row.type === 'instance.closed'"
placement="top">
<template #content>
<location
v-if="scope.row.location"
:location="scope.row.location"
:hint="scope.row.worldName"
:grouphint="scope.row.groupName"
:link="false" />
</template>
<span
class="x-link"
@click="showWorldDialog(scope.row.location)"
v-text="t('view.notification.filters.' + scope.row.type)"></span>
</el-tooltip>
<el-tooltip
v-else-if="scope.row.link"
placement="top"
:content="scope.row.linkText"
:disabled="hideTooltips">
<span
class="x-link"
@click="openNotificationLink(scope.row.link)"
v-text="t('view.notification.filters.' + scope.row.type)"></span>
</el-tooltip>
<span v-else v-text="t('view.notification.filters.' + scope.row.type)"></span>
</template>
</el-table-column>
<el-table-column :label="t('table.notification.user_group')" prop="senderUsername" width="150">
<template #default="scope">
<template v-if="scope.row.type === 'groupChange'">
<span
class="x-link"
@click="showGroupDialog(scope.row.senderUserId)"
v-text="scope.row.senderUsername"></span>
</template>
<template v-else-if="scope.row.senderUserId">
<span
class="x-link"
@click="showUserDialog(scope.row.senderUserId)"
v-text="scope.row.senderUsername"></span>
</template>
<template v-else-if="scope.row.link && scope.row.data?.groupName">
<span
class="x-link"
@click="openNotificationLink(scope.row.link)"
v-text="scope.row.data?.groupName"></span>
</template>
<template v-else-if="scope.row.link">
<span
class="x-link"
@click="openNotificationLink(scope.row.link)"
v-text="scope.row.linkText"></span>
</template>
</template>
</el-table-column>
<el-table-column :label="t('table.notification.photo')" width="100" prop="photo">
<template #default="scope">
<template v-if="scope.row.details && scope.row.details.imageUrl">
<el-popover placement="right" width="500px" trigger="click">
<template #reference>
<img
class="x-link"
:src="getSmallThumbnailUrl(scope.row.details.imageUrl)"
style="flex: none; height: 50px; border-radius: 4px" />
</template>
<img
v-lazy="scope.row.details.imageUrl"
class="x-link"
style="width: 500px"
@click="showFullscreenImageDialog(scope.row.details.imageUrl)" />
</el-popover>
</template>
<template v-else-if="scope.row.imageUrl">
<el-popover placement="right" width="500px" trigger="click">
<template #reference>
<img
class="x-link"
:src="getSmallThumbnailUrl(scope.row.imageUrl)"
style="flex: none; height: 50px; border-radius: 4px" />
</template>
<img
v-lazy="scope.row.imageUrl"
class="x-link"
style="width: 500px"
@click="showFullscreenImageDialog(scope.row.imageUrl)" />
</el-popover>
</template>
</template>
</el-table-column>
<el-table-column :label="t('table.notification.message')" prop="message">
<template #default="scope">
<span v-if="scope.row.type === 'invite'" style="display: flex">
<location
v-if="scope.row.details"
:location="scope.row.details.worldId"
:hint="scope.row.details.worldName"
:grouphint="scope.row.details.groupName"
:link="true" />
<br v-if="scope.row.details" />
</span>
<el-tooltip
v-if="
scope.row.message &&
scope.row.message !== `This is a generated invite to ${scope.row.details?.worldName}`
"
placement="top">
<template #content>
<pre
class="extra"
style="
display: inline-block;
vertical-align: top;
font-family: inherit;
font-size: 12px;
white-space: pre-wrap;
margin: 0;
"
>{{ scope.row.message || '-' }}</pre
>
</template>
<div v-text="scope.row.message"></div>
</el-tooltip>
<span
v-else-if="scope.row.details && scope.row.details.inviteMessage"
v-text="scope.row.details.inviteMessage"></span>
<span
v-else-if="scope.row.details && scope.row.details.requestMessage"
v-text="scope.row.details.requestMessage"></span>
<span
v-else-if="scope.row.details && scope.row.details.responseMessage"
v-text="scope.row.details.responseMessage"></span>
</template>
</el-table-column>
<el-table-column :label="t('table.notification.action')" width="100" align="right">
<template #default="scope">
<template v-if="scope.row.senderUserId !== API.currentUser.id && !scope.row.$isExpired">
<template v-if="scope.row.type === 'friendRequest'">
<el-tooltip placement="top" content="Accept" :disabled="hideTooltips">
<el-button
type="text"
icon="el-icon-check"
style="color: #67c23a"
size="mini"
@click="acceptFriendRequestNotification(scope.row)" />
</el-tooltip>
</template>
<template v-else-if="scope.row.type === 'invite'">
<el-tooltip placement="top" content="Decline with message" :disabled="hideTooltips">
<el-button
type="text"
icon="el-icon-chat-line-square"
size="mini"
@click="showSendInviteResponseDialog(scope.row)" />
</el-tooltip>
</template>
<template v-else-if="scope.row.type === 'requestInvite'">
<template
v-if="lastLocation.location && isGameRunning && checkCanInvite(lastLocation.location)">
<el-tooltip placement="top" content="Invite" :disabled="hideTooltips">
<el-button
type="text"
icon="el-icon-check"
style="color: #67c23a"
size="mini"
@click="acceptRequestInvite(scope.row)" />
</el-tooltip>
</template>
<el-tooltip placement="top" content="Decline with message" :disabled="hideTooltips">
<el-button
type="text"
icon="el-icon-chat-line-square"
size="mini"
style="margin-left: 5px"
@click="showSendInviteRequestResponseDialog(scope.row)" />
</el-tooltip>
</template>
<template v-if="scope.row.responses">
<template v-for="response in scope.row.responses">
<el-tooltip placement="top" :content="response.text" :disabled="hideTooltips">
<el-button
v-if="response.icon === 'check'"
type="text"
icon="el-icon-check"
size="mini"
style="margin-left: 5px"
@click="
sendNotificationResponse(scope.row.id, scope.row.responses, response.type)
" />
<el-button
v-else-if="response.icon === 'cancel'"
type="text"
icon="el-icon-close"
size="mini"
style="margin-left: 5px"
@click="
sendNotificationResponse(scope.row.id, scope.row.responses, response.type)
" />
<el-button
v-else-if="response.icon === 'ban'"
type="text"
icon="el-icon-circle-close"
size="mini"
style="margin-left: 5px"
@click="
sendNotificationResponse(scope.row.id, scope.row.responses, response.type)
" />
<el-button
v-else-if="response.icon === 'bell-slash'"
type="text"
icon="el-icon-bell"
size="mini"
style="margin-left: 5px"
@click="
sendNotificationResponse(scope.row.id, scope.row.responses, response.type)
" />
<!--//el-button(-->
<!--// v-else-if='response.icon === "reply" && scope.row.type === "boop"'-->
<!--// type='text'-->
<!--// icon='el-icon-chat-line-square'-->
<!--// size='mini'-->
<!--// style='margin-left: 5px'-->
<!--// @click='showSendBoopDialog(scope.row.senderUserId)')-->
<el-button
v-else-if="response.icon === 'reply'"
type="text"
icon="el-icon-chat-line-square"
size="mini"
style="margin-left: 5px"
@click="
sendNotificationResponse(scope.row.id, scope.row.responses, response.type)
" />
<el-button
v-else
type="text"
icon="el-icon-collection-tag"
size="mini"
style="margin-left: 5px"
@click="
sendNotificationResponse(scope.row.id, scope.row.responses, response.type)
" />
</el-tooltip>
</template>
</template>
<template
v-if="
scope.row.type !== 'requestInviteResponse' &&
scope.row.type !== 'inviteResponse' &&
scope.row.type !== 'message' &&
scope.row.type !== 'boop' &&
scope.row.type !== 'groupChange' &&
!scope.row.type.includes('group.') &&
!scope.row.type.includes('moderation.') &&
!scope.row.type.includes('instance.')
">
<el-tooltip placement="top" content="Decline" :disabled="hideTooltips">
<el-button
v-if="shiftHeld"
style="color: #f56c6c; margin-left: 5px"
type="text"
icon="el-icon-close"
size="mini"
@click="hideNotification(scope.row)" />
<el-button
v-else
type="text"
icon="el-icon-close"
size="mini"
style="margin-left: 5px"
@click="hideNotificationPrompt(scope.row)" />
</el-tooltip>
</template>
</template>
<template v-if="scope.row.type === 'group.queueReady'">
<el-tooltip placement="top" content="Delete log" :disabled="hideTooltips">
<el-button
v-if="shiftHeld"
style="color: #f56c6c; margin-left: 5px"
type="text"
icon="el-icon-close"
size="mini"
@click="deleteNotificationLog(scope.row)" />
<el-button
v-else
type="text"
icon="el-icon-delete"
size="mini"
style="margin-left: 5px"
@click="deleteNotificationLogPrompt(scope.row)" />
</el-tooltip>
</template>
<template
v-if="
scope.row.type !== 'friendRequest' &&
scope.row.type !== 'ignoredFriendRequest' &&
!scope.row.type.includes('group.') &&
!scope.row.type.includes('moderation.')
">
<el-tooltip placement="top" content="Delete log" :disabled="hideTooltips">
<el-button
v-if="shiftHeld"
style="color: #f56c6c; margin-left: 5px"
type="text"
icon="el-icon-close"
size="mini"
@click="deleteNotificationLog(scope.row)" />
<el-button
v-else
type="text"
icon="el-icon-delete"
size="mini"
style="margin-left: 5px"
@click="deleteNotificationLogPrompt(scope.row)" />
</el-tooltip>
</template>
</template>
</el-table-column>
</data-tables>
<SendInviteResponseDialog
:send-invite-response-dialog-visible.sync="sendInviteResponseDialogVisible"
:invite-response-message-table="inviteResponseMessageTable"
:upload-image="uploadImage"
@invite-image-upload="inviteImageUpload" />
<SendInviteRequestResponseDialog
:send-invite-request-response-dialog-visible.sync="sendInviteRequestResponseDialogVisible"
:invite-request-response-message-table="inviteRequestResponseMessageTable"
:upload-image="uploadImage"
@invite-image-upload="inviteImageUpload" />
</div>
</template>
<script>
export default {
name: 'NotificationTab'
};
</script>
<script setup>
import { getCurrentInstance, inject, ref } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import { friendRequest, inviteMessagesRequest, notificationRequest, worldRequest } from '../../api';
import utils from '../../classes/utils';
import { parseLocation } from '../../composables/instance/utils';
import { convertFileUrlToImageUrl } from '../../composables/shared/utils';
import configRepository from '../../service/config';
import database from '../../service/database';
import SendInviteRequestResponseDialog from './dialogs/SendInviteRequestResponseDialog.vue';
import SendInviteResponseDialog from './dialogs/SendInviteResponseDialog.vue';
import Location from '../../components/Location.vue';
const { t } = useI18n();
const { $confirm, $message } = getCurrentInstance().proxy;
const API = inject('API');
const showWorldDialog = inject('showWorldDialog');
const showGroupDialog = inject('showGroupDialog');
const showUserDialog = inject('showUserDialog');
const showFullscreenImageDialog = inject('showFullscreenImageDialog');
const props = defineProps({
menuActiveIndex: {
type: String,
default: ''
},
notificationTable: {
type: Object,
default: () => ({})
},
shiftHeld: { type: Boolean, default: false },
hideTooltips: { type: Boolean, default: false },
lastLocation: { type: Object, default: () => ({}) },
inviteResponseMessageTable: {
type: Object,
default: () => ({})
},
uploadImage: {
type: String,
default: ''
},
lastLocationDestination: {
type: String,
default: ''
},
isGameRunning: {
type: Boolean,
default: false
},
checkCanInvite: {
type: Function,
default: () => true
},
inviteRequestResponseMessageTable: {
type: Object,
default: () => ({})
}
});
const emit = defineEmits(['inviteImageUpload', 'clearInviteImageUpload']);
const sendInviteResponseDialog = ref({
message: '',
messageSlot: 0,
invite: {}
});
const sendInviteResponseDialogVisible = ref(false);
const sendInviteRequestResponseDialogVisible = ref(false);
function inviteImageUpload(event) {
emit('inviteImageUpload', event);
}
function saveTableFilters() {
configRepository.setString(
'VRCX_notificationTableFilters',
JSON.stringify(props.notificationTable.filters[0].value)
);
}
function openNotificationLink(link) {
if (!link) {
return;
}
const data = link.split(':');
if (!data.length) {
return;
}
switch (data[0]) {
case 'group':
showGroupDialog(data[1]);
break;
case 'user':
showUserDialog(data[1]);
break;
}
}
function getSmallThumbnailUrl(url) {
return convertFileUrlToImageUrl(url);
}
function acceptFriendRequestNotification(row) {
// FIXME: 메시지 수정
$confirm('Continue? Accept Friend Request', 'Confirm', {
confirmButtonText: 'Confirm',
cancelButtonText: 'Cancel',
type: 'info',
callback: (action) => {
if (action === 'confirm') {
notificationRequest.acceptFriendRequestNotification({
notificationId: row.id
});
}
}
});
}
function showSendInviteResponseDialog(invite) {
sendInviteResponseDialog.value = {
invite
};
inviteMessagesRequest.refreshInviteMessageTableData('response');
clearInviteImageUpload();
sendInviteResponseDialogVisible.value = true;
}
function clearInviteImageUpload() {
emit('clearInviteImageUpload');
}
function acceptRequestInvite(row) {
$confirm('Continue? Send Invite', 'Confirm', {
confirmButtonText: 'Confirm',
cancelButtonText: 'Cancel',
type: 'info',
callback: (action) => {
if (action === 'confirm') {
let currentLocation = props.lastLocation.location;
// todo
if (props.lastLocation.location === 'traveling') {
currentLocation = props.lastLocationDestination;
}
const L = parseLocation(currentLocation);
worldRequest
.getCachedWorld({
worldId: L.worldId
})
.then((args) => {
notificationRequest
.sendInvite(
{
instanceId: L.tag,
worldId: L.tag,
worldName: args.ref.name,
rsvp: true
},
row.senderUserId
)
.then((_args) => {
$message('Invite sent');
notificationRequest.hideNotification({
notificationId: row.id
});
return _args;
});
});
}
}
});
}
function showSendInviteRequestResponseDialog(invite) {
sendInviteResponseDialog.value = {
invite
};
inviteMessagesRequest.refreshInviteMessageTableData('requestResponse');
clearInviteImageUpload();
sendInviteRequestResponseDialogVisible.value = true;
}
function sendNotificationResponse(notificationId, responses, responseType) {
if (!Array.isArray(responses) || responses.length === 0) {
return null;
}
let responseData = '';
for (let i = 0; i < responses.length; i++) {
if (responses[i].type === responseType) {
responseData = responses[i].data;
break;
}
}
return notificationRequest.sendNotificationResponse({
notificationId,
responseType,
responseData
});
}
function hideNotification(row) {
if (row.type === 'ignoredFriendRequest') {
friendRequest.deleteHiddenFriendRequest(
{
notificationId: row.id
},
row.senderUserId
);
} else {
notificationRequest.hideNotification({
notificationId: row.id
});
}
}
function hideNotificationPrompt(row) {
$confirm(`Continue? Decline ${row.type}`, 'Confirm', {
confirmButtonText: 'Confirm',
cancelButtonText: 'Cancel',
type: 'info',
callback: (action) => {
if (action === 'confirm') {
hideNotification(row);
}
}
});
}
function deleteNotificationLog(row) {
utils.removeFromArray(props.notificationTable.data, row);
if (row.type !== 'friendRequest' && row.type !== 'ignoredFriendRequest') {
database.deleteNotification(row.id);
}
}
function deleteNotificationLogPrompt(row) {
$confirm(`Continue? Delete ${row.type}`, 'Confirm', {
confirmButtonText: 'Confirm',
cancelButtonText: 'Cancel',
type: 'info',
callback: (action) => {
if (action === 'confirm') {
deleteNotificationLog(row);
}
}
});
}
</script>

View File

@@ -0,0 +1,133 @@
<template>
<safe-dialog
class="x-dialog"
:visible.sync="editAndSendInviteResponseDialog.visible"
:title="t('dialog.edit_send_invite_response_message.header')"
width="400px"
append-to-body>
<div style="font-size: 12px">
<span>{{ t('dialog.edit_send_invite_response_message.description') }}</span>
</div>
<el-input
v-model="editAndSendInviteResponseDialog.newMessage"
type="textarea"
size="mini"
maxlength="64"
show-word-limit
:autosize="{ minRows: 2, maxRows: 5 }"
placeholder=""
style="margin-top: 10px">
</el-input>
<template #footer>
<el-button type="small" @click="cancelEditAndSendInviteResponse">{{
t('dialog.edit_send_invite_response_message.cancel')
}}</el-button>
<el-button type="primary" size="small" @click="saveEditAndSendInviteResponse">{{
t('dialog.edit_send_invite_response_message.send')
}}</el-button>
</template>
</safe-dialog>
</template>
<script setup>
import { getCurrentInstance, inject } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import { inviteMessagesRequest, notificationRequest } from '../../../api';
const { t } = useI18n();
const instance = getCurrentInstance();
const $message = instance.proxy.$message;
const API = inject('API');
const props = defineProps({
editAndSendInviteResponseDialog: {
type: Object,
required: true
},
uploadImage: {
type: String
},
sendInviteResponseDialog: {
type: Object,
default: () => ({})
}
});
const emit = defineEmits(['closeInviteDialog', 'update:editAndSendInviteResponseDialog']);
function cancelEditAndSendInviteResponse() {
emit('update:editAndSendInviteResponseDialog', { ...props.editAndSendInviteResponseDialog, visible: false });
}
async function saveEditAndSendInviteResponse() {
const D = props.editAndSendInviteResponseDialog;
D.visible = false;
const messageType = D.messageType;
const slot = D.inviteMessage.slot;
if (D.inviteMessage.message !== D.newMessage) {
const params = {
message: D.newMessage
};
await inviteMessagesRequest
.editInviteMessage(params, messageType, slot)
.catch((err) => {
throw err;
})
.then((args) => {
API.$emit(`INVITE:${messageType.toUpperCase()}`, args);
if (args.json[slot].message === D.inviteMessage.message) {
$message({
message: "VRChat API didn't update message, try again",
type: 'error'
});
throw new Error("VRChat API didn't update message, try again");
} else {
$message('Invite message updated');
}
return args;
});
}
const I = props.sendInviteResponseDialog;
const params = {
responseSlot: slot,
rsvp: true
};
if (props.uploadImage) {
notificationRequest
.sendInviteResponsePhoto(params, I.invite.id)
.catch((err) => {
throw err;
})
.then((args) => {
notificationRequest.hideNotification({
notificationId: I.invite.id
});
$message({
message: 'Invite response message sent',
type: 'success'
});
emit('closeInviteDialog');
return args;
});
} else {
notificationRequest
.sendInviteResponse(params, I.invite.id)
.catch((err) => {
throw err;
})
.then((args) => {
notificationRequest.hideNotification({
notificationId: I.invite.id
});
$message({
message: 'Invite response message sent',
type: 'success'
});
emit('closeInviteDialog');
return args;
});
}
}
</script>

View File

@@ -0,0 +1,125 @@
<template>
<safe-dialog
class="x-dialog"
:visible="sendInviteRequestResponseDialogVisible"
:title="t('dialog.invite_request_response_message.header')"
width="800px"
append-to-body
@close="cancelSendInviteRequestResponse">
<template v-if="API.currentUser.$isVRCPlus">
<input class="inviteImageUploadButton" type="file" accept="image/*" @change="inviteImageUpload" />
</template>
<data-tables
v-bind="inviteRequestResponseMessageTable"
style="margin-top: 10px; cursor: pointer"
@row-click="showSendInviteResponseConfirmDialog">
<el-table-column :label="t('table.profile.invite_messages.slot')" prop="slot" sortable="custom" width="70">
</el-table-column>
<el-table-column :label="t('table.profile.invite_messages.message')" prop="message"> </el-table-column>
<el-table-column
:label="t('table.profile.invite_messages.cool_down')"
prop="updatedAt"
sortable="custom"
width="110"
align="right">
<template #default="scope">
<countdown-timer :datetime="scope.row.updatedAt" :hours="1"></countdown-timer>
</template>
</el-table-column>
<el-table-column :label="t('table.profile.invite_messages.action')" width="70" align="right">
<template #default="scope">
<el-button
type="text"
icon="el-icon-edit"
size="mini"
@click.stop="showEditAndSendInviteResponseDialog('requestResponse', scope.row)">
</el-button>
</template>
</el-table-column>
</data-tables>
<template #footer>
<el-button type="small" @click="cancelSendInviteRequestResponse">
{{ t('dialog.invite_request_response_message.cancel') }}
</el-button>
<el-button type="small" @click="API.refreshInviteMessageTableData('requestResponse')">
{{ t('dialog.invite_request_response_message.refresh') }}
</el-button>
</template>
<EditAndSendInviteResponseDialog
:edit-and-send-invite-response-dialog.sync="editAndSendInviteResponseDialog"
:upload-image="uploadImage"
:send-invite-response-dialog="sendInviteResponseDialog" />
<SendInviteResponseConfirmDialog
:send-invite-response-dialog.sync="sendInviteResponseConfirmDialog"
:upload-image="uploadImage"
:send-invite-response-confirm-dialog="sendInviteResponseDialog" />
</safe-dialog>
</template>
<script setup>
import { inject, ref } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import EditAndSendInviteResponseDialog from './EditAndSendInviteResponseDialog.vue';
import SendInviteResponseConfirmDialog from './SendInviteResponseConfirmDialog.vue';
const { t } = useI18n();
const API = inject('API');
defineProps({
sendInviteRequestResponseDialogVisible: {
type: Boolean,
default: false
},
inviteRequestResponseMessageTable: {
type: Object,
default: () => ({})
},
uploadImage: {
type: String
}
});
const emit = defineEmits(['update:sendInviteRequestResponseDialogVisible', 'inviteImageUpload']);
const editAndSendInviteResponseDialog = ref({
visible: false,
inviteMessage: {},
messageType: '',
newMessage: ''
});
const sendInviteResponseConfirmDialog = ref({
visible: false
});
const sendInviteResponseDialog = ref({
message: '',
messageSlot: 0,
invite: {}
});
function inviteImageUpload(event) {
emit('inviteImageUpload', event);
}
function showSendInviteResponseConfirmDialog(row) {
sendInviteResponseConfirmDialog.value.visible = true;
sendInviteResponseDialog.value.messageSlot = row.slot;
}
function showEditAndSendInviteResponseDialog(messageType, inviteMessage) {
editAndSendInviteResponseDialog.value = {
newMessage: inviteMessage.message,
visible: true,
messageType,
inviteMessage
};
}
function cancelSendInviteRequestResponse() {
emit('update:sendInviteRequestResponseDialogVisible', false);
}
</script>

View File

@@ -0,0 +1,95 @@
<template>
<safe-dialog
class="x-dialog"
:visible="sendInviteResponseConfirmDialog.visible"
:title="t('dialog.invite_response_message.header')"
width="400px"
append-to-body
@close="cancelInviteResponseConfirm">
<div style="font-size: 12px">
<span>{{ t('dialog.invite_response_message.confirmation') }}</span>
</div>
<template #footer>
<el-button type="small" @click="cancelInviteResponseConfirm">{{
t('dialog.invite_response_message.cancel')
}}</el-button>
<el-button type="primary" size="small" @click="sendInviteResponseConfirm">{{
t('dialog.invite_response_message.confirm')
}}</el-button>
</template>
</safe-dialog>
</template>
<script setup>
import { getCurrentInstance } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import { notificationRequest } from '../../../api';
const { t } = useI18n();
const instance = getCurrentInstance();
const $message = instance.proxy.$message;
const props = defineProps({
sendInviteResponseConfirmDialog: {
type: Object,
required: true
},
uploadImage: {
type: String
},
sendInviteResponseDialog: {
type: Object,
default: () => ({})
}
});
const emit = defineEmits(['update:sendInviteResponseConfirmDialog', 'closeInviteDialog']);
function cancelInviteResponseConfirm() {
emit('update:sendInviteResponseConfirmDialog', { visible: false });
}
function sendInviteResponseConfirm() {
const D = props.sendInviteResponseDialog;
const params = {
responseSlot: D.messageSlot,
rsvp: true
};
if (props.uploadImage) {
notificationRequest
.sendInviteResponsePhoto(params, D.invite.id, D.messageType)
.catch((err) => {
throw err;
})
.then((args) => {
notificationRequest.hideNotification({
notificationId: D.invite.id
});
$message({
message: 'Invite response photo message sent',
type: 'success'
});
return args;
});
} else {
notificationRequest
.sendInviteResponse(params, D.invite.id, D.messageType)
.catch((err) => {
throw err;
})
.then((args) => {
notificationRequest.hideNotification({
notificationId: D.invite.id
});
$message({
message: 'Invite response message sent',
type: 'success'
});
return args;
});
}
cancelInviteResponseConfirm();
emit('closeInviteDialog');
}
</script>

View File

@@ -0,0 +1,126 @@
<template>
<safe-dialog
class="x-dialog"
:visible="sendInviteResponseDialogVisible"
:title="t('dialog.invite_response_message.header')"
width="800px"
append-to-body
@close="cancelSendInviteResponse">
<template v-if="API.currentUser.$isVRCPlus">
<input class="inviteImageUploadButton" type="file" accept="image/*" @change="inviteImageUpload" />
</template>
<data-tables
v-bind="inviteResponseMessageTable"
style="margin-top: 10px; cursor: pointer"
@row-click="showSendInviteResponseConfirmDialog">
<el-table-column
:label="t('table.profile.invite_messages.slot')"
prop="slot"
sortable="custom"
width="70" />
<el-table-column :label="t('table.profile.invite_messages.message')" prop="message" />
<el-table-column
:label="t('table.profile.invite_messages.cool_down')"
prop="updatedAt"
sortable="custom"
width="110"
align="right">
<template #default="scope">
<countdown-timer :datetime="scope.row.updatedAt" :hours="1" />
</template>
</el-table-column>
<el-table-column :label="t('table.profile.invite_messages.action')" width="70" align="right">
<template #default="scope">
<el-button
type="text"
icon="el-icon-edit"
size="mini"
@click.stop="showEditAndSendInviteResponseDialog('response', scope.row)" />
</template>
</el-table-column>
</data-tables>
<template #footer>
<el-button type="small" @click="cancelSendInviteResponse">{{
t('dialog.invite_response_message.cancel')
}}</el-button>
<el-button type="small" @click="API.refreshInviteMessageTableData('response')">{{
t('dialog.invite_response_message.refresh')
}}</el-button>
</template>
<EditAndSendInviteResponseDialog
:edit-and-send-invite-response-dialog.sync="editAndSendInviteResponseDialog"
:upload-image="uploadImage"
:send-invite-response-confirm-dialog="sendInviteResponseDialog" />
<SendInviteResponseConfirmDialog
:send-invite-response-dialog.sync="sendInviteResponseConfirmDialog"
:upload-image="uploadImage"
:send-invite-response-confirm-dialog="sendInviteResponseDialog" />
</safe-dialog>
</template>
<script setup>
import { inject, ref } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import EditAndSendInviteResponseDialog from './EditAndSendInviteResponseDialog.vue';
import SendInviteResponseConfirmDialog from './SendInviteResponseConfirmDialog.vue';
const { t } = useI18n();
const API = inject('API');
defineProps({
sendInviteResponseDialogVisible: {
type: Boolean,
default: false
},
inviteResponseMessageTable: {
type: Object,
default: () => ({})
},
uploadImage: {
type: String
}
});
const editAndSendInviteResponseDialog = ref({
visible: false,
inviteMessage: {},
messageType: '',
newMessage: ''
});
const emit = defineEmits(['update:sendInviteResponseDialogVisible', 'inviteImageUpload']);
const sendInviteResponseConfirmDialog = ref({
visible: false
});
const sendInviteResponseDialog = ref({
message: '',
messageSlot: 0,
invite: {}
});
function cancelSendInviteResponse() {
emit('update:sendInviteResponseDialogVisible', false);
}
function showEditAndSendInviteResponseDialog(messageType, inviteMessage) {
editAndSendInviteResponseDialog.value = {
newMessage: inviteMessage.message,
visible: true,
messageType,
inviteMessage
};
}
function inviteImageUpload(event) {
emit('inviteImageUpload', event);
}
function showSendInviteResponseConfirmDialog(row) {
sendInviteResponseConfirmDialog.value.visible = true;
sendInviteResponseDialog.value.messageSlot = row.slot;
}
</script>

View File

@@ -0,0 +1,984 @@
<template>
<div v-if="menuActiveIndex === 'playerList'" class="x-container" style="padding-top: 5px">
<div style="display: flex; flex-direction: column; height: 100%">
<div v-if="currentInstanceWorld.ref.id" style="display: flex">
<el-popover placement="right" width="500px" trigger="click" style="height: 120px">
<img
slot="reference"
v-lazy="currentInstanceWorld.ref.thumbnailImageUrl"
class="x-link"
style="flex: none; width: 160px; height: 120px; border-radius: 4px" />
<img
v-lazy="currentInstanceWorld.ref.imageUrl"
class="x-link"
style="width: 500px; height: 375px"
@click="showFullscreenImageDialog(currentInstanceWorld.ref.imageUrl)" />
</el-popover>
<div style="margin-left: 10px; display: flex; flex-direction: column; min-width: 320px; width: 100%">
<div>
<span
class="x-link"
style="
font-weight: bold;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
"
@click="showWorldDialog(currentInstanceWorld.ref.id)">
<i
v-show="
API.currentUser.$homeLocation &&
API.currentUser.$homeLocation.worldId === currentInstanceWorld.ref.id
"
class="el-icon-s-home"
style="margin-right: 5px"></i>
{{ currentInstanceWorld.ref.name }}
</span>
</div>
<div>
<span
class="x-link x-grey"
style="font-family: monospace"
@click="showUserDialog(currentInstanceWorld.ref.authorId)"
v-text="currentInstanceWorld.ref.authorName"></span>
</div>
<div style="margin-top: 5px">
<el-tag
v-if="currentInstanceWorld.ref.$isLabs"
type="primary"
effect="plain"
size="mini"
style="margin-right: 5px"
>{{ t('dialog.world.tags.labs') }}</el-tag
>
<el-tag
v-else-if="currentInstanceWorld.ref.releaseStatus === 'public'"
type="success"
effect="plain"
size="mini"
style="margin-right: 5px"
>{{ t('dialog.world.tags.public') }}</el-tag
>
<el-tag
v-else-if="currentInstanceWorld.ref.releaseStatus === 'private'"
type="danger"
effect="plain"
size="mini"
style="margin-right: 5px"
>{{ t('dialog.world.tags.private') }}</el-tag
>
<el-tag
v-if="currentInstanceWorld.isPC"
class="x-tag-platform-pc"
type="info"
effect="plain"
size="mini"
style="margin-right: 5px"
>PC
<span
v-if="currentInstanceWorld.bundleSizes['standalonewindows']"
class="x-grey"
style="margin-left: 5px; border-left: inherit; padding-left: 5px"
>{{ currentInstanceWorld.bundleSizes['standalonewindows'].fileSize }}</span
>
</el-tag>
<el-tag
v-if="currentInstanceWorld.isQuest"
class="x-tag-platform-quest"
type="info"
effect="plain"
size="mini"
style="margin-right: 5px"
>Android
<span
v-if="currentInstanceWorld.bundleSizes['android']"
class="x-grey"
style="margin-left: 5px; border-left: inherit; padding-left: 5px"
>{{ currentInstanceWorld.bundleSizes['android'].fileSize }}</span
>
</el-tag>
<el-tag
v-if="currentInstanceWorld.isIOS"
class="x-tag-platform-ios"
type="info"
effect="plain"
size="mini"
style="margin-right: 5px"
>iOS
<span
v-if="currentInstanceWorld.bundleSizes['ios']"
class="x-grey"
style="margin-left: 5px; border-left: inherit; padding-left: 5px"
>{{ currentInstanceWorld.bundleSizes['ios'].fileSize }}</span
>
</el-tag>
<el-tag
v-if="currentInstanceWorld.avatarScalingDisabled"
type="warning"
effect="plain"
size="mini"
style="margin-right: 5px; margin-top: 5px"
>{{ t('dialog.world.tags.avatar_scaling_disabled') }}</el-tag
>
<el-tag
v-if="currentInstanceWorld.inCache"
type="info"
effect="plain"
size="mini"
style="margin-right: 5px">
<span>{{ currentInstanceWorld.cacheSize }} {{ t('dialog.world.tags.cache') }}</span>
</el-tag>
</div>
<div style="margin-top: 5px">
<location-world
:locationobject="currentInstanceLocation"
:currentuserid="API.currentUser.id"
@show-launch-dialog="showLaunchDialog"></location-world>
<span v-if="lastLocation.playerList.size > 0" style="margin-left: 5px">
{{ lastLocation.playerList.size }}
<template v-if="lastLocation.friendList.size > 0"
>({{ lastLocation.friendList.size }})</template
>
&nbsp;&horbar; <timer v-if="lastLocation.date" :epoch="lastLocation.date"></timer>
</span>
</div>
<div style="margin-top: 5px">
<span
v-show="currentInstanceWorld.ref.name !== currentInstanceWorld.ref.description"
:style="{
fontSize: '12px',
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitBoxOrient: 'vertical',
WebkitLineClamp: currentInstanceWorldDescriptionExpanded ? 'none' : '2'
}"
v-text="currentInstanceWorld.ref.description"></span>
<div style="display: flex; justify-content: end">
<el-button
v-if="
currentInstanceWorld.ref.description.length > 50 &&
!currentInstanceWorldDescriptionExpanded
"
type="text"
size="mini"
@click="currentInstanceWorldDescriptionExpanded = true"
>{{ !currentInstanceWorldDescriptionExpanded && 'Show more' }}</el-button
>
</div>
</div>
</div>
<div style="display: flex; flex-direction: column; margin-left: 20px">
<div class="x-friend-item" style="cursor: default">
<div class="detail">
<span class="name">{{ t('dialog.world.info.capacity') }}</span>
<span class="extra"
>{{ currentInstanceWorld.ref.recommendedCapacity | commaNumber }} ({{
currentInstanceWorld.ref.capacity | commaNumber
}})</span
>
</div>
</div>
<div class="x-friend-item" style="cursor: default">
<div class="detail">
<span class="name">{{ t('dialog.world.info.last_updated') }}</span>
<span class="extra">{{ currentInstanceWorld.lastUpdated | formatDate('long') }}</span>
</div>
</div>
<div class="x-friend-item" style="cursor: default">
<div class="detail">
<span class="name">{{ t('dialog.world.info.created_at') }}</span>
<span class="extra">{{ currentInstanceWorld.ref.created_at | formatDate('long') }}</span>
</div>
</div>
</div>
</div>
<div v-if="photonLoggingEnabled" class="photon-event-table">
<div style="position: absolute; width: 600px; margin-left: 215px; z-index: 1">
<el-select
v-model="photonEventTableTypeFilter"
multiple
clearable
collapse-tags
style="flex: 1; width: 220px"
:placeholder="t('view.player_list.photon.filter_placeholder')"
@change="photonEventTableFilterChange">
<el-option
v-for="type in photonEventTableTypeFilterList"
:key="type"
:label="type"
:value="type"></el-option>
</el-select>
<el-input
v-model="photonEventTableFilter"
:placeholder="t('view.player_list.photon.search_placeholder')"
clearable
style="width: 150px; margin-left: 10px"
@input="photonEventTableFilterChange"></el-input>
<el-button style="margin-left: 10px" @click="showChatboxBlacklistDialog">{{
t('view.player_list.photon.chatbox_blacklist')
}}</el-button>
<el-tooltip
placement="bottom"
:content="t('view.player_list.photon.status_tooltip')"
:disabled="hideTooltips">
<div
style="
display: inline-block;
margin-left: 15px;
font-size: 14px;
vertical-align: text-top;
margin-top: 1px;
">
<span v-if="ipcEnabled && !photonEventIcon">🟢</span>
<span v-else-if="ipcEnabled">⚪</span>
<span v-else>🔴</span>
</div>
</el-tooltip>
</div>
<el-tabs type="card">
<el-tab-pane :label="t('view.player_list.photon.current')">
<data-tables v-bind="photonEventTable" style="margin-bottom: 10px">
<el-table-column :label="t('table.playerList.date')" prop="created_at" width="120">
<template #default="scope">
<el-tooltip placement="right">
<template #content>
<span>{{ scope.row.created_at | formatDate('long') }}</span>
</template>
<span>{{ scope.row.created_at | formatDate('short') }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="t('table.playerList.user')" prop="photonId" width="160">
<template #default="scope">
<span
class="x-link"
style="padding-right: 10px"
@click="showUserFromPhotonId(scope.row.photonId)"
v-text="scope.row.displayName"></span>
</template>
</el-table-column>
<el-table-column
:label="t('table.playerList.type')"
prop="type"
width="140"></el-table-column>
<el-table-column :label="t('table.playerList.detail')" prop="text">
<template #default="scope">
<template v-if="scope.row.type === 'ChangeAvatar'">
<span
class="x-link"
@click="showAvatarDialog(scope.row.avatar.id)"
v-text="scope.row.avatar.name"></span>
&nbsp;
<span v-if="!scope.row.inCache" style="color: #aaa"
><i class="el-icon-download"></i>&nbsp;</span
>
<span
v-if="scope.row.avatar.releaseStatus === 'public'"
class="avatar-info-public"
>{{ t('dialog.avatar.labels.public') }}</span
>
<span
v-else-if="scope.row.avatar.releaseStatus === 'private'"
class="avatar-info-own"
>{{ t('dialog.avatar.labels.private') }}</span
>
<template
v-if="
scope.row.avatar.description &&
scope.row.avatar.name !== scope.row.avatar.description
">
- {{ scope.row.avatar.description }}
</template>
</template>
<template v-else-if="scope.row.type === 'ChangeStatus'">
<template v-if="scope.row.status !== scope.row.previousStatus">
<el-tooltip placement="top">
<template #content>
<span v-if="scope.row.previousStatus === 'active'">{{
t('dialog.user.status.active')
}}</span>
<span v-else-if="scope.row.previousStatus === 'join me'">{{
t('dialog.user.status.join_me')
}}</span>
<span v-else-if="scope.row.previousStatus === 'ask me'">{{
t('dialog.user.status.ask_me')
}}</span>
<span v-else-if="scope.row.previousStatus === 'busy'">{{
t('dialog.user.status.busy')
}}</span>
<span v-else>{{ t('dialog.user.status.offline') }}</span>
</template>
<i
class="x-user-status"
:class="statusClass(scope.row.previousStatus)"></i>
</el-tooltip>
<span>
<i class="el-icon-right"></i>
</span>
<el-tooltip placement="top">
<template #content>
<span v-if="scope.row.status === 'active'">{{
t('dialog.user.status.active')
}}</span>
<span v-else-if="scope.row.status === 'join me'">{{
t('dialog.user.status.join_me')
}}</span>
<span v-else-if="scope.row.status === 'ask me'">{{
t('dialog.user.status.ask_me')
}}</span>
<span v-else-if="scope.row.status === 'busy'">{{
t('dialog.user.status.busy')
}}</span>
<span v-else>{{ t('dialog.user.status.offline') }}</span>
</template>
<i
class="x-user-status"
:class="statusClass(scope.row.status)"
style="margin-right: 5px"></i>
</el-tooltip>
</template>
<span
v-if="scope.row.statusDescription !== scope.row.previousStatusDescription"
v-text="scope.row.statusDescription"></span>
</template>
<template v-else-if="scope.row.type === 'ChangeGroup'">
<span
v-if="scope.row.previousGroupName"
class="x-link"
style="margin-right: 5px"
@click="showGroupDialog(scope.row.previousGroupId)"
v-text="scope.row.previousGroupName"></span>
<span
v-else
class="x-link"
style="margin-right: 5px"
@click="showGroupDialog(scope.row.previousGroupId)"
v-text="scope.row.previousGroupId"></span>
<span>
<i class="el-icon-right"></i>
</span>
<span
v-if="scope.row.groupName"
class="x-link"
style="margin-left: 5px"
@click="showGroupDialog(scope.row.groupId)"
v-text="scope.row.groupName"></span>
<span
v-else
class="x-link"
style="margin-left: 5px"
@click="showGroupDialog(scope.row.groupId)"
v-text="scope.row.groupId"></span>
</template>
<span
v-else-if="scope.row.type === 'PortalSpawn'"
class="x-link"
@click="showWorldDialog(scope.row.location, scope.row.shortName)">
<location
:location="scope.row.location"
:hint="scope.row.worldName"
:grouphint="scope.row.groupName"
:link="false"></location>
</span>
<span
v-else-if="scope.row.type === 'ChatBoxMessage'"
v-text="scope.row.text"></span>
<span v-else-if="scope.row.type === 'OnPlayerJoined'">
<span v-if="scope.row.platform === 'Desktop'" style="color: #409eff"
>Desktop&nbsp;</span
>
<span v-else-if="scope.row.platform === 'VR'" style="color: #409eff"
>VR&nbsp;</span
>
<span v-else-if="scope.row.platform === 'Quest'" style="color: #67c23a"
>Android&nbsp;</span
>
<span
class="x-link"
@click="showAvatarDialog(scope.row.avatar.id)"
v-text="scope.row.avatar.name"></span>
&nbsp;
<span v-if="!scope.row.inCache" style="color: #aaa"
><i class="el-icon-download"></i>&nbsp;</span
>
<span
v-if="scope.row.avatar.releaseStatus === 'public'"
class="avatar-info-public"
>{{ t('dialog.avatar.labels.public') }}</span
>
<span
v-else-if="scope.row.avatar.releaseStatus === 'private'"
class="avatar-info-own"
>{{ t('dialog.avatar.labels.private') }}</span
>
</span>
<span v-else-if="scope.row.type === 'SpawnEmoji'">
<span v-if="scope.row.imageUrl">
<el-tooltip placement="right">
<template #content>
<img
v-lazy="scope.row.imageUrl"
class="friends-list-avatar"
style="height: 500px; cursor: pointer"
@click="showFullscreenImageDialog(scope.row.imageUrl)" />
</template>
<span v-text="scope.row.fileId"></span>
</el-tooltip>
</span>
<span v-else v-text="scope.row.text"></span>
</span>
<span
v-else-if="scope.row.color === 'yellow'"
style="color: yellow"
v-text="scope.row.text"></span>
<span v-else v-text="scope.row.text"></span>
</template>
</el-table-column>
</data-tables>
</el-tab-pane>
<el-tab-pane :label="t('view.player_list.photon.previous')">
<data-tables v-bind="photonEventTablePrevious" style="margin-bottom: 10px">
<el-table-column :label="t('table.playerList.date')" prop="created_at" width="120">
<template #default="scope">
<el-tooltip placement="right">
<template #content>
<span>{{ scope.row.created_at | formatDate('long') }}</span>
</template>
<span>{{ scope.row.created_at | formatDate('short') }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="t('table.playerList.user')" prop="photonId" width="160">
<template #default="scope">
<span
class="x-link"
style="padding-right: 10px"
@click="lookupUser(scope.row)"
v-text="scope.row.displayName"></span>
</template>
</el-table-column>
<el-table-column
:label="t('table.playerList.type')"
prop="type"
width="140"></el-table-column>
<el-table-column :label="t('table.playerList.detail')" prop="text">
<template #default="scope">
<template v-if="scope.row.type === 'ChangeAvatar'">
<span
class="x-link"
@click="showAvatarDialog(scope.row.avatar.id)"
v-text="scope.row.avatar.name"></span>
&nbsp;
<span v-if="!scope.row.inCache" style="color: #aaa"
><i class="el-icon-download"></i>&nbsp;</span
>
<span
v-if="scope.row.avatar.releaseStatus === 'public'"
class="avatar-info-public"
>{{ t('dialog.avatar.labels.public') }}</span
>
<span
v-else-if="scope.row.avatar.releaseStatus === 'private'"
class="avatar-info-own"
>{{ t('dialog.avatar.labels.private') }}</span
>
<template
v-if="
scope.row.avatar.description &&
scope.row.avatar.name !== scope.row.avatar.description
">
| - {{ scope.row.avatar.description }}
</template>
</template>
<template v-else-if="scope.row.type === 'ChangeStatus'">
<template v-if="scope.row.status !== scope.row.previousStatus">
<el-tooltip placement="top">
<template #content>
<span v-if="scope.row.previousStatus === 'active'">{{
t('dialog.user.status.active')
}}</span>
<span v-else-if="scope.row.previousStatus === 'join me'">{{
t('dialog.user.status.join_me')
}}</span>
<span v-else-if="scope.row.previousStatus === 'ask me'">{{
t('dialog.user.status.ask_me')
}}</span>
<span v-else-if="scope.row.previousStatus === 'busy'">{{
t('dialog.user.status.busy')
}}</span>
<span v-else>{{ t('dialog.user.status.offline') }}</span>
</template>
<i
class="x-user-status"
:class="statusClass(scope.row.previousStatus)"></i>
</el-tooltip>
<span>
<i class="el-icon-right"></i>
</span>
<el-tooltip placement="top">
<template #content>
<span v-if="scope.row.status === 'active'">{{
t('dialog.user.status.active')
}}</span>
<span v-else-if="scope.row.status === 'join me'">{{
t('dialog.user.status.join_me')
}}</span>
<span v-else-if="scope.row.status === 'ask me'">{{
t('dialog.user.status.ask_me')
}}</span>
<span v-else-if="scope.row.status === 'busy'">{{
t('dialog.user.status.busy')
}}</span>
<span v-else>{{ t('dialog.user.status.offline') }}</span>
</template>
<i
class="x-user-status"
:class="statusClass(scope.row.status)"
style="margin-right: 5px"></i>
</el-tooltip>
</template>
<span
v-if="scope.row.statusDescription !== scope.row.previousStatusDescription"
v-text="scope.row.statusDescription"></span>
</template>
<template v-else-if="scope.row.type === 'ChangeGroup'">
<span
v-if="scope.row.previousGroupName"
class="x-link"
style="margin-right: 5px"
@click="showGroupDialog(scope.row.previousGroupId)"
v-text="scope.row.previousGroupName"></span>
<span
v-else
class="x-link"
style="margin-right: 5px"
@click="showGroupDialog(scope.row.previousGroupId)"
v-text="scope.row.previousGroupId"></span>
<span>
<i class="el-icon-right"></i>
</span>
<span
v-if="scope.row.groupName"
class="x-link"
style="margin-left: 5px"
@click="showGroupDialog(scope.row.groupId)"
v-text="scope.row.groupName"></span>
<span
v-else
class="x-link"
style="margin-left: 5px"
@click="showGroupDialog(scope.row.groupId)"
v-text="scope.row.groupId"></span>
</template>
<span
v-else-if="scope.row.type === 'PortalSpawn'"
class="x-link"
@click="showWorldDialog(scope.row.location, scope.row.shortName)">
<location
:location="scope.row.location"
:hint="scope.row.worldName"
:grouphint="scope.row.groupName"
:link="false"></location>
</span>
<span
v-else-if="scope.row.type === 'ChatBoxMessage'"
v-text="scope.row.text"></span>
<span v-else-if="scope.row.type === 'OnPlayerJoined'">
<span v-if="scope.row.platform === 'Desktop'" style="color: #409eff"
>Desktop&nbsp;</span
>
<span v-else-if="scope.row.platform === 'VR'" style="color: #409eff"
>VR&nbsp;</span
>
<span v-else-if="scope.row.platform === 'Quest'" style="color: #67c23a"
>Android&nbsp;</span
>
<span
class="x-link"
@click="showAvatarDialog(scope.row.avatar.id)"
v-text="scope.row.avatar.name"></span>
&nbsp;
<span v-if="!scope.row.inCache" style="color: #aaa"
><i class="el-icon-download"></i>&nbsp;</span
>
<span
v-if="scope.row.avatar.releaseStatus === 'public'"
class="avatar-info-public"
>{{ t('dialog.avatar.labels.public') }}</span
>
<span
v-else-if="scope.row.avatar.releaseStatus === 'private'"
class="avatar-info-own"
>{{ t('dialog.avatar.labels.private') }}</span
>
</span>
<span v-else-if="scope.row.type === 'SpawnEmoji'">
<span v-if="scope.row.imageUrl">
<el-tooltip placement="right">
<template #content>
<img
v-lazy="scope.row.imageUrl"
class="friends-list-avatar"
style="height: 500px; cursor: pointer"
@click="showFullscreenImageDialog(scope.row.imageUrl)" />
</template>
<span v-text="scope.row.fileId"></span>
</el-tooltip>
</span>
<span v-else v-text="scope.row.text"></span>
</span>
<span
v-else-if="scope.row.color === 'yellow'"
style="color: yellow"
v-text="scope.row.text"></span>
<span v-else v-text="scope.row.text"></span>
</template>
</el-table-column>
</data-tables>
</el-tab-pane>
</el-tabs>
</div>
<div class="current-instance-table">
<data-tables
v-bind="currentInstanceUserList"
style="margin-top: 10px; cursor: pointer"
@row-click="selectCurrentInstanceRow">
<el-table-column :label="t('table.playerList.avatar')" width="70" prop="photo">
<template #default="scope">
<template v-if="userImage(scope.row.ref)">
<el-popover placement="right" height="500px" trigger="hover">
<img
slot="reference"
v-lazy="userImage(scope.row.ref)"
class="friends-list-avatar" />
<img
v-lazy="userImageFull(scope.row.ref)"
class="friends-list-avatar"
style="height: 500px; cursor: pointer"
@click="showFullscreenImageDialog(userImageFull(scope.row.ref))" />
</el-popover>
</template>
</template>
</el-table-column>
<el-table-column :label="t('table.playerList.timer')" width="80" prop="timer" sortable>
<template #default="scope">
<timer :epoch="scope.row.timer"></timer>
</template>
</el-table-column>
<el-table-column
v-if="photonLoggingEnabled"
:label="t('table.playerList.photonId')"
width="110"
prop="photonId"
sortable>
<template #default="scope">
<template v-if="chatboxUserBlacklist.has(scope.row.ref.id)">
<el-tooltip placement="left" content="Unblock chatbox messages">
<el-button
type="text"
icon="el-icon-turn-off-microphone"
size="mini"
style="color: red; margin-right: 5px"
@click.stop="deleteChatboxUserBlacklist(scope.row.ref.id)"></el-button>
</el-tooltip>
</template>
<template v-else>
<el-tooltip placement="left" content="Block chatbox messages">
<el-button
type="text"
icon="el-icon-microphone"
size="mini"
style="margin-right: 5px"
@click.stop="addChatboxUserBlacklist(scope.row.ref)"></el-button>
</el-tooltip>
</template>
<span v-text="scope.row.photonId"></span>
</template>
</el-table-column>
<el-table-column :label="t('table.playerList.icon')" prop="isMaster" width="70" align="center">
<template #default="scope">
<el-tooltip v-if="scope.row.isMaster" placement="left" content="Instance Master">
<span>👑</span>
</el-tooltip>
<el-tooltip v-if="scope.row.isModerator" placement="left" content="Moderator">
<span>⚔️</span>
</el-tooltip>
<el-tooltip v-if="scope.row.isFriend" placement="left" content="Friend">
<span>💚</span>
</el-tooltip>
<el-tooltip v-if="scope.row.timeoutTime" placement="left" content="Timeout">
<span style="color: red">🔴{{ scope.row.timeoutTime }}s</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="t('table.playerList.platform')" prop="inVRMode" width="80">
<template #default="scope">
<template v-if="scope.row.ref.last_platform">
<span v-if="scope.row.ref.last_platform === 'standalonewindows'" style="color: #409eff"
>PC</span
>
<span v-else-if="scope.row.ref.last_platform === 'android'" style="color: #67c23a"
>A</span
>
<span v-else-if="scope.row.ref.last_platform === 'ios'" style="color: #c7c7ce"
>iOS</span
>
<span v-else>{{ scope.row.ref.last_platform }}</span>
</template>
<template v-if="scope.row.inVRMode !== null">
<span v-if="scope.row.inVRMode">VR</span>
<span
v-else-if="
scope.row.ref.last_platform === 'android' ||
scope.row.ref.last_platform === 'ios'
"
>M</span
>
<span v-else>D</span>
</template>
</template>
</el-table-column>
<el-table-column
:label="t('table.playerList.displayName')"
min-width="140"
prop="displayName"
sortable="custom">
<template #default="scope">
<span
v-if="randomUserColours"
:style="{ color: scope.row.ref.$userColour }"
v-text="scope.row.ref.displayName"></span>
<span v-else v-text="scope.row.ref.displayName"></span>
</template>
</el-table-column>
<el-table-column :label="t('table.playerList.status')" min-width="180" prop="ref.status">
<template #default="scope">
<template v-if="scope.row.ref.status">
<i
class="x-user-status"
:class="statusClass(scope.row.ref.status)"
style="margin-right: 3px"></i>
<span v-text="scope.row.ref.statusDescription"></span>
<!--//- el-table-column(label="Group" min-width="180" prop="groupOnNameplate" sortable)-->
<!--//- template(v-once #default="scope")-->
<!--//- span(v-text="scope.row.groupOnNameplate")-->
</template>
</template>
</el-table-column>
<el-table-column
:label="t('table.playerList.rank')"
width="110"
prop="trustSortNum"
sortable="custom">
<template #default="scope">
<span
class="name"
:class="scope.row.ref.trustClass"
v-text="scope.row.ref.trustLevel"></span>
</template>
</el-table-column>
<el-table-column :label="t('table.playerList.language')" width="100" prop="ref.$languages">
<template #default="scope">
<el-tooltip v-for="item in scope.row.ref.$languages" :key="item.key" placement="top">
<template #content>
<span>{{ item.value }} ({{ item.key }})</span>
</template>
<span
class="flags"
:class="languageClass(item.key)"
style="display: inline-block; margin-right: 5px"></span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="t('table.playerList.bioLink')" width="100" prop="ref.bioLinks">
<template #default="scope">
<div style="display: flex; align-items: center">
<el-tooltip v-for="(link, index) in scope.row.ref.bioLinks" v-if="link" :key="index">
<template #content>
<span v-text="link"></span>
</template>
<img
:src="getFaviconUrl(link)"
style="
width: 16px;
height: 16px;
vertical-align: middle;
margin-right: 5px;
cursor: pointer;
"
@click.stop="openExternalLink(link)" />
</el-tooltip>
</div>
</template>
</el-table-column>
</data-tables>
</div>
</div>
<ChatboxBlacklistDialog
:chatbox-blacklist-dialog="chatboxBlacklistDialog"
:chatbox-user-blacklist="chatboxUserBlacklist"
@delete-chatbox-user-blacklist="deleteChatboxUserBlacklist" />
</div>
</template>
<script>
export default {
name: 'PlayerListTab'
};
</script>
<script setup>
import { inject, ref } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import { languageClass } from '../../composables/user/utils';
import configRepository from '../../service/config';
import ChatboxBlacklistDialog from './dialogs/ChatboxBlacklistDialog.vue';
import { getFaviconUrl } from '../../composables/shared/utils';
const { t } = useI18n();
const API = inject('API');
const showFullscreenImageDialog = inject('showFullscreenImageDialog');
const showWorldDialog = inject('showWorldDialog');
const showUserDialog = inject('showUserDialog');
const showLaunchDialog = inject('showLaunchDialog');
const showAvatarDialog = inject('showAvatarDialog');
const statusClass = inject('statusClass');
const showGroupDialog = inject('showGroupDialog');
const openExternalLink = inject('openExternalLink');
const userImage = inject('userImage');
const userImageFull = inject('userImageFull');
const props = defineProps({
menuActiveIndex: {
type: String,
default: 'playerList'
},
currentInstanceWorld: {
type: Object,
default: () => ({})
},
currentInstanceLocation: {
type: Object,
default: () => ({})
},
currentInstanceWorldDescriptionExpanded: {
type: Boolean,
default: false
},
photonLoggingEnabled: {
type: Boolean,
default: false
},
photonEventTableTypeFilter: {
type: Array,
default: []
},
photonEventTableTypeFilterList: {
type: Array,
default: () => []
},
photonEventTableFilter: {
type: String,
default: ''
},
hideTooltips: {
type: Boolean,
default: false
},
ipcEnabled: {
type: Boolean,
default: false
},
photonEventIcon: {
type: Boolean,
default: false
},
photonEventTable: {
type: Object,
default: () => ({})
},
photonEventTablePrevious: {
type: Object,
default: () => ({})
},
currentInstanceUserList: {
type: Object,
default: () => ({})
},
chatboxUserBlacklist: {
type: Map
},
randomUserColours: {
type: Boolean,
default: false
},
lastLocation: {
type: Object,
default: () => ({})
}
});
const emit = defineEmits([
'photonEventTableFilterChange',
'getCurrentInstanceUserList',
'showUserFromPhotonId',
'lookupUser'
]);
const chatboxBlacklistDialog = ref({
visible: false,
loading: false
});
function photonEventTableFilterChange(value) {
emit('photonEventTableFilterChange', value);
}
function showChatboxBlacklistDialog() {
const D = chatboxBlacklistDialog.value;
D.visible = true;
}
function showUserFromPhotonId(photonId) {
emit('showUserFromPhotonId', photonId);
}
function lookupUser(user) {
emit('lookupUser', user);
}
function selectCurrentInstanceRow(val) {
if (val === null) {
return;
}
const ref = val.ref;
if (ref.id) {
showUserDialog(ref.id);
} else {
lookupUser(ref);
}
}
async function deleteChatboxUserBlacklist(userId) {
props.chatboxUserBlacklist.delete(userId);
await saveChatboxUserBlacklist();
emit('getCurrentInstanceUserList');
}
async function saveChatboxUserBlacklist() {
await configRepository.setString(
'VRCX_chatboxUserBlacklist',
JSON.stringify(Object.fromEntries(props.chatboxUserBlacklist))
);
}
async function addChatboxUserBlacklist(user) {
props.chatboxUserBlacklist.set(user.id, user.displayName);
await saveChatboxUserBlacklist();
emit('getCurrentInstanceUserList');
}
</script>

View File

@@ -1,12 +1,9 @@
<template>
<el-dialog
<safe-dialog
class="x-dialog"
:before-close="beforeDialogClose"
:visible.sync="chatboxBlacklistDialog.visible"
:title="t('dialog.chatbox_blacklist.header')"
width="600px"
@mousedown.native="dialogMouseDown"
@mouseup.native="dialogMouseUp">
width="600px">
<div v-if="chatboxBlacklistDialog.visible" v-loading="chatboxBlacklistDialog.loading">
<h2>{{ t('dialog.chatbox_blacklist.keyword_blacklist') }}</h2>
<el-input
@@ -42,20 +39,15 @@
<span>{{ user[1] }}</span>
</el-tag>
</div>
</el-dialog>
</safe-dialog>
</template>
<script setup>
// TODO: untested
import { inject, ref } from 'vue';
import { ref } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import configRepository from '../../../service/config';
const { t } = useI18n();
const beforeDialogClose = inject('beforeDialogClose');
const dialogMouseDown = inject('dialogMouseDown');
const dialogMouseUp = inject('dialogMouseUp');
defineProps({
chatboxBlacklistDialog: {
type: Object,

View File

@@ -0,0 +1,725 @@
<template>
<div v-if="menuActiveIndex === 'profile'" class="x-container">
<div class="options-container" style="margin-top: 0">
<span class="header">{{ t('view.profile.profile.header') }}</span>
<div class="x-friend-list" style="margin-top: 10px">
<div class="x-friend-item" @click="showUserDialog(API.currentUser.id)">
<div class="avatar">
<img v-lazy="userImage(API.currentUser, true)" />
</div>
<div class="detail">
<span class="name" v-text="API.currentUser.displayName"></span>
<span class="extra" v-text="API.currentUser.username"></span>
</div>
</div>
<div class="x-friend-item" style="cursor: default">
<div class="detail">
<span class="name">{{ t('view.profile.profile.last_activity') }}</span>
<span class="extra">{{ API.currentUser.last_activity | formatDate('long') }}</span>
</div>
</div>
<div class="x-friend-item" style="cursor: default">
<div class="detail">
<span class="name">{{ t('view.profile.profile.two_factor') }}</span>
<span class="extra">{{
API.currentUser.twoFactorAuthEnabled
? t('view.profile.profile.two_factor_enabled')
: t('view.profile.profile.two_factor_disabled')
}}</span>
</div>
</div>
<div class="x-friend-item" @click="getVRChatCredits()">
<div class="detail">
<span class="name">{{ t('view.profile.profile.vrchat_credits') }}</span>
<span class="extra">{{ vrchatCredit ?? t('view.profile.profile.refresh') }}</span>
</div>
</div>
</div>
<div style="margin-top: 10px">
<el-button
size="small"
type="danger"
plain
icon="el-icon-switch-button"
style="margin-left: 0; margin-right: 5px; margin-top: 10px"
@click="logout()"
>{{ t('view.profile.profile.logout') }}</el-button
>
<el-button
size="small"
icon="el-icon-picture-outline"
style="margin-left: 0; margin-right: 5px; margin-top: 10px"
@click="showGalleryDialog()"
>{{ t('view.profile.profile.manage_gallery_icon') }}</el-button
>
<el-button
size="small"
icon="el-icon-chat-dot-round"
style="margin-left: 0; margin-right: 5px; margin-top: 10px"
@click="showDiscordNamesDialog()"
>{{ t('view.profile.profile.discord_names') }}</el-button
>
<el-button
size="small"
icon="el-icon-printer"
style="margin-left: 0; margin-right: 5px; margin-top: 10px"
@click="showExportFriendsListDialog()"
>{{ t('view.profile.profile.export_friend_list') }}</el-button
>
<el-button
size="small"
icon="el-icon-user"
style="margin-left: 0; margin-right: 5px; margin-top: 10px"
@click="showExportAvatarsListDialog()"
>{{ t('view.profile.profile.export_own_avatars') }}</el-button
>
</div>
</div>
<div class="options-container">
<span class="header">{{ t('view.profile.game_info.header') }}</span>
<div class="x-friend-list" style="margin-top: 10px">
<div class="x-friend-item">
<div class="detail" @click="getVisits">
<span class="name">{{ t('view.profile.game_info.online_users') }}</span>
<span v-if="visits" class="extra">{{
t('view.profile.game_info.user_online', { count: visits })
}}</span>
<span v-else class="extra">{{ t('view.profile.game_info.refresh') }}</span>
</div>
</div>
</div>
</div>
<div class="options-container">
<div class="header-bar">
<span class="header">{{ t('view.profile.vrc_sdk_downloads.header') }}</span>
<el-tooltip placement="top" :content="t('view.profile.refresh_tooltip')" :disabled="hideTooltips">
<el-button
type="default"
size="mini"
icon="el-icon-refresh"
circle
style="margin-left: 5px"
@click="API.getConfig()"></el-button>
</el-tooltip>
</div>
<div class="x-friend-list" style="margin-top: 10px">
<div
v-for="(link, item) in API.cachedConfig.downloadUrls"
:key="item"
class="x-friend-item"
placement="top">
<div class="detail" @click="openExternalLink(link)">
<span class="name" v-text="item"></span>
<span class="extra" v-text="link"></span>
</div>
</div>
</div>
</div>
<div class="options-container">
<span class="header">{{ t('view.profile.direct_access.header') }}</span>
<div style="margin-top: 10px">
<el-button-group>
<el-button size="small" @click="promptUsernameDialog()">{{
t('view.profile.direct_access.username')
}}</el-button>
<el-button size="small" @click="promptUserIdDialog()">{{
t('view.profile.direct_access.user_id')
}}</el-button>
<el-button size="small" @click="promptWorldDialog()">{{
t('view.profile.direct_access.world_instance')
}}</el-button>
<el-button size="small" @click="promptAvatarDialog()">{{
t('view.profile.direct_access.avatar')
}}</el-button>
</el-button-group>
</div>
</div>
<div class="options-container">
<div class="header-bar">
<span class="header">{{ t('view.profile.invite_messages') }}</span>
<el-tooltip placement="top" :content="t('view.profile.refresh_tooltip')" :disabled="hideTooltips">
<el-button
type="default"
size="mini"
icon="el-icon-refresh"
circle
style="margin-left: 5px"
@click="
inviteMessageTable.visible = true;
refreshInviteMessageTable('message');
"></el-button>
</el-tooltip>
<el-tooltip placement="top" :content="t('view.profile.clear_results_tooltip')" :disabled="hideTooltips">
<el-button
type="default"
size="mini"
icon="el-icon-delete"
circle
style="margin-left: 5px"
@click="inviteMessageTable.visible = false"></el-button>
</el-tooltip>
</div>
<data-tables v-if="inviteMessageTable.visible" v-bind="inviteMessageTable" style="margin-top: 10px">
<el-table-column
:label="t('table.profile.invite_messages.slot')"
prop="slot"
sortable="custom"
width="70"></el-table-column>
<el-table-column :label="t('table.profile.invite_messages.message')" prop="message"></el-table-column>
<el-table-column
:label="t('table.profile.invite_messages.cool_down')"
prop="updatedAt"
sortable="custom"
width="110"
align="right">
<template #default="scope">
<countdown-timer :datetime="scope.row.updatedAt" :hours="1"></countdown-timer>
</template>
</el-table-column>
<el-table-column :label="t('table.profile.invite_messages.action')" width="60" align="right">
<template #default="scope">
<el-button
type="text"
icon="el-icon-edit"
size="mini"
@click="showEditInviteMessageDialog('message', scope.row)"></el-button>
</template>
</el-table-column>
</data-tables>
</div>
<div class="options-container">
<div class="header-bar">
<span class="header">{{ t('view.profile.invite_response_messages') }}</span>
<el-tooltip placement="top" :content="t('view.profile.refresh_tooltip')" :disabled="hideTooltips">
<el-button
type="default"
size="mini"
icon="el-icon-refresh"
circle
style="margin-left: 5px"
@click="
inviteResponseMessageTable.visible = true;
refreshInviteMessageTable('response');
"></el-button>
</el-tooltip>
<el-tooltip placement="top" :content="t('view.profile.clear_results_tooltip')" :disabled="hideTooltips">
<el-button
type="default"
size="mini"
icon="el-icon-delete"
circle
style="margin-left: 5px"
@click="inviteResponseMessageTable.visible = false"></el-button>
</el-tooltip>
</div>
<data-tables
v-if="inviteResponseMessageTable.visible"
v-bind="inviteResponseMessageTable"
style="margin-top: 10px">
<el-table-column
:label="t('table.profile.invite_messages.slot')"
prop="slot"
sortable="custom"
width="70"></el-table-column>
<el-table-column :label="t('table.profile.invite_messages.message')" prop="message"></el-table-column>
<el-table-column
:label="t('table.profile.invite_messages.cool_down')"
prop="updatedAt"
sortable="custom"
width="110"
align="right">
<template #default="scope">
<countdown-timer :datetime="scope.row.updatedAt" :hours="1"></countdown-timer>
</template>
</el-table-column>
<el-table-column :label="t('table.profile.invite_messages.action')" width="60" align="right">
<template #default="scope">
<el-button
type="text"
icon="el-icon-edit"
size="mini"
@click="showEditInviteMessageDialog('response', scope.row)"></el-button>
</template>
</el-table-column>
</data-tables>
</div>
<div class="options-container">
<div class="header-bar">
<span class="header">{{ t('view.profile.invite_request_messages') }}</span>
<el-tooltip placement="top" :content="t('view.profile.refresh_tooltip')" :disabled="hideTooltips">
<el-button
type="default"
size="mini"
icon="el-icon-refresh"
circle
style="margin-left: 5px"
@click="
inviteRequestMessageTable.visible = true;
refreshInviteMessageTable('request');
"></el-button>
</el-tooltip>
<el-tooltip placement="top" :content="t('view.profile.clear_results_tooltip')" :disabled="hideTooltips">
<el-button
type="default"
size="mini"
icon="el-icon-delete"
circle
style="margin-left: 5px"
@click="inviteRequestMessageTable.visible = false"></el-button>
</el-tooltip>
</div>
<data-tables
v-if="inviteRequestMessageTable.visible"
v-bind="inviteRequestMessageTable"
style="margin-top: 10px">
<el-table-column
:label="t('table.profile.invite_messages.slot')"
prop="slot"
sortable="custom"
width="70"></el-table-column>
<el-table-column :label="t('table.profile.invite_messages.message')" prop="message"></el-table-column>
<el-table-column
:label="t('table.profile.invite_messages.cool_down')"
prop="updatedAt"
sortable="custom"
width="110"
align="right">
<template #default="scope">
<countdown-timer :datetime="scope.row.updatedAt" :hours="1"></countdown-timer>
</template>
</el-table-column>
<el-table-column :label="t('table.profile.invite_messages.action')" width="60" align="right">
<template #default="scope">
<el-button
type="text"
icon="el-icon-edit"
size="mini"
@click="showEditInviteMessageDialog('request', scope.row)"></el-button>
</template>
</el-table-column>
</data-tables>
</div>
<div class="options-container">
<div class="header-bar">
<span class="header">{{ t('view.profile.invite_request_response_messages') }}</span>
<el-tooltip placement="top" :content="t('view.profile.refresh_tooltip')" :disabled="hideTooltips">
<el-button
type="default"
size="mini"
icon="el-icon-refresh"
circle
style="margin-left: 5px"
@click="
inviteRequestResponseMessageTable.visible = true;
refreshInviteMessageTable('requestResponse');
"></el-button>
</el-tooltip>
<el-tooltip placement="top" :content="t('view.profile.clear_results_tooltip')" :disabled="hideTooltips">
<el-button
type="default"
size="mini"
icon="el-icon-delete"
circle
style="margin-left: 5px"
@click="inviteRequestResponseMessageTable.visible = false"></el-button>
</el-tooltip>
</div>
<data-tables
v-if="inviteRequestResponseMessageTable.visible"
v-bind="inviteRequestResponseMessageTable"
style="margin-top: 10px">
<el-table-column
:label="t('table.profile.invite_messages.slot')"
prop="slot"
sortable="custom"
width="70"></el-table-column>
<el-table-column :label="t('table.profile.invite_messages.message')" prop="message"></el-table-column>
<el-table-column
:label="t('table.profile.invite_messages.cool_down')"
prop="updatedAt"
sortable="custom"
width="110"
align="right">
<template #default="scope">
<countdown-timer :datetime="scope.row.updatedAt" :hours="1"></countdown-timer>
</template>
</el-table-column>
<el-table-column :label="t('table.profile.invite_messages.action')" width="60" align="right">
<template #default="scope">
<el-button
type="text"
icon="el-icon-edit"
size="mini"
@click="showEditInviteMessageDialog('requestResponse', scope.row)"></el-button>
</template>
</el-table-column>
</data-tables>
</div>
<div class="options-container">
<span class="header">{{ t('view.profile.past_display_names') }}</span>
<data-tables v-bind="pastDisplayNameTable" style="margin-top: 10px">
<el-table-column
:label="t('table.profile.previous_display_name.date')"
prop="updated_at"
sortable="custom">
<template #default="scope">
<span>{{ scope.row.updated_at | formatDate('long') }}</span>
</template>
</el-table-column>
<el-table-column
:label="t('table.profile.previous_display_name.name')"
prop="displayName"></el-table-column>
</data-tables>
</div>
<div class="options-container">
<div class="header-bar">
<span class="header">{{ t('view.profile.config_json') }}</span>
<el-tooltip placement="top" :content="t('view.profile.refresh_tooltip')" :disabled="hideTooltips">
<el-button
type="default"
size="mini"
icon="el-icon-refresh"
circle
style="margin-left: 5px"
@click="refreshConfigTreeData()"></el-button>
</el-tooltip>
<el-tooltip placement="top" :content="t('view.profile.clear_results_tooltip')" :disabled="hideTooltips">
<el-button
type="default"
size="mini"
icon="el-icon-delete"
circle
style="margin-left: 5px"
@click="configTreeData = []"></el-button>
</el-tooltip>
</div>
<el-tree v-if="configTreeData.length > 0" :data="configTreeData" style="margin-top: 10px; font-size: 12px">
<template #default="scope">
<span>
<span style="font-weight: bold; margin-right: 5px" v-text="scope.data.key"></span>
<span v-if="!scope.data.children" v-text="scope.data.value"></span>
</span>
</template>
</el-tree>
</div>
<div class="options-container">
<div class="header-bar">
<span class="header">{{ t('view.profile.current_user_json') }}</span>
<el-tooltip placement="top" :content="t('view.profile.refresh_tooltip')" :disabled="hideTooltips">
<el-button
type="default"
size="mini"
icon="el-icon-refresh"
circle
style="margin-left: 5px"
@click="refreshCurrentUserTreeData()"></el-button>
</el-tooltip>
<el-tooltip placement="top" :content="t('view.profile.clear_results_tooltip')" :disabled="hideTooltips">
<el-button
type="default"
size="mini"
icon="el-icon-delete"
circle
style="margin-left: 5px"
@click="currentUserTreeData = []"></el-button>
</el-tooltip>
</div>
<el-tree
v-if="currentUserTreeData.length > 0"
:data="currentUserTreeData"
style="margin-top: 10px; font-size: 12px">
<template #default="scope">
<span>
<span style="font-weight: bold; margin-right: 5px" v-text="scope.data.key"></span>
<span v-if="!scope.data.children" v-text="scope.data.value"></span>
</span>
</template>
</el-tree>
</div>
<div class="options-container">
<div class="header-bar">
<span class="header">{{ t('view.profile.feedback') }}</span>
<el-tooltip placement="top" :content="t('view.profile.refresh_tooltip')" :disabled="hideTooltips">
<el-button
type="default"
size="mini"
icon="el-icon-refresh"
circle
style="margin-left: 5px"
@click="getCurrentUserFeedback()"></el-button>
</el-tooltip>
<el-tooltip placement="top" :content="t('view.profile.clear_results_tooltip')" :disabled="hideTooltips">
<el-button
type="default"
size="mini"
icon="el-icon-delete"
circle
style="margin-left: 5px"
@click="currentUserFeedbackData = []"></el-button>
</el-tooltip>
</div>
<el-tree
v-if="currentUserFeedbackData.length > 0"
:data="currentUserFeedbackData"
style="margin-top: 10px; font-size: 12px">
<template #default="scope">
<span>
<span style="font-weight: bold; margin-right: 5px" v-text="scope.data.key"></span>
<span v-if="!scope.data.children" v-text="scope.data.value"></span>
</span>
</template>
</el-tree>
</div>
<DiscordNamesDialog :discord-names-dialog-visible.sync="discordNamesDialogVisible" :friends="friends" />
<ExportFriendsListDialog
:is-export-friends-list-dialog-visible.sync="isExportFriendsListDialogVisible"
:friends="friends" />
<ExportAvatarsListDialog :is-export-avatars-list-dialog-visible.sync="isExportAvatarsListDialogVisible" />
</div>
</template>
<script>
export default {
name: 'ProfileTab'
};
</script>
<script setup>
import { inject, ref, getCurrentInstance } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import { inviteMessagesRequest, miscRequest, userRequest } from '../../api';
import utils from '../../classes/utils';
import { parseAvatarUrl } from '../../composables/avatar/utils';
import DiscordNamesDialog from './dialogs/DiscordNamesDialog.vue';
import ExportFriendsListDialog from './dialogs/ExportFriendsListDialog.vue';
import ExportAvatarsListDialog from './dialogs/ExportAvatarsListDialog.vue';
const { t } = useI18n();
const { $prompt, $message } = getCurrentInstance().proxy;
const API = inject('API');
const userImage = inject('userImage');
const showUserDialog = inject('showUserDialog');
const showAvatarDialog = inject('showAvatarDialog');
const showGalleryDialog = inject('showGalleryDialog');
const openExternalLink = inject('openExternalLink');
const props = defineProps({
menuActiveIndex: {
type: String,
default: 'profile'
},
hideTooltips: {
type: Boolean,
default: false
},
inviteMessageTable: {
type: Object,
default: () => ({
visible: false,
data: []
})
},
inviteResponseMessageTable: {
type: Object,
default: () => ({
visible: false,
data: []
})
},
inviteRequestMessageTable: {
type: Object,
default: () => ({
visible: false,
data: []
})
},
inviteRequestResponseMessageTable: {
type: Object,
default: () => ({
visible: false,
data: []
})
},
pastDisplayNameTable: {
type: Object,
default: () => ({
visible: false,
data: []
})
},
friends: {
type: Map,
default: () => new Map()
},
directAccessWorld: {
type: Function,
default: () => {}
}
});
const emit = defineEmits(['logout', 'lookupUser', 'showEditInviteMessageDialog']);
const vrchatCredit = ref(null);
const configTreeData = ref([]);
const currentUserTreeData = ref([]);
const currentUserFeedbackData = ref([]);
const discordNamesDialogVisible = ref(false);
const isExportFriendsListDialogVisible = ref(false);
const isExportAvatarsListDialogVisible = ref(false);
const visits = ref(0);
function getVisits() {
miscRequest.getVisits().then((args) => {
// API.$on('VISITS')
visits.value = args.json;
});
}
function getVRChatCredits() {
// API.$on('VRCCREDITS')
miscRequest.getVRChatCredits().then((args) => (vrchatCredit.value = args.json?.balance));
}
function logout() {
emit('logout');
}
function showDiscordNamesDialog() {
discordNamesDialogVisible.value = true;
}
function showExportFriendsListDialog() {
isExportFriendsListDialogVisible.value = true;
}
function showExportAvatarsListDialog() {
isExportAvatarsListDialogVisible.value = true;
}
function promptUsernameDialog() {
$prompt(t('prompt.direct_access_username.description'), t('prompt.direct_access_username.header'), {
distinguishCancelAndClose: true,
confirmButtonText: t('prompt.direct_access_username.ok'),
cancelButtonText: t('prompt.direct_access_username.cancel'),
inputPattern: /\S+/,
inputErrorMessage: t('prompt.direct_access_username.input_error'),
callback: (action, instance) => {
if (action === 'confirm' && instance.inputValue) {
emit('lookupUser', {
displayName: instance.inputValue
});
}
}
});
}
function promptUserIdDialog() {
$prompt(t('prompt.direct_access_user_id.description'), t('prompt.direct_access_user_id.header'), {
distinguishCancelAndClose: true,
confirmButtonText: t('prompt.direct_access_user_id.ok'),
cancelButtonText: t('prompt.direct_access_user_id.cancel'),
inputPattern: /\S+/,
inputErrorMessage: t('prompt.direct_access_user_id.input_error'),
callback: (action, instance) => {
if (action === 'confirm' && instance.inputValue) {
const testUrl = instance.inputValue.substring(0, 15);
if (testUrl === 'https://vrchat.') {
const userId = this.parseUserUrl(instance.inputValue);
if (userId) {
showUserDialog(userId);
} else {
$message({
message: t('prompt.direct_access_user_id.message.error'),
type: 'error'
});
}
} else {
showUserDialog(instance.inputValue);
}
}
}
});
}
function promptWorldDialog() {
$prompt(t('prompt.direct_access_world_id.description'), t('prompt.direct_access_world_id.header'), {
distinguishCancelAndClose: true,
confirmButtonText: t('prompt.direct_access_world_id.ok'),
cancelButtonText: t('prompt.direct_access_world_id.cancel'),
inputPattern: /\S+/,
inputErrorMessage: t('prompt.direct_access_world_id.input_error'),
callback: (action, instance) => {
if (action === 'confirm' && instance.inputValue) {
if (!props.directAccessWorld(instance.inputValue)) {
$message({
message: t('prompt.direct_access_world_id.message.error'),
type: 'error'
});
}
}
}
});
}
function promptAvatarDialog() {
$prompt(t('prompt.direct_access_avatar_id.description'), t('prompt.direct_access_avatar_id.header'), {
distinguishCancelAndClose: true,
confirmButtonText: t('prompt.direct_access_avatar_id.ok'),
cancelButtonText: t('prompt.direct_access_avatar_id.cancel'),
inputPattern: /\S+/,
inputErrorMessage: t('prompt.direct_access_avatar_id.input_error'),
callback: (action, instance) => {
if (action === 'confirm' && instance.inputValue) {
const testUrl = instance.inputValue.substring(0, 15);
if (testUrl === 'https://vrchat.') {
const avatarId = parseAvatarUrl(instance.inputValue);
if (avatarId) {
showAvatarDialog(avatarId);
} else {
$message({
message: t('prompt.direct_access_avatar_id.message.error'),
type: 'error'
});
}
} else {
showAvatarDialog(instance.inputValue);
}
}
}
});
}
function showEditInviteMessageDialog(messageType, inviteMessage) {
emit('showEditInviteMessageDialog', {
messageType,
inviteMessage
});
}
function refreshInviteMessageTable(messageType) {
inviteMessagesRequest.refreshInviteMessageTableData(messageType);
}
async function refreshConfigTreeData() {
await API.getConfig();
configTreeData.value = utils.buildTreeData(API.cachedConfig);
}
async function refreshCurrentUserTreeData() {
await API.getCurrentUser();
currentUserTreeData.value = utils.buildTreeData(API.currentUser);
}
function getCurrentUserFeedback() {
userRequest.getUserFeedback({ userId: API.currentUser.id }).then((args) => {
// API.$on('USER:FEEDBACK')
if (args.params.userId === API.currentUser.id) {
currentUserFeedbackData.value = utils.buildTreeData(args.json);
}
});
}
</script>

View File

@@ -1,13 +1,10 @@
<template>
<el-dialog
<safe-dialog
class="x-dialog"
:before-close="beforeDialogClose"
:visible="discordNamesDialogVisible"
:title="t('dialog.discord_names.header')"
width="650px"
@close="closeDialog"
@mousedown.native="dialogMouseDown"
@mouseup.native="dialogMouseUp">
@close="closeDialog">
<div style="font-size: 12px">
{{ t('dialog.discord_names.description') }}
</div>
@@ -19,7 +16,7 @@
resize="none"
readonly
style="margin-top: 15px" />
</el-dialog>
</safe-dialog>
</template>
<script setup>
@@ -27,9 +24,6 @@
import { useI18n } from 'vue-i18n-bridge';
const API = inject('API');
const beforeDialogClose = inject('beforeDialogClose');
const dialogMouseDown = inject('dialogMouseDown');
const dialogMouseUp = inject('dialogMouseUp');
const { t } = useI18n();

View File

@@ -1,13 +1,10 @@
<template>
<el-dialog
<safe-dialog
class="x-dialog"
:before-close="beforeDialogClose"
:visible="editInviteMessageDialog.visible"
:title="t('dialog.edit_invite_message.header')"
width="400px"
@close="closeDialog"
@mousedown.native="dialogMouseDown"
@mouseup.native="dialogMouseUp">
@close="closeDialog">
<div style="font-size: 12px">
<span>{{ t('dialog.edit_invite_message.description') }}</span>
<el-input
@@ -23,10 +20,10 @@
<template #footer>
<el-button type="small" @click="closeDialog">{{ $t('dialog.edit_invite_message.cancel') }}</el-button>
<el-button type="primary" size="small" @click="saveEditInviteMessage">{{
$t('dialog.edit_invite_message.save')
t('dialog.edit_invite_message.save')
}}</el-button>
</template>
</el-dialog>
</safe-dialog>
</template>
<script setup>
@@ -38,9 +35,6 @@
const instance = getCurrentInstance();
const $message = instance.proxy.$message;
const API = inject('API');
const beforeDialogClose = inject('beforeDialogClose');
const dialogMouseDown = inject('dialogMouseDown');
const dialogMouseUp = inject('dialogMouseUp');
const props = defineProps({
editInviteMessageDialog: {

View File

@@ -1,11 +1,5 @@
<template>
<el-dialog
:before-close="beforeDialogClose"
:visible.sync="isVisible"
:title="$t('dialog.export_own_avatars.header')"
width="650px"
@mousedown.native="dialogMouseDown"
@mouseup.native="dialogMouseUp">
<safe-dialog :visible.sync="isVisible" :title="$t('dialog.export_own_avatars.header')" width="650px">
<el-input
v-model="exportAvatarsListCsv"
v-loading="loading"
@@ -16,7 +10,7 @@
readonly
style="margin-top: 15px"
@click.native="$event.target.tagName === 'TEXTAREA' && $event.target.select()" />
</el-dialog>
</safe-dialog>
</template>
<script>
@@ -24,7 +18,7 @@
export default {
name: 'ExportAvatarsListDialog',
inject: ['API', 'beforeDialogClose', 'dialogMouseDown', 'dialogMouseUp'],
inject: ['API'],
props: {
isExportAvatarsListDialogVisible: Boolean
},

View File

@@ -1,11 +1,5 @@
<template>
<el-dialog
:before-close="beforeDialogClose"
:title="$t('dialog.export_friends_list.header')"
:visible.sync="isVisible"
width="650px"
@mousedown.native="dialogMouseDown"
@mouseup.native="dialogMouseUp">
<safe-dialog :title="$t('dialog.export_friends_list.header')" :visible.sync="isVisible" width="650px">
<el-tabs type="card">
<el-tab-pane :label="$t('dialog.export_friends_list.csv')">
<el-input
@@ -30,13 +24,13 @@
@click.native="$event.target.tagName === 'TEXTAREA' && $event.target.select()" />
</el-tab-pane>
</el-tabs>
</el-dialog>
</safe-dialog>
</template>
<script>
export default {
name: 'ExportFriendsListDialog',
inject: ['API', 'beforeDialogClose', 'dialogMouseDown', 'dialogMouseUp'],
inject: ['API'],
props: {
friends: Map,
isExportFriendsListDialogVisible: Boolean

721
src/views/Search/Search.vue Normal file
View File

@@ -0,0 +1,721 @@
<template>
<div v-if="menuActiveIndex === 'search'" class="x-container">
<div style="margin: 0 0 10px; display: flex; align-items: center">
<el-input
:value="searchText"
:placeholder="t('view.search.search_placeholder')"
style="flex: 1"
@input="updateSearchText"
@keyup.native.13="search"></el-input>
<el-tooltip placement="bottom" :content="t('view.search.clear_results_tooltip')" :disabled="hideTooltips">
<el-button
type="default"
icon="el-icon-delete"
circle
style="flex: none; margin-left: 10px"
@click="clearSearch"></el-button>
</el-tooltip>
</div>
<el-tabs ref="searchTabRef" type="card" style="margin-top: 15px" @tab-click="searchText = ''">
<el-tab-pane v-loading="isSearchUserLoading" :label="t('view.search.user.header')" style="min-height: 60px">
<el-checkbox v-model="searchUserByBio" style="margin-left: 10px">{{
t('view.search.user.search_by_bio')
}}</el-checkbox>
<el-checkbox v-model="searchUserSortByLastLoggedIn" style="margin-left: 10px">{{
t('view.search.user.sort_by_last_logged_in')
}}</el-checkbox>
<div class="x-friend-list" style="min-height: 500px">
<div
v-for="user in searchUserResults"
:key="user.id"
class="x-friend-item"
@click="showUserDialog(user.id)">
<template>
<div class="avatar">
<img v-lazy="userImage(user, true)" />
</div>
<div class="detail">
<span class="name" v-text="user.displayName"></span>
<span
v-if="randomUserColours"
class="extra"
:class="user.$trustClass"
v-text="user.$trustLevel"></span>
<span
v-else
class="extra"
:style="{ color: user.$userColour }"
v-text="user.$trustLevel"></span>
</div>
</template>
</div>
</div>
<el-button-group v-if="searchUserResults.length" style="margin-top: 15px">
<el-button
:disabled="!searchUserParams.offset"
icon="el-icon-back"
size="small"
@click="moreSearchUser(-1)"
>{{ t('view.search.prev_page') }}</el-button
>
<el-button
:disabled="searchUserResults.length < 10"
icon="el-icon-right"
size="small"
@click="moreSearchUser(1)"
>{{ t('view.search.next_page') }}</el-button
>
</el-button-group>
</el-tab-pane>
<el-tab-pane
v-loading="isSearchWorldLoading"
:label="t('view.search.world.header')"
style="min-height: 60px">
<el-dropdown
size="small"
trigger="click"
style="margin-bottom: 15px"
@command="(row) => searchWorld(row)">
<el-button size="small"
>{{ t('view.search.world.category') }} <i class="el-icon-arrow-down el-icon--right"></i
></el-button>
<el-dropdown-menu v-slot="dropdown">
<el-dropdown-item
v-for="row in API.cachedConfig.dynamicWorldRows"
:key="row.index"
:command="row"
v-text="row.name"></el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
<el-checkbox v-model="searchWorldLabs" style="margin-left: 10px">{{
t('view.search.world.community_lab')
}}</el-checkbox>
<div class="x-friend-list" style="min-height: 500px">
<div
v-for="world in searchWorldResults"
:key="world.id"
class="x-friend-item"
@click="showWorldDialog(world.id)">
<template>
<div class="avatar">
<img v-lazy="world.thumbnailImageUrl" />
</div>
<div class="detail">
<span class="name" v-text="world.name"></span>
<span v-if="world.occupants" class="extra"
>{{ world.authorName }} ({{ world.occupants }})</span
>
<span v-else class="extra" v-text="world.authorName"></span>
</div>
</template>
</div>
</div>
<el-button-group v-if="searchWorldResults.length" style="margin-top: 15px">
<el-button
:disabled="!searchWorldParams.offset"
icon="el-icon-back"
size="small"
@click="moreSearchWorld(-1)"
>{{ t('view.search.prev_page') }}</el-button
>
<el-button
:disabled="searchWorldResults.length < 10"
icon="el-icon-right"
size="small"
@click="moreSearchWorld(1)"
>{{ t('view.search.next_page') }}</el-button
>
</el-button-group>
</el-tab-pane>
<el-tab-pane
v-loading="isSearchAvatarLoading"
:label="t('view.search.avatar.header')"
style="min-height: 60px">
<div style="display: flex; align-items: center; justify-content: space-between">
<div style="display: flex; align-items: center">
<el-dropdown
v-if="avatarRemoteDatabaseProviderList.length > 1"
trigger="click"
size="mini"
style="margin-right: 5px"
@click.native.stop>
<el-button size="small"
>{{ t('view.search.avatar.search_provider') }}
<i class="el-icon-arrow-down el-icon--right"></i
></el-button>
<el-dropdown-menu v-slot="dropdown">
<el-dropdown-item
v-for="provider in avatarRemoteDatabaseProviderList"
:key="provider"
@click.native="setAvatarProvider(provider)">
<i
v-if="provider === avatarRemoteDatabaseProvider"
class="el-icon-check el-icon--left"></i>
{{ provider }}
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
<el-tooltip
placement="bottom"
:content="t('view.search.avatar.refresh_tooltip')"
:disabled="hideTooltips">
<el-button
type="default"
:loading="userDialog.isAvatarsLoading"
size="mini"
icon="el-icon-refresh"
circle
@click="refreshUserDialogAvatars"></el-button>
</el-tooltip>
<span style="font-size: 14px; margin-left: 5px; margin-right: 5px">{{
t('view.search.avatar.result_count', {
count: searchAvatarResults.length
})
}}</span>
</div>
<div style="display: flex; align-items: center">
<el-radio-group
v-model="searchAvatarFilter"
size="mini"
style="margin: 5px; display: block"
@change="searchAvatar">
<el-radio label="all">{{ t('view.search.avatar.all') }}</el-radio>
<el-radio label="public">{{ t('view.search.avatar.public') }}</el-radio>
<el-radio label="private">{{ t('view.search.avatar.private') }}</el-radio>
</el-radio-group>
<el-divider direction="vertical"></el-divider>
<el-radio-group
v-model="searchAvatarFilterRemote"
size="mini"
style="margin: 5px; display: block"
@change="searchAvatar">
<el-radio label="all">{{ t('view.search.avatar.all') }}</el-radio>
<el-radio label="local">{{ t('view.search.avatar.local') }}</el-radio>
<el-radio label="remote" :disabled="!avatarRemoteDatabase">{{
t('view.search.avatar.remote')
}}</el-radio>
</el-radio-group>
</div>
</div>
<div style="display: flex; justify-content: end">
<el-radio-group
v-model="searchAvatarSort"
:disabled="searchAvatarFilterRemote !== 'local'"
size="mini"
style="margin: 5px; display: block"
@change="searchAvatar">
<el-radio label="name">{{ t('view.search.avatar.sort_name') }}</el-radio>
<el-radio label="update">{{ t('view.search.avatar.sort_update') }}</el-radio>
<el-radio label="created">{{ t('view.search.avatar.sort_created') }}</el-radio>
</el-radio-group>
</div>
<div class="x-friend-list" style="margin-top: 20px; min-height: 500px">
<div
v-for="avatar in searchAvatarPage"
:key="avatar.id"
class="x-friend-item"
@click="showAvatarDialog(avatar.id)">
<template>
<div class="avatar">
<img v-if="avatar.thumbnailImageUrl" v-lazy="avatar.thumbnailImageUrl" />
<img v-else-if="avatar.imageUrl" v-lazy="avatar.imageUrl" />
</div>
<div class="detail">
<span class="name" v-text="avatar.name"></span>
<span
v-if="avatar.releaseStatus === 'public'"
class="extra"
style="color: #67c23a"
v-text="avatar.releaseStatus"></span>
<span
v-else-if="avatar.releaseStatus === 'private'"
class="extra"
style="color: #f56c6c"
v-text="avatar.releaseStatus"></span>
<span v-else class="extra" v-text="avatar.releaseStatus"></span>
<span class="extra" v-text="avatar.authorName"></span>
</div>
</template>
</div>
</div>
<el-button-group v-if="searchAvatarPage.length" style="margin-top: 15px">
<el-button
:disabled="!searchAvatarPageNum"
icon="el-icon-back"
size="small"
@click="moreSearchAvatar(-1)"
>{{ t('view.search.prev_page') }}</el-button
>
<el-button
:disabled="
searchAvatarResults.length < 10 ||
(searchAvatarPageNum + 1) * 10 >= searchAvatarResults.length
"
icon="el-icon-right"
size="small"
@click="moreSearchAvatar(1)"
>{{ t('view.search.next_page') }}</el-button
>
</el-button-group>
</el-tab-pane>
<el-tab-pane
v-loading="isSearchGroupLoading"
:label="t('view.search.group.header')"
style="min-height: 60px">
<div class="x-friend-list" style="min-height: 500px">
<div
v-for="group in searchGroupResults"
:key="group.id"
class="x-friend-item"
@click="showGroupDialog(group.id)">
<template>
<div class="avatar">
<img v-lazy="getSmallThumbnailUrl(group.iconUrl)" />
</div>
<div class="detail">
<span class="name">
<span v-text="group.name"></span>
<span style="margin-left: 5px; font-weight: normal">({{ group.memberCount }})</span>
<span
style="
margin-left: 5px;
color: #909399;
font-weight: normal;
font-family: monospace;
font-size: 12px;
"
>{{ group.shortCode }}.{{ group.discriminator }}</span
>
</span>
<span class="extra" v-text="group.description"></span>
</div>
</template>
</div>
</div>
<el-button-group v-if="searchGroupResults.length" style="margin-top: 15px">
<el-button
:disabled="!searchGroupParams.offset"
icon="el-icon-back"
size="small"
@click="moreSearchGroup(-1)"
>{{ t('view.search.prev_page') }}</el-button
>
<el-button
:disabled="searchGroupResults.length < 10"
icon="el-icon-right"
size="small"
@click="moreSearchGroup(1)"
>{{ t('view.search.next_page') }}</el-button
>
</el-button-group>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script>
export default {
name: 'SearchTab'
};
</script>
<script setup>
import { inject, ref } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import { groupRequest, worldRequest } from '../../api';
import utils from '../../classes/utils';
import { convertFileUrlToImageUrl } from '../../composables/shared/utils';
const { t } = useI18n();
const API = inject('API');
const showUserDialog = inject('showUserDialog');
const userImage = inject('userImage');
const showWorldDialog = inject('showWorldDialog');
const showAvatarDialog = inject('showAvatarDialog');
const showGroupDialog = inject('showGroupDialog');
const props = defineProps({
menuActiveIndex: {
type: String,
default: ''
},
searchText: {
type: String,
default: ''
},
searchUserResults: {
type: Array,
default: () => []
},
randomUserColours: {
type: Boolean,
default: false
},
avatarRemoteDatabaseProviderList: {
type: Array,
default: () => []
},
avatarRemoteDatabaseProvider: {
type: String,
default: ''
},
hideTooltips: {
type: Boolean,
default: false
},
userDialog: {
type: Object,
default: () => ({})
},
lookupAvatars: {
type: Function,
default: () => () => {}
},
avatarRemoteDatabase: {
type: Boolean,
default: false
}
});
const emit = defineEmits([
'clearSearch',
'setAvatarProvider',
'refreshUserDialogAvatars',
'moreSearchUser',
'update:searchText'
]);
const searchTabRef = ref(null);
const searchUserParams = ref({});
const searchUserByBio = ref(false);
const searchUserSortByLastLoggedIn = ref(false);
const isSearchUserLoading = ref(false);
const isSearchWorldLoading = ref(false);
const isSearchAvatarLoading = ref(false);
const isSearchGroupLoading = ref(false);
const searchWorldOption = ref('');
const searchWorldLabs = ref(false);
const searchWorldParams = ref({});
const searchWorldResults = ref([]);
const searchAvatarFilter = ref('');
const searchAvatarSort = ref('');
const searchAvatarFilterRemote = ref('');
const searchAvatarPageNum = ref(0);
const searchAvatarResults = ref([]);
const searchAvatarPage = ref([]);
const searchGroupParams = ref({});
const searchGroupResults = ref([]);
function getSmallThumbnailUrl(url) {
convertFileUrlToImageUrl(url);
}
function clearSearch() {
searchUserParams.value = {};
searchWorldParams.value = {};
searchWorldResults.value = [];
searchAvatarResults.value = [];
searchAvatarPage.value = [];
searchAvatarPageNum.value = 0;
searchGroupParams.value = {};
searchGroupResults.value = [];
emit('clearSearch');
}
function updateSearchText(text) {
emit('update:searchText', text);
}
function search() {
switch (searchTabRef.value.currentName) {
case '0':
searchUser();
break;
case '1':
searchWorld({});
break;
case '2':
searchAvatar();
break;
case '3':
searchGroup();
break;
}
}
async function searchUser() {
searchUserParams.value = {
n: 10,
offset: 0,
search: props.searchText,
customFields: searchUserByBio.value ? 'bio' : 'displayName',
sort: searchUserSortByLastLoggedIn.value ? 'last_login' : 'relevance'
};
await moreSearchUser();
}
async function moreSearchUser(go = null) {
emit('moreSearchUser', go, searchUserParams.value);
}
function searchWorld(ref) {
searchWorldOption.value = '';
const 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':
searchWorldOption.value = 'active';
break;
case 'recent':
searchWorldOption.value = 'recent';
break;
case 'favorite':
searchWorldOption.value = 'favorites';
break;
case 'labs':
params.sort = 'labsPublicationDate';
break;
case 'heat':
params.sort = 'heat';
params.featured = 'false';
break;
default:
params.sort = 'relevance';
params.search = utils.replaceBioSymbols(props.searchText);
break;
}
params.order = ref.sortOrder || 'descending';
if (ref.sortOwnership === 'mine') {
params.user = 'me';
params.releaseStatus = 'all';
}
if (ref.tag) {
params.tag = ref.tag;
}
if (!searchWorldLabs.value) {
if (params.tag) {
params.tag += ',system_approved';
} else {
params.tag = 'system_approved';
}
}
// TODO: option.platform
searchWorldParams.value = params;
moreSearchWorld();
}
function moreSearchWorld(go) {
const params = searchWorldParams.value;
if (go) {
params.offset += params.n * go;
if (params.offset < 0) {
params.offset = 0;
}
}
isSearchWorldLoading.value = true;
worldRequest
.getWorlds(params, searchWorldOption.value)
.finally(() => {
isSearchWorldLoading.value = false;
})
.then((args) => {
const map = new Map();
for (const json of args.json) {
const ref = API.cachedWorlds.get(json.id);
if (typeof ref !== 'undefined') {
map.set(ref.id, ref);
}
}
searchWorldResults.value = Array.from(map.values());
return args;
});
}
function setAvatarProvider(provider) {
emit('setAvatarProvider', provider);
}
function refreshUserDialogAvatars(fileId) {
emit('refreshUserDialogAvatars', fileId);
}
async function searchAvatar() {
let ref;
isSearchAvatarLoading.value = true;
if (!searchAvatarFilter.value) {
searchAvatarFilter.value = 'all';
}
if (!searchAvatarSort.value) {
searchAvatarSort.value = 'name';
}
if (!searchAvatarFilterRemote.value) {
searchAvatarFilterRemote.value = 'all';
}
if (searchAvatarFilterRemote.value !== 'local') {
searchAvatarSort.value = 'name';
}
const avatars = new Map();
const query = props.searchText.toUpperCase();
if (!query) {
for (ref of API.cachedAvatars.values()) {
switch (searchAvatarFilter.value) {
case 'all':
avatars.set(ref.id, ref);
break;
case 'public':
if (ref.releaseStatus === 'public') {
avatars.set(ref.id, ref);
}
break;
case 'private':
if (ref.releaseStatus === 'private') {
avatars.set(ref.id, ref);
}
break;
}
}
isSearchAvatarLoading.value = false;
} else {
if (searchAvatarFilterRemote.value === 'all' || searchAvatarFilterRemote.value === 'local') {
for (ref of API.cachedAvatars.values()) {
let match = ref.name.toUpperCase().includes(query);
if (!match && ref.description) {
match = ref.description.toUpperCase().includes(query);
}
if (!match && ref.authorName) {
match = ref.authorName.toUpperCase().includes(query);
}
if (match) {
switch (searchAvatarFilter.value) {
case 'all':
avatars.set(ref.id, ref);
break;
case 'public':
if (ref.releaseStatus === 'public') {
avatars.set(ref.id, ref);
}
break;
case 'private':
if (ref.releaseStatus === 'private') {
avatars.set(ref.id, ref);
}
break;
}
}
}
}
if (
(searchAvatarFilterRemote.value === 'all' || searchAvatarFilterRemote.value === 'remote') &&
props.avatarRemoteDatabase &&
query.length >= 3
) {
const data = await props.lookupAvatars('search', query);
if (data && typeof data === 'object') {
data.forEach((avatar) => {
avatars.set(avatar.id, avatar);
});
}
}
isSearchAvatarLoading.value = false;
}
const avatarsArray = Array.from(avatars.values());
if (searchAvatarFilterRemote.value === 'local') {
switch (searchAvatarSort.value) {
case 'updated':
avatarsArray.sort(utils.compareByUpdatedAt);
break;
case 'created':
avatarsArray.sort(utils.compareByCreatedAt);
break;
case 'name':
avatarsArray.sort(utils.compareByName);
break;
}
}
searchAvatarPageNum.value = 0;
searchAvatarResults.value = avatarsArray;
searchAvatarPage.value = avatarsArray.slice(0, 10);
}
function moreSearchAvatar(n) {
let offset;
if (n === -1) {
searchAvatarPageNum.value--;
offset = searchAvatarPageNum.value * 10;
}
if (n === 1) {
searchAvatarPageNum.value++;
offset = searchAvatarPageNum.value * 10;
}
searchAvatarPage.value = searchAvatarResults.value.slice(offset, offset + 10);
}
async function searchGroup() {
searchGroupParams.value = {
n: 10,
offset: 0,
query: utils.replaceBioSymbols(props.searchText)
};
await moreSearchGroup();
}
async function moreSearchGroup(go) {
const params = searchGroupParams.value;
if (go) {
params.offset += params.n * go;
if (params.offset < 0) {
params.offset = 0;
}
}
isSearchGroupLoading.value = true;
await groupRequest
.groupSearch(params)
.finally(() => {
isSearchGroupLoading.value = false;
})
.then((args) => {
// API.$on('GROUP:SEARCH', function (args) {
for (const json of args.json) {
API.$emit('GROUP', {
json,
params: {
groupId: json.id
}
});
}
// });
const map = new Map();
for (const json of args.json) {
const ref = API.cachedGroups.get(json.id);
if (typeof ref !== 'undefined') {
map.set(ref.id, ref);
}
}
searchGroupResults.value = Array.from(map.values());
return args;
});
}
</script>

View File

@@ -1,13 +1,10 @@
<template>
<el-dialog
<safe-dialog
class="x-dialog"
:visible="isAvatarProviderDialogVisible"
:title="t('dialog.avatar_database_provider.header')"
width="600px"
:before-close="beforeDialogClose"
@close="closeDialog"
@mousedown.native="dialogMouseDown"
@mouseup.native="dialogMouseUp">
@close="closeDialog">
<div>
<el-input
v-for="(provider, index) in avatarRemoteDatabaseProviderList"
@@ -24,18 +21,13 @@
{{ t('dialog.avatar_database_provider.add_provider') }}
</el-button>
</div>
</el-dialog>
</safe-dialog>
</template>
<script setup>
import { inject } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
const { t } = useI18n();
const beforeDialogClose = inject('beforeDialogClose');
const dialogMouseDown = inject('dialogMouseDown');
const dialogMouseUp = inject('dialogMouseUp');
defineProps({
avatarRemoteDatabaseProviderList: {
type: Array,

View File

@@ -1,13 +1,10 @@
<template>
<el-dialog
<safe-dialog
class="x-dialog"
:before-close="beforeDialogClose"
:visible="changeLogDialog.visible"
:title="t('dialog.change_log.header')"
width="800px"
top="5vh"
@mousedown.native="dialogMouseDown"
@mouseup.native="dialogMouseUp"
@close="closeDialog">
<div v-if="changeLogDialog.visible" class="changelog-dialog">
<h2 v-text="changeLogDialog.buildName"></h2>
@@ -32,7 +29,7 @@
{{ t('dialog.change_log.close') }}
</el-button>
</template>
</el-dialog>
</safe-dialog>
</template>
<script setup>
@@ -41,9 +38,6 @@
const { t } = useI18n();
const openExternalLink = inject('openExternalLink');
const beforeDialogClose = inject('beforeDialogClose');
const dialogMouseDown = inject('dialogMouseDown');
const dialogMouseUp = inject('dialogMouseUp');
const props = defineProps({
changeLogDialog: {

View File

@@ -1,13 +1,10 @@
<template>
<el-dialog
:before-close="beforeDialogClose"
<safe-dialog
:visible="!!feedFiltersDialogMode"
:title="dialogTitle"
width="550px"
top="5vh"
destroy-on-close
@mousedown.native="dialogMouseDown"
@mouseup.native="dialogMouseUp"
@close="handleDialogClose">
<div class="toggle-list" style="height: 75vh; overflow-y: auto">
<div v-for="setting in currentOptions" :key="setting.key" class="toggle-item">
@@ -58,18 +55,14 @@
t('dialog.shared_feed_filters.close')
}}</el-button>
</template>
</el-dialog>
</safe-dialog>
</template>
<script setup>
import { computed, inject } from 'vue';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import configRepository from '../../../service/config';
import { feedFiltersOptions } from '../../../composables/settings/constants/feedFiltersOptions';
const beforeDialogClose = inject('beforeDialogClose');
const dialogMouseDown = inject('dialogMouseDown');
const dialogMouseUp = inject('dialogMouseUp');
import { feedFiltersOptions } from '../../../composables/setting/constants/feedFiltersOptions';
const { t } = useI18n();

View File

@@ -1,12 +1,9 @@
<template>
<el-dialog
<safe-dialog
class="x-dialog"
:before-close="beforeDialogClose"
:visible="isLaunchOptionsDialogVisible"
:title="t('dialog.launch_options.header')"
width="600px"
@mousedown.native="dialogMouseDown"
@mouseup.native="dialogMouseUp"
@close="closeDialog">
<div style="font-size: 12px">
{{ t('dialog.launch_options.description') }} <br />
@@ -53,7 +50,7 @@
</el-button>
</div>
</template>
</el-dialog>
</safe-dialog>
</template>
<script setup>
@@ -61,9 +58,6 @@
import { useI18n } from 'vue-i18n-bridge';
import configRepository from '../../../service/config';
const beforeDialogClose = inject('beforeDialogClose');
const dialogMouseDown = inject('dialogMouseDown');
const dialogMouseUp = inject('dialogMouseUp');
const openExternalLink = inject('openExternalLink');
const { t } = useI18n();

View File

@@ -1,13 +1,10 @@
<template>
<el-dialog
<safe-dialog
class="x-dialog"
:before-close="beforeDialogClose"
:visible="isNoteExportDialogVisible"
:title="t('dialog.note_export.header')"
width="1000px"
@close="closeDialog"
@mousedown.native="dialogMouseDown"
@mouseup.native="dialogMouseUp">
@close="closeDialog">
<div style="font-size: 12px">
{{ t('dialog.note_export.description1') }} <br />
{{ t('dialog.note_export.description2') }} <br />
@@ -87,7 +84,7 @@
</template>
</el-table-column>
</data-tables>
</el-dialog>
</safe-dialog>
</template>
<script setup>
@@ -99,9 +96,6 @@
const { t } = useI18n();
const beforeDialogClose = inject('beforeDialogClose');
const dialogMouseDown = inject('dialogMouseDown');
const dialogMouseUp = inject('dialogMouseUp');
const userImage = inject('userImage');
const userImageFull = inject('userImageFull');
const showUserDialog = inject('showUserDialog');

View File

@@ -1,13 +1,10 @@
<template>
<el-dialog
<safe-dialog
class="x-dialog"
:visible="isNotificationPositionDialogVisible"
:title="t('dialog.notification_position.header')"
width="400px"
:before-close="beforeDialogClose"
@close="closeDialog"
@mousedown.native="dialogMouseDown"
@mouseup.native="dialogMouseUp">
@close="closeDialog">
<div style="font-size: 12px">
{{ t('dialog.notification_position.description') }}
</div>
@@ -45,18 +42,13 @@
</el-button>
</div>
</template>
</el-dialog>
</safe-dialog>
</template>
<script setup>
import { inject } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
const { t } = useI18n();
const beforeDialogClose = inject('beforeDialogClose');
const dialogMouseDown = inject('dialogMouseDown');
const dialogMouseUp = inject('dialogMouseUp');
defineProps({
isNotificationPositionDialogVisible: {
type: Boolean,

View File

@@ -1,13 +1,10 @@
<template>
<el-dialog
<safe-dialog
class="x-dialog"
:before-close="beforeDialogClose"
:visible="ossDialog"
:title="t('dialog.open_source.header')"
width="650px"
@close="closeDialog"
@mousedown.native="dialogMouseDown"
@mouseup.native="dialogMouseUp">
@close="closeDialog">
<div v-once style="height: 350px; overflow: hidden scroll; word-break: break-all">
<div>
<span>{{ t('dialog.open_source.description') }}</span>
@@ -18,17 +15,12 @@
<pre style="font-size: 12px; white-space: pre-line">{{ lib.licenseText }}</pre>
</div>
</div>
</el-dialog>
</safe-dialog>
</template>
<script setup>
import { inject } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import { openSourceSoftwareLicenses } from '../../../composables/settings/constants/openSourceSoftwareLicenses';
const beforeDialogClose = inject('beforeDialogClose');
const dialogMouseDown = inject('dialogMouseDown');
const dialogMouseUp = inject('dialogMouseUp');
import { openSourceSoftwareLicenses } from '../../../composables/setting/constants/openSourceSoftwareLicenses';
const { t } = useI18n();

View File

@@ -1,5 +1,5 @@
<template>
<el-dialog
<safe-dialog
class="x-dialog"
:visible.sync="enablePrimaryPasswordDialog.visible"
:before-close="enablePrimaryPasswordDialog.beforeClose"
@@ -36,7 +36,7 @@
{{ t('dialog.primary_password.ok') }}
</el-button>
</template>
</el-dialog>
</safe-dialog>
</template>
<script setup>

View File

@@ -1,14 +1,11 @@
<template>
<el-dialog
<safe-dialog
class="x-dialog"
:before-close="beforeDialogClose"
:visible="isRegistryBackupDialogVisible"
:title="t('dialog.registry_backup.header')"
width="600px"
@close="closeDialog"
@closed="clearVrcRegistryDialog"
@mousedown.native="dialogMouseDown"
@mouseup.native="dialogMouseUp">
@closed="clearVrcRegistryDialog">
<div style="margin-top: 10px">
<div style="display: flex; align-items: center; justify-content: space-between; font-size: 12px">
<span class="name" style="margin-right: 24px">{{ t('dialog.registry_backup.auto_backup') }}</span>
@@ -70,19 +67,17 @@
</div>
</div>
</div>
</el-dialog>
</safe-dialog>
</template>
<script setup>
import { getCurrentInstance, inject, ref, watch } from 'vue';
import { getCurrentInstance, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import configRepository from '../../../service/config';
import utils from '../../../classes/utils';
const { t } = useI18n();
import { downloadAndSaveJson } from '../../../composables/shared/utils';
import configRepository from '../../../service/config';
const beforeDialogClose = inject('beforeDialogClose');
const dialogMouseDown = inject('dialogMouseDown');
const dialogMouseUp = inject('dialogMouseUp');
const { t } = useI18n();
const instance = getCurrentInstance();
const { $confirm, $message, $prompt } = instance.proxy;
@@ -172,7 +167,7 @@
}
function saveVrcRegistryBackupToFile(row) {
utils.downloadAndSaveJson(row.name, row.data);
downloadAndSaveJson(row.name, row.data);
}
async function deleteVrcRegistryBackup(row) {

View File

@@ -1,13 +1,10 @@
<template>
<el-dialog
<safe-dialog
class="x-dialog"
:before-close="beforeDialogClose"
:visible.sync="screenshotMetadataDialog.visible"
:title="t('dialog.screenshot_metadata.header')"
width="1050px"
top="10vh"
@mousedown.native="dialogMouseDown"
@mouseup.native="dialogMouseUp">
top="10vh">
<div
v-if="screenshotMetadataDialog.visible"
v-loading="screenshotMetadataDialog.loading"
@@ -161,19 +158,16 @@
<br />
</span>
</div>
</el-dialog>
</safe-dialog>
</template>
<script setup>
import { ref, inject, computed, getCurrentInstance, watch } from 'vue';
import { ref, inject, getCurrentInstance, watch } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import { vrcPlusImageRequest } from '../../../api';
import Location from '../../../components/Location.vue';
const API = inject('API');
const beforeDialogClose = inject('beforeDialogClose');
const dialogMouseDown = inject('dialogMouseDown');
const dialogMouseUp = inject('dialogMouseUp');
const showFullscreenImageDialog = inject('showFullscreenImageDialog');
const { t } = useI18n();
@@ -302,6 +296,9 @@
vrcPlusImageRequest
.uploadGalleryImage(base64Body)
.then((args) => {
// about uploadGalleryImage -> emit 'GALLERYIMAGE:ADD'
// no need to add to the gallery logic here
// because it refreshes when you open the gallery
$message({
message: t('message.gallery.uploaded'),
type: 'success'

View File

@@ -1,14 +1,11 @@
<template>
<el-dialog
<safe-dialog
class="x-dialog"
:before-close="beforeDialogClose"
:visible="isVRChatConfigDialogVisible"
:title="t('dialog.config_json.header')"
width="420px"
top="10vh"
@close="closeDialog"
@mousedown.native="dialogMouseDown"
@mouseup.native="dialogMouseUp">
@close="closeDialog">
<div v-loading="loading">
<div style="font-size: 12px; word-break: keep-all">
{{ t('dialog.config_json.description1') }} <br />
@@ -185,26 +182,24 @@
</div>
</div>
</template>
</el-dialog>
</safe-dialog>
</template>
<script setup>
import { ref, watch, inject, getCurrentInstance, computed } from 'vue';
import { computed, getCurrentInstance, inject, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import {
getVRChatResolution,
VRChatScreenshotResolutions,
VRChatCameraResolutions
} from '../../../composables/settings/constants/vrchatResolutions';
VRChatCameraResolutions,
VRChatScreenshotResolutions
} from '../../../composables/setting/constants/vrchatResolutions';
import { getVRChatResolution } from '../../../composables/setting/utils';
const { t } = useI18n();
const instance = getCurrentInstance();
const $confirm = instance.proxy.$confirm;
const $message = instance.proxy.$message;
const beforeDialogClose = inject('beforeDialogClose');
const dialogMouseDown = inject('dialogMouseDown');
const dialogMouseUp = inject('dialogMouseUp');
const openExternalLink = inject('openExternalLink');
const props = defineProps({

View File

@@ -1,13 +1,10 @@
<template>
<el-dialog
<safe-dialog
class="x-dialog"
:before-close="beforeDialogClose"
:visible="isYouTubeApiDialogVisible"
:title="t('dialog.youtube_api.header')"
width="400px"
@close="closeDialog"
@mousedown.native="dialogMouseDown"
@mouseup.native="dialogMouseUp">
@close="closeDialog">
<div style="font-size: 12px">{{ t('dialog.youtube_api.description') }} <br /></div>
<el-input
@@ -32,7 +29,7 @@
</el-button>
</div>
</template>
</el-dialog>
</safe-dialog>
</template>
<script setup>
@@ -44,9 +41,6 @@
const instance = getCurrentInstance();
const $message = instance.proxy.$message;
const beforeDialogClose = inject('beforeDialogClose');
const dialogMouseDown = inject('dialogMouseDown');
const dialogMouseUp = inject('dialogMouseUp');
const openExternalLink = inject('openExternalLink');
const props = defineProps({

View File

@@ -179,8 +179,8 @@
<script>
import FriendItem from '../../../components/FriendItem.vue';
import Location from '../../../components/Location.vue';
import { isRealInstance as _isRealInstance, parseLocation } from '../../../composables/instance/utils';
import configRepository from '../../../service/config';
import utils from '../../../classes/utils';
export default {
name: 'FriendsSidebar',
@@ -234,7 +234,7 @@
if (!friend.ref.$location.isRealInstance && this.lastLocation.friendList.has(friend.id)) {
locationTag = this.lastLocation.location;
}
let isRealInstance = this.isRealInstance(locationTag);
const isRealInstance = this.isRealInstance(locationTag);
if (!isRealInstance) {
return;
}
@@ -254,30 +254,30 @@
return sortedFriendsList.sort((a, b) => b.length - a.length);
},
sameInstanceTag() {
const sameInstanceTag = new Set();
sameInstanceFriendId() {
const sameInstanceFriendId = new Set();
for (const item of this.friendsInSameInstance) {
for (const friend of item) {
if (utils.isRealInstance(friend.ref?.$location.tag)) {
sameInstanceTag.add(friend.ref?.$location.tag);
if (this.isRealInstance(friend.ref?.$location.tag)) {
sameInstanceFriendId.add(friend.id);
}
}
}
return sameInstanceTag;
return sameInstanceFriendId;
},
onlineFriendsByGroupStatus() {
if (!this.isSidebarGroupByInstance || !this.isHideFriendsInSameInstance) {
return this.onlineFriends;
}
return this.onlineFriends.filter((item) => !this.sameInstanceTag.has(item.ref?.$location.tag));
return this.onlineFriends.filter((item) => !this.sameInstanceFriendId.has(item.id));
},
vipFriendsByGroupStatus() {
if (!this.isSidebarGroupByInstance || !this.isHideFriendsInSameInstance) {
return this.vipFriends;
}
return this.vipFriends.filter((item) => !this.sameInstanceTag.has(item.ref?.$location.tag));
return this.vipFriends.filter((item) => !this.sameInstanceFriendId.has(item.id));
},
// VIP friends divide by group
vipFriendsDivideByGroup() {
@@ -292,7 +292,7 @@
const filteredFriends = this.vipFriends.filter((friend) =>
groupFriends.some((item) => {
if (this.isSidebarGroupByInstance && this.isHideFriendsInSameInstance) {
return item.id === friend.id && !this.sameInstanceTag.has(item.ref?.$location.tag);
return item.id === friend.id && !this.sameInstanceFriendId.has(item.id);
}
return item.id === friend.id;
})
@@ -337,7 +337,7 @@
);
},
isRealInstance(locationTag) {
return utils.isRealInstance(locationTag);
return _isRealInstance(locationTag);
},
toggleSwitchGroupByInstanceCollapsed() {
this.isSidebarGroupByInstanceCollapsed = !this.isSidebarGroupByInstanceCollapsed;
@@ -357,7 +357,7 @@
}
}
for (const friend of friendsArr) {
if (utils.isRealInstance(friend.ref?.travelingToLocation)) {
if (this.isRealInstance(friend.ref?.travelingToLocation)) {
return friend.ref.travelingToLocation;
}
}

View File

@@ -44,7 +44,7 @@
<script>
import Location from '../../../components/Location.vue';
import utils from '../../../classes/utils';
import { convertFileUrlToImageUrl } from '../../../composables/shared/utils';
export default {
name: 'GroupsSidebar',
@@ -97,7 +97,7 @@
},
methods: {
getSmallGroupIconUrl(url) {
return utils.convertFileUrlToImageUrl(url);
return convertFileUrlToImageUrl(url);
},
toggleGroupSidebarCollapse(groupId) {
this.groupInstancesCfg[groupId].isCollapsed = !this.groupInstancesCfg[groupId].isCollapsed;