mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-19 06:43:51 +02:00
feat: Friend tab
This commit is contained in:
@@ -104,7 +104,7 @@
|
|||||||
import LaunchDialog from './components/dialogs/LaunchDialog.vue';
|
import LaunchDialog from './components/dialogs/LaunchDialog.vue';
|
||||||
import LaunchOptionsDialog from './views/Settings/dialogs/LaunchOptionsDialog.vue';
|
import LaunchOptionsDialog from './views/Settings/dialogs/LaunchOptionsDialog.vue';
|
||||||
import Login from './views/Login/Login.vue';
|
import Login from './views/Login/Login.vue';
|
||||||
import MacOSTitleBar from './components/TitleBar/MacOSTitleBar.vue';
|
import MacOSTitleBar from './components/MacOSTitleBar.vue';
|
||||||
import NavMenu from './components/NavMenu.vue';
|
import NavMenu from './components/NavMenu.vue';
|
||||||
import PreviousInstancesInfoDialog from './components/dialogs/PreviousInstancesDialog/PreviousInstancesInfoDialog.vue';
|
import PreviousInstancesInfoDialog from './components/dialogs/PreviousInstancesDialog/PreviousInstancesInfoDialog.vue';
|
||||||
import PrimaryPasswordDialog from './views/Settings/dialogs/PrimaryPasswordDialog.vue';
|
import PrimaryPasswordDialog from './views/Settings/dialogs/PrimaryPasswordDialog.vue';
|
||||||
|
|||||||
@@ -53,7 +53,10 @@
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true
|
default: true
|
||||||
},
|
},
|
||||||
isOpenPreviousInstanceInfoDialog: Boolean
|
isOpenPreviousInstanceInfoDialog: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const text = ref('');
|
const text = ref('');
|
||||||
|
|||||||
@@ -64,6 +64,7 @@
|
|||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ index: 'feed', icon: 'ri-rss-line', tooltip: 'nav_tooltip.feed' },
|
{ index: 'feed', icon: 'ri-rss-line', tooltip: 'nav_tooltip.feed' },
|
||||||
|
{ index: 'friend', icon: 'ri-group-line', tooltip: 'nav_tooltip.friend' },
|
||||||
{ index: 'gameLog', icon: 'ri-history-line', tooltip: 'nav_tooltip.game_log' },
|
{ index: 'gameLog', icon: 'ri-history-line', tooltip: 'nav_tooltip.game_log' },
|
||||||
{ index: 'playerList', icon: 'ri-group-3-line', tooltip: 'nav_tooltip.player_list' },
|
{ index: 'playerList', icon: 'ri-group-3-line', tooltip: 'nav_tooltip.player_list' },
|
||||||
{ index: 'search', icon: 'ri-search-line', tooltip: 'nav_tooltip.search' },
|
{ index: 'search', icon: 'ri-search-line', tooltip: 'nav_tooltip.search' },
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"language": "English (en)",
|
"language": "English (en)",
|
||||||
"translator": "-",
|
"translator": "-",
|
||||||
"nav_tooltip": {
|
"nav_tooltip": {
|
||||||
|
"friend": "Friend",
|
||||||
"feed": "Feed",
|
"feed": "Feed",
|
||||||
"game_log": "Game Log",
|
"game_log": "Game Log",
|
||||||
"player_list": "Player List",
|
"player_list": "Player List",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createRouter, createWebHashHistory } from 'vue-router';
|
|||||||
import Charts from './../views/Charts/Charts.vue';
|
import Charts from './../views/Charts/Charts.vue';
|
||||||
import Favorites from './../views/Favorites/Favorites.vue';
|
import Favorites from './../views/Favorites/Favorites.vue';
|
||||||
import Feed from './../views/Feed/Feed.vue';
|
import Feed from './../views/Feed/Feed.vue';
|
||||||
|
import Friend from './../views/Friend/Friend.vue';
|
||||||
import FriendList from './../views/FriendList/FriendList.vue';
|
import FriendList from './../views/FriendList/FriendList.vue';
|
||||||
import FriendLog from './../views/FriendLog/FriendLog.vue';
|
import FriendLog from './../views/FriendLog/FriendLog.vue';
|
||||||
import GameLog from './../views/GameLog/GameLog.vue';
|
import GameLog from './../views/GameLog/GameLog.vue';
|
||||||
@@ -14,6 +15,7 @@ import Settings from './../views/Settings/Settings.vue';
|
|||||||
import Tools from './../views/Tools/Tools.vue';
|
import Tools from './../views/Tools/Tools.vue';
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
|
{ path: '/friend', name: 'friend', component: Friend },
|
||||||
{ path: '/feed', name: 'feed', component: Feed },
|
{ path: '/feed', name: 'feed', component: Feed },
|
||||||
{ path: '/gamelog', name: 'gameLog', component: GameLog },
|
{ path: '/gamelog', name: 'gameLog', component: GameLog },
|
||||||
{ path: '/playerlist', name: 'playerList', component: PlayerList },
|
{ path: '/playerlist', name: 'playerList', component: PlayerList },
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { isRealInstance } from './instance.js';
|
||||||
|
import { useLocationStore } from '../../stores/location.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {string} location
|
* @param {string} location
|
||||||
@@ -141,4 +144,28 @@ function parseLocation(tag) {
|
|||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { parseLocation, displayLocation };
|
function getFriendsLocations(friendsArr) {
|
||||||
|
const locaationStore = useLocationStore();
|
||||||
|
// prevent the instance title display as "Traveling".
|
||||||
|
if (!friendsArr?.length) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
for (const friend of friendsArr) {
|
||||||
|
if (isRealInstance(friend.ref?.location)) {
|
||||||
|
return friend.ref.location;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const friend of friendsArr) {
|
||||||
|
if (isRealInstance(friend.ref?.travelingToLocation)) {
|
||||||
|
return friend.ref.travelingToLocation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const friend of friendsArr) {
|
||||||
|
if (locaationStore.lastLocation.friendList.has(friend.id)) {
|
||||||
|
return locaationStore.lastLocation.location;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return friendsArr[0].ref?.location;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { parseLocation, displayLocation, getFriendsLocations };
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { useFavoriteStore } from './favorite';
|
|||||||
import { useFeedStore } from './feed';
|
import { useFeedStore } from './feed';
|
||||||
import { useGeneralSettingsStore } from './settings/general';
|
import { useGeneralSettingsStore } from './settings/general';
|
||||||
import { useGroupStore } from './group';
|
import { useGroupStore } from './group';
|
||||||
|
import { useLocationStore } from './location';
|
||||||
import { useNotificationStore } from './notification';
|
import { useNotificationStore } from './notification';
|
||||||
import { useSharedFeedStore } from './sharedFeed';
|
import { useSharedFeedStore } from './sharedFeed';
|
||||||
import { useUiStore } from './ui';
|
import { useUiStore } from './ui';
|
||||||
@@ -45,6 +46,7 @@ export const useFriendStore = defineStore('Friend', () => {
|
|||||||
const sharedFeedStore = useSharedFeedStore();
|
const sharedFeedStore = useSharedFeedStore();
|
||||||
const updateLoopStore = useUpdateLoopStore();
|
const updateLoopStore = useUpdateLoopStore();
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
|
const locationStore = useLocationStore();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const state = reactive({
|
const state = reactive({
|
||||||
@@ -135,6 +137,49 @@ export const useFriendStore = defineStore('Friend', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const friendsInSameInstance = computed(() => {
|
||||||
|
const friendsList = {};
|
||||||
|
|
||||||
|
const allFriends = [...vipFriends.value, ...onlineFriends.value];
|
||||||
|
allFriends.forEach((friend) => {
|
||||||
|
if (!friend.ref?.$location) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let locationTag = friend.ref.$location.tag;
|
||||||
|
if (
|
||||||
|
!friend.ref.$location.isRealInstance &&
|
||||||
|
locationStore.lastLocation.friendList.has(friend.id)
|
||||||
|
) {
|
||||||
|
locationTag = locationStore.lastLocation.location;
|
||||||
|
}
|
||||||
|
const isReal = isRealInstance(locationTag);
|
||||||
|
if (!isReal) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!friendsList[locationTag]) {
|
||||||
|
friendsList[locationTag] = [];
|
||||||
|
}
|
||||||
|
friendsList[locationTag].push(friend);
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedFriendsList = [];
|
||||||
|
for (const group of Object.values(friendsList)) {
|
||||||
|
if (group.length > 1) {
|
||||||
|
sortedFriendsList.push(
|
||||||
|
group.sort(
|
||||||
|
getFriendsSortFunction(
|
||||||
|
appearanceSettingsStore.sidebarSortMethods
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortedFriendsList.sort((a, b) => b.length - a.length);
|
||||||
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => watchState.isLoggedIn,
|
() => watchState.isLoggedIn,
|
||||||
(isLoggedIn) => {
|
(isLoggedIn) => {
|
||||||
@@ -1570,6 +1615,7 @@ export const useFriendStore = defineStore('Friend', () => {
|
|||||||
onlineFriends,
|
onlineFriends,
|
||||||
activeFriends,
|
activeFriends,
|
||||||
offlineFriends,
|
offlineFriends,
|
||||||
|
friendsInSameInstance,
|
||||||
|
|
||||||
localFavoriteFriends,
|
localFavoriteFriends,
|
||||||
isRefreshFriendsLoading,
|
isRefreshFriendsLoading,
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export const useAppearanceSettingsStore = defineStore(
|
|||||||
const isSideBarTabShow = computed(() => {
|
const isSideBarTabShow = computed(() => {
|
||||||
const currentRouteName = router.currentRoute.value?.name;
|
const currentRouteName = router.currentRoute.value?.name;
|
||||||
return !(
|
return !(
|
||||||
|
currentRouteName === 'friend' ||
|
||||||
currentRouteName === 'friendList' ||
|
currentRouteName === 'friendList' ||
|
||||||
currentRouteName === 'charts'
|
currentRouteName === 'charts'
|
||||||
);
|
);
|
||||||
|
|||||||
433
src/views/Friend/Friend.vue
Normal file
433
src/views/Friend/Friend.vue
Normal file
@@ -0,0 +1,433 @@
|
|||||||
|
<template>
|
||||||
|
<div class="friend-view x-container">
|
||||||
|
<div class="friend-view__toolbar">
|
||||||
|
<el-segmented
|
||||||
|
v-model="activeSegment"
|
||||||
|
class="friend-view__segmented"
|
||||||
|
:options="segmentedOptions"
|
||||||
|
size="small" />
|
||||||
|
<div class="friend-view__actions">
|
||||||
|
<span class="friend-view__slider-label">Card Scale</span>
|
||||||
|
<el-slider
|
||||||
|
v-model="cardScale"
|
||||||
|
class="friend-view__slider"
|
||||||
|
:min="0.6"
|
||||||
|
:max="1.2"
|
||||||
|
:step="0.05"
|
||||||
|
:show-tooltip="false" />
|
||||||
|
<el-input
|
||||||
|
v-model="searchTerm"
|
||||||
|
class="friend-view__search"
|
||||||
|
:prefix-icon="Search"
|
||||||
|
clearable
|
||||||
|
placeholder="Search Friend"></el-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-scrollbar ref="scrollbarRef" class="friend-view__scroll" @scroll="handleScroll">
|
||||||
|
<template v-if="isSameInstanceView">
|
||||||
|
<div v-if="visibleSameInstanceGroups.length" class="friend-view__instances">
|
||||||
|
<section
|
||||||
|
v-for="group in visibleSameInstanceGroups"
|
||||||
|
:key="group.instanceId"
|
||||||
|
class="friend-view__instance">
|
||||||
|
<header class="friend-view__instance-header">
|
||||||
|
<span class="friend-view__instance-id" :title="group.instanceId">{{
|
||||||
|
group.instanceId
|
||||||
|
}}</span>
|
||||||
|
<span class="friend-view__instance-count">{{ group.friends.length }}</span>
|
||||||
|
</header>
|
||||||
|
<div class="friend-view__grid" :style="gridStyle">
|
||||||
|
<FriendCard
|
||||||
|
v-for="friend in group.friends"
|
||||||
|
:key="friend.id ?? friend.userId ?? friend.displayName"
|
||||||
|
:friend="friend"
|
||||||
|
:card-scale="cardScale" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<div v-else class="friend-view__empty">No matching friends</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div v-if="visibleFriends.length" class="friend-view__grid" :style="gridStyle">
|
||||||
|
<FriendCard
|
||||||
|
v-for="entry in visibleFriends"
|
||||||
|
:key="entry.id ?? entry.friend.id ?? entry.friend.displayName"
|
||||||
|
:friend="entry.friend"
|
||||||
|
:card-scale="cardScale" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="friend-view__empty">No matching friends</div>
|
||||||
|
</template>
|
||||||
|
<div v-if="isLoadingMore" class="friend-view__loading">
|
||||||
|
<el-icon class="friend-view__loading-icon" :size="18">
|
||||||
|
<Loading />
|
||||||
|
</el-icon>
|
||||||
|
<span>Loading more...</span>
|
||||||
|
</div>
|
||||||
|
</el-scrollbar>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||||
|
import { Loading, Search } from '@element-plus/icons-vue';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
|
||||||
|
import { getFriendsLocations } from '../../shared/utils/location.js';
|
||||||
|
import { useFriendStore } from '../../stores';
|
||||||
|
|
||||||
|
import FriendCard from './components/FriendCard.vue';
|
||||||
|
|
||||||
|
const friendStore = useFriendStore();
|
||||||
|
const { onlineFriends, vipFriends, activeFriends, offlineFriends, friendsInSameInstance } =
|
||||||
|
storeToRefs(friendStore);
|
||||||
|
|
||||||
|
const segmentedOptions = [
|
||||||
|
{ label: 'Online', value: 'online' },
|
||||||
|
{ label: 'Favorite', value: 'favorite' },
|
||||||
|
{ label: 'Same Instance', value: 'same-instance' },
|
||||||
|
{ label: 'Active', value: 'active' },
|
||||||
|
{ label: 'Offline', value: 'offline' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const PAGE_SIZE = 18;
|
||||||
|
const VIEWPORT_BUFFER = 32;
|
||||||
|
|
||||||
|
const activeSegment = ref('online');
|
||||||
|
const searchTerm = ref('');
|
||||||
|
const cardScale = ref(1);
|
||||||
|
const itemsToShow = ref(PAGE_SIZE);
|
||||||
|
const isLoadingMore = ref(false);
|
||||||
|
const scrollbarRef = ref();
|
||||||
|
|
||||||
|
const normalizedSearchTerm = computed(() => searchTerm.value.trim().toLowerCase());
|
||||||
|
|
||||||
|
const toEntries = (list = [], instanceId) =>
|
||||||
|
Array.isArray(list)
|
||||||
|
? list.map((friend) => ({
|
||||||
|
id: friend.id ?? friend.userId ?? friend.displayName,
|
||||||
|
friend,
|
||||||
|
instanceId
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const sameInstanceGroups = computed(() => {
|
||||||
|
const source = friendsInSameInstance?.value;
|
||||||
|
|
||||||
|
if (!Array.isArray(source) || source.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return source.map((group, index) => {
|
||||||
|
if (!Array.isArray(group) || group.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const friends = group;
|
||||||
|
|
||||||
|
const instanceId = getFriendsLocations(friends) || `instance-${index + 1}`;
|
||||||
|
return {
|
||||||
|
instanceId: String(instanceId),
|
||||||
|
friends
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const sameInstanceEntries = computed(() =>
|
||||||
|
sameInstanceGroups.value.flatMap((group) => toEntries(group.friends, group.instanceId))
|
||||||
|
);
|
||||||
|
|
||||||
|
const uniqueEntries = (entries = []) => {
|
||||||
|
const seen = new Set();
|
||||||
|
return entries.filter((entry) => {
|
||||||
|
const key = entry.id;
|
||||||
|
if (!key) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (seen.has(key)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredFriends = computed(() => {
|
||||||
|
if (normalizedSearchTerm.value) {
|
||||||
|
const pools = [
|
||||||
|
...toEntries(vipFriends.value),
|
||||||
|
...toEntries(onlineFriends.value),
|
||||||
|
...toEntries(activeFriends.value),
|
||||||
|
...toEntries(offlineFriends.value)
|
||||||
|
];
|
||||||
|
|
||||||
|
return uniqueEntries(pools).filter(({ friend }) => {
|
||||||
|
const haystack =
|
||||||
|
`${friend.displayName ?? friend.name ?? ''} ${friend.signature ?? ''} ${friend.worldName ?? ''}`.toLowerCase();
|
||||||
|
return haystack.includes(normalizedSearchTerm.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (activeSegment.value) {
|
||||||
|
case 'online':
|
||||||
|
return toEntries(onlineFriends.value);
|
||||||
|
case 'favorite':
|
||||||
|
return toEntries(vipFriends.value);
|
||||||
|
case 'same-instance':
|
||||||
|
return sameInstanceEntries.value;
|
||||||
|
case 'active':
|
||||||
|
return toEntries(activeFriends.value);
|
||||||
|
case 'offline':
|
||||||
|
return toEntries(offlineFriends.value);
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const visibleFriends = computed(() => filteredFriends.value.slice(0, itemsToShow.value));
|
||||||
|
|
||||||
|
const isSameInstanceView = computed(() => activeSegment.value === 'same-instance' && !normalizedSearchTerm.value);
|
||||||
|
|
||||||
|
const visibleSameInstanceGroups = computed(() => {
|
||||||
|
if (!isSameInstanceView.value) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const grouped = new Map();
|
||||||
|
|
||||||
|
for (const entry of visibleFriends.value) {
|
||||||
|
const bucketId = entry.instanceId ?? 'instance';
|
||||||
|
if (!grouped.has(bucketId)) {
|
||||||
|
grouped.set(bucketId, []);
|
||||||
|
}
|
||||||
|
grouped.get(bucketId).push(entry.friend);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sameInstanceGroups.value
|
||||||
|
.filter((group) => grouped.has(group.instanceId))
|
||||||
|
.map((group) => ({
|
||||||
|
instanceId: group.instanceId,
|
||||||
|
friends: grouped.get(group.instanceId) ?? []
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
const gridStyle = computed(() => {
|
||||||
|
const baseWidth = 220;
|
||||||
|
const baseGap = 14;
|
||||||
|
const cardWidth = baseWidth * cardScale.value;
|
||||||
|
const gap = baseGap + (cardScale.value - 1) * 10;
|
||||||
|
return {
|
||||||
|
'--friend-card-min-width': `${Math.round(cardWidth)}px`,
|
||||||
|
'--friend-card-gap': `${gap.toFixed(0)}px`
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (isLoadingMore.value || filteredFriends.value.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrap = scrollbarRef.value?.wrapRef;
|
||||||
|
|
||||||
|
if (!wrap) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { scrollHeight, scrollTop, clientHeight } = wrap;
|
||||||
|
|
||||||
|
if (scrollTop + clientHeight >= scrollHeight - 120) {
|
||||||
|
loadMoreFriends();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function loadMoreFriends() {
|
||||||
|
if (isLoadingMore.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoadingMore.value = true;
|
||||||
|
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (itemsToShow.value < filteredFriends.value.length) {
|
||||||
|
itemsToShow.value = Math.min(itemsToShow.value + PAGE_SIZE, filteredFriends.value.length);
|
||||||
|
}
|
||||||
|
isLoadingMore.value = false;
|
||||||
|
maybeFillViewport();
|
||||||
|
}, 350);
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeFillViewport() {
|
||||||
|
nextTick(() => {
|
||||||
|
const wrap = scrollbarRef.value?.wrapRef;
|
||||||
|
if (!wrap) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { scrollHeight, clientHeight } = wrap;
|
||||||
|
const hasSpace = scrollHeight <= clientHeight + VIEWPORT_BUFFER;
|
||||||
|
|
||||||
|
if (!hasSpace || isLoadingMore.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filteredFriends.value.length > visibleFriends.value.length) {
|
||||||
|
loadMoreFriends();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([searchTerm, activeSegment], () => {
|
||||||
|
itemsToShow.value = PAGE_SIZE;
|
||||||
|
maybeFillViewport();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => filteredFriends.value.length,
|
||||||
|
(length) => {
|
||||||
|
if (itemsToShow.value > length) {
|
||||||
|
itemsToShow.value = length;
|
||||||
|
}
|
||||||
|
maybeFillViewport();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(cardScale, () => {
|
||||||
|
nextTick(() => {
|
||||||
|
scrollbarRef.value?.update?.();
|
||||||
|
maybeFillViewport();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
maybeFillViewport();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.friend-view {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-view__toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 6px 2px 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-view__segmented :deep(.el-segmented) {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||||
|
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-view__segmented :deep(.el-segmented__item) {
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-view__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex: none;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
color: rgba(15, 23, 42, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-view__slider-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-view__slider {
|
||||||
|
width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-view__search {
|
||||||
|
width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-view__scroll {
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-view__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(var(--friend-card-min-width, 200px), 1fr));
|
||||||
|
gap: var(--friend-card-gap, 18px);
|
||||||
|
padding: 6px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-view__instances {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 6px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-view__instance {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-view__instance-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 4px 2px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(15, 23, 42, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-view__instance-id {
|
||||||
|
max-width: 75%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-view__instance-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(15, 23, 42, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-view__empty {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: 240px;
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
font-size: 15px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-view__loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 18px 0 12px;
|
||||||
|
color: rgba(0, 0, 0, 0.55);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-view__loading-icon {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
234
src/views/Friend/components/FriendCard.vue
Normal file
234
src/views/Friend/components/FriendCard.vue
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
<template>
|
||||||
|
<el-card
|
||||||
|
class="friend-card"
|
||||||
|
shadow="never"
|
||||||
|
:body-style="{ padding: `${16 * cardScale}px` }"
|
||||||
|
:style="cardStyle"
|
||||||
|
@click="showUserDialog(friend.id)">
|
||||||
|
<div class="friend-card__header">
|
||||||
|
<div class="friend-card__avatar-wrapper">
|
||||||
|
<el-avatar :size="avatarSize" :src="userImage(props.friend.ref, true)" class="friend-card__avatar">
|
||||||
|
{{ avatarFallback }}
|
||||||
|
</el-avatar>
|
||||||
|
</div>
|
||||||
|
<span class="friend-card__status-dot" :class="statusDotClass"></span>
|
||||||
|
<div class="friend-card__name" :title="friend.name">{{ friend.name }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="friend-card__body">
|
||||||
|
<div class="friend-card__signature" :title="friend.ref?.statusDescription">
|
||||||
|
<i v-if="friend.ref?.statusDescription" class="ri-pencil-line" style="opacity: 0.7"></i>
|
||||||
|
{{ friend.ref?.statusDescription || ' ' }}
|
||||||
|
</div>
|
||||||
|
<div class="friend-card__world" :title="friend.worldName">
|
||||||
|
<Location
|
||||||
|
class="friend-card__location"
|
||||||
|
:location="friend.ref?.location"
|
||||||
|
:traveling="friend.ref?.travelingToLocation"
|
||||||
|
:link="false" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import { userImage, userStatusClass } from '../../../shared/utils';
|
||||||
|
import { useUserStore } from '../../../stores';
|
||||||
|
|
||||||
|
const { showUserDialog } = useUserStore();
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
friend: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
cardScale: {
|
||||||
|
type: Number,
|
||||||
|
default: 1
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const avatarSize = computed(() => 48 * props.cardScale);
|
||||||
|
|
||||||
|
const cardStyle = computed(() => ({
|
||||||
|
'--card-scale': props.cardScale,
|
||||||
|
cursor: 'pointer'
|
||||||
|
}));
|
||||||
|
|
||||||
|
const avatarFallback = computed(() => props.friend.name.charAt(0) ?? '?');
|
||||||
|
|
||||||
|
const statusDotClass = computed(() => {
|
||||||
|
const status = userStatusClass(props.friend.ref, props.friend.pendingOffline);
|
||||||
|
|
||||||
|
if (status.joinme) {
|
||||||
|
return 'friend-card__status-dot--join';
|
||||||
|
}
|
||||||
|
if (status.online || status.active) {
|
||||||
|
return 'friend-card__status-dot--online';
|
||||||
|
}
|
||||||
|
if (status.askme) {
|
||||||
|
return 'friend-card__status-dot--ask';
|
||||||
|
}
|
||||||
|
if (status.busy) {
|
||||||
|
return 'friend-card__status-dot--busy';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'friend-card__status-dot--hidden';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.friend-card {
|
||||||
|
--card-scale: 1;
|
||||||
|
--friend-card-width: 300px;
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
gap: calc(14px * var(--card-scale));
|
||||||
|
border-radius: calc(8px * var(--card-scale));
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.32);
|
||||||
|
box-shadow: 0 calc(6px * var(--card-scale)) calc(16px * var(--card-scale)) rgba(15, 23, 42, 0.04);
|
||||||
|
transition:
|
||||||
|
box-shadow 0.2s ease,
|
||||||
|
transform 0.2s ease;
|
||||||
|
width: var(--friend-card-width, auto);
|
||||||
|
max-width: 100%;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 calc(10px * var(--card-scale)) calc(24px * var(--card-scale)) rgba(15, 23, 42, 0.07);
|
||||||
|
transform: translateY(calc(-2px * var(--card-scale)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-card__header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: calc(12px * var(--card-scale));
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-card__avatar-wrapper {
|
||||||
|
position: static;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-card__avatar {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.85);
|
||||||
|
box-shadow: 0 calc(5px * var(--card-scale)) calc(10px * var(--card-scale)) rgba(15, 23, 42, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-card__status-dot {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(8px * var(--card-scale));
|
||||||
|
right: calc(8px * var(--card-scale));
|
||||||
|
width: calc(8px * var(--card-scale));
|
||||||
|
height: calc(8px * var(--card-scale));
|
||||||
|
border-radius: 999px;
|
||||||
|
border: calc(2px * var(--card-scale)) solid #fff;
|
||||||
|
box-shadow: 0 0 calc(4px * var(--card-scale)) rgba(15, 23, 42, 0.12);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-card__status-dot--hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-card__status-dot--online {
|
||||||
|
background: linear-gradient(145deg, #67c23a, #4aa12d);
|
||||||
|
box-shadow: 0 0 calc(8px * var(--card-scale)) rgba(103, 194, 58, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-card__status-dot--join {
|
||||||
|
background: linear-gradient(145deg, #409eff, #2f7ed9);
|
||||||
|
box-shadow: 0 0 calc(8px * var(--card-scale)) rgba(64, 158, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-card__status-dot--busy {
|
||||||
|
background: linear-gradient(145deg, #ff2c2c, #d81f1f);
|
||||||
|
box-shadow: 0 0 calc(8px * var(--card-scale)) rgba(255, 44, 44, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-card__status-dot--ask {
|
||||||
|
background: linear-gradient(145deg, #ff9500, #d97800);
|
||||||
|
box-shadow: 0 0 calc(8px * var(--card-scale)) rgba(255, 149, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-card__body {
|
||||||
|
display: grid;
|
||||||
|
gap: calc(12px * var(--card-scale));
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-card__name {
|
||||||
|
font-size: calc(17px * var(--card-scale));
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
line-height: 1.2;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-card__signature {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: calc(13px * var(--card-scale));
|
||||||
|
color: rgba(31, 41, 55, 0.7);
|
||||||
|
line-height: 1.4;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-card__world {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: calc(36px * var(--card-scale));
|
||||||
|
padding: calc(6px * var(--card-scale)) calc(10px * var(--card-scale));
|
||||||
|
border-radius: calc(12px * var(--card-scale));
|
||||||
|
background: rgba(148, 163, 184, 0.18);
|
||||||
|
color: rgba(71, 85, 105, 0.95);
|
||||||
|
font-size: calc(12px * var(--card-scale));
|
||||||
|
line-height: 1.3;
|
||||||
|
box-sizing: border-box;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-card__location {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-height: calc(36px * var(--card-scale));
|
||||||
|
overflow: hidden;
|
||||||
|
line-height: 1.3;
|
||||||
|
white-space: normal;
|
||||||
|
word-break: break-word;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-card__location :deep(.x-location__text) {
|
||||||
|
display: -webkit-box;
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-card__location :deep(.x-location__text:only-child) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: calc(24px * var(--card-scale));
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-card__location :deep(.x-location__text:only-child span) {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-card__location :deep(.x-location__meta) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
|
|
||||||
const { friends, isRefreshFriendsLoading, onlineFriendCount } = storeToRefs(useFriendStore());
|
const { friends, isRefreshFriendsLoading, onlineFriendCount } = storeToRefs(useFriendStore());
|
||||||
const { refreshFriendsList, confirmDeleteFriend } = useFriendStore();
|
const { refreshFriendsList, confirmDeleteFriend } = useFriendStore();
|
||||||
const { quickSearchRemoteMethod, quickSearchChange, directAccessPaste } = useSearchStore();
|
const { quickSearchRemoteMethod, quickSearchChange } = useSearchStore();
|
||||||
const { quickSearchItems } = storeToRefs(useSearchStore());
|
const { quickSearchItems } = storeToRefs(useSearchStore());
|
||||||
const { inGameGroupOrder, groupInstances } = storeToRefs(useGroupStore());
|
const { inGameGroupOrder, groupInstances } = storeToRefs(useGroupStore());
|
||||||
const { logout } = useAuthStore();
|
const { logout } = useAuthStore();
|
||||||
|
|||||||
@@ -63,8 +63,8 @@
|
|||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
import { useAppearanceSettingsStore, useFriendStore } from '../stores';
|
import { useAppearanceSettingsStore, useFriendStore } from '../../../stores';
|
||||||
import { userImage, userStatusClass } from '../shared/utils';
|
import { userImage, userStatusClass } from '../../../shared/utils';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
friend: { type: Object, required: true },
|
friend: { type: Object, required: true },
|
||||||
@@ -183,16 +183,19 @@
|
|||||||
useLocationStore,
|
useLocationStore,
|
||||||
useUserStore
|
useUserStore
|
||||||
} from '../../../stores';
|
} from '../../../stores';
|
||||||
import { getFriendsSortFunction, isRealInstance, userImage, userStatusClass } from '../../../shared/utils';
|
import { isRealInstance, userImage, userStatusClass } from '../../../shared/utils';
|
||||||
|
import { getFriendsLocations } from '../../../shared/utils/location.js';
|
||||||
|
|
||||||
import FriendItem from '../../../components/FriendItem.vue';
|
import FriendItem from './FriendItem.vue';
|
||||||
import configRepository from '../../../service/config';
|
import configRepository from '../../../service/config';
|
||||||
|
|
||||||
const emit = defineEmits(['confirm-delete-friend']);
|
const emit = defineEmits(['confirm-delete-friend']);
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const { vipFriends, onlineFriends, activeFriends, offlineFriends } = storeToRefs(useFriendStore());
|
const friendStore = useFriendStore();
|
||||||
const { isSidebarGroupByInstance, isHideFriendsInSameInstance, isSidebarDivideByFriendGroup, sidebarSortMethods } =
|
const { vipFriends, onlineFriends, activeFriends, offlineFriends, friendsInSameInstance } =
|
||||||
|
storeToRefs(friendStore);
|
||||||
|
const { isSidebarGroupByInstance, isHideFriendsInSameInstance, isSidebarDivideByFriendGroup } =
|
||||||
storeToRefs(useAppearanceSettingsStore());
|
storeToRefs(useAppearanceSettingsStore());
|
||||||
const { gameLogDisabled } = storeToRefs(useAdvancedSettingsStore());
|
const { gameLogDisabled } = storeToRefs(useAdvancedSettingsStore());
|
||||||
const { showUserDialog } = useUserStore();
|
const { showUserDialog } = useUserStore();
|
||||||
@@ -210,40 +213,6 @@
|
|||||||
|
|
||||||
loadFriendsGroupStates();
|
loadFriendsGroupStates();
|
||||||
|
|
||||||
const friendsInSameInstance = computed(() => {
|
|
||||||
const friendsList = {};
|
|
||||||
|
|
||||||
const allFriends = [...vipFriends.value, ...onlineFriends.value];
|
|
||||||
allFriends.forEach((friend) => {
|
|
||||||
if (!friend.ref?.$location) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let locationTag = friend.ref.$location.tag;
|
|
||||||
if (!friend.ref.$location.isRealInstance && lastLocation.value.friendList.has(friend.id)) {
|
|
||||||
locationTag = lastLocation.value.location;
|
|
||||||
}
|
|
||||||
const isReal = isRealInstance(locationTag);
|
|
||||||
if (!isReal) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!friendsList[locationTag]) {
|
|
||||||
friendsList[locationTag] = [];
|
|
||||||
}
|
|
||||||
friendsList[locationTag].push(friend);
|
|
||||||
});
|
|
||||||
|
|
||||||
const sortedFriendsList = [];
|
|
||||||
for (const group of Object.values(friendsList)) {
|
|
||||||
if (group.length > 1) {
|
|
||||||
sortedFriendsList.push(group.sort(getFriendsSortFunction(sidebarSortMethods.value)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return sortedFriendsList.sort((a, b) => b.length - a.length);
|
|
||||||
});
|
|
||||||
|
|
||||||
const sameInstanceFriendId = computed(() => {
|
const sameInstanceFriendId = computed(() => {
|
||||||
const sameInstanceFriendId = new Set();
|
const sameInstanceFriendId = new Set();
|
||||||
for (const item of friendsInSameInstance.value) {
|
for (const item of friendsInSameInstance.value) {
|
||||||
@@ -332,29 +301,6 @@
|
|||||||
configRepository.setBool('VRCX_sidebarGroupByInstanceCollapsed', isSidebarGroupByInstanceCollapsed.value);
|
configRepository.setBool('VRCX_sidebarGroupByInstanceCollapsed', isSidebarGroupByInstanceCollapsed.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFriendsLocations(friendsArr) {
|
|
||||||
// prevent the instance title display as "Traveling".
|
|
||||||
if (!friendsArr?.length) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
for (const friend of friendsArr) {
|
|
||||||
if (isRealInstance(friend.ref?.location)) {
|
|
||||||
return friend.ref.location;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const friend of friendsArr) {
|
|
||||||
if (isRealInstance(friend.ref?.travelingToLocation)) {
|
|
||||||
return friend.ref.travelingToLocation;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const friend of friendsArr) {
|
|
||||||
if (lastLocation.value.friendList.has(friend.id)) {
|
|
||||||
return lastLocation.value.location;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return friendsArr[0].ref?.location;
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmDeleteFriend(friend) {
|
function confirmDeleteFriend(friend) {
|
||||||
emit('confirm-delete-friend', friend);
|
emit('confirm-delete-friend', friend);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user