- i1 (Important): RecallBanner shownIds → useRef (state setState 트리거 race 차단)
store 의 recallShownIds 필드 제거 (dead — useRef 가 대체)
- m1 (Minor): snoozeRecall candidate-null race 코멘트 (의도적 emit skip 명시)
- m2 (Minor): dismissRecallNote 후 recallSnoozeUntilMs = null clear
- m3 (Minor): CaptureService.markRecallOpened 의 dead local 'before' inline check 로 제거
- m4 (Minor): RecallBanner title 빈 케이스 fallback '(제목 없음)'
- n2 (Nit): NoteCard id load-bearing 의미 1줄 코멘트
skip: n1 (KST 4번째 inline duplicate — 프로젝트 전반 패턴, v0.2.4 nextKstMidnightMs 통합),
n3 (ipcMain.on vs handle — 다른 IPC 와 패턴 일관)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
101 lines
3.7 KiB
TypeScript
101 lines
3.7 KiB
TypeScript
import React, { useEffect, useRef, useState } from 'react';
|
|
import { useInbox } from '../store.js';
|
|
import { inboxApi } from '../api.js';
|
|
|
|
export function RecallBanner(): React.ReactElement | null {
|
|
const candidate = useInbox((s) => s.recallCandidate);
|
|
const snoozeUntilMs = useInbox((s) => s.recallSnoozeUntilMs);
|
|
const openRecall = useInbox((s) => s.openRecall);
|
|
const dismissRecallNote = useInbox((s) => s.dismissRecallNote);
|
|
const snoozeRecall = useInbox((s) => s.snoozeRecall);
|
|
|
|
// i1 fix — shownIds 를 useRef 로 관리해 race 차단 (setState 트리거 X)
|
|
// 같은 RecallBanner 컴포넌트 인스턴스 동안 per-noteId 1회 emit 보장.
|
|
// 컴포넌트 언마운트/리마운트 시 reset (session-local 의도).
|
|
const shownIdsRef = useRef<Set<string>>(new Set());
|
|
|
|
// ExpiryBanner 패턴 — snoozeUntilMs 만료 시 force re-render
|
|
const [, setTick] = useState(0);
|
|
useEffect(() => {
|
|
if (snoozeUntilMs === null) return;
|
|
const remaining = snoozeUntilMs - Date.now();
|
|
if (remaining <= 0) return;
|
|
const t = setTimeout(() => setTick((n) => n + 1), remaining);
|
|
return () => clearTimeout(t);
|
|
}, [snoozeUntilMs]);
|
|
|
|
// first-render emit recall_shown (per-banner-lifetime 1회 per note)
|
|
useEffect(() => {
|
|
if (!candidate) return;
|
|
if (snoozeUntilMs !== null && Date.now() < snoozeUntilMs) return;
|
|
if (shownIdsRef.current.has(candidate.id)) return;
|
|
void inboxApi.emitRecallShown(candidate.id);
|
|
shownIdsRef.current.add(candidate.id);
|
|
}, [candidate, snoozeUntilMs]);
|
|
|
|
if (candidate === null) return null;
|
|
if (snoozeUntilMs !== null && Date.now() < snoozeUntilMs) return null;
|
|
|
|
const ageDays = computeAgeDays(candidate.lastRecalledAt ?? candidate.createdAt);
|
|
// m4 fix — rawText 와 aiTitle 모두 비었을 때 빈 제목 방지
|
|
const title = candidate.aiTitle?.trim() || candidate.rawText.trim().slice(0, 60) || '(제목 없음)';
|
|
|
|
function onOpen() {
|
|
void openRecall(candidate!.id);
|
|
const el = document.getElementById(`note-${candidate!.id}`);
|
|
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
}
|
|
|
|
return (
|
|
<div style={{
|
|
background: '#e8f0fe', border: '1px solid #4a7ec0', borderRadius: 6,
|
|
padding: '8px 12px', margin: '8px 0', fontSize: 13
|
|
}}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<span>💭 <b>오늘 회상해볼 노트</b></span>
|
|
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: '#234' }}>
|
|
{title}
|
|
</span>
|
|
<span style={{ color: '#6a7e9a', fontSize: 12 }}>{ageDays}일 전</span>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
|
|
<button
|
|
onClick={onOpen}
|
|
style={{
|
|
background: '#4a7ec0', color: '#fff',
|
|
border: 'none', borderRadius: 4,
|
|
padding: '4px 12px', fontSize: 12, cursor: 'pointer'
|
|
}}
|
|
>
|
|
열어보기
|
|
</button>
|
|
<button
|
|
onClick={() => void snoozeRecall()}
|
|
style={{
|
|
background: 'transparent', color: '#4a7ec0',
|
|
border: '1px solid #4a7ec0', borderRadius: 4,
|
|
padding: '4px 12px', fontSize: 12, cursor: 'pointer'
|
|
}}
|
|
>
|
|
다음에
|
|
</button>
|
|
<button
|
|
onClick={() => void dismissRecallNote(candidate.id)}
|
|
style={{
|
|
marginLeft: 'auto',
|
|
background: 'transparent', color: '#888',
|
|
border: 'none', fontSize: 12, cursor: 'pointer'
|
|
}}
|
|
>
|
|
더 이상
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function computeAgeDays(refIso: string): number {
|
|
const refMs = new Date(refIso).getTime();
|
|
return Math.max(0, Math.floor((Date.now() - refMs) / 86_400_000));
|
|
}
|