feat: 개인 메시지 도착 알림과 읽음 동작 복원

Ref와 같이 새 수신 개인 메시지를 10분 동안 알리고 보러가기와 이미읽음에서 최신 읽음 커서를 반영한다. 실시간 중복, 초기 조회 순서, 자기 발신 제외와 모바일 이동을 production Chromium 회귀로 고정한다.
This commit is contained in:
2026-08-27 07:38:08 +00:00
parent 4ca749f0b0
commit 5135c262c7
3 changed files with 333 additions and 4 deletions
@@ -155,6 +155,14 @@ const emitReadModelInvalidation = (page: Page, invalidation: ReturnType<typeof r
);
}, invalidation);
const emitMessagesInvalidation = (page: Page) =>
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) => {
+53 -3
View File
@@ -45,6 +45,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
type MapLayout = Awaited<ReturnType<typeof trpc.world.getMapLayout.query>>;
type CommandTable = Awaited<ReturnType<typeof trpc.turns.getCommandTable.query>>;
type MessageBundle = Awaited<ReturnType<typeof trpc.messages.getRecent.query>>;
type PrivateMessageNotice = { messageId: number };
type MessageContacts = Awaited<ReturnType<typeof trpc.messages.getContacts.query>>;
type BoardAccess = Awaited<ReturnType<typeof trpc.board.getAccess.query>>;
type ReservedTurnView = Awaited<ReturnType<typeof trpc.turns.reserved.getGeneral.query>>['turns'][number];
@@ -128,6 +129,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
const tournamentStage = ref(0);
const tournamentType = ref<TournamentType | null>(null);
const surveyNotice = ref<NonNullable<FrontStatus['latestVote']> | null>(null);
const privateMessageNotice = ref<PrivateMessageNotice | null>(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<ReturnType<typeof trpc.general.getRecentRecords.query>>) => {
@@ -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<unknown>(
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<boolean> => {
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<boolean> => {
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,
+111 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import type { RuntimeNavigationConfig } from '@sammo-ts/common/navigation/menuConfig';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { useMediaQuery } from '@vueuse/core';
import PanelCard from '../components/ui/PanelCard.vue';
@@ -86,6 +86,7 @@ const {
tournamentStage,
tournamentType,
surveyNotice,
privateMessageNotice,
messageDraftText,
targetMailbox,
mailboxGroups,
@@ -128,6 +129,7 @@ const formatRecord = (entry: { text: string; createdAt?: string | Date }, append
};
let surveyNoticeTimer: ReturnType<typeof setTimeout> | null = null;
let privateMessageNoticeTimer: ReturnType<typeof setTimeout> | null = null;
watch(surveyNotice, (notice) => {
if (surveyNoticeTimer) {
clearTimeout(surveyNoticeTimer);
@@ -137,10 +139,22 @@ watch(surveyNotice, (notice) => {
surveyNoticeTimer = setTimeout(() => dashboard.dismissSurveyNotice(), 60_000);
}
});
watch(privateMessageNotice, (notice) => {
if (privateMessageNoticeTimer) {
clearTimeout(privateMessageNoticeTimer);
privateMessageNoticeTimer = null;
}
if (notice) {
privateMessageNoticeTimer = setTimeout(() => dashboard.dismissPrivateMessageNotice(), 10 * 60_000);
}
});
onUnmounted(() => {
if (surveyNoticeTimer) {
clearTimeout(surveyNoticeTimer);
}
if (privateMessageNoticeTimer) {
clearTimeout(privateMessageNoticeTimer);
}
dashboard.stopRealtime();
window.removeEventListener('storage', handleMobilePanelStorage);
document.removeEventListener(MOBILE_MAIN_PANEL_ORDER_CHANGED_EVENT, reloadMobilePanelOrder);
@@ -196,6 +210,13 @@ const moveQuick = (item: QuickNavigationItem) => {
document.querySelector(item.selector)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
const acknowledgePrivateMessageNotice = async (moveToMessage: boolean) => {
if (!(await dashboard.acknowledgePrivateMessageNotice())) return;
if (!moveToMessage) return;
await nextTick();
document.querySelector('.PrivateTalk > .stickyAnchor')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
const handleNavigationAction = (action: NonNullable<MainNavigationLink['action']>) => {
if (action === 'show-version') versionDialog.value?.showModal();
};
@@ -265,6 +286,31 @@ watch(
<RouterLink to="/survey">새로운 설문조사가 있습니다.</RouterLink>
</aside>
<aside
v-if="privateMessageNotice"
class="private-message-notice"
role="status"
aria-live="polite"
data-testid="private-message-notice"
>
<div class="private-message-notice-title">
<strong>새로운 개인 메시지</strong>
<button
type="button"
class="private-message-notice-close"
aria-label="개인 메시지 알림 닫기"
@click="dashboard.dismissPrivateMessageNotice"
>
×
</button>
</div>
<p>새로운 개인 메시지가 도착했습니다.</p>
<div class="private-message-notice-actions">
<button type="button" @click="acknowledgePrivateMessageNotice(true)">보러가기</button>
<button type="button" @click="acknowledgePrivateMessageNotice(false)">이미읽음</button>
</div>
</aside>
<section v-if="isMobile" class="layout-mobile">
<template v-for="(panelId, panelIndex) in mobilePanelOrder" :key="panelId">
<div v-if="panelId === 'commands'" class="mobile-panel" data-mobile-panel-id="commands">
@@ -751,6 +797,65 @@ button {
text-decoration: underline;
}
.private-message-notice {
position: fixed;
z-index: 1080;
top: 16px;
right: 16px;
box-sizing: border-box;
width: min(350px, calc(100vw - 32px));
border: 1px solid rgba(91, 145, 207, 0.85);
border-radius: 4px;
background: rgba(12, 12, 12, 0.96);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.45);
color: #fff;
font-size: 14px;
line-height: 1.3;
}
.private-message-notice-title {
display: flex;
min-height: 35px;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid rgba(91, 145, 207, 0.55);
padding: 8px 12px;
color: #8cb9eb;
}
.private-message-notice-close {
padding: 0 4px;
cursor: pointer;
font-size: 20px;
line-height: 1;
}
.private-message-notice > p {
margin: 0;
padding: 12px;
}
.private-message-notice-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 0 12px 12px;
}
.private-message-notice-actions button {
border: 1px solid #8cb9eb;
border-radius: 3px;
padding: 5px 9px;
color: #fff;
background: rgba(91, 145, 207, 0.18);
cursor: pointer;
}
.private-message-notice-actions button:hover,
.private-message-notice-actions button:focus-visible {
background: rgba(91, 145, 207, 0.38);
}
.layout-desktop {
display: grid;
grid-template-columns: repeat(10, minmax(0, 1fr));
@@ -973,6 +1078,11 @@ button {
bottom: 16px;
}
.private-message-notice {
z-index: 90;
top: 16px;
}
.layout-mobile [data-main-target='world-history'] {
height: 359px;
min-height: 0;