From f71ac77377c661d658d9219cd8c1c933b0b00337 Mon Sep 17 00:00:00 2001 From: pa Date: Fri, 27 Mar 2026 12:20:49 +0900 Subject: [PATCH] fix: load mutual friends button and mutual opt-out status in friend list --- src/localization/en.json | 3 ++- src/services/database/mutualGraph.js | 15 ++++++++++++ src/shared/utils/userTransforms.js | 1 + src/stores/charts.js | 19 +++++++--------- src/stores/friend.js | 34 ++++++++++++++++++++++++++++ src/types/api/user.d.ts | 1 + src/views/FriendList/FriendList.vue | 24 ++++++++++++++------ src/views/FriendList/columns.jsx | 18 +++++++++++++-- 8 files changed, 94 insertions(+), 21 deletions(-) diff --git a/src/localization/en.json b/src/localization/en.json index cd5195d7..2b2ece93 100644 --- a/src/localization/en.json +++ b/src/localization/en.json @@ -2861,7 +2861,8 @@ "lastLogin": "Last Login", "dateJoined": "Date Joined", "unfriend": "Unfriend", - "mutualFriends": "Mutual Friends" + "mutualFriends": "Mutual Friends", + "mutualOptedOut": "This user has disabled mutual friends sharing. Showing cached data." }, "profile": { "invite_messages": { diff --git a/src/services/database/mutualGraph.js b/src/services/database/mutualGraph.js index 0081c586..afb9b1a9 100644 --- a/src/services/database/mutualGraph.js +++ b/src/services/database/mutualGraph.js @@ -52,6 +52,21 @@ const mutualGraph = { await sqliteService.executeNonQuery('COMMIT'); 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 edgeValues = ''; pairs.forEach((mutualIds, friendId) => { diff --git a/src/shared/utils/userTransforms.js b/src/shared/utils/userTransforms.js index a713a868..590df683 100644 --- a/src/shared/utils/userTransforms.js +++ b/src/shared/utils/userTransforms.js @@ -234,6 +234,7 @@ export function createDefaultUserRef(json) { $timeSpent: 0, $lastSeen: '', $mutualCount: 0, + $mutualOptedOut: false, $nickName: '', $previousLocation: '', $customTag: '', diff --git a/src/stores/charts.js b/src/stores/charts.js index d5e180ca..02615a6b 100644 --- a/src/stores/charts.js +++ b/src/stores/charts.js @@ -322,6 +322,14 @@ export const useChartsStore = defineStore('Charts', () => { mutualGraphStatus.friendSignature = friendCount.value; 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 { const entries = new Map(); 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 }); return mutualMap; } catch (err) { diff --git a/src/stores/friend.js b/src/stores/friend.js index 006c6097..fc1b7b96 100644 --- a/src/stores/friend.js +++ b/src/stores/friend.js @@ -61,6 +61,7 @@ export const useFriendStore = defineStore('Friend', () => { let pendingSortedFriendsRebuild = false; let allUserStatsRequestId = 0; let allUserMutualCountRequestId = 0; + let allUserMutualOptedOutRequestId = 0; const derivedDebugCounters = reactive({ allFavoriteFriendIds: 0, @@ -904,6 +905,11 @@ export const useFriendStore = defineStore('Friend', () => { return; } runInSortedFriendsBatch(() => { + for (const ctx of friends.values()) { + if (ctx?.ref) { + ctx.ref.$mutualCount = 0; + } + } for (const [userId, mutualCount] of mutualCountMap.entries()) { const ref = friends.get(userId); 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 @@ -1398,6 +1431,7 @@ export const useFriendStore = defineStore('Friend', () => { updateOnlineFriendCounter, getAllUserStats, getAllUserMutualCount, + getAllUserMutualOptedOut, initFriendLog, migrateFriendLog, getFriendLog, diff --git a/src/types/api/user.d.ts b/src/types/api/user.d.ts index b2bcc613..97ddcf97 100644 --- a/src/types/api/user.d.ts +++ b/src/types/api/user.d.ts @@ -56,6 +56,7 @@ export interface VrcxUser extends GetUserResponse { $timeSpent: number; $lastSeen: string; $mutualCount: number; + $mutualOptedOut: boolean; $nickName: string; $previousLocation: string; $customTag: string; diff --git a/src/views/FriendList/FriendList.vue b/src/views/FriendList/FriendList.vue index 4aeee6ee..3d23f1ef 100644 --- a/src/views/FriendList/FriendList.vue +++ b/src/views/FriendList/FriendList.vue @@ -78,7 +78,7 @@ @update:modelValue="toggleFriendsListBulkUnfriendMode" />
- @@ -133,9 +133,11 @@ import { useAppearanceSettingsStore, + useChartsStore, useFriendStore, useModalStore, - useSearchStore + useSearchStore, + useUserStore } from '../../stores'; import { friendRequest, userRequest } from '../../api'; import { DataTableLayout } from '../../components/ui/data-table'; @@ -144,7 +146,6 @@ import { createColumns } from './columns.jsx'; import { localeIncludes } from '../../shared/utils'; import removeConfusables, { removeWhitespace } from '../../services/confusables'; - import { router } from '../../plugins/router'; import { useVrcxVueTable } from '../../lib/table/useVrcxVueTable'; import { showUserDialog } from '../../coordinators/userCoordinator'; import { confirmDeleteFriend, handleFriendDelete } from '../../coordinators/friendRelationshipCoordinator'; @@ -156,7 +157,10 @@ const { friends, allFavoriteFriendIds } = storeToRefs(useFriendStore()); 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 { randomUserColours } = storeToRefs(appearanceSettingsStore); const { userImage } = useUserDisplay(); @@ -303,7 +307,8 @@ } friendStatsRefreshInFlight = Promise.allSettled([ getAllUserStats(), - getAllUserMutualCount() + getAllUserMutualCount(), + getAllUserMutualOptedOut() ]).then((results) => { if (results.every((result) => result.status === 'fulfilled')) { lastFriendStatsRefreshAt = Date.now(); @@ -556,8 +561,13 @@ /** * */ - function openChartsTab() { - router.push({ name: 'charts' }); + async function loadMutualFriends() { + if (isMutualFetching.value) return; + await chartsStore.fetchMutualGraph(); + await Promise.allSettled([ + getAllUserMutualCount(), + getAllUserMutualOptedOut() + ]); } /** diff --git a/src/views/FriendList/columns.jsx b/src/views/FriendList/columns.jsx index af93a957..3f405e73 100644 --- a/src/views/FriendList/columns.jsx +++ b/src/views/FriendList/columns.jsx @@ -1,4 +1,4 @@ -import { ArrowUpDown, User, UserMinus } from 'lucide-vue-next'; +import { ArrowUpDown, EyeOff, User, UserMinus } from 'lucide-vue-next'; import { Avatar, AvatarFallback, @@ -391,7 +391,21 @@ export const createColumns = ({ }, cell: ({ row }) => { const count = row.original?.$mutualCount; - return count ? {count} : null; + const optedOut = row.original?.$mutualOptedOut; + if (!count && !optedOut) return null; + return ( + + {count || null} + {optedOut ? ( + + + + ) : null} + + ); } }, {