diff --git a/src/localization/en.json b/src/localization/en.json index 25067161..5f896d9c 100644 --- a/src/localization/en.json +++ b/src/localization/en.json @@ -297,6 +297,41 @@ "show_no_friend_instance": "Show No Friend Instance", "show_detail": "Show Detail" } + }, + "mutual_friend": { + "tab_label": "Mutual Friend", + "actions": { + "start_fetch": "Start Fetch", + "fetch_again": "Fetch Again", + "stop": "Stop", + "stop_fetching": "Stop fetching" + }, + "status": { + "no_friends_to_process": "You have no friends to process" + }, + "progress": { + "friends_processed": "Friends processed", + "no_relationships_discovered": "No relationships discovered" + }, + "prompt": { + "title": "Mutual Friend Graph", + "message": "No cached mutual friend graph data was found. Start fetching now?\\nThis may take a while, we will notify you when it is finishes", + "confirm": "Start Fetch", + "cancel": "Maybe Later" + }, + "messages": { + "fetch_cancelled_graph_not_updated": "Fetch cancelled" + }, + "notifications": { + "start_fetching": "Start fetching", + "mutual_friend_graph_ready_title": "Mutual Friend Graph", + "mutual_friend_graph_ready_message": "Mutual friend graph is ready", + "friend_list_changed_fetch_again": "Friend list changed. Please fetch the mutual graph again." + }, + "tooltip": { + "mutual_friends_count": "Mutual friends: {count}", + "edge": "{source} ↔ {target}" + } } }, "tools": { diff --git a/src/service/database.js b/src/service/database.js index 59fe6ea2..9c8cda3f 100644 --- a/src/service/database.js +++ b/src/service/database.js @@ -5,6 +5,7 @@ import { friendLogHistory } from './database/friendLogHistory.js'; import { gameLog } from './database/gameLog.js'; import { memos } from './database/memos.js'; import { moderation } from './database/moderation.js'; +import { mutualGraph } from './database/mutualGraph.js'; import { notifications } from './database/notifications.js'; import { tableAlter } from './database/tableAlter.js'; import { tableFixes } from './database/tableFixes.js'; @@ -32,6 +33,7 @@ const database = { ...tableAlter, ...tableFixes, ...tableSize, + ...mutualGraph, setMaxTableSize(limit) { dbVars.maxTableSize = limit; @@ -77,6 +79,12 @@ const database = { await sqliteService.executeNonQuery( `CREATE TABLE IF NOT EXISTS ${dbVars.userPrefix}_notes (user_id TEXT PRIMARY KEY, display_name TEXT, note TEXT, created_at TEXT)` ); + await sqliteService.executeNonQuery( + `CREATE TABLE IF NOT EXISTS ${dbVars.userPrefix}_mutual_graph_friends (friend_id TEXT PRIMARY KEY)` + ); + 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))` + ); }, async initTables() { diff --git a/src/service/database/mutualGraph.js b/src/service/database/mutualGraph.js new file mode 100644 index 00000000..4e99fa05 --- /dev/null +++ b/src/service/database/mutualGraph.js @@ -0,0 +1,92 @@ +import { dbVars } from '../database'; + +import sqliteService from '../sqlite.js'; + +const mutualGraph = { + async getMutualGraphSnapshot() { + const snapshot = new Map(); + if (!dbVars.userPrefix) { + return snapshot; + } + const friendTable = `${dbVars.userPrefix}_mutual_graph_friends`; + const linkTable = `${dbVars.userPrefix}_mutual_graph_links`; + await sqliteService.execute((dbRow) => { + const friendId = dbRow[0]; + if (friendId && !snapshot.has(friendId)) { + snapshot.set(friendId, []); + } + }, `SELECT friend_id FROM ${friendTable}`); + await sqliteService.execute((dbRow) => { + const friendId = dbRow[0]; + const mutualId = dbRow[1]; + if (!friendId || !mutualId) { + return; + } + let list = snapshot.get(friendId); + if (!list) { + list = []; + snapshot.set(friendId, list); + } + list.push(mutualId); + }, `SELECT friend_id, mutual_id FROM ${linkTable}`); + return snapshot; + }, + + async saveMutualGraphSnapshot(entries) { + if (!dbVars.userPrefix) { + return; + } + const friendTable = `${dbVars.userPrefix}_mutual_graph_friends`; + const linkTable = `${dbVars.userPrefix}_mutual_graph_links`; + 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}`); + if (pairs.size === 0) { + await sqliteService.executeNonQuery('COMMIT'); + return; + } + let friendValues = ''; + let edgeValues = ''; + pairs.forEach((mutualIds, friendId) => { + if (!friendId) { + return; + } + const safeFriendId = friendId.replace(/'/g, "''"); + friendValues += `('${safeFriendId}'),`; + let collection = []; + if (Array.isArray(mutualIds)) { + collection = mutualIds; + } else if (mutualIds instanceof Set) { + collection = Array.from(mutualIds); + } + for (const mutual of collection) { + if (!mutual) { + continue; + } + const safeMutualId = String(mutual).replace(/'/g, "''"); + edgeValues += `('${safeFriendId}', '${safeMutualId}'),`; + } + }); + if (friendValues) { + friendValues = friendValues.slice(0, -1); + await sqliteService.executeNonQuery( + `INSERT OR REPLACE INTO ${friendTable} (friend_id) VALUES ${friendValues}` + ); + } + if (edgeValues) { + edgeValues = edgeValues.slice(0, -1); + await sqliteService.executeNonQuery( + `INSERT OR REPLACE INTO ${linkTable} (friend_id, mutual_id) VALUES ${edgeValues}` + ); + } + await sqliteService.executeNonQuery('COMMIT'); + } catch (err) { + await sqliteService.executeNonQuery('ROLLBACK'); + throw err; + } + } +}; + +export { mutualGraph }; diff --git a/src/shared/utils/index.js b/src/shared/utils/index.js index 44980036..ff94544d 100644 --- a/src/shared/utils/index.js +++ b/src/shared/utils/index.js @@ -17,3 +17,5 @@ export * from './location'; export * from './invite'; export * from './world'; export * from './memos'; +export * from './throttle'; +export * from './retry'; diff --git a/src/shared/utils/retry.js b/src/shared/utils/retry.js new file mode 100644 index 00000000..2736d9ea --- /dev/null +++ b/src/shared/utils/retry.js @@ -0,0 +1,24 @@ +export async function executeWithBackoff(fn, options = {}) { + const { + maxRetries = 5, + baseDelay = 1000, + shouldRetry = () => true + } = options; + + async function attempt(remaining) { + try { + return await fn(); + } catch (err) { + if (remaining <= 0 || !shouldRetry(err)) { + throw err; + } + const delay = + baseDelay * + Math.pow(2, (options.maxRetries || maxRetries) - remaining); + await new Promise((resolve) => setTimeout(resolve, delay)); + return attempt(remaining - 1); + } + } + + return attempt(maxRetries); +} diff --git a/src/shared/utils/throttle.js b/src/shared/utils/throttle.js new file mode 100644 index 00000000..f4c4a676 --- /dev/null +++ b/src/shared/utils/throttle.js @@ -0,0 +1,28 @@ +export function createRateLimiter({ limitPerInterval, intervalMs }) { + const stamps = []; + + async function throttle() { + const now = Date.now(); + while (stamps.length && now - stamps[0] > intervalMs) { + stamps.shift(); + } + if (stamps.length >= limitPerInterval) { + const wait = intervalMs - (now - stamps[0]); + await new Promise((resolve) => setTimeout(resolve, wait)); + } + stamps.push(Date.now()); + } + + return { + async schedule(fn) { + await throttle(); + return fn(); + }, + async wait() { + await throttle(); + }, + clear() { + stamps.length = 0; + } + }; +} diff --git a/src/stores/charts.js b/src/stores/charts.js new file mode 100644 index 00000000..31e40914 --- /dev/null +++ b/src/stores/charts.js @@ -0,0 +1,120 @@ +import { computed, reactive, ref, watch } from 'vue'; +import { ElMessage, ElNotification } from 'element-plus'; +import { defineStore } from 'pinia'; +import { useI18n } from 'vue-i18n'; + +import { useFriendStore } from './friend'; + +function createDefaultFetchState() { + return { + processedFriends: 0 + }; +} + +function createDefaultPayload() { + return { + nodes: [], + links: [] + }; +} + +export const useChartsStore = defineStore('Charts', () => { + const friendStore = useFriendStore(); + + const { t } = useI18n(); + + const activeTab = ref('instance'); + const mutualGraphPayload = ref(createDefaultPayload()); + const mutualGraphFetchState = reactive(createDefaultFetchState()); + const mutualGraphStatus = reactive({ + isFetching: false, + hasFetched: false, + fetchError: '', + completionNotified: false, + friendSignature: 0, + needsRefetch: false, + cancelRequested: false + }); + + const friendCount = computed(() => friendStore.friends.size || 0); + + function showInfoMessage(message, type) { + ElMessage({ + message, + type, + duration: 4000, + grouping: true + }); + } + + 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; + ElNotification({ + title: t( + 'view.charts.mutual_friend.notifications.mutual_friend_graph_ready_title' + ), + message: t( + 'view.charts.mutual_friend.notifications.mutual_friend_graph_ready_message' + ), + type: 'success', + position: 'top-right', + duration: 5000, + showClose: true + }); + } + } + ); + + watch(friendCount, (count) => { + if ( + !mutualGraphStatus.hasFetched || + mutualGraphStatus.isFetching || + !mutualGraphStatus.friendSignature || + mutualGraphStatus.needsRefetch + ) { + return; + } + if (count !== mutualGraphStatus.friendSignature) { + mutualGraphStatus.needsRefetch = true; + showInfoMessage( + t( + 'view.charts.mutual_friend.notifications.friend_list_changed_fetch_again' + ), + 'warning' + ); + } + }); + + function resetMutualGraphState() { + mutualGraphPayload.value = createDefaultPayload(); + Object.assign(mutualGraphFetchState, createDefaultFetchState()); + mutualGraphStatus.isFetching = false; + mutualGraphStatus.hasFetched = false; + mutualGraphStatus.fetchError = ''; + mutualGraphStatus.completionNotified = false; + mutualGraphStatus.friendSignature = 0; + mutualGraphStatus.needsRefetch = false; + mutualGraphStatus.cancelRequested = false; + } + + return { + activeTab, + mutualGraphPayload, + mutualGraphFetchState, + mutualGraphStatus, + resetMutualGraphState + }; +}); diff --git a/src/stores/friend.js b/src/stores/friend.js index 7b36f814..9e5fd015 100644 --- a/src/stores/friend.js +++ b/src/stores/friend.js @@ -5,6 +5,8 @@ import { useI18n } from 'vue-i18n'; import { compareByCreatedAtAscending, + createRateLimiter, + executeWithBackoff, getFriendsSortFunction, getGroupName, getNameColour, @@ -690,39 +692,30 @@ export const useFriendStore = defineStore('Friend', () => { const MAX_RETRY = 5; const RETRY_BASE_DELAY = 1000; - const stamps = []; - async function throttle() { - const now = Date.now(); - while (stamps.length && now - stamps[0] > 60_000) stamps.shift(); - if (stamps.length >= RATE_PER_MINUTE) { - const wait = 60_000 - (now - stamps[0]); - await new Promise((r) => setTimeout(r, wait)); - } - stamps.push(Date.now()); - } + const rateLimiter = createRateLimiter({ + limitPerInterval: RATE_PER_MINUTE, + intervalMs: 60_000 + }); - async function fetchPage(offset, retries = MAX_RETRY) { - try { - const { json } = await friendRequest.getFriends({ - ...args, - n: PAGE_SIZE, - offset - }); - return Array.isArray(json) ? json : []; - } catch (err) { - const is429 = - err.status === 429 || (err.message || '').includes('429'); - if (is429 && retries > 0) { - await new Promise((r) => - setTimeout( - r, - RETRY_BASE_DELAY * Math.pow(2, MAX_RETRY - retries) - ) - ); - return fetchPage(offset, retries - 1); + async function fetchPage(offset) { + const result = await executeWithBackoff( + async () => { + const { json } = await friendRequest.getFriends({ + ...args, + n: PAGE_SIZE, + offset + }); + return Array.isArray(json) ? json : []; + }, + { + maxRetries: MAX_RETRY, + baseDelay: RETRY_BASE_DELAY, + shouldRetry: (err) => + err?.status === 429 || + (err?.message || '').includes('429') } - throw err; - } + ); + return result; } let nextOffset = 0; @@ -742,7 +735,7 @@ export const useFriendStore = defineStore('Friend', () => { const offset = getNextOffset(); if (offset === null) break; - await throttle(); + await rateLimiter.wait(); const page = await fetchPage(offset); if (page.length === 0) { diff --git a/src/stores/index.js b/src/stores/index.js index 6531d12b..6a0d936a 100644 --- a/src/stores/index.js +++ b/src/stores/index.js @@ -1,5 +1,4 @@ import { createPinia } from 'pinia'; -import { createSentryPiniaPlugin } from '@sentry/vue'; import { isSentryEnabled } from '../plugin'; import { useAdvancedSettingsStore } from './settings/advanced'; @@ -7,6 +6,7 @@ import { useAppearanceSettingsStore } from './settings/appearance'; import { useAuthStore } from './auth'; import { useAvatarProviderStore } from './avatarProvider'; import { useAvatarStore } from './avatar'; +import { useChartsStore } from './charts'; import { useDiscordPresenceSettingsStore } from './settings/discordPresence'; import { useFavoriteStore } from './favorite'; import { useFeedStore } from './feed'; @@ -152,7 +152,8 @@ export function createGlobalStores() { sharedFeed: useSharedFeedStore(), updateLoop: useUpdateLoopStore(), auth: useAuthStore(), - vrcStatus: useVrcStatusStore() + vrcStatus: useVrcStatusStore(), + charts: useChartsStore() }; } @@ -175,6 +176,7 @@ export { useNotificationStore, usePhotonStore, useSearchStore, + useChartsStore, useAdvancedSettingsStore, useAppearanceSettingsStore, useDiscordPresenceSettingsStore, diff --git a/src/views/Charts/Charts.vue b/src/views/Charts/Charts.vue index 5831b151..dead02e8 100644 --- a/src/views/Charts/Charts.vue +++ b/src/views/Charts/Charts.vue @@ -3,16 +3,37 @@
-