a tool for shared writing and social publishing
0
fork

Configure Feed

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

at main 558 lines 18 kB view raw
1import { useRef, useEffect, useState, useCallback } from "react"; 2import { elementId } from "src/utils/elementId"; 3import { useReplicache, useEntity } from "src/replicache"; 4import { isVisible } from "src/utils/isVisible"; 5import { EditorState, TextSelection } from "prosemirror-state"; 6import { EditorView } from "prosemirror-view"; 7import { RenderYJSFragment } from "./RenderYJSFragment"; 8import { useHasPageLoaded } from "components/InitialPageLoadProvider"; 9import { BlockProps } from "../Block"; 10import { focusBlock } from "src/utils/focusBlock"; 11import { useUIState } from "src/useUIState"; 12import { addBlueskyPostBlock, addLinkBlock } from "src/utils/addLinkBlock"; 13import { BlockCommandBar } from "components/Blocks/BlockCommandBar"; 14import { useEditorStates } from "src/state/useEditorState"; 15import { useEntitySetContext } from "components/EntitySetProvider"; 16import { TooltipButton } from "components/Buttons"; 17import { blockCommands } from "../BlockCommands"; 18import { betterIsUrl } from "src/utils/isURL"; 19import { useSmoker } from "components/Toast"; 20import { AddTiny } from "components/Icons/AddTiny"; 21import { BlockDocPageSmall } from "components/Icons/BlockDocPageSmall"; 22import { BlockImageSmall } from "components/Icons/BlockImageSmall"; 23import { isIOS } from "src/utils/isDevice"; 24import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 25import { DotLoader } from "components/utils/DotLoader"; 26import { useMountProsemirror } from "./mountProsemirror"; 27import { schema } from "./schema"; 28 29import { Mention, MentionAutocomplete } from "components/Mention"; 30import { addMentionToEditor } from "app/[leaflet_id]/publish/BskyPostEditorProsemirror"; 31 32const HeadingStyle = { 33 1: "text-xl font-bold", 34 2: "text-lg font-bold", 35 3: "text-base font-bold text-secondary ", 36} as { [level: number]: string }; 37 38export function TextBlock( 39 props: BlockProps & { 40 className?: string; 41 preview?: boolean; 42 }, 43) { 44 let initialized = useHasPageLoaded(); 45 let first = props.previousBlock === null; 46 let permission = useEntitySetContext().permissions.write; 47 48 return ( 49 <> 50 {(!initialized || !permission || props.preview) && ( 51 <RenderedTextBlock 52 type={props.type} 53 entityID={props.entityID} 54 className={props.className} 55 first={first} 56 pageType={props.pageType} 57 previousBlock={props.previousBlock} 58 /> 59 )} 60 {permission && !props.preview && ( 61 <div 62 className={`w-full relative group ${!initialized ? "hidden" : ""}`} 63 > 64 <IOSBS {...props} /> 65 <BaseTextBlock {...props} /> 66 </div> 67 )} 68 </> 69 ); 70} 71 72export function IOSBS(props: BlockProps) { 73 let [initialRender, setInitialRender] = useState(true); 74 useEffect(() => { 75 setInitialRender(false); 76 }, []); 77 if (initialRender || !isIOS()) return null; 78 return ( 79 <div 80 className="h-full w-full absolute cursor-text group-focus-within:hidden py-[18px]" 81 onPointerUp={(e) => { 82 e.preventDefault(); 83 focusBlock(props, { 84 type: "coord", 85 top: e.clientY, 86 left: e.clientX, 87 }); 88 setTimeout(async () => { 89 let target = document.getElementById( 90 elementId.block(props.entityID).container, 91 ); 92 let vis = await isVisible(target as Element); 93 if (!vis) { 94 let parentEl = document.getElementById( 95 elementId.page(props.parent).container, 96 ); 97 if (!parentEl) return; 98 parentEl?.scrollBy({ 99 top: 250, 100 behavior: "smooth", 101 }); 102 } 103 }, 100); 104 }} 105 /> 106 ); 107} 108 109export function RenderedTextBlock(props: { 110 entityID: string; 111 className?: string; 112 first?: boolean; 113 pageType?: "canvas" | "doc"; 114 type: BlockProps["type"]; 115 previousBlock?: BlockProps["previousBlock"]; 116}) { 117 let initialFact = useEntity(props.entityID, "block/text"); 118 let headingLevel = useEntity(props.entityID, "block/heading-level"); 119 let textSize = useEntity(props.entityID, "block/text-size"); 120 let alignment = 121 useEntity(props.entityID, "block/text-alignment")?.data.value || "left"; 122 let alignmentClass = { 123 left: "text-left", 124 right: "text-right", 125 center: "text-center", 126 justify: "text-justify", 127 }[alignment]; 128 let textStyle = 129 textSize?.data.value === "small" 130 ? "text-sm" 131 : textSize?.data.value === "large" 132 ? "text-lg" 133 : ""; 134 let { permissions } = useEntitySetContext(); 135 136 let content = <br />; 137 if (!initialFact) { 138 if (permissions.write && (props.first || props.pageType === "canvas")) 139 content = ( 140 <div 141 className={`${props.className} 142 pointer-events-none italic text-tertiary flex flex-col `} 143 > 144 {headingLevel?.data.value === 1 145 ? "Title" 146 : headingLevel?.data.value === 2 147 ? "Header" 148 : headingLevel?.data.value === 3 149 ? "Subheader" 150 : "write something..."} 151 <div className=" text-xs font-normal"> 152 or type &quot;/&quot; for commands 153 </div> 154 </div> 155 ); 156 } else { 157 content = <RenderYJSFragment value={initialFact.data.value} wrapper="p" />; 158 } 159 return ( 160 <div 161 style={{ wordBreak: "break-word" }} // better than tailwind break-all! 162 className={` 163 ${alignmentClass} 164 ${props.type === "blockquote" ? (props.previousBlock?.type === "blockquote" ? `blockquote pt-3 ` : "blockquote") : ""} 165 ${props.type === "heading" ? HeadingStyle[headingLevel?.data.value || 1] : textStyle} 166 w-full whitespace-pre-wrap outline-hidden ${props.className} `} 167 > 168 {content} 169 </div> 170 ); 171} 172 173export function BaseTextBlock(props: BlockProps & { className?: string }) { 174 let headingLevel = useEntity(props.entityID, "block/heading-level"); 175 let textSize = useEntity(props.entityID, "block/text-size"); 176 let alignment = 177 useEntity(props.entityID, "block/text-alignment")?.data.value || "left"; 178 179 let rep = useReplicache(); 180 181 let selected = useUIState( 182 (s) => !!s.selectedBlocks.find((b) => b.value === props.entityID), 183 ); 184 let focused = useUIState((s) => s.focusedEntity?.entityID === props.entityID); 185 let alignmentClass = { 186 left: "text-left", 187 right: "text-right", 188 center: "text-center", 189 justify: "text-justify", 190 }[alignment]; 191 let textStyle = 192 textSize?.data.value === "small" 193 ? "text-sm text-secondary" 194 : textSize?.data.value === "large" 195 ? "text-lg text-primary" 196 : "text-base text-primary"; 197 198 let editorState = useEditorStates( 199 (s) => s.editorStates[props.entityID], 200 )?.editor; 201 const { 202 viewRef, 203 mentionOpen, 204 mentionCoords, 205 openMentionAutocomplete, 206 handleMentionSelect, 207 handleMentionOpenChange, 208 } = useMentionState(props.entityID); 209 210 let { mountRef, actionTimeout } = useMountProsemirror({ 211 props, 212 openMentionAutocomplete, 213 }); 214 215 return ( 216 <> 217 <div 218 className={`flex items-center justify-between w-full 219 ${selected && props.pageType === "canvas" && "bg-bg-page rounded-md"} 220 ${ 221 props.type === "blockquote" 222 ? props.previousBlock?.type === "blockquote" && !props.listData 223 ? "blockquote pt-3" 224 : "blockquote" 225 : "" 226 }`} 227 > 228 <pre 229 data-entityid={props.entityID} 230 onBlur={async () => { 231 if ( 232 ["***", "---", "___"].includes( 233 editorState?.doc.textContent.trim() || "", 234 ) 235 ) { 236 await rep.rep?.mutate.assertFact({ 237 entity: props.entityID, 238 attribute: "block/type", 239 data: { type: "block-type-union", value: "horizontal-rule" }, 240 }); 241 } 242 if (actionTimeout.current) { 243 rep.undoManager.endGroup(); 244 window.clearTimeout(actionTimeout.current); 245 actionTimeout.current = null; 246 } 247 }} 248 onFocus={() => { 249 handleMentionOpenChange(false); 250 setTimeout(() => { 251 useUIState.getState().setSelectedBlock(props); 252 useUIState.setState(() => ({ 253 focusedEntity: { 254 entityType: "block", 255 entityID: props.entityID, 256 parent: props.parent, 257 }, 258 })); 259 }, 5); 260 }} 261 id={elementId.block(props.entityID).text} 262 // unless we break *only* on urls, this is better than tailwind 'break-all' 263 // b/c break-all can cause breaks in the middle of words, but break-word still 264 // forces break if a single text string (e.g. a url) spans more than a full line 265 style={{ wordBreak: "break-word" }} 266 className={` 267 ${alignmentClass} 268 grow resize-none align-top whitespace-pre-wrap bg-transparent 269 outline-hidden 270 271 ${props.type === "heading" ? HeadingStyle[headingLevel?.data.value || 1] : textStyle} 272 ${props.className}`} 273 ref={mountRef} 274 /> 275 {focused && ( 276 <MentionAutocomplete 277 open={mentionOpen} 278 onOpenChange={handleMentionOpenChange} 279 view={viewRef} 280 onSelect={handleMentionSelect} 281 coords={mentionCoords} 282 /> 283 )} 284 {editorState?.doc.textContent.length === 0 && 285 props.previousBlock === null && 286 props.nextBlock === null ? ( 287 // if this is the only block on the page and is empty or is a canvas, show placeholder 288 <div 289 className={`${props.className} ${alignmentClass} w-full pointer-events-none absolute top-0 left-0 italic text-tertiary flex flex-col 290 ${props.type === "heading" ? HeadingStyle[headingLevel?.data.value || 1] : textStyle} 291 `} 292 > 293 {props.type === "text" 294 ? "write something..." 295 : headingLevel?.data.value === 3 296 ? "Subheader" 297 : headingLevel?.data.value === 2 298 ? "Header" 299 : "Title"} 300 <div className=" text-xs font-normal"> 301 or type &quot;/&quot; to add a block 302 </div> 303 </div> 304 ) : editorState?.doc.textContent.length === 0 && focused ? ( 305 // if not the only block on page but is the block is empty and selected, but NOT multiselected show add button 306 <CommandOptions {...props} className={props.className} /> 307 ) : null} 308 309 {editorState?.doc.textContent.startsWith("/") && selected && ( 310 <BlockCommandBar 311 props={props} 312 searchValue={editorState.doc.textContent.slice(1)} 313 /> 314 )} 315 </div> 316 <BlockifyLink entityID={props.entityID} editorState={editorState} /> 317 </> 318 ); 319} 320 321const blueskyclients = ["blacksky.community/", "bsky.app/", "witchsky.app/"]; 322 323const BlockifyLink = (props: { 324 entityID: string; 325 editorState: EditorState | undefined; 326}) => { 327 let [loading, setLoading] = useState(false); 328 let { editorState } = props; 329 let rep = useReplicache(); 330 let smoker = useSmoker(); 331 let focused = useUIState((s) => s.focusedEntity?.entityID === props.entityID); 332 333 let isBlueskyPost = 334 blueskyclients.some((client) => 335 editorState?.doc.textContent.includes(client), 336 ) && editorState?.doc.textContent.includes("post"); 337 // only if the line starts with http or https and doesn't have other content 338 // if its bluesky, change text to embed post 339 340 if ( 341 focused && 342 editorState && 343 betterIsUrl(editorState.doc.textContent) && 344 !editorState.doc.textContent.includes(" ") 345 ) { 346 return ( 347 <button 348 onClick={async (e) => { 349 if (!rep.rep) return; 350 rep.undoManager.startGroup(); 351 if (isBlueskyPost) { 352 let success = await addBlueskyPostBlock( 353 editorState.doc.textContent, 354 props.entityID, 355 rep.rep, 356 ); 357 if (!success) 358 smoker({ 359 error: true, 360 text: "post not found!", 361 position: { 362 x: e.clientX + 12, 363 y: e.clientY, 364 }, 365 }); 366 } else { 367 setLoading(true); 368 await addLinkBlock( 369 editorState.doc.textContent, 370 props.entityID, 371 rep.rep, 372 ); 373 setLoading(false); 374 } 375 rep.undoManager.endGroup(); 376 }} 377 className="absolute right-0 top-0 px-1 py-0.5 text-xs text-tertiary sm:hover:text-accent-contrast border border-border-light sm:hover:border-accent-contrast sm:outline-accent-tertiary rounded-md bg-bg-page selected-outline " 378 > 379 {loading ? <DotLoader /> : "embed"} 380 </button> 381 ); 382 } else return null; 383}; 384 385const CommandOptions = (props: BlockProps & { className?: string }) => { 386 let rep = useReplicache(); 387 let entity_set = useEntitySetContext(); 388 let { data: pub } = useLeafletPublicationData(); 389 390 return ( 391 <div 392 className={`absolute top-0 right-0 w-fit flex gap-[6px] items-center font-bold rounded-md text-sm text-border ${props.pageType === "canvas" && "mr-[6px]"}`} 393 > 394 <TooltipButton 395 className={props.className} 396 onMouseDown={async () => { 397 let command = blockCommands.find((f) => f.name === "Image"); 398 if (!rep.rep) return; 399 await command?.onSelect( 400 rep.rep, 401 { ...props, entity_set: entity_set.set }, 402 rep.undoManager, 403 ); 404 }} 405 side="bottom" 406 tooltipContent={ 407 <div className="flex gap-1 font-bold">Add an Image</div> 408 } 409 > 410 <BlockImageSmall className="hover:text-accent-contrast text-border" /> 411 </TooltipButton> 412 413 {!pub && ( 414 <TooltipButton 415 className={props.className} 416 onMouseDown={async () => { 417 let command = blockCommands.find((f) => f.name === "New Page"); 418 if (!rep.rep) return; 419 await command?.onSelect( 420 rep.rep, 421 { ...props, entity_set: entity_set.set }, 422 rep.undoManager, 423 ); 424 }} 425 side="bottom" 426 tooltipContent={ 427 <div className="flex gap-1 font-bold">Add a Subpage</div> 428 } 429 > 430 <BlockDocPageSmall className="hover:text-accent-contrast text-border" /> 431 </TooltipButton> 432 )} 433 434 <TooltipButton 435 className={props.className} 436 onMouseDown={(e) => { 437 e.preventDefault(); 438 let editor = useEditorStates.getState().editorStates[props.entityID]; 439 440 let editorState = editor?.editor; 441 if (editorState) { 442 editor?.view?.focus(); 443 let tr = editorState.tr.insertText("/", 1); 444 tr.setSelection(TextSelection.create(tr.doc, 2)); 445 useEditorStates.setState((s) => ({ 446 editorStates: { 447 ...s.editorStates, 448 [props.entityID]: { 449 ...s.editorStates[props.entityID]!, 450 editor: editorState!.apply(tr), 451 }, 452 }, 453 })); 454 } 455 focusBlock( 456 { 457 type: props.type, 458 value: props.entityID, 459 parent: props.parent, 460 }, 461 { type: "end" }, 462 ); 463 }} 464 side="bottom" 465 tooltipContent={<div className="flex gap-1 font-bold">Add More!</div>} 466 > 467 <div className="w-6 h-6 flex place-items-center justify-center"> 468 <AddTiny className="text-accent-contrast" /> 469 </div> 470 </TooltipButton> 471 </div> 472 ); 473}; 474 475const useMentionState = (entityID: string) => { 476 let view = useEditorStates((s) => s.editorStates[entityID])?.view; 477 let viewRef = useRef(view || null); 478 viewRef.current = view || null; 479 480 const [mentionOpen, setMentionOpen] = useState(false); 481 const [mentionCoords, setMentionCoords] = useState<{ 482 top: number; 483 left: number; 484 } | null>(null); 485 const [mentionInsertPos, setMentionInsertPos] = useState<number | null>(null); 486 487 // Close autocomplete when this block is no longer focused 488 const isFocused = useUIState((s) => s.focusedEntity?.entityID === entityID); 489 useEffect(() => { 490 if (!isFocused) { 491 setMentionOpen(false); 492 setMentionCoords(null); 493 setMentionInsertPos(null); 494 } 495 }, [isFocused]); 496 497 const openMentionAutocomplete = useCallback(() => { 498 const view = useEditorStates.getState().editorStates[entityID]?.view; 499 if (!view) return; 500 501 // Get the position right after the @ we just inserted 502 const pos = view.state.selection.from; 503 setMentionInsertPos(pos); 504 505 // Get coordinates for the popup relative to the positioned parent 506 const coords = view.coordsAtPos(pos - 1); // Position of the @ 507 508 // Find the relative positioned parent container 509 const editorEl = view.dom; 510 const container = editorEl.closest(".relative") as HTMLElement | null; 511 512 if (container) { 513 const containerRect = container.getBoundingClientRect(); 514 setMentionCoords({ 515 top: coords.bottom - containerRect.top, 516 left: coords.left - containerRect.left, 517 }); 518 } else { 519 setMentionCoords({ 520 top: coords.bottom, 521 left: coords.left, 522 }); 523 } 524 setMentionOpen(true); 525 }, [entityID]); 526 527 const handleMentionSelect = useCallback( 528 (mention: Mention) => { 529 const view = useEditorStates.getState().editorStates[entityID]?.view; 530 if (!view || mentionInsertPos === null) return; 531 532 // The @ is at mentionInsertPos - 1, we need to replace it with the mention 533 const from = mentionInsertPos - 1; 534 const to = mentionInsertPos; 535 536 addMentionToEditor(mention, { from, to }, view); 537 view.focus(); 538 }, 539 [entityID, mentionInsertPos], 540 ); 541 542 const handleMentionOpenChange = useCallback((open: boolean) => { 543 setMentionOpen(open); 544 if (!open) { 545 setMentionCoords(null); 546 setMentionInsertPos(null); 547 } 548 }, []); 549 550 return { 551 viewRef, 552 mentionOpen, 553 mentionCoords, 554 openMentionAutocomplete, 555 handleMentionSelect, 556 handleMentionOpenChange, 557 }; 558};