feat: mutual friend graph (#1491)

This commit is contained in:
pa
2025-11-18 23:30:00 +09:00
committed by Natsumi
parent 0bc9980cae
commit 424edb04e0
12 changed files with 1073 additions and 35 deletions
+120
View File
@@ -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
};
});
+25 -32
View File
@@ -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) {
+4 -2
View File
@@ -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,