From a52383376ba1cd558487236ae5d4b2e2721887be Mon Sep 17 00:00:00 2001 From: Macbook Date: Fri, 5 Jun 2026 12:38:19 +0900 Subject: [PATCH] =?UTF-8?q?=ED=8C=9D=EC=97=85=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/contexts/TradeNotificationContext.tsx | 56 ++++++++++++++++--- 1 file changed, 48 insertions(+), 8 deletions(-) diff --git a/frontend/src/contexts/TradeNotificationContext.tsx b/frontend/src/contexts/TradeNotificationContext.tsx index 331d08a..e37ea72 100644 --- a/frontend/src/contexts/TradeNotificationContext.tsx +++ b/frontend/src/contexts/TradeNotificationContext.tsx @@ -223,18 +223,41 @@ export const TradeNotificationProvider: React.FC = ({ let newlyArrived: TradeNotificationItem[] = []; setAllNotifications(prev => { - /** - * rawServerList=true 는 앱 초기 로드(새로고침·로그인 직후)이므로 - * 이미 존재하는 서버 이력을 실시간 신규 알림으로 취급하지 않는다. - * 실제 새 알림 판단은 STOMP addNotification 과 일반 refreshHistory 에서만 한다. - */ if (!opts?.rawServerList) { + /** + * 일반 폴링·serverOnly 경로: 이미 목록에 없는 미읽음 신호만 신규 도착으로 판정. + */ newlyArrived = items.filter( item => !item.isRead && !readIdsRef.current.has(item.id) && // readIds 에 없는(진짜 새) 알림만 !prev.some(p => p.id === item.id), // 이미 목록에 없는 알림만 ); + } else if (popupEnabled) { + /** + * rawServerList=true (앱 시작·로그인 직후) + 팝업 ON: + * + * 기존 로직은 서버 이력을 "실시간 신규 알림"으로 취급하지 않아 + * suppressBefore 이후 생성된 미읽음 신호가 배지에는 표시되지만 + * 팝업은 전혀 뜨지 않는 문제가 있었다. + * + * 서버 재시작·짧은 연결 끊김 등으로 STOMP 를 놓친 신호도 팝업으로 + * 보여줘야 하므로, suppressBefore 이후에 생성된 미읽음 신호 전체를 + * newlyArrived 에 포함시킨다. + * + * suppressBefore == 0 (전체닫기 이력 없음) 인 경우 최근 2시간 이내 + * 신호로 범위를 제한해 과도한 팝업을 방지한다. + */ + const cutoff = suppressBeforeRef.current > 0 + ? suppressBeforeRef.current * 1000 // 마지막 전체닫기 이후 신호 + : Date.now() - 2 * 60 * 60 * 1000; // 또는 최근 2시간 + + newlyArrived = items.filter( + item => + !item.isRead && + !readIdsRef.current.has(item.id) && + item.receivedAt > cutoff, + ); } if (opts?.serverOnly || opts?.rawServerList) { @@ -266,7 +289,8 @@ export const TradeNotificationProvider: React.FC = ({ for (const n of newlyArrived) map.set(n.id, n); return [...map.values()].slice(0, MAX_TOAST_QUEUE); }); - if (soundEnabled) { + // rawServerList (앱 시작) 시 기존 미읽음 신호 팝업은 사운드 없이 조용히 표시 + if (soundEnabled && !opts?.rawServerList) { playTradeAlertSound(alertSoundId); } } @@ -275,13 +299,19 @@ export const TradeNotificationProvider: React.FC = ({ } }, [popupEnabled, soundEnabled, alertSoundId]); + const prevSessionKeyRef = useRef(settingsSessionKey); + useEffect(() => { if (!settingsLoaded) return; + + const isSessionSwitch = prevSessionKeyRef.current !== settingsSessionKey; + prevSessionKeyRef.current = settingsSessionKey; + /** * 앱 설정이 DB 에서 로드 완료된 시점에 readIds 를 즉시 동기화한다. * STOMP 가 앱 시작 직후 구독 시 readIds 가 아직 빈 상태여서 이미 닫은 * 알림이 다시 팝업으로 뜨는 타이밍 버그를 방지한다. - * 세션 전환 시 allSeenIdsRef 도 초기화해 이전 세션 알림 ID 가 잔류하지 않게 한다. + * 세션 전환(로그인·로그아웃) 시 allSeenIdsRef 를 초기화해 이전 세션 ID 잔류 방지. */ allSeenIdsRef.current = new Set(); const dbRead = loadReadIds(); @@ -292,7 +322,17 @@ export const TradeNotificationProvider: React.FC = ({ } // 전체닫기·삭제 시각 복원 (재접속 후에도 오래된 신호 팝업 방지) suppressBeforeRef.current = loadSuppressBefore(); - setToastNotifications([]); + + /** + * [버그 수정] 기존 코드는 항상 setToastNotifications([]) 로 초기화했는데, + * 이 경우 STOMP 가 설정 로드보다 먼저 도착한 토스트까지 소거되는 race condition 이 있었다. + * 세션 전환(로그인·로그아웃) 시에만 초기화하고, 단순 설정 로드 완료 시에는 + * 초기화하지 않는다 — rawServerList 로드가 최신 토스트를 올바르게 추가한다. + */ + if (isSessionSwitch) { + setToastNotifications([]); + } + void refreshHistory({ serverOnly: true, rawServerList: true }); }, [refreshHistory, settingsLoaded, settingsSessionKey]);