a tool for shared writing and social publishing
0

Configure Feed

Select the types of activity you want to include in your feed.

adding things to make the comments more legible

celine (May 29, 2026, 12:42 AM EDT) 9bd86ab3 6eb91527

+282 -233
+28 -17
app/(app)/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx
··· 62 62 <div className="flex flex-col items-start shrink-0 w-fit pointer-events-auto"> 63 63 <Avatar 64 64 src={post.author.avatar} 65 - displayName={post.author.displayName} 65 + displayName={ 66 + post.author.displayName 67 + ? post.author.displayName 68 + : post.author.handle 69 + } 66 70 size={avatarSize ? avatarSize : "medium"} 67 71 /> 68 72 </div> ··· 142 146 replyOnClick?: (e: React.MouseEvent) => void; 143 147 clientHost?: string; 144 148 }) { 145 - const { post, parent, quoteEnabled, replyEnabled, replyOnClick, clientHost = "bsky.app" } = props; 149 + const { 150 + post, 151 + parent, 152 + quoteEnabled, 153 + replyEnabled, 154 + replyOnClick, 155 + clientHost = "bsky.app", 156 + } = props; 146 157 147 158 const record = post.record as AppBskyFeedPost.Record; 148 159 const postId = post.uri.split("/")[4]; ··· 202 213 ); 203 214 } 204 215 205 - function PostInfo(props: { 206 - displayName?: string; 216 + export function PostInfo(props: { 217 + displayName?: string | null; 207 218 handle: string; 208 219 createdAt: string; 209 220 compact?: boolean; ··· 212 223 213 224 return ( 214 225 <div className="postInfo flex items-center gap-2 leading-tight w-full"> 215 - <div className="flex gap-2 items-center min-w-0"> 216 - <div className={`font-bold text-secondary truncate`}> 217 - {displayName} 218 - </div> 219 - <div className="truncate items-end flex pointer-events-auto"> 220 - <ProfilePopover 221 - trigger={ 226 + <ProfilePopover 227 + trigger={ 228 + <div className="flex gap-2 items-baseline min-w-0 w-full"> 229 + <div className={`font-bold text-secondary truncate min-w-0`}> 230 + {displayName ? displayName : handle} 231 + </div> 232 + {displayName && ( 222 233 <div 223 - className={`${compact ? "text-xs" : "text-sm"} text-tertiary hover:underline w-full truncate `} 234 + className={`truncate min-w-0 shrink pointer-events-auto ${compact ? "text-xs" : "text-sm"} text-tertiary hover:underline`} 224 235 > 225 236 @{handle} 226 237 </div> 227 - } 228 - didOrHandle={handle} 229 - /> 230 - </div> 231 - </div> 238 + )} 239 + </div> 240 + } 241 + didOrHandle={handle} 242 + /> 232 243 <div className="w-1 h-1 rounded-full bg-border shrink-0" /> 233 244 <div 234 245 className={`${compact ? "text-xs" : "text-sm"} text-tertiary shrink-0`}
+6 -4
app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx
··· 219 219 uri: result.uri, 220 220 profile: { 221 221 did: new AtUri(result.uri).host, 222 - handle: null, 222 + handle: record?.handle ?? null, 223 223 displayName: 224 + record?.displayName ?? 224 225 (result.profile as { displayName?: string } | null) 225 - ?.displayName ?? null, 226 - avatar: null, 227 - description: null, 226 + ?.displayName ?? 227 + null, 228 + avatar: record?.avatar ?? null, 229 + description: record?.description ?? null, 228 230 }, 229 231 }, 230 232 ],
+159 -123
app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx
··· 5 5 import { Json } from "supabase/database.types"; 6 6 import { PubLeafletComment } from "lexicons/api"; 7 7 import { BaseTextBlock } from "../../Blocks/BaseTextBlock"; 8 - import { useMemo, useState } from "react"; 8 + import { useEffect, useMemo, useRef, useState } from "react"; 9 + import { useSpring, animated } from "@react-spring/web"; 10 + import useMeasure from "react-use-measure"; 9 11 import { CommentTiny } from "components/Icons/CommentTiny"; 10 12 import { Separator } from "components/Layout"; 11 13 import { Popover } from "components/Popover"; ··· 17 19 import { ProfilePopover } from "components/ProfilePopover"; 18 20 import { LoginModal } from "components/LoginButton"; 19 21 import { type Profile } from "src/identity"; 22 + import { PostInfo } from "../../BskyPostContent"; 23 + import { Avatar } from "components/Avatar"; 20 24 21 25 export type Comment = { 22 26 record: Json; ··· 35 39 (c) => (c.record as PubLeafletComment.Record)?.onPage === pageId, 36 40 ); 37 41 return [ 38 - ...localComments.filter( 39 - (c) => (c.record as any)?.onPage === pageId, 40 - ), 42 + ...localComments.filter((c) => (c.record as any)?.onPage === pageId), 41 43 ...filtered, 42 44 ]; 43 45 }, [props.comments, localComments, pageId]); ··· 76 78 <hr className="border-border-light" /> 77 79 </> 78 80 )} 79 - <div className="flex flex-col gap-4 py-2"> 81 + <div className="comments flex flex-col gap-6 py-2"> 80 82 {comments 81 83 .sort((a, b) => { 82 84 let aRecord = a.record as PubLeafletComment.Record; ··· 92 94 .map((comment) => { 93 95 let record = comment.record as PubLeafletComment.Record; 94 96 return ( 95 - <Comment 96 - pageId={pageId} 97 - profile={comment.profile} 98 - document={props.document_uri} 99 - comment={comment} 100 - record={record} 101 - comments={comments} 102 - key={comment.uri} 103 - /> 97 + <> 98 + <Comment 99 + pageId={pageId} 100 + profile={comment.profile} 101 + document={props.document_uri} 102 + comment={comment} 103 + record={record} 104 + comments={comments} 105 + key={comment.uri} 106 + /> 107 + <hr className="border-border last:hidden" /> 108 + </> 104 109 ); 105 110 })} 106 111 </div> ··· 118 123 }) => { 119 124 const did = props.profile?.did; 120 125 121 - let timeAgoDate = timeAgo(props.record.createdAt, { compact: true }); 126 + return ( 127 + <div 128 + id={props.comment.uri} 129 + className="comment flex gap-2 pointer-events-auto" 130 + > 131 + <Avatar 132 + src={props.profile?.avatar || undefined} 133 + displayName={ 134 + props.profile?.displayName 135 + ? props.profile?.displayName 136 + : props.profile?.handle || undefined 137 + } 138 + size={"medium"} 139 + /> 140 + 141 + <div className="grow flex flex-col pt-1"> 142 + <PostInfo 143 + displayName={props.profile?.displayName} 144 + handle={props.profile?.handle || ""} 145 + createdAt={props.record.createdAt} 146 + compact 147 + /> 148 + <div className="spacer w-full h-0.5" /> 122 149 123 - return ( 124 - <div id={props.comment.uri} className="comment"> 125 - <div className="flex gap-2 items-center"> 126 - {did ? ( 127 - <ProfilePopover 128 - didOrHandle={did} 129 - trigger={ 130 - <div className="text-sm text-secondary font-bold hover:underline"> 131 - {props.profile?.displayName} 132 - </div> 133 - } 150 + {props.record.attachment && 151 + PubLeafletComment.isLinearDocumentQuote(props.record.attachment) && ( 152 + <div className="mt-1 mb-2"> 153 + <QuoteContent 154 + index={-1} 155 + position={props.record.attachment.quote} 156 + did={new AtUri(props.record.attachment.document).host} 157 + /> 158 + </div> 159 + )} 160 + <pre 161 + key={props.comment.uri} 162 + style={{ wordBreak: "break-word", fontFamily: "inherit" }} 163 + className="whitespace-pre-wrap text-secondary pb-[4px] " 164 + > 165 + <BaseTextBlock 166 + index={[]} 167 + plaintext={props.record.plaintext} 168 + facets={props.record.facets} 134 169 /> 135 - ) : null} 136 - 137 - <div className="w-1 h-1 rounded-full bg-border shrink-0" /> 138 - <div className="text-sm text-tertiary">{timeAgoDate}</div> 139 - </div> 140 - {props.record.attachment && 141 - PubLeafletComment.isLinearDocumentQuote(props.record.attachment) && ( 142 - <div className="mt-1 mb-2"> 143 - <QuoteContent 144 - index={-1} 145 - position={props.record.attachment.quote} 146 - did={new AtUri(props.record.attachment.document).host} 147 - /> 148 - </div> 149 - )} 150 - <pre 151 - key={props.comment.uri} 152 - style={{ wordBreak: "break-word", fontFamily: "inherit" }} 153 - className="whitespace-pre-wrap text-secondary pb-[4px] " 154 - > 155 - <BaseTextBlock 156 - index={[]} 157 - plaintext={props.record.plaintext} 158 - facets={props.record.facets} 170 + </pre> 171 + <Replies 172 + pageId={props.pageId} 173 + comment_uri={props.comment.uri} 174 + comments={props.comments} 175 + document={props.document} 159 176 /> 160 - </pre> 161 - <Replies 162 - pageId={props.pageId} 163 - comment_uri={props.comment.uri} 164 - comments={props.comments} 165 - document={props.document} 166 - /> 177 + </div> 167 178 </div> 168 179 ); 169 180 }; ··· 194 205 ); 195 206 }); 196 207 197 - let repliesOrReplyBoxOpen = 198 - replyBoxOpen || (repliesOpen && replies.length > 0); 199 208 return ( 200 209 <> 201 210 <div className="flex gap-2 items-center"> ··· 210 219 {replies.length !== 0 && replies.length} 211 220 </button> 212 221 {identity?.atp_did && ( 213 - <> 214 - <Separator classname="h-[14px]" /> 215 - <button 216 - className="text-accent-contrast text-sm" 217 - onClick={() => { 218 - setRepliesOpen(true); 219 - setReplyBoxOpen(true); 220 - }} 221 - > 222 - Reply 223 - </button> 224 - </> 222 + <button 223 + className="text-accent-contrast text-sm" 224 + onClick={() => { 225 + setRepliesOpen(true); 226 + setReplyBoxOpen(true); 227 + }} 228 + > 229 + Reply 230 + </button> 225 231 )} 226 232 </div> 227 - {repliesOrReplyBoxOpen && ( 228 - <div className="flex flex-col pt-1"> 229 - {replyBoxOpen && ( 230 - <div className="repliesWrapper flex w-full"> 231 - <button 232 - className="repliesCollapse pr-[14px] ml-[7px]" 233 - onClick={() => { 234 - setReplyBoxOpen(false); 235 - setRepliesOpen(false); 236 - }} 237 - > 238 - <div className="bg-border-light w-[2px] h-full" /> 239 - </button> 240 - <CommentBox 241 - className="pt-3" 242 - pageId={props.pageId} 243 - doc_uri={props.document} 244 - replyTo={props.comment_uri} 245 - autoFocus={true} 246 - onSubmit={() => { 247 - setReplyBoxOpen(false); 248 - }} 249 - /> 233 + {replyBoxOpen && ( 234 + <div className="repliesWrapper flex w-full pt-1"> 235 + <button 236 + className="repliesCollapse mr-[14px] ml-[7px]" 237 + onClick={() => { 238 + setReplyBoxOpen(false); 239 + setRepliesOpen(false); 240 + }} 241 + > 242 + <div className="bg-border-light w-[2px] h-full" /> 243 + </button> 244 + <CommentBox 245 + className="pt-3" 246 + pageId={props.pageId} 247 + doc_uri={props.document} 248 + replyTo={props.comment_uri} 249 + autoFocus={true} 250 + onSubmit={() => { 251 + setReplyBoxOpen(false); 252 + }} 253 + /> 254 + </div> 255 + )} 256 + {replies.length > 0 && ( 257 + <CollapsibleReplies open={repliesOpen}> 258 + <div className="repliesWrapper flex pt-1 relative"> 259 + {/* the thread line itself is non-interactive; a transparent button 260 + is overlaid on top of it (z-10) to catch clicks, so the line 261 + stays clickable even though the comments re-enable pointer 262 + events with pointer-events-auto */} 263 + <div className="-mr-[14px] ml-[7px] pointer-events-none"> 264 + <div className="bg-border-light w-[2px] h-full" /> 250 265 </div> 251 - )} 252 - {repliesOpen && replies.length > 0 && ( 253 - <div className="repliesWrapper flex"> 254 - <button 255 - className="repliesCollapse pr-[14px] ml-[7px]" 256 - onClick={() => { 257 - setReplyBoxOpen(false); 258 - setRepliesOpen(false); 259 - }} 260 - > 261 - <div className="bg-border-light w-[2px] h-full" /> 262 - </button> 263 - <div className="repliesContent flex flex-col gap-3 pt-2 w-full"> 264 - {replies.map((reply) => { 265 - return ( 266 - <Comment 267 - pageId={props.pageId} 268 - document={props.document} 269 - key={reply.uri} 270 - comment={reply} 271 - profile={reply.profile} 272 - record={reply.record as PubLeafletComment.Record} 273 - comments={props.comments} 274 - /> 275 - ); 276 - })} 277 - </div> 266 + <button 267 + className="repliesCollapse absolute top-0 bottom-0 left-0 w-[20px] z-10" 268 + onClick={() => { 269 + setReplyBoxOpen(false); 270 + setRepliesOpen(false); 271 + }} 272 + /> 273 + <div className="repliesContent flex flex-col gap-8 pt-4 w-full"> 274 + {replies.map((reply) => { 275 + return ( 276 + <Comment 277 + pageId={props.pageId} 278 + document={props.document} 279 + key={reply.uri} 280 + comment={reply} 281 + profile={reply.profile} 282 + record={reply.record as PubLeafletComment.Record} 283 + comments={props.comments} 284 + /> 285 + ); 286 + })} 278 287 </div> 279 - )} 280 - </div> 288 + </div> 289 + </CollapsibleReplies> 281 290 )} 282 291 </> 292 + ); 293 + }; 294 + 295 + // Animates the replies list height when it opens/closes instead of snapping 296 + // it in and out of the DOM. 297 + const CollapsibleReplies = (props: { 298 + open: boolean; 299 + children: React.ReactNode; 300 + }) => { 301 + let [ref, { height }] = useMeasure(); 302 + // Skip the spring on the first render with a real height so replies that 303 + // are open by default don't animate in on page load. 304 + let measured = useRef(false); 305 + let style = useSpring({ 306 + height: props.open ? height : 0, 307 + opacity: props.open ? 1 : 0, 308 + immediate: !measured.current, 309 + config: { tension: 280, friction: 30 }, 310 + }); 311 + useEffect(() => { 312 + if (height > 0) measured.current = true; 313 + }, [height]); 314 + 315 + return ( 316 + <animated.div style={{ ...style }}> 317 + <div ref={ref}>{props.children}</div> 318 + </animated.div> 283 319 ); 284 320 }; 285 321
+48 -31
app/(app)/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx
··· 9 9 import { SandwichSpacer } from "components/LeafletLayout"; 10 10 import { decodeQuotePosition } from "../quotePosition"; 11 11 import { CloseTiny } from "components/Icons/CloseTiny"; 12 + import { ToggleGroup } from "components/ToggleGroup"; 12 13 13 14 export const InteractionDrawer = (props: { 14 15 showPageBackground: boolean | undefined; ··· 30 31 return quotePosition?.pageId === props.pageId; 31 32 }); 32 33 34 + // commentsSlot is null when comments are disabled by permissions; mentions 35 + // are only available when there's something to show on this page. 36 + const commentsAvailable = props.commentsSlot != null; 37 + const mentionsAvailable = filteredQuotesAndMentions.length > 0; 38 + const bothAvailable = commentsAvailable && mentionsAvailable; 39 + 40 + // Resolve the active tab, falling back to whichever option is available. 41 + let activeTab: "comments" | "quotes" = 42 + drawer.drawer === "quotes" ? "quotes" : "comments"; 43 + if (activeTab === "comments" && !commentsAvailable) activeTab = "quotes"; 44 + if (activeTab === "quotes" && !mentionsAvailable) activeTab = "comments"; 45 + 33 46 return ( 34 47 <> 35 48 <SandwichSpacer noWidth /> ··· 38 51 id="interaction-drawer" 39 52 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"}`} 40 53 > 41 - {drawer.drawer === "quotes" ? ( 42 - <> 43 - <button 44 - className="text-tertiary absolute top-4 right-4" 45 - onClick={() => 46 - setInteractionState(props.document_uri, { drawerOpen: false }) 47 - } 48 - > 49 - <CloseTiny /> 50 - </button> 51 - <MentionsDrawerContent 52 - did={props.did} 53 - quotesAndMentions={filteredQuotesAndMentions} 54 - /> 55 - </> 54 + <div className="w-full flex items-center gap-2 mb-3"> 55 + <div className="flex-1 min-w-0"> 56 + {bothAvailable ? ( 57 + <ToggleGroup 58 + fullWidth 59 + value={activeTab} 60 + onChange={(value) => 61 + setInteractionState(props.document_uri, { drawer: value }) 62 + } 63 + options={[ 64 + { value: "comments", label: "Comments" }, 65 + { value: "quotes", label: "Bluesky Mentions" }, 66 + ]} 67 + /> 68 + ) : ( 69 + <h4> 70 + {activeTab === "quotes" ? "Bluesky Mentions" : "Comments"} 71 + </h4> 72 + )} 73 + </div> 74 + <button 75 + className="text-tertiary shrink-0" 76 + onClick={() => 77 + setInteractionState(props.document_uri, { drawerOpen: false }) 78 + } 79 + > 80 + <CloseTiny /> 81 + </button> 82 + </div> 83 + {activeTab === "quotes" ? ( 84 + <MentionsDrawerContent 85 + did={props.did} 86 + quotesAndMentions={filteredQuotesAndMentions} 87 + /> 56 88 ) : ( 57 - <> 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, { 64 - drawerOpen: false, 65 - }) 66 - } 67 - > 68 - <CloseTiny /> 69 - </button> 70 - </div> 71 - {props.commentsSlot} 72 - </> 89 + props.commentsSlot 73 90 )} 74 91 </div> 75 92 </div>
+41 -58
app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
··· 1 1 "use client"; 2 2 import { CommentTiny } from "components/Icons/CommentTiny"; 3 - import { QuoteTiny } from "components/Icons/QuoteTiny"; 4 3 import { flushSync } from "react-dom"; 5 4 import type { Json } from "supabase/database.types"; 6 5 import { create } from "zustand"; ··· 131 130 const tags = normalizedDocument.tags; 132 131 const tagCount = tags?.length || 0; 133 132 134 - let interactionsAvailable = 135 - props.showComments || 136 - (props.showMentions && props.quotesCount > 0) || 137 - props.showRecommends; 133 + let commentsAvailable = props.showComments; 134 + let mentionsAvailable = props.showMentions && props.quotesCount > 0; 135 + let discussionsAvailable = commentsAvailable || mentionsAvailable; 136 + let defaultDiscussionTab: "comments" | "quotes" = commentsAvailable 137 + ? "comments" 138 + : "quotes"; 139 + 140 + let interactionsAvailable = discussionsAvailable || props.showRecommends; 138 141 139 142 return ( 140 143 <div ··· 147 150 /> 148 151 )} 149 152 150 - {/*MENTIONS BUTTON*/} 151 - {props.quotesCount === 0 || props.showMentions === false ? null : ( 153 + {/*DISCUSSIONS BUTTON*/} 154 + {!discussionsAvailable ? null : ( 152 155 <button 153 - className="flex w-fit gap-1 items-center" 156 + className="flex gap-1 items-center w-fit" 154 157 onClick={() => { 155 - if (!drawerOpen || drawer !== "quotes") 156 - openInteractionDrawer("quotes", document_uri, props.pageId); 158 + if ( 159 + !drawerOpen || 160 + (drawer !== "comments" && drawer !== "quotes") || 161 + pageId !== props.pageId 162 + ) 163 + openInteractionDrawer( 164 + defaultDiscussionTab, 165 + document_uri, 166 + props.pageId, 167 + ); 157 168 else setInteractionState(document_uri, { drawerOpen: false }); 158 169 }} 159 170 onMouseEnter={handleQuotePrefetch} 160 171 onTouchStart={handleQuotePrefetch} 161 - aria-label="Post quotes" 172 + aria-label="Discussions" 162 173 > 163 - <QuoteTiny aria-hidden /> {props.quotesCount} 164 - </button> 165 - )} 166 - {/*COMMENT BUTTON*/} 167 - {props.showComments === false ? null : ( 168 - <button 169 - className="flex gap-1 items-center w-fit" 170 - onClick={() => { 171 - if (!drawerOpen || drawer !== "comments" || pageId !== props.pageId) 172 - openInteractionDrawer("comments", document_uri, props.pageId); 173 - else setInteractionState(document_uri, { drawerOpen: false }); 174 - }} 175 - aria-label="Post comments" 176 - > 177 - <CommentTiny aria-hidden /> {props.commentsCount} 174 + <CommentTiny aria-hidden /> {props.commentsCount + props.quotesCount} 178 175 </button> 179 176 )} 180 177 ··· 218 215 const tags = normalizedDocument.tags; 219 216 const tagCount = tags?.length || 0; 220 217 221 - let noInteractions = 222 - !props.showComments && !props.showMentions && !props.showRecommends; 218 + let commentsAvailable = props.showComments; 219 + let mentionsAvailable = props.showMentions && props.quotesCount > 0; 220 + let discussionsAvailable = commentsAvailable || mentionsAvailable; 221 + let defaultDiscussionTab: "comments" | "quotes" = commentsAvailable 222 + ? "comments" 223 + : "quotes"; 224 + 225 + let noInteractions = !discussionsAvailable && !props.showRecommends; 223 226 224 227 return ( 225 228 <div ··· 248 251 expanded 249 252 /> 250 253 )} 251 - {props.quotesCount === 0 || !props.showMentions ? null : ( 252 - <ButtonSecondary 253 - onClick={() => { 254 - if (!drawerOpen || drawer !== "quotes") 255 - openInteractionDrawer( 256 - "quotes", 257 - document_uri, 258 - props.pageId, 259 - ); 260 - else 261 - setInteractionState(document_uri, { drawerOpen: false }); 262 - }} 263 - onMouseEnter={handleQuotePrefetch} 264 - onTouchStart={handleQuotePrefetch} 265 - aria-label="Post quotes" 266 - > 267 - <QuoteTiny aria-hidden /> {props.quotesCount} 268 - <Separator classname="h-4! text-accent-contrast!" /> 269 - Mention{props.quotesCount > 1 ? "s" : ""} 270 - </ButtonSecondary> 271 - )} 272 - {!props.showComments ? null : ( 254 + {!discussionsAvailable ? null : ( 273 255 <ButtonSecondary 274 256 onClick={() => { 275 257 if ( 276 258 !drawerOpen || 277 - drawer !== "comments" || 259 + (drawer !== "comments" && drawer !== "quotes") || 278 260 pageId !== props.pageId 279 261 ) 280 262 openInteractionDrawer( 281 - "comments", 263 + defaultDiscussionTab, 282 264 document_uri, 283 265 props.pageId, 284 266 ); 285 267 else 286 268 setInteractionState(document_uri, { drawerOpen: false }); 287 269 }} 288 - aria-label="Post comments" 270 + onMouseEnter={handleQuotePrefetch} 271 + onTouchStart={handleQuotePrefetch} 272 + aria-label="Discussions" 289 273 > 290 - <CommentTiny aria-hidden />{" "} 291 - {props.commentsCount > 0 && ( 274 + <CommentTiny aria-hidden /> 275 + {props.quotesCount + props.commentsCount !== 0 && ( 292 276 <> 293 - {props.commentsCount} 277 + {props.quotesCount + props.commentsCount}{" "} 294 278 <Separator classname="h-4! text-accent-contrast!" /> 295 279 </> 296 280 )} 297 - Comment{props.commentsCount > 1 ? "s" : ""} 281 + Discussion 298 282 </ButtonSecondary> 299 283 )} 300 284 </div> ··· 365 349 }).length; 366 350 } 367 351 } 368 - 369 352 370 353 const EditButton = (props: { 371 354 publication: { identity_did: string } | null;