Files
openhuman/app/src/components/notifications/NotificationCard.tsx
T

171 lines
7.1 KiB
TypeScript

import { useT } from '../../lib/i18n/I18nContext';
import type { IntegrationNotification } from '../../types/notifications';
import Button from '../ui/Button';
import NotificationBody from './NotificationBody';
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
/** Relative human-readable time string, e.g. "2m ago". */
function relativeTime(isoString: string): string {
const diff = Date.now() - new Date(isoString).getTime();
const s = Math.floor(diff / 1000);
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
/** Provider badge color class based on slug. */
function providerBadgeClass(provider: string): string {
switch (provider) {
case 'gmail':
return 'bg-red-100 text-red-700 border-red-200';
case 'slack':
return 'bg-purple-100 text-purple-700 border-purple-200';
case 'whatsapp':
return 'bg-green-100 text-green-700 border-green-200';
case 'discord':
return 'bg-indigo-100 text-indigo-700 border-indigo-200';
case 'telegram':
return 'bg-blue-100 text-blue-700 border-blue-200';
case 'linkedin':
return 'bg-sky-100 text-sky-700 border-sky-200';
default:
return 'bg-stone-100 dark:bg-neutral-800 text-stone-700 dark:text-neutral-200 border-stone-200 dark:border-neutral-800';
}
}
/** Score badge color. */
function scoreBadgeClass(score: number): string {
if (score >= 0.75) return 'bg-coral-500/20 text-red-600 border-red-200';
if (score >= 0.4) return 'bg-amber-100 text-amber-700 border-amber-200';
return 'bg-sage-500/20 text-green-700 border-green-200';
}
// ─────────────────────────────────────────────────────────────────────────────
// Component
// ─────────────────────────────────────────────────────────────────────────────
interface Props {
notification: IntegrationNotification;
onMarkRead: (id: string) => void;
onNavigate?: (id: string) => void;
onDismiss?: (id: string) => void;
}
const NotificationCard = ({ notification: n, onMarkRead, onNavigate, onDismiss }: Props) => {
const { t } = useT();
const isUnread = n.status === 'unread';
const handleBodyClick = () => {
if (onNavigate) {
onNavigate(n.id);
} else if (isUnread) {
onMarkRead(n.id);
}
};
return (
<div
className={`w-full p-3 border-b border-stone-100 dark:border-neutral-800 hover:bg-stone-50 dark:hover:bg-neutral-800/60 transition-colors duration-150 ${
isUnread ? 'bg-primary-50/30' : 'bg-white dark:bg-neutral-900'
}`}>
<div className="flex items-start gap-3">
{/* Unread dot — reserve space so text stays aligned whether read or unread */}
<div className="mt-1.5 flex-shrink-0 w-2">
{isUnread && (
<span className="block w-2 h-2 rounded-full bg-primary-500" aria-hidden="true" />
)}
</div>
{/* `role="button"` + key handler instead of a real `<button>` because
this wrapper contains `NotificationLinkPill` (also a `<button>`),
and nested interactive elements break keyboard / screen-reader
behaviour (HTML spec disallows it). */}
<div
role="button"
tabIndex={0}
onClick={handleBodyClick}
onKeyDown={e => {
// Ignore bubbled keydown from inner controls (e.g. the link pill).
// Without this, pressing Enter/Space on a focused pill would also
// activate the card body.
if (e.target !== e.currentTarget) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleBodyClick();
}
}}
className="flex-1 min-w-0 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-1 rounded-sm">
{/* Header row: provider badge + timestamp */}
<div className="flex items-center gap-2 mb-1">
<span
className={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium border ${providerBadgeClass(n.provider)}`}>
{n.provider}
</span>
{n.importance_score !== undefined && (
<span
className={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium border ${scoreBadgeClass(n.importance_score)}`}
title={t('notifications.card.importanceTitle').replace(
'{pct}',
(n.importance_score * 100).toFixed(0)
)}>
{(n.importance_score * 100).toFixed(0)}%
</span>
)}
{n.triage_action && n.triage_action !== 'drop' && n.triage_action !== 'acknowledge' && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium border bg-amber-100 text-amber-700 border-amber-200">
{n.triage_action}
</span>
)}
<span className="ml-auto text-[11px] text-stone-400 dark:text-neutral-500 flex-shrink-0">
{relativeTime(n.received_at)}
</span>
</div>
{/* Title */}
<p className="text-sm font-medium text-stone-900 dark:text-neutral-100 truncate">
{n.title}
</p>
{/* Body preview — `<openhuman-link>` tags render as pills */}
{n.body && (
<p
data-testid="notification-card-body"
className="text-xs text-stone-500 dark:text-neutral-400 mt-0.5 line-clamp-2">
<NotificationBody body={n.body} />
</p>
)}
</div>
{onDismiss && (
<Button
iconOnly
variant="tertiary"
size="xs"
onClick={() => onDismiss(n.id)}
className="mt-0.5 ml-1 flex-shrink-0 text-stone-400 dark:text-neutral-500 hover:text-stone-600 dark:hover:text-neutral-300"
aria-label={t('notifications.card.dismiss')}>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</Button>
)}
</div>
</div>
);
};
export default NotificationCard;