diff --git a/app/(app)/page.tsx b/app/(app)/page.tsx index 24c0c6f2..b52d63e8 100644 --- a/app/(app)/page.tsx +++ b/app/(app)/page.tsx @@ -1,29 +1,40 @@ "use client"; import Link from "next/link"; -import { Compass, LogIn, Sparkles } from "lucide-react"; +import { Compass, Loader2, LogIn, Sparkles } from "lucide-react"; import { useAuth } from "@/components/auth/auth-provider"; import { SadhnaStreakCTA } from "@/components/sadhna/streak-cta"; +import { HomeFeed } from "@/components/feed/home-feed"; /** * / — the Home (Feed) gate. * - * The Instagram-inspired infinite-scroll feed of posts from people you - * follow will live here. Until that ships we show two paths: - * - * • Sign in — for visitors who don't have an account yet; the eventual - * feed is personalised, so an account is the prerequisite. - * • Explore the Knowledgebase — Śrīla Prabhupāda's full library is - * ready today, regardless of feed status. - * - * The "Sign in" CTA hides once the user is authenticated — at that point - * there's nothing to gate, just a "feed coming soon" note above the - * always-on Knowledgebase entry. + * • Signed in → the daily sādhana streak on top, then the global, + * personalised infinite-scroll feed of community posts. + * • Anonymous → a welcome screen: sign in (the feed is personalised, so an + * account is the prerequisite) or jump into the Knowledgebase. */ - export default function HomePage() { const { user, loading } = useAuth(); - const authed = !loading && !!user; + + if (loading) { + return ( +
+ +
+ ); + } + + if (user) { + return ( +
+
+ +
+ +
+ ); + } return (
@@ -36,51 +47,41 @@ export default function HomePage() {

- {authed - ? "Your feed is coming soon" - : "Welcome to Prabhupāda Says"} + Welcome to Prabhupāda Says

- {authed - ? "We're building a feed of quotes shared by the community. Until it lands, dive into the Knowledgebase — Śrīla Prabhupāda's books, letters, lectures, and teachings." - : "Sign in to follow seekers and see their quotes here once the feed launches, or jump straight into the Knowledgebase — the original, unedited writings of His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda."} + Sign in to follow seekers and see their quotes in your feed, or jump + straight into the Knowledgebase — the original, unedited writings of + His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda.

- {!authed ? ( - - - Sign in to view feed - - ) : null} + + + Sign in to view feed + Explore Knowledgebase
- {!authed ? ( -

- No account?{" "} - - Create one - {" "} - — it's free. -

- ) : null} +

+ No account?{" "} + + Create one + {" "} + — it's free. +

- {/* Daily sādhana streak — a simple entry point until the feed lands. */} + {/* Daily sādhana streak — a simple entry point for visitors. */}
diff --git a/components/auth/auth-provider.tsx b/components/auth/auth-provider.tsx index 44e0baed..28d3d4b4 100644 --- a/components/auth/auth-provider.tsx +++ b/components/auth/auth-provider.tsx @@ -9,6 +9,7 @@ import { useState, } from "react"; import { auth, type User } from "@/lib/auth"; +import { feed } from "@/lib/social"; /** * AuthProvider holds the current-user state and exposes the auth actions @@ -89,6 +90,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { ); const logOut = useCallback(async () => { + // Drop the home-feed seen-set first, while the auth cookie is still set + // (the reset endpoint is auth-gated). Best-effort — a stale set would + // otherwise expire on its own TTL. + await feed.reset().catch(() => {}); await auth.logOut(); setUser(null); }, []); diff --git a/components/feed/home-feed.tsx b/components/feed/home-feed.tsx new file mode 100644 index 00000000..7a04cb22 --- /dev/null +++ b/components/feed/home-feed.tsx @@ -0,0 +1,154 @@ +"use client"; + +/** + * HomeFeed — the global, personalised feed of community posts. + * + * Architecture: + * • Window-virtualised list (dynamic row heights via measureElement) so a + * long feed stays cheap to render. + * • Infinite scroll: 10 posts per page, fetched as a bottom sentinel nears + * the viewport. The server orders not-yet-liked posts from people you + * follow first, then everyone else — seamless, no visible boundary. + * • When the feed is exhausted, a "new on the platform" strip (≤5 accounts + * that joined in the last 24h) caps it off — hidden if there are none. + * + * The card's Follow / Follow back affordance is enabled here (enableFollow); + * the server hydrates each post's viewer↔author relationship. + */ + +import { useCallback, useEffect, useRef, useState } from "react"; +import { useWindowVirtualizer } from "@tanstack/react-virtual"; +import { Loader2 } from "lucide-react"; +import { feed, type Post, type UserSummary } from "@/lib/social"; +import { PostCard } from "@/components/social/post/post-card"; +import { NewUsersStrip } from "./new-users-strip"; + +const PAGE = 10; + +export function HomeFeed() { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [hasMore, setHasMore] = useState(true); + const [newUsers, setNewUsers] = useState([]); + + const fetchingRef = useRef(false); + const offsetRef = useRef(0); + const parentRef = useRef(null); + const sentinelRef = useRef(null); + const [offsetTop, setOffsetTop] = useState(0); + + const loadPage = useCallback(async () => { + if (fetchingRef.current) return; + fetchingRef.current = true; + try { + const rows = (await feed.home(PAGE, offsetRef.current)) ?? []; + offsetRef.current += rows.length; + setItems((prev) => { + const seen = new Set(prev.map((p) => p.id)); + return [...prev, ...rows.filter((p) => !seen.has(p.id))]; + }); + if (rows.length < PAGE) { + setHasMore(false); + // Feed exhausted → load the discovery strip once. + feed + .newUsers() + .then((u) => setNewUsers(u ?? [])) + .catch(() => setNewUsers([])); + } + } catch { + setHasMore(false); + } finally { + fetchingRef.current = false; + setLoading(false); + } + }, []); + + // Initial page + measure where the list sits in the document (so the window + // virtualiser offsets correctly below the streak component). + useEffect(() => { + void loadPage(); + }, [loadPage]); + useEffect(() => { + if (parentRef.current) setOffsetTop(parentRef.current.offsetTop); + }, [loading]); + + const virtualizer = useWindowVirtualizer({ + count: items.length, + estimateSize: () => 460, + overscan: 4, + scrollMargin: offsetTop, + }); + + // Infinite scroll — fetch the next page as the sentinel approaches. + useEffect(() => { + const node = sentinelRef.current; + if (!node || !hasMore) return; + const io = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && hasMore && !fetchingRef.current) { + void loadPage(); + } + }, + { rootMargin: "600px 0px" }, + ); + io.observe(node); + return () => io.disconnect(); + }, [hasMore, loadPage, items.length]); + + if (loading && items.length === 0) { + return ( +
+ +
+ ); + } + + if (!loading && items.length === 0) { + return ( +
+

+ Your feed is quiet for now. Follow a few devotees, or share a verse to + get things started. +

+ +
+ ); + } + + const virtualItems = virtualizer.getVirtualItems(); + + return ( +
+
+ {virtualItems.map((vi) => { + const post = items[vi.index]; + return ( +
+
+ +
+
+ ); + })} +
+ + {hasMore ? ( +
+ +
+ ) : ( + + )} +
+ ); +} diff --git a/components/feed/new-users-strip.tsx b/components/feed/new-users-strip.tsx new file mode 100644 index 00000000..217c42e7 --- /dev/null +++ b/components/feed/new-users-strip.tsx @@ -0,0 +1,59 @@ +"use client"; + +/** + * NewUsersStrip — the "new on the platform" discovery row shown at the very + * end of the home feed. Up to 5 accounts that joined in the last 24h; each + * avatar links to the user's profile. Renders nothing when the list is empty + * (the feed simply ends). + */ + +import Link from "next/link"; +import { Sparkles } from "lucide-react"; +import type { UserSummary } from "@/lib/social"; +import { UserAvatar } from "@/components/ui/user-avatar"; + +export function NewUsersStrip({ users }: { users: UserSummary[] }) { + if (users.length === 0) return null; + return ( +
+
+ + + +
+

+ New to Prabhupāda Says +

+

+ Welcome them — follow the devotees who just joined. +

+
+
+
    + {users.map((u) => ( +
  • + + + + {u.name} + + + @{u.handle} + + +
  • + ))} +
+
+ ); +} diff --git a/components/notifications/notifications-provider.tsx b/components/notifications/notifications-provider.tsx index d80ca185..7426b9df 100644 --- a/components/notifications/notifications-provider.tsx +++ b/components/notifications/notifications-provider.tsx @@ -26,7 +26,7 @@ import { } from "react"; import { toast } from "sonner"; import { useAuth } from "@/components/auth/auth-provider"; -import { notifications } from "@/lib/social"; +import { feed, notifications } from "@/lib/social"; import { sadhna } from "@/lib/sadhna"; type NotificationsContextValue = { @@ -113,8 +113,24 @@ export function NotificationsProvider({ } }, [user?.id]); - // Open the realtime connection while signed in. const userId = user?.id; + + // When the session ends — the page unloads / the realtime socket drops as + // the user navigates away or closes the tab — clear the home-feed seen-set + // so it doesn't linger in Redis. `keepalive` lets the request outlive the + // page. (Genuine network drops with no unload are caught by the set's TTL; + // we deliberately don't clear on transient socket blips, which would re-show + // posts mid-scroll.) + useEffect(() => { + if (!userId) return; + const onLeave = () => { + void feed.reset().catch(() => {}); + }; + window.addEventListener("pagehide", onLeave); + return () => window.removeEventListener("pagehide", onLeave); + }, [userId]); + + // Open the realtime connection while signed in. useEffect(() => { if (!userId) return; const key = process.env.NEXT_PUBLIC_SOKETI_KEY; diff --git a/components/selection-toolbar/collections-drawer.tsx b/components/selection-toolbar/collections-drawer.tsx index 7de81f9f..847433db 100644 --- a/components/selection-toolbar/collections-drawer.tsx +++ b/components/selection-toolbar/collections-drawer.tsx @@ -206,6 +206,10 @@ export function CollectionsDrawer({ open={open} onOpenChange={(o) => !o && onClose()} direction={direction} + // Let the browser handle the on-screen keyboard. vaul's default + // input-repositioning resizes/translates the sheet and gets stuck on + // iOS after the keyboard hides (matches the other input-bearing drawers). + repositionInputs={false} > setNewTitle(e.target.value)} placeholder="Collection title" maxLength={120} - className="w-full bg-white/5 text-white text-sm rounded-md px-3 py-2 placeholder:text-white/30 outline-none focus:bg-white/10" + // 16px on mobile (text-base) so iOS Safari doesn't auto-zoom + // on focus; back to 14px from sm: up. + className="w-full bg-white/5 text-white text-base sm:text-sm rounded-md px-3 py-2 placeholder:text-white/30 outline-none focus:bg-white/10" />
+ {enableFollow && authorHandle && !post.viewer_follows_author ? ( + + ) : null} diff --git a/lib/social.ts b/lib/social.ts index 3be3eac4..e8b63696 100644 --- a/lib/social.ts +++ b/lib/social.ts @@ -90,6 +90,10 @@ export type Post = { pinned_comment?: Comment | null; /** Total comments + replies on the post (drives the footer comment count). */ comments_count?: number; + /** Home-feed only: does the viewer follow this post's author (accepted)? */ + viewer_follows_author?: boolean; + /** Home-feed only: does the author follow the viewer (drives "Follow back")? */ + author_follows_viewer?: boolean; }; /** A comment or reply on a post. Threading is one level deep: `parent_id` is @@ -541,6 +545,31 @@ export const profile = { }, }; +// ─── Home feed (global, personalised) ────────────────────────────────────── + +export const feed = { + /** One page of the global home feed (auth required). Returns at most + * `limit` posts; callers infer "more" from a full page. */ + home(limit = 10, offset = 0) { + const qs = new URLSearchParams({ + limit: String(limit), + offset: String(offset), + }); + return call(`/feed?${qs}`); + }, + /** Up to 5 accounts that joined in the last 24h — the feed's discovery + * strip. Empty array when there are none. */ + newUsers() { + return call(`/feed/new-users`); + }, + /** Clear the server-side "seen" set so the feed starts fresh. Called on + * logout and on page unload; `keepalive` lets the request outlive the + * unloading page (like a beacon). */ + reset() { + return call(`/feed/reset`, { method: "POST", keepalive: true }); + }, +}; + // ─── Relationships (follow / block / report) ─────────────────────────────── /** Allowed user-report categories — mirrors the BFF allow-list. */