diff --git a/src/localization/en.json b/src/localization/en.json index dfce9e9a..cd5195d7 100644 --- a/src/localization/en.json +++ b/src/localization/en.json @@ -17,7 +17,8 @@ "reset": "Reset", "view_details": "View Details", "configure": "Configure", - "refresh": "Refresh" + "refresh": "Refresh", + "cancel": "Cancel" }, "sort_by": "Sort by:", "time_units": { @@ -568,7 +569,14 @@ }, "context_menu": { "view_details": "View Details", - "hide_friend": "Hide from Graph" + "hide_friend": "Hide from Graph", + "refresh_mutuals": "Refresh Mutuals", + "confirm_non_friend_title": "Not a Friend", + "confirm_non_friend_message": "This user is no longer your friend. Do you still want to fetch their mutual friends data?", + "refresh_success": "Mutual friends data updated for {name}", + "user_opted_out": "This user has disabled mutual friends sharing. Existing data has been preserved.", + "refresh_error": "Failed to refresh mutual friends data", + "last_fetched": "Last fetched" } }, "hot_worlds": { diff --git a/src/services/database/index.js b/src/services/database/index.js index 7c70642b..b0b7c01f 100644 --- a/src/services/database/index.js +++ b/src/services/database/index.js @@ -147,6 +147,9 @@ const database = { await sqliteService.executeNonQuery( `CREATE TABLE IF NOT EXISTS ${dbVars.userPrefix}_mutual_graph_links (friend_id TEXT NOT NULL, mutual_id TEXT NOT NULL, PRIMARY KEY(friend_id, mutual_id))` ); + await sqliteService.executeNonQuery( + `CREATE TABLE IF NOT EXISTS ${dbVars.userPrefix}_mutual_graph_meta (friend_id TEXT PRIMARY KEY, last_fetched_at TEXT, opted_out INTEGER DEFAULT 0)` + ); }, async initTables() { diff --git a/src/services/database/mutualGraph.js b/src/services/database/mutualGraph.js index 2d59d249..0081c586 100644 --- a/src/services/database/mutualGraph.js +++ b/src/services/database/mutualGraph.js @@ -38,11 +38,16 @@ const mutualGraph = { } const friendTable = `${dbVars.userPrefix}_mutual_graph_friends`; const linkTable = `${dbVars.userPrefix}_mutual_graph_links`; + const metaTable = `${dbVars.userPrefix}_mutual_graph_meta`; const pairs = entries instanceof Map ? entries : new Map(); await sqliteService.executeNonQuery('BEGIN'); try { - await sqliteService.executeNonQuery(`DELETE FROM ${friendTable}`); - await sqliteService.executeNonQuery(`DELETE FROM ${linkTable}`); + await sqliteService.executeNonQuery( + `DELETE FROM ${linkTable} WHERE friend_id NOT IN (SELECT friend_id FROM ${metaTable} WHERE opted_out = 1)` + ); + await sqliteService.executeNonQuery( + `DELETE FROM ${friendTable} WHERE friend_id NOT IN (SELECT friend_id FROM ${metaTable} WHERE opted_out = 1)` + ); if (pairs.size === 0) { await sqliteService.executeNonQuery('COMMIT'); return; @@ -131,6 +136,61 @@ const mutualGraph = { } }, `SELECT mutual_id, COUNT(*) FROM ${linkTable} GROUP BY mutual_id`); return mutualCountMap; + }, + + async upsertMutualGraphMeta(friendId, { lastFetchedAt, optedOut }) { + if (!dbVars.userPrefix || !friendId) { + return; + } + const metaTable = `${dbVars.userPrefix}_mutual_graph_meta`; + const escapedId = friendId.replace(/'/g, "''"); + const time = (lastFetchedAt || new Date().toISOString()).replace( + /'/g, + "''" + ); + const optedOutInt = optedOut ? 1 : 0; + await sqliteService.executeNonQuery( + `INSERT OR REPLACE INTO ${metaTable} (friend_id, last_fetched_at, opted_out) VALUES ('${escapedId}', '${time}', ${optedOutInt})` + ); + }, + + async bulkUpsertMutualGraphMeta(entries) { + if (!dbVars.userPrefix || !entries || entries.size === 0) { + return; + } + const metaTable = `${dbVars.userPrefix}_mutual_graph_meta`; + let values = ''; + const now = new Date().toISOString(); + entries.forEach(({ optedOut }, friendId) => { + if (!friendId) return; + const escapedId = friendId.replace(/'/g, "''"); + const optedOutInt = optedOut ? 1 : 0; + values += `('${escapedId}', '${now}', ${optedOutInt}),`; + }); + if (values) { + values = values.slice(0, -1); + await sqliteService.executeNonQuery( + `INSERT OR REPLACE INTO ${metaTable} (friend_id, last_fetched_at, opted_out) VALUES ${values}` + ); + } + }, + + async getMutualGraphMeta() { + const metaMap = new Map(); + if (!dbVars.userPrefix) { + return metaMap; + } + const metaTable = `${dbVars.userPrefix}_mutual_graph_meta`; + await sqliteService.execute((dbRow) => { + const friendId = dbRow[0]; + if (friendId) { + metaMap.set(friendId, { + lastFetchedAt: dbRow[1] || null, + optedOut: dbRow[2] === 1 + }); + } + }, `SELECT friend_id, last_fetched_at, opted_out FROM ${metaTable}`); + return metaMap; } }; diff --git a/src/stores/charts.js b/src/stores/charts.js index b0e1d1dc..d5e180ca 100644 --- a/src/stores/charts.js +++ b/src/stores/charts.js @@ -139,6 +139,99 @@ export const useChartsStore = defineStore('Charts', () => { mutualGraphStatus.cancelRequested = true; } + /** + * Shared helper: fetch mutual friends for a single userId. + * @param {string} userId + * @param {object} [options] + * @param {{ wait(): Promise }} [options.rateLimiter] + * @param {() => boolean} [options.isCancelled] + * @returns {Promise} collected mutual friend entries + */ + async function fetchMutualFriendsForUser(userId, options = {}) { + const { rateLimiter, isCancelled = () => false } = options; + const collected = []; + let offset = 0; + + while (true) { + if (isCancelled()) break; + if (rateLimiter) await rateLimiter.wait(); + if (isCancelled()) break; + + const args = await executeWithBackoff( + () => { + if (isCancelled()) throw new Error('cancelled'); + return userRequest.getMutualFriends({ + userId, + offset, + n: 100 + }); + }, + { + maxRetries: 4, + baseDelay: 500, + shouldRetry: (err) => + err?.status === 429 || + (err?.message || '').includes('429') + } + ).catch((err) => { + if ((err?.message || '') === 'cancelled') return null; + throw err; + }); + + if (!args || isCancelled()) break; + + collected.push( + ...args.json.filter((entry) => + isValidMutualIdentifier(entry?.id) + ) + ); + + if (args.json.length < 100) break; + offset += args.json.length; + } + + return collected; + } + + /** + * Fetch mutual friends for a single friend, independent of the full graph fetch. + * @param {string} friendId + * @returns {Promise<{success: boolean, mutuals: Array, optedOut: boolean}>} + */ + async function fetchSingleFriendMutuals(friendId) { + if (!friendId || isOptOut.value) { + return { success: false, mutuals: [], optedOut: false }; + } + + try { + const mutuals = await fetchMutualFriendsForUser(friendId); + + const mutualIds = mutuals + .map((entry) => normalizeIdentifier(entry?.id)) + .filter(isValidMutualIdentifier); + await database.updateMutualsForFriend(friendId, mutualIds); + await database.upsertMutualGraphMeta(friendId, { + optedOut: false + }); + + return { success: true, mutuals, optedOut: false }; + } catch (err) { + const status = err?.status; + if (status === 403 || status === 404) { + await database.upsertMutualGraphMeta(friendId, { + optedOut: true + }); + return { success: false, mutuals: [], optedOut: true }; + } + console.error( + '[MutualNetworkGraph] Single fetch error', + friendId, + err + ); + return { success: false, mutuals: [], optedOut: false }; + } + } + async function fetchMutualGraph() { if (mutualGraphStatus.isFetching || isOptOut.value) return null; @@ -157,51 +250,6 @@ export const useChartsStore = defineStore('Charts', () => { const isCancelled = () => mutualGraphStatus.cancelRequested === true; - const fetchMutualFriends = async (userId) => { - const collected = []; - let offset = 0; - - while (true) { - if (isCancelled()) break; - await rateLimiter.wait(); - if (isCancelled()) break; - - const args = await executeWithBackoff( - () => { - if (isCancelled()) throw new Error('cancelled'); - return userRequest.getMutualFriends({ - userId, - offset, - n: 100 - }); - }, - { - maxRetries: 4, - baseDelay: 500, - shouldRetry: (err) => - err?.status === 429 || - (err?.message || '').includes('429') - } - ).catch((err) => { - if ((err?.message || '') === 'cancelled') return null; - throw err; - }); - - if (!args || isCancelled()) break; - - collected.push( - ...args.json.filter((entry) => - isValidMutualIdentifier(entry?.id) - ) - ); - - if (args.json.length < 100) break; - offset += args.json.length; - } - - return collected; - }; - mutualGraphStatus.isFetching = true; mutualGraphStatus.completionNotified = false; mutualGraphStatus.needsRefetch = false; @@ -211,6 +259,7 @@ export const useChartsStore = defineStore('Charts', () => { const friendSnapshot = Array.from(friendStore.friends.values()); const mutualMap = new Map(); + const metaEntries = new Map(); let cancelled = false; @@ -225,17 +274,25 @@ export const useChartsStore = defineStore('Charts', () => { } try { - const mutuals = await fetchMutualFriends(friend.id); + const mutuals = await fetchMutualFriendsForUser(friend.id, { + rateLimiter, + isCancelled + }); if (isCancelled()) { cancelled = true; break; } mutualMap.set(friend.id, { friend, mutuals }); + metaEntries.set(friend.id, { optedOut: false }); } catch (err) { if ((err?.message || '') === 'cancelled' || isCancelled()) { cancelled = true; break; } + const status = err?.status; + if (status === 403 || status === 404) { + metaEntries.set(friend.id, { optedOut: true }); + } console.warn( '[MutualNetworkGraph] Skipping friend due to fetch error', friend.id, @@ -291,6 +348,17 @@ 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) { @@ -308,6 +376,7 @@ export const useChartsStore = defineStore('Charts', () => { resetMutualGraphState, markMutualGraphLoaded, requestMutualGraphCancel, - fetchMutualGraph + fetchMutualGraph, + fetchSingleFriendMutuals }; }); diff --git a/src/views/Charts/components/MutualFriends.vue b/src/views/Charts/components/MutualFriends.vue index 16cf4ed3..f9224565 100644 --- a/src/views/Charts/components/MutualFriends.vue +++ b/src/views/Charts/components/MutualFriends.vue @@ -250,6 +250,11 @@ {{ t('view.charts.mutual_friend.context_menu.view_details') }} + + + {{ t('view.charts.mutual_friend.context_menu.refresh_mutuals') }} + + {{ t('view.charts.mutual_friend.context_menu.hide_friend') }} @@ -284,7 +289,13 @@ ContextMenuSeparator, ContextMenuTrigger } from '@/components/ui/context-menu'; - import { Check as CheckIcon, EyeOff as EyeOffIcon, Settings, User as UserIcon } from 'lucide-vue-next'; + import { + Check as CheckIcon, + EyeOff as EyeOffIcon, + RefreshCw as RefreshCwIcon, + Settings, + User as UserIcon + } from 'lucide-vue-next'; import { Button } from '@/components/ui/button'; import { Progress } from '@/components/ui/progress'; import { Slider } from '@/components/ui/slider'; @@ -552,6 +563,8 @@ const selectedFriendId = ref(null); const contextMenuNodeId = ref(null); + const graphMeta = ref(new Map()); + const isRefreshingNode = ref(false); const EXCLUDED_FRIENDS_KEY = 'VRCX_MutualGraphExcludedFriends'; const excludedFriendIds = useLocalStorage(EXCLUDED_FRIENDS_KEY, []); @@ -828,7 +841,7 @@ }); } - async function buildGraphFromMutualMap(mutualMap) { + async function buildGraphFromMutualMap(mutualMap, meta = null) { const graph = new Graph({ type: 'undirected', multi: false, @@ -880,7 +893,13 @@ const degree = nodeDegree.get(id) || 0; const size = 4 + (maxDegree ? (degree / maxDegree) * 18 : 0); const label = truncateLabelText(nodeNames.get(id) || id); - graph.mergeNodeAttributes(id, { label, size, type: 'border' }); + const attrs = { label, size, type: 'border' }; + if (meta?.has(id)) { + const m = meta.get(id); + attrs.optedOut = m.optedOut; + attrs.lastFetchedAt = m.lastFetchedAt; + } + graph.mergeNodeAttributes(id, attrs); }); if (graph.order > 1) { @@ -939,6 +958,7 @@ const fontSize = settings.labelSize ?? 12; const font = settings.labelFont ?? 'sans-serif'; + const smallFontSize = Math.max(9, fontSize - 2); ctx.font = `${fontSize}px ${font}`; ctx.textBaseline = 'middle'; @@ -946,9 +966,21 @@ const paddingX = 6; const paddingY = 4; - const textWidth = ctx.measureText(data.label).width; - const w = textWidth + paddingX * 2; - const h = fontSize + paddingY * 2; + let subLine = ''; + if (data.lastFetchedAt) { + const d = new Date(data.lastFetchedAt); + subLine = `${t('view.charts.mutual_friend.context_menu.last_fetched')}: ${d.toLocaleString()}`; + } + + const labelWidth = ctx.measureText(data.label).width; + ctx.font = `${smallFontSize}px ${font}`; + const subWidth = subLine ? ctx.measureText(subLine).width : 0; + ctx.font = `${fontSize}px ${font}`; + + const w = Math.max(labelWidth, subWidth) + paddingX * 2; + const lineHeight = fontSize + paddingY; + const totalLines = subLine ? 2 : 1; + const h = lineHeight * totalLines + paddingY; const x = data.x + data.size - 5; const y = data.y - h / 2; @@ -961,8 +993,18 @@ ctx.fillStyle = 'rgba(255, 255, 255, 1)'; ctx.fillRect(x, y, w, h); + ctx.shadowBlur = 0; + ctx.shadowColor = 'transparent'; + ctx.fillStyle = '#111827'; - ctx.fillText(data.label, x + paddingX, y + h / 2); + ctx.font = `${fontSize}px ${font}`; + ctx.fillText(data.label, x + paddingX, y + paddingY + fontSize / 2); + + if (subLine) { + ctx.fillStyle = data.optedOut ? '#dc2626' : '#6b7280'; + ctx.font = `${smallFontSize}px ${font}`; + ctx.fillText(subLine, x + paddingX, y + paddingY + lineHeight + smallFontSize / 2); + } } }); } else { @@ -990,8 +1032,12 @@ sigmaInstance.setSetting('nodeReducer', (node, data) => { const res = { ...data }; + if (data.optedOut) { + res.borderColor = '#9ca3af'; + } + if (!hovered) { - res.color = data.color; + res.color = data.optedOut ? '#d1d5db' : data.color; res.zIndex = 1; return res; } @@ -1081,7 +1127,7 @@ async function applyGraph(mutualMap) { lastMutualMap = mutualMap; - const graph = await buildGraphFromMutualMap(mutualMap); + const graph = await buildGraphFromMutualMap(mutualMap, graphMeta.value); currentGraph = graph; renderGraph(graph); } @@ -1096,7 +1142,9 @@ // loadingToastId.value = toast.info(t('view.charts.mutual_friend.status.loading_cache')); try { - const snapshot = await database.getMutualGraphSnapshot(); + const [snapshot, meta] = await Promise.all([database.getMutualGraphSnapshot(), database.getMutualGraphMeta()]); + graphMeta.value = meta; + if (!snapshot || snapshot.size === 0) { if (totalFriends.value === 0) { showStatusMessage(t('view.charts.mutual_friend.status.no_friends_to_process'), 'info'); @@ -1210,4 +1258,72 @@ } contextMenuNodeId.value = null; } + + async function handleNodeMenuRefresh() { + const nodeId = contextMenuNodeId.value; + contextMenuNodeId.value = null; + if (!nodeId || isRefreshingNode.value) return; + + const isFriend = friends.value?.has(nodeId); + + if (!isFriend) { + try { + const { ok } = await modalStore.confirm({ + title: t('view.charts.mutual_friend.context_menu.confirm_non_friend_title'), + description: t('view.charts.mutual_friend.context_menu.confirm_non_friend_message'), + confirmText: t('common.actions.confirm'), + cancelText: t('common.actions.cancel') + }); + if (!ok) return; + } catch { + return; + } + } + + isRefreshingNode.value = true; + try { + const result = await chartsStore.fetchSingleFriendMutuals(nodeId); + + if (result.optedOut) { + toast.warning(t('view.charts.mutual_friend.context_menu.user_opted_out'), { duration: 5000 }); + graphMeta.value.set(nodeId, { + lastFetchedAt: new Date().toISOString(), + optedOut: true + }); + } else if (result.success) { + const cached = cachedUsers.get(nodeId); + const name = cached?.displayName || nodeId; + toast.success(t('view.charts.mutual_friend.context_menu.refresh_success', { name }), { duration: 3000 }); + graphMeta.value.set(nodeId, { + lastFetchedAt: new Date().toISOString(), + optedOut: false + }); + } else { + toast.error(t('view.charts.mutual_friend.context_menu.refresh_error'), { duration: 4000 }); + return; + } + + const snapshot = await database.getMutualGraphSnapshot(); + if (snapshot && snapshot.size > 0) { + const mutualMap = new Map(); + snapshot.forEach((mutualIds, fId) => { + if (!fId) return; + const friendEntry = friends.value?.get ? friends.value.get(fId) : undefined; + const fallbackRef = friendEntry?.ref || cachedUsers.get(fId); + let normalizedMutuals = Array.isArray(mutualIds) ? mutualIds : []; + normalizedMutuals = normalizedMutuals.filter((id) => id != 'usr_00000000-0000-0000-0000-000000000000'); + mutualMap.set(fId, { + friend: friendEntry || (fallbackRef ? { id: fId, ref: fallbackRef } : { id: fId }), + mutuals: normalizedMutuals.map((id) => ({ id })) + }); + }); + await applyGraph(mutualMap); + } + } catch (err) { + console.error('[MutualNetworkGraph] Refresh node error', err); + toast.error(t('view.charts.mutual_friend.context_menu.refresh_error'), { duration: 4000 }); + } finally { + isRefreshingNode.value = false; + } + }