a tool for shared writing and social publishing

Merge branch 'main' into feature/publish-leaflets

+553 -314
+5 -3
app/(home-pages)/notifications/NotificationList.tsx
··· 23 23 }, 500); 24 24 }, []); 25 25 26 - if (notifications.length === 0) 26 + if (notifications.length !== 0) 27 27 return ( 28 - <div className="w-full container italic text-tertiary text-center sm:p-4 p-3"> 29 - no notifications yet... 28 + <div className="w-full text-sm flex flex-col gap-1 container italic text-tertiary text-center sm:p-4 p-3"> 29 + <div className="text-base font-bold">no notifications yet...</div> 30 + Here, you&apos;ll find notifications about new follows, comments, 31 + mentions, and replies! 30 32 </div> 31 33 ); 32 34 return (
+9 -8
app/[leaflet_id]/publish/publishBskyPost.ts
··· 12 12 import { createOauthClient } from "src/atproto-oauth"; 13 13 import { supabaseServerClient } from "supabase/serverClient"; 14 14 import { Json } from "supabase/database.types"; 15 + import { 16 + getMicroLinkOgImage, 17 + getWebpageImage, 18 + } from "src/utils/getMicroLinkOgImage"; 15 19 16 20 export async function publishPostToBsky(args: { 17 21 text: string; ··· 31 35 credentialSession.fetchHandler.bind(credentialSession), 32 36 ); 33 37 let newPostUrl = args.url; 34 - let preview_image = await fetch( 35 - `https://pro.microlink.io/?url=${newPostUrl}&screenshot=true&viewport.width=1400&viewport.height=733&meta=false&embed=screenshot.url&force=true`, 36 - { 37 - headers: { 38 - "x-api-key": process.env.MICROLINK_API_KEY!, 39 - }, 40 - }, 41 - ); 38 + let preview_image = await getWebpageImage(newPostUrl, { 39 + width: 1400, 40 + height: 733, 41 + noCache: true, 42 + }); 42 43 43 44 let binary = await preview_image.blob(); 44 45 let resized_preview_image = await sharp(await binary.arrayBuffer())
+2 -1
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx
··· 43 43 replyTo?: string; 44 44 onSubmit?: () => void; 45 45 autoFocus?: boolean; 46 + className?: string; 46 47 }) { 47 48 let mountRef = useRef<HTMLPreElement | null>(null); 48 49 let { ··· 216 217 }, []); 217 218 218 219 return ( 219 - <div className=" flex flex-col"> 220 + <div className={`flex flex-col grow ${props.className}`}> 220 221 {quote && ( 221 222 <div className="relative mt-2 mb-2"> 222 223 <QuoteContent position={quote} did="" index={-1} />
+64 -43
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx
··· 162 162 163 163 let [replyBoxOpen, setReplyBoxOpen] = useState(false); 164 164 let [repliesOpen, setRepliesOpen] = useState(true); 165 + 165 166 let replies = props.comments 166 167 .filter( 167 168 (comment) => ··· 176 177 new Date(aRecord.createdAt).getTime() 177 178 ); 178 179 }); 180 + 181 + let repliesOrReplyBoxOpen = 182 + replyBoxOpen || (repliesOpen && replies.length > 0); 179 183 return ( 180 184 <> 181 185 <div className="flex gap-2 items-center"> ··· 203 207 </> 204 208 )} 205 209 </div> 206 - <div className="flex flex-col gap-2"> 207 - {replyBoxOpen && ( 208 - <CommentBox 209 - pageId={props.pageId} 210 - doc_uri={props.document} 211 - replyTo={props.comment_uri} 212 - autoFocus={true} 213 - onSubmit={() => { 214 - setReplyBoxOpen(false); 215 - }} 216 - /> 217 - )} 218 - {repliesOpen && replies.length > 0 && ( 219 - <div className="repliesWrapper flex"> 220 - <button 221 - className="repliesCollapse pr-[14px] ml-[7px] pt-0.5" 222 - onClick={() => { 223 - setReplyBoxOpen(false); 224 - setRepliesOpen(false); 225 - }} 226 - > 227 - <div className="bg-border-light w-[2px] h-full" /> 228 - </button> 229 - <div className="repliesContent flex flex-col gap-3 pt-2 w-full"> 230 - {replies.map((reply) => { 231 - return ( 232 - <Comment 233 - pageId={props.pageId} 234 - document={props.document} 235 - key={reply.uri} 236 - comment={reply} 237 - profile={ 238 - reply.bsky_profiles?.record as AppBskyActorProfile.Record 239 - } 240 - record={reply.record as PubLeafletComment.Record} 241 - comments={props.comments} 242 - /> 243 - ); 244 - })} 210 + {repliesOrReplyBoxOpen && ( 211 + <div className="flex flex-col pt-1"> 212 + {replyBoxOpen && ( 213 + <div className="repliesWrapper flex w-full"> 214 + <button 215 + className="repliesCollapse pr-[14px] ml-[7px]" 216 + onClick={() => { 217 + setReplyBoxOpen(false); 218 + setRepliesOpen(false); 219 + }} 220 + > 221 + <div className="bg-border-light w-[2px] h-full" /> 222 + </button> 223 + <CommentBox 224 + className="pt-3" 225 + pageId={props.pageId} 226 + doc_uri={props.document} 227 + replyTo={props.comment_uri} 228 + autoFocus={true} 229 + onSubmit={() => { 230 + setReplyBoxOpen(false); 231 + }} 232 + /> 233 + </div> 234 + )} 235 + {repliesOpen && replies.length > 0 && ( 236 + <div className="repliesWrapper flex"> 237 + <button 238 + className="repliesCollapse pr-[14px] ml-[7px]" 239 + onClick={() => { 240 + setReplyBoxOpen(false); 241 + setRepliesOpen(false); 242 + }} 243 + > 244 + <div className="bg-border-light w-[2px] h-full" /> 245 + </button> 246 + <div className="repliesContent flex flex-col gap-3 pt-2 w-full"> 247 + {replies.map((reply) => { 248 + return ( 249 + <Comment 250 + pageId={props.pageId} 251 + document={props.document} 252 + key={reply.uri} 253 + comment={reply} 254 + profile={ 255 + reply.bsky_profiles 256 + ?.record as AppBskyActorProfile.Record 257 + } 258 + record={reply.record as PubLeafletComment.Record} 259 + comments={props.comments} 260 + /> 261 + ); 262 + })} 263 + </div> 245 264 </div> 246 - </div> 247 - )} 248 - </div> 265 + )} 266 + </div> 267 + )} 249 268 </> 250 269 ); 251 270 }; ··· 263 282 return ( 264 283 <Popover 265 284 trigger={ 266 - <div className="italic text-sm text-tertiary hover:underline">{timeAgoText}</div> 285 + <div className="italic text-sm text-tertiary hover:underline"> 286 + {timeAgoText} 287 + </div> 267 288 } 268 289 > 269 290 <div className="text-sm text-secondary">{fullDate}</div>
+2 -2
app/lish/[did]/[publication]/[rkey]/PublishBskyPostBlock.tsx
··· 7 7 import { focusBlock } from "src/utils/focusBlock"; 8 8 import { AppBskyFeedDefs, AppBskyFeedPost, RichText } from "@atproto/api"; 9 9 import { Separator } from "components/Layout"; 10 - import { useInitialPageLoad } from "components/InitialPageLoadProvider"; 10 + import { useHasPageLoaded } from "components/InitialPageLoadProvider"; 11 11 import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 12 12 import { CommentTiny } from "components/Icons/CommentTiny"; 13 13 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; ··· 123 123 }; 124 124 125 125 const ClientDate = (props: { date?: string }) => { 126 - let pageLoaded = useInitialPageLoad(); 126 + let pageLoaded = useHasPageLoaded(); 127 127 const formattedDate = useLocalizedDate( 128 128 props.date || new Date().toISOString(), 129 129 {
+7 -2
components/Blocks/BlockCommandBar.tsx
··· 47 47 }; 48 48 49 49 let commandResults = blockCommands.filter((command) => { 50 - const matchesSearch = command.name 50 + const lowerSearchValue = searchValue.toLocaleLowerCase(); 51 + const matchesName = command.name 51 52 .toLocaleLowerCase() 52 - .includes(searchValue.toLocaleLowerCase()); 53 + .includes(lowerSearchValue); 54 + const matchesAlternate = command.alternateNames?.some((altName) => 55 + altName.toLocaleLowerCase().includes(lowerSearchValue) 56 + ) ?? false; 57 + const matchesSearch = matchesName || matchesAlternate; 53 58 const isVisible = !pub || !command.hiddenInPublication; 54 59 return matchesSearch && isVisible; 55 60 });
+4
components/Blocks/BlockCommands.tsx
··· 102 102 name: string; 103 103 icon: React.ReactNode; 104 104 type: string; 105 + alternateNames?: string[]; 105 106 hiddenInPublication?: boolean; 106 107 onSelect: ( 107 108 rep: Replicache<ReplicacheMutators>, ··· 125 126 name: "Title", 126 127 icon: <Header1Small />, 127 128 type: "text", 129 + alternateNames: ["h1"], 128 130 onSelect: async (rep, props, um) => { 129 131 await setHeaderCommand(1, rep, props); 130 132 }, ··· 133 135 name: "Header", 134 136 icon: <Header2Small />, 135 137 type: "text", 138 + alternateNames: ["h2"], 136 139 onSelect: async (rep, props, um) => { 137 140 await setHeaderCommand(2, rep, props); 138 141 }, ··· 141 144 name: "Subheader", 142 145 icon: <Header3Small />, 143 146 type: "text", 147 + alternateNames: ["h3"], 144 148 onSelect: async (rep, props, um) => { 145 149 await setHeaderCommand(3, rep, props); 146 150 },
-1
components/Blocks/BlueskyPostBlock/index.tsx
··· 10 10 import { BlueskyPostEmpty } from "./BlueskyEmpty"; 11 11 import { BlueskyRichText } from "./BlueskyRichText"; 12 12 import { Separator } from "components/Layout"; 13 - import { useInitialPageLoad } from "components/InitialPageLoadProvider"; 14 13 import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 15 14 import { CommentTiny } from "components/Icons/CommentTiny"; 16 15 import { useLocalizedDate } from "src/hooks/useLocalizedDate";
+2 -2
components/Blocks/DateTimeBlock.tsx
··· 8 8 import { setHours, setMinutes } from "date-fns"; 9 9 import { Separator } from "react-aria-components"; 10 10 import { Checkbox } from "components/Checkbox"; 11 - import { useInitialPageLoad } from "components/InitialPageLoadProvider"; 11 + import { useHasPageLoaded } from "components/InitialPageLoadProvider"; 12 12 import { useSpring, animated } from "@react-spring/web"; 13 13 import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; 14 14 import { BlockCalendarSmall } from "components/Icons/BlockCalendarSmall"; 15 15 16 16 export function DateTimeBlock(props: BlockProps) { 17 17 const [isClient, setIsClient] = useState(false); 18 - let initialPageLoad = useInitialPageLoad(); 18 + let initialPageLoad = useHasPageLoaded(); 19 19 20 20 useEffect(() => { 21 21 setIsClient(true);
+20 -198
components/Blocks/TextBlock/index.tsx
··· 1 - import { useRef, useEffect, useState, useLayoutEffect } from "react"; 1 + import { useRef, useEffect, useState } from "react"; 2 2 import { elementId } from "src/utils/elementId"; 3 - import { baseKeymap } from "prosemirror-commands"; 4 - import { keymap } from "prosemirror-keymap"; 5 - import * as Y from "yjs"; 6 - import * as base64 from "base64-js"; 7 - import { useReplicache, useEntity, ReplicacheMutators } from "src/replicache"; 3 + import { useReplicache, useEntity } from "src/replicache"; 8 4 import { isVisible } from "src/utils/isVisible"; 9 - 10 5 import { EditorState, TextSelection } from "prosemirror-state"; 11 - import { EditorView } from "prosemirror-view"; 12 - 13 - import { ySyncPlugin } from "y-prosemirror"; 14 - import { Replicache } from "replicache"; 15 6 import { RenderYJSFragment } from "./RenderYJSFragment"; 16 - import { useInitialPageLoad } from "components/InitialPageLoadProvider"; 7 + import { useHasPageLoaded } from "components/InitialPageLoadProvider"; 17 8 import { BlockProps } from "../Block"; 18 9 import { focusBlock } from "src/utils/focusBlock"; 19 - import { TextBlockKeymap } from "./keymap"; 20 - import { multiBlockSchema, schema } from "./schema"; 21 10 import { useUIState } from "src/useUIState"; 22 11 import { addBlueskyPostBlock, addLinkBlock } from "src/utils/addLinkBlock"; 23 12 import { BlockCommandBar } from "components/Blocks/BlockCommandBar"; 24 13 import { useEditorStates } from "src/state/useEditorState"; 25 14 import { useEntitySetContext } from "components/EntitySetProvider"; 26 - import { useHandlePaste } from "./useHandlePaste"; 27 - import { highlightSelectionPlugin } from "./plugins"; 28 - import { inputrules } from "./inputRules"; 29 - import { autolink } from "./autolink-plugin"; 30 15 import { TooltipButton } from "components/Buttons"; 31 16 import { blockCommands } from "../BlockCommands"; 32 17 import { betterIsUrl } from "src/utils/isURL"; ··· 37 22 import { isIOS } from "src/utils/isDevice"; 38 23 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 39 24 import { DotLoader } from "components/utils/DotLoader"; 25 + import { useMountProsemirror } from "./mountProsemirror"; 40 26 41 27 const HeadingStyle = { 42 28 1: "text-xl font-bold", ··· 51 37 }, 52 38 ) { 53 39 let isLocked = useEntity(props.entityID, "block/is-locked"); 54 - let initialized = useInitialPageLoad(); 40 + let initialized = useHasPageLoaded(); 55 41 let first = props.previousBlock === null; 56 42 let permission = useEntitySetContext().permissions.write; 57 43 ··· 177 163 } 178 164 179 165 export function BaseTextBlock(props: BlockProps & { className?: string }) { 180 - let mountRef = useRef<HTMLPreElement | null>(null); 181 - let actionTimeout = useRef<number | null>(null); 182 - let repRef = useRef<null | Replicache<ReplicacheMutators>>(null); 183 166 let headingLevel = useEntity(props.entityID, "block/heading-level"); 184 - let entity_set = useEntitySetContext(); 185 167 let alignment = 186 168 useEntity(props.entityID, "block/text-alignment")?.data.value || "left"; 187 - let propsRef = useRef({ ...props, entity_set, alignment }); 188 - useEffect(() => { 189 - propsRef.current = { ...props, entity_set, alignment }; 190 - }, [props, entity_set, alignment]); 169 + 191 170 let rep = useReplicache(); 192 - useEffect(() => { 193 - repRef.current = rep.rep; 194 - }, [rep?.rep]); 195 171 196 172 let selected = useUIState( 197 173 (s) => !!s.selectedBlocks.find((b) => b.value === props.entityID), ··· 204 180 justify: "text-justify", 205 181 }[alignment]; 206 182 207 - let value = useYJSValue(props.entityID); 208 - 209 183 let editorState = useEditorStates( 210 184 (s) => s.editorStates[props.entityID], 211 185 )?.editor; 212 - let handlePaste = useHandlePaste(props.entityID, propsRef); 213 - useLayoutEffect(() => { 214 - if (!mountRef.current) return; 215 - let km = TextBlockKeymap(propsRef, repRef, rep.undoManager); 216 - let editor = EditorState.create({ 217 - schema: schema, 218 - plugins: [ 219 - ySyncPlugin(value), 220 - keymap(km), 221 - inputrules(propsRef, repRef), 222 - keymap(baseKeymap), 223 - highlightSelectionPlugin, 224 - autolink({ 225 - type: schema.marks.link, 226 - shouldAutoLink: () => true, 227 - defaultProtocol: "https", 228 - }), 229 - ], 230 - }); 231 186 232 - let unsubscribe = useEditorStates.subscribe((s) => { 233 - let editorState = s.editorStates[props.entityID]; 234 - if (editorState?.initial) return; 235 - if (editorState?.editor) 236 - editorState.view?.updateState(editorState.editor); 237 - }); 238 - let view = new EditorView( 239 - { mount: mountRef.current }, 240 - { 241 - state: editor, 242 - handlePaste, 243 - handleClickOn: (view, _pos, node, _nodePos, _event, direct) => { 244 - if (!direct) return; 245 - if (node.nodeSize - 2 <= _pos) return; 246 - let mark = 247 - node 248 - .nodeAt(_pos - 1) 249 - ?.marks.find((f) => f.type === schema.marks.link) || 250 - node 251 - .nodeAt(Math.max(_pos - 2, 0)) 252 - ?.marks.find((f) => f.type === schema.marks.link); 253 - if (mark) { 254 - window.open(mark.attrs.href, "_blank"); 255 - } 256 - }, 257 - dispatchTransaction(tr) { 258 - useEditorStates.setState((s) => { 259 - let oldEditorState = this.state; 260 - let newState = this.state.apply(tr); 261 - let addToHistory = tr.getMeta("addToHistory"); 262 - let isBulkOp = tr.getMeta("bulkOp"); 263 - let docHasChanges = tr.steps.length !== 0 || tr.docChanged; 264 - if (addToHistory !== false && docHasChanges) { 265 - if (actionTimeout.current) { 266 - window.clearTimeout(actionTimeout.current); 267 - } else { 268 - if (!isBulkOp) rep.undoManager.startGroup(); 269 - } 270 - 271 - if (!isBulkOp) 272 - actionTimeout.current = window.setTimeout(() => { 273 - rep.undoManager.endGroup(); 274 - actionTimeout.current = null; 275 - }, 200); 276 - rep.undoManager.add({ 277 - redo: () => { 278 - useEditorStates.setState((oldState) => { 279 - let view = oldState.editorStates[props.entityID]?.view; 280 - if (!view?.hasFocus() && !isBulkOp) view?.focus(); 281 - return { 282 - editorStates: { 283 - ...oldState.editorStates, 284 - [props.entityID]: { 285 - ...oldState.editorStates[props.entityID]!, 286 - editor: newState, 287 - }, 288 - }, 289 - }; 290 - }); 291 - }, 292 - undo: () => { 293 - useEditorStates.setState((oldState) => { 294 - let view = oldState.editorStates[props.entityID]?.view; 295 - if (!view?.hasFocus() && !isBulkOp) view?.focus(); 296 - return { 297 - editorStates: { 298 - ...oldState.editorStates, 299 - [props.entityID]: { 300 - ...oldState.editorStates[props.entityID]!, 301 - editor: oldEditorState, 302 - }, 303 - }, 304 - }; 305 - }); 306 - }, 307 - }); 308 - } 309 - 310 - return { 311 - editorStates: { 312 - ...s.editorStates, 313 - [props.entityID]: { 314 - editor: newState, 315 - view: this as unknown as EditorView, 316 - initial: false, 317 - keymap: km, 318 - }, 319 - }, 320 - }; 321 - }); 322 - }, 323 - }, 324 - ); 325 - return () => { 326 - unsubscribe(); 327 - view.destroy(); 328 - useEditorStates.setState((s) => ({ 329 - ...s, 330 - editorStates: { 331 - ...s.editorStates, 332 - [props.entityID]: undefined, 333 - }, 334 - })); 335 - }; 336 - }, [props.entityID, props.parent, value, handlePaste, rep]); 187 + let { mountRef, actionTimeout } = useMountProsemirror({ 188 + props, 189 + }); 337 190 338 191 return ( 339 192 <> ··· 586 439 ); 587 440 }; 588 441 589 - function useYJSValue(entityID: string) { 590 - const [ydoc] = useState(new Y.Doc()); 591 - const docStateFromReplicache = useEntity(entityID, "block/text"); 592 - let rep = useReplicache(); 593 - const [yText] = useState(ydoc.getXmlFragment("prosemirror")); 594 - 595 - if (docStateFromReplicache) { 596 - const update = base64.toByteArray(docStateFromReplicache.data.value); 597 - Y.applyUpdate(ydoc, update); 598 - } 599 - 600 - useEffect(() => { 601 - if (!rep.rep) return; 602 - let timeout = null as null | number; 603 - const updateReplicache = async () => { 604 - const update = Y.encodeStateAsUpdate(ydoc); 605 - await rep.rep?.mutate.assertFact({ 606 - //These undos are handled above in the Prosemirror context 607 - ignoreUndo: true, 608 - entity: entityID, 609 - attribute: "block/text", 610 - data: { 611 - value: base64.fromByteArray(update), 612 - type: "text", 613 - }, 614 - }); 615 - }; 616 - const f = async (events: Y.YEvent<any>[], transaction: Y.Transaction) => { 617 - if (!transaction.origin) return; 618 - if (timeout) clearTimeout(timeout); 619 - timeout = window.setTimeout(async () => { 620 - updateReplicache(); 621 - }, 300); 622 - }; 623 - 624 - yText.observeDeep(f); 625 - return () => { 626 - yText.unobserveDeep(f); 627 - }; 628 - }, [yText, entityID, rep, ydoc]); 629 - return yText; 630 - } 442 + const useMentionState = () => { 443 + const [editorState, setEditorState] = useState<EditorState | null>(null); 444 + const [mentionState, setMentionState] = useState<{ 445 + active: boolean; 446 + range: { from: number; to: number } | null; 447 + selectedMention: { handle: string; did: string } | null; 448 + }>({ active: false, range: null, selectedMention: null }); 449 + const mentionStateRef = useRef(mentionState); 450 + mentionStateRef.current = mentionState; 451 + return { mentionStateRef }; 452 + };
+203
components/Blocks/TextBlock/mountProsemirror.ts
··· 1 + import { useLayoutEffect, useRef, useEffect, useState } from "react"; 2 + import { EditorState } from "prosemirror-state"; 3 + import { EditorView } from "prosemirror-view"; 4 + import { baseKeymap } from "prosemirror-commands"; 5 + import { keymap } from "prosemirror-keymap"; 6 + import { ySyncPlugin } from "y-prosemirror"; 7 + import * as Y from "yjs"; 8 + import * as base64 from "base64-js"; 9 + import { Replicache } from "replicache"; 10 + import { produce } from "immer"; 11 + 12 + import { schema } from "./schema"; 13 + import { TextBlockKeymap } from "./keymap"; 14 + import { inputrules } from "./inputRules"; 15 + import { highlightSelectionPlugin } from "./plugins"; 16 + import { autolink } from "./autolink-plugin"; 17 + import { useEditorStates } from "src/state/useEditorState"; 18 + import { 19 + useEntity, 20 + useReplicache, 21 + type ReplicacheMutators, 22 + } from "src/replicache"; 23 + import { useHandlePaste } from "./useHandlePaste"; 24 + import { BlockProps } from "../Block"; 25 + import { useEntitySetContext } from "components/EntitySetProvider"; 26 + 27 + export function useMountProsemirror({ props }: { props: BlockProps }) { 28 + let { entityID, parent } = props; 29 + let rep = useReplicache(); 30 + let mountRef = useRef<HTMLPreElement | null>(null); 31 + const repRef = useRef<Replicache<ReplicacheMutators> | null>(null); 32 + let value = useYJSValue(entityID); 33 + let entity_set = useEntitySetContext(); 34 + let alignment = 35 + useEntity(entityID, "block/text-alignment")?.data.value || "left"; 36 + let propsRef = useRef({ ...props, entity_set, alignment }); 37 + let handlePaste = useHandlePaste(entityID, propsRef); 38 + 39 + const actionTimeout = useRef<number | null>(null); 40 + 41 + propsRef.current = { ...props, entity_set, alignment }; 42 + repRef.current = rep.rep; 43 + 44 + useLayoutEffect(() => { 45 + if (!mountRef.current) return; 46 + 47 + const km = TextBlockKeymap(propsRef, repRef, rep.undoManager); 48 + const editor = EditorState.create({ 49 + schema: schema, 50 + plugins: [ 51 + ySyncPlugin(value), 52 + keymap(km), 53 + inputrules(propsRef, repRef), 54 + keymap(baseKeymap), 55 + highlightSelectionPlugin, 56 + autolink({ 57 + type: schema.marks.link, 58 + shouldAutoLink: () => true, 59 + defaultProtocol: "https", 60 + }), 61 + ], 62 + }); 63 + 64 + const view = new EditorView( 65 + { mount: mountRef.current }, 66 + { 67 + state: editor, 68 + handlePaste, 69 + handleClickOn: (_view, _pos, node, _nodePos, _event, direct) => { 70 + if (!direct) return; 71 + if (node.nodeSize - 2 <= _pos) return; 72 + let mark = 73 + node 74 + .nodeAt(_pos - 1) 75 + ?.marks.find((f) => f.type === schema.marks.link) || 76 + node 77 + .nodeAt(Math.max(_pos - 2, 0)) 78 + ?.marks.find((f) => f.type === schema.marks.link); 79 + if (mark) { 80 + window.open(mark.attrs.href, "_blank"); 81 + } 82 + }, 83 + dispatchTransaction, 84 + }, 85 + ); 86 + 87 + const unsubscribe = useEditorStates.subscribe((s) => { 88 + let editorState = s.editorStates[entityID]; 89 + if (editorState?.initial) return; 90 + if (editorState?.editor) 91 + editorState.view?.updateState(editorState.editor); 92 + }); 93 + 94 + let editorState = useEditorStates.getState().editorStates[entityID]; 95 + if (editorState?.editor && !editorState.initial) 96 + editorState.view?.updateState(editorState.editor); 97 + 98 + return () => { 99 + unsubscribe(); 100 + view.destroy(); 101 + useEditorStates.setState((s) => ({ 102 + ...s, 103 + editorStates: { 104 + ...s.editorStates, 105 + [entityID]: undefined, 106 + }, 107 + })); 108 + }; 109 + 110 + function dispatchTransaction(this: EditorView, tr: any) { 111 + useEditorStates.setState((s) => { 112 + let oldEditorState = this.state; 113 + let newState = this.state.apply(tr); 114 + let addToHistory = tr.getMeta("addToHistory"); 115 + let isBulkOp = tr.getMeta("bulkOp"); 116 + let docHasChanges = tr.steps.length !== 0 || tr.docChanged; 117 + 118 + // Handle undo/redo history with timeout-based grouping 119 + if (addToHistory !== false && docHasChanges) { 120 + if (actionTimeout.current) window.clearTimeout(actionTimeout.current); 121 + else if (!isBulkOp) rep.undoManager.startGroup(); 122 + 123 + if (!isBulkOp) { 124 + actionTimeout.current = window.setTimeout(() => { 125 + rep.undoManager.endGroup(); 126 + actionTimeout.current = null; 127 + }, 200); 128 + } 129 + 130 + let setState = (s: EditorState) => () => 131 + useEditorStates.setState( 132 + produce((draft) => { 133 + let view = draft.editorStates[entityID]?.view; 134 + if (!view?.hasFocus() && !isBulkOp) view?.focus(); 135 + draft.editorStates[entityID]!.editor = s; 136 + }), 137 + ); 138 + 139 + rep.undoManager.add({ 140 + redo: setState(newState), 141 + undo: setState(oldEditorState), 142 + }); 143 + } 144 + 145 + return { 146 + editorStates: { 147 + ...s.editorStates, 148 + [entityID]: { 149 + editor: newState, 150 + view: this as unknown as EditorView, 151 + initial: false, 152 + keymap: km, 153 + }, 154 + }, 155 + }; 156 + }); 157 + } 158 + }, [entityID, parent, value, handlePaste, rep]); 159 + return { mountRef, actionTimeout }; 160 + } 161 + 162 + function useYJSValue(entityID: string) { 163 + const [ydoc] = useState(new Y.Doc()); 164 + const docStateFromReplicache = useEntity(entityID, "block/text"); 165 + let rep = useReplicache(); 166 + const [yText] = useState(ydoc.getXmlFragment("prosemirror")); 167 + 168 + if (docStateFromReplicache) { 169 + const update = base64.toByteArray(docStateFromReplicache.data.value); 170 + Y.applyUpdate(ydoc, update); 171 + } 172 + 173 + useEffect(() => { 174 + if (!rep.rep) return; 175 + let timeout = null as null | number; 176 + const updateReplicache = async () => { 177 + const update = Y.encodeStateAsUpdate(ydoc); 178 + await rep.rep?.mutate.assertFact({ 179 + //These undos are handled above in the Prosemirror context 180 + ignoreUndo: true, 181 + entity: entityID, 182 + attribute: "block/text", 183 + data: { 184 + value: base64.fromByteArray(update), 185 + type: "text", 186 + }, 187 + }); 188 + }; 189 + const f = async (events: Y.YEvent<any>[], transaction: Y.Transaction) => { 190 + if (!transaction.origin) return; 191 + if (timeout) clearTimeout(timeout); 192 + timeout = window.setTimeout(async () => { 193 + updateReplicache(); 194 + }, 300); 195 + }; 196 + 197 + yText.observeDeep(f); 198 + return () => { 199 + yText.unobserveDeep(f); 200 + }; 201 + }, [yText, entityID, rep, ydoc]); 202 + return yText; 203 + }
+2 -2
components/InitialPageLoadProvider.tsx
··· 2 2 import { useEffect } from "react"; 3 3 import { create } from "zustand"; 4 4 5 - export const useInitialPageLoad = create(() => false); 5 + export const useHasPageLoaded = create(() => false); 6 6 export function InitialPageLoad(props: { children: React.ReactNode }) { 7 7 useEffect(() => { 8 8 setTimeout(() => { 9 - useInitialPageLoad.setState(() => true); 9 + useHasPageLoaded.setState(() => true); 10 10 }, 80); 11 11 }, []); 12 12 return <>{props.children}</>;
+5 -1
components/Providers/RequestHeadersProvider.tsx
··· 21 21 }) => { 22 22 return ( 23 23 <RequestHeadersContext.Provider 24 - value={{ country: props.country, language: props.language, timezone: props.timezone }} 24 + value={{ 25 + country: props.country, 26 + language: props.language, 27 + timezone: props.timezone, 28 + }} 25 29 > 26 30 {props.children} 27 31 </RequestHeadersContext.Provider>
+16 -8
drizzle/relations.ts
··· 1 1 import { relations } from "drizzle-orm/relations"; 2 - import { identities, publications, documents, comments_on_documents, bsky_profiles, entity_sets, entities, facts, email_auth_tokens, poll_votes_on_entity, permission_tokens, phone_rsvps_to_entity, custom_domains, custom_domain_routes, email_subscriptions_to_entity, atp_poll_records, atp_poll_votes, bsky_follows, subscribers_to_publications, permission_token_on_homepage, documents_in_publications, document_mentions_in_bsky, bsky_posts, publication_domains, leaflets_in_publications, publication_subscriptions, permission_token_rights } from "./schema"; 2 + import { identities, notifications, publications, documents, comments_on_documents, bsky_profiles, entity_sets, entities, facts, email_auth_tokens, poll_votes_on_entity, permission_tokens, phone_rsvps_to_entity, custom_domains, custom_domain_routes, email_subscriptions_to_entity, atp_poll_records, atp_poll_votes, bsky_follows, subscribers_to_publications, permission_token_on_homepage, documents_in_publications, document_mentions_in_bsky, bsky_posts, publication_domains, leaflets_in_publications, publication_subscriptions, permission_token_rights } from "./schema"; 3 3 4 - export const publicationsRelations = relations(publications, ({one, many}) => ({ 4 + export const notificationsRelations = relations(notifications, ({one}) => ({ 5 5 identity: one(identities, { 6 - fields: [publications.identity_did], 6 + fields: [notifications.recipient], 7 7 references: [identities.atp_did] 8 8 }), 9 - subscribers_to_publications: many(subscribers_to_publications), 10 - documents_in_publications: many(documents_in_publications), 11 - publication_domains: many(publication_domains), 12 - leaflets_in_publications: many(leaflets_in_publications), 13 - publication_subscriptions: many(publication_subscriptions), 14 9 })); 15 10 16 11 export const identitiesRelations = relations(identities, ({one, many}) => ({ 12 + notifications: many(notifications), 17 13 publications: many(publications), 18 14 email_auth_tokens: many(email_auth_tokens), 19 15 bsky_profiles: many(bsky_profiles), ··· 36 32 subscribers_to_publications: many(subscribers_to_publications), 37 33 permission_token_on_homepages: many(permission_token_on_homepage), 38 34 publication_domains: many(publication_domains), 35 + publication_subscriptions: many(publication_subscriptions), 36 + })); 37 + 38 + export const publicationsRelations = relations(publications, ({one, many}) => ({ 39 + identity: one(identities, { 40 + fields: [publications.identity_did], 41 + references: [identities.atp_did] 42 + }), 43 + subscribers_to_publications: many(subscribers_to_publications), 44 + documents_in_publications: many(documents_in_publications), 45 + publication_domains: many(publication_domains), 46 + leaflets_in_publications: many(leaflets_in_publications), 39 47 publication_subscriptions: many(publication_subscriptions), 40 48 })); 41 49
+10 -2
drizzle/schema.ts
··· 1 - import { pgTable, pgEnum, text, jsonb, index, foreignKey, timestamp, uuid, bigint, boolean, unique, uniqueIndex, smallint, primaryKey } from "drizzle-orm/pg-core" 1 + import { pgTable, pgEnum, text, jsonb, foreignKey, timestamp, boolean, uuid, index, bigint, unique, uniqueIndex, smallint, primaryKey } from "drizzle-orm/pg-core" 2 2 import { sql } from "drizzle-orm" 3 3 4 4 export const aal_level = pgEnum("aal_level", ['aal1', 'aal2', 'aal3']) ··· 15 15 export const rsvp_status = pgEnum("rsvp_status", ['GOING', 'NOT_GOING', 'MAYBE']) 16 16 export const action = pgEnum("action", ['INSERT', 'UPDATE', 'DELETE', 'TRUNCATE', 'ERROR']) 17 17 export const equality_op = pgEnum("equality_op", ['eq', 'neq', 'lt', 'lte', 'gt', 'gte', 'in']) 18 - export const buckettype = pgEnum("buckettype", ['STANDARD', 'ANALYTICS']) 18 + export const buckettype = pgEnum("buckettype", ['STANDARD', 'ANALYTICS', 'VECTOR']) 19 19 20 20 21 21 export const oauth_state_store = pgTable("oauth_state_store", { 22 22 key: text("key").primaryKey().notNull(), 23 23 state: jsonb("state").notNull(), 24 + }); 25 + 26 + export const notifications = pgTable("notifications", { 27 + recipient: text("recipient").notNull().references(() => identities.atp_did, { onDelete: "cascade", onUpdate: "cascade" } ), 28 + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 29 + read: boolean("read").default(false).notNull(), 30 + data: jsonb("data").notNull(), 31 + id: uuid("id").primaryKey().notNull(), 24 32 }); 25 33 26 34 export const publications = pgTable("publications", {
+1
next.config.js
··· 21 21 }, 22 22 ]; 23 23 }, 24 + serverExternalPackages: ["yjs"], 24 25 pageExtensions: ["js", "jsx", "ts", "tsx", "md", "mdx"], 25 26 images: { 26 27 loader: "custom",
+5 -5
package-lock.json
··· 44 44 "feed": "^5.1.0", 45 45 "fractional-indexing": "^3.2.0", 46 46 "hono": "^4.7.11", 47 + "immer": "^10.2.0", 47 48 "inngest": "^3.40.1", 48 49 "ioredis": "^5.6.1", 49 50 "katex": "^0.16.22", ··· 10877 10878 } 10878 10879 }, 10879 10880 "node_modules/immer": { 10880 - "version": "10.1.1", 10881 - "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", 10882 - "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==", 10883 - "optional": true, 10884 - "peer": true, 10881 + "version": "10.2.0", 10882 + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", 10883 + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", 10884 + "license": "MIT", 10885 10885 "funding": { 10886 10886 "type": "opencollective", 10887 10887 "url": "https://opencollective.com/immer"
+2 -1
package.json
··· 4 4 "description": "", 5 5 "main": "index.js", 6 6 "scripts": { 7 - "dev": "next dev --turbo", 7 + "dev": "TZ=UTC next dev --turbo", 8 8 "publish-lexicons": "tsx lexicons/publish.ts", 9 9 "generate-db-types": "supabase gen types --local > supabase/database.types.ts && drizzle-kit introspect && rm -rf ./drizzle/*.sql ./drizzle/meta", 10 10 "lexgen": "tsx ./lexicons/build.ts && lex gen-api ./lexicons/api ./lexicons/pub/leaflet/* ./lexicons/pub/leaflet/*/* ./lexicons/com/atproto/*/* ./lexicons/app/bsky/*/* --yes && find './lexicons/api' -type f -exec sed -i 's/\\.js'/'/g' {} \\;", ··· 54 54 "feed": "^5.1.0", 55 55 "fractional-indexing": "^3.2.0", 56 56 "hono": "^4.7.11", 57 + "immer": "^10.2.0", 57 58 "inngest": "^3.40.1", 58 59 "ioredis": "^5.6.1", 59 60 "katex": "^0.16.22",
+5 -5
src/hooks/useLocalizedDate.ts
··· 2 2 import { useContext, useMemo } from "react"; 3 3 import { DateTime } from "luxon"; 4 4 import { RequestHeadersContext } from "components/Providers/RequestHeadersProvider"; 5 - import { useInitialPageLoad } from "components/InitialPageLoadProvider"; 5 + import { useHasPageLoaded } from "components/InitialPageLoadProvider"; 6 6 7 7 /** 8 8 * Hook that formats a date string using Luxon with timezone and locale from request headers. ··· 20 20 options?: Intl.DateTimeFormatOptions, 21 21 ): string { 22 22 const { timezone, language } = useContext(RequestHeadersContext); 23 - const isInitialPageLoad = useInitialPageLoad(); 23 + const hasPageLoaded = useHasPageLoaded(); 24 24 25 25 return useMemo(() => { 26 26 // Parse the date string to Luxon DateTime 27 27 let dateTime = DateTime.fromISO(dateString); 28 28 29 29 // On initial page load, use header timezone. After hydration, use system timezone 30 - const effectiveTimezone = isInitialPageLoad 30 + const effectiveTimezone = !hasPageLoaded 31 31 ? timezone 32 32 : Intl.DateTimeFormat().resolvedOptions().timeZone; 33 33 ··· 39 39 // On initial page load, use header locale. After hydration, use system locale 40 40 // Parse locale from accept-language header (take first locale) 41 41 // accept-language format: "en-US,en;q=0.9,es;q=0.8" 42 - const effectiveLocale = isInitialPageLoad 42 + const effectiveLocale = !hasPageLoaded 43 43 ? language?.split(",")[0]?.split(";")[0]?.trim() || "en-US" 44 44 : Intl.DateTimeFormat().resolvedOptions().locale; 45 45 ··· 49 49 // Fallback to en-US if locale is invalid 50 50 return dateTime.toLocaleString(options, { locale: "en-US" }); 51 51 } 52 - }, [dateString, options, timezone, language, isInitialPageLoad]); 52 + }, [dateString, options, timezone, language, hasPageLoaded]); 53 53 }
+50 -19
src/utils/getMicroLinkOgImage.ts
··· 2 2 3 3 export async function getMicroLinkOgImage( 4 4 path: string, 5 - options?: { width?: number; height?: number; deviceScaleFactor?: number }, 5 + options?: { 6 + width?: number; 7 + height?: number; 8 + deviceScaleFactor?: number; 9 + noCache?: boolean; 10 + }, 6 11 ) { 7 12 const headersList = await headers(); 8 - const hostname = headersList.get("x-forwarded-host"); 13 + let hostname = headersList.get("x-forwarded-host"); 9 14 let protocol = headersList.get("x-forwarded-proto"); 15 + if (process.env.NODE_ENV === "development") { 16 + protocol === "https"; 17 + hostname = "leaflet.pub"; 18 + } 10 19 let full_path = `${protocol}://${hostname}${path}`; 20 + return getWebpageImage(full_path, options); 21 + } 22 + 23 + export async function getWebpageImage( 24 + url: string, 25 + options?: { 26 + width?: number; 27 + height?: number; 28 + deviceScaleFactor?: number; 29 + noCache?: boolean; 30 + }, 31 + ) { 11 32 let response = await fetch( 12 - `https://pro.microlink.io/?url=${encodeURIComponent(full_path)}&screenshot=true&viewport.width=${options?.width || 1400}&viewport.height=${options?.height || 733}&viewport.deviceScaleFactor=${options?.deviceScaleFactor || 1}&meta=false&embed=screenshot.url&force=true`, 33 + `https://api.cloudflare.com/client/v4/accounts/${process.env.CLOUDFLARE_ACCOUNT}/browser-rendering/screenshot`, 13 34 { 35 + method: "POST", 14 36 headers: { 15 - "x-api-key": process.env.MICROLINK_API_KEY!, 37 + "Content-type": "application/json", 38 + Authorization: `Bearer ${process.env.CLOUDFLARE_API_TOKEN}`, 16 39 }, 17 - next: { 18 - revalidate: 600, 19 - }, 40 + body: JSON.stringify({ 41 + url, 42 + scrollPage: true, 43 + addStyleTag: [ 44 + { 45 + content: `* {scrollbar-width:none; }`, 46 + }, 47 + ], 48 + gotoOptions: { 49 + waitUntil: "load", 50 + }, 51 + viewport: { 52 + width: options?.width || 1400, 53 + height: options?.height || 733, 54 + deviceScaleFactor: options?.deviceScaleFactor, 55 + }, 56 + }), 57 + next: !options?.noCache 58 + ? undefined 59 + : { 60 + revalidate: 600, 61 + }, 20 62 }, 21 63 ); 22 - const clonedResponse = response.clone(); 23 - if (clonedResponse.status == 200) { 24 - clonedResponse.headers.set( 25 - "CDN-Cache-Control", 26 - "s-maxage=600, stale-while-revalidate=3600", 27 - ); 28 - clonedResponse.headers.set( 29 - "Cache-Control", 30 - "s-maxage=600, stale-while-revalidate=3600", 31 - ); 32 - } 33 64 34 - return clonedResponse; 65 + return response; 35 66 }
+39
supabase/database.types.ts
··· 624 624 }, 625 625 ] 626 626 } 627 + leaflets_to_documents: { 628 + Row: { 629 + created_at: string 630 + description: string 631 + document: string 632 + leaflet: string 633 + title: string 634 + } 635 + Insert: { 636 + created_at?: string 637 + description?: string 638 + document: string 639 + leaflet: string 640 + title?: string 641 + } 642 + Update: { 643 + created_at?: string 644 + description?: string 645 + document?: string 646 + leaflet?: string 647 + title?: string 648 + } 649 + Relationships: [ 650 + { 651 + foreignKeyName: "leaflets_to_documents_document_fkey" 652 + columns: ["document"] 653 + isOneToOne: false 654 + referencedRelation: "documents" 655 + referencedColumns: ["uri"] 656 + }, 657 + { 658 + foreignKeyName: "leaflets_to_documents_leaflet_fkey" 659 + columns: ["leaflet"] 660 + isOneToOne: false 661 + referencedRelation: "permission_tokens" 662 + referencedColumns: ["id"] 663 + }, 664 + ] 665 + } 627 666 notifications: { 628 667 Row: { 629 668 created_at: string
+63
supabase/migrations/20251118185507_add_leaflets_to_documents.sql
··· 1 + create table "public"."leaflets_to_documents" ( 2 + "leaflet" uuid not null, 3 + "document" text not null, 4 + "created_at" timestamp with time zone not null default now(), 5 + "title" text not null default ''::text, 6 + "description" text not null default ''::text 7 + ); 8 + 9 + alter table "public"."leaflets_to_documents" enable row level security; 10 + 11 + CREATE UNIQUE INDEX leaflets_to_documents_pkey ON public.leaflets_to_documents USING btree (leaflet, document); 12 + 13 + alter table "public"."leaflets_to_documents" add constraint "leaflets_to_documents_pkey" PRIMARY KEY using index "leaflets_to_documents_pkey"; 14 + 15 + alter table "public"."leaflets_to_documents" add constraint "leaflets_to_documents_document_fkey" FOREIGN KEY (document) REFERENCES documents(uri) ON UPDATE CASCADE ON DELETE CASCADE not valid; 16 + 17 + alter table "public"."leaflets_to_documents" validate constraint "leaflets_to_documents_document_fkey"; 18 + 19 + alter table "public"."leaflets_to_documents" add constraint "leaflets_to_documents_leaflet_fkey" FOREIGN KEY (leaflet) REFERENCES permission_tokens(id) ON UPDATE CASCADE ON DELETE CASCADE not valid; 20 + 21 + alter table "public"."leaflets_to_documents" validate constraint "leaflets_to_documents_leaflet_fkey"; 22 + 23 + grant delete on table "public"."leaflets_to_documents" to "anon"; 24 + 25 + grant insert on table "public"."leaflets_to_documents" to "anon"; 26 + 27 + grant references on table "public"."leaflets_to_documents" to "anon"; 28 + 29 + grant select on table "public"."leaflets_to_documents" to "anon"; 30 + 31 + grant trigger on table "public"."leaflets_to_documents" to "anon"; 32 + 33 + grant truncate on table "public"."leaflets_to_documents" to "anon"; 34 + 35 + grant update on table "public"."leaflets_to_documents" to "anon"; 36 + 37 + grant delete on table "public"."leaflets_to_documents" to "authenticated"; 38 + 39 + grant insert on table "public"."leaflets_to_documents" to "authenticated"; 40 + 41 + grant references on table "public"."leaflets_to_documents" to "authenticated"; 42 + 43 + grant select on table "public"."leaflets_to_documents" to "authenticated"; 44 + 45 + grant trigger on table "public"."leaflets_to_documents" to "authenticated"; 46 + 47 + grant truncate on table "public"."leaflets_to_documents" to "authenticated"; 48 + 49 + grant update on table "public"."leaflets_to_documents" to "authenticated"; 50 + 51 + grant delete on table "public"."leaflets_to_documents" to "service_role"; 52 + 53 + grant insert on table "public"."leaflets_to_documents" to "service_role"; 54 + 55 + grant references on table "public"."leaflets_to_documents" to "service_role"; 56 + 57 + grant select on table "public"."leaflets_to_documents" to "service_role"; 58 + 59 + grant trigger on table "public"."leaflets_to_documents" to "service_role"; 60 + 61 + grant truncate on table "public"."leaflets_to_documents" to "service_role"; 62 + 63 + grant update on table "public"."leaflets_to_documents" to "service_role";