Files
VRCX/src/stores/user.js
T
2026-03-16 16:01:38 +09:00

834 lines
24 KiB
JavaScript

import { computed, reactive, ref, shallowReactive, watch } from 'vue';
import { defineStore } from 'pinia';
import {
compareByCreatedAt,
compareByDisplayName,
compareByLocationAt,
compareByName,
compareByUpdatedAt,
isRealInstance,
parseLocation,
replaceBioSymbols
} from '../shared/utils';
import { getAllUserMemos } from '../coordinators/memoCoordinator';
import { instanceRequest, userRequest } from '../api';
import { AppDebug } from '../services/appConfig';
import { database } from '../services/database';
import { runUpdateCurrentUserLocationFlow } from '../coordinators/locationCoordinator';
import { useAppearanceSettingsStore } from './settings/appearance';
import { useFriendStore } from './friend';
import { useInstanceStore } from './instance';
import { useLocationStore } from './location';
import { syncFriendSearchIndex } from '../coordinators/searchIndexCoordinator';
import { useUiStore } from './ui';
import { watchState } from '../services/watchState';
import * as workerTimers from 'worker-timers';
export const useUserStore = defineStore('User', () => {
const appearanceSettingsStore = useAppearanceSettingsStore();
const friendStore = useFriendStore();
const locationStore = useLocationStore();
const instanceStore = useInstanceStore();
const uiStore = useUiStore();
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: '',
discordDetails: {
global_name: '',
id: ''
},
discordId: '',
displayName: '',
emailVerified: false,
fallbackAvatar: '',
friendGroupNames: [],
friendKey: '',
friends: [],
googleId: '',
hasBirthday: false,
hasDiscordFriendsOptOut: 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,
activeTab: 'Info',
lastActiveTab: 'Info',
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,
userFavoriteWorlds: [],
userGroups: {
groups: [],
ownGroups: [],
mutualGroups: [],
remainingGroups: []
},
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',
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 cachedUserIdsByDisplayName = shallowReactive(new Map());
function addCachedUserDisplayNameEntry(displayName, userId) {
if (!displayName || !userId) {
return;
}
let userIds = cachedUserIdsByDisplayName.get(displayName);
if (!userIds) {
userIds = new Set();
cachedUserIdsByDisplayName.set(displayName, userIds);
}
userIds.add(userId);
}
function removeCachedUserDisplayNameEntry(displayName, userId) {
if (!displayName || !userId) {
return;
}
const userIds = cachedUserIdsByDisplayName.get(displayName);
if (!userIds) {
return;
}
userIds.delete(userId);
if (userIds.size === 0) {
cachedUserIdsByDisplayName.delete(displayName);
}
}
function syncCachedUserDisplayName(ref, previousDisplayName = '') {
if (!ref?.id) {
return;
}
if (previousDisplayName && previousDisplayName !== ref.displayName) {
removeCachedUserDisplayNameEntry(previousDisplayName, ref.id);
}
addCachedUserDisplayNameEntry(ref.displayName, ref.id);
}
function setCachedUser(
ref,
previousDisplayName = '',
{ skipIndex = false } = {}
) {
if (!ref?.id) {
return;
}
cachedUsers.set(ref.id, ref);
if (!skipIndex) {
syncCachedUserDisplayName(ref, previousDisplayName);
}
}
function deleteCachedUser(userId) {
const ref = cachedUsers.get(userId);
if (!ref) {
return false;
}
removeCachedUserDisplayNameEntry(ref.displayName, userId);
return cachedUsers.delete(userId);
}
function clearCachedUsers() {
cachedUsers.clear();
cachedUserIdsByDisplayName.clear();
}
function rebuildCachedUserDisplayNameIndex() {
cachedUserIdsByDisplayName.clear();
for (const ref of cachedUsers.values()) {
addCachedUserDisplayNameEntry(ref.displayName, ref.id);
}
}
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' }
);
/**
* @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;
}
runUpdateCurrentUserLocationFlow();
}
/**
* @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;
}
/**
* @param array
*/
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;
}
/**
*/
async function initUserNotes() {
state.lastNoteCheck = new Date();
state.lastDbNoteDate = null;
state.notes.clear();
try {
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;
const friendCtx = friendStore.friends.get(note.userId);
if (friendCtx) {
syncFriendSearchIndex(friendCtx);
}
}
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
};
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;
await new Promise((resolve) => {
workerTimers.setTimeout(resolve, 1000);
});
}
} catch (error) {
console.error('Error fetching user notes:', error);
}
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;
const friendCtx = friendStore.friends.get(note.targetUserId);
if (friendCtx) {
syncFriendSearchIndex(friendCtx);
}
}
}
}
/**
* @param userId
* @param newNote
*/
async function checkNote(userId, newNote) {
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();
}
}
/**
* @param userId
*/
function showSendBoopDialog(userId) {
sendBoopDialog.value.userId = userId;
sendBoopDialog.value.visible = true;
}
/**
* @param {string} value
*/
function setUserDialogMemo(value) {
userDialog.value.memo = value;
}
/**
* @param {boolean} value
*/
function setUserDialogVisible(value) {
userDialog.value.visible = value;
}
/**
* @param {boolean} value
*/
function setUserDialogIsFavorite(value) {
userDialog.value.isFavorite = value;
}
/**
* @param {string} value
*/
function setCurrentUserColour(value) {
currentUser.value.$userColour = value;
}
/**
* @param {string} location
* @param {string} travelingToLocation
* @param {number} timestamp
*/
function setCurrentUserLocationState(
location,
travelingToLocation,
timestamp = Date.now()
) {
currentUser.value.$location_at = timestamp;
currentUser.value.$travelingToTime = timestamp;
currentUser.value.$locationTag = location;
currentUser.value.$travelingToLocation = travelingToLocation;
}
/**
* @param {number} value
*/
function setCurrentUserTravelingToTime(value) {
currentUser.value.$travelingToTime = value;
}
/**
* @param {object} value
*/
function setCurrentUser(value) {
currentUser.value = value;
}
/**
* @param {object} value
*/
function setSubsetOfLanguages(value) {
subsetOfLanguages.value = value;
}
/**
* @param {Array} value
*/
function setLanguageDialogLanguages(value) {
languageDialog.value.languages = value;
}
/**
*/
function markCurrentUserGameStarted() {
currentUser.value.$online_for = Date.now();
currentUser.value.$offline_for = '';
currentUser.value.$previousAvatarSwapTime = Date.now();
}
/**
*/
function markCurrentUserGameStopped() {
currentUser.value.$online_for = 0;
currentUser.value.$offline_for = Date.now();
currentUser.value.$previousAvatarSwapTime = null;
}
/**
*/
function toggleSharedConnectionsOptOut() {
userRequest.saveCurrentUser({
hasSharedConnectionsOptOut:
!currentUser.value.hasSharedConnectionsOptOut
});
}
/**
*/
function toggleDiscordFriendsOptOut() {
userRequest.saveCurrentUser({
hasDiscordFriendsOptOut: !currentUser.value.hasDiscordFriendsOptOut
});
}
return {
state,
currentUser,
currentTravelers,
userDialog,
subsetOfLanguages,
languageDialog,
sendBoopDialog,
showUserDialogHistory,
customUserTags,
cachedUsers,
cachedUserIdsByDisplayName,
isLocalUserVrcPlusSupporter,
applyUserLanguage,
applyPresenceLocation,
applyUserDialogLocation,
setCachedUser,
syncCachedUserDisplayName,
deleteCachedUser,
clearCachedUsers,
rebuildCachedUserDisplayNameIndex,
sortUserDialogAvatars,
initUserNotes,
showSendBoopDialog,
setUserDialogMemo,
setUserDialogVisible,
setUserDialogIsFavorite,
setCurrentUserColour,
setCurrentUserLocationState,
setCurrentUserTravelingToTime,
setCurrentUser,
setSubsetOfLanguages,
setLanguageDialogLanguages,
markCurrentUserGameStarted,
markCurrentUserGameStopped,
checkNote,
toggleSharedConnectionsOptOut,
toggleDiscordFriendsOptOut
};
});