Files
VRCX/src/stores/search.js
2026-02-08 16:01:26 +09:00

432 lines
14 KiB
JavaScript

import { computed, ref, watch } from 'vue';
import { defineStore } from 'pinia';
import { toast } from 'vue-sonner';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { compareByName, localeIncludes } from '../shared/utils';
import { instanceRequest, userRequest } from '../api';
import { groupRequest } from '../api/';
import removeConfusables, { removeWhitespace } from '../service/confusables';
import { useAppearanceSettingsStore } from './settings/appearance';
import { useAvatarStore } from './avatar';
import { useFriendStore } from './friend';
import { useGroupStore } from './group';
import { useModalStore } from './modal';
import { useUserStore } from './user';
import { useWorldStore } from './world';
import { watchState } from '../service/watchState';
export const useSearchStore = defineStore('Search', () => {
const userStore = useUserStore();
const router = useRouter();
const appearanceSettingsStore = useAppearanceSettingsStore();
const friendStore = useFriendStore();
const worldStore = useWorldStore();
const avatarStore = useAvatarStore();
const groupStore = useGroupStore();
const modalStore = useModalStore();
const { t } = useI18n();
const searchText = ref('');
const searchUserResults = ref([]);
const quickSearchItems = ref([]);
const friendsListSearch = ref('');
const directAccessPrompt = ref(null);
const stringComparer = computed(() =>
Intl.Collator(appearanceSettingsStore.appLanguage.replace('_', '-'), {
usage: 'search',
sensitivity: 'base'
})
);
watch(
() => watchState.isLoggedIn,
() => {
searchText.value = '';
searchUserResults.value = [];
},
{ flush: 'sync' }
);
function clearSearch() {
searchText.value = '';
searchUserResults.value = [];
}
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);
}
}
searchUserResults.value = Array.from(map.values());
return args;
});
}
function quickSearchRemoteMethod(query) {
if (!query) {
quickSearchItems.value = quickSearchUserHistory();
return;
}
if (query.length < 2) {
quickSearchItems.value = quickSearchUserHistory();
return;
}
const results = [];
const cleanQuery = removeWhitespace(query);
if (!cleanQuery) {
quickSearchItems.value = 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
});
quickSearchItems.value = results;
}
function quickSearchChange(value) {
if (!value) {
return;
}
if (value.startsWith('search:')) {
const searchTerm = value.slice(7);
if (quickSearchItems.value.length > 1 && searchTerm.length) {
friendsListSearch.value = searchTerm;
router.push({ name: 'friend-list' });
} else {
router.push({ name: 'search' });
searchText.value = searchTerm;
userStore.lookupUser({ displayName: searchTerm });
}
} 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;
}
async function directAccessPaste() {
let cbText = '';
if (LINUX) {
cbText = await window.electron.getClipboardText();
} else {
cbText = await AppApi.GetClipboard().catch((e) => {
console.log(e);
return '';
});
}
let trimemd = cbText.trim();
if (!directAccessParse(trimemd)) {
promptOmniDirectDialog();
} else {
toast.success(
t('prompt.direct_access_omni.message.opened_from_clipboard')
);
}
}
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_' ||
input.substring(0, 2) === 'b_'
) {
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_' ||
input.substring(0, 4) === 'wld_' ||
input.substring(0, 2) === 'o_'
) {
// 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;
}
async function promptOmniDirectDialog() {
if (directAccessPrompt.value) return;
// Element Plus: prompt(message, title, options)
directAccessPrompt.value = modalStore.prompt({
title: t('prompt.direct_access_omni.header'),
description: t('prompt.direct_access_omni.description'),
confirmText: t('prompt.direct_access_omni.ok'),
cancelText: t('prompt.direct_access_omni.cancel'),
pattern: /\S+/,
errorMessage: t('prompt.direct_access_omni.input_error')
});
try {
const { ok, value } = await directAccessPrompt.value;
if (ok && value) {
const input = value.trim();
if (!directAccessParse(input)) {
toast.error(t('prompt.direct_access_omni.message.error'));
}
}
} catch (error) {
console.log(error);
} finally {
directAccessPrompt.value = null;
}
}
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 {
searchText,
searchUserResults,
stringComparer,
quickSearchItems,
friendsListSearch,
clearSearch,
searchUserByDisplayName,
moreSearchUser,
quickSearchUserHistory,
quickSearchRemoteMethod,
quickSearchChange,
directAccessParse,
directAccessPaste,
directAccessWorld,
verifyShortName
};
});