668 lines
24 KiB
TypeScript
668 lines
24 KiB
TypeScript
/**
|
|
* 실시간 매매 시그널 알림 Context
|
|
*
|
|
* - STOMP 수신 시 토스트 스택(미확인)에 추가
|
|
* - 알림 목록·읽음 상태는 localStorage + 서버 trade-signals 이력 병합
|
|
*/
|
|
import React, {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
import type { TradeSignalInfo } from '../components/TradeAlertModal';
|
|
import {
|
|
deleteAllTradeSignals,
|
|
deleteTradeSignal,
|
|
deleteTradeSignalsBatch,
|
|
loadTradeSignals,
|
|
type TradeSignalDto,
|
|
} from '../utils/backendApi';
|
|
import {
|
|
normalizeTradeAlertSoundId,
|
|
playTradeAlertSound,
|
|
type TradeAlertSoundId,
|
|
} from '../utils/tradeAlertSound';
|
|
import { normalizeStrategyId } from '../utils/resolveNotificationStrategy';
|
|
|
|
import { getUiPreferences, patchUiPreferences } from '../utils/uiPreferencesDb';
|
|
import { useAppSettings } from '../hooks/useAppSettings';
|
|
const MAX_HISTORY = 300;
|
|
/** 팝업 대기열 최대 건수 (페이지 슬라이딩) */
|
|
const MAX_TOAST_QUEUE = 50;
|
|
|
|
export interface TradeNotificationItem extends TradeSignalInfo {
|
|
/** 클라이언트 고유 키 (market:time:signal) */
|
|
id: string;
|
|
dbId?: number;
|
|
isRead: boolean;
|
|
receivedAt: number;
|
|
}
|
|
|
|
interface TradeNotificationContextValue {
|
|
/** 우측 스택에 표시 중인 미확인 알림 */
|
|
toastNotifications: TradeNotificationItem[];
|
|
/** 전체 이력 (목록 화면) */
|
|
allNotifications: TradeNotificationItem[];
|
|
unreadCount: number;
|
|
addNotification: (signal: TradeSignalInfo & { dbId?: number }) => void;
|
|
dismissToast: (id: string) => void;
|
|
/** 지정 id 알림만 팝업 대기열에서 닫기 (읽음 처리) */
|
|
dismissToasts: (ids: string[]) => void;
|
|
/** 팝업 대기열(toastNotifications) 전체 닫기 — 모든 페이지 포함 */
|
|
dismissAllToasts: () => void;
|
|
markAsRead: (id: string) => void;
|
|
markAllAsRead: () => void;
|
|
/** 알림 목록에서 단건 삭제 */
|
|
deleteNotification: (id: string) => Promise<void>;
|
|
/** 지정 id 알림 일괄 삭제 (목록 선택 삭제) */
|
|
deleteNotifications: (ids: string[]) => Promise<void>;
|
|
/** 읽지 않은(미확인) 알림만 삭제 */
|
|
deleteUnreadNotifications: () => Promise<void>;
|
|
/** 알림 목록 전체 삭제 */
|
|
deleteAllNotifications: () => Promise<void>;
|
|
refreshHistory: (opts?: { serverOnly?: boolean; rawServerList?: boolean }) => Promise<void>;
|
|
detailSignal: TradeSignalInfo | null;
|
|
/** 상세 모달을 화면 중앙에 표시 (알림 목록에서 열 때) */
|
|
detailCentered: boolean;
|
|
openDetail: (item: TradeNotificationItem, options?: { centered?: boolean }) => void;
|
|
closeDetail: () => void;
|
|
}
|
|
|
|
const TradeNotificationContext = createContext<TradeNotificationContextValue | null>(null);
|
|
|
|
function makeId(signal: Pick<TradeSignalInfo, 'market' | 'candleTime' | 'signalType'>): string {
|
|
return `${signal.market}:${signal.candleTime}:${signal.signalType}`;
|
|
}
|
|
|
|
function dtoToItem(dto: TradeSignalDto, readIds: Set<string>): TradeNotificationItem {
|
|
const id = `${dto.market}:${dto.candleTime}:${dto.signalType}`;
|
|
return {
|
|
id,
|
|
dbId: dto.id,
|
|
market: dto.market,
|
|
signalType: dto.signalType,
|
|
price: dto.price,
|
|
candleTime: dto.candleTime,
|
|
strategyName: dto.strategyName,
|
|
strategyId: normalizeStrategyId(dto.strategyId) ?? undefined,
|
|
executionType: dto.executionType,
|
|
candleType: dto.candleType,
|
|
isRead: readIds.has(id),
|
|
receivedAt: new Date(dto.createdAt).getTime() || dto.candleTime * 1000,
|
|
};
|
|
}
|
|
|
|
function loadReadIds(): Set<string> {
|
|
const ids = getUiPreferences().tradeNotifications?.readIds;
|
|
return new Set(ids ?? []);
|
|
}
|
|
|
|
function saveReadIds(ids: Set<string>, immediate = false) {
|
|
patchUiPreferences({ tradeNotifications: { readIds: [...ids] } }, immediate);
|
|
}
|
|
|
|
function loadHiddenIds(): Set<string> {
|
|
const ids = getUiPreferences().tradeNotifications?.hiddenIds;
|
|
return new Set(ids ?? []);
|
|
}
|
|
|
|
function saveHiddenIds(ids: Set<string>) {
|
|
patchUiPreferences({ tradeNotifications: { hiddenIds: [...ids] } });
|
|
}
|
|
|
|
/** 전체닫기·삭제 시각 (epoch 초) 로드 */
|
|
function loadSuppressBefore(): number {
|
|
return getUiPreferences().tradeNotifications?.suppressBefore ?? 0;
|
|
}
|
|
|
|
/** 전체닫기·삭제 시각 저장 */
|
|
function saveSuppressBefore(ts: number, immediate = false) {
|
|
patchUiPreferences({ tradeNotifications: { suppressBefore: ts } }, immediate);
|
|
}
|
|
|
|
export function useTradeNotification(): TradeNotificationContextValue {
|
|
const ctx = useContext(TradeNotificationContext);
|
|
if (!ctx) {
|
|
throw new Error('useTradeNotification must be used within TradeNotificationProvider');
|
|
}
|
|
return ctx;
|
|
}
|
|
|
|
interface ProviderProps {
|
|
children: React.ReactNode;
|
|
/** 매매 알림 팝업 ON (설정) */
|
|
popupEnabled: boolean;
|
|
/** 매매 알림 사운드 ON (설정) */
|
|
soundEnabled?: boolean;
|
|
/** 알림음 ID */
|
|
soundId?: string;
|
|
/** 로그인·게스트 전환 시 app-settings 재로드 키 */
|
|
settingsSessionKey?: number;
|
|
}
|
|
|
|
export const TradeNotificationProvider: React.FC<ProviderProps> = ({
|
|
children,
|
|
popupEnabled,
|
|
soundEnabled = true,
|
|
soundId = 'bell',
|
|
settingsSessionKey = 0,
|
|
}) => {
|
|
const { isLoaded: settingsLoaded } = useAppSettings(settingsSessionKey);
|
|
const alertSoundId = normalizeTradeAlertSoundId(soundId) as TradeAlertSoundId;
|
|
const [readIds, setReadIds] = useState<Set<string>>(() => loadReadIds());
|
|
const readIdsRef = useRef(readIds);
|
|
readIdsRef.current = readIds;
|
|
const [toastNotifications, setToastNotifications] = useState<TradeNotificationItem[]>([]);
|
|
const toastNotificationsRef = useRef(toastNotifications);
|
|
toastNotificationsRef.current = toastNotifications;
|
|
const [allNotifications, setAllNotifications] = useState<TradeNotificationItem[]>([]);
|
|
const allNotificationsRef = useRef(allNotifications);
|
|
allNotificationsRef.current = allNotifications;
|
|
/**
|
|
* addNotification 에서 수신된 모든 알림 ID 를 동기적으로 누적.
|
|
* dismissAllToasts 가 호출될 때 allNotificationsRef 가 stale 한 경우에도
|
|
* 같은 틱에 수신된 신호 ID 까지 readIds 에 포함시킬 수 있다.
|
|
*/
|
|
const allSeenIdsRef = useRef<Set<string>>(new Set());
|
|
/**
|
|
* 전체닫기·삭제 실행 시각 (epoch 초).
|
|
* candleTime < suppressBefore 인 오래된 신호는 팝업을 띄우지 않는다.
|
|
* 백엔드 backfill 이 STOMP 로 수십 일치 과거 신호를 재전송해도 무시한다.
|
|
*/
|
|
const suppressBeforeRef = useRef<number>(loadSuppressBefore());
|
|
const [detailSignal, setDetailSignal] = useState<TradeSignalInfo | null>(null);
|
|
const [detailCentered, setDetailCentered] = useState(false);
|
|
|
|
const unreadCount = useMemo(
|
|
() => allNotifications.filter(n => !n.isRead).length,
|
|
[allNotifications],
|
|
);
|
|
|
|
const refreshHistory = useCallback(async (opts?: { serverOnly?: boolean; rawServerList?: boolean }) => {
|
|
try {
|
|
const dtos = await loadTradeSignals();
|
|
/**
|
|
* readIds 의 최신 상태를 가져올 때:
|
|
* - readIdsRef.current : dismissAllToasts / markAllAsRead 직후 즉시 반영되는 인메모리 값
|
|
* - loadReadIds() : DB 캐시 기반 값 (약간의 지연 가능)
|
|
* 두 Set 의 합집합(union)을 사용하면 어느 쪽 타이밍 경쟁에서도 읽음 상태가 보존된다.
|
|
*/
|
|
const cacheRead = loadReadIds();
|
|
const memRead = readIdsRef.current;
|
|
const suppressBefore = suppressBeforeRef.current;
|
|
|
|
/**
|
|
* suppressBefore 이전 캔들 신호: readIds 에 추가하고 isRead=true 처리.
|
|
* DB 에 남아있는 오래된 신호가 폴링에서 미읽음으로 잡히는 것을 방지한다.
|
|
*/
|
|
const suppressed = dtos.filter(
|
|
d => suppressBefore > 0 && (d.candleTime ?? 0) < suppressBefore,
|
|
);
|
|
let effectiveRead = new Set([...cacheRead, ...memRead]);
|
|
if (suppressed.length > 0) {
|
|
const suppressedIds = suppressed.map(
|
|
d => `${d.market}:${d.candleTime}:${d.signalType}`,
|
|
);
|
|
const nextRead = new Set([...effectiveRead, ...suppressedIds]);
|
|
if (nextRead.size !== effectiveRead.size) {
|
|
readIdsRef.current = nextRead;
|
|
effectiveRead = nextRead;
|
|
saveReadIds(nextRead, true);
|
|
}
|
|
}
|
|
|
|
const read = effectiveRead;
|
|
const hidden = opts?.rawServerList ? new Set<string>() : loadHiddenIds();
|
|
const items = dtos
|
|
.map(d => dtoToItem(d, read))
|
|
.filter(item => opts?.rawServerList || !hidden.has(item.id));
|
|
|
|
let newlyArrived: TradeNotificationItem[] = [];
|
|
|
|
setAllNotifications(prev => {
|
|
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) {
|
|
return items
|
|
.sort((a, b) => b.receivedAt - a.receivedAt)
|
|
.slice(0, MAX_HISTORY);
|
|
}
|
|
|
|
const map = new Map<string, TradeNotificationItem>();
|
|
for (const n of items) {
|
|
const wasRead = read.has(n.id) || prev.find(p => p.id === n.id)?.isRead;
|
|
map.set(n.id, wasRead ? { ...n, isRead: true } : n);
|
|
}
|
|
for (const n of prev) {
|
|
if (hidden.has(n.id)) continue;
|
|
// 서버 이력에 없는 STOMP-only 항목은 세션 전환 후 제거 (serverOnly가 아닐 때만 병합)
|
|
if (!map.has(n.id)) map.set(n.id, n);
|
|
else if (n.isRead) map.set(n.id, { ...map.get(n.id)!, isRead: true });
|
|
}
|
|
return [...map.values()]
|
|
.filter(n => !hidden.has(n.id))
|
|
.sort((a, b) => b.receivedAt - a.receivedAt)
|
|
.slice(0, MAX_HISTORY);
|
|
});
|
|
|
|
if (popupEnabled && newlyArrived.length > 0) {
|
|
setToastNotifications(tprev => {
|
|
const map = new Map(tprev.map(t => [t.id, t]));
|
|
for (const n of newlyArrived) map.set(n.id, n);
|
|
return [...map.values()].slice(0, MAX_TOAST_QUEUE);
|
|
});
|
|
// rawServerList (앱 시작) 시 기존 미읽음 신호 팝업은 사운드 없이 조용히 표시
|
|
if (soundEnabled && !opts?.rawServerList) {
|
|
playTradeAlertSound(alertSoundId);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn('[TradeNotification] 이력 로드 실패:', e);
|
|
}
|
|
}, [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.current = new Set();
|
|
const dbRead = loadReadIds();
|
|
const merged = new Set([...readIdsRef.current, ...dbRead]);
|
|
if (merged.size !== readIdsRef.current.size) {
|
|
readIdsRef.current = merged;
|
|
setReadIds(merged);
|
|
}
|
|
// 전체닫기·삭제 시각 복원 (재접속 후에도 오래된 신호 팝업 방지)
|
|
suppressBeforeRef.current = loadSuppressBefore();
|
|
|
|
/**
|
|
* [버그 수정] 기존 코드는 항상 setToastNotifications([]) 로 초기화했는데,
|
|
* 이 경우 STOMP 가 설정 로드보다 먼저 도착한 토스트까지 소거되는 race condition 이 있었다.
|
|
* 세션 전환(로그인·로그아웃) 시에만 초기화하고, 단순 설정 로드 완료 시에는
|
|
* 초기화하지 않는다 — rawServerList 로드가 최신 토스트를 올바르게 추가한다.
|
|
*/
|
|
if (isSessionSwitch) {
|
|
setToastNotifications([]);
|
|
}
|
|
|
|
void refreshHistory({ serverOnly: true, rawServerList: true });
|
|
}, [refreshHistory, settingsLoaded, settingsSessionKey]);
|
|
|
|
const markAsRead = useCallback((id: string) => {
|
|
setReadIds(prev => {
|
|
const next = new Set(prev);
|
|
next.add(id);
|
|
saveReadIds(next);
|
|
readIdsRef.current = next;
|
|
return next;
|
|
});
|
|
setToastNotifications(prev => prev.filter(n => n.id !== id));
|
|
setAllNotifications(prev =>
|
|
prev.map(n => (n.id === id ? { ...n, isRead: true } : n)),
|
|
);
|
|
}, []);
|
|
|
|
const addNotification = useCallback((signal: TradeSignalInfo & { dbId?: number }) => {
|
|
const id = makeId(signal);
|
|
|
|
// 수신한 모든 ID 를 동기적으로 누적 (dismissAllToasts 가 참조)
|
|
allSeenIdsRef.current.add(id);
|
|
|
|
/**
|
|
* ── 오래된 신호 억제 (핵심 필터) ───────────────────────────────────────
|
|
* 전체닫기·삭제 시각(suppressBefore) 이전에 시작된 캔들의 신호는
|
|
* 백엔드 backfill 이 STOMP 로 재전송해도 팝업을 띄우지 않는다.
|
|
* candleTime 은 epoch 초 단위이고 suppressBefore 도 epoch 초이다.
|
|
*/
|
|
const isSuppressed =
|
|
suppressBeforeRef.current > 0 &&
|
|
signal.candleTime < suppressBeforeRef.current;
|
|
|
|
/**
|
|
* 읽음 여부 3중 확인:
|
|
* 1. readIdsRef.current — dismissAllToasts 가 즉시 업데이트하는 인메모리 ref
|
|
* 2. loadReadIds() — DB 캐시 기반 값
|
|
* 3. allNotificationsRef — functional updater 가 isRead:true 예약 후 ref 미반영 커버
|
|
*/
|
|
const alreadyRead =
|
|
isSuppressed ||
|
|
readIdsRef.current.has(id) ||
|
|
loadReadIds().has(id) ||
|
|
(allNotificationsRef.current.find(n => n.id === id)?.isRead === true);
|
|
|
|
// readIds 에 누락된 읽음 항목 보정
|
|
if (alreadyRead && !readIdsRef.current.has(id)) {
|
|
const next = new Set(readIdsRef.current);
|
|
next.add(id);
|
|
readIdsRef.current = next;
|
|
saveReadIds(next);
|
|
}
|
|
|
|
setAllNotifications(prev => {
|
|
const existing = prev.find(n => n.id === id);
|
|
if (existing) {
|
|
// 읽음 처리된 항목은 isRead 를 false 로 되돌리지 않는다
|
|
if (existing.isRead || alreadyRead) return prev;
|
|
return prev.map(n =>
|
|
n.id === id ? { ...n, isRead: false, receivedAt: Date.now() } : n,
|
|
);
|
|
}
|
|
const item: TradeNotificationItem = {
|
|
...signal,
|
|
id,
|
|
dbId: signal.dbId,
|
|
candleType: signal.candleType ?? '1m',
|
|
isRead: alreadyRead,
|
|
receivedAt: Date.now(),
|
|
};
|
|
return [item, ...prev].slice(0, MAX_HISTORY);
|
|
});
|
|
|
|
// 이미 읽음 처리된 알림은 팝업·알림음 모두 생략
|
|
if (alreadyRead) return;
|
|
|
|
if (soundEnabled) {
|
|
playTradeAlertSound(alertSoundId);
|
|
}
|
|
|
|
if (!popupEnabled) return;
|
|
|
|
const toastItem: TradeNotificationItem = {
|
|
...signal,
|
|
id,
|
|
dbId: signal.dbId,
|
|
candleType: signal.candleType ?? '1m',
|
|
isRead: false,
|
|
receivedAt: Date.now(),
|
|
};
|
|
setToastNotifications(prev => {
|
|
const rest = prev.filter(n => n.id !== id);
|
|
return [toastItem, ...rest].slice(0, MAX_TOAST_QUEUE);
|
|
});
|
|
}, [popupEnabled, soundEnabled, alertSoundId]);
|
|
|
|
const dismissToast = useCallback((id: string) => {
|
|
markAsRead(id);
|
|
}, [markAsRead]);
|
|
|
|
const dismissToasts = useCallback((ids: string[]) => {
|
|
if (ids.length === 0) return;
|
|
const idSet = new Set(ids);
|
|
const toastSnapshot = toastNotificationsRef.current;
|
|
setReadIds(r => {
|
|
const next = new Set(r);
|
|
ids.forEach(i => next.add(i));
|
|
saveReadIds(next);
|
|
readIdsRef.current = next;
|
|
return next;
|
|
});
|
|
setToastNotifications(prev => prev.filter(n => !idSet.has(n.id)));
|
|
setAllNotifications(all => {
|
|
const byId = new Map(all.map(n => [n.id, n]));
|
|
for (const id of ids) {
|
|
const existing = byId.get(id);
|
|
if (existing) {
|
|
byId.set(id, { ...existing, isRead: true });
|
|
} else {
|
|
const fromToast = toastSnapshot.find(n => n.id === id);
|
|
if (fromToast) byId.set(id, { ...fromToast, isRead: true });
|
|
}
|
|
}
|
|
return [...byId.values()]
|
|
.sort((a, b) => b.receivedAt - a.receivedAt)
|
|
.slice(0, MAX_HISTORY);
|
|
});
|
|
}, []);
|
|
|
|
const dismissAllToasts = useCallback(() => {
|
|
/**
|
|
* 전체 닫기 — 모든 알림을 읽음 처리하고 즉시 DB 저장.
|
|
*
|
|
* allSeenIdsRef: addNotification 에서 동기적으로 누적된 ID 집합.
|
|
* - allNotificationsRef.current 가 stale 하더라도(배치 미반영)
|
|
* 동일 틱에 수신된 STOMP 신호 ID 도 포함 → readIds 에서 누락 없음.
|
|
* side-effect(readIdsRef, saveReadIds)를 functional updater 밖에서 실행하여
|
|
* React concurrent 렌더링에서의 중복 실행 문제를 방지.
|
|
*/
|
|
const nextRead = new Set([
|
|
...readIdsRef.current,
|
|
...toastNotificationsRef.current.map(n => n.id),
|
|
...allNotificationsRef.current.map(n => n.id),
|
|
...allSeenIdsRef.current, // 동일 틱에 수신됐지만 ref 미반영 ID
|
|
]);
|
|
|
|
// 전체닫기 시각 기록 — 이 시각 이전 candleTime 신호는 이후 팝업 금지
|
|
const nowSec = Math.floor(Date.now() / 1000);
|
|
suppressBeforeRef.current = nowSec;
|
|
saveSuppressBefore(nowSec, true);
|
|
|
|
// 1) 인메모리 ref 즉시 갱신 (다음 addNotification 호출에서 즉시 참조)
|
|
readIdsRef.current = nextRead;
|
|
|
|
// 2) React state 갱신
|
|
setReadIds(nextRead);
|
|
setAllNotifications(prev => prev.map(n => ({ ...n, isRead: true })));
|
|
setToastNotifications([]);
|
|
|
|
// 3) 인메모리 캐시 + 즉시 DB 저장 (immediate=true)
|
|
saveReadIds(nextRead, true);
|
|
}, []);
|
|
|
|
const markAllAsRead = useCallback(() => {
|
|
const nextRead = new Set([
|
|
...readIdsRef.current,
|
|
...allNotificationsRef.current.map(n => n.id),
|
|
...allSeenIdsRef.current,
|
|
]);
|
|
readIdsRef.current = nextRead;
|
|
setReadIds(nextRead);
|
|
setAllNotifications(prev => prev.map(n => ({ ...n, isRead: true })));
|
|
setToastNotifications([]);
|
|
saveReadIds(nextRead, true);
|
|
}, []);
|
|
|
|
const purgeNotifications = useCallback((ids: string[]) => {
|
|
if (ids.length === 0) return;
|
|
const idSet = new Set(ids);
|
|
|
|
// hiddenIds: 목록에서 숨김 처리
|
|
const hidden = loadHiddenIds();
|
|
ids.forEach(id => hidden.add(id));
|
|
saveHiddenIds(hidden);
|
|
|
|
/**
|
|
* readIds 에서 삭제하지 않고 오히려 추가한다.
|
|
* 삭제된 ID 를 readIds 에서 제거하면, 백엔드가 동일 신호를 STOMP 로 재전송할 때
|
|
* alreadyRead = false 가 되어 팝업이 다시 뜨는 버그가 발생한다.
|
|
*/
|
|
const nextRead = new Set([...readIdsRef.current, ...ids]);
|
|
readIdsRef.current = nextRead;
|
|
setReadIds(nextRead);
|
|
saveReadIds(nextRead, true);
|
|
|
|
setToastNotifications(prev => prev.filter(n => !idSet.has(n.id)));
|
|
setAllNotifications(prev => prev.filter(n => !idSet.has(n.id)));
|
|
if (detailSignal) {
|
|
const detailId = makeId({
|
|
market: detailSignal.market,
|
|
candleTime: detailSignal.candleTime,
|
|
signalType: detailSignal.signalType,
|
|
});
|
|
if (idSet.has(detailId)) {
|
|
setDetailSignal(null);
|
|
setDetailCentered(false);
|
|
}
|
|
}
|
|
}, [detailSignal]);
|
|
|
|
const findNotificationById = useCallback((id: string): TradeNotificationItem | undefined => {
|
|
return allNotifications.find(n => n.id === id)
|
|
?? toastNotificationsRef.current.find(n => n.id === id);
|
|
}, [allNotifications]);
|
|
|
|
const deleteNotifications = useCallback(async (ids: string[]) => {
|
|
const unique = [...new Set(ids)].filter(Boolean);
|
|
if (unique.length === 0) return;
|
|
const dbIds: number[] = [];
|
|
for (const id of unique) {
|
|
const item = findNotificationById(id);
|
|
if (item?.dbId != null) dbIds.push(item.dbId);
|
|
}
|
|
purgeNotifications(unique);
|
|
if (dbIds.length > 0) {
|
|
try {
|
|
await deleteTradeSignalsBatch(dbIds);
|
|
} catch (e) {
|
|
console.warn('[TradeNotification] 일괄 삭제 실패:', e);
|
|
}
|
|
}
|
|
}, [findNotificationById, purgeNotifications]);
|
|
|
|
const deleteNotification = useCallback(async (id: string) => {
|
|
await deleteNotifications([id]);
|
|
}, [deleteNotifications]);
|
|
|
|
const deleteUnreadNotifications = useCallback(async () => {
|
|
const unread = allNotifications.filter(n => !n.isRead);
|
|
if (unread.length === 0) return;
|
|
await deleteNotifications(unread.map(n => n.id));
|
|
}, [allNotifications, deleteNotifications]);
|
|
|
|
const deleteAllNotifications = useCallback(async () => {
|
|
const ids = [
|
|
...new Set([
|
|
...allNotifications.map(n => n.id),
|
|
...toastNotificationsRef.current.map(n => n.id),
|
|
...allSeenIdsRef.current,
|
|
]),
|
|
];
|
|
|
|
// 전체 삭제 시각 기록 — 이 시각 이전 candleTime 신호는 이후 팝업 금지
|
|
const nowSec = Math.floor(Date.now() / 1000);
|
|
suppressBeforeRef.current = nowSec;
|
|
saveSuppressBefore(nowSec, true);
|
|
|
|
/**
|
|
* readIds 에 삭제된 ID 를 추가 (초기화 X).
|
|
* 백엔드가 동일 신호를 STOMP 로 재전송해도 suppressBefore + readIds 이중 필터.
|
|
*/
|
|
const nextRead = new Set([...readIdsRef.current, ...ids]);
|
|
readIdsRef.current = nextRead;
|
|
setReadIds(nextRead);
|
|
saveReadIds(nextRead, true);
|
|
|
|
// hiddenIds 초기화 (전체 삭제 후 목록 완전히 비움)
|
|
saveHiddenIds(new Set());
|
|
|
|
setToastNotifications([]);
|
|
setAllNotifications([]);
|
|
|
|
if (detailSignal) {
|
|
setDetailSignal(null);
|
|
setDetailCentered(false);
|
|
}
|
|
|
|
try {
|
|
await deleteAllTradeSignals();
|
|
} catch (e) {
|
|
console.warn('[TradeNotification] 전체 삭제 실패:', e);
|
|
}
|
|
}, [allNotifications, detailSignal]);
|
|
|
|
const openDetail = useCallback((item: TradeNotificationItem, options?: { centered?: boolean }) => {
|
|
setDetailCentered(options?.centered ?? false);
|
|
setDetailSignal({
|
|
market: item.market,
|
|
signalType: item.signalType,
|
|
price: item.price,
|
|
candleTime: item.candleTime,
|
|
strategyName: item.strategyName,
|
|
strategyId: item.strategyId,
|
|
executionType: item.executionType,
|
|
candleType: item.candleType,
|
|
});
|
|
markAsRead(item.id);
|
|
}, [markAsRead]);
|
|
|
|
const closeDetail = useCallback(() => {
|
|
setDetailSignal(null);
|
|
setDetailCentered(false);
|
|
}, []);
|
|
|
|
const value: TradeNotificationContextValue = {
|
|
toastNotifications,
|
|
allNotifications,
|
|
unreadCount,
|
|
addNotification,
|
|
dismissToast,
|
|
dismissToasts,
|
|
dismissAllToasts,
|
|
markAsRead,
|
|
markAllAsRead,
|
|
deleteNotification,
|
|
deleteNotifications,
|
|
deleteUnreadNotifications,
|
|
deleteAllNotifications,
|
|
refreshHistory,
|
|
detailSignal,
|
|
detailCentered,
|
|
openDetail,
|
|
closeDetail,
|
|
};
|
|
|
|
return (
|
|
<TradeNotificationContext.Provider value={value}>
|
|
{children}
|
|
</TradeNotificationContext.Provider>
|
|
);
|
|
};
|