Files
inkling/src/renderer/inbox/components/RecallBanner.tsx
altair823 0447b69b82 refactor(v026): #24+#41 Banner shared component (severity prop)
4 banner inline style 중복 (warning 황색 / error 적색 / info 청색)
→ <Banner severity="warning|error|info"> wrapper. THEMES map 단일 source.

- ExpiryBanner: warning
- OllamaBanner: warning
- FailedBanner: error
- RecallBanner: info

OllamaSettingsModal 은 modal 형식이라 banner 와 분리 (별개 inline style 유지).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 01:42:16 +09:00

99 lines
3.7 KiB
TypeScript

import React, { useEffect, useRef, useState } from 'react';
import { useInbox } from '../store.js';
import { inboxApi } from '../api.js';
import { Banner } from './Banner.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 (
<Banner severity="info">
<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>
</Banner>
);
}
function computeAgeDays(refIso: string): number {
const refMs = new Date(refIso).getTime();
return Math.max(0, Math.floor((Date.now() - refMs) / 86_400_000));
}