Skip to content

Commit 424edb0

Browse files
Map1enNatsumi-sama
authored andcommitted
feat: mutual friend graph (#1491)
1 parent 0bc9980 commit 424edb0

File tree

12 files changed

+1073
-35
lines changed

12 files changed

+1073
-35
lines changed

src/localization/en.json

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,41 @@
297297
"show_no_friend_instance": "Show No Friend Instance",
298298
"show_detail": "Show Detail"
299299
}
300+
},
301+
"mutual_friend": {
302+
"tab_label": "Mutual Friend",
303+
"actions": {
304+
"start_fetch": "Start Fetch",
305+
"fetch_again": "Fetch Again",
306+
"stop": "Stop",
307+
"stop_fetching": "Stop fetching"
308+
},
309+
"status": {
310+
"no_friends_to_process": "You have no friends to process"
311+
},
312+
"progress": {
313+
"friends_processed": "Friends processed",
314+
"no_relationships_discovered": "No relationships discovered"
315+
},
316+
"prompt": {
317+
"title": "Mutual Friend Graph",
318+
"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",
319+
"confirm": "Start Fetch",
320+
"cancel": "Maybe Later"
321+
},
322+
"messages": {
323+
"fetch_cancelled_graph_not_updated": "Fetch cancelled"
324+
},
325+
"notifications": {
326+
"start_fetching": "Start fetching",
327+
"mutual_friend_graph_ready_title": "Mutual Friend Graph",
328+
"mutual_friend_graph_ready_message": "Mutual friend graph is ready",
329+
"friend_list_changed_fetch_again": "Friend list changed. Please fetch the mutual graph again."
330+
},
331+
"tooltip": {
332+
"mutual_friends_count": "Mutual friends: {count}",
333+
"edge": "{source} ↔ {target}"
334+
}
300335
}
301336
},
302337
"tools": {

src/service/database.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { friendLogHistory } from './database/friendLogHistory.js';
55
import { gameLog } from './database/gameLog.js';
66
import { memos } from './database/memos.js';
77
import { moderation } from './database/moderation.js';
8+
import { mutualGraph } from './database/mutualGraph.js';
89
import { notifications } from './database/notifications.js';
910
import { tableAlter } from './database/tableAlter.js';
1011
import { tableFixes } from './database/tableFixes.js';
@@ -32,6 +33,7 @@ const database = {
3233
...tableAlter,
3334
...tableFixes,
3435
...tableSize,
36+
...mutualGraph,
3537

3638
setMaxTableSize(limit) {
3739
dbVars.maxTableSize = limit;
@@ -77,6 +79,12 @@ const database = {
7779
await sqliteService.executeNonQuery(
7880
`CREATE TABLE IF NOT EXISTS ${dbVars.userPrefix}_notes (user_id TEXT PRIMARY KEY, display_name TEXT, note TEXT, created_at TEXT)`
7981
);
82+
await sqliteService.executeNonQuery(
83+
`CREATE TABLE IF NOT EXISTS ${dbVars.userPrefix}_mutual_graph_friends (friend_id TEXT PRIMARY KEY)`
84+
);
85+
await sqliteService.executeNonQuery(
86+
`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))`
87+
);
8088
},
8189

8290
async initTables() {
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { dbVars } from '../database';
2+
3+
import sqliteService from '../sqlite.js';
4+
5+
const mutualGraph = {
6+
async getMutualGraphSnapshot() {
7+
const snapshot = new Map();
8+
if (!dbVars.userPrefix) {
9+
return snapshot;
10+
}
11+
const friendTable = `${dbVars.userPrefix}_mutual_graph_friends`;
12+
const linkTable = `${dbVars.userPrefix}_mutual_graph_links`;
13+
await sqliteService.execute((dbRow) => {
14+
const friendId = dbRow[0];
15+
if (friendId && !snapshot.has(friendId)) {
16+
snapshot.set(friendId, []);
17+
}
18+
}, `SELECT friend_id FROM ${friendTable}`);
19+
await sqliteService.execute((dbRow) => {
20+
const friendId = dbRow[0];
21+
const mutualId = dbRow[1];
22+
if (!friendId || !mutualId) {
23+
return;
24+
}
25+
let list = snapshot.get(friendId);
26+
if (!list) {
27+
list = [];
28+
snapshot.set(friendId, list);
29+
}
30+
list.push(mutualId);
31+
}, `SELECT friend_id, mutual_id FROM ${linkTable}`);
32+
return snapshot;
33+
},
34+
35+
async saveMutualGraphSnapshot(entries) {
36+
if (!dbVars.userPrefix) {
37+
return;
38+
}
39+
const friendTable = `${dbVars.userPrefix}_mutual_graph_friends`;
40+
const linkTable = `${dbVars.userPrefix}_mutual_graph_links`;
41+
const pairs = entries instanceof Map ? entries : new Map();
42+
await sqliteService.executeNonQuery('BEGIN');
43+
try {
44+
await sqliteService.executeNonQuery(`DELETE FROM ${friendTable}`);
45+
await sqliteService.executeNonQuery(`DELETE FROM ${linkTable}`);
46+
if (pairs.size === 0) {
47+
await sqliteService.executeNonQuery('COMMIT');
48+
return;
49+
}
50+
let friendValues = '';
51+
let edgeValues = '';
52+
pairs.forEach((mutualIds, friendId) => {
53+
if (!friendId) {
54+
return;
55+
}
56+
const safeFriendId = friendId.replace(/'/g, "''");
57+
friendValues += `('${safeFriendId}'),`;
58+
let collection = [];
59+
if (Array.isArray(mutualIds)) {
60+
collection = mutualIds;
61+
} else if (mutualIds instanceof Set) {
62+
collection = Array.from(mutualIds);
63+
}
64+
for (const mutual of collection) {
65+
if (!mutual) {
66+
continue;
67+
}
68+
const safeMutualId = String(mutual).replace(/'/g, "''");
69+
edgeValues += `('${safeFriendId}', '${safeMutualId}'),`;
70+
}
71+
});
72+
if (friendValues) {
73+
friendValues = friendValues.slice(0, -1);
74+
await sqliteService.executeNonQuery(
75+
`INSERT OR REPLACE INTO ${friendTable} (friend_id) VALUES ${friendValues}`
76+
);
77+
}
78+
if (edgeValues) {
79+
edgeValues = edgeValues.slice(0, -1);
80+
await sqliteService.executeNonQuery(
81+
`INSERT OR REPLACE INTO ${linkTable} (friend_id, mutual_id) VALUES ${edgeValues}`
82+
);
83+
}
84+
await sqliteService.executeNonQuery('COMMIT');
85+
} catch (err) {
86+
await sqliteService.executeNonQuery('ROLLBACK');
87+
throw err;
88+
}
89+
}
90+
};
91+
92+
export { mutualGraph };

src/shared/utils/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,5 @@ export * from './location';
1717
export * from './invite';
1818
export * from './world';
1919
export * from './memos';
20+
export * from './throttle';
21+
export * from './retry';

src/shared/utils/retry.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
export async function executeWithBackoff(fn, options = {}) {
2+
const {
3+
maxRetries = 5,
4+
baseDelay = 1000,
5+
shouldRetry = () => true
6+
} = options;
7+
8+
async function attempt(remaining) {
9+
try {
10+
return await fn();
11+
} catch (err) {
12+
if (remaining <= 0 || !shouldRetry(err)) {
13+
throw err;
14+
}
15+
const delay =
16+
baseDelay *
17+
Math.pow(2, (options.maxRetries || maxRetries) - remaining);
18+
await new Promise((resolve) => setTimeout(resolve, delay));
19+
return attempt(remaining - 1);
20+
}
21+
}
22+
23+
return attempt(maxRetries);
24+
}

src/shared/utils/throttle.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
export function createRateLimiter({ limitPerInterval, intervalMs }) {
2+
const stamps = [];
3+
4+
async function throttle() {
5+
const now = Date.now();
6+
while (stamps.length && now - stamps[0] > intervalMs) {
7+
stamps.shift();
8+
}
9+
if (stamps.length >= limitPerInterval) {
10+
const wait = intervalMs - (now - stamps[0]);
11+
await new Promise((resolve) => setTimeout(resolve, wait));
12+
}
13+
stamps.push(Date.now());
14+
}
15+
16+
return {
17+
async schedule(fn) {
18+
await throttle();
19+
return fn();
20+
},
21+
async wait() {
22+
await throttle();
23+
},
24+
clear() {
25+
stamps.length = 0;
26+
}
27+
};
28+
}

src/stores/charts.js

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { computed, reactive, ref, watch } from 'vue';
2+
import { ElMessage, ElNotification } from 'element-plus';
3+
import { defineStore } from 'pinia';
4+
import { useI18n } from 'vue-i18n';
5+
6+
import { useFriendStore } from './friend';
7+
8+
function createDefaultFetchState() {
9+
return {
10+
processedFriends: 0
11+
};
12+
}
13+
14+
function createDefaultPayload() {
15+
return {
16+
nodes: [],
17+
links: []
18+
};
19+
}
20+
21+
export const useChartsStore = defineStore('Charts', () => {
22+
const friendStore = useFriendStore();
23+
24+
const { t } = useI18n();
25+
26+
const activeTab = ref('instance');
27+
const mutualGraphPayload = ref(createDefaultPayload());
28+
const mutualGraphFetchState = reactive(createDefaultFetchState());
29+
const mutualGraphStatus = reactive({
30+
isFetching: false,
31+
hasFetched: false,
32+
fetchError: '',
33+
completionNotified: false,
34+
friendSignature: 0,
35+
needsRefetch: false,
36+
cancelRequested: false
37+
});
38+
39+
const friendCount = computed(() => friendStore.friends.size || 0);
40+
41+
function showInfoMessage(message, type) {
42+
ElMessage({
43+
message,
44+
type,
45+
duration: 4000,
46+
grouping: true
47+
});
48+
}
49+
50+
watch(
51+
() => mutualGraphStatus.isFetching,
52+
(isFetching, wasFetching) => {
53+
if (isFetching) {
54+
showInfoMessage(
55+
t('view.charts.mutual_friend.notifications.start_fetching'),
56+
'info'
57+
);
58+
mutualGraphStatus.completionNotified = false;
59+
} else if (
60+
wasFetching &&
61+
mutualGraphStatus.hasFetched &&
62+
!mutualGraphStatus.completionNotified
63+
) {
64+
mutualGraphStatus.completionNotified = true;
65+
ElNotification({
66+
title: t(
67+
'view.charts.mutual_friend.notifications.mutual_friend_graph_ready_title'
68+
),
69+
message: t(
70+
'view.charts.mutual_friend.notifications.mutual_friend_graph_ready_message'
71+
),
72+
type: 'success',
73+
position: 'top-right',
74+
duration: 5000,
75+
showClose: true
76+
});
77+
}
78+
}
79+
);
80+
81+
watch(friendCount, (count) => {
82+
if (
83+
!mutualGraphStatus.hasFetched ||
84+
mutualGraphStatus.isFetching ||
85+
!mutualGraphStatus.friendSignature ||
86+
mutualGraphStatus.needsRefetch
87+
) {
88+
return;
89+
}
90+
if (count !== mutualGraphStatus.friendSignature) {
91+
mutualGraphStatus.needsRefetch = true;
92+
showInfoMessage(
93+
t(
94+
'view.charts.mutual_friend.notifications.friend_list_changed_fetch_again'
95+
),
96+
'warning'
97+
);
98+
}
99+
});
100+
101+
function resetMutualGraphState() {
102+
mutualGraphPayload.value = createDefaultPayload();
103+
Object.assign(mutualGraphFetchState, createDefaultFetchState());
104+
mutualGraphStatus.isFetching = false;
105+
mutualGraphStatus.hasFetched = false;
106+
mutualGraphStatus.fetchError = '';
107+
mutualGraphStatus.completionNotified = false;
108+
mutualGraphStatus.friendSignature = 0;
109+
mutualGraphStatus.needsRefetch = false;
110+
mutualGraphStatus.cancelRequested = false;
111+
}
112+
113+
return {
114+
activeTab,
115+
mutualGraphPayload,
116+
mutualGraphFetchState,
117+
mutualGraphStatus,
118+
resetMutualGraphState
119+
};
120+
});

0 commit comments

Comments
 (0)