This commit is contained in:
pa
2026-03-06 04:22:16 +09:00
parent 761ef5ad6b
commit 787f25705e
55 changed files with 6437 additions and 506 deletions

View File

@@ -257,6 +257,7 @@
useGroupStore,
useNotificationStore
} from '../../stores';
import { normalizeFavoriteGroupsChange, resolveFavoriteGroups } from './sidebarSettingsUtils';
import { useGlobalSearchStore } from '../../stores/globalSearch';
import FriendsSidebar from './components/FriendsSidebar.vue';
@@ -317,30 +318,16 @@
return keys;
});
const resolvedSidebarFavoriteGroups = computed(() => {
if (sidebarFavoriteGroups.value.length === 0) {
return allFavoriteGroupKeys.value;
}
return sidebarFavoriteGroups.value;
});
const resolvedSidebarFavoriteGroups = computed(() =>
resolveFavoriteGroups(sidebarFavoriteGroups.value, allFavoriteGroupKeys.value)
);
/**
*
* @param value
*/
function handleFavoriteGroupsChange(value) {
if (!value || value.length === 0) {
// Deselected all → reset to all (store as empty)
setSidebarFavoriteGroups([]);
return;
}
// If all groups are selected, store as empty (= all)
const allKeys = allFavoriteGroupKeys.value;
if (value.length >= allKeys.length && allKeys.every((k) => value.includes(k))) {
setSidebarFavoriteGroups([]);
return;
}
setSidebarFavoriteGroups(value);
setSidebarFavoriteGroups(normalizeFavoriteGroupsChange(value, allFavoriteGroupKeys.value));
}
const selectedFavGroupLabel = computed(() => {

View File

@@ -0,0 +1,145 @@
import { describe, expect, test } from 'vitest';
import {
buildFriendRow,
buildInstanceHeaderRow,
buildToggleRow,
estimateRowSize
} from '../friendsSidebarUtils';
// ─── buildToggleRow ──────────────────────────────────────────────────
describe('buildToggleRow', () => {
test('creates a toggle-header row with defaults', () => {
const row = buildToggleRow({ key: 'online', label: 'Online' });
expect(row).toEqual({
type: 'toggle-header',
key: 'online',
label: 'Online',
count: null,
expanded: true,
headerPadding: null,
paddingBottom: null,
onClick: null
});
});
test('accepts all optional parameters', () => {
const onClick = () => {};
const row = buildToggleRow({
key: 'vip',
label: 'VIP',
count: 5,
expanded: false,
headerPadding: 10,
paddingBottom: 8,
onClick
});
expect(row.count).toBe(5);
expect(row.expanded).toBe(false);
expect(row.headerPadding).toBe(10);
expect(row.paddingBottom).toBe(8);
expect(row.onClick).toBe(onClick);
});
test('always sets type to toggle-header', () => {
const row = buildToggleRow({ key: 'x', label: 'X' });
expect(row.type).toBe('toggle-header');
});
});
// ─── buildFriendRow ──────────────────────────────────────────────────
describe('buildFriendRow', () => {
const friend = { id: 'usr_123', displayName: 'TestUser' };
test('creates a friend-item row with defaults', () => {
const row = buildFriendRow(friend, 'friend:usr_123');
expect(row).toEqual({
type: 'friend-item',
key: 'friend:usr_123',
friend,
isGroupByInstance: undefined,
paddingBottom: undefined,
itemStyle: undefined
});
});
test('passes options through', () => {
const style = { opacity: 0.5 };
const row = buildFriendRow(friend, 'k', {
isGroupByInstance: true,
paddingBottom: 4,
itemStyle: style
});
expect(row.isGroupByInstance).toBe(true);
expect(row.paddingBottom).toBe(4);
expect(row.itemStyle).toBe(style);
});
test('always sets type to friend-item', () => {
const row = buildFriendRow(friend, 'k');
expect(row.type).toBe('friend-item');
});
});
// ─── buildInstanceHeaderRow ──────────────────────────────────────────
describe('buildInstanceHeaderRow', () => {
test('creates an instance-header row', () => {
const row = buildInstanceHeaderRow(
'wrld_123:456~private',
3,
'inst:wrld_123'
);
expect(row).toEqual({
type: 'instance-header',
key: 'inst:wrld_123',
location: 'wrld_123:456~private',
count: 3,
paddingBottom: 4
});
});
test('always has paddingBottom of 4', () => {
const row = buildInstanceHeaderRow('loc', 1, 'k');
expect(row.paddingBottom).toBe(4);
});
});
// ─── estimateRowSize ─────────────────────────────────────────────────
describe('estimateRowSize', () => {
test('returns 44 for null/undefined', () => {
expect(estimateRowSize(null)).toBe(44);
expect(estimateRowSize(undefined)).toBe(44);
});
test('returns 28 + paddingBottom for toggle-header', () => {
expect(estimateRowSize({ type: 'toggle-header' })).toBe(28);
expect(
estimateRowSize({ type: 'toggle-header', paddingBottom: 8 })
).toBe(36);
});
test('returns 24 + paddingBottom for vip-subheader', () => {
expect(estimateRowSize({ type: 'vip-subheader' })).toBe(24);
expect(
estimateRowSize({ type: 'vip-subheader', paddingBottom: 4 })
).toBe(28);
});
test('returns 26 + paddingBottom for instance-header', () => {
expect(estimateRowSize({ type: 'instance-header' })).toBe(26);
expect(
estimateRowSize({ type: 'instance-header', paddingBottom: 4 })
).toBe(30);
});
test('returns 52 + paddingBottom for any other type (friend-item)', () => {
expect(estimateRowSize({ type: 'friend-item' })).toBe(52);
expect(estimateRowSize({ type: 'friend-item', paddingBottom: 6 })).toBe(
58
);
});
});

View File

@@ -0,0 +1,159 @@
import { describe, expect, test } from 'vitest';
import {
buildGroupHeaderRow,
buildGroupItemRow,
estimateGroupRowSize,
getGroupId
} from '../groupsSidebarUtils';
// ─── getGroupId ──────────────────────────────────────────────────────
describe('getGroupId', () => {
test('extracts groupId from first element', () => {
const group = [{ group: { groupId: 'grp_abc' } }];
expect(getGroupId(group)).toBe('grp_abc');
});
test('returns empty string for empty array', () => {
expect(getGroupId([])).toBe('');
});
test('returns empty string when group property is missing', () => {
expect(getGroupId([{}])).toBe('');
expect(getGroupId([{ group: {} }])).toBe('');
});
});
// ─── buildGroupHeaderRow ─────────────────────────────────────────────
describe('buildGroupHeaderRow', () => {
const group = [
{ group: { groupId: 'grp_1', name: 'Test Group' } },
{ group: { groupId: 'grp_1', name: 'Test Group' } }
];
test('builds header row with correct properties', () => {
const cfg = { grp_1: { isCollapsed: false } };
const row = buildGroupHeaderRow(group, 0, cfg);
expect(row).toEqual({
type: 'group-header',
key: 'group-header:grp_1',
groupId: 'grp_1',
label: 'Test Group',
count: 2,
isCollapsed: false,
headerPaddingTop: '0px'
});
});
test('sets headerPaddingTop to 10px for non-first groups', () => {
const cfg = {};
const row = buildGroupHeaderRow(group, 1, cfg);
expect(row.headerPaddingTop).toBe('10px');
});
test('reflects collapsed state from config', () => {
const cfg = { grp_1: { isCollapsed: true } };
const row = buildGroupHeaderRow(group, 0, cfg);
expect(row.isCollapsed).toBe(true);
});
test('defaults to not collapsed when cfg entry is missing', () => {
const cfg = {};
const row = buildGroupHeaderRow(group, 0, cfg);
expect(row.isCollapsed).toBe(false);
});
});
// ─── buildGroupItemRow ───────────────────────────────────────────────
describe('buildGroupItemRow', () => {
const ref = {
group: { iconUrl: 'https://example.com/icon.png', name: 'My Group' },
instance: {
id: 'inst_123',
ownerId: 'usr_456',
userCount: 5,
capacity: 16,
location: 'wrld_abc:inst_123~private'
}
};
test('builds item row with correct properties', () => {
const row = buildGroupItemRow(ref, 0, 'grp_1', true);
expect(row).toEqual({
type: 'group-item',
key: 'group-item:grp_1:inst_123',
ownerId: 'usr_456',
iconUrl: 'https://example.com/icon.png',
name: 'My Group',
userCount: 5,
capacity: 16,
location: 'wrld_abc:inst_123~private',
isVisible: true
});
});
test('uses index as fallback key when instance id is missing', () => {
const row = buildGroupItemRow({}, 7, 'grp_1', true);
expect(row.key).toBe('group-item:grp_1:7');
});
test('defaults to empty/zero values for missing properties', () => {
const row = buildGroupItemRow({}, 0, 'grp_1', true);
expect(row.ownerId).toBe('');
expect(row.iconUrl).toBe('');
expect(row.name).toBe('');
expect(row.userCount).toBe(0);
expect(row.capacity).toBe(0);
expect(row.location).toBe('');
});
test('hides age-gated instances when isAgeGatedVisible is false', () => {
const ageGatedRef = { ...ref, ageGate: true };
const row = buildGroupItemRow(ageGatedRef, 0, 'grp_1', false);
expect(row.isVisible).toBe(false);
});
test('shows age-gated instances when isAgeGatedVisible is true', () => {
const ageGatedRef = { ...ref, ageGate: true };
const row = buildGroupItemRow(ageGatedRef, 0, 'grp_1', true);
expect(row.isVisible).toBe(true);
});
test('detects age gate from location string', () => {
const refWithAgeGateLocation = {
...ref,
location: 'wrld_abc:inst_123~ageGate'
};
const row = buildGroupItemRow(
refWithAgeGateLocation,
0,
'grp_1',
false
);
expect(row.isVisible).toBe(false);
});
});
// ─── estimateGroupRowSize ────────────────────────────────────────────
describe('estimateGroupRowSize', () => {
test('returns 44 for null/undefined', () => {
expect(estimateGroupRowSize(null)).toBe(44);
expect(estimateGroupRowSize(undefined)).toBe(44);
});
test('returns 30 for group-header', () => {
expect(estimateGroupRowSize({ type: 'group-header' })).toBe(30);
});
test('returns 52 for group-item', () => {
expect(estimateGroupRowSize({ type: 'group-item' })).toBe(52);
});
test('returns 52 for unknown type', () => {
expect(estimateGroupRowSize({ type: 'unknown' })).toBe(52);
});
});

View File

@@ -0,0 +1,69 @@
import { describe, expect, test } from 'vitest';
import {
normalizeFavoriteGroupsChange,
resolveFavoriteGroups
} from '../sidebarSettingsUtils';
// ─── resolveFavoriteGroups ───────────────────────────────────────────
describe('resolveFavoriteGroups', () => {
const allKeys = ['group_1', 'group_2', 'local:MyGroup'];
test('returns allKeys when stored is empty (= all)', () => {
expect(resolveFavoriteGroups([], allKeys)).toEqual(allKeys);
});
test('returns stored value when not empty', () => {
const stored = ['group_1'];
expect(resolveFavoriteGroups(stored, allKeys)).toEqual(stored);
});
test('returns stored even if it equals allKeys', () => {
expect(resolveFavoriteGroups([...allKeys], allKeys)).toEqual(allKeys);
});
test('handles empty allKeys', () => {
expect(resolveFavoriteGroups([], [])).toEqual([]);
});
});
// ─── normalizeFavoriteGroupsChange ───────────────────────────────────
describe('normalizeFavoriteGroupsChange', () => {
const allKeys = ['group_1', 'group_2', 'local:MyGroup'];
test('returns [] when value is null', () => {
expect(normalizeFavoriteGroupsChange(null, allKeys)).toEqual([]);
});
test('returns [] when value is empty array', () => {
expect(normalizeFavoriteGroupsChange([], allKeys)).toEqual([]);
});
test('returns [] when all groups are selected', () => {
expect(normalizeFavoriteGroupsChange([...allKeys], allKeys)).toEqual(
[]
);
});
test('returns [] when value is superset of allKeys', () => {
expect(
normalizeFavoriteGroupsChange([...allKeys, 'extra'], allKeys)
).toEqual([]);
});
test('returns filter subset when not all selected', () => {
const subset = ['group_1'];
expect(normalizeFavoriteGroupsChange(subset, allKeys)).toEqual(subset);
});
test('returns filter subset with two items', () => {
const subset = ['group_1', 'group_2'];
expect(normalizeFavoriteGroupsChange(subset, allKeys)).toEqual(subset);
});
test('treats non-empty value as all-selected when allKeys is empty (vacuous truth)', () => {
expect(normalizeFavoriteGroupsChange(['group_1'], [])).toEqual([]);
});
});

View File

@@ -152,6 +152,7 @@
useLocationStore,
useUserStore
} from '../../../stores';
import { buildFriendRow, buildInstanceHeaderRow, buildToggleRow, estimateRowSize } from '../friendsSidebarUtils';
import { getFriendsSortFunction, isRealInstance, userImage, userStatusClass } from '../../../shared/utils';
import { getFriendsLocations } from '../../../shared/utils/location.js';
import { userRequest } from '../../../api';
@@ -214,6 +215,10 @@
const shouldHideSameInstance = computed(() => isSidebarGroupByInstance.value && isHideFriendsInSameInstance.value);
/**
*
* @param list
*/
function excludeSameInstance(list) {
if (!shouldHideSameInstance.value) {
return list;
@@ -323,41 +328,6 @@
});
});
const buildToggleRow = ({
key,
label,
count = null,
expanded = true,
headerPadding = null,
paddingBottom = null,
onClick = null
}) => ({
type: 'toggle-header',
key,
label,
count,
expanded,
headerPadding,
paddingBottom,
onClick
});
const buildFriendRow = (friend, key, options = {}) => ({
type: 'friend-item',
key,
friend,
isGroupByInstance: options.isGroupByInstance,
paddingBottom: options.paddingBottom,
itemStyle: options.itemStyle
});
const buildInstanceHeaderRow = (location, count, key) => ({
type: 'instance-header',
key,
location,
count,
paddingBottom: 4
});
const virtualRows = computed(() => {
const rows = [];
@@ -521,22 +491,6 @@
return rows;
});
const estimateRowSize = (row) => {
if (!row) {
return 44;
}
if (row.type === 'toggle-header') {
return 28 + (row.paddingBottom || 0);
}
if (row.type === 'vip-subheader') {
return 24 + (row.paddingBottom || 0);
}
if (row.type === 'instance-header') {
return 26 + (row.paddingBottom || 0);
}
return 52 + (row.paddingBottom || 0);
};
const virtualizer = useVirtualizer(
computed(() => ({
count: virtualRows.value.length,
@@ -568,6 +522,9 @@
};
};
/**
*
*/
function saveFriendsGroupStates() {
configRepository.setBool('VRCX_isFriendsGroupMe', isFriendsGroupMe.value);
configRepository.setBool('VRCX_isFriendsGroupFavorites', isVIPFriends.value);
@@ -576,6 +533,9 @@
configRepository.setBool('VRCX_isFriendsGroupOffline', isOfflineFriends.value);
}
/**
*
*/
async function loadFriendsGroupStates() {
isFriendsGroupMe.value = await configRepository.getBool('VRCX_isFriendsGroupMe', true);
isVIPFriends.value = await configRepository.getBool('VRCX_isFriendsGroupFavorites', true);
@@ -588,31 +548,49 @@
);
}
/**
*
*/
function toggleSwitchGroupByInstanceCollapsed() {
isSidebarGroupByInstanceCollapsed.value = !isSidebarGroupByInstanceCollapsed.value;
configRepository.setBool('VRCX_sidebarGroupByInstanceCollapsed', isSidebarGroupByInstanceCollapsed.value);
}
/**
*
*/
function toggleFriendsGroupMe() {
isFriendsGroupMe.value = !isFriendsGroupMe.value;
saveFriendsGroupStates();
}
/**
*
*/
function toggleVIPFriends() {
isVIPFriends.value = !isVIPFriends.value;
saveFriendsGroupStates();
}
/**
*
*/
function toggleOnlineFriends() {
isOnlineFriends.value = !isOnlineFriends.value;
saveFriendsGroupStates();
}
/**
*
*/
function toggleActiveFriends() {
isActiveFriends.value = !isActiveFriends.value;
saveFriendsGroupStates();
}
/**
*
*/
function toggleOfflineFriends() {
isOfflineFriends.value = !isOfflineFriends.value;
saveFriendsGroupStates();
@@ -660,12 +638,20 @@
return history.slice(0, 10);
});
/**
*
* @param value
*/
function changeStatus(value) {
userRequest.saveCurrentUser({ status: value }).then(() => {
toast.success('Status updated');
});
}
/**
*
* @param status
*/
function setStatusFromHistory(status) {
userRequest.saveCurrentUser({ statusDescription: status }).then(() => {
toast.success('Status updated');

View File

@@ -62,6 +62,7 @@
import { storeToRefs } from 'pinia';
import { useVirtualizer } from '@tanstack/vue-virtual';
import { buildGroupHeaderRow, buildGroupItemRow, estimateGroupRowSize, getGroupId } from '../groupsSidebarUtils';
import { useAppearanceSettingsStore, useGroupStore } from '../../../stores';
import { convertFileUrlToImageUrl } from '../../../shared/utils';
@@ -98,50 +99,27 @@
return Array.from(groupMap.values()).sort(sortGroupInstancesByInGame);
});
const buildGroupHeaderRow = (group, index) => ({
type: 'group-header',
key: `group-header:${getGroupId(group)}`,
groupId: getGroupId(group),
label: group[0]?.group?.name ?? '',
count: group.length,
isCollapsed: Boolean(groupInstancesCfg.value[getGroupId(group)]?.isCollapsed),
headerPaddingTop: index === 0 ? '0px' : '10px'
});
const buildGroupHeaderRowLocal = (group, index) => buildGroupHeaderRow(group, index, groupInstancesCfg.value);
const buildGroupItemRow = (ref, index, groupId) => ({
type: 'group-item',
key: `group-item:${groupId}:${ref?.instance?.id ?? index}`,
ownerId: ref?.instance?.ownerId ?? '',
iconUrl: ref?.group?.iconUrl ?? '',
name: ref?.group?.name ?? '',
userCount: ref?.instance?.userCount ?? 0,
capacity: ref?.instance?.capacity ?? 0,
location: ref?.instance?.location ?? '',
isVisible: Boolean(isAgeGatedInstancesVisible.value || !(ref?.ageGate || ref?.location?.includes('~ageGate')))
});
const buildGroupItemRowLocal = (ref, index, groupId) =>
buildGroupItemRow(ref, index, groupId, isAgeGatedInstancesVisible.value);
const virtualRows = computed(() => {
const rows = [];
groupedGroupInstances.value.forEach((group, index) => {
if (!group?.length) return;
const groupId = getGroupId(group);
rows.push(buildGroupHeaderRow(group, index));
rows.push(buildGroupHeaderRowLocal(group, index));
if (!groupInstancesCfg.value[groupId]?.isCollapsed) {
group.forEach((ref, idx) => {
rows.push(buildGroupItemRow(ref, idx, groupId));
rows.push(buildGroupItemRowLocal(ref, idx, groupId));
});
}
});
return rows;
});
const estimateRowSize = (row) => {
if (!row) return 44;
if (row.type === 'group-header') {
return 30;
}
return 52;
};
const estimateRowSize = (row) => estimateGroupRowSize(row);
const virtualizer = useVirtualizer(
computed(() => ({
@@ -170,18 +148,22 @@
transform: `translateY(${item.virtualItem.start}px)`
});
/**
*
* @param url
*/
function getSmallGroupIconUrl(url) {
return convertFileUrlToImageUrl(url);
}
/**
*
* @param groupId
*/
function toggleGroupSidebarCollapse(groupId) {
groupInstancesCfg.value[groupId].isCollapsed = !groupInstancesCfg.value[groupId].isCollapsed;
}
function getGroupId(group) {
return group[0]?.group?.groupId || '';
}
onMounted(() => {
nextTick(() => {
virtualizer.value?.measure?.();

View File

@@ -0,0 +1,88 @@
/**
* @param {object} opts
* @param {string} opts.key - Unique key
* @param {string} opts.label - Display label
* @param {number|null} [opts.count] - Item count
* @param {boolean} [opts.expanded] - Whether section is expanded
* @param {number|null} [opts.headerPadding] - Top padding in px
* @param {number|null} [opts.paddingBottom] - Bottom padding in px
* @param {Function|null} [opts.onClick] - Click handler
* @returns {object} Row object
*/
export function buildToggleRow({
key,
label,
count = null,
expanded = true,
headerPadding = null,
paddingBottom = null,
onClick = null
}) {
return {
type: 'toggle-header',
key,
label,
count,
expanded,
headerPadding,
paddingBottom,
onClick
};
}
/**
* @param {object} friend - Friend data object
* @param {string} key - Unique key
* @param {object} [options] - Additional options
* @param {boolean} [options.isGroupByInstance] - Whether grouped by instance
* @param {number} [options.paddingBottom] - Bottom padding
* @param {object} [options.itemStyle] - Additional style
* @returns {object} Row object
*/
export function buildFriendRow(friend, key, options = {}) {
return {
type: 'friend-item',
key,
friend,
isGroupByInstance: options.isGroupByInstance,
paddingBottom: options.paddingBottom,
itemStyle: options.itemStyle
};
}
/**
* @param {string} location - Instance location string
* @param {number} count - Number of friends in instance
* @param {string} key - Unique key
* @returns {object} Row object
*/
export function buildInstanceHeaderRow(location, count, key) {
return {
type: 'instance-header',
key,
location,
count,
paddingBottom: 4
};
}
/**
* Estimate pixel height for a virtual row.
* @param {object} row - Row object with type property
* @returns {number} Estimated height in pixels
*/
export function estimateRowSize(row) {
if (!row) {
return 44;
}
if (row.type === 'toggle-header') {
return 28 + (row.paddingBottom || 0);
}
if (row.type === 'vip-subheader') {
return 24 + (row.paddingBottom || 0);
}
if (row.type === 'instance-header') {
return 26 + (row.paddingBottom || 0);
}
return 52 + (row.paddingBottom || 0);
}

View File

@@ -0,0 +1,62 @@
/**
* @param {Array} group - Array of group instance refs
* @returns {string} The groupId, or empty string
*/
export function getGroupId(group) {
return group[0]?.group?.groupId || '';
}
/**
* @param {Array} group - Array of group instance refs
* @param {number} index - Index of the group in the list
* @param {object} cfg - Collapsed state config object
* @returns {object} Row object
*/
export function buildGroupHeaderRow(group, index, cfg) {
const groupId = getGroupId(group);
return {
type: 'group-header',
key: `group-header:${groupId}`,
groupId,
label: group[0]?.group?.name ?? '',
count: group.length,
isCollapsed: Boolean(cfg[groupId]?.isCollapsed),
headerPaddingTop: index === 0 ? '0px' : '10px'
};
}
/**
* @param {object} ref - Group instance ref object
* @param {number} index - Index within the group
* @param {string} groupId - Parent group ID
* @param {boolean} isAgeGatedVisible - Whether age-gated instances should be visible
* @returns {object} Row object
*/
export function buildGroupItemRow(ref, index, groupId, isAgeGatedVisible) {
return {
type: 'group-item',
key: `group-item:${groupId}:${ref?.instance?.id ?? index}`,
ownerId: ref?.instance?.ownerId ?? '',
iconUrl: ref?.group?.iconUrl ?? '',
name: ref?.group?.name ?? '',
userCount: ref?.instance?.userCount ?? 0,
capacity: ref?.instance?.capacity ?? 0,
location: ref?.instance?.location ?? '',
isVisible: Boolean(
isAgeGatedVisible ||
!(ref?.ageGate || ref?.location?.includes('~ageGate'))
)
};
}
/**
* @param {object} row - Row object with type property
* @returns {number} Estimated height in pixels
*/
export function estimateGroupRowSize(row) {
if (!row) return 44;
if (row.type === 'group-header') {
return 30;
}
return 52;
}

View File

@@ -0,0 +1,29 @@
/**
* @param {string[]} stored - Stored favorite groups selection
* @param {string[]} allKeys - All available group keys
* @returns {string[]} Resolved group keys
*/
export function resolveFavoriteGroups(stored, allKeys) {
if (stored.length === 0) {
return allKeys;
}
return stored;
}
/**
* @param {string[]|null} value - New selection value
* @param {string[]} allKeys - All available group keys
* @returns {string[]} Value to store
*/
export function normalizeFavoriteGroupsChange(value, allKeys) {
if (!value || value.length === 0) {
return [];
}
if (
value.length >= allKeys.length &&
allKeys.every((k) => value.includes(k))
) {
return [];
}
return value;
}