a tool for shared writing and social publishing
at main 310 lines 9.7 kB view raw
1import Link from "next/link"; 2import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 3import { useRef, useState } from "react"; 4import { useReplicache } from "src/replicache"; 5import { AsyncValueAutosizeTextarea } from "components/utils/AutosizeTextarea"; 6import { Separator } from "components/Layout"; 7import { AtUri } from "@atproto/syntax"; 8import { 9 getBasePublicationURL, 10 getPublicationURL, 11} from "app/lish/createPub/getPublicationURL"; 12import { useSubscribe } from "src/replicache/useSubscribe"; 13import { useEntitySetContext } from "components/EntitySetProvider"; 14import { timeAgo } from "src/utils/timeAgo"; 15import { CommentTiny } from "components/Icons/CommentTiny"; 16import { QuoteTiny } from "components/Icons/QuoteTiny"; 17import { TagTiny } from "components/Icons/TagTiny"; 18import { Popover } from "components/Popover"; 19import { TagSelector } from "components/Tags"; 20import { useIdentityData } from "components/IdentityProvider"; 21import { PostHeaderLayout } from "app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader"; 22import { Backdater } from "./Backdater"; 23import { RecommendTinyEmpty } from "components/Icons/RecommendTiny"; 24import { mergePreferences } from "src/utils/mergePreferences"; 25 26export const PublicationMetadata = (props: { noInteractions?: boolean }) => { 27 let { rep } = useReplicache(); 28 let { 29 data: pub, 30 normalizedDocument, 31 normalizedPublication, 32 } = useLeafletPublicationData(); 33 let { identity } = useIdentityData(); 34 let title = useSubscribe(rep, (tx) => tx.get<string>("publication_title")); 35 let description = useSubscribe(rep, (tx) => 36 tx.get<string>("publication_description"), 37 ); 38 let postPreferences = useSubscribe(rep, (tx) => 39 tx.get<{ 40 showComments?: boolean; 41 showMentions?: boolean; 42 showRecommends?: boolean; 43 } | null>("post_preferences"), 44 ); 45 let merged = mergePreferences( 46 postPreferences || undefined, 47 normalizedPublication?.preferences, 48 ); 49 let publishedAt = normalizedDocument?.publishedAt; 50 51 if (!pub) return null; 52 53 if (typeof title !== "string") { 54 title = pub?.title || ""; 55 } 56 if (typeof description !== "string") { 57 description = pub?.description || ""; 58 } 59 let tags = true; 60 61 return ( 62 <PostHeaderLayout 63 pubLink={ 64 <div className="flex gap-2 items-center"> 65 {pub.publications && ( 66 <Link 67 href={ 68 identity?.atp_did === pub.publications?.identity_did 69 ? `${getBasePublicationURL(pub.publications)}/dashboard` 70 : getPublicationURL(pub.publications) 71 } 72 className="leafletMetadata text-accent-contrast font-bold hover:no-underline" 73 > 74 {pub.publications?.name} 75 </Link> 76 )} 77 <div className="font-bold text-tertiary px-1 h-[20px] text-sm flex place-items-center bg-border-light rounded-md "> 78 DRAFT 79 </div> 80 </div> 81 } 82 postTitle={ 83 <TextField 84 className="leading-tight pt-0.5 text-xl font-bold outline-hidden bg-transparent" 85 value={title} 86 onChange={async (newTitle) => { 87 await rep?.mutate.updatePublicationDraft({ 88 title: newTitle, 89 description, 90 }); 91 }} 92 placeholder="Untitled" 93 /> 94 } 95 postDescription={ 96 <TextField 97 placeholder="add an optional description..." 98 className="pt-1 italic text-secondary outline-hidden bg-transparent" 99 value={description} 100 onChange={async (newDescription) => { 101 await rep?.mutate.updatePublicationDraft({ 102 title, 103 description: newDescription, 104 }); 105 }} 106 /> 107 } 108 postInfo={ 109 <> 110 {pub.doc ? ( 111 <div className="flex gap-2 items-center"> 112 <p className="text-sm text-tertiary"> 113 Published{" "} 114 {publishedAt && ( 115 <Backdater publishedAt={publishedAt} docURI={pub.doc} /> 116 )} 117 </p> 118 119 <Link 120 target="_blank" 121 className="text-sm" 122 href={ 123 pub.publications 124 ? `${getPublicationURL(pub.publications)}/${new AtUri(pub.doc).rkey}` 125 : `/p/${new AtUri(pub.doc).host}/${new AtUri(pub.doc).rkey}` 126 } 127 > 128 View 129 </Link> 130 </div> 131 ) : ( 132 <p>Draft</p> 133 )} 134 {!props.noInteractions && ( 135 <div className="flex gap-2 text-border items-center"> 136 {merged.showRecommends !== false && ( 137 <div className="flex gap-1 items-center"> 138 <RecommendTinyEmpty /> 139 </div> 140 )} 141 142 {merged.showMentions !== false && ( 143 <div className="flex gap-1 items-center"> 144 <QuoteTiny /> 145 </div> 146 )} 147 {merged.showComments !== false && ( 148 <div className="flex gap-1 items-center"> 149 <CommentTiny /> 150 </div> 151 )} 152 {tags && ( 153 <> 154 {merged.showRecommends !== false || 155 merged.showMentions !== false || 156 merged.showComments !== false ? ( 157 <Separator classname="h-4!" /> 158 ) : null} 159 <AddTags /> 160 </> 161 )} 162 </div> 163 )} 164 </> 165 } 166 /> 167 ); 168}; 169 170export const TextField = ({ 171 value, 172 onChange, 173 className, 174 placeholder, 175}: { 176 value: string; 177 onChange: (v: string) => Promise<void>; 178 className: string; 179 placeholder: string; 180}) => { 181 let { undoManager } = useReplicache(); 182 let actionTimeout = useRef<number | null>(null); 183 let { permissions } = useEntitySetContext(); 184 let previousSelection = useRef<null | { start: number; end: number }>(null); 185 let ref = useRef<HTMLTextAreaElement | null>(null); 186 return ( 187 <AsyncValueAutosizeTextarea 188 ref={ref} 189 disabled={!permissions.write} 190 onSelect={(e) => { 191 let start = e.currentTarget.selectionStart, 192 end = e.currentTarget.selectionEnd; 193 previousSelection.current = { start, end }; 194 }} 195 className={className} 196 value={value} 197 onBlur={async () => { 198 if (actionTimeout.current) { 199 undoManager.endGroup(); 200 window.clearTimeout(actionTimeout.current); 201 actionTimeout.current = null; 202 } 203 }} 204 onChange={async (e) => { 205 let newValue = e.currentTarget.value; 206 let oldValue = value; 207 let start = e.currentTarget.selectionStart, 208 end = e.currentTarget.selectionEnd; 209 await onChange(e.currentTarget.value); 210 211 if (actionTimeout.current) { 212 window.clearTimeout(actionTimeout.current); 213 } else { 214 undoManager.startGroup(); 215 } 216 217 actionTimeout.current = window.setTimeout(() => { 218 undoManager.endGroup(); 219 actionTimeout.current = null; 220 }, 200); 221 let previousStart = previousSelection.current?.start || null, 222 previousEnd = previousSelection.current?.end || null; 223 undoManager.add({ 224 redo: async () => { 225 await onChange(newValue); 226 ref.current?.setSelectionRange(start, end); 227 ref.current?.focus(); 228 }, 229 undo: async () => { 230 await onChange(oldValue); 231 ref.current?.setSelectionRange(previousStart, previousEnd); 232 ref.current?.focus(); 233 }, 234 }); 235 }} 236 placeholder={placeholder} 237 /> 238 ); 239}; 240 241export const PublicationMetadataPreview = () => { 242 let { data: pub, normalizedDocument } = useLeafletPublicationData(); 243 let publishedAt = normalizedDocument?.publishedAt; 244 245 if (!pub) return null; 246 247 return ( 248 <PostHeaderLayout 249 pubLink={ 250 <div className="text-accent-contrast font-bold hover:no-underline"> 251 {pub.publications?.name} 252 </div> 253 } 254 postTitle={pub.title} 255 postDescription={pub.description} 256 postInfo={ 257 pub.doc ? ( 258 <p>Published {publishedAt && timeAgo(publishedAt)}</p> 259 ) : ( 260 <p>Draft</p> 261 ) 262 } 263 /> 264 ); 265}; 266 267export const AddTags = () => { 268 let { data: pub, normalizedDocument } = useLeafletPublicationData(); 269 let { rep } = useReplicache(); 270 271 // Get tags from Replicache local state or published document 272 let replicacheTags = useSubscribe(rep, (tx) => 273 tx.get<string[]>("publication_tags"), 274 ); 275 276 // Determine which tags to use - prioritize Replicache state 277 let tags: string[] = []; 278 if (Array.isArray(replicacheTags)) { 279 tags = replicacheTags; 280 } else if ( 281 normalizedDocument?.tags && 282 Array.isArray(normalizedDocument.tags) 283 ) { 284 tags = normalizedDocument.tags as string[]; 285 } 286 287 // Update tags in replicache local state 288 const handleTagsChange = async (newTags: string[]) => { 289 // Store tags in replicache for next publish/update 290 await rep?.mutate.updatePublicationDraft({ 291 tags: newTags, 292 }); 293 }; 294 295 return ( 296 <Popover 297 className="p-2! w-full min-w-xs" 298 trigger={ 299 <div className="addTagTrigger flex gap-1 hover:underline text-sm items-center text-tertiary"> 300 <TagTiny />{" "} 301 {tags.length > 0 302 ? `${tags.length} Tag${tags.length === 1 ? "" : "s"}` 303 : "Add Tags"} 304 </div> 305 } 306 > 307 <TagSelector selectedTags={tags} setSelectedTags={handleTagsChange} /> 308 </Popover> 309 ); 310};