feat: 개인 메시지 도착 알림과 읽음 동작 복원
Ref와 같이 새 수신 개인 메시지를 10분 동안 알리고 보러가기와 이미읽음에서 최신 읽음 커서를 반영한다. 실시간 중복, 초기 조회 순서, 자기 발신 제외와 모바일 이동을 production Chromium 회귀로 고정한다.
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user