InboxView.svelte raw

   1  <script>
   2      import { onMount, onDestroy, tick } from 'svelte';
   3      import { conversations, selectedConversation, inboxLoading, markConversationRead } from './chatStores.js';
   4      import { fetchEvents, fetchUserProfile, nostrClient } from './nostr.js';
   5  
   6      export let isLoggedIn = false;
   7      export let userPubkey = "";
   8      export let userSigner = null;
   9  
  10      let profiles = new Map();
  11      let messageInput = "";
  12      let messagesEnd;
  13      let sending = false;
  14      let initialized = false;
  15  
  16      // Sorted conversation list: most recent first
  17      $: conversationList = getConversationList($conversations);
  18  
  19      function getConversationList(convMap) {
  20          const list = [];
  21          for (const [pubkey, conv] of convMap.entries()) {
  22              if (!conv.messages || conv.messages.length === 0) continue;
  23              const lastMsg = conv.messages[conv.messages.length - 1];
  24              list.push({
  25                  pubkey,
  26                  lastMessage: lastMsg,
  27                  unreadCount: conv.unreadCount || 0,
  28                  protocol: conv.protocol || "nip04",
  29              });
  30          }
  31          list.sort((a, b) => (b.lastMessage?.created_at || 0) - (a.lastMessage?.created_at || 0));
  32          return list;
  33      }
  34  
  35      // Current conversation messages
  36      $: currentMessages = $selectedConversation
  37          ? ($conversations.get($selectedConversation)?.messages || [])
  38          : [];
  39  
  40      // Auto-scroll on new messages
  41      $: if (currentMessages.length > 0 && messagesEnd) {
  42          tick().then(() => {
  43              if (messagesEnd) messagesEnd.scrollIntoView({ behavior: 'smooth' });
  44          });
  45      }
  46  
  47      // Mark as read when selecting conversation
  48      $: if ($selectedConversation) {
  49          markConversationRead($selectedConversation);
  50      }
  51  
  52      onMount(() => {
  53          if (isLoggedIn && userPubkey && !initialized) {
  54              initialized = true;
  55              loadDMs();
  56          }
  57      });
  58  
  59      async function loadDMs() {
  60          if ($inboxLoading || !userPubkey) return;
  61          inboxLoading.set(true);
  62  
  63          try {
  64              // Fetch NIP-04 DMs (kind 4) where user is author or tagged
  65              const [sent, received] = await Promise.all([
  66                  fetchEvents(
  67                      [{ kinds: [4], authors: [userPubkey], limit: 200 }],
  68                      { timeout: 15000, useCache: false }
  69                  ),
  70                  fetchEvents(
  71                      [{ kinds: [4], "#p": [userPubkey], limit: 200 }],
  72                      { timeout: 15000, useCache: false }
  73                  ),
  74              ]);
  75  
  76              const allDMs = [...(sent || []), ...(received || [])];
  77              if (allDMs.length === 0) {
  78                  inboxLoading.set(false);
  79                  return;
  80              }
  81  
  82              // Group by conversation partner
  83              const convMap = new Map();
  84              for (const ev of allDMs) {
  85                  const partner = ev.pubkey === userPubkey
  86                      ? ev.tags.find(t => t[0] === "p")?.[1]
  87                      : ev.pubkey;
  88                  if (!partner) continue;
  89  
  90                  if (!convMap.has(partner)) {
  91                      convMap.set(partner, { messages: [], lastRead: 0, unreadCount: 0, protocol: "nip04" });
  92                  }
  93  
  94                  // Decrypt content
  95                  let decrypted = ev.content;
  96                  try {
  97                      if (userSigner?.nip04Decrypt) {
  98                          decrypted = await userSigner.nip04Decrypt(partner, ev.content);
  99                      }
 100                  } catch {
 101                      decrypted = "[encrypted]";
 102                  }
 103  
 104                  convMap.get(partner).messages.push({
 105                      ...ev,
 106                      decrypted,
 107                      isMine: ev.pubkey === userPubkey,
 108                  });
 109              }
 110  
 111              // Sort messages within each conversation
 112              for (const conv of convMap.values()) {
 113                  conv.messages.sort((a, b) => a.created_at - b.created_at);
 114              }
 115  
 116              conversations.set(convMap);
 117  
 118              // Load profiles for conversation partners
 119              const pubkeys = [...convMap.keys()];
 120              loadProfiles(pubkeys);
 121  
 122          } catch (err) {
 123              console.error("[Inbox] Error loading DMs:", err);
 124          } finally {
 125              inboxLoading.set(false);
 126          }
 127      }
 128  
 129      async function loadProfiles(pubkeys) {
 130          for (const pk of pubkeys) {
 131              if (profiles.has(pk)) continue;
 132              try {
 133                  const profile = await fetchUserProfile(pk);
 134                  if (profile) {
 135                      profiles.set(pk, profile);
 136                      profiles = profiles;
 137                  }
 138              } catch {
 139                  // skip
 140              }
 141          }
 142      }
 143  
 144      function getDisplayName(pubkey) {
 145          const p = profiles.get(pubkey);
 146          if (p?.name) return p.name;
 147          if (p?.display_name) return p.display_name;
 148          return pubkey?.slice(0, 12) + '...';
 149      }
 150  
 151      function getAvatar(pubkey) {
 152          return profiles.get(pubkey)?.picture || null;
 153      }
 154  
 155      function formatTime(ts) {
 156          if (!ts) return '';
 157          const d = new Date(ts * 1000);
 158          const now = new Date();
 159          if (d.toDateString() === now.toDateString()) {
 160              return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
 161          }
 162          return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
 163      }
 164  
 165      function selectConversation(pubkey) {
 166          selectedConversation.set(pubkey);
 167      }
 168  
 169      function backToList() {
 170          selectedConversation.set(null);
 171      }
 172  
 173      async function sendMessage() {
 174          if (!messageInput.trim() || !$selectedConversation || !userSigner || sending) return;
 175          sending = true;
 176  
 177          try {
 178              const plaintext = messageInput.trim();
 179              const partner = $selectedConversation;
 180  
 181              // Encrypt with NIP-04
 182              let ciphertext;
 183              if (userSigner.nip04Encrypt) {
 184                  ciphertext = await userSigner.nip04Encrypt(partner, plaintext);
 185              } else {
 186                  throw new Error("Signer does not support NIP-04 encryption");
 187              }
 188  
 189              const event = {
 190                  kind: 4,
 191                  created_at: Math.floor(Date.now() / 1000),
 192                  tags: [["p", partner]],
 193                  content: ciphertext,
 194              };
 195  
 196              const signedEvent = await userSigner.signEvent(event);
 197              await nostrClient.publish(signedEvent);
 198  
 199              // Add to local conversation immediately
 200              conversations.update(map => {
 201                  const conv = map.get(partner) || { messages: [], lastRead: 0, unreadCount: 0, protocol: "nip04" };
 202                  conv.messages.push({
 203                      ...signedEvent,
 204                      decrypted: plaintext,
 205                      isMine: true,
 206                  });
 207                  map.set(partner, conv);
 208                  return new Map(map);
 209              });
 210  
 211              messageInput = "";
 212          } catch (err) {
 213              console.error("[Inbox] Failed to send message:", err);
 214          } finally {
 215              sending = false;
 216          }
 217      }
 218  
 219      function handleKeydown(e) {
 220          if (e.key === 'Enter' && !e.shiftKey) {
 221              e.preventDefault();
 222              sendMessage();
 223          }
 224      }
 225  
 226      function truncateMessage(text, maxLen = 50) {
 227          if (!text || text.length <= maxLen) return text;
 228          return text.slice(0, maxLen) + '...';
 229      }
 230  </script>
 231  
 232  <div class="inbox" class:has-selected={$selectedConversation}>
 233      <!-- Conversation List -->
 234      <div class="conversation-list" class:hidden-mobile={$selectedConversation}>
 235          {#if !isLoggedIn}
 236              <div class="inbox-empty">Log in to see your messages.</div>
 237          {:else if $inboxLoading}
 238              <div class="inbox-loading">
 239                  <div class="spinner"></div>
 240              </div>
 241          {:else if conversationList.length === 0}
 242              <div class="inbox-empty">No conversations yet.</div>
 243          {:else}
 244              {#each conversationList as conv (conv.pubkey)}
 245                  <!-- svelte-ignore a11y-click-events-have-key-events -->
 246                  <!-- svelte-ignore a11y-no-static-element-interactions -->
 247                  <div
 248                      class="conversation-item"
 249                      class:active={$selectedConversation === conv.pubkey}
 250                      on:click={() => selectConversation(conv.pubkey)}
 251                  >
 252                      {#if getAvatar(conv.pubkey)}
 253                          <img src={getAvatar(conv.pubkey)} alt="" class="conv-avatar" />
 254                      {:else}
 255                          <div class="conv-avatar-placeholder">
 256                              {getDisplayName(conv.pubkey).charAt(0).toUpperCase()}
 257                          </div>
 258                      {/if}
 259                      <div class="conv-info">
 260                          <div class="conv-header-row">
 261                              <span class="conv-name">{getDisplayName(conv.pubkey)}</span>
 262                              <span class="conv-time">{formatTime(conv.lastMessage?.created_at)}</span>
 263                          </div>
 264                          <div class="conv-preview">
 265                              {truncateMessage(conv.lastMessage?.decrypted || conv.lastMessage?.content || '')}
 266                          </div>
 267                      </div>
 268                      {#if conv.unreadCount > 0}
 269                          <span class="conv-badge">{conv.unreadCount}</span>
 270                      {/if}
 271                  </div>
 272              {/each}
 273          {/if}
 274      </div>
 275  
 276      <!-- Message Thread -->
 277      {#if $selectedConversation}
 278          <div class="message-thread">
 279              <div class="thread-header">
 280                  <button class="back-btn" on:click={backToList}>
 281                      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
 282                  </button>
 283                  <div class="thread-user">
 284                      {#if getAvatar($selectedConversation)}
 285                          <img src={getAvatar($selectedConversation)} alt="" class="thread-avatar" />
 286                      {:else}
 287                          <div class="thread-avatar-placeholder">
 288                              {getDisplayName($selectedConversation).charAt(0).toUpperCase()}
 289                          </div>
 290                      {/if}
 291                      <span class="thread-name">{getDisplayName($selectedConversation)}</span>
 292                  </div>
 293              </div>
 294  
 295              <div class="messages-container">
 296                  {#each currentMessages as msg (msg.id)}
 297                      <div class="message" class:mine={msg.isMine} class:theirs={!msg.isMine}>
 298                          <div class="message-bubble">
 299                              <div class="message-text">{msg.decrypted || msg.content}</div>
 300                              <span class="message-time">{formatTime(msg.created_at)}</span>
 301                          </div>
 302                      </div>
 303                  {/each}
 304                  <div bind:this={messagesEnd}></div>
 305              </div>
 306  
 307              <div class="compose-bar">
 308                  <textarea
 309                      bind:value={messageInput}
 310                      on:keydown={handleKeydown}
 311                      placeholder="Type a message..."
 312                      rows="1"
 313                      disabled={sending}
 314                  ></textarea>
 315                  <button class="send-btn" on:click={sendMessage} disabled={sending || !messageInput.trim()}>
 316                      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
 317                  </button>
 318              </div>
 319          </div>
 320      {/if}
 321  </div>
 322  
 323  <style>
 324      .inbox {
 325          display: flex;
 326          width: 100%;
 327          height: 100%;
 328          overflow: hidden;
 329      }
 330  
 331      .conversation-list {
 332          width: 320px;
 333          flex-shrink: 0;
 334          border-right: 1px solid var(--border-color);
 335          overflow-y: auto;
 336      }
 337  
 338      .conversation-item {
 339          display: flex;
 340          align-items: center;
 341          gap: 0.6em;
 342          padding: 0.7em 0.8em;
 343          cursor: pointer;
 344          transition: background 0.15s;
 345          border-bottom: 1px solid var(--border-color);
 346      }
 347  
 348      .conversation-item:hover {
 349          background: var(--primary-bg);
 350      }
 351  
 352      .conversation-item.active {
 353          background: var(--primary-bg);
 354      }
 355  
 356      .conv-avatar, .conv-avatar-placeholder {
 357          width: 40px;
 358          height: 40px;
 359          border-radius: 50%;
 360          flex-shrink: 0;
 361      }
 362  
 363      .conv-avatar {
 364          object-fit: cover;
 365      }
 366  
 367      .conv-avatar-placeholder {
 368          background: var(--primary);
 369          color: #000;
 370          display: flex;
 371          align-items: center;
 372          justify-content: center;
 373          font-weight: bold;
 374          font-size: 0.85rem;
 375      }
 376  
 377      .conv-info {
 378          flex: 1;
 379          min-width: 0;
 380      }
 381  
 382      .conv-header-row {
 383          display: flex;
 384          justify-content: space-between;
 385          align-items: baseline;
 386          gap: 0.5em;
 387      }
 388  
 389      .conv-name {
 390          font-weight: 600;
 391          font-size: 0.85rem;
 392          color: var(--text-color);
 393          white-space: nowrap;
 394          overflow: hidden;
 395          text-overflow: ellipsis;
 396      }
 397  
 398      .conv-time {
 399          font-size: 0.7rem;
 400          color: var(--text-muted);
 401          flex-shrink: 0;
 402      }
 403  
 404      .conv-preview {
 405          font-size: 0.78rem;
 406          color: var(--text-muted);
 407          white-space: nowrap;
 408          overflow: hidden;
 409          text-overflow: ellipsis;
 410          margin-top: 0.15em;
 411      }
 412  
 413      .conv-badge {
 414          background: var(--primary);
 415          color: #000;
 416          font-size: 0.7rem;
 417          font-weight: bold;
 418          min-width: 18px;
 419          height: 18px;
 420          border-radius: 9px;
 421          display: flex;
 422          align-items: center;
 423          justify-content: center;
 424          padding: 0 4px;
 425          flex-shrink: 0;
 426      }
 427  
 428      .message-thread {
 429          flex: 1;
 430          display: flex;
 431          flex-direction: column;
 432          min-width: 0;
 433      }
 434  
 435      .thread-header {
 436          display: flex;
 437          align-items: center;
 438          gap: 0.5em;
 439          padding: 0.6em 0.8em;
 440          border-bottom: 1px solid var(--border-color);
 441          background: var(--bg-color);
 442      }
 443  
 444      .back-btn {
 445          display: none;
 446          background: none;
 447          border: none;
 448          color: var(--text-muted);
 449          cursor: pointer;
 450          padding: 0.2em;
 451      }
 452  
 453      .back-btn svg {
 454          width: 1.2em;
 455          height: 1.2em;
 456      }
 457  
 458      .thread-user {
 459          display: flex;
 460          align-items: center;
 461          gap: 0.5em;
 462      }
 463  
 464      .thread-avatar, .thread-avatar-placeholder {
 465          width: 28px;
 466          height: 28px;
 467          border-radius: 50%;
 468      }
 469  
 470      .thread-avatar {
 471          object-fit: cover;
 472      }
 473  
 474      .thread-avatar-placeholder {
 475          background: var(--primary);
 476          color: #000;
 477          display: flex;
 478          align-items: center;
 479          justify-content: center;
 480          font-weight: bold;
 481          font-size: 0.7rem;
 482      }
 483  
 484      .thread-name {
 485          font-weight: 600;
 486          font-size: 0.85rem;
 487          color: var(--text-color);
 488      }
 489  
 490      .messages-container {
 491          flex: 1;
 492          overflow-y: auto;
 493          padding: 0.75em;
 494          display: flex;
 495          flex-direction: column;
 496          gap: 0.3em;
 497      }
 498  
 499      .message {
 500          display: flex;
 501          max-width: 75%;
 502      }
 503  
 504      .message.mine {
 505          align-self: flex-end;
 506      }
 507  
 508      .message.theirs {
 509          align-self: flex-start;
 510      }
 511  
 512      .message-bubble {
 513          padding: 0.5em 0.75em;
 514          border-radius: 12px;
 515          font-size: 0.85rem;
 516          line-height: 1.4;
 517          word-break: break-word;
 518      }
 519  
 520      .mine .message-bubble {
 521          background: var(--primary);
 522          color: #000;
 523          border-bottom-right-radius: 4px;
 524      }
 525  
 526      .theirs .message-bubble {
 527          background: var(--card-bg, #1a1a1a);
 528          color: var(--text-color);
 529          border-bottom-left-radius: 4px;
 530      }
 531  
 532      .message-time {
 533          display: block;
 534          font-size: 0.65rem;
 535          opacity: 0.6;
 536          margin-top: 0.2em;
 537          text-align: right;
 538      }
 539  
 540      .compose-bar {
 541          display: flex;
 542          align-items: flex-end;
 543          gap: 0.4em;
 544          padding: 0.5em 0.75em;
 545          border-top: 1px solid var(--border-color);
 546          background: var(--bg-color);
 547      }
 548  
 549      .compose-bar textarea {
 550          flex: 1;
 551          background: var(--card-bg, #1a1a1a);
 552          border: 1px solid var(--border-color);
 553          border-radius: 8px;
 554          padding: 0.5em 0.75em;
 555          color: var(--text-color);
 556          font-size: 0.85rem;
 557          resize: none;
 558          outline: none;
 559          max-height: 120px;
 560          font-family: inherit;
 561      }
 562  
 563      .compose-bar textarea::placeholder {
 564          color: var(--text-muted);
 565      }
 566  
 567      .send-btn {
 568          background: var(--primary);
 569          border: none;
 570          border-radius: 50%;
 571          width: 34px;
 572          height: 34px;
 573          display: flex;
 574          align-items: center;
 575          justify-content: center;
 576          cursor: pointer;
 577          flex-shrink: 0;
 578          color: #000;
 579          transition: opacity 0.15s;
 580      }
 581  
 582      .send-btn:disabled {
 583          opacity: 0.4;
 584          cursor: not-allowed;
 585      }
 586  
 587      .send-btn svg {
 588          width: 1em;
 589          height: 1em;
 590      }
 591  
 592      .inbox-empty {
 593          text-align: center;
 594          padding: 3em 1em;
 595          color: var(--text-muted);
 596          font-size: 0.85rem;
 597      }
 598  
 599      .inbox-loading {
 600          display: flex;
 601          justify-content: center;
 602          padding: 2em;
 603      }
 604  
 605      .spinner {
 606          width: 24px;
 607          height: 24px;
 608          border: 2px solid var(--border-color);
 609          border-top-color: var(--primary);
 610          border-radius: 50%;
 611          animation: spin 0.8s linear infinite;
 612      }
 613  
 614      @keyframes spin {
 615          to { transform: rotate(360deg); }
 616      }
 617  
 618      /* Mobile: single-pane */
 619      @media (max-width: 640px) {
 620          .conversation-list {
 621              width: 100%;
 622              border-right: none;
 623          }
 624  
 625          .conversation-list.hidden-mobile {
 626              display: none;
 627          }
 628  
 629          .inbox:not(.has-selected) .message-thread {
 630              display: none;
 631          }
 632  
 633          .inbox.has-selected .conversation-list {
 634              display: none;
 635          }
 636  
 637          .back-btn {
 638              display: flex;
 639          }
 640      }
 641  </style>
 642