a tool for shared writing and social publishing

add reply notifications

+294 -72
+74 -22
app/(home-pages)/discover/SortedPublicationList.tsx
··· 1 1 "use client"; 2 2 import Link from "next/link"; 3 - import { useState } from "react"; 3 + import { useState, useEffect, useRef } from "react"; 4 4 import { theme } from "tailwind.config"; 5 - import { PublicationsList } from "./page"; 6 5 import { PubListing } from "./PubListing"; 6 + import useSWRInfinite from "swr/infinite"; 7 + import { getPublications, type Cursor, type Publication } from "./getPublications"; 7 8 8 9 export function SortedPublicationList(props: { 9 - publications: PublicationsList; 10 + publications: Publication[]; 10 11 order: string; 12 + nextCursor: Cursor | null; 11 13 }) { 12 14 let [order, setOrder] = useState(props.order); 15 + 16 + const getKey = ( 17 + pageIndex: number, 18 + previousPageData: { publications: Publication[]; nextCursor: Cursor | null } | null, 19 + ) => { 20 + // Reached the end 21 + if (previousPageData && !previousPageData.nextCursor) return null; 22 + 23 + // First page, we don't have previousPageData 24 + if (pageIndex === 0) return ["discover-publications", order, null] as const; 25 + 26 + // Add the cursor to the key 27 + return ["discover-publications", order, previousPageData?.nextCursor] as const; 28 + }; 29 + 30 + const { data, error, size, setSize, isValidating } = useSWRInfinite( 31 + getKey, 32 + ([_, orderValue, cursor]) => { 33 + const orderParam = orderValue === "popular" ? "popular" : "recentlyUpdated"; 34 + return getPublications(orderParam, cursor); 35 + }, 36 + { 37 + fallbackData: order === props.order 38 + ? [{ publications: props.publications, nextCursor: props.nextCursor }] 39 + : undefined, 40 + revalidateFirstPage: false, 41 + }, 42 + ); 43 + 44 + const loadMoreRef = useRef<HTMLDivElement>(null); 45 + 46 + // Set up intersection observer to load more when trigger element is visible 47 + useEffect(() => { 48 + const observer = new IntersectionObserver( 49 + (entries) => { 50 + if (entries[0].isIntersecting && !isValidating) { 51 + const hasMore = data && data[data.length - 1]?.nextCursor; 52 + if (hasMore) { 53 + setSize(size + 1); 54 + } 55 + } 56 + }, 57 + { threshold: 0.1 }, 58 + ); 59 + 60 + if (loadMoreRef.current) { 61 + observer.observe(loadMoreRef.current); 62 + } 63 + 64 + return () => observer.disconnect(); 65 + }, [data, size, setSize, isValidating]); 66 + 67 + const allPublications = data ? data.flatMap((page) => page.publications) : []; 68 + 13 69 return ( 14 70 <div className="discoverHeader flex flex-col items-center "> 15 71 <SortButtons ··· 21 77 setOrder(o); 22 78 }} 23 79 /> 24 - <div className="discoverPubList flex flex-col gap-3 pt-6 w-full"> 25 - {props.publications 26 - ?.filter((pub) => pub.documents_in_publications.length > 0) 27 - ?.sort((a, b) => { 28 - if (order === "popular") { 29 - return ( 30 - b.publication_subscriptions[0].count - 31 - a.publication_subscriptions[0].count 32 - ); 33 - } 34 - const aDate = new Date( 35 - a.documents_in_publications[0]?.indexed_at || 0, 36 - ); 37 - const bDate = new Date( 38 - b.documents_in_publications[0]?.indexed_at || 0, 39 - ); 40 - return bDate.getTime() - aDate.getTime(); 41 - }) 42 - .map((pub) => <PubListing resizeHeight key={pub.uri} {...pub} />)} 80 + <div className="discoverPubList flex flex-col gap-3 pt-6 w-full relative"> 81 + {allPublications.map((pub) => ( 82 + <PubListing resizeHeight key={pub.uri} {...pub} /> 83 + ))} 84 + {/* Trigger element for loading more publications */} 85 + <div 86 + ref={loadMoreRef} 87 + className="absolute bottom-96 left-0 w-full h-px pointer-events-none" 88 + aria-hidden="true" 89 + /> 90 + {isValidating && ( 91 + <div className="text-center text-tertiary py-4"> 92 + Loading more publications... 93 + </div> 94 + )} 43 95 </div> 44 96 </div> 45 97 );
+119
app/(home-pages)/discover/getPublications.ts
··· 1 + "use server"; 2 + 3 + import { supabaseServerClient } from "supabase/serverClient"; 4 + 5 + export type Cursor = { 6 + indexed_at?: string; 7 + count?: number; 8 + uri: string; 9 + }; 10 + 11 + export type Publication = Awaited< 12 + ReturnType<typeof getPublications> 13 + >["publications"][number]; 14 + 15 + export async function getPublications( 16 + order: "recentlyUpdated" | "popular" = "recentlyUpdated", 17 + cursor?: Cursor | null, 18 + ): Promise<{ publications: any[]; nextCursor: Cursor | null }> { 19 + const limit = 25; 20 + 21 + // Fetch all publications with their most recent document 22 + let { data: publications, error } = await supabaseServerClient 23 + .from("publications") 24 + .select( 25 + "*, documents_in_publications(*, documents(*)), publication_subscriptions(count)", 26 + ) 27 + .or( 28 + "record->preferences->showInDiscover.is.null,record->preferences->>showInDiscover.eq.true", 29 + ) 30 + .order("indexed_at", { 31 + referencedTable: "documents_in_publications", 32 + ascending: false, 33 + }) 34 + .limit(1, { referencedTable: "documents_in_publications" }); 35 + 36 + if (error) { 37 + console.error("Error fetching publications:", error); 38 + return { publications: [], nextCursor: null }; 39 + } 40 + 41 + // Filter out publications without documents 42 + const allPubs = (publications || []).filter( 43 + (pub) => pub.documents_in_publications.length > 0, 44 + ); 45 + 46 + // Sort on the server 47 + allPubs.sort((a, b) => { 48 + if (order === "popular") { 49 + const aCount = a.publication_subscriptions[0]?.count || 0; 50 + const bCount = b.publication_subscriptions[0]?.count || 0; 51 + if (bCount !== aCount) { 52 + return bCount - aCount; 53 + } 54 + // Secondary sort by uri for stability 55 + return b.uri.localeCompare(a.uri); 56 + } else { 57 + // recentlyUpdated 58 + const aDate = new Date( 59 + a.documents_in_publications[0]?.indexed_at || 0, 60 + ).getTime(); 61 + const bDate = new Date( 62 + b.documents_in_publications[0]?.indexed_at || 0, 63 + ).getTime(); 64 + if (bDate !== aDate) { 65 + return bDate - aDate; 66 + } 67 + // Secondary sort by uri for stability 68 + return b.uri.localeCompare(a.uri); 69 + } 70 + }); 71 + 72 + // Find cursor position and slice 73 + let startIndex = 0; 74 + if (cursor) { 75 + startIndex = allPubs.findIndex((pub) => { 76 + if (order === "popular") { 77 + const pubCount = pub.publication_subscriptions[0]?.count || 0; 78 + // Find first pub after cursor 79 + return ( 80 + pubCount < (cursor.count || 0) || 81 + (pubCount === cursor.count && pub.uri < cursor.uri) 82 + ); 83 + } else { 84 + const pubDate = pub.documents_in_publications[0]?.indexed_at || ""; 85 + // Find first pub after cursor 86 + return ( 87 + pubDate < (cursor.indexed_at || "") || 88 + (pubDate === cursor.indexed_at && pub.uri < cursor.uri) 89 + ); 90 + } 91 + }); 92 + // If not found, we're at the end 93 + if (startIndex === -1) { 94 + return { publications: [], nextCursor: null }; 95 + } 96 + } 97 + 98 + // Get the page 99 + const page = allPubs.slice(startIndex, startIndex + limit); 100 + 101 + // Create next cursor 102 + const nextCursor = 103 + page.length === limit && startIndex + limit < allPubs.length 104 + ? order === "recentlyUpdated" 105 + ? { 106 + indexed_at: page[page.length - 1].documents_in_publications[0]?.indexed_at, 107 + uri: page[page.length - 1].uri, 108 + } 109 + : { 110 + count: page[page.length - 1].publication_subscriptions[0]?.count || 0, 111 + uri: page[page.length - 1].uri, 112 + } 113 + : null; 114 + 115 + return { 116 + publications: page, 117 + nextCursor, 118 + }; 119 + }
+9 -21
app/(home-pages)/discover/page.tsx
··· 1 - import { supabaseServerClient } from "supabase/serverClient"; 2 1 import Link from "next/link"; 3 2 import { SortedPublicationList } from "./SortedPublicationList"; 4 3 import { Metadata } from "next"; 5 4 import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; 6 - 7 - export type PublicationsList = Awaited<ReturnType<typeof getPublications>>; 8 - async function getPublications() { 9 - let { data: publications, error } = await supabaseServerClient 10 - .from("publications") 11 - .select( 12 - "*, documents_in_publications(*, documents(*)), publication_subscriptions(count)", 13 - ) 14 - .or( 15 - "record->preferences->showInDiscover.is.null,record->preferences->>showInDiscover.eq.true", 16 - ) 17 - .order("indexed_at", { 18 - referencedTable: "documents_in_publications", 19 - ascending: false, 20 - }) 21 - .limit(1, { referencedTable: "documents_in_publications" }); 22 - return publications; 23 - } 5 + import { getPublications } from "./getPublications"; 24 6 25 7 export const metadata: Metadata = { 26 8 title: "Leaflet Discover", ··· 50 32 } 51 33 52 34 const DiscoverContent = async (props: { order: string }) => { 53 - let publications = await getPublications(); 35 + const orderValue = 36 + props.order === "popular" ? "popular" : "recentlyUpdated"; 37 + let { publications, nextCursor } = await getPublications(orderValue); 54 38 55 39 return ( 56 40 <div className="max-w-prose mx-auto w-full"> ··· 61 45 <Link href="/lish/createPub">make your own</Link>! 62 46 </p> 63 47 </div> 64 - <SortedPublicationList publications={publications} order={props.order} /> 48 + <SortedPublicationList 49 + publications={publications} 50 + order={props.order} 51 + nextCursor={nextCursor} 52 + /> 65 53 </div> 66 54 ); 67 55 };
+9 -1
app/(home-pages)/notifications/NotificationList.tsx
··· 5 5 import { useEntity, useReplicache } from "src/replicache"; 6 6 import { useEffect } from "react"; 7 7 import { markAsRead } from "./getNotifications"; 8 + import { ReplyNotification } from "./ReplyNotification"; 8 9 9 10 export function NotificationList({ 10 11 notifications, ··· 31 32 <div className={`flex flex-col ${cardBorderHidden ? "gap-6" : "gap-2"}`}> 32 33 {notifications.map((n) => { 33 34 if (n.type === "comment") { 34 - n; 35 + if (n.parentData) 36 + return ( 37 + <ReplyNotification 38 + cardBorderHidden={!!cardBorderHidden} 39 + key={n.id} 40 + {...n} 41 + /> 42 + ); 35 43 return ( 36 44 <CommentNotification 37 45 cardBorderHidden={!!cardBorderHidden}
+58 -20
app/(home-pages)/notifications/ReplyNotification.tsx
··· 6 6 ContentLayout, 7 7 Notification, 8 8 } from "./Notification"; 9 + import { HydratedCommentNotification } from "src/notifications"; 10 + import { 11 + PubLeafletComment, 12 + PubLeafletDocument, 13 + PubLeafletPublication, 14 + } from "lexicons/api"; 15 + import { AppBskyActorProfile, AtUri } from "@atproto/api"; 16 + import { blobRefToSrc } from "src/utils/blobRefToSrc"; 9 17 10 - export const DummyReplyNotification = (props: { 11 - cardBorderHidden: boolean; 12 - }) => { 18 + export const ReplyNotification = ( 19 + props: { cardBorderHidden: boolean } & HydratedCommentNotification, 20 + ) => { 21 + let docRecord = props.commentData.documents 22 + ?.data as PubLeafletDocument.Record; 23 + let commentRecord = props.commentData.record as PubLeafletComment.Record; 24 + let profileRecord = props.commentData.bsky_profiles 25 + ?.record as AppBskyActorProfile.Record; 26 + const displayName = 27 + profileRecord.displayName || 28 + props.commentData.bsky_profiles?.handle || 29 + "Someone"; 30 + 31 + let parentRecord = props.parentData?.record as PubLeafletComment.Record; 32 + let parentProfile = props.parentData?.bsky_profiles 33 + ?.record as AppBskyActorProfile.Record; 34 + const parentDisplayName = 35 + parentProfile.displayName || 36 + props.parentData?.bsky_profiles?.handle || 37 + "Someone"; 38 + 39 + let rkey = new AtUri(props.commentData.documents?.uri!).rkey; 40 + const pubRecord = props.commentData.documents?.documents_in_publications[0] 41 + ?.publications?.record as PubLeafletPublication.Record; 42 + 13 43 return ( 14 44 <Notification 15 - href="/" 45 + href={`https://${pubRecord.base_path}/${rkey}?interactionDrawer=comments`} 16 46 icon={<ReplyTiny />} 17 - actionText={<>jared replied to your comment</>} 47 + actionText={`${displayName} replied to your comment`} 18 48 cardBorderHidden={props.cardBorderHidden} 19 49 content={ 20 50 <ContentLayout 21 51 cardBorderHidden={props.cardBorderHidden} 22 - postTitle="This is the Post Title" 23 - pubRecord={{ name: "My Publication" } as any} 52 + postTitle={docRecord.title} 53 + pubRecord={pubRecord} 24 54 > 25 55 <CommentInNotification 26 - className="text-tertiary italic line-clamp-1!" 27 - avatar={undefined} 28 - displayName="celine" 56 + className="" 57 + avatar={ 58 + parentProfile?.avatar?.ref && 59 + blobRefToSrc( 60 + parentProfile?.avatar?.ref, 61 + props.parentData?.bsky_profiles?.did || "", 62 + ) 63 + } 64 + displayName={parentDisplayName} 29 65 index={[]} 30 - plaintext={ 31 - "This the original comment. To make a point I'm gonna make the comment really pretty long so you can see for youself how it truncates" 32 - } 33 - facets={[]} 66 + plaintext={parentRecord.plaintext} 67 + facets={parentRecord.facets} 34 68 /> 35 69 <div className="h-3 -mt-[1px] ml-[10px] border-l border-border" /> 36 70 <CommentInNotification 37 71 className="" 38 - avatar={undefined} 39 - displayName="celine" 72 + avatar={ 73 + profileRecord?.avatar?.ref && 74 + blobRefToSrc( 75 + profileRecord?.avatar?.ref, 76 + props.commentData.bsky_profiles?.did || "", 77 + ) 78 + } 79 + displayName={displayName} 40 80 index={[]} 41 - plaintext={ 42 - "This is a thoughful and very respectful reply. Violating the code of conduct for me is literally like water for the wicked witch of the west. EeEeEEeeK IT BURNSssSs!!!" 43 - } 44 - facets={[]} 81 + plaintext={commentRecord.plaintext} 82 + facets={commentRecord.facets} 45 83 /> 46 84 </ContentLayout> 47 85 }
+16 -6
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts
··· 67 67 } as unknown as Json, 68 68 }) 69 69 .select(); 70 - let notifications: Notification[] = [ 71 - { 70 + let notifications: Notification[] = []; 71 + if ( 72 + !args.comment.replyTo && 73 + new AtUri(args.document).host !== credentialSession.did 74 + ) 75 + notifications.push({ 72 76 id: v7(), 73 77 recipient: new AtUri(args.document).host, 74 78 data: { type: "comment", comment_uri: uri.toString() }, 75 - }, 76 - ]; 77 - if (args.comment.replyTo) 79 + }); 80 + if ( 81 + args.comment.replyTo && 82 + new AtUri(args.comment.replyTo).host !== credentialSession.did 83 + ) 78 84 notifications.push({ 79 85 id: v7(), 80 86 recipient: new AtUri(args.comment.replyTo).host, 81 - data: { type: "comment", comment_uri: uri.toString() }, 87 + data: { 88 + type: "comment", 89 + comment_uri: uri.toString(), 90 + parent_uri: args.comment.replyTo, 91 + }, 82 92 }); 83 93 // SOMEDAY: move this out the action with inngest or workflows 84 94 await supabaseServerClient.from("notifications").insert(notifications);
+9 -2
src/notifications.ts
··· 10 10 }; 11 11 12 12 export type NotificationData = 13 - | { type: "comment"; comment_uri: string } 13 + | { type: "comment"; comment_uri: string; parent_uri?: string } 14 14 | { type: "subscribe"; subscription_uri: string }; 15 15 16 16 export type HydratedNotification = ··· 58 58 } 59 59 60 60 // Fetch comment data from the database 61 - const commentUris = commentNotifications.map((n) => n.data.comment_uri); 61 + const commentUris = commentNotifications.flatMap((n) => 62 + n.data.parent_uri 63 + ? [n.data.comment_uri, n.data.parent_uri] 64 + : [n.data.comment_uri], 65 + ); 62 66 const { data: comments } = await supabaseServerClient 63 67 .from("comments_on_documents") 64 68 .select( ··· 72 76 created_at: notification.created_at, 73 77 type: "comment" as const, 74 78 comment_uri: notification.data.comment_uri, 79 + parentData: notification.data.parent_uri 80 + ? comments?.find((c) => c.uri === notification.data.parent_uri)! 81 + : undefined, 75 82 commentData: comments?.find( 76 83 (c) => c.uri === notification.data.comment_uri, 77 84 )!,