diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index e2e9206f..282609ca 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -155,6 +155,14 @@ const emitReadModelInvalidation = (page: Page, invalidation: ReturnType + page.evaluate(() => { + (window as unknown as { __emitMainRealtime: (type: string, value: unknown) => void }).__emitMainRealtime( + 'messagesInvalidated', + {} + ); + }); + const waitForMainRealtime = (page: Page) => expect .poll(() => @@ -400,6 +408,30 @@ const emptyMessages = (permission: number) => ({ canRespondDiplomacy: false, }); +const privateMessage = (id: number, srcGeneralId: number) => ({ + id, + msgType: 'private', + src: { + generalId: srcGeneralId, + generalName: srcGeneralId === 7 ? '메뉴검증장수' : '보낸장수', + nationId: 1, + nationName: '위', + color: '#008000', + icon: '', + }, + dest: { + generalId: srcGeneralId === 7 ? 9 : 7, + generalName: srcGeneralId === 7 ? '받는장수' : '메뉴검증장수', + nationId: 1, + nationName: '위', + color: '#008000', + icon: '', + }, + text: `개인 메시지 ${id}`, + option: null, + time: '0185-01-01 00:00:00', +}); + const generalContext = (state: NavigationFixture) => ({ general: { id: 7, @@ -1201,6 +1233,143 @@ test('keeps the active survey title after voting without reopening the new-surve await expect(page.locator('.survey-notice')).toHaveCount(0); }); +test('notifies only for a new incoming private message and marks it read from the notice', async ({ + page, +}, testInfo) => { + const state: NavigationFixture = { + officerLevel: 5, + permission: 2, + nationLevel: 3, + stage: 0, + npcMode: 1, + latestVote: null, + generalMeCalls: 0, + operations: [], + messages: { + ...emptyMessages(2), + private: [privateMessage(19, 9)], + latestRead: { private: 19, diplomacy: 0 }, + }, + }; + await installRealtimeHarness(page); + await installFixture(page, state); + await page.setViewportSize({ width: 1200, height: 900 }); + await waitForMain(page); + await waitForMainRealtime(page); + + const initialMessageRefreshes = state.operations.filter((operation) => operation === 'messages.getRecent').length; + state.messages = { + ...emptyMessages(2), + private: [privateMessage(20, 7), privateMessage(19, 9)], + latestRead: { private: 19, diplomacy: 0 }, + }; + await emitMessagesInvalidation(page); + await expect + .poll(() => state.operations.filter((operation) => operation === 'messages.getRecent').length) + .toBe(initialMessageRefreshes + 1); + await expect(page.getByTestId('private-message-notice')).toHaveCount(0); + + state.messages = { + ...emptyMessages(2), + private: [privateMessage(21, 9), privateMessage(20, 7), privateMessage(19, 9)], + latestRead: { private: 19, diplomacy: 0 }, + }; + await emitMessagesInvalidation(page); + const notice = page.getByTestId('private-message-notice'); + await expect(notice).toContainText('새로운 개인 메시지'); + await expect(notice).toContainText('새로운 개인 메시지가 도착했습니다.'); + await expect(notice.getByRole('button', { name: '보러가기' })).toBeVisible(); + await expect(notice.getByRole('button', { name: '이미읽음' })).toBeVisible(); + const desktopNoticeGeometry = await notice.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, + position: style.position, + backgroundColor: style.backgroundColor, + border: style.border, + fontSize: style.fontSize, + lineHeight: style.lineHeight, + }; + }); + expect(desktopNoticeGeometry.position).toBe('fixed'); + expect(desktopNoticeGeometry.rect.x + desktopNoticeGeometry.rect.width).toBeLessThanOrEqual(1200); + await testInfo.attach('desktop-private-message-notice.json', { + body: Buffer.from(`${JSON.stringify(desktopNoticeGeometry, null, 2)}\n`), + contentType: 'application/json', + }); + await testInfo.attach('desktop-private-message-notice.png', { + body: await notice.screenshot(), + contentType: 'image/png', + }); + + await emitMessagesInvalidation(page); + await expect(notice).toBeVisible(); + await expect(notice).toHaveCount(1); + + await notice.getByRole('button', { name: '이미읽음' }).click(); + await expect(notice).toHaveCount(0); + await expect(page.locator('.PrivateTalk .btn-more-small')).toBeDisabled(); + await expect + .poll(() => + state.trpcRequests?.some( + ({ operations, body }) => + operations.includes('messages.readLatest') && + JSON.stringify(body).includes('"type":"private"') && + JSON.stringify(body).includes('"messageId":21') + ) + ) + .toBe(true); +}); + +test('the private-message notice moves a mobile reader to the private section', async ({ page }, testInfo) => { + const state: NavigationFixture = { + officerLevel: 5, + permission: 2, + nationLevel: 3, + stage: 0, + npcMode: 1, + latestVote: null, + generalMeCalls: 0, + operations: [], + messages: { ...emptyMessages(2), private: [privateMessage(31, 9)] }, + }; + await installFixture(page, state); + await page.setViewportSize({ width: 500, height: 800 }); + await waitForMain(page); + + const notice = page.getByTestId('private-message-notice'); + await expect(notice).toBeVisible(); + const mobileNoticeGeometry = await notice.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, + position: style.position, + fontSize: style.fontSize, + lineHeight: style.lineHeight, + }; + }); + expect(mobileNoticeGeometry.rect.width).toBeLessThanOrEqual(468); + await testInfo.attach('mobile-private-message-notice.json', { + body: Buffer.from(`${JSON.stringify(mobileNoticeGeometry, null, 2)}\n`), + contentType: 'application/json', + }); + await testInfo.attach('mobile-private-message-notice.png', { + body: await notice.screenshot(), + contentType: 'image/png', + }); + await notice.getByRole('button', { name: '보러가기' }).click(); + + await expect(notice).toHaveCount(0); + await expect + .poll(() => + page.locator('.PrivateTalk > .stickyAnchor').evaluate((element) => element.getBoundingClientRect().top) + ) + .toBeLessThan(80); + expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(500); +}); + test('desktop menus preserve ref columns, prefix-safe routes, and controlled dropdown behavior', async ({ page, }, testInfo) => { diff --git a/app/game-frontend/src/stores/mainDashboard.ts b/app/game-frontend/src/stores/mainDashboard.ts index 8fbd5447..5af8e874 100644 --- a/app/game-frontend/src/stores/mainDashboard.ts +++ b/app/game-frontend/src/stores/mainDashboard.ts @@ -45,6 +45,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { type MapLayout = Awaited>; type CommandTable = Awaited>; type MessageBundle = Awaited>; + type PrivateMessageNotice = { messageId: number }; type MessageContacts = Awaited>; type BoardAccess = Awaited>; type ReservedTurnView = Awaited>['turns'][number]; @@ -128,6 +129,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { const tournamentStage = ref(0); const tournamentType = ref(null); const surveyNotice = ref | null>(null); + const privateMessageNotice = ref(null); + let dismissedPrivateMessageId = 0; let lastGeneralRecordId = 0; let lastWorldHistoryId = 0; let recordGeneralId: number | null = null; @@ -323,6 +326,31 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { surveyNotice.value = null; }; + const reconcilePrivateMessageNotice = (nextMessages: MessageBundle | null) => { + const viewerGeneralId = general.value?.id; + if (!nextMessages || !viewerGeneralId) { + privateMessageNotice.value = null; + return; + } + const newestIncomingId = nextMessages.private + .filter((message) => message.src.generalId !== viewerGeneralId) + .reduce((latest, message) => Math.max(latest, message.id), 0); + const latestReadId = nextMessages.latestRead.private; + if (dismissedPrivateMessageId <= latestReadId) dismissedPrivateMessageId = 0; + if (newestIncomingId <= latestReadId || newestIncomingId <= dismissedPrivateMessageId) { + privateMessageNotice.value = null; + return; + } + if (privateMessageNotice.value?.messageId !== newestIncomingId) { + privateMessageNotice.value = { messageId: newestIncomingId }; + } + }; + + const dismissPrivateMessageNotice = () => { + dismissedPrivateMessageId = Math.max(dismissedPrivateMessageId, privateMessageNotice.value?.messageId ?? 0); + privateMessageNotice.value = null; + }; + const mergeRecentRecords = (current: RecentRecord[], incoming: RecentRecord[]): RecentRecord[] => { const merged = new Map(current.map((entry) => [entry.id, entry])); for (const entry of incoming) { @@ -340,6 +368,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { recordGeneralId = id; frontStatus.value = null; surveyNotice.value = null; + privateMessageNotice.value = null; + dismissedPrivateMessageId = 0; }; const applyRecentRecords = (records: Awaited>) => { @@ -415,7 +445,14 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { commandTableSnapshot = patch.commandTable ?? undefined; commandTable.value = structurallyShare(commandTable.value, patch.commandTable); } - if (patch.messages !== undefined) messages.value = structurallyShare(messages.value, patch.messages); + if (patch.messages !== undefined) { + messages.value = structurallyShare(messages.value, patch.messages); + reconcilePrivateMessageNotice(messages.value); + } else if (patch.contextSnapshot !== undefined || patch.general !== undefined) { + // Main data is fetched concurrently, so the message bundle can arrive + // before the authenticated general context needed to identify senders. + reconcilePrivateMessageNotice(messages.value); + } if (patch.messageContacts !== undefined) { messageContacts.value = structurallyShare(messageContacts.value, patch.messageContacts); } @@ -650,6 +687,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { lobbyInfo.value = structurallyShare(lobbyInfo.value, lobby); worldMap.value = structurallyShare(worldMap.value, map); messages.value = structurallyShare(messages.value, messageData); + reconcilePrivateMessageNotice(messages.value); messageContacts.value = structurallyShare(messageContacts.value, contacts); reservedGeneralTurns.value = structurallyShare( reservedGeneralTurns.value, @@ -902,10 +940,10 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { } }; - const readLatestMessage = async (type: 'private' | 'diplomacy', messageId: number) => { + const readLatestMessage = async (type: 'private' | 'diplomacy', messageId: number): Promise => { const id = generalId.value; if (!id || messageId <= 0) { - return; + return false; } try { await trpc.messages.readLatest.mutate({ @@ -921,12 +959,21 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { [type]: Math.max(messages.value.latestRead[type], messageId), }, }; + reconcilePrivateMessageNotice(messages.value); } + return true; } catch (err) { error.value = resolveErrorMessage(err); + return false; } }; + const acknowledgePrivateMessageNotice = async (): Promise => { + const messageId = privateMessageNotice.value?.messageId; + if (!messageId) return false; + return readLatestMessage('private', messageId); + }; + const deleteMessage = async (messageId: number) => { const id = generalId.value; if (!id) { @@ -1349,6 +1396,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { tournamentStage, tournamentType, surveyNotice, + privateMessageNotice, messageDraftText, targetMailbox, mailboxGroups, @@ -1358,6 +1406,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { startRealtime, stopRealtime, dismissSurveyNotice, + dismissPrivateMessageNotice, + acknowledgePrivateMessageNotice, loadMainData, refreshMessages, sendMessage, diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index 1a782515..0411673b 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -1,7 +1,7 @@