From 80ea9419100815df60a7dde89a5b7e303de3d03e Mon Sep 17 00:00:00 2001 From: Brendan Schlagel Date: Thu, 18 Dec 2025 12:42:58 -0500 Subject: [PATCH] Feature/profiles (#247) * created profile page, 404s * init page profile stuff * replaces placehodler with profile pic * added tab component, rm activity tab from profile * add publications * add posts to profile page * paginate posts * update to teh layout, wip comment deisgn * fixed up some weirdness that happened with merging * new comment design * render profile sub things as pages * small icon size adjustment * side scroll the pub list * refactored cardBorderHidden into a context in ThemeProvider and a hook * sticky header for tabs in profile page * simplied the scroll logic, fixed a boo boo * wire up comments and implement deep linking to em * fix comment action import * add subscriptions to profiles * moved profile pic, removed console logs * bluesky profile link * get home theme on profile pages * Profile popover init * use bluesky api for profile data * parse and render description * small updates to the profile popover styling * tweaks to get the tabs working if cardBorderHidden * new styling on @mentions and adding profile popover on hover * prefetch profile data on hover * fix adding local comments * render known followers * link to bsky known followers page * fixes * add titles to tags and profiles * fix links and themes for standalone docs on profile * add popover to post header and pub page * use linkify js for urls in profiles * wip tooltip stuff * use localizeddate in post listing --------- Co-authored-by: celine Co-authored-by: Jared Pereira --- app/(home-pages)/discover/page.tsx | 4 +- app/(home-pages)/home/HomeLayout.tsx | 8 - .../home/LeafletList/LeafletListItem.tsx | 11 +- .../home/LeafletList/LeafletPreview.tsx | 25 +- .../looseleafs/LooseleafsLayout.tsx | 6 - app/(home-pages)/notifications/page.tsx | 1 - .../p/[didOrHandle]/PostsContent.tsx | 88 +++++++ .../p/[didOrHandle]/ProfileHeader.tsx | 243 ++++++++++++++++++ .../p/[didOrHandle]/ProfileLayout.tsx | 24 ++ .../p/[didOrHandle]/ProfileTabs.tsx | 119 +++++++++ .../comments/CommentsContent.tsx | 219 ++++++++++++++++ .../comments/getProfileComments.ts | 133 ++++++++++ .../p/[didOrHandle]/comments/page.tsx | 28 ++ .../p/[didOrHandle]/getProfilePosts.ts | 95 +++++++ app/(home-pages)/p/[didOrHandle]/layout.tsx | 109 ++++++++ app/(home-pages)/p/[didOrHandle]/page.tsx | 24 ++ .../subscriptions/SubscriptionsContent.tsx | 103 ++++++++ .../p/[didOrHandle]/subscriptions/page.tsx | 28 ++ .../reader/SubscriptionsContent.tsx | 2 +- app/(home-pages)/reader/getReaderFeed.ts | 2 +- app/(home-pages)/reader/getSubscriptions.ts | 17 +- app/(home-pages)/reader/page.tsx | 1 - app/(home-pages)/tag/[tag]/page.tsx | 10 +- app/api/rpc/[command]/get_profile_data.ts | 69 +++++ app/api/rpc/[command]/route.ts | 2 + app/globals.css | 26 +- .../[did]/[publication]/PublicationAuthor.tsx | 21 ++ .../[publication]/[rkey]/BaseTextBlock.tsx | 23 +- .../[did]/[publication]/[rkey]/CanvasPage.tsx | 1 - .../Interactions/Comments/CommentBox.tsx | 7 +- .../[rkey]/Interactions/Comments/index.tsx | 114 ++------ .../[rkey]/Interactions/InteractionDrawer.tsx | 5 +- .../[rkey]/LinearDocumentPage.tsx | 1 - .../[rkey]/PostHeader/PostHeader.tsx | 21 +- .../[did]/[publication]/[rkey]/PostPages.tsx | 8 +- .../[did]/[publication]/[rkey]/ThreadPage.tsx | 1 - .../[publication]/dashboard/DraftList.tsx | 1 - .../dashboard/PublicationDashboard.tsx | 1 - app/lish/[did]/[publication]/page.tsx | 15 +- app/p/[didOrHandle]/[rkey]/page.tsx | 16 +- components/ActionBar/Navigation.tsx | 3 +- components/ActionBar/Publications.tsx | 2 +- components/AtMentionLink.tsx | 4 +- components/Avatar.tsx | 5 +- .../Blocks/TextBlock/RenderYJSFragment.tsx | 19 +- components/Blocks/TextBlock/schema.ts | 7 +- components/Icons/ReplyTiny.tsx | 1 + components/PageHeader.tsx | 15 +- components/PageLayouts/DashboardLayout.tsx | 26 +- components/Pages/Page.tsx | 8 +- components/Pages/PageOptions.tsx | 36 +-- components/Pages/useCardBorderHidden.ts | 21 +- components/PostListing.tsx | 67 ++--- components/ProfilePopover.tsx | 98 +++++++ components/Tab.tsx | 18 ++ .../ThemeManager/PublicationThemeProvider.tsx | 11 +- components/ThemeManager/ThemeProvider.tsx | 48 ++-- components/Tooltip.tsx | 2 +- src/hooks/useLocalizedDate.ts | 2 - 59 files changed, 1677 insertions(+), 348 deletions(-) create mode 100644 app/(home-pages)/p/[didOrHandle]/PostsContent.tsx create mode 100644 app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx create mode 100644 app/(home-pages)/p/[didOrHandle]/ProfileLayout.tsx create mode 100644 app/(home-pages)/p/[didOrHandle]/ProfileTabs.tsx create mode 100644 app/(home-pages)/p/[didOrHandle]/comments/CommentsContent.tsx create mode 100644 app/(home-pages)/p/[didOrHandle]/comments/getProfileComments.ts create mode 100644 app/(home-pages)/p/[didOrHandle]/comments/page.tsx create mode 100644 app/(home-pages)/p/[didOrHandle]/getProfilePosts.ts create mode 100644 app/(home-pages)/p/[didOrHandle]/layout.tsx create mode 100644 app/(home-pages)/p/[didOrHandle]/page.tsx create mode 100644 app/(home-pages)/p/[didOrHandle]/subscriptions/SubscriptionsContent.tsx create mode 100644 app/(home-pages)/p/[didOrHandle]/subscriptions/page.tsx create mode 100644 app/api/rpc/[command]/get_profile_data.ts create mode 100644 app/lish/[did]/[publication]/PublicationAuthor.tsx create mode 100644 components/ProfilePopover.tsx create mode 100644 components/Tab.tsx diff --git a/app/(home-pages)/discover/page.tsx b/app/(home-pages)/discover/page.tsx index 4e01207c..facf7c12 100644 --- a/app/(home-pages)/discover/page.tsx +++ b/app/(home-pages)/discover/page.tsx @@ -17,7 +17,6 @@ export default async function Discover(props: { return ( { - const orderValue = - props.order === "popular" ? "popular" : "recentlyUpdated"; + const orderValue = props.order === "popular" ? "popular" : "recentlyUpdated"; let { publications, nextCursor } = await getPublications(orderValue); return ( diff --git a/app/(home-pages)/home/HomeLayout.tsx b/app/(home-pages)/home/HomeLayout.tsx index f5406650..7ba4b9b6 100644 --- a/app/(home-pages)/home/HomeLayout.tsx +++ b/app/(home-pages)/home/HomeLayout.tsx @@ -20,7 +20,6 @@ import { useDashboardState, } from "components/PageLayouts/DashboardLayout"; import { Actions } from "./Actions/Actions"; -import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; import { GetLeafletDataReturnType } from "app/api/rpc/[command]/get_leaflet_data"; import { useState } from "react"; import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; @@ -56,7 +55,6 @@ export const HomeLayout = (props: { props.entityID, "theme/background-image", ); - let cardBorderHidden = !!useCardBorderHidden(props.entityID); let [searchValue, setSearchValue] = useState(""); let [debouncedSearchValue, setDebouncedSearchValue] = useState(""); @@ -81,7 +79,6 @@ export const HomeLayout = (props: { return ( } @@ -101,7 +98,6 @@ export const HomeLayout = (props: { ), @@ -117,7 +113,6 @@ export function HomeLeafletList(props: { [root_entity: string]: Fact[]; }; searchValue: string; - cardBorderHidden: boolean; }) { let { identity } = useIdentityData(); let { data: initialFacts } = useSWR( @@ -171,7 +166,6 @@ export function HomeLeafletList(props: { searchValue={props.searchValue} leaflets={leaflets} titles={initialFacts?.titles || {}} - cardBorderHidden={props.cardBorderHidden} initialFacts={initialFacts?.facts || {}} showPreview /> @@ -192,7 +186,6 @@ export function LeafletList(props: { [root_entity: string]: Fact[]; }; searchValue: string; - cardBorderHidden: boolean; showPreview?: boolean; }) { let { identity } = useIdentityData(); @@ -238,7 +231,6 @@ export function LeafletList(props: { loggedIn={!!identity} display={display} added_at={added_at} - cardBorderHidden={props.cardBorderHidden} index={index} showPreview={props.showPreview} isHidden={ diff --git a/app/(home-pages)/home/LeafletList/LeafletListItem.tsx b/app/(home-pages)/home/LeafletList/LeafletListItem.tsx index aaaa146c..d647206e 100644 --- a/app/(home-pages)/home/LeafletList/LeafletListItem.tsx +++ b/app/(home-pages)/home/LeafletList/LeafletListItem.tsx @@ -4,18 +4,19 @@ import { LeafletInfo } from "./LeafletInfo"; import { useState, useRef, useEffect } from "react"; import { SpeedyLink } from "components/SpeedyLink"; import { useLeafletPublicationStatus } from "components/PageSWRDataProvider"; +import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; export const LeafletListItem = (props: { archived?: boolean | null; loggedIn: boolean; display: "list" | "grid"; - cardBorderHidden: boolean; added_at: string; title?: string; index: number; isHidden: boolean; showPreview?: boolean; }) => { + const cardBorderHidden = useCardBorderHidden(); const pubStatus = useLeafletPublicationStatus(); let [isOnScreen, setIsOnScreen] = useState(props.index < 16 ? true : false); let previewRef = useRef(null); @@ -47,9 +48,9 @@ export const LeafletListItem = (props: { ref={previewRef} className={`relative flex gap-3 w-full ${props.isHidden ? "hidden" : "flex"} - ${props.cardBorderHidden ? "" : "px-2 py-1 block-border hover:outline-border relative"}`} + ${cardBorderHidden ? "" : "px-2 py-1 block-border hover:outline-border relative"}`} style={{ - backgroundColor: props.cardBorderHidden + backgroundColor: cardBorderHidden ? "transparent" : "rgba(var(--bg-page), var(--bg-page-alpha))", }} @@ -67,7 +68,7 @@ export const LeafletListItem = (props: { loggedIn={props.loggedIn} /> - {props.cardBorderHidden && ( + {cardBorderHidden && (
{ - const { root, page, cardBorderHidden, contentWrapperStyle, contentWrapperClass } = - useLeafletPreviewData(); + const { + root, + page, + cardBorderHidden, + contentWrapperStyle, + contentWrapperClass, + } = useLeafletPreviewData(); return ( +
diff --git a/app/(home-pages)/looseleafs/LooseleafsLayout.tsx b/app/(home-pages)/looseleafs/LooseleafsLayout.tsx index d40d28d4..5f0c12b3 100644 --- a/app/(home-pages)/looseleafs/LooseleafsLayout.tsx +++ b/app/(home-pages)/looseleafs/LooseleafsLayout.tsx @@ -1,6 +1,5 @@ "use client"; import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; -import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; import { useState } from "react"; import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; import { Fact, PermissionToken } from "src/replicache"; @@ -30,11 +29,9 @@ export const LooseleafsLayout = (props: { [searchValue], ); - let cardBorderHidden = !!useCardBorderHidden(props.entityID); return ( } @@ -45,7 +42,6 @@ export const LooseleafsLayout = (props: { ), @@ -61,7 +57,6 @@ export const LooseleafList = (props: { [root_entity: string]: Fact[]; }; searchValue: string; - cardBorderHidden: boolean; }) => { let { identity } = useIdentityData(); let { data: initialFacts } = useSWR( @@ -108,7 +103,6 @@ export const LooseleafList = (props: { searchValue={props.searchValue} leaflets={leaflets} titles={initialFacts?.titles || {}} - cardBorderHidden={props.cardBorderHidden} initialFacts={initialFacts?.facts || {}} showPreview /> diff --git a/app/(home-pages)/notifications/page.tsx b/app/(home-pages)/notifications/page.tsx index 9be3f1d3..a6f64a79 100644 --- a/app/(home-pages)/notifications/page.tsx +++ b/app/(home-pages)/notifications/page.tsx @@ -10,7 +10,6 @@ export default async function Notifications() { return ( { + const getKey = ( + pageIndex: number, + previousPageData: { + posts: Post[]; + nextCursor: Cursor | null; + } | null, + ) => { + // Reached the end + if (previousPageData && !previousPageData.nextCursor) return null; + + // First page, we don't have previousPageData + if (pageIndex === 0) return ["profile-posts", props.did, null] as const; + + // Add the cursor to the key + return ["profile-posts", props.did, previousPageData?.nextCursor] as const; + }; + + const { data, size, setSize, isValidating } = useSWRInfinite( + getKey, + ([_, did, cursor]) => getProfilePosts(did, cursor), + { + fallbackData: [{ posts: props.posts, nextCursor: props.nextCursor }], + 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 allPosts = data ? data.flatMap((page) => page.posts) : []; + + if (allPosts.length === 0 && !isValidating) { + return
No posts yet
; + } + + return ( +
+ {allPosts.map((post) => ( + + ))} + {/* Trigger element for loading more posts */} + + ); +}; diff --git a/app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx b/app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx new file mode 100644 index 00000000..459ef99c --- /dev/null +++ b/app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx @@ -0,0 +1,243 @@ +"use client"; +import { Avatar } from "components/Avatar"; +import { AppBskyActorProfile, PubLeafletPublication } from "lexicons/api"; +import { blobRefToSrc } from "src/utils/blobRefToSrc"; +import type { ProfileData } from "./layout"; +import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; +import { colorToString } from "components/ThemeManager/useColorAttribute"; +import { PubIcon } from "components/ActionBar/Publications"; +import { Json } from "supabase/database.types"; +import { BlueskyTiny } from "components/Icons/BlueskyTiny"; +import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; +import { SpeedyLink } from "components/SpeedyLink"; +import { ReactNode } from "react"; +import * as linkify from "linkifyjs"; + +export const ProfileHeader = (props: { + profile: ProfileViewDetailed; + publications: { record: Json; uri: string }[]; + popover?: boolean; +}) => { + let profileRecord = props.profile; + const profileUrl = `/p/${props.profile.handle}`; + + const avatarElement = ( + + ); + + const displayNameElement = ( +

+ {profileRecord.displayName + ? profileRecord.displayName + : `@${props.profile.handle}`} +

+ ); + + const handleElement = profileRecord.displayName && ( +
+ @{props.profile.handle} +
+ ); + + return ( +
+ +
+
+ {props.popover ? ( + + {avatarElement} + + ) : ( + avatarElement + )} + {props.popover ? ( + + {displayNameElement} + + ) : ( + displayNameElement + )} + {props.popover && handleElement ? ( + + {handleElement} + + ) : ( + handleElement + )} +
+
+          {profileRecord.description
+            ? parseDescription(profileRecord.description)
+            : null}
+        
+
+
+ {props.publications.map((p) => ( + + ))} +
+
+
+
+ ); +}; + +const ProfileLinks = (props: { handle: string }) => { + return ( +
+ + + +
+ ); +}; +const PublicationCard = (props: { + record: PubLeafletPublication.Record; + uri: string; +}) => { + const { record, uri } = props; + const { bgLeaflet, bgPage, primary } = usePubTheme(record.theme); + + return ( + +
+ +

+ {record.name} +

+
+
+ ); +}; + +function parseDescription(description: string): ReactNode[] { + // Find all mentions using regex + const mentionRegex = /@\S+/g; + const mentions: { start: number; end: number; value: string }[] = []; + let mentionMatch; + while ((mentionMatch = mentionRegex.exec(description)) !== null) { + mentions.push({ + start: mentionMatch.index, + end: mentionMatch.index + mentionMatch[0].length, + value: mentionMatch[0], + }); + } + + // Find all URLs using linkifyjs + const links = linkify.find(description).filter((link) => link.type === "url"); + + // Filter out URLs that overlap with mentions (mentions take priority) + const nonOverlappingLinks = links.filter((link) => { + return !mentions.some( + (mention) => + (link.start >= mention.start && link.start < mention.end) || + (link.end > mention.start && link.end <= mention.end) || + (link.start <= mention.start && link.end >= mention.end), + ); + }); + + // Combine into a single sorted list + const allMatches: Array<{ + start: number; + end: number; + value: string; + href: string; + type: "url" | "mention"; + }> = [ + ...nonOverlappingLinks.map((link) => ({ + start: link.start, + end: link.end, + value: link.value, + href: link.href, + type: "url" as const, + })), + ...mentions.map((mention) => ({ + start: mention.start, + end: mention.end, + value: mention.value, + href: `/p/${mention.value.slice(1)}`, + type: "mention" as const, + })), + ].sort((a, b) => a.start - b.start); + + const parts: ReactNode[] = []; + let lastIndex = 0; + let key = 0; + + for (const match of allMatches) { + // Add text before this match + if (match.start > lastIndex) { + parts.push(description.slice(lastIndex, match.start)); + } + + if (match.type === "mention") { + parts.push( + + {match.value} + , + ); + } else { + // It's a URL + const urlWithoutProtocol = match.value + .replace(/^https?:\/\//, "") + .replace(/\/+$/, ""); + const displayText = + urlWithoutProtocol.length > 50 + ? urlWithoutProtocol.slice(0, 50) + "…" + : urlWithoutProtocol; + parts.push( + + {displayText} + , + ); + } + + lastIndex = match.end; + } + + // Add remaining text after last match + if (lastIndex < description.length) { + parts.push(description.slice(lastIndex)); + } + + return parts; +} diff --git a/app/(home-pages)/p/[didOrHandle]/ProfileLayout.tsx b/app/(home-pages)/p/[didOrHandle]/ProfileLayout.tsx new file mode 100644 index 00000000..a298dec3 --- /dev/null +++ b/app/(home-pages)/p/[didOrHandle]/ProfileLayout.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; + +export function ProfileLayout(props: { children: React.ReactNode }) { + let cardBorderHidden = useCardBorderHidden(); + return ( +
+ {props.children} +
+ ); +} diff --git a/app/(home-pages)/p/[didOrHandle]/ProfileTabs.tsx b/app/(home-pages)/p/[didOrHandle]/ProfileTabs.tsx new file mode 100644 index 00000000..4609a0a1 --- /dev/null +++ b/app/(home-pages)/p/[didOrHandle]/ProfileTabs.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { SpeedyLink } from "components/SpeedyLink"; +import { useSelectedLayoutSegment } from "next/navigation"; +import { useState, useEffect } from "react"; +import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; + +export type ProfileTabType = "posts" | "comments" | "subscriptions"; + +export const ProfileTabs = (props: { didOrHandle: string }) => { + const cardBorderHidden = useCardBorderHidden(); + const segment = useSelectedLayoutSegment(); + const currentTab = (segment || "posts") as ProfileTabType; + const [scrollPosWithinTabContent, setScrollPosWithinTabContent] = useState(0); + const [headerHeight, setHeaderHeight] = useState(0); + useEffect(() => { + let headerHeight = + document.getElementById("profile-header")?.clientHeight || 0; + setHeaderHeight(headerHeight); + + const profileContent = cardBorderHidden + ? document.getElementById("home-content") + : document.getElementById("profile-content"); + const handleScroll = () => { + if (profileContent) { + setScrollPosWithinTabContent( + profileContent.scrollTop - headerHeight > 0 + ? profileContent.scrollTop - headerHeight + : 0, + ); + } + }; + + if (profileContent) { + profileContent.addEventListener("scroll", handleScroll); + return () => profileContent.removeEventListener("scroll", handleScroll); + } + }, []); + + const baseUrl = `/p/${props.didOrHandle}`; + const bgColor = cardBorderHidden ? "var(--bg-leaflet)" : "var(--bg-page)"; + + return ( +
+
+
20 ? "border-border-light" : "border-transparent"} + py-1 + w-full `} + style={ + scrollPosWithinTabContent < 20 + ? { + backgroundColor: !cardBorderHidden + ? `rgba(${bgColor}, ${scrollPosWithinTabContent / 60 + 0.75})` + : `rgba(${bgColor}, ${scrollPosWithinTabContent / 20})`, + paddingLeft: !cardBorderHidden + ? "4px" + : `calc(${scrollPosWithinTabContent / 20} * 4px)`, + paddingRight: !cardBorderHidden + ? "4px" + : `calc(${scrollPosWithinTabContent / 20} * 4px)`, + } + : { + backgroundColor: `rgb(${bgColor})`, + paddingLeft: "4px", + paddingRight: "4px", + } + } + > +
+
+ + +
+ +
+
+
+
+ ); +}; + +const TabLink = (props: { href: string; name: string; selected: boolean }) => { + return ( + + {props.name} + + ); +}; diff --git a/app/(home-pages)/p/[didOrHandle]/comments/CommentsContent.tsx b/app/(home-pages)/p/[didOrHandle]/comments/CommentsContent.tsx new file mode 100644 index 00000000..3948adbf --- /dev/null +++ b/app/(home-pages)/p/[didOrHandle]/comments/CommentsContent.tsx @@ -0,0 +1,219 @@ +"use client"; + +import { useEffect, useRef, useMemo } from "react"; +import useSWRInfinite from "swr/infinite"; +import { AppBskyActorProfile, AtUri } from "@atproto/api"; +import { PubLeafletComment, PubLeafletDocument } from "lexicons/api"; +import { ReplyTiny } from "components/Icons/ReplyTiny"; +import { Avatar } from "components/Avatar"; +import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/BaseTextBlock"; +import { blobRefToSrc } from "src/utils/blobRefToSrc"; +import { + getProfileComments, + type ProfileComment, + type Cursor, +} from "./getProfileComments"; +import { timeAgo } from "src/utils/timeAgo"; +import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; + +export const ProfileCommentsContent = (props: { + did: string; + comments: ProfileComment[]; + nextCursor: Cursor | null; +}) => { + const getKey = ( + pageIndex: number, + previousPageData: { + comments: ProfileComment[]; + nextCursor: Cursor | null; + } | null, + ) => { + // Reached the end + if (previousPageData && !previousPageData.nextCursor) return null; + + // First page, we don't have previousPageData + if (pageIndex === 0) return ["profile-comments", props.did, null] as const; + + // Add the cursor to the key + return [ + "profile-comments", + props.did, + previousPageData?.nextCursor, + ] as const; + }; + + const { data, size, setSize, isValidating } = useSWRInfinite( + getKey, + ([_, did, cursor]) => getProfileComments(did, cursor), + { + fallbackData: [ + { comments: props.comments, nextCursor: props.nextCursor }, + ], + 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 allComments = data ? data.flatMap((page) => page.comments) : []; + + if (allComments.length === 0 && !isValidating) { + return ( +
No comments yet
+ ); + } + + return ( +
+ {allComments.map((comment) => ( + + ))} + {/* Trigger element for loading more comments */} + + ); +}; + +const CommentItem = ({ comment }: { comment: ProfileComment }) => { + const record = comment.record as PubLeafletComment.Record; + const profile = comment.bsky_profiles?.record as + | AppBskyActorProfile.Record + | undefined; + const displayName = + profile?.displayName || comment.bsky_profiles?.handle || "Unknown"; + + // Get commenter DID from comment URI + const commenterDid = new AtUri(comment.uri).host; + + const isReply = !!record.reply; + + // Get document title + const docData = comment.document?.data as + | PubLeafletDocument.Record + | undefined; + const postTitle = docData?.title || "Untitled"; + + // Get parent comment info for replies + const parentRecord = comment.parentComment?.record as + | PubLeafletComment.Record + | undefined; + const parentProfile = comment.parentComment?.bsky_profiles?.record as + | AppBskyActorProfile.Record + | undefined; + const parentDisplayName = + parentProfile?.displayName || comment.parentComment?.bsky_profiles?.handle; + + // Build direct link to the comment + const commentLink = useMemo(() => { + if (!comment.document) return null; + const docUri = new AtUri(comment.document.uri); + + // Get base URL using getPublicationURL if publication exists, otherwise build path + let baseUrl: string; + if (comment.publication) { + baseUrl = getPublicationURL(comment.publication); + const pubUri = new AtUri(comment.publication.uri); + // If getPublicationURL returns a relative path, append the document rkey + if (baseUrl.startsWith("/")) { + baseUrl = `${baseUrl}/${docUri.rkey}`; + } else { + // For custom domains, append the document rkey + baseUrl = `${baseUrl}/${docUri.rkey}`; + } + } else { + baseUrl = `/lish/${docUri.host}/-/${docUri.rkey}`; + } + + // Build query parameters + const params = new URLSearchParams(); + params.set("interactionDrawer", "comments"); + if (record.onPage) { + params.set("page", record.onPage); + } + + // Use comment URI as hash for direct reference + const commentId = encodeURIComponent(comment.uri); + + return `${baseUrl}?${params.toString()}#${commentId}`; + }, [comment.document, comment.publication, comment.uri, record.onPage]); + + // Get avatar source + const avatarSrc = profile?.avatar?.ref + ? blobRefToSrc(profile.avatar.ref, commenterDid) + : undefined; + + return ( +
+
+ +
+
+
+ {displayName}{" "} + {isReply ? "replied" : "commented"} on{" "} + {commentLink ? ( + + {postTitle} + + ) : ( + {postTitle} + )} +
+
+ {isReply && parentRecord && ( +
+ + {parentDisplayName && ( +
{parentDisplayName}
+ )} +
{parentRecord.plaintext}
+
+ )} +
+            
+          
+
+
+
+ ); +}; diff --git a/app/(home-pages)/p/[didOrHandle]/comments/getProfileComments.ts b/app/(home-pages)/p/[didOrHandle]/comments/getProfileComments.ts new file mode 100644 index 00000000..23e4cdb0 --- /dev/null +++ b/app/(home-pages)/p/[didOrHandle]/comments/getProfileComments.ts @@ -0,0 +1,133 @@ +"use server"; + +import { supabaseServerClient } from "supabase/serverClient"; +import { Json } from "supabase/database.types"; +import { PubLeafletComment } from "lexicons/api"; + +export type Cursor = { + indexed_at: string; + uri: string; +}; + +export type ProfileComment = { + uri: string; + record: Json; + indexed_at: string; + bsky_profiles: { record: Json; handle: string | null } | null; + document: { + uri: string; + data: Json; + } | null; + publication: { + uri: string; + record: Json; + } | null; + // For replies, include the parent comment info + parentComment: { + uri: string; + record: Json; + bsky_profiles: { record: Json; handle: string | null } | null; + } | null; +}; + +export async function getProfileComments( + did: string, + cursor?: Cursor | null, +): Promise<{ comments: ProfileComment[]; nextCursor: Cursor | null }> { + const limit = 20; + + let query = supabaseServerClient + .from("comments_on_documents") + .select( + `*, + bsky_profiles(record, handle), + documents(uri, data, documents_in_publications(publications(*)))`, + ) + .eq("profile", did) + .order("indexed_at", { ascending: false }) + .order("uri", { ascending: false }) + .limit(limit); + + if (cursor) { + query = query.or( + `indexed_at.lt.${cursor.indexed_at},and(indexed_at.eq.${cursor.indexed_at},uri.lt.${cursor.uri})`, + ); + } + + const { data: rawComments } = await query; + + if (!rawComments || rawComments.length === 0) { + return { comments: [], nextCursor: null }; + } + + // Collect parent comment URIs for replies + const parentUris = rawComments + .map((c) => (c.record as PubLeafletComment.Record).reply?.parent) + .filter((uri): uri is string => !!uri); + + // Fetch parent comments if there are any replies + let parentCommentsMap = new Map< + string, + { + uri: string; + record: Json; + bsky_profiles: { record: Json; handle: string | null } | null; + } + >(); + + if (parentUris.length > 0) { + const { data: parentComments } = await supabaseServerClient + .from("comments_on_documents") + .select(`uri, record, bsky_profiles(record, handle)`) + .in("uri", parentUris); + + if (parentComments) { + for (const pc of parentComments) { + parentCommentsMap.set(pc.uri, { + uri: pc.uri, + record: pc.record, + bsky_profiles: pc.bsky_profiles, + }); + } + } + } + + // Transform to ProfileComment format + const comments: ProfileComment[] = rawComments.map((comment) => { + const record = comment.record as PubLeafletComment.Record; + const doc = comment.documents; + const pub = doc?.documents_in_publications?.[0]?.publications; + + return { + uri: comment.uri, + record: comment.record, + indexed_at: comment.indexed_at, + bsky_profiles: comment.bsky_profiles, + document: doc + ? { + uri: doc.uri, + data: doc.data, + } + : null, + publication: pub + ? { + uri: pub.uri, + record: pub.record, + } + : null, + parentComment: record.reply?.parent + ? parentCommentsMap.get(record.reply.parent) || null + : null, + }; + }); + + const nextCursor = + comments.length === limit + ? { + indexed_at: comments[comments.length - 1].indexed_at, + uri: comments[comments.length - 1].uri, + } + : null; + + return { comments, nextCursor }; +} diff --git a/app/(home-pages)/p/[didOrHandle]/comments/page.tsx b/app/(home-pages)/p/[didOrHandle]/comments/page.tsx new file mode 100644 index 00000000..07cad5fe --- /dev/null +++ b/app/(home-pages)/p/[didOrHandle]/comments/page.tsx @@ -0,0 +1,28 @@ +import { idResolver } from "app/(home-pages)/reader/idResolver"; +import { getProfileComments } from "./getProfileComments"; +import { ProfileCommentsContent } from "./CommentsContent"; + +export default async function ProfileCommentsPage(props: { + params: Promise<{ didOrHandle: string }>; +}) { + let params = await props.params; + let didOrHandle = decodeURIComponent(params.didOrHandle); + + // Resolve handle to DID if necessary + let did = didOrHandle; + if (!didOrHandle.startsWith("did:")) { + let resolved = await idResolver.handle.resolve(didOrHandle); + if (!resolved) return null; + did = resolved; + } + + const { comments, nextCursor } = await getProfileComments(did); + + return ( + + ); +} diff --git a/app/(home-pages)/p/[didOrHandle]/getProfilePosts.ts b/app/(home-pages)/p/[didOrHandle]/getProfilePosts.ts new file mode 100644 index 00000000..cc9e762d --- /dev/null +++ b/app/(home-pages)/p/[didOrHandle]/getProfilePosts.ts @@ -0,0 +1,95 @@ +"use server"; + +import { supabaseServerClient } from "supabase/serverClient"; +import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; +import type { Post } from "app/(home-pages)/reader/getReaderFeed"; + +export type Cursor = { + indexed_at: string; + uri: string; +}; + +export async function getProfilePosts( + did: string, + cursor?: Cursor | null, +): Promise<{ posts: Post[]; nextCursor: Cursor | null }> { + const limit = 20; + + let query = supabaseServerClient + .from("documents") + .select( + `*, + comments_on_documents(count), + document_mentions_in_bsky(count), + documents_in_publications(publications(*))`, + ) + .like("uri", `at://${did}/%`) + .order("indexed_at", { ascending: false }) + .order("uri", { ascending: false }) + .limit(limit); + + if (cursor) { + query = query.or( + `indexed_at.lt.${cursor.indexed_at},and(indexed_at.eq.${cursor.indexed_at},uri.lt.${cursor.uri})`, + ); + } + + let [{ data: docs }, { data: pubs }, { data: profile }] = await Promise.all([ + query, + supabaseServerClient + .from("publications") + .select("*") + .eq("identity_did", did), + supabaseServerClient + .from("bsky_profiles") + .select("handle") + .eq("did", did) + .single(), + ]); + + // Build a map of publications for quick lookup + let pubMap = new Map[number]>(); + for (let pub of pubs || []) { + pubMap.set(pub.uri, pub); + } + + // Transform data to Post[] format + let handle = profile?.handle ? `@${profile.handle}` : null; + let posts: Post[] = []; + + for (let doc of docs || []) { + let pubFromDoc = doc.documents_in_publications?.[0]?.publications; + let pub = pubFromDoc ? pubMap.get(pubFromDoc.uri) || pubFromDoc : null; + + let post: Post = { + author: handle, + documents: { + data: doc.data, + uri: doc.uri, + indexed_at: doc.indexed_at, + comments_on_documents: doc.comments_on_documents, + document_mentions_in_bsky: doc.document_mentions_in_bsky, + }, + }; + + if (pub) { + post.publication = { + href: getPublicationURL(pub), + pubRecord: pub.record, + uri: pub.uri, + }; + } + + posts.push(post); + } + + const nextCursor = + posts.length === limit + ? { + indexed_at: posts[posts.length - 1].documents.indexed_at, + uri: posts[posts.length - 1].documents.uri, + } + : null; + + return { posts, nextCursor }; +} diff --git a/app/(home-pages)/p/[didOrHandle]/layout.tsx b/app/(home-pages)/p/[didOrHandle]/layout.tsx new file mode 100644 index 00000000..3921d3d1 --- /dev/null +++ b/app/(home-pages)/p/[didOrHandle]/layout.tsx @@ -0,0 +1,109 @@ +import { idResolver } from "app/(home-pages)/reader/idResolver"; +import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; +import { supabaseServerClient } from "supabase/serverClient"; +import { Json } from "supabase/database.types"; +import { ProfileHeader } from "./ProfileHeader"; +import { ProfileTabs } from "./ProfileTabs"; +import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; +import { ProfileLayout } from "./ProfileLayout"; +import { Agent } from "@atproto/api"; +import { get_profile_data } from "app/api/rpc/[command]/get_profile_data"; +import { Metadata } from "next"; + +export async function generateMetadata(props: { + params: Promise<{ didOrHandle: string }>; +}): Promise { + let params = await props.params; + let didOrHandle = decodeURIComponent(params.didOrHandle); + + let did = didOrHandle; + if (!didOrHandle.startsWith("did:")) { + let resolved = await idResolver.handle.resolve(didOrHandle); + if (!resolved) return { title: "Profile - Leaflet" }; + did = resolved; + } + + let profileData = await get_profile_data.handler( + { didOrHandle: did }, + { supabase: supabaseServerClient }, + ); + let { profile } = profileData.result; + + if (!profile) return { title: "Profile - Leaflet" }; + + const displayName = profile.displayName; + const handle = profile.handle; + + const title = displayName + ? `${displayName} (@${handle}) - Leaflet` + : `@${handle} - Leaflet`; + + return { title }; +} + +export default async function ProfilePageLayout(props: { + params: Promise<{ didOrHandle: string }>; + children: React.ReactNode; +}) { + let params = await props.params; + let didOrHandle = decodeURIComponent(params.didOrHandle); + + // Resolve handle to DID if necessary + let did = didOrHandle; + + if (!didOrHandle.startsWith("did:")) { + let resolved = await idResolver.handle.resolve(didOrHandle); + if (!resolved) { + return ( + +

Sorry, can't resolve handle!

+

+ This may be a glitch on our end. If the issue persists please{" "} + send us a note. +

+
+ ); + } + did = resolved; + } + let profileData = await get_profile_data.handler( + { didOrHandle: did }, + { supabase: supabaseServerClient }, + ); + let { publications, profile } = profileData.result; + + if (!profile) return null; + + return ( + + + +
+ {props.children} +
+ + ), + }, + }} + /> + ); +} + +export type ProfileData = { + did: string; + handle: string | null; + indexed_at: string; + record: Json; +}; diff --git a/app/(home-pages)/p/[didOrHandle]/page.tsx b/app/(home-pages)/p/[didOrHandle]/page.tsx new file mode 100644 index 00000000..f1d4002b --- /dev/null +++ b/app/(home-pages)/p/[didOrHandle]/page.tsx @@ -0,0 +1,24 @@ +import { idResolver } from "app/(home-pages)/reader/idResolver"; +import { getProfilePosts } from "./getProfilePosts"; +import { ProfilePostsContent } from "./PostsContent"; + +export default async function ProfilePostsPage(props: { + params: Promise<{ didOrHandle: string }>; +}) { + let params = await props.params; + let didOrHandle = decodeURIComponent(params.didOrHandle); + + // Resolve handle to DID if necessary + let did = didOrHandle; + if (!didOrHandle.startsWith("did:")) { + let resolved = await idResolver.handle.resolve(didOrHandle); + if (!resolved) return null; + did = resolved; + } + + const { posts, nextCursor } = await getProfilePosts(did); + + return ( + + ); +} diff --git a/app/(home-pages)/p/[didOrHandle]/subscriptions/SubscriptionsContent.tsx b/app/(home-pages)/p/[didOrHandle]/subscriptions/SubscriptionsContent.tsx new file mode 100644 index 00000000..9e5e4ca8 --- /dev/null +++ b/app/(home-pages)/p/[didOrHandle]/subscriptions/SubscriptionsContent.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import useSWRInfinite from "swr/infinite"; +import { PubListing } from "app/(home-pages)/discover/PubListing"; +import { + getSubscriptions, + type PublicationSubscription, +} from "app/(home-pages)/reader/getSubscriptions"; +import { Cursor } from "app/(home-pages)/reader/getReaderFeed"; + +export const ProfileSubscriptionsContent = (props: { + did: string; + subscriptions: PublicationSubscription[]; + nextCursor: Cursor | null; +}) => { + const getKey = ( + pageIndex: number, + previousPageData: { + subscriptions: PublicationSubscription[]; + nextCursor: Cursor | null; + } | null, + ) => { + // Reached the end + if (previousPageData && !previousPageData.nextCursor) return null; + + // First page, we don't have previousPageData + if (pageIndex === 0) + return ["profile-subscriptions", props.did, null] as const; + + // Add the cursor to the key + return [ + "profile-subscriptions", + props.did, + previousPageData?.nextCursor, + ] as const; + }; + + const { data, size, setSize, isValidating } = useSWRInfinite( + getKey, + ([_, did, cursor]) => getSubscriptions(did, cursor), + { + fallbackData: [ + { subscriptions: props.subscriptions, nextCursor: props.nextCursor }, + ], + 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 allSubscriptions = data + ? data.flatMap((page) => page.subscriptions) + : []; + + if (allSubscriptions.length === 0 && !isValidating) { + return ( +
No subscriptions yet
+ ); + } + + return ( +
+
+ {allSubscriptions.map((sub) => ( + + ))} +
+ {/* Trigger element for loading more subscriptions */} + + ); +}; diff --git a/app/(home-pages)/p/[didOrHandle]/subscriptions/page.tsx b/app/(home-pages)/p/[didOrHandle]/subscriptions/page.tsx new file mode 100644 index 00000000..bdbe3c56 --- /dev/null +++ b/app/(home-pages)/p/[didOrHandle]/subscriptions/page.tsx @@ -0,0 +1,28 @@ +import { idResolver } from "app/(home-pages)/reader/idResolver"; +import { getSubscriptions } from "app/(home-pages)/reader/getSubscriptions"; +import { ProfileSubscriptionsContent } from "./SubscriptionsContent"; + +export default async function ProfileSubscriptionsPage(props: { + params: Promise<{ didOrHandle: string }>; +}) { + const params = await props.params; + const didOrHandle = decodeURIComponent(params.didOrHandle); + + // Resolve handle to DID if necessary + let did = didOrHandle; + if (!didOrHandle.startsWith("did:")) { + const resolved = await idResolver.handle.resolve(didOrHandle); + if (!resolved) return null; + did = resolved; + } + + const { subscriptions, nextCursor } = await getSubscriptions(did); + + return ( + + ); +} diff --git a/app/(home-pages)/reader/SubscriptionsContent.tsx b/app/(home-pages)/reader/SubscriptionsContent.tsx index ca9cce13..9645822f 100644 --- a/app/(home-pages)/reader/SubscriptionsContent.tsx +++ b/app/(home-pages)/reader/SubscriptionsContent.tsx @@ -32,7 +32,7 @@ export const SubscriptionsContent = (props: { const { data, error, size, setSize, isValidating } = useSWRInfinite( getKey, - ([_, cursor]) => getSubscriptions(cursor), + ([_, cursor]) => getSubscriptions(null, cursor), { fallbackData: [ { subscriptions: props.publications, nextCursor: props.nextCursor }, diff --git a/app/(home-pages)/reader/getReaderFeed.ts b/app/(home-pages)/reader/getReaderFeed.ts index 9cb7d2b0..c084649d 100644 --- a/app/(home-pages)/reader/getReaderFeed.ts +++ b/app/(home-pages)/reader/getReaderFeed.ts @@ -83,7 +83,7 @@ export async function getReaderFeed( export type Post = { author: string | null; - publication: { + publication?: { href: string; pubRecord: Json; uri: string; diff --git a/app/(home-pages)/reader/getSubscriptions.ts b/app/(home-pages)/reader/getSubscriptions.ts index 98f7934f..bff1cd6d 100644 --- a/app/(home-pages)/reader/getSubscriptions.ts +++ b/app/(home-pages)/reader/getSubscriptions.ts @@ -8,12 +8,21 @@ import { supabaseServerClient } from "supabase/serverClient"; import { idResolver } from "./idResolver"; import { Cursor } from "./getReaderFeed"; -export async function getSubscriptions(cursor?: Cursor | null): Promise<{ +export async function getSubscriptions( + did?: string | null, + cursor?: Cursor | null, +): Promise<{ nextCursor: null | Cursor; subscriptions: PublicationSubscription[]; }> { - let auth_res = await getIdentityData(); - if (!auth_res?.atp_did) return { subscriptions: [], nextCursor: null }; + // If no DID provided, use logged-in user's DID + let identity = did; + if (!identity) { + const auth_res = await getIdentityData(); + if (!auth_res?.atp_did) return { subscriptions: [], nextCursor: null }; + identity = auth_res.atp_did; + } + let query = supabaseServerClient .from("publication_subscriptions") .select(`*, publications(*, documents_in_publications(*, documents(*)))`) @@ -25,7 +34,7 @@ export async function getSubscriptions(cursor?: Cursor | null): Promise<{ }) .limit(1, { referencedTable: "publications.documents_in_publications" }) .limit(25) - .eq("identity", auth_res.atp_did); + .eq("identity", identity); if (cursor) { query = query.or( diff --git a/app/(home-pages)/reader/page.tsx b/app/(home-pages)/reader/page.tsx index f30e480a..3372c83c 100644 --- a/app/(home-pages)/reader/page.tsx +++ b/app/(home-pages)/reader/page.tsx @@ -12,7 +12,6 @@ export default async function Reader(props: {}) { return ( ; +}): Promise { + const params = await props.params; + const decodedTag = decodeURIComponent(params.tag); + return { title: `${decodedTag} - Leaflet` }; +} export default async function TagPage(props: { params: Promise<{ tag: string }>; @@ -14,7 +23,6 @@ export default async function TagPage(props: { return ( +>; + +export const get_profile_data = makeRoute({ + route: "get_profile_data", + input: z.object({ + didOrHandle: z.string(), + }), + handler: async ({ didOrHandle }, { supabase }: Pick) => { + // Resolve handle to DID if necessary + let did = didOrHandle; + + if (!didOrHandle.startsWith("did:")) { + const resolved = await idResolver.handle.resolve(didOrHandle); + if (!resolved) { + throw new Error("Could not resolve handle to DID"); + } + did = resolved; + } + let agent; + let authed_identity = await getIdentityData(); + if (authed_identity?.atp_did) { + try { + const oauthClient = await createOauthClient(); + let credentialSession = await oauthClient.restore( + authed_identity.atp_did, + ); + agent = new Agent(credentialSession); + } catch (e) { + agent = new Agent({ + service: "https://public.api.bsky.app", + }); + } + } else { + agent = new Agent({ + service: "https://public.api.bsky.app", + }); + } + + let profileReq = agent.app.bsky.actor.getProfile({ actor: did }); + + let publicationsReq = supabase + .from("publications") + .select("*") + .eq("identity_did", did); + + let [{ data: profile }, { data: publications }] = await Promise.all([ + profileReq, + publicationsReq, + ]); + + return { + result: { + profile, + publications: publications || [], + }, + }; + }, +}); diff --git a/app/api/rpc/[command]/route.ts b/app/api/rpc/[command]/route.ts index 4767ffb4..df7a865b 100644 --- a/app/api/rpc/[command]/route.ts +++ b/app/api/rpc/[command]/route.ts @@ -13,6 +13,7 @@ import { get_leaflet_data } from "./get_leaflet_data"; import { get_publication_data } from "./get_publication_data"; import { search_publication_names } from "./search_publication_names"; import { search_publication_documents } from "./search_publication_documents"; +import { get_profile_data } from "./get_profile_data"; let supabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, @@ -39,6 +40,7 @@ let Routes = [ get_publication_data, search_publication_names, search_publication_documents, + get_profile_data, ]; export async function POST( req: Request, diff --git a/app/globals.css b/app/globals.css index c23c8897..ae584c7a 100644 --- a/app/globals.css +++ b/app/globals.css @@ -296,14 +296,32 @@ pre.shiki { @apply py-[1.5px]; } -/* Underline mention nodes when selected in ProseMirror */ +.ProseMirror:focus-within .selection-highlight { + background-color: transparent; +} + .ProseMirror .atMention.ProseMirror-selectednode, .ProseMirror .didMention.ProseMirror-selectednode { - text-decoration: underline; + @apply text-accent-contrast; + @apply px-0.5; + @apply -mx-[3px]; /* extra px to account for the border*/ + @apply -my-px; /*to account for the border*/ + @apply rounded-[4px]; + @apply box-decoration-clone; + background-color: rgba(var(--accent-contrast), 0.2); + border: 1px solid rgba(var(--accent-contrast), 1); } -.ProseMirror:focus-within .selection-highlight { - background-color: transparent; +.mention { + @apply cursor-pointer; + @apply text-accent-contrast; + @apply px-0.5; + @apply -mx-[3px]; + @apply -my-px; /*to account for the border*/ + @apply rounded-[4px]; + @apply box-decoration-clone; + background-color: rgba(var(--accent-contrast), 0.2); + border: 1px solid transparent; } .multiselected:focus-within .selection-highlight { diff --git a/app/lish/[did]/[publication]/PublicationAuthor.tsx b/app/lish/[did]/[publication]/PublicationAuthor.tsx new file mode 100644 index 00000000..15f3e949 --- /dev/null +++ b/app/lish/[did]/[publication]/PublicationAuthor.tsx @@ -0,0 +1,21 @@ +"use client"; +import { ProfilePopover } from "components/ProfilePopover"; + +export const PublicationAuthor = (props: { + did: string; + displayName?: string; + handle: string; +}) => { + return ( +

+ + by {props.displayName} @{props.handle} + + } + /> +

