From a72c20d9af0ece7f9c7e833759dfc0187736fab8 Mon Sep 17 00:00:00 2001 From: zomop <1.80929302e+08+zomop@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:57:29 +0000 Subject: [PATCH 1/6] fix: use cursor in friends pagination --- src/content/core/utils/trackers/friendslist.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/content/core/utils/trackers/friendslist.js b/src/content/core/utils/trackers/friendslist.js index 874d0e39c..d9de97500 100644 --- a/src/content/core/utils/trackers/friendslist.js +++ b/src/content/core/utils/trackers/friendslist.js @@ -115,8 +115,9 @@ export async function fetchFriendsCustom( ) { try { let endpoint = `/v1/users/${userId}/friends/find`; - if (cursor) params.append('cursor', value); - if (params.size > 0) endpoint += `?${params.toString()}`; + const queryParams = new URLSearchParams(params); + if (cursor) queryParams.set('cursor', cursor); + if (queryParams.size > 0) endpoint += `?${queryParams.toString()}`; return await callRobloxApiJson({ subdomain: 'friends', endpoint: endpoint, From 385f7d285841d84fcb0b4926e7999c735cb03ceb Mon Sep 17 00:00:00 2001 From: zomop Date: Wed, 11 Nov 2026 13:14:26 +0700 Subject: [PATCH 2/6] fix: preserve friends cache on incomplete refresh --- .../core/utils/trackers/friendslist.js | 38 ++++++++++++++++--- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/src/content/core/utils/trackers/friendslist.js b/src/content/core/utils/trackers/friendslist.js index d9de97500..228685f49 100644 --- a/src/content/core/utils/trackers/friendslist.js +++ b/src/content/core/utils/trackers/friendslist.js @@ -185,9 +185,8 @@ export async function updateFriendsList(userId) { chrome.storage.local.get([FRIENDS_DATA_KEY], resolve), ); const allUsersFriendsData = storageResult[FRIENDS_DATA_KEY] || {}; - const existingMap = new Map( - (allUsersFriendsData[userId]?.friendsList || []).map((f) => [f.id, f]), - ); + const existingFriendsList = allUsersFriendsData[userId]?.friendsList || []; + const existingMap = new Map(existingFriendsList.map((f) => [f.id, f])); try { const [friendsCount, conversations, onlineData, allTrustedFriendsSet] = @@ -258,11 +257,33 @@ export async function updateFriendsList(userId) { }); } + const seenCursors = new Set(); + do { const page = await fetchFriendsPage(userId, friendsCursor); - if (!page || !page.PageItems) break; + + if (!page || !Array.isArray(page.PageItems)) { + console.error( + 'RoValra: Friends pagination failed; keeping existing cache', + ); + return existingFriendsList; + } + allFriends = allFriends.concat(page.PageItems); - friendsCursor = page.NextCursor; + + const nextCursor = page.NextCursor; + if ( + nextCursor && + (nextCursor === friendsCursor || seenCursors.has(nextCursor)) + ) { + console.error( + 'RoValra: Friends pagination returned a repeated cursor; keeping existing cache', + ); + return existingFriendsList; + } + + if (nextCursor) seenCursors.add(nextCursor); + friendsCursor = nextCursor; } while (friendsCursor); const batchSize = 50; @@ -302,6 +323,13 @@ export async function updateFriendsList(userId) { }); } + if (!Array.isArray(profileData?.profileDetails)) { + console.error( + 'RoValra: Failed to fetch friend profiles; keeping existing cache', + ); + return existingFriendsList; + } + if (profileData?.profileDetails) { const deletedUserIds = profileData.profileDetails .filter((profile) => profile.isDeleted) From e28a290de0bb426907d5e91b9c149128075efe3c Mon Sep 17 00:00:00 2001 From: zomop <1.80929302e+08+zomop@users.noreply.github.com> Date: Wed, 11 Nov 2026 06:50:55 +0000 Subject: [PATCH 3/6] fix: guard friends refresh against incomplete pages --- .../core/utils/trackers/friendslist.js | 59 +++- tests/friendslist-pagination.test.mjs | 318 ++++++++++++++++++ 2 files changed, 370 insertions(+), 7 deletions(-) create mode 100644 tests/friendslist-pagination.test.mjs diff --git a/src/content/core/utils/trackers/friendslist.js b/src/content/core/utils/trackers/friendslist.js index 228685f49..fca222304 100644 --- a/src/content/core/utils/trackers/friendslist.js +++ b/src/content/core/utils/trackers/friendslist.js @@ -131,6 +131,7 @@ export async function fetchFriendsCustom( export async function fetchAllTrustedFriends(userId) { const trustedIds = new Set(); let cursor = null; + const seenCursors = new Set(); try { do { let endpoint = `/v1/users/${userId}/friends/find?findFriendsType=FindTrustedFriends`; @@ -140,12 +141,21 @@ export async function fetchAllTrustedFriends(userId) { endpoint, useBackground: true, }); - if (!response || !response.PageItems) break; + if (!response || !Array.isArray(response.PageItems)) return null; response.PageItems.forEach((item) => trustedIds.add(item.id)); - cursor = response.NextCursor; + const nextCursor = response.NextCursor; + if ( + nextCursor && + (nextCursor === cursor || seenCursors.has(nextCursor)) + ) { + return null; + } + if (nextCursor) seenCursors.add(nextCursor); + cursor = nextCursor; } while (cursor); } catch (error) { console.error('RoValra: Failed to fetch all trusted friends', error); + return null; } return trustedIds; } @@ -197,6 +207,13 @@ export async function updateFriendsList(userId) { fetchAllTrustedFriends(userId), ]); + if (!(allTrustedFriendsSet instanceof Set)) { + console.error( + 'RoValra: Trusted friends refresh failed; keeping existing cache', + ); + return existingFriendsList; + } + const onlineMap = new Map(); onlineData.forEach((item) => { onlineMap.set(item.id, { @@ -286,11 +303,31 @@ export async function updateFriendsList(userId) { friendsCursor = nextCursor; } while (friendsCursor); + const uniqueFriendsById = new Map(); + allFriends.forEach((friend) => { + if ( + friend && + (typeof friend.id === 'number' || + typeof friend.id === 'string') && + !uniqueFriendsById.has(String(friend.id)) + ) { + uniqueFriendsById.set(String(friend.id), friend); + } + }); + const uniqueFriends = [...uniqueFriendsById.values()]; + + if (friendsCount !== null && uniqueFriends.length < friendsCount) { + console.error( + 'RoValra: Friends pagination returned fewer unique friends than expected; keeping existing cache', + ); + return existingFriendsList; + } + const batchSize = 50; let fullFriendsList = []; - for (let i = 0; i < allFriends.length; i += batchSize) { - const batchIds = allFriends + for (let i = 0; i < uniqueFriends.length; i += batchSize) { + const batchIds = uniqueFriends .slice(i, i + batchSize) .map((f) => f.id); const [profileData, insightsData, playedTogetherData] = @@ -323,9 +360,16 @@ export async function updateFriendsList(userId) { }); } - if (!Array.isArray(profileData?.profileDetails)) { + if ( + !Array.isArray(profileData?.profileDetails) || + !batchIds.every((id) => + profileData.profileDetails.some( + (profile) => String(profile.userId) === String(id), + ), + ) + ) { console.error( - 'RoValra: Failed to fetch friend profiles; keeping existing cache', + 'RoValra: Failed to fetch complete friend profiles; keeping existing cache', ); return existingFriendsList; } @@ -498,7 +542,7 @@ export async function updateFriendsList(userId) { return fullFriendsList; } catch (error) { console.error('RoValra: Failed to update friends list', error); - return []; + return existingFriendsList; } } @@ -554,6 +598,7 @@ async function updateOnlineStatusOnly(userId, currentFriendsList) { async function updateTrustedFriendsOnly(userId, currentFriendsList) { try { const trustedIds = await fetchAllTrustedFriends(userId); + if (!(trustedIds instanceof Set)) return currentFriendsList; const updatedList = currentFriendsList.map((friend) => ({ ...friend, isTrusted: trustedIds.has(friend.id), diff --git a/tests/friendslist-pagination.test.mjs b/tests/friendslist-pagination.test.mjs new file mode 100644 index 000000000..0a9d5bee4 --- /dev/null +++ b/tests/friendslist-pagination.test.mjs @@ -0,0 +1,318 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import test from 'node:test'; +import { build } from 'esbuild'; + +const sourcePath = path.resolve( + 'src/content/core/utils/trackers/friendslist.js', +); +const temporaryDirectory = await mkdtemp( + path.join(os.tmpdir(), 'rovalra-friends-test-'), +); +const bundlePath = path.join(temporaryDirectory, 'friendslist.mjs'); + +const mockModules = { + '../../api': `export const callRobloxApiJson = (...args) => globalThis.__friendsTest.callApi(...args);`, + '../../user': `export const getAuthenticatedUserId = async () => 7;`, + '../../locale/i18n.js': `export const ts = (key) => key;`, + '../../settings/getSettings.js': `export const settings = { unfriendDetectorEnabled: false };`, + './unfriendDetector.js': `export const reportDetectedUnfriends = async () => {};`, + '../../apis/users.js': ` + export const getMultiProfileInsights = async () => ({ userInsights: [] }); + export const getUserProfileData = async (ids) => globalThis.__friendsTest.getProfiles(ids); + export const INSIGHT_CASES = {}; + export const RANKING_STRATEGIES = { TC_INFO_BOOST: 'tc_info_boost', PROFILE_INFO_BOOST: 'profile_info_boost' }; + `, +}; + +await build({ + entryPoints: [sourcePath], + outfile: bundlePath, + bundle: true, + platform: 'node', + format: 'esm', + plugins: [ + { + name: 'friendslist-test-mocks', + setup(buildApi) { + buildApi.onResolve({ filter: /.*/ }, (args) => { + if (Object.hasOwn(mockModules, args.path)) { + return { path: args.path, namespace: 'friendslist-test' }; + } + return undefined; + }); + buildApi.onLoad( + { filter: /.*/, namespace: 'friendslist-test' }, + (args) => ({ contents: mockModules[args.path], loader: 'js' }), + ); + }, + }, + ], +}); + +const { fetchAllTrustedFriends, updateFriendsList } = await import( + `${pathToFileURL(bundlePath).href}?test=${Date.now()}` +); + +function makeFriend(id) { + return { id }; +} + +function makeProfiles(ids) { + return { + profileDetails: ids.map((userId) => ({ + userId, + names: { + username: `user${userId}`, + displayName: `User ${userId}`, + combinedName: `User ${userId} (@user${userId})`, + }, + isDeleted: false, + isVerified: false, + hasRobloxSubscription: false, + })), + }; +} + +function setup({ + pages, + count, + profiles, + trustedPages, + existingFriends = [{ id: 99 }], +}) { + const storage = { + rovalra_friends_data: { + 7: { friendsList: existingFriends, friendsCount: existingFriends.length }, + }, + }; + const requests = []; + let profileCalls = []; + globalThis.chrome = { + storage: { + local: { + get(keys, callback) { + const result = {}; + for (const key of keys) { + if (key in storage) result[key] = structuredClone(storage[key]); + } + callback(result); + }, + set(values, callback) { + Object.assign(storage, structuredClone(values)); + callback?.(); + }, + }, + }, + }; + globalThis.__friendsTest = { + requests, + getProfiles(ids) { + profileCalls.push([...ids]); + return profiles ? profiles(ids) : makeProfiles(ids); + }, + async callApi({ subdomain, endpoint }) { + requests.push({ subdomain, endpoint }); + if (subdomain === 'friends' && endpoint.endsWith('/friends/count')) { + return { count }; + } + if (subdomain === 'friends' && endpoint.endsWith('/friends/online')) { + return { data: [] }; + } + if (subdomain === 'apis' && endpoint.includes('get-user-conversations')) { + return { conversations: [] }; + } + if (subdomain === 'friends' && endpoint.includes('/friends/find')) { + if (endpoint.includes('findFriendsType=FindTrustedFriends')) { + const query = new URLSearchParams(endpoint.split('?')[1]); + const cursor = query.get('cursor'); + const trustedPage = trustedPages?.[cursor ?? 'first']; + if (trustedPage instanceof Error) throw trustedPage; + return trustedPage ?? { PageItems: [], NextCursor: null }; + } + const query = new URLSearchParams(endpoint.split('?')[1]); + const cursor = query.get('cursor'); + const page = pages[cursor ?? 'first']; + if (page instanceof Error) throw page; + return page; + } + throw new Error(`Unexpected mock API request: ${subdomain} ${endpoint}`); + }, + }; + return { storage, requests, getProfileCalls: () => profileCalls }; +} + +test('follows valid cursors through multiple pages and stores all unique friends', async () => { + const harness = setup({ + count: 2, + pages: { + first: { PageItems: [makeFriend(1)], NextCursor: 'next/1' }, + 'next/1': { PageItems: [makeFriend(2)], NextCursor: null }, + }, + existingFriends: [], + }); + const result = await updateFriendsList(7); + assert.deepEqual(result.map((friend) => friend.id), [1, 2]); + assert.ok( + harness.requests.some((request) => + request.endpoint.includes('cursor=next%2F1'), + ), + ); +}); + +test('accepts an empty final page with no NextCursor', async () => { + setup({ + count: 1, + pages: { + first: { PageItems: [makeFriend(1)], NextCursor: 'last' }, + last: { PageItems: [] }, + }, + existingFriends: [], + }); + const result = await updateFriendsList(7); + assert.deepEqual(result.map((friend) => friend.id), [1]); +}); + +test('terminates after a non-empty page when NextCursor is absent', async () => { + setup({ + count: 1, + pages: { first: { PageItems: [makeFriend(1)] } }, + existingFriends: [], + }); + const result = await updateFriendsList(7); + assert.deepEqual(result.map((friend) => friend.id), [1]); +}); + +test('keeps the previous cache when a cursor repeats', async () => { + const previous = [{ id: 99 }]; + const harness = setup({ + count: 2, + pages: { + first: { PageItems: [makeFriend(1)], NextCursor: 'repeat' }, + repeat: { PageItems: [makeFriend(2)], NextCursor: 'repeat' }, + }, + existingFriends: previous, + }); + const result = await updateFriendsList(7); + assert.deepEqual(result, previous); + assert.deepEqual(harness.storage.rovalra_friends_data[7].friendsList, previous); +}); + +test('keeps the previous cache when a later page request fails', async () => { + const previous = [{ id: 99 }]; + const harness = setup({ + count: 2, + pages: { + first: { PageItems: [makeFriend(1)], NextCursor: 'fail' }, + fail: new Error('mock request failed'), + }, + existingFriends: previous, + }); + const result = await updateFriendsList(7); + assert.deepEqual(result, previous); + assert.deepEqual(harness.storage.rovalra_friends_data[7].friendsList, previous); +}); + +test('deduplicates repeated friend IDs before profile processing', async () => { + const harness = setup({ + count: 1, + pages: { + first: { PageItems: [makeFriend(1)], NextCursor: 'second' }, + second: { PageItems: [makeFriend('1')], NextCursor: null }, + }, + existingFriends: [], + }); + const result = await updateFriendsList(7); + assert.deepEqual(result.map((friend) => friend.id), [1]); + assert.deepEqual(harness.getProfileCalls(), [[1]]); +}); + +test('keeps the previous cache if unique paginated IDs are fewer than the API count', async () => { + const previous = [{ id: 99 }]; + const harness = setup({ + count: 2, + pages: { + first: { PageItems: [makeFriend(1)], NextCursor: 'second' }, + second: { PageItems: [makeFriend(1)], NextCursor: null }, + }, + existingFriends: previous, + }); + const result = await updateFriendsList(7); + assert.deepEqual(result, previous); + assert.deepEqual(harness.storage.rovalra_friends_data[7].friendsList, previous); +}); + +test('keeps the previous cache if profile data omits a requested friend', async () => { + const previous = [{ id: 99 }]; + const harness = setup({ + count: 1, + pages: { first: { PageItems: [makeFriend(1)], NextCursor: null } }, + profiles: async () => ({ profileDetails: [] }), + existingFriends: previous, + }); + const result = await updateFriendsList(7); + assert.deepEqual(result, previous); + assert.deepEqual(harness.storage.rovalra_friends_data[7].friendsList, previous); +}); + +test('returns cached records after an unexpected processing error', async () => { + const previous = [{ id: 99 }]; + const harness = setup({ + count: 1, + pages: { first: { PageItems: [makeFriend(1)], NextCursor: null } }, + profiles: async () => { + throw new Error('unexpected profile processing failure'); + }, + existingFriends: previous, + }); + const result = await updateFriendsList(7); + assert.deepEqual(result, previous); + assert.deepEqual(harness.storage.rovalra_friends_data[7].friendsList, previous); +}); + +test('returns null rather than partial trusted IDs after a page failure', async () => { + setup({ + count: 0, + pages: { first: { PageItems: [], NextCursor: null } }, + trustedPages: { first: new Error('mock trusted-friends failure') }, + }); + assert.equal(await fetchAllTrustedFriends(7), null); +}); + +test('stops trusted-friends pagination when a cursor repeats', async () => { + const harness = setup({ + count: 0, + pages: { first: { PageItems: [], NextCursor: null } }, + trustedPages: { + first: { PageItems: [{ id: 5 }], NextCursor: 'repeat' }, + repeat: { PageItems: [{ id: 6 }], NextCursor: 'repeat' }, + }, + }); + assert.equal(await fetchAllTrustedFriends(7), null); + const trustedRequests = harness.requests.filter((request) => + request.endpoint.includes('findFriendsType=FindTrustedFriends'), + ); + assert.equal(trustedRequests.length, 2); +}); + +test('preserves the friend cache when trusted-friends refresh is incomplete', async () => { + const previous = [{ id: 99, isTrusted: true }]; + const harness = setup({ + count: 1, + pages: { first: { PageItems: [makeFriend(1)], NextCursor: null } }, + trustedPages: { first: new Error('mock trusted-friends failure') }, + existingFriends: previous, + }); + const result = await updateFriendsList(7); + assert.deepEqual(result, previous); + assert.deepEqual(harness.storage.rovalra_friends_data[7].friendsList, previous); +}); + +test.after(async () => { + delete globalThis.chrome; + delete globalThis.__friendsTest; + await rm(temporaryDirectory, { recursive: true, force: true }); +}); From ece65f293ed67cd2dd054f79a19573dbcdde2b6a Mon Sep 17 00:00:00 2001 From: zomop Date: Wed, 11 Nov 2026 15:52:11 +0700 Subject: [PATCH 4/6] fix: handle friends and conversation pagination safely --- .../core/utils/trackers/friendslist.js | 165 ++++++- src/content/features/plus/sendRobux.js | 98 +++- tests/friendslist-pagination.test.mjs | 447 ++++++++++++++++-- 3 files changed, 650 insertions(+), 60 deletions(-) diff --git a/src/content/core/utils/trackers/friendslist.js b/src/content/core/utils/trackers/friendslist.js index fca222304..6852088e2 100644 --- a/src/content/core/utils/trackers/friendslist.js +++ b/src/content/core/utils/trackers/friendslist.js @@ -63,7 +63,7 @@ async function fetchChatConversationsPage(cursor = null) { endpoint: endpoint, useBackground: true, }); - } catch (error) { + } catch { return null; } } @@ -71,11 +71,38 @@ async function fetchChatConversationsPage(cursor = null) { async function fetchAllConversations() { let allConversations = []; let cursor = null; + const seenCursors = new Set(); do { const data = await fetchChatConversationsPage(cursor); - if (!data || !data.conversations) break; + if (!data || !Array.isArray(data.conversations)) { + console.error( + 'RoValra: Conversation pagination failed; discarding partial data', + ); + return null; + } allConversations = allConversations.concat(data.conversations); - cursor = data.next_cursor; + const nextCursor = data.next_cursor; + if ( + nextCursor !== undefined && + nextCursor !== null && + typeof nextCursor !== 'string' + ) { + console.error( + 'RoValra: Conversation pagination returned an invalid cursor', + ); + return null; + } + if ( + nextCursor && + (nextCursor === cursor || seenCursors.has(nextCursor)) + ) { + console.error( + 'RoValra: Conversation pagination returned a repeated cursor', + ); + return null; + } + if (nextCursor) seenCursors.add(nextCursor); + cursor = nextCursor; } while (cursor); return allConversations; } @@ -89,7 +116,7 @@ export async function fetchFriendsPage(userId, cursor = null) { endpoint: endpoint, useBackground: true, }); - } catch (error) { + } catch { return null; } } @@ -123,7 +150,64 @@ export async function fetchFriendsCustom( endpoint: endpoint, useBackground: true, }); + } catch { + return null; + } +} + +export async function fetchAllFriendsCustom( + userId, + params = new URLSearchParams(), +) { + const allFriends = []; + const seenCursors = new Set(); + const customParams = new URLSearchParams(params); + let cursor = null; + customParams.delete('cursor'); + + try { + do { + const response = await fetchFriendsCustom( + userId, + customParams, + cursor, + ); + if ( + !response || + !Array.isArray(response.PageItems) || + response.PageItems.some( + (friend) => + !friend || + (typeof friend.id !== 'number' && + typeof friend.id !== 'string'), + ) + ) { + return null; + } + + const nextCursor = response.NextCursor; + if ( + nextCursor !== undefined && + nextCursor !== null && + typeof nextCursor !== 'string' + ) { + return null; + } + if ( + nextCursor && + (nextCursor === cursor || seenCursors.has(nextCursor)) + ) { + return null; + } + + allFriends.push(...response.PageItems); + if (nextCursor) seenCursors.add(nextCursor); + cursor = nextCursor; + } while (cursor); + + return allFriends; } catch (error) { + console.error('RoValra: Failed to fetch custom friends pages', error); return null; } } @@ -141,9 +225,27 @@ export async function fetchAllTrustedFriends(userId) { endpoint, useBackground: true, }); - if (!response || !Array.isArray(response.PageItems)) return null; + if ( + !response || + !Array.isArray(response.PageItems) || + response.PageItems.some( + (friend) => + !friend || + (typeof friend.id !== 'number' && + typeof friend.id !== 'string'), + ) + ) { + return null; + } response.PageItems.forEach((item) => trustedIds.add(item.id)); const nextCursor = response.NextCursor; + if ( + nextCursor !== undefined && + nextCursor !== null && + typeof nextCursor !== 'string' + ) { + return null; + } if ( nextCursor && (nextCursor === cursor || seenCursors.has(nextCursor)) @@ -167,7 +269,7 @@ export async function fetchFriendsOnlineStatus(userId) { endpoint: `/v1/users/${userId}/friends/online`, useBackground: true, }); - return response?.data || []; + return Array.isArray(response?.data) ? response.data : []; } catch (error) { console.error('RoValra: Failed to fetch online status', error); return []; @@ -279,16 +381,33 @@ export async function updateFriendsList(userId) { do { const page = await fetchFriendsPage(userId, friendsCursor); - if (!page || !Array.isArray(page.PageItems)) { + if ( + !page || + !Array.isArray(page.PageItems) || + page.PageItems.some( + (friend) => + !friend || + (typeof friend.id !== 'number' && + typeof friend.id !== 'string'), + ) + ) { console.error( 'RoValra: Friends pagination failed; keeping existing cache', ); return existingFriendsList; } - allFriends = allFriends.concat(page.PageItems); - const nextCursor = page.NextCursor; + if ( + nextCursor !== undefined && + nextCursor !== null && + typeof nextCursor !== 'string' + ) { + console.error( + 'RoValra: Friends pagination returned an invalid cursor; keeping existing cache', + ); + return existingFriendsList; + } if ( nextCursor && (nextCursor === friendsCursor || seenCursors.has(nextCursor)) @@ -299,6 +418,7 @@ export async function updateFriendsList(userId) { return existingFriendsList; } + allFriends = allFriends.concat(page.PageItems); if (nextCursor) seenCursors.add(nextCursor); friendsCursor = nextCursor; } while (friendsCursor); @@ -323,6 +443,17 @@ export async function updateFriendsList(userId) { return existingFriendsList; } + if ( + friendsCount === null && + uniqueFriends.length === 0 && + existingFriendsList.length > 0 + ) { + console.error( + 'RoValra: Empty friends response without a verified count; keeping existing cache', + ); + return existingFriendsList; + } + const batchSize = 50; let fullFriendsList = []; @@ -497,8 +628,18 @@ export async function updateFriendsList(userId) { profile.hasRobloxSubscription, isDeleted: profile.isDeleted, isTrusted: isTrusted, - canChat: chatStatus?.canChat ?? null, - hasAgeChecked: chatStatus?.hasAgeChecked ?? null, + canChat: + chatStatus?.canChat ?? + (conversations === null + ? existingFriend?.canChat + : null) ?? + null, + hasAgeChecked: + chatStatus?.hasAgeChecked ?? + (conversations === null + ? existingFriend?.hasAgeChecked + : null) ?? + null, mutualFriends: mutualFriends, accountCreated: accountCreated, friendsSince: friendsSince, @@ -590,7 +731,7 @@ async function updateOnlineStatusOnly(userId, currentFriendsList) { ); return updatedList; - } catch (error) { + } catch { return currentFriendsList; } } diff --git a/src/content/features/plus/sendRobux.js b/src/content/features/plus/sendRobux.js index b756413b2..131d1c6fd 100644 --- a/src/content/features/plus/sendRobux.js +++ b/src/content/features/plus/sendRobux.js @@ -17,7 +17,7 @@ import { getUserFullData, getUserProfileData } from '../../core/apis/users.js'; import { fetchUserThumbnailWithApiKey, getBatchThumbnails } from '../../core/thumbnail/thumbnails.js'; import { createUserCard } from '../../core/ui/profile/userCard.js'; import { observeElement, observeChildren, startObserving } from '../../core/observer.js'; -import { fetchFriendsCustom, fetchFriendsOnlineStatus } from '../../core/utils/trackers/friendslist.js'; +import { fetchAllFriendsCustom, fetchFriendsOnlineStatus } from '../../core/utils/trackers/friendslist.js'; import { getUserSettings } from '../../core/donators/settingHandler.js'; import { applyDisplayNameGradientToElement } from '../profile/header/displayNameGradient.js'; import { applyBorderToContainer } from '../profile/avatarBorder.js'; @@ -686,33 +686,77 @@ export function initBuyRobuxPage() { // friendsRobuxCache async function friendsRobuxInit() { const userId = await getAuthenticatedUserId(); - const friendsOnline = await fetchFriendsOnlineStatus(userId); + + if (!userId) { + throw new Error('Authenticated user ID is unavailable.'); + } + const usersInUserSort1Params = new URLSearchParams({ - userSort: 1 + userSort: '1', }); - const usersInUserSort1 = await fetchFriendsCustom(userId, usersInUserSort1Params); - const userSortIds = new Set(usersInUserSort1.PageItems.map(user => user.id)); + const [friendsOnline, usersInUserSort1] = await Promise.all([ + fetchFriendsOnlineStatus(userId), + fetchAllFriendsCustom(userId, usersInUserSort1Params), + ]); - const addFriendsOnlineIds = friendsOnline - .filter(e => !userSortIds.has(e.id)) - .map(e => e.id) + if (!Array.isArray(usersInUserSort1)) { + throw new Error('Failed to fetch the complete friends list.'); + } - if (addFriendsOnlineIds.length > 0) userSortIds.add(...addFriendsOnlineIds); + const userSortIds = new Set(); + + for (const user of usersInUserSort1) { + if (user?.id !== undefined && user?.id !== null) { + userSortIds.add(String(user.id)); + } + } + + if (Array.isArray(friendsOnline)) { + for (const friend of friendsOnline) { + if (friend?.id !== undefined && friend?.id !== null) { + userSortIds.add(String(friend.id)); + } + } + } const userSortIdsArray = [...userSortIds]; + const allUserProfiles = + await getUserProfileData(userSortIdsArray); + if (!Array.isArray(allUserProfiles?.profileDetails)) { + throw new Error('Invalid friends profile response.'); + } - var allUserProfiles = await getUserProfileData(userSortIdsArray); - var thumbnails = await getBatchThumbnails(userSortIdsArray, 'AvatarHeadshot'); + const profileMap = new Map( + allUserProfiles.profileDetails + .filter( + (profile) => + profile?.userId !== undefined && + profile?.userId !== null, + ) + .map((profile) => [String(profile.userId), profile]), + ); - for (const idIndexString in userSortIdsArray) { - const idIndex = Number(idIndexString); - console.log(allUserProfiles.profileDetails[idIndex]); - allUserProfiles.profileDetails[idIndex].imageUrl = thumbnails[idIndex].imageUrl; + if (!userSortIdsArray.every((id) => profileMap.has(id))) { + throw new Error('Incomplete friends profile response.'); } - friendsToSendRobux = allUserProfiles; + const thumbnails = await getBatchThumbnails( + userSortIdsArray, + 'AvatarHeadshot', + ); + + const orderedProfiles = userSortIdsArray.map((id, index) => ({ + ...profileMap.get(id), + imageUrl: thumbnails[index]?.imageUrl || '', + })); + + friendsToSendRobux = { + ...allUserProfiles, + profileDetails: orderedProfiles, + }; + thumbnailData = thumbnails; } @@ -752,9 +796,25 @@ export function initBuyRobuxPage() { }); observeElement('.fui-base-sheet-overlay.foundation-web-portal-zindex.fixed div.friends-listbox-cap', async (element) => { - if (element.dataset.rovalraSendrobuxFriendlistselect == true) return; - element.dataset.rovalraSendrobuxFriendlistselect = true - if (friendsToSendRobux == null) await friendsRobuxInit(); + if (element.dataset.rovalraSendrobuxFriendlistselect) return; + element.dataset.rovalraSendrobuxFriendlistselect = 'loading'; + + try { + if (friendsToSendRobux == null) await friendsRobuxInit(); + + if (!friendsToSendRobux || !Array.isArray(friendsToSendRobux.profileDetails)) { + delete element.dataset.rovalraSendrobuxFriendlistselect; + console.error('RoValra: Could not initialize the Send Robux friends list.'); + return; + } + + element.dataset.rovalraSendrobuxFriendlistselect = 'true'; + } catch (error) { + delete element.dataset.rovalraSendrobuxFriendlistselect; + console.error('RoValra: Failed to initialize the Send Robux friends list.', error); + return; + } + const profiles = friendsToSendRobux.profileDetails; for (const profile of profiles) { diff --git a/tests/friendslist-pagination.test.mjs b/tests/friendslist-pagination.test.mjs index 0a9d5bee4..675efa5c4 100644 --- a/tests/friendslist-pagination.test.mjs +++ b/tests/friendslist-pagination.test.mjs @@ -16,7 +16,7 @@ const bundlePath = path.join(temporaryDirectory, 'friendslist.mjs'); const mockModules = { '../../api': `export const callRobloxApiJson = (...args) => globalThis.__friendsTest.callApi(...args);`, - '../../user': `export const getAuthenticatedUserId = async () => 7;`, + '../../user': `export const getAuthenticatedUserId = async () => globalThis.__friendsTest?.authenticatedUserId ?? 7;`, '../../locale/i18n.js': `export const ts = (key) => key;`, '../../settings/getSettings.js': `export const settings = { unfriendDetectorEnabled: false };`, './unfriendDetector.js': `export const reportDetectedUnfriends = async () => {};`, @@ -40,22 +40,32 @@ await build({ setup(buildApi) { buildApi.onResolve({ filter: /.*/ }, (args) => { if (Object.hasOwn(mockModules, args.path)) { - return { path: args.path, namespace: 'friendslist-test' }; + return { + path: args.path, + namespace: 'friendslist-test', + }; } return undefined; }); buildApi.onLoad( { filter: /.*/, namespace: 'friendslist-test' }, - (args) => ({ contents: mockModules[args.path], loader: 'js' }), + (args) => ({ + contents: mockModules[args.path], + loader: 'js', + }), ); }, }, ], }); -const { fetchAllTrustedFriends, updateFriendsList } = await import( - `${pathToFileURL(bundlePath).href}?test=${Date.now()}` -); +const { + fetchAllFriendsCustom, + fetchAllTrustedFriends, + fetchFriendsCustom, + getFriendsList, + updateFriendsList, +} = await import(`${pathToFileURL(bundlePath).href}?test=${Date.now()}`); function makeFriend(id) { return { id }; @@ -82,11 +92,16 @@ function setup({ count, profiles, trustedPages, + conversationPages, + authenticatedUserId = 7, existingFriends = [{ id: 99 }], }) { const storage = { rovalra_friends_data: { - 7: { friendsList: existingFriends, friendsCount: existingFriends.length }, + 7: { + friendsList: existingFriends, + friendsCount: existingFriends.length, + }, }, }; const requests = []; @@ -97,7 +112,8 @@ function setup({ get(keys, callback) { const result = {}; for (const key of keys) { - if (key in storage) result[key] = structuredClone(storage[key]); + if (key in storage) + result[key] = structuredClone(storage[key]); } callback(result); }, @@ -110,20 +126,34 @@ function setup({ }; globalThis.__friendsTest = { requests, + authenticatedUserId, getProfiles(ids) { profileCalls.push([...ids]); return profiles ? profiles(ids) : makeProfiles(ids); }, async callApi({ subdomain, endpoint }) { requests.push({ subdomain, endpoint }); - if (subdomain === 'friends' && endpoint.endsWith('/friends/count')) { + if ( + subdomain === 'friends' && + endpoint.endsWith('/friends/count') + ) { return { count }; } - if (subdomain === 'friends' && endpoint.endsWith('/friends/online')) { + if ( + subdomain === 'friends' && + endpoint.endsWith('/friends/online') + ) { return { data: [] }; } - if (subdomain === 'apis' && endpoint.includes('get-user-conversations')) { - return { conversations: [] }; + if ( + subdomain === 'apis' && + endpoint.includes('get-user-conversations') + ) { + const query = new URLSearchParams(endpoint.split('?')[1]); + const cursor = query.get('cursor'); + const conversationPage = conversationPages?.[cursor ?? 'first']; + if (conversationPage instanceof Error) throw conversationPage; + return conversationPage ?? { conversations: [] }; } if (subdomain === 'friends' && endpoint.includes('/friends/find')) { if (endpoint.includes('findFriendsType=FindTrustedFriends')) { @@ -139,7 +169,9 @@ function setup({ if (page instanceof Error) throw page; return page; } - throw new Error(`Unexpected mock API request: ${subdomain} ${endpoint}`); + throw new Error( + `Unexpected mock API request: ${subdomain} ${endpoint}`, + ); }, }; return { storage, requests, getProfileCalls: () => profileCalls }; @@ -149,18 +181,30 @@ test('follows valid cursors through multiple pages and stores all unique friends const harness = setup({ count: 2, pages: { - first: { PageItems: [makeFriend(1)], NextCursor: 'next/1' }, - 'next/1': { PageItems: [makeFriend(2)], NextCursor: null }, + first: { PageItems: [makeFriend(1)], NextCursor: 'next:/1' }, + 'next:/1': { PageItems: [makeFriend(2)], NextCursor: null }, }, existingFriends: [], }); const result = await updateFriendsList(7); - assert.deepEqual(result.map((friend) => friend.id), [1, 2]); - assert.ok( - harness.requests.some((request) => - request.endpoint.includes('cursor=next%2F1'), - ), + assert.deepEqual( + result.map((friend) => friend.id), + [1, 2], + ); + const friendPageRequests = harness.requests.filter( + (request) => + request.subdomain === 'friends' && + request.endpoint.includes('/friends/find') && + !request.endpoint.includes('findFriendsType='), + ); + assert.equal( + friendPageRequests[0].endpoint, + '/v1/users/7/friends/find?limit=50', ); + const secondPageParams = new URLSearchParams( + friendPageRequests[1].endpoint.split('?')[1], + ); + assert.deepEqual(secondPageParams.getAll('cursor'), ['next:/1']); }); test('accepts an empty final page with no NextCursor', async () => { @@ -173,7 +217,10 @@ test('accepts an empty final page with no NextCursor', async () => { existingFriends: [], }); const result = await updateFriendsList(7); - assert.deepEqual(result.map((friend) => friend.id), [1]); + assert.deepEqual( + result.map((friend) => friend.id), + [1], + ); }); test('terminates after a non-empty page when NextCursor is absent', async () => { @@ -183,7 +230,10 @@ test('terminates after a non-empty page when NextCursor is absent', async () => existingFriends: [], }); const result = await updateFriendsList(7); - assert.deepEqual(result.map((friend) => friend.id), [1]); + assert.deepEqual( + result.map((friend) => friend.id), + [1], + ); }); test('keeps the previous cache when a cursor repeats', async () => { @@ -198,7 +248,10 @@ test('keeps the previous cache when a cursor repeats', async () => { }); const result = await updateFriendsList(7); assert.deepEqual(result, previous); - assert.deepEqual(harness.storage.rovalra_friends_data[7].friendsList, previous); + assert.deepEqual( + harness.storage.rovalra_friends_data[7].friendsList, + previous, + ); }); test('keeps the previous cache when a later page request fails', async () => { @@ -213,7 +266,10 @@ test('keeps the previous cache when a later page request fails', async () => { }); const result = await updateFriendsList(7); assert.deepEqual(result, previous); - assert.deepEqual(harness.storage.rovalra_friends_data[7].friendsList, previous); + assert.deepEqual( + harness.storage.rovalra_friends_data[7].friendsList, + previous, + ); }); test('deduplicates repeated friend IDs before profile processing', async () => { @@ -226,7 +282,10 @@ test('deduplicates repeated friend IDs before profile processing', async () => { existingFriends: [], }); const result = await updateFriendsList(7); - assert.deepEqual(result.map((friend) => friend.id), [1]); + assert.deepEqual( + result.map((friend) => friend.id), + [1], + ); assert.deepEqual(harness.getProfileCalls(), [[1]]); }); @@ -242,7 +301,10 @@ test('keeps the previous cache if unique paginated IDs are fewer than the API co }); const result = await updateFriendsList(7); assert.deepEqual(result, previous); - assert.deepEqual(harness.storage.rovalra_friends_data[7].friendsList, previous); + assert.deepEqual( + harness.storage.rovalra_friends_data[7].friendsList, + previous, + ); }); test('keeps the previous cache if profile data omits a requested friend', async () => { @@ -255,7 +317,10 @@ test('keeps the previous cache if profile data omits a requested friend', async }); const result = await updateFriendsList(7); assert.deepEqual(result, previous); - assert.deepEqual(harness.storage.rovalra_friends_data[7].friendsList, previous); + assert.deepEqual( + harness.storage.rovalra_friends_data[7].friendsList, + previous, + ); }); test('returns cached records after an unexpected processing error', async () => { @@ -270,7 +335,10 @@ test('returns cached records after an unexpected processing error', async () => }); const result = await updateFriendsList(7); assert.deepEqual(result, previous); - assert.deepEqual(harness.storage.rovalra_friends_data[7].friendsList, previous); + assert.deepEqual( + harness.storage.rovalra_friends_data[7].friendsList, + previous, + ); }); test('returns null rather than partial trusted IDs after a page failure', async () => { @@ -298,6 +366,25 @@ test('stops trusted-friends pagination when a cursor repeats', async () => { assert.equal(trustedRequests.length, 2); }); +test('rejects malformed trusted-friends cursors', async () => { + const harness = setup({ + count: 0, + pages: { first: { PageItems: [], NextCursor: null } }, + trustedPages: { + first: { + PageItems: [makeFriend(5)], + NextCursor: { cursor: 'bad' }, + }, + }, + }); + + assert.equal(await fetchAllTrustedFriends(7), null); + const trustedRequests = harness.requests.filter((request) => + request.endpoint.includes('findFriendsType=FindTrustedFriends'), + ); + assert.equal(trustedRequests.length, 1); +}); + test('preserves the friend cache when trusted-friends refresh is incomplete', async () => { const previous = [{ id: 99, isTrusted: true }]; const harness = setup({ @@ -308,7 +395,309 @@ test('preserves the friend cache when trusted-friends refresh is incomplete', as }); const result = await updateFriendsList(7); assert.deepEqual(result, previous); - assert.deepEqual(harness.storage.rovalra_friends_data[7].friendsList, previous); + assert.deepEqual( + harness.storage.rovalra_friends_data[7].friendsList, + previous, + ); +}); + +test('fetchFriendsCustom overrides a stale cursor without duplicating the parameter', async () => { + const harness = setup({ + count: 0, + pages: { + 'cursor:/two': { + PageItems: [makeFriend(5)], + NextCursor: null, + }, + }, + existingFriends: [], + }); + const params = new URLSearchParams({ userSort: '1', cursor: 'stale' }); + + const response = await fetchFriendsCustom(7, params, 'cursor:/two'); + + assert.deepEqual(response.PageItems, [makeFriend(5)]); + const customRequest = harness.requests.find( + (request) => + request.subdomain === 'friends' && + request.endpoint.includes('userSort=1'), + ); + const query = new URLSearchParams(customRequest.endpoint.split('?')[1]); + assert.deepEqual(query.getAll('cursor'), ['cursor:/two']); +}); + +test('fetchAllFriendsCustom follows every page and retains custom query parameters', async () => { + const harness = setup({ + count: 0, + pages: { + first: { PageItems: [makeFriend(5)], NextCursor: 'custom:next' }, + 'custom:next': { PageItems: [makeFriend(6)], NextCursor: null }, + }, + existingFriends: [], + }); + + const result = await fetchAllFriendsCustom( + 7, + new URLSearchParams({ userSort: '1', cursor: 'stale' }), + ); + + assert.deepEqual( + result.map((friend) => friend.id), + [5, 6], + ); + const customRequests = harness.requests.filter( + (request) => + request.subdomain === 'friends' && + request.endpoint.includes('userSort=1'), + ); + const firstPageParams = new URLSearchParams( + customRequests[0].endpoint.split('?')[1], + ); + const secondPageParams = new URLSearchParams( + customRequests[1].endpoint.split('?')[1], + ); + assert.deepEqual(firstPageParams.getAll('cursor'), []); + assert.deepEqual(secondPageParams.getAll('cursor'), ['custom:next']); +}); + +test('fetchAllFriendsCustom rejects partial data after a later page failure', async () => { + setup({ + count: 0, + pages: { + first: { PageItems: [makeFriend(5)], NextCursor: 'fail' }, + fail: new Error('custom page failed'), + }, + existingFriends: [], + }); + + assert.equal( + await fetchAllFriendsCustom(7, new URLSearchParams({ userSort: '1' })), + null, + ); +}); + +test('fetchAllFriendsCustom terminates on repeated cursors', async () => { + const harness = setup({ + count: 0, + pages: { + first: { PageItems: [makeFriend(5)], NextCursor: 'repeat' }, + repeat: { PageItems: [makeFriend(6)], NextCursor: 'repeat' }, + }, + existingFriends: [], + }); + + assert.equal( + await fetchAllFriendsCustom(7, new URLSearchParams({ userSort: '1' })), + null, + ); + const customRequests = harness.requests.filter( + (request) => + request.subdomain === 'friends' && + request.endpoint.includes('userSort=1'), + ); + assert.equal(customRequests.length, 2); +}); + +test('trusted friends pagination follows cursors through completion', async () => { + const harness = setup({ + count: 0, + pages: { first: { PageItems: [], NextCursor: null } }, + trustedPages: { + first: { PageItems: [makeFriend(2)], NextCursor: 'trusted:next' }, + 'trusted:next': { PageItems: [makeFriend(3)], NextCursor: null }, + }, + }); + + const result = await fetchAllTrustedFriends(7); + + assert.deepEqual([...result], [2, 3]); + const trustedRequests = harness.requests.filter((request) => + request.endpoint.includes('findFriendsType=FindTrustedFriends'), + ); + const secondPageParams = new URLSearchParams( + trustedRequests[1].endpoint.split('?')[1], + ); + assert.deepEqual(secondPageParams.getAll('cursor'), ['trusted:next']); +}); + +test('an empty first page with count zero replaces the old cache', async () => { + const harness = setup({ + count: 0, + pages: { first: { PageItems: [], NextCursor: null } }, + existingFriends: [{ id: 99 }], + }); + + const result = await updateFriendsList(7); + + assert.deepEqual(result, []); + assert.deepEqual(harness.storage.rovalra_friends_data[7].friendsList, []); + assert.equal(harness.storage.rovalra_friends_data[7].friendsCount, 0); +}); + +test('an empty response without a verified count preserves non-empty cache', async () => { + const previous = [{ id: 99 }]; + const harness = setup({ + count: undefined, + pages: { first: { PageItems: [], NextCursor: null } }, + existingFriends: previous, + }); + + const result = await updateFriendsList(7); + + assert.deepEqual(result, previous); + assert.deepEqual( + harness.storage.rovalra_friends_data[7].friendsList, + previous, + ); +}); + +test('invalid friend entries preserve the previous cache', async () => { + const previous = [{ id: 99 }]; + const harness = setup({ + count: 1, + pages: { + first: { + PageItems: [{ username: 'missing-id' }], + NextCursor: null, + }, + }, + existingFriends: previous, + }); + + const result = await updateFriendsList(7); + + assert.deepEqual(result, previous); + assert.deepEqual( + harness.storage.rovalra_friends_data[7].friendsList, + previous, + ); +}); + +test('invalid cursor values preserve the previous cache', async () => { + const previous = [{ id: 99 }]; + const harness = setup({ + count: 1, + pages: { + first: { + PageItems: [makeFriend(1)], + NextCursor: { cursor: 'bad' }, + }, + }, + existingFriends: previous, + }); + + const result = await updateFriendsList(7); + + assert.deepEqual(result, previous); + assert.deepEqual( + harness.storage.rovalra_friends_data[7].friendsList, + previous, + ); +}); + +test('conversation pagination stops on a repeated cursor and preserves cached analysis', async () => { + const previous = [{ id: 1, canChat: false, hasAgeChecked: false }]; + const harness = setup({ + count: 1, + pages: { first: { PageItems: [makeFriend(1)], NextCursor: null } }, + conversationPages: { + first: { + conversations: [ + { + participant_user_ids: [7, 1], + messages: [], + }, + ], + next_cursor: 'repeat', + }, + repeat: { + conversations: [], + next_cursor: 'repeat', + }, + }, + existingFriends: previous, + }); + + const result = await updateFriendsList(7); + + assert.equal(result.length, 1); + assert.equal(result[0].canChat, false); + assert.equal(result[0].hasAgeChecked, false); + assert.equal( + harness.requests.filter((request) => + request.endpoint.includes('get-user-conversations'), + ).length, + 2, + ); + assert.deepEqual( + harness.storage.rovalra_friends_data[7].friendsList, + result, + ); +}); + +test('a complete empty conversation response clears stale chat analysis', async () => { + setup({ + count: 1, + pages: { first: { PageItems: [makeFriend(1)], NextCursor: null } }, + conversationPages: { first: { conversations: [], next_cursor: null } }, + existingFriends: [{ id: 1, canChat: false, hasAgeChecked: false }], + }); + + const result = await updateFriendsList(7); + + assert.equal(result[0].canChat, null); + assert.equal(result[0].hasAgeChecked, null); +}); + +test('getFriendsList fetches the authenticated user rather than a profile target', async () => { + const harness = setup({ + count: 1, + pages: { first: { PageItems: [makeFriend(1)], NextCursor: null } }, + authenticatedUserId: 7, + existingFriends: [], + }); + + const result = await getFriendsList(); + + assert.deepEqual( + result.map((friend) => friend.id), + [1], + ); + const friendRequests = harness.requests.filter( + (request) => + request.subdomain === 'friends' && + request.endpoint.includes('/friends/find') && + !request.endpoint.includes('findFriendsType='), + ); + assert.equal( + friendRequests[0].endpoint, + '/v1/users/7/friends/find?limit=50', + ); +}); + +test('successful refresh replaces old cache contents', async () => { + const harness = setup({ + count: 2, + pages: { + first: { PageItems: [makeFriend(1)], NextCursor: 'next' }, + next: { PageItems: [makeFriend(2)], NextCursor: null }, + }, + existingFriends: [{ id: 99 }], + }); + + const result = await updateFriendsList(7); + + assert.deepEqual( + result.map((friend) => friend.id), + [1, 2], + ); + assert.deepEqual( + harness.storage.rovalra_friends_data[7].friendsList.map( + (friend) => friend.id, + ), + [1, 2], + ); + assert.equal(harness.storage.rovalra_friends_data[7].friendsCount, 2); }); test.after(async () => { From 248b83d0ec740982783787823876dcd7cbadb8ef Mon Sep 17 00:00:00 2001 From: zomop Date: Wed, 11 Nov 2026 16:47:02 +0700 Subject: [PATCH 5/6] Fix trusted friend ID normalization --- .../core/utils/trackers/friendslist.js | 6 ++--- tests/friendslist-pagination.test.mjs | 26 ++++++++++++++++++- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/content/core/utils/trackers/friendslist.js b/src/content/core/utils/trackers/friendslist.js index 6852088e2..d7cf709d2 100644 --- a/src/content/core/utils/trackers/friendslist.js +++ b/src/content/core/utils/trackers/friendslist.js @@ -237,7 +237,7 @@ export async function fetchAllTrustedFriends(userId) { ) { return null; } - response.PageItems.forEach((item) => trustedIds.add(item.id)); + response.PageItems.forEach((item) => trustedIds.add(String(item.id))); const nextCursor = response.NextCursor; if ( nextCursor !== undefined && @@ -521,7 +521,7 @@ export async function updateFriendsList(userId) { const enrichedFriends = profileData.profileDetails.map( (profile) => { const friendId = profile.userId; - const isTrusted = allTrustedFriendsSet.has(friendId); + const isTrusted = allTrustedFriendsSet.has(String(friendId)); const chatStatus = chatAnalysisMap.get(friendId); const userInsights = insightMap.get(friendId) || []; const playedTogetherInsights = @@ -742,7 +742,7 @@ async function updateTrustedFriendsOnly(userId, currentFriendsList) { if (!(trustedIds instanceof Set)) return currentFriendsList; const updatedList = currentFriendsList.map((friend) => ({ ...friend, - isTrusted: trustedIds.has(friend.id), + isTrusted: trustedIds.has(String(friend.id)), })); const storageResult = await new Promise((resolve) => diff --git a/tests/friendslist-pagination.test.mjs b/tests/friendslist-pagination.test.mjs index 675efa5c4..085f929ca 100644 --- a/tests/friendslist-pagination.test.mjs +++ b/tests/friendslist-pagination.test.mjs @@ -510,7 +510,7 @@ test('trusted friends pagination follows cursors through completion', async () = const result = await fetchAllTrustedFriends(7); - assert.deepEqual([...result], [2, 3]); + assert.deepEqual([...result], ['2', '3']); const trustedRequests = harness.requests.filter((request) => request.endpoint.includes('findFriendsType=FindTrustedFriends'), ); @@ -520,6 +520,30 @@ test('trusted friends pagination follows cursors through completion', async () = assert.deepEqual(secondPageParams.getAll('cursor'), ['trusted:next']); }); +test('matches trusted friends when numeric and string IDs represent the same user', async () => { + setup({ + count: 1, + pages: { + first: { + PageItems: [makeFriend(123)], + NextCursor: null, + }, + }, + trustedPages: { + first: { + PageItems: [makeFriend('123')], + NextCursor: null, + }, + }, + existingFriends: [], + }); + + const result = await updateFriendsList(7); + + assert.equal(result.length, 1); + assert.equal(result[0].isTrusted, true); +}); + test('an empty first page with count zero replaces the old cache', async () => { const harness = setup({ count: 0, From 98635f193c522fa36364e88ea9df782bbe9052a9 Mon Sep 17 00:00:00 2001 From: zomop Date: Wed, 11 Nov 2026 22:01:30 +0700 Subject: [PATCH 6/6] Keep friends pagination changes focused --- .../core/utils/trackers/friendslist.js | 57 ----------- src/content/features/plus/sendRobux.js | 98 ++++--------------- tests/friendslist-pagination.test.mjs | 73 -------------- 3 files changed, 19 insertions(+), 209 deletions(-) diff --git a/src/content/core/utils/trackers/friendslist.js b/src/content/core/utils/trackers/friendslist.js index d7cf709d2..36c9935fd 100644 --- a/src/content/core/utils/trackers/friendslist.js +++ b/src/content/core/utils/trackers/friendslist.js @@ -155,63 +155,6 @@ export async function fetchFriendsCustom( } } -export async function fetchAllFriendsCustom( - userId, - params = new URLSearchParams(), -) { - const allFriends = []; - const seenCursors = new Set(); - const customParams = new URLSearchParams(params); - let cursor = null; - customParams.delete('cursor'); - - try { - do { - const response = await fetchFriendsCustom( - userId, - customParams, - cursor, - ); - if ( - !response || - !Array.isArray(response.PageItems) || - response.PageItems.some( - (friend) => - !friend || - (typeof friend.id !== 'number' && - typeof friend.id !== 'string'), - ) - ) { - return null; - } - - const nextCursor = response.NextCursor; - if ( - nextCursor !== undefined && - nextCursor !== null && - typeof nextCursor !== 'string' - ) { - return null; - } - if ( - nextCursor && - (nextCursor === cursor || seenCursors.has(nextCursor)) - ) { - return null; - } - - allFriends.push(...response.PageItems); - if (nextCursor) seenCursors.add(nextCursor); - cursor = nextCursor; - } while (cursor); - - return allFriends; - } catch (error) { - console.error('RoValra: Failed to fetch custom friends pages', error); - return null; - } -} - export async function fetchAllTrustedFriends(userId) { const trustedIds = new Set(); let cursor = null; diff --git a/src/content/features/plus/sendRobux.js b/src/content/features/plus/sendRobux.js index 131d1c6fd..b756413b2 100644 --- a/src/content/features/plus/sendRobux.js +++ b/src/content/features/plus/sendRobux.js @@ -17,7 +17,7 @@ import { getUserFullData, getUserProfileData } from '../../core/apis/users.js'; import { fetchUserThumbnailWithApiKey, getBatchThumbnails } from '../../core/thumbnail/thumbnails.js'; import { createUserCard } from '../../core/ui/profile/userCard.js'; import { observeElement, observeChildren, startObserving } from '../../core/observer.js'; -import { fetchAllFriendsCustom, fetchFriendsOnlineStatus } from '../../core/utils/trackers/friendslist.js'; +import { fetchFriendsCustom, fetchFriendsOnlineStatus } from '../../core/utils/trackers/friendslist.js'; import { getUserSettings } from '../../core/donators/settingHandler.js'; import { applyDisplayNameGradientToElement } from '../profile/header/displayNameGradient.js'; import { applyBorderToContainer } from '../profile/avatarBorder.js'; @@ -686,77 +686,33 @@ export function initBuyRobuxPage() { // friendsRobuxCache async function friendsRobuxInit() { const userId = await getAuthenticatedUserId(); - - if (!userId) { - throw new Error('Authenticated user ID is unavailable.'); - } - + const friendsOnline = await fetchFriendsOnlineStatus(userId); const usersInUserSort1Params = new URLSearchParams({ - userSort: '1', + userSort: 1 }); + const usersInUserSort1 = await fetchFriendsCustom(userId, usersInUserSort1Params); - const [friendsOnline, usersInUserSort1] = await Promise.all([ - fetchFriendsOnlineStatus(userId), - fetchAllFriendsCustom(userId, usersInUserSort1Params), - ]); + const userSortIds = new Set(usersInUserSort1.PageItems.map(user => user.id)); - if (!Array.isArray(usersInUserSort1)) { - throw new Error('Failed to fetch the complete friends list.'); - } + const addFriendsOnlineIds = friendsOnline + .filter(e => !userSortIds.has(e.id)) + .map(e => e.id) - const userSortIds = new Set(); - - for (const user of usersInUserSort1) { - if (user?.id !== undefined && user?.id !== null) { - userSortIds.add(String(user.id)); - } - } - - if (Array.isArray(friendsOnline)) { - for (const friend of friendsOnline) { - if (friend?.id !== undefined && friend?.id !== null) { - userSortIds.add(String(friend.id)); - } - } - } + if (addFriendsOnlineIds.length > 0) userSortIds.add(...addFriendsOnlineIds); const userSortIdsArray = [...userSortIds]; - const allUserProfiles = - await getUserProfileData(userSortIdsArray); - if (!Array.isArray(allUserProfiles?.profileDetails)) { - throw new Error('Invalid friends profile response.'); - } - const profileMap = new Map( - allUserProfiles.profileDetails - .filter( - (profile) => - profile?.userId !== undefined && - profile?.userId !== null, - ) - .map((profile) => [String(profile.userId), profile]), - ); + var allUserProfiles = await getUserProfileData(userSortIdsArray); + var thumbnails = await getBatchThumbnails(userSortIdsArray, 'AvatarHeadshot'); - if (!userSortIdsArray.every((id) => profileMap.has(id))) { - throw new Error('Incomplete friends profile response.'); + for (const idIndexString in userSortIdsArray) { + const idIndex = Number(idIndexString); + console.log(allUserProfiles.profileDetails[idIndex]); + allUserProfiles.profileDetails[idIndex].imageUrl = thumbnails[idIndex].imageUrl; } - const thumbnails = await getBatchThumbnails( - userSortIdsArray, - 'AvatarHeadshot', - ); - - const orderedProfiles = userSortIdsArray.map((id, index) => ({ - ...profileMap.get(id), - imageUrl: thumbnails[index]?.imageUrl || '', - })); - - friendsToSendRobux = { - ...allUserProfiles, - profileDetails: orderedProfiles, - }; - + friendsToSendRobux = allUserProfiles; thumbnailData = thumbnails; } @@ -796,25 +752,9 @@ export function initBuyRobuxPage() { }); observeElement('.fui-base-sheet-overlay.foundation-web-portal-zindex.fixed div.friends-listbox-cap', async (element) => { - if (element.dataset.rovalraSendrobuxFriendlistselect) return; - element.dataset.rovalraSendrobuxFriendlistselect = 'loading'; - - try { - if (friendsToSendRobux == null) await friendsRobuxInit(); - - if (!friendsToSendRobux || !Array.isArray(friendsToSendRobux.profileDetails)) { - delete element.dataset.rovalraSendrobuxFriendlistselect; - console.error('RoValra: Could not initialize the Send Robux friends list.'); - return; - } - - element.dataset.rovalraSendrobuxFriendlistselect = 'true'; - } catch (error) { - delete element.dataset.rovalraSendrobuxFriendlistselect; - console.error('RoValra: Failed to initialize the Send Robux friends list.', error); - return; - } - + if (element.dataset.rovalraSendrobuxFriendlistselect == true) return; + element.dataset.rovalraSendrobuxFriendlistselect = true + if (friendsToSendRobux == null) await friendsRobuxInit(); const profiles = friendsToSendRobux.profileDetails; for (const profile of profiles) { diff --git a/tests/friendslist-pagination.test.mjs b/tests/friendslist-pagination.test.mjs index 085f929ca..4d228e7e7 100644 --- a/tests/friendslist-pagination.test.mjs +++ b/tests/friendslist-pagination.test.mjs @@ -60,7 +60,6 @@ await build({ }); const { - fetchAllFriendsCustom, fetchAllTrustedFriends, fetchFriendsCustom, getFriendsList, @@ -426,78 +425,6 @@ test('fetchFriendsCustom overrides a stale cursor without duplicating the parame assert.deepEqual(query.getAll('cursor'), ['cursor:/two']); }); -test('fetchAllFriendsCustom follows every page and retains custom query parameters', async () => { - const harness = setup({ - count: 0, - pages: { - first: { PageItems: [makeFriend(5)], NextCursor: 'custom:next' }, - 'custom:next': { PageItems: [makeFriend(6)], NextCursor: null }, - }, - existingFriends: [], - }); - - const result = await fetchAllFriendsCustom( - 7, - new URLSearchParams({ userSort: '1', cursor: 'stale' }), - ); - - assert.deepEqual( - result.map((friend) => friend.id), - [5, 6], - ); - const customRequests = harness.requests.filter( - (request) => - request.subdomain === 'friends' && - request.endpoint.includes('userSort=1'), - ); - const firstPageParams = new URLSearchParams( - customRequests[0].endpoint.split('?')[1], - ); - const secondPageParams = new URLSearchParams( - customRequests[1].endpoint.split('?')[1], - ); - assert.deepEqual(firstPageParams.getAll('cursor'), []); - assert.deepEqual(secondPageParams.getAll('cursor'), ['custom:next']); -}); - -test('fetchAllFriendsCustom rejects partial data after a later page failure', async () => { - setup({ - count: 0, - pages: { - first: { PageItems: [makeFriend(5)], NextCursor: 'fail' }, - fail: new Error('custom page failed'), - }, - existingFriends: [], - }); - - assert.equal( - await fetchAllFriendsCustom(7, new URLSearchParams({ userSort: '1' })), - null, - ); -}); - -test('fetchAllFriendsCustom terminates on repeated cursors', async () => { - const harness = setup({ - count: 0, - pages: { - first: { PageItems: [makeFriend(5)], NextCursor: 'repeat' }, - repeat: { PageItems: [makeFriend(6)], NextCursor: 'repeat' }, - }, - existingFriends: [], - }); - - assert.equal( - await fetchAllFriendsCustom(7, new URLSearchParams({ userSort: '1' })), - null, - ); - const customRequests = harness.requests.filter( - (request) => - request.subdomain === 'friends' && - request.endpoint.includes('userSort=1'), - ); - assert.equal(customRequests.length, 2); -}); - test('trusted friends pagination follows cursors through completion', async () => { const harness = setup({ count: 0,