mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-30 04:03:48 +02:00
split some store func and add test
This commit is contained in:
73
src/shared/__tests__/notificationCategory.test.js
Normal file
73
src/shared/__tests__/notificationCategory.test.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
FRIEND_TYPES,
|
||||
GROUP_EXACT_TYPES,
|
||||
getNotificationCategory,
|
||||
getNotificationTs
|
||||
} from '../notificationCategory';
|
||||
|
||||
describe('getNotificationCategory', () => {
|
||||
test('returns "other" for falsy type', () => {
|
||||
expect(getNotificationCategory('')).toBe('other');
|
||||
expect(getNotificationCategory(null)).toBe('other');
|
||||
expect(getNotificationCategory(undefined)).toBe('other');
|
||||
});
|
||||
|
||||
test('returns "friend" for friend types', () => {
|
||||
for (const type of FRIEND_TYPES) {
|
||||
expect(getNotificationCategory(type)).toBe('friend');
|
||||
}
|
||||
});
|
||||
|
||||
test('returns "group" for group exact types', () => {
|
||||
for (const type of GROUP_EXACT_TYPES) {
|
||||
expect(getNotificationCategory(type)).toBe('group');
|
||||
}
|
||||
});
|
||||
|
||||
test('returns "group" for group prefix types', () => {
|
||||
expect(getNotificationCategory('group.announcement')).toBe('group');
|
||||
expect(getNotificationCategory('group.invite')).toBe('group');
|
||||
expect(getNotificationCategory('group.transfer')).toBe('group');
|
||||
expect(getNotificationCategory('moderation.warning')).toBe('group');
|
||||
});
|
||||
|
||||
test('returns "other" for unknown types', () => {
|
||||
expect(getNotificationCategory('OnPlayerJoined')).toBe('other');
|
||||
expect(getNotificationCategory('GPS')).toBe('other');
|
||||
expect(getNotificationCategory('VideoPlay')).toBe('other');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNotificationTs', () => {
|
||||
test('returns millisecond timestamp from created_at string', () => {
|
||||
const ts = getNotificationTs({
|
||||
created_at: '2024-01-01T00:00:00.000Z'
|
||||
});
|
||||
expect(ts).toBe(new Date('2024-01-01T00:00:00.000Z').getTime());
|
||||
});
|
||||
|
||||
test('returns millisecond timestamp from createdAt string', () => {
|
||||
const ts = getNotificationTs({
|
||||
createdAt: '2024-06-15T12:00:00.000Z'
|
||||
});
|
||||
expect(ts).toBe(new Date('2024-06-15T12:00:00.000Z').getTime());
|
||||
});
|
||||
|
||||
test('prefers created_at over createdAt', () => {
|
||||
const ts = getNotificationTs({
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
createdAt: '2025-01-01T00:00:00.000Z'
|
||||
});
|
||||
expect(ts).toBe(new Date('2024-01-01T00:00:00.000Z').getTime());
|
||||
});
|
||||
|
||||
test('handles numeric millisecond timestamp', () => {
|
||||
const ms = 1700000000000;
|
||||
expect(getNotificationTs({ created_at: ms })).toBe(ms);
|
||||
});
|
||||
|
||||
test('converts seconds to milliseconds', () => {
|
||||
const sec = 1700000000;
|
||||
expect(getNotificationTs({ created_at: sec })).toBe(sec * 1000);
|
||||
});
|
||||
});
|
||||
346
src/shared/__tests__/notificationMessage.test.js
Normal file
346
src/shared/__tests__/notificationMessage.test.js
Normal file
@@ -0,0 +1,346 @@
|
||||
import {
|
||||
getNotificationMessage,
|
||||
toNotificationText
|
||||
} from '../notificationMessage';
|
||||
|
||||
// Mock displayLocation to return a predictable string
|
||||
vi.mock('../utils', () => ({
|
||||
displayLocation: (location, worldName, groupName) => {
|
||||
let text = worldName || location;
|
||||
if (groupName) text += ` (${groupName})`;
|
||||
return text;
|
||||
}
|
||||
}));
|
||||
|
||||
describe('getNotificationMessage', () => {
|
||||
test('returns null for unknown type', () => {
|
||||
expect(getNotificationMessage({ type: 'unknown' }, '')).toBeNull();
|
||||
});
|
||||
|
||||
test('OnPlayerJoined', () => {
|
||||
const result = getNotificationMessage(
|
||||
{ type: 'OnPlayerJoined', displayName: 'Alice' },
|
||||
''
|
||||
);
|
||||
expect(result).toEqual({ title: 'Alice', body: 'has joined' });
|
||||
});
|
||||
|
||||
test('OnPlayerLeft', () => {
|
||||
const result = getNotificationMessage(
|
||||
{ type: 'OnPlayerLeft', displayName: 'Bob' },
|
||||
''
|
||||
);
|
||||
expect(result).toEqual({ title: 'Bob', body: 'has left' });
|
||||
});
|
||||
|
||||
test('OnPlayerJoining', () => {
|
||||
const result = getNotificationMessage(
|
||||
{ type: 'OnPlayerJoining', displayName: 'Alice' },
|
||||
''
|
||||
);
|
||||
expect(result).toEqual({ title: 'Alice', body: 'is joining' });
|
||||
});
|
||||
|
||||
test('GPS', () => {
|
||||
const result = getNotificationMessage(
|
||||
{
|
||||
type: 'GPS',
|
||||
displayName: 'Alice',
|
||||
location: 'wrld_123',
|
||||
worldName: 'TestWorld',
|
||||
groupName: ''
|
||||
},
|
||||
''
|
||||
);
|
||||
expect(result.title).toBe('Alice');
|
||||
expect(result.body).toContain('is in');
|
||||
expect(result.body).toContain('TestWorld');
|
||||
});
|
||||
|
||||
test('Online with worldName', () => {
|
||||
const result = getNotificationMessage(
|
||||
{
|
||||
type: 'Online',
|
||||
displayName: 'Alice',
|
||||
worldName: 'Lobby',
|
||||
location: 'wrld_456',
|
||||
groupName: ''
|
||||
},
|
||||
''
|
||||
);
|
||||
expect(result.title).toBe('Alice');
|
||||
expect(result.body).toContain('has logged in');
|
||||
expect(result.body).toContain('Lobby');
|
||||
});
|
||||
|
||||
test('Online without worldName', () => {
|
||||
const result = getNotificationMessage(
|
||||
{ type: 'Online', displayName: 'Alice', worldName: '' },
|
||||
''
|
||||
);
|
||||
expect(result).toEqual({
|
||||
title: 'Alice',
|
||||
body: 'has logged in'
|
||||
});
|
||||
});
|
||||
|
||||
test('Offline', () => {
|
||||
const result = getNotificationMessage(
|
||||
{ type: 'Offline', displayName: 'Alice' },
|
||||
''
|
||||
);
|
||||
expect(result).toEqual({ title: 'Alice', body: 'has logged out' });
|
||||
});
|
||||
|
||||
test('Status', () => {
|
||||
const result = getNotificationMessage(
|
||||
{
|
||||
type: 'Status',
|
||||
displayName: 'Alice',
|
||||
status: 'busy',
|
||||
statusDescription: 'working'
|
||||
},
|
||||
''
|
||||
);
|
||||
expect(result).toEqual({
|
||||
title: 'Alice',
|
||||
body: 'status is now busy working'
|
||||
});
|
||||
});
|
||||
|
||||
test('invite', () => {
|
||||
const result = getNotificationMessage(
|
||||
{
|
||||
type: 'invite',
|
||||
senderUsername: 'Bob',
|
||||
details: { worldId: 'wrld_1', worldName: 'Hub' }
|
||||
},
|
||||
' (msg)'
|
||||
);
|
||||
expect(result.title).toBe('Bob');
|
||||
expect(result.body).toContain('has invited you to');
|
||||
expect(result.body).toContain('Hub');
|
||||
expect(result.body).toContain('(msg)');
|
||||
});
|
||||
|
||||
test('requestInvite', () => {
|
||||
const result = getNotificationMessage(
|
||||
{ type: 'requestInvite', senderUsername: 'Bob' },
|
||||
' hey'
|
||||
);
|
||||
expect(result).toEqual({
|
||||
title: 'Bob',
|
||||
body: 'has requested an invite hey'
|
||||
});
|
||||
});
|
||||
|
||||
test('friendRequest', () => {
|
||||
const result = getNotificationMessage(
|
||||
{ type: 'friendRequest', senderUsername: 'Charlie' },
|
||||
''
|
||||
);
|
||||
expect(result).toEqual({
|
||||
title: 'Charlie',
|
||||
body: 'has sent you a friend request'
|
||||
});
|
||||
});
|
||||
|
||||
test('Friend', () => {
|
||||
const result = getNotificationMessage(
|
||||
{ type: 'Friend', displayName: 'Dave' },
|
||||
''
|
||||
);
|
||||
expect(result).toEqual({
|
||||
title: 'Dave',
|
||||
body: 'is now your friend'
|
||||
});
|
||||
});
|
||||
|
||||
test('DisplayName', () => {
|
||||
const result = getNotificationMessage(
|
||||
{
|
||||
type: 'DisplayName',
|
||||
previousDisplayName: 'OldName',
|
||||
displayName: 'NewName'
|
||||
},
|
||||
''
|
||||
);
|
||||
expect(result).toEqual({
|
||||
title: 'OldName',
|
||||
body: 'changed their name to NewName'
|
||||
});
|
||||
});
|
||||
|
||||
test('boop', () => {
|
||||
const result = getNotificationMessage(
|
||||
{ type: 'boop', senderUsername: 'Eve', message: 'boop!' },
|
||||
''
|
||||
);
|
||||
expect(result).toEqual({ title: 'Eve', body: 'boop!' });
|
||||
});
|
||||
|
||||
test('groupChange', () => {
|
||||
const result = getNotificationMessage(
|
||||
{ type: 'groupChange', senderUsername: 'Mod', message: 'rank up' },
|
||||
''
|
||||
);
|
||||
expect(result).toEqual({ title: 'Mod', body: 'rank up' });
|
||||
});
|
||||
|
||||
test('group.announcement', () => {
|
||||
const result = getNotificationMessage(
|
||||
{ type: 'group.announcement', message: 'Hello all' },
|
||||
''
|
||||
);
|
||||
expect(result).toEqual({
|
||||
title: 'Group Announcement',
|
||||
body: 'Hello all'
|
||||
});
|
||||
});
|
||||
|
||||
test('PortalSpawn with displayName', () => {
|
||||
const result = getNotificationMessage(
|
||||
{
|
||||
type: 'PortalSpawn',
|
||||
displayName: 'Alice',
|
||||
instanceId: 'inst_1',
|
||||
worldName: 'Room',
|
||||
groupName: ''
|
||||
},
|
||||
''
|
||||
);
|
||||
expect(result.title).toBe('Alice');
|
||||
expect(result.body).toContain('has spawned a portal to');
|
||||
});
|
||||
|
||||
test('PortalSpawn without displayName', () => {
|
||||
const result = getNotificationMessage(
|
||||
{ type: 'PortalSpawn', displayName: '' },
|
||||
''
|
||||
);
|
||||
expect(result).toEqual({
|
||||
title: '',
|
||||
body: 'User has spawned a portal'
|
||||
});
|
||||
});
|
||||
|
||||
test('VideoPlay', () => {
|
||||
const result = getNotificationMessage(
|
||||
{ type: 'VideoPlay', notyName: 'Cool Song' },
|
||||
''
|
||||
);
|
||||
expect(result).toEqual({
|
||||
title: 'Now playing',
|
||||
body: 'Cool Song'
|
||||
});
|
||||
});
|
||||
|
||||
test('BlockedOnPlayerJoined', () => {
|
||||
const result = getNotificationMessage(
|
||||
{ type: 'BlockedOnPlayerJoined', displayName: 'Troll' },
|
||||
''
|
||||
);
|
||||
expect(result).toEqual({
|
||||
title: 'Troll',
|
||||
body: 'Blocked user has joined'
|
||||
});
|
||||
});
|
||||
|
||||
test('Event', () => {
|
||||
const result = getNotificationMessage(
|
||||
{ type: 'Event', data: 'something happened' },
|
||||
''
|
||||
);
|
||||
expect(result).toEqual({
|
||||
title: 'Event',
|
||||
body: 'something happened'
|
||||
});
|
||||
});
|
||||
|
||||
test('External', () => {
|
||||
const result = getNotificationMessage(
|
||||
{ type: 'External', message: 'ext msg' },
|
||||
''
|
||||
);
|
||||
expect(result).toEqual({ title: 'External', body: 'ext msg' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('toNotificationText', () => {
|
||||
test('body-only types return just the body', () => {
|
||||
expect(toNotificationText('Eve', 'boop!', 'boop')).toBe('boop!');
|
||||
expect(
|
||||
toNotificationText(
|
||||
'Group Announcement',
|
||||
'Hello',
|
||||
'group.announcement'
|
||||
)
|
||||
).toBe('Hello');
|
||||
expect(toNotificationText('Event', 'data', 'Event')).toBe('data');
|
||||
expect(toNotificationText('External', 'msg', 'External')).toBe('msg');
|
||||
expect(
|
||||
toNotificationText('Instance Closed', 'closing', 'instance.closed')
|
||||
).toBe('closing');
|
||||
});
|
||||
|
||||
test('colon separator types use ": "', () => {
|
||||
expect(toNotificationText('Mod', 'rank up', 'groupChange')).toBe(
|
||||
'Mod: rank up'
|
||||
);
|
||||
expect(toNotificationText('Now playing', 'Song', 'VideoPlay')).toBe(
|
||||
'Now playing: Song'
|
||||
);
|
||||
});
|
||||
|
||||
test('colon separator with empty title returns body only', () => {
|
||||
expect(toNotificationText('', 'rank up', 'groupChange')).toBe(
|
||||
'rank up'
|
||||
);
|
||||
});
|
||||
|
||||
test('custom format messages for blocked/muted', () => {
|
||||
expect(
|
||||
toNotificationText(
|
||||
'Troll',
|
||||
'blocked user has joined',
|
||||
'BlockedOnPlayerJoined'
|
||||
)
|
||||
).toBe('Blocked user Troll has joined');
|
||||
expect(
|
||||
toNotificationText(
|
||||
'Troll',
|
||||
'blocked user has left',
|
||||
'BlockedOnPlayerLeft'
|
||||
)
|
||||
).toBe('Blocked user Troll has left');
|
||||
expect(
|
||||
toNotificationText(
|
||||
'Troll',
|
||||
'muted user has joined',
|
||||
'MutedOnPlayerJoined'
|
||||
)
|
||||
).toBe('Muted user Troll has joined');
|
||||
expect(
|
||||
toNotificationText(
|
||||
'Troll',
|
||||
'muted user has left',
|
||||
'MutedOnPlayerLeft'
|
||||
)
|
||||
).toBe('Muted user Troll has left');
|
||||
});
|
||||
|
||||
test('default types use space separator', () => {
|
||||
expect(
|
||||
toNotificationText('Alice', 'has joined', 'OnPlayerJoined')
|
||||
).toBe('Alice has joined');
|
||||
expect(toNotificationText('Bob', 'has logged out', 'Offline')).toBe(
|
||||
'Bob has logged out'
|
||||
);
|
||||
});
|
||||
|
||||
test('default with empty title returns body only', () => {
|
||||
expect(
|
||||
toNotificationText('', 'User has spawned a portal', 'PortalSpawn')
|
||||
).toBe('User has spawned a portal');
|
||||
});
|
||||
});
|
||||
86
src/shared/__tests__/resolveRef.test.js
Normal file
86
src/shared/__tests__/resolveRef.test.js
Normal file
@@ -0,0 +1,86 @@
|
||||
import { resolveRef } from '../resolveRef';
|
||||
|
||||
describe('resolveRef', () => {
|
||||
const emptyDefault = { id: '', name: '' };
|
||||
const opts = {
|
||||
emptyDefault,
|
||||
idAlias: 'worldId',
|
||||
nameKey: 'name',
|
||||
fetchFn: vi.fn()
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
opts.fetchFn.mockReset();
|
||||
});
|
||||
|
||||
test('returns emptyDefault for null input', async () => {
|
||||
expect(await resolveRef(null, opts)).toEqual(emptyDefault);
|
||||
});
|
||||
|
||||
test('returns emptyDefault for undefined input', async () => {
|
||||
expect(await resolveRef(undefined, opts)).toEqual(emptyDefault);
|
||||
});
|
||||
|
||||
test('normalises string input to object', async () => {
|
||||
const result = await resolveRef('wrld_123', {
|
||||
...opts,
|
||||
fetchFn: vi.fn().mockResolvedValue({
|
||||
ref: { name: 'MyWorld', extra: true }
|
||||
})
|
||||
});
|
||||
expect(result.id).toBe('wrld_123');
|
||||
expect(result.name).toBe('MyWorld');
|
||||
});
|
||||
|
||||
test('uses idAlias when id is missing', async () => {
|
||||
const result = await resolveRef(
|
||||
{ worldId: 'wrld_456', name: 'World' },
|
||||
opts
|
||||
);
|
||||
expect(result.id).toBe('wrld_456');
|
||||
expect(result.name).toBe('World');
|
||||
expect(opts.fetchFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('fetches when name is empty', async () => {
|
||||
opts.fetchFn.mockResolvedValue({
|
||||
ref: { name: 'Fetched', data: 42 }
|
||||
});
|
||||
const result = await resolveRef({ id: 'wrld_789', name: '' }, opts);
|
||||
expect(opts.fetchFn).toHaveBeenCalledWith('wrld_789');
|
||||
expect(result.id).toBe('wrld_789');
|
||||
expect(result.name).toBe('Fetched');
|
||||
expect(result.data).toBe(42);
|
||||
});
|
||||
|
||||
test('returns input with id when fetch fails', async () => {
|
||||
opts.fetchFn.mockRejectedValue(new Error('network'));
|
||||
const result = await resolveRef({ id: 'wrld_111', name: '' }, opts);
|
||||
expect(result.id).toBe('wrld_111');
|
||||
expect(result.name).toBe('');
|
||||
});
|
||||
|
||||
test('does not fetch when name is already present', async () => {
|
||||
const result = await resolveRef(
|
||||
{ id: 'wrld_222', name: 'Known' },
|
||||
opts
|
||||
);
|
||||
expect(opts.fetchFn).not.toHaveBeenCalled();
|
||||
expect(result.name).toBe('Known');
|
||||
});
|
||||
|
||||
test('works with user-style config (displayName)', async () => {
|
||||
const userOpts = {
|
||||
emptyDefault: { id: '', displayName: '' },
|
||||
idAlias: 'userId',
|
||||
nameKey: 'displayName',
|
||||
fetchFn: vi.fn().mockResolvedValue({
|
||||
ref: { displayName: 'Alice', status: 'online' }
|
||||
})
|
||||
};
|
||||
const result = await resolveRef('usr_1', userOpts);
|
||||
expect(result.id).toBe('usr_1');
|
||||
expect(result.displayName).toBe('Alice');
|
||||
expect(result.status).toBe('online');
|
||||
});
|
||||
});
|
||||
49
src/shared/notificationCategory.js
Normal file
49
src/shared/notificationCategory.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const FRIEND_TYPES = new Set([
|
||||
'friendRequest',
|
||||
'ignoredFriendRequest',
|
||||
'invite',
|
||||
'requestInvite',
|
||||
'inviteResponse',
|
||||
'requestInviteResponse',
|
||||
'boop'
|
||||
]);
|
||||
const GROUP_TYPES_PREFIX = ['group.', 'moderation.'];
|
||||
const GROUP_EXACT_TYPES = new Set(['groupChange', 'event.announcement']);
|
||||
|
||||
/**
|
||||
* Determine the category of a notification type.
|
||||
* @param {string} type
|
||||
* @returns {'friend'|'group'|'other'}
|
||||
*/
|
||||
function getNotificationCategory(type) {
|
||||
if (!type) return 'other';
|
||||
if (FRIEND_TYPES.has(type)) return 'friend';
|
||||
if (
|
||||
GROUP_EXACT_TYPES.has(type) ||
|
||||
GROUP_TYPES_PREFIX.some((p) => type.startsWith(p))
|
||||
)
|
||||
return 'group';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a millisecond timestamp from a notification object.
|
||||
* @param {object} n - A notification with created_at or createdAt field
|
||||
* @returns {number}
|
||||
*/
|
||||
function getNotificationTs(n) {
|
||||
const raw = n.created_at ?? n.createdAt;
|
||||
if (typeof raw === 'number') return raw > 1e12 ? raw : raw * 1000;
|
||||
const ts = dayjs(raw).valueOf();
|
||||
return Number.isFinite(ts) ? ts : 0;
|
||||
}
|
||||
|
||||
export {
|
||||
FRIEND_TYPES,
|
||||
GROUP_TYPES_PREFIX,
|
||||
GROUP_EXACT_TYPES,
|
||||
getNotificationCategory,
|
||||
getNotificationTs
|
||||
};
|
||||
228
src/shared/notificationMessage.js
Normal file
228
src/shared/notificationMessage.js
Normal file
@@ -0,0 +1,228 @@
|
||||
import { displayLocation } from './utils';
|
||||
|
||||
/**
|
||||
* Extracts the notification title and body from a notification object.
|
||||
* This is the single source of truth for notification message content,
|
||||
* used by desktop toast, XS overlay, and OVRT overlay.
|
||||
*
|
||||
* @param {object} noty - The notification object
|
||||
* @param {string} message - Pre-built invite/request message string
|
||||
* @returns {{ title: string, body: string } | null}
|
||||
*/
|
||||
export function getNotificationMessage(noty, message) {
|
||||
switch (noty.type) {
|
||||
case 'OnPlayerJoined':
|
||||
return { title: noty.displayName, body: 'has joined' };
|
||||
case 'OnPlayerLeft':
|
||||
return { title: noty.displayName, body: 'has left' };
|
||||
case 'OnPlayerJoining':
|
||||
return { title: noty.displayName, body: 'is joining' };
|
||||
case 'GPS':
|
||||
return {
|
||||
title: noty.displayName,
|
||||
body: `is in ${displayLocation(
|
||||
noty.location,
|
||||
noty.worldName,
|
||||
noty.groupName
|
||||
)}`
|
||||
};
|
||||
case 'Online': {
|
||||
let locationName = '';
|
||||
if (noty.worldName) {
|
||||
locationName = ` to ${displayLocation(
|
||||
noty.location,
|
||||
noty.worldName,
|
||||
noty.groupName
|
||||
)}`;
|
||||
}
|
||||
return {
|
||||
title: noty.displayName,
|
||||
body: `has logged in${locationName}`
|
||||
};
|
||||
}
|
||||
case 'Offline':
|
||||
return { title: noty.displayName, body: 'has logged out' };
|
||||
case 'Status':
|
||||
return {
|
||||
title: noty.displayName,
|
||||
body: `status is now ${noty.status} ${noty.statusDescription}`
|
||||
};
|
||||
case 'invite':
|
||||
return {
|
||||
title: noty.senderUsername,
|
||||
body: `has invited you to ${displayLocation(
|
||||
noty.details.worldId,
|
||||
noty.details.worldName
|
||||
)}${message}`
|
||||
};
|
||||
case 'requestInvite':
|
||||
return {
|
||||
title: noty.senderUsername,
|
||||
body: `has requested an invite${message}`
|
||||
};
|
||||
case 'inviteResponse':
|
||||
return {
|
||||
title: noty.senderUsername,
|
||||
body: `has responded to your invite${message}`
|
||||
};
|
||||
case 'requestInviteResponse':
|
||||
return {
|
||||
title: noty.senderUsername,
|
||||
body: `has responded to your invite request${message}`
|
||||
};
|
||||
case 'friendRequest':
|
||||
return {
|
||||
title: noty.senderUsername,
|
||||
body: 'has sent you a friend request'
|
||||
};
|
||||
case 'Friend':
|
||||
return { title: noty.displayName, body: 'is now your friend' };
|
||||
case 'Unfriend':
|
||||
return {
|
||||
title: noty.displayName,
|
||||
body: 'is no longer your friend'
|
||||
};
|
||||
case 'TrustLevel':
|
||||
return {
|
||||
title: noty.displayName,
|
||||
body: `trust level is now ${noty.trustLevel}`
|
||||
};
|
||||
case 'DisplayName':
|
||||
return {
|
||||
title: noty.previousDisplayName,
|
||||
body: `changed their name to ${noty.displayName}`
|
||||
};
|
||||
case 'boop':
|
||||
return { title: noty.senderUsername, body: noty.message };
|
||||
case 'groupChange':
|
||||
return { title: noty.senderUsername, body: noty.message };
|
||||
case 'group.announcement':
|
||||
return { title: 'Group Announcement', body: noty.message };
|
||||
case 'group.informative':
|
||||
return { title: 'Group Informative', body: noty.message };
|
||||
case 'group.invite':
|
||||
return { title: 'Group Invite', body: noty.message };
|
||||
case 'group.joinRequest':
|
||||
return { title: 'Group Join Request', body: noty.message };
|
||||
case 'group.transfer':
|
||||
return { title: 'Group Transfer Request', body: noty.message };
|
||||
case 'group.queueReady':
|
||||
return { title: 'Instance Queue Ready', body: noty.message };
|
||||
case 'instance.closed':
|
||||
return { title: 'Instance Closed', body: noty.message };
|
||||
case 'PortalSpawn':
|
||||
if (noty.displayName) {
|
||||
return {
|
||||
title: noty.displayName,
|
||||
body: `has spawned a portal to ${displayLocation(
|
||||
noty.instanceId,
|
||||
noty.worldName,
|
||||
noty.groupName
|
||||
)}`
|
||||
};
|
||||
}
|
||||
return { title: '', body: 'User has spawned a portal' };
|
||||
case 'AvatarChange':
|
||||
return {
|
||||
title: noty.displayName,
|
||||
body: `changed into avatar ${noty.name}`
|
||||
};
|
||||
case 'ChatBoxMessage':
|
||||
return {
|
||||
title: noty.displayName,
|
||||
body: `said ${noty.text}`
|
||||
};
|
||||
case 'Event':
|
||||
return { title: 'Event', body: noty.data };
|
||||
case 'External':
|
||||
return { title: 'External', body: noty.message };
|
||||
case 'VideoPlay':
|
||||
return { title: 'Now playing', body: noty.notyName };
|
||||
case 'BlockedOnPlayerJoined':
|
||||
return {
|
||||
title: noty.displayName,
|
||||
body: 'Blocked user has joined'
|
||||
};
|
||||
case 'BlockedOnPlayerLeft':
|
||||
return {
|
||||
title: noty.displayName,
|
||||
body: 'Blocked user has left'
|
||||
};
|
||||
case 'MutedOnPlayerJoined':
|
||||
return {
|
||||
title: noty.displayName,
|
||||
body: 'Muted user has joined'
|
||||
};
|
||||
case 'MutedOnPlayerLeft':
|
||||
return {
|
||||
title: noty.displayName,
|
||||
body: 'Muted user has left'
|
||||
};
|
||||
case 'Blocked':
|
||||
return { title: noty.displayName, body: 'has blocked you' };
|
||||
case 'Unblocked':
|
||||
return { title: noty.displayName, body: 'has unblocked you' };
|
||||
case 'Muted':
|
||||
return { title: noty.displayName, body: 'has muted you' };
|
||||
case 'Unmuted':
|
||||
return { title: noty.displayName, body: 'has unmuted you' };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Types where the full message is just the body (no title prefix).
|
||||
* Used by XS/OVRT notifications.
|
||||
*/
|
||||
const BODY_ONLY_TYPES = new Set([
|
||||
'boop',
|
||||
'group.announcement',
|
||||
'group.informative',
|
||||
'group.invite',
|
||||
'group.joinRequest',
|
||||
'group.transfer',
|
||||
'group.queueReady',
|
||||
'instance.closed',
|
||||
'Event',
|
||||
'External'
|
||||
]);
|
||||
|
||||
/**
|
||||
* Types where title and body are joined with ": " instead of " ".
|
||||
*/
|
||||
const COLON_SEPARATOR_TYPES = new Set(['groupChange', 'VideoPlay']);
|
||||
|
||||
/**
|
||||
* Types where the full message has a custom word order
|
||||
* different from "{title} {body}".
|
||||
*/
|
||||
const CUSTOM_FORMAT_MESSAGES = {
|
||||
BlockedOnPlayerJoined: (title) => `Blocked user ${title} has joined`,
|
||||
BlockedOnPlayerLeft: (title) => `Blocked user ${title} has left`,
|
||||
MutedOnPlayerJoined: (title) => `Muted user ${title} has joined`,
|
||||
MutedOnPlayerLeft: (title) => `Muted user ${title} has left`
|
||||
};
|
||||
|
||||
/**
|
||||
* Combines title and body into a single notification text string.
|
||||
* Handles per-type formatting differences for XS/OVRT overlays.
|
||||
*
|
||||
* @param {string} title
|
||||
* @param {string} body
|
||||
* @param {string} type - The notification type
|
||||
* @returns {string}
|
||||
*/
|
||||
export function toNotificationText(title, body, type) {
|
||||
if (BODY_ONLY_TYPES.has(type)) {
|
||||
return body;
|
||||
}
|
||||
if (COLON_SEPARATOR_TYPES.has(type)) {
|
||||
return title ? `${title}: ${body}` : body;
|
||||
}
|
||||
const customFmt = CUSTOM_FORMAT_MESSAGES[type];
|
||||
if (customFmt) {
|
||||
return customFmt(title);
|
||||
}
|
||||
return title ? `${title} ${body}` : body;
|
||||
}
|
||||
34
src/shared/resolveRef.js
Normal file
34
src/shared/resolveRef.js
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Generic resolver for user/world/group references.
|
||||
* Normalises the input, optionally fetches the display name if missing.
|
||||
*
|
||||
* @param {string|object|null|undefined} input
|
||||
* @param {object} opts
|
||||
* @param {object} opts.emptyDefault - value to return when input is falsy
|
||||
* @param {string} opts.idAlias - alternative id key on input (e.g. 'userId')
|
||||
* @param {string} opts.nameKey - name property key (e.g. 'displayName' or 'name')
|
||||
* @param {(id: string) => Promise<{ref: object}>} opts.fetchFn - fetch function
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
async function resolveRef(input, { emptyDefault, idAlias, nameKey, fetchFn }) {
|
||||
if (!input) {
|
||||
return emptyDefault;
|
||||
}
|
||||
if (typeof input === 'string') {
|
||||
input = { id: input, [nameKey]: '' };
|
||||
}
|
||||
const id = input.id || input[idAlias] || '';
|
||||
let name = input[nameKey] || '';
|
||||
if (id && !name) {
|
||||
try {
|
||||
const args = await fetchFn(id);
|
||||
name = args?.ref?.[nameKey] || name;
|
||||
return { ...args.ref, id, [nameKey]: name };
|
||||
} catch {
|
||||
return { ...input, id, [nameKey]: name };
|
||||
}
|
||||
}
|
||||
return { ...input, id, [nameKey]: name };
|
||||
}
|
||||
|
||||
export { resolveRef };
|
||||
98
src/shared/utils/__tests__/gameLog.test.js
Normal file
98
src/shared/utils/__tests__/gameLog.test.js
Normal file
@@ -0,0 +1,98 @@
|
||||
import { gameLogSearchFilter } from '../gameLog';
|
||||
|
||||
describe('gameLogSearchFilter', () => {
|
||||
test('returns true for empty search query', () => {
|
||||
expect(gameLogSearchFilter({}, '')).toBe(true);
|
||||
expect(gameLogSearchFilter({}, ' ')).toBe(true);
|
||||
});
|
||||
|
||||
test('matches Location by worldName', () => {
|
||||
const row = { type: 'Location', worldName: 'Test World' };
|
||||
expect(gameLogSearchFilter(row, 'test')).toBe(true);
|
||||
expect(gameLogSearchFilter(row, 'WORLD')).toBe(true);
|
||||
expect(gameLogSearchFilter(row, 'nope')).toBe(false);
|
||||
});
|
||||
|
||||
test('matches OnPlayerJoined by displayName', () => {
|
||||
const row = { type: 'OnPlayerJoined', displayName: 'Alice' };
|
||||
expect(gameLogSearchFilter(row, 'alice')).toBe(true);
|
||||
expect(gameLogSearchFilter(row, 'bob')).toBe(false);
|
||||
});
|
||||
|
||||
test('matches OnPlayerLeft by displayName', () => {
|
||||
const row = { type: 'OnPlayerLeft', displayName: 'Bob' };
|
||||
expect(gameLogSearchFilter(row, 'bob')).toBe(true);
|
||||
expect(gameLogSearchFilter(row, 'alice')).toBe(false);
|
||||
});
|
||||
|
||||
test('matches PortalSpawn by displayName or worldName', () => {
|
||||
const row = {
|
||||
type: 'PortalSpawn',
|
||||
displayName: 'Alice',
|
||||
worldName: 'Portal Room'
|
||||
};
|
||||
expect(gameLogSearchFilter(row, 'alice')).toBe(true);
|
||||
expect(gameLogSearchFilter(row, 'portal')).toBe(true);
|
||||
expect(gameLogSearchFilter(row, 'bob')).toBe(false);
|
||||
});
|
||||
|
||||
test('matches Event by data', () => {
|
||||
const row = { type: 'Event', data: 'something happened' };
|
||||
expect(gameLogSearchFilter(row, 'something')).toBe(true);
|
||||
expect(gameLogSearchFilter(row, 'nothing')).toBe(false);
|
||||
});
|
||||
|
||||
test('matches External by message or displayName', () => {
|
||||
const row = {
|
||||
type: 'External',
|
||||
message: 'hello world',
|
||||
displayName: 'Plugin'
|
||||
};
|
||||
expect(gameLogSearchFilter(row, 'hello')).toBe(true);
|
||||
expect(gameLogSearchFilter(row, 'plugin')).toBe(true);
|
||||
expect(gameLogSearchFilter(row, 'foo')).toBe(false);
|
||||
});
|
||||
|
||||
test('matches VideoPlay by displayName, videoName, or videoUrl', () => {
|
||||
const row = {
|
||||
type: 'VideoPlay',
|
||||
displayName: 'Alice',
|
||||
videoName: 'Cool Song',
|
||||
videoUrl: 'https://example.com/video'
|
||||
};
|
||||
expect(gameLogSearchFilter(row, 'alice')).toBe(true);
|
||||
expect(gameLogSearchFilter(row, 'cool')).toBe(true);
|
||||
expect(gameLogSearchFilter(row, 'example.com')).toBe(true);
|
||||
expect(gameLogSearchFilter(row, 'nope')).toBe(false);
|
||||
});
|
||||
|
||||
test('matches StringLoad/ImageLoad by resourceUrl', () => {
|
||||
const rowStr = {
|
||||
type: 'StringLoad',
|
||||
resourceUrl: 'https://cdn.example.com/res'
|
||||
};
|
||||
expect(gameLogSearchFilter(rowStr, 'cdn')).toBe(true);
|
||||
expect(gameLogSearchFilter(rowStr, 'nope')).toBe(false);
|
||||
|
||||
const rowImg = {
|
||||
type: 'ImageLoad',
|
||||
resourceUrl: 'https://cdn.example.com/img'
|
||||
};
|
||||
expect(gameLogSearchFilter(rowImg, 'img')).toBe(true);
|
||||
});
|
||||
|
||||
test('matches location prefix wrld_ or grp_ against row.location', () => {
|
||||
const row = {
|
||||
type: 'Location',
|
||||
location: 'wrld_123456~hidden',
|
||||
worldName: 'Test'
|
||||
};
|
||||
expect(gameLogSearchFilter(row, 'wrld_123456')).toBe(true);
|
||||
expect(gameLogSearchFilter(row, 'wrld_999')).toBe(false);
|
||||
});
|
||||
|
||||
test('returns true for unknown type', () => {
|
||||
const row = { type: 'SomeNewType' };
|
||||
expect(gameLogSearchFilter(row, 'anything')).toBe(true);
|
||||
});
|
||||
});
|
||||
59
src/shared/utils/__tests__/user.findByDisplayName.test.js
Normal file
59
src/shared/utils/__tests__/user.findByDisplayName.test.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import { findUserByDisplayName } from '../user';
|
||||
|
||||
vi.mock('../../../views/Feed/Feed.vue', () => ({
|
||||
default: { name: 'Feed' }
|
||||
}));
|
||||
vi.mock('../../../views/Feed/columns.jsx', () => ({ columns: [] }));
|
||||
vi.mock('../../../plugin/router', () => ({
|
||||
default: { push: vi.fn(), currentRoute: { value: {} } }
|
||||
}));
|
||||
|
||||
describe('findUserByDisplayName', () => {
|
||||
function createCachedUsers(entries) {
|
||||
const map = new Map();
|
||||
for (const entry of entries) {
|
||||
map.set(entry.id, entry);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
test('returns the user matching displayName', () => {
|
||||
const users = createCachedUsers([
|
||||
{ id: 'usr_1', displayName: 'Alice' },
|
||||
{ id: 'usr_2', displayName: 'Bob' },
|
||||
{ id: 'usr_3', displayName: 'Charlie' }
|
||||
]);
|
||||
const result = findUserByDisplayName(users, 'Bob');
|
||||
expect(result).toEqual({ id: 'usr_2', displayName: 'Bob' });
|
||||
});
|
||||
|
||||
test('returns undefined when no match found', () => {
|
||||
const users = createCachedUsers([
|
||||
{ id: 'usr_1', displayName: 'Alice' }
|
||||
]);
|
||||
expect(findUserByDisplayName(users, 'Unknown')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('returns undefined for empty map', () => {
|
||||
const users = new Map();
|
||||
expect(findUserByDisplayName(users, 'Alice')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('returns first match when duplicates exist', () => {
|
||||
const users = createCachedUsers([
|
||||
{ id: 'usr_1', displayName: 'Alice' },
|
||||
{ id: 'usr_2', displayName: 'Alice' }
|
||||
]);
|
||||
// Map preserves insertion order, first match wins
|
||||
const result = findUserByDisplayName(users, 'Alice');
|
||||
expect(result.id).toBe('usr_1');
|
||||
});
|
||||
|
||||
test('match is exact (case-sensitive)', () => {
|
||||
const users = createCachedUsers([
|
||||
{ id: 'usr_1', displayName: 'Alice' }
|
||||
]);
|
||||
expect(findUserByDisplayName(users, 'alice')).toBeUndefined();
|
||||
expect(findUserByDisplayName(users, 'ALICE')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
76
src/shared/utils/gameLog.js
Normal file
76
src/shared/utils/gameLog.js
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Filter a game log row by search query.
|
||||
* @param {object} row
|
||||
* @param {string} searchQuery
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function gameLogSearchFilter(row, searchQuery) {
|
||||
const value = searchQuery.trim().toUpperCase();
|
||||
if (!value) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
(value.startsWith('WRLD_') || value.startsWith('GRP_')) &&
|
||||
String(row.location).toUpperCase().includes(value)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
switch (row.type) {
|
||||
case 'Location':
|
||||
if (String(row.worldName).toUpperCase().includes(value)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
case 'OnPlayerJoined':
|
||||
if (String(row.displayName).toUpperCase().includes(value)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
case 'OnPlayerLeft':
|
||||
if (String(row.displayName).toUpperCase().includes(value)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
case 'PortalSpawn':
|
||||
if (String(row.displayName).toUpperCase().includes(value)) {
|
||||
return true;
|
||||
}
|
||||
if (String(row.worldName).toUpperCase().includes(value)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
case 'Event':
|
||||
if (String(row.data).toUpperCase().includes(value)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
case 'External':
|
||||
if (String(row.message).toUpperCase().includes(value)) {
|
||||
return true;
|
||||
}
|
||||
if (String(row.displayName).toUpperCase().includes(value)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
case 'VideoPlay':
|
||||
if (String(row.displayName).toUpperCase().includes(value)) {
|
||||
return true;
|
||||
}
|
||||
if (String(row.videoName).toUpperCase().includes(value)) {
|
||||
return true;
|
||||
}
|
||||
if (String(row.videoUrl).toUpperCase().includes(value)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
case 'StringLoad':
|
||||
case 'ImageLoad':
|
||||
if (String(row.resourceUrl).toUpperCase().includes(value)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export { gameLogSearchFilter };
|
||||
@@ -22,3 +22,4 @@ export * from './world';
|
||||
export * from './memos';
|
||||
export * from './throttle';
|
||||
export * from './retry';
|
||||
export * from './gameLog';
|
||||
|
||||
@@ -291,6 +291,21 @@ function userOnlineFor(ref) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a user object from cachedUsers by displayName.
|
||||
* @param {Map} cachedUsers
|
||||
* @param {string} displayName
|
||||
* @returns {object|undefined}
|
||||
*/
|
||||
function findUserByDisplayName(cachedUsers, displayName) {
|
||||
for (const ref of cachedUsers.values()) {
|
||||
if (ref.displayName === displayName) {
|
||||
return ref;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export {
|
||||
userOnlineForTimestamp,
|
||||
languageClass,
|
||||
@@ -301,5 +316,6 @@ export {
|
||||
userImage,
|
||||
userImageFull,
|
||||
parseUserUrl,
|
||||
userOnlineFor
|
||||
userOnlineFor,
|
||||
findUserByDisplayName
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user