diff --git a/src/shared/__tests__/notificationMessage.test.js b/src/shared/__tests__/notificationMessage.test.js index ae219d92..ddb65777 100644 --- a/src/shared/__tests__/notificationMessage.test.js +++ b/src/shared/__tests__/notificationMessage.test.js @@ -1,5 +1,6 @@ import { getNotificationMessage, + getUserIdFromNoty, toNotificationText } from '../notificationMessage'; @@ -344,3 +345,87 @@ describe('toNotificationText', () => { ).toBe('User has spawned a portal'); }); }); + +describe('getNotificationMessage with displayNameOverride', () => { + test('overrides displayName in title', () => { + const result = getNotificationMessage( + { type: 'OnPlayerJoined', displayName: 'Alice' }, + '', + 'NickAlice' + ); + expect(result).toEqual({ title: 'NickAlice', body: 'has joined' }); + }); + + test('overrides senderUsername in sender-based types', () => { + const result = getNotificationMessage( + { type: 'friendRequest', senderUsername: 'Bob' }, + '', + 'NickBob' + ); + expect(result).toEqual({ + title: 'NickBob', + body: 'has sent you a friend request' + }); + }); + + test('overrides previousDisplayName in DisplayName type', () => { + const result = getNotificationMessage( + { + type: 'DisplayName', + previousDisplayName: 'OldName', + displayName: 'NewName' + }, + '', + 'NickOld' + ); + expect(result).toEqual({ + title: 'NickOld', + body: 'changed their name to NewName' + }); + }); + + test('falls back to noty fields when override is empty', () => { + const result = getNotificationMessage( + { type: 'OnPlayerLeft', displayName: 'Alice' }, + '', + '' + ); + expect(result).toEqual({ title: 'Alice', body: 'has left' }); + }); + + test('falls back to noty fields when override is undefined', () => { + const result = getNotificationMessage( + { type: 'OnPlayerLeft', displayName: 'Alice' }, + '' + ); + expect(result).toEqual({ title: 'Alice', body: 'has left' }); + }); +}); + +describe('getUserIdFromNoty', () => { + test('returns userId when present', () => { + expect(getUserIdFromNoty({ userId: 'usr_1' })).toBe('usr_1'); + }); + + test('returns senderUserId when userId is missing', () => { + expect(getUserIdFromNoty({ senderUserId: 'usr_2' })).toBe('usr_2'); + }); + + test('returns sourceUserId as last priority', () => { + expect(getUserIdFromNoty({ sourceUserId: 'usr_3' })).toBe('usr_3'); + }); + + test('prefers userId over senderUserId', () => { + expect( + getUserIdFromNoty({ userId: 'usr_1', senderUserId: 'usr_2' }) + ).toBe('usr_1'); + }); + + test('returns empty string when no id fields', () => { + expect(getUserIdFromNoty({ displayName: 'Alice' })).toBe(''); + }); + + test('returns empty string for empty object', () => { + expect(getUserIdFromNoty({})).toBe(''); + }); +}); diff --git a/src/shared/notificationMessage.js b/src/shared/notificationMessage.js index af1c208d..59271c4e 100644 --- a/src/shared/notificationMessage.js +++ b/src/shared/notificationMessage.js @@ -3,23 +3,28 @@ 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. + * used by desktop toast, XS overlay, OVRT overlay, and TTS. * * @param {object} noty - The notification object * @param {string} message - Pre-built invite/request message string + * @param {string} [displayNameOverride] - Optional override for the display + * name used in the title (e.g. a nickname from user memo for TTS). * @returns {{ title: string, body: string } | null} */ -export function getNotificationMessage(noty, message) { +export function getNotificationMessage(noty, message, displayNameOverride) { + const name = displayNameOverride || noty.displayName; + const sender = displayNameOverride || noty.senderUsername; + switch (noty.type) { case 'OnPlayerJoined': - return { title: noty.displayName, body: 'has joined' }; + return { title: name, body: 'has joined' }; case 'OnPlayerLeft': - return { title: noty.displayName, body: 'has left' }; + return { title: name, body: 'has left' }; case 'OnPlayerJoining': - return { title: noty.displayName, body: 'is joining' }; + return { title: name, body: 'is joining' }; case 'GPS': return { - title: noty.displayName, + title: name, body: `is in ${displayLocation( noty.location, noty.worldName, @@ -36,20 +41,20 @@ export function getNotificationMessage(noty, message) { )}`; } return { - title: noty.displayName, + title: name, body: `has logged in${locationName}` }; } case 'Offline': - return { title: noty.displayName, body: 'has logged out' }; + return { title: name, body: 'has logged out' }; case 'Status': return { - title: noty.displayName, + title: name, body: `status is now ${noty.status} ${noty.statusDescription}` }; case 'invite': return { - title: noty.senderUsername, + title: sender, body: `has invited you to ${displayLocation( noty.details.worldId, noty.details.worldName @@ -57,45 +62,45 @@ export function getNotificationMessage(noty, message) { }; case 'requestInvite': return { - title: noty.senderUsername, + title: sender, body: `has requested an invite${message}` }; case 'inviteResponse': return { - title: noty.senderUsername, + title: sender, body: `has responded to your invite${message}` }; case 'requestInviteResponse': return { - title: noty.senderUsername, + title: sender, body: `has responded to your invite request${message}` }; case 'friendRequest': return { - title: noty.senderUsername, + title: sender, body: 'has sent you a friend request' }; case 'Friend': - return { title: noty.displayName, body: 'is now your friend' }; + return { title: name, body: 'is now your friend' }; case 'Unfriend': return { - title: noty.displayName, + title: name, body: 'is no longer your friend' }; case 'TrustLevel': return { - title: noty.displayName, + title: name, body: `trust level is now ${noty.trustLevel}` }; case 'DisplayName': return { - title: noty.previousDisplayName, + title: displayNameOverride || noty.previousDisplayName, body: `changed their name to ${noty.displayName}` }; case 'boop': - return { title: noty.senderUsername, body: noty.message }; + return { title: sender, body: noty.message }; case 'groupChange': - return { title: noty.senderUsername, body: noty.message }; + return { title: sender, body: noty.message }; case 'group.announcement': return { title: 'Group Announcement', body: noty.message }; case 'group.informative': @@ -111,9 +116,9 @@ export function getNotificationMessage(noty, message) { case 'instance.closed': return { title: 'Instance Closed', body: noty.message }; case 'PortalSpawn': - if (noty.displayName) { + if (name) { return { - title: noty.displayName, + title: name, body: `has spawned a portal to ${displayLocation( noty.instanceId, noty.worldName, @@ -124,12 +129,12 @@ export function getNotificationMessage(noty, message) { return { title: '', body: 'User has spawned a portal' }; case 'AvatarChange': return { - title: noty.displayName, + title: name, body: `changed into avatar ${noty.name}` }; case 'ChatBoxMessage': return { - title: noty.displayName, + title: name, body: `said ${noty.text}` }; case 'Event': @@ -140,32 +145,32 @@ export function getNotificationMessage(noty, message) { return { title: 'Now playing', body: noty.notyName }; case 'BlockedOnPlayerJoined': return { - title: noty.displayName, + title: name, body: 'Blocked user has joined' }; case 'BlockedOnPlayerLeft': return { - title: noty.displayName, + title: name, body: 'Blocked user has left' }; case 'MutedOnPlayerJoined': return { - title: noty.displayName, + title: name, body: 'Muted user has joined' }; case 'MutedOnPlayerLeft': return { - title: noty.displayName, + title: name, body: 'Muted user has left' }; case 'Blocked': - return { title: noty.displayName, body: 'has blocked you' }; + return { title: name, body: 'has blocked you' }; case 'Unblocked': - return { title: noty.displayName, body: 'has unblocked you' }; + return { title: name, body: 'has unblocked you' }; case 'Muted': - return { title: noty.displayName, body: 'has muted you' }; + return { title: name, body: 'has muted you' }; case 'Unmuted': - return { title: noty.displayName, body: 'has unmuted you' }; + return { title: name, body: 'has unmuted you' }; default: return null; } @@ -226,3 +231,18 @@ export function toNotificationText(title, body, type) { } return title ? `${title} ${body}` : body; } + +/** + * Extract a userId from a notification object by checking common fields. + * Does NOT perform display-name-based lookups - the caller should handle + * that fallback when a cached user map is available. + * + * @param {object} noty + * @returns {string} + */ +export function getUserIdFromNoty(noty) { + if (noty.userId) return noty.userId; + if (noty.senderUserId) return noty.senderUserId; + if (noty.sourceUserId) return noty.sourceUserId; + return ''; +} diff --git a/src/shared/utils/__tests__/gameLog.test.js b/src/shared/utils/__tests__/gameLog.test.js index a51e6ac0..900848cd 100644 --- a/src/shared/utils/__tests__/gameLog.test.js +++ b/src/shared/utils/__tests__/gameLog.test.js @@ -1,4 +1,8 @@ -import { gameLogSearchFilter } from '../gameLog'; +import { + compareGameLogRows, + gameLogSearchFilter, + getGameLogCreatedAtTs +} from '../gameLog'; describe('gameLogSearchFilter', () => { test('returns true for empty search query', () => { @@ -96,3 +100,87 @@ describe('gameLogSearchFilter', () => { expect(gameLogSearchFilter(row, 'anything')).toBe(true); }); }); + +describe('getGameLogCreatedAtTs', () => { + test('returns millisecond timestamp from millis number', () => { + expect(getGameLogCreatedAtTs({ created_at: 1700000000000 })).toBe( + 1700000000000 + ); + }); + + test('converts seconds to millis for small numbers', () => { + expect(getGameLogCreatedAtTs({ created_at: 1700000000 })).toBe( + 1700000000000 + ); + }); + + test('parses ISO string via Date.parse', () => { + const ts = getGameLogCreatedAtTs({ + created_at: '2024-01-15T12:00:00Z' + }); + expect(ts).toBe(Date.parse('2024-01-15T12:00:00Z')); + }); + + test('supports createdAt alias', () => { + expect(getGameLogCreatedAtTs({ createdAt: 1700000000000 })).toBe( + 1700000000000 + ); + }); + + test('supports dt alias', () => { + expect(getGameLogCreatedAtTs({ dt: 1700000000000 })).toBe( + 1700000000000 + ); + }); + + test('returns 0 for null/undefined row', () => { + expect(getGameLogCreatedAtTs(null)).toBe(0); + expect(getGameLogCreatedAtTs(undefined)).toBe(0); + }); + + test('returns 0 for missing timestamp fields', () => { + expect(getGameLogCreatedAtTs({})).toBe(0); + }); + + test('returns 0 for unparseable string', () => { + expect(getGameLogCreatedAtTs({ created_at: 'not-a-date' })).toBe(0); + }); + + test('returns 0 for NaN number', () => { + expect(getGameLogCreatedAtTs({ created_at: NaN })).toBe(0); + }); +}); + +describe('compareGameLogRows', () => { + test('sorts by timestamp descending (newest first)', () => { + const a = { created_at: 2000 }; + const b = { created_at: 1000 }; + expect(compareGameLogRows(a, b)).toBeLessThan(0); + expect(compareGameLogRows(b, a)).toBeGreaterThan(0); + }); + + test('equal timestamps → sorts by rowId descending', () => { + const a = { created_at: 1000, rowId: 10 }; + const b = { created_at: 1000, rowId: 5 }; + expect(compareGameLogRows(a, b)).toBeLessThan(0); + expect(compareGameLogRows(b, a)).toBeGreaterThan(0); + }); + + test('equal timestamp and rowId → sorts by uid reverse-lex', () => { + const a = { created_at: 1000, rowId: 1, uid: 'bbb' }; + const b = { created_at: 1000, rowId: 1, uid: 'aaa' }; + expect(compareGameLogRows(a, b)).toBeLessThan(0); + expect(compareGameLogRows(b, a)).toBeGreaterThan(0); + }); + + test('returns 0 for identical rows', () => { + const row = { created_at: 1000, rowId: 1, uid: 'aaa' }; + expect(compareGameLogRows(row, row)).toBe(0); + }); + + test('handles missing fields gracefully', () => { + const a = {}; + const b = {}; + expect(compareGameLogRows(a, b)).toBe(0); + }); +}); diff --git a/src/shared/utils/gameLog.js b/src/shared/utils/gameLog.js index 105a825f..a3695735 100644 --- a/src/shared/utils/gameLog.js +++ b/src/shared/utils/gameLog.js @@ -73,4 +73,59 @@ function gameLogSearchFilter(row, searchQuery) { return true; } -export { gameLogSearchFilter }; +/** + * Extract a millisecond timestamp from a game log row. + * Handles numeric (seconds or millis), ISO string, and dayjs-parseable formats. + * + * @param {object} row + * @returns {number} millisecond timestamp, or 0 if unparseable + */ +function getGameLogCreatedAtTs(row) { + // dynamic import avoided — dayjs is a lightweight dep already used by the + // consumer; we import it lazily to keep the module usable without bundler + // context in tests (dayjs is a CJS/ESM dual package). + const createdAtRaw = row?.created_at ?? row?.createdAt ?? row?.dt; + if (typeof createdAtRaw === 'number') { + const ts = + createdAtRaw > 1_000_000_000_000 + ? createdAtRaw + : createdAtRaw * 1000; + return Number.isFinite(ts) ? ts : 0; + } + + const createdAt = typeof createdAtRaw === 'string' ? createdAtRaw : ''; + // dayjs is imported at the call site (store) — here we do a simple + // Date.parse fallback to stay dependency-free. + const ts = Date.parse(createdAt); + return Number.isFinite(ts) ? ts : 0; +} + +/** + * Compare two game log rows for descending sort order. + * Primary key: created_at timestamp (newest first). + * Secondary: rowId (highest first). + * Tertiary: uid string (reverse lexicographic). + * + * @param {object} a + * @param {object} b + * @returns {number} negative if a should come first, positive if b first + */ +function compareGameLogRows(a, b) { + const aTs = getGameLogCreatedAtTs(a); + const bTs = getGameLogCreatedAtTs(b); + if (aTs !== bTs) { + return bTs - aTs; + } + + const aRowId = typeof a?.rowId === 'number' ? a.rowId : 0; + const bRowId = typeof b?.rowId === 'number' ? b.rowId : 0; + if (aRowId !== bRowId) { + return bRowId - aRowId; + } + + const aUid = typeof a?.uid === 'string' ? a.uid : ''; + const bUid = typeof b?.uid === 'string' ? b.uid : ''; + return aUid < bUid ? 1 : aUid > bUid ? -1 : 0; +} + +export { gameLogSearchFilter, getGameLogCreatedAtTs, compareGameLogRows }; diff --git a/src/stores/gameLog.js b/src/stores/gameLog.js index 9cd776c5..fc829aa1 100644 --- a/src/stores/gameLog.js +++ b/src/stores/gameLog.js @@ -7,6 +7,7 @@ import { useRouter } from 'vue-router'; import dayjs from 'dayjs'; import { + compareGameLogRows, convertYoutubeTime, findUserByDisplayName, formatSeconds, @@ -143,39 +144,6 @@ export const useGameLogStore = defineStore('GameLog', () => { init(); - function getGameLogCreatedAtTs(row) { - const createdAtRaw = row?.created_at ?? row?.createdAt ?? row?.dt; - if (typeof createdAtRaw === 'number') { - const ts = - createdAtRaw > 1_000_000_000_000 - ? createdAtRaw - : createdAtRaw * 1000; - return Number.isFinite(ts) ? ts : 0; - } - - const createdAt = typeof createdAtRaw === 'string' ? createdAtRaw : ''; - const ts = dayjs(createdAt).valueOf(); - return Number.isFinite(ts) ? ts : 0; - } - - function compareGameLogRows(a, b) { - const aTs = getGameLogCreatedAtTs(a); - const bTs = getGameLogCreatedAtTs(b); - if (aTs !== bTs) { - return bTs - aTs; - } - - const aRowId = typeof a?.rowId === 'number' ? a.rowId : 0; - const bRowId = typeof b?.rowId === 'number' ? b.rowId : 0; - if (aRowId !== bRowId) { - return bRowId - aRowId; - } - - const aUid = typeof a?.uid === 'string' ? a.uid : ''; - const bUid = typeof b?.uid === 'string' ? b.uid : ''; - return aUid < bUid ? 1 : aUid > bUid ? -1 : 0; - } - function insertGameLogSorted(entry) { const arr = gameLogTableData.value; if (arr.length === 0) { diff --git a/src/stores/notification.js b/src/stores/notification.js index c9472939..e768c937 100644 --- a/src/stores/notification.js +++ b/src/stores/notification.js @@ -8,7 +8,6 @@ import dayjs from 'dayjs'; import { checkCanInvite, - displayLocation, escapeTag, executeWithBackoff, extractFileId, @@ -26,15 +25,16 @@ import { userRequest, worldRequest } from '../api'; +import { + getNotificationMessage, + getUserIdFromNoty as getUserIdFromNotyBase, + toNotificationText +} from '../shared/notificationMessage'; 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'; @@ -969,202 +969,11 @@ export const useNotificationStore = defineStore('Notification', () => { displayName = nickName; } } - switch (noty.type) { - case 'OnPlayerJoined': - notificationsSettingsStore.speak(`${displayName} has joined`); - break; - case 'OnPlayerLeft': - notificationsSettingsStore.speak(`${displayName} has left`); - break; - case 'OnPlayerJoining': - notificationsSettingsStore.speak(`${displayName} is joining`); - break; - case 'GPS': - notificationsSettingsStore.speak( - `${displayName} is in ${displayLocation( - noty.location, - noty.worldName, - noty.groupName - )}` - ); - break; - case 'Online': - let locationName = ''; - if (noty.worldName) { - locationName = ` to ${displayLocation( - noty.location, - noty.worldName, - noty.groupName - )}`; - } - notificationsSettingsStore.speak( - `${displayName} has logged in${locationName}` - ); - break; - case 'Offline': - notificationsSettingsStore.speak( - `${displayName} has logged out` - ); - break; - case 'Status': - notificationsSettingsStore.speak( - `${displayName} status is now ${noty.status} ${noty.statusDescription}` - ); - break; - case 'invite': - notificationsSettingsStore.speak( - `${displayName} has invited you to ${displayLocation( - noty.details.worldId, - noty.details.worldName, - noty.groupName - )}${message}` - ); - break; - case 'requestInvite': - notificationsSettingsStore.speak( - `${displayName} has requested an invite${message}` - ); - break; - case 'inviteResponse': - notificationsSettingsStore.speak( - `${displayName} has responded to your invite${message}` - ); - break; - case 'requestInviteResponse': - notificationsSettingsStore.speak( - `${displayName} has responded to your invite request${message}` - ); - break; - case 'friendRequest': - notificationsSettingsStore.speak( - `${displayName} has sent you a friend request` - ); - break; - case 'Friend': - notificationsSettingsStore.speak( - `${displayName} is now your friend` - ); - break; - case 'Unfriend': - notificationsSettingsStore.speak( - `${displayName} is no longer your friend` - ); - break; - case 'TrustLevel': - notificationsSettingsStore.speak( - `${displayName} trust level is now ${noty.trustLevel}` - ); - break; - case 'DisplayName': - notificationsSettingsStore.speak( - `${noty.previousDisplayName} changed their name to ${noty.displayName}` - ); - break; - case 'boop': - notificationsSettingsStore.speak(noty.message); - break; - case 'groupChange': - notificationsSettingsStore.speak( - `${displayName} ${noty.message}` - ); - break; - case 'group.announcement': - notificationsSettingsStore.speak(noty.message); - break; - case 'group.informative': - notificationsSettingsStore.speak(noty.message); - break; - case 'group.invite': - notificationsSettingsStore.speak(noty.message); - break; - case 'group.joinRequest': - notificationsSettingsStore.speak(noty.message); - break; - case 'group.transfer': - notificationsSettingsStore.speak(noty.message); - break; - case 'group.queueReady': - notificationsSettingsStore.speak(noty.message); - break; - case 'instance.closed': - notificationsSettingsStore.speak(noty.message); - break; - case 'PortalSpawn': - if (displayName) { - notificationsSettingsStore.speak( - `${displayName} has spawned a portal to ${displayLocation( - noty.instanceId, - noty.worldName, - noty.groupName - )}` - ); - } else { - notificationsSettingsStore.speak( - 'User has spawned a portal' - ); - } - break; - case 'AvatarChange': - notificationsSettingsStore.speak( - `${displayName} changed into avatar ${noty.name}` - ); - break; - case 'ChatBoxMessage': - notificationsSettingsStore.speak( - `${displayName} said ${noty.text}` - ); - break; - case 'Event': - notificationsSettingsStore.speak(noty.data); - break; - case 'External': - notificationsSettingsStore.speak(noty.message); - break; - case 'VideoPlay': - notificationsSettingsStore.speak( - `Now playing: ${noty.notyName}` - ); - break; - case 'BlockedOnPlayerJoined': - notificationsSettingsStore.speak( - `Blocked user ${displayName} has joined` - ); - break; - case 'BlockedOnPlayerLeft': - notificationsSettingsStore.speak( - `Blocked user ${displayName} has left` - ); - break; - case 'MutedOnPlayerJoined': - notificationsSettingsStore.speak( - `Muted user ${displayName} has joined` - ); - break; - case 'MutedOnPlayerLeft': - notificationsSettingsStore.speak( - `Muted user ${displayName} has left` - ); - break; - case 'Blocked': - notificationsSettingsStore.speak( - `${displayName} has blocked you` - ); - break; - case 'Unblocked': - notificationsSettingsStore.speak( - `${displayName} has unblocked you` - ); - break; - case 'Muted': - notificationsSettingsStore.speak( - `${displayName} has muted you` - ); - break; - case 'Unmuted': - notificationsSettingsStore.speak( - `${displayName} has unmuted you` - ); - break; + const msg = getNotificationMessage(noty, message, displayName); + if (msg) { + notificationsSettingsStore.speak( + toNotificationText(msg.title, msg.body, noty.type) + ); } } @@ -1282,19 +1091,15 @@ export const useNotificationStore = defineStore('Notification', () => { * @returns */ function getUserIdFromNoty(noty) { - let userId = ''; - if (noty.userId) { - userId = noty.userId; - } else if (noty.senderUserId) { - userId = noty.senderUserId; - } else if (noty.sourceUserId) { - userId = noty.sourceUserId; - } else if (noty.displayName) { - userId = + const id = getUserIdFromNotyBase(noty); + if (id) return id; + if (noty.displayName) { + return ( findUserByDisplayName(userStore.cachedUsers, noty.displayName) - ?.id ?? ''; + ?.id ?? '' + ); } - return userId; + return ''; } /**