NotificationDropdown.svelte raw
1 <script>
2 import { notificationDropdownOpen } from './stores.js';
3 import {
4 replyNotifications, reactionNotifications, zapNotifications,
5 totalUnreadCount, markCategoryRead, markAllRead,
6 addReplyNotifications, addReactionNotifications, addZapNotifications
7 } from './notificationStores.js';
8 import { totalUnreadDMs, totalUnreadChannels } from './chatStores.js';
9 import { fetchEvents } from './nostr.js';
10 import { onMount, onDestroy } from 'svelte';
11
12 export let userPubkey = "";
13 export let isLoggedIn = false;
14
15 let fetched = false;
16
17 // Close on outside click
18 function handleWindowClick() {
19 if ($notificationDropdownOpen) {
20 notificationDropdownOpen.set(false);
21 }
22 }
23
24 // Fetch notifications when opened
25 $: if ($notificationDropdownOpen && isLoggedIn && userPubkey && !fetched) {
26 fetchNotifications();
27 }
28
29 async function fetchNotifications() {
30 fetched = true;
31 try {
32 // Fetch replies/mentions (kind 1 with #p tag)
33 const [replies, reactions, zaps] = await Promise.all([
34 fetchEvents(
35 [{ kinds: [1], "#p": [userPubkey], limit: 30 }],
36 { timeout: 10000, useCache: false }
37 ),
38 fetchEvents(
39 [{ kinds: [7], "#p": [userPubkey], limit: 30 }],
40 { timeout: 10000, useCache: false }
41 ),
42 fetchEvents(
43 [{ kinds: [9735], "#p": [userPubkey], limit: 30 }],
44 { timeout: 10000, useCache: false }
45 ),
46 ]);
47
48 if (replies?.length) addReplyNotifications(replies);
49 if (reactions?.length) addReactionNotifications(reactions);
50 if (zaps?.length) addZapNotifications(zaps);
51 } catch (err) {
52 console.error("[Notifications] Fetch error:", err);
53 }
54 }
55
56 function formatTime(ts) {
57 if (!ts) return '';
58 const now = Math.floor(Date.now() / 1000);
59 const diff = now - ts;
60 if (diff < 60) return 'now';
61 if (diff < 3600) return `${Math.floor(diff / 60)}m`;
62 if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
63 return `${Math.floor(diff / 86400)}d`;
64 }
65
66 function truncate(text, len = 60) {
67 if (!text || text.length <= len) return text || '';
68 return text.slice(0, len) + '...';
69 }
70
71 function handleMarkAll() {
72 markAllRead();
73 }
74 </script>
75
76 <svelte:window on:click={handleWindowClick} />
77
78 {#if $notificationDropdownOpen}
79 <!-- svelte-ignore a11y-click-events-have-key-events -->
80 <!-- svelte-ignore a11y-no-static-element-interactions -->
81 <div class="notification-dropdown" on:click|stopPropagation>
82 <div class="notif-header">
83 <span class="notif-title">Notifications</span>
84 {#if $totalUnreadCount > 0}
85 <button class="mark-all-btn" on:click={handleMarkAll}>Mark all read</button>
86 {/if}
87 </div>
88
89 <div class="notif-body">
90 <!-- Replies -->
91 <div class="notif-section">
92 <div class="section-header">
93 <span class="section-label">Replies</span>
94 {#if $replyNotifications.unreadCount > 0}
95 <span class="section-count">{$replyNotifications.unreadCount}</span>
96 <button class="section-read" on:click={() => markCategoryRead("replies")}>read</button>
97 {/if}
98 </div>
99 {#if $replyNotifications.items.length === 0}
100 <div class="notif-empty">No replies yet.</div>
101 {:else}
102 {#each $replyNotifications.items.slice(0, 10) as item (item.id)}
103 <div class="notif-item">
104 <div class="notif-icon reply-icon">
105 <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>
106 </div>
107 <div class="notif-content">
108 <span class="notif-text">{truncate(item.content)}</span>
109 <span class="notif-time">{formatTime(item.created_at)}</span>
110 </div>
111 </div>
112 {/each}
113 {/if}
114 </div>
115
116 <!-- Reactions -->
117 <div class="notif-section">
118 <div class="section-header">
119 <span class="section-label">Reactions</span>
120 {#if $reactionNotifications.unreadCount > 0}
121 <span class="section-count">{$reactionNotifications.unreadCount}</span>
122 <button class="section-read" on:click={() => markCategoryRead("reactions")}>read</button>
123 {/if}
124 </div>
125 {#if $reactionNotifications.items.length === 0}
126 <div class="notif-empty">No reactions yet.</div>
127 {:else}
128 {#each $reactionNotifications.items.slice(0, 10) as item (item.id)}
129 <div class="notif-item">
130 <div class="notif-icon react-icon">
131 <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>
132 </div>
133 <div class="notif-content">
134 <span class="notif-text">{item.content || '+'}</span>
135 <span class="notif-time">{formatTime(item.created_at)}</span>
136 </div>
137 </div>
138 {/each}
139 {/if}
140 </div>
141
142 <!-- Zaps -->
143 <div class="notif-section">
144 <div class="section-header">
145 <span class="section-label">Zaps</span>
146 {#if $zapNotifications.unreadCount > 0}
147 <span class="section-count">{$zapNotifications.unreadCount}</span>
148 <button class="section-read" on:click={() => markCategoryRead("zaps")}>read</button>
149 {/if}
150 </div>
151 {#if $zapNotifications.items.length === 0}
152 <div class="notif-empty">No zaps yet.</div>
153 {:else}
154 {#each $zapNotifications.items.slice(0, 10) as item (item.id)}
155 <div class="notif-item">
156 <div class="notif-icon zap-icon">
157 <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>
158 </div>
159 <div class="notif-content">
160 <span class="notif-text">Zap received</span>
161 <span class="notif-time">{formatTime(item.created_at)}</span>
162 </div>
163 </div>
164 {/each}
165 {/if}
166 </div>
167
168 <!-- DMs and Channels summary -->
169 {#if $totalUnreadDMs > 0 || $totalUnreadChannels > 0}
170 <div class="notif-section">
171 <div class="section-header">
172 <span class="section-label">Messages</span>
173 </div>
174 {#if $totalUnreadDMs > 0}
175 <div class="notif-item">
176 <div class="notif-icon dm-icon">
177 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M22 7l-10 7L2 7"/></svg>
178 </div>
179 <div class="notif-content">
180 <span class="notif-text">{$totalUnreadDMs} unread message{$totalUnreadDMs > 1 ? 's' : ''}</span>
181 </div>
182 </div>
183 {/if}
184 {#if $totalUnreadChannels > 0}
185 <div class="notif-item">
186 <div class="notif-icon channel-icon">
187 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 9h16M4 15h16M10 3L8 21M16 3l-2 18"/></svg>
188 </div>
189 <div class="notif-content">
190 <span class="notif-text">{$totalUnreadChannels} unread channel message{$totalUnreadChannels > 1 ? 's' : ''}</span>
191 </div>
192 </div>
193 {/if}
194 </div>
195 {/if}
196 </div>
197 </div>
198 {/if}
199
200 <style>
201 .notification-dropdown {
202 position: fixed;
203 top: 3em;
204 right: 0.5em;
205 width: 340px;
206 max-height: 70vh;
207 background: var(--card-bg, #0a0a0a);
208 border: 1px solid var(--border-color);
209 border-radius: 10px;
210 box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
211 z-index: 1100;
212 display: flex;
213 flex-direction: column;
214 overflow: hidden;
215 }
216
217 .notif-header {
218 display: flex;
219 align-items: center;
220 justify-content: space-between;
221 padding: 0.7em 0.9em;
222 border-bottom: 1px solid var(--border-color);
223 }
224
225 .notif-title {
226 font-weight: 600;
227 font-size: 0.9rem;
228 color: var(--text-color);
229 }
230
231 .mark-all-btn {
232 background: none;
233 border: none;
234 color: var(--primary);
235 font-size: 0.75rem;
236 cursor: pointer;
237 }
238
239 .mark-all-btn:hover {
240 text-decoration: underline;
241 }
242
243 .notif-body {
244 overflow-y: auto;
245 max-height: calc(70vh - 3em);
246 }
247
248 .notif-section {
249 border-bottom: 1px solid var(--border-color);
250 }
251
252 .notif-section:last-child {
253 border-bottom: none;
254 }
255
256 .section-header {
257 display: flex;
258 align-items: center;
259 gap: 0.4em;
260 padding: 0.5em 0.9em;
261 background: var(--bg-color);
262 }
263
264 .section-label {
265 font-size: 0.75rem;
266 font-weight: 600;
267 color: var(--text-muted);
268 text-transform: uppercase;
269 letter-spacing: 0.5px;
270 flex: 1;
271 }
272
273 .section-count {
274 background: var(--primary);
275 color: #000;
276 font-size: 0.6rem;
277 font-weight: 700;
278 min-width: 16px;
279 height: 16px;
280 border-radius: 8px;
281 display: flex;
282 align-items: center;
283 justify-content: center;
284 padding: 0 3px;
285 }
286
287 .section-read {
288 background: none;
289 border: none;
290 color: var(--text-muted);
291 font-size: 0.65rem;
292 cursor: pointer;
293 }
294
295 .section-read:hover {
296 color: var(--primary);
297 }
298
299 .notif-item {
300 display: flex;
301 align-items: flex-start;
302 gap: 0.5em;
303 padding: 0.5em 0.9em;
304 transition: background 0.1s;
305 }
306
307 .notif-item:hover {
308 background: var(--primary-bg);
309 }
310
311 .notif-icon {
312 flex-shrink: 0;
313 width: 1.1em;
314 height: 1.1em;
315 margin-top: 0.1em;
316 }
317
318 .notif-icon svg {
319 width: 100%;
320 height: 100%;
321 }
322
323 .reply-icon { color: var(--primary); }
324 .react-icon { color: #E91E63; }
325 .zap-icon { color: var(--primary); }
326 .dm-icon { color: var(--text-muted); }
327 .channel-icon { color: var(--text-muted); }
328
329 .notif-content {
330 flex: 1;
331 min-width: 0;
332 display: flex;
333 flex-direction: column;
334 }
335
336 .notif-text {
337 font-size: 0.8rem;
338 color: var(--text-color);
339 line-height: 1.3;
340 word-break: break-word;
341 }
342
343 .notif-time {
344 font-size: 0.65rem;
345 color: var(--text-muted);
346 margin-top: 0.15em;
347 }
348
349 .notif-empty {
350 padding: 0.7em 0.9em;
351 font-size: 0.78rem;
352 color: var(--text-muted);
353 }
354
355 @media (max-width: 640px) {
356 .notification-dropdown {
357 right: 0;
358 left: 0;
359 width: auto;
360 border-radius: 0 0 10px 10px;
361 }
362 }
363 </style>
364