+ ); +}; diff --git a/app/lish/[did]/[publication]/[rkey]/BaseTextBlock.tsx b/app/lish/[did]/[publication]/[rkey]/BaseTextBlock.tsx index 6c6de80d..8b90b8a4 100644 --- a/app/lish/[did]/[publication]/[rkey]/BaseTextBlock.tsx +++ b/app/lish/[did]/[publication]/[rkey]/BaseTextBlock.tsx @@ -2,6 +2,7 @@ import { UnicodeString } from "@atproto/api"; import { PubLeafletRichtextFacet } from "lexicons/api"; import { didToBlueskyUrl } from "src/utils/mentionUtils"; import { AtMentionLink } from "components/AtMentionLink"; +import { ProfilePopover } from "components/ProfilePopover"; type Facet = PubLeafletRichtextFacet.Main; export function BaseTextBlock(props: { @@ -27,9 +28,7 @@ export function BaseTextBlock(props: { let isDidMention = segment.facet?.find( PubLeafletRichtextFacet.isDidMention, ); - let isAtMention = segment.facet?.find( - PubLeafletRichtextFacet.isAtMention, - ); + let isAtMention = segment.facet?.find(PubLeafletRichtextFacet.isAtMention); let isUnderline = segment.facet?.find(PubLeafletRichtextFacet.isUnderline); let isItalic = segment.facet?.find(PubLeafletRichtextFacet.isItalic); let isHighlighted = segment.facet?.find( @@ -45,9 +44,11 @@ export function BaseTextBlock(props: { ${isHighlighted ? "highlight bg-highlight-1" : ""}`.replaceAll("\n", " "); // Split text by newlines and insert
tags - const textParts = segment.text.split('\n'); + const textParts = segment.text.split("\n"); const renderedText = textParts.flatMap((part, i) => - i < textParts.length - 1 ? [part,
] : [part] + i < textParts.length - 1 + ? [part,
] + : [part], ); if (isCode) { @@ -58,15 +59,11 @@ export function BaseTextBlock(props: { ); } else if (isDidMention) { children.push( - - {renderedText} - , + didOrHandle={isDidMention.did} + trigger={{renderedText}} + />, ); } else if (isAtMention) { children.push( diff --git a/app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx b/app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx index 41c92b6b..007774ae 100644 --- a/app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx +++ b/app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx @@ -57,7 +57,6 @@ export function CanvasPage({ { + const did = props.comment.bsky_profiles?.did; + return ( -
+
- {props.profile && ( - + {did && ( + + {props.profile.displayName} +
+ } + /> )} -
{props.record.attachment && PubLeafletComment.isLinearDocumentQuote(props.record.attachment) && ( @@ -291,96 +300,3 @@ const DatePopover = (props: { date: string }) => { ); }; - -const ProfilePopover = (props: { - profile: AppBskyActorProfile.Record; - comment: string; -}) => { - let commenterId = new AtUri(props.comment).host; - - return ( - <> - - {props.profile.displayName} - - {/* - { - setHovering(true); - hoverTimeout.current = window.setTimeout(() => { - setLoadProfile(true); - }, 500); - }} - onMouseOut={() => { - setHovering(false); - clearTimeout(hoverTimeout.current); - }} - className="font-bold text-tertiary text-sm hover:underline" - > - {props.profile.displayName} -
- } - className="max-w-sm" - > - {profile && ( - <> -
-
-
-
- -
- -
- {profile.description} -
-
- {!profile.viewer?.following ? ( -
- Following -
- ) : ( - - Follow - - )} - {profile.viewer?.followedBy && ( -
Follows You
- )} -
-
-
- -
-
-
- {profile.viewer?.knownFollowers?.followers.map((follower) => { - return ( -
- ); - })} -
-
-
-
- - )} - - */} - - ); -}; diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx b/app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx index a9df0ad5..7771432a 100644 --- a/app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx @@ -58,10 +58,13 @@ export const InteractionDrawer = (props: { export const useDrawerOpen = (uri: string) => { let params = useSearchParams(); let interactionDrawerSearchParam = params.get("interactionDrawer"); + let pageParam = params.get("page"); let { drawerOpen: open, drawer, pageId } = useInteractionState(uri); if (open === false || (open === undefined && !interactionDrawerSearchParam)) return null; drawer = drawer || (interactionDrawerSearchParam as InteractionState["drawer"]); - return { drawer, pageId }; + // Use pageId from state, or fall back to page search param + const resolvedPageId = pageId ?? pageParam ?? undefined; + return { drawer, pageId: resolvedPageId }; }; diff --git a/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx b/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx index c5fe24c0..302c16bf 100644 --- a/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx +++ b/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx @@ -61,7 +61,6 @@ export function LinearDocumentPage({
{profile ? ( - <> - - {profile.displayName || profile.handle} - - + + {profile.displayName || profile.handle} + + } + /> ) : null} {record.publishedAt ? ( <> @@ -119,9 +120,9 @@ export const PostHeaderLayout = (props: { {props.postTitle ? props.postTitle : "Untitled"} {props.postDescription ? ( -

+

{props.postDescription} -

+
) : null}
{props.postInfo} diff --git a/app/lish/[did]/[publication]/[rkey]/PostPages.tsx b/app/lish/[did]/[publication]/[rkey]/PostPages.tsx index 420c201f..1476ad55 100644 --- a/app/lish/[did]/[publication]/[rkey]/PostPages.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PostPages.tsx @@ -81,7 +81,6 @@ export const useInitializeOpenPages = () => { }); return; } - // Then check for quote param if (quote) { const decodedQuote = decodeQuotePosition(quote as string); @@ -96,7 +95,7 @@ export const useInitializeOpenPages = () => { // Mark as initialized even if no pageId found usePostPageUIState.setState({ initialized: true }); } - }, [quote]); + }, [quote, pageParam]); }; export const openPage = ( @@ -355,10 +354,7 @@ const PageOptions = (props: { absolute sm:-right-[20px] right-3 sm:top-3 top-0 flex sm:flex-col flex-row-reverse gap-1 items-start`} > - +
diff --git a/app/lish/[did]/[publication]/[rkey]/ThreadPage.tsx b/app/lish/[did]/[publication]/[rkey]/ThreadPage.tsx index 9d7dbf6d..51e62ed4 100644 --- a/app/lish/[did]/[publication]/[rkey]/ThreadPage.tsx +++ b/app/lish/[did]/[publication]/[rkey]/ThreadPage.tsx @@ -94,7 +94,6 @@ export function ThreadPage(props: { return ( !l.documents) .filter((l) => !l.archived) diff --git a/app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx b/app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx index a5524c70..28aecd87 100644 --- a/app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx +++ b/app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx @@ -39,7 +39,6 @@ export default function PublicationDashboard({ return ( ; @@ -91,15 +92,11 @@ export default async function Publication(props: { {record?.description}{" "}

{profile && ( -

- by {profile.displayName}{" "} - - @{profile.handle} - -

+ )}
; @@ -34,7 +35,8 @@ export async function generateMetadata(props: { let docRecord = document.data as PubLeafletDocument.Record; // For documents in publications, include publication name - let publicationName = document.documents_in_publications[0]?.publications?.name; + let publicationName = + document.documents_in_publications[0]?.publications?.name; return { icons: { @@ -63,25 +65,25 @@ export default async function StandaloneDocumentPage(props: { let resolved = await idResolver.handle.resolve(didOrHandle); if (!resolved) { return ( -
-

Sorry, can't resolve handle.

+ +

Sorry, we can't find this handle!

This may be a glitch on our end. If the issue persists please{" "} send us a note.

-
+ ); } did = resolved; } catch (e) { return ( -
-

Sorry, can't resolve handle.

+ +

Sorry, we can't find this leaflet!

This may be a glitch on our end. If the issue persists please{" "} send us a note.

-
+ ); } } diff --git a/components/ActionBar/Navigation.tsx b/components/ActionBar/Navigation.tsx index 4336c9a3..636dc851 100644 --- a/components/ActionBar/Navigation.tsx +++ b/components/ActionBar/Navigation.tsx @@ -25,7 +25,8 @@ export type navPages = | "discover" | "notifications" | "looseleafs" - | "tag"; + | "tag" + | "profile"; export const DesktopNavigation = (props: { currentPage: navPages; diff --git a/components/ActionBar/Publications.tsx b/components/ActionBar/Publications.tsx index 2423777a..964d3b5e 100644 --- a/components/ActionBar/Publications.tsx +++ b/components/ActionBar/Publications.tsx @@ -193,7 +193,7 @@ export const PubIcon = (props: { return props.record.icon ? (
{icon} {children} diff --git a/components/Avatar.tsx b/components/Avatar.tsx index bc9bb6a3..8efc20a3 100644 --- a/components/Avatar.tsx +++ b/components/Avatar.tsx @@ -3,12 +3,15 @@ import { AccountTiny } from "./Icons/AccountTiny"; export const Avatar = (props: { src: string | undefined; displayName: string | undefined; + className?: string; tiny?: boolean; + large?: boolean; + giant?: boolean; }) => { if (props.src) return ( {; } // Handle didMention inline nodes - if (node.constructor === XmlElement && node.nodeName === "didMention") { + if ( + node.constructor === XmlElement && + node.nodeName === "didMention" + ) { const did = node.getAttribute("did") || ""; const text = node.getAttribute("text") || ""; return ( @@ -77,7 +84,7 @@ export function RenderYJSFragment({ target="_blank" rel="noopener noreferrer" key={index} - className="text-accent-contrast hover:underline cursor-pointer" + className="mention" > {text} @@ -85,7 +92,10 @@ export function RenderYJSFragment({ } // Handle atMention inline nodes - if (node.constructor === XmlElement && node.nodeName === "atMention") { + if ( + node.constructor === XmlElement && + node.nodeName === "atMention" + ) { const atURI = node.getAttribute("atURI") || ""; const text = node.getAttribute("text") || ""; return ( @@ -161,4 +171,3 @@ function attributesToStyle(d: Delta) { return props; } - diff --git a/components/Blocks/TextBlock/schema.ts b/components/Blocks/TextBlock/schema.ts index a82aa7e3..2e6a444c 100644 --- a/components/Blocks/TextBlock/schema.ts +++ b/components/Blocks/TextBlock/schema.ts @@ -147,7 +147,7 @@ let baseSchema = { toDOM(node) { // NOTE: This rendering should match the AtMentionLink component in // components/AtMentionLink.tsx. If you update one, update the other. - let className = "atMention text-accent-contrast"; + let className = "atMention mention"; let aturi = new AtUri(node.attrs.atURI); if (aturi.collection === "pub.leaflet.publication") className += " font-bold"; @@ -168,7 +168,8 @@ let baseSchema = { "img", { src: `/api/pub_icon?at_uri=${encodeURIComponent(node.attrs.atURI)}`, - class: "inline-block w-5 h-5 rounded-full mr-1 align-text-top", + class: + "inline-block w-4 h-4 rounded-full mt-[3px] mr-1 align-text-top", alt: "", width: "16", height: "16", @@ -214,7 +215,7 @@ let baseSchema = { return [ "span", { - class: "didMention text-accent-contrast", + class: "didMention mention", "data-did": node.attrs.did, }, node.attrs.text, diff --git a/components/Icons/ReplyTiny.tsx b/components/Icons/ReplyTiny.tsx index a902e84b..658cc950 100644 --- a/components/Icons/ReplyTiny.tsx +++ b/components/Icons/ReplyTiny.tsx @@ -8,6 +8,7 @@ export const ReplyTiny = (props: Props) => { viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" + {...props} > { +export const Header = (props: { children: React.ReactNode }) => { + let cardBorderHidden = useCardBorderHidden(); let [scrollPos, setScrollPos] = useState(0); useEffect(() => { @@ -22,7 +21,7 @@ export const Header = (props: { } }, []); - let headerBGColor = props.cardBorderHidden + let headerBGColor = !cardBorderHidden ? "var(--bg-leaflet)" : "var(--bg-page)"; @@ -54,13 +53,13 @@ export const Header = (props: { style={ scrollPos < 20 ? { - backgroundColor: props.cardBorderHidden + backgroundColor: !cardBorderHidden ? `rgba(${headerBGColor}, ${scrollPos / 60 + 0.75})` : `rgba(${headerBGColor}, ${scrollPos / 20})`, - paddingLeft: props.cardBorderHidden + paddingLeft: !cardBorderHidden ? "4px" : `calc(${scrollPos / 20}*4px)`, - paddingRight: props.cardBorderHidden + paddingRight: !cardBorderHidden ? "8px" : `calc(${scrollPos / 20}*8px)`, } diff --git a/components/PageLayouts/DashboardLayout.tsx b/components/PageLayouts/DashboardLayout.tsx index 9fcd52df..84d903ab 100644 --- a/components/PageLayouts/DashboardLayout.tsx +++ b/components/PageLayouts/DashboardLayout.tsx @@ -25,6 +25,7 @@ import { updateIdentityInterfaceState } from "actions/updateIdentityInterfaceSta import Link from "next/link"; import { ExternalLinkTiny } from "components/Icons/ExternalLinkTiny"; import { usePreserveScroll } from "src/hooks/usePreserveScroll"; +import { Tab } from "components/Tab"; export type DashboardState = { display?: "grid" | "list"; @@ -133,7 +134,6 @@ export function DashboardLayout< }, >(props: { id: string; - cardBorderHidden: boolean; tabs: T; defaultTab: keyof T; currentPage: navPages; @@ -186,7 +186,7 @@ export function DashboardLayout< > {Object.keys(props.tabs).length <= 1 && !controls ? null : ( <> -
+
{headerState === "default" ? ( <> {Object.keys(props.tabs).length > 1 && ( @@ -355,27 +355,7 @@ const DisplayToggle = (props: { ); }; -function Tab(props: { - name: string; - selected: boolean; - onSelect: () => void; - href?: string; -}) { - return ( -
props.onSelect()} - > - {props.name} - {props.href && } -
- ); -} - -const FilterOptions = (props: { - hasPubs: boolean; - hasArchived: boolean; -}) => { +const FilterOptions = (props: { hasPubs: boolean; hasArchived: boolean }) => { let { filter } = useDashboardState(); let setState = useSetDashboardState(); let filterCount = Object.values(filter).filter(Boolean).length; diff --git a/components/Pages/Page.tsx b/components/Pages/Page.tsx index a6b28246..78ffdbcf 100644 --- a/components/Pages/Page.tsx +++ b/components/Pages/Page.tsx @@ -34,7 +34,6 @@ export function Page(props: { return focusedPageID === props.entityID; }); let pageType = useEntity(props.entityID, "page/type")?.data.value || "doc"; - let cardBorderHidden = useCardBorderHidden(props.entityID); let drawerOpen = useDrawerOpen(props.entityID); return ( @@ -49,7 +48,6 @@ export function Page(props: { }} id={elementId.page(props.entityID).container} drawerOpen={!!drawerOpen} - cardBorderHidden={!!cardBorderHidden} isFocused={isFocused} fullPageScroll={props.fullPageScroll} pageType={pageType} @@ -77,13 +75,13 @@ export const PageWrapper = (props: { id: string; children: React.ReactNode; pageOptions?: React.ReactNode; - cardBorderHidden: boolean; fullPageScroll: boolean; isFocused?: boolean; onClickAction?: (e: React.MouseEvent) => void; pageType: "canvas" | "doc"; drawerOpen: boolean | undefined; }) => { + const cardBorderHidden = useCardBorderHidden(); let { ref } = usePreserveScroll(props.id); return ( // this div wraps the contents AND the page options. @@ -106,13 +104,13 @@ export const PageWrapper = (props: { shrink-0 snap-center overflow-y-scroll ${ - !props.cardBorderHidden && + !cardBorderHidden && `h-full border bg-[rgba(var(--bg-page),var(--bg-page-alpha))] ${props.drawerOpen ? "rounded-l-lg " : "rounded-lg"} ${props.isFocused ? "shadow-md border-border" : "border-border-light"}` } - ${props.cardBorderHidden && "sm:h-[calc(100%+48px)] h-[calc(100%+20px)] sm:-my-6 -my-3 sm:pt-6 pt-3"} + ${cardBorderHidden && "sm:h-[calc(100%+48px)] h-[calc(100%+20px)] sm:-my-6 -my-3 sm:pt-6 pt-3"} ${props.fullPageScroll && "max-w-full "} ${props.pageType === "doc" && !props.fullPageScroll && "w-[10000px] sm:mx-0 max-w-[var(--page-width-units)]"} ${ diff --git a/components/Pages/PageOptions.tsx b/components/Pages/PageOptions.tsx index 2903ce9d..cb91d346 100644 --- a/components/Pages/PageOptions.tsx +++ b/components/Pages/PageOptions.tsx @@ -21,17 +21,16 @@ import { useLeafletPublicationData } from "components/PageSWRDataProvider"; export const PageOptionButton = ({ children, secondary, - cardBorderHidden, className, disabled, ...props }: { children: React.ReactNode; secondary?: boolean; - cardBorderHidden: boolean | undefined; className?: string; disabled?: boolean; } & Omit) => { + const cardBorderHidden = useCardBorderHidden(); return (