From 236e2e85de7f719f0d93f85e627a93f0e169d38e Mon Sep 17 00:00:00 2001 From: pa Date: Tue, 3 Feb 2026 22:03:54 +0900 Subject: [PATCH] fix mutual friends fetch and render logic --- src/localization/en.json | 3 + src/stores/charts.js | 251 ++++++++++++++++-- src/views/Charts/components/MutualFriends.vue | 177 ++---------- 3 files changed, 255 insertions(+), 176 deletions(-) diff --git a/src/localization/en.json b/src/localization/en.json index 73c236ad..2af6a1d0 100644 --- a/src/localization/en.json +++ b/src/localization/en.json @@ -2533,6 +2533,9 @@ "common": { "no_data": "No data", "no_matching_records": "No matching records", + "actions": { + "open": "Open" + }, "time_units": { "y": "y", "d": "d", diff --git a/src/stores/charts.js b/src/stores/charts.js index 608c5cd9..1bcb2cd0 100644 --- a/src/stores/charts.js +++ b/src/stores/charts.js @@ -2,8 +2,13 @@ import { computed, reactive, watch } from 'vue'; import { defineStore } from 'pinia'; import { toast } from 'vue-sonner'; import { useI18n } from 'vue-i18n'; +import { useRouter } from 'vue-router'; +import { createRateLimiter, executeWithBackoff } from '../shared/utils'; +import { database } from '../service/database'; import { useFriendStore } from './friend'; +import { useUserStore } from './user'; +import { userRequest } from '../api'; function createDefaultFetchState() { return { @@ -13,9 +18,12 @@ function createDefaultFetchState() { export const useChartsStore = defineStore('Charts', () => { const friendStore = useFriendStore(); + const userStore = useUserStore(); const { t } = useI18n(); + const router = useRouter(); + const mutualGraphFetchState = reactive(createDefaultFetchState()); const mutualGraphStatus = reactive({ isFetching: false, @@ -27,6 +35,12 @@ export const useChartsStore = defineStore('Charts', () => { }); const friendCount = computed(() => friendStore.friends.size || 0); + const currentUser = computed( + () => userStore.currentUser?.value ?? userStore.currentUser + ); + const isOptOut = computed(() => + Boolean(currentUser.value?.hasSharedConnectionsOptOut) + ); function showInfoMessage(message, type) { const toastFn = toast[type] ?? toast; @@ -35,30 +49,40 @@ export const useChartsStore = defineStore('Charts', () => { watch( () => mutualGraphStatus.isFetching, - (isFetching, wasFetching) => { - if (isFetching) { - showInfoMessage( - t('view.charts.mutual_friend.notifications.start_fetching'), - 'info' - ); - mutualGraphStatus.completionNotified = false; - } else if ( - wasFetching && - mutualGraphStatus.hasFetched && - !mutualGraphStatus.completionNotified - ) { - mutualGraphStatus.completionNotified = true; - toast.success( - t( - 'view.charts.mutual_friend.notifications.mutual_friend_graph_ready_title' + (isFetching) => { + if (!isFetching) return; + showInfoMessage( + t('view.charts.mutual_friend.notifications.start_fetching'), + 'info' + ); + mutualGraphStatus.completionNotified = false; + } + ); + + watch( + () => [mutualGraphStatus.hasFetched, mutualGraphStatus.isFetching], + ([hasFetched, isFetching]) => { + if ( + !hasFetched || + isFetching || + mutualGraphStatus.completionNotified + ) + return; + mutualGraphStatus.completionNotified = true; + toast.success( + t( + 'view.charts.mutual_friend.notifications.mutual_friend_graph_ready_title' + ), + { + description: t( + 'view.charts.mutual_friend.notifications.mutual_friend_graph_ready_message' ), - { - description: t( - 'view.charts.mutual_friend.notifications.mutual_friend_graph_ready_message' - ) + action: { + label: t('common.actions.open'), + onClick: () => router.push({ name: 'charts-mutual' }) } - ); - } + } + ); } ); @@ -92,9 +116,190 @@ export const useChartsStore = defineStore('Charts', () => { mutualGraphStatus.cancelRequested = false; } + function markMutualGraphLoaded({ notify = true } = {}) { + mutualGraphStatus.completionNotified = notify ? false : true; + mutualGraphStatus.hasFetched = true; + } + + function requestMutualGraphCancel() { + if (mutualGraphStatus.isFetching) + mutualGraphStatus.cancelRequested = true; + } + + async function fetchMutualGraph() { + if (mutualGraphStatus.isFetching || isOptOut.value) return null; + + if (!friendCount.value) { + showInfoMessage( + t('view.charts.mutual_friend.status.no_friends_to_process'), + 'info' + ); + return null; + } + + const rateLimiter = createRateLimiter({ + limitPerInterval: 5, + intervalMs: 1000 + }); + + 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); + + if (args.json.length < 100) break; + offset += args.json.length; + } + + return collected; + }; + + mutualGraphStatus.isFetching = true; + mutualGraphStatus.completionNotified = false; + mutualGraphStatus.needsRefetch = false; + mutualGraphStatus.cancelRequested = false; + mutualGraphStatus.hasFetched = false; + Object.assign(mutualGraphFetchState, { processedFriends: 0 }); + + const friendSnapshot = Array.from(friendStore.friends.values()); + const mutualMap = new Map(); + + let cancelled = false; + + try { + for (let index = 0; index < friendSnapshot.length; index += 1) { + const friend = friendSnapshot[index]; + if (!friend?.id) continue; + + if (isCancelled()) { + cancelled = true; + break; + } + + try { + const mutuals = await fetchMutualFriends(friend.id); + if (isCancelled()) { + cancelled = true; + break; + } + mutualMap.set(friend.id, { friend, mutuals }); + } catch (err) { + if ((err?.message || '') === 'cancelled' || isCancelled()) { + cancelled = true; + break; + } + console.warn( + '[MutualNetworkGraph] Skipping friend due to fetch error', + friend.id, + err + ); + continue; + } + + mutualGraphFetchState.processedFriends = index + 1; + if (mutualGraphStatus.cancelRequested) { + cancelled = true; + break; + } + } + + if (cancelled) { + mutualGraphStatus.hasFetched = false; + showInfoMessage( + t( + 'view.charts.mutual_friend.messages.fetch_cancelled_graph_not_updated' + ), + 'warning' + ); + return null; + } + + mutualGraphStatus.friendSignature = friendCount.value; + mutualGraphStatus.needsRefetch = false; + + try { + const entries = new Map(); + mutualMap.forEach((value, friendId) => { + if (!friendId) return; + const normalizedFriendId = String(friendId); + const collection = Array.isArray(value?.mutuals) + ? value.mutuals + : []; + const ids = []; + + for (const entry of collection) { + const identifier = + typeof entry?.id === 'string' + ? entry.id + : entry?.id !== undefined && entry?.id !== null + ? String(entry.id) + : ''; + if ( + identifier && + identifier !== + 'usr_00000000-0000-0000-0000-000000000000' + ) + ids.push(identifier); + } + + entries.set(normalizedFriendId, ids); + }); + await database.saveMutualGraphSnapshot(entries); + } catch (persistErr) { + console.error( + '[MutualNetworkGraph] Failed to cache data', + persistErr + ); + } + + markMutualGraphLoaded({ notify: true }); + return mutualMap; + } catch (err) { + console.error('[MutualNetworkGraph] fetch aborted', err); + return null; + } finally { + mutualGraphStatus.isFetching = false; + mutualGraphStatus.cancelRequested = false; + } + } + return { mutualGraphFetchState, mutualGraphStatus, - resetMutualGraphState + resetMutualGraphState, + markMutualGraphLoaded, + requestMutualGraphCancel, + fetchMutualGraph }; }); diff --git a/src/views/Charts/components/MutualFriends.vue b/src/views/Charts/components/MutualFriends.vue index 9f0d1e3f..9aaf7b96 100644 --- a/src/views/Charts/components/MutualFriends.vue +++ b/src/views/Charts/components/MutualFriends.vue @@ -4,8 +4,7 @@ class="mt-0 flex min-h-[calc(100vh-140px)] flex-col items-center justify-betweenpt-12" ref="mutualGraphRef">
-
+
-
+ class="grid grid-cols-[repeat(auto-fit,minmax(150px,1fr))] items-center rounded-md bg-transparent p-3 ml-auto w-70"> +
{{ t('view.charts.mutual_friend.progress.friends_processed') }} {{ fetchState.processedFriends }} / {{ totalFriends }}
@@ -62,7 +61,6 @@ import { Progress } from '@/components/ui/progress'; import { Spinner } from '@/components/ui/spinner'; import { createNodeBorderProgram } from '@sigma/node-border'; - import { onBeforeRouteLeave } from 'vue-router'; import { storeToRefs } from 'pinia'; import { toast } from 'vue-sonner'; import { useI18n } from 'vue-i18n'; @@ -81,9 +79,7 @@ useModalStore, useUserStore } from '../../../stores'; - import { createRateLimiter, executeWithBackoff } from '../../../shared/utils'; import { database } from '../../../service/database'; - import { userRequest } from '../../../api'; import { watchState } from '../../../service/watchState'; const { t } = useI18n(); @@ -128,6 +124,7 @@ let sigmaInstance = null; let currentGraph = null; let resizeObserver = null; + let pendingRender = null; watch(isDarkMode, () => { if (!currentGraph) return; @@ -338,6 +335,16 @@ function renderGraph(graph, forceRecreate = false) { if (!graphContainerRef.value) return; + const container = graphContainerRef.value; + const { width, height } = container.getBoundingClientRect(); + if (!width || !height) { + if (pendingRender) return; + pendingRender = requestAnimationFrame(() => { + pendingRender = null; + renderGraph(graph, forceRecreate); + }); + return; + } const DEFAULT_LABEL_THRESHOLD = 10; @@ -357,7 +364,7 @@ } if (!sigmaInstance) { - sigmaInstance = new Sigma(graph, graphContainerRef.value, { + sigmaInstance = new Sigma(graph, container, { renderLabels: true, labelRenderedSizeThreshold: DEFAULT_LABEL_THRESHOLD, labelColor: { color: labelColor }, @@ -512,11 +519,10 @@ if (!watchState.isLoggedIn || !currentUser.value?.id) return; if (!watchState.isFriendsLoaded) return; if (isFetching.value || isLoadingSnapshot.value) return; - if (hasFetched.value && !status.needsRefetch) return; + if (hasFetched.value && !status.needsRefetch && currentGraph) return; isLoadingSnapshot.value = true; - toast.dismiss(loadingToastId.value); - loadingToastId.value = toast.loading(t('view.charts.mutual_friend.status.loading_cache')); + // loadingToastId.value = toast.info(t('view.charts.mutual_friend.status.loading_cache')); try { const snapshot = await database.getMutualGraphSnapshot(); @@ -554,7 +560,7 @@ } applyGraph(mutualMap); - hasFetched.value = true; + chartsStore.markMutualGraphLoaded({ notify: false }); fetchState.processedFriends = Math.min(mutualMap.size, totalFriends.value || mutualMap.size); status.friendSignature = totalFriends.value; status.needsRefetch = false; @@ -562,7 +568,6 @@ console.error('[MutualNetworkGraph] Failed to load cached mutual graph', err); } finally { isLoadingSnapshot.value = false; - toast.dismiss(loadingToastId.value); } } @@ -598,148 +603,14 @@ .catch(() => {}); } - function cancelFetch() { - if (isFetching.value) status.cancelRequested = true; - } - - const isCancelled = () => status.cancelRequested === true; - async function startFetch() { - const rateLimiter = createRateLimiter({ limitPerInterval: 5, intervalMs: 1000 }); - - 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); - - if (args.json.length < 100) break; - offset += args.json.length; - } - - return collected; - }; - if (isFetching.value || isOptOut.value) return; - - if (!totalFriends.value) { - showStatusMessage(t('view.charts.mutual_friend.status.no_friends_to_process'), 'info'); - return; - } - - isFetching.value = true; - status.completionNotified = false; - status.needsRefetch = false; - status.cancelRequested = false; - hasFetched.value = false; - Object.assign(fetchState, { processedFriends: 0 }); - - const friendSnapshot = Array.from(friends.value.values()); - const mutualMap = new Map(); - - let cancelled = false; - - try { - for (let index = 0; index < friendSnapshot.length; index += 1) { - const friend = friendSnapshot[index]; - if (!friend?.id) continue; - - if (isCancelled()) { - cancelled = true; - break; - } - - try { - const mutuals = await fetchMutualFriends(friend.id); - if (isCancelled()) { - cancelled = true; - break; - } - mutualMap.set(friend.id, { friend, mutuals }); - } catch (err) { - if ((err?.message || '') === 'cancelled' || isCancelled()) { - cancelled = true; - break; - } - console.warn('[MutualNetworkGraph] Skipping friend due to fetch error', friend.id, err); - continue; - } - - fetchState.processedFriends = index + 1; - if (status.cancelRequested) { - cancelled = true; - break; - } - } - - if (cancelled) { - hasFetched.value = false; - showStatusMessage(t('view.charts.mutual_friend.messages.fetch_cancelled_graph_not_updated'), 'warning'); - return; - } - - applyGraph(mutualMap); - status.friendSignature = totalFriends.value; - status.needsRefetch = false; - - try { - const entries = new Map(); - mutualMap.forEach((value, friendId) => { - if (!friendId) return; - const normalizedFriendId = String(friendId); - const collection = Array.isArray(value?.mutuals) ? value.mutuals : []; - const ids = []; - - for (const entry of collection) { - const identifier = - typeof entry?.id === 'string' - ? entry.id - : entry?.id !== undefined && entry?.id !== null - ? String(entry.id) - : ''; - if (identifier && identifier !== 'usr_00000000-0000-0000-0000-000000000000') - ids.push(identifier); - } - - entries.set(normalizedFriendId, ids); - }); - await database.saveMutualGraphSnapshot(entries); - } catch (persistErr) { - console.error('[MutualNetworkGraph] Failed to cache data', persistErr); - } - - hasFetched.value = true; - } catch (err) { - console.error('[MutualNetworkGraph] fetch aborted', err); - } finally { - isFetching.value = false; - status.cancelRequested = false; - } + const mutualMap = await chartsStore.fetchMutualGraph(); + if (!mutualMap) return; + applyGraph(mutualMap); } - onBeforeRouteLeave(() => { - chartsStore.resetMutualGraphState(); - }); + function cancelFetch() { + chartsStore.requestMutualGraphCancel(); + }