mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-19 14:53:50 +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",
|
"reset": "Reset",
|
||||||
"view_details": "View Details",
|
"view_details": "View Details",
|
||||||
"configure": "Configure",
|
"configure": "Configure",
|
||||||
"refresh": "Refresh"
|
"refresh": "Refresh",
|
||||||
|
"cancel": "Cancel"
|
||||||
},
|
},
|
||||||
"sort_by": "Sort by:",
|
"sort_by": "Sort by:",
|
||||||
"time_units": {
|
"time_units": {
|
||||||
@@ -568,7 +569,14 @@
|
|||||||
},
|
},
|
||||||
"context_menu": {
|
"context_menu": {
|
||||||
"view_details": "View Details",
|
"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": {
|
"hot_worlds": {
|
||||||
|
|||||||
@@ -147,6 +147,9 @@ const database = {
|
|||||||
await sqliteService.executeNonQuery(
|
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))`
|
`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() {
|
async initTables() {
|
||||||
|
|||||||
@@ -38,11 +38,16 @@ const mutualGraph = {
|
|||||||
}
|
}
|
||||||
const friendTable = `${dbVars.userPrefix}_mutual_graph_friends`;
|
const friendTable = `${dbVars.userPrefix}_mutual_graph_friends`;
|
||||||
const linkTable = `${dbVars.userPrefix}_mutual_graph_links`;
|
const linkTable = `${dbVars.userPrefix}_mutual_graph_links`;
|
||||||
|
const metaTable = `${dbVars.userPrefix}_mutual_graph_meta`;
|
||||||
const pairs = entries instanceof Map ? entries : new Map();
|
const pairs = entries instanceof Map ? entries : new Map();
|
||||||
await sqliteService.executeNonQuery('BEGIN');
|
await sqliteService.executeNonQuery('BEGIN');
|
||||||
try {
|
try {
|
||||||
await sqliteService.executeNonQuery(`DELETE FROM ${friendTable}`);
|
await sqliteService.executeNonQuery(
|
||||||
await sqliteService.executeNonQuery(`DELETE FROM ${linkTable}`);
|
`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) {
|
if (pairs.size === 0) {
|
||||||
await sqliteService.executeNonQuery('COMMIT');
|
await sqliteService.executeNonQuery('COMMIT');
|
||||||
return;
|
return;
|
||||||
@@ -131,6 +136,61 @@ const mutualGraph = {
|
|||||||
}
|
}
|
||||||
}, `SELECT mutual_id, COUNT(*) FROM ${linkTable} GROUP BY mutual_id`);
|
}, `SELECT mutual_id, COUNT(*) FROM ${linkTable} GROUP BY mutual_id`);
|
||||||
return mutualCountMap;
|
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;
|
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() {
|
async function fetchMutualGraph() {
|
||||||
if (mutualGraphStatus.isFetching || isOptOut.value) return null;
|
if (mutualGraphStatus.isFetching || isOptOut.value) return null;
|
||||||
|
|
||||||
@@ -157,51 +250,6 @@ export const useChartsStore = defineStore('Charts', () => {
|
|||||||
|
|
||||||
const isCancelled = () => mutualGraphStatus.cancelRequested === true;
|
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.isFetching = true;
|
||||||
mutualGraphStatus.completionNotified = false;
|
mutualGraphStatus.completionNotified = false;
|
||||||
mutualGraphStatus.needsRefetch = false;
|
mutualGraphStatus.needsRefetch = false;
|
||||||
@@ -211,6 +259,7 @@ export const useChartsStore = defineStore('Charts', () => {
|
|||||||
|
|
||||||
const friendSnapshot = Array.from(friendStore.friends.values());
|
const friendSnapshot = Array.from(friendStore.friends.values());
|
||||||
const mutualMap = new Map();
|
const mutualMap = new Map();
|
||||||
|
const metaEntries = new Map();
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
@@ -225,17 +274,25 @@ export const useChartsStore = defineStore('Charts', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const mutuals = await fetchMutualFriends(friend.id);
|
const mutuals = await fetchMutualFriendsForUser(friend.id, {
|
||||||
|
rateLimiter,
|
||||||
|
isCancelled
|
||||||
|
});
|
||||||
if (isCancelled()) {
|
if (isCancelled()) {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
mutualMap.set(friend.id, { friend, mutuals });
|
mutualMap.set(friend.id, { friend, mutuals });
|
||||||
|
metaEntries.set(friend.id, { optedOut: false });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if ((err?.message || '') === 'cancelled' || isCancelled()) {
|
if ((err?.message || '') === 'cancelled' || isCancelled()) {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
const status = err?.status;
|
||||||
|
if (status === 403 || status === 404) {
|
||||||
|
metaEntries.set(friend.id, { optedOut: true });
|
||||||
|
}
|
||||||
console.warn(
|
console.warn(
|
||||||
'[MutualNetworkGraph] Skipping friend due to fetch error',
|
'[MutualNetworkGraph] Skipping friend due to fetch error',
|
||||||
friend.id,
|
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 });
|
markMutualGraphLoaded({ notify: true });
|
||||||
return mutualMap;
|
return mutualMap;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -308,6 +376,7 @@ export const useChartsStore = defineStore('Charts', () => {
|
|||||||
resetMutualGraphState,
|
resetMutualGraphState,
|
||||||
markMutualGraphLoaded,
|
markMutualGraphLoaded,
|
||||||
requestMutualGraphCancel,
|
requestMutualGraphCancel,
|
||||||
fetchMutualGraph
|
fetchMutualGraph,
|
||||||
|
fetchSingleFriendMutuals
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -250,6 +250,11 @@
|
|||||||
{{ t('view.charts.mutual_friend.context_menu.view_details') }}
|
{{ t('view.charts.mutual_friend.context_menu.view_details') }}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem @click="handleNodeMenuRefresh">
|
||||||
|
<RefreshCwIcon class="mr-2 size-4" />
|
||||||
|
{{ t('view.charts.mutual_friend.context_menu.refresh_mutuals') }}
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuSeparator />
|
||||||
<ContextMenuItem @click="handleNodeMenuHide">
|
<ContextMenuItem @click="handleNodeMenuHide">
|
||||||
<EyeOffIcon class="mr-2 size-4" />
|
<EyeOffIcon class="mr-2 size-4" />
|
||||||
{{ t('view.charts.mutual_friend.context_menu.hide_friend') }}
|
{{ t('view.charts.mutual_friend.context_menu.hide_friend') }}
|
||||||
@@ -284,7 +289,13 @@
|
|||||||
ContextMenuSeparator,
|
ContextMenuSeparator,
|
||||||
ContextMenuTrigger
|
ContextMenuTrigger
|
||||||
} from '@/components/ui/context-menu';
|
} 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 { Button } from '@/components/ui/button';
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
import { Slider } from '@/components/ui/slider';
|
import { Slider } from '@/components/ui/slider';
|
||||||
@@ -552,6 +563,8 @@
|
|||||||
const selectedFriendId = ref(null);
|
const selectedFriendId = ref(null);
|
||||||
|
|
||||||
const contextMenuNodeId = ref(null);
|
const contextMenuNodeId = ref(null);
|
||||||
|
const graphMeta = ref(new Map());
|
||||||
|
const isRefreshingNode = ref(false);
|
||||||
|
|
||||||
const EXCLUDED_FRIENDS_KEY = 'VRCX_MutualGraphExcludedFriends';
|
const EXCLUDED_FRIENDS_KEY = 'VRCX_MutualGraphExcludedFriends';
|
||||||
const excludedFriendIds = useLocalStorage(EXCLUDED_FRIENDS_KEY, []);
|
const excludedFriendIds = useLocalStorage(EXCLUDED_FRIENDS_KEY, []);
|
||||||
@@ -828,7 +841,7 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildGraphFromMutualMap(mutualMap) {
|
async function buildGraphFromMutualMap(mutualMap, meta = null) {
|
||||||
const graph = new Graph({
|
const graph = new Graph({
|
||||||
type: 'undirected',
|
type: 'undirected',
|
||||||
multi: false,
|
multi: false,
|
||||||
@@ -880,7 +893,13 @@
|
|||||||
const degree = nodeDegree.get(id) || 0;
|
const degree = nodeDegree.get(id) || 0;
|
||||||
const size = 4 + (maxDegree ? (degree / maxDegree) * 18 : 0);
|
const size = 4 + (maxDegree ? (degree / maxDegree) * 18 : 0);
|
||||||
const label = truncateLabelText(nodeNames.get(id) || id);
|
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) {
|
if (graph.order > 1) {
|
||||||
@@ -939,6 +958,7 @@
|
|||||||
|
|
||||||
const fontSize = settings.labelSize ?? 12;
|
const fontSize = settings.labelSize ?? 12;
|
||||||
const font = settings.labelFont ?? 'sans-serif';
|
const font = settings.labelFont ?? 'sans-serif';
|
||||||
|
const smallFontSize = Math.max(9, fontSize - 2);
|
||||||
|
|
||||||
ctx.font = `${fontSize}px ${font}`;
|
ctx.font = `${fontSize}px ${font}`;
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = 'middle';
|
||||||
@@ -946,9 +966,21 @@
|
|||||||
const paddingX = 6;
|
const paddingX = 6;
|
||||||
const paddingY = 4;
|
const paddingY = 4;
|
||||||
|
|
||||||
const textWidth = ctx.measureText(data.label).width;
|
let subLine = '';
|
||||||
const w = textWidth + paddingX * 2;
|
if (data.lastFetchedAt) {
|
||||||
const h = fontSize + paddingY * 2;
|
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 x = data.x + data.size - 5;
|
||||||
const y = data.y - h / 2;
|
const y = data.y - h / 2;
|
||||||
@@ -961,8 +993,18 @@
|
|||||||
ctx.fillStyle = 'rgba(255, 255, 255, 1)';
|
ctx.fillStyle = 'rgba(255, 255, 255, 1)';
|
||||||
ctx.fillRect(x, y, w, h);
|
ctx.fillRect(x, y, w, h);
|
||||||
|
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
ctx.shadowColor = 'transparent';
|
||||||
|
|
||||||
ctx.fillStyle = '#111827';
|
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 {
|
} else {
|
||||||
@@ -990,8 +1032,12 @@
|
|||||||
sigmaInstance.setSetting('nodeReducer', (node, data) => {
|
sigmaInstance.setSetting('nodeReducer', (node, data) => {
|
||||||
const res = { ...data };
|
const res = { ...data };
|
||||||
|
|
||||||
|
if (data.optedOut) {
|
||||||
|
res.borderColor = '#9ca3af';
|
||||||
|
}
|
||||||
|
|
||||||
if (!hovered) {
|
if (!hovered) {
|
||||||
res.color = data.color;
|
res.color = data.optedOut ? '#d1d5db' : data.color;
|
||||||
res.zIndex = 1;
|
res.zIndex = 1;
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
@@ -1081,7 +1127,7 @@
|
|||||||
|
|
||||||
async function applyGraph(mutualMap) {
|
async function applyGraph(mutualMap) {
|
||||||
lastMutualMap = mutualMap;
|
lastMutualMap = mutualMap;
|
||||||
const graph = await buildGraphFromMutualMap(mutualMap);
|
const graph = await buildGraphFromMutualMap(mutualMap, graphMeta.value);
|
||||||
currentGraph = graph;
|
currentGraph = graph;
|
||||||
renderGraph(graph);
|
renderGraph(graph);
|
||||||
}
|
}
|
||||||
@@ -1096,7 +1142,9 @@
|
|||||||
// loadingToastId.value = toast.info(t('view.charts.mutual_friend.status.loading_cache'));
|
// loadingToastId.value = toast.info(t('view.charts.mutual_friend.status.loading_cache'));
|
||||||
|
|
||||||
try {
|
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 (!snapshot || snapshot.size === 0) {
|
||||||
if (totalFriends.value === 0) {
|
if (totalFriends.value === 0) {
|
||||||
showStatusMessage(t('view.charts.mutual_friend.status.no_friends_to_process'), 'info');
|
showStatusMessage(t('view.charts.mutual_friend.status.no_friends_to_process'), 'info');
|
||||||
@@ -1210,4 +1258,72 @@
|
|||||||
}
|
}
|
||||||
contextMenuNodeId.value = null;
|
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>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user