FeedView.svelte raw

   1  <script>
   2      import NoteCard from './NoteCard.svelte';
   3      import { feedNotes, feedLoading, feedHasMore, feedOldestTimestamp, prependFeedNotes, appendFeedNotes, resetFeedState } from './feedStores.js';
   4      import { fetchEvents, fetchUserContactList, fetchUserProfile } from './nostr.js';
   5      import { onMount, onDestroy } from 'svelte';
   6  
   7      export let isLoggedIn = false;
   8      export let userPubkey = "";
   9      export let userContactList = null;
  10  
  11      // Profile cache: pubkey -> profile object
  12      let profiles = new Map();
  13      let initialized = false;
  14      let feedContainer;
  15  
  16      // Extract follow pubkeys from contact list (kind 3)
  17      $: followPubkeys = extractFollows(userContactList);
  18  
  19      function extractFollows(contactList) {
  20          if (!contactList?.tags) return [];
  21          return contactList.tags
  22              .filter(t => t[0] === 'p' && t[1])
  23              .map(t => t[1]);
  24      }
  25  
  26      onMount(() => {
  27          if (isLoggedIn && followPubkeys.length > 0 && $feedNotes.length === 0) {
  28              loadFeed();
  29          }
  30      });
  31  
  32      // Reload feed when follows change
  33      $: if (isLoggedIn && followPubkeys.length > 0 && !initialized) {
  34          initialized = true;
  35          if ($feedNotes.length === 0) {
  36              loadFeed();
  37          }
  38      }
  39  
  40      async function loadFeed() {
  41          if ($feedLoading || followPubkeys.length === 0) return;
  42          feedLoading.set(true);
  43  
  44          try {
  45              const events = await fetchEvents(
  46                  [{ kinds: [1], authors: followPubkeys, limit: 40 }],
  47                  { timeout: 15000, useCache: false }
  48              );
  49  
  50              if (events && events.length > 0) {
  51                  prependFeedNotes(events);
  52                  loadProfiles(events);
  53              }
  54          } catch (err) {
  55              console.error("[Feed] Error loading feed:", err);
  56          } finally {
  57              feedLoading.set(false);
  58          }
  59      }
  60  
  61      async function loadMore() {
  62          if ($feedLoading || !$feedHasMore || followPubkeys.length === 0) return;
  63          feedLoading.set(true);
  64  
  65          try {
  66              const events = await fetchEvents(
  67                  [{ kinds: [1], authors: followPubkeys, until: $feedOldestTimestamp, limit: 40 }],
  68                  { timeout: 15000, useCache: false }
  69              );
  70  
  71              if (events) {
  72                  appendFeedNotes(events);
  73                  loadProfiles(events);
  74              }
  75          } catch (err) {
  76              console.error("[Feed] Error loading more:", err);
  77          } finally {
  78              feedLoading.set(false);
  79          }
  80      }
  81  
  82      function handleScroll(e) {
  83          const el = e.target;
  84          if (el.scrollHeight - el.scrollTop - el.clientHeight < 200) {
  85              loadMore();
  86          }
  87      }
  88  
  89      // Batch-load profiles for note authors we haven't cached
  90      async function loadProfiles(events) {
  91          const missing = new Set();
  92          for (const ev of events) {
  93              if (ev.pubkey && !profiles.has(ev.pubkey)) {
  94                  missing.add(ev.pubkey);
  95              }
  96          }
  97  
  98          for (const pk of missing) {
  99              try {
 100                  const profile = await fetchUserProfile(pk);
 101                  if (profile) {
 102                      profiles.set(pk, profile);
 103                      profiles = profiles; // trigger reactivity
 104                  }
 105              } catch {
 106                  // Silently skip failed profile fetches
 107              }
 108          }
 109      }
 110  
 111      function handleRefresh() {
 112          resetFeedState();
 113          initialized = false;
 114          loadFeed();
 115      }
 116  </script>
 117  
 118  <div class="feed-view" on:scroll={handleScroll} bind:this={feedContainer}>
 119      <div class="feed-header">
 120          <h2>Feed</h2>
 121          <button class="refresh-btn" on:click={handleRefresh} disabled={$feedLoading}>
 122              {$feedLoading ? '...' : 'Refresh'}
 123          </button>
 124      </div>
 125  
 126      {#if !isLoggedIn}
 127          <div class="feed-empty">
 128              <p>Log in to see your feed.</p>
 129          </div>
 130      {:else if followPubkeys.length === 0}
 131          <div class="feed-empty">
 132              <p>You aren't following anyone yet.</p>
 133              <p class="feed-hint">Follow some people to see their notes here.</p>
 134          </div>
 135      {:else}
 136          {#each $feedNotes as note (note.id)}
 137              <NoteCard event={note} {userPubkey} {profiles} />
 138          {/each}
 139  
 140          {#if $feedLoading}
 141              <div class="feed-loading">
 142                  <div class="spinner"></div>
 143              </div>
 144          {/if}
 145  
 146          {#if !$feedHasMore && $feedNotes.length > 0}
 147              <div class="feed-end">No more notes.</div>
 148          {/if}
 149  
 150          {#if !$feedLoading && $feedNotes.length === 0}
 151              <div class="feed-empty">
 152                  <p>No notes from your follows yet.</p>
 153              </div>
 154          {/if}
 155      {/if}
 156  </div>
 157  
 158  <style>
 159      .feed-view {
 160          width: 100%;
 161          max-width: 640px;
 162          height: 100%;
 163          overflow-y: auto;
 164          margin: 0 auto;
 165      }
 166  
 167      .feed-header {
 168          display: flex;
 169          align-items: center;
 170          justify-content: space-between;
 171          padding: 0.75em 1em;
 172          border-bottom: 1px solid var(--border-color);
 173          position: sticky;
 174          top: 0;
 175          background: var(--bg-color);
 176          z-index: 1;
 177      }
 178  
 179      .feed-header h2 {
 180          margin: 0;
 181          font-size: 1.1rem;
 182          color: var(--text-color);
 183      }
 184  
 185      .refresh-btn {
 186          background: var(--button-bg);
 187          border: 1px solid var(--border-color);
 188          border-radius: 6px;
 189          padding: 0.3em 0.75em;
 190          font-size: 0.8rem;
 191          cursor: pointer;
 192          color: var(--text-color);
 193          transition: background 0.15s;
 194      }
 195  
 196      .refresh-btn:hover:not(:disabled) {
 197          background: var(--button-hover-bg);
 198      }
 199  
 200      .refresh-btn:disabled {
 201          opacity: 0.5;
 202          cursor: not-allowed;
 203      }
 204  
 205      .feed-empty {
 206          text-align: center;
 207          padding: 3em 1em;
 208          color: var(--text-muted);
 209      }
 210  
 211      .feed-empty p {
 212          margin: 0 0 0.5em;
 213      }
 214  
 215      .feed-hint {
 216          font-size: 0.85rem;
 217      }
 218  
 219      .feed-loading {
 220          display: flex;
 221          justify-content: center;
 222          padding: 1.5em;
 223      }
 224  
 225      .spinner {
 226          width: 24px;
 227          height: 24px;
 228          border: 2px solid var(--border-color);
 229          border-top-color: var(--primary);
 230          border-radius: 50%;
 231          animation: spin 0.8s linear infinite;
 232      }
 233  
 234      @keyframes spin {
 235          to { transform: rotate(360deg); }
 236      }
 237  
 238      .feed-end {
 239          text-align: center;
 240          padding: 1.5em;
 241          color: var(--text-muted);
 242          font-size: 0.85rem;
 243      }
 244  </style>
 245