diff --git a/app/(home-pages)/notifications/FollowNotification.tsx b/app/(home-pages)/notifications/FollowNotification.tsx index dbdd5efd..a0d82043 100644 --- a/app/(home-pages)/notifications/FollowNotification.tsx +++ b/app/(home-pages)/notifications/FollowNotification.tsx @@ -23,7 +23,7 @@ export const FollowNotification = (props: HydratedSubscribeNotification) => { } + icon={} actionText={ <> {displayName} subscribed to {pubRecord?.name}! diff --git a/app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx b/app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx index be7d44da..3fb2b0fd 100644 --- a/app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx +++ b/app/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx @@ -23,7 +23,7 @@ export const ProfileHeader = (props: { src={profileRecord.avatar} displayName={profileRecord.displayName} className="profileAvatar mx-auto mt-3 sm:mt-4" - giant + size="giant" /> ); @@ -100,7 +100,10 @@ const ProfileLinks = (props: { handle: string }) => { ); }; -const PublicationCard = (props: { record: NormalizedPublication; uri: string }) => { +const PublicationCard = (props: { + record: NormalizedPublication; + uri: string; +}) => { const { record, uri } = props; const { bgLeaflet, bgPage, primary } = usePubTheme(record.theme); diff --git a/app/lish/[did]/[publication]/[rkey]/Blocks/PublishBskyPostBlock.tsx b/app/lish/[did]/[publication]/[rkey]/Blocks/PublishBskyPostBlock.tsx index 44c41a60..99cbc481 100644 --- a/app/lish/[did]/[publication]/[rkey]/Blocks/PublishBskyPostBlock.tsx +++ b/app/lish/[did]/[publication]/[rkey]/Blocks/PublishBskyPostBlock.tsx @@ -1,18 +1,7 @@ import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; import { AppBskyFeedDefs, AppBskyFeedPost } from "@atproto/api"; -import { Separator } from "components/Layout"; -import { useHasPageLoaded } from "components/InitialPageLoadProvider"; -import { BlueskyTiny } from "components/Icons/BlueskyTiny"; -import { CommentTiny } from "components/Icons/CommentTiny"; -import { QuoteTiny } from "components/Icons/QuoteTiny"; -import { ThreadLink, QuotesLink } from "../PostLinks"; -import { useLocalizedDate } from "src/hooks/useLocalizedDate"; -import { - BlueskyEmbed, - PostNotAvailable, -} from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; -import { BlueskyRichText } from "components/Blocks/BlueskyPostBlock/BlueskyRichText"; -import { openPage } from "../PostPages"; +import { PostNotAvailable } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; +import { BskyPostContent } from "../BskyPostContent"; export const PubBlueskyPostBlock = (props: { post: PostView; @@ -21,13 +10,6 @@ export const PubBlueskyPostBlock = (props: { }) => { let post = props.post; - const handleOpenThread = () => { - openPage(props.pageId ? { type: "doc", id: props.pageId } : undefined, { - type: "thread", - uri: post.uri, - }); - }; - switch (true) { case AppBskyFeedDefs.isBlockedPost(post) || AppBskyFeedDefs.isBlockedAuthor(post) || @@ -49,6 +31,8 @@ export const PubBlueskyPostBlock = (props: { //getting the url to the post let postId = post.uri.split("/")[4]; + let postView = post as PostView; + let url = `https://bsky.app/profile/${post.author.handle}/post/${postId}`; const parent = props.pageId @@ -56,119 +40,16 @@ export const PubBlueskyPostBlock = (props: { : undefined; return ( -
- {post.author && record && ( - <> -
- {post.author.avatar && ( - {`${post.author?.displayName}'s - )} -
-
- {post.author?.displayName} -
- e.stopPropagation()} - > - @{post.author?.handle} - -
-
- -
-
-
-                    {BlueskyRichText({
-                      record: record as AppBskyFeedPost.Record | null,
-                    })}
-                  
-
- {post.embed && ( -
e.stopPropagation()}> - -
- )} -
- - )} -
- -
- {post.replyCount != null && post.replyCount > 0 && ( - <> - e.stopPropagation()} - > - {post.replyCount} - - - - - )} - {post.quoteCount != null && post.quoteCount > 0 && ( - <> - e.stopPropagation()} - > - {post.quoteCount} - - - - - )} - - e.stopPropagation()} - > - - -
-
-
+ ); } }; - -const ClientDate = (props: { date?: string }) => { - let pageLoaded = useHasPageLoaded(); - const formattedDate = useLocalizedDate( - props.date || new Date().toISOString(), - { - month: "short", - day: "numeric", - year: "numeric", - hour: "numeric", - minute: "numeric", - hour12: true, - }, - ); - - if (!pageLoaded) return null; - - return
{formattedDate}
; -}; diff --git a/app/lish/[did]/[publication]/[rkey]/BlueskyQuotesPage.tsx b/app/lish/[did]/[publication]/[rkey]/BlueskyQuotesPage.tsx index 299969a0..f2812cb8 100644 --- a/app/lish/[did]/[publication]/[rkey]/BlueskyQuotesPage.tsx +++ b/app/lish/[did]/[publication]/[rkey]/BlueskyQuotesPage.tsx @@ -7,7 +7,12 @@ import { DotLoader } from "components/utils/DotLoader"; import { QuoteTiny } from "components/Icons/QuoteTiny"; import { openPage } from "./PostPages"; import { BskyPostContent } from "./BskyPostContent"; -import { QuotesLink, getQuotesKey, fetchQuotes, prefetchQuotes } from "./PostLinks"; +import { + QuotesLink, + getQuotesKey, + fetchQuotes, + prefetchQuotes, +} from "./PostLinks"; // Re-export for backwards compatibility export { QuotesLink, getQuotesKey, fetchQuotes, prefetchQuotes }; @@ -27,21 +32,21 @@ export function BlueskyQuotesPage(props: { data: quotesData, isLoading, error, - } = useSWR(postUri ? getQuotesKey(postUri) : null, () => fetchQuotes(postUri)); + } = useSWR(postUri ? getQuotesKey(postUri) : null, () => + fetchQuotes(postUri), + ); return (
-
- - Bluesky Quotes -
+

Bluesky Quotes

{isLoading ? (
loading quotes @@ -68,38 +73,32 @@ function QuotesContent(props: { posts: PostView[]; postUri: string }) { return (
- {posts.map((post) => ( - + {posts.map((post, index) => ( + <> + + {posts.length !== index + 1 && ( +
+ )} + ))}
); } -function QuotePost(props: { - post: PostView; - quotesUri: string; -}) { +function QuotePost(props: { post: PostView; quotesUri: string }) { const { post, quotesUri } = props; const parent = { type: "quotes" as const, uri: quotesUri }; return ( -
openPage(parent, { type: "thread", uri: post.uri })} - > - e.stopPropagation()} - onEmbedClick={(e) => e.stopPropagation()} - /> -
+ ); } diff --git a/app/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx b/app/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx index 579a9255..d8145d12 100644 --- a/app/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx +++ b/app/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx @@ -1,8 +1,6 @@ "use client"; import { AppBskyFeedDefs, AppBskyFeedPost } from "@atproto/api"; -import { - BlueskyEmbed, -} from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; +import { BlueskyEmbed } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; import { BlueskyRichText } from "components/Blocks/BlueskyPostBlock/BlueskyRichText"; import { BlueskyTiny } from "components/Icons/BlueskyTiny"; import { CommentTiny } from "components/Icons/CommentTiny"; @@ -10,154 +8,286 @@ import { QuoteTiny } from "components/Icons/QuoteTiny"; import { Separator } from "components/Layout"; import { useLocalizedDate } from "src/hooks/useLocalizedDate"; import { useHasPageLoaded } from "components/InitialPageLoadProvider"; -import { OpenPage } from "./PostPages"; +import { OpenPage, openPage } from "./PostPages"; import { ThreadLink, QuotesLink } from "./PostLinks"; +import { BlueskyLinkTiny } from "components/Icons/BlueskyLinkTiny"; +import { Avatar } from "components/Avatar"; +import { timeAgo } from "src/utils/timeAgo"; +import { ProfilePopover } from "components/ProfilePopover"; type PostView = AppBskyFeedDefs.PostView; export function BskyPostContent(props: { post: PostView; - parent?: OpenPage; - linksEnabled?: boolean; - avatarSize?: "sm" | "md"; + parent: OpenPage | undefined; + avatarSize?: "tiny" | "small" | "medium" | "large" | "giant"; + className?: string; showEmbed?: boolean; + compactEmbed?: boolean; showBlueskyLink?: boolean; - onEmbedClick?: (e: React.MouseEvent) => void; - onLinkClick?: (e: React.MouseEvent) => void; + quoteEnabled?: boolean; + replyEnabled?: boolean; + replyOnClick?: (e: React.MouseEvent) => void; }) { const { post, parent, - linksEnabled = true, - avatarSize = "md", + avatarSize = "medium", showEmbed = true, + compactEmbed = false, showBlueskyLink = true, - onEmbedClick, - onLinkClick, + quoteEnabled, + replyEnabled, + replyOnClick, } = props; const record = post.record as AppBskyFeedPost.Record; const postId = post.uri.split("/")[4]; const url = `https://bsky.app/profile/${post.author.handle}/post/${postId}`; - const avatarClass = avatarSize === "sm" ? "w-8 h-8" : "w-10 h-10"; - return ( - <> -
- {post.author.avatar ? ( - + + {(post.quoteCount && post.quoteCount > 0) || + (post.replyCount && post.replyCount > 0) ? ( +
+ +
+ ) : null}
+
+
+ ); +} + +function PostInfo(props: { + displayName?: string; + handle: string; + createdAt: string; + compact?: boolean; +}) { + const { displayName, handle, createdAt, compact = false } = props; -
- - +
+
+ {displayName} +
+
+ + @{handle} +
+ } + didOrHandle={handle} />
- +
+
+ {timeAgo(createdAt, { compact: true })} +
+
); } function PostCounts(props: { post: PostView; parent?: OpenPage; - linksEnabled: boolean; + quoteEnabled?: boolean; + replyEnabled?: boolean; + replyOnClick?: (e: React.MouseEvent) => void; showBlueskyLink: boolean; url: string; - onLinkClick?: (e: React.MouseEvent) => void; }) { - const { post, parent, linksEnabled, showBlueskyLink, url, onLinkClick } = props; + const replyContent = props.post.replyCount != null && + props.post.replyCount > 0 && ( +
+ + {props.post.replyCount} +
+ ); + + const quoteContent = props.post.quoteCount != null && + props.post.quoteCount > 0 && ( +
+ + {props.post.quoteCount} +
+ ); return ( -
- {post.replyCount != null && post.replyCount > 0 && ( - <> - - {linksEnabled ? ( - - {post.replyCount} - - - ) : ( -
- {post.replyCount} - -
- )} - - )} - {post.quoteCount != null && post.quoteCount > 0 && ( - <> - +
+ {replyContent && + (props.replyEnabled ? ( + + {replyContent} + + ) : ( + replyContent + ))} + {quoteContent && + (props.quoteEnabled ? ( - {post.quoteCount} - + {quoteContent} - - )} - {showBlueskyLink && ( - <> - - - - - - )} + ) : ( + quoteContent + ))}
); } diff --git a/app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx b/app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx index a2cdf6ce..bf57101e 100644 --- a/app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx +++ b/app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx @@ -15,7 +15,10 @@ import { PostPages } from "./PostPages"; import { extractCodeBlocks } from "./extractCodeBlocks"; import { LeafletLayout } from "components/LeafletLayout"; import { fetchPollData } from "./fetchPollData"; -import { getDocumentPages, hasLeafletContent } from "src/utils/normalizeRecords"; +import { + getDocumentPages, + hasLeafletContent, +} from "src/utils/normalizeRecords"; import { DocumentProvider } from "contexts/DocumentContext"; import { LeafletContentProvider } from "contexts/LeafletContentContext"; @@ -118,8 +121,15 @@ export async function DocumentPageRenderer({ return ( - - + + -
- Comments +
+

Comments

)}
-
+
{comments .sort((a, b) => { let aRecord = a.record as PubLeafletComment.Record; @@ -119,26 +119,23 @@ const Comment = (props: { }) => { const did = props.comment.bsky_profiles?.did; - let timeAgoDate = timeAgo(props.record.createdAt); - const formattedDate = useLocalizedDate(props.record.createdAt, { - year: "numeric", - month: "long", - day: "2-digit", - }); + let timeAgoDate = timeAgo(props.record.createdAt, { compact: true }); return (
-
+
{did ? ( +
{props.profile.displayName}
} /> ) : null} + +
{timeAgoDate}
{props.record.attachment && @@ -210,7 +207,8 @@ const Replies = (props: { setReplyBoxOpen(false); }} > - {replies.length} + {" "} + {replies.length !== 0 && replies.length} {identity?.atp_did && ( <> diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx b/app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx index 9283aa1c..b690925b 100644 --- a/app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx @@ -1,9 +1,9 @@ "use client"; import { Media } from "components/Media"; -import { Quotes } from "./Quotes"; +import { MentionsDrawerContent } from "./Quotes"; import { InteractionState, useInteractionState } from "./Interactions"; import { Json } from "supabase/database.types"; -import { Comment, Comments } from "./Comments"; +import { Comment, CommentsDrawerContent } from "./Comments"; import { useSearchParams } from "next/navigation"; import { SandwichSpacer } from "components/LeafletLayout"; import { decodeQuotePosition } from "../quotePosition"; @@ -42,9 +42,12 @@ export const InteractionDrawer = (props: { className={`opaque-container h-full w-full px-3 sm:px-4 pt-2 sm:pt-3 pb-6 overflow-scroll ${props.showPageBackground ? "rounded-l-none! rounded-r-lg! -ml-[1px]" : "rounded-lg! sm:ml-4"}`} > {drawer.drawer === "quotes" ? ( - + ) : ( - { @@ -85,18 +86,13 @@ export const Quotes = (props: { }); return ( -
-
- Quotes - -
+
+ {props.quotesAndMentions.length === 0 ? (
no quotes yet!
@@ -108,62 +104,58 @@ export const Quotes = (props: {
) : ( -
- {/* Quotes with links (quoted content) */} - {quotesWithLinks.map((q, index) => { - const pv = postViewMap.get(q.uri); - if (!pv || !q.link) return null; - const url = new URL(q.link); - const quoteParam = url.pathname.split("/l-quote/")[1]; - if (!quoteParam) return null; - const quotePosition = decodeQuotePosition(quoteParam); - if (!quotePosition) return null; - return ( -
- - -
- -
- ); - })} - +
+ {quotesWithLinks.length > 0 && ( +
+

Quotes on Bluesky

+ {/* Quotes with links (quoted content) */} + {quotesWithLinks.map((q, index) => { + return ( + <> + + {quotesWithLinks.length !== index + 1 && ( +
+ )} + + ); + })} +
+ )} {/* Direct post mentions (without quoted content) */} {directMentions.length > 0 && ( -
-
Post Mentions
-
- {directMentions.map((q, index) => { - const pv = postViewMap.get(q.uri); - if (!pv) return null; - return ( - +

Mentions on Bluesky

+ {directMentions.map((q, index) => { + const post = postViewMap.get(q.uri); + if (!post) return null; + + const parent = { type: "thread" as const, uri: q.uri }; + return ( + <> + - ); - })} -
+ {directMentions.length !== index + 1 && ( +
+ )} + + ); + })}
)}
@@ -172,6 +164,47 @@ export const Quotes = (props: { ); }; +const Quote = (props: { + q: { + uri: string; + link?: string; + }; + index: number; + did: string; + postViewMap: Map; +}) => { + const post = props.postViewMap.get(props.q.uri); + if (!post || !props.q.link) return null; + const parent = { type: "thread" as const, uri: props.q.uri }; + const url = new URL(props.q.link); + const quoteParam = url.pathname.split("/l-quote/")[1]; + if (!quoteParam) return null; + const quotePosition = decodeQuotePosition(quoteParam); + if (!quotePosition) return null; + + return ( +
+ + +
+ +
+ ); +}; + export const QuoteContent = (props: { position: QuotePosition; index: number; @@ -206,12 +239,15 @@ export const QuoteContent = (props: { className="quoteSectionQuote text-secondary text-sm text-left hover:cursor-pointer" onClick={(e) => { if (props.position.pageId) - flushSync(() => openPage(undefined, { type: "doc", id: props.position.pageId! })); + flushSync(() => + openPage(undefined, { type: "doc", id: props.position.pageId! }), + ); let scrollMargin = isMobile ? 16 : e.currentTarget.getBoundingClientRect().top; let scrollContainerId = `post-page-${props.position.pageId ?? document_uri}`; - let scrollContainer = window.document.getElementById(scrollContainerId); + let scrollContainer = + window.document.getElementById(scrollContainerId); let el = window.document.getElementById( props.position.start.block.join("."), ); @@ -236,7 +272,7 @@ export const QuoteContent = (props: { blocks={content} did={props.did} preview - className="py-0!" + className="py-0! px-0! text-tertiary" />
@@ -244,72 +280,6 @@ export const QuoteContent = (props: { ); }; -export const BskyPost = (props: { - uri: string; - rkey: string; - content: string; - user: string; - handle: string; - profile: ProfileViewBasic; - replyCount?: number; - quoteCount?: number; -}) => { - const handleOpenThread = () => { - openPage(undefined, { type: "thread", uri: props.uri }); - }; - - return ( -
- {props.profile.avatar && ( - {props.profile.displayName} - )} -
- -
{props.content}
-
- {props.replyCount != null && props.replyCount > 0 && ( - e.stopPropagation()} - className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast" - > - - {props.replyCount} {props.replyCount === 1 ? "reply" : "replies"} - - )} - {props.quoteCount != null && props.quoteCount > 0 && ( - e.stopPropagation()} - className="flex items-center gap-1 text-tertiary text-xs hover:text-accent-contrast" - > - - {props.quoteCount} {props.quoteCount === 1 ? "quote" : "quotes"} - - )} -
-
-
- ); -}; - function extractQuotedBlocks( blocks: PubLeafletPagesLinearDocument.Block[], quotePosition: QuotePosition, diff --git a/app/lish/[did]/[publication]/[rkey]/PostContent.tsx b/app/lish/[did]/[publication]/[rkey]/PostContent.tsx index 58096e70..8e53ba17 100644 --- a/app/lish/[did]/[publication]/[rkey]/PostContent.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PostContent.tsx @@ -28,8 +28,6 @@ import { StaticMathBlock } from "./Blocks/StaticMathBlock"; import { PubCodeBlock } from "./Blocks/PubCodeBlock"; import { AppBskyFeedDefs } from "@atproto/api"; import { PubBlueskyPostBlock } from "./Blocks/PublishBskyPostBlock"; -import { openPage } from "./PostPages"; -import { PageLinkBlock } from "components/Blocks/PageLinkBlock"; import { PublishedPageLinkBlock } from "./Blocks/PublishedPageBlock"; import { PublishedPollBlock } from "./Blocks/PublishedPollBlock"; import { PollData } from "./fetchPollData"; diff --git a/app/lish/[did]/[publication]/[rkey]/PostLinks.tsx b/app/lish/[did]/[publication]/[rkey]/PostLinks.tsx index 80da034b..5762dea8 100644 --- a/app/lish/[did]/[publication]/[rkey]/PostLinks.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PostLinks.tsx @@ -55,22 +55,23 @@ export const prefetchQuotes = (uri: string) => { // Link component for opening thread pages with prefetching export function ThreadLink(props: { - threadUri: string; + postUri: string; parent?: OpenPage; children: React.ReactNode; className?: string; onClick?: (e: React.MouseEvent) => void; }) { - const { threadUri, parent, children, className, onClick } = props; + const { postUri, parent, children, className, onClick } = props; const handleClick = (e: React.MouseEvent) => { + e.stopPropagation(); onClick?.(e); if (e.defaultPrevented) return; - openPage(parent, { type: "thread", uri: threadUri }); + openPage(parent, { type: "thread", uri: postUri }); }; const handlePrefetch = () => { - prefetchThread(threadUri); + prefetchThread(postUri); }; return ( @@ -96,6 +97,7 @@ export function QuotesLink(props: { const { postUri, parent, children, className, onClick } = props; const handleClick = (e: React.MouseEvent) => { + e.stopPropagation(); onClick?.(e); if (e.defaultPrevented) return; openPage(parent, { type: "quotes", uri: postUri }); @@ -104,7 +106,6 @@ export function QuotesLink(props: { const handlePrefetch = () => { prefetchQuotes(postUri); }; - return ( - {isCollapsed ? ( - - ) : ( -
- -
- )} -
- )} - {hasReplies && depth >= 3 && ( - - View more replies - - )} -
+ toggleCollapsed(uri)} + isCollapsed={isCollapsed} + depth={props.depth} + /> ); })} + {pageUri && depth > 0 && replies.length > 3 && ( + +
+ View {replies.length - 3} more{" "} + {replies.length === 4 ? "reply" : "replies"} + + )}
); } -function ReplyPost(props: { +const ReplyPost = (props: { post: ThreadViewPost; - showReplyLine: boolean; isLast: boolean; - threadUri: string; -}) { - const { post, threadUri } = props; + pageUri: string; + parentPostUri: string; + toggleCollapsed: (uri: string) => void; + isCollapsed: boolean; + depth: number; +}) => { + const { post, pageUri, parentPostUri } = props; const postView = post.post; - const parent = { type: "thread" as const, uri: threadUri }; + + const hasReplies = props.post.replies && props.post.replies.length > 0; return ( -
openPage(parent, { type: "thread", uri: postView.uri })} - > - e.stopPropagation()} - onEmbedClick={(e) => e.stopPropagation()} - /> +
+ {props.depth > 0 && ( + <> +
+
+
+
); -} +}; diff --git a/components/Avatar.tsx b/components/Avatar.tsx index 8efc20a3..0e193e34 100644 --- a/components/Avatar.tsx +++ b/components/Avatar.tsx @@ -4,14 +4,25 @@ export const Avatar = (props: { src: string | undefined; displayName: string | undefined; className?: string; - tiny?: boolean; - large?: boolean; - giant?: boolean; + size?: "tiny" | "small" | "medium" | "large" | "giant"; }) => { + let sizeClassName = + props.size === "tiny" + ? "w-4 h-4" + : props.size === "small" + ? "w-5 h-5" + : props.size === "medium" + ? "h-6 w-6" + : props.size === "large" + ? "w-8 h-8" + : props.size === "giant" + ? "h-16 w-16" + : "w-6 h-6"; + if (props.src) return ( { - +
); }; diff --git a/components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx b/components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx index 96b70eb0..a55c8092 100644 --- a/components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx +++ b/components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx @@ -10,10 +10,18 @@ import { AppBskyGraphDefs, AppBskyLabelerDefs, } from "@atproto/api"; +import { Avatar } from "components/Avatar"; +import { + OpenPage, + openPage, +} from "app/lish/[did]/[publication]/[rkey]/PostPages"; export const BlueskyEmbed = (props: { embed: Exclude; postUrl?: string; + className?: string; + compact?: boolean; + parent?: OpenPage; }) => { // check this file from bluesky for ref // https://github.com/bluesky-social/social-app/blob/main/bskyembed/src/components/embed.tsx @@ -21,7 +29,7 @@ export const BlueskyEmbed = (props: { case AppBskyEmbedImages.isView(props.embed): let imageEmbed = props.embed; return ( -
+
{imageEmbed.images.map( ( image: { @@ -68,7 +76,7 @@ export const BlueskyEmbed = (props: { let isGif = externalEmbed.external.uri.includes(".gif"); if (isGif) { return ( -
+
{externalEmbed.external.title} {externalEmbed.external.thumb === undefined ? null : ( <> -
+ -
+ {!props.compact &&
} )} -
-
-

{externalEmbed.external.title}

-

+

+

+ {externalEmbed.external.title}{" "} +

+
+

{externalEmbed.external.description}

-
-
+ +
+
{externalEmbed.external.uri}
@@ -116,7 +132,7 @@ export const BlueskyEmbed = (props: { : 16 / 9; return (
{ + e.preventDefault(); + e.stopPropagation(); + + openPage(props.parent, { type: "thread", uri: record.uri }); + }} > -
- {record.author.avatar && ( - {`${record.author?.displayName}'s - )} -
- {record.author?.displayName} + +
+
+
+ {record.author?.displayName} +
+ + @{record.author?.handle} + +
+
+ {text && ( +
+                    {text}
+                  
+ )} + {/*{record.embeds !== undefined + ? record.embeds.map((embed, index) => ( + + )) + : null}*/}
- - @{record.author?.handle} - -
- -
- {text && ( -
{text}
- )} - {record.embeds !== undefined - ? record.embeds.map((embed, index) => ( - - )) - : null}
-
+ ); } @@ -207,7 +232,7 @@ export const BlueskyEmbed = (props: { case AppBskyEmbedRecordWithMedia.isView(props.embed) && AppBskyEmbedRecord.isViewRecord(props.embed.record.record): return ( -
+
{ let { permissions } = useEntitySetContext(); @@ -76,76 +78,24 @@ export const BlueskyPostBlock = (props: BlockProps & { preview?: boolean }) => { //getting the url to the post let postId = post.post.uri.split("/")[4]; + let postView = post.post as PostView; let url = `https://bsky.app/profile/${post.post.author.handle}/post/${postId}`; return ( - {post.post.author && record && ( - <> -
- {post.post.author?.avatar ? ( - {`${post.post.author?.displayName}'s - ) : ( -
- )} -
-
- {post.post.author?.displayName} -
- - @{post.post.author?.handle} - -
-
- -
-
-
-                    {BlueskyRichText({
-                      record: record as AppBskyFeedPost.Record | null,
-                    })}
-                  
-
- {post.post.embed && ( - - )} -
- - )} -
- {timestamp && } -
- {post.post.replyCount != null && post.post.replyCount > 0 && ( - <> - - {post.post.replyCount} - - - - - )} - - - - -
-
+
); } diff --git a/components/Pages/Page.tsx b/components/Pages/Page.tsx index 78ffdbcf..134f6ede 100644 --- a/components/Pages/Page.tsx +++ b/components/Pages/Page.tsx @@ -80,6 +80,7 @@ export const PageWrapper = (props: { onClickAction?: (e: React.MouseEvent) => void; pageType: "canvas" | "doc"; drawerOpen: boolean | undefined; + fixedWidth?: boolean; }) => { const cardBorderHidden = useCardBorderHidden(); let { ref } = usePreserveScroll(props.id); @@ -112,7 +113,7 @@ export const PageWrapper = (props: { } ${cardBorderHidden && "sm:h-[calc(100%+48px)] h-[calc(100%+20px)] sm:-my-6 -my-3 sm:pt-6 pt-3"} ${props.fullPageScroll && "max-w-full "} - ${props.pageType === "doc" && !props.fullPageScroll && "w-[10000px] sm:mx-0 max-w-[var(--page-width-units)]"} + ${props.pageType === "doc" && !props.fullPageScroll ? (props.fixedWidth ? "w-[10000px] sm:max-w-prose max-w-[var(--page-width-units)]" : "w-[10000px] sm:mx-0 max-w-[var(--page-width-units)]") : ""} ${ props.pageType === "canvas" && !props.fullPageScroll && diff --git a/src/utils/scrollIntoView.ts b/src/utils/scrollIntoView.ts index 1b8ff6a1..ce2dd4de 100644 --- a/src/utils/scrollIntoView.ts +++ b/src/utils/scrollIntoView.ts @@ -6,5 +6,12 @@ export function scrollIntoView( threshold: number = 0.9, ) { const element = document.getElementById(elementId); - scrollIntoViewIfNeeded(element, false, "smooth"); + // Use double requestAnimationFrame to ensure the element is fully painted + // before attempting to scroll. This fixes smooth scrolling when opening + // pages from within other pages. + requestAnimationFrame(() => { + requestAnimationFrame(() => { + scrollIntoViewIfNeeded(element, false, "smooth"); + }); + }); } diff --git a/src/utils/timeAgo.ts b/src/utils/timeAgo.ts index 0a59025d..dfdb1754 100644 --- a/src/utils/timeAgo.ts +++ b/src/utils/timeAgo.ts @@ -1,4 +1,8 @@ -export function timeAgo(timestamp: string): string { +export function timeAgo( + timestamp: string, + options?: { compact?: boolean }, +): string { + const { compact } = options ?? {}; const now = new Date(); const date = new Date(timestamp); const diffMs = now.getTime() - date.getTime(); @@ -10,6 +14,24 @@ export function timeAgo(timestamp: string): string { const diffMonths = Math.floor(diffDays / 30); const diffYears = Math.floor(diffDays / 365); + if (compact) { + if (diffYears > 0) { + return `${diffYears}y`; + } else if (diffMonths > 0) { + return `${diffMonths}mo`; + } else if (diffWeeks > 0) { + return `${diffWeeks}w`; + } else if (diffDays > 0) { + return `${diffDays}d`; + } else if (diffHours > 0) { + return `${diffHours}h`; + } else if (diffMinutes > 0) { + return `${diffMinutes}m`; + } else { + return "now"; + } + } + if (diffYears > 0) { return `${diffYears} year${diffYears === 1 ? "" : "s"} ago`; } else if (diffMonths > 0) { -- 2.51.2 From 7c6ed6226fec96ecf5d905bb4f2f2462356d080b Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Tue, 27 Jan 2026 18:17:58 -0500 Subject: [PATCH 2/3] add recommends lexicon --- appview/index.ts | 24 +++++ lexicons/api/index.ts | 97 +++++++++++++++++++ lexicons/api/lexicons.ts | 26 +++++ .../pub/leaflet/interactions/recommend.ts | 32 ++++++ lexicons/build.ts | 2 + lexicons/pub/leaflet/authFullPermissions.json | 3 +- .../pub/leaflet/interactions/recommend.json | 28 ++++++ lexicons/src/authFullPermissions.ts | 2 + lexicons/src/interactions/index.ts | 21 ++++ .../20260127000000_add_recommends_table.sql | 65 +++++++++++++ 10 files changed, 299 insertions(+), 1 deletion(-) create mode 100644 lexicons/api/types/pub/leaflet/interactions/recommend.ts create mode 100644 lexicons/pub/leaflet/interactions/recommend.json create mode 100644 lexicons/src/interactions/index.ts create mode 100644 supabase/migrations/20260127000000_add_recommends_table.sql diff --git a/appview/index.ts b/appview/index.ts index b9f5a2de..3588b659 100644 --- a/appview/index.ts +++ b/appview/index.ts @@ -11,6 +11,7 @@ import { PubLeafletComment, PubLeafletPollVote, PubLeafletPollDefinition, + PubLeafletInteractionsRecommend, SiteStandardDocument, SiteStandardPublication, SiteStandardGraphSubscription, @@ -48,6 +49,7 @@ async function main() { ids.PubLeafletComment, ids.PubLeafletPollVote, ids.PubLeafletPollDefinition, + ids.PubLeafletInteractionsRecommend, // ids.AppBskyActorProfile, "app.bsky.feed.post", ids.SiteStandardDocument, @@ -210,6 +212,28 @@ async function handleEvent(evt: Event) { .eq("uri", evt.uri.toString()); } } + if (evt.collection === ids.PubLeafletInteractionsRecommend) { + if (evt.event === "create" || evt.event === "update") { + let record = PubLeafletInteractionsRecommend.validateRecord(evt.record); + if (!record.success) return; + await supabase + .from("identities") + .upsert({ atp_did: evt.did }, { onConflict: "atp_did" }); + let { error } = await supabase.from("recommends_on_documents").upsert({ + uri: evt.uri.toString(), + recommender_did: evt.did, + document: record.value.subject, + record: record.value as Json, + }); + if (error) console.log("Error upserting recommend:", error); + } + if (evt.event === "delete") { + await supabase + .from("recommends_on_documents") + .delete() + .eq("uri", evt.uri.toString()); + } + } if (evt.collection === ids.PubLeafletGraphSubscription) { if (evt.event === "create" || evt.event === "update") { let record = PubLeafletGraphSubscription.validateRecord(evt.record); diff --git a/lexicons/api/index.ts b/lexicons/api/index.ts index fd28a572..76fdef62 100644 --- a/lexicons/api/index.ts +++ b/lexicons/api/index.ts @@ -41,6 +41,7 @@ import * as PubLeafletComment from './types/pub/leaflet/comment' import * as PubLeafletContent from './types/pub/leaflet/content' import * as PubLeafletDocument from './types/pub/leaflet/document' import * as PubLeafletGraphSubscription from './types/pub/leaflet/graph/subscription' +import * as PubLeafletInteractionsRecommend from './types/pub/leaflet/interactions/recommend' import * as PubLeafletPagesCanvas from './types/pub/leaflet/pages/canvas' import * as PubLeafletPagesLinearDocument from './types/pub/leaflet/pages/linearDocument' import * as PubLeafletPollDefinition from './types/pub/leaflet/poll/definition' @@ -87,6 +88,7 @@ export * as PubLeafletComment from './types/pub/leaflet/comment' export * as PubLeafletContent from './types/pub/leaflet/content' export * as PubLeafletDocument from './types/pub/leaflet/document' export * as PubLeafletGraphSubscription from './types/pub/leaflet/graph/subscription' +export * as PubLeafletInteractionsRecommend from './types/pub/leaflet/interactions/recommend' export * as PubLeafletPagesCanvas from './types/pub/leaflet/pages/canvas' export * as PubLeafletPagesLinearDocument from './types/pub/leaflet/pages/linearDocument' export * as PubLeafletPollDefinition from './types/pub/leaflet/poll/definition' @@ -408,6 +410,7 @@ export class PubLeafletNS { publication: PubLeafletPublicationRecord blocks: PubLeafletBlocksNS graph: PubLeafletGraphNS + interactions: PubLeafletInteractionsNS pages: PubLeafletPagesNS poll: PubLeafletPollNS richtext: PubLeafletRichtextNS @@ -417,6 +420,7 @@ export class PubLeafletNS { this._client = client this.blocks = new PubLeafletBlocksNS(client) this.graph = new PubLeafletGraphNS(client) + this.interactions = new PubLeafletInteractionsNS(client) this.pages = new PubLeafletPagesNS(client) this.poll = new PubLeafletPollNS(client) this.richtext = new PubLeafletRichtextNS(client) @@ -528,6 +532,99 @@ export class PubLeafletGraphSubscriptionRecord { } } +export class PubLeafletInteractionsNS { + _client: XrpcClient + recommend: PubLeafletInteractionsRecommendRecord + + constructor(client: XrpcClient) { + this._client = client + this.recommend = new PubLeafletInteractionsRecommendRecord(client) + } +} + +export class PubLeafletInteractionsRecommendRecord { + _client: XrpcClient + + constructor(client: XrpcClient) { + this._client = client + } + + async list( + params: OmitKey, + ): Promise<{ + cursor?: string + records: { uri: string; value: PubLeafletInteractionsRecommend.Record }[] + }> { + const res = await this._client.call('com.atproto.repo.listRecords', { + collection: 'pub.leaflet.interactions.recommend', + ...params, + }) + return res.data + } + + async get( + params: OmitKey, + ): Promise<{ + uri: string + cid: string + value: PubLeafletInteractionsRecommend.Record + }> { + const res = await this._client.call('com.atproto.repo.getRecord', { + collection: 'pub.leaflet.interactions.recommend', + ...params, + }) + return res.data + } + + async create( + params: OmitKey< + ComAtprotoRepoCreateRecord.InputSchema, + 'collection' | 'record' + >, + record: Un$Typed, + headers?: Record, + ): Promise<{ uri: string; cid: string }> { + const collection = 'pub.leaflet.interactions.recommend' + const res = await this._client.call( + 'com.atproto.repo.createRecord', + undefined, + { collection, ...params, record: { ...record, $type: collection } }, + { encoding: 'application/json', headers }, + ) + return res.data + } + + async put( + params: OmitKey< + ComAtprotoRepoPutRecord.InputSchema, + 'collection' | 'record' + >, + record: Un$Typed, + headers?: Record, + ): Promise<{ uri: string; cid: string }> { + const collection = 'pub.leaflet.interactions.recommend' + const res = await this._client.call( + 'com.atproto.repo.putRecord', + undefined, + { collection, ...params, record: { ...record, $type: collection } }, + { encoding: 'application/json', headers }, + ) + return res.data + } + + async delete( + params: OmitKey, + headers?: Record, + ): Promise { + await this._client.call( + 'com.atproto.repo.deleteRecord', + undefined, + { collection: 'pub.leaflet.interactions.recommend', ...params }, + { headers }, + ) + } +} + export class PubLeafletPagesNS { _client: XrpcClient diff --git a/lexicons/api/lexicons.ts b/lexicons/api/lexicons.ts index 5ef436e0..a197ea95 100644 --- a/lexicons/api/lexicons.ts +++ b/lexicons/api/lexicons.ts @@ -1517,6 +1517,31 @@ export const schemaDict = { }, }, }, + PubLeafletInteractionsRecommend: { + lexicon: 1, + id: 'pub.leaflet.interactions.recommend', + defs: { + main: { + type: 'record', + key: 'tid', + description: 'Record representing a recommend on a document', + record: { + type: 'object', + required: ['subject', 'createdAt'], + properties: { + subject: { + type: 'string', + format: 'at-uri', + }, + createdAt: { + type: 'string', + format: 'datetime', + }, + }, + }, + }, + }, + }, PubLeafletPagesCanvas: { lexicon: 1, id: 'pub.leaflet.pages.canvas', @@ -2418,6 +2443,7 @@ export const ids = { PubLeafletContent: 'pub.leaflet.content', PubLeafletDocument: 'pub.leaflet.document', PubLeafletGraphSubscription: 'pub.leaflet.graph.subscription', + PubLeafletInteractionsRecommend: 'pub.leaflet.interactions.recommend', PubLeafletPagesCanvas: 'pub.leaflet.pages.canvas', PubLeafletPagesLinearDocument: 'pub.leaflet.pages.linearDocument', PubLeafletPollDefinition: 'pub.leaflet.poll.definition', diff --git a/lexicons/api/types/pub/leaflet/interactions/recommend.ts b/lexicons/api/types/pub/leaflet/interactions/recommend.ts new file mode 100644 index 00000000..2db6e55d --- /dev/null +++ b/lexicons/api/types/pub/leaflet/interactions/recommend.ts @@ -0,0 +1,32 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { type ValidationResult, BlobRef } from '@atproto/lexicon' +import { CID } from 'multiformats/cid' +import { validate as _validate } from '../../../../lexicons' +import { + type $Typed, + is$typed as _is$typed, + type OmitKey, +} from '../../../../util' + +const is$typed = _is$typed, + validate = _validate +const id = 'pub.leaflet.interactions.recommend' + +export interface Record { + $type: 'pub.leaflet.interactions.recommend' + subject: string + createdAt: string + [k: string]: unknown +} + +const hashRecord = 'main' + +export function isRecord(v: V) { + return is$typed(v, id, hashRecord) +} + +export function validateRecord(v: V) { + return validate(v, id, hashRecord, true) +} diff --git a/lexicons/build.ts b/lexicons/build.ts index 1b5fcc29..3f38f99e 100644 --- a/lexicons/build.ts +++ b/lexicons/build.ts @@ -3,6 +3,7 @@ import { BlockLexicons } from "./src/blocks"; import { PubLeafletDocument } from "./src/document"; import * as PublicationLexicons from "./src/publication"; import * as PollLexicons from "./src/polls"; +import * as InteractionsLexicons from "./src/interactions"; import { ThemeLexicons } from "./src/theme"; import * as fs from "fs"; @@ -31,6 +32,7 @@ const lexicons = [ ...BlockLexicons, ...Object.values(PublicationLexicons), ...Object.values(PollLexicons), + ...Object.values(InteractionsLexicons), ]; // Write each lexicon to a file diff --git a/lexicons/pub/leaflet/authFullPermissions.json b/lexicons/pub/leaflet/authFullPermissions.json index 5d66b6c2..4c5492c6 100644 --- a/lexicons/pub/leaflet/authFullPermissions.json +++ b/lexicons/pub/leaflet/authFullPermissions.json @@ -21,7 +21,8 @@ "pub.leaflet.comment", "pub.leaflet.poll.definition", "pub.leaflet.poll.vote", - "pub.leaflet.graph.subscription" + "pub.leaflet.graph.subscription", + "pub.leaflet.interactions.recommend" ] } ] diff --git a/lexicons/pub/leaflet/interactions/recommend.json b/lexicons/pub/leaflet/interactions/recommend.json new file mode 100644 index 00000000..a9920cc6 --- /dev/null +++ b/lexicons/pub/leaflet/interactions/recommend.json @@ -0,0 +1,28 @@ +{ + "lexicon": 1, + "id": "pub.leaflet.interactions.recommend", + "defs": { + "main": { + "type": "record", + "key": "tid", + "description": "Record representing a recommend on a document", + "record": { + "type": "object", + "required": [ + "subject", + "createdAt" + ], + "properties": { + "subject": { + "type": "string", + "format": "at-uri" + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + } + } + } + } +} \ No newline at end of file diff --git a/lexicons/src/authFullPermissions.ts b/lexicons/src/authFullPermissions.ts index 1087df94..68889ecc 100644 --- a/lexicons/src/authFullPermissions.ts +++ b/lexicons/src/authFullPermissions.ts @@ -6,6 +6,7 @@ import { } from "./publication"; import { PubLeafletComment } from "./comment"; import { PubLeafletPollDefinition, PubLeafletPollVote } from "./polls"; +import { PubLeafletInteractionsRecommend } from "./interactions"; export const PubLeafletAuthFullPermissions: LexiconDoc = { lexicon: 1, @@ -28,6 +29,7 @@ export const PubLeafletAuthFullPermissions: LexiconDoc = { PubLeafletPollDefinition.id, PubLeafletPollVote.id, PubLeafletPublicationSubscription.id, + PubLeafletInteractionsRecommend.id, ], }, ], diff --git a/lexicons/src/interactions/index.ts b/lexicons/src/interactions/index.ts new file mode 100644 index 00000000..0da8aedb --- /dev/null +++ b/lexicons/src/interactions/index.ts @@ -0,0 +1,21 @@ +import { LexiconDoc } from "@atproto/lexicon"; + +export const PubLeafletInteractionsRecommend: LexiconDoc = { + lexicon: 1, + id: "pub.leaflet.interactions.recommend", + defs: { + main: { + type: "record", + key: "tid", + description: "Record representing a recommend on a document", + record: { + type: "object", + required: ["subject", "createdAt"], + properties: { + subject: { type: "string", format: "at-uri" }, + createdAt: { type: "string", format: "datetime" }, + }, + }, + }, + }, +}; diff --git a/supabase/migrations/20260127000000_add_recommends_table.sql b/supabase/migrations/20260127000000_add_recommends_table.sql new file mode 100644 index 00000000..68ee1ce2 --- /dev/null +++ b/supabase/migrations/20260127000000_add_recommends_table.sql @@ -0,0 +1,65 @@ +create table "public"."recommends_on_documents" ( + "uri" text not null, + "record" jsonb not null, + "document" text not null, + "recommender_did" text not null, + "indexed_at" timestamp with time zone not null default now() +); + +alter table "public"."recommends_on_documents" enable row level security; + +CREATE UNIQUE INDEX recommends_on_documents_pkey ON public.recommends_on_documents USING btree (uri); + +alter table "public"."recommends_on_documents" add constraint "recommends_on_documents_pkey" PRIMARY KEY using index "recommends_on_documents_pkey"; + +CREATE INDEX recommends_on_documents_document_idx ON public.recommends_on_documents USING btree (document); + +CREATE INDEX recommends_on_documents_recommender_did_idx ON public.recommends_on_documents USING btree (recommender_did); + +CREATE UNIQUE INDEX recommends_on_documents_recommender_document_idx ON public.recommends_on_documents USING btree (recommender_did, document); + +alter table "public"."recommends_on_documents" add constraint "recommends_on_documents_document_fkey" FOREIGN KEY (document) REFERENCES documents(uri) ON UPDATE CASCADE ON DELETE CASCADE; + +alter table "public"."recommends_on_documents" add constraint "recommends_on_documents_recommender_did_fkey" FOREIGN KEY (recommender_did) REFERENCES identities(atp_did) ON UPDATE CASCADE ON DELETE CASCADE; + +grant delete on table "public"."recommends_on_documents" to "anon"; + +grant insert on table "public"."recommends_on_documents" to "anon"; + +grant references on table "public"."recommends_on_documents" to "anon"; + +grant select on table "public"."recommends_on_documents" to "anon"; + +grant trigger on table "public"."recommends_on_documents" to "anon"; + +grant truncate on table "public"."recommends_on_documents" to "anon"; + +grant update on table "public"."recommends_on_documents" to "anon"; + +grant delete on table "public"."recommends_on_documents" to "authenticated"; + +grant insert on table "public"."recommends_on_documents" to "authenticated"; + +grant references on table "public"."recommends_on_documents" to "authenticated"; + +grant select on table "public"."recommends_on_documents" to "authenticated"; + +grant trigger on table "public"."recommends_on_documents" to "authenticated"; + +grant truncate on table "public"."recommends_on_documents" to "authenticated"; + +grant update on table "public"."recommends_on_documents" to "authenticated"; + +grant delete on table "public"."recommends_on_documents" to "service_role"; + +grant insert on table "public"."recommends_on_documents" to "service_role"; + +grant references on table "public"."recommends_on_documents" to "service_role"; + +grant select on table "public"."recommends_on_documents" to "service_role"; + +grant trigger on table "public"."recommends_on_documents" to "service_role"; + +grant truncate on table "public"."recommends_on_documents" to "service_role"; + +grant update on table "public"."recommends_on_documents" to "service_role"; -- 2.51.2 From fcfee9d6ff23f078641057c9c48bc539b54f32b7 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Tue, 27 Jan 2026 18:54:52 -0500 Subject: [PATCH 3/3] add db types for recommends_on_documents --- supabase/database.types.ts | 45 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/supabase/database.types.ts b/supabase/database.types.ts index ed234f44..edbfe86e 100644 --- a/supabase/database.types.ts +++ b/supabase/database.types.ts @@ -1075,6 +1075,45 @@ export type Database = { }, ] } + recommends_on_documents: { + Row: { + document: string + indexed_at: string + recommender_did: string + record: Json + uri: string + } + Insert: { + document: string + indexed_at?: string + recommender_did: string + record: Json + uri: string + } + Update: { + document?: string + indexed_at?: string + recommender_did?: string + record?: Json + uri?: string + } + Relationships: [ + { + foreignKeyName: "recommends_on_documents_document_fkey" + columns: ["document"] + isOneToOne: false + referencedRelation: "documents" + referencedColumns: ["uri"] + }, + { + foreignKeyName: "recommends_on_documents_recommender_did_fkey" + columns: ["recommender_did"] + isOneToOne: false + referencedRelation: "identities" + referencedColumns: ["atp_did"] + }, + ] + } replicache_clients: { Row: { client_group: string @@ -1304,6 +1343,12 @@ export type Database = { like: unknown }[] } + parse_iso_timestamp: { + Args: { + "": string + } + Returns: string + } pull_data: { Args: { token_id: string