a tool for shared writing and social publishing

Compare changes

Choose any two refs to compare.

Changed files
+1688 -1800
actions
app
components
lexicons
api
types
pub
leaflet
pub
leaflet
src
src
+1 -1
.prettierrc
··· 1 - {} 1 + {}
+8 -18
actions/publishToPublication.ts
··· 2 2 3 3 import * as Y from "yjs"; 4 4 import * as base64 from "base64-js"; 5 - import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth"; 5 + import { 6 + restoreOAuthSession, 7 + OAuthSessionError, 8 + } from "src/atproto-oauth"; 6 9 import { getIdentityData } from "actions/getIdentityData"; 7 10 import { 8 11 AtpBaseClient, ··· 47 50 ColorToRGBA, 48 51 } from "components/ThemeManager/colorToLexicons"; 49 52 import { parseColor } from "@react-stately/color"; 50 - import { 51 - Notification, 52 - pingIdentityToUpdateNotification, 53 - } from "src/notifications"; 53 + import { Notification, pingIdentityToUpdateNotification } from "src/notifications"; 54 54 import { v7 } from "uuid"; 55 55 56 56 type PublishResult = ··· 253 253 254 254 // Create notifications for mentions (only on first publish) 255 255 if (!existingDocUri) { 256 - await createMentionNotifications( 257 - result.uri, 258 - record, 259 - credentialSession.did!, 260 - ); 256 + await createMentionNotifications(result.uri, record, credentialSession.did!); 261 257 } 262 258 263 259 return { success: true, rkey, record: JSON.parse(JSON.stringify(record)) }; ··· 467 463 468 464 if (b.type == "text") { 469 465 let [stringValue, facets] = getBlockContent(b.value); 470 - let [textSize] = scan.eav(b.value, "block/text-size"); 471 466 let block: $Typed<PubLeafletBlocksText.Main> = { 472 467 $type: ids.PubLeafletBlocksText, 473 468 plaintext: stringValue, 474 469 facets, 475 - ...(textSize && { textSize: textSize.data.value }), 476 470 }; 477 471 return block; 478 472 } ··· 871 865 .single(); 872 866 873 867 if (publication && publication.identity_did !== authorDid) { 874 - mentionedPublications.set( 875 - publication.identity_did, 876 - feature.atURI, 877 - ); 868 + mentionedPublications.set(publication.identity_did, feature.atURI); 878 869 } 879 870 } else if (uri.collection === "pub.leaflet.document") { 880 871 // Get the document owner's DID ··· 885 876 .single(); 886 877 887 878 if (document) { 888 - const docRecord = 889 - document.data as PubLeafletDocument.Record; 879 + const docRecord = document.data as PubLeafletDocument.Record; 890 880 if (docRecord.author !== authorDid) { 891 881 mentionedDocuments.set(docRecord.author, feature.atURI); 892 882 }
+1 -1
app/(home-pages)/notifications/CommentNotication.tsx
··· 1 - import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock"; 1 + import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/BaseTextBlock"; 2 2 import { 3 3 AppBskyActorProfile, 4 4 PubLeafletComment,
+1 -1
app/(home-pages)/notifications/Notification.tsx
··· 1 1 "use client"; 2 2 import { Avatar } from "components/Avatar"; 3 - import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock"; 3 + import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/BaseTextBlock"; 4 4 import { PubLeafletPublication, PubLeafletRichtextFacet } from "lexicons/api"; 5 5 import { timeAgo } from "src/utils/timeAgo"; 6 6 import { useReplicache, useEntity } from "src/replicache";
+1 -1
app/(home-pages)/notifications/ReplyNotification.tsx
··· 1 1 import { Avatar } from "components/Avatar"; 2 - import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock"; 2 + import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/BaseTextBlock"; 3 3 import { ReplyTiny } from "components/Icons/ReplyTiny"; 4 4 import { 5 5 CommentInNotification,
+1 -1
app/(home-pages)/p/[didOrHandle]/comments/CommentsContent.tsx
··· 6 6 import { PubLeafletComment, PubLeafletDocument } from "lexicons/api"; 7 7 import { ReplyTiny } from "components/Icons/ReplyTiny"; 8 8 import { Avatar } from "components/Avatar"; 9 - import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock"; 9 + import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/BaseTextBlock"; 10 10 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 11 11 import { 12 12 getProfileComments,
-24
app/[leaflet_id]/actions/HelpButton.tsx
··· 58 58 keys={[metaKey(), isMac() ? "Ctrl" : "Meta", "X"]} 59 59 /> 60 60 <KeyboardShortcut name="Inline Link" keys={[metaKey(), "K"]} /> 61 - <KeyboardShortcut 62 - name="Make Title" 63 - keys={[metaKey(), isMac() ? "Opt" : "Alt", "1"]} 64 - /> 65 - <KeyboardShortcut 66 - name="Make Heading" 67 - keys={[metaKey(), isMac() ? "Opt" : "Alt", "2"]} 68 - /> 69 - <KeyboardShortcut 70 - name="Make Subheading" 71 - keys={[metaKey(), isMac() ? "Opt" : "Alt", "3"]} 72 - /> 73 - <KeyboardShortcut 74 - name="Regular Text" 75 - keys={[metaKey(), isMac() ? "Opt" : "Alt", "0"]} 76 - /> 77 - <KeyboardShortcut 78 - name="Large Text" 79 - keys={[metaKey(), isMac() ? "Opt" : "Alt", "+"]} 80 - /> 81 - <KeyboardShortcut 82 - name="Small Text" 83 - keys={[metaKey(), isMac() ? "Opt" : "Alt", "-"]} 84 - /> 85 61 86 62 <Label>Block Shortcuts</Label> 87 63 {/* shift + up/down arrows (or click + drag): select multiple blocks */}
+2 -2
app/[leaflet_id]/actions/PublishButton.tsx
··· 136 136 content: ( 137 137 <div> 138 138 {pub.doc ? "Updated! " : "Published! "} 139 - <SpeedyLink className="underline font-bold" href={docUrl}> 140 - See Post 139 + <SpeedyLink className="underline" href={docUrl}> 140 + See Published Post 141 141 </SpeedyLink> 142 142 </div> 143 143 ),
+1 -5
app/[leaflet_id]/publish/PublishPost.tsx
··· 199 199 className="place-self-end h-[30px]" 200 200 disabled={charCount > 300} 201 201 > 202 - {isLoading ? ( 203 - <DotLoader className="h-[23px]" /> 204 - ) : ( 205 - "Publish this Post!" 206 - )} 202 + {isLoading ? <DotLoader /> : "Publish this Post!"} 207 203 </ButtonPrimary> 208 204 </div> 209 205 {oauthError && (
+26
app/lish/[did]/[publication]/[rkey]/BaseTextBlock.tsx
··· 1 + import { ProfilePopover } from "components/ProfilePopover"; 2 + import { TextBlockCore, TextBlockCoreProps, RichText } from "./TextBlockCore"; 3 + import { ReactNode } from "react"; 4 + 5 + // Re-export RichText for backwards compatibility 6 + export { RichText }; 7 + 8 + function DidMentionWithPopover(props: { did: string; children: ReactNode }) { 9 + return ( 10 + <ProfilePopover 11 + didOrHandle={props.did} 12 + trigger={props.children} 13 + /> 14 + ); 15 + } 16 + 17 + export function BaseTextBlock(props: Omit<TextBlockCoreProps, "renderers">) { 18 + return ( 19 + <TextBlockCore 20 + {...props} 21 + renderers={{ 22 + DidMention: DidMentionWithPopover, 23 + }} 24 + /> 25 + ); 26 + }
-25
app/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock.tsx
··· 1 - import { ProfilePopover } from "components/ProfilePopover"; 2 - import { 3 - TextBlockCore, 4 - TextBlockCoreProps, 5 - RichText, 6 - } from "../Blocks/TextBlockCore"; 7 - import { ReactNode } from "react"; 8 - 9 - // Re-export RichText for backwards compatibility 10 - export { RichText }; 11 - 12 - function DidMentionWithPopover(props: { did: string; children: ReactNode }) { 13 - return <ProfilePopover didOrHandle={props.did} trigger={props.children} />; 14 - } 15 - 16 - export function BaseTextBlock(props: Omit<TextBlockCoreProps, "renderers">) { 17 - return ( 18 - <TextBlockCore 19 - {...props} 20 - renderers={{ 21 - DidMention: DidMentionWithPopover, 22 - }} 23 - /> 24 - ); 25 - }
-28
app/lish/[did]/[publication]/[rkey]/Blocks/PubCodeBlock.tsx
··· 1 - "use client"; 2 - 3 - import { PubLeafletBlocksCode } from "lexicons/api"; 4 - import { useLayoutEffect, useState } from "react"; 5 - import { codeToHtml, bundledLanguagesInfo, bundledThemesInfo } from "shiki"; 6 - 7 - export function PubCodeBlock({ 8 - block, 9 - prerenderedCode, 10 - }: { 11 - block: PubLeafletBlocksCode.Main; 12 - prerenderedCode?: string; 13 - }) { 14 - const [html, setHTML] = useState<string | null>(prerenderedCode || null); 15 - 16 - useLayoutEffect(() => { 17 - const lang = bundledLanguagesInfo.find((l) => l.id === block.language)?.id || "plaintext"; 18 - const theme = bundledThemesInfo.find((t) => t.id === block.syntaxHighlightingTheme)?.id || "github-light"; 19 - 20 - codeToHtml(block.plaintext, { lang, theme }).then(setHTML); 21 - }, [block]); 22 - return ( 23 - <div 24 - className="w-full min-h-[42px] my-2 rounded-md border-border-light outline-border-light selected-outline" 25 - dangerouslySetInnerHTML={{ __html: html || "" }} 26 - /> 27 - ); 28 - }
-174
app/lish/[did]/[publication]/[rkey]/Blocks/PublishBskyPostBlock.tsx
··· 1 - import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 2 - import { AppBskyFeedDefs, AppBskyFeedPost } from "@atproto/api"; 3 - import { Separator } from "components/Layout"; 4 - import { useHasPageLoaded } from "components/InitialPageLoadProvider"; 5 - import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 6 - import { CommentTiny } from "components/Icons/CommentTiny"; 7 - import { QuoteTiny } from "components/Icons/QuoteTiny"; 8 - import { ThreadLink, QuotesLink } from "../PostLinks"; 9 - import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 10 - import { 11 - BlueskyEmbed, 12 - PostNotAvailable, 13 - } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; 14 - import { BlueskyRichText } from "components/Blocks/BlueskyPostBlock/BlueskyRichText"; 15 - import { openPage } from "../PostPages"; 16 - 17 - export const PubBlueskyPostBlock = (props: { 18 - post: PostView; 19 - className: string; 20 - pageId?: string; 21 - }) => { 22 - let post = props.post; 23 - 24 - const handleOpenThread = () => { 25 - openPage(props.pageId ? { type: "doc", id: props.pageId } : undefined, { 26 - type: "thread", 27 - uri: post.uri, 28 - }); 29 - }; 30 - 31 - switch (true) { 32 - case AppBskyFeedDefs.isBlockedPost(post) || 33 - AppBskyFeedDefs.isBlockedAuthor(post) || 34 - AppBskyFeedDefs.isNotFoundPost(post): 35 - return ( 36 - <div className={`w-full`}> 37 - <PostNotAvailable /> 38 - </div> 39 - ); 40 - 41 - case AppBskyFeedDefs.validatePostView(post).success: 42 - let record = post.record as AppBskyFeedDefs.PostView["record"]; 43 - 44 - // silliness to get the text and timestamp from the record with proper types 45 - let timestamp: string | undefined = undefined; 46 - if (AppBskyFeedPost.isRecord(record)) { 47 - timestamp = (record as AppBskyFeedPost.Record).createdAt; 48 - } 49 - 50 - //getting the url to the post 51 - let postId = post.uri.split("/")[4]; 52 - let url = `https://bsky.app/profile/${post.author.handle}/post/${postId}`; 53 - 54 - const parent = props.pageId 55 - ? { type: "doc" as const, id: props.pageId } 56 - : undefined; 57 - 58 - return ( 59 - <div 60 - onClick={handleOpenThread} 61 - className={` 62 - ${props.className} 63 - block-border 64 - mb-2 65 - flex flex-col gap-2 relative w-full overflow-hidden group/blueskyPostBlock sm:p-3 p-2 text-sm text-secondary bg-bg-page 66 - cursor-pointer hover:border-accent-contrast 67 - `} 68 - > 69 - {post.author && record && ( 70 - <> 71 - <div className="bskyAuthor w-full flex items-center gap-2"> 72 - {post.author.avatar && ( 73 - <img 74 - src={post.author?.avatar} 75 - alt={`${post.author?.displayName}'s avatar`} 76 - className="shink-0 w-8 h-8 rounded-full border border-border-light" 77 - /> 78 - )} 79 - <div className="grow flex flex-col gap-0.5 leading-tight"> 80 - <div className=" font-bold text-secondary"> 81 - {post.author?.displayName} 82 - </div> 83 - <a 84 - className="text-xs text-tertiary hover:underline" 85 - target="_blank" 86 - href={`https://bsky.app/profile/${post.author?.handle}`} 87 - onClick={(e) => e.stopPropagation()} 88 - > 89 - @{post.author?.handle} 90 - </a> 91 - </div> 92 - </div> 93 - 94 - <div className="flex flex-col gap-2 "> 95 - <div> 96 - <pre className="whitespace-pre-wrap"> 97 - {BlueskyRichText({ 98 - record: record as AppBskyFeedPost.Record | null, 99 - })} 100 - </pre> 101 - </div> 102 - {post.embed && ( 103 - <div onClick={(e) => e.stopPropagation()}> 104 - <BlueskyEmbed embed={post.embed} postUrl={url} /> 105 - </div> 106 - )} 107 - </div> 108 - </> 109 - )} 110 - <div className="w-full flex gap-2 items-center justify-between"> 111 - <ClientDate date={timestamp} /> 112 - <div className="flex gap-2 items-center"> 113 - {post.replyCount != null && post.replyCount > 0 && ( 114 - <> 115 - <ThreadLink 116 - threadUri={post.uri} 117 - parent={parent} 118 - className="flex items-center gap-1 hover:text-accent-contrast" 119 - onClick={(e) => e.stopPropagation()} 120 - > 121 - {post.replyCount} 122 - <CommentTiny /> 123 - </ThreadLink> 124 - <Separator classname="h-4" /> 125 - </> 126 - )} 127 - {post.quoteCount != null && post.quoteCount > 0 && ( 128 - <> 129 - <QuotesLink 130 - postUri={post.uri} 131 - parent={parent} 132 - className="flex items-center gap-1 hover:text-accent-contrast" 133 - onClick={(e) => e.stopPropagation()} 134 - > 135 - {post.quoteCount} 136 - <QuoteTiny /> 137 - </QuotesLink> 138 - <Separator classname="h-4" /> 139 - </> 140 - )} 141 - 142 - <a 143 - className="" 144 - target="_blank" 145 - href={url} 146 - onClick={(e) => e.stopPropagation()} 147 - > 148 - <BlueskyTiny /> 149 - </a> 150 - </div> 151 - </div> 152 - </div> 153 - ); 154 - } 155 - }; 156 - 157 - const ClientDate = (props: { date?: string }) => { 158 - let pageLoaded = useHasPageLoaded(); 159 - const formattedDate = useLocalizedDate( 160 - props.date || new Date().toISOString(), 161 - { 162 - month: "short", 163 - day: "numeric", 164 - year: "numeric", 165 - hour: "numeric", 166 - minute: "numeric", 167 - hour12: true, 168 - }, 169 - ); 170 - 171 - if (!pageLoaded) return null; 172 - 173 - return <div className="text-xs text-tertiary">{formattedDate}</div>; 174 - };
-344
app/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx
··· 1 - "use client"; 2 - 3 - import { useEntity, useReplicache } from "src/replicache"; 4 - import { useUIState } from "src/useUIState"; 5 - import { CSSProperties, useContext, useRef } from "react"; 6 - import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 7 - import { PostContent, Block } from "../PostContent"; 8 - import { 9 - PubLeafletBlocksHeader, 10 - PubLeafletBlocksText, 11 - PubLeafletComment, 12 - PubLeafletPagesLinearDocument, 13 - PubLeafletPagesCanvas, 14 - PubLeafletPublication, 15 - } from "lexicons/api"; 16 - import { AppBskyFeedDefs } from "@atproto/api"; 17 - import { TextBlock } from "./TextBlock"; 18 - import { PostPageContext } from "../PostPageContext"; 19 - import { openPage, useOpenPages } from "../PostPages"; 20 - import { 21 - openInteractionDrawer, 22 - setInteractionState, 23 - useInteractionState, 24 - } from "../Interactions/Interactions"; 25 - import { CommentTiny } from "components/Icons/CommentTiny"; 26 - import { QuoteTiny } from "components/Icons/QuoteTiny"; 27 - import { CanvasBackgroundPattern } from "components/Canvas"; 28 - 29 - export function PublishedPageLinkBlock(props: { 30 - blocks: PubLeafletPagesLinearDocument.Block[] | PubLeafletPagesCanvas.Block[]; 31 - parentPageId: string | undefined; 32 - pageId: string; 33 - did: string; 34 - preview?: boolean; 35 - className?: string; 36 - prerenderedCodeBlocks?: Map<string, string>; 37 - bskyPostData: AppBskyFeedDefs.PostView[]; 38 - isCanvas?: boolean; 39 - pages?: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 40 - }) { 41 - //switch to use actually state 42 - let openPages = useOpenPages(); 43 - let isOpen = openPages.some((p) => p.type === "doc" && p.id === props.pageId); 44 - return ( 45 - <div 46 - className={`w-full cursor-pointer 47 - pageLinkBlockWrapper relative group/pageLinkBlock 48 - bg-bg-page shadow-sm 49 - flex overflow-clip 50 - block-border 51 - ${isOpen && "!border-tertiary"} 52 - ${props.className} 53 - `} 54 - onClick={(e) => { 55 - if (e.isDefaultPrevented()) return; 56 - if (e.shiftKey) return; 57 - e.preventDefault(); 58 - e.stopPropagation(); 59 - 60 - openPage( 61 - props.parentPageId 62 - ? { type: "doc", id: props.parentPageId } 63 - : undefined, 64 - { type: "doc", id: props.pageId }, 65 - ); 66 - }} 67 - > 68 - {props.isCanvas ? ( 69 - <CanvasLinkBlock 70 - blocks={props.blocks as PubLeafletPagesCanvas.Block[]} 71 - did={props.did} 72 - pageId={props.pageId} 73 - bskyPostData={props.bskyPostData} 74 - pages={props.pages || []} 75 - /> 76 - ) : ( 77 - <DocLinkBlock 78 - {...props} 79 - blocks={props.blocks as PubLeafletPagesLinearDocument.Block[]} 80 - /> 81 - )} 82 - </div> 83 - ); 84 - } 85 - export function DocLinkBlock(props: { 86 - blocks: PubLeafletPagesLinearDocument.Block[]; 87 - pageId: string; 88 - parentPageId?: string; 89 - did: string; 90 - preview?: boolean; 91 - className?: string; 92 - prerenderedCodeBlocks?: Map<string, string>; 93 - bskyPostData: AppBskyFeedDefs.PostView[]; 94 - }) { 95 - let [title, description] = props.blocks 96 - .map((b) => b.block) 97 - .filter( 98 - (b) => PubLeafletBlocksText.isMain(b) || PubLeafletBlocksHeader.isMain(b), 99 - ); 100 - 101 - return ( 102 - <div 103 - style={{ "--list-marker-width": "20px" } as CSSProperties} 104 - className={` 105 - w-full h-[104px] 106 - `} 107 - > 108 - <> 109 - <div className="pageLinkBlockContent w-full flex overflow-clip cursor-pointer h-full"> 110 - <div className="my-2 ml-3 grow min-w-0 text-sm bg-transparent overflow-clip flex flex-col "> 111 - <div className="grow"> 112 - {title && ( 113 - <div 114 - className={`pageBlockOne outline-none resize-none align-top gap-2 ${title.$type === "pub.leaflet.blocks.header" ? "font-bold text-base" : ""}`} 115 - > 116 - <TextBlock 117 - facets={title.facets} 118 - plaintext={title.plaintext} 119 - index={[]} 120 - preview 121 - /> 122 - </div> 123 - )} 124 - {description && ( 125 - <div 126 - className={`pageBlockLineTwo outline-none resize-none align-top gap-2 ${description.$type === "pub.leaflet.blocks.header" ? "font-bold" : ""}`} 127 - > 128 - <TextBlock 129 - facets={description.facets} 130 - plaintext={description.plaintext} 131 - index={[]} 132 - preview 133 - /> 134 - </div> 135 - )} 136 - </div> 137 - 138 - <Interactions 139 - pageId={props.pageId} 140 - parentPageId={props.parentPageId} 141 - /> 142 - </div> 143 - {!props.preview && ( 144 - <PagePreview blocks={props.blocks} did={props.did} /> 145 - )} 146 - </div> 147 - </> 148 - </div> 149 - ); 150 - } 151 - 152 - export function PagePreview(props: { 153 - did: string; 154 - blocks: PubLeafletPagesLinearDocument.Block[]; 155 - }) { 156 - let previewRef = useRef<HTMLDivElement | null>(null); 157 - let { rootEntity } = useReplicache(); 158 - let data = useContext(PostPageContext); 159 - let theme = data?.theme; 160 - let pageWidth = `var(--page-width-unitless)`; 161 - let cardBorderHidden = !theme?.showPageBackground; 162 - return ( 163 - <div 164 - ref={previewRef} 165 - className={`pageLinkBlockPreview w-[120px] overflow-clip mx-3 mt-3 -mb-2 border rounded-md shrink-0 border-border-light flex flex-col gap-0.5 rotate-[4deg] origin-center ${cardBorderHidden ? "" : "bg-bg-page"}`} 166 - > 167 - <div 168 - className="absolute top-0 left-0 origin-top-left pointer-events-none " 169 - style={{ 170 - width: `calc(1px * ${pageWidth})`, 171 - height: `calc(100vh - 64px)`, 172 - transform: `scale(calc((120 / ${pageWidth} )))`, 173 - backgroundColor: "rgba(var(--bg-page), var(--bg-page-alpha))", 174 - }} 175 - > 176 - {!cardBorderHidden && ( 177 - <div 178 - className={`pageLinkBlockBackground 179 - absolute top-0 left-0 right-0 bottom-0 180 - pointer-events-none 181 - `} 182 - /> 183 - )} 184 - <PostContent 185 - pollData={[]} 186 - pages={[]} 187 - did={props.did} 188 - blocks={props.blocks} 189 - preview 190 - bskyPostData={[]} 191 - /> 192 - </div> 193 - </div> 194 - ); 195 - } 196 - 197 - const Interactions = (props: { pageId: string; parentPageId?: string }) => { 198 - const data = useContext(PostPageContext); 199 - const document_uri = data?.uri; 200 - if (!document_uri) 201 - throw new Error("document_uri not available in PostPageContext"); 202 - let comments = data.comments_on_documents.filter( 203 - (c) => (c.record as PubLeafletComment.Record)?.onPage === props.pageId, 204 - ).length; 205 - let quotes = data.document_mentions_in_bsky.filter((q) => 206 - q.link.includes(props.pageId), 207 - ).length; 208 - 209 - let { drawerOpen, drawer, pageId } = useInteractionState(document_uri); 210 - 211 - return ( 212 - <div 213 - className={`flex gap-2 text-tertiary text-sm absolute bottom-2 bg-bg-page`} 214 - > 215 - {quotes > 0 && ( 216 - <button 217 - className={`flex gap-1 items-center`} 218 - onClick={(e) => { 219 - e.preventDefault(); 220 - e.stopPropagation(); 221 - openPage( 222 - props.parentPageId 223 - ? { type: "doc", id: props.parentPageId } 224 - : undefined, 225 - { type: "doc", id: props.pageId }, 226 - { scrollIntoView: false }, 227 - ); 228 - if (!drawerOpen || drawer !== "quotes") 229 - openInteractionDrawer("quotes", document_uri, props.pageId); 230 - else setInteractionState(document_uri, { drawerOpen: false }); 231 - }} 232 - > 233 - <span className="sr-only">Page quotes</span> 234 - <QuoteTiny aria-hidden /> {quotes}{" "} 235 - </button> 236 - )} 237 - {comments > 0 && ( 238 - <button 239 - className={`flex gap-1 items-center`} 240 - onClick={(e) => { 241 - e.preventDefault(); 242 - e.stopPropagation(); 243 - openPage( 244 - props.parentPageId 245 - ? { type: "doc", id: props.parentPageId } 246 - : undefined, 247 - { type: "doc", id: props.pageId }, 248 - { scrollIntoView: false }, 249 - ); 250 - if (!drawerOpen || drawer !== "comments" || pageId !== props.pageId) 251 - openInteractionDrawer("comments", document_uri, props.pageId); 252 - else setInteractionState(document_uri, { drawerOpen: false }); 253 - }} 254 - > 255 - <span className="sr-only">Page comments</span> 256 - <CommentTiny aria-hidden /> {comments}{" "} 257 - </button> 258 - )} 259 - </div> 260 - ); 261 - }; 262 - 263 - const CanvasLinkBlock = (props: { 264 - blocks: PubLeafletPagesCanvas.Block[]; 265 - did: string; 266 - pageId: string; 267 - bskyPostData: AppBskyFeedDefs.PostView[]; 268 - pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 269 - }) => { 270 - let pageWidth = `var(--page-width-unitless)`; 271 - let height = 272 - props.blocks.length > 0 ? Math.max(...props.blocks.map((b) => b.y), 0) : 0; 273 - 274 - return ( 275 - <div 276 - style={{ contain: "size layout paint" }} 277 - className={`pageLinkBlockPreview shrink-0 h-[200px] w-full overflow-clip relative`} 278 - > 279 - <div 280 - className={`absolute top-0 left-0 origin-top-left pointer-events-none w-full`} 281 - style={{ 282 - width: `calc(1px * ${pageWidth})`, 283 - height: "calc(1150px * 2)", 284 - transform: `scale(calc(((${pageWidth} - 36) / 1272 )))`, 285 - }} 286 - > 287 - <div 288 - style={{ 289 - minHeight: height + 512, 290 - contain: "size layout paint", 291 - }} 292 - className="relative h-full w-[1272px]" 293 - > 294 - <div className="w-full h-full pointer-events-none"> 295 - <CanvasBackgroundPattern pattern="grid" /> 296 - </div> 297 - {props.blocks 298 - .sort((a, b) => { 299 - if (a.y === b.y) { 300 - return a.x - b.x; 301 - } 302 - return a.y - b.y; 303 - }) 304 - .map((canvasBlock, index) => { 305 - let { x, y, width, rotation } = canvasBlock; 306 - let transform = `translate(${x}px, ${y}px)${rotation ? ` rotate(${rotation}deg)` : ""}`; 307 - 308 - // Wrap the block in a LinearDocument.Block structure for compatibility 309 - let linearBlock: PubLeafletPagesLinearDocument.Block = { 310 - $type: "pub.leaflet.pages.linearDocument#block", 311 - block: canvasBlock.block, 312 - }; 313 - 314 - return ( 315 - <div 316 - key={index} 317 - className="absolute rounded-lg flex items-stretch origin-center p-3" 318 - style={{ 319 - top: 0, 320 - left: 0, 321 - width, 322 - transform, 323 - }} 324 - > 325 - <div className="contents"> 326 - <Block 327 - pollData={[]} 328 - pageId={props.pageId} 329 - pages={props.pages} 330 - bskyPostData={props.bskyPostData} 331 - block={linearBlock} 332 - did={props.did} 333 - index={[index]} 334 - preview={true} 335 - /> 336 - </div> 337 - </div> 338 - ); 339 - })} 340 - </div> 341 - </div> 342 - </div> 343 - ); 344 - };
-346
app/lish/[did]/[publication]/[rkey]/Blocks/PublishedPollBlock.tsx
··· 1 - "use client"; 2 - 3 - import { 4 - PubLeafletBlocksPoll, 5 - PubLeafletPollDefinition, 6 - PubLeafletPollVote, 7 - } from "lexicons/api"; 8 - import { useState, useEffect } from "react"; 9 - import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; 10 - import { useIdentityData } from "components/IdentityProvider"; 11 - import { AtpAgent } from "@atproto/api"; 12 - import { voteOnPublishedPoll } from "../voteOnPublishedPoll"; 13 - import { PollData } from "../fetchPollData"; 14 - import { Popover } from "components/Popover"; 15 - import LoginForm from "app/login/LoginForm"; 16 - import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 17 - import { getVoterIdentities, VoterIdentity } from "../getVoterIdentities"; 18 - import { Json } from "supabase/database.types"; 19 - import { InfoSmall } from "components/Icons/InfoSmall"; 20 - 21 - // Helper function to extract the first option from a vote record 22 - const getVoteOption = (voteRecord: any): string | null => { 23 - try { 24 - const record = voteRecord as PubLeafletPollVote.Record; 25 - return record.option && record.option.length > 0 ? record.option[0] : null; 26 - } catch { 27 - return null; 28 - } 29 - }; 30 - 31 - export const PublishedPollBlock = (props: { 32 - block: PubLeafletBlocksPoll.Main; 33 - pollData: PollData; 34 - className?: string; 35 - }) => { 36 - const { identity } = useIdentityData(); 37 - const [selectedOption, setSelectedOption] = useState<string | null>(null); 38 - const [isVoting, setIsVoting] = useState(false); 39 - const [showResults, setShowResults] = useState(false); 40 - const [optimisticVote, setOptimisticVote] = useState<{ 41 - option: string; 42 - voter_did: string; 43 - } | null>(null); 44 - let pollRecord = props.pollData.record as PubLeafletPollDefinition.Record; 45 - let [isClient, setIsClient] = useState(false); 46 - useEffect(() => { 47 - setIsClient(true); 48 - }, []); 49 - 50 - const handleVote = async () => { 51 - if (!selectedOption || !identity?.atp_did) return; 52 - 53 - setIsVoting(true); 54 - 55 - // Optimistically add the vote 56 - setOptimisticVote({ 57 - option: selectedOption, 58 - voter_did: identity.atp_did, 59 - }); 60 - setShowResults(true); 61 - 62 - try { 63 - const result = await voteOnPublishedPoll( 64 - props.block.pollRef.uri, 65 - props.block.pollRef.cid, 66 - selectedOption, 67 - ); 68 - 69 - if (!result.success) { 70 - console.error("Failed to vote:", result.error); 71 - // Revert optimistic update on failure 72 - setOptimisticVote(null); 73 - setShowResults(false); 74 - } 75 - } catch (error) { 76 - console.error("Failed to vote:", error); 77 - // Revert optimistic update on failure 78 - setOptimisticVote(null); 79 - setShowResults(false); 80 - } finally { 81 - setIsVoting(false); 82 - } 83 - }; 84 - 85 - const hasVoted = 86 - !!identity?.atp_did && 87 - (!!props.pollData?.atp_poll_votes.find( 88 - (v) => v.voter_did === identity?.atp_did, 89 - ) || 90 - !!optimisticVote); 91 - let isCreator = 92 - identity?.atp_did && props.pollData.uri.includes(identity?.atp_did); 93 - const displayResults = showResults || hasVoted; 94 - 95 - return ( 96 - <div 97 - className={`poll flex flex-col gap-2 p-3 w-full ${props.className} block-border`} 98 - style={{ 99 - backgroundColor: 100 - "color-mix(in oklab, rgb(var(--accent-1)), rgb(var(--bg-page)) 85%)", 101 - }} 102 - > 103 - {displayResults ? ( 104 - <> 105 - <PollResults 106 - pollData={props.pollData} 107 - hasVoted={hasVoted} 108 - setShowResults={setShowResults} 109 - optimisticVote={optimisticVote} 110 - /> 111 - {!hasVoted && ( 112 - <div className="flex justify-start"> 113 - <button 114 - className="w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast" 115 - onClick={() => setShowResults(false)} 116 - > 117 - Back to Voting 118 - </button> 119 - </div> 120 - )} 121 - </> 122 - ) : ( 123 - <> 124 - {pollRecord.options.map((option, index) => ( 125 - <PollOptionButton 126 - key={index} 127 - option={option} 128 - optionIndex={index.toString()} 129 - selected={selectedOption === index.toString()} 130 - onSelect={() => setSelectedOption(index.toString())} 131 - disabled={!identity?.atp_did} 132 - /> 133 - ))} 134 - <div className="flex flex-col-reverse sm:flex-row sm:justify-between gap-2 items-center pt-2"> 135 - <div className="text-sm text-tertiary">All votes are public</div> 136 - <div className="flex sm:gap-3 sm:flex-row flex-col-reverse sm:justify-end justify-center gap-1 items-center"> 137 - <button 138 - className="w-fit font-bold text-accent-contrast" 139 - onClick={() => setShowResults(!showResults)} 140 - > 141 - See Results 142 - </button> 143 - {identity?.atp_did ? ( 144 - <ButtonPrimary 145 - className="place-self-end" 146 - onClick={handleVote} 147 - disabled={!selectedOption || isVoting} 148 - > 149 - {isVoting ? "Voting..." : "Vote!"} 150 - </ButtonPrimary> 151 - ) : ( 152 - <Popover 153 - asChild 154 - trigger={ 155 - <ButtonPrimary className="place-self-center"> 156 - <BlueskyTiny /> Login to vote 157 - </ButtonPrimary> 158 - } 159 - > 160 - {isClient && ( 161 - <LoginForm 162 - text="Log in to vote on this poll!" 163 - noEmail 164 - redirectRoute={window?.location.href + "?refreshAuth"} 165 - /> 166 - )} 167 - </Popover> 168 - )} 169 - </div> 170 - </div> 171 - </> 172 - )} 173 - </div> 174 - ); 175 - }; 176 - 177 - const PollOptionButton = (props: { 178 - option: PubLeafletPollDefinition.Option; 179 - optionIndex: string; 180 - selected: boolean; 181 - onSelect: () => void; 182 - disabled?: boolean; 183 - }) => { 184 - const ButtonComponent = props.selected ? ButtonPrimary : ButtonSecondary; 185 - 186 - return ( 187 - <div className="flex gap-2 items-center"> 188 - <ButtonComponent 189 - className="pollOption grow max-w-full flex" 190 - onClick={props.onSelect} 191 - disabled={props.disabled} 192 - > 193 - {props.option.text} 194 - </ButtonComponent> 195 - </div> 196 - ); 197 - }; 198 - 199 - const PollResults = (props: { 200 - pollData: PollData; 201 - hasVoted: boolean; 202 - setShowResults: (show: boolean) => void; 203 - optimisticVote: { option: string; voter_did: string } | null; 204 - }) => { 205 - // Merge optimistic vote with actual votes 206 - const allVotes = props.optimisticVote 207 - ? [ 208 - ...props.pollData.atp_poll_votes, 209 - { 210 - voter_did: props.optimisticVote.voter_did, 211 - record: { 212 - $type: "pub.leaflet.poll.vote", 213 - option: [props.optimisticVote.option], 214 - }, 215 - }, 216 - ] 217 - : props.pollData.atp_poll_votes; 218 - 219 - const totalVotes = allVotes.length || 0; 220 - let pollRecord = props.pollData.record as PubLeafletPollDefinition.Record; 221 - let optionsWithCount = pollRecord.options.map((o, index) => ({ 222 - ...o, 223 - votes: allVotes.filter((v) => getVoteOption(v.record) == index.toString()), 224 - })); 225 - 226 - const highestVotes = Math.max(...optionsWithCount.map((o) => o.votes.length)); 227 - return ( 228 - <> 229 - {pollRecord.options.map((option, index) => { 230 - const voteRecords = allVotes.filter( 231 - (v) => getVoteOption(v.record) === index.toString(), 232 - ); 233 - const isWinner = totalVotes > 0 && voteRecords.length === highestVotes; 234 - 235 - return ( 236 - <PollResult 237 - key={index} 238 - option={option} 239 - votes={voteRecords.length} 240 - voteRecords={voteRecords} 241 - totalVotes={totalVotes} 242 - winner={isWinner} 243 - /> 244 - ); 245 - })} 246 - </> 247 - ); 248 - }; 249 - 250 - const VoterListPopover = (props: { 251 - votes: number; 252 - voteRecords: { voter_did: string; record: Json }[]; 253 - }) => { 254 - const [voterIdentities, setVoterIdentities] = useState<VoterIdentity[]>([]); 255 - const [isLoading, setIsLoading] = useState(false); 256 - const [hasFetched, setHasFetched] = useState(false); 257 - 258 - const handleOpenChange = async () => { 259 - if (!hasFetched && props.voteRecords.length > 0) { 260 - setIsLoading(true); 261 - setHasFetched(true); 262 - try { 263 - const dids = props.voteRecords.map((v) => v.voter_did); 264 - const identities = await getVoterIdentities(dids); 265 - setVoterIdentities(identities); 266 - } catch (error) { 267 - console.error("Failed to fetch voter identities:", error); 268 - } finally { 269 - setIsLoading(false); 270 - } 271 - } 272 - }; 273 - 274 - return ( 275 - <Popover 276 - trigger={ 277 - <button 278 - className="hover:underline cursor-pointer" 279 - disabled={props.votes === 0} 280 - > 281 - {props.votes} 282 - </button> 283 - } 284 - onOpenChange={handleOpenChange} 285 - className="w-64 max-h-80" 286 - > 287 - {isLoading ? ( 288 - <div className="flex justify-center py-4"> 289 - <div className="text-sm text-secondary">Loading...</div> 290 - </div> 291 - ) : ( 292 - <div className="flex flex-col gap-1 text-sm py-0.5"> 293 - {voterIdentities.map((voter) => ( 294 - <a 295 - key={voter.did} 296 - href={`https://bsky.app/profile/${voter.handle || voter.did}`} 297 - target="_blank" 298 - rel="noopener noreferrer" 299 - className="" 300 - > 301 - @{voter.handle || voter.did} 302 - </a> 303 - ))} 304 - </div> 305 - )} 306 - </Popover> 307 - ); 308 - }; 309 - 310 - const PollResult = (props: { 311 - option: PubLeafletPollDefinition.Option; 312 - votes: number; 313 - voteRecords: { voter_did: string; record: Json }[]; 314 - totalVotes: number; 315 - winner: boolean; 316 - }) => { 317 - return ( 318 - <div 319 - className={`pollResult relative grow py-0.5 px-2 border-accent-contrast rounded-md overflow-hidden ${props.winner ? "font-bold border-2" : "border"}`} 320 - > 321 - <div 322 - style={{ 323 - WebkitTextStroke: `${props.winner ? "6px" : "6px"} rgb(var(--bg-page))`, 324 - paintOrder: "stroke fill", 325 - }} 326 - className="pollResultContent text-accent-contrast relative flex gap-2 justify-between z-10" 327 - > 328 - <div className="grow max-w-full truncate">{props.option.text}</div> 329 - <VoterListPopover votes={props.votes} voteRecords={props.voteRecords} /> 330 - </div> 331 - <div className="pollResultBG absolute bg-bg-page w-full top-0 bottom-0 left-0 right-0 flex flex-row z-0"> 332 - <div 333 - className="bg-accent-contrast rounded-[2px] m-0.5" 334 - style={{ 335 - maskImage: "var(--hatchSVG)", 336 - maskRepeat: "repeat repeat", 337 - ...(props.votes === 0 338 - ? { width: "4px" } 339 - : { flexBasis: `${(props.votes / props.totalVotes) * 100}%` }), 340 - }} 341 - /> 342 - <div /> 343 - </div> 344 - </div> 345 - ); 346 - };
-20
app/lish/[did]/[publication]/[rkey]/Blocks/StaticMathBlock.tsx
··· 1 - import { PubLeafletBlocksMath } from "lexicons/api"; 2 - import Katex from "katex"; 3 - import "katex/dist/katex.min.css"; 4 - 5 - export const StaticMathBlock = ({ 6 - block, 7 - }: { 8 - block: PubLeafletBlocksMath.Main; 9 - }) => { 10 - const html = Katex.renderToString(block.tex, { 11 - displayMode: true, 12 - output: "html", 13 - throwOnError: false, 14 - }); 15 - return ( 16 - <div className="math-block my-2"> 17 - <div dangerouslySetInnerHTML={{ __html: html }} /> 18 - </div> 19 - ); 20 - };
-95
app/lish/[did]/[publication]/[rkey]/Blocks/TextBlock.tsx
··· 1 - "use client"; 2 - import { UnicodeString } from "@atproto/api"; 3 - import { PubLeafletRichtextFacet } from "lexicons/api"; 4 - import { useMemo } from "react"; 5 - import { useHighlight } from "../useHighlight"; 6 - import { BaseTextBlock } from "./BaseTextBlock"; 7 - 8 - type Facet = PubLeafletRichtextFacet.Main; 9 - export function TextBlock(props: { 10 - plaintext: string; 11 - facets?: Facet[]; 12 - index: number[]; 13 - preview?: boolean; 14 - pageId?: string; 15 - }) { 16 - let children = []; 17 - let highlights = useHighlight(props.index, props.pageId); 18 - let facets = useMemo(() => { 19 - if (props.preview) return props.facets; 20 - let facets = [...(props.facets || [])]; 21 - for (let highlight of highlights) { 22 - const fragmentId = props.pageId 23 - ? `${props.pageId}~${props.index.join(".")}_${highlight.startOffset || 0}` 24 - : `${props.index.join(".")}_${highlight.startOffset || 0}`; 25 - facets = addFacet( 26 - facets, 27 - { 28 - index: { 29 - byteStart: highlight.startOffset 30 - ? new UnicodeString( 31 - props.plaintext.slice(0, highlight.startOffset), 32 - ).length 33 - : 0, 34 - byteEnd: new UnicodeString( 35 - props.plaintext.slice(0, highlight.endOffset ?? undefined), 36 - ).length, 37 - }, 38 - features: [ 39 - { $type: "pub.leaflet.richtext.facet#highlight" }, 40 - { 41 - $type: "pub.leaflet.richtext.facet#id", 42 - id: fragmentId, 43 - }, 44 - ], 45 - }, 46 - new UnicodeString(props.plaintext).length, 47 - ); 48 - } 49 - return facets; 50 - }, [props.plaintext, props.facets, highlights, props.preview, props.pageId]); 51 - return <BaseTextBlock {...props} facets={facets} />; 52 - } 53 - 54 - function addFacet(facets: Facet[], newFacet: Facet, length: number) { 55 - if (facets.length === 0) { 56 - return [newFacet]; 57 - } 58 - 59 - const allFacets = [...facets, newFacet]; 60 - 61 - // Collect all boundary positions 62 - const boundaries = new Set<number>(); 63 - boundaries.add(0); 64 - boundaries.add(length); 65 - 66 - for (const facet of allFacets) { 67 - boundaries.add(facet.index.byteStart); 68 - boundaries.add(facet.index.byteEnd); 69 - } 70 - 71 - const sortedBoundaries = Array.from(boundaries).sort((a, b) => a - b); 72 - const result: Facet[] = []; 73 - 74 - // Process segments between consecutive boundaries 75 - for (let i = 0; i < sortedBoundaries.length - 1; i++) { 76 - const start = sortedBoundaries[i]; 77 - const end = sortedBoundaries[i + 1]; 78 - 79 - // Find facets that are active at the start position 80 - const activeFacets = allFacets.filter( 81 - (facet) => facet.index.byteStart <= start && facet.index.byteEnd > start, 82 - ); 83 - 84 - // Only create facet if there are active facets (features present) 85 - if (activeFacets.length > 0) { 86 - const features = activeFacets.flatMap((f) => f.features); 87 - result.push({ 88 - index: { byteStart: start, byteEnd: end }, 89 - features, 90 - }); 91 - } 92 - } 93 - 94 - return result; 95 - }
-181
app/lish/[did]/[publication]/[rkey]/Blocks/TextBlockCore.tsx
··· 1 - import { UnicodeString } from "@atproto/api"; 2 - import { PubLeafletRichtextFacet } from "lexicons/api"; 3 - import { AtMentionLink } from "components/AtMentionLink"; 4 - import { ReactNode } from "react"; 5 - 6 - type Facet = PubLeafletRichtextFacet.Main; 7 - 8 - export type FacetRenderers = { 9 - DidMention?: (props: { did: string; children: ReactNode }) => ReactNode; 10 - }; 11 - 12 - export type TextBlockCoreProps = { 13 - plaintext: string; 14 - facets?: Facet[]; 15 - index: number[]; 16 - preview?: boolean; 17 - renderers?: FacetRenderers; 18 - }; 19 - 20 - export function TextBlockCore(props: TextBlockCoreProps) { 21 - let children = []; 22 - let richText = new RichText({ 23 - text: props.plaintext, 24 - facets: props.facets || [], 25 - }); 26 - let counter = 0; 27 - for (const segment of richText.segments()) { 28 - let id = segment.facet?.find(PubLeafletRichtextFacet.isId); 29 - let link = segment.facet?.find(PubLeafletRichtextFacet.isLink); 30 - let isBold = segment.facet?.find(PubLeafletRichtextFacet.isBold); 31 - let isCode = segment.facet?.find(PubLeafletRichtextFacet.isCode); 32 - let isStrikethrough = segment.facet?.find( 33 - PubLeafletRichtextFacet.isStrikethrough, 34 - ); 35 - let isDidMention = segment.facet?.find( 36 - PubLeafletRichtextFacet.isDidMention, 37 - ); 38 - let isAtMention = segment.facet?.find(PubLeafletRichtextFacet.isAtMention); 39 - let isUnderline = segment.facet?.find(PubLeafletRichtextFacet.isUnderline); 40 - let isItalic = segment.facet?.find(PubLeafletRichtextFacet.isItalic); 41 - let isHighlighted = segment.facet?.find( 42 - PubLeafletRichtextFacet.isHighlight, 43 - ); 44 - let className = ` 45 - ${isCode ? "inline-code" : ""} 46 - ${id ? "scroll-mt-12 scroll-mb-10" : ""} 47 - ${isBold ? "font-bold" : ""} 48 - ${isItalic ? "italic" : ""} 49 - ${isUnderline ? "underline" : ""} 50 - ${isStrikethrough ? "line-through decoration-tertiary" : ""} 51 - ${isHighlighted ? "highlight bg-highlight-1" : ""}`.replaceAll("\n", " "); 52 - 53 - // Split text by newlines and insert <br> tags 54 - const textParts = segment.text.split("\n"); 55 - const renderedText = textParts.flatMap((part, i) => 56 - i < textParts.length - 1 57 - ? [part, <br key={`br-${counter}-${i}`} />] 58 - : [part], 59 - ); 60 - 61 - if (isCode) { 62 - children.push( 63 - <code key={counter} className={className} id={id?.id}> 64 - {renderedText} 65 - </code>, 66 - ); 67 - } else if (isDidMention) { 68 - const DidMentionRenderer = props.renderers?.DidMention; 69 - if (DidMentionRenderer) { 70 - children.push( 71 - <DidMentionRenderer key={counter} did={isDidMention.did}> 72 - <span className="mention">{renderedText}</span> 73 - </DidMentionRenderer>, 74 - ); 75 - } else { 76 - // Default: render as a simple link 77 - children.push( 78 - <a 79 - key={counter} 80 - href={`https://leaflet.pub/p/${isDidMention.did}`} 81 - target="_blank" 82 - className="no-underline" 83 - > 84 - <span className="mention">{renderedText}</span> 85 - </a>, 86 - ); 87 - } 88 - } else if (isAtMention) { 89 - children.push( 90 - <AtMentionLink 91 - key={counter} 92 - atURI={isAtMention.atURI} 93 - className={className} 94 - > 95 - {renderedText} 96 - </AtMentionLink>, 97 - ); 98 - } else if (link) { 99 - children.push( 100 - <a 101 - key={counter} 102 - href={link.uri.trim()} 103 - className={`text-accent-contrast hover:underline ${className}`} 104 - target="_blank" 105 - > 106 - {renderedText} 107 - </a>, 108 - ); 109 - } else { 110 - children.push( 111 - <span key={counter} className={className} id={id?.id}> 112 - {renderedText} 113 - </span>, 114 - ); 115 - } 116 - 117 - counter++; 118 - } 119 - return <>{children}</>; 120 - } 121 - 122 - type RichTextSegment = { 123 - text: string; 124 - facet?: Exclude<Facet["features"], { $type: string }>; 125 - }; 126 - 127 - export class RichText { 128 - unicodeText: UnicodeString; 129 - facets?: Facet[]; 130 - 131 - constructor(props: { text: string; facets: Facet[] }) { 132 - this.unicodeText = new UnicodeString(props.text); 133 - this.facets = props.facets; 134 - if (this.facets) { 135 - this.facets = this.facets 136 - .filter((facet) => facet.index.byteStart <= facet.index.byteEnd) 137 - .sort((a, b) => a.index.byteStart - b.index.byteStart); 138 - } 139 - } 140 - 141 - *segments(): Generator<RichTextSegment, void, void> { 142 - const facets = this.facets || []; 143 - if (!facets.length) { 144 - yield { text: this.unicodeText.utf16 }; 145 - return; 146 - } 147 - 148 - let textCursor = 0; 149 - let facetCursor = 0; 150 - do { 151 - const currFacet = facets[facetCursor]; 152 - if (textCursor < currFacet.index.byteStart) { 153 - yield { 154 - text: this.unicodeText.slice(textCursor, currFacet.index.byteStart), 155 - }; 156 - } else if (textCursor > currFacet.index.byteStart) { 157 - facetCursor++; 158 - continue; 159 - } 160 - if (currFacet.index.byteStart < currFacet.index.byteEnd) { 161 - const subtext = this.unicodeText.slice( 162 - currFacet.index.byteStart, 163 - currFacet.index.byteEnd, 164 - ); 165 - if (!subtext.trim()) { 166 - // dont empty string entities 167 - yield { text: subtext }; 168 - } else { 169 - yield { text: subtext, facet: currFacet.features }; 170 - } 171 - } 172 - textCursor = currFacet.index.byteEnd; 173 - facetCursor++; 174 - } while (facetCursor < facets.length); 175 - if (textCursor < this.unicodeText.length) { 176 - yield { 177 - text: this.unicodeText.slice(textCursor, this.unicodeText.length), 178 - }; 179 - } 180 - } 181 - }
+6 -1
app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
··· 202 202 isSubpage: boolean | undefined; 203 203 data: PostPageData; 204 204 profile: ProfileViewDetailed; 205 - preferences: { showComments?: boolean }; 205 + preferences: { 206 + showComments?: boolean; 207 + showMentions?: boolean; 208 + showPrevNext?: boolean; 209 + }; 206 210 quotesCount: number | undefined; 207 211 commentsCount: number | undefined; 208 212 }) => { ··· 213 217 quotesCount={props.quotesCount || 0} 214 218 commentsCount={props.commentsCount || 0} 215 219 showComments={props.preferences.showComments} 220 + showMentions={props.preferences.showMentions} 216 221 pageId={props.pageId} 217 222 /> 218 223 {!props.isSubpage && (
+5 -2
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx
··· 5 5 import { CommentBox } from "./CommentBox"; 6 6 import { Json } from "supabase/database.types"; 7 7 import { PubLeafletComment } from "lexicons/api"; 8 - import { BaseTextBlock } from "../../Blocks/BaseTextBlock"; 8 + import { BaseTextBlock } from "../../BaseTextBlock"; 9 9 import { useMemo, useState } from "react"; 10 10 import { CommentTiny } from "components/Icons/CommentTiny"; 11 11 import { Separator } from "components/Layout"; ··· 51 51 }, []); 52 52 53 53 return ( 54 - <div id={"commentsDrawer"} className="flex flex-col gap-2 relative"> 54 + <div 55 + id={"commentsDrawer"} 56 + className="flex flex-col gap-2 relative text-sm text-secondary" 57 + > 55 58 <div className="w-full flex justify-between text-secondary font-bold"> 56 59 Comments 57 60 <button
+2 -1
app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx
··· 9 9 import { decodeQuotePosition } from "../quotePosition"; 10 10 11 11 export const InteractionDrawer = (props: { 12 + showPageBackground: boolean | undefined; 12 13 document_uri: string; 13 14 quotesAndMentions: { uri: string; link?: string }[]; 14 15 comments: Comment[]; ··· 38 39 <div className="snap-center h-full flex z-10 shrink-0 w-[calc(var(--page-width-units)-6px)] sm:w-[calc(var(--page-width-units))]"> 39 40 <div 40 41 id="interaction-drawer" 41 - className="opaque-container rounded-l-none! rounded-r-lg! h-full w-full px-3 sm:px-4 pt-2 sm:pt-3 pb-6 overflow-scroll -ml-[1px] " 42 + className={`opaque-container h-full w-full px-3 sm:px-4 pt-2 sm:pt-3 pb-6 overflow-scroll -ml-[1px] ${props.showPageBackground ? "rounded-l-none! rounded-r-lg!" : "rounded-lg! sm:mx-2"}`} 42 43 > 43 44 {drawer.drawer === "quotes" ? ( 44 45 <Quotes {...props} quotesAndMentions={filteredQuotesAndMentions} />
+68 -44
app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
··· 108 108 commentsCount: number; 109 109 className?: string; 110 110 showComments?: boolean; 111 + showMentions?: boolean; 111 112 pageId?: string; 112 113 }) => { 113 114 const data = useContext(PostPageContext); ··· 131 132 <div className={`flex gap-2 text-tertiary text-sm ${props.className}`}> 132 133 {tagCount > 0 && <TagPopover tags={tags} tagCount={tagCount} />} 133 134 134 - {props.quotesCount > 0 && ( 135 + {props.quotesCount === 0 || props.showMentions === false ? null : ( 135 136 <button 136 137 className="flex w-fit gap-2 items-center" 137 138 onClick={() => { ··· 168 169 commentsCount: number; 169 170 className?: string; 170 171 showComments?: boolean; 172 + showMentions?: boolean; 171 173 pageId?: string; 172 174 }) => { 173 175 const data = useContext(PostPageContext); ··· 189 191 const tags = (data?.data as any)?.tags as string[] | undefined; 190 192 const tagCount = tags?.length || 0; 191 193 194 + let noInteractions = !props.showComments && !props.showMentions; 195 + 192 196 let subscribed = 193 197 identity?.atp_did && 194 198 publication?.publication_subscriptions && ··· 229 233 <TagList tags={tags} className="mb-3" /> 230 234 </> 231 235 )} 236 + 232 237 <hr className="border-border-light mb-3 " /> 238 + 233 239 <div className="flex gap-2 justify-between"> 234 - <div className="flex gap-2"> 235 - {props.quotesCount > 0 && ( 236 - <button 237 - className="flex w-fit gap-2 items-center px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline" 238 - onClick={() => { 239 - if (!drawerOpen || drawer !== "quotes") 240 - openInteractionDrawer("quotes", document_uri, props.pageId); 241 - else setInteractionState(document_uri, { drawerOpen: false }); 242 - }} 243 - onMouseEnter={handleQuotePrefetch} 244 - onTouchStart={handleQuotePrefetch} 245 - aria-label="Post quotes" 246 - > 247 - <QuoteTiny aria-hidden /> {props.quotesCount}{" "} 248 - <span 249 - aria-hidden 250 - >{`Mention${props.quotesCount === 1 ? "" : "s"}`}</span> 251 - </button> 252 - )} 253 - {props.showComments === false ? null : ( 254 - <button 255 - className="flex gap-2 items-center w-fit px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline" 256 - onClick={() => { 257 - if ( 258 - !drawerOpen || 259 - drawer !== "comments" || 260 - pageId !== props.pageId 261 - ) 262 - openInteractionDrawer("comments", document_uri, props.pageId); 263 - else setInteractionState(document_uri, { drawerOpen: false }); 264 - }} 265 - aria-label="Post comments" 266 - > 267 - <CommentTiny aria-hidden />{" "} 268 - {props.commentsCount > 0 ? ( 269 - <span aria-hidden> 270 - {`${props.commentsCount} Comment${props.commentsCount === 1 ? "" : "s"}`} 271 - </span> 272 - ) : ( 273 - "Comment" 240 + {noInteractions ? ( 241 + <div /> 242 + ) : ( 243 + <> 244 + <div className="flex gap-2"> 245 + {props.quotesCount === 0 || 246 + props.showMentions === false ? null : ( 247 + <button 248 + className="flex w-fit gap-2 items-center px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline" 249 + onClick={() => { 250 + if (!drawerOpen || drawer !== "quotes") 251 + openInteractionDrawer( 252 + "quotes", 253 + document_uri, 254 + props.pageId, 255 + ); 256 + else 257 + setInteractionState(document_uri, { drawerOpen: false }); 258 + }} 259 + onMouseEnter={handleQuotePrefetch} 260 + onTouchStart={handleQuotePrefetch} 261 + aria-label="Post quotes" 262 + > 263 + <QuoteTiny aria-hidden /> {props.quotesCount}{" "} 264 + <span 265 + aria-hidden 266 + >{`Mention${props.quotesCount === 1 ? "" : "s"}`}</span> 267 + </button> 274 268 )} 275 - </button> 276 - )} 277 - </div> 269 + {props.showComments === false ? null : ( 270 + <button 271 + className="flex gap-2 items-center w-fit px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline" 272 + onClick={() => { 273 + if ( 274 + !drawerOpen || 275 + drawer !== "comments" || 276 + pageId !== props.pageId 277 + ) 278 + openInteractionDrawer( 279 + "comments", 280 + document_uri, 281 + props.pageId, 282 + ); 283 + else 284 + setInteractionState(document_uri, { drawerOpen: false }); 285 + }} 286 + aria-label="Post comments" 287 + > 288 + <CommentTiny aria-hidden />{" "} 289 + {props.commentsCount > 0 ? ( 290 + <span aria-hidden> 291 + {`${props.commentsCount} Comment${props.commentsCount === 1 ? "" : "s"}`} 292 + </span> 293 + ) : ( 294 + "Comment" 295 + )} 296 + </button> 297 + )} 298 + </div> 299 + </> 300 + )} 301 + 278 302 <EditButton document={data} /> 279 303 {subscribed && publication && ( 280 304 <ManageSubscription
+7 -2
app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
··· 14 14 ExpandedInteractions, 15 15 getCommentCount, 16 16 getQuoteCount, 17 - Interactions, 18 17 } from "./Interactions/Interactions"; 19 18 import { PostContent } from "./PostContent"; 20 19 import { PostHeader } from "./PostHeader/PostHeader"; ··· 25 24 import { decodeQuotePosition } from "./quotePosition"; 26 25 import { PollData } from "./fetchPollData"; 27 26 import { SharedPageProps } from "./PostPages"; 27 + import { PostPrevNextButtons } from "./PostPrevNextButtons"; 28 28 29 29 export function LinearDocumentPage({ 30 30 blocks, ··· 56 56 57 57 const isSubpage = !!pageId; 58 58 59 + console.log("prev/next?: " + preferences.showPrevNext); 60 + 59 61 return ( 60 62 <> 61 63 <PageWrapper ··· 83 85 did={did} 84 86 prerenderedCodeBlocks={prerenderedCodeBlocks} 85 87 /> 86 - 88 + <PostPrevNextButtons 89 + showPrevNext={preferences.showPrevNext && !isSubpage} 90 + /> 87 91 <ExpandedInteractions 88 92 pageId={pageId} 89 93 showComments={preferences.showComments} 94 + showMentions={preferences.showMentions} 90 95 commentsCount={getCommentCount(document, pageId) || 0} 91 96 quotesCount={getQuoteCount(document, pageId) || 0} 92 97 />
+8 -18
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
··· 20 20 } from "lexicons/api"; 21 21 22 22 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 23 - import { TextBlock } from "./Blocks/TextBlock"; 23 + import { TextBlock } from "./TextBlock"; 24 24 import { Popover } from "components/Popover"; 25 25 import { theme } from "tailwind.config"; 26 26 import { ImageAltSmall } from "components/Icons/ImageAlt"; 27 - import { StaticMathBlock } from "./Blocks/StaticMathBlock"; 28 - import { PubCodeBlock } from "./Blocks/PubCodeBlock"; 27 + import { StaticMathBlock } from "./StaticMathBlock"; 28 + import { PubCodeBlock } from "./PubCodeBlock"; 29 29 import { AppBskyFeedDefs } from "@atproto/api"; 30 - import { PubBlueskyPostBlock } from "./Blocks/PublishBskyPostBlock"; 30 + import { PubBlueskyPostBlock } from "./PublishBskyPostBlock"; 31 31 import { openPage } from "./PostPages"; 32 32 import { PageLinkBlock } from "components/Blocks/PageLinkBlock"; 33 - import { PublishedPageLinkBlock } from "./Blocks/PublishedPageBlock"; 34 - import { PublishedPollBlock } from "./Blocks/PublishedPollBlock"; 33 + import { PublishedPageLinkBlock } from "./PublishedPageBlock"; 34 + import { PublishedPollBlock } from "./PublishedPollBlock"; 35 35 import { PollData } from "./fetchPollData"; 36 36 import { ButtonPrimary } from "components/Buttons"; 37 37 ··· 173 173 let uri = b.block.postRef.uri; 174 174 let post = bskyPostData.find((p) => p.uri === uri); 175 175 if (!post) return <div>no prefetched post rip</div>; 176 - return ( 177 - <PubBlueskyPostBlock 178 - post={post} 179 - className={className} 180 - pageId={pageId} 181 - /> 182 - ); 176 + return <PubBlueskyPostBlock post={post} className={className} pageId={pageId} />; 183 177 } 184 178 case PubLeafletBlocksIframe.isMain(b.block): { 185 179 return ( ··· 345 339 } 346 340 case PubLeafletBlocksText.isMain(b.block): 347 341 return ( 348 - <p 349 - className={`textBlock ${className} ${b.block.textSize === "small" ? "text-sm text-secondary" : b.block.textSize === "large" ? "text-lg" : ""}`} 350 - {...blockProps} 351 - > 342 + <p className={`textBlock ${className}`} {...blockProps}> 352 343 <TextBlock 353 344 facets={b.block.facets} 354 345 plaintext={b.block.plaintext} ··· 358 349 /> 359 350 </p> 360 351 ); 361 - 362 352 case PubLeafletBlocksHeader.isMain(b.block): { 363 353 if (b.block.level === 1) 364 354 return (
+2 -1
app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
··· 23 23 export function PostHeader(props: { 24 24 data: PostPageData; 25 25 profile: ProfileViewDetailed; 26 - preferences: { showComments?: boolean }; 26 + preferences: { showComments?: boolean; showMentions?: boolean }; 27 27 }) { 28 28 let { identity } = useIdentityData(); 29 29 let document = props.data; ··· 91 91 </div> 92 92 <Interactions 93 93 showComments={props.preferences.showComments} 94 + showMentions={props.preferences.showMentions} 94 95 quotesCount={getQuoteCount(document) || 0} 95 96 commentsCount={getCommentCount(document) || 0} 96 97 />
+22 -4
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
··· 147 147 document: PostPageData; 148 148 did: string; 149 149 profile: ProfileViewDetailed; 150 - preferences: { showComments?: boolean }; 150 + preferences: { 151 + showComments?: boolean; 152 + showMentions?: boolean; 153 + showPrevNext?: boolean; 154 + }; 151 155 pubRecord?: PubLeafletPublication.Record; 152 156 theme?: PubLeafletPublication.Theme | null; 153 157 prerenderedCodeBlocks?: Map<string, string>; ··· 206 210 did: string; 207 211 prerenderedCodeBlocks?: Map<string, string>; 208 212 bskyPostData: AppBskyFeedDefs.PostView[]; 209 - preferences: { showComments?: boolean }; 213 + preferences: { 214 + showComments?: boolean; 215 + showMentions?: boolean; 216 + showPrevNext?: boolean; 217 + }; 210 218 pollData: PollData[]; 211 219 }) { 212 220 let drawer = useDrawerOpen(document_uri); ··· 261 269 262 270 {drawer && !drawer.pageId && ( 263 271 <InteractionDrawer 272 + showPageBackground={pubRecord?.theme?.showPageBackground} 264 273 document_uri={document.uri} 265 274 comments={ 266 275 pubRecord?.preferences?.showComments === false 267 276 ? [] 268 277 : document.comments_on_documents 269 278 } 270 - quotesAndMentions={quotesAndMentions} 279 + quotesAndMentions={ 280 + pubRecord?.preferences?.showMentions === false 281 + ? [] 282 + : quotesAndMentions 283 + } 271 284 did={did} 272 285 /> 273 286 )} ··· 347 360 /> 348 361 {drawer && drawer.pageId === page.id && ( 349 362 <InteractionDrawer 363 + showPageBackground={pubRecord?.theme?.showPageBackground} 350 364 pageId={page.id} 351 365 document_uri={document.uri} 352 366 comments={ ··· 354 368 ? [] 355 369 : document.comments_on_documents 356 370 } 357 - quotesAndMentions={quotesAndMentions} 371 + quotesAndMentions={ 372 + pubRecord?.preferences?.showMentions === false 373 + ? [] 374 + : quotesAndMentions 375 + } 358 376 did={did} 359 377 /> 360 378 )}
+58
app/lish/[did]/[publication]/[rkey]/PostPrevNextButtons.tsx
··· 1 + "use client"; 2 + import { PubLeafletDocument } from "lexicons/api"; 3 + import { usePublicationData } from "../dashboard/PublicationSWRProvider"; 4 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 5 + import { AtUri } from "@atproto/api"; 6 + import { useParams } from "next/navigation"; 7 + import { getPostPageData } from "./getPostPageData"; 8 + import { PostPageContext } from "./PostPageContext"; 9 + import { useContext } from "react"; 10 + import { SpeedyLink } from "components/SpeedyLink"; 11 + import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; 12 + 13 + export const PostPrevNextButtons = (props: { 14 + showPrevNext: boolean | undefined; 15 + }) => { 16 + let postData = useContext(PostPageContext); 17 + let pub = postData?.documents_in_publications[0]?.publications; 18 + 19 + if (!props.showPrevNext || !pub || !postData) return; 20 + 21 + function getPostLink(uri: string) { 22 + return pub && uri 23 + ? `${getPublicationURL(pub)}/${new AtUri(uri).rkey}` 24 + : "leaflet.pub/not-found"; 25 + } 26 + let prevPost = postData?.prevNext?.prev; 27 + let nextPost = postData?.prevNext?.next; 28 + 29 + return ( 30 + <div className="flex flex-col gap-1 w-full px-3 sm:px-4 pb-2 pt-2"> 31 + {/*<hr className="border-border-light" />*/} 32 + <div className="flex justify-between w-full gap-8 "> 33 + {nextPost ? ( 34 + <SpeedyLink 35 + href={getPostLink(nextPost.uri)} 36 + className="flex gap-1 items-center truncate min-w-0 basis-1/2" 37 + > 38 + <ArrowRightTiny className="rotate-180 shrink-0" /> 39 + <div className="min-w-0 truncate">{nextPost.title}</div> 40 + </SpeedyLink> 41 + ) : ( 42 + <div /> 43 + )} 44 + {prevPost ? ( 45 + <SpeedyLink 46 + href={getPostLink(prevPost.uri)} 47 + className="flex gap-1 items-center truncate min-w-0 basis-1/2 justify-end" 48 + > 49 + <div className="min-w-0 truncate">{prevPost.title}</div> 50 + <ArrowRightTiny className="shrink-0" /> 51 + </SpeedyLink> 52 + ) : ( 53 + <div /> 54 + )} 55 + </div> 56 + </div> 57 + ); 58 + };
+28
app/lish/[did]/[publication]/[rkey]/PubCodeBlock.tsx
··· 1 + "use client"; 2 + 3 + import { PubLeafletBlocksCode } from "lexicons/api"; 4 + import { useLayoutEffect, useState } from "react"; 5 + import { codeToHtml, bundledLanguagesInfo, bundledThemesInfo } from "shiki"; 6 + 7 + export function PubCodeBlock({ 8 + block, 9 + prerenderedCode, 10 + }: { 11 + block: PubLeafletBlocksCode.Main; 12 + prerenderedCode?: string; 13 + }) { 14 + const [html, setHTML] = useState<string | null>(prerenderedCode || null); 15 + 16 + useLayoutEffect(() => { 17 + const lang = bundledLanguagesInfo.find((l) => l.id === block.language)?.id || "plaintext"; 18 + const theme = bundledThemesInfo.find((t) => t.id === block.syntaxHighlightingTheme)?.id || "github-light"; 19 + 20 + codeToHtml(block.plaintext, { lang, theme }).then(setHTML); 21 + }, [block]); 22 + return ( 23 + <div 24 + className="w-full min-h-[42px] my-2 rounded-md border-border-light outline-border-light selected-outline" 25 + dangerouslySetInnerHTML={{ __html: html || "" }} 26 + /> 27 + ); 28 + }
+172
app/lish/[did]/[publication]/[rkey]/PublishBskyPostBlock.tsx
··· 1 + import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 2 + import { AppBskyFeedDefs, AppBskyFeedPost } from "@atproto/api"; 3 + import { Separator } from "components/Layout"; 4 + import { useHasPageLoaded } from "components/InitialPageLoadProvider"; 5 + import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 6 + import { CommentTiny } from "components/Icons/CommentTiny"; 7 + import { QuoteTiny } from "components/Icons/QuoteTiny"; 8 + import { ThreadLink, QuotesLink } from "./PostLinks"; 9 + import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 10 + import { 11 + BlueskyEmbed, 12 + PostNotAvailable, 13 + } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; 14 + import { BlueskyRichText } from "components/Blocks/BlueskyPostBlock/BlueskyRichText"; 15 + import { openPage } from "./PostPages"; 16 + 17 + export const PubBlueskyPostBlock = (props: { 18 + post: PostView; 19 + className: string; 20 + pageId?: string; 21 + }) => { 22 + let post = props.post; 23 + 24 + const handleOpenThread = () => { 25 + openPage( 26 + props.pageId ? { type: "doc", id: props.pageId } : undefined, 27 + { type: "thread", uri: post.uri }, 28 + ); 29 + }; 30 + 31 + switch (true) { 32 + case AppBskyFeedDefs.isBlockedPost(post) || 33 + AppBskyFeedDefs.isBlockedAuthor(post) || 34 + AppBskyFeedDefs.isNotFoundPost(post): 35 + return ( 36 + <div className={`w-full`}> 37 + <PostNotAvailable /> 38 + </div> 39 + ); 40 + 41 + case AppBskyFeedDefs.validatePostView(post).success: 42 + let record = post.record as AppBskyFeedDefs.PostView["record"]; 43 + 44 + // silliness to get the text and timestamp from the record with proper types 45 + let timestamp: string | undefined = undefined; 46 + if (AppBskyFeedPost.isRecord(record)) { 47 + timestamp = (record as AppBskyFeedPost.Record).createdAt; 48 + } 49 + 50 + //getting the url to the post 51 + let postId = post.uri.split("/")[4]; 52 + let url = `https://bsky.app/profile/${post.author.handle}/post/${postId}`; 53 + 54 + const parent = props.pageId ? { type: "doc" as const, id: props.pageId } : undefined; 55 + 56 + return ( 57 + <div 58 + onClick={handleOpenThread} 59 + className={` 60 + ${props.className} 61 + block-border 62 + mb-2 63 + flex flex-col gap-2 relative w-full overflow-hidden group/blueskyPostBlock sm:p-3 p-2 text-sm text-secondary bg-bg-page 64 + cursor-pointer hover:border-accent-contrast 65 + `} 66 + > 67 + {post.author && record && ( 68 + <> 69 + <div className="bskyAuthor w-full flex items-center gap-2"> 70 + {post.author.avatar && ( 71 + <img 72 + src={post.author?.avatar} 73 + alt={`${post.author?.displayName}'s avatar`} 74 + className="shink-0 w-8 h-8 rounded-full border border-border-light" 75 + /> 76 + )} 77 + <div className="grow flex flex-col gap-0.5 leading-tight"> 78 + <div className=" font-bold text-secondary"> 79 + {post.author?.displayName} 80 + </div> 81 + <a 82 + className="text-xs text-tertiary hover:underline" 83 + target="_blank" 84 + href={`https://bsky.app/profile/${post.author?.handle}`} 85 + onClick={(e) => e.stopPropagation()} 86 + > 87 + @{post.author?.handle} 88 + </a> 89 + </div> 90 + </div> 91 + 92 + <div className="flex flex-col gap-2 "> 93 + <div> 94 + <pre className="whitespace-pre-wrap"> 95 + {BlueskyRichText({ 96 + record: record as AppBskyFeedPost.Record | null, 97 + })} 98 + </pre> 99 + </div> 100 + {post.embed && ( 101 + <div onClick={(e) => e.stopPropagation()}> 102 + <BlueskyEmbed embed={post.embed} postUrl={url} /> 103 + </div> 104 + )} 105 + </div> 106 + </> 107 + )} 108 + <div className="w-full flex gap-2 items-center justify-between"> 109 + <ClientDate date={timestamp} /> 110 + <div className="flex gap-2 items-center"> 111 + {post.replyCount != null && post.replyCount > 0 && ( 112 + <> 113 + <ThreadLink 114 + threadUri={post.uri} 115 + parent={parent} 116 + className="flex items-center gap-1 hover:text-accent-contrast" 117 + onClick={(e) => e.stopPropagation()} 118 + > 119 + {post.replyCount} 120 + <CommentTiny /> 121 + </ThreadLink> 122 + <Separator classname="h-4" /> 123 + </> 124 + )} 125 + {post.quoteCount != null && post.quoteCount > 0 && ( 126 + <> 127 + <QuotesLink 128 + postUri={post.uri} 129 + parent={parent} 130 + className="flex items-center gap-1 hover:text-accent-contrast" 131 + onClick={(e) => e.stopPropagation()} 132 + > 133 + {post.quoteCount} 134 + <QuoteTiny /> 135 + </QuotesLink> 136 + <Separator classname="h-4" /> 137 + </> 138 + )} 139 + 140 + <a 141 + className="" 142 + target="_blank" 143 + href={url} 144 + onClick={(e) => e.stopPropagation()} 145 + > 146 + <BlueskyTiny /> 147 + </a> 148 + </div> 149 + </div> 150 + </div> 151 + ); 152 + } 153 + }; 154 + 155 + const ClientDate = (props: { date?: string }) => { 156 + let pageLoaded = useHasPageLoaded(); 157 + const formattedDate = useLocalizedDate( 158 + props.date || new Date().toISOString(), 159 + { 160 + month: "short", 161 + day: "numeric", 162 + year: "numeric", 163 + hour: "numeric", 164 + minute: "numeric", 165 + hour12: true, 166 + }, 167 + ); 168 + 169 + if (!pageLoaded) return null; 170 + 171 + return <div className="text-xs text-tertiary">{formattedDate}</div>; 172 + };
+340
app/lish/[did]/[publication]/[rkey]/PublishedPageBlock.tsx
··· 1 + "use client"; 2 + 3 + import { useEntity, useReplicache } from "src/replicache"; 4 + import { useUIState } from "src/useUIState"; 5 + import { CSSProperties, useContext, useRef } from "react"; 6 + import { useCardBorderHidden } from "components/Pages/useCardBorderHidden"; 7 + import { PostContent, Block } from "./PostContent"; 8 + import { 9 + PubLeafletBlocksHeader, 10 + PubLeafletBlocksText, 11 + PubLeafletComment, 12 + PubLeafletPagesLinearDocument, 13 + PubLeafletPagesCanvas, 14 + PubLeafletPublication, 15 + } from "lexicons/api"; 16 + import { AppBskyFeedDefs } from "@atproto/api"; 17 + import { TextBlock } from "./TextBlock"; 18 + import { PostPageContext } from "./PostPageContext"; 19 + import { openPage, useOpenPages } from "./PostPages"; 20 + import { 21 + openInteractionDrawer, 22 + setInteractionState, 23 + useInteractionState, 24 + } from "./Interactions/Interactions"; 25 + import { CommentTiny } from "components/Icons/CommentTiny"; 26 + import { QuoteTiny } from "components/Icons/QuoteTiny"; 27 + import { CanvasBackgroundPattern } from "components/Canvas"; 28 + 29 + export function PublishedPageLinkBlock(props: { 30 + blocks: PubLeafletPagesLinearDocument.Block[] | PubLeafletPagesCanvas.Block[]; 31 + parentPageId: string | undefined; 32 + pageId: string; 33 + did: string; 34 + preview?: boolean; 35 + className?: string; 36 + prerenderedCodeBlocks?: Map<string, string>; 37 + bskyPostData: AppBskyFeedDefs.PostView[]; 38 + isCanvas?: boolean; 39 + pages?: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 40 + }) { 41 + //switch to use actually state 42 + let openPages = useOpenPages(); 43 + let isOpen = openPages.some( 44 + (p) => p.type === "doc" && p.id === props.pageId, 45 + ); 46 + return ( 47 + <div 48 + className={`w-full cursor-pointer 49 + pageLinkBlockWrapper relative group/pageLinkBlock 50 + bg-bg-page shadow-sm 51 + flex overflow-clip 52 + block-border 53 + ${isOpen && "!border-tertiary"} 54 + ${props.className} 55 + `} 56 + onClick={(e) => { 57 + if (e.isDefaultPrevented()) return; 58 + if (e.shiftKey) return; 59 + e.preventDefault(); 60 + e.stopPropagation(); 61 + 62 + openPage( 63 + props.parentPageId ? { type: "doc", id: props.parentPageId } : undefined, 64 + { type: "doc", id: props.pageId }, 65 + ); 66 + }} 67 + > 68 + {props.isCanvas ? ( 69 + <CanvasLinkBlock 70 + blocks={props.blocks as PubLeafletPagesCanvas.Block[]} 71 + did={props.did} 72 + pageId={props.pageId} 73 + bskyPostData={props.bskyPostData} 74 + pages={props.pages || []} 75 + /> 76 + ) : ( 77 + <DocLinkBlock 78 + {...props} 79 + blocks={props.blocks as PubLeafletPagesLinearDocument.Block[]} 80 + /> 81 + )} 82 + </div> 83 + ); 84 + } 85 + export function DocLinkBlock(props: { 86 + blocks: PubLeafletPagesLinearDocument.Block[]; 87 + pageId: string; 88 + parentPageId?: string; 89 + did: string; 90 + preview?: boolean; 91 + className?: string; 92 + prerenderedCodeBlocks?: Map<string, string>; 93 + bskyPostData: AppBskyFeedDefs.PostView[]; 94 + }) { 95 + let [title, description] = props.blocks 96 + .map((b) => b.block) 97 + .filter( 98 + (b) => PubLeafletBlocksText.isMain(b) || PubLeafletBlocksHeader.isMain(b), 99 + ); 100 + 101 + return ( 102 + <div 103 + style={{ "--list-marker-width": "20px" } as CSSProperties} 104 + className={` 105 + w-full h-[104px] 106 + `} 107 + > 108 + <> 109 + <div className="pageLinkBlockContent w-full flex overflow-clip cursor-pointer h-full"> 110 + <div className="my-2 ml-3 grow min-w-0 text-sm bg-transparent overflow-clip flex flex-col "> 111 + <div className="grow"> 112 + {title && ( 113 + <div 114 + className={`pageBlockOne outline-none resize-none align-top gap-2 ${title.$type === "pub.leaflet.blocks.header" ? "font-bold text-base" : ""}`} 115 + > 116 + <TextBlock 117 + facets={title.facets} 118 + plaintext={title.plaintext} 119 + index={[]} 120 + preview 121 + /> 122 + </div> 123 + )} 124 + {description && ( 125 + <div 126 + className={`pageBlockLineTwo outline-none resize-none align-top gap-2 ${description.$type === "pub.leaflet.blocks.header" ? "font-bold" : ""}`} 127 + > 128 + <TextBlock 129 + facets={description.facets} 130 + plaintext={description.plaintext} 131 + index={[]} 132 + preview 133 + /> 134 + </div> 135 + )} 136 + </div> 137 + 138 + <Interactions 139 + pageId={props.pageId} 140 + parentPageId={props.parentPageId} 141 + /> 142 + </div> 143 + {!props.preview && ( 144 + <PagePreview blocks={props.blocks} did={props.did} /> 145 + )} 146 + </div> 147 + </> 148 + </div> 149 + ); 150 + } 151 + 152 + export function PagePreview(props: { 153 + did: string; 154 + blocks: PubLeafletPagesLinearDocument.Block[]; 155 + }) { 156 + let previewRef = useRef<HTMLDivElement | null>(null); 157 + let { rootEntity } = useReplicache(); 158 + let data = useContext(PostPageContext); 159 + let theme = data?.theme; 160 + let pageWidth = `var(--page-width-unitless)`; 161 + let cardBorderHidden = !theme?.showPageBackground; 162 + return ( 163 + <div 164 + ref={previewRef} 165 + className={`pageLinkBlockPreview w-[120px] overflow-clip mx-3 mt-3 -mb-2 border rounded-md shrink-0 border-border-light flex flex-col gap-0.5 rotate-[4deg] origin-center ${cardBorderHidden ? "" : "bg-bg-page"}`} 166 + > 167 + <div 168 + className="absolute top-0 left-0 origin-top-left pointer-events-none " 169 + style={{ 170 + width: `calc(1px * ${pageWidth})`, 171 + height: `calc(100vh - 64px)`, 172 + transform: `scale(calc((120 / ${pageWidth} )))`, 173 + backgroundColor: "rgba(var(--bg-page), var(--bg-page-alpha))", 174 + }} 175 + > 176 + {!cardBorderHidden && ( 177 + <div 178 + className={`pageLinkBlockBackground 179 + absolute top-0 left-0 right-0 bottom-0 180 + pointer-events-none 181 + `} 182 + /> 183 + )} 184 + <PostContent 185 + pollData={[]} 186 + pages={[]} 187 + did={props.did} 188 + blocks={props.blocks} 189 + preview 190 + bskyPostData={[]} 191 + /> 192 + </div> 193 + </div> 194 + ); 195 + } 196 + 197 + const Interactions = (props: { pageId: string; parentPageId?: string }) => { 198 + const data = useContext(PostPageContext); 199 + const document_uri = data?.uri; 200 + if (!document_uri) 201 + throw new Error("document_uri not available in PostPageContext"); 202 + let comments = data.comments_on_documents.filter( 203 + (c) => (c.record as PubLeafletComment.Record)?.onPage === props.pageId, 204 + ).length; 205 + let quotes = data.document_mentions_in_bsky.filter((q) => 206 + q.link.includes(props.pageId), 207 + ).length; 208 + 209 + let { drawerOpen, drawer, pageId } = useInteractionState(document_uri); 210 + 211 + return ( 212 + <div 213 + className={`flex gap-2 text-tertiary text-sm absolute bottom-2 bg-bg-page`} 214 + > 215 + {quotes > 0 && ( 216 + <button 217 + className={`flex gap-1 items-center`} 218 + onClick={(e) => { 219 + e.preventDefault(); 220 + e.stopPropagation(); 221 + openPage( 222 + props.parentPageId ? { type: "doc", id: props.parentPageId } : undefined, 223 + { type: "doc", id: props.pageId }, 224 + { scrollIntoView: false }, 225 + ); 226 + if (!drawerOpen || drawer !== "quotes") 227 + openInteractionDrawer("quotes", document_uri, props.pageId); 228 + else setInteractionState(document_uri, { drawerOpen: false }); 229 + }} 230 + > 231 + <span className="sr-only">Page quotes</span> 232 + <QuoteTiny aria-hidden /> {quotes}{" "} 233 + </button> 234 + )} 235 + {comments > 0 && ( 236 + <button 237 + className={`flex gap-1 items-center`} 238 + onClick={(e) => { 239 + e.preventDefault(); 240 + e.stopPropagation(); 241 + openPage( 242 + props.parentPageId ? { type: "doc", id: props.parentPageId } : undefined, 243 + { type: "doc", id: props.pageId }, 244 + { scrollIntoView: false }, 245 + ); 246 + if (!drawerOpen || drawer !== "comments" || pageId !== props.pageId) 247 + openInteractionDrawer("comments", document_uri, props.pageId); 248 + else setInteractionState(document_uri, { drawerOpen: false }); 249 + }} 250 + > 251 + <span className="sr-only">Page comments</span> 252 + <CommentTiny aria-hidden /> {comments}{" "} 253 + </button> 254 + )} 255 + </div> 256 + ); 257 + }; 258 + 259 + const CanvasLinkBlock = (props: { 260 + blocks: PubLeafletPagesCanvas.Block[]; 261 + did: string; 262 + pageId: string; 263 + bskyPostData: AppBskyFeedDefs.PostView[]; 264 + pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 265 + }) => { 266 + let pageWidth = `var(--page-width-unitless)`; 267 + let height = 268 + props.blocks.length > 0 ? Math.max(...props.blocks.map((b) => b.y), 0) : 0; 269 + 270 + return ( 271 + <div 272 + style={{ contain: "size layout paint" }} 273 + className={`pageLinkBlockPreview shrink-0 h-[200px] w-full overflow-clip relative`} 274 + > 275 + <div 276 + className={`absolute top-0 left-0 origin-top-left pointer-events-none w-full`} 277 + style={{ 278 + width: `calc(1px * ${pageWidth})`, 279 + height: "calc(1150px * 2)", 280 + transform: `scale(calc(((${pageWidth} - 36) / 1272 )))`, 281 + }} 282 + > 283 + <div 284 + style={{ 285 + minHeight: height + 512, 286 + contain: "size layout paint", 287 + }} 288 + className="relative h-full w-[1272px]" 289 + > 290 + <div className="w-full h-full pointer-events-none"> 291 + <CanvasBackgroundPattern pattern="grid" /> 292 + </div> 293 + {props.blocks 294 + .sort((a, b) => { 295 + if (a.y === b.y) { 296 + return a.x - b.x; 297 + } 298 + return a.y - b.y; 299 + }) 300 + .map((canvasBlock, index) => { 301 + let { x, y, width, rotation } = canvasBlock; 302 + let transform = `translate(${x}px, ${y}px)${rotation ? ` rotate(${rotation}deg)` : ""}`; 303 + 304 + // Wrap the block in a LinearDocument.Block structure for compatibility 305 + let linearBlock: PubLeafletPagesLinearDocument.Block = { 306 + $type: "pub.leaflet.pages.linearDocument#block", 307 + block: canvasBlock.block, 308 + }; 309 + 310 + return ( 311 + <div 312 + key={index} 313 + className="absolute rounded-lg flex items-stretch origin-center p-3" 314 + style={{ 315 + top: 0, 316 + left: 0, 317 + width, 318 + transform, 319 + }} 320 + > 321 + <div className="contents"> 322 + <Block 323 + pollData={[]} 324 + pageId={props.pageId} 325 + pages={props.pages} 326 + bskyPostData={props.bskyPostData} 327 + block={linearBlock} 328 + did={props.did} 329 + index={[index]} 330 + preview={true} 331 + /> 332 + </div> 333 + </div> 334 + ); 335 + })} 336 + </div> 337 + </div> 338 + </div> 339 + ); 340 + };
+346
app/lish/[did]/[publication]/[rkey]/PublishedPollBlock.tsx
··· 1 + "use client"; 2 + 3 + import { 4 + PubLeafletBlocksPoll, 5 + PubLeafletPollDefinition, 6 + PubLeafletPollVote, 7 + } from "lexicons/api"; 8 + import { useState, useEffect } from "react"; 9 + import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; 10 + import { useIdentityData } from "components/IdentityProvider"; 11 + import { AtpAgent } from "@atproto/api"; 12 + import { voteOnPublishedPoll } from "./voteOnPublishedPoll"; 13 + import { PollData } from "./fetchPollData"; 14 + import { Popover } from "components/Popover"; 15 + import LoginForm from "app/login/LoginForm"; 16 + import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 17 + import { getVoterIdentities, VoterIdentity } from "./getVoterIdentities"; 18 + import { Json } from "supabase/database.types"; 19 + import { InfoSmall } from "components/Icons/InfoSmall"; 20 + 21 + // Helper function to extract the first option from a vote record 22 + const getVoteOption = (voteRecord: any): string | null => { 23 + try { 24 + const record = voteRecord as PubLeafletPollVote.Record; 25 + return record.option && record.option.length > 0 ? record.option[0] : null; 26 + } catch { 27 + return null; 28 + } 29 + }; 30 + 31 + export const PublishedPollBlock = (props: { 32 + block: PubLeafletBlocksPoll.Main; 33 + pollData: PollData; 34 + className?: string; 35 + }) => { 36 + const { identity } = useIdentityData(); 37 + const [selectedOption, setSelectedOption] = useState<string | null>(null); 38 + const [isVoting, setIsVoting] = useState(false); 39 + const [showResults, setShowResults] = useState(false); 40 + const [optimisticVote, setOptimisticVote] = useState<{ 41 + option: string; 42 + voter_did: string; 43 + } | null>(null); 44 + let pollRecord = props.pollData.record as PubLeafletPollDefinition.Record; 45 + let [isClient, setIsClient] = useState(false); 46 + useEffect(() => { 47 + setIsClient(true); 48 + }, []); 49 + 50 + const handleVote = async () => { 51 + if (!selectedOption || !identity?.atp_did) return; 52 + 53 + setIsVoting(true); 54 + 55 + // Optimistically add the vote 56 + setOptimisticVote({ 57 + option: selectedOption, 58 + voter_did: identity.atp_did, 59 + }); 60 + setShowResults(true); 61 + 62 + try { 63 + const result = await voteOnPublishedPoll( 64 + props.block.pollRef.uri, 65 + props.block.pollRef.cid, 66 + selectedOption, 67 + ); 68 + 69 + if (!result.success) { 70 + console.error("Failed to vote:", result.error); 71 + // Revert optimistic update on failure 72 + setOptimisticVote(null); 73 + setShowResults(false); 74 + } 75 + } catch (error) { 76 + console.error("Failed to vote:", error); 77 + // Revert optimistic update on failure 78 + setOptimisticVote(null); 79 + setShowResults(false); 80 + } finally { 81 + setIsVoting(false); 82 + } 83 + }; 84 + 85 + const hasVoted = 86 + !!identity?.atp_did && 87 + (!!props.pollData?.atp_poll_votes.find( 88 + (v) => v.voter_did === identity?.atp_did, 89 + ) || 90 + !!optimisticVote); 91 + let isCreator = 92 + identity?.atp_did && props.pollData.uri.includes(identity?.atp_did); 93 + const displayResults = showResults || hasVoted; 94 + 95 + return ( 96 + <div 97 + className={`poll flex flex-col gap-2 p-3 w-full ${props.className} block-border`} 98 + style={{ 99 + backgroundColor: 100 + "color-mix(in oklab, rgb(var(--accent-1)), rgb(var(--bg-page)) 85%)", 101 + }} 102 + > 103 + {displayResults ? ( 104 + <> 105 + <PollResults 106 + pollData={props.pollData} 107 + hasVoted={hasVoted} 108 + setShowResults={setShowResults} 109 + optimisticVote={optimisticVote} 110 + /> 111 + {!hasVoted && ( 112 + <div className="flex justify-start"> 113 + <button 114 + className="w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast" 115 + onClick={() => setShowResults(false)} 116 + > 117 + Back to Voting 118 + </button> 119 + </div> 120 + )} 121 + </> 122 + ) : ( 123 + <> 124 + {pollRecord.options.map((option, index) => ( 125 + <PollOptionButton 126 + key={index} 127 + option={option} 128 + optionIndex={index.toString()} 129 + selected={selectedOption === index.toString()} 130 + onSelect={() => setSelectedOption(index.toString())} 131 + disabled={!identity?.atp_did} 132 + /> 133 + ))} 134 + <div className="flex flex-col-reverse sm:flex-row sm:justify-between gap-2 items-center pt-2"> 135 + <div className="text-sm text-tertiary">All votes are public</div> 136 + <div className="flex sm:gap-3 sm:flex-row flex-col-reverse sm:justify-end justify-center gap-1 items-center"> 137 + <button 138 + className="w-fit font-bold text-accent-contrast" 139 + onClick={() => setShowResults(!showResults)} 140 + > 141 + See Results 142 + </button> 143 + {identity?.atp_did ? ( 144 + <ButtonPrimary 145 + className="place-self-end" 146 + onClick={handleVote} 147 + disabled={!selectedOption || isVoting} 148 + > 149 + {isVoting ? "Voting..." : "Vote!"} 150 + </ButtonPrimary> 151 + ) : ( 152 + <Popover 153 + asChild 154 + trigger={ 155 + <ButtonPrimary className="place-self-center"> 156 + <BlueskyTiny /> Login to vote 157 + </ButtonPrimary> 158 + } 159 + > 160 + {isClient && ( 161 + <LoginForm 162 + text="Log in to vote on this poll!" 163 + noEmail 164 + redirectRoute={window?.location.href + "?refreshAuth"} 165 + /> 166 + )} 167 + </Popover> 168 + )} 169 + </div> 170 + </div> 171 + </> 172 + )} 173 + </div> 174 + ); 175 + }; 176 + 177 + const PollOptionButton = (props: { 178 + option: PubLeafletPollDefinition.Option; 179 + optionIndex: string; 180 + selected: boolean; 181 + onSelect: () => void; 182 + disabled?: boolean; 183 + }) => { 184 + const ButtonComponent = props.selected ? ButtonPrimary : ButtonSecondary; 185 + 186 + return ( 187 + <div className="flex gap-2 items-center"> 188 + <ButtonComponent 189 + className="pollOption grow max-w-full flex" 190 + onClick={props.onSelect} 191 + disabled={props.disabled} 192 + > 193 + {props.option.text} 194 + </ButtonComponent> 195 + </div> 196 + ); 197 + }; 198 + 199 + const PollResults = (props: { 200 + pollData: PollData; 201 + hasVoted: boolean; 202 + setShowResults: (show: boolean) => void; 203 + optimisticVote: { option: string; voter_did: string } | null; 204 + }) => { 205 + // Merge optimistic vote with actual votes 206 + const allVotes = props.optimisticVote 207 + ? [ 208 + ...props.pollData.atp_poll_votes, 209 + { 210 + voter_did: props.optimisticVote.voter_did, 211 + record: { 212 + $type: "pub.leaflet.poll.vote", 213 + option: [props.optimisticVote.option], 214 + }, 215 + }, 216 + ] 217 + : props.pollData.atp_poll_votes; 218 + 219 + const totalVotes = allVotes.length || 0; 220 + let pollRecord = props.pollData.record as PubLeafletPollDefinition.Record; 221 + let optionsWithCount = pollRecord.options.map((o, index) => ({ 222 + ...o, 223 + votes: allVotes.filter((v) => getVoteOption(v.record) == index.toString()), 224 + })); 225 + 226 + const highestVotes = Math.max(...optionsWithCount.map((o) => o.votes.length)); 227 + return ( 228 + <> 229 + {pollRecord.options.map((option, index) => { 230 + const voteRecords = allVotes.filter( 231 + (v) => getVoteOption(v.record) === index.toString(), 232 + ); 233 + const isWinner = totalVotes > 0 && voteRecords.length === highestVotes; 234 + 235 + return ( 236 + <PollResult 237 + key={index} 238 + option={option} 239 + votes={voteRecords.length} 240 + voteRecords={voteRecords} 241 + totalVotes={totalVotes} 242 + winner={isWinner} 243 + /> 244 + ); 245 + })} 246 + </> 247 + ); 248 + }; 249 + 250 + const VoterListPopover = (props: { 251 + votes: number; 252 + voteRecords: { voter_did: string; record: Json }[]; 253 + }) => { 254 + const [voterIdentities, setVoterIdentities] = useState<VoterIdentity[]>([]); 255 + const [isLoading, setIsLoading] = useState(false); 256 + const [hasFetched, setHasFetched] = useState(false); 257 + 258 + const handleOpenChange = async () => { 259 + if (!hasFetched && props.voteRecords.length > 0) { 260 + setIsLoading(true); 261 + setHasFetched(true); 262 + try { 263 + const dids = props.voteRecords.map((v) => v.voter_did); 264 + const identities = await getVoterIdentities(dids); 265 + setVoterIdentities(identities); 266 + } catch (error) { 267 + console.error("Failed to fetch voter identities:", error); 268 + } finally { 269 + setIsLoading(false); 270 + } 271 + } 272 + }; 273 + 274 + return ( 275 + <Popover 276 + trigger={ 277 + <button 278 + className="hover:underline cursor-pointer" 279 + disabled={props.votes === 0} 280 + > 281 + {props.votes} 282 + </button> 283 + } 284 + onOpenChange={handleOpenChange} 285 + className="w-64 max-h-80" 286 + > 287 + {isLoading ? ( 288 + <div className="flex justify-center py-4"> 289 + <div className="text-sm text-secondary">Loading...</div> 290 + </div> 291 + ) : ( 292 + <div className="flex flex-col gap-1 text-sm py-0.5"> 293 + {voterIdentities.map((voter) => ( 294 + <a 295 + key={voter.did} 296 + href={`https://bsky.app/profile/${voter.handle || voter.did}`} 297 + target="_blank" 298 + rel="noopener noreferrer" 299 + className="" 300 + > 301 + @{voter.handle || voter.did} 302 + </a> 303 + ))} 304 + </div> 305 + )} 306 + </Popover> 307 + ); 308 + }; 309 + 310 + const PollResult = (props: { 311 + option: PubLeafletPollDefinition.Option; 312 + votes: number; 313 + voteRecords: { voter_did: string; record: Json }[]; 314 + totalVotes: number; 315 + winner: boolean; 316 + }) => { 317 + return ( 318 + <div 319 + className={`pollResult relative grow py-0.5 px-2 border-accent-contrast rounded-md overflow-hidden ${props.winner ? "font-bold border-2" : "border"}`} 320 + > 321 + <div 322 + style={{ 323 + WebkitTextStroke: `${props.winner ? "6px" : "6px"} rgb(var(--bg-page))`, 324 + paintOrder: "stroke fill", 325 + }} 326 + className="pollResultContent text-accent-contrast relative flex gap-2 justify-between z-10" 327 + > 328 + <div className="grow max-w-full truncate">{props.option.text}</div> 329 + <VoterListPopover votes={props.votes} voteRecords={props.voteRecords} /> 330 + </div> 331 + <div className="pollResultBG absolute bg-bg-page w-full top-0 bottom-0 left-0 right-0 flex flex-row z-0"> 332 + <div 333 + className="bg-accent-contrast rounded-[2px] m-0.5" 334 + style={{ 335 + maskImage: "var(--hatchSVG)", 336 + maskRepeat: "repeat repeat", 337 + ...(props.votes === 0 338 + ? { width: "4px" } 339 + : { flexBasis: `${(props.votes / props.totalVotes) * 100}%` }), 340 + }} 341 + /> 342 + <div /> 343 + </div> 344 + </div> 345 + ); 346 + };
+3 -2
app/lish/[did]/[publication]/[rkey]/QuoteHandler.tsx
··· 186 186 <BlueskyLinkTiny className="shrink-0" /> 187 187 Bluesky 188 188 </a> 189 - <Separator classname="h-4" /> 189 + <Separator classname="h-4!" /> 190 190 <button 191 191 id="copy-quote-link" 192 192 className="flex gap-1 items-center hover:font-bold px-1" ··· 211 211 </button> 212 212 {pubRecord?.preferences?.showComments !== false && identity?.atp_did && ( 213 213 <> 214 - <Separator classname="h-4" /> 214 + <Separator classname="h-4! " /> 215 + 215 216 <button 216 217 className="flex gap-1 items-center hover:font-bold px-1" 217 218 onClick={() => {
+20
app/lish/[did]/[publication]/[rkey]/StaticMathBlock.tsx
··· 1 + import { PubLeafletBlocksMath } from "lexicons/api"; 2 + import Katex from "katex"; 3 + import "katex/dist/katex.min.css"; 4 + 5 + export const StaticMathBlock = ({ 6 + block, 7 + }: { 8 + block: PubLeafletBlocksMath.Main; 9 + }) => { 10 + const html = Katex.renderToString(block.tex, { 11 + displayMode: true, 12 + output: "html", 13 + throwOnError: false, 14 + }); 15 + return ( 16 + <div className="math-block my-2"> 17 + <div dangerouslySetInnerHTML={{ __html: html }} /> 18 + </div> 19 + ); 20 + };
+2 -2
app/lish/[did]/[publication]/[rkey]/StaticPostContent.tsx
··· 12 12 PubLeafletPagesLinearDocument, 13 13 } from "lexicons/api"; 14 14 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 15 - import { TextBlockCore, TextBlockCoreProps } from "./Blocks/TextBlockCore"; 16 - import { StaticMathBlock } from "./Blocks/StaticMathBlock"; 15 + import { TextBlockCore, TextBlockCoreProps } from "./TextBlockCore"; 16 + import { StaticMathBlock } from "./StaticMathBlock"; 17 17 import { codeToHtml, bundledLanguagesInfo, bundledThemesInfo } from "shiki"; 18 18 19 19 function StaticBaseTextBlock(props: Omit<TextBlockCoreProps, "renderers">) {
+95
app/lish/[did]/[publication]/[rkey]/TextBlock.tsx
··· 1 + "use client"; 2 + import { UnicodeString } from "@atproto/api"; 3 + import { PubLeafletRichtextFacet } from "lexicons/api"; 4 + import { useMemo } from "react"; 5 + import { useHighlight } from "./useHighlight"; 6 + import { BaseTextBlock } from "./BaseTextBlock"; 7 + 8 + type Facet = PubLeafletRichtextFacet.Main; 9 + export function TextBlock(props: { 10 + plaintext: string; 11 + facets?: Facet[]; 12 + index: number[]; 13 + preview?: boolean; 14 + pageId?: string; 15 + }) { 16 + let children = []; 17 + let highlights = useHighlight(props.index, props.pageId); 18 + let facets = useMemo(() => { 19 + if (props.preview) return props.facets; 20 + let facets = [...(props.facets || [])]; 21 + for (let highlight of highlights) { 22 + const fragmentId = props.pageId 23 + ? `${props.pageId}~${props.index.join(".")}_${highlight.startOffset || 0}` 24 + : `${props.index.join(".")}_${highlight.startOffset || 0}`; 25 + facets = addFacet( 26 + facets, 27 + { 28 + index: { 29 + byteStart: highlight.startOffset 30 + ? new UnicodeString( 31 + props.plaintext.slice(0, highlight.startOffset), 32 + ).length 33 + : 0, 34 + byteEnd: new UnicodeString( 35 + props.plaintext.slice(0, highlight.endOffset ?? undefined), 36 + ).length, 37 + }, 38 + features: [ 39 + { $type: "pub.leaflet.richtext.facet#highlight" }, 40 + { 41 + $type: "pub.leaflet.richtext.facet#id", 42 + id: fragmentId, 43 + }, 44 + ], 45 + }, 46 + new UnicodeString(props.plaintext).length, 47 + ); 48 + } 49 + return facets; 50 + }, [props.plaintext, props.facets, highlights, props.preview, props.pageId]); 51 + return <BaseTextBlock {...props} facets={facets} />; 52 + } 53 + 54 + function addFacet(facets: Facet[], newFacet: Facet, length: number) { 55 + if (facets.length === 0) { 56 + return [newFacet]; 57 + } 58 + 59 + const allFacets = [...facets, newFacet]; 60 + 61 + // Collect all boundary positions 62 + const boundaries = new Set<number>(); 63 + boundaries.add(0); 64 + boundaries.add(length); 65 + 66 + for (const facet of allFacets) { 67 + boundaries.add(facet.index.byteStart); 68 + boundaries.add(facet.index.byteEnd); 69 + } 70 + 71 + const sortedBoundaries = Array.from(boundaries).sort((a, b) => a - b); 72 + const result: Facet[] = []; 73 + 74 + // Process segments between consecutive boundaries 75 + for (let i = 0; i < sortedBoundaries.length - 1; i++) { 76 + const start = sortedBoundaries[i]; 77 + const end = sortedBoundaries[i + 1]; 78 + 79 + // Find facets that are active at the start position 80 + const activeFacets = allFacets.filter( 81 + (facet) => facet.index.byteStart <= start && facet.index.byteEnd > start, 82 + ); 83 + 84 + // Only create facet if there are active facets (features present) 85 + if (activeFacets.length > 0) { 86 + const features = activeFacets.flatMap((f) => f.features); 87 + result.push({ 88 + index: { byteStart: start, byteEnd: end }, 89 + features, 90 + }); 91 + } 92 + } 93 + 94 + return result; 95 + }
+181
app/lish/[did]/[publication]/[rkey]/TextBlockCore.tsx
··· 1 + import { UnicodeString } from "@atproto/api"; 2 + import { PubLeafletRichtextFacet } from "lexicons/api"; 3 + import { AtMentionLink } from "components/AtMentionLink"; 4 + import { ReactNode } from "react"; 5 + 6 + type Facet = PubLeafletRichtextFacet.Main; 7 + 8 + export type FacetRenderers = { 9 + DidMention?: (props: { did: string; children: ReactNode }) => ReactNode; 10 + }; 11 + 12 + export type TextBlockCoreProps = { 13 + plaintext: string; 14 + facets?: Facet[]; 15 + index: number[]; 16 + preview?: boolean; 17 + renderers?: FacetRenderers; 18 + }; 19 + 20 + export function TextBlockCore(props: TextBlockCoreProps) { 21 + let children = []; 22 + let richText = new RichText({ 23 + text: props.plaintext, 24 + facets: props.facets || [], 25 + }); 26 + let counter = 0; 27 + for (const segment of richText.segments()) { 28 + let id = segment.facet?.find(PubLeafletRichtextFacet.isId); 29 + let link = segment.facet?.find(PubLeafletRichtextFacet.isLink); 30 + let isBold = segment.facet?.find(PubLeafletRichtextFacet.isBold); 31 + let isCode = segment.facet?.find(PubLeafletRichtextFacet.isCode); 32 + let isStrikethrough = segment.facet?.find( 33 + PubLeafletRichtextFacet.isStrikethrough, 34 + ); 35 + let isDidMention = segment.facet?.find( 36 + PubLeafletRichtextFacet.isDidMention, 37 + ); 38 + let isAtMention = segment.facet?.find(PubLeafletRichtextFacet.isAtMention); 39 + let isUnderline = segment.facet?.find(PubLeafletRichtextFacet.isUnderline); 40 + let isItalic = segment.facet?.find(PubLeafletRichtextFacet.isItalic); 41 + let isHighlighted = segment.facet?.find( 42 + PubLeafletRichtextFacet.isHighlight, 43 + ); 44 + let className = ` 45 + ${isCode ? "inline-code" : ""} 46 + ${id ? "scroll-mt-12 scroll-mb-10" : ""} 47 + ${isBold ? "font-bold" : ""} 48 + ${isItalic ? "italic" : ""} 49 + ${isUnderline ? "underline" : ""} 50 + ${isStrikethrough ? "line-through decoration-tertiary" : ""} 51 + ${isHighlighted ? "highlight bg-highlight-1" : ""}`.replaceAll("\n", " "); 52 + 53 + // Split text by newlines and insert <br> tags 54 + const textParts = segment.text.split("\n"); 55 + const renderedText = textParts.flatMap((part, i) => 56 + i < textParts.length - 1 57 + ? [part, <br key={`br-${counter}-${i}`} />] 58 + : [part], 59 + ); 60 + 61 + if (isCode) { 62 + children.push( 63 + <code key={counter} className={className} id={id?.id}> 64 + {renderedText} 65 + </code>, 66 + ); 67 + } else if (isDidMention) { 68 + const DidMentionRenderer = props.renderers?.DidMention; 69 + if (DidMentionRenderer) { 70 + children.push( 71 + <DidMentionRenderer key={counter} did={isDidMention.did}> 72 + <span className="mention">{renderedText}</span> 73 + </DidMentionRenderer>, 74 + ); 75 + } else { 76 + // Default: render as a simple link 77 + children.push( 78 + <a 79 + key={counter} 80 + href={`https://leaflet.pub/p/${isDidMention.did}`} 81 + target="_blank" 82 + className="no-underline" 83 + > 84 + <span className="mention">{renderedText}</span> 85 + </a>, 86 + ); 87 + } 88 + } else if (isAtMention) { 89 + children.push( 90 + <AtMentionLink 91 + key={counter} 92 + atURI={isAtMention.atURI} 93 + className={className} 94 + > 95 + {renderedText} 96 + </AtMentionLink>, 97 + ); 98 + } else if (link) { 99 + children.push( 100 + <a 101 + key={counter} 102 + href={link.uri.trim()} 103 + className={`text-accent-contrast hover:underline ${className}`} 104 + target="_blank" 105 + > 106 + {renderedText} 107 + </a>, 108 + ); 109 + } else { 110 + children.push( 111 + <span key={counter} className={className} id={id?.id}> 112 + {renderedText} 113 + </span>, 114 + ); 115 + } 116 + 117 + counter++; 118 + } 119 + return <>{children}</>; 120 + } 121 + 122 + type RichTextSegment = { 123 + text: string; 124 + facet?: Exclude<Facet["features"], { $type: string }>; 125 + }; 126 + 127 + export class RichText { 128 + unicodeText: UnicodeString; 129 + facets?: Facet[]; 130 + 131 + constructor(props: { text: string; facets: Facet[] }) { 132 + this.unicodeText = new UnicodeString(props.text); 133 + this.facets = props.facets; 134 + if (this.facets) { 135 + this.facets = this.facets 136 + .filter((facet) => facet.index.byteStart <= facet.index.byteEnd) 137 + .sort((a, b) => a.index.byteStart - b.index.byteStart); 138 + } 139 + } 140 + 141 + *segments(): Generator<RichTextSegment, void, void> { 142 + const facets = this.facets || []; 143 + if (!facets.length) { 144 + yield { text: this.unicodeText.utf16 }; 145 + return; 146 + } 147 + 148 + let textCursor = 0; 149 + let facetCursor = 0; 150 + do { 151 + const currFacet = facets[facetCursor]; 152 + if (textCursor < currFacet.index.byteStart) { 153 + yield { 154 + text: this.unicodeText.slice(textCursor, currFacet.index.byteStart), 155 + }; 156 + } else if (textCursor > currFacet.index.byteStart) { 157 + facetCursor++; 158 + continue; 159 + } 160 + if (currFacet.index.byteStart < currFacet.index.byteEnd) { 161 + const subtext = this.unicodeText.slice( 162 + currFacet.index.byteStart, 163 + currFacet.index.byteEnd, 164 + ); 165 + if (!subtext.trim()) { 166 + // dont empty string entities 167 + yield { text: subtext }; 168 + } else { 169 + yield { text: subtext, facet: currFacet.features }; 170 + } 171 + } 172 + textCursor = currFacet.index.byteEnd; 173 + facetCursor++; 174 + } while (facetCursor < facets.length); 175 + if (textCursor < this.unicodeText.length) { 176 + yield { 177 + text: this.unicodeText.slice(textCursor, this.unicodeText.length), 178 + }; 179 + } 180 + } 181 + }
+58 -1
app/lish/[did]/[publication]/[rkey]/getPostPageData.ts
··· 10 10 data, 11 11 uri, 12 12 comments_on_documents(*, bsky_profiles(*)), 13 - documents_in_publications(publications(*, publication_subscriptions(*))), 13 + documents_in_publications(publications(*, 14 + documents_in_publications(documents(uri, data)), 15 + publication_subscriptions(*)) 16 + ), 14 17 document_mentions_in_bsky(*), 15 18 leaflets_in_publications(*) 16 19 `, ··· 51 54 ?.record as PubLeafletPublication.Record 52 55 )?.theme || (document?.data as PubLeafletDocument.Record)?.theme; 53 56 57 + // Calculate prev/next documents from the fetched publication documents 58 + let prevNext: 59 + | { 60 + prev?: { uri: string; title: string }; 61 + next?: { uri: string; title: string }; 62 + } 63 + | undefined; 64 + 65 + const currentPublishedAt = (document.data as PubLeafletDocument.Record) 66 + ?.publishedAt; 67 + const allDocs = 68 + document.documents_in_publications[0]?.publications 69 + ?.documents_in_publications; 70 + 71 + if (currentPublishedAt && allDocs) { 72 + // Filter and sort documents by publishedAt 73 + const sortedDocs = allDocs 74 + .map((dip) => ({ 75 + uri: dip?.documents?.uri, 76 + title: (dip?.documents?.data as PubLeafletDocument.Record).title, 77 + publishedAt: (dip?.documents?.data as PubLeafletDocument.Record) 78 + .publishedAt, 79 + })) 80 + .filter((doc) => doc.publishedAt) // Only include docs with publishedAt 81 + .sort( 82 + (a, b) => 83 + new Date(a.publishedAt!).getTime() - 84 + new Date(b.publishedAt!).getTime(), 85 + ); 86 + 87 + // Find current document index 88 + const currentIndex = sortedDocs.findIndex((doc) => doc.uri === uri); 89 + 90 + if (currentIndex !== -1) { 91 + prevNext = { 92 + prev: 93 + currentIndex > 0 94 + ? { 95 + uri: sortedDocs[currentIndex - 1].uri || "", 96 + title: sortedDocs[currentIndex - 1].title, 97 + } 98 + : undefined, 99 + next: 100 + currentIndex < sortedDocs.length - 1 101 + ? { 102 + uri: sortedDocs[currentIndex + 1].uri || "", 103 + title: sortedDocs[currentIndex + 1].title, 104 + } 105 + : undefined, 106 + }; 107 + } 108 + } 109 + 54 110 return { 55 111 ...document, 56 112 quotesAndMentions, 57 113 theme, 114 + prevNext, 58 115 }; 59 116 } 60 117
+1
app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
··· 140 140 commentsCount={comments} 141 141 tags={tags} 142 142 showComments={pubRecord?.preferences?.showComments} 143 + showMentions={pubRecord?.preferences?.showMentions} 143 144 postUrl={`${getPublicationURL(publication)}/${uri.rkey}`} 144 145 /> 145 146 </div>
+31 -25
app/lish/[did]/[publication]/dashboard/settings/PostOptions.tsx
··· 22 22 ? true 23 23 : record.preferences.showComments, 24 24 ); 25 - let [showMentions, setShowMentions] = useState(true); 26 - let [showPrevNext, setShowPrevNext] = useState(true); 25 + let [showMentions, setShowMentions] = useState( 26 + record?.preferences?.showMentions === undefined 27 + ? true 28 + : record.preferences.showMentions, 29 + ); 30 + let [showPrevNext, setShowPrevNext] = useState( 31 + record?.preferences?.showPrevNext === undefined 32 + ? true 33 + : record.preferences.showPrevNext, 34 + ); 27 35 28 36 let toast = useToaster(); 29 37 return ( 30 38 <form 31 39 onSubmit={async (e) => { 32 - // if (!pubData) return; 33 - // e.preventDefault(); 34 - // props.setLoading(true); 35 - // let data = await updatePublication({ 36 - // uri: pubData.uri, 37 - // name: nameValue, 38 - // description: descriptionValue, 39 - // iconFile: iconFile, 40 - // preferences: { 41 - // showInDiscover: showInDiscover, 42 - // showComments: showComments, 43 - // }, 44 - // }); 45 - // toast({ type: "success", content: "Posts Updated!" }); 46 - // props.setLoading(false); 47 - // mutate("publication-data"); 40 + if (!pubData) return; 41 + e.preventDefault(); 42 + props.setLoading(true); 43 + let data = await updatePublication({ 44 + name: record.name, 45 + uri: pubData.uri, 46 + preferences: { 47 + showInDiscover: 48 + record?.preferences?.showInDiscover === undefined 49 + ? true 50 + : record.preferences.showInDiscover, 51 + showComments: showComments, 52 + showMentions: showMentions, 53 + showPrevNext: showPrevNext, 54 + }, 55 + }); 56 + toast({ type: "success", content: <strong>Posts Updated!</strong> }); 57 + console.log(record.preferences?.showPrevNext); 58 + props.setLoading(false); 59 + mutate("publication-data"); 48 60 }} 49 61 className="text-primary flex flex-col" 50 62 > ··· 57 69 Post Options 58 70 </PubSettingsHeader> 59 71 <h4 className="mb-1">Layout</h4> 60 - {/*<div>Max Post Width</div>*/} 61 72 <Toggle 62 73 toggle={showPrevNext} 63 74 onToggle={() => { 64 75 setShowPrevNext(!showPrevNext); 65 76 }} 66 77 > 67 - <div className="flex flex-col justify-start"> 68 - <div className="font-bold">Show Prev/Next Buttons</div> 69 - <div className="text-tertiary text-sm leading-tight"> 70 - Show buttons that navigate to the previous and next posts 71 - </div> 72 - </div> 78 + <div className="font-bold">Show Prev/Next Buttons</div> 73 79 </Toggle> 74 80 <hr className="my-2 border-border-light" /> 75 81 <h4 className="mb-1">Interactions</h4>
+2 -2
app/lish/[did]/[publication]/dashboard/settings/PublicationSettings.tsx
··· 103 103 Theme and Layout 104 104 <ArrowRightTiny /> 105 105 </button> 106 - {/*<button 106 + <button 107 107 className={menuItemClassName} 108 108 type="button" 109 109 onClick={() => props.setState("post-options")} 110 110 > 111 111 Post Options 112 112 <ArrowRightTiny /> 113 - </button>*/} 113 + </button> 114 114 </div> 115 115 ); 116 116 };
+3 -7
app/lish/[did]/[publication]/page.tsx
··· 18 18 import { LocalizedDate } from "./LocalizedDate"; 19 19 import { PublicationHomeLayout } from "./PublicationHomeLayout"; 20 20 import { PublicationAuthor } from "./PublicationAuthor"; 21 - import { Separator } from "components/Layout"; 22 21 23 22 export default async function Publication(props: { 24 23 params: Promise<{ publication: string; did: string }>; ··· 148 147 </p> 149 148 </SpeedyLink> 150 149 151 - <div className="text-sm text-tertiary flex gap-1 flex-wrap pt-2 items-center"> 150 + <div className="text-sm text-tertiary flex gap-1 flex-wrap pt-2"> 152 151 <p className="text-sm text-tertiary "> 153 152 {doc_record.publishedAt && ( 154 153 <LocalizedDate ··· 161 160 /> 162 161 )}{" "} 163 162 </p> 164 - {comments > 0 || quotes > 0 || tags.length > 0 ? ( 165 - <Separator classname="h-4! mx-1" /> 166 - ) : ( 167 - "" 168 - )} 163 + {comments > 0 || quotes > 0 ? "| " : ""} 169 164 <InteractionPreview 170 165 quotesCount={quotes} 171 166 commentsCount={comments} 172 167 tags={tags} 173 168 postUrl={`${getPublicationURL(publication)}/${uri.rkey}`} 174 169 showComments={record?.preferences?.showComments} 170 + showMentions={record?.preferences?.showMentions} 175 171 /> 176 172 </div> 177 173 </div>
+9 -2
app/lish/createPub/CreatePubForm.tsx
··· 53 53 description: descriptionValue, 54 54 iconFile: logoFile, 55 55 subdomain: domainValue, 56 - preferences: { showInDiscover, showComments: true }, 56 + preferences: { 57 + showInDiscover, 58 + showComments: true, 59 + showMentions: true, 60 + showPrevNext: false, 61 + }, 57 62 }); 58 63 59 64 if (!result.success) { ··· 68 73 setTimeout(() => { 69 74 setFormState("normal"); 70 75 if (result.publication) 71 - router.push(`${getBasePublicationURL(result.publication)}/dashboard`); 76 + router.push( 77 + `${getBasePublicationURL(result.publication)}/dashboard`, 78 + ); 72 79 }, 500); 73 80 }} 74 81 >
+19 -14
app/lish/createPub/UpdatePubForm.tsx
··· 21 21 import { Checkbox } from "components/Checkbox"; 22 22 import type { GetDomainConfigResponseBody } from "@vercel/sdk/esm/models/getdomainconfigop"; 23 23 import { PubSettingsHeader } from "../[did]/[publication]/dashboard/settings/PublicationSettings"; 24 + import { Toggle } from "components/Toggle"; 24 25 25 26 export const EditPubForm = (props: { 26 27 backToMenuAction: () => void; ··· 43 44 ? true 44 45 : record.preferences.showComments, 45 46 ); 47 + let showMentions = 48 + record?.preferences?.showMentions === undefined 49 + ? true 50 + : record.preferences.showMentions; 51 + let showPrevNext = 52 + record?.preferences?.showPrevNext === undefined 53 + ? true 54 + : record.preferences.showPrevNext; 55 + 46 56 let [descriptionValue, setDescriptionValue] = useState( 47 57 record?.description || "", 48 58 ); ··· 74 84 preferences: { 75 85 showInDiscover: showInDiscover, 76 86 showComments: showComments, 87 + showMentions: showMentions, 88 + showPrevNext: showPrevNext, 77 89 }, 78 90 }); 79 91 toast({ type: "success", content: "Updated!" }); ··· 90 102 General Settings 91 103 </PubSettingsHeader> 92 104 <div className="flex flex-col gap-3 w-[1000px] max-w-full pb-2"> 93 - <div className="flex items-center justify-between gap-2 "> 105 + <div className="flex items-center justify-between gap-2 mt-2 "> 94 106 <p className="pl-0.5 pb-0.5 text-tertiary italic text-sm font-bold"> 95 107 Logo <span className="font-normal">(optional)</span> 96 108 </p> ··· 160 172 <CustomDomainForm /> 161 173 <hr className="border-border-light" /> 162 174 163 - <Checkbox 164 - checked={showInDiscover} 165 - onChange={(e) => setShowInDiscover(e.target.checked)} 175 + <Toggle 176 + toggle={showInDiscover} 177 + onToggle={() => setShowInDiscover(!showInDiscover)} 166 178 > 167 - <div className=" pt-0.5 flex flex-col text-sm italic text-tertiary "> 179 + <div className=" pt-0.5 flex flex-col text-sm text-tertiary "> 168 180 <p className="font-bold"> 169 181 Show In{" "} 170 182 <a href="/discover" target="_blank"> ··· 179 191 page. You can change this at any time! 180 192 </p> 181 193 </div> 182 - </Checkbox> 194 + </Toggle> 183 195 184 - <Checkbox 185 - checked={showComments} 186 - onChange={(e) => setShowComments(e.target.checked)} 187 - > 188 - <div className=" pt-0.5 flex flex-col text-sm italic text-tertiary "> 189 - <p className="font-bold">Show comments on posts</p> 190 - </div> 191 - </Checkbox> 196 + 192 197 </div> 193 198 </form> 194 199 );
+2 -2
app/lish/createPub/updatePublication.ts
··· 25 25 }: { 26 26 uri: string; 27 27 name: string; 28 - description: string; 29 - iconFile: File | null; 28 + description?: string; 29 + iconFile?: File | null; 30 30 preferences?: Omit<PubLeafletPublication.Preferences, "$type">; 31 31 }): Promise<UpdatePublicationResult> { 32 32 let identity = await getIdentityData();
+2 -2
components/ActionBar/ActionButton.tsx
··· 70 70 > 71 71 <div className="shrink-0">{icon}</div> 72 72 <div 73 - className={`flex flex-col pr-1 ${subtext && "leading-snug"} max-w-full min-w-0 ${sidebar.open ? "block" : showLabelOnMobile ? "sm:hidden block" : "hidden"}`} 73 + className={`flex flex-col pr-1 leading-snug max-w-full min-w-0 ${sidebar.open ? "block" : showLabelOnMobile ? "sm:hidden block" : "hidden"}`} 74 74 > 75 - <div className="truncate text-left">{label}</div> 75 + <div className="truncate text-left pt-[1px]">{label}</div> 76 76 {subtext && ( 77 77 <div className="text-xs text-tertiary font-normal text-left"> 78 78 {subtext}
+1 -1
components/Blocks/Block.tsx
··· 10 10 import { useHandleDrop } from "./useHandleDrop"; 11 11 import { useEntitySetContext } from "components/EntitySetProvider"; 12 12 13 - import { TextBlock } from "./TextBlock/index"; 13 + import { TextBlock } from "components/Blocks/TextBlock"; 14 14 import { ImageBlock } from "./ImageBlock"; 15 15 import { PageLinkBlock } from "./PageLinkBlock"; 16 16 import { ExternalLinkBlock } from "./ExternalLinkBlock";
+1 -1
components/Blocks/TextBlock/RenderYJSFragment.tsx
··· 8 8 import { Delta } from "src/utils/yjsFragmentToString"; 9 9 import { ProfilePopover } from "components/ProfilePopover"; 10 10 11 - type BlockElements = "h1" | "h2" | "h3" | null | "blockquote" | "p" | "small"; 11 + type BlockElements = "h1" | "h2" | "h3" | null | "blockquote" | "p"; 12 12 export function RenderYJSFragment({ 13 13 value, 14 14 wrapper,
+4 -18
components/Blocks/TextBlock/index.tsx
··· 120 120 }) { 121 121 let initialFact = useEntity(props.entityID, "block/text"); 122 122 let headingLevel = useEntity(props.entityID, "block/heading-level"); 123 - let textSize = useEntity(props.entityID, "block/text-size"); 124 123 let alignment = 125 124 useEntity(props.entityID, "block/text-alignment")?.data.value || "left"; 126 125 let alignmentClass = { ··· 129 128 center: "text-center", 130 129 justify: "text-justify", 131 130 }[alignment]; 132 - let textStyle = 133 - textSize?.data.value === "small" 134 - ? "text-sm" 135 - : textSize?.data.value === "large" 136 - ? "text-lg" 137 - : ""; 138 131 let { permissions } = useEntitySetContext(); 139 132 140 133 let content = <br />; ··· 166 159 className={` 167 160 ${alignmentClass} 168 161 ${props.type === "blockquote" ? (props.previousBlock?.type === "blockquote" ? `blockquote pt-3 ` : "blockquote") : ""} 169 - ${props.type === "heading" ? HeadingStyle[headingLevel?.data.value || 1] : textStyle} 162 + ${props.type === "heading" ? HeadingStyle[headingLevel?.data.value || 1] : ""} 170 163 w-full whitespace-pre-wrap outline-hidden ${props.className} `} 171 164 > 172 165 {content} ··· 176 169 177 170 export function BaseTextBlock(props: BlockProps & { className?: string }) { 178 171 let headingLevel = useEntity(props.entityID, "block/heading-level"); 179 - let textSize = useEntity(props.entityID, "block/text-size"); 180 172 let alignment = 181 173 useEntity(props.entityID, "block/text-alignment")?.data.value || "left"; 182 174 ··· 192 184 center: "text-center", 193 185 justify: "text-justify", 194 186 }[alignment]; 195 - let textStyle = 196 - textSize?.data.value === "small" 197 - ? "text-sm text-secondary" 198 - : textSize?.data.value === "large" 199 - ? "text-lg text-primary" 200 - : "text-base text-primary"; 201 187 202 188 let editorState = useEditorStates( 203 189 (s) => s.editorStates[props.entityID], ··· 272 258 grow resize-none align-top whitespace-pre-wrap bg-transparent 273 259 outline-hidden 274 260 275 - ${props.type === "heading" ? HeadingStyle[headingLevel?.data.value || 1] : textStyle} 261 + ${props.type === "heading" ? HeadingStyle[headingLevel?.data.value || 1] : ""} 276 262 ${props.className}`} 277 263 ref={mountRef} 278 264 /> ··· 291 277 // if this is the only block on the page and is empty or is a canvas, show placeholder 292 278 <div 293 279 className={`${props.className} ${alignmentClass} w-full pointer-events-none absolute top-0 left-0 italic text-tertiary flex flex-col 294 - ${props.type === "heading" ? HeadingStyle[headingLevel?.data.value || 1] : textStyle} 280 + ${props.type === "heading" ? HeadingStyle[headingLevel?.data.value || 1] : ""} 295 281 `} 296 282 > 297 283 {props.type === "text" ··· 510 496 511 497 // Find the relative positioned parent container 512 498 const editorEl = view.dom; 513 - const container = editorEl.closest(".relative") as HTMLElement | null; 499 + const container = editorEl.closest('.relative') as HTMLElement | null; 514 500 515 501 if (container) { 516 502 const containerRect = container.getBoundingClientRect();
-14
components/Blocks/TextBlock/keymap.ts
··· 555 555 }, 556 556 }); 557 557 } 558 - let [textSize] = 559 - (await repRef.current?.query((tx) => 560 - scanIndex(tx).eav(propsRef.current.entityID, "block/text-size"), 561 - )) || []; 562 - if (textSize) { 563 - await repRef.current?.mutate.assertFact({ 564 - entity: newEntityID, 565 - attribute: "block/text-size", 566 - data: { 567 - type: "text-size-union", 568 - value: textSize.data.value, 569 - }, 570 - }); 571 - } 572 558 }; 573 559 asyncRun().then(() => { 574 560 useUIState.getState().setSelectedBlock({
-11
components/Blocks/TextBlock/useHandlePaste.ts
··· 299 299 }, 300 300 }); 301 301 } 302 - let textSize = child.getAttribute("data-text-size"); 303 - if (textSize && ["default", "small", "large"].includes(textSize)) { 304 - rep.mutate.assertFact({ 305 - entity: entityID, 306 - attribute: "block/text-size", 307 - data: { 308 - type: "text-size-union", 309 - value: textSize as "default" | "small" | "large", 310 - }, 311 - }); 312 - } 313 302 if (child.tagName === "A") { 314 303 let href = child.getAttribute("href"); 315 304 let dataType = child.getAttribute("data-type");
+6 -3
components/Canvas.tsx
··· 170 170 171 171 let pubRecord = pub.publications.record as PubLeafletPublication.Record; 172 172 let showComments = pubRecord.preferences?.showComments; 173 + let showMentions = pubRecord.preferences?.showMentions; 173 174 174 175 return ( 175 176 <div className="flex flex-row gap-3 items-center absolute top-6 right-3 sm:top-4 sm:right-4 bg-bg-page border-border-light rounded-md px-2 py-1 h-fit z-20"> ··· 178 179 <CommentTiny className="text-border" /> โ€” 179 180 </div> 180 181 )} 181 - <div className="flex gap-1 text-tertiary items-center"> 182 - <QuoteTiny className="text-border" /> โ€” 183 - </div> 182 + {showComments && ( 183 + <div className="flex gap-1 text-tertiary items-center"> 184 + <QuoteTiny className="text-border" /> โ€” 185 + </div> 186 + )} 184 187 185 188 {!props.isSubpage && ( 186 189 <>
+4 -2
components/InteractionsPreview.tsx
··· 14 14 tags?: string[]; 15 15 postUrl: string; 16 16 showComments: boolean | undefined; 17 + showMentions: boolean | undefined; 18 + 17 19 share?: boolean; 18 20 }) => { 19 21 let smoker = useSmoker(); 20 22 let interactionsAvailable = 21 - props.quotesCount > 0 || 23 + (props.quotesCount > 0 && props.showMentions !== false) || 22 24 (props.showComments !== false && props.commentsCount > 0); 23 25 24 26 const tagsCount = props.tags?.length || 0; ··· 36 38 </> 37 39 )} 38 40 39 - {props.quotesCount === 0 ? null : ( 41 + {props.showMentions === false || props.quotesCount === 0 ? null : ( 40 42 <SpeedyLink 41 43 aria-label="Post quotes" 42 44 href={`${props.postUrl}?interactionDrawer=quotes`}
+5 -3
components/Pages/PublicationMetadata.tsx
··· 121 121 <Separator classname="h-4!" /> 122 122 </> 123 123 )} 124 - <div className="flex gap-1 items-center"> 125 - <QuoteTiny />โ€” 126 - </div> 124 + {pubRecord?.preferences?.showMentions && ( 125 + <div className="flex gap-1 items-center"> 126 + <QuoteTiny />โ€” 127 + </div> 128 + )} 127 129 {pubRecord?.preferences?.showComments && ( 128 130 <div className="flex gap-1 items-center"> 129 131 <CommentTiny />โ€”
+1
components/PostListing.tsx
··· 97 97 commentsCount={comments} 98 98 tags={tags} 99 99 showComments={pubRecord?.preferences?.showComments} 100 + showMentions={pubRecord?.preferences?.showMentions} 100 101 share 101 102 /> 102 103 </div>
+1 -148
components/SelectionManager/index.tsx
··· 89 89 }, 90 90 { 91 91 metaKey: true, 92 - altKey: true, 93 - key: ["1", "ยก"], 94 - handler: async () => { 95 - let [sortedBlocks] = await getSortedSelectionBound(); 96 - for (let block of sortedBlocks) { 97 - await rep?.mutate.assertFact({ 98 - entity: block.value, 99 - attribute: "block/heading-level", 100 - data: { type: "number", value: 1 }, 101 - }); 102 - await rep?.mutate.assertFact({ 103 - entity: block.value, 104 - attribute: "block/type", 105 - data: { type: "block-type-union", value: "heading" }, 106 - }); 107 - } 108 - }, 109 - }, 110 - { 111 - metaKey: true, 112 - altKey: true, 113 - key: ["2", "โ„ข"], 114 - handler: async () => { 115 - let [sortedBlocks] = await getSortedSelectionBound(); 116 - for (let block of sortedBlocks) { 117 - await rep?.mutate.assertFact({ 118 - entity: block.value, 119 - attribute: "block/heading-level", 120 - data: { type: "number", value: 2 }, 121 - }); 122 - await rep?.mutate.assertFact({ 123 - entity: block.value, 124 - attribute: "block/type", 125 - data: { type: "block-type-union", value: "heading" }, 126 - }); 127 - } 128 - }, 129 - }, 130 - { 131 - metaKey: true, 132 - altKey: true, 133 - key: ["3", "ยฃ"], 134 - handler: async () => { 135 - let [sortedBlocks] = await getSortedSelectionBound(); 136 - for (let block of sortedBlocks) { 137 - await rep?.mutate.assertFact({ 138 - entity: block.value, 139 - attribute: "block/heading-level", 140 - data: { type: "number", value: 3 }, 141 - }); 142 - await rep?.mutate.assertFact({ 143 - entity: block.value, 144 - attribute: "block/type", 145 - data: { type: "block-type-union", value: "heading" }, 146 - }); 147 - } 148 - }, 149 - }, 150 - { 151 - metaKey: true, 152 - altKey: true, 153 - key: ["0", "ยบ"], 154 - handler: async () => { 155 - let [sortedBlocks] = await getSortedSelectionBound(); 156 - for (let block of sortedBlocks) { 157 - // Convert to text block 158 - await rep?.mutate.assertFact({ 159 - entity: block.value, 160 - attribute: "block/type", 161 - data: { type: "block-type-union", value: "text" }, 162 - }); 163 - // Remove heading level if exists 164 - let headingLevel = await rep?.query((tx) => 165 - scanIndex(tx).eav(block.value, "block/heading-level"), 166 - ); 167 - if (headingLevel?.[0]) { 168 - await rep?.mutate.retractFact({ factID: headingLevel[0].id }); 169 - } 170 - // Remove text-size to make it default 171 - let textSizeFact = await rep?.query((tx) => 172 - scanIndex(tx).eav(block.value, "block/text-size"), 173 - ); 174 - if (textSizeFact?.[0]) { 175 - await rep?.mutate.retractFact({ factID: textSizeFact[0].id }); 176 - } 177 - } 178 - }, 179 - }, 180 - { 181 - metaKey: true, 182 - altKey: true, 183 - key: ["+", "โ‰ "], 184 - handler: async () => { 185 - let [sortedBlocks] = await getSortedSelectionBound(); 186 - for (let block of sortedBlocks) { 187 - // Convert to text block 188 - await rep?.mutate.assertFact({ 189 - entity: block.value, 190 - attribute: "block/type", 191 - data: { type: "block-type-union", value: "text" }, 192 - }); 193 - // Remove heading level if exists 194 - let headingLevel = await rep?.query((tx) => 195 - scanIndex(tx).eav(block.value, "block/heading-level"), 196 - ); 197 - if (headingLevel?.[0]) { 198 - await rep?.mutate.retractFact({ factID: headingLevel[0].id }); 199 - } 200 - // Set text size to large 201 - await rep?.mutate.assertFact({ 202 - entity: block.value, 203 - attribute: "block/text-size", 204 - data: { type: "text-size-union", value: "large" }, 205 - }); 206 - } 207 - }, 208 - }, 209 - { 210 - metaKey: true, 211 - altKey: true, 212 - key: ["-", "โ€“"], 213 - handler: async () => { 214 - let [sortedBlocks] = await getSortedSelectionBound(); 215 - for (let block of sortedBlocks) { 216 - // Convert to text block 217 - await rep?.mutate.assertFact({ 218 - entity: block.value, 219 - attribute: "block/type", 220 - data: { type: "block-type-union", value: "text" }, 221 - }); 222 - // Remove heading level if exists 223 - let headingLevel = await rep?.query((tx) => 224 - scanIndex(tx).eav(block.value, "block/heading-level"), 225 - ); 226 - if (headingLevel?.[0]) { 227 - await rep?.mutate.retractFact({ factID: headingLevel[0].id }); 228 - } 229 - // Set text size to small 230 - await rep?.mutate.assertFact({ 231 - entity: block.value, 232 - attribute: "block/text-size", 233 - data: { type: "text-size-union", value: "small" }, 234 - }); 235 - } 236 - }, 237 - }, 238 - { 239 - metaKey: true, 240 92 shift: true, 241 93 key: ["ArrowDown", "J"], 242 94 handler: async () => { ··· 832 684 } 833 685 return null; 834 686 } 687 + 835 688 836 689 function toggleMarkInBlocks(blocks: string[], mark: MarkType, attrs?: any) { 837 690 let everyBlockHasMark = blocks.reduce((acc, block) => {
+2 -3
components/ThemeManager/ThemeSetter.tsx
··· 1 1 "use client"; 2 2 import { Popover } from "components/Popover"; 3 - import { theme } from "../../tailwind.config"; 4 3 5 4 import { Color } from "react-aria-components"; 6 5 ··· 166 165 setOpenPicker={(pickers) => setOpenPicker(pickers)} 167 166 /> 168 167 <SectionArrow 169 - fill={theme.colors["accent-2"]} 170 - stroke={theme.colors["accent-1"]} 168 + fill="rgb(var(--accent-2))" 169 + stroke="rgb(var(--accent-1))" 171 170 className="ml-2" 172 171 /> 173 172 </div>
+5 -9
components/Toolbar/BlockToolbar.tsx
··· 5 5 import { useUIState } from "src/useUIState"; 6 6 import { LockBlockButton } from "./LockBlockButton"; 7 7 import { TextAlignmentButton } from "./TextAlignmentToolbar"; 8 - import { 9 - ImageFullBleedButton, 10 - ImageAltTextButton, 11 - ImageCoverButton, 12 - } from "./ImageToolbar"; 8 + import { ImageFullBleedButton, ImageAltTextButton, ImageCoverButton } from "./ImageToolbar"; 13 9 import { DeleteSmall } from "components/Icons/DeleteSmall"; 14 10 import { getSortedSelection } from "components/SelectionManager/selectionState"; 15 11 ··· 41 37 > 42 38 <DeleteSmall /> 43 39 </ToolbarButton> 44 - <Separator classname="h-6!" /> 40 + <Separator classname="h-6" /> 45 41 <MoveBlockButtons /> 46 42 {blockType === "image" && ( 47 43 <> ··· 50 46 <ImageAltTextButton setToolbarState={props.setToolbarState} /> 51 47 <ImageCoverButton /> 52 48 {focusedEntityType?.data.value !== "canvas" && ( 53 - <Separator classname="h-6!" /> 49 + <Separator classname="h-6" /> 54 50 )} 55 51 </> 56 52 )} ··· 58 54 <> 59 55 <TextAlignmentButton setToolbarState={props.setToolbarState} /> 60 56 {focusedEntityType?.data.value !== "canvas" && ( 61 - <Separator classname="h-6!" /> 57 + <Separator classname="h-6" /> 62 58 )} 63 59 </> 64 60 )} ··· 179 175 > 180 176 <MoveBlockDown /> 181 177 </ToolbarButton> 182 - <Separator classname="h-6!" /> 178 + <Separator classname="h-6" /> 183 179 </> 184 180 ); 185 181 };
+1 -1
components/Toolbar/HighlightToolbar.tsx
··· 126 126 setLastUsedHightlight={props.setLastUsedHighlight} 127 127 /> 128 128 129 - <Separator classname="h-6!" /> 129 + <Separator classname="h-6" /> 130 130 <HighlightColorSettings pageID={props.pageID} /> 131 131 </div> 132 132 </div>
+1 -1
components/Toolbar/InlineLinkToolbar.tsx
··· 132 132 return ( 133 133 <div className="w-full flex items-center gap-[6px] grow"> 134 134 <LinkSmall /> 135 - <Separator classname="h-6!" /> 135 + <Separator classname="h-6" /> 136 136 <Input 137 137 autoFocus 138 138 className="w-full grow bg-transparent border-none outline-hidden "
+2 -2
components/Toolbar/ListToolbar.tsx
··· 131 131 > 132 132 <ListIndentIncreaseSmall /> 133 133 </ToolbarButton> 134 - <Separator classname="h-6!" /> 134 + <Separator classname="h-6" /> 135 135 <ToolbarButton 136 136 disabled={!isList?.data.value} 137 137 tooltipContent=<div className="flex flex-col gap-1 justify-center"> 138 138 <div className="text-center">Add a Checkbox</div> 139 139 <div className="flex gap-1 font-normal"> 140 - <ShortcutKey>[</ShortcutKey> 140 + start line with <ShortcutKey>[</ShortcutKey> 141 141 <ShortcutKey>]</ShortcutKey> 142 142 </div> 143 143 </div>
+95 -154
components/Toolbar/TextBlockTypeToolbar.tsx
··· 4 4 Header3Small, 5 5 } from "components/Icons/BlockTextSmall"; 6 6 import { Props } from "components/Icons/Props"; 7 - import { ShortcutKey, Separator } from "components/Layout"; 7 + import { ShortcutKey } from "components/Layout"; 8 8 import { ToolbarButton } from "components/Toolbar"; 9 9 import { TextSelection } from "prosemirror-state"; 10 10 import { useCallback } from "react"; ··· 22 22 focusedBlock?.entityID || null, 23 23 "block/heading-level", 24 24 ); 25 - 26 - let textSize = useEntity(focusedBlock?.entityID || null, "block/text-size"); 27 25 let { rep } = useReplicache(); 28 26 29 27 let setLevel = useCallback( ··· 53 51 ); 54 52 return ( 55 53 // This Toolbar should close once the user starts typing again 56 - <> 57 - <ToolbarButton 58 - className={props.className} 59 - onClick={() => { 60 - setLevel(1); 61 - }} 62 - active={ 63 - blockType?.data.value === "heading" && headingLevel?.data.value === 1 64 - } 65 - tooltipContent={ 66 - <div className="flex flex-col justify-center"> 67 - <div className="font-bold text-center">Title</div> 68 - <div className="flex gap-1 font-normal"> 69 - start line with 70 - <ShortcutKey>#</ShortcutKey> 54 + <div className="flex w-full justify-between items-center gap-4"> 55 + <div className="flex items-center gap-[6px]"> 56 + <ToolbarButton 57 + className={props.className} 58 + onClick={() => { 59 + setLevel(1); 60 + }} 61 + active={ 62 + blockType?.data.value === "heading" && 63 + headingLevel?.data.value === 1 64 + } 65 + tooltipContent={ 66 + <div className="flex flex-col justify-center"> 67 + <div className="font-bold text-center">Title</div> 68 + <div className="flex gap-1 font-normal"> 69 + start line with 70 + <ShortcutKey>#</ShortcutKey> 71 + </div> 71 72 </div> 72 - </div> 73 - } 74 - > 75 - <Header1Small /> 76 - </ToolbarButton> 77 - <ToolbarButton 78 - className={props.className} 79 - onClick={() => { 80 - setLevel(2); 81 - }} 82 - active={ 83 - blockType?.data.value === "heading" && headingLevel?.data.value === 2 84 - } 85 - tooltipContent={ 86 - <div className="flex flex-col justify-center"> 87 - <div className="font-bold text-center">Heading</div> 88 - <div className="flex gap-1 font-normal"> 89 - start line with 90 - <ShortcutKey>##</ShortcutKey> 73 + } 74 + > 75 + <Header1Small /> 76 + </ToolbarButton> 77 + <ToolbarButton 78 + className={props.className} 79 + onClick={() => { 80 + setLevel(2); 81 + }} 82 + active={ 83 + blockType?.data.value === "heading" && 84 + headingLevel?.data.value === 2 85 + } 86 + tooltipContent={ 87 + <div className="flex flex-col justify-center"> 88 + <div className="font-bold text-center">Heading</div> 89 + <div className="flex gap-1 font-normal"> 90 + start line with 91 + <ShortcutKey>##</ShortcutKey> 92 + </div> 91 93 </div> 92 - </div> 93 - } 94 - > 95 - <Header2Small /> 96 - </ToolbarButton> 97 - <ToolbarButton 98 - className={props.className} 99 - onClick={() => { 100 - setLevel(3); 101 - }} 102 - active={ 103 - blockType?.data.value === "heading" && headingLevel?.data.value === 3 104 - } 105 - tooltipContent={ 106 - <div className="flex flex-col justify-center"> 107 - <div className="font-bold text-center">Subheading</div> 108 - <div className="flex gap-1 font-normal"> 109 - start line with 110 - <ShortcutKey>###</ShortcutKey> 94 + } 95 + > 96 + <Header2Small /> 97 + </ToolbarButton> 98 + <ToolbarButton 99 + className={props.className} 100 + onClick={() => { 101 + setLevel(3); 102 + }} 103 + active={ 104 + blockType?.data.value === "heading" && 105 + headingLevel?.data.value === 3 106 + } 107 + tooltipContent={ 108 + <div className="flex flex-col justify-center"> 109 + <div className="font-bold text-center">Subheading</div> 110 + <div className="flex gap-1 font-normal"> 111 + start line with 112 + <ShortcutKey>###</ShortcutKey> 113 + </div> 111 114 </div> 112 - </div> 113 - } 114 - > 115 - <Header3Small /> 116 - </ToolbarButton> 117 - <Separator classname="h-6!" /> 118 - <ToolbarButton 119 - className={`px-[6px] ${props.className}`} 120 - onClick={async () => { 121 - if (headingLevel) 122 - await rep?.mutate.retractFact({ factID: headingLevel.id }); 123 - if (textSize) await rep?.mutate.retractFact({ factID: textSize.id }); 124 - if (!focusedBlock || !blockType) return; 125 - if (blockType.data.value !== "text") { 126 - let existingEditor = 127 - useEditorStates.getState().editorStates[focusedBlock.entityID]; 128 - let selection = existingEditor?.editor.selection; 129 - await rep?.mutate.assertFact({ 130 - entity: focusedBlock?.entityID, 131 - attribute: "block/type", 132 - data: { type: "block-type-union", value: "text" }, 133 - }); 134 - 135 - let newEditor = 136 - useEditorStates.getState().editorStates[focusedBlock.entityID]; 137 - if (!newEditor || !selection) return; 138 - newEditor.view?.dispatch( 139 - newEditor.editor.tr.setSelection( 140 - TextSelection.create(newEditor.editor.doc, selection.anchor), 141 - ), 142 - ); 143 - 144 - newEditor.view?.focus(); 145 115 } 146 - }} 147 - active={ 148 - blockType?.data.value === "text" && 149 - textSize?.data.value !== "small" && 150 - textSize?.data.value !== "large" 151 - } 152 - tooltipContent={<div>Normal Text</div>} 153 - > 154 - Text 155 - </ToolbarButton> 156 - <ToolbarButton 157 - className={`px-[6px] text-lg ${props.className}`} 158 - onClick={async () => { 159 - if (!focusedBlock || !blockType) return; 160 - if (blockType.data.value !== "text") { 161 - // Convert to text block first if it's a heading 162 - if (headingLevel) 163 - await rep?.mutate.retractFact({ factID: headingLevel.id }); 164 - await rep?.mutate.assertFact({ 165 - entity: focusedBlock.entityID, 166 - attribute: "block/type", 167 - data: { type: "block-type-union", value: "text" }, 168 - }); 169 - } 170 - // Set text size to large 171 - await rep?.mutate.assertFact({ 172 - entity: focusedBlock.entityID, 173 - attribute: "block/text-size", 174 - data: { type: "text-size-union", value: "large" }, 175 - }); 176 - }} 177 - active={ 178 - blockType?.data.value === "text" && textSize?.data.value === "large" 179 - } 180 - tooltipContent={<div>Large Text</div>} 181 - > 182 - <div className="leading-[1.625rem]">Large</div> 183 - </ToolbarButton> 184 - <ToolbarButton 185 - className={`px-[6px] text-sm text-secondary ${props.className}`} 186 - onClick={async () => { 187 - if (!focusedBlock || !blockType) return; 188 - if (blockType.data.value !== "text") { 189 - // Convert to text block first if it's a heading 116 + > 117 + <Header3Small /> 118 + </ToolbarButton> 119 + <ToolbarButton 120 + className={`px-[6px] ${props.className}`} 121 + onClick={async () => { 190 122 if (headingLevel) 191 123 await rep?.mutate.retractFact({ factID: headingLevel.id }); 192 - await rep?.mutate.assertFact({ 193 - entity: focusedBlock.entityID, 194 - attribute: "block/type", 195 - data: { type: "block-type-union", value: "text" }, 196 - }); 197 - } 198 - // Set text size to small 199 - await rep?.mutate.assertFact({ 200 - entity: focusedBlock.entityID, 201 - attribute: "block/text-size", 202 - data: { type: "text-size-union", value: "small" }, 203 - }); 204 - }} 205 - active={ 206 - blockType?.data.value === "text" && textSize?.data.value === "small" 207 - } 208 - tooltipContent={<div>Small Text</div>} 209 - > 210 - <div className="leading-[1.625rem]">Small</div> 211 - </ToolbarButton> 212 - </> 124 + if (!focusedBlock || !blockType) return; 125 + if (blockType.data.value !== "text") { 126 + let existingEditor = 127 + useEditorStates.getState().editorStates[focusedBlock.entityID]; 128 + let selection = existingEditor?.editor.selection; 129 + await rep?.mutate.assertFact({ 130 + entity: focusedBlock?.entityID, 131 + attribute: "block/type", 132 + data: { type: "block-type-union", value: "text" }, 133 + }); 134 + 135 + let newEditor = 136 + useEditorStates.getState().editorStates[focusedBlock.entityID]; 137 + if (!newEditor || !selection) return; 138 + newEditor.view?.dispatch( 139 + newEditor.editor.tr.setSelection( 140 + TextSelection.create(newEditor.editor.doc, selection.anchor), 141 + ), 142 + ); 143 + 144 + newEditor.view?.focus(); 145 + } 146 + }} 147 + active={blockType?.data.value === "text"} 148 + tooltipContent={<div>Paragraph</div>} 149 + > 150 + Paragraph 151 + </ToolbarButton> 152 + </div> 153 + </div> 213 154 ); 214 155 }; 215 156
+3 -3
components/Toolbar/TextToolbar.tsx
··· 74 74 lastUsedHighlight={props.lastUsedHighlight} 75 75 setToolbarState={props.setToolbarState} 76 76 /> 77 - <Separator classname="h-6!" /> 77 + <Separator classname="h-6" /> 78 78 <LinkButton setToolbarState={props.setToolbarState} /> 79 - <Separator classname="h-6!" /> 79 + <Separator classname="h-6" /> 80 80 <TextBlockTypeButton setToolbarState={props.setToolbarState} /> 81 81 <TextAlignmentButton setToolbarState={props.setToolbarState} /> 82 82 <ListButton setToolbarState={props.setToolbarState} /> 83 - <Separator classname="h-6!" /> 83 + <Separator classname="h-6" /> 84 84 85 85 <LockBlockButton /> 86 86 </>
+2 -2
components/utils/DotLoader.tsx
··· 1 1 import { useEffect, useState } from "react"; 2 2 3 - export function DotLoader(props: { className?: string }) { 3 + export function DotLoader() { 4 4 let [dots, setDots] = useState(1); 5 5 useEffect(() => { 6 6 let id = setInterval(() => { ··· 11 11 }; 12 12 }, []); 13 13 return ( 14 - <div className={`w-[26px] h-[24px] text-center text-sm ${props.className}`}> 14 + <div className="w-[26px] h-[24px] text-center text-sm"> 15 15 {".".repeat(dots) + "\u00a0".repeat(3 - dots)} 16 16 </div> 17 17 );
+8 -4
lexicons/api/lexicons.ts
··· 1246 1246 plaintext: { 1247 1247 type: 'string', 1248 1248 }, 1249 - textSize: { 1250 - type: 'string', 1251 - enum: ['default', 'small', 'large'], 1252 - }, 1253 1249 facets: { 1254 1250 type: 'array', 1255 1251 items: { ··· 1807 1803 default: true, 1808 1804 }, 1809 1805 showComments: { 1806 + type: 'boolean', 1807 + default: true, 1808 + }, 1809 + showMentions: { 1810 + type: 'boolean', 1811 + default: true, 1812 + }, 1813 + showPrevNext: { 1810 1814 type: 'boolean', 1811 1815 default: true, 1812 1816 },
-1
lexicons/api/types/pub/leaflet/blocks/text.ts
··· 18 18 export interface Main { 19 19 $type?: 'pub.leaflet.blocks.text' 20 20 plaintext: string 21 - textSize?: 'default' | 'small' | 'large' 22 21 facets?: PubLeafletRichtextFacet.Main[] 23 22 } 24 23
+2
lexicons/api/types/pub/leaflet/publication.ts
··· 37 37 $type?: 'pub.leaflet.publication#preferences' 38 38 showInDiscover: boolean 39 39 showComments: boolean 40 + showMentions: boolean 41 + showPrevNext: boolean 40 42 } 41 43 42 44 const hashPreferences = 'preferences'
-8
lexicons/pub/leaflet/blocks/text.json
··· 11 11 "plaintext": { 12 12 "type": "string" 13 13 }, 14 - "textSize": { 15 - "type": "string", 16 - "enum": [ 17 - "default", 18 - "small", 19 - "large" 20 - ] 21 - }, 22 14 "facets": { 23 15 "type": "array", 24 16 "items": {
+8
lexicons/pub/leaflet/publication.json
··· 51 51 "showComments": { 52 52 "type": "boolean", 53 53 "default": true 54 + }, 55 + "showMentions": { 56 + "type": "boolean", 57 + "default": true 58 + }, 59 + "showPrevNext": { 60 + "type": "boolean", 61 + "default": true 54 62 } 55 63 } 56 64 },
-1
lexicons/src/blocks.ts
··· 10 10 required: ["plaintext"], 11 11 properties: { 12 12 plaintext: { type: "string" }, 13 - textSize: { type: "string", enum: ["default", "small", "large"] }, 14 13 facets: { 15 14 type: "array", 16 15 items: { type: "ref", ref: PubLeafletRichTextFacet.id },
+2
lexicons/src/publication.ts
··· 27 27 properties: { 28 28 showInDiscover: { type: "boolean", default: true }, 29 29 showComments: { type: "boolean", default: true }, 30 + showMentions: { type: "boolean", default: true }, 31 + showPrevNext: { type: "boolean", default: false }, 30 32 }, 31 33 }, 32 34 theme: {
-8
src/replicache/attributes.ts
··· 71 71 type: "number", 72 72 cardinality: "one", 73 73 }, 74 - "block/text-size": { 75 - type: "text-size-union", 76 - cardinality: "one", 77 - }, 78 74 "block/image": { 79 75 type: "image", 80 76 cardinality: "one", ··· 325 321 "text-alignment-type-union": { 326 322 type: "text-alignment-type-union"; 327 323 value: "right" | "left" | "center" | "justify"; 328 - }; 329 - "text-size-union": { 330 - type: "text-size-union"; 331 - value: "default" | "small" | "large"; 332 324 }; 333 325 "page-type-union": { type: "page-type-union"; value: "doc" | "canvas" }; 334 326 "block-type-union": {
-3
src/utils/getBlocksAsHTML.tsx
··· 171 171 }, 172 172 text: async (b, tx, a) => { 173 173 let [value] = await scanIndex(tx).eav(b.value, "block/text"); 174 - let [textSize] = await scanIndex(tx).eav(b.value, "block/text-size"); 175 - 176 174 return ( 177 175 <RenderYJSFragment 178 176 value={value?.data.value} 179 177 attrs={{ 180 178 "data-alignment": a, 181 - "data-text-size": textSize?.data.value, 182 179 }} 183 180 wrapper="p" 184 181 />