diff --git a/src/shared/__tests__/notificationCategory.test.js b/src/shared/__tests__/notificationCategory.test.js new file mode 100644 index 00000000..325fdc5e --- /dev/null +++ b/src/shared/__tests__/notificationCategory.test.js @@ -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); + }); +}); diff --git a/src/shared/__tests__/notificationMessage.test.js b/src/shared/__tests__/notificationMessage.test.js new file mode 100644 index 00000000..ae219d92 --- /dev/null +++ b/src/shared/__tests__/notificationMessage.test.js @@ -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'); + }); +}); diff --git a/src/shared/__tests__/resolveRef.test.js b/src/shared/__tests__/resolveRef.test.js new file mode 100644 index 00000000..d7d1cefd --- /dev/null +++ b/src/shared/__tests__/resolveRef.test.js @@ -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'); + }); +}); diff --git a/src/shared/notificationCategory.js b/src/shared/notificationCategory.js new file mode 100644 index 00000000..ece97d18 --- /dev/null +++ b/src/shared/notificationCategory.js @@ -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 +}; diff --git a/src/shared/notificationMessage.js b/src/shared/notificationMessage.js new file mode 100644 index 00000000..af1c208d --- /dev/null +++ b/src/shared/notificationMessage.js @@ -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; +} diff --git a/src/shared/resolveRef.js b/src/shared/resolveRef.js new file mode 100644 index 00000000..d42922b0 --- /dev/null +++ b/src/shared/resolveRef.js @@ -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} + */ +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 }; diff --git a/src/shared/utils/__tests__/gameLog.test.js b/src/shared/utils/__tests__/gameLog.test.js new file mode 100644 index 00000000..a51e6ac0 --- /dev/null +++ b/src/shared/utils/__tests__/gameLog.test.js @@ -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); + }); +}); diff --git a/src/shared/utils/__tests__/user.findByDisplayName.test.js b/src/shared/utils/__tests__/user.findByDisplayName.test.js new file mode 100644 index 00000000..3b1a24dc --- /dev/null +++ b/src/shared/utils/__tests__/user.findByDisplayName.test.js @@ -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(); + }); +}); diff --git a/src/shared/utils/gameLog.js b/src/shared/utils/gameLog.js new file mode 100644 index 00000000..105a825f --- /dev/null +++ b/src/shared/utils/gameLog.js @@ -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 }; diff --git a/src/shared/utils/index.js b/src/shared/utils/index.js index dce8d1c9..cbfc0f60 100644 --- a/src/shared/utils/index.js +++ b/src/shared/utils/index.js @@ -22,3 +22,4 @@ export * from './world'; export * from './memos'; export * from './throttle'; export * from './retry'; +export * from './gameLog'; diff --git a/src/shared/utils/user.js b/src/shared/utils/user.js index b0fb63a3..de2a7240 100644 --- a/src/shared/utils/user.js +++ b/src/shared/utils/user.js @@ -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 }; diff --git a/src/stores/gameLog.js b/src/stores/gameLog.js index 304beeb8..9cd776c5 100644 --- a/src/stores/gameLog.js +++ b/src/stores/gameLog.js @@ -8,7 +8,9 @@ import dayjs from 'dayjs'; import { convertYoutubeTime, + findUserByDisplayName, formatSeconds, + gameLogSearchFilter, getGroupName, isRpcWorld, parseLocation, @@ -221,12 +223,11 @@ export const useGameLogStore = defineStore('GameLog', () => { const ctx = structuredClone(data); if (nowPlaying.value.url !== ctx.videoUrl) { if (!ctx.userId && ctx.displayName) { - for (const ref of userStore.cachedUsers.values()) { - if (ref.displayName === ctx.displayName) { - ctx.userId = ref.id; - break; - } - } + ctx.userId = + findUserByDisplayName( + userStore.cachedUsers, + ctx.displayName + )?.id ?? ''; } notificationStore.queueGameLogNoty(ctx); addGameLog(ctx); @@ -324,12 +325,11 @@ export const useGameLogStore = defineStore('GameLog', () => { ctx = data[i]; if (ctx.type === 'OnPlayerJoined') { if (!ctx.userId) { - for (let ref of userStore.cachedUsers.values()) { - if (ref.displayName === ctx.displayName) { - ctx.userId = ref.id; - break; - } - } + ctx.userId = + findUserByDisplayName( + userStore.cachedUsers, + ctx.displayName + )?.id ?? ''; } const userMap = { displayName: ctx.displayName, @@ -484,72 +484,7 @@ export const useGameLogStore = defineStore('GameLog', () => { } function gameLogSearch(row) { - const value = gameLogTable.value.search.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; + return gameLogSearchFilter(row, gameLogTable.value.search); } function sweepGameLog() { @@ -566,12 +501,11 @@ export const useGameLogStore = defineStore('GameLog', () => { } let userId = String(gameLog.userId || ''); if (!userId && gameLog.displayName) { - for (const ref of userStore.cachedUsers.values()) { - if (ref.displayName === gameLog.displayName) { - userId = ref.id; - break; - } - } + userId = + findUserByDisplayName( + userStore.cachedUsers, + gameLog.displayName + )?.id ?? ''; } switch (gameLog.type) { case 'location-destination': @@ -890,12 +824,13 @@ export const useGameLogStore = defineStore('GameLog', () => { const photonId = parseInt(gameLog.photonId, 10); const ref2 = photonStore.photonLobby.get(photonId); if (typeof ref2 === 'undefined') { - for (const ctx of userStore.cachedUsers.values()) { - if (ctx.displayName === gameLog.displayName) { - photonStore.photonLobby.set(photonId, ctx); - photonStore.photonLobbyCurrent.set(photonId, ctx); - break; - } + const foundUser = findUserByDisplayName( + userStore.cachedUsers, + gameLog.displayName + ); + if (foundUser) { + photonStore.photonLobby.set(photonId, foundUser); + photonStore.photonLobbyCurrent.set(photonId, foundUser); } const ctx1 = { displayName: gameLog.displayName @@ -1101,12 +1036,9 @@ export const useGameLogStore = defineStore('GameLog', () => { } let userId = ''; if (displayName) { - for (const ref of userStore.cachedUsers.values()) { - if (ref.displayName === displayName) { - userId = ref.id; - break; - } - } + userId = + findUserByDisplayName(userStore.cachedUsers, displayName)?.id ?? + ''; } if (videoId === 'YouTube') { const entry1 = { @@ -1172,12 +1104,9 @@ export const useGameLogStore = defineStore('GameLog', () => { } let userId = ''; if (displayName) { - for (let ref of userStore.cachedUsers.values()) { - if (ref.displayName === displayName) { - userId = ref.id; - break; - } - } + userId = + findUserByDisplayName(userStore.cachedUsers, displayName)?.id ?? + ''; } if (videoId === 'YouTube') { const entry1 = { @@ -1238,12 +1167,9 @@ export const useGameLogStore = defineStore('GameLog', () => { } let userId = ''; if (displayName) { - for (const ref of userStore.cachedUsers.values()) { - if (ref.displayName === displayName) { - userId = ref.id; - break; - } - } + userId = + findUserByDisplayName(userStore.cachedUsers, displayName)?.id ?? + ''; } if (videoId === 'YouTube') { const entry1 = { @@ -1298,12 +1224,9 @@ export const useGameLogStore = defineStore('GameLog', () => { } let userId = ''; if (displayName) { - for (const ref of userStore.cachedUsers.values()) { - if (ref.displayName === displayName) { - userId = ref.id; - break; - } - } + userId = + findUserByDisplayName(userStore.cachedUsers, displayName)?.id ?? + ''; } const entry1 = { created_at: gameLog.dt, @@ -1358,12 +1281,9 @@ export const useGameLogStore = defineStore('GameLog', () => { } let userId = ''; if (displayName) { - for (const ref of userStore.cachedUsers.values()) { - if (ref.displayName === displayName) { - userId = ref.id; - break; - } - } + userId = + findUserByDisplayName(userStore.cachedUsers, displayName)?.id ?? + ''; } const entry1 = { created_at: gameLog.dt, diff --git a/src/stores/instance.js b/src/stores/instance.js index e12c7832..d2cd925e 100644 --- a/src/stores/instance.js +++ b/src/stores/instance.js @@ -30,6 +30,7 @@ import { instanceContentSettings } from '../shared/constants'; import { database } from '../service/database'; +import { resolveRef } from '../shared/resolveRef'; import { useAppearanceSettingsStore } from './settings/appearance'; import { useFriendStore } from './friend'; import { useGroupStore } from './group'; @@ -211,67 +212,31 @@ export const useInstanceStore = defineStore('Instance', () => { previousInstancesListDialog.value.visible = false; } - async function resolveUserRef(input) { - if (!input) { - return { id: '', displayName: '' }; - } - if (typeof input === 'string') { - input = { id: input, displayName: '' }; - } - const id = input.id || input.userId || ''; - let displayName = input.displayName || ''; - if (id && !displayName) { - try { - const args = await userRequest.getCachedUser({ userId: id }); - displayName = args?.ref?.displayName || displayName; - return { ...args.ref, id, displayName }; - } catch { - return { ...input, id, displayName }; - } - } - return { ...input, id, displayName }; + function resolveUserRef(input) { + return resolveRef(input, { + emptyDefault: { id: '', displayName: '' }, + idAlias: 'userId', + nameKey: 'displayName', + fetchFn: (id) => userRequest.getCachedUser({ userId: id }) + }); } - async function resolveWorldRef(input) { - if (!input) { - return { id: '', name: '' }; - } - if (typeof input === 'string') { - input = { id: input, name: '' }; - } - const id = input.id || input.worldId || ''; - let name = input.name || ''; - if (id && !name) { - try { - const args = await worldRequest.getCachedWorld({ worldId: id }); - name = args?.ref?.name || name; - return { ...args.ref, id, name }; - } catch { - return { ...input, id, name }; - } - } - return { ...input, id, name }; + function resolveWorldRef(input) { + return resolveRef(input, { + emptyDefault: { id: '', name: '' }, + idAlias: 'worldId', + nameKey: 'name', + fetchFn: (id) => worldRequest.getCachedWorld({ worldId: id }) + }); } - async function resolveGroupRef(input) { - if (!input) { - return { id: '', name: '' }; - } - if (typeof input === 'string') { - input = { id: input, name: '' }; - } - const id = input.id || input.groupId || ''; - let name = input.name || ''; - if (id && !name) { - try { - const args = await groupRequest.getCachedGroup({ groupId: id }); - name = args?.ref?.name || name; - return { ...args.ref, id, name }; - } catch { - return { ...input, id, name }; - } - } - return { ...input, id, name }; + function resolveGroupRef(input) { + return resolveRef(input, { + emptyDefault: { id: '', name: '' }, + idAlias: 'groupId', + nameKey: 'name', + fetchFn: (id) => groupRequest.getCachedGroup({ groupId: id }) + }); } function translateAccessType(accessTypeNameRaw) { diff --git a/src/stores/notification.js b/src/stores/notification.js index a7278f64..c9472939 100644 --- a/src/stores/notification.js +++ b/src/stores/notification.js @@ -13,6 +13,7 @@ import { executeWithBackoff, extractFileId, extractFileVersion, + findUserByDisplayName, getUserMemo, parseLocation, removeFromArray, @@ -26,6 +27,14 @@ import { worldRequest } from '../api'; import { database, dbVars } from '../service/database'; +import { + getNotificationCategory, + getNotificationTs +} from '../shared/notificationCategory'; +import { + getNotificationMessage, + toNotificationText +} from '../shared/notificationMessage'; import { AppDebug } from '../service/appConfig'; import { useAdvancedSettingsStore } from './settings/advanced'; import { useAppearanceSettingsStore } from './settings/appearance'; @@ -87,29 +96,6 @@ export const useNotificationStore = defineStore('Notification', () => { const isNotificationsLoading = ref(false); const isNotificationCenterOpen = ref(false); - 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']); - - 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'; - } - const friendNotifications = computed(() => notificationTable.value.data.filter( (n) => getNotificationCategory(n.type) === 'friend' @@ -136,12 +122,6 @@ export const useNotificationStore = defineStore('Notification', () => { otherNotifications.value.filter((n) => unseenSet.value.has(n.id)) ); const recentCutoff = computed(() => dayjs().subtract(24, 'hour').valueOf()); - 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; - } const recentFriendNotifications = computed(() => friendNotifications.value.filter( (n) => @@ -1221,235 +1201,9 @@ export const useNotificationStore = defineStore('Notification', () => { } function displayDesktopToast(noty, message, image) { - switch (noty.type) { - case 'OnPlayerJoined': - desktopNotification(noty.displayName, 'has joined', image); - break; - case 'OnPlayerLeft': - desktopNotification(noty.displayName, 'has left', image); - break; - case 'OnPlayerJoining': - desktopNotification(noty.displayName, 'is joining', image); - break; - case 'GPS': - desktopNotification( - noty.displayName, - `is in ${displayLocation( - noty.location, - noty.worldName, - noty.groupName - )}`, - image - ); - break; - case 'Online': - let locationName = ''; - if (noty.worldName) { - locationName = ` to ${displayLocation( - noty.location, - noty.worldName, - noty.groupName - )}`; - } - desktopNotification( - noty.displayName, - `has logged in${locationName}`, - image - ); - break; - case 'Offline': - desktopNotification(noty.displayName, 'has logged out', image); - break; - case 'Status': - desktopNotification( - noty.displayName, - `status is now ${noty.status} ${noty.statusDescription}`, - image - ); - break; - case 'invite': - desktopNotification( - noty.senderUsername, - `has invited you to ${displayLocation( - noty.details.worldId, - noty.details.worldName - )}${message}`, - image - ); - break; - case 'requestInvite': - desktopNotification( - noty.senderUsername, - `has requested an invite${message}`, - image - ); - break; - case 'inviteResponse': - desktopNotification( - noty.senderUsername, - `has responded to your invite${message}`, - image - ); - break; - case 'requestInviteResponse': - desktopNotification( - noty.senderUsername, - `has responded to your invite request${message}`, - image - ); - break; - case 'friendRequest': - desktopNotification( - noty.senderUsername, - 'has sent you a friend request', - image - ); - break; - case 'Friend': - desktopNotification( - noty.displayName, - 'is now your friend', - image - ); - break; - case 'Unfriend': - desktopNotification( - noty.displayName, - 'is no longer your friend', - image - ); - break; - case 'TrustLevel': - desktopNotification( - noty.displayName, - `trust level is now ${noty.trustLevel}`, - image - ); - break; - case 'DisplayName': - desktopNotification( - noty.previousDisplayName, - `changed their name to ${noty.displayName}`, - image - ); - break; - case 'boop': - desktopNotification(noty.senderUsername, noty.message, image); - break; - case 'groupChange': - desktopNotification(noty.senderUsername, noty.message, image); - break; - case 'group.announcement': - desktopNotification('Group Announcement', noty.message, image); - break; - case 'group.informative': - desktopNotification('Group Informative', noty.message, image); - break; - case 'group.invite': - desktopNotification('Group Invite', noty.message, image); - break; - case 'group.joinRequest': - desktopNotification('Group Join Request', noty.message, image); - break; - case 'group.transfer': - desktopNotification( - 'Group Transfer Request', - noty.message, - image - ); - break; - case 'group.queueReady': - desktopNotification( - 'Instance Queue Ready', - noty.message, - image - ); - break; - case 'instance.closed': - desktopNotification('Instance Closed', noty.message, image); - break; - case 'PortalSpawn': - if (noty.displayName) { - desktopNotification( - noty.displayName, - `has spawned a portal to ${displayLocation( - noty.instanceId, - noty.worldName, - noty.groupName - )}`, - image - ); - } else { - desktopNotification('', 'User has spawned a portal', image); - } - break; - case 'AvatarChange': - desktopNotification( - noty.displayName, - `changed into avatar ${noty.name}`, - image - ); - break; - case 'ChatBoxMessage': - desktopNotification( - noty.displayName, - `said ${noty.text}`, - image - ); - break; - case 'Event': - desktopNotification('Event', noty.data, image); - break; - case 'External': - desktopNotification('External', noty.message, image); - break; - case 'VideoPlay': - desktopNotification('Now playing', noty.notyName, image); - break; - case 'BlockedOnPlayerJoined': - desktopNotification( - noty.displayName, - 'blocked user has joined', - image - ); - break; - case 'BlockedOnPlayerLeft': - desktopNotification( - noty.displayName, - 'blocked user has left', - image - ); - break; - case 'MutedOnPlayerJoined': - desktopNotification( - noty.displayName, - 'muted user has joined', - image - ); - break; - case 'MutedOnPlayerLeft': - desktopNotification( - noty.displayName, - 'muted user has left', - image - ); - break; - case 'Blocked': - desktopNotification(noty.displayName, 'has blocked you', image); - break; - case 'Unblocked': - desktopNotification( - noty.displayName, - 'has unblocked you', - image - ); - break; - case 'Muted': - desktopNotification(noty.displayName, 'has muted you', image); - break; - case 'Unmuted': - desktopNotification(noty.displayName, 'has unmuted you', image); - break; + const result = getNotificationMessage(noty, message); + if (result) { + desktopNotification(result.title, result.body, image); } } @@ -1477,6 +1231,8 @@ export const useNotificationStore = defineStore('Notification', () => { * @param {string} image */ function displayXSNotification(noty, message, image) { + const result = getNotificationMessage(noty, message); + if (!result) return; const timeout = Math.floor( parseInt( notificationsSettingsStore.notificationTimeout.toString(), @@ -1486,392 +1242,8 @@ export const useNotificationStore = defineStore('Notification', () => { const opacity = parseFloat(advancedSettingsStore.notificationOpacity.toString()) / 100; - switch (noty.type) { - case 'OnPlayerJoined': - AppApi.XSNotification( - 'VRCX', - `${noty.displayName} has joined`, - timeout, - opacity, - image - ); - break; - case 'OnPlayerLeft': - AppApi.XSNotification( - 'VRCX', - `${noty.displayName} has left`, - timeout, - opacity, - image - ); - break; - case 'OnPlayerJoining': - AppApi.XSNotification( - 'VRCX', - `${noty.displayName} is joining`, - timeout, - opacity, - image - ); - break; - case 'GPS': - AppApi.XSNotification( - 'VRCX', - `${noty.displayName} is in ${displayLocation( - noty.location, - noty.worldName, - noty.groupName - )}`, - timeout, - opacity, - image - ); - break; - case 'Online': - let locationName = ''; - if (noty.worldName) { - locationName = ` to ${displayLocation( - noty.location, - noty.worldName, - noty.groupName - )}`; - } - AppApi.XSNotification( - 'VRCX', - `${noty.displayName} has logged in${locationName}`, - timeout, - opacity, - image - ); - break; - case 'Offline': - AppApi.XSNotification( - 'VRCX', - `${noty.displayName} has logged out`, - timeout, - opacity, - image - ); - break; - case 'Status': - AppApi.XSNotification( - 'VRCX', - `${noty.displayName} status is now ${noty.status} ${noty.statusDescription}`, - timeout, - opacity, - image - ); - break; - case 'invite': - AppApi.XSNotification( - 'VRCX', - `${ - noty.senderUsername - } has invited you to ${displayLocation( - noty.details.worldId, - noty.details.worldName - )}${message}`, - timeout, - opacity, - image - ); - break; - case 'requestInvite': - AppApi.XSNotification( - 'VRCX', - `${noty.senderUsername} has requested an invite${message}`, - timeout, - opacity, - image - ); - break; - case 'inviteResponse': - AppApi.XSNotification( - 'VRCX', - `${noty.senderUsername} has responded to your invite${message}`, - timeout, - opacity, - image - ); - break; - case 'requestInviteResponse': - AppApi.XSNotification( - 'VRCX', - `${noty.senderUsername} has responded to your invite request${message}`, - timeout, - opacity, - image - ); - break; - case 'friendRequest': - AppApi.XSNotification( - 'VRCX', - `${noty.senderUsername} has sent you a friend request`, - timeout, - opacity, - image - ); - break; - case 'Friend': - AppApi.XSNotification( - 'VRCX', - `${noty.displayName} is now your friend`, - timeout, - opacity, - image - ); - break; - case 'Unfriend': - AppApi.XSNotification( - 'VRCX', - `${noty.displayName} is no longer your friend`, - timeout, - opacity, - image - ); - break; - case 'TrustLevel': - AppApi.XSNotification( - 'VRCX', - `${noty.displayName} trust level is now ${noty.trustLevel}`, - timeout, - opacity, - image - ); - break; - case 'DisplayName': - AppApi.XSNotification( - 'VRCX', - `${noty.previousDisplayName} changed their name to ${noty.displayName}`, - timeout, - opacity, - image - ); - break; - case 'boop': - AppApi.XSNotification( - 'VRCX', - noty.message, - timeout, - opacity, - image - ); - break; - case 'groupChange': - AppApi.XSNotification( - 'VRCX', - `${noty.senderUsername}: ${noty.message}`, - timeout, - opacity, - image - ); - break; - case 'group.announcement': - AppApi.XSNotification( - 'VRCX', - noty.message, - timeout, - opacity, - image - ); - break; - case 'group.informative': - AppApi.XSNotification( - 'VRCX', - noty.message, - timeout, - opacity, - image - ); - break; - case 'group.invite': - AppApi.XSNotification( - 'VRCX', - noty.message, - timeout, - opacity, - image - ); - break; - case 'group.joinRequest': - AppApi.XSNotification( - 'VRCX', - noty.message, - timeout, - opacity, - image - ); - break; - case 'group.transfer': - AppApi.XSNotification( - 'VRCX', - noty.message, - timeout, - opacity, - image - ); - break; - case 'group.queueReady': - AppApi.XSNotification( - 'VRCX', - noty.message, - timeout, - opacity, - image - ); - break; - case 'instance.closed': - AppApi.XSNotification( - 'VRCX', - noty.message, - timeout, - opacity, - image - ); - break; - case 'PortalSpawn': - if (noty.displayName) { - AppApi.XSNotification( - 'VRCX', - `${ - noty.displayName - } has spawned a portal to ${displayLocation( - noty.instanceId, - noty.worldName, - noty.groupName - )}`, - timeout, - opacity, - image - ); - } else { - AppApi.XSNotification( - 'VRCX', - 'User has spawned a portal', - timeout, - opacity, - image - ); - } - break; - case 'AvatarChange': - AppApi.XSNotification( - 'VRCX', - `${noty.displayName} changed into avatar ${noty.name}`, - timeout, - opacity, - image - ); - break; - case 'ChatBoxMessage': - AppApi.XSNotification( - 'VRCX', - `${noty.displayName} said ${noty.text}`, - timeout, - opacity, - image - ); - break; - case 'Event': - AppApi.XSNotification( - 'VRCX', - noty.data, - timeout, - opacity, - image - ); - break; - case 'External': - AppApi.XSNotification( - 'VRCX', - noty.message, - timeout, - opacity, - image - ); - break; - case 'VideoPlay': - AppApi.XSNotification( - 'VRCX', - `Now playing: ${noty.notyName}`, - timeout, - opacity, - image - ); - break; - case 'BlockedOnPlayerJoined': - AppApi.XSNotification( - 'VRCX', - `Blocked user ${noty.displayName} has joined`, - timeout, - opacity, - image - ); - break; - case 'BlockedOnPlayerLeft': - AppApi.XSNotification( - 'VRCX', - `Blocked user ${noty.displayName} has left`, - timeout, - opacity, - image - ); - break; - case 'MutedOnPlayerJoined': - AppApi.XSNotification( - 'VRCX', - `Muted user ${noty.displayName} has joined`, - timeout, - opacity, - image - ); - break; - case 'MutedOnPlayerLeft': - AppApi.XSNotification( - 'VRCX', - `Muted user ${noty.displayName} has left`, - timeout, - opacity, - image - ); - break; - case 'Blocked': - AppApi.XSNotification( - 'VRCX', - `${noty.displayName} has blocked you`, - timeout, - opacity, - image - ); - break; - case 'Unblocked': - AppApi.XSNotification( - 'VRCX', - `${noty.displayName} has unblocked you`, - timeout, - opacity, - image - ); - break; - case 'Muted': - AppApi.XSNotification( - 'VRCX', - `${noty.displayName} has muted you`, - timeout, - opacity, - image - ); - break; - case 'Unmuted': - AppApi.XSNotification( - 'VRCX', - `${noty.displayName} has unmuted you`, - timeout, - opacity, - image - ); - break; - } + const text = toNotificationText(result.title, result.body, noty.type); + AppApi.XSNotification('VRCX', text, timeout, opacity, image); } function displayOvrtNotification( @@ -1881,6 +1253,8 @@ export const useNotificationStore = defineStore('Notification', () => { message, image ) { + const result = getNotificationMessage(noty, message); + if (!result) return; const timeout = Math.floor( parseInt( notificationsSettingsStore.notificationTimeout.toString(), @@ -1890,472 +1264,16 @@ export const useNotificationStore = defineStore('Notification', () => { const opacity = parseFloat(advancedSettingsStore.notificationOpacity.toString()) / 100; - switch (noty.type) { - case 'OnPlayerJoined': - AppApi.OVRTNotification( - playOvrtHudNotifications, - playOvrtWristNotifications, - 'VRCX', - `${noty.displayName} has joined`, - timeout, - opacity, - image - ); - break; - case 'OnPlayerLeft': - AppApi.OVRTNotification( - playOvrtHudNotifications, - playOvrtWristNotifications, - 'VRCX', - `${noty.displayName} has left`, - timeout, - opacity, - image - ); - break; - case 'OnPlayerJoining': - AppApi.OVRTNotification( - playOvrtHudNotifications, - playOvrtWristNotifications, - 'VRCX', - `${noty.displayName} is joining`, - timeout, - opacity, - image - ); - break; - case 'GPS': - AppApi.OVRTNotification( - playOvrtHudNotifications, - playOvrtWristNotifications, - 'VRCX', - `${noty.displayName} is in ${displayLocation( - noty.location, - noty.worldName, - noty.groupName - )}`, - timeout, - opacity, - image - ); - break; - case 'Online': - let locationName = ''; - if (noty.worldName) { - locationName = ` to ${displayLocation( - noty.location, - noty.worldName, - noty.groupName - )}`; - } - AppApi.OVRTNotification( - playOvrtHudNotifications, - playOvrtWristNotifications, - 'VRCX', - `${noty.displayName} has logged in${locationName}`, - timeout, - opacity, - image - ); - break; - case 'Offline': - AppApi.OVRTNotification( - playOvrtHudNotifications, - playOvrtWristNotifications, - 'VRCX', - `${noty.displayName} has logged out`, - timeout, - opacity, - image - ); - break; - case 'Status': - AppApi.OVRTNotification( - playOvrtHudNotifications, - playOvrtWristNotifications, - 'VRCX', - `${noty.displayName} status is now ${noty.status} ${noty.statusDescription}`, - timeout, - opacity, - image - ); - break; - case 'invite': - AppApi.OVRTNotification( - playOvrtHudNotifications, - playOvrtWristNotifications, - 'VRCX', - `${ - noty.senderUsername - } has invited you to ${displayLocation( - noty.details.worldId, - noty.details.worldName - )}${message}`, - timeout, - opacity, - image - ); - break; - case 'requestInvite': - AppApi.OVRTNotification( - playOvrtHudNotifications, - playOvrtWristNotifications, - 'VRCX', - `${noty.senderUsername} has requested an invite${message}`, - timeout, - opacity, - image - ); - break; - case 'inviteResponse': - AppApi.OVRTNotification( - playOvrtHudNotifications, - playOvrtWristNotifications, - 'VRCX', - `${noty.senderUsername} has responded to your invite${message}`, - timeout, - opacity, - image - ); - break; - case 'requestInviteResponse': - AppApi.OVRTNotification( - playOvrtHudNotifications, - playOvrtWristNotifications, - 'VRCX', - `${noty.senderUsername} has responded to your invite request${message}`, - timeout, - opacity, - image - ); - break; - case 'friendRequest': - AppApi.OVRTNotification( - playOvrtHudNotifications, - playOvrtWristNotifications, - 'VRCX', - `${noty.senderUsername} has sent you a friend request`, - timeout, - opacity, - image - ); - break; - case 'Friend': - AppApi.OVRTNotification( - playOvrtHudNotifications, - playOvrtWristNotifications, - 'VRCX', - `${noty.displayName} is now your friend`, - timeout, - opacity, - image - ); - break; - case 'Unfriend': - AppApi.OVRTNotification( - playOvrtHudNotifications, - playOvrtWristNotifications, - 'VRCX', - `${noty.displayName} is no longer your friend`, - timeout, - opacity, - image - ); - break; - case 'TrustLevel': - AppApi.OVRTNotification( - playOvrtHudNotifications, - playOvrtWristNotifications, - 'VRCX', - `${noty.displayName} trust level is now ${noty.trustLevel}`, - timeout, - opacity, - image - ); - break; - case 'DisplayName': - AppApi.OVRTNotification( - playOvrtHudNotifications, - playOvrtWristNotifications, - 'VRCX', - `${noty.previousDisplayName} changed their name to ${noty.displayName}`, - timeout, - opacity, - image - ); - break; - case 'boop': - AppApi.OVRTNotification( - playOvrtHudNotifications, - playOvrtWristNotifications, - 'VRCX', - noty.message, - timeout, - opacity, - image - ); - break; - case 'groupChange': - AppApi.OVRTNotification( - playOvrtHudNotifications, - playOvrtWristNotifications, - 'VRCX', - `${noty.senderUsername}: ${noty.message}`, - timeout, - opacity, - image - ); - break; - case 'group.announcement': - AppApi.OVRTNotification( - playOvrtHudNotifications, - playOvrtWristNotifications, - 'VRCX', - noty.message, - timeout, - opacity, - image - ); - break; - case 'group.informative': - AppApi.OVRTNotification( - playOvrtHudNotifications, - playOvrtWristNotifications, - 'VRCX', - noty.message, - timeout, - opacity, - image - ); - break; - case 'group.invite': - AppApi.OVRTNotification( - playOvrtHudNotifications, - playOvrtWristNotifications, - 'VRCX', - noty.message, - timeout, - opacity, - image - ); - break; - case 'group.joinRequest': - AppApi.OVRTNotification( - playOvrtHudNotifications, - playOvrtWristNotifications, - 'VRCX', - noty.message, - timeout, - opacity, - image - ); - break; - case 'group.transfer': - AppApi.OVRTNotification( - playOvrtHudNotifications, - playOvrtWristNotifications, - 'VRCX', - noty.message, - timeout, - opacity, - image - ); - break; - case 'group.queueReady': - AppApi.OVRTNotification( - playOvrtHudNotifications, - playOvrtWristNotifications, - 'VRCX', - noty.message, - timeout, - opacity, - image - ); - break; - case 'instance.closed': - AppApi.OVRTNotification( - playOvrtHudNotifications, - playOvrtWristNotifications, - 'VRCX', - noty.message, - timeout, - opacity, - image - ); - break; - case 'PortalSpawn': - if (noty.displayName) { - AppApi.OVRTNotification( - playOvrtHudNotifications, - playOvrtWristNotifications, - 'VRCX', - `${ - noty.displayName - } has spawned a portal to ${displayLocation( - noty.instanceId, - noty.worldName, - noty.groupName - )}`, - timeout, - opacity, - image - ); - } else { - AppApi.OVRTNotification( - playOvrtHudNotifications, - playOvrtWristNotifications, - 'VRCX', - 'User has spawned a portal', - timeout, - opacity, - image - ); - } - break; - case 'AvatarChange': - AppApi.OVRTNotification( - playOvrtHudNotifications, - playOvrtWristNotifications, - 'VRCX', - `${noty.displayName} changed into avatar ${noty.name}`, - timeout, - opacity, - image - ); - break; - case 'ChatBoxMessage': - AppApi.OVRTNotification( - playOvrtHudNotifications, - playOvrtWristNotifications, - 'VRCX', - `${noty.displayName} said ${noty.text}`, - timeout, - opacity, - image - ); - break; - case 'Event': - AppApi.OVRTNotification( - playOvrtHudNotifications, - playOvrtWristNotifications, - 'VRCX', - noty.data, - timeout, - opacity, - image - ); - break; - case 'External': - AppApi.OVRTNotification( - playOvrtHudNotifications, - playOvrtWristNotifications, - 'VRCX', - noty.message, - timeout, - opacity, - image - ); - break; - case 'VideoPlay': - AppApi.OVRTNotification( - playOvrtHudNotifications, - playOvrtWristNotifications, - 'VRCX', - `Now playing: ${noty.notyName}`, - timeout, - opacity, - image - ); - break; - case 'BlockedOnPlayerJoined': - AppApi.OVRTNotification( - playOvrtHudNotifications, - playOvrtWristNotifications, - 'VRCX', - `Blocked user ${noty.displayName} has joined`, - timeout, - opacity, - image - ); - break; - case 'BlockedOnPlayerLeft': - AppApi.OVRTNotification( - playOvrtHudNotifications, - playOvrtWristNotifications, - 'VRCX', - `Blocked user ${noty.displayName} has left`, - timeout, - opacity, - image - ); - break; - case 'MutedOnPlayerJoined': - AppApi.OVRTNotification( - playOvrtHudNotifications, - playOvrtWristNotifications, - 'VRCX', - `Muted user ${noty.displayName} has joined`, - timeout, - opacity, - image - ); - break; - case 'MutedOnPlayerLeft': - AppApi.OVRTNotification( - playOvrtHudNotifications, - playOvrtWristNotifications, - 'VRCX', - `Muted user ${noty.displayName} has left`, - timeout, - opacity, - image - ); - break; - case 'Blocked': - AppApi.OVRTNotification( - playOvrtHudNotifications, - playOvrtWristNotifications, - 'VRCX', - `${noty.displayName} has blocked you`, - timeout, - opacity, - image - ); - break; - case 'Unblocked': - AppApi.OVRTNotification( - playOvrtHudNotifications, - playOvrtWristNotifications, - 'VRCX', - `${noty.displayName} has unblocked you`, - timeout, - opacity, - image - ); - break; - case 'Muted': - AppApi.OVRTNotification( - playOvrtHudNotifications, - playOvrtWristNotifications, - 'VRCX', - `${noty.displayName} has muted you`, - timeout, - opacity, - image - ); - break; - case 'Unmuted': - AppApi.OVRTNotification( - playOvrtHudNotifications, - playOvrtWristNotifications, - 'VRCX', - `${noty.displayName} has unmuted you`, - timeout, - opacity, - image - ); - break; - } + const text = toNotificationText(result.title, result.body, noty.type); + AppApi.OVRTNotification( + playOvrtHudNotifications, + playOvrtWristNotifications, + 'VRCX', + text, + timeout, + opacity, + image + ); } /** @@ -2372,12 +1290,9 @@ export const useNotificationStore = defineStore('Notification', () => { } else if (noty.sourceUserId) { userId = noty.sourceUserId; } else if (noty.displayName) { - for (const ref of userStore.cachedUsers.values()) { - if (ref.displayName === noty.displayName) { - userId = ref.id; - break; - } - } + userId = + findUserByDisplayName(userStore.cachedUsers, noty.displayName) + ?.id ?? ''; } return userId; } @@ -2494,14 +1409,13 @@ export const useNotificationStore = defineStore('Notification', () => { noty.isFriend = friendStore.friends.has(noty.userId); noty.isFavorite = friendStore.localFavoriteFriends.has(noty.userId); } else if (noty.displayName) { - for (const ref of userStore.cachedUsers.values()) { - if (ref.displayName === noty.displayName) { - noty.isFriend = friendStore.friends.has(ref.id); - noty.isFavorite = friendStore.localFavoriteFriends.has( - ref.id - ); - break; - } + const ref = findUserByDisplayName( + userStore.cachedUsers, + noty.displayName + ); + if (ref) { + noty.isFriend = friendStore.friends.has(ref.id); + noty.isFavorite = friendStore.localFavoriteFriends.has(ref.id); } } const notyFilter = notificationsSettingsStore.sharedFeedFilters.noty; diff --git a/src/stores/user.js b/src/stores/user.js index 72378edd..a31afbb2 100644 --- a/src/stores/user.js +++ b/src/stores/user.js @@ -13,6 +13,7 @@ import { compareByName, compareByUpdatedAt, extractFileId, + findUserByDisplayName, getAllUserMemos, getGroupName, getUserMemo, @@ -1257,11 +1258,10 @@ export const useUserStore = defineStore('User', () => { if (!ref.displayName || ref.displayName.substring(0, 3) === 'ID:') { return; } - for (ctx of cachedUsers.values()) { - if (ctx.displayName === ref.displayName) { - showUserDialog(ctx.id); - return; - } + const found = findUserByDisplayName(cachedUsers, ref.displayName); + if (found) { + showUserDialog(found.id); + return; } searchStore.searchText = ref.displayName; await searchStore.searchUserByDisplayName(ref.displayName);