Add following feed + persist likes to localStorage

- Following tab in feed header (visible when logged in)
- Fetches kind 1 notes from followed pubkeys via NDK
- fetchFollows on login using NDK user.follows()
- fetchFollowFeed added to nostr lib
- Liked note IDs persisted in localStorage so likes survive refresh

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jure
2026-03-08 18:52:26 +01:00
parent 5879a640df
commit 30b5bb8d42
5 changed files with 121 additions and 23 deletions

View File

@@ -1,22 +1,78 @@
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { useFeedStore } from "../../stores/feed"; import { useFeedStore } from "../../stores/feed";
import { useUserStore } from "../../stores/user"; import { useUserStore } from "../../stores/user";
import { fetchFollowFeed } from "../../lib/nostr";
import { NoteCard } from "./NoteCard"; import { NoteCard } from "./NoteCard";
import { ComposeBox } from "./ComposeBox"; import { ComposeBox } from "./ComposeBox";
import { NDKEvent } from "@nostr-dev-kit/ndk";
type FeedTab = "global" | "following";
export function Feed() { export function Feed() {
const { notes, loading, connected, error, connect, loadFeed } = useFeedStore(); const { notes, loading, connected, error, connect, loadFeed } = useFeedStore();
const { loggedIn } = useUserStore(); const { loggedIn, follows } = useUserStore();
const [tab, setTab] = useState<FeedTab>("global");
const [followNotes, setFollowNotes] = useState<NDKEvent[]>([]);
const [followLoading, setFollowLoading] = useState(false);
useEffect(() => { useEffect(() => {
connect().then(() => loadFeed()); connect().then(() => loadFeed());
}, []); }, []);
useEffect(() => {
if (tab === "following" && loggedIn && follows.length > 0) {
loadFollowFeed();
}
}, [tab, follows]);
const loadFollowFeed = async () => {
setFollowLoading(true);
try {
const events = await fetchFollowFeed(follows);
setFollowNotes(events);
} finally {
setFollowLoading(false);
}
};
const isFollowing = tab === "following";
const activeNotes = isFollowing ? followNotes : notes;
const isLoading = isFollowing ? followLoading : loading;
const filteredNotes = activeNotes.filter((event) => {
const c = event.content.trim();
return c.length > 0 && !c.startsWith("{") && !c.startsWith("[");
});
return ( return (
<div className="h-full flex flex-col"> <div className="h-full flex flex-col">
{/* Header */} {/* Header */}
<header className="border-b border-border px-4 py-2.5 flex items-center justify-between shrink-0"> <header className="border-b border-border px-4 py-2.5 flex items-center justify-between shrink-0">
<h1 className="text-text text-sm font-medium tracking-wide">Global Feed</h1> <div className="flex items-center gap-1">
<button
onClick={() => setTab("global")}
className={`px-3 py-1 text-[12px] transition-colors ${
tab === "global"
? "text-text border-b-2 border-accent"
: "text-text-muted hover:text-text"
}`}
>
Global
</button>
{loggedIn && (
<button
onClick={() => setTab("following")}
className={`px-3 py-1 text-[12px] transition-colors ${
tab === "following"
? "text-text border-b-2 border-accent"
: "text-text-muted hover:text-text"
}`}
>
Following
</button>
)}
</div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{connected && ( {connected && (
<span className="text-success text-[11px] flex items-center gap-1"> <span className="text-success text-[11px] flex items-center gap-1">
@@ -25,11 +81,11 @@ export function Feed() {
</span> </span>
)} )}
<button <button
onClick={loadFeed} onClick={isFollowing ? loadFollowFeed : loadFeed}
disabled={loading} disabled={isLoading}
className="text-text-muted hover:text-text text-[11px] px-2 py-1 border border-border hover:border-text-dim transition-colors disabled:opacity-40" className="text-text-muted hover:text-text text-[11px] px-2 py-1 border border-border hover:border-text-dim transition-colors disabled:opacity-40"
> >
{loading ? "loading…" : "refresh"} {isLoading ? "loading…" : "refresh"}
</button> </button>
</div> </div>
</header> </header>
@@ -39,32 +95,29 @@ export function Feed() {
{/* Feed */} {/* Feed */}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{error && ( {error && !isFollowing && (
<div className="px-4 py-3 text-danger text-[12px] border-b border-border bg-danger/5"> <div className="px-4 py-3 text-danger text-[12px] border-b border-border bg-danger/5">
{error} {error}
</div> </div>
)} )}
{loading && notes.length === 0 && ( {isLoading && filteredNotes.length === 0 && (
<div className="px-4 py-8 text-text-dim text-[12px] text-center"> <div className="px-4 py-8 text-text-dim text-[12px] text-center">
Connecting to relays {isFollowing ? "Loading notes from people you follow…" : "Connecting to relays…"}
</div> </div>
)} )}
{!loading && notes.length === 0 && !error && ( {!isLoading && filteredNotes.length === 0 && (
<div className="px-4 py-8 text-text-dim text-[12px] text-center"> <div className="px-4 py-8 text-text-dim text-[12px] text-center">
No notes yet. {isFollowing && follows.length === 0
? "You're not following anyone yet."
: "No notes yet."}
</div> </div>
)} )}
{notes {filteredNotes.map((event) => (
.filter((event) => { <NoteCard key={event.id} event={event} />
const c = event.content.trim(); ))}
return c.length > 0 && !c.startsWith("{") && !c.startsWith("[");
})
.map((event) => (
<NoteCard key={event.id} event={event} />
))}
</div> </div>
</div> </div>
); );

View File

@@ -20,7 +20,12 @@ export function NoteCard({ event }: NoteCardProps) {
const { loggedIn } = useUserStore(); const { loggedIn } = useUserStore();
const { openProfile } = useUIStore(); const { openProfile } = useUIStore();
const [liked, setLiked] = useState(false); const likedKey = "wrystr_liked";
const getLiked = () => {
try { return new Set<string>(JSON.parse(localStorage.getItem(likedKey) || "[]")); }
catch { return new Set<string>(); }
};
const [liked, setLiked] = useState(() => getLiked().has(event.id));
const [liking, setLiking] = useState(false); const [liking, setLiking] = useState(false);
const [showReply, setShowReply] = useState(false); const [showReply, setShowReply] = useState(false);
const [replyText, setReplyText] = useState(""); const [replyText, setReplyText] = useState("");
@@ -34,6 +39,9 @@ export function NoteCard({ event }: NoteCardProps) {
setLiking(true); setLiking(true);
try { try {
await publishReaction(event.id, event.pubkey); await publishReaction(event.id, event.pubkey);
const liked = getLiked();
liked.add(event.id);
localStorage.setItem(likedKey, JSON.stringify(Array.from(liked)));
setLiked(true); setLiked(true);
} finally { } finally {
setLiking(false); setLiking(false);

View File

@@ -99,6 +99,23 @@ export async function publishNote(content: string): Promise<void> {
await event.publish(); await event.publish();
} }
export async function fetchFollowFeed(pubkeys: string[], limit = 80): Promise<NDKEvent[]> {
if (pubkeys.length === 0) return [];
const instance = getNDK();
const filter: NDKFilter = {
kinds: [NDKKind.Text],
authors: pubkeys,
limit,
};
const events = await instance.fetchEvents(filter, {
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
});
return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0));
}
export async function fetchUserNotes(pubkey: string, limit = 30): Promise<NDKEvent[]> { export async function fetchUserNotes(pubkey: string, limit = 30): Promise<NDKEvent[]> {
const instance = getNDK(); const instance = getNDK();
const filter: NDKFilter = { const filter: NDKFilter = {

View File

@@ -1 +1 @@
export { getNDK, connectToRelays, fetchGlobalFeed, publishNote, publishReaction, publishReply, fetchUserNotes, fetchProfile } from "./client"; export { getNDK, connectToRelays, fetchGlobalFeed, fetchFollowFeed, publishNote, publishReaction, publishReply, fetchUserNotes, fetchProfile } from "./client";

View File

@@ -7,6 +7,7 @@ interface UserState {
pubkey: string | null; pubkey: string | null;
npub: string | null; npub: string | null;
profile: any | null; profile: any | null;
follows: string[];
loggedIn: boolean; loggedIn: boolean;
loginError: string | null; loginError: string | null;
@@ -14,12 +15,14 @@ interface UserState {
loginWithPubkey: (pubkey: string) => Promise<void>; loginWithPubkey: (pubkey: string) => Promise<void>;
logout: () => void; logout: () => void;
fetchOwnProfile: () => Promise<void>; fetchOwnProfile: () => Promise<void>;
fetchFollows: () => Promise<void>;
} }
export const useUserStore = create<UserState>((set, get) => ({ export const useUserStore = create<UserState>((set, get) => ({
pubkey: null, pubkey: null,
npub: null, npub: null,
profile: null, profile: null,
follows: [],
loggedIn: false, loggedIn: false,
loginError: null, loginError: null,
@@ -54,8 +57,9 @@ export const useUserStore = create<UserState>((set, get) => ({
localStorage.setItem("wrystr_pubkey", pubkey); localStorage.setItem("wrystr_pubkey", pubkey);
localStorage.setItem("wrystr_login_type", "nsec"); localStorage.setItem("wrystr_login_type", "nsec");
// Fetch profile // Fetch profile and follows
get().fetchOwnProfile(); get().fetchOwnProfile();
get().fetchFollows();
} catch (err) { } catch (err) {
set({ loginError: `Login failed: ${err}` }); set({ loginError: `Login failed: ${err}` });
} }
@@ -84,6 +88,7 @@ export const useUserStore = create<UserState>((set, get) => ({
localStorage.setItem("wrystr_login_type", "pubkey"); localStorage.setItem("wrystr_login_type", "pubkey");
get().fetchOwnProfile(); get().fetchOwnProfile();
get().fetchFollows();
} catch (err) { } catch (err) {
set({ loginError: `Login failed: ${err}` }); set({ loginError: `Login failed: ${err}` });
} }
@@ -94,7 +99,7 @@ export const useUserStore = create<UserState>((set, get) => ({
ndk.signer = undefined; ndk.signer = undefined;
localStorage.removeItem("wrystr_pubkey"); localStorage.removeItem("wrystr_pubkey");
localStorage.removeItem("wrystr_login_type"); localStorage.removeItem("wrystr_login_type");
set({ pubkey: null, npub: null, profile: null, loggedIn: false, loginError: null }); set({ pubkey: null, npub: null, profile: null, follows: [], loggedIn: false, loginError: null });
}, },
fetchOwnProfile: async () => { fetchOwnProfile: async () => {
@@ -110,4 +115,19 @@ export const useUserStore = create<UserState>((set, get) => ({
// Profile fetch is non-critical // Profile fetch is non-critical
} }
}, },
fetchFollows: async () => {
const { pubkey } = get();
if (!pubkey) return;
try {
const ndk = getNDK();
const user = ndk.getUser({ pubkey });
const followSet = await user.follows();
const follows = Array.from(followSet).map((u) => u.pubkey);
set({ follows });
} catch {
// Non-critical
}
},
})); }));