Files
VRCX/src/stores/search.js
2025-09-25 05:20:25 +12:00

432 lines
14 KiB
JavaScript

import { defineStore } from 'pinia';
import { computed, reactive, watch } from 'vue';
import { ElMessageBox, ElMessage } from 'element-plus';
import { instanceRequest, userRequest } from '../api';
import { groupRequest } from '../api/';
import removeConfusables, { removeWhitespace } from '../service/confusables';
import { watchState } from '../service/watchState';
import { compareByName, localeIncludes } from '../shared/utils';
import { useAvatarStore } from './avatar';
import { useFriendStore } from './friend';
import { useGroupStore } from './group';
import { useAppearanceSettingsStore } from './settings/appearance';
import { useUiStore } from './ui';
import { useUserStore } from './user';
import { useWorldStore } from './world';
import { useI18n } from 'vue-i18n';
export const useSearchStore = defineStore('Search', () => {
const userStore = useUserStore();
const uiStore = useUiStore();
const appearanceSettingsStore = useAppearanceSettingsStore();
const friendStore = useFriendStore();
const worldStore = useWorldStore();
const avatarStore = useAvatarStore();
const groupStore = useGroupStore();
const { t } = useI18n();
const state = reactive({
searchText: '',
searchUserResults: [],
quickSearchItems: [],
friendsListSearch: ''
});
const searchText = computed({
get: () => state.searchText,
set: (value) => {
state.searchText = value;
}
});
const searchUserResults = computed({
get: () => state.searchUserResults,
set: (value) => {
state.searchUserResults = value;
}
});
const quickSearchItems = computed({
get: () => state.quickSearchItems,
set: (value) => {
state.quickSearchItems = value;
}
});
const stringComparer = computed(() =>
Intl.Collator(appearanceSettingsStore.appLanguage.replace('_', '-'), {
usage: 'search',
sensitivity: 'base'
})
);
const friendsListSearch = computed({
get: () => state.friendsListSearch,
set: (value) => {
state.friendsListSearch = value;
}
});
watch(
() => watchState.isLoggedIn,
() => {
state.searchText = '';
state.searchUserResults = [];
},
{ flush: 'sync' }
);
function clearSearch() {
state.searchText = '';
state.searchUserResults = [];
}
async function searchUserByDisplayName(displayName) {
const params = {
n: 10,
offset: 0,
fuzzy: false,
search: displayName
};
await moreSearchUser(null, params);
}
async function moreSearchUser(go, params) {
if (go) {
params.offset += params.n * go;
if (params.offset < 0) {
params.offset = 0;
}
}
await userRequest.getUsers(params).then((args) => {
for (const json of args.json) {
if (!json.displayName) {
console.error('getUsers gave us garbage', json);
continue;
}
userStore.applyUser(json);
}
const map = new Map();
for (const json of args.json) {
const ref = userStore.cachedUsers.get(json.id);
if (typeof ref !== 'undefined') {
map.set(ref.id, ref);
}
}
state.searchUserResults = Array.from(map.values());
return args;
});
}
function quickSearchRemoteMethod(query) {
if (!query) {
state.quickSearchItems = quickSearchUserHistory();
return;
}
if (query.length < 2) {
state.quickSearchItems = quickSearchUserHistory();
return;
}
const results = [];
const cleanQuery = removeWhitespace(query);
if (!cleanQuery) {
state.quickSearchItems = quickSearchUserHistory();
return;
}
for (const ctx of friendStore.friends.values()) {
if (typeof ctx.ref === 'undefined') {
continue;
}
const cleanName = removeConfusables(ctx.name);
let match = localeIncludes(
cleanName,
cleanQuery,
stringComparer.value
);
if (!match) {
// Also check regular name in case search is with special characters
match = localeIncludes(
ctx.name,
cleanQuery,
stringComparer.value
);
}
// Use query with whitespace for notes and memos as people are more
// likely to include spaces in memos and notes
if (!match && ctx.memo) {
match = localeIncludes(ctx.memo, query, stringComparer.value);
}
if (!match && ctx.ref.note) {
match = localeIncludes(
ctx.ref.note,
query,
stringComparer.value
);
}
if (match) {
results.push({
value: ctx.id,
label: ctx.name,
ref: ctx.ref,
name: ctx.name
});
}
}
results.sort(function (a, b) {
const A =
stringComparer.value.compare(
a.name.substring(0, cleanQuery.length),
cleanQuery
) === 0;
const B =
stringComparer.value.compare(
b.name.substring(0, cleanQuery.length),
cleanQuery
) === 0;
if (A && !B) {
return -1;
} else if (B && !A) {
return 1;
}
return compareByName(a, b);
});
if (results.length > 4) {
results.length = 4;
}
results.push({
value: `search:${query}`,
label: query
});
state.quickSearchItems = results;
}
function quickSearchChange(value) {
if (value) {
if (value.startsWith('search:')) {
const searchText = value.substr(7);
if (state.quickSearchItems.length > 1 && searchText.length) {
state.friendsListSearch = searchText;
uiStore.menuActiveIndex = 'friendList';
} else {
uiStore.menuActiveIndex = 'search';
state.searchText = searchText;
userStore.lookupUser({ displayName: searchText });
}
} else {
userStore.showUserDialog(value);
}
}
}
function quickSearchUserHistory() {
const userHistory = Array.from(userStore.showUserDialogHistory.values())
.reverse()
.slice(0, 5);
const results = [];
userHistory.forEach((userId) => {
const ref = userStore.cachedUsers.get(userId);
if (typeof ref !== 'undefined') {
results.push({
value: ref.id,
label: ref.name,
ref
});
}
});
return results;
}
function directAccessPaste() {
AppApi.GetClipboard().then((clipboard) => {
if (!directAccessParse(clipboard.trim())) {
promptOmniDirectDialog();
}
});
}
function directAccessParse(input) {
if (!input) {
return false;
}
if (directAccessWorld(input)) {
return true;
}
if (input.startsWith('https://vrchat.')) {
const url = new URL(input);
const urlPath = url.pathname;
const urlPathSplit = urlPath.split('/');
if (urlPathSplit.length < 4) {
return false;
}
const type = urlPathSplit[2];
if (type === 'user') {
const userId = urlPathSplit[3];
userStore.showUserDialog(userId);
return true;
} else if (type === 'avatar') {
const avatarId = urlPathSplit[3];
avatarStore.showAvatarDialog(avatarId);
return true;
} else if (type === 'group') {
const groupId = urlPathSplit[3];
groupStore.showGroupDialog(groupId);
return true;
}
} else if (input.startsWith('https://vrc.group/')) {
const shortCode = input.substring(18);
showGroupDialogShortCode(shortCode);
return true;
} else if (/^[A-Za-z0-9]{3,6}\.[0-9]{4}$/g.test(input)) {
showGroupDialogShortCode(input);
return true;
} else if (
input.substring(0, 4) === 'usr_' ||
/^[A-Za-z0-9]{10}$/g.test(input)
) {
userStore.showUserDialog(input);
return true;
} else if (input.substring(0, 5) === 'avtr_') {
avatarStore.showAvatarDialog(input);
return true;
} else if (input.substring(0, 4) === 'grp_') {
groupStore.showGroupDialog(input);
return true;
}
return false;
}
function directAccessWorld(textBoxInput) {
let worldId;
let shortName;
let input = textBoxInput;
if (input.startsWith('/home/')) {
input = `https://vrchat.com${input}`;
}
if (input.length === 8) {
return verifyShortName('', input);
} else if (input.startsWith('https://vrch.at/')) {
shortName = input.substring(16, 24);
return verifyShortName('', shortName);
} else if (
input.startsWith('https://vrchat.') ||
input.startsWith('/home/')
) {
const url = new URL(input);
const urlPath = url.pathname;
const urlPathSplit = urlPath.split('/');
if (urlPathSplit.length >= 4 && urlPathSplit[2] === 'world') {
worldId = urlPathSplit[3];
worldStore.showWorldDialog(worldId);
return true;
} else if (urlPath.substring(5, 12) === '/launch') {
const urlParams = new URLSearchParams(url.search);
worldId = urlParams.get('worldId');
const instanceId = urlParams.get('instanceId');
if (instanceId) {
shortName = urlParams.get('shortName');
const location = `${worldId}:${instanceId}`;
if (shortName) {
return verifyShortName(location, shortName);
}
worldStore.showWorldDialog(location);
return true;
} else if (worldId) {
worldStore.showWorldDialog(worldId);
return true;
}
}
} else if (input.substring(0, 5) === 'wrld_') {
// a bit hacky, but supports weird malformed inputs cut out from url, why not
if (input.indexOf('&instanceId=') >= 0) {
input = `https://vrchat.com/home/launch?worldId=${input}`;
return directAccessWorld(input);
}
worldStore.showWorldDialog(input.trim());
return true;
}
return false;
}
function promptOmniDirectDialog() {
ElMessageBox.prompt(
t('prompt.direct_access_omni.description'),
t('prompt.direct_access_omni.header'),
{
distinguishCancelAndClose: true,
confirmButtonText: t('prompt.direct_access_omni.ok'),
cancelButtonText: t('prompt.direct_access_omni.cancel'),
inputPattern: /\S+/,
inputErrorMessage: t('prompt.direct_access_omni.input_error')
}
).then(({ value, action }) => {
if (action === 'confirm' && value) {
const input = value.trim();
if (!directAccessParse(input)) {
ElMessage({
message: t('prompt.direct_access_omni.message.error'),
type: 'error'
});
}
}
});
}
function showGroupDialogShortCode(shortCode) {
groupRequest.groupStrictsearch({ query: shortCode }).then((args) => {
for (const group of args.json) {
if (`${group.shortCode}.${group.discriminator}` === shortCode) {
groupStore.showGroupDialog(group.id);
break;
}
}
return args;
});
}
function verifyShortName(location, shortName) {
return instanceRequest
.getInstanceFromShortName({ shortName })
.then((args) => {
const newLocation = args.json.location;
const newShortName = args.json.shortName;
if (newShortName) {
worldStore.showWorldDialog(newLocation, newShortName);
} else if (newLocation) {
worldStore.showWorldDialog(newLocation);
} else {
worldStore.showWorldDialog(location);
}
return args;
});
}
return {
state,
searchText,
searchUserResults,
stringComparer,
quickSearchItems,
friendsListSearch,
clearSearch,
searchUserByDisplayName,
moreSearchUser,
quickSearchUserHistory,
quickSearchRemoteMethod,
quickSearchChange,
directAccessParse,
directAccessPaste,
directAccessWorld,
verifyShortName
};
});