import { computed, nextTick, reactive, ref, shallowReactive, watch } from 'vue'; import { defineStore } from 'pinia'; import { toast } from 'vue-sonner'; import { useI18n } from 'vue-i18n'; import Noty from 'noty'; import { arraysMatch, compareByCreatedAt, compareByDisplayName, compareByLocationAt, compareByName, compareByUpdatedAt, extractFileId, getAllUserMemos, getGroupName, getUserMemo, getWorldName, isRealInstance, parseLocation, removeEmojis, replaceBioSymbols } from '../shared/utils'; import { avatarRequest, groupRequest, instanceRequest, userRequest } from '../api'; import { processBulk, request } from '../service/request'; import { AppDebug } from '../service/appConfig'; import { database } from '../service/database'; import { formatJsonVars } from '../shared/utils/base/ui'; import { useAppearanceSettingsStore } from './settings/appearance'; import { useAuthStore } from './auth'; import { useAvatarStore } from './avatar'; import { useFavoriteStore } from './favorite'; import { useFeedStore } from './feed'; import { useFriendStore } from './friend'; import { useGameStore } from './game'; import { useGeneralSettingsStore } from './settings/general'; import { useGroupStore } from './group'; import { useInstanceStore } from './instance'; import { useLocationStore } from './location'; import { useModerationStore } from './moderation'; import { useNotificationStore } from './notification'; import { usePhotonStore } from './photon'; import { useSearchStore } from './search'; import { useSharedFeedStore } from './sharedFeed'; import { useUiStore } from './ui'; import { useWorldStore } from './world'; import { watchState } from '../service/watchState'; import * as workerTimers from 'worker-timers'; export const useUserStore = defineStore('User', () => { const appearanceSettingsStore = useAppearanceSettingsStore(); const friendStore = useFriendStore(); const favoriteStore = useFavoriteStore(); const locationStore = useLocationStore(); const instanceStore = useInstanceStore(); const avatarStore = useAvatarStore(); const generalSettingsStore = useGeneralSettingsStore(); const searchStore = useSearchStore(); const gameStore = useGameStore(); const notificationStore = useNotificationStore(); const authStore = useAuthStore(); const groupStore = useGroupStore(); const feedStore = useFeedStore(); const worldStore = useWorldStore(); const uiStore = useUiStore(); const moderationStore = useModerationStore(); const photonStore = usePhotonStore(); const sharedFeedStore = useSharedFeedStore(); const { t } = useI18n(); const currentUser = ref({ acceptedPrivacyVersion: 0, acceptedTOSVersion: 0, accountDeletionDate: null, accountDeletionLog: null, activeFriends: [], ageVerificationStatus: '', ageVerified: false, allowAvatarCopying: false, badges: [], bio: '', bioLinks: [], currentAvatar: '', currentAvatarImageUrl: '', currentAvatarTags: [], currentAvatarThumbnailImageUrl: '', date_joined: '', developerType: '', displayName: '', emailVerified: false, fallbackAvatar: '', friendGroupNames: [], friendKey: '', friends: [], googleId: '', hasBirthday: false, hasEmail: false, hasLoggedInFromClient: false, hasPendingEmail: false, hasSharedConnectionsOptOut: false, hideContentFilterSettings: false, homeLocation: '', id: '', isAdult: true, isBoopingEnabled: false, isFriend: false, last_activity: '', last_login: '', last_mobile: null, last_platform: '', obfuscatedEmail: '', obfuscatedPendingEmail: '', oculusId: '', offlineFriends: [], onlineFriends: [], pastDisplayNames: [], picoId: '', presence: { avatarThumbnail: '', currentAvatarTags: '', debugflag: '', displayName: '', groups: [], id: '', instance: '', instanceType: '', platform: '', profilePicOverride: '', status: '', travelingToInstance: '', travelingToWorld: '', userIcon: '', world: '' }, profilePicOverride: '', profilePicOverrideThumbnail: '', pronouns: '', queuedInstance: '', state: '', status: '', statusDescription: '', statusFirstTime: false, statusHistory: [], steamDetails: {}, steamId: '', tags: [], twoFactorAuthEnabled: false, twoFactorAuthEnabledDate: null, unsubscribe: false, updated_at: '', userIcon: '', userLanguage: '', userLanguageCode: '', username: '', viveId: '', // VRCX $online_for: Date.now(), $offline_for: null, $location_at: Date.now(), $travelingToTime: Date.now(), $previousAvatarSwapTime: null, $homeLocation: {}, $isVRCPlus: false, $isModerator: false, $isTroll: false, $isProbableTroll: false, $trustLevel: 'Visitor', $trustClass: 'x-tag-untrusted', $userColour: '', $trustSortNum: 1, $languages: [], $locationTag: '', $travelingToLocation: '' }); const userDialog = ref({ visible: false, loading: false, id: '', ref: {}, friend: {}, isFriend: false, note: '', incomingRequest: false, outgoingRequest: false, isBlock: false, isMute: false, isHideAvatar: false, isShowAvatar: false, isInteractOff: false, isMuteChat: false, isFavorite: false, $location: {}, $homeLocationName: '', users: [], instance: { id: '', tag: '', $location: {}, friendCount: 0, users: [], shortName: '', ref: {} }, worlds: [], avatars: [], isWorldsLoading: false, isFavoriteWorldsLoading: false, isAvatarsLoading: false, isGroupsLoading: false, worldSorting: { name: 'dialog.user.worlds.sorting.updated', value: 'updated' }, worldOrder: { name: 'dialog.user.worlds.order.descending', value: 'descending' }, groupSorting: { name: 'dialog.user.groups.sorting.alphabetical', value: 'alphabetical' }, mutualFriendSorting: { name: 'dialog.user.mutual_friends.sorting.alphabetical', value: 'alphabetical' }, avatarSorting: 'update', avatarReleaseStatus: 'all', treeData: {}, memo: '', $avatarInfo: { ownerId: '', avatarName: '', fileCreatedAt: '' }, representedGroup: { bannerId: '', bannerUrl: '', description: '', discriminator: '', groupId: '', iconUrl: '', id: '', isRepresenting: false, memberCount: 0, memberVisibility: '', name: '', ownerId: '', privacy: '', shortCode: '', $thumbnailUrl: '', $memberId: '' }, isRepresentedGroupLoading: false, joinCount: 0, timeSpent: 0, lastSeen: '', avatarModeration: 0, previousDisplayNames: [], dateFriended: '', unFriended: false, dateFriendedInfo: [], mutualFriendCount: 0, mutualGroupCount: 0, mutualFriends: [], isMutualFriendsLoading: false }); const currentTravelers = reactive(new Map()); const subsetOfLanguages = ref([]); const languageDialog = ref({ visible: false, loading: false, languageChoice: false, languages: [] }); const sendBoopDialog = ref({ visible: false, userId: '' }); const showUserDialogHistory = reactive(new Set()); const customUserTags = reactive(new Map()); const state = reactive({ instancePlayerCount: new Map(), lastNoteCheck: null, lastDbNoteDate: null, notes: new Map() }); const cachedUsers = shallowReactive(new Map()); const isLocalUserVrcPlusSupporter = computed( () => currentUser.value.$isVRCPlus || AppDebug.debugVrcPlus ); watch( () => watchState.isLoggedIn, (isLoggedIn) => { if (!isLoggedIn) { currentTravelers.clear(); showUserDialogHistory.clear(); state.instancePlayerCount.clear(); customUserTags.clear(); state.notes.clear(); subsetOfLanguages.value = []; uiStore.clearDialogCrumbs(); } }, { flush: 'sync' } ); watch( () => watchState.isFriendsLoaded, (isFriendsLoaded) => { if (isFriendsLoaded) { getAllUserMemos(); initUserNotes(); } }, { flush: 'sync' } ); function handleConfig(args) { const authStore = useAuthStore(); const ref = { ...args.json }; args.ref = ref; authStore.cachedConfig = ref; if (typeof args.ref?.whiteListedAssetUrls !== 'object') { console.error('Invalid config whiteListedAssetUrls'); } AppApi.PopulateImageHosts( JSON.stringify(args.ref.whiteListedAssetUrls) ); const languages = args.ref?.constants?.LANGUAGE?.SPOKEN_LANGUAGE_OPTIONS; if (!languages) { return; } subsetOfLanguages.value = languages; const data = []; for (const key in languages) { const value = languages[key]; data.push({ key, value }); } languageDialog.value.languages = data; } /** * * @param {object} ref */ function applyUserLanguage(ref) { if (!ref || !ref.tags || !subsetOfLanguages.value) { return; } ref.$languages = []; const languagePrefix = 'language_'; const prefixLength = languagePrefix.length; for (const tag of ref.tags) { if (tag.startsWith(languagePrefix)) { const key = tag.substring(prefixLength); const value = subsetOfLanguages.value[key]; if (value !== undefined) { ref.$languages.push({ key, value }); } } } } /** * * @param {object} ref */ function applyPresenceLocation(ref) { const presence = ref.presence; if (isRealInstance(presence.world)) { ref.$locationTag = `${presence.world}:${presence.instance}`; } else { ref.$locationTag = presence.world; } if (isRealInstance(presence.travelingToWorld)) { ref.$travelingToLocation = `${presence.travelingToWorld}:${presence.travelingToInstance}`; } else { ref.$travelingToLocation = presence.travelingToWorld; } locationStore.updateCurrentUserLocation(); } const robotUrl = `${AppDebug.endpointDomain}/file/file_0e8c4e32-7444-44ea-ade4-313c010d4bae/1/file`; /** * * @param {Map} userCache * @param {Map} friendMap */ function cleanupUserCache(userCache, friendMap) { const bufferSize = 300; const currentFriendCount = friendMap.size; const currentTotalSize = userCache.size; const effectiveMaxSize = currentFriendCount + bufferSize; if (currentTotalSize <= effectiveMaxSize) { return; } const targetDeleteCount = currentTotalSize - effectiveMaxSize; let deletedCount = 0; const keysToDelete = []; for (const userId of userCache.keys()) { if (friendMap.has(userId)) { continue; } if (deletedCount >= targetDeleteCount) { break; } keysToDelete.push(userId); deletedCount++; } for (const id of keysToDelete) { userCache.delete(id); } console.log( `User cache cleanup: Deleted ${deletedCount}. Current cache size: ${userCache.size}` ); } /** * * @param {import('../types/api/user').GetUserResponse} json * @returns {import('../types/api/user').VrcxUser} */ function applyUser(json) { let hasPropChanged = false; const changedProps = {}; let ref = cachedUsers.get(json.id); if (json.statusDescription) { json.statusDescription = replaceBioSymbols(json.statusDescription); json.statusDescription = removeEmojis(json.statusDescription); } if (json.bio) { json.bio = replaceBioSymbols(json.bio); } if (json.note) { json.note = replaceBioSymbols(json.note); } if (json.currentAvatarImageUrl === robotUrl) { delete json.currentAvatarImageUrl; delete json.currentAvatarThumbnailImageUrl; } if (typeof ref === 'undefined') { ref = reactive({ ageVerificationStatus: '', ageVerified: false, allowAvatarCopying: false, badges: [], bio: '', bioLinks: [], currentAvatarImageUrl: '', currentAvatarTags: [], currentAvatarThumbnailImageUrl: '', date_joined: '', developerType: '', displayName: '', friendKey: '', friendRequestStatus: '', id: '', instanceId: '', isFriend: false, last_activity: '', last_login: '', last_mobile: null, last_platform: '', location: '', platform: '', note: null, profilePicOverride: '', profilePicOverrideThumbnail: '', pronouns: '', state: '', status: '', statusDescription: '', tags: [], travelingToInstance: '', travelingToLocation: '', travelingToWorld: '', userIcon: '', worldId: '', // only in bulk request fallbackAvatar: '', // VRCX $location: {}, $location_at: Date.now(), $online_for: Date.now(), $travelingToTime: Date.now(), $offline_for: null, $active_for: Date.now(), $isVRCPlus: false, $isModerator: false, $isTroll: false, $isProbableTroll: false, $trustLevel: 'Visitor', $trustClass: 'x-tag-untrusted', $userColour: '', $trustSortNum: 1, $languages: [], $joinCount: 0, $timeSpent: 0, $lastSeen: '', $mutualCount: 0, $nickName: '', $previousLocation: '', $customTag: '', $customTagColour: '', $friendNumber: 0, $platform: '', $moderations: {}, // ...json }); if (locationStore.lastLocation.playerList.has(json.id)) { // update $location_at from instance join time const player = locationStore.lastLocation.playerList.get( json.id ); ref.$location_at = player.joinTime; ref.$online_for = player.joinTime; } if (ref.isFriend || ref.id === currentUser.value.id) { // update instancePlayerCount let newCount = state.instancePlayerCount.get(ref.location); if (typeof newCount === 'undefined') { newCount = 0; } newCount++; state.instancePlayerCount.set(ref.location, newCount); } const tag = customUserTags.get(json.id); if (tag) { ref.$customTag = tag.tag; ref.$customTagColour = tag.colour; } else if (ref.$customTag) { ref.$customTag = ''; ref.$customTagColour = ''; } cleanupUserCache(cachedUsers, friendStore.friends); cachedUsers.set(ref.id, ref); friendStore.updateFriend(ref.id); } else { if (json.state !== 'online') { // offline event before GPS to offline location friendStore.updateFriend(ref.id, json.state); } for (const prop in ref) { if (typeof json[prop] === 'undefined') { continue; } // Only compare primitive values if (ref[prop] === null || typeof ref[prop] !== 'object') { changedProps[prop] = true; } } for (const prop in json) { if (typeof ref[prop] === 'undefined') { continue; } if (Array.isArray(json[prop]) && Array.isArray(ref[prop])) { if (!arraysMatch(json[prop], ref[prop])) { changedProps[prop] = true; } } else if ( json[prop] === null || typeof json[prop] !== 'object' ) { changedProps[prop] = true; } } for (const prop in changedProps) { const asIs = ref[prop]; const toBe = json[prop]; if (asIs === toBe) { delete changedProps[prop]; } else { hasPropChanged = true; changedProps[prop] = [toBe, asIs]; } } for (const prop in json) { if (typeof json[prop] !== 'undefined') { ref[prop] = json[prop]; } } } ref.$moderations = moderationStore.getUserModerations(ref.id); ref.$isVRCPlus = ref.tags.includes('system_supporter'); appearanceSettingsStore.applyUserTrustLevel(ref); applyUserLanguage(ref); if ( ref.platform && ref.platform !== 'offline' && ref.platform !== 'web' ) { ref.$platform = ref.platform; } else { ref.$platform = ref.last_platform; } // traveling if (ref.location === 'traveling') { ref.$location = parseLocation(ref.travelingToLocation); if (!currentTravelers.has(ref.id) && ref.travelingToLocation) { const travelRef = reactive({ created_at: new Date().toJSON(), ...ref }); currentTravelers.set(ref.id, travelRef); onPlayerTraveling(travelRef); } } else { ref.$location = parseLocation(ref.location); currentTravelers.delete(ref.id); } if ( !instanceStore.cachedInstances.has(ref.$location.tag) && isRealInstance(ref.location) ) { instanceRequest.getInstance({ worldId: ref.$location.worldId, instanceId: ref.$location.instanceId }); } if ( ref.$isVRCPlus && ref.badges && ref.badges.every( (x) => x.badgeId !== 'bdg_754f9935-0f97-49d8-b857-95afb9b673fa' ) ) { // I doubt this will last long ref.badges.unshift({ badgeId: 'bdg_754f9935-0f97-49d8-b857-95afb9b673fa', badgeName: 'Supporter', badgeDescription: 'Supports VRChat through VRC+', badgeImageUrl: 'https://assets.vrchat.com/badges/fa/bdgai_583f6b13-91ab-4e1b-974e-ab91600b06cb.png', hidden: true, showcased: false }); } const friendCtx = friendStore.friends.get(ref.id); if (friendCtx) { friendCtx.ref = ref; friendCtx.name = ref.displayName; } if (ref.id === currentUser.value.id) { if (ref.status) { currentUser.value.status = ref.status; } locationStore.updateCurrentUserLocation(); } // add user ref to playerList, friendList, photonLobby, photonLobbyCurrent const playerListRef = locationStore.lastLocation.playerList.get(ref.id); if (playerListRef) { // add/remove friends from lastLocation.friendList if ( !locationStore.lastLocation.friendList.has(ref.id) && friendStore.friends.has(ref.id) ) { const userMap = { displayName: ref.displayName, userId: ref.id, joinTime: playerListRef.joinTime }; locationStore.lastLocation.friendList.set(ref.id, userMap); } if ( locationStore.lastLocation.friendList.has(ref.id) && !friendStore.friends.has(ref.id) ) { locationStore.lastLocation.friendList.delete(ref.id); } photonStore.photonLobby.forEach((ref1, id) => { if ( typeof ref1 !== 'undefined' && ref1.displayName === ref.displayName && ref1 !== ref ) { photonStore.photonLobby.set(id, ref); if (photonStore.photonLobbyCurrent.has(id)) { photonStore.photonLobbyCurrent.set(id, ref); } } }); instanceStore.getCurrentInstanceUserList(); } if (ref.state === 'online') { friendStore.updateFriend(ref.id, ref.state); // online/offline } favoriteStore.applyFavorite('friend', ref.id); friendStore.userOnFriend(ref); const D = userDialog.value; if (D.visible && D.id === ref.id) { D.ref = ref; D.note = String(ref.note || ''); D.incomingRequest = false; D.outgoingRequest = false; if (D.ref.friendRequestStatus === 'incoming') { D.incomingRequest = true; } else if (D.ref.friendRequestStatus === 'outgoing') { D.outgoingRequest = true; } // refresh user dialog JSON tab refreshUserDialogTreeData(); } if (hasPropChanged) { if ( changedProps.location && changedProps.location[0] !== 'traveling' ) { const ts = Date.now(); changedProps.location.push(ts - ref.$location_at); ref.$location_at = ts; } handleUserUpdate(ref, changedProps); if (AppDebug.debugUserDiff) { delete changedProps.last_login; delete changedProps.last_activity; if (Object.keys(changedProps).length !== 0) { console.log('>', ref.displayName, changedProps); } } } return ref; } /** * * @param {string} userId */ function showUserDialog(userId, options = {}) { if ( !userId || typeof userId !== 'string' || userId === 'usr_00000000-0000-0000-0000-000000000000' ) { return; } if ( !userDialog.value.visible && !worldStore.worldDialog.visible && !avatarStore.avatarDialog.visible && !groupStore.groupDialog.visible ) { uiStore.clearDialogCrumbs(); } if (!options.skipBreadcrumb) { uiStore.pushDialogCrumb('user', userId); } worldStore.worldDialog.visible = false; avatarStore.avatarDialog.visible = false; groupStore.groupDialog.visible = false; const D = userDialog.value; D.id = userId; D.treeData = {}; D.memo = ''; D.note = ''; getUserMemo(userId).then((memo) => { if (memo.userId === userId) { D.memo = memo.memo; const ref = friendStore.friends.get(userId); if (ref) { ref.memo = String(memo.memo || ''); if (memo.memo) { ref.$nickName = memo.memo.split('\n')[0]; } else { ref.$nickName = ''; } } } }); D.loading = true; D.avatars = []; D.worlds = []; D.instance = { id: '', tag: '', $location: {}, friendCount: 0, users: [], shortName: '', ref: {} }; D.isRepresentedGroupLoading = true; D.representedGroup = { bannerId: '', bannerUrl: '', description: '', discriminator: '', groupId: '', id: '', iconUrl: '', isRepresenting: false, memberCount: 0, memberVisibility: '', name: '', ownerId: '', privacy: '', shortCode: '', $thumbnailUrl: '', $memberId: '' }; D.lastSeen = ''; D.joinCount = 0; D.timeSpent = 0; D.avatarModeration = 0; D.isHideAvatar = false; D.isShowAvatar = false; D.previousDisplayNames = []; D.dateFriended = ''; D.unFriended = false; D.dateFriendedInfo = []; D.mutualFriendCount = 0; D.mutualGroupCount = 0; if (userId === currentUser.value.id) { getWorldName(currentUser.value.homeLocation).then((worldName) => { D.$homeLocationName = worldName; }); } AppApi.SendIpc('ShowUserDialog', userId); userRequest .getCachedUser({ userId }) .catch((err) => { D.loading = false; D.visible = false; toast.error(t('message.user.load_failed')); throw err; }) .then((args) => { if (args.ref.id === D.id) { requestAnimationFrame(() => { D.ref = args.ref; uiStore.setDialogCrumbLabel( 'user', D.id, D.ref?.displayName || D.id ); D.friend = friendStore.friends.get(D.id); D.isFriend = Boolean(D.friend); D.note = String(D.ref.note || ''); D.incomingRequest = false; D.outgoingRequest = false; D.isBlock = false; D.isMute = false; D.isInteractOff = false; D.isMuteChat = false; for (const ref of moderationStore.cachedPlayerModerations.values()) { if ( ref.targetUserId === D.id && ref.sourceUserId === currentUser.value.id ) { if (ref.type === 'block') { D.isBlock = true; } else if (ref.type === 'mute') { D.isMute = true; } else if (ref.type === 'interactOff') { D.isInteractOff = true; } else if (ref.type === 'muteChat') { D.isMuteChat = true; } } } D.isFavorite = favoriteStore.getCachedFavoritesByObjectId(D.id); if (D.ref.friendRequestStatus === 'incoming') { D.incomingRequest = true; } else if (D.ref.friendRequestStatus === 'outgoing') { D.outgoingRequest = true; } userRequest.getUser(args.params); let inCurrentWorld = false; if ( locationStore.lastLocation.playerList.has(D.ref.id) ) { inCurrentWorld = true; } if (userId !== currentUser.value.id) { database .getUserStats(D.ref, inCurrentWorld) .then((ref1) => { if (ref1.userId === D.id) { D.lastSeen = ref1.lastSeen; D.joinCount = ref1.joinCount; D.timeSpent = ref1.timeSpent; } const displayNameMap = ref1.previousDisplayNames; friendStore.friendLogTable.data.forEach( (ref2) => { if (ref2.userId === D.id) { if ( ref2.type === 'DisplayName' ) { displayNameMap.set( ref2.previousDisplayName, ref2.created_at ); } if (!D.dateFriended) { if ( ref2.type === 'Unfriend' ) { D.unFriended = true; if ( !appearanceSettingsStore.hideUnfriends ) { D.dateFriended = ref2.created_at; } } if ( ref2.type === 'Friend' ) { D.unFriended = false; D.dateFriended = ref2.created_at; } } if ( ref2.type === 'Friend' || (ref2.type === 'Unfriend' && !appearanceSettingsStore.hideUnfriends) ) { D.dateFriendedInfo.push( ref2 ); } } } ); displayNameMap.forEach( (updated_at, displayName) => { D.previousDisplayNames.push({ displayName, updated_at }); } ); }); AppApi.GetVRChatUserModeration( currentUser.value.id, userId ).then((result) => { D.avatarModeration = result; if (result === 4) { D.isHideAvatar = true; } else if (result === 5) { D.isShowAvatar = true; } }); if (!currentUser.value.hasSharedConnectionsOptOut) { try { userRequest .getMutualCounts({ userId }) .then((args) => { if (args.params.userId === D.id) { D.mutualFriendCount = args.json.friends; D.mutualGroupCount = args.json.groups; } }); } catch (error) { console.error(error); } } } else { D.previousDisplayNames = currentUser.value.pastDisplayNames; database .getUserStats(D.ref, inCurrentWorld) .then((ref1) => { if (ref1.userId === D.id) { D.lastSeen = ref1.lastSeen; D.joinCount = ref1.joinCount; D.timeSpent = ref1.timeSpent; } }); } groupRequest .getRepresentedGroup({ userId }) .then((args1) => { groupStore.handleGroupRepresented(args1); }); D.loading = false; D.visible = true; applyUserDialogLocation(true); }); } }); showUserDialogHistory.delete(userId); showUserDialogHistory.add(userId); searchStore.quickSearchItems = searchStore.quickSearchUserHistory(); } /** * * @param {object} ref */ function onPlayerTraveling(ref) { if ( !gameStore.isGameRunning || !locationStore.lastLocation.location || locationStore.lastLocation.location !== ref.travelingToLocation || ref.id === currentUser.value.id || locationStore.lastLocation.playerList.has(ref.id) ) { return; } const onPlayerJoining = { created_at: new Date(ref.created_at).toJSON(), userId: ref.id, displayName: ref.displayName, type: 'OnPlayerJoining' }; notificationStore.queueFeedNoty(onPlayerJoining); } /** * * @param {boolean} updateInstanceOccupants */ function applyUserDialogLocation(updateInstanceOccupants = false) { let addUser; let friend; let ref; const D = userDialog.value; if (!D.visible) { return; } const L = parseLocation(D.ref.$location?.tag); if (updateInstanceOccupants && L.isRealInstance) { instanceRequest.getInstance({ worldId: L.worldId, instanceId: L.instanceId }); } D.$location = L; L.user = {}; if (L.userId) { ref = cachedUsers.get(L.userId); if (typeof ref === 'undefined') { userRequest .getUser({ userId: L.userId }) .then((args) => { if (args.ref.id === L.userId) { Object.assign(L.user, args.ref); D.$location = L; applyUserDialogLocation(); } }); } else { L.user = ref; } } const users = []; let friendCount = 0; const playersInInstance = locationStore.lastLocation.playerList; const cachedCurrentUser = cachedUsers.get(currentUser.value.id); const currentLocation = cachedCurrentUser?.$location?.tag; if (!L.isOffline && currentLocation === L.tag) { ref = cachedUsers.get(currentUser.value.id); if (typeof ref !== 'undefined') { users.push(ref); // add self } } // dont use gamelog when using api location if ( locationStore.lastLocation.location === L.tag && playersInInstance.size > 0 ) { const friendsInInstance = locationStore.lastLocation.friendList; for (friend of friendsInInstance.values()) { // if friend isn't in instance add them addUser = !users.some(function (user) { return friend.userId === user.id; }); if (addUser) { ref = cachedUsers.get(friend.userId); if (typeof ref !== 'undefined') { users.push(ref); } } } friendCount = users.length - 1; } if (!L.isOffline) { for (friend of friendStore.friends.values()) { if (typeof friend.ref === 'undefined') { continue; } if ( friend.ref.location === locationStore.lastLocation.location ) { // don't add friends to currentUser gameLog instance (except when traveling) continue; } if (friend.ref.$location.tag === L.tag) { if ( friend.state !== 'online' && friend.ref.location === 'private' ) { // don't add offline friends to private instances continue; } // if friend isn't in instance add them addUser = !users.some(function (user) { return friend.name === user.displayName; }); if (addUser) { users.push(friend.ref); } } } friendCount = users.length; } if (appearanceSettingsStore.instanceUsersSortAlphabetical) { users.sort(compareByDisplayName); } else { users.sort(compareByLocationAt); } D.users = users; if ( (L.worldId && currentLocation === L.tag && playersInInstance.size > 0) || !L.isRealInstance ) { D.instance = { id: L.instanceId, tag: L.tag, $location: L, friendCount: 0, users: [], shortName: '', ref: {} }; } const instanceRef = instanceStore.cachedInstances.get(L.tag) || {}; Object.assign(D.instance.ref, instanceRef); D.instance.friendCount = friendCount; } function sortUserDialogAvatars(array) { const D = userDialog.value; if (D.avatarSorting === 'update') { array.sort(compareByUpdatedAt); } else if (D.avatarSorting === 'createdAt') { array.sort(compareByCreatedAt); } else { array.sort(compareByName); } D.avatars = array; } function refreshUserDialogAvatars(fileId) { const D = userDialog.value; if (D.isAvatarsLoading) { return; } D.isAvatarsLoading = true; if (fileId) { D.loading = true; } D.avatarSorting = 'update'; D.avatarReleaseStatus = 'all'; const params = { n: 50, offset: 0, sort: 'updated', order: 'descending', releaseStatus: 'all', user: 'me' }; for (const ref of avatarStore.cachedAvatars.values()) { if (ref.authorId === D.id) { avatarStore.cachedAvatars.delete(ref.id); } } const map = new Map(); processBulk({ fn: avatarRequest.getAvatars, N: -1, params, handle: (args) => { for (const json of args.json) { const ref = avatarStore.applyAvatar(json); map.set(ref.id, ref); } }, done: () => { const array = Array.from(map.values()); sortUserDialogAvatars(array); D.isAvatarsLoading = false; if (fileId) { D.loading = false; for (const ref of array) { if (extractFileId(ref.imageUrl) === fileId) { avatarStore.showAvatarDialog(ref.id); return; } } toast.error('Own avatar not found'); } } }); } function refreshUserDialogTreeData() { const D = userDialog.value; if (D.id === currentUser.value.id) { D.treeData = formatJsonVars({ ...currentUser.value, ...D.ref }); return; } D.treeData = formatJsonVars(D.ref); } async function lookupUser(ref) { let ctx; if (ref.userId) { showUserDialog(ref.userId); return; } if (!ref.displayName || ref.displayName.substring(0, 3) === 'ID:') { return; } for (ctx of cachedUsers.values()) { if (ctx.displayName === ref.displayName) { showUserDialog(ctx.id); return; } } searchStore.searchText = ref.displayName; await searchStore.searchUserByDisplayName(ref.displayName); for (ctx of searchStore.searchUserResults) { if (ctx.displayName === ref.displayName) { searchStore.searchText = ''; searchStore.clearSearch(); showUserDialog(ctx.id); return; } } } /** * @param {object} ref * @param {object} props * @returns {Promise} */ async function handleUserUpdate(ref, props) { let feed; let newLocation; let previousLocation; const friend = friendStore.friends.get(ref.id); if (typeof friend === 'undefined') { return; } if (props.location) { // update instancePlayerCount previousLocation = props.location[1]; newLocation = props.location[0]; let oldCount = state.instancePlayerCount.get(previousLocation); if (typeof oldCount !== 'undefined') { oldCount--; if (oldCount <= 0) { state.instancePlayerCount.delete(previousLocation); } else { state.instancePlayerCount.set(previousLocation, oldCount); } } let newCount = state.instancePlayerCount.get(newLocation); if (typeof newCount === 'undefined') { newCount = 0; } newCount++; state.instancePlayerCount.set(newLocation, newCount); const previousLocationL = parseLocation(previousLocation); const newLocationL = parseLocation(newLocation); if ( previousLocationL.tag === userDialog.value.$location.tag || newLocationL.tag === userDialog.value.$location.tag ) { // update user dialog instance occupants applyUserDialogLocation(true); } if ( previousLocationL.worldId === worldStore.worldDialog.id || newLocationL.worldId === worldStore.worldDialog.id ) { instanceStore.applyWorldDialogInstances(); } if ( previousLocationL.groupId === groupStore.groupDialog.id || newLocationL.groupId === groupStore.groupDialog.id ) { instanceStore.applyGroupDialogInstances(); } } if ( !props.state && props.location && props.location[0] !== 'offline' && props.location[0] !== '' && props.location[1] !== 'offline' && props.location[1] !== '' && props.location[0] !== 'traveling' ) { // skip GPS if user is offline or traveling previousLocation = props.location[1]; newLocation = props.location[0]; let time = props.location[2]; if (previousLocation === 'traveling' && ref.$previousLocation) { previousLocation = ref.$previousLocation; const travelTime = Date.now() - ref.$travelingToTime; time -= travelTime; if (time < 0) { time = 0; } } if (AppDebug.debugFriendState && previousLocation) { console.log( `${ref.displayName} GPS ${previousLocation} -> ${newLocation}` ); } if (previousLocation === 'offline') { previousLocation = ''; } if (!previousLocation) { // no previous location if (AppDebug.debugFriendState) { console.log( ref.displayName, 'Ignoring GPS, no previous location', newLocation ); } } else if (ref.$previousLocation === newLocation) { // location traveled to is the same ref.$location_at = Date.now() - time; } else { const worldName = await getWorldName(newLocation); const groupName = await getGroupName(newLocation); feed = { created_at: new Date().toJSON(), type: 'GPS', userId: ref.id, displayName: ref.displayName, location: newLocation, worldName, groupName, previousLocation, time }; feedStore.addFeed(feed); database.addGPSToDatabase(feed); // clear previousLocation after GPS ref.$previousLocation = ''; ref.$travelingToTime = Date.now(); } } if ( props.location && props.location[0] === 'traveling' && props.location[1] !== 'traveling' ) { // store previous location when user is traveling ref.$previousLocation = props.location[1]; ref.$travelingToTime = Date.now(); } let imageMatches = false; if ( props.currentAvatarThumbnailImageUrl && props.currentAvatarThumbnailImageUrl[0] && props.currentAvatarThumbnailImageUrl[1] && props.currentAvatarThumbnailImageUrl[0] === props.currentAvatarThumbnailImageUrl[1] ) { imageMatches = true; } if ( (((props.currentAvatarImageUrl || props.currentAvatarThumbnailImageUrl) && !ref.profilePicOverride) || props.currentAvatarTags) && !imageMatches ) { let currentAvatarImageUrl = ''; let previousCurrentAvatarImageUrl = ''; let currentAvatarThumbnailImageUrl = ''; let previousCurrentAvatarThumbnailImageUrl = ''; let currentAvatarTags = ''; let previousCurrentAvatarTags = ''; if (props.currentAvatarImageUrl) { currentAvatarImageUrl = props.currentAvatarImageUrl[0]; previousCurrentAvatarImageUrl = props.currentAvatarImageUrl[1]; } else { currentAvatarImageUrl = ref.currentAvatarImageUrl; previousCurrentAvatarImageUrl = ref.currentAvatarImageUrl; } if (props.currentAvatarThumbnailImageUrl) { currentAvatarThumbnailImageUrl = props.currentAvatarThumbnailImageUrl[0]; previousCurrentAvatarThumbnailImageUrl = props.currentAvatarThumbnailImageUrl[1]; } else { currentAvatarThumbnailImageUrl = ref.currentAvatarThumbnailImageUrl; previousCurrentAvatarThumbnailImageUrl = ref.currentAvatarThumbnailImageUrl; } if (props.currentAvatarTags) { currentAvatarTags = props.currentAvatarTags[0]; previousCurrentAvatarTags = props.currentAvatarTags[1]; if ( ref.profilePicOverride && !props.currentAvatarThumbnailImageUrl ) { // forget last seen avatar ref.currentAvatarImageUrl = ''; ref.currentAvatarThumbnailImageUrl = ''; } } else { currentAvatarTags = ref.currentAvatarTags; previousCurrentAvatarTags = ref.currentAvatarTags; } if ( generalSettingsStore.logEmptyAvatars || ref.currentAvatarImageUrl ) { let avatarInfo = { ownerId: '', avatarName: '' }; try { avatarInfo = await avatarStore.getAvatarName( currentAvatarImageUrl ); } catch (err) { console.log(err); } let previousAvatarInfo = { ownerId: '', avatarName: '' }; try { previousAvatarInfo = await avatarStore.getAvatarName( previousCurrentAvatarImageUrl ); } catch (err) { console.log(err); } feed = { created_at: new Date().toJSON(), type: 'Avatar', userId: ref.id, displayName: ref.displayName, ownerId: avatarInfo.ownerId, previousOwnerId: previousAvatarInfo.ownerId, avatarName: avatarInfo.avatarName, previousAvatarName: previousAvatarInfo.avatarName, currentAvatarImageUrl, currentAvatarThumbnailImageUrl, previousCurrentAvatarImageUrl, previousCurrentAvatarThumbnailImageUrl, currentAvatarTags, previousCurrentAvatarTags }; feedStore.addFeed(feed); database.addAvatarToDatabase(feed); } } // if status is offline, ignore status and statusDescription if ( (props.status && props.status[0] !== 'offline' && props.status[1] !== 'offline') || (!props.status && props.statusDescription) ) { let status = ''; let previousStatus = ''; let statusDescription = ''; let previousStatusDescription = ''; if (props.status) { if (props.status[0]) { status = props.status[0]; } if (props.status[1]) { previousStatus = props.status[1]; } } else if (ref.status) { status = ref.status; previousStatus = ref.status; } if (props.statusDescription) { if (props.statusDescription[0]) { statusDescription = props.statusDescription[0]; } if (props.statusDescription[1]) { previousStatusDescription = props.statusDescription[1]; } } else if (ref.statusDescription) { statusDescription = ref.statusDescription; previousStatusDescription = ref.statusDescription; } feed = { created_at: new Date().toJSON(), type: 'Status', userId: ref.id, displayName: ref.displayName, status, statusDescription, previousStatus, previousStatusDescription }; feedStore.addFeed(feed); database.addStatusToDatabase(feed); } if (props.bio && props.bio[0] && props.bio[1]) { let bio = ''; let previousBio = ''; if (props.bio[0]) { bio = props.bio[0]; } if (props.bio[1]) { previousBio = props.bio[1]; } feed = { created_at: new Date().toJSON(), type: 'Bio', userId: ref.id, displayName: ref.displayName, bio, previousBio }; feedStore.addFeed(feed); database.addBioToDatabase(feed); } if ( props.note && props.note[0] !== null && props.note[0] !== props.note[1] ) { checkNote(ref.id, props.note[0]); } } function updateAutoStateChange() { if ( !generalSettingsStore.autoStateChangeEnabled || !gameStore.isGameRunning || !locationStore.lastLocation.playerList.size || locationStore.lastLocation.location === '' || locationStore.lastLocation.location === 'traveling' ) { return; } const $location = parseLocation(locationStore.lastLocation.location); let instanceType = $location.accessType; if (instanceType === 'group') { if ($location.groupAccessType === 'members') { instanceType = 'groupOnly'; } else if ($location.groupAccessType === 'plus') { instanceType = 'groupPlus'; } else { instanceType = 'groupPublic'; } } if ( generalSettingsStore.autoStateChangeInstanceTypes.length > 0 && !generalSettingsStore.autoStateChangeInstanceTypes.includes( instanceType ) ) { return; } let withCompany = locationStore.lastLocation.playerList.size > 1; if (generalSettingsStore.autoStateChangeNoFriends) { withCompany = locationStore.lastLocation.friendList.size >= 1; } const currentStatus = currentUser.value.status; const newStatus = withCompany ? generalSettingsStore.autoStateChangeCompanyStatus : generalSettingsStore.autoStateChangeAloneStatus; if (currentStatus === newStatus) { return; } userRequest .saveCurrentUser({ status: newStatus }) .then(() => { const text = `Status automatically changed to ${newStatus}`; if (AppDebug.errorNoty) { AppDebug.errorNoty.close(); } AppDebug.errorNoty = new Noty({ type: 'info', text }); AppDebug.errorNoty.show(); console.log(text); }); } function addCustomTag(data) { if (data.Tag) { customUserTags.set(data.UserId, { tag: data.Tag, colour: data.TagColour }); } else { customUserTags.delete(data.UserId); } const feedUpdate = { userId: data.UserId, colour: data.TagColour }; AppApi.ExecuteVrOverlayFunction( 'updateHudFeedTag', JSON.stringify(feedUpdate) ); const ref = cachedUsers.get(data.UserId); if (typeof ref !== 'undefined') { ref.$customTag = data.Tag; ref.$customTagColour = data.TagColour; } sharedFeedStore.addTag(data.UserId, data.TagColour); } async function initUserNotes() { state.lastNoteCheck = new Date(); state.lastDbNoteDate = null; state.notes.clear(); try { // todo: get users from store const users = cachedUsers; const dbNotes = await database.getAllUserNotes(); for (const note of dbNotes) { state.notes.set(note.userId, note.note); const user = users.get(note.userId); if (user) { user.note = note.note; } if ( !state.lastDbNoteDate || state.lastDbNoteDate < note.createdAt ) { state.lastDbNoteDate = note.createdAt; } } await getLatestUserNotes(); } catch (error) { console.error('Error initializing user notes:', error); } } async function getLatestUserNotes() { state.lastNoteCheck = new Date(); const params = { offset: 0, n: 10 // start light }; const newNotes = new Map(); let done = false; try { for (let i = 0; i < 100; i++) { params.offset = i * params.n; const args = await userRequest.getUserNotes(params); for (const note of args.json) { if ( state.lastDbNoteDate && state.lastDbNoteDate > note.createdAt ) { done = true; } if ( !state.lastDbNoteDate || state.lastDbNoteDate < note.createdAt ) { state.lastDbNoteDate = note.createdAt; } note.note = replaceBioSymbols(note.note); newNotes.set(note.targetUserId, note); } if (done || args.json.length === 0) { break; } params.n = 100; // crank it after first run await new Promise((resolve) => { workerTimers.setTimeout(resolve, 1000); }); } } catch (error) { console.error('Error fetching user notes:', error); } // todo: get users from store const users = cachedUsers; for (const note of newNotes.values()) { const newNote = { userId: note.targetUserId, displayName: note.targetUser?.displayName || note.targetUserId, note: note.note, createdAt: note.createdAt }; await database.addUserNote(newNote); state.notes.set(note.targetUserId, note.note); const user = users.get(note.targetUserId); if (user) { user.note = note.note; } } } async function checkNote(userId, newNote) { // last check was more than than 5 minutes ago if ( !state.lastNoteCheck || state.lastNoteCheck.getTime() + 5 * 60 * 1000 > Date.now() ) { return; } const existingNote = state.notes.get(userId); if (typeof existingNote !== 'undefined' && !newNote) { console.log('deleting note', userId); state.notes.delete(userId); await database.deleteUserNote(userId); return; } if (typeof existingNote === 'undefined' || existingNote !== newNote) { console.log('detected note change', userId, newNote); await getLatestUserNotes(); } } function getCurrentUser() { return request('auth/user', { method: 'GET' }).then((json) => { const args = { json }; authStore.handleCurrentUserUpdate(json); return args; }); } /** * @param {import('../types/api/user').GetCurrentUserResponse} json * @returns {import('../types/api/user').GetCurrentUserResponse} */ function applyCurrentUser(json) { authStore.attemptingAutoLogin = false; let ref = currentUser.value; if (watchState.isLoggedIn) { if (json.currentAvatar !== ref.currentAvatar) { avatarStore.addAvatarToHistory(json.currentAvatar); if (gameStore.isGameRunning) { avatarStore.addAvatarWearTime(ref.currentAvatar); ref.$previousAvatarSwapTime = Date.now(); } } for (const prop in json) { if (typeof json[prop] !== 'undefined') { ref[prop] = json[prop]; } } } else { ref = { acceptedPrivacyVersion: 0, acceptedTOSVersion: 0, accountDeletionDate: null, accountDeletionLog: null, activeFriends: [], ageVerificationStatus: '', ageVerified: false, allowAvatarCopying: false, badges: [], bio: '', bioLinks: [], currentAvatar: '', currentAvatarImageUrl: '', currentAvatarTags: [], currentAvatarThumbnailImageUrl: '', date_joined: '', developerType: '', displayName: '', emailVerified: false, fallbackAvatar: '', friendGroupNames: [], friendKey: '', friends: [], googleId: '', hasBirthday: false, hasEmail: false, hasLoggedInFromClient: false, hasPendingEmail: false, hasSharedConnectionsOptOut: false, hideContentFilterSettings: false, homeLocation: '', id: '', isAdult: true, isBoopingEnabled: false, isFriend: false, last_activity: '', last_login: '', last_mobile: null, last_platform: '', obfuscatedEmail: '', obfuscatedPendingEmail: '', oculusId: '', offlineFriends: [], onlineFriends: [], pastDisplayNames: [], picoId: '', presence: { avatarThumbnail: '', currentAvatarTags: '', debugflag: '', displayName: '', groups: [], id: '', instance: '', instanceType: '', platform: '', profilePicOverride: '', status: '', travelingToInstance: '', travelingToWorld: '', userIcon: '', world: '', ...json.presence }, profilePicOverride: '', profilePicOverrideThumbnail: '', pronouns: '', queuedInstance: '', state: '', status: '', statusDescription: '', statusFirstTime: false, statusHistory: [], steamDetails: {}, steamId: '', tags: [], twoFactorAuthEnabled: false, twoFactorAuthEnabledDate: null, unsubscribe: false, updated_at: '', userIcon: '', userLanguage: '', userLanguageCode: '', username: '', viveId: '', // VRCX $online_for: null, $offline_for: null, $location_at: Date.now(), $travelingToTime: Date.now(), $previousAvatarSwapTime: null, $homeLocation: {}, $isVRCPlus: false, $isModerator: false, $isTroll: false, $isProbableTroll: false, $trustLevel: 'Visitor', $trustClass: 'x-tag-untrusted', $userColour: '', $trustSortNum: 1, $languages: [], $locationTag: '', $travelingToLocation: '', ...json }; if (gameStore.isGameRunning) { ref.$previousAvatarSwapTime = Date.now(); } cachedUsers.clear(); // clear before running applyUser currentUser.value = ref; authStore.loginComplete(); } ref.$isVRCPlus = ref.tags.includes('system_supporter'); appearanceSettingsStore.applyUserTrustLevel(ref); applyUserLanguage(ref); applyPresenceLocation(ref); groupStore.applyPresenceGroups(ref); instanceStore.applyQueuedInstance(ref.queuedInstance); friendStore.updateUserCurrentStatus(ref); friendStore.updateFriendships(ref); if (ref.homeLocation !== ref.$homeLocation?.tag) { ref.$homeLocation = parseLocation(ref.homeLocation); // apply home location name to user dialog if (userDialog.value.visible && userDialog.value.id === ref.id) { getWorldName(currentUser.value.homeLocation).then( (worldName) => { userDialog.value.$homeLocationName = worldName; } ); } } // when isGameRunning use gameLog instead of API const $location = parseLocation(locationStore.lastLocation.location); const $travelingLocation = parseLocation( locationStore.lastLocationDestination ); let location = locationStore.lastLocation.location; let instanceId = $location.instanceId; let worldId = $location.worldId; let travelingToLocation = locationStore.lastLocationDestination; let travelingToWorld = $travelingLocation.worldId; let travelingToInstance = $travelingLocation.instanceId; if (!gameStore.isGameRunning && json.presence) { if (isRealInstance(json.presence.world)) { location = `${json.presence.world}:${json.presence.instance}`; } else { location = json.presence.world; } if (isRealInstance(json.presence.travelingToWorld)) { travelingToLocation = `${json.presence.travelingToWorld}:${json.presence.travelingToInstance}`; } else { travelingToLocation = json.presence.travelingToWorld; } instanceId = json.presence.instance; worldId = json.presence.world; travelingToInstance = json.presence.travelingToInstance; travelingToWorld = json.presence.travelingToWorld; } const userRef = applyUser({ ageVerificationStatus: json.ageVerificationStatus, ageVerified: json.ageVerified, allowAvatarCopying: json.allowAvatarCopying, badges: json.badges, bio: json.bio, bioLinks: json.bioLinks, currentAvatarImageUrl: json.currentAvatarImageUrl, currentAvatarTags: json.currentAvatarTags, currentAvatarThumbnailImageUrl: json.currentAvatarThumbnailImageUrl, date_joined: json.date_joined, developerType: json.developerType, displayName: json.displayName, friendKey: json.friendKey, // json.friendRequestStatus - missing from currentUser id: json.id, // instanceId - missing from currentUser isFriend: json.isFriend, last_activity: json.last_activity, last_login: json.last_login, last_mobile: json.last_mobile, last_platform: json.last_platform, // location - missing from currentUser // note - missing from currentUser // platform - not always present profilePicOverride: json.profilePicOverride, profilePicOverrideThumbnail: json.profilePicOverrideThumbnail, pronouns: json.pronouns, state: json.state, status: json.status, statusDescription: json.statusDescription, tags: json.tags, // travelingToInstance - missing from currentUser // travelingToLocation - missing from currentUser // travelingToWorld - missing from currentUser userIcon: json.userIcon, // worldId - missing from currentUser // fallbackAvatar - gone from user // Location from gameLog/presence location, instanceId, worldId, travelingToLocation, travelingToInstance, travelingToWorld // $online_for: currentUser.value.$online_for, // $offline_for: currentUser.value.$offline_for, // $location_at: currentUser.value.$location_at, // $travelingToTime: currentUser.value.$travelingToTime }); // set VRCX online/offline timers userRef.$online_for = currentUser.value.$online_for; userRef.$offline_for = currentUser.value.$offline_for; userRef.$location_at = currentUser.value.$location_at; userRef.$travelingToTime = currentUser.value.$travelingToTime; if (json.presence?.platform) { userRef.platform = json.presence.platform; } return ref; } function showSendBoopDialog(userId) { sendBoopDialog.value.userId = userId; sendBoopDialog.value.visible = true; } function toggleSharedConnectionsOptOut() { userRequest.saveCurrentUser({ hasSharedConnectionsOptOut: !currentUser.value.hasSharedConnectionsOptOut }); } return { state, currentUser, currentTravelers, userDialog, subsetOfLanguages, languageDialog, sendBoopDialog, showUserDialogHistory, customUserTags, cachedUsers, isLocalUserVrcPlusSupporter, applyCurrentUser, applyUser, showUserDialog, applyUserDialogLocation, sortUserDialogAvatars, refreshUserDialogAvatars, refreshUserDialogTreeData, lookupUser, updateAutoStateChange, addCustomTag, initUserNotes, getCurrentUser, handleConfig, showSendBoopDialog, checkNote, toggleSharedConnectionsOptOut }; });