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">