-
-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Expand file tree
/
Copy pathNotificationCard.tsx
More file actions
142 lines (128 loc) · 4.32 KB
/
Copy pathNotificationCard.tsx
File metadata and controls
142 lines (128 loc) · 4.32 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
import { XMarkIcon } from "@heroicons/react/20/solid";
import { useLayoutEffect, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import { cn } from "~/utils/cn";
export function NotificationCard({
title,
description,
image,
actionUrl,
onDismiss,
onCardClick,
onLinkClick,
}: {
title: string;
description: string;
image?: string;
actionUrl?: string;
onDismiss?: () => void;
onCardClick?: () => void;
onLinkClick?: () => void;
}) {
const [isExpanded, setIsExpanded] = useState(false);
const [isOverflowing, setIsOverflowing] = useState(false);
const descriptionRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const el = descriptionRef.current;
if (!el) return;
const check = () => setIsOverflowing(el.scrollHeight - el.clientHeight > 1);
check();
const observer = new ResizeObserver(check);
observer.observe(el);
return () => observer.disconnect();
}, [description]);
const handleDismiss = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onDismiss?.();
};
const handleToggleExpand = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setIsExpanded((v) => !v);
};
const safeActionUrl = sanitizeUrl(actionUrl);
const safeImage = sanitizeUrl(image);
return (
<div className="group/card relative overflow-hidden rounded border border-charcoal-650 bg-charcoal-700/50 shadow-lg">
{safeActionUrl && (
<a
href={safeActionUrl}
target="_blank"
rel="noopener noreferrer"
aria-label={title}
onClick={onCardClick}
className="absolute inset-0 z-10"
/>
)}
<div className="flex items-start gap-1 px-2.5 pt-2">
<p className="flex-1 text-[13px] font-medium leading-normal text-text-bright">{title}</p>
<button
type="button"
onClick={handleDismiss}
aria-label="Dismiss notification"
title="Dismiss notification"
className="relative z-20 -mr-1 shrink-0 rounded p-0.5 text-text-dimmed opacity-0 transition group-hover/card:opacity-100 hover:bg-charcoal-700 hover:text-text-bright focus-visible:opacity-100"
>
<XMarkIcon className="size-3.5" />
</button>
</div>
<div className="px-2.5 pb-2">
<div ref={descriptionRef} className={cn(!isExpanded && "line-clamp-3")}>
<ReactMarkdown components={getMarkdownComponents(onLinkClick)}>
{description}
</ReactMarkdown>
</div>
{(isOverflowing || isExpanded) && (
<button
type="button"
onClick={handleToggleExpand}
className="relative z-20 mt-0.5 text-xs text-indigo-400 hover:text-indigo-300"
>
{isExpanded ? "Show less" : "Show more"}
</button>
)}
{safeImage && <img src={safeImage} alt="" className="mt-1.5 rounded" />}
</div>
</div>
);
}
function getMarkdownComponents(onLinkClick?: () => void) {
return {
p: ({ children }: { children?: React.ReactNode }) => (
<p className="my-0.5 text-xs leading-normal text-text-dimmed">{children}</p>
),
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="relative z-20 text-indigo-400 underline transition-colors hover:text-indigo-300"
onClick={(e) => {
e.stopPropagation();
onLinkClick?.();
}}
>
{children}
</a>
),
strong: ({ children }: { children?: React.ReactNode }) => (
<strong className="font-semibold text-text-bright">{children}</strong>
),
em: ({ children }: { children?: React.ReactNode }) => <em>{children}</em>,
code: ({ children }: { children?: React.ReactNode }) => (
<code className="rounded bg-charcoal-700 px-1 py-0.5 text-[11px]">{children}</code>
),
};
}
const SAFE_URL_PROTOCOLS = new Set(["http:", "https:", "mailto:", "tel:"]);
/** Sanitize a URL to prevent XSS via javascript: or data: URIs. Returns "" if invalid. */
function sanitizeUrl(url: string | undefined): string {
if (!url) return "";
try {
const parsed = new URL(url);
return SAFE_URL_PROTOCOLS.has(parsed.protocol) ? parsed.href : "";
} catch {
return "";
}
}