import { computed, reactive, ref, shallowReactive, watch } from 'vue'; import { defineStore } from 'pinia'; import { toast } from 'vue-sonner'; import { useI18n } from 'vue-i18n'; import { compareByName, createDefaultFavoriteCachedRef, createDefaultFavoriteGroupRef, removeFromArray, replaceReactiveObject } from '../shared/utils'; import { avatarRequest, favoriteRequest } from '../api'; import { database } from '../service/database'; import { processBulk } from '../service/request'; import { useAppearanceSettingsStore } from './settings/appearance'; import { useAvatarStore } from './avatar'; import { useFriendStore } from './friend'; import { useGeneralSettingsStore } from './settings/general'; import { useUserStore } from './user'; import { useWorldStore } from './world'; import { watchState } from '../service/watchState'; export const useFavoriteStore = defineStore('Favorite', () => { const appearanceSettingsStore = useAppearanceSettingsStore(); const friendStore = useFriendStore(); const generalSettingsStore = useGeneralSettingsStore(); const avatarStore = useAvatarStore(); const worldStore = useWorldStore(); const userStore = useUserStore(); const { t } = useI18n(); const state = reactive({ favoriteObjects: new Map(), favoriteFriends_: [], favoriteWorlds_: [], favoriteAvatars_: [] }); const cachedFavorites = reactive(new Map()); const cachedFavoritesByObjectId = reactive(new Map()); const cachedFavoriteGroups = ref({}); const isFavoriteGroupLoading = ref(false); const favoriteFriendGroups = ref([]); const favoriteWorldGroups = ref([]); const favoriteAvatarGroups = ref([]); const favoriteLimits = ref({ maxFavoriteGroups: { avatar: 6, friend: 3, vrcPlusWorld: 4, world: 4 }, maxFavoritesPerGroup: { avatar: 50, friend: 150, vrcPlusWorld: 100, world: 100 } }); const isFavoriteLoading = ref(false); const friendImportDialogInput = ref(''); const worldImportDialogInput = ref(''); const avatarImportDialogInput = ref(''); const worldImportDialogVisible = ref(false); const avatarImportDialogVisible = ref(false); const friendImportDialogVisible = ref(false); const localWorldFavorites = reactive({}); const localAvatarFavorites = reactive({}); const localFriendFavorites = reactive({}); const selectedFavoriteFriends = ref([]); const selectedFavoriteWorlds = ref([]); const selectedFavoriteAvatars = ref([]); const favoriteDialog = ref({ visible: false, loading: false, type: '', objectId: '', currentGroup: {} }); const favoritesSortOrder = ref([]); const favoriteFriends = computed(() => { if (appearanceSettingsStore.sortFavorites) { return state.favoriteFriends_.sort(compareByFavoriteSortOrder); } return state.favoriteFriends_.sort(compareByName); }); const favoriteWorlds = computed(() => { if (appearanceSettingsStore.sortFavorites) { return state.favoriteWorlds_.sort(compareByFavoriteSortOrder); } return state.favoriteWorlds_.sort(compareByName); }); const favoriteAvatars = computed(() => { if (appearanceSettingsStore.sortFavorites) { return state.favoriteAvatars_.sort(compareByFavoriteSortOrder); } return state.favoriteAvatars_.sort(compareByName); }); watch( favoriteFriends, (list) => { syncFavoriteSelection(list, selectedFavoriteFriends); }, { immediate: true } ); watch( favoriteWorlds, (list) => { syncFavoriteSelection(list, selectedFavoriteWorlds); }, { immediate: true } ); watch( favoriteAvatars, (list) => { syncFavoriteSelection(list, selectedFavoriteAvatars); }, { immediate: true } ); const localAvatarFavoriteGroups = computed(() => Object.keys(localAvatarFavorites).sort() ); const localWorldFavoriteGroups = computed(() => Object.keys(localWorldFavorites).sort() ); const localWorldFavoritesList = computed(() => Object.values(localWorldFavorites) .flat() .map((fav) => fav.id) ); const localAvatarFavoritesList = computed(() => Object.values(localAvatarFavorites) .flat() .map((fav) => fav.id) ); const localFriendFavoritesList = computed(() => Object.values(localFriendFavorites) .flat() .map((userId) => userId) ); const groupedByGroupKeyFavoriteFriends = computed(() => { const groupedByGroupKeyFavoriteFriends = {}; favoriteFriends.value.forEach((friend) => { if (friend.groupKey) { if (!groupedByGroupKeyFavoriteFriends[friend.groupKey]) { groupedByGroupKeyFavoriteFriends[friend.groupKey] = []; } groupedByGroupKeyFavoriteFriends[friend.groupKey].push(friend); } }); return groupedByGroupKeyFavoriteFriends; }); const localWorldFavGroupLength = computed(() => (group) => { const favoriteGroup = localWorldFavorites[group]; if (!favoriteGroup) { return 0; } return favoriteGroup.length; }); const localAvatarFavGroupLength = computed(() => (group) => { const favoriteGroup = localAvatarFavorites[group]; if (!favoriteGroup) { return 0; } return favoriteGroup.length; }); const localFriendFavoriteGroups = computed(() => Object.keys(localFriendFavorites).sort() ); const localFriendFavGroupLength = computed(() => (group) => { const favoriteGroup = localFriendFavorites[group]; if (!favoriteGroup) { return 0; } return favoriteGroup.length; }); /** * * @param {Array} list * @param {object} selectionRef * @returns {void} */ function syncFavoriteSelection(list, selectionRef) { if (!Array.isArray(list)) { selectionRef.value = []; return; } const availableIds = new Set(list.map((item) => item.id)); const filtered = selectionRef.value.filter((id) => availableIds.has(id) ); if (filtered.length !== selectionRef.value.length) { selectionRef.value = filtered; } } watch( () => watchState.isLoggedIn, (isLoggedIn) => { friendStore.localFavoriteFriends.clear(); cachedFavorites.clear(); cachedFavoritesByObjectId.clear(); cachedFavoriteGroups.value = {}; favoriteFriendGroups.value = []; favoriteWorldGroups.value = []; favoriteAvatarGroups.value = []; isFavoriteLoading.value = false; isFavoriteGroupLoading.value = false; state.favoriteObjects.clear(); state.favoriteFriends_ = []; state.favoriteWorlds_ = []; state.favoriteAvatars_ = []; replaceReactiveObject(localWorldFavorites, {}); replaceReactiveObject(localAvatarFavorites, {}); selectedFavoriteFriends.value = []; selectedFavoriteWorlds.value = []; selectedFavoriteAvatars.value = []; favoriteDialog.value.visible = false; worldImportDialogVisible.value = false; avatarImportDialogVisible.value = false; friendImportDialogVisible.value = false; if (isLoggedIn) { initFavorites(); } }, { flush: 'sync' } ); /** * @returns {void} */ function getCachedFavoriteGroupsByTypeName() { const group = {}; for (const k in favoriteFriendGroups.value) { const element = favoriteFriendGroups.value[k]; group[element.key] = element; } for (const k in favoriteWorldGroups.value) { const element = favoriteWorldGroups.value[k]; group[element.key] = element; } for (const k in favoriteAvatarGroups.value) { const element = favoriteAvatarGroups.value[k]; group[element.key] = element; } return group; } /** * * @param {string} objectId * @returns {object | undefined} */ function getCachedFavoritesByObjectId(objectId) { return cachedFavoritesByObjectId.get(objectId); } /** * * @param {object} args * @returns {void} */ function handleFavoriteAdd(args) { handleFavorite({ json: args.json, params: { favoriteId: args.json.id } }); if (!favoritesSortOrder.value.includes(args.params.favoriteId)) { favoritesSortOrder.value.unshift(args.params.favoriteId); } if ( args.params.type === 'avatar' && !avatarStore.cachedAvatars.has(args.params.favoriteId) ) { refreshFavoriteAvatars(args.params.tags); } if ( args.params.type === 'friend' && (!generalSettingsStore.localFavoriteFriendsGroups.some( (key) => !key.startsWith('local:') ) || generalSettingsStore.localFavoriteFriendsGroups.includes( 'friend:' + args.params.tags )) ) { friendStore.updateLocalFavoriteFriends(); } updateFavoriteDialog(args.params.objectId); } /** * * @param {object} args * @returns {void} */ function handleFavorite(args) { args.ref = applyFavoriteCached(args.json); applyFavorite(args.ref.type, args.ref.favoriteId); friendStore.updateFriend(args.ref.favoriteId); const { ref } = args; const userDialog = userStore.userDialog; if (userDialog.visible && ref.favoriteId === userDialog.id) { userStore.setUserDialogIsFavorite(true); } const worldDialog = worldStore.worldDialog; if (worldDialog.visible && ref.favoriteId === worldDialog.id) { worldStore.setWorldDialogIsFavorite(true); } const avatarDialog = avatarStore.avatarDialog; if (avatarDialog.visible && ref.favoriteId === avatarDialog.id) { avatarStore.setAvatarDialogIsFavorite(true); } } /** * * @param {string} objectId * @returns {void} */ function handleFavoriteDelete(objectId) { const ref = getCachedFavoritesByObjectId(objectId); if (typeof ref === 'undefined') { return; } handleFavoriteAtDelete(ref); } /** * * @param {object} args * @returns {void} */ function handleFavoriteGroup(args) { args.ref = applyFavoriteGroup(args.json); } /** * * @param {object} args * @returns {void} */ function handleFavoriteGroupClear(args) { const key = `${args.params.type}:${args.params.group}`; for (const ref of cachedFavorites.values()) { if (ref.$groupKey !== key) { continue; } handleFavoriteAtDelete(ref); } } /** * * @param {object} args * @returns {void} */ function handleFavoriteWorldList(args) { for (const json of args.json) { if (json.id === '???') { continue; } worldStore.applyWorld(json); } } /** * * @param {object} args */ function handleFavoriteAvatarList(args) { for (const json of args.json) { if (json.releaseStatus === 'hidden') { continue; } avatarStore.applyAvatar(json); } } /** * * @param {object} ref * @returns {void} */ function handleFavoriteAtDelete(ref) { const favorite = state.favoriteObjects.get(ref.favoriteId); removeFromArray(state.favoriteFriends_, favorite); removeFromArray(state.favoriteWorlds_, favorite); removeFromArray(state.favoriteAvatars_, favorite); cachedFavorites.delete(ref.id); cachedFavoritesByObjectId.delete(ref.favoriteId); state.favoriteObjects.delete(ref.favoriteId); friendStore.localFavoriteFriends.delete(ref.favoriteId); favoritesSortOrder.value = favoritesSortOrder.value.filter( (id) => id !== ref.favoriteId ); friendStore.updateFriend(ref.favoriteId); friendStore.updateSidebarFavorites(); const userDialog = userStore.userDialog; if (userDialog.visible && userDialog.id === ref.favoriteId) { userStore.setUserDialogIsFavorite(false); } const worldDialog = worldStore.worldDialog; if (worldDialog.visible && worldDialog.id === ref.favoriteId) { worldStore.setWorldDialogIsFavorite( localWorldFavoritesList.value.includes(worldDialog.id) ); } const avatarDialog = avatarStore.avatarDialog; if (avatarDialog.visible && avatarDialog.id === ref.favoriteId) { avatarStore.setAvatarDialogIsFavorite(false); } countFavoriteGroups(); } /** * * @param {'friend' | 'world' | 'vrcPlusWorld' | 'avatar'} type * @param {string} objectId * @returns {Promise} */ async function applyFavorite(type, objectId) { let ref; const favorite = getCachedFavoritesByObjectId(objectId); let ctx = state.favoriteObjects.get(objectId); if (ctx) { ctx = shallowReactive(ctx); } if (typeof favorite !== 'undefined') { let isTypeChanged = false; if (typeof ctx === 'undefined') { ctx = { id: objectId, type, groupKey: favorite.$groupKey, ref: null, name: '' }; if (type === 'friend') { ref = userStore.cachedUsers.get(objectId); if (typeof ref === 'undefined') { ref = friendStore.friendLog.get(objectId); if (typeof ref !== 'undefined' && ref.displayName) { ctx.name = ref.displayName; } } else { ctx.ref = ref; ctx.name = ref.displayName; } } else if (type === 'world' || type === 'vrcPlusWorld') { ref = worldStore.cachedWorlds.get(objectId); if (typeof ref !== 'undefined') { ctx.ref = ref; ctx.name = ref.name; } } else if (type === 'avatar') { ref = avatarStore.cachedAvatars.get(objectId); if (typeof ref !== 'undefined') { ctx.ref = ref; ctx.name = ref.name; } } state.favoriteObjects.set(objectId, ctx); isTypeChanged = true; } else { if (ctx.type !== type) { // WTF??? isTypeChanged = true; if (type === 'friend') { removeFromArray(state.favoriteFriends_, ctx); } else if (type === 'world' || type === 'vrcPlusWorld') { removeFromArray(state.favoriteWorlds_, ctx); } else if (type === 'avatar') { removeFromArray(state.favoriteAvatars_, ctx); } } if (type === 'friend') { ref = userStore.cachedUsers.get(objectId); if (typeof ref !== 'undefined') { if (ctx.ref !== ref) { ctx.ref = ref; } if (ctx.name !== ref.displayName) { ctx.name = ref.displayName; } } // else too bad } else if (type === 'world' || type === 'vrcPlusWorld') { ref = worldStore.cachedWorlds.get(objectId); if (typeof ref !== 'undefined') { if (ctx.ref !== ref) { ctx.ref = ref; } if (ctx.name !== ref.name) { ctx.name = ref.name; } } else { // try fetch from local world favorites const world = await database.getCachedWorldById(objectId); if (world) { ctx.ref = world; ctx.name = world.name; ctx.deleted = true; } if (!world) { // try fetch from local world history const worldName = await database.getGameLogWorldNameByWorldId( objectId ); if (worldName) { ctx.name = worldName; ctx.deleted = true; } } } } else if (type === 'avatar') { ref = avatarStore.cachedAvatars.get(objectId); if (typeof ref !== 'undefined') { if (ctx.ref !== ref) { ctx.ref = ref; } if (ctx.name !== ref.name) { ctx.name = ref.name; } } else { // try fetch from local avatar history const avatar = await database.getCachedAvatarById(objectId); if (avatar) { ctx.ref = avatar; ctx.name = avatar.name; ctx.deleted = true; } } } } if (isTypeChanged) { if (type === 'friend') { state.favoriteFriends_.push(ctx); } else if (type === 'world' || type === 'vrcPlusWorld') { state.favoriteWorlds_.push(ctx); } else if (type === 'avatar') { state.favoriteAvatars_.push(ctx); } } } } /** * @returns {void} */ function refreshFavoriteGroups() { if (isFavoriteGroupLoading.value) { return; } isFavoriteGroupLoading.value = true; processBulk({ fn: favoriteRequest.getCachedFavoriteGroups, N: -1, params: { n: 50, offset: 0 }, handle: (args) => { for (const json of args.json) { handleFavoriteGroup({ json, params: { favoriteGroupId: json.id } }); } }, done(ok) { if (ok) { buildFavoriteGroups(); } isFavoriteGroupLoading.value = false; } }); } /** * */ function buildFavoriteGroups() { let group; let groups; let i; // 450 = ['group_0', 'group_1', 'group_2'] x 150 favoriteFriendGroups.value = []; for (i = 0; i < favoriteLimits.value.maxFavoriteGroups.friend; ++i) { favoriteFriendGroups.value.push({ assign: false, key: `friend:group_${i}`, type: 'friend', name: `group_${i}`, displayName: `Group ${i + 1}`, capacity: favoriteLimits.value.maxFavoritesPerGroup.friend, count: 0, visibility: 'private' }); } // 400 = ['worlds1', 'worlds2', 'worlds3', 'worlds4'] x 100 favoriteWorldGroups.value = []; for (i = 0; i < favoriteLimits.value.maxFavoriteGroups.world; ++i) { favoriteWorldGroups.value.push({ assign: false, key: `world:worlds${i + 1}`, type: 'world', name: `worlds${i + 1}`, displayName: `Group ${i + 1}`, capacity: favoriteLimits.value.maxFavoritesPerGroup.world, count: 0, visibility: 'private' }); } // 400 = ['vrcPlusWorlds1', 'vrcPlusWorlds2', 'vrcPlusWorlds3', 'vrcPlusWorlds4'] x 100 for ( i = 0; i < favoriteLimits.value.maxFavoriteGroups.vrcPlusWorld; ++i ) { favoriteWorldGroups.value.push({ assign: false, key: `vrcPlusWorld:vrcPlusWorlds${i + 1}`, type: 'vrcPlusWorld', name: `vrcPlusWorlds${i + 1}`, displayName: `VRC+ Group ${i + 1}`, capacity: favoriteLimits.value.maxFavoritesPerGroup.vrcPlusWorld, count: 0, visibility: 'private' }); } // 350 = ['avatars1', ...] x 50 // Favorite Avatars (0/50) // VRC+ Group 1..5 (0/50) favoriteAvatarGroups.value = []; for (i = 0; i < favoriteLimits.value.maxFavoriteGroups.avatar; ++i) { favoriteAvatarGroups.value.push({ assign: false, key: `avatar:avatars${i + 1}`, type: 'avatar', name: `avatars${i + 1}`, displayName: `Group ${i + 1}`, capacity: favoriteLimits.value.maxFavoritesPerGroup.avatar, count: 0, visibility: 'private' }); } const types = { friend: favoriteFriendGroups.value, world: favoriteWorldGroups.value, vrcPlusWorld: favoriteWorldGroups.value, avatar: favoriteAvatarGroups.value }; const assigns = new Set(); // assign the same name first for (const key in cachedFavoriteGroups.value) { const ref = cachedFavoriteGroups.value[key]; groups = types[ref.type]; if (typeof groups === 'undefined') { continue; } for (group of groups) { if (group.assign === false && group.name === ref.name) { group.assign = true; if (ref.displayName) { group.displayName = ref.displayName; } group.visibility = ref.visibility; assigns.add(ref.id); break; } } } for (const key in cachedFavoriteGroups.value) { const ref = cachedFavoriteGroups.value[key]; if (assigns.has(ref.id)) { continue; } groups = types[ref.type]; if (typeof groups === 'undefined') { continue; } for (group of groups) { if (group.assign === false) { group.assign = true; group.key = `${group.type}:${ref.name}`; group.name = ref.name; group.displayName = ref.displayName; assigns.add(ref.id); break; } } } countFavoriteGroups(); } /** * */ function countFavoriteGroups() { const cachedFavoriteGroups = getCachedFavoriteGroupsByTypeName(); for (const key in cachedFavoriteGroups) { cachedFavoriteGroups[key].count = 0; } for (let ref of cachedFavorites.values()) { let group = cachedFavoriteGroups[ref.$groupKey]; if (typeof group === 'undefined') { continue; } ++group.count; } } /** * * @returns {Promise} */ async function refreshFavorites() { if (isFavoriteLoading.value) { return; } isFavoriteLoading.value = true; try { const args = await favoriteRequest.getCachedFavoriteLimits(); favoriteLimits.value = { ...favoriteLimits.value, ...args.json }; } catch (err) { console.error(err); } let newFavoriteSortOrder = []; processBulk({ fn: favoriteRequest.getCachedFavorites, N: -1, params: { n: 300, offset: 0 }, handle(args) { for (const json of args.json) { newFavoriteSortOrder.push(json.favoriteId); handleFavorite({ json, params: { favoriteId: json.id } }); } }, done(ok) { if (ok) { for (const id of favoritesSortOrder.value) { if (!newFavoriteSortOrder.includes(id)) { const fav = cachedFavorites.get(id); if (fav) { handleFavoriteAtDelete(fav); } } } favoritesSortOrder.value = newFavoriteSortOrder; } refreshFavoriteItems(); refreshFavoriteGroups(); friendStore.updateLocalFavoriteFriends(); isFavoriteLoading.value = false; watchState.isFavoritesLoaded = true; countFavoriteGroups(); } }); } /** * * @param {object} json * @returns {object} */ function applyFavoriteGroup(json) { let ref = cachedFavoriteGroups.value[json.id]; if (typeof ref === 'undefined') { ref = createDefaultFavoriteGroupRef(json); cachedFavoriteGroups.value[ref.id] = ref; } else { Object.assign(ref, json); } return ref; } /** * * @param {object} json * @returns {object} */ function applyFavoriteCached(json) { let ref = cachedFavorites.get(json.id); if (typeof ref === 'undefined') { ref = createDefaultFavoriteCachedRef(json); cachedFavorites.set(ref.id, ref); cachedFavoritesByObjectId.set(ref.favoriteId, ref); if ( ref.type === 'friend' && (!generalSettingsStore.localFavoriteFriendsGroups.some( (key) => !key.startsWith('local:') ) || generalSettingsStore.localFavoriteFriendsGroups.includes( ref.$groupKey )) ) { friendStore.localFavoriteFriends.add(ref.favoriteId); friendStore.updateSidebarFavorites(); } if (!isFavoriteLoading.value) { countFavoriteGroups(); } } else { if (ref.favoriteId !== json.favoriteId) { cachedFavoritesByObjectId.delete(ref.favoriteId); } Object.assign(ref, json); cachedFavoritesByObjectId.set(ref.favoriteId, ref); } return ref; } /** * * @param {string} tag * @returns {void} */ async function refreshFavoriteAvatars(tag) { const params = { n: 300, offset: 0, tag }; const args = await favoriteRequest.getCachedFavoriteAvatars(params); handleFavoriteAvatarList(args); } /** * @returns {void} */ function refreshFavoriteItems() { const types = { world: [0, favoriteRequest.getCachedFavoriteWorlds], avatar: [0, favoriteRequest.getCachedFavoriteAvatars] }; const tags = []; for (const ref of cachedFavorites.values()) { const type = types[ref.type]; if (typeof type === 'undefined') { continue; } if (ref.type === 'avatar' && !tags.includes(ref.tags[0])) { tags.push(ref.tags[0]); } ++type[0]; } for (const type in types) { const [N, fn] = types[type]; if (N > 0) { if (type === 'avatar') { for (const tag of tags) { processBulk({ fn, N, handle: (args) => handleFavoriteAvatarList(args), params: { n: 300, offset: 0, tag } }); } } else { processBulk({ fn, N, handle: (args) => handleFavoriteWorldList(args), params: { n: 300, offset: 0 } }); } } } } /** * @returns {void} */ function showWorldImportDialog() { worldImportDialogVisible.value = true; } /** * @returns {void} */ function showAvatarImportDialog() { avatarImportDialogVisible.value = true; } /** * @returns {void} */ function showFriendImportDialog() { friendImportDialogVisible.value = true; } /** * @param {string} value */ function setAvatarImportDialogInput(value) { avatarImportDialogInput.value = value; } /** * @param {string} value */ function setWorldImportDialogInput(value) { worldImportDialogInput.value = value; } /** * @param {string} value */ function setFriendImportDialogInput(value) { friendImportDialogInput.value = value; } /** * @param {object} avatarRef */ function syncLocalAvatarFavoriteRef(avatarRef) { if (!avatarRef?.id) { return; } for (let i = 0; i < localAvatarFavoriteGroups.value.length; ++i) { const groupName = localAvatarFavoriteGroups.value[i]; const group = localAvatarFavorites[groupName]; if (!group) { continue; } for (let j = 0; j < group.length; ++j) { if (group[j]?.id === avatarRef.id) { group[j] = avatarRef; } } } } /** * * @param {string} worldId * @param {string} group */ function addLocalWorldFavorite(worldId, group) { if (hasLocalWorldFavorite(worldId, group)) { return; } const ref = worldStore.cachedWorlds.get(worldId); if (typeof ref === 'undefined') { return; } if (!localWorldFavorites[group]) { localWorldFavorites[group] = []; } localWorldFavorites[group].unshift(ref); database.addWorldToCache(ref); database.addWorldToFavorites(worldId, group); if ( favoriteDialog.value.visible && favoriteDialog.value.objectId === worldId ) { updateFavoriteDialog(worldId); } if ( worldStore.worldDialog.visible && worldStore.worldDialog.id === worldId ) { worldStore.setWorldDialogIsFavorite(true); } // update UI sortLocalWorldFavorites(); } /** * * @param {string} worldId * @param {string} group * @returns {boolean} */ function hasLocalWorldFavorite(worldId, group) { const favoriteGroup = localWorldFavorites[group]; if (!favoriteGroup) { return false; } for (let i = 0; i < favoriteGroup.length; ++i) { if (favoriteGroup[i].id === worldId) { return true; } } return false; } /** * * @param {string} avatarId * @param {string} group */ function addLocalAvatarFavorite(avatarId, group) { if (hasLocalAvatarFavorite(avatarId, group)) { return; } const ref = avatarStore.cachedAvatars.get(avatarId); if (typeof ref === 'undefined') { return; } if (!localAvatarFavorites[group]) { localAvatarFavorites[group] = []; } localAvatarFavorites[group].unshift(ref); database.addAvatarToCache(ref); database.addAvatarToFavorites(avatarId, group); if ( favoriteDialog.value.visible && favoriteDialog.value.objectId === avatarId ) { updateFavoriteDialog(avatarId); } if ( avatarStore.avatarDialog.visible && avatarStore.avatarDialog.id === avatarId ) { avatarStore.setAvatarDialogIsFavorite(true); } // update UI sortLocalAvatarFavorites(); } /** * * @param {string} avatarId * @param {string} group * @returns {boolean} */ function hasLocalAvatarFavorite(avatarId, group) { const favoriteGroup = localAvatarFavorites[group]; if (!favoriteGroup) { return false; } for (let i = 0; i < favoriteGroup.length; ++i) { if (favoriteGroup[i].id === avatarId) { return true; } } return false; } /** * * @param {string} objectId * @returns {void} */ function updateFavoriteDialog(objectId) { const D = favoriteDialog.value; if (!D.visible || D.objectId !== objectId) { return; } D.currentGroup = {}; const favorite = state.favoriteObjects.get(objectId); if (favorite) { let group; for (group of favoriteWorldGroups.value) { if (favorite.groupKey === group.key) { D.currentGroup = group; return; } } for (group of favoriteAvatarGroups.value) { if (favorite.groupKey === group.key) { D.currentGroup = group; return; } } for (group of favoriteFriendGroups.value) { if (favorite.groupKey === group.key) { D.currentGroup = group; return; } } } } /** * * @param {string} group */ function deleteLocalAvatarFavoriteGroup(group) { let i; // remove from cache if no longer in favorites const avatarIdRemoveList = new Set(); const favoriteGroup = localAvatarFavorites[group]; for (i = 0; i < favoriteGroup.length; ++i) { avatarIdRemoveList.add(favoriteGroup[i].id); } delete localAvatarFavorites[group]; database.deleteAvatarFavoriteGroup(group); for (i = 0; i < localAvatarFavoriteGroups.value.length; ++i) { const groupName = localAvatarFavoriteGroups.value[i]; if (!localAvatarFavorites[groupName]) { continue; } for (let j = 0; j < localAvatarFavorites[groupName].length; ++j) { const avatarId = localAvatarFavorites[groupName][j].id; if (avatarIdRemoveList.has(avatarId)) { avatarIdRemoveList.delete(avatarId); break; } } } avatarIdRemoveList.forEach((id) => { // remove from cache if no longer in favorites let avatarInFavorites = false; loop: for ( let i = 0; i < localAvatarFavoriteGroups.value.length; ++i ) { const groupName = localAvatarFavoriteGroups.value[i]; if (!localAvatarFavorites[groupName] || group === groupName) { continue loop; } for ( let j = 0; j < localAvatarFavorites[groupName].length; ++j ) { const avatarId = localAvatarFavorites[groupName][j].id; if (id === avatarId) { avatarInFavorites = true; break loop; } } } if (!avatarInFavorites) { if (!avatarStore.avatarHistory.includes(id)) { database.removeAvatarFromCache(id); } } }); } /** * @returns {void} */ function sortLocalAvatarFavorites() { if (!appearanceSettingsStore.sortFavorites) { for (let i = 0; i < localAvatarFavoriteGroups.value.length; ++i) { const group = localAvatarFavoriteGroups.value[i]; if (localAvatarFavorites[group]) { localAvatarFavorites[group].sort(compareByName); } } } } /** * * @param {string} newName * @param {string} group */ function renameLocalAvatarFavoriteGroup(newName, group) { if (localAvatarFavoriteGroups.value.includes(newName)) { toast.error( t('prompt.local_favorite_group_rename.message.error', { name: newName }) ); return; } localAvatarFavorites[newName] = localAvatarFavorites[group]; delete localAvatarFavorites[group]; database.renameAvatarFavoriteGroup(newName, group); sortLocalAvatarFavorites(); } /** * * @param {string} group */ function newLocalAvatarFavoriteGroup(group) { if (localAvatarFavoriteGroups.value.includes(group)) { toast.error( t('prompt.new_local_favorite_group.message.error', { name: group }) ); return; } if (!localAvatarFavorites[group]) { localAvatarFavorites[group] = []; } sortLocalAvatarFavorites(); } /** * * @returns {Promise} */ async function getLocalAvatarFavorites() { const localGroups = new Set(); const localListSet = new Set(); const localFavorites = Object.create(null); const avatarCache = await database.getAvatarCache(); for (let i = 0; i < avatarCache.length; ++i) { const ref = avatarCache[i]; if (!avatarStore.cachedAvatars.has(ref.id)) { avatarStore.applyAvatar(ref); } } const favorites = await database.getAvatarFavorites(); for (let i = 0; i < favorites.length; ++i) { const favorite = favorites[i]; localListSet.add(favorite.avatarId); if (!localFavorites[favorite.groupName]) { localFavorites[favorite.groupName] = []; } localGroups.add(favorite.groupName); let ref = avatarStore.cachedAvatars.get(favorite.avatarId); if (typeof ref === 'undefined') { ref = { id: favorite.avatarId }; } localFavorites[favorite.groupName].unshift(ref); } let groupsArr = Array.from(localGroups); if (groupsArr.length === 0) { // default group localFavorites.Favorites = []; groupsArr = ['Favorites']; } replaceReactiveObject(localAvatarFavorites, localFavorites); sortLocalAvatarFavorites(); } /** * * @param {string} avatarId * @param {string} group */ function removeLocalAvatarFavorite(avatarId, group) { let i; const favoriteGroup = localAvatarFavorites[group]; for (i = 0; i < favoriteGroup.length; ++i) { if (favoriteGroup[i].id === avatarId) { favoriteGroup.splice(i, 1); } } // remove from cache if no longer in favorites let avatarInFavorites = false; for (i = 0; i < localAvatarFavoriteGroups.value.length; ++i) { const groupName = localAvatarFavoriteGroups.value[i]; if (!localAvatarFavorites[groupName] || group === groupName) { continue; } for (let j = 0; j < localAvatarFavorites[groupName].length; ++j) { const id = localAvatarFavorites[groupName][j].id; if (id === avatarId) { avatarInFavorites = true; break; } } } if (!avatarInFavorites) { if (!avatarStore.avatarHistory.includes(avatarId)) { database.removeAvatarFromCache(avatarId); } } database.removeAvatarFromFavorites(avatarId, group); if ( favoriteDialog.value.visible && favoriteDialog.value.objectId === avatarId ) { updateFavoriteDialog(avatarId); } if ( avatarStore.avatarDialog.visible && avatarStore.avatarDialog.id === avatarId ) { avatarStore.setAvatarDialogIsFavorite( getCachedFavoritesByObjectId(avatarId) ); } // update UI sortLocalAvatarFavorites(); } /** * * @param {string} group */ function deleteLocalWorldFavoriteGroup(group) { let i; // remove from cache if no longer in favorites const worldIdRemoveList = new Set(); const favoriteGroup = localWorldFavorites[group]; for (i = 0; i < favoriteGroup.length; ++i) { worldIdRemoveList.add(favoriteGroup[i].id); } delete localWorldFavorites[group]; database.deleteWorldFavoriteGroup(group); for (i = 0; i < localWorldFavoriteGroups.value.length; ++i) { const groupName = localWorldFavoriteGroups.value[i]; if (!localWorldFavorites[groupName]) { continue; } for (let j = 0; j < localWorldFavorites[groupName].length; ++j) { const worldId = localWorldFavorites[groupName][j].id; if (worldIdRemoveList.has(worldId)) { worldIdRemoveList.delete(worldId); break; } } } worldIdRemoveList.forEach((id) => { database.removeWorldFromCache(id); }); } /** * @returns {void} */ function sortLocalWorldFavorites() { if (!appearanceSettingsStore.sortFavorites) { for (let i = 0; i < localWorldFavoriteGroups.value.length; ++i) { const group = localWorldFavoriteGroups.value[i]; if (localWorldFavorites[group]) { localWorldFavorites[group].sort(compareByName); } } } } /** * Check invalid local avatar favorites * @param {string | null} targetGroup - Target group to check, null for all groups * @param {Function | null} onProgress - Progress callback function, receives (current, total) parameters * @returns {Promise<{total: number, invalid: number, invalidIds: string[]}>} */ async function checkInvalidLocalAvatars( targetGroup = null, onProgress = null ) { const result = { total: 0, invalid: 0, invalidIds: [] }; const groupsToCheck = targetGroup ? [targetGroup] : localAvatarFavoriteGroups.value; for (const group of groupsToCheck) { const favoriteGroup = localAvatarFavorites[group]; if (favoriteGroup && favoriteGroup.length > 0) { result.total += favoriteGroup.length; } } let currentIndex = 0; for (const group of groupsToCheck) { const favoriteGroup = localAvatarFavorites[group]; if (!favoriteGroup || favoriteGroup.length === 0) { continue; } for (const favorite of favoriteGroup) { currentIndex++; if (typeof onProgress === 'function') { onProgress(currentIndex, result.total); } try { await avatarRequest.getAvatar({ avatarId: favorite.id }); await new Promise((resolve) => setTimeout(resolve, 500)); } catch (err) { console.error( `Failed to fetch avatar ${favorite.id}:`, err ); result.invalid++; result.invalidIds.push(favorite.id); } } } return result; } /** * Remove invalid avatars from local favorites * @param {string[]} avatarIds - Array of avatar IDs to remove * @param {string | null} targetGroup - Target group, null for all groups * @returns {Promise<{removed: number, removedIds: string[]}>} */ async function removeInvalidLocalAvatars(avatarIds, targetGroup = null) { const result = { removed: 0, removedIds: [] }; const groupsToCheck = targetGroup ? [targetGroup] : localAvatarFavoriteGroups.value; for (const group of groupsToCheck) { const favoriteGroup = localAvatarFavorites[group]; if (!favoriteGroup) { continue; } for (const avatarId of avatarIds) { const index = favoriteGroup.findIndex( (fav) => fav.id === avatarId ); if (index !== -1) { removeLocalAvatarFavorite(avatarId, group); result.removed++; if (!result.removedIds.includes(avatarId)) { result.removedIds.push(avatarId); } } } } return result; } /** * * @param {string} newName * @param {string} group */ function renameLocalWorldFavoriteGroup(newName, group) { if (localWorldFavoriteGroups.value.includes(newName)) { toast.error( t('prompt.local_favorite_group_rename.message.error', { name: newName }) ); return; } localWorldFavorites[newName] = localWorldFavorites[group]; delete localWorldFavorites[group]; database.renameWorldFavoriteGroup(newName, group); sortLocalWorldFavorites(); } /** * * @param {string} worldId * @param {string} group */ function removeLocalWorldFavorite(worldId, group) { let i; const favoriteGroup = localWorldFavorites[group]; for (i = 0; i < favoriteGroup.length; ++i) { if (favoriteGroup[i].id === worldId) { favoriteGroup.splice(i, 1); } } // remove from cache if no longer in favorites let worldInFavorites = false; for (i = 0; i < localWorldFavoriteGroups.value.length; ++i) { const groupName = localWorldFavoriteGroups.value[i]; if (!localWorldFavorites[groupName] || group === groupName) { continue; } for (let j = 0; j < localWorldFavorites[groupName].length; ++j) { const id = localWorldFavorites[groupName][j].id; if (id === worldId) { worldInFavorites = true; break; } } } if (!worldInFavorites) { database.removeWorldFromCache(worldId); } database.removeWorldFromFavorites(worldId, group); if ( favoriteDialog.value.visible && favoriteDialog.value.objectId === worldId ) { updateFavoriteDialog(worldId); } if ( worldStore.worldDialog.visible && worldStore.worldDialog.id === worldId ) { worldStore.setWorldDialogIsFavorite( getCachedFavoritesByObjectId(worldId) ); } // update UI sortLocalWorldFavorites(); } /** * * @returns {Promise} */ async function getLocalWorldFavorites() { const localGroups = new Set(); const localListSet = new Set(); const localFavorites = Object.create(null); const worldCache = await database.getWorldCache(); for (let i = 0; i < worldCache.length; ++i) { const ref = worldCache[i]; if (!worldStore.cachedWorlds.has(ref.id)) { worldStore.applyWorld(ref); } } const favorites = await database.getWorldFavorites(); for (let i = 0; i < favorites.length; ++i) { const favorite = favorites[i]; localListSet.add(favorite.worldId); if (!localFavorites[favorite.groupName]) { localFavorites[favorite.groupName] = []; } localGroups.add(favorite.groupName); let ref = worldStore.cachedWorlds.get(favorite.worldId); if (typeof ref === 'undefined') { ref = { id: favorite.worldId }; } localFavorites[favorite.groupName].unshift(ref); } let groupsArr = Array.from(localGroups); if (groupsArr.length === 0) { localFavorites.Favorites = []; // default group groupsArr = ['Favorites']; } replaceReactiveObject(localWorldFavorites, localFavorites); sortLocalWorldFavorites(); } /** * * @param {string} group */ function newLocalWorldFavoriteGroup(group) { if (localWorldFavoriteGroups.value.includes(group)) { toast.error( t('prompt.new_local_favorite_group.message.error', { name: group }) ); return; } if (!localWorldFavorites[group]) { localWorldFavorites[group] = []; } sortLocalWorldFavorites(); } /** * @param {string} userId * @param {string} group */ function addLocalFriendFavorite(userId, group) { if (hasLocalFriendFavorite(userId, group)) { return; } if (!localFriendFavorites[group]) { localFriendFavorites[group] = []; } localFriendFavorites[group].unshift(userId); database.addFriendToLocalFavorites(userId, group); if ( favoriteDialog.value.visible && favoriteDialog.value.objectId === userId ) { updateFavoriteDialog(userId); } const userDialog = userStore.userDialog; if (userDialog.visible && userDialog.id === userId) { userStore.setUserDialogIsFavorite(true); } friendStore.updateLocalFavoriteFriends(); } /** * @param {string} userId * @param {string} group * @returns {boolean} */ function hasLocalFriendFavorite(userId, group) { const favoriteGroup = localFriendFavorites[group]; if (!favoriteGroup) { return false; } return favoriteGroup.includes(userId); } /** * Check if a user is in any local friend favorite group. * @param {string} userId * @returns {boolean} */ function isInAnyLocalFriendGroup(userId) { for (const group in localFriendFavorites) { if (localFriendFavorites[group]?.includes(userId)) { return true; } } return false; } /** * @param {string} userId * @param {string} group */ function removeLocalFriendFavorite(userId, group) { const favoriteGroup = localFriendFavorites[group]; if (favoriteGroup) { const idx = favoriteGroup.indexOf(userId); if (idx !== -1) { favoriteGroup.splice(idx, 1); } } database.removeFriendFromLocalFavorites(userId, group); if ( favoriteDialog.value.visible && favoriteDialog.value.objectId === userId ) { updateFavoriteDialog(userId); } const userDialog = userStore.userDialog; if (userDialog.visible && userDialog.id === userId) { userStore.setUserDialogIsFavorite( getCachedFavoritesByObjectId(userId) || isInAnyLocalFriendGroup(userId) ); } friendStore.updateLocalFavoriteFriends(); } /** * @param {string} group */ function deleteLocalFriendFavoriteGroup(group) { delete localFriendFavorites[group]; database.deleteFriendFavoriteGroup(group); friendStore.updateLocalFavoriteFriends(); } /** * @param {string} newName * @param {string} group */ function renameLocalFriendFavoriteGroup(newName, group) { if (localFriendFavoriteGroups.value.includes(newName)) { toast.error( t('prompt.local_favorite_group_rename.message.error', { name: newName }) ); return; } localFriendFavorites[newName] = localFriendFavorites[group]; delete localFriendFavorites[group]; database.renameFriendFavoriteGroup(newName, group); const oldKey = `local:${group}`; const idx = generalSettingsStore.localFavoriteFriendsGroups.indexOf(oldKey); if (idx !== -1) { const updated = [ ...generalSettingsStore.localFavoriteFriendsGroups ]; updated[idx] = `local:${newName}`; generalSettingsStore.setLocalFavoriteFriendsGroups(updated); } } /** * @param {string} group */ function newLocalFriendFavoriteGroup(group) { if (localFriendFavoriteGroups.value.includes(group)) { toast.error( t('prompt.new_local_favorite_group.message.error', { name: group }) ); return; } if (!localFriendFavorites[group]) { localFriendFavorites[group] = []; } } /** * @returns {Promise} */ async function getLocalFriendFavorites() { const localFavorites = Object.create(null); const favorites = await database.getFriendFavorites(); for (let i = 0; i < favorites.length; ++i) { const favorite = favorites[i]; if (!localFavorites[favorite.groupName]) { localFavorites[favorite.groupName] = []; } localFavorites[favorite.groupName].unshift(favorite.userId); } if (Object.keys(localFavorites).length === 0) { localFavorites.Favorites = []; } replaceReactiveObject(localFriendFavorites, localFavorites); friendStore.updateLocalFavoriteFriends(); } /** * * @param {string} objectId */ function deleteFavoriteNoConfirm(objectId) { if (!objectId) { return; } favoriteDialog.value.visible = true; favoriteRequest .deleteFavorite({ objectId }) .then(() => { favoriteDialog.value.visible = false; }) .finally(() => { favoriteDialog.value.loading = false; }); } /** * * @param type * @param objectId */ function showFavoriteDialog(type, objectId) { const D = favoriteDialog.value; D.type = type; D.objectId = objectId; D.visible = true; updateFavoriteDialog(objectId); } /** * */ async function saveSortFavoritesOption() { getLocalWorldFavorites(); getLocalFriendFavorites(); appearanceSettingsStore.setSortFavorites(); } /** * */ async function initFavorites() { refreshFavorites(); getLocalWorldFavorites(); getLocalAvatarFavorites(); getLocalFriendFavorites(); } /** * * @param a * @param b */ function compareByFavoriteSortOrder(a, b) { const indexA = favoritesSortOrder.value.indexOf(a.id); const indexB = favoritesSortOrder.value.indexOf(b.id); return indexA - indexB; } return { state, favoriteFriends, favoriteWorlds, favoriteAvatars, isFavoriteGroupLoading, favoriteFriendGroups, cachedFavoriteGroups, favoriteLimits, cachedFavorites, favoriteWorldGroups, favoriteAvatarGroups, isFavoriteLoading, friendImportDialogInput, worldImportDialogInput, avatarImportDialogInput, worldImportDialogVisible, avatarImportDialogVisible, friendImportDialogVisible, localWorldFavorites, localAvatarFavorites, localAvatarFavoritesList, localAvatarFavoriteGroups, favoriteDialog, localWorldFavoritesList, localFriendFavoritesList, localWorldFavoriteGroups, localFriendFavorites, localFriendFavoriteGroups, localFriendFavGroupLength, groupedByGroupKeyFavoriteFriends, selectedFavoriteFriends, selectedFavoriteWorlds, selectedFavoriteAvatars, localWorldFavGroupLength, localAvatarFavGroupLength, favoritesSortOrder, initFavorites, applyFavorite, refreshFavoriteGroups, refreshFavorites, applyFavoriteGroup, refreshFavoriteAvatars, showWorldImportDialog, showAvatarImportDialog, showFriendImportDialog, setAvatarImportDialogInput, setWorldImportDialogInput, setFriendImportDialogInput, syncLocalAvatarFavoriteRef, addLocalWorldFavorite, hasLocalWorldFavorite, hasLocalAvatarFavorite, addLocalAvatarFavorite, updateFavoriteDialog, deleteLocalAvatarFavoriteGroup, renameLocalAvatarFavoriteGroup, newLocalAvatarFavoriteGroup, getLocalAvatarFavorites, removeLocalAvatarFavorite, deleteLocalWorldFavoriteGroup, sortLocalWorldFavorites, renameLocalWorldFavoriteGroup, removeLocalWorldFavorite, getLocalWorldFavorites, newLocalWorldFavoriteGroup, deleteFavoriteNoConfirm, showFavoriteDialog, saveSortFavoritesOption, handleFavoriteWorldList, handleFavoriteGroupClear, handleFavoriteGroup, handleFavoriteDelete, handleFavoriteAdd, getCachedFavoritesByObjectId, checkInvalidLocalAvatars, removeInvalidLocalAvatars, getCachedFavoriteGroupsByTypeName, addLocalFriendFavorite, hasLocalFriendFavorite, isInAnyLocalFriendGroup, removeLocalFriendFavorite, deleteLocalFriendFavoriteGroup, renameLocalFriendFavoriteGroup, newLocalFriendFavoriteGroup, getLocalFriendFavorites }; });