Mechanical reformat — no logic changes. 398 files changed, 19125 insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc (singleQuote:true, semi:true, tabWidth:2, trailingComma:all, printWidth:100). Isolated per D-13-08 for reviewability.
101 lines
3.6 KiB
TypeScript
101 lines
3.6 KiB
TypeScript
/**
|
|
* PermissionDeniedBanner — persistent OS-revoked notification banner (D-10).
|
|
*
|
|
* Shown ONLY when:
|
|
* - Notification.permission === 'denied' (OS revoked), AND
|
|
* - localStorage.notificationsEnabled was previously '1' (user had it on)
|
|
*
|
|
* Silent re-subscribe (D-10) covers the expired-subscription case. This banner
|
|
* is strictly for the OS-revoked case — the one case the app cannot silently fix.
|
|
*
|
|
* UI-SPEC §Surface 3:
|
|
* - role="alert" (assertive live region — permission loss is high-priority)
|
|
* - AlertCircle (--color-destructive) + "Notifications blocked" heading
|
|
* - "Re-enable in your browser settings." + "How to enable" inline button
|
|
* - No dismiss — persists until OS permission is restored
|
|
* - "How to enable" opens an OS-specific instruction sheet (iOS 4-step / Android 4-step)
|
|
*
|
|
* Security: T-05-24 — all copy is plain-text JSX children, no dangerouslySetInnerHTML.
|
|
*/
|
|
|
|
import { useState } from 'react';
|
|
import { AlertCircle } from 'lucide-react';
|
|
import { readNotificationsEnabled } from '../hooks/usePushSubscription.js';
|
|
import { InstructionSheet } from './InstructionSheet.js';
|
|
|
|
// ── PermissionDeniedBanner ────────────────────────────────────────────────
|
|
|
|
export function PermissionDeniedBanner() {
|
|
const [instructionsOpen, setInstructionsOpen] = useState(false);
|
|
|
|
// Only show when OS permission is 'denied' AND the user previously had notifications on.
|
|
// This is the OS-revoked case (D-10). Silent re-subscribe handles the expired-sub case.
|
|
const permissionDenied =
|
|
typeof Notification !== 'undefined' && Notification.permission === 'denied';
|
|
const wasEnabled = readNotificationsEnabled();
|
|
|
|
if (!permissionDenied || !wasEnabled) return null;
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
role="alert"
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 'var(--space-3, 12px)',
|
|
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
|
|
background: 'var(--color-surface-raised, #ffffff)',
|
|
borderBottom: '1px solid var(--color-border, #e2e4e9)',
|
|
fontFamily: 'var(--font-family-base, system-ui, sans-serif)',
|
|
}}
|
|
>
|
|
<AlertCircle
|
|
size={24}
|
|
aria-hidden="true"
|
|
style={{ color: 'var(--color-destructive, #DC2626)', flexShrink: 0 }}
|
|
/>
|
|
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div
|
|
style={{
|
|
fontSize: 'var(--text-label-size, 13px)',
|
|
fontWeight: 600,
|
|
color: 'var(--color-text-primary, #111318)',
|
|
lineHeight: 1.4,
|
|
}}
|
|
>
|
|
Notifications blocked
|
|
</div>
|
|
<div
|
|
style={{
|
|
fontSize: 'var(--text-label-size, 13px)',
|
|
color: 'var(--color-text-secondary, #5c6472)',
|
|
lineHeight: 1.4,
|
|
}}
|
|
>
|
|
Re-enable in your browser settings.{' '}
|
|
<button
|
|
onClick={() => setInstructionsOpen(true)}
|
|
style={{
|
|
background: 'none',
|
|
border: 'none',
|
|
padding: 0,
|
|
cursor: 'pointer',
|
|
fontSize: 'var(--text-label-size, 13px)',
|
|
color: 'var(--color-focus-ring, #4A90D9)',
|
|
textDecoration: 'underline',
|
|
fontFamily: 'inherit',
|
|
}}
|
|
>
|
|
How to enable
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{instructionsOpen && <InstructionSheet onClose={() => setInstructionsOpen(false)} />}
|
|
</>
|
|
);
|
|
}
|