imrove friend list search responsiveness and stats refresh logic

This commit is contained in:
pa
2026-03-23 11:49:11 +09:00
parent 1895d0f25c
commit 3e9bff2f1b
4 changed files with 232 additions and 24 deletions
+123 -19
View File
@@ -61,6 +61,7 @@
:placeholder="t('view.friend_list.search_placeholder')"
clearable
class="w-[250px]"
@input="scheduleFriendsListSearchChange"
@change="friendsListSearchChange" />
</div>
<div class="flex items-center">
@@ -120,7 +121,7 @@
<script setup>
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { computed, nextTick, ref, watch } from 'vue';
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { Button } from '@/components/ui/button';
import { InputGroupField } from '@/components/ui/input-group';
import { Progress } from '@/components/ui/progress';
@@ -185,6 +186,13 @@
});
const friendsListRef = ref(null);
const friendSearchCache = new Map();
const FRIEND_LIST_SEARCH_DEBOUNCE_MS = 150;
const FRIEND_STATS_REFRESH_INTERVAL_MS = 30000;
let friendsListSearchTimer = 0;
let friendStatsRefreshInFlight = null;
let lastFriendStatsRefreshAt = 0;
let lastFriendStatsRefreshKey = '';
const friendsListColumns = computed(() =>
createColumns({
@@ -257,7 +265,7 @@
() => route.path,
() => {
refreshFriendStats();
nextTick(() => friendsListSearchChange());
nextTick(() => applyFriendsListSearchChange());
},
{ immediate: true }
);
@@ -265,23 +273,116 @@
watch(
() => friends.value.size,
() => {
refreshFriendStats();
friendsListSearchChange();
friendSearchCache.clear();
refreshFriendStats({ force: true });
applyFriendsListSearchChange();
}
);
function refreshFriendStats() {
getAllUserStats();
getAllUserMutualCount();
onBeforeUnmount(() => {
if (friendsListSearchTimer) {
clearTimeout(friendsListSearchTimer);
}
});
function getFriendStatsRefreshKey() {
return Array.from(friends.value.keys()).sort().join('\u0000');
}
async function refreshFriendStats({ force = false } = {}) {
const friendStatsRefreshKey = getFriendStatsRefreshKey();
if (!friendStatsRefreshKey) {
return;
}
const now = Date.now();
const isStillFresh =
friendStatsRefreshKey === lastFriendStatsRefreshKey &&
now - lastFriendStatsRefreshAt < FRIEND_STATS_REFRESH_INTERVAL_MS;
if (!force && (friendStatsRefreshInFlight || isStillFresh)) {
return friendStatsRefreshInFlight;
}
friendStatsRefreshInFlight = Promise.allSettled([
getAllUserStats(),
getAllUserMutualCount()
]).then((results) => {
if (results.every((result) => result.status === 'fulfilled')) {
lastFriendStatsRefreshAt = Date.now();
lastFriendStatsRefreshKey = friendStatsRefreshKey;
}
return results;
}).finally(() => {
friendStatsRefreshInFlight = null;
});
return friendStatsRefreshInFlight;
}
/**
*
*/
function scheduleFriendsListSearchChange() {
if (friendsListSearchTimer) {
clearTimeout(friendsListSearchTimer);
}
friendsListSearchTimer = setTimeout(() => {
friendsListSearchTimer = 0;
applyFriendsListSearchChange();
}, FRIEND_LIST_SEARCH_DEBOUNCE_MS);
}
/**
*
*/
function friendsListSearchChange() {
if (friendsListSearchTimer) {
clearTimeout(friendsListSearchTimer);
friendsListSearchTimer = 0;
}
applyFriendsListSearchChange();
}
/**
*
* @param {object} ctx
* @returns {object | null}
*/
function getFriendSearchEntry(ctx) {
if (!ctx?.ref?.id) {
return null;
}
const signature = [
ctx.memo ?? '',
ctx.ref.displayName ?? '',
ctx.ref.note ?? '',
ctx.ref.bio ?? '',
ctx.ref.statusDescription ?? '',
ctx.ref.$trustLevel ?? ''
].join('\u0000');
const cached = friendSearchCache.get(ctx.id);
if (cached?.signature === signature) {
return cached;
}
const entry = {
signature,
bio: ctx.ref.bio ?? '',
displayName: ctx.ref.displayName ?? '',
memo: ctx.memo ?? '',
normalizedDisplayName: removeConfusables(ctx.ref.displayName ?? ''),
note: ctx.ref.note ?? '',
rank: String(ctx.ref.$trustLevel ?? '').toUpperCase(),
status: ctx.ref.statusDescription ?? ''
};
friendSearchCache.set(ctx.id, entry);
return entry;
}
/**
*
*/
function applyFriendsListSearchChange() {
friendsListLoading.value = true;
let query = '';
let cleanedQuery = '';
let upperQuery = '';
friendsListDisplayData.value = [];
let filters = friendsListSearchFilters.value.length
? [...friendsListSearchFilters.value]
@@ -290,31 +391,34 @@
if (friendsListSearch.value) {
query = friendsListSearch.value;
cleanedQuery = removeWhitespace(query);
upperQuery = query.toUpperCase();
}
for (const ctx of friends.value.values()) {
if (!ctx.ref) continue;
if (friendsListSearchFilterVIP.value && !allFavoriteFriendIds.value.has(ctx.id)) continue;
if (query) {
let match = false;
if (!match && filters.includes('Display Name') && ctx.ref.displayName) {
const searchEntry = getFriendSearchEntry(ctx);
if (!searchEntry) continue;
if (!match && filters.includes('Display Name') && searchEntry.displayName) {
match =
localeIncludes(ctx.ref.displayName, cleanedQuery, stringComparer.value) ||
localeIncludes(removeConfusables(ctx.ref.displayName), cleanedQuery, stringComparer.value);
localeIncludes(searchEntry.displayName, cleanedQuery, stringComparer.value) ||
localeIncludes(searchEntry.normalizedDisplayName, cleanedQuery, stringComparer.value);
}
if (!match && filters.includes('Memo') && ctx.memo) {
match = localeIncludes(ctx.memo, query, stringComparer.value);
if (!match && filters.includes('Memo') && searchEntry.memo) {
match = localeIncludes(searchEntry.memo, query, stringComparer.value);
}
if (!match && filters.includes('Note') && ctx.ref.note) {
match = localeIncludes(ctx.ref.note, query, stringComparer.value);
if (!match && filters.includes('Note') && searchEntry.note) {
match = localeIncludes(searchEntry.note, query, stringComparer.value);
}
if (!match && filters.includes('Bio') && ctx.ref.bio) {
match = localeIncludes(ctx.ref.bio, query, stringComparer.value);
if (!match && filters.includes('Bio') && searchEntry.bio) {
match = localeIncludes(searchEntry.bio, query, stringComparer.value);
}
if (!match && filters.includes('Status') && ctx.ref.statusDescription) {
match = localeIncludes(ctx.ref.statusDescription, query, stringComparer.value);
if (!match && filters.includes('Status') && searchEntry.status) {
match = localeIncludes(searchEntry.status, query, stringComparer.value);
}
if (!match && filters.includes('Rank')) {
match = String(ctx.ref.$trustLevel).toUpperCase().includes(query.toUpperCase());
match = searchEntry.rank.includes(upperQuery);
}
if (!match) continue;
}