mirror of
https://github.com/hoornet/vega.git
synced 2026-05-13 04:08:36 -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 { 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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;
|
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
|
||||||
|
}
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user