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