a tool for shared writing and social publishing

add a drawer for comments and mentions in reader

+187 -63
+82 -2
app/(home-pages)/reader/InboxContent.tsx
··· 10 10 import { SortSmall } from "components/Icons/SortSmall"; 11 11 import { Input } from "components/Input"; 12 12 import { useHasBackgroundImage } from "components/Pages/useHasBackgroundImage"; 13 - import { InteractionDrawer } from "app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer"; 13 + import { 14 + SelectedPostListing, 15 + useSelectedPostListing, 16 + } from "src/useSelectedPostState"; 17 + import { AtUri } from "@atproto/api"; 18 + import { MentionsDrawerContent } from "app/lish/[did]/[publication]/[rkey]/Interactions/Quotes"; 19 + import { CommentsDrawerContent } from "app/lish/[did]/[publication]/[rkey]/Interactions/Comments"; 20 + import { CloseTiny } from "components/Icons/CloseTiny"; 21 + import { SpeedyLink } from "components/SpeedyLink"; 22 + import { GoToArrow } from "components/Icons/GoToArrow"; 14 23 15 24 export const InboxContent = (props: { 16 25 posts: Post[]; ··· 86 95 let hasBackgroundImage = useHasBackgroundImage(); 87 96 88 97 return ( 89 - <div className="flex flex-row gap-6"> 98 + <div className="flex flex-row gap-6 "> 90 99 <div className="flex flex-col gap-6 relative"> 91 100 <div className="flex justify-between gap-4 text-tertiary"> 92 101 <Input ··· 129 138 </div> 130 139 )} 131 140 </div> 141 + <DesktopInteractionPreviewDrawer /> 142 + <MobileInteractionPreviewDrawer /> 132 143 </div> 133 144 ); 145 + }; 146 + 147 + const MobileInteractionPreviewDrawer = () => { 148 + let selectedPost = useSelectedPostListing((s) => s.selectedPostListing); 149 + 150 + return ( 151 + <div 152 + className={`z-20 fixed bottom-0 left-0 right-0 border border-border-light shrink-0 w-screen h-[90vh] px-3 bg-bg-leaflet rounded-t-lg overflow-auto ${selectedPost === null ? "hidden" : "block md:hidden "}`} 153 + > 154 + <PreviewDrawerContent selectedPost={selectedPost} /> 155 + </div> 156 + ); 157 + }; 158 + const DesktopInteractionPreviewDrawer = () => { 159 + let selectedPost = useSelectedPostListing((s) => s.selectedPostListing); 160 + 161 + return ( 162 + <div 163 + className={`hidden md:block border border-border-light shrink-0 w-96 mr-2 px-3 h-[calc(100vh-100px)] sticky top-11 bottom-4 right-0 rounded-lg overflow-auto ${selectedPost === null ? "shadow-none border-dashed bg-transparent" : "shadow-md border-border bg-bg-page "}`} 164 + > 165 + <PreviewDrawerContent selectedPost={selectedPost} /> 166 + </div> 167 + ); 168 + }; 169 + 170 + const PreviewDrawerContent = (props: { 171 + selectedPost: SelectedPostListing | null; 172 + }) => { 173 + if (!props.selectedPost || !props.selectedPost.document) return; 174 + 175 + if (props.selectedPost.drawer === "quotes") { 176 + return ( 177 + <> 178 + {/*<MentionsDrawerContent 179 + did={selectedPost.document_uri} 180 + quotesAndMentions={[]} 181 + />*/} 182 + </> 183 + ); 184 + } else 185 + return ( 186 + <> 187 + <div className="w-full text-sm text-tertiary flex justify-between pt-3 gap-3"> 188 + <div className="truncate min-w-0 grow"> 189 + Comments for {props.selectedPost.document.title} 190 + </div> 191 + <button 192 + className="text-tertiary" 193 + onClick={() => 194 + useSelectedPostListing.getState().setSelectedPostListing(null) 195 + } 196 + > 197 + <CloseTiny /> 198 + </button> 199 + </div> 200 + <SpeedyLink 201 + className="shrink-0 flex gap-1 items-center " 202 + href={"/"} 203 + ></SpeedyLink> 204 + <ButtonPrimary fullWidth compact className="text-sm! mt-1"> 205 + See Full Post <GoToArrow /> 206 + </ButtonPrimary> 207 + <CommentsDrawerContent 208 + noCommentBox 209 + document_uri={props.selectedPost.document_uri} 210 + comments={[]} 211 + /> 212 + </> 213 + ); 134 214 }; 135 215 136 216 export const ReaderEmpty = () => {
+13 -19
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx
··· 29 29 document_uri: string; 30 30 comments: Comment[]; 31 31 pageId?: string; 32 + noCommentBox?: boolean; 32 33 }) { 33 34 let { identity } = useIdentityData(); 34 35 let { localComments } = useInteractionState(props.document_uri); ··· 55 56 id={"commentsDrawer"} 56 57 className="flex flex-col gap-2 relative text-sm text-secondary" 57 58 > 58 - <div className="w-full flex justify-between"> 59 - <h4> Comments</h4> 60 - <button 61 - className="text-tertiary" 62 - onClick={() => 63 - setInteractionState(props.document_uri, { drawerOpen: false }) 64 - } 65 - > 66 - <CloseTiny /> 67 - </button> 68 - </div> 69 - {identity?.atp_did ? ( 70 - <CommentBox doc_uri={props.document_uri} pageId={props.pageId} /> 71 - ) : ( 72 - <div className="w-full accent-container text-tertiary text-center italic p-3 flex flex-col gap-2"> 73 - Connect a Bluesky account to comment 74 - <BlueskyLogin redirectRoute={redirectRoute} /> 75 - </div> 59 + {!props.noCommentBox && ( 60 + <> 61 + {identity?.atp_did ? ( 62 + <CommentBox doc_uri={props.document_uri} pageId={props.pageId} /> 63 + ) : ( 64 + <div className="w-full accent-container text-tertiary text-center italic p-3 flex flex-col gap-2"> 65 + Connect a Bluesky account to comment 66 + <BlueskyLogin redirectRoute={redirectRoute} /> 67 + </div> 68 + )} 69 + <hr className="border-border-light" /> 70 + </> 76 71 )} 77 - <hr className="border-border-light" /> 78 72 <div className="flex flex-col gap-4 py-2"> 79 73 {comments 80 74 .sort((a, b) => {
+41 -11
app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx
··· 1 1 "use client"; 2 2 import { Media } from "components/Media"; 3 3 import { MentionsDrawerContent } from "./Quotes"; 4 - import { InteractionState, useInteractionState } from "./Interactions"; 4 + import { 5 + InteractionState, 6 + setInteractionState, 7 + useInteractionState, 8 + } from "./Interactions"; 5 9 import { Json } from "supabase/database.types"; 6 10 import { Comment, CommentsDrawerContent } from "./Comments"; 7 11 import { useSearchParams } from "next/navigation"; 8 12 import { SandwichSpacer } from "components/LeafletLayout"; 9 13 import { decodeQuotePosition } from "../quotePosition"; 14 + import { CloseTiny } from "components/Icons/CloseTiny"; 10 15 11 16 export const InteractionDrawer = (props: { 12 17 showPageBackground: boolean | undefined; ··· 39 44 <div className="snap-center h-full flex z-10 shrink-0 sm:max-w-prose sm:w-full w-[calc(100vw-12px)]"> 40 45 <div 41 46 id="interaction-drawer" 42 - className={`opaque-container h-full w-full px-3 sm:px-4 pt-2 sm:pt-3 pb-6 overflow-scroll ${props.showPageBackground ? "rounded-l-none! rounded-r-lg! -ml-[1px]" : "rounded-lg! sm:ml-4"}`} 47 + className={`opaque-container relative h-full w-full px-3 sm:px-4 pt-2 sm:pt-3 pb-6 overflow-scroll flex flex-col ${props.showPageBackground ? "rounded-l-none! rounded-r-lg! -ml-[1px]" : "rounded-lg! sm:ml-4"}`} 43 48 > 44 49 {drawer.drawer === "quotes" ? ( 45 - <MentionsDrawerContent 46 - {...props} 47 - quotesAndMentions={filteredQuotesAndMentions} 48 - /> 50 + <> 51 + <button 52 + className="text-tertiary absolute top-0 right-0" 53 + onClick={() => 54 + setInteractionState(props.document_uri, { drawerOpen: false }) 55 + } 56 + > 57 + <CloseTiny /> 58 + </button> 59 + <MentionsDrawerContent 60 + {...props} 61 + quotesAndMentions={filteredQuotesAndMentions} 62 + /> 63 + </> 49 64 ) : ( 50 - <CommentsDrawerContent 51 - document_uri={props.document_uri} 52 - comments={filteredComments} 53 - pageId={props.pageId} 54 - /> 65 + <> 66 + <div className="w-full flex justify-between"> 67 + <h4> Comments</h4> 68 + <button 69 + className="text-tertiary" 70 + onClick={() => 71 + setInteractionState(props.document_uri, { 72 + drawerOpen: false, 73 + }) 74 + } 75 + > 76 + <CloseTiny /> 77 + </button> 78 + </div> 79 + <CommentsDrawerContent 80 + document_uri={props.document_uri} 81 + comments={filteredComments} 82 + pageId={props.pageId} 83 + /> 84 + </> 55 85 )} 56 86 </div> 57 87 </div>
+2 -8
app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx
··· 86 86 }); 87 87 88 88 return ( 89 - <div className="relative w-full flex justify-between "> 90 - <button 91 - className="text-tertiary absolute top-0 right-0" 92 - onClick={() => setInteractionState(document_uri, { drawerOpen: false })} 93 - > 94 - <CloseTiny /> 95 - </button> 89 + <> 96 90 {props.quotesAndMentions.length === 0 ? ( 97 91 <div className="opaque-container flex flex-col gap-0.5 p-[6px] text-tertiary italic text-sm text-center"> 98 92 <div className="font-bold">no quotes yet!</div> ··· 160 154 )} 161 155 </div> 162 156 )} 163 - </div> 157 + </> 164 158 ); 165 159 }; 166 160
+24 -13
app/lish/[did]/[publication]/[rkey]/getPostPageData.ts
··· 3 3 import { 4 4 normalizeDocumentRecord, 5 5 normalizePublicationRecord, 6 - type NormalizedDocument, 7 - type NormalizedPublication, 8 6 } from "src/utils/normalizeRecords"; 9 7 import { PubLeafletPublication, SiteStandardPublication } from "lexicons/api"; 10 8 import { documentUriFilter } from "src/utils/uriHelpers"; ··· 33 31 if (!document) return null; 34 32 35 33 // Normalize the document record - this is the primary way consumers should access document data 36 - const normalizedDocument = normalizeDocumentRecord(document.data, document.uri); 34 + const normalizedDocument = normalizeDocumentRecord( 35 + document.data, 36 + document.uri, 37 + ); 37 38 if (!normalizedDocument) return null; 38 39 39 40 // Normalize the publication record - this is the primary way consumers should access publication data 40 41 const normalizedPublication = normalizePublicationRecord( 41 - document.documents_in_publications[0]?.publications?.record 42 + document.documents_in_publications[0]?.publications?.record, 42 43 ); 43 44 44 45 // Fetch constellation backlinks for mentions ··· 83 84 // Filter and sort documents by publishedAt 84 85 const sortedDocs = allDocs 85 86 .map((dip) => { 86 - const normalizedData = normalizeDocumentRecord(dip?.documents?.data, dip?.documents?.uri); 87 + const normalizedData = normalizeDocumentRecord( 88 + dip?.documents?.data, 89 + dip?.documents?.uri, 90 + ); 87 91 return { 88 92 uri: dip?.documents?.uri, 89 93 title: normalizedData?.title, ··· 98 102 ); 99 103 100 104 // Find current document index 101 - const currentIndex = sortedDocs.findIndex((doc) => doc.uri === document.uri); 105 + const currentIndex = sortedDocs.findIndex( 106 + (doc) => doc.uri === document.uri, 107 + ); 102 108 103 109 if (currentIndex !== -1) { 104 110 prevNext = { ··· 122 128 123 129 // Build explicit publication context for consumers 124 130 const rawPub = document.documents_in_publications[0]?.publications; 125 - const publication = rawPub ? { 126 - uri: rawPub.uri, 127 - name: rawPub.name, 128 - identity_did: rawPub.identity_did, 129 - record: rawPub.record as PubLeafletPublication.Record | SiteStandardPublication.Record | null, 130 - publication_subscriptions: rawPub.publication_subscriptions || [], 131 - } : null; 131 + const publication = rawPub 132 + ? { 133 + uri: rawPub.uri, 134 + name: rawPub.name, 135 + identity_did: rawPub.identity_did, 136 + record: rawPub.record as 137 + | PubLeafletPublication.Record 138 + | SiteStandardPublication.Record 139 + | null, 140 + publication_subscriptions: rawPub.publication_subscriptions || [], 141 + } 142 + : null; 132 143 133 144 return { 134 145 ...document,
+25 -10
components/PostListing.tsx
··· 15 15 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 16 16 import { useSmoker } from "./Toast"; 17 17 import { Separator } from "./Layout"; 18 - import { SpeedyLink } from "./SpeedyLink"; 19 18 import { CommentTiny } from "./Icons/CommentTiny"; 20 19 import { QuoteTiny } from "./Icons/QuoteTiny"; 21 20 import { ShareTiny } from "./Icons/ShareTiny"; 21 + import { useSelectedPostListing } from "src/useSelectedPostState"; 22 22 23 23 export const PostListing = (props: Post) => { 24 24 let pubRecord = props.publication?.pubRecord as ··· 149 149 tags={tags} 150 150 showComments={pubRecord?.preferences?.showComments !== false} 151 151 showMentions={pubRecord?.preferences?.showMentions !== false} 152 - />{" "} 152 + documentUri={props.documents.uri} 153 + document={postRecord} 154 + /> 153 155 <Share postUrl={postUrl} /> 154 156 </div> 155 157 </div> ··· 193 195 postUrl: string; 194 196 showComments: boolean; 195 197 showMentions: boolean; 198 + documentUri: string; 199 + document: NormalizedDocument; 196 200 }) => { 201 + let setSelectedPostListing = useSelectedPostListing( 202 + (s) => s.setSelectedPostListing, 203 + ); 204 + let selectPostListing = (drawer: "quotes" | "comments") => { 205 + setSelectedPostListing({ 206 + document_uri: props.documentUri, 207 + document: props.document, 208 + drawer, 209 + }); 210 + }; 211 + 197 212 return ( 198 213 <div 199 214 className={`flex gap-2 text-tertiary text-sm items-center justify-between px-1`} 200 215 > 201 216 <div className="postListingsInteractions flex gap-3"> 202 217 {!props.showMentions || props.quotesCount === 0 ? null : ( 203 - <SpeedyLink 218 + <button 204 219 aria-label="Post quotes" 205 - href={`${props.postUrl}?interactionDrawer=quotes`} 206 - className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast hover:no-underline! text-tertiary" 220 + onClick={() => selectPostListing("quotes")} 221 + className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast text-tertiary" 207 222 > 208 223 <QuoteTiny /> {props.quotesCount} 209 - </SpeedyLink> 224 + </button> 210 225 )} 211 226 {!props.showComments || props.commentsCount === 0 ? null : ( 212 - <SpeedyLink 227 + <button 213 228 aria-label="Post comments" 214 - href={`${props.postUrl}?interactionDrawer=comments`} 215 - className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast hover:no-underline! text-tertiary" 229 + onClick={() => selectPostListing("comments")} 230 + className="relative flex flex-row gap-1 text-sm items-center hover:text-accent-contrast text-tertiary" 216 231 > 217 232 <CommentTiny /> {props.commentsCount} 218 - </SpeedyLink> 233 + </button> 219 234 )} 220 235 </div> 221 236 </div>