feat: add option to refresh mutual friends data for individual nodes in the graph context menu

This commit is contained in:
pa
2026-03-25 22:05:18 +09:00
parent a811100038
commit 6b728951fa
5 changed files with 317 additions and 61 deletions

View File

@@ -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": {

View File

@@ -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() {

View File

@@ -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;
}
};

View File

@@ -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<void> }} [options.rateLimiter]
* @param {() => boolean} [options.isCancelled]
* @returns {Promise<Array>} 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
};
});

View File

@@ -250,6 +250,11 @@
{{ t('view.charts.mutual_friend.context_menu.view_details') }}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem @click="handleNodeMenuRefresh">
<RefreshCwIcon class="mr-2 size-4" />
{{ t('view.charts.mutual_friend.context_menu.refresh_mutuals') }}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem @click="handleNodeMenuHide">
<EyeOffIcon class="mr-2 size-4" />
{{ 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;
}
}
</script>