mirror of
https://github.com/hoornet/vega.git
synced 2026-05-16 12:54:51 -07:00
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:
@@ -1,22 +1,78 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useFeedStore } from "../../stores/feed";
|
||||
import { useUserStore } from "../../stores/user";
|
||||
import { fetchFollowFeed } from "../../lib/nostr";
|
||||
import { NoteCard } from "./NoteCard";
|
||||
import { ComposeBox } from "./ComposeBox";
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
|
||||
type FeedTab = "global" | "following";
|
||||
|
||||
export function Feed() {
|
||||
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(() => {
|
||||
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 (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<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">
|
||||
{connected && (
|
||||
<span className="text-success text-[11px] flex items-center gap-1">
|
||||
@@ -25,11 +81,11 @@ export function Feed() {
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={loadFeed}
|
||||
disabled={loading}
|
||||
onClick={isFollowing ? loadFollowFeed : loadFeed}
|
||||
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"
|
||||
>
|
||||
{loading ? "loading…" : "refresh"}
|
||||
{isLoading ? "loading…" : "refresh"}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -39,32 +95,29 @@ export function Feed() {
|
||||
|
||||
{/* Feed */}
|
||||
<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">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && notes.length === 0 && (
|
||||
{isLoading && filteredNotes.length === 0 && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{!loading && notes.length === 0 && !error && (
|
||||
{!isLoading && filteredNotes.length === 0 && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{notes
|
||||
.filter((event) => {
|
||||
const c = event.content.trim();
|
||||
return c.length > 0 && !c.startsWith("{") && !c.startsWith("[");
|
||||
})
|
||||
.map((event) => (
|
||||
<NoteCard key={event.id} event={event} />
|
||||
))}
|
||||
{filteredNotes.map((event) => (
|
||||
<NoteCard key={event.id} event={event} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -20,7 +20,12 @@ export function NoteCard({ event }: NoteCardProps) {
|
||||
|
||||
const { loggedIn } = useUserStore();
|
||||
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 [showReply, setShowReply] = useState(false);
|
||||
const [replyText, setReplyText] = useState("");
|
||||
@@ -34,6 +39,9 @@ export function NoteCard({ event }: NoteCardProps) {
|
||||
setLiking(true);
|
||||
try {
|
||||
await publishReaction(event.id, event.pubkey);
|
||||
const liked = getLiked();
|
||||
liked.add(event.id);
|
||||
localStorage.setItem(likedKey, JSON.stringify(Array.from(liked)));
|
||||
setLiked(true);
|
||||
} finally {
|
||||
setLiking(false);
|
||||
|
||||
@@ -99,6 +99,23 @@ export async function publishNote(content: string): Promise<void> {
|
||||
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[]> {
|
||||
const instance = getNDK();
|
||||
const filter: NDKFilter = {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -7,6 +7,7 @@ interface UserState {
|
||||
pubkey: string | null;
|
||||
npub: string | null;
|
||||
profile: any | null;
|
||||
follows: string[];
|
||||
loggedIn: boolean;
|
||||
loginError: string | null;
|
||||
|
||||
@@ -14,12 +15,14 @@ interface UserState {
|
||||
loginWithPubkey: (pubkey: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
fetchOwnProfile: () => Promise<void>;
|
||||
fetchFollows: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useUserStore = create<UserState>((set, get) => ({
|
||||
pubkey: null,
|
||||
npub: null,
|
||||
profile: null,
|
||||
follows: [],
|
||||
loggedIn: false,
|
||||
loginError: null,
|
||||
|
||||
@@ -54,8 +57,9 @@ export const useUserStore = create<UserState>((set, get) => ({
|
||||
localStorage.setItem("wrystr_pubkey", pubkey);
|
||||
localStorage.setItem("wrystr_login_type", "nsec");
|
||||
|
||||
// Fetch profile
|
||||
// Fetch profile and follows
|
||||
get().fetchOwnProfile();
|
||||
get().fetchFollows();
|
||||
} catch (err) {
|
||||
set({ loginError: `Login failed: ${err}` });
|
||||
}
|
||||
@@ -84,6 +88,7 @@ export const useUserStore = create<UserState>((set, get) => ({
|
||||
localStorage.setItem("wrystr_login_type", "pubkey");
|
||||
|
||||
get().fetchOwnProfile();
|
||||
get().fetchFollows();
|
||||
} catch (err) {
|
||||
set({ loginError: `Login failed: ${err}` });
|
||||
}
|
||||
@@ -94,7 +99,7 @@ export const useUserStore = create<UserState>((set, get) => ({
|
||||
ndk.signer = undefined;
|
||||
localStorage.removeItem("wrystr_pubkey");
|
||||
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 () => {
|
||||
@@ -110,4 +115,19 @@ export const useUserStore = create<UserState>((set, get) => ({
|
||||
// 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
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user