mirror of
https://github.com/vrcx-team/VRCX.git
synced 2026-04-06 00:32:02 +02:00
feat: add option to refresh mutual friends data for individual nodes in the graph context menu
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user