NoteCard.svelte raw
1 <script>
2 import { createEventDispatcher } from 'svelte';
3
4 export let event = null;
5 export let userPubkey = "";
6 export let profiles = new Map();
7
8 const dispatch = createEventDispatcher();
9
10 $: authorProfile = profiles.get(event?.pubkey) || null;
11 $: displayName = getDisplayName(authorProfile, event?.pubkey);
12 $: timeAgo = formatTimeAgo(event?.created_at);
13 $: parsedContent = parseContent(event?.content || "");
14 $: isOwnNote = event?.pubkey === userPubkey;
15
16 function getDisplayName(profile, pubkey) {
17 if (profile?.name) return profile.name;
18 if (profile?.display_name) return profile.display_name;
19 if (pubkey) return pubkey.slice(0, 8) + '...';
20 return 'Anonymous';
21 }
22
23 function formatTimeAgo(timestamp) {
24 if (!timestamp) return '';
25 const now = Math.floor(Date.now() / 1000);
26 const diff = now - timestamp;
27 if (diff < 60) return `${diff}s`;
28 if (diff < 3600) return `${Math.floor(diff / 60)}m`;
29 if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
30 if (diff < 604800) return `${Math.floor(diff / 86400)}d`;
31 return new Date(timestamp * 1000).toLocaleDateString();
32 }
33
34 function parseContent(content) {
35 // Escape HTML first
36 let text = content
37 .replace(/&/g, '&')
38 .replace(/</g, '<')
39 .replace(/>/g, '>');
40
41 // Convert URLs to links
42 text = text.replace(
43 /(https?:\/\/[^\s<]+)/g,
44 '<a href="$1" target="_blank" rel="noopener noreferrer" class="note-link">$1</a>'
45 );
46
47 // Convert nostr: links to styled spans
48 text = text.replace(
49 /nostr:(npub1[a-z0-9]+|note1[a-z0-9]+|nevent1[a-z0-9]+|nprofile1[a-z0-9]+|naddr1[a-z0-9]+)/g,
50 '<span class="nostr-ref">$&</span>'
51 );
52
53 // Convert newlines to <br>
54 text = text.replace(/\n/g, '<br>');
55
56 return text;
57 }
58
59 // Extract image URLs from content
60 $: images = extractImages(event?.content || "");
61
62 function extractImages(content) {
63 const urlRegex = /https?:\/\/[^\s<]+\.(jpg|jpeg|png|gif|webp|svg)(\?[^\s<]*)?/gi;
64 return [...(content.matchAll(urlRegex) || [])].map(m => m[0]);
65 }
66
67 // Content warning check
68 $: contentWarning = event?.tags?.find(t => t[0] === 'content-warning')?.[1] || null;
69 let showWarned = false;
70
71 function handleReply() {
72 dispatch('reply', event);
73 }
74
75 function handleReaction() {
76 dispatch('reaction', event);
77 }
78
79 function handleRepost() {
80 dispatch('repost', event);
81 }
82
83 function handleZap() {
84 dispatch('zap', event);
85 }
86 </script>
87
88 {#if event}
89 <article class="note-card">
90 <div class="note-header">
91 <div class="note-author">
92 {#if authorProfile?.picture}
93 <img src={authorProfile.picture} alt="" class="author-avatar" />
94 {:else}
95 <div class="author-avatar-placeholder">
96 {displayName.charAt(0).toUpperCase()}
97 </div>
98 {/if}
99 <div class="author-info">
100 <span class="author-name">{displayName}</span>
101 {#if authorProfile?.nip05}
102 <span class="author-nip05">{authorProfile.nip05}</span>
103 {/if}
104 </div>
105 </div>
106 <span class="note-time" title={new Date(event.created_at * 1000).toLocaleString()}>
107 {timeAgo}
108 </span>
109 </div>
110
111 <div class="note-body">
112 {#if contentWarning && !showWarned}
113 <div class="content-warning">
114 <span>CW: {contentWarning}</span>
115 <button on:click={() => showWarned = true}>Show</button>
116 </div>
117 {:else}
118 <div class="note-text">{@html parsedContent}</div>
119 {#if images.length > 0}
120 <div class="note-images" class:gallery={images.length > 1}>
121 {#each images as src}
122 <img {src} alt="" class="note-image" loading="lazy" />
123 {/each}
124 </div>
125 {/if}
126 {/if}
127 </div>
128
129 <div class="note-actions">
130 <button class="action-btn reply-btn" on:click={handleReply} title="Reply">
131 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>
132 </button>
133 <button class="action-btn repost-btn" on:click={handleRepost} title="Repost">
134 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
135 </button>
136 <button class="action-btn react-btn" on:click={handleReaction} title="React">
137 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
138 </button>
139 <button class="action-btn zap-btn" on:click={handleZap} title="Zap">
140 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
141 </button>
142 </div>
143 </article>
144 {/if}
145
146 <style>
147 .note-card {
148 border-bottom: 1px solid var(--border-color);
149 padding: 0.75em 1em;
150 transition: background 0.15s;
151 }
152
153 .note-card:hover {
154 background: var(--primary-bg);
155 }
156
157 .note-header {
158 display: flex;
159 align-items: center;
160 justify-content: space-between;
161 margin-bottom: 0.4em;
162 }
163
164 .note-author {
165 display: flex;
166 align-items: center;
167 gap: 0.5em;
168 min-width: 0;
169 }
170
171 .author-avatar {
172 width: 36px;
173 height: 36px;
174 border-radius: 50%;
175 object-fit: cover;
176 flex-shrink: 0;
177 }
178
179 .author-avatar-placeholder {
180 width: 36px;
181 height: 36px;
182 border-radius: 50%;
183 background: var(--primary);
184 color: #000;
185 display: flex;
186 align-items: center;
187 justify-content: center;
188 font-weight: bold;
189 font-size: 0.85rem;
190 flex-shrink: 0;
191 }
192
193 .author-info {
194 display: flex;
195 flex-direction: column;
196 min-width: 0;
197 }
198
199 .author-name {
200 font-weight: 600;
201 font-size: 0.85rem;
202 color: var(--text-color);
203 white-space: nowrap;
204 overflow: hidden;
205 text-overflow: ellipsis;
206 }
207
208 .author-nip05 {
209 font-size: 0.7rem;
210 color: var(--text-muted);
211 white-space: nowrap;
212 overflow: hidden;
213 text-overflow: ellipsis;
214 }
215
216 .note-time {
217 font-size: 0.75rem;
218 color: var(--text-muted);
219 flex-shrink: 0;
220 margin-left: 0.5em;
221 }
222
223 .note-body {
224 margin-bottom: 0.5em;
225 }
226
227 .note-text {
228 font-size: 0.9rem;
229 line-height: 1.5;
230 color: var(--text-color);
231 word-break: break-word;
232 overflow-wrap: break-word;
233 }
234
235 :global(.note-link) {
236 color: var(--primary);
237 text-decoration: none;
238 word-break: break-all;
239 }
240
241 :global(.note-link:hover) {
242 text-decoration: underline;
243 }
244
245 :global(.nostr-ref) {
246 color: var(--primary);
247 font-size: 0.8em;
248 background: var(--primary-bg);
249 padding: 0.1em 0.3em;
250 border-radius: 3px;
251 word-break: break-all;
252 }
253
254 .note-images {
255 margin-top: 0.5em;
256 border-radius: 8px;
257 overflow: hidden;
258 }
259
260 .note-images.gallery {
261 display: grid;
262 grid-template-columns: repeat(2, 1fr);
263 gap: 2px;
264 }
265
266 .note-image {
267 width: 100%;
268 max-height: 400px;
269 object-fit: cover;
270 display: block;
271 }
272
273 .content-warning {
274 display: flex;
275 align-items: center;
276 gap: 0.75em;
277 padding: 0.6em;
278 background: var(--card-bg);
279 border: 1px solid var(--border-color);
280 border-radius: 6px;
281 font-size: 0.85rem;
282 color: var(--text-muted);
283 }
284
285 .content-warning button {
286 background: var(--button-bg);
287 border: 1px solid var(--border-color);
288 border-radius: 4px;
289 padding: 0.2em 0.6em;
290 font-size: 0.8rem;
291 cursor: pointer;
292 color: var(--text-color);
293 }
294
295 .note-actions {
296 display: flex;
297 gap: 0.5em;
298 }
299
300 .action-btn {
301 display: flex;
302 align-items: center;
303 justify-content: center;
304 background: none;
305 border: none;
306 cursor: pointer;
307 padding: 0.3em;
308 border-radius: 50%;
309 color: var(--text-muted);
310 transition: color 0.15s, background 0.15s;
311 }
312
313 .action-btn svg {
314 width: 1em;
315 height: 1em;
316 }
317
318 .action-btn:hover {
319 background: var(--primary-bg);
320 }
321
322 .reply-btn:hover { color: var(--primary); }
323 .repost-btn:hover { color: var(--success); }
324 .react-btn:hover { color: #E91E63; }
325 .zap-btn:hover { color: var(--primary); }
326 </style>
327