diff --git a/app/(home-pages)/discover/SortedPublicationList.tsx b/app/(home-pages)/discover/SortedPublicationList.tsx index c980939f..d72a3e14 100644 --- a/app/(home-pages)/discover/SortedPublicationList.tsx +++ b/app/(home-pages)/discover/SortedPublicationList.tsx @@ -1,15 +1,71 @@ "use client"; import Link from "next/link"; -import { useState } from "react"; +import { useState, useEffect, useRef } from "react"; import { theme } from "tailwind.config"; -import { PublicationsList } from "./page"; import { PubListing } from "./PubListing"; +import useSWRInfinite from "swr/infinite"; +import { getPublications, type Cursor, type Publication } from "./getPublications"; export function SortedPublicationList(props: { - publications: PublicationsList; + publications: Publication[]; order: string; + nextCursor: Cursor | null; }) { let [order, setOrder] = useState(props.order); + + const getKey = ( + pageIndex: number, + previousPageData: { publications: Publication[]; nextCursor: Cursor | null } | null, + ) => { + // Reached the end + if (previousPageData && !previousPageData.nextCursor) return null; + + // First page, we don't have previousPageData + if (pageIndex === 0) return ["discover-publications", order, null] as const; + + // Add the cursor to the key + return ["discover-publications", order, previousPageData?.nextCursor] as const; + }; + + const { data, error, size, setSize, isValidating } = useSWRInfinite( + getKey, + ([_, orderValue, cursor]) => { + const orderParam = orderValue === "popular" ? "popular" : "recentlyUpdated"; + return getPublications(orderParam, cursor); + }, + { + fallbackData: order === props.order + ? [{ publications: props.publications, nextCursor: props.nextCursor }] + : undefined, + revalidateFirstPage: false, + }, + ); + + const loadMoreRef = useRef(null); + + // Set up intersection observer to load more when trigger element is visible + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && !isValidating) { + const hasMore = data && data[data.length - 1]?.nextCursor; + if (hasMore) { + setSize(size + 1); + } + } + }, + { threshold: 0.1 }, + ); + + if (loadMoreRef.current) { + observer.observe(loadMoreRef.current); + } + + return () => observer.disconnect(); + }, [data, size, setSize, isValidating]); + + const allPublications = data ? data.flatMap((page) => page.publications) : []; + return (
-
- {props.publications - ?.filter((pub) => pub.documents_in_publications.length > 0) - ?.sort((a, b) => { - if (order === "popular") { - return ( - b.publication_subscriptions[0].count - - a.publication_subscriptions[0].count - ); - } - const aDate = new Date( - a.documents_in_publications[0]?.indexed_at || 0, - ); - const bDate = new Date( - b.documents_in_publications[0]?.indexed_at || 0, - ); - return bDate.getTime() - aDate.getTime(); - }) - .map((pub) => )} +
+ {allPublications.map((pub) => ( + + ))} + {/* Trigger element for loading more publications */} +
); diff --git a/app/(home-pages)/discover/getPublications.ts b/app/(home-pages)/discover/getPublications.ts new file mode 100644 index 00000000..46843116 --- /dev/null +++ b/app/(home-pages)/discover/getPublications.ts @@ -0,0 +1,119 @@ +"use server"; + +import { supabaseServerClient } from "supabase/serverClient"; + +export type Cursor = { + indexed_at?: string; + count?: number; + uri: string; +}; + +export type Publication = Awaited< + ReturnType +>["publications"][number]; + +export async function getPublications( + order: "recentlyUpdated" | "popular" = "recentlyUpdated", + cursor?: Cursor | null, +): Promise<{ publications: any[]; nextCursor: Cursor | null }> { + const limit = 25; + + // Fetch all publications with their most recent document + let { data: publications, error } = await supabaseServerClient + .from("publications") + .select( + "*, documents_in_publications(*, documents(*)), publication_subscriptions(count)", + ) + .or( + "record->preferences->showInDiscover.is.null,record->preferences->>showInDiscover.eq.true", + ) + .order("indexed_at", { + referencedTable: "documents_in_publications", + ascending: false, + }) + .limit(1, { referencedTable: "documents_in_publications" }); + + if (error) { + console.error("Error fetching publications:", error); + return { publications: [], nextCursor: null }; + } + + // Filter out publications without documents + const allPubs = (publications || []).filter( + (pub) => pub.documents_in_publications.length > 0, + ); + + // Sort on the server + allPubs.sort((a, b) => { + if (order === "popular") { + const aCount = a.publication_subscriptions[0]?.count || 0; + const bCount = b.publication_subscriptions[0]?.count || 0; + if (bCount !== aCount) { + return bCount - aCount; + } + // Secondary sort by uri for stability + return b.uri.localeCompare(a.uri); + } else { + // recentlyUpdated + const aDate = new Date( + a.documents_in_publications[0]?.indexed_at || 0, + ).getTime(); + const bDate = new Date( + b.documents_in_publications[0]?.indexed_at || 0, + ).getTime(); + if (bDate !== aDate) { + return bDate - aDate; + } + // Secondary sort by uri for stability + return b.uri.localeCompare(a.uri); + } + }); + + // Find cursor position and slice + let startIndex = 0; + if (cursor) { + startIndex = allPubs.findIndex((pub) => { + if (order === "popular") { + const pubCount = pub.publication_subscriptions[0]?.count || 0; + // Find first pub after cursor + return ( + pubCount < (cursor.count || 0) || + (pubCount === cursor.count && pub.uri < cursor.uri) + ); + } else { + const pubDate = pub.documents_in_publications[0]?.indexed_at || ""; + // Find first pub after cursor + return ( + pubDate < (cursor.indexed_at || "") || + (pubDate === cursor.indexed_at && pub.uri < cursor.uri) + ); + } + }); + // If not found, we're at the end + if (startIndex === -1) { + return { publications: [], nextCursor: null }; + } + } + + // Get the page + const page = allPubs.slice(startIndex, startIndex + limit); + + // Create next cursor + const nextCursor = + page.length === limit && startIndex + limit < allPubs.length + ? order === "recentlyUpdated" + ? { + indexed_at: page[page.length - 1].documents_in_publications[0]?.indexed_at, + uri: page[page.length - 1].uri, + } + : { + count: page[page.length - 1].publication_subscriptions[0]?.count || 0, + uri: page[page.length - 1].uri, + } + : null; + + return { + publications: page, + nextCursor, + }; +} diff --git a/app/(home-pages)/discover/page.tsx b/app/(home-pages)/discover/page.tsx index 7e14bf2a..4e01207c 100644 --- a/app/(home-pages)/discover/page.tsx +++ b/app/(home-pages)/discover/page.tsx @@ -1,26 +1,8 @@ -import { supabaseServerClient } from "supabase/serverClient"; import Link from "next/link"; import { SortedPublicationList } from "./SortedPublicationList"; import { Metadata } from "next"; import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; - -export type PublicationsList = Awaited>; -async function getPublications() { - let { data: publications, error } = await supabaseServerClient - .from("publications") - .select( - "*, documents_in_publications(*, documents(*)), publication_subscriptions(count)", - ) - .or( - "record->preferences->showInDiscover.is.null,record->preferences->>showInDiscover.eq.true", - ) - .order("indexed_at", { - referencedTable: "documents_in_publications", - ascending: false, - }) - .limit(1, { referencedTable: "documents_in_publications" }); - return publications; -} +import { getPublications } from "./getPublications"; export const metadata: Metadata = { title: "Leaflet Discover", @@ -50,7 +32,9 @@ export default async function Discover(props: { } const DiscoverContent = async (props: { order: string }) => { - let publications = await getPublications(); + const orderValue = + props.order === "popular" ? "popular" : "recentlyUpdated"; + let { publications, nextCursor } = await getPublications(orderValue); return (
@@ -61,7 +45,11 @@ const DiscoverContent = async (props: { order: string }) => { make your own!

- +
); }; diff --git a/app/(home-pages)/notifications/NotificationList.tsx b/app/(home-pages)/notifications/NotificationList.tsx index da3fd115..a527ef29 100644 --- a/app/(home-pages)/notifications/NotificationList.tsx +++ b/app/(home-pages)/notifications/NotificationList.tsx @@ -5,6 +5,7 @@ import { CommentNotification } from "./CommentNotication"; import { useEntity, useReplicache } from "src/replicache"; import { useEffect } from "react"; import { markAsRead } from "./getNotifications"; +import { ReplyNotification } from "./ReplyNotification"; export function NotificationList({ notifications, @@ -31,7 +32,14 @@ export function NotificationList({
{notifications.map((n) => { if (n.type === "comment") { - n; + if (n.parentData) + return ( + + ); return ( { + let docRecord = props.commentData.documents + ?.data as PubLeafletDocument.Record; + let commentRecord = props.commentData.record as PubLeafletComment.Record; + let profileRecord = props.commentData.bsky_profiles + ?.record as AppBskyActorProfile.Record; + const displayName = + profileRecord.displayName || + props.commentData.bsky_profiles?.handle || + "Someone"; + + let parentRecord = props.parentData?.record as PubLeafletComment.Record; + let parentProfile = props.parentData?.bsky_profiles + ?.record as AppBskyActorProfile.Record; + const parentDisplayName = + parentProfile.displayName || + props.parentData?.bsky_profiles?.handle || + "Someone"; + + let rkey = new AtUri(props.commentData.documents?.uri!).rkey; + const pubRecord = props.commentData.documents?.documents_in_publications[0] + ?.publications?.record as PubLeafletPublication.Record; -export const DummyReplyNotification = (props: { - cardBorderHidden: boolean; -}) => { return ( } - actionText={<>jared replied to your comment} + actionText={`${displayName} replied to your comment`} cardBorderHidden={props.cardBorderHidden} content={
} diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts b/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts index fbc85963..44f64af0 100644 --- a/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts @@ -67,18 +67,28 @@ export async function publishComment(args: { } as unknown as Json, }) .select(); - let notifications: Notification[] = [ - { + let notifications: Notification[] = []; + if ( + !args.comment.replyTo && + new AtUri(args.document).host !== credentialSession.did + ) + notifications.push({ id: v7(), recipient: new AtUri(args.document).host, data: { type: "comment", comment_uri: uri.toString() }, - }, - ]; - if (args.comment.replyTo) + }); + if ( + args.comment.replyTo && + new AtUri(args.comment.replyTo).host !== credentialSession.did + ) notifications.push({ id: v7(), recipient: new AtUri(args.comment.replyTo).host, - data: { type: "comment", comment_uri: uri.toString() }, + data: { + type: "comment", + comment_uri: uri.toString(), + parent_uri: args.comment.replyTo, + }, }); // SOMEDAY: move this out the action with inngest or workflows await supabaseServerClient.from("notifications").insert(notifications); diff --git a/src/notifications.ts b/src/notifications.ts index 43924397..7748a4f9 100644 --- a/src/notifications.ts +++ b/src/notifications.ts @@ -10,7 +10,7 @@ export type Notification = Omit, "data"> & { }; export type NotificationData = - | { type: "comment"; comment_uri: string } + | { type: "comment"; comment_uri: string; parent_uri?: string } | { type: "subscribe"; subscription_uri: string }; export type HydratedNotification = @@ -58,7 +58,11 @@ async function hydrateCommentNotifications(notifications: NotificationRow[]) { } // Fetch comment data from the database - const commentUris = commentNotifications.map((n) => n.data.comment_uri); + const commentUris = commentNotifications.flatMap((n) => + n.data.parent_uri + ? [n.data.comment_uri, n.data.parent_uri] + : [n.data.comment_uri], + ); const { data: comments } = await supabaseServerClient .from("comments_on_documents") .select( @@ -72,6 +76,9 @@ async function hydrateCommentNotifications(notifications: NotificationRow[]) { created_at: notification.created_at, type: "comment" as const, comment_uri: notification.data.comment_uri, + parentData: notification.data.parent_uri + ? comments?.find((c) => c.uri === notification.data.parent_uri)! + : undefined, commentData: comments?.find( (c) => c.uri === notification.data.comment_uri, )!,