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 (
+
+
- {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 (
+
+ );
+}
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. */