import { computed, reactive, ref, watch } from 'vue'; import { defineStore } from 'pinia'; import { useRouter } from 'vue-router'; import { i18n } from '../plugins/i18n'; import { compareByCreatedAtAscending, createRateLimiter, executeWithBackoff, getFriendsSortFunction, isRealInstance } from '../shared/utils'; import { getUserMemo } from '../coordinators/memoCoordinator'; import { friendRequest, userRequest } from '../api'; import { runInitFriendsListFlow, runRefreshFriendsListFlow } from '../coordinators/friendSyncCoordinator'; import { runPendingOfflineTickFlow, runUpdateFriendFlow } from '../coordinators/friendPresenceCoordinator'; import { updateFriendship, runUpdateFriendshipsFlow } from '../coordinators/friendRelationshipCoordinator'; import { applyUser } from '../coordinators/userCoordinator'; import { AppDebug } from '../services/appConfig'; import { database } from '../services/database'; import { useAppearanceSettingsStore } from './settings/appearance'; import { useFavoriteStore } from './favorite'; import { useGeneralSettingsStore } from './settings/general'; import { useGroupStore } from './group'; import { useLocationStore } from './location'; import { useUserStore } from './user'; import { watchState } from '../services/watchState'; import configRepository from '../services/config'; import * as workerTimers from 'worker-timers'; export const useFriendStore = defineStore('Friend', () => { const appearanceSettingsStore = useAppearanceSettingsStore(); const generalSettingsStore = useGeneralSettingsStore(); const userStore = useUserStore(); const groupStore = useGroupStore(); const locationStore = useLocationStore(); const favoriteStore = useFavoriteStore(); const router = useRouter(); const t = i18n.global.t; const state = reactive({ friendNumber: 0 }); const friendLog = new Map(); const friends = reactive(new Map()); const localFavoriteFriends = reactive(new Set()); const allFavoriteFriendIds = computed(() => { const favoriteStore = useFavoriteStore(); const set = new Set(); for (const ref of favoriteStore.cachedFavorites.values()) { if (ref.type === 'friend') { set.add(ref.favoriteId); } } for (const groupName in favoriteStore.localFriendFavorites) { const userIds = favoriteStore.localFriendFavorites[groupName]; if (userIds) { for (const id of userIds) { set.add(id); } } } return set; }); const allFavoriteOnlineFriends = computed(() => { return Array.from(friends.values()) .filter( (f) => f.state === 'online' && allFavoriteFriendIds.value.has(f.id) ) .sort( getFriendsSortFunction( appearanceSettingsStore.sidebarSortMethods ) ); }); const isRefreshFriendsLoading = ref(false); const onlineFriendCount = ref(0); const pendingOfflineDelay = 170000; let pendingOfflineWorker = null; const pendingOfflineMap = new Map(); const friendLogTable = ref({ data: [], filters: [ { prop: 'type', value: [] }, { prop: 'displayName', value: '' }, { prop: 'type', value: false, filterFn: (row, filter) => !(filter.value && row.type === 'Unfriend') } ], pageSizeLinked: true, loading: false }); watch( router.currentRoute, (value) => { if (value.name === 'friend-log') { initFriendLogHistoryTable(); } else { friendLogTable.value.data = []; } }, { immediate: true } ); const vipFriends = computed(() => { return Array.from(friends.values()) .filter((f) => f.state === 'online' && f.isVIP) .sort( getFriendsSortFunction( appearanceSettingsStore.sidebarSortMethods ) ); }); const onlineFriends = computed(() => { return Array.from(friends.values()) .filter((f) => f.state === 'online' && !f.isVIP) .sort( getFriendsSortFunction( appearanceSettingsStore.sidebarSortMethods ) ); }); const activeFriends = computed(() => { return Array.from(friends.values()) .filter((f) => f.state === 'active') .sort( getFriendsSortFunction( appearanceSettingsStore.sidebarSortMethods ) ); }); const offlineFriends = computed(() => { return Array.from(friends.values()) .filter((f) => f.state === 'offline' || !f.state) .sort( getFriendsSortFunction( appearanceSettingsStore.sidebarSortMethods ) ); }); const friendsInSameInstance = computed(() => { const friendsList = {}; const allFriends = [...vipFriends.value, ...onlineFriends.value]; allFriends.forEach((friend) => { if (!friend.ref?.$location) { return; } let locationTag = friend.ref.$location.tag; if ( !friend.ref.$location.isRealInstance && locationStore.lastLocation.friendList.has(friend.id) ) { locationTag = locationStore.lastLocation.location; } const isReal = isRealInstance(locationTag); if (!isReal) { return; } if (!friendsList[locationTag]) { friendsList[locationTag] = []; } friendsList[locationTag].push(friend); }); const sortedFriendsList = []; for (const group of Object.values(friendsList)) { if (group.length > 1) { sortedFriendsList.push( group.sort( getFriendsSortFunction( appearanceSettingsStore.sidebarSortMethods ) ) ); } } return sortedFriendsList.sort((a, b) => b.length - a.length); }); watch( () => watchState.isLoggedIn, (isLoggedIn) => { friends.clear(); state.friendNumber = 0; friendLog.clear(); friendLogTable.value.data = []; groupStore.clearGroupInstances(); onlineFriendCount.value = 0; pendingOfflineMap.clear(); if (isLoggedIn) { runInitFriendsListFlow(i18n.global.t); pendingOfflineWorkerFunction(); } else { if (pendingOfflineWorker !== null) { workerTimers.clearInterval(pendingOfflineWorker); pendingOfflineWorker = null; } } }, { flush: 'sync' } ); watch( () => watchState.isFriendsLoaded, (isFriendsLoaded) => { if (isFriendsLoaded) { updateOnlineFriendCounter(); } }, { flush: 'sync' } ); /** * */ async function init() { const friendLogTableFiltersValue = JSON.parse( await configRepository.getString('VRCX_friendLogTableFilters', '[]') ); friendLogTable.value.filters[0].value = friendLogTableFiltersValue; } init(); /** * */ function updateLocalFavoriteFriends() { const favoriteStore = useFavoriteStore(); localFavoriteFriends.clear(); const groups = generalSettingsStore.localFavoriteFriendsGroups; const hasRemoteGroupFilter = groups.some( (key) => !key.startsWith('local:') ); // Remote favorites: filter by selected remote groups for (const ref of favoriteStore.cachedFavorites.values()) { if ( ref.type === 'friend' && (!hasRemoteGroupFilter || groups.includes(ref.$groupKey)) ) { localFavoriteFriends.add(ref.favoriteId); } } // Local favorites: always include all for (const groupName in favoriteStore.localFriendFavorites) { const userIds = favoriteStore.localFriendFavorites[groupName]; if (userIds) { for (let i = 0; i < userIds.length; ++i) { localFavoriteFriends.add(userIds[i]); } } } updateSidebarFavorites(); } /** * */ function updateSidebarFavorites() { for (const ctx of friends.values()) { const isVIP = localFavoriteFriends.has(ctx.id); if (ctx.isVIP === isVIP) { continue; } ctx.isVIP = isVIP; } } /** * */ async function pendingOfflineWorkerFunction() { pendingOfflineWorker = workerTimers.setInterval(() => { runPendingOfflineTickFlow(); }, 1000); } /** * @param {string} id */ function deleteFriend(id) { const ctx = friends.get(id); if (typeof ctx === 'undefined') { return; } friends.delete(id); } /** * * @param ref */ function refreshFriendsStatus(ref) { let id; const map = new Map(); for (id of ref.friends) { map.set(id, 'offline'); } for (id of ref.offlineFriends) { map.set(id, 'offline'); } for (id of ref.activeFriends) { map.set(id, 'active'); } for (id of ref.onlineFriends) { map.set(id, 'online'); } for (const friend of map) { const [id, state_input] = friend; if (friends.has(id)) { runUpdateFriendFlow(id, state_input); } else { addFriend(id, state_input); } } for (id of friends.keys()) { if (map.has(id) === false) { deleteFriend(id); } } } /** * @param {string} id * @param {string?} state_input */ function addFriend(id, state_input = undefined) { if (friends.has(id)) { return; } const ref = userStore.cachedUsers.get(id); const isVIP = localFavoriteFriends.has(id); let name = ''; const friend = friendLog.get(id); if (friend) { name = friend.displayName; } const ctx = reactive({ id, state: state_input || 'offline', isVIP, ref, name, memo: '', pendingOffline: false, $nickName: '' }); if (watchState.isFriendsLoaded) { getUserMemo(id).then((memo) => { if (memo.userId === id) { ctx.memo = memo.memo; ctx.$nickName = ''; if (memo.memo) { const array = memo.memo.split('\n'); ctx.$nickName = array[0]; } } }); } if (typeof ref === 'undefined') { const friendLogRef = friendLog.get(id); if (friendLogRef?.displayName) { ctx.name = friendLogRef.displayName; } } else { ctx.name = ref.name; } friends.set(id, ctx); } /** * * @returns {Promise<*[]>} */ async function refreshFriends() { isRefreshFriendsLoading.value = true; try { const onlineFriends = await bulkRefreshFriends({ offline: false }); const offlineFriends = await bulkRefreshFriends({ offline: true }); var friends = onlineFriends.concat(offlineFriends); friends = await refetchBrokenFriends(friends); if (!watchState.isFriendsLoaded) { friends = await refreshRemainingFriends(friends); } isRefreshFriendsLoading.value = false; return friends; } catch (err) { isRefreshFriendsLoading.value = false; throw err; } } /** * @param {object} args * @returns {Promise<*[]>} */ async function bulkRefreshFriends(args) { // API offset limit *was* 5000 // it is now 7500 const MAX_OFFSET = 7500; const PAGE_SIZE = 50; const CONCURRENCY = 5; const RATE_PER_MINUTE = 60; const MAX_RETRY = 5; const RETRY_BASE_DELAY = 1000; const rateLimiter = createRateLimiter({ limitPerInterval: RATE_PER_MINUTE, intervalMs: 60_000 }); /** * * @param offset */ 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') } ); return result; } let nextOffset = 0; let stopFlag = false; const friends = []; /** * */ function getNextOffset() { if (stopFlag) return null; const cur = nextOffset; nextOffset += PAGE_SIZE; if (cur > MAX_OFFSET) return null; return cur; } /** * */ async function worker() { while (true) { const offset = getNextOffset(); if (offset === null) break; await rateLimiter.wait(); const page = await fetchPage(offset); if (page.length === 0) { stopFlag = true; break; } friends.push(...page); } } await Promise.all(Array.from({ length: CONCURRENCY }, () => worker())); return friends; } /** * @param {Array} friendsArray * @returns {Promise<*>} */ async function refetchBrokenFriends(friendsArray) { // attempt to fix broken data from bulk friend fetch for (let i = 0; i < friendsArray.length; i++) { const friend = friendsArray[i]; try { // we don't update friend state here, it's not reliable let state_input = 'offline'; if (friend.platform === 'web') { state_input = 'active'; } else if (friend.platform) { state_input = 'online'; } const ref = friends.get(friend.id); if (ref?.state !== state_input) { if (AppDebug.debugFriendState) { console.log( `Refetching friend state it does not match ${friend.displayName} from ${ref?.state} to ${state_input}`, friend ); } const args = await userRequest.getUser({ userId: friend.id }); friendsArray[i] = args.json; } else if (friend.location === 'traveling') { if (AppDebug.debugFriendState) { console.log( 'Refetching traveling friend', friend.displayName ); } const args = await userRequest.getUser({ userId: friend.id }); friendsArray[i] = args.json; } } catch (err) { console.error(err); } } return friendsArray; } /** * @param {Array} friends * @returns {Promise<*>} */ async function refreshRemainingFriends(friends) { const friendsSet = new Set(friends.map((x) => x.id)); for (const userId of userStore.currentUser.friends) { if (!friendsSet.has(userId)) { try { if (!watchState.isLoggedIn) { console.error(`User isn't logged in`); return friends; } console.log('Fetching remaining friend', userId); const args = await userRequest.getUser({ userId }); friends.push(args.json); } catch (err) { console.error(err); } } } return friends; } /** * @returns {Promise} */ /** * * @param forceUpdate */ function updateOnlineFriendCounter(forceUpdate = false) { const onlineFriendCounts = vipFriends.value.length + onlineFriends.value.length; if (onlineFriendCounts !== onlineFriendCount.value || forceUpdate) { AppApi.ExecuteVrOverlayFunction( 'updateOnlineFriendCount', `${onlineFriendCounts}` ); onlineFriendCount.value = onlineFriendCounts; } } /** * */ async function getAllUserStats() { let ref; let item; const userIds = []; const displayNames = []; for (const ctx of friends.values()) { userIds.push(ctx.id); if (ctx.ref?.displayName) { displayNames.push(ctx.ref.displayName); } } const data = await database.getAllUserStats(userIds, displayNames); const dataByDisplayName = new Map(); const friendsByDisplayName = new Map(); for (const ref of data) { if (ref.displayName && ref.userId) { dataByDisplayName.set(ref.displayName, ref.userId); } } for (const ref of friends.values()) { if (ref?.ref?.id && ref.ref.displayName) { friendsByDisplayName.set(ref.ref.displayName, ref.id); } } const friendListMap = new Map(); for (item of data) { if (!item.userId) { // find userId from previous data with matching displayName item.userId = dataByDisplayName.get(item.displayName); // if still no userId, find userId from friends list if (!item.userId) { item.userId = friendsByDisplayName.get(item.displayName); } // if still no userId, skip if (!item.userId) { continue; } } const friend = friendListMap.get(item.userId); if (!friend) { friendListMap.set(item.userId, item); continue; } if (Date.parse(item.lastSeen) > Date.parse(friend.lastSeen)) { friend.lastSeen = item.lastSeen; } friend.timeSpent += item.timeSpent; friend.joinCount += item.joinCount; friend.displayName = item.displayName; friendListMap.set(item.userId, friend); } for (item of friendListMap.values()) { ref = friends.get(item.userId); if (ref?.ref) { ref.ref.$joinCount = item.joinCount; ref.ref.$lastSeen = item.lastSeen; ref.ref.$timeSpent = item.timeSpent; } } } /** * */ async function getAllUserMutualCount() { const mutualCountMap = await database.getMutualCountForAllUsers(); for (const [userId, mutualCount] of mutualCountMap.entries()) { const ref = friends.get(userId); if (ref?.ref) { ref.ref.$mutualCount = mutualCount; } } } /** * * @param {string} id */ /** * * @param {object} ref */ /** * * @param {object} currentUser * @returns {Promise} */ async function initFriendLog(currentUser) { refreshFriendsStatus(currentUser); const sqlValues = []; const friends = await refreshFriends(); for (const friend of friends) { const ref = applyUser(friend); const row = { userId: ref.id, displayName: ref.displayName, trustLevel: ref.$trustLevel, friendNumber: 0 }; friendLog.set(friend.id, row); sqlValues.unshift(row); } database.setFriendLogCurrentArray(sqlValues); await configRepository.setBool(`friendLogInit_${currentUser.id}`, true); watchState.isFriendsLoaded = true; } /** * * @param {string} userId * @returns {Promise} */ async function migrateFriendLog(userId) { VRCXStorage.Remove(`${userId}_friendLogUpdatedAt`); VRCXStorage.Remove(`${userId}_friendLog`); friendLogTable.value.data = await VRCXStorage.GetArray( `${userId}_friendLogTable` ); database.addFriendLogHistoryArray(friendLogTable.value.data); VRCXStorage.Remove(`${userId}_friendLogTable`); await configRepository.setBool(`friendLogInit_${userId}`, true); } /** * * @param {object} currentUser * @returns {Promise} */ async function getFriendLog(currentUser) { let friend; state.friendNumber = await configRepository.getInt( `VRCX_friendNumber_${currentUser.id}`, 0 ); const maxFriendLogNumber = await database.getMaxFriendLogNumber(); if (state.friendNumber < maxFriendLogNumber) { state.friendNumber = maxFriendLogNumber; } const friendLogCurrentArray = await database.getFriendLogCurrent(); for (friend of friendLogCurrentArray) { friendLog.set(friend.userId, friend); } refreshFriendsStatus(currentUser); await refreshFriends(); watchState.isFriendsLoaded = true; // check for friend/name/rank change AFTER isFriendsLoaded is set for (friend of friendLogCurrentArray) { const ref = userStore.cachedUsers.get(friend.userId); if (typeof ref !== 'undefined') { updateFriendship(ref); } } if (typeof currentUser.friends !== 'undefined') { runUpdateFriendshipsFlow(currentUser); } } /** * */ async function initFriendLogHistoryTable() { friendLogTable.value.loading = true; friendLogTable.value.data = await database.getFriendLogHistory(); friendLogTable.value.loading = false; } /** * @returns void * @param {number} friendNumber * @param {string} userId */ function setFriendNumber(friendNumber, userId) { const ref = friendLog.get(userId); if (!ref) { return; } ref.friendNumber = friendNumber; friendLog.set(ref.userId, ref); database.setFriendLogCurrent(ref); const friendRef = friends.get(userId); if (friendRef?.ref) { friendRef.ref.$friendNumber = friendNumber; } } /** * */ async function tryApplyFriendOrder() { const lastUpdate = await configRepository.getString( `VRCX_lastStoreTime_${userStore.currentUser.id}` ); if (lastUpdate === '-5') { // this means we're done return; } // reset friendNumber and friendLog state.friendNumber = 0; for (const ref of friendLog.values()) { ref.friendNumber = 0; } const friendOrder = userStore.currentUser.friends; for (let i = 0; i < friendOrder.length; i++) { const userId = friendOrder[i]; state.friendNumber++; setFriendNumber(state.friendNumber, userId); } if (state.friendNumber === 0) { state.friendNumber = friends.size; } console.log('Applied friend order from API', state.friendNumber); await configRepository.setInt( `VRCX_friendNumber_${userStore.currentUser.id}`, state.friendNumber ); await configRepository.setString( `VRCX_lastStoreTime_${userStore.currentUser.id}`, '-5' ); } /** * @deprecated We might need this again one day */ async function tryRestoreFriendNumber() { const lastUpdate = await configRepository.getString( `VRCX_lastStoreTime_${userStore.currentUser.id}` ); if (lastUpdate === '-4') { // this means the backup was already applied return; } var status = false; state.friendNumber = 0; for (const ref of friendLog.values()) { ref.friendNumber = 0; } try { if (lastUpdate) { // backup ready to try apply status = await restoreFriendNumber(); } // needs to be in reverse because we don't know the starting number applyFriendLogFriendOrderInReverse(); } catch (err) { console.error(err); } // if (status) { // this.$message({ // message: 'Friend order restored from backup', // type: 'success', // duration: 0, // showClose: true // }); // } else if (this.friendLogTable.data.length > 0) { // this.$message({ // message: // 'No backup found, friend order partially restored from friendLog', // type: 'success', // duration: 0, // showClose: true // }); // } await configRepository.setString( `VRCX_lastStoreTime_${userStore.currentUser.id}`, '-4' ); } /** * */ async function restoreFriendNumber() { let message; let storedData = null; try { const data = await configRepository.getString( `VRCX_friendOrder_${userStore.currentUser.id}` ); if (data) { storedData = JSON.parse(data); } } catch (err) { console.error(err); } if (!storedData || storedData.length === 0) { message = 'whomp whomp, no friend order backup found'; console.error(message); return false; } const friendLogTable = getFriendLogFriendOrder(); // for storedData const machList = []; for (let i = 0; i < Object.keys(storedData).length; i++) { const key = Object.keys(storedData)[i]; const value = storedData[key]; const item = parseFriendOrderBackup(friendLogTable, key, value); machList.push(item); } machList.sort((a, b) => b.matches - a.matches); console.log( `friendLog: ${friendLogTable.length} friendOrderBackups:`, machList ); const bestBackup = machList[0]; if (!bestBackup?.isValid) { message = 'whomp whomp, no valid backup found'; console.error(message); return false; } applyFriendOrderBackup(bestBackup.table); applyFriendLogFriendOrder(); await configRepository.setInt( `VRCX_friendNumber_${userStore.currentUser.id}`, state.friendNumber ); return true; } /** * */ function applyFriendLogFriendOrderInReverse() { state.friendNumber = friends.size + 1; const friendLogTable = getFriendLogFriendOrder(); for (let i = friendLogTable.length - 1; i > -1; i--) { const friendLogEntry = friendLogTable[i]; const ref = friendLog.get(friendLogEntry.id); if (!ref) { continue; } if (ref.friendNumber) { break; } ref.friendNumber = --state.friendNumber; friendLog.set(ref.userId, ref); database.setFriendLogCurrent(ref); const friendRef = friends.get(friendLogEntry.id); if (friendRef?.ref) { friendRef.ref.$friendNumber = ref.friendNumber; } } state.friendNumber = friends.size; console.log('Applied friend order from friendLog'); } /** * */ function getFriendLogFriendOrder() { const result = []; for (let i = 0; i < friendLogTable.value.data.length; i++) { const ref = friendLogTable.value.data[i]; if (ref.type !== 'Friend') { continue; } if (result.findIndex((x) => x.id === ref.userId) !== -1) { // console.log( // 'ignoring duplicate friend', // ref.displayName, // ref.created_at // ); continue; } result.push({ id: ref.userId, displayName: ref.displayName, created_at: ref.created_at }); } result.sort(compareByCreatedAtAscending); return result; } /** * * @param friendLogTable * @param created_at * @param backupUserIds */ function parseFriendOrderBackup(friendLogTable, created_at, backupUserIds) { let i; const backupTable = []; for (i = 0; i < backupUserIds.length; i++) { const userId = backupUserIds[i]; const ctx = friends.get(userId); if (ctx) { backupTable.push({ id: ctx.id, displayName: ctx.name }); } } // var compareTable = []; // compare 2 tables, find max amount of id's in same order let maxMatches = 0; let currentMatches = 0; let backupIndex = 0; for (i = 0; i < friendLogTable.length; i++) { var isMatch = false; const ref = friendLogTable[i]; if (backupIndex <= 0) { backupIndex = backupTable.findIndex((x) => x.id === ref.id); if (backupIndex !== -1) { currentMatches = 1; } } else if (backupTable[backupIndex].id === ref.id) { currentMatches++; isMatch = true; } else { backupIndex = backupTable.findIndex((x) => x.id === ref.id); if (backupIndex !== -1) { currentMatches = 1; } } if (backupIndex === backupTable.length - 1) { backupIndex = 0; } else { backupIndex++; } if (currentMatches > maxMatches) { maxMatches = currentMatches; } // compareTable.push({ // id: ref.id, // displayName: ref.displayName, // match: isMatch // }); } const lerp = (a, b, alpha) => { return a + alpha * (b - a); }; return { matches: parseFloat(`${maxMatches}.${created_at}`), table: backupUserIds, isValid: maxMatches > lerp(4, 10, backupTable.length / 1000) // pls no collisions }; } /** * * @param userIdOrder */ function applyFriendOrderBackup(userIdOrder) { for (let i = 0; i < userIdOrder.length; i++) { const userId = userIdOrder[i]; const ctx = friends.get(userId); const ref = ctx?.ref; if (!ref || ref.$friendNumber) { continue; } const friendLogCurrent = { userId, displayName: ref.displayName, trustLevel: ref.$trustLevel, friendNumber: i + 1 }; friendLog.set(userId, friendLogCurrent); database.setFriendLogCurrent(friendLogCurrent); state.friendNumber = i + 1; } } /** * */ function applyFriendLogFriendOrder() { const friendLogTable = getFriendLogFriendOrder(); if (state.friendNumber === 0) { console.log('No backup applied, applying friend log in reverse'); // this means no FriendOrderBackup was applied // will need to apply in reverse order instead return; } for (const friendLogEntry of friendLogTable) { const ref = friendLog.get(friendLogEntry.id); if (!ref || ref.friendNumber) { continue; } ref.friendNumber = ++state.friendNumber; friendLog.set(ref.userId, ref); database.setFriendLogCurrent(ref); const friendRef = friends.get(friendLogEntry.id); if (friendRef?.ref) { friendRef.ref.$friendNumber = ref.friendNumber; } } } /** * * @param id */ /** * Clears all entries in friendLog. * Uses .clear() instead of reassignment to keep the same Map reference, * so that coordinators reading friendStore.friendLog stay in sync. */ function resetFriendLog() { friendLog.clear(); } return { state, friends, vipFriends, onlineFriends, activeFriends, offlineFriends, friendsInSameInstance, allFavoriteFriendIds, allFavoriteOnlineFriends, localFavoriteFriends, isRefreshFriendsLoading, onlineFriendCount, friendLog, friendLogTable, pendingOfflineMap, pendingOfflineDelay, updateLocalFavoriteFriends, updateSidebarFavorites, deleteFriend, refreshFriendsStatus, addFriend, refreshFriends, updateOnlineFriendCounter, getAllUserStats, getAllUserMutualCount, initFriendLog, migrateFriendLog, getFriendLog, tryApplyFriendOrder, resetFriendLog, initFriendLogHistoryTable }; });