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