fix: load mutual friends button and mutual opt-out status in friend list

This commit is contained in:
pa
2026-03-27 12:20:49 +09:00
parent b3b1d68cc9
commit f71ac77377
8 changed files with 94 additions and 21 deletions
+2 -1
View File
@@ -2861,7 +2861,8 @@
"lastLogin": "Last Login", "lastLogin": "Last Login",
"dateJoined": "Date Joined", "dateJoined": "Date Joined",
"unfriend": "Unfriend", "unfriend": "Unfriend",
"mutualFriends": "Mutual Friends" "mutualFriends": "Mutual Friends",
"mutualOptedOut": "This user has disabled mutual friends sharing. Showing cached data."
}, },
"profile": { "profile": {
"invite_messages": { "invite_messages": {
+15
View File
@@ -52,6 +52,21 @@ const mutualGraph = {
await sqliteService.executeNonQuery('COMMIT'); await sqliteService.executeNonQuery('COMMIT');
return; return;
} }
// Also clean links for friends in the new entries even if they
// were previously opted_out. We have fresh data for them now so
// old links must not linger.
let idsToClean = '';
pairs.forEach((_, friendId) => {
if (!friendId) return;
const safe = friendId.replace(/'/g, "''");
idsToClean += `'${safe}',`;
});
if (idsToClean) {
idsToClean = idsToClean.slice(0, -1);
await sqliteService.executeNonQuery(
`DELETE FROM ${linkTable} WHERE friend_id IN (${idsToClean})`
);
}
let friendValues = ''; let friendValues = '';
let edgeValues = ''; let edgeValues = '';
pairs.forEach((mutualIds, friendId) => { pairs.forEach((mutualIds, friendId) => {
+1
View File
@@ -234,6 +234,7 @@ export function createDefaultUserRef(json) {
$timeSpent: 0, $timeSpent: 0,
$lastSeen: '', $lastSeen: '',
$mutualCount: 0, $mutualCount: 0,
$mutualOptedOut: false,
$nickName: '', $nickName: '',
$previousLocation: '', $previousLocation: '',
$customTag: '', $customTag: '',
+8 -11
View File
@@ -322,6 +322,14 @@ export const useChartsStore = defineStore('Charts', () => {
mutualGraphStatus.friendSignature = friendCount.value; mutualGraphStatus.friendSignature = friendCount.value;
mutualGraphStatus.needsRefetch = false; mutualGraphStatus.needsRefetch = false;
// Write meta first so saveMutualGraphSnapshot's DELETE
// uses up-to-date opted_out flags to decide what to preserve.
// If this fails, we must NOT proceed with snapshot save because
// the DELETE would use stale meta and corrupt data.
if (metaEntries.size > 0) {
await database.bulkUpsertMutualGraphMeta(metaEntries);
}
try { try {
const entries = new Map(); const entries = new Map();
mutualMap.forEach((value, friendId) => { mutualMap.forEach((value, friendId) => {
@@ -348,17 +356,6 @@ export const useChartsStore = defineStore('Charts', () => {
); );
} }
try {
if (metaEntries.size > 0) {
await database.bulkUpsertMutualGraphMeta(metaEntries);
}
} catch (metaErr) {
console.error(
'[MutualNetworkGraph] Failed to write meta',
metaErr
);
}
markMutualGraphLoaded({ notify: true }); markMutualGraphLoaded({ notify: true });
return mutualMap; return mutualMap;
} catch (err) { } catch (err) {
+34
View File
@@ -61,6 +61,7 @@ export const useFriendStore = defineStore('Friend', () => {
let pendingSortedFriendsRebuild = false; let pendingSortedFriendsRebuild = false;
let allUserStatsRequestId = 0; let allUserStatsRequestId = 0;
let allUserMutualCountRequestId = 0; let allUserMutualCountRequestId = 0;
let allUserMutualOptedOutRequestId = 0;
const derivedDebugCounters = reactive({ const derivedDebugCounters = reactive({
allFavoriteFriendIds: 0, allFavoriteFriendIds: 0,
@@ -904,6 +905,11 @@ export const useFriendStore = defineStore('Friend', () => {
return; return;
} }
runInSortedFriendsBatch(() => { runInSortedFriendsBatch(() => {
for (const ctx of friends.values()) {
if (ctx?.ref) {
ctx.ref.$mutualCount = 0;
}
}
for (const [userId, mutualCount] of mutualCountMap.entries()) { for (const [userId, mutualCount] of mutualCountMap.entries()) {
const ref = friends.get(userId); const ref = friends.get(userId);
if (ref?.ref) { if (ref?.ref) {
@@ -914,6 +920,33 @@ export const useFriendStore = defineStore('Friend', () => {
}); });
} }
/**
*
*/
async function getAllUserMutualOptedOut() {
if (!friends.size) {
return;
}
const requestId = ++allUserMutualOptedOutRequestId;
const metaMap = await database.getMutualGraphMeta();
if (requestId !== allUserMutualOptedOutRequestId) {
return;
}
runInSortedFriendsBatch(() => {
for (const ctx of friends.values()) {
if (ctx?.ref) {
ctx.ref.$mutualOptedOut = false;
}
}
for (const [userId, meta] of metaMap.entries()) {
const ref = friends.get(userId);
if (ref?.ref) {
ref.ref.$mutualOptedOut = Boolean(meta.optedOut);
}
}
});
}
/** /**
* *
* @param {string} id * @param {string} id
@@ -1398,6 +1431,7 @@ export const useFriendStore = defineStore('Friend', () => {
updateOnlineFriendCounter, updateOnlineFriendCounter,
getAllUserStats, getAllUserStats,
getAllUserMutualCount, getAllUserMutualCount,
getAllUserMutualOptedOut,
initFriendLog, initFriendLog,
migrateFriendLog, migrateFriendLog,
getFriendLog, getFriendLog,
+1
View File
@@ -56,6 +56,7 @@ export interface VrcxUser extends GetUserResponse {
$timeSpent: number; $timeSpent: number;
$lastSeen: string; $lastSeen: string;
$mutualCount: number; $mutualCount: number;
$mutualOptedOut: boolean;
$nickName: string; $nickName: string;
$previousLocation: string; $previousLocation: string;
$customTag: string; $customTag: string;
+17 -7
View File
@@ -78,7 +78,7 @@
@update:modelValue="toggleFriendsListBulkUnfriendMode" /> @update:modelValue="toggleFriendsListBulkUnfriendMode" />
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
<Button variant="outline" class="mr-2" @click="openChartsTab"> <Button variant="outline" class="mr-2" :disabled="isMutualFetching || isMutualOptOut" @click="loadMutualFriends">
{{ t('view.friend_list.load_mutual_friends') }} {{ t('view.friend_list.load_mutual_friends') }}
</Button> </Button>
@@ -133,9 +133,11 @@
import { import {
useAppearanceSettingsStore, useAppearanceSettingsStore,
useChartsStore,
useFriendStore, useFriendStore,
useModalStore, useModalStore,
useSearchStore useSearchStore,
useUserStore
} from '../../stores'; } from '../../stores';
import { friendRequest, userRequest } from '../../api'; import { friendRequest, userRequest } from '../../api';
import { DataTableLayout } from '../../components/ui/data-table'; import { DataTableLayout } from '../../components/ui/data-table';
@@ -144,7 +146,6 @@
import { createColumns } from './columns.jsx'; import { createColumns } from './columns.jsx';
import { localeIncludes } from '../../shared/utils'; import { localeIncludes } from '../../shared/utils';
import removeConfusables, { removeWhitespace } from '../../services/confusables'; import removeConfusables, { removeWhitespace } from '../../services/confusables';
import { router } from '../../plugins/router';
import { useVrcxVueTable } from '../../lib/table/useVrcxVueTable'; import { useVrcxVueTable } from '../../lib/table/useVrcxVueTable';
import { showUserDialog } from '../../coordinators/userCoordinator'; import { showUserDialog } from '../../coordinators/userCoordinator';
import { confirmDeleteFriend, handleFriendDelete } from '../../coordinators/friendRelationshipCoordinator'; import { confirmDeleteFriend, handleFriendDelete } from '../../coordinators/friendRelationshipCoordinator';
@@ -156,7 +157,10 @@
const { friends, allFavoriteFriendIds } = storeToRefs(useFriendStore()); const { friends, allFavoriteFriendIds } = storeToRefs(useFriendStore());
const modalStore = useModalStore(); const modalStore = useModalStore();
const { getAllUserStats, getAllUserMutualCount } = useFriendStore(); const { getAllUserStats, getAllUserMutualCount, getAllUserMutualOptedOut } = useFriendStore();
const chartsStore = useChartsStore();
const isMutualFetching = computed(() => chartsStore.mutualGraphStatus.isFetching);
const isMutualOptOut = computed(() => Boolean(useUserStore().currentUser?.hasSharedConnectionsOptOut));
const appearanceSettingsStore = useAppearanceSettingsStore(); const appearanceSettingsStore = useAppearanceSettingsStore();
const { randomUserColours } = storeToRefs(appearanceSettingsStore); const { randomUserColours } = storeToRefs(appearanceSettingsStore);
const { userImage } = useUserDisplay(); const { userImage } = useUserDisplay();
@@ -303,7 +307,8 @@
} }
friendStatsRefreshInFlight = Promise.allSettled([ friendStatsRefreshInFlight = Promise.allSettled([
getAllUserStats(), getAllUserStats(),
getAllUserMutualCount() getAllUserMutualCount(),
getAllUserMutualOptedOut()
]).then((results) => { ]).then((results) => {
if (results.every((result) => result.status === 'fulfilled')) { if (results.every((result) => result.status === 'fulfilled')) {
lastFriendStatsRefreshAt = Date.now(); lastFriendStatsRefreshAt = Date.now();
@@ -556,8 +561,13 @@
/** /**
* *
*/ */
function openChartsTab() { async function loadMutualFriends() {
router.push({ name: 'charts' }); if (isMutualFetching.value) return;
await chartsStore.fetchMutualGraph();
await Promise.allSettled([
getAllUserMutualCount(),
getAllUserMutualOptedOut()
]);
} }
/** /**
+16 -2
View File
@@ -1,4 +1,4 @@
import { ArrowUpDown, User, UserMinus } from 'lucide-vue-next'; import { ArrowUpDown, EyeOff, User, UserMinus } from 'lucide-vue-next';
import { import {
Avatar, Avatar,
AvatarFallback, AvatarFallback,
@@ -391,7 +391,21 @@ export const createColumns = ({
}, },
cell: ({ row }) => { cell: ({ row }) => {
const count = row.original?.$mutualCount; const count = row.original?.$mutualCount;
return count ? <span>{count}</span> : null; const optedOut = row.original?.$mutualOptedOut;
if (!count && !optedOut) return null;
return (
<span class="inline-flex items-center gap-1">
{count || null}
{optedOut ? (
<TooltipWrapper
side="top"
content={t('table.friendList.mutualOptedOut')}
>
<EyeOff class="h-3.5 w-3.5 text-muted-foreground" />
</TooltipWrapper>
) : null}
</span>
);
} }
}, },
{ {