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, '&amp;')
  38              .replace(/</g, '&lt;')
  39              .replace(/>/g, '&gt;');
  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