a tool for shared writing and social publishing

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 <celine@hyperlink.academy>
Co-authored-by: Jared Pereira <jared@awarm.space>

authored by schlage.town celine Jared Pereira and committed by GitHub 80ea9419 95dc81d1

+1 -3
app/(home-pages)/discover/page.tsx
··· 17 17 return ( 18 18 <DashboardLayout 19 19 id="discover" 20 - cardBorderHidden={false} 21 20 currentPage="discover" 22 21 defaultTab="default" 23 22 actions={null} ··· 32 31 } 33 32 34 33 const DiscoverContent = async (props: { order: string }) => { 35 - const orderValue = 36 - props.order === "popular" ? "popular" : "recentlyUpdated"; 34 + const orderValue = props.order === "popular" ? "popular" : "recentlyUpdated"; 37 35 let { publications, nextCursor } = await getPublications(orderValue); 38 36 39 37 return (
-8
app/(home-pages)/home/HomeLayout.tsx
··· 20 20 useDashboardState, 21 21 } from "components/PageLayouts/DashboardLayout"; 22 22 import { Actions } from "./Actions/Actions"; 23 - import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 24 23 import { GetLeafletDataReturnType } from "app/api/rpc/[command]/get_leaflet_data"; 25 24 import { useState } from "react"; 26 25 import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; ··· 56 55 props.entityID, 57 56 "theme/background-image", 58 57 ); 59 - let cardBorderHidden = !!useCardBorderHidden(props.entityID); 60 58 61 59 let [searchValue, setSearchValue] = useState(""); 62 60 let [debouncedSearchValue, setDebouncedSearchValue] = useState(""); ··· 81 79 return ( 82 80 <DashboardLayout 83 81 id="home" 84 - cardBorderHidden={cardBorderHidden} 85 82 currentPage="home" 86 83 defaultTab="home" 87 84 actions={<Actions />} ··· 101 98 <HomeLeafletList 102 99 titles={props.titles} 103 100 initialFacts={props.initialFacts} 104 - cardBorderHidden={cardBorderHidden} 105 101 searchValue={debouncedSearchValue} 106 102 /> 107 103 ), ··· 117 113 [root_entity: string]: Fact<Attribute>[]; 118 114 }; 119 115 searchValue: string; 120 - cardBorderHidden: boolean; 121 116 }) { 122 117 let { identity } = useIdentityData(); 123 118 let { data: initialFacts } = useSWR( ··· 171 166 searchValue={props.searchValue} 172 167 leaflets={leaflets} 173 168 titles={initialFacts?.titles || {}} 174 - cardBorderHidden={props.cardBorderHidden} 175 169 initialFacts={initialFacts?.facts || {}} 176 170 showPreview 177 171 /> ··· 192 186 [root_entity: string]: Fact<Attribute>[]; 193 187 }; 194 188 searchValue: string; 195 - cardBorderHidden: boolean; 196 189 showPreview?: boolean; 197 190 }) { 198 191 let { identity } = useIdentityData(); ··· 238 231 loggedIn={!!identity} 239 232 display={display} 240 233 added_at={added_at} 241 - cardBorderHidden={props.cardBorderHidden} 242 234 index={index} 243 235 showPreview={props.showPreview} 244 236 isHidden={
+6 -5
app/(home-pages)/home/LeafletList/LeafletListItem.tsx
··· 4 4 import { useState, useRef, useEffect } from "react"; 5 5 import { SpeedyLink } from "components/SpeedyLink"; 6 6 import { useLeafletPublicationStatus } from "components/PageSWRDataProvider"; 7 + import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 7 8 8 9 export const LeafletListItem = (props: { 9 10 archived?: boolean | null; 10 11 loggedIn: boolean; 11 12 display: "list" | "grid"; 12 - cardBorderHidden: boolean; 13 13 added_at: string; 14 14 title?: string; 15 15 index: number; 16 16 isHidden: boolean; 17 17 showPreview?: boolean; 18 18 }) => { 19 + const cardBorderHidden = useCardBorderHidden(); 19 20 const pubStatus = useLeafletPublicationStatus(); 20 21 let [isOnScreen, setIsOnScreen] = useState(props.index < 16 ? true : false); 21 22 let previewRef = useRef<HTMLDivElement | null>(null); ··· 47 48 ref={previewRef} 48 49 className={`relative flex gap-3 w-full 49 50 ${props.isHidden ? "hidden" : "flex"} 50 - ${props.cardBorderHidden ? "" : "px-2 py-1 block-border hover:outline-border relative"}`} 51 + ${cardBorderHidden ? "" : "px-2 py-1 block-border hover:outline-border relative"}`} 51 52 style={{ 52 - backgroundColor: props.cardBorderHidden 53 + backgroundColor: cardBorderHidden 53 54 ? "transparent" 54 55 : "rgba(var(--bg-page), var(--bg-page-alpha))", 55 56 }} ··· 67 68 loggedIn={props.loggedIn} 68 69 /> 69 70 </div> 70 - {props.cardBorderHidden && ( 71 + {cardBorderHidden && ( 71 72 <hr 72 73 className="last:hidden border-border-light" 73 74 style={{ ··· 87 88 ${props.isHidden ? "hidden" : "flex"} 88 89 `} 89 90 style={{ 90 - backgroundColor: props.cardBorderHidden 91 + backgroundColor: cardBorderHidden 91 92 ? "transparent" 92 93 : "rgba(var(--bg-page), var(--bg-page-alpha))", 93 94 }}
+18 -7
app/(home-pages)/home/LeafletList/LeafletPreview.tsx
··· 18 18 const firstPage = useEntity(root, "root/page")[0]; 19 19 const page = firstPage?.data.value || root; 20 20 21 - const cardBorderHidden = useCardBorderHidden(root); 21 + const cardBorderHidden = useEntity(root, "theme/card-border-hidden")?.data 22 + .value; 22 23 const rootBackgroundImage = useEntity(root, "theme/card-background-image"); 23 24 const rootBackgroundRepeat = useEntity( 24 25 root, ··· 49 50 50 51 const contentWrapperClass = `leafletContentWrapper h-full sm:w-48 w-40 mx-auto overflow-clip ${!cardBorderHidden && "border border-border-light border-b-0 rounded-t-md"}`; 51 52 52 - return { root, page, cardBorderHidden, contentWrapperStyle, contentWrapperClass }; 53 + return { 54 + root, 55 + page, 56 + cardBorderHidden, 57 + contentWrapperStyle, 58 + contentWrapperClass, 59 + }; 53 60 } 54 61 55 62 export const LeafletListPreview = (props: { isVisible: boolean }) => { 56 - const { root, page, cardBorderHidden, contentWrapperStyle, contentWrapperClass } = 57 - useLeafletPreviewData(); 63 + const { 64 + root, 65 + page, 66 + cardBorderHidden, 67 + contentWrapperStyle, 68 + contentWrapperClass, 69 + } = useLeafletPreviewData(); 58 70 59 71 return ( 60 72 <Tooltip 61 - open={true} 62 - delayDuration={0} 63 73 side="right" 74 + asChild 64 75 trigger={ 65 - <div className="w-12 h-full py-1"> 76 + <div className="w-12 h-full py-1 z-10"> 66 77 <div className="rounded-md h-full overflow-hidden"> 67 78 <ThemeProvider local entityID={root} className=""> 68 79 <ThemeBackgroundProvider entityID={root}>
-6
app/(home-pages)/looseleafs/LooseleafsLayout.tsx
··· 1 1 "use client"; 2 2 import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; 3 - import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 4 3 import { useState } from "react"; 5 4 import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; 6 5 import { Fact, PermissionToken } from "src/replicache"; ··· 30 29 [searchValue], 31 30 ); 32 31 33 - let cardBorderHidden = !!useCardBorderHidden(props.entityID); 34 32 return ( 35 33 <DashboardLayout 36 34 id="looseleafs" 37 - cardBorderHidden={cardBorderHidden} 38 35 currentPage="looseleafs" 39 36 defaultTab="home" 40 37 actions={<Actions />} ··· 45 42 <LooseleafList 46 43 titles={props.titles} 47 44 initialFacts={props.initialFacts} 48 - cardBorderHidden={cardBorderHidden} 49 45 searchValue={debouncedSearchValue} 50 46 /> 51 47 ), ··· 61 57 [root_entity: string]: Fact<Attribute>[]; 62 58 }; 63 59 searchValue: string; 64 - cardBorderHidden: boolean; 65 60 }) => { 66 61 let { identity } = useIdentityData(); 67 62 let { data: initialFacts } = useSWR( ··· 108 103 searchValue={props.searchValue} 109 104 leaflets={leaflets} 110 105 titles={initialFacts?.titles || {}} 111 - cardBorderHidden={props.cardBorderHidden} 112 106 initialFacts={initialFacts?.facts || {}} 113 107 showPreview 114 108 />
-1
app/(home-pages)/notifications/page.tsx
··· 10 10 return ( 11 11 <DashboardLayout 12 12 id="discover" 13 - cardBorderHidden={true} 14 13 currentPage="notifications" 15 14 defaultTab="default" 16 15 actions={null}
+88
app/(home-pages)/p/[didOrHandle]/PostsContent.tsx
··· 1 + "use client"; 2 + 3 + import { PostListing } from "components/PostListing"; 4 + import type { Post } from "app/(home-pages)/reader/getReaderFeed"; 5 + import type { Cursor } from "./getProfilePosts"; 6 + import { getProfilePosts } from "./getProfilePosts"; 7 + import useSWRInfinite from "swr/infinite"; 8 + import { useEffect, useRef } from "react"; 9 + 10 + export const ProfilePostsContent = (props: { 11 + did: string; 12 + posts: Post[]; 13 + nextCursor: Cursor | null; 14 + }) => { 15 + const getKey = ( 16 + pageIndex: number, 17 + previousPageData: { 18 + posts: Post[]; 19 + nextCursor: Cursor | null; 20 + } | null, 21 + ) => { 22 + // Reached the end 23 + if (previousPageData && !previousPageData.nextCursor) return null; 24 + 25 + // First page, we don't have previousPageData 26 + if (pageIndex === 0) return ["profile-posts", props.did, null] as const; 27 + 28 + // Add the cursor to the key 29 + return ["profile-posts", props.did, previousPageData?.nextCursor] as const; 30 + }; 31 + 32 + const { data, size, setSize, isValidating } = useSWRInfinite( 33 + getKey, 34 + ([_, did, cursor]) => getProfilePosts(did, cursor), 35 + { 36 + fallbackData: [{ posts: props.posts, nextCursor: props.nextCursor }], 37 + revalidateFirstPage: false, 38 + }, 39 + ); 40 + 41 + const loadMoreRef = useRef<HTMLDivElement>(null); 42 + 43 + // Set up intersection observer to load more when trigger element is visible 44 + useEffect(() => { 45 + const observer = new IntersectionObserver( 46 + (entries) => { 47 + if (entries[0].isIntersecting && !isValidating) { 48 + const hasMore = data && data[data.length - 1]?.nextCursor; 49 + if (hasMore) { 50 + setSize(size + 1); 51 + } 52 + } 53 + }, 54 + { threshold: 0.1 }, 55 + ); 56 + 57 + if (loadMoreRef.current) { 58 + observer.observe(loadMoreRef.current); 59 + } 60 + 61 + return () => observer.disconnect(); 62 + }, [data, size, setSize, isValidating]); 63 + 64 + const allPosts = data ? data.flatMap((page) => page.posts) : []; 65 + 66 + if (allPosts.length === 0 && !isValidating) { 67 + return <div className="text-tertiary text-center py-4">No posts yet</div>; 68 + } 69 + 70 + return ( 71 + <div className="flex flex-col gap-3 text-left relative"> 72 + {allPosts.map((post) => ( 73 + <PostListing key={post.documents.uri} {...post} /> 74 + ))} 75 + {/* Trigger element for loading more posts */} 76 + <div 77 + ref={loadMoreRef} 78 + className="absolute bottom-96 left-0 w-full h-px pointer-events-none" 79 + aria-hidden="true" 80 + /> 81 + {isValidating && ( 82 + <div className="text-center text-tertiary py-4"> 83 + Loading more posts... 84 + </div> 85 + )} 86 + </div> 87 + ); 88 + };
+243
app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx
··· 1 + "use client"; 2 + import { Avatar } from "components/Avatar"; 3 + import { AppBskyActorProfile, PubLeafletPublication } from "lexicons/api"; 4 + import { blobRefToSrc } from "src/utils/blobRefToSrc"; 5 + import type { ProfileData } from "./layout"; 6 + import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; 7 + import { colorToString } from "components/ThemeManager/useColorAttribute"; 8 + import { PubIcon } from "components/ActionBar/Publications"; 9 + import { Json } from "supabase/database.types"; 10 + import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 11 + import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 12 + import { SpeedyLink } from "components/SpeedyLink"; 13 + import { ReactNode } from "react"; 14 + import * as linkify from "linkifyjs"; 15 + 16 + export const ProfileHeader = (props: { 17 + profile: ProfileViewDetailed; 18 + publications: { record: Json; uri: string }[]; 19 + popover?: boolean; 20 + }) => { 21 + let profileRecord = props.profile; 22 + const profileUrl = `/p/${props.profile.handle}`; 23 + 24 + const avatarElement = ( 25 + <Avatar 26 + src={profileRecord.avatar} 27 + displayName={profileRecord.displayName} 28 + className="mx-auto mt-3 sm:mt-4" 29 + giant 30 + /> 31 + ); 32 + 33 + const displayNameElement = ( 34 + <h3 className=" px-3 sm:px-4 pt-2 leading-tight"> 35 + {profileRecord.displayName 36 + ? profileRecord.displayName 37 + : `@${props.profile.handle}`} 38 + </h3> 39 + ); 40 + 41 + const handleElement = profileRecord.displayName && ( 42 + <div 43 + className={`text-tertiary ${props.popover ? "text-xs" : "text-sm"} pb-1 italic px-3 sm:px-4 truncate`} 44 + > 45 + @{props.profile.handle} 46 + </div> 47 + ); 48 + 49 + return ( 50 + <div 51 + className={`flex flex-col relative ${props.popover && "text-sm"}`} 52 + id="profile-header" 53 + > 54 + <ProfileLinks handle={props.profile.handle || ""} /> 55 + <div className="flex flex-col"> 56 + <div className="flex flex-col group"> 57 + {props.popover ? ( 58 + <SpeedyLink className={"hover:no-underline!"} href={profileUrl}> 59 + {avatarElement} 60 + </SpeedyLink> 61 + ) : ( 62 + avatarElement 63 + )} 64 + {props.popover ? ( 65 + <SpeedyLink 66 + className={" text-primary group-hover:underline"} 67 + href={profileUrl} 68 + > 69 + {displayNameElement} 70 + </SpeedyLink> 71 + ) : ( 72 + displayNameElement 73 + )} 74 + {props.popover && handleElement ? ( 75 + <SpeedyLink className={"group-hover:underline"} href={profileUrl}> 76 + {handleElement} 77 + </SpeedyLink> 78 + ) : ( 79 + handleElement 80 + )} 81 + </div> 82 + <pre className="text-secondary px-3 sm:px-4 whitespace-pre-wrap"> 83 + {profileRecord.description 84 + ? parseDescription(profileRecord.description) 85 + : null} 86 + </pre> 87 + <div className=" w-full overflow-x-scroll py-3 mb-3 "> 88 + <div 89 + className={`grid grid-flow-col gap-2 mx-auto w-fit px-3 sm:px-4 ${props.popover ? "auto-cols-[164px]" : "auto-cols-[164px] sm:auto-cols-[240px]"}`} 90 + > 91 + {props.publications.map((p) => ( 92 + <PublicationCard 93 + key={p.uri} 94 + record={p.record as PubLeafletPublication.Record} 95 + uri={p.uri} 96 + /> 97 + ))} 98 + </div> 99 + </div> 100 + </div> 101 + </div> 102 + ); 103 + }; 104 + 105 + const ProfileLinks = (props: { handle: string }) => { 106 + return ( 107 + <div className="absolute sm:top-4 top-3 sm:right-4 right-3 flex flex-row gap-2"> 108 + <a 109 + className="text-tertiary hover:text-accent-contrast hover:no-underline!" 110 + href={`https://bsky.app/profile/${props.handle}`} 111 + > 112 + <BlueskyTiny /> 113 + </a> 114 + </div> 115 + ); 116 + }; 117 + const PublicationCard = (props: { 118 + record: PubLeafletPublication.Record; 119 + uri: string; 120 + }) => { 121 + const { record, uri } = props; 122 + const { bgLeaflet, bgPage, primary } = usePubTheme(record.theme); 123 + 124 + return ( 125 + <a 126 + href={`https://${record.base_path}`} 127 + className="border border-border p-2 rounded-lg hover:no-underline! text-primary basis-1/2" 128 + style={{ backgroundColor: `rgb(${colorToString(bgLeaflet, "rgb")})` }} 129 + > 130 + <div 131 + className="rounded-md p-2 flex flex-row gap-2" 132 + style={{ 133 + backgroundColor: record.theme?.showPageBackground 134 + ? `rgb(${colorToString(bgPage, "rgb")})` 135 + : undefined, 136 + }} 137 + > 138 + <PubIcon record={record} uri={uri} /> 139 + <h4 140 + className="truncate min-w-0" 141 + style={{ 142 + color: `rgb(${colorToString(primary, "rgb")})`, 143 + }} 144 + > 145 + {record.name} 146 + </h4> 147 + </div> 148 + </a> 149 + ); 150 + }; 151 + 152 + function parseDescription(description: string): ReactNode[] { 153 + // Find all mentions using regex 154 + const mentionRegex = /@\S+/g; 155 + const mentions: { start: number; end: number; value: string }[] = []; 156 + let mentionMatch; 157 + while ((mentionMatch = mentionRegex.exec(description)) !== null) { 158 + mentions.push({ 159 + start: mentionMatch.index, 160 + end: mentionMatch.index + mentionMatch[0].length, 161 + value: mentionMatch[0], 162 + }); 163 + } 164 + 165 + // Find all URLs using linkifyjs 166 + const links = linkify.find(description).filter((link) => link.type === "url"); 167 + 168 + // Filter out URLs that overlap with mentions (mentions take priority) 169 + const nonOverlappingLinks = links.filter((link) => { 170 + return !mentions.some( 171 + (mention) => 172 + (link.start >= mention.start && link.start < mention.end) || 173 + (link.end > mention.start && link.end <= mention.end) || 174 + (link.start <= mention.start && link.end >= mention.end), 175 + ); 176 + }); 177 + 178 + // Combine into a single sorted list 179 + const allMatches: Array<{ 180 + start: number; 181 + end: number; 182 + value: string; 183 + href: string; 184 + type: "url" | "mention"; 185 + }> = [ 186 + ...nonOverlappingLinks.map((link) => ({ 187 + start: link.start, 188 + end: link.end, 189 + value: link.value, 190 + href: link.href, 191 + type: "url" as const, 192 + })), 193 + ...mentions.map((mention) => ({ 194 + start: mention.start, 195 + end: mention.end, 196 + value: mention.value, 197 + href: `/p/${mention.value.slice(1)}`, 198 + type: "mention" as const, 199 + })), 200 + ].sort((a, b) => a.start - b.start); 201 + 202 + const parts: ReactNode[] = []; 203 + let lastIndex = 0; 204 + let key = 0; 205 + 206 + for (const match of allMatches) { 207 + // Add text before this match 208 + if (match.start > lastIndex) { 209 + parts.push(description.slice(lastIndex, match.start)); 210 + } 211 + 212 + if (match.type === "mention") { 213 + parts.push( 214 + <SpeedyLink key={key++} href={match.href}> 215 + {match.value} 216 + </SpeedyLink>, 217 + ); 218 + } else { 219 + // It's a URL 220 + const urlWithoutProtocol = match.value 221 + .replace(/^https?:\/\//, "") 222 + .replace(/\/+$/, ""); 223 + const displayText = 224 + urlWithoutProtocol.length > 50 225 + ? urlWithoutProtocol.slice(0, 50) + "…" 226 + : urlWithoutProtocol; 227 + parts.push( 228 + <a key={key++} href={match.href} target="_blank" rel="noopener noreferrer"> 229 + {displayText} 230 + </a>, 231 + ); 232 + } 233 + 234 + lastIndex = match.end; 235 + } 236 + 237 + // Add remaining text after last match 238 + if (lastIndex < description.length) { 239 + parts.push(description.slice(lastIndex)); 240 + } 241 + 242 + return parts; 243 + }
+24
app/(home-pages)/p/[didOrHandle]/ProfileLayout.tsx
··· 1 + "use client"; 2 + 3 + import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 4 + 5 + export function ProfileLayout(props: { children: React.ReactNode }) { 6 + let cardBorderHidden = useCardBorderHidden(); 7 + return ( 8 + <div 9 + id="profile-content" 10 + className={` 11 + ${ 12 + cardBorderHidden 13 + ? "" 14 + : "overflow-y-scroll h-full border border-border-light rounded-lg bg-bg-page" 15 + } 16 + max-w-prose mx-auto w-full 17 + flex flex-col 18 + text-center 19 + `} 20 + > 21 + {props.children} 22 + </div> 23 + ); 24 + }
+119
app/(home-pages)/p/[didOrHandle]/ProfileTabs.tsx
··· 1 + "use client"; 2 + 3 + import { SpeedyLink } from "components/SpeedyLink"; 4 + import { useSelectedLayoutSegment } from "next/navigation"; 5 + import { useState, useEffect } from "react"; 6 + import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 7 + 8 + export type ProfileTabType = "posts" | "comments" | "subscriptions"; 9 + 10 + export const ProfileTabs = (props: { didOrHandle: string }) => { 11 + const cardBorderHidden = useCardBorderHidden(); 12 + const segment = useSelectedLayoutSegment(); 13 + const currentTab = (segment || "posts") as ProfileTabType; 14 + const [scrollPosWithinTabContent, setScrollPosWithinTabContent] = useState(0); 15 + const [headerHeight, setHeaderHeight] = useState(0); 16 + useEffect(() => { 17 + let headerHeight = 18 + document.getElementById("profile-header")?.clientHeight || 0; 19 + setHeaderHeight(headerHeight); 20 + 21 + const profileContent = cardBorderHidden 22 + ? document.getElementById("home-content") 23 + : document.getElementById("profile-content"); 24 + const handleScroll = () => { 25 + if (profileContent) { 26 + setScrollPosWithinTabContent( 27 + profileContent.scrollTop - headerHeight > 0 28 + ? profileContent.scrollTop - headerHeight 29 + : 0, 30 + ); 31 + } 32 + }; 33 + 34 + if (profileContent) { 35 + profileContent.addEventListener("scroll", handleScroll); 36 + return () => profileContent.removeEventListener("scroll", handleScroll); 37 + } 38 + }, []); 39 + 40 + const baseUrl = `/p/${props.didOrHandle}`; 41 + const bgColor = cardBorderHidden ? "var(--bg-leaflet)" : "var(--bg-page)"; 42 + 43 + return ( 44 + <div className="flex flex-col w-full sticky top-3 sm:top-4 z-20 sm:px-4 px-3"> 45 + <div 46 + style={ 47 + scrollPosWithinTabContent < 20 48 + ? { 49 + paddingLeft: `calc(${scrollPosWithinTabContent / 20} * 12px )`, 50 + paddingRight: `calc(${scrollPosWithinTabContent / 20} * 12px )`, 51 + } 52 + : { paddingLeft: "12px", paddingRight: "12px" } 53 + } 54 + > 55 + <div 56 + className={` 57 + border rounded-lg 58 + ${scrollPosWithinTabContent > 20 ? "border-border-light" : "border-transparent"} 59 + py-1 60 + w-full `} 61 + style={ 62 + scrollPosWithinTabContent < 20 63 + ? { 64 + backgroundColor: !cardBorderHidden 65 + ? `rgba(${bgColor}, ${scrollPosWithinTabContent / 60 + 0.75})` 66 + : `rgba(${bgColor}, ${scrollPosWithinTabContent / 20})`, 67 + paddingLeft: !cardBorderHidden 68 + ? "4px" 69 + : `calc(${scrollPosWithinTabContent / 20} * 4px)`, 70 + paddingRight: !cardBorderHidden 71 + ? "4px" 72 + : `calc(${scrollPosWithinTabContent / 20} * 4px)`, 73 + } 74 + : { 75 + backgroundColor: `rgb(${bgColor})`, 76 + paddingLeft: "4px", 77 + paddingRight: "4px", 78 + } 79 + } 80 + > 81 + <div className="flex gap-2 justify-between"> 82 + <div className="flex gap-2"> 83 + <TabLink 84 + href={baseUrl} 85 + name="Posts" 86 + selected={currentTab === "posts"} 87 + /> 88 + <TabLink 89 + href={`${baseUrl}/comments`} 90 + name="Comments" 91 + selected={currentTab === "comments"} 92 + /> 93 + </div> 94 + <TabLink 95 + href={`${baseUrl}/subscriptions`} 96 + name="Subscriptions" 97 + selected={currentTab === "subscriptions"} 98 + /> 99 + </div> 100 + </div> 101 + </div> 102 + </div> 103 + ); 104 + }; 105 + 106 + const TabLink = (props: { href: string; name: string; selected: boolean }) => { 107 + return ( 108 + <SpeedyLink 109 + href={props.href} 110 + className={`pubTabs px-1 py-0 flex gap-1 items-center rounded-md hover:cursor-pointer hover:no-underline! ${ 111 + props.selected 112 + ? "text-accent-2 bg-accent-1 font-bold -mb-px" 113 + : "text-tertiary" 114 + }`} 115 + > 116 + {props.name} 117 + </SpeedyLink> 118 + ); 119 + };
+219
app/(home-pages)/p/[didOrHandle]/comments/CommentsContent.tsx
··· 1 + "use client"; 2 + 3 + import { useEffect, useRef, useMemo } from "react"; 4 + import useSWRInfinite from "swr/infinite"; 5 + import { AppBskyActorProfile, AtUri } from "@atproto/api"; 6 + import { PubLeafletComment, PubLeafletDocument } from "lexicons/api"; 7 + import { ReplyTiny } from "components/Icons/ReplyTiny"; 8 + import { Avatar } from "components/Avatar"; 9 + import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/BaseTextBlock"; 10 + import { blobRefToSrc } from "src/utils/blobRefToSrc"; 11 + import { 12 + getProfileComments, 13 + type ProfileComment, 14 + type Cursor, 15 + } from "./getProfileComments"; 16 + import { timeAgo } from "src/utils/timeAgo"; 17 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 18 + 19 + export const ProfileCommentsContent = (props: { 20 + did: string; 21 + comments: ProfileComment[]; 22 + nextCursor: Cursor | null; 23 + }) => { 24 + const getKey = ( 25 + pageIndex: number, 26 + previousPageData: { 27 + comments: ProfileComment[]; 28 + nextCursor: Cursor | null; 29 + } | null, 30 + ) => { 31 + // Reached the end 32 + if (previousPageData && !previousPageData.nextCursor) return null; 33 + 34 + // First page, we don't have previousPageData 35 + if (pageIndex === 0) return ["profile-comments", props.did, null] as const; 36 + 37 + // Add the cursor to the key 38 + return [ 39 + "profile-comments", 40 + props.did, 41 + previousPageData?.nextCursor, 42 + ] as const; 43 + }; 44 + 45 + const { data, size, setSize, isValidating } = useSWRInfinite( 46 + getKey, 47 + ([_, did, cursor]) => getProfileComments(did, cursor), 48 + { 49 + fallbackData: [ 50 + { comments: props.comments, nextCursor: props.nextCursor }, 51 + ], 52 + revalidateFirstPage: false, 53 + }, 54 + ); 55 + 56 + const loadMoreRef = useRef<HTMLDivElement>(null); 57 + 58 + // Set up intersection observer to load more when trigger element is visible 59 + useEffect(() => { 60 + const observer = new IntersectionObserver( 61 + (entries) => { 62 + if (entries[0].isIntersecting && !isValidating) { 63 + const hasMore = data && data[data.length - 1]?.nextCursor; 64 + if (hasMore) { 65 + setSize(size + 1); 66 + } 67 + } 68 + }, 69 + { threshold: 0.1 }, 70 + ); 71 + 72 + if (loadMoreRef.current) { 73 + observer.observe(loadMoreRef.current); 74 + } 75 + 76 + return () => observer.disconnect(); 77 + }, [data, size, setSize, isValidating]); 78 + 79 + const allComments = data ? data.flatMap((page) => page.comments) : []; 80 + 81 + if (allComments.length === 0 && !isValidating) { 82 + return ( 83 + <div className="text-tertiary text-center py-4">No comments yet</div> 84 + ); 85 + } 86 + 87 + return ( 88 + <div className="flex flex-col gap-2 text-left relative"> 89 + {allComments.map((comment) => ( 90 + <CommentItem key={comment.uri} comment={comment} /> 91 + ))} 92 + {/* Trigger element for loading more comments */} 93 + <div 94 + ref={loadMoreRef} 95 + className="absolute bottom-96 left-0 w-full h-px pointer-events-none" 96 + aria-hidden="true" 97 + /> 98 + {isValidating && ( 99 + <div className="text-center text-tertiary py-4"> 100 + Loading more comments... 101 + </div> 102 + )} 103 + </div> 104 + ); 105 + }; 106 + 107 + const CommentItem = ({ comment }: { comment: ProfileComment }) => { 108 + const record = comment.record as PubLeafletComment.Record; 109 + const profile = comment.bsky_profiles?.record as 110 + | AppBskyActorProfile.Record 111 + | undefined; 112 + const displayName = 113 + profile?.displayName || comment.bsky_profiles?.handle || "Unknown"; 114 + 115 + // Get commenter DID from comment URI 116 + const commenterDid = new AtUri(comment.uri).host; 117 + 118 + const isReply = !!record.reply; 119 + 120 + // Get document title 121 + const docData = comment.document?.data as 122 + | PubLeafletDocument.Record 123 + | undefined; 124 + const postTitle = docData?.title || "Untitled"; 125 + 126 + // Get parent comment info for replies 127 + const parentRecord = comment.parentComment?.record as 128 + | PubLeafletComment.Record 129 + | undefined; 130 + const parentProfile = comment.parentComment?.bsky_profiles?.record as 131 + | AppBskyActorProfile.Record 132 + | undefined; 133 + const parentDisplayName = 134 + parentProfile?.displayName || comment.parentComment?.bsky_profiles?.handle; 135 + 136 + // Build direct link to the comment 137 + const commentLink = useMemo(() => { 138 + if (!comment.document) return null; 139 + const docUri = new AtUri(comment.document.uri); 140 + 141 + // Get base URL using getPublicationURL if publication exists, otherwise build path 142 + let baseUrl: string; 143 + if (comment.publication) { 144 + baseUrl = getPublicationURL(comment.publication); 145 + const pubUri = new AtUri(comment.publication.uri); 146 + // If getPublicationURL returns a relative path, append the document rkey 147 + if (baseUrl.startsWith("/")) { 148 + baseUrl = `${baseUrl}/${docUri.rkey}`; 149 + } else { 150 + // For custom domains, append the document rkey 151 + baseUrl = `${baseUrl}/${docUri.rkey}`; 152 + } 153 + } else { 154 + baseUrl = `/lish/${docUri.host}/-/${docUri.rkey}`; 155 + } 156 + 157 + // Build query parameters 158 + const params = new URLSearchParams(); 159 + params.set("interactionDrawer", "comments"); 160 + if (record.onPage) { 161 + params.set("page", record.onPage); 162 + } 163 + 164 + // Use comment URI as hash for direct reference 165 + const commentId = encodeURIComponent(comment.uri); 166 + 167 + return `${baseUrl}?${params.toString()}#${commentId}`; 168 + }, [comment.document, comment.publication, comment.uri, record.onPage]); 169 + 170 + // Get avatar source 171 + const avatarSrc = profile?.avatar?.ref 172 + ? blobRefToSrc(profile.avatar.ref, commenterDid) 173 + : undefined; 174 + 175 + return ( 176 + <div id={comment.uri} className="w-full flex flex-col text-left mb-8"> 177 + <div className="flex gap-2 w-full"> 178 + <Avatar src={avatarSrc} displayName={displayName} /> 179 + <div className="flex flex-col w-full min-w-0 grow"> 180 + <div className="flex flex-row gap-2"> 181 + <div className="text-tertiary text-sm truncate"> 182 + <span className="font-bold text-secondary">{displayName}</span>{" "} 183 + {isReply ? "replied" : "commented"} on{" "} 184 + {commentLink ? ( 185 + <a 186 + href={commentLink} 187 + className="italic text-accent-contrast hover:underline" 188 + > 189 + {postTitle} 190 + </a> 191 + ) : ( 192 + <span className="italic text-accent-contrast">{postTitle}</span> 193 + )} 194 + </div> 195 + </div> 196 + {isReply && parentRecord && ( 197 + <div className="text-xs text-tertiary flex flex-row gap-2 w-full my-0.5 items-center"> 198 + <ReplyTiny className="shrink-0 scale-75" /> 199 + {parentDisplayName && ( 200 + <div className="font-bold shrink-0">{parentDisplayName}</div> 201 + )} 202 + <div className="grow truncate">{parentRecord.plaintext}</div> 203 + </div> 204 + )} 205 + <pre 206 + style={{ wordBreak: "break-word" }} 207 + className="whitespace-pre-wrap text-secondary" 208 + > 209 + <BaseTextBlock 210 + index={[]} 211 + plaintext={record.plaintext} 212 + facets={record.facets} 213 + /> 214 + </pre> 215 + </div> 216 + </div> 217 + </div> 218 + ); 219 + };
+133
app/(home-pages)/p/[didOrHandle]/comments/getProfileComments.ts
··· 1 + "use server"; 2 + 3 + import { supabaseServerClient } from "supabase/serverClient"; 4 + import { Json } from "supabase/database.types"; 5 + import { PubLeafletComment } from "lexicons/api"; 6 + 7 + export type Cursor = { 8 + indexed_at: string; 9 + uri: string; 10 + }; 11 + 12 + export type ProfileComment = { 13 + uri: string; 14 + record: Json; 15 + indexed_at: string; 16 + bsky_profiles: { record: Json; handle: string | null } | null; 17 + document: { 18 + uri: string; 19 + data: Json; 20 + } | null; 21 + publication: { 22 + uri: string; 23 + record: Json; 24 + } | null; 25 + // For replies, include the parent comment info 26 + parentComment: { 27 + uri: string; 28 + record: Json; 29 + bsky_profiles: { record: Json; handle: string | null } | null; 30 + } | null; 31 + }; 32 + 33 + export async function getProfileComments( 34 + did: string, 35 + cursor?: Cursor | null, 36 + ): Promise<{ comments: ProfileComment[]; nextCursor: Cursor | null }> { 37 + const limit = 20; 38 + 39 + let query = supabaseServerClient 40 + .from("comments_on_documents") 41 + .select( 42 + `*, 43 + bsky_profiles(record, handle), 44 + documents(uri, data, documents_in_publications(publications(*)))`, 45 + ) 46 + .eq("profile", did) 47 + .order("indexed_at", { ascending: false }) 48 + .order("uri", { ascending: false }) 49 + .limit(limit); 50 + 51 + if (cursor) { 52 + query = query.or( 53 + `indexed_at.lt.${cursor.indexed_at},and(indexed_at.eq.${cursor.indexed_at},uri.lt.${cursor.uri})`, 54 + ); 55 + } 56 + 57 + const { data: rawComments } = await query; 58 + 59 + if (!rawComments || rawComments.length === 0) { 60 + return { comments: [], nextCursor: null }; 61 + } 62 + 63 + // Collect parent comment URIs for replies 64 + const parentUris = rawComments 65 + .map((c) => (c.record as PubLeafletComment.Record).reply?.parent) 66 + .filter((uri): uri is string => !!uri); 67 + 68 + // Fetch parent comments if there are any replies 69 + let parentCommentsMap = new Map< 70 + string, 71 + { 72 + uri: string; 73 + record: Json; 74 + bsky_profiles: { record: Json; handle: string | null } | null; 75 + } 76 + >(); 77 + 78 + if (parentUris.length > 0) { 79 + const { data: parentComments } = await supabaseServerClient 80 + .from("comments_on_documents") 81 + .select(`uri, record, bsky_profiles(record, handle)`) 82 + .in("uri", parentUris); 83 + 84 + if (parentComments) { 85 + for (const pc of parentComments) { 86 + parentCommentsMap.set(pc.uri, { 87 + uri: pc.uri, 88 + record: pc.record, 89 + bsky_profiles: pc.bsky_profiles, 90 + }); 91 + } 92 + } 93 + } 94 + 95 + // Transform to ProfileComment format 96 + const comments: ProfileComment[] = rawComments.map((comment) => { 97 + const record = comment.record as PubLeafletComment.Record; 98 + const doc = comment.documents; 99 + const pub = doc?.documents_in_publications?.[0]?.publications; 100 + 101 + return { 102 + uri: comment.uri, 103 + record: comment.record, 104 + indexed_at: comment.indexed_at, 105 + bsky_profiles: comment.bsky_profiles, 106 + document: doc 107 + ? { 108 + uri: doc.uri, 109 + data: doc.data, 110 + } 111 + : null, 112 + publication: pub 113 + ? { 114 + uri: pub.uri, 115 + record: pub.record, 116 + } 117 + : null, 118 + parentComment: record.reply?.parent 119 + ? parentCommentsMap.get(record.reply.parent) || null 120 + : null, 121 + }; 122 + }); 123 + 124 + const nextCursor = 125 + comments.length === limit 126 + ? { 127 + indexed_at: comments[comments.length - 1].indexed_at, 128 + uri: comments[comments.length - 1].uri, 129 + } 130 + : null; 131 + 132 + return { comments, nextCursor }; 133 + }
+28
app/(home-pages)/p/[didOrHandle]/comments/page.tsx
··· 1 + import { idResolver } from "app/(home-pages)/reader/idResolver"; 2 + import { getProfileComments } from "./getProfileComments"; 3 + import { ProfileCommentsContent } from "./CommentsContent"; 4 + 5 + export default async function ProfileCommentsPage(props: { 6 + params: Promise<{ didOrHandle: string }>; 7 + }) { 8 + let params = await props.params; 9 + let didOrHandle = decodeURIComponent(params.didOrHandle); 10 + 11 + // Resolve handle to DID if necessary 12 + let did = didOrHandle; 13 + if (!didOrHandle.startsWith("did:")) { 14 + let resolved = await idResolver.handle.resolve(didOrHandle); 15 + if (!resolved) return null; 16 + did = resolved; 17 + } 18 + 19 + const { comments, nextCursor } = await getProfileComments(did); 20 + 21 + return ( 22 + <ProfileCommentsContent 23 + did={did} 24 + comments={comments} 25 + nextCursor={nextCursor} 26 + /> 27 + ); 28 + }
+95
app/(home-pages)/p/[didOrHandle]/getProfilePosts.ts
··· 1 + "use server"; 2 + 3 + import { supabaseServerClient } from "supabase/serverClient"; 4 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 5 + import type { Post } from "app/(home-pages)/reader/getReaderFeed"; 6 + 7 + export type Cursor = { 8 + indexed_at: string; 9 + uri: string; 10 + }; 11 + 12 + export async function getProfilePosts( 13 + did: string, 14 + cursor?: Cursor | null, 15 + ): Promise<{ posts: Post[]; nextCursor: Cursor | null }> { 16 + const limit = 20; 17 + 18 + let query = supabaseServerClient 19 + .from("documents") 20 + .select( 21 + `*, 22 + comments_on_documents(count), 23 + document_mentions_in_bsky(count), 24 + documents_in_publications(publications(*))`, 25 + ) 26 + .like("uri", `at://${did}/%`) 27 + .order("indexed_at", { ascending: false }) 28 + .order("uri", { ascending: false }) 29 + .limit(limit); 30 + 31 + if (cursor) { 32 + query = query.or( 33 + `indexed_at.lt.${cursor.indexed_at},and(indexed_at.eq.${cursor.indexed_at},uri.lt.${cursor.uri})`, 34 + ); 35 + } 36 + 37 + let [{ data: docs }, { data: pubs }, { data: profile }] = await Promise.all([ 38 + query, 39 + supabaseServerClient 40 + .from("publications") 41 + .select("*") 42 + .eq("identity_did", did), 43 + supabaseServerClient 44 + .from("bsky_profiles") 45 + .select("handle") 46 + .eq("did", did) 47 + .single(), 48 + ]); 49 + 50 + // Build a map of publications for quick lookup 51 + let pubMap = new Map<string, NonNullable<typeof pubs>[number]>(); 52 + for (let pub of pubs || []) { 53 + pubMap.set(pub.uri, pub); 54 + } 55 + 56 + // Transform data to Post[] format 57 + let handle = profile?.handle ? `@${profile.handle}` : null; 58 + let posts: Post[] = []; 59 + 60 + for (let doc of docs || []) { 61 + let pubFromDoc = doc.documents_in_publications?.[0]?.publications; 62 + let pub = pubFromDoc ? pubMap.get(pubFromDoc.uri) || pubFromDoc : null; 63 + 64 + let post: Post = { 65 + author: handle, 66 + documents: { 67 + data: doc.data, 68 + uri: doc.uri, 69 + indexed_at: doc.indexed_at, 70 + comments_on_documents: doc.comments_on_documents, 71 + document_mentions_in_bsky: doc.document_mentions_in_bsky, 72 + }, 73 + }; 74 + 75 + if (pub) { 76 + post.publication = { 77 + href: getPublicationURL(pub), 78 + pubRecord: pub.record, 79 + uri: pub.uri, 80 + }; 81 + } 82 + 83 + posts.push(post); 84 + } 85 + 86 + const nextCursor = 87 + posts.length === limit 88 + ? { 89 + indexed_at: posts[posts.length - 1].documents.indexed_at, 90 + uri: posts[posts.length - 1].documents.uri, 91 + } 92 + : null; 93 + 94 + return { posts, nextCursor }; 95 + }
+109
app/(home-pages)/p/[didOrHandle]/layout.tsx
··· 1 + import { idResolver } from "app/(home-pages)/reader/idResolver"; 2 + import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 3 + import { supabaseServerClient } from "supabase/serverClient"; 4 + import { Json } from "supabase/database.types"; 5 + import { ProfileHeader } from "./ProfileHeader"; 6 + import { ProfileTabs } from "./ProfileTabs"; 7 + import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; 8 + import { ProfileLayout } from "./ProfileLayout"; 9 + import { Agent } from "@atproto/api"; 10 + import { get_profile_data } from "app/api/rpc/[command]/get_profile_data"; 11 + import { Metadata } from "next"; 12 + 13 + export async function generateMetadata(props: { 14 + params: Promise<{ didOrHandle: string }>; 15 + }): Promise<Metadata> { 16 + let params = await props.params; 17 + let didOrHandle = decodeURIComponent(params.didOrHandle); 18 + 19 + let did = didOrHandle; 20 + if (!didOrHandle.startsWith("did:")) { 21 + let resolved = await idResolver.handle.resolve(didOrHandle); 22 + if (!resolved) return { title: "Profile - Leaflet" }; 23 + did = resolved; 24 + } 25 + 26 + let profileData = await get_profile_data.handler( 27 + { didOrHandle: did }, 28 + { supabase: supabaseServerClient }, 29 + ); 30 + let { profile } = profileData.result; 31 + 32 + if (!profile) return { title: "Profile - Leaflet" }; 33 + 34 + const displayName = profile.displayName; 35 + const handle = profile.handle; 36 + 37 + const title = displayName 38 + ? `${displayName} (@${handle}) - Leaflet` 39 + : `@${handle} - Leaflet`; 40 + 41 + return { title }; 42 + } 43 + 44 + export default async function ProfilePageLayout(props: { 45 + params: Promise<{ didOrHandle: string }>; 46 + children: React.ReactNode; 47 + }) { 48 + let params = await props.params; 49 + let didOrHandle = decodeURIComponent(params.didOrHandle); 50 + 51 + // Resolve handle to DID if necessary 52 + let did = didOrHandle; 53 + 54 + if (!didOrHandle.startsWith("did:")) { 55 + let resolved = await idResolver.handle.resolve(didOrHandle); 56 + if (!resolved) { 57 + return ( 58 + <NotFoundLayout> 59 + <p className="font-bold">Sorry, can&apos;t resolve handle!</p> 60 + <p> 61 + This may be a glitch on our end. If the issue persists please{" "} 62 + <a href="mailto:contact@leaflet.pub">send us a note</a>. 63 + </p> 64 + </NotFoundLayout> 65 + ); 66 + } 67 + did = resolved; 68 + } 69 + let profileData = await get_profile_data.handler( 70 + { didOrHandle: did }, 71 + { supabase: supabaseServerClient }, 72 + ); 73 + let { publications, profile } = profileData.result; 74 + 75 + if (!profile) return null; 76 + 77 + return ( 78 + <DashboardLayout 79 + id="profile" 80 + defaultTab="default" 81 + currentPage="profile" 82 + actions={null} 83 + tabs={{ 84 + default: { 85 + controls: null, 86 + content: ( 87 + <ProfileLayout> 88 + <ProfileHeader 89 + profile={profile} 90 + publications={publications || []} 91 + /> 92 + <ProfileTabs didOrHandle={params.didOrHandle} /> 93 + <div className="h-full pt-3 pb-4 px-3 sm:px-4 flex flex-col"> 94 + {props.children} 95 + </div> 96 + </ProfileLayout> 97 + ), 98 + }, 99 + }} 100 + /> 101 + ); 102 + } 103 + 104 + export type ProfileData = { 105 + did: string; 106 + handle: string | null; 107 + indexed_at: string; 108 + record: Json; 109 + };
+24
app/(home-pages)/p/[didOrHandle]/page.tsx
··· 1 + import { idResolver } from "app/(home-pages)/reader/idResolver"; 2 + import { getProfilePosts } from "./getProfilePosts"; 3 + import { ProfilePostsContent } from "./PostsContent"; 4 + 5 + export default async function ProfilePostsPage(props: { 6 + params: Promise<{ didOrHandle: string }>; 7 + }) { 8 + let params = await props.params; 9 + let didOrHandle = decodeURIComponent(params.didOrHandle); 10 + 11 + // Resolve handle to DID if necessary 12 + let did = didOrHandle; 13 + if (!didOrHandle.startsWith("did:")) { 14 + let resolved = await idResolver.handle.resolve(didOrHandle); 15 + if (!resolved) return null; 16 + did = resolved; 17 + } 18 + 19 + const { posts, nextCursor } = await getProfilePosts(did); 20 + 21 + return ( 22 + <ProfilePostsContent did={did} posts={posts} nextCursor={nextCursor} /> 23 + ); 24 + }
+103
app/(home-pages)/p/[didOrHandle]/subscriptions/SubscriptionsContent.tsx
··· 1 + "use client"; 2 + 3 + import { useEffect, useRef } from "react"; 4 + import useSWRInfinite from "swr/infinite"; 5 + import { PubListing } from "app/(home-pages)/discover/PubListing"; 6 + import { 7 + getSubscriptions, 8 + type PublicationSubscription, 9 + } from "app/(home-pages)/reader/getSubscriptions"; 10 + import { Cursor } from "app/(home-pages)/reader/getReaderFeed"; 11 + 12 + export const ProfileSubscriptionsContent = (props: { 13 + did: string; 14 + subscriptions: PublicationSubscription[]; 15 + nextCursor: Cursor | null; 16 + }) => { 17 + const getKey = ( 18 + pageIndex: number, 19 + previousPageData: { 20 + subscriptions: PublicationSubscription[]; 21 + nextCursor: Cursor | null; 22 + } | null, 23 + ) => { 24 + // Reached the end 25 + if (previousPageData && !previousPageData.nextCursor) return null; 26 + 27 + // First page, we don't have previousPageData 28 + if (pageIndex === 0) 29 + return ["profile-subscriptions", props.did, null] as const; 30 + 31 + // Add the cursor to the key 32 + return [ 33 + "profile-subscriptions", 34 + props.did, 35 + previousPageData?.nextCursor, 36 + ] as const; 37 + }; 38 + 39 + const { data, size, setSize, isValidating } = useSWRInfinite( 40 + getKey, 41 + ([_, did, cursor]) => getSubscriptions(did, cursor), 42 + { 43 + fallbackData: [ 44 + { subscriptions: props.subscriptions, nextCursor: props.nextCursor }, 45 + ], 46 + revalidateFirstPage: false, 47 + }, 48 + ); 49 + 50 + const loadMoreRef = useRef<HTMLDivElement>(null); 51 + 52 + // Set up intersection observer to load more when trigger element is visible 53 + useEffect(() => { 54 + const observer = new IntersectionObserver( 55 + (entries) => { 56 + if (entries[0].isIntersecting && !isValidating) { 57 + const hasMore = data && data[data.length - 1]?.nextCursor; 58 + if (hasMore) { 59 + setSize(size + 1); 60 + } 61 + } 62 + }, 63 + { threshold: 0.1 }, 64 + ); 65 + 66 + if (loadMoreRef.current) { 67 + observer.observe(loadMoreRef.current); 68 + } 69 + 70 + return () => observer.disconnect(); 71 + }, [data, size, setSize, isValidating]); 72 + 73 + const allSubscriptions = data 74 + ? data.flatMap((page) => page.subscriptions) 75 + : []; 76 + 77 + if (allSubscriptions.length === 0 && !isValidating) { 78 + return ( 79 + <div className="text-tertiary text-center py-4">No subscriptions yet</div> 80 + ); 81 + } 82 + 83 + return ( 84 + <div className="relative"> 85 + <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 gap-3"> 86 + {allSubscriptions.map((sub) => ( 87 + <PubListing key={sub.uri} {...sub} /> 88 + ))} 89 + </div> 90 + {/* Trigger element for loading more subscriptions */} 91 + <div 92 + ref={loadMoreRef} 93 + className="absolute bottom-96 left-0 w-full h-px pointer-events-none" 94 + aria-hidden="true" 95 + /> 96 + {isValidating && ( 97 + <div className="text-center text-tertiary py-4"> 98 + Loading more subscriptions... 99 + </div> 100 + )} 101 + </div> 102 + ); 103 + };
+28
app/(home-pages)/p/[didOrHandle]/subscriptions/page.tsx
··· 1 + import { idResolver } from "app/(home-pages)/reader/idResolver"; 2 + import { getSubscriptions } from "app/(home-pages)/reader/getSubscriptions"; 3 + import { ProfileSubscriptionsContent } from "./SubscriptionsContent"; 4 + 5 + export default async function ProfileSubscriptionsPage(props: { 6 + params: Promise<{ didOrHandle: string }>; 7 + }) { 8 + const params = await props.params; 9 + const didOrHandle = decodeURIComponent(params.didOrHandle); 10 + 11 + // Resolve handle to DID if necessary 12 + let did = didOrHandle; 13 + if (!didOrHandle.startsWith("did:")) { 14 + const resolved = await idResolver.handle.resolve(didOrHandle); 15 + if (!resolved) return null; 16 + did = resolved; 17 + } 18 + 19 + const { subscriptions, nextCursor } = await getSubscriptions(did); 20 + 21 + return ( 22 + <ProfileSubscriptionsContent 23 + did={did} 24 + subscriptions={subscriptions} 25 + nextCursor={nextCursor} 26 + /> 27 + ); 28 + }
+1 -1
app/(home-pages)/reader/SubscriptionsContent.tsx
··· 32 32 33 33 const { data, error, size, setSize, isValidating } = useSWRInfinite( 34 34 getKey, 35 - ([_, cursor]) => getSubscriptions(cursor), 35 + ([_, cursor]) => getSubscriptions(null, cursor), 36 36 { 37 37 fallbackData: [ 38 38 { subscriptions: props.publications, nextCursor: props.nextCursor },
+1 -1
app/(home-pages)/reader/getReaderFeed.ts
··· 83 83 84 84 export type Post = { 85 85 author: string | null; 86 - publication: { 86 + publication?: { 87 87 href: string; 88 88 pubRecord: Json; 89 89 uri: string;
+13 -4
app/(home-pages)/reader/getSubscriptions.ts
··· 8 8 import { idResolver } from "./idResolver"; 9 9 import { Cursor } from "./getReaderFeed"; 10 10 11 - export async function getSubscriptions(cursor?: Cursor | null): Promise<{ 11 + export async function getSubscriptions( 12 + did?: string | null, 13 + cursor?: Cursor | null, 14 + ): Promise<{ 12 15 nextCursor: null | Cursor; 13 16 subscriptions: PublicationSubscription[]; 14 17 }> { 15 - let auth_res = await getIdentityData(); 16 - if (!auth_res?.atp_did) return { subscriptions: [], nextCursor: null }; 18 + // If no DID provided, use logged-in user's DID 19 + let identity = did; 20 + if (!identity) { 21 + const auth_res = await getIdentityData(); 22 + if (!auth_res?.atp_did) return { subscriptions: [], nextCursor: null }; 23 + identity = auth_res.atp_did; 24 + } 25 + 17 26 let query = supabaseServerClient 18 27 .from("publication_subscriptions") 19 28 .select(`*, publications(*, documents_in_publications(*, documents(*)))`) ··· 25 34 }) 26 35 .limit(1, { referencedTable: "publications.documents_in_publications" }) 27 36 .limit(25) 28 - .eq("identity", auth_res.atp_did); 37 + .eq("identity", identity); 29 38 30 39 if (cursor) { 31 40 query = query.or(
-1
app/(home-pages)/reader/page.tsx
··· 12 12 return ( 13 13 <DashboardLayout 14 14 id="reader" 15 - cardBorderHidden={false} 16 15 currentPage="reader" 17 16 defaultTab="Read" 18 17 actions={null}
+9 -1
app/(home-pages)/tag/[tag]/page.tsx
··· 3 3 import { PostListing } from "components/PostListing"; 4 4 import { getDocumentsByTag } from "./getDocumentsByTag"; 5 5 import { TagTiny } from "components/Icons/TagTiny"; 6 + import { Metadata } from "next"; 7 + 8 + export async function generateMetadata(props: { 9 + params: Promise<{ tag: string }>; 10 + }): Promise<Metadata> { 11 + const params = await props.params; 12 + const decodedTag = decodeURIComponent(params.tag); 13 + return { title: `${decodedTag} - Leaflet` }; 14 + } 6 15 7 16 export default async function TagPage(props: { 8 17 params: Promise<{ tag: string }>; ··· 14 23 return ( 15 24 <DashboardLayout 16 25 id="tag" 17 - cardBorderHidden={false} 18 26 currentPage="tag" 19 27 defaultTab="default" 20 28 actions={null}
+69
app/api/rpc/[command]/get_profile_data.ts
··· 1 + import { z } from "zod"; 2 + import { makeRoute } from "../lib"; 3 + import type { Env } from "./route"; 4 + import { idResolver } from "app/(home-pages)/reader/idResolver"; 5 + import { supabaseServerClient } from "supabase/serverClient"; 6 + import { Agent } from "@atproto/api"; 7 + import { getIdentityData } from "actions/getIdentityData"; 8 + import { createOauthClient } from "src/atproto-oauth"; 9 + 10 + export type GetProfileDataReturnType = Awaited< 11 + ReturnType<(typeof get_profile_data)["handler"]> 12 + >; 13 + 14 + export const get_profile_data = makeRoute({ 15 + route: "get_profile_data", 16 + input: z.object({ 17 + didOrHandle: z.string(), 18 + }), 19 + handler: async ({ didOrHandle }, { supabase }: Pick<Env, "supabase">) => { 20 + // Resolve handle to DID if necessary 21 + let did = didOrHandle; 22 + 23 + if (!didOrHandle.startsWith("did:")) { 24 + const resolved = await idResolver.handle.resolve(didOrHandle); 25 + if (!resolved) { 26 + throw new Error("Could not resolve handle to DID"); 27 + } 28 + did = resolved; 29 + } 30 + let agent; 31 + let authed_identity = await getIdentityData(); 32 + if (authed_identity?.atp_did) { 33 + try { 34 + const oauthClient = await createOauthClient(); 35 + let credentialSession = await oauthClient.restore( 36 + authed_identity.atp_did, 37 + ); 38 + agent = new Agent(credentialSession); 39 + } catch (e) { 40 + agent = new Agent({ 41 + service: "https://public.api.bsky.app", 42 + }); 43 + } 44 + } else { 45 + agent = new Agent({ 46 + service: "https://public.api.bsky.app", 47 + }); 48 + } 49 + 50 + let profileReq = agent.app.bsky.actor.getProfile({ actor: did }); 51 + 52 + let publicationsReq = supabase 53 + .from("publications") 54 + .select("*") 55 + .eq("identity_did", did); 56 + 57 + let [{ data: profile }, { data: publications }] = await Promise.all([ 58 + profileReq, 59 + publicationsReq, 60 + ]); 61 + 62 + return { 63 + result: { 64 + profile, 65 + publications: publications || [], 66 + }, 67 + }; 68 + }, 69 + });
+2
app/api/rpc/[command]/route.ts
··· 13 13 import { get_publication_data } from "./get_publication_data"; 14 14 import { search_publication_names } from "./search_publication_names"; 15 15 import { search_publication_documents } from "./search_publication_documents"; 16 + import { get_profile_data } from "./get_profile_data"; 16 17 17 18 let supabase = createClient<Database>( 18 19 process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, ··· 39 40 get_publication_data, 40 41 search_publication_names, 41 42 search_publication_documents, 43 + get_profile_data, 42 44 ]; 43 45 export async function POST( 44 46 req: Request,
+22 -4
app/globals.css
··· 296 296 @apply py-[1.5px]; 297 297 } 298 298 299 - /* Underline mention nodes when selected in ProseMirror */ 299 + .ProseMirror:focus-within .selection-highlight { 300 + background-color: transparent; 301 + } 302 + 300 303 .ProseMirror .atMention.ProseMirror-selectednode, 301 304 .ProseMirror .didMention.ProseMirror-selectednode { 302 - text-decoration: underline; 305 + @apply text-accent-contrast; 306 + @apply px-0.5; 307 + @apply -mx-[3px]; /* extra px to account for the border*/ 308 + @apply -my-px; /*to account for the border*/ 309 + @apply rounded-[4px]; 310 + @apply box-decoration-clone; 311 + background-color: rgba(var(--accent-contrast), 0.2); 312 + border: 1px solid rgba(var(--accent-contrast), 1); 303 313 } 304 314 305 - .ProseMirror:focus-within .selection-highlight { 306 - background-color: transparent; 315 + .mention { 316 + @apply cursor-pointer; 317 + @apply text-accent-contrast; 318 + @apply px-0.5; 319 + @apply -mx-[3px]; 320 + @apply -my-px; /*to account for the border*/ 321 + @apply rounded-[4px]; 322 + @apply box-decoration-clone; 323 + background-color: rgba(var(--accent-contrast), 0.2); 324 + border: 1px solid transparent; 307 325 } 308 326 309 327 .multiselected:focus-within .selection-highlight {
+21
app/lish/[did]/[publication]/PublicationAuthor.tsx
··· 1 + "use client"; 2 + import { ProfilePopover } from "components/ProfilePopover"; 3 + 4 + export const PublicationAuthor = (props: { 5 + did: string; 6 + displayName?: string; 7 + handle: string; 8 + }) => { 9 + return ( 10 + <p className="italic text-tertiary sm:text-base text-sm"> 11 + <ProfilePopover 12 + didOrHandle={props.did} 13 + trigger={ 14 + <span className="hover:underline"> 15 + <strong>by {props.displayName}</strong> @{props.handle} 16 + </span> 17 + } 18 + /> 19 + </p> 20 + ); 21 + };
+10 -13
app/lish/[did]/[publication]/[rkey]/BaseTextBlock.tsx
··· 2 2 import { PubLeafletRichtextFacet } from "lexicons/api"; 3 3 import { didToBlueskyUrl } from "src/utils/mentionUtils"; 4 4 import { AtMentionLink } from "components/AtMentionLink"; 5 + import { ProfilePopover } from "components/ProfilePopover"; 5 6 6 7 type Facet = PubLeafletRichtextFacet.Main; 7 8 export function BaseTextBlock(props: { ··· 27 28 let isDidMention = segment.facet?.find( 28 29 PubLeafletRichtextFacet.isDidMention, 29 30 ); 30 - let isAtMention = segment.facet?.find( 31 - PubLeafletRichtextFacet.isAtMention, 32 - ); 31 + let isAtMention = segment.facet?.find(PubLeafletRichtextFacet.isAtMention); 33 32 let isUnderline = segment.facet?.find(PubLeafletRichtextFacet.isUnderline); 34 33 let isItalic = segment.facet?.find(PubLeafletRichtextFacet.isItalic); 35 34 let isHighlighted = segment.facet?.find( ··· 45 44 ${isHighlighted ? "highlight bg-highlight-1" : ""}`.replaceAll("\n", " "); 46 45 47 46 // Split text by newlines and insert <br> tags 48 - const textParts = segment.text.split('\n'); 47 + const textParts = segment.text.split("\n"); 49 48 const renderedText = textParts.flatMap((part, i) => 50 - i < textParts.length - 1 ? [part, <br key={`br-${counter}-${i}`} />] : [part] 49 + i < textParts.length - 1 50 + ? [part, <br key={`br-${counter}-${i}`} />] 51 + : [part], 51 52 ); 52 53 53 54 if (isCode) { ··· 58 59 ); 59 60 } else if (isDidMention) { 60 61 children.push( 61 - <a 62 + <ProfilePopover 62 63 key={counter} 63 - href={didToBlueskyUrl(isDidMention.did)} 64 - className={`text-accent-contrast hover:underline cursor-pointer ${className}`} 65 - target="_blank" 66 - rel="noopener noreferrer" 67 - > 68 - {renderedText} 69 - </a>, 64 + didOrHandle={isDidMention.did} 65 + trigger={<span className="mention">{renderedText}</span>} 66 + />, 70 67 ); 71 68 } else if (isAtMention) { 72 69 children.push(
-1
app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
··· 57 57 <PageWrapper 58 58 pageType="canvas" 59 59 fullPageScroll={fullPageScroll} 60 - cardBorderHidden={!hasPageBackground} 61 60 id={pageId ? `post-page-${pageId}` : "post-page"} 62 61 drawerOpen={ 63 62 !!drawer && (pageId ? drawer.pageId === pageId : !drawer.pageId)
+5 -2
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx
··· 1 - import { UnicodeString } from "@atproto/api"; 1 + import { AtUri, UnicodeString } from "@atproto/api"; 2 2 import { autolink } from "components/Blocks/TextBlock/autolink-plugin"; 3 3 import { multiBlockSchema } from "components/Blocks/TextBlock/schema"; 4 4 import { PubLeafletRichtextFacet } from "lexicons/api"; ··· 196 196 { 197 197 record: comment.record, 198 198 uri: comment.uri, 199 - bsky_profiles: { record: comment.profile as Json }, 199 + bsky_profiles: { 200 + record: comment.profile as Json, 201 + did: new AtUri(comment.uri).host, 202 + }, 200 203 }, 201 204 ], 202 205 }));
+15 -99
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx
··· 18 18 import { QuoteContent } from "../Quotes"; 19 19 import { timeAgo } from "src/utils/timeAgo"; 20 20 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 21 + import { ProfilePopover } from "components/ProfilePopover"; 21 22 22 23 export type Comment = { 23 24 record: Json; 24 25 uri: string; 25 - bsky_profiles: { record: Json } | null; 26 + bsky_profiles: { record: Json; did: string } | null; 26 27 }; 27 28 export function Comments(props: { 28 29 document_uri: string; ··· 109 110 document: string; 110 111 comment: Comment; 111 112 comments: Comment[]; 112 - profile?: AppBskyActorProfile.Record; 113 + profile: AppBskyActorProfile.Record; 113 114 record: PubLeafletComment.Record; 114 115 pageId?: string; 115 116 }) => { 117 + const did = props.comment.bsky_profiles?.did; 118 + 116 119 return ( 117 - <div className="comment"> 120 + <div id={props.comment.uri} className="comment"> 118 121 <div className="flex gap-2"> 119 - {props.profile && ( 120 - <ProfilePopover profile={props.profile} comment={props.comment.uri} /> 122 + {did && ( 123 + <ProfilePopover 124 + didOrHandle={did} 125 + trigger={ 126 + <div className="text-sm text-tertiary font-bold hover:underline"> 127 + {props.profile.displayName} 128 + </div> 129 + } 130 + /> 121 131 )} 122 - <DatePopover date={props.record.createdAt} /> 123 132 </div> 124 133 {props.record.attachment && 125 134 PubLeafletComment.isLinearDocumentQuote(props.record.attachment) && ( ··· 291 300 </Popover> 292 301 ); 293 302 }; 294 - 295 - const ProfilePopover = (props: { 296 - profile: AppBskyActorProfile.Record; 297 - comment: string; 298 - }) => { 299 - let commenterId = new AtUri(props.comment).host; 300 - 301 - return ( 302 - <> 303 - <a 304 - className="font-bold text-tertiary text-sm hover:underline" 305 - href={`https://bsky.app/profile/${commenterId}`} 306 - > 307 - {props.profile.displayName} 308 - </a> 309 - {/*<Media mobile={false}> 310 - <Popover 311 - align="start" 312 - trigger={ 313 - <div 314 - onMouseOver={() => { 315 - setHovering(true); 316 - hoverTimeout.current = window.setTimeout(() => { 317 - setLoadProfile(true); 318 - }, 500); 319 - }} 320 - onMouseOut={() => { 321 - setHovering(false); 322 - clearTimeout(hoverTimeout.current); 323 - }} 324 - className="font-bold text-tertiary text-sm hover:underline" 325 - > 326 - {props.profile.displayName} 327 - </div> 328 - } 329 - className="max-w-sm" 330 - > 331 - {profile && ( 332 - <> 333 - <div className="profilePopover text-sm flex gap-2"> 334 - <div className="w-5 h-5 bg-test rounded-full shrink-0 mt-[2px]" /> 335 - <div className="flex flex-col"> 336 - <div className="flex justify-between"> 337 - <div className="profileHeader flex gap-2 items-center"> 338 - <div className="font-bold">celine</div> 339 - <a className="text-tertiary" href="/"> 340 - @{profile.handle} 341 - </a> 342 - </div> 343 - </div> 344 - 345 - <div className="profileBio text-secondary "> 346 - {profile.description} 347 - </div> 348 - <div className="flex flex-row gap-2 items-center pt-2 font-bold"> 349 - {!profile.viewer?.following ? ( 350 - <div className="text-tertiary bg-border-light rounded-md px-1 py-0"> 351 - Following 352 - </div> 353 - ) : ( 354 - <ButtonPrimary compact className="text-sm"> 355 - Follow <BlueskyTiny /> 356 - </ButtonPrimary> 357 - )} 358 - {profile.viewer?.followedBy && ( 359 - <div className="text-tertiary">Follows You</div> 360 - )} 361 - </div> 362 - </div> 363 - </div> 364 - 365 - <hr className="my-2 border-border-light" /> 366 - <div className="flex gap-2 leading-tight items-center text-tertiary text-sm"> 367 - <div className="flex flex-col w-6 justify-center"> 368 - {profile.viewer?.knownFollowers?.followers.map((follower) => { 369 - return ( 370 - <div 371 - className="w-[18px] h-[18px] bg-test rounded-full border-2 border-bg-page" 372 - key={follower.did} 373 - /> 374 - ); 375 - })} 376 - <div className="w-[18px] h-[18px] bg-test rounded-full -mt-2 border-2 border-bg-page" /> 377 - <div className="w-[18px] h-[18px] bg-test rounded-full -mt-2 border-2 border-bg-page" /> 378 - </div> 379 - </div> 380 - </> 381 - )} 382 - </Popover> 383 - </Media>*/} 384 - </> 385 - ); 386 - };
+4 -1
app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx
··· 58 58 export const useDrawerOpen = (uri: string) => { 59 59 let params = useSearchParams(); 60 60 let interactionDrawerSearchParam = params.get("interactionDrawer"); 61 + let pageParam = params.get("page"); 61 62 let { drawerOpen: open, drawer, pageId } = useInteractionState(uri); 62 63 if (open === false || (open === undefined && !interactionDrawerSearchParam)) 63 64 return null; 64 65 drawer = 65 66 drawer || (interactionDrawerSearchParam as InteractionState["drawer"]); 66 - return { drawer, pageId }; 67 + // Use pageId from state, or fall back to page search param 68 + const resolvedPageId = pageId ?? pageParam ?? undefined; 69 + return { drawer, pageId: resolvedPageId }; 67 70 };
-1
app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
··· 61 61 <PageWrapper 62 62 pageType="doc" 63 63 fullPageScroll={fullPageScroll} 64 - cardBorderHidden={!hasPageBackground} 65 64 id={pageId ? `post-page-${pageId}` : "post-page"} 66 65 drawerOpen={ 67 66 !!drawer && (pageId ? drawer.pageId === pageId : !drawer.pageId)
+11 -10
app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
··· 18 18 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 19 19 import Post from "app/p/[didOrHandle]/[rkey]/l-quote/[quote]/page"; 20 20 import { Separator } from "components/Layout"; 21 + import { ProfilePopover } from "components/ProfilePopover"; 21 22 22 23 export function PostHeader(props: { 23 24 data: PostPageData; ··· 72 73 <> 73 74 <div className="flex flex-row gap-2 items-center"> 74 75 {profile ? ( 75 - <> 76 - <a 77 - className="text-tertiary" 78 - href={`https://bsky.app/profile/${profile.handle}`} 79 - > 80 - {profile.displayName || profile.handle} 81 - </a> 82 - </> 76 + <ProfilePopover 77 + didOrHandle={profile.did} 78 + trigger={ 79 + <span className="text-tertiary hover:underline"> 80 + {profile.displayName || profile.handle} 81 + </span> 82 + } 83 + /> 83 84 ) : null} 84 85 {record.publishedAt ? ( 85 86 <> ··· 119 120 {props.postTitle ? props.postTitle : "Untitled"} 120 121 </h2> 121 122 {props.postDescription ? ( 122 - <p className="postDescription italic text-secondary outline-hidden bg-transparent pt-1"> 123 + <div className="postDescription italic text-secondary outline-hidden bg-transparent pt-1"> 123 124 {props.postDescription} 124 - </p> 125 + </div> 125 126 ) : null} 126 127 <div className="postInfo text-sm text-tertiary pt-3 flex gap-1 flex-wrap justify-between"> 127 128 {props.postInfo}
+2 -6
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
··· 81 81 }); 82 82 return; 83 83 } 84 - 85 84 // Then check for quote param 86 85 if (quote) { 87 86 const decodedQuote = decodeQuotePosition(quote as string); ··· 96 95 // Mark as initialized even if no pageId found 97 96 usePostPageUIState.setState({ initialized: true }); 98 97 } 99 - }, [quote]); 98 + }, [quote, pageParam]); 100 99 }; 101 100 102 101 export const openPage = ( ··· 355 354 absolute sm:-right-[20px] right-3 sm:top-3 top-0 356 355 flex sm:flex-col flex-row-reverse gap-1 items-start`} 357 356 > 358 - <PageOptionButton 359 - cardBorderHidden={!props.hasPageBackground} 360 - onClick={props.onClick} 361 - > 357 + <PageOptionButton onClick={props.onClick}> 362 358 <CloseTiny /> 363 359 </PageOptionButton> 364 360 </div>
-1
app/lish/[did]/[publication]/[rkey]/ThreadPage.tsx
··· 94 94 95 95 return ( 96 96 <PageWrapper 97 - cardBorderHidden={!!cardBorderHidden} 98 97 pageType="doc" 99 98 fullPageScroll={false} 100 99 id={`post-page-${pageId}`}
-1
app/lish/[did]/[publication]/dashboard/DraftList.tsx
··· 23 23 searchValue={props.searchValue} 24 24 showPreview={false} 25 25 defaultDisplay="list" 26 - cardBorderHidden={!props.showPageBackground} 27 26 leaflets={leaflets_in_publications 28 27 .filter((l) => !l.documents) 29 28 .filter((l) => !l.archived)
-1
app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx
··· 39 39 return ( 40 40 <DashboardLayout 41 41 id={publication.uri} 42 - cardBorderHidden={!!record.theme?.showPageBackground} 43 42 defaultTab="Drafts" 44 43 tabs={{ 45 44 Drafts: {
+6 -9
app/lish/[did]/[publication]/page.tsx
··· 17 17 import { InteractionPreview } from "components/InteractionsPreview"; 18 18 import { LocalizedDate } from "./LocalizedDate"; 19 19 import { PublicationHomeLayout } from "./PublicationHomeLayout"; 20 + import { PublicationAuthor } from "./PublicationAuthor"; 20 21 21 22 export default async function Publication(props: { 22 23 params: Promise<{ publication: string; did: string }>; ··· 91 92 {record?.description}{" "} 92 93 </p> 93 94 {profile && ( 94 - <p className="italic text-tertiary sm:text-base text-sm"> 95 - <strong className="">by {profile.displayName}</strong>{" "} 96 - <a 97 - className="text-tertiary" 98 - href={`https://bsky.app/profile/${profile.handle}`} 99 - > 100 - @{profile.handle} 101 - </a> 102 - </p> 95 + <PublicationAuthor 96 + did={profile.did} 97 + displayName={profile.displayName} 98 + handle={profile.handle} 99 + /> 103 100 )} 104 101 <div className="sm:pt-4 pt-4"> 105 102 <SubscribeWithBluesky
+9 -7
app/p/[didOrHandle]/[rkey]/page.tsx
··· 5 5 import { Metadata } from "next"; 6 6 import { idResolver } from "app/(home-pages)/reader/idResolver"; 7 7 import { DocumentPageRenderer } from "app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer"; 8 + import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 8 9 9 10 export async function generateMetadata(props: { 10 11 params: Promise<{ didOrHandle: string; rkey: string }>; ··· 34 35 let docRecord = document.data as PubLeafletDocument.Record; 35 36 36 37 // For documents in publications, include publication name 37 - let publicationName = document.documents_in_publications[0]?.publications?.name; 38 + let publicationName = 39 + document.documents_in_publications[0]?.publications?.name; 38 40 39 41 return { 40 42 icons: { ··· 63 65 let resolved = await idResolver.handle.resolve(didOrHandle); 64 66 if (!resolved) { 65 67 return ( 66 - <div className="p-4 text-lg text-center flex flex-col gap-4"> 67 - <p>Sorry, can&apos;t resolve handle.</p> 68 + <NotFoundLayout> 69 + <p className="font-bold">Sorry, we can't find this handle!</p> 68 70 <p> 69 71 This may be a glitch on our end. If the issue persists please{" "} 70 72 <a href="mailto:contact@leaflet.pub">send us a note</a>. 71 73 </p> 72 - </div> 74 + </NotFoundLayout> 73 75 ); 74 76 } 75 77 did = resolved; 76 78 } catch (e) { 77 79 return ( 78 - <div className="p-4 text-lg text-center flex flex-col gap-4"> 79 - <p>Sorry, can&apos;t resolve handle.</p> 80 + <NotFoundLayout> 81 + <p className="font-bold">Sorry, we can't find this leaflet!</p> 80 82 <p> 81 83 This may be a glitch on our end. If the issue persists please{" "} 82 84 <a href="mailto:contact@leaflet.pub">send us a note</a>. 83 85 </p> 84 - </div> 86 + </NotFoundLayout> 85 87 ); 86 88 } 87 89 }
+2 -1
components/ActionBar/Navigation.tsx
··· 25 25 | "discover" 26 26 | "notifications" 27 27 | "looseleafs" 28 - | "tag"; 28 + | "tag" 29 + | "profile"; 29 30 30 31 export const DesktopNavigation = (props: { 31 32 currentPage: navPages;
+1 -1
components/ActionBar/Publications.tsx
··· 193 193 194 194 return props.record.icon ? ( 195 195 <div 196 - className={`${iconSizeClassName} ${props.className} relative overflow-hidden`} 196 + className={`${iconSizeClassName} ${props.className} relative overflow-hidden shrink-0`} 197 197 > 198 198 <img 199 199 src={`/api/atproto_images?did=${new AtUri(props.uri).host}&cid=${(props.record.icon?.ref as unknown as { $link: string })["$link"]}`}
+2 -2
components/AtMentionLink.tsx
··· 24 24 isPublication || isDocument ? ( 25 25 <img 26 26 src={`/api/pub_icon?at_uri=${encodeURIComponent(atURI)}`} 27 - className="inline-block w-5 h-5 rounded-full mr-1 align-text-top" 27 + className="inline-block w-4 h-4 rounded-full mr-1 mt-[3px] align-text-top" 28 28 alt="" 29 29 width="20" 30 30 height="20" ··· 37 37 href={atUriToUrl(atURI)} 38 38 target="_blank" 39 39 rel="noopener noreferrer" 40 - className={`text-accent-contrast hover:underline cursor-pointer ${isPublication ? "font-bold" : ""} ${isDocument ? "italic" : ""} ${className}`} 40 + className={`mention ${isPublication ? "font-bold" : ""} ${isDocument ? "italic" : ""} ${className}`} 41 41 > 42 42 {icon} 43 43 {children}
+4 -1
components/Avatar.tsx
··· 3 3 export const Avatar = (props: { 4 4 src: string | undefined; 5 5 displayName: string | undefined; 6 + className?: string; 6 7 tiny?: boolean; 8 + large?: boolean; 9 + giant?: boolean; 7 10 }) => { 8 11 if (props.src) 9 12 return ( 10 13 <img 11 - className={`${props.tiny ? "w-4 h-4" : "w-5 h-5"} rounded-full shrink-0 border border-border-light`} 14 + className={`${props.tiny ? "w-4 h-4" : props.large ? "h-8 w-8" : props.giant ? "h-16 w-16" : "w-5 h-5"} rounded-full shrink-0 border border-border-light ${props.className}`} 12 15 src={props.src} 13 16 alt={ 14 17 props.displayName
+14 -5
components/Blocks/TextBlock/RenderYJSFragment.tsx
··· 6 6 import { didToBlueskyUrl } from "src/utils/mentionUtils"; 7 7 import { AtMentionLink } from "components/AtMentionLink"; 8 8 import { Delta } from "src/utils/yjsFragmentToString"; 9 + import { ProfilePopover } from "components/ProfilePopover"; 9 10 10 11 type BlockElements = "h1" | "h2" | "h3" | null | "blockquote" | "p"; 11 12 export function RenderYJSFragment({ ··· 63 64 ); 64 65 } 65 66 66 - if (node.constructor === XmlElement && node.nodeName === "hard_break") { 67 + if ( 68 + node.constructor === XmlElement && 69 + node.nodeName === "hard_break" 70 + ) { 67 71 return <br key={index} />; 68 72 } 69 73 70 74 // Handle didMention inline nodes 71 - if (node.constructor === XmlElement && node.nodeName === "didMention") { 75 + if ( 76 + node.constructor === XmlElement && 77 + node.nodeName === "didMention" 78 + ) { 72 79 const did = node.getAttribute("did") || ""; 73 80 const text = node.getAttribute("text") || ""; 74 81 return ( ··· 77 84 target="_blank" 78 85 rel="noopener noreferrer" 79 86 key={index} 80 - className="text-accent-contrast hover:underline cursor-pointer" 87 + className="mention" 81 88 > 82 89 {text} 83 90 </a> ··· 85 92 } 86 93 87 94 // Handle atMention inline nodes 88 - if (node.constructor === XmlElement && node.nodeName === "atMention") { 95 + if ( 96 + node.constructor === XmlElement && 97 + node.nodeName === "atMention" 98 + ) { 89 99 const atURI = node.getAttribute("atURI") || ""; 90 100 const text = node.getAttribute("text") || ""; 91 101 return ( ··· 161 171 162 172 return props; 163 173 } 164 -
+4 -3
components/Blocks/TextBlock/schema.ts
··· 147 147 toDOM(node) { 148 148 // NOTE: This rendering should match the AtMentionLink component in 149 149 // components/AtMentionLink.tsx. If you update one, update the other. 150 - let className = "atMention text-accent-contrast"; 150 + let className = "atMention mention"; 151 151 let aturi = new AtUri(node.attrs.atURI); 152 152 if (aturi.collection === "pub.leaflet.publication") 153 153 className += " font-bold"; ··· 168 168 "img", 169 169 { 170 170 src: `/api/pub_icon?at_uri=${encodeURIComponent(node.attrs.atURI)}`, 171 - class: "inline-block w-5 h-5 rounded-full mr-1 align-text-top", 171 + class: 172 + "inline-block w-4 h-4 rounded-full mt-[3px] mr-1 align-text-top", 172 173 alt: "", 173 174 width: "16", 174 175 height: "16", ··· 214 215 return [ 215 216 "span", 216 217 { 217 - class: "didMention text-accent-contrast", 218 + class: "didMention mention", 218 219 "data-did": node.attrs.did, 219 220 }, 220 221 node.attrs.text,
+1
components/Icons/ReplyTiny.tsx
··· 8 8 viewBox="0 0 16 16" 9 9 fill="none" 10 10 xmlns="http://www.w3.org/2000/svg" 11 + {...props} 11 12 > 12 13 <path 13 14 fillRule="evenodd"
+7 -8
components/PageHeader.tsx
··· 1 1 "use client"; 2 2 import { useState, useEffect } from "react"; 3 + import { useCardBorderHidden } from "./Pages/useCardBorderHidden"; 3 4 4 - export const Header = (props: { 5 - children: React.ReactNode; 6 - cardBorderHidden: boolean; 7 - }) => { 5 + export const Header = (props: { children: React.ReactNode }) => { 6 + let cardBorderHidden = useCardBorderHidden(); 8 7 let [scrollPos, setScrollPos] = useState(0); 9 8 10 9 useEffect(() => { ··· 22 21 } 23 22 }, []); 24 23 25 - let headerBGColor = props.cardBorderHidden 24 + let headerBGColor = !cardBorderHidden 26 25 ? "var(--bg-leaflet)" 27 26 : "var(--bg-page)"; 28 27 ··· 54 53 style={ 55 54 scrollPos < 20 56 55 ? { 57 - backgroundColor: props.cardBorderHidden 56 + backgroundColor: !cardBorderHidden 58 57 ? `rgba(${headerBGColor}, ${scrollPos / 60 + 0.75})` 59 58 : `rgba(${headerBGColor}, ${scrollPos / 20})`, 60 - paddingLeft: props.cardBorderHidden 59 + paddingLeft: !cardBorderHidden 61 60 ? "4px" 62 61 : `calc(${scrollPos / 20}*4px)`, 63 - paddingRight: props.cardBorderHidden 62 + paddingRight: !cardBorderHidden 64 63 ? "8px" 65 64 : `calc(${scrollPos / 20}*8px)`, 66 65 }
+3 -23
components/PageLayouts/DashboardLayout.tsx
··· 25 25 import Link from "next/link"; 26 26 import { ExternalLinkTiny } from "components/Icons/ExternalLinkTiny"; 27 27 import { usePreserveScroll } from "src/hooks/usePreserveScroll"; 28 + import { Tab } from "components/Tab"; 28 29 29 30 export type DashboardState = { 30 31 display?: "grid" | "list"; ··· 133 134 }, 134 135 >(props: { 135 136 id: string; 136 - cardBorderHidden: boolean; 137 137 tabs: T; 138 138 defaultTab: keyof T; 139 139 currentPage: navPages; ··· 186 186 > 187 187 {Object.keys(props.tabs).length <= 1 && !controls ? null : ( 188 188 <> 189 - <Header cardBorderHidden={props.cardBorderHidden}> 189 + <Header> 190 190 {headerState === "default" ? ( 191 191 <> 192 192 {Object.keys(props.tabs).length > 1 && ( ··· 355 355 ); 356 356 }; 357 357 358 - function Tab(props: { 359 - name: string; 360 - selected: boolean; 361 - onSelect: () => void; 362 - href?: string; 363 - }) { 364 - return ( 365 - <div 366 - className={`pubTabs px-1 py-0 flex gap-1 items-center rounded-md hover:cursor-pointer ${props.selected ? "text-accent-2 bg-accent-1 font-bold -mb-px" : "text-tertiary"}`} 367 - onClick={() => props.onSelect()} 368 - > 369 - {props.name} 370 - {props.href && <ExternalLinkTiny />} 371 - </div> 372 - ); 373 - } 374 - 375 - const FilterOptions = (props: { 376 - hasPubs: boolean; 377 - hasArchived: boolean; 378 - }) => { 358 + const FilterOptions = (props: { hasPubs: boolean; hasArchived: boolean }) => { 379 359 let { filter } = useDashboardState(); 380 360 let setState = useSetDashboardState(); 381 361 let filterCount = Object.values(filter).filter(Boolean).length;
+3 -5
components/Pages/Page.tsx
··· 34 34 return focusedPageID === props.entityID; 35 35 }); 36 36 let pageType = useEntity(props.entityID, "page/type")?.data.value || "doc"; 37 - let cardBorderHidden = useCardBorderHidden(props.entityID); 38 37 39 38 let drawerOpen = useDrawerOpen(props.entityID); 40 39 return ( ··· 49 48 }} 50 49 id={elementId.page(props.entityID).container} 51 50 drawerOpen={!!drawerOpen} 52 - cardBorderHidden={!!cardBorderHidden} 53 51 isFocused={isFocused} 54 52 fullPageScroll={props.fullPageScroll} 55 53 pageType={pageType} ··· 77 75 id: string; 78 76 children: React.ReactNode; 79 77 pageOptions?: React.ReactNode; 80 - cardBorderHidden: boolean; 81 78 fullPageScroll: boolean; 82 79 isFocused?: boolean; 83 80 onClickAction?: (e: React.MouseEvent) => void; 84 81 pageType: "canvas" | "doc"; 85 82 drawerOpen: boolean | undefined; 86 83 }) => { 84 + const cardBorderHidden = useCardBorderHidden(); 87 85 let { ref } = usePreserveScroll<HTMLDivElement>(props.id); 88 86 return ( 89 87 // this div wraps the contents AND the page options. ··· 106 104 shrink-0 snap-center 107 105 overflow-y-scroll 108 106 ${ 109 - !props.cardBorderHidden && 107 + !cardBorderHidden && 110 108 `h-full border 111 109 bg-[rgba(var(--bg-page),var(--bg-page-alpha))] 112 110 ${props.drawerOpen ? "rounded-l-lg " : "rounded-lg"} 113 111 ${props.isFocused ? "shadow-md border-border" : "border-border-light"}` 114 112 } 115 - ${props.cardBorderHidden && "sm:h-[calc(100%+48px)] h-[calc(100%+20px)] sm:-my-6 -my-3 sm:pt-6 pt-3"} 113 + ${cardBorderHidden && "sm:h-[calc(100%+48px)] h-[calc(100%+20px)] sm:-my-6 -my-3 sm:pt-6 pt-3"} 116 114 ${props.fullPageScroll && "max-w-full "} 117 115 ${props.pageType === "doc" && !props.fullPageScroll && "w-[10000px] sm:mx-0 max-w-[var(--page-width-units)]"} 118 116 ${
+7 -29
components/Pages/PageOptions.tsx
··· 21 21 export const PageOptionButton = ({ 22 22 children, 23 23 secondary, 24 - cardBorderHidden, 25 24 className, 26 25 disabled, 27 26 ...props 28 27 }: { 29 28 children: React.ReactNode; 30 29 secondary?: boolean; 31 - cardBorderHidden: boolean | undefined; 32 30 className?: string; 33 31 disabled?: boolean; 34 32 } & Omit<JSX.IntrinsicElements["button"], "content">) => { 33 + const cardBorderHidden = useCardBorderHidden(); 35 34 return ( 36 35 <button 37 36 className={` ··· 58 57 first: boolean | undefined; 59 58 isFocused: boolean; 60 59 }) => { 61 - let cardBorderHidden = useCardBorderHidden(props.entityID); 62 - 63 60 return ( 64 61 <div 65 62 className={`pageOptions w-fit z-10 ··· 69 66 > 70 67 {!props.first && ( 71 68 <PageOptionButton 72 - cardBorderHidden={cardBorderHidden} 73 69 secondary 74 70 onClick={() => { 75 71 useUIState.getState().closePage(props.entityID); ··· 78 74 <CloseTiny /> 79 75 </PageOptionButton> 80 76 )} 81 - <OptionsMenu 82 - entityID={props.entityID} 83 - first={!!props.first} 84 - cardBorderHidden={cardBorderHidden} 85 - /> 86 - <UndoButtons cardBorderHidden={cardBorderHidden} /> 77 + <OptionsMenu entityID={props.entityID} first={!!props.first} /> 78 + <UndoButtons /> 87 79 </div> 88 80 ); 89 81 }; 90 82 91 - export const UndoButtons = (props: { 92 - cardBorderHidden: boolean | undefined; 93 - }) => { 83 + export const UndoButtons = () => { 94 84 let undoState = useUndoState(); 95 85 let { undoManager } = useReplicache(); 96 86 return ( 97 87 <Media mobile> 98 88 {undoState.canUndo && ( 99 89 <div className="gap-1 flex sm:flex-col"> 100 - <PageOptionButton 101 - secondary 102 - cardBorderHidden={props.cardBorderHidden} 103 - onClick={() => undoManager.undo()} 104 - > 90 + <PageOptionButton secondary onClick={() => undoManager.undo()}> 105 91 <UndoTiny /> 106 92 </PageOptionButton> 107 93 108 94 <PageOptionButton 109 95 secondary 110 - cardBorderHidden={props.cardBorderHidden} 111 96 onClick={() => undoManager.undo()} 112 97 disabled={!undoState.canRedo} 113 98 > ··· 119 104 ); 120 105 }; 121 106 122 - export const OptionsMenu = (props: { 123 - entityID: string; 124 - first: boolean; 125 - cardBorderHidden: boolean | undefined; 126 - }) => { 107 + export const OptionsMenu = (props: { entityID: string; first: boolean }) => { 127 108 let [state, setState] = useState<"normal" | "theme" | "share">("normal"); 128 109 let { permissions } = useEntitySetContext(); 129 110 if (!permissions.write) return null; ··· 138 119 if (!open) setState("normal"); 139 120 }} 140 121 trigger={ 141 - <PageOptionButton 142 - cardBorderHidden={props.cardBorderHidden} 143 - className="!w-8 !h-5 sm:!w-5 sm:!h-8" 144 - > 122 + <PageOptionButton className="!w-8 !h-5 sm:!w-5 sm:!h-8"> 145 123 <MoreOptionsTiny className="sm:rotate-90" /> 146 124 </PageOptionButton> 147 125 }
+3 -18
components/Pages/useCardBorderHidden.ts
··· 1 - import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 2 - import { PubLeafletPublication } from "lexicons/api"; 3 - import { useEntity, useReplicache } from "src/replicache"; 1 + import { useCardBorderHiddenContext } from "components/ThemeManager/ThemeProvider"; 4 2 5 - export function useCardBorderHidden(entityID: string | null) { 6 - let { rootEntity } = useReplicache(); 7 - let { data: pub } = useLeafletPublicationData(); 8 - let rootCardBorderHidden = useEntity(rootEntity, "theme/card-border-hidden"); 9 - 10 - let cardBorderHidden = 11 - useEntity(entityID, "theme/card-border-hidden") || rootCardBorderHidden; 12 - if (!cardBorderHidden && !rootCardBorderHidden) { 13 - if (pub?.publications?.record) { 14 - let record = pub.publications.record as PubLeafletPublication.Record; 15 - return !record.theme?.showPageBackground; 16 - } 17 - return false; 18 - } 19 - return (cardBorderHidden || rootCardBorderHidden)?.data.value; 3 + export function useCardBorderHidden(entityID?: string | null) { 4 + return useCardBorderHiddenContext(); 20 5 }
+38 -29
components/PostListing.tsx
··· 13 13 14 14 import Link from "next/link"; 15 15 import { InteractionPreview } from "./InteractionsPreview"; 16 + import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 16 17 17 18 export const PostListing = (props: Post) => { 18 - let pubRecord = props.publication.pubRecord as PubLeafletPublication.Record; 19 + let pubRecord = props.publication?.pubRecord as 20 + | PubLeafletPublication.Record 21 + | undefined; 19 22 20 23 let postRecord = props.documents.data as PubLeafletDocument.Record; 21 24 let postUri = new AtUri(props.documents.uri); 22 25 23 - let theme = usePubTheme(pubRecord.theme); 24 - let backgroundImage = pubRecord?.theme?.backgroundImage?.image?.ref 25 - ? blobRefToSrc( 26 - pubRecord?.theme?.backgroundImage?.image?.ref, 27 - new AtUri(props.publication.uri).host, 28 - ) 29 - : null; 26 + let theme = usePubTheme(pubRecord?.theme || postRecord?.theme); 27 + let backgroundImage = 28 + pubRecord?.theme?.backgroundImage?.image?.ref && props.publication 29 + ? blobRefToSrc( 30 + pubRecord.theme.backgroundImage.image.ref, 31 + new AtUri(props.publication.uri).host, 32 + ) 33 + : null; 30 34 31 35 let backgroundImageRepeat = pubRecord?.theme?.backgroundImage?.repeat; 32 36 let backgroundImageSize = pubRecord?.theme?.backgroundImage?.width || 500; 33 37 34 - let showPageBackground = pubRecord.theme?.showPageBackground; 38 + let showPageBackground = pubRecord?.theme?.showPageBackground; 35 39 36 40 let quotes = props.documents.document_mentions_in_bsky?.[0]?.count || 0; 37 41 let comments = 38 - pubRecord.preferences?.showComments === false 42 + pubRecord?.preferences?.showComments === false 39 43 ? 0 40 44 : props.documents.comments_on_documents?.[0]?.count || 0; 41 45 let tags = (postRecord?.tags as string[] | undefined) || []; 42 46 47 + // For standalone posts, link directly to the document 48 + let postHref = props.publication 49 + ? `${props.publication.href}/${postUri.rkey}` 50 + : `/p/${postUri.host}/${postUri.rkey}`; 51 + 43 52 return ( 44 53 <BaseThemeProvider {...theme} local> 45 54 <div 46 55 style={{ 47 - backgroundImage: `url(${backgroundImage})`, 56 + backgroundImage: backgroundImage 57 + ? `url(${backgroundImage})` 58 + : undefined, 48 59 backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat", 49 60 backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`, 50 61 }} ··· 55 66 hover:outline-accent-contrast hover:border-accent-contrast 56 67 `} 57 68 > 58 - <Link 59 - className="h-full w-full absolute top-0 left-0" 60 - href={`${props.publication.href}/${postUri.rkey}`} 61 - /> 69 + <Link className="h-full w-full absolute top-0 left-0" href={postHref} /> 62 70 <div 63 71 className={`${showPageBackground ? "bg-bg-page " : "bg-transparent"} rounded-md w-full px-[10px] pt-2 pb-2`} 64 72 style={{ ··· 71 79 72 80 <p className="text-secondary italic">{postRecord.description}</p> 73 81 <div className="flex flex-col-reverse md:flex-row md gap-2 text-sm text-tertiary items-center justify-start pt-1.5 md:pt-3 w-full"> 74 - <PubInfo 75 - href={props.publication.href} 76 - pubRecord={pubRecord} 77 - uri={props.publication.uri} 78 - /> 82 + {props.publication && pubRecord && ( 83 + <PubInfo 84 + href={props.publication.href} 85 + pubRecord={pubRecord} 86 + uri={props.publication.uri} 87 + /> 88 + )} 79 89 <div className="flex flex-row justify-between gap-2 items-center w-full"> 80 90 <PostInfo publishedAt={postRecord.publishedAt} /> 81 91 <InteractionPreview 82 - postUrl={`${props.publication.href}/${postUri.rkey}`} 92 + postUrl={postHref} 83 93 quotesCount={quotes} 84 94 commentsCount={comments} 85 95 tags={tags} 86 - showComments={pubRecord.preferences?.showComments} 96 + showComments={pubRecord?.preferences?.showComments} 87 97 share 88 98 /> 89 99 </div> ··· 114 124 }; 115 125 116 126 const PostInfo = (props: { publishedAt: string | undefined }) => { 127 + let localizedDate = useLocalizedDate(props.publishedAt || "", { 128 + year: "numeric", 129 + month: "short", 130 + day: "numeric", 131 + }); 117 132 return ( 118 133 <div className="flex gap-2 items-center shrink-0 self-start"> 119 134 {props.publishedAt && ( 120 135 <> 121 - <div className="shrink-0"> 122 - {new Date(props.publishedAt).toLocaleDateString("en-US", { 123 - year: "numeric", 124 - month: "short", 125 - day: "numeric", 126 - })} 127 - </div> 136 + <div className="shrink-0">{localizedDate}</div> 128 137 </> 129 138 )} 130 139 </div>
+98
components/ProfilePopover.tsx
··· 1 + "use client"; 2 + import { Popover } from "./Popover"; 3 + import useSWR from "swr"; 4 + import { callRPC } from "app/api/rpc/client"; 5 + import { useRef, useState } from "react"; 6 + import { ProfileHeader } from "app/(home-pages)/p/[didOrHandle]/ProfileHeader"; 7 + import { SpeedyLink } from "./SpeedyLink"; 8 + import { Tooltip } from "./Tooltip"; 9 + import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 10 + 11 + export const ProfilePopover = (props: { 12 + trigger: React.ReactNode; 13 + didOrHandle: string; 14 + }) => { 15 + const [isOpen, setIsOpen] = useState(false); 16 + let [isHovered, setIsHovered] = useState(false); 17 + const hoverTimeout = useRef<null | number>(null); 18 + 19 + const { data, isLoading } = useSWR( 20 + isHovered ? ["profile-data", props.didOrHandle] : null, 21 + async () => { 22 + const response = await callRPC("get_profile_data", { 23 + didOrHandle: props.didOrHandle, 24 + }); 25 + return response.result; 26 + }, 27 + ); 28 + 29 + return ( 30 + <Tooltip 31 + className="max-w-sm p-0! text-center" 32 + asChild 33 + trigger={ 34 + <a 35 + className="no-underline" 36 + href={`https://leaflet.pub/p/${props.didOrHandle}`} 37 + target="_blank" 38 + onPointerEnter={(e) => { 39 + if (hoverTimeout.current) { 40 + window.clearTimeout(hoverTimeout.current); 41 + } 42 + hoverTimeout.current = window.setTimeout(async () => { 43 + setIsHovered(true); 44 + }, 150); 45 + }} 46 + onPointerLeave={() => { 47 + if (isHovered) return; 48 + if (hoverTimeout.current) { 49 + window.clearTimeout(hoverTimeout.current); 50 + hoverTimeout.current = null; 51 + } 52 + setIsHovered(false); 53 + }} 54 + > 55 + {props.trigger} 56 + </a> 57 + } 58 + onOpenChange={setIsOpen} 59 + > 60 + {isLoading ? ( 61 + <div className="text-secondary p-4">Loading...</div> 62 + ) : data ? ( 63 + <div> 64 + <ProfileHeader 65 + profile={data.profile} 66 + publications={data.publications} 67 + popover 68 + /> 69 + <KnownFollowers viewer={data.profile.viewer} did={data.profile.did} /> 70 + </div> 71 + ) : ( 72 + <div className="text-secondary py-2 px-4">Profile not found</div> 73 + )} 74 + </Tooltip> 75 + ); 76 + }; 77 + 78 + let KnownFollowers = (props: { 79 + viewer: ProfileViewDetailed["viewer"]; 80 + did: string; 81 + }) => { 82 + if (!props.viewer?.knownFollowers) return null; 83 + let count = props.viewer.knownFollowers.count; 84 + return ( 85 + <> 86 + <hr className="border-border" /> 87 + Followed by{" "} 88 + <a 89 + className="hover:underline" 90 + href={`https://bsky.social/profile/${props.did}/known-followers`} 91 + target="_blank" 92 + > 93 + {props.viewer?.knownFollowers?.followers[0]?.displayName}{" "} 94 + {count > 1 ? `and ${count - 1} other${count > 2 ? "s" : ""}` : ""} 95 + </a> 96 + </> 97 + ); 98 + };
+18
components/Tab.tsx
··· 1 + import { ExternalLinkTiny } from "./Icons/ExternalLinkTiny"; 2 + 3 + export const Tab = (props: { 4 + name: string; 5 + selected: boolean; 6 + onSelect: () => void; 7 + href?: string; 8 + }) => { 9 + return ( 10 + <div 11 + className={`pubTabs px-1 py-0 flex gap-1 items-center rounded-md hover:cursor-pointer ${props.selected ? "text-accent-2 bg-accent-1 font-bold -mb-px" : "text-tertiary"}`} 12 + onClick={() => props.onSelect()} 13 + > 14 + {props.name} 15 + {props.href && <ExternalLinkTiny />} 16 + </div> 17 + ); 18 + };
+7 -4
components/ThemeManager/PublicationThemeProvider.tsx
··· 4 4 import { useEntity } from "src/replicache"; 5 5 import { getColorContrast } from "./themeUtils"; 6 6 import { useColorAttribute, colorToString } from "./useColorAttribute"; 7 - import { BaseThemeProvider } from "./ThemeProvider"; 7 + import { BaseThemeProvider, CardBorderHiddenContext } from "./ThemeProvider"; 8 8 import { PubLeafletPublication, PubLeafletThemeColor } from "lexicons/api"; 9 9 import { usePublicationData } from "app/lish/[did]/[publication]/dashboard/PublicationSWRProvider"; 10 10 import { blobRefToSrc } from "src/utils/blobRefToSrc"; ··· 103 103 isStandalone?: boolean; 104 104 }) { 105 105 let colors = usePubTheme(props.theme, props.isStandalone); 106 + let cardBorderHidden = !colors.showPageBackground; 106 107 return ( 107 - <BaseThemeProvider local={props.local} {...colors}> 108 - {props.children} 109 - </BaseThemeProvider> 108 + <CardBorderHiddenContext.Provider value={cardBorderHidden}> 109 + <BaseThemeProvider local={props.local} {...colors}> 110 + {props.children} 111 + </BaseThemeProvider> 112 + </CardBorderHiddenContext.Provider> 110 113 ); 111 114 } 112 115
+26 -22
components/ThemeManager/ThemeProvider.tsx
··· 1 1 "use client"; 2 2 3 - import { 4 - createContext, 5 - CSSProperties, 6 - useContext, 7 - useEffect, 8 - } from "react"; 3 + import { createContext, CSSProperties, useContext, useEffect } from "react"; 4 + 5 + // Context for cardBorderHidden 6 + export const CardBorderHiddenContext = createContext<boolean>(false); 7 + 8 + export function useCardBorderHiddenContext() { 9 + return useContext(CardBorderHiddenContext); 10 + } 9 11 import { 10 12 colorToString, 11 13 useColorAttribute, ··· 58 60 }) { 59 61 let bgLeaflet = useColorAttribute(props.entityID, "theme/page-background"); 60 62 let bgPage = useColorAttribute(props.entityID, "theme/card-background"); 61 - let showPageBackground = !useEntity( 63 + let cardBorderHiddenValue = useEntity( 62 64 props.entityID, 63 65 "theme/card-border-hidden", 64 66 )?.data.value; 67 + let showPageBackground = !cardBorderHiddenValue; 65 68 let primary = useColorAttribute(props.entityID, "theme/primary"); 66 69 67 70 let highlight1 = useEntity(props.entityID, "theme/highlight-1"); ··· 72 75 let accent2 = useColorAttribute(props.entityID, "theme/accent-text"); 73 76 74 77 return ( 75 - <BaseThemeProvider 76 - local={props.local} 77 - bgLeaflet={bgLeaflet} 78 - bgPage={bgPage} 79 - primary={primary} 80 - highlight2={highlight2} 81 - highlight3={highlight3} 82 - highlight1={highlight1?.data.value} 83 - accent1={accent1} 84 - accent2={accent2} 85 - showPageBackground={showPageBackground} 86 - > 87 - {props.children} 88 - </BaseThemeProvider> 78 + <CardBorderHiddenContext.Provider value={!!cardBorderHiddenValue}> 79 + <BaseThemeProvider 80 + local={props.local} 81 + bgLeaflet={bgLeaflet} 82 + bgPage={bgPage} 83 + primary={primary} 84 + highlight2={highlight2} 85 + highlight3={highlight3} 86 + highlight1={highlight1?.data.value} 87 + accent1={accent1} 88 + accent2={accent2} 89 + showPageBackground={showPageBackground} 90 + > 91 + {props.children} 92 + </BaseThemeProvider> 93 + </CardBorderHiddenContext.Provider> 89 94 ); 90 95 } 91 96 ··· 337 342 </div> 338 343 ); 339 344 }; 340 -
+1 -1
components/Tooltip.tsx
··· 26 26 props.skipDelayDuration ? props.skipDelayDuration : 300 27 27 } 28 28 > 29 - <RadixTooltip.Root> 29 + <RadixTooltip.Root onOpenChange={props.onOpenChange}> 30 30 <RadixTooltip.Trigger disabled={props.disabled} asChild={props.asChild}> 31 31 {props.trigger} 32 32 </RadixTooltip.Trigger>
-2
src/hooks/useLocalizedDate.ts
··· 31 31 ? timezone || "UTC" 32 32 : Intl.DateTimeFormat().resolvedOptions().timeZone; 33 33 34 - console.log("tz", effectiveTimezone); 35 - 36 34 // Apply timezone if available 37 35 if (effectiveTimezone) { 38 36 dateTime = dateTime.setZone(effectiveTimezone);