a tool for shared writing and social publishing
298
fork

Configure Feed

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

Merge branch 'main' into feature/page-blocks

+2771 -667
+1 -1
actions/getIdentityData.ts
··· 16 16 identities( 17 17 *, 18 18 bsky_profiles(*), 19 - subscribers_to_publications(*), 19 + publication_subscriptions(*), 20 20 custom_domains!custom_domains_identity_id_fkey(publication_domains(*), *), 21 21 home_leaflet:permission_tokens!identities_home_page_fkey(*, permission_token_rights(*)), 22 22 permission_token_on_homepage(
+595
app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx
··· 1 + "use client"; 2 + import { Agent, AppBskyRichtextFacet, UnicodeString } from "@atproto/api"; 3 + import { 4 + useState, 5 + useCallback, 6 + useRef, 7 + useLayoutEffect, 8 + useEffect, 9 + } from "react"; 10 + import { createPortal } from "react-dom"; 11 + import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; 12 + import * as Popover from "@radix-ui/react-popover"; 13 + import { EditorState, TextSelection, Plugin } from "prosemirror-state"; 14 + import { EditorView } from "prosemirror-view"; 15 + import { Schema, MarkSpec, Mark } from "prosemirror-model"; 16 + import { baseKeymap } from "prosemirror-commands"; 17 + import { keymap } from "prosemirror-keymap"; 18 + import { history, undo, redo } from "prosemirror-history"; 19 + import { inputRules, InputRule } from "prosemirror-inputrules"; 20 + import { autolink } from "components/Blocks/TextBlock/autolink-plugin"; 21 + import { IOSBS } from "app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox"; 22 + 23 + // Schema with only links, mentions, and hashtags marks 24 + const bskyPostSchema = new Schema({ 25 + nodes: { 26 + doc: { content: "block+" }, 27 + paragraph: { 28 + content: "inline*", 29 + group: "block", 30 + parseDOM: [{ tag: "p" }], 31 + toDOM: () => ["p", 0] as const, 32 + }, 33 + text: { 34 + group: "inline", 35 + }, 36 + }, 37 + marks: { 38 + link: { 39 + attrs: { 40 + href: {}, 41 + }, 42 + inclusive: false, 43 + parseDOM: [ 44 + { 45 + tag: "a[href]", 46 + getAttrs(dom: HTMLElement) { 47 + return { 48 + href: dom.getAttribute("href"), 49 + }; 50 + }, 51 + }, 52 + ], 53 + toDOM(node) { 54 + let { href } = node.attrs; 55 + return ["a", { href, target: "_blank", class: "text-accent" }, 0]; 56 + }, 57 + } as MarkSpec, 58 + mention: { 59 + attrs: { 60 + did: {}, 61 + }, 62 + inclusive: false, 63 + parseDOM: [ 64 + { 65 + tag: "span.mention", 66 + getAttrs(dom: HTMLElement) { 67 + return { 68 + did: dom.getAttribute("data-did"), 69 + }; 70 + }, 71 + }, 72 + ], 73 + toDOM(node) { 74 + let { did } = node.attrs; 75 + return [ 76 + "span", 77 + { 78 + class: "mention text-accent-contrast", 79 + "data-did": did, 80 + }, 81 + 0, 82 + ]; 83 + }, 84 + } as MarkSpec, 85 + hashtag: { 86 + attrs: { 87 + tag: {}, 88 + }, 89 + inclusive: false, 90 + parseDOM: [ 91 + { 92 + tag: "span.hashtag", 93 + getAttrs(dom: HTMLElement) { 94 + return { 95 + tag: dom.getAttribute("data-tag"), 96 + }; 97 + }, 98 + }, 99 + ], 100 + toDOM(node) { 101 + let { tag } = node.attrs; 102 + return [ 103 + "span", 104 + { 105 + class: "hashtag text-accent-contrast", 106 + "data-tag": tag, 107 + }, 108 + 0, 109 + ]; 110 + }, 111 + } as MarkSpec, 112 + }, 113 + }); 114 + 115 + // Input rule to automatically apply hashtag mark 116 + function createHashtagInputRule() { 117 + return new InputRule(/#([\w]+)\s$/, (state, match, start, end) => { 118 + const [fullMatch, tag] = match; 119 + const tr = state.tr; 120 + 121 + // Replace the matched text (including space) with just the hashtag and space 122 + tr.replaceWith(start, end, [ 123 + state.schema.text("#" + tag), 124 + state.schema.text(" "), 125 + ]); 126 + 127 + // Apply hashtag mark to # and tag text only (not the space) 128 + tr.addMark( 129 + start, 130 + start + tag.length + 1, 131 + bskyPostSchema.marks.hashtag.create({ tag }), 132 + ); 133 + 134 + return tr; 135 + }); 136 + } 137 + 138 + export function BlueskyPostEditorProsemirror(props: { 139 + editorStateRef: React.MutableRefObject<EditorState | null>; 140 + initialContent?: string; 141 + onCharCountChange?: (count: number) => void; 142 + }) { 143 + const mountRef = useRef<HTMLDivElement | null>(null); 144 + const viewRef = useRef<EditorView | null>(null); 145 + const [editorState, setEditorState] = useState<EditorState | null>(null); 146 + const [mentionState, setMentionState] = useState<{ 147 + active: boolean; 148 + range: { from: number; to: number } | null; 149 + selectedMention: { handle: string; did: string } | null; 150 + }>({ active: false, range: null, selectedMention: null }); 151 + 152 + const handleMentionSelect = useCallback( 153 + ( 154 + mention: { handle: string; did: string }, 155 + range: { from: number; to: number }, 156 + ) => { 157 + if (!viewRef.current) return; 158 + const view = viewRef.current; 159 + const { from, to } = range; 160 + const tr = view.state.tr; 161 + 162 + // Delete the query text (keep the @) 163 + tr.delete(from + 1, to); 164 + 165 + // Insert the mention text after the @ 166 + const mentionText = mention.handle; 167 + tr.insertText(mentionText, from + 1); 168 + 169 + // Apply mention mark to @ and handle 170 + tr.addMark( 171 + from, 172 + from + 1 + mentionText.length, 173 + bskyPostSchema.marks.mention.create({ did: mention.did }), 174 + ); 175 + 176 + // Add a space after the mention 177 + tr.insertText(" ", from + 1 + mentionText.length); 178 + 179 + view.dispatch(tr); 180 + view.focus(); 181 + }, 182 + [], 183 + ); 184 + 185 + const mentionStateRef = useRef(mentionState); 186 + mentionStateRef.current = mentionState; 187 + 188 + useLayoutEffect(() => { 189 + if (!mountRef.current) return; 190 + 191 + const initialState = EditorState.create({ 192 + schema: bskyPostSchema, 193 + doc: props.initialContent 194 + ? bskyPostSchema.nodeFromJSON({ 195 + type: "doc", 196 + content: props.initialContent.split("\n").map((line) => ({ 197 + type: "paragraph", 198 + content: line ? [{ type: "text", text: line }] : undefined, 199 + })), 200 + }) 201 + : undefined, 202 + plugins: [ 203 + inputRules({ rules: [createHashtagInputRule()] }), 204 + keymap({ 205 + "Mod-z": undo, 206 + "Mod-y": redo, 207 + "Shift-Mod-z": redo, 208 + Enter: (state, dispatch) => { 209 + // Check if mention autocomplete is active 210 + const currentMentionState = mentionStateRef.current; 211 + if ( 212 + currentMentionState.active && 213 + currentMentionState.selectedMention && 214 + currentMentionState.range 215 + ) { 216 + handleMentionSelect( 217 + currentMentionState.selectedMention, 218 + currentMentionState.range, 219 + ); 220 + return true; 221 + } 222 + // Otherwise let the default Enter behavior happen (new paragraph) 223 + return false; 224 + }, 225 + }), 226 + keymap(baseKeymap), 227 + autolink({ 228 + type: bskyPostSchema.marks.link, 229 + shouldAutoLink: () => true, 230 + defaultProtocol: "https", 231 + }), 232 + history(), 233 + ], 234 + }); 235 + 236 + setEditorState(initialState); 237 + props.editorStateRef.current = initialState; 238 + 239 + const view = new EditorView( 240 + { mount: mountRef.current }, 241 + { 242 + state: initialState, 243 + dispatchTransaction(tr) { 244 + const newState = view.state.apply(tr); 245 + view.updateState(newState); 246 + setEditorState(newState); 247 + props.editorStateRef.current = newState; 248 + props.onCharCountChange?.(newState.doc.textContent.length); 249 + }, 250 + }, 251 + ); 252 + 253 + viewRef.current = view; 254 + 255 + return () => { 256 + view.destroy(); 257 + viewRef.current = null; 258 + }; 259 + }, [handleMentionSelect]); 260 + 261 + return ( 262 + <div className="relative w-full h-full group"> 263 + {editorState && ( 264 + <MentionAutocomplete 265 + editorState={editorState} 266 + view={viewRef} 267 + onSelect={handleMentionSelect} 268 + onMentionStateChange={(active, range, selectedMention) => { 269 + setMentionState({ active, range, selectedMention }); 270 + }} 271 + /> 272 + )} 273 + {editorState?.doc.textContent.length === 0 && ( 274 + <div className="italic text-tertiary absolute top-0 left-0 pointer-events-none"> 275 + Write a post to share your writing! 276 + </div> 277 + )} 278 + <div 279 + ref={mountRef} 280 + className="border-none outline-none whitespace-pre-wrap min-h-[80px] max-h-[200px] overflow-y-auto prose-sm" 281 + style={{ 282 + wordWrap: "break-word", 283 + overflowWrap: "break-word", 284 + }} 285 + /> 286 + <IOSBS view={viewRef} /> 287 + </div> 288 + ); 289 + } 290 + 291 + function MentionAutocomplete(props: { 292 + editorState: EditorState; 293 + view: React.RefObject<EditorView | null>; 294 + onSelect: ( 295 + mention: { handle: string; did: string }, 296 + range: { from: number; to: number }, 297 + ) => void; 298 + onMentionStateChange: ( 299 + active: boolean, 300 + range: { from: number; to: number } | null, 301 + selectedMention: { handle: string; did: string } | null, 302 + ) => void; 303 + }) { 304 + const [mentionQuery, setMentionQuery] = useState<string | null>(null); 305 + const [mentionRange, setMentionRange] = useState<{ 306 + from: number; 307 + to: number; 308 + } | null>(null); 309 + const [mentionCoords, setMentionCoords] = useState<{ 310 + top: number; 311 + left: number; 312 + } | null>(null); 313 + 314 + const { suggestionIndex, setSuggestionIndex, suggestions } = 315 + useMentionSuggestions(mentionQuery); 316 + 317 + // Check for mention pattern whenever editor state changes 318 + useEffect(() => { 319 + const { $from } = props.editorState.selection; 320 + const textBefore = $from.parent.textBetween( 321 + Math.max(0, $from.parentOffset - 50), 322 + $from.parentOffset, 323 + null, 324 + "\ufffc", 325 + ); 326 + 327 + // Look for @ followed by word characters before cursor 328 + const match = textBefore.match(/@([\w.]*)$/); 329 + 330 + if (match && props.view.current) { 331 + const queryBefore = match[1]; 332 + const from = $from.pos - queryBefore.length - 1; 333 + 334 + // Get text after cursor to find the rest of the handle 335 + const textAfter = $from.parent.textBetween( 336 + $from.parentOffset, 337 + Math.min($from.parent.content.size, $from.parentOffset + 50), 338 + null, 339 + "\ufffc", 340 + ); 341 + 342 + // Match word characters after cursor until space or end 343 + const afterMatch = textAfter.match(/^([\w.]*)/); 344 + const queryAfter = afterMatch ? afterMatch[1] : ""; 345 + 346 + // Combine the full handle 347 + const query = queryBefore + queryAfter; 348 + const to = $from.pos + queryAfter.length; 349 + 350 + setMentionQuery(query); 351 + setMentionRange({ from, to }); 352 + 353 + // Get coordinates for the autocomplete popup 354 + const coords = props.view.current.coordsAtPos(from); 355 + setMentionCoords({ 356 + top: coords.bottom + window.scrollY, 357 + left: coords.left + window.scrollX, 358 + }); 359 + setSuggestionIndex(0); 360 + } else { 361 + setMentionQuery(null); 362 + setMentionRange(null); 363 + setMentionCoords(null); 364 + } 365 + }, [props.editorState, props.view, setSuggestionIndex]); 366 + 367 + // Update parent's mention state 368 + useEffect(() => { 369 + const active = mentionQuery !== null && suggestions.length > 0; 370 + const selectedMention = 371 + active && suggestions[suggestionIndex] 372 + ? suggestions[suggestionIndex] 373 + : null; 374 + props.onMentionStateChange(active, mentionRange, selectedMention); 375 + }, [mentionQuery, suggestions, suggestionIndex, mentionRange]); 376 + 377 + // Handle keyboard navigation for arrow keys only 378 + useEffect(() => { 379 + if (!mentionQuery || !props.view.current) return; 380 + 381 + const handleKeyDown = (e: KeyboardEvent) => { 382 + if (suggestions.length === 0) return; 383 + 384 + if (e.key === "ArrowUp") { 385 + e.preventDefault(); 386 + if (suggestionIndex > 0) { 387 + setSuggestionIndex((i) => i - 1); 388 + } 389 + } else if (e.key === "ArrowDown") { 390 + e.preventDefault(); 391 + if (suggestionIndex < suggestions.length - 1) { 392 + setSuggestionIndex((i) => i + 1); 393 + } 394 + } 395 + }; 396 + 397 + const dom = props.view.current.dom; 398 + dom.addEventListener("keydown", handleKeyDown); 399 + 400 + return () => { 401 + dom.removeEventListener("keydown", handleKeyDown); 402 + }; 403 + }, [ 404 + mentionQuery, 405 + suggestions, 406 + suggestionIndex, 407 + props.view, 408 + setSuggestionIndex, 409 + ]); 410 + 411 + if (!mentionCoords || suggestions.length === 0) return null; 412 + 413 + // The styles in this component should match the Menu styles in components/Layout.tsx 414 + return ( 415 + <Popover.Root open> 416 + {createPortal( 417 + <Popover.Anchor 418 + style={{ 419 + top: mentionCoords.top, 420 + left: mentionCoords.left, 421 + position: "absolute", 422 + }} 423 + />, 424 + document.body, 425 + )} 426 + <Popover.Portal> 427 + <Popover.Content 428 + side="bottom" 429 + align="start" 430 + sideOffset={4} 431 + collisionPadding={20} 432 + onOpenAutoFocus={(e) => e.preventDefault()} 433 + className={`dropdownMenu z-20 bg-bg-page flex flex-col py-1 gap-0.5 border border-border rounded-md shadow-md`} 434 + > 435 + <ul className="list-none p-0 text-sm"> 436 + {suggestions.map((result, index) => { 437 + return ( 438 + <div 439 + className={` 440 + MenuItem 441 + font-bold z-10 py-1 px-3 442 + text-left text-secondary 443 + flex gap-2 444 + ${index === suggestionIndex ? "bg-border-light data-[highlighted]:text-secondary" : ""} 445 + hover:bg-border-light hover:text-secondary 446 + outline-none 447 + `} 448 + key={result.did} 449 + onClick={() => { 450 + if (mentionRange) { 451 + props.onSelect(result, mentionRange); 452 + setMentionQuery(null); 453 + setMentionRange(null); 454 + setMentionCoords(null); 455 + } 456 + }} 457 + onMouseDown={(e) => e.preventDefault()} 458 + > 459 + @{result.handle} 460 + </div> 461 + ); 462 + })} 463 + </ul> 464 + </Popover.Content> 465 + </Popover.Portal> 466 + </Popover.Root> 467 + ); 468 + } 469 + 470 + function useMentionSuggestions(query: string | null) { 471 + const [suggestionIndex, setSuggestionIndex] = useState(0); 472 + const [suggestions, setSuggestions] = useState< 473 + { handle: string; did: string }[] 474 + >([]); 475 + 476 + useDebouncedEffect( 477 + async () => { 478 + if (!query) { 479 + setSuggestions([]); 480 + return; 481 + } 482 + 483 + const agent = new Agent("https://public.api.bsky.app"); 484 + const result = await agent.searchActorsTypeahead({ 485 + q: query, 486 + limit: 8, 487 + }); 488 + setSuggestions( 489 + result.data.actors.map((actor) => ({ 490 + handle: actor.handle, 491 + did: actor.did, 492 + })), 493 + ); 494 + }, 495 + 300, 496 + [query], 497 + ); 498 + 499 + useEffect(() => { 500 + if (suggestionIndex > suggestions.length - 1) { 501 + setSuggestionIndex(Math.max(0, suggestions.length - 1)); 502 + } 503 + }, [suggestionIndex, suggestions.length]); 504 + 505 + return { 506 + suggestions, 507 + suggestionIndex, 508 + setSuggestionIndex, 509 + }; 510 + } 511 + 512 + /** 513 + * Converts a ProseMirror editor state to Bluesky post facets. 514 + * Extracts mentions, links, and hashtags from the editor state and returns them 515 + * as an array of Bluesky richtext facets with proper byte positions. 516 + */ 517 + export function editorStateToFacetedText( 518 + state: EditorState, 519 + ): [string, AppBskyRichtextFacet.Main[]] { 520 + let fullText = ""; 521 + let facets: AppBskyRichtextFacet.Main[] = []; 522 + let byteOffset = 0; 523 + 524 + // Iterate through each paragraph in the document 525 + state.doc.forEach((paragraph) => { 526 + if (paragraph.type.name !== "paragraph") return; 527 + 528 + // Process each inline node in the paragraph 529 + paragraph.forEach((node) => { 530 + if (node.isText) { 531 + const text = node.text || ""; 532 + const unicodeString = new UnicodeString(text); 533 + 534 + // If this text node has marks, create a facet 535 + if (node.marks.length > 0) { 536 + const facet: AppBskyRichtextFacet.Main = { 537 + index: { 538 + byteStart: byteOffset, 539 + byteEnd: byteOffset + unicodeString.length, 540 + }, 541 + features: marksToFeatures(node.marks), 542 + }; 543 + 544 + if (facet.features.length > 0) { 545 + facets.push(facet); 546 + } 547 + } 548 + 549 + fullText += text; 550 + byteOffset += unicodeString.length; 551 + } 552 + }); 553 + 554 + // Add newline between paragraphs (except after the last one) 555 + if (paragraph !== state.doc.lastChild) { 556 + const newline = "\n"; 557 + const unicodeNewline = new UnicodeString(newline); 558 + fullText += newline; 559 + byteOffset += unicodeNewline.length; 560 + } 561 + }); 562 + 563 + return [fullText, facets]; 564 + } 565 + 566 + function marksToFeatures(marks: readonly Mark[]) { 567 + const features: AppBskyRichtextFacet.Main["features"] = []; 568 + 569 + for (const mark of marks) { 570 + switch (mark.type.name) { 571 + case "mention": { 572 + features.push({ 573 + $type: "app.bsky.richtext.facet#mention", 574 + did: mark.attrs.did, 575 + }); 576 + break; 577 + } 578 + case "hashtag": { 579 + features.push({ 580 + $type: "app.bsky.richtext.facet#tag", 581 + tag: mark.attrs.tag, 582 + }); 583 + break; 584 + } 585 + case "link": 586 + features.push({ 587 + $type: "app.bsky.richtext.facet#link", 588 + uri: mark.attrs.href as string, 589 + }); 590 + break; 591 + } 592 + } 593 + 594 + return features; 595 + }
+22 -11
app/[leaflet_id]/publish/PublishPost.tsx
··· 1 1 "use client"; 2 2 import { publishToPublication } from "actions/publishToPublication"; 3 3 import { DotLoader } from "components/utils/DotLoader"; 4 - import { useState } from "react"; 4 + import { useState, useRef } from "react"; 5 5 import { ButtonPrimary } from "components/Buttons"; 6 6 import { Radio } from "components/Checkbox"; 7 7 import { useParams } from "next/navigation"; ··· 13 13 import { AtUri } from "@atproto/syntax"; 14 14 import { PublishIllustration } from "./PublishIllustration/PublishIllustration"; 15 15 import { useReplicache } from "src/replicache"; 16 + import { 17 + BlueskyPostEditorProsemirror, 18 + editorStateToFacetedText, 19 + } from "./BskyPostEditorProsemirror"; 20 + import { EditorState } from "prosemirror-state"; 16 21 17 22 type Props = { 18 23 title: string; ··· 51 56 } & Props, 52 57 ) => { 53 58 let [shareOption, setShareOption] = useState<"bluesky" | "quiet">("bluesky"); 54 - let [postContent, setPostContent] = useState(""); 59 + let editorStateRef = useRef<EditorState | null>(null); 55 60 let [isLoading, setIsLoading] = useState(false); 61 + let [charCount, setCharCount] = useState(0); 56 62 let params = useParams(); 57 63 let { rep } = useReplicache(); 58 64 ··· 70 76 if (!doc) return; 71 77 72 78 let post_url = `https://${props.record?.base_path}/${doc.rkey}`; 79 + let [text, facets] = editorStateRef.current 80 + ? editorStateToFacetedText(editorStateRef.current) 81 + : []; 73 82 if (shareOption === "bluesky") 74 83 await publishPostToBsky({ 75 - text: postContent, 84 + facets: facets || [], 85 + text: text || "", 76 86 title: props.title, 77 87 url: post_url, 78 88 description: props.description, ··· 145 155 <p className="text-tertiary">@{props.profile.handle}</p> 146 156 </div> 147 157 <div className="flex flex-col"> 148 - <AutosizeTextarea 149 - value={postContent} 150 - onChange={(e) => 151 - setPostContent(e.currentTarget.value.slice(0, 300)) 152 - } 153 - placeholder="Write a post to share your writing!" 158 + <BlueskyPostEditorProsemirror 159 + editorStateRef={editorStateRef} 160 + onCharCountChange={setCharCount} 154 161 /> 155 162 </div> 156 163 <div className="opaque-container overflow-hidden flex flex-col mt-4 w-full"> ··· 165 172 </div> 166 173 </div> 167 174 <div className="text-xs text-secondary italic place-self-end pt-2"> 168 - {postContent.length}/300 175 + {charCount}/300 169 176 </div> 170 177 </div> 171 178 </div> ··· 178 185 > 179 186 Back 180 187 </Link> 181 - <ButtonPrimary type="submit" className="place-self-end h-[30px]"> 188 + <ButtonPrimary 189 + type="submit" 190 + className="place-self-end h-[30px]" 191 + disabled={charCount > 300} 192 + > 182 193 {isLoading ? <DotLoader /> : "Publish this Post!"} 183 194 </ButtonPrimary> 184 195 </div>
+7 -1
app/[leaflet_id]/publish/publishBskyPost.ts
··· 1 1 "use server"; 2 2 3 - import { Agent as BskyAgent } from "@atproto/api"; 3 + import { 4 + AppBskyRichtextFacet, 5 + Agent as BskyAgent, 6 + UnicodeString, 7 + } from "@atproto/api"; 4 8 import sharp from "sharp"; 5 9 import { TID } from "@atproto/common"; 6 10 import { getIdentityData } from "actions/getIdentityData"; ··· 16 20 description: string; 17 21 document_record: PubLeafletDocument.Record; 18 22 rkey: string; 23 + facets: AppBskyRichtextFacet.Main[]; 19 24 }) { 20 25 const oauthClient = await createOauthClient(); 21 26 let identity = await getIdentityData(); ··· 56 61 { 57 62 text: args.text, 58 63 createdAt: new Date().toISOString(), 64 + facets: args.facets, 59 65 embed: { 60 66 $type: "app.bsky.embed.external", 61 67 external: {
+5
app/api/inngest/client.ts
··· 3 3 import { EventSchemas } from "inngest"; 4 4 5 5 export type Events = { 6 + "feeds/index-follows": { 7 + data: { 8 + did: string; 9 + }; 10 + }; 6 11 "appview/profile-update": { 7 12 data: { 8 13 record: any;
+158
app/api/inngest/functions/index_follows.ts
··· 1 + import { supabaseServerClient } from "supabase/serverClient"; 2 + import { AtpAgent, AtUri } from "@atproto/api"; 3 + import { createIdentity } from "actions/createIdentity"; 4 + import { drizzle } from "drizzle-orm/node-postgres"; 5 + import { inngest } from "../client"; 6 + import { pool } from "supabase/pool"; 7 + 8 + export const index_follows = inngest.createFunction( 9 + { 10 + id: "index_follows", 11 + throttle: { 12 + limit: 1, 13 + period: "5m", 14 + key: "event.data.did", 15 + }, 16 + }, 17 + { event: "feeds/index-follows" }, 18 + async ({ event, step }) => { 19 + let follows: string[] = []; 20 + let cursor: null | string = null; 21 + let hasMore = true; 22 + let pageNumber = 0; 23 + while (hasMore) { 24 + let page: { 25 + cursor?: string; 26 + follows: string[]; 27 + } = await step.run(`get-follows-${pageNumber}`, async () => { 28 + let agent = new AtpAgent({ service: "https://public.api.bsky.app" }); 29 + let follows = await agent.app.bsky.graph.getFollows({ 30 + actor: event.data.did, 31 + limit: 100, 32 + cursor: cursor || undefined, 33 + }); 34 + if (!follows.success) 35 + throw new Error( 36 + "error during querying follows for: " + event.data.did, 37 + ); 38 + return { 39 + cursor: follows.data.cursor, 40 + follows: follows.data.follows.map((f) => f.did), 41 + }; 42 + }); 43 + pageNumber++; 44 + follows.push(...page.follows); 45 + cursor = page.cursor || null; 46 + if (!cursor) hasMore = false; 47 + } 48 + let existingFollows: string[] = []; 49 + const batchSize = 100; 50 + let batchNumber = 0; 51 + 52 + // Create all check batches in parallel 53 + const checkBatches: Promise<any>[] = [ 54 + step.run("check-if-identity-exists", async () => { 55 + let { data: exists } = await supabaseServerClient 56 + .from("identities") 57 + .select() 58 + .eq("atp_did", event.data.did) 59 + .single(); 60 + if (!exists) { 61 + const client = await pool.connect(); 62 + let db = drizzle(client); 63 + let identity = await createIdentity(db, { atp_did: event.data.did }); 64 + client.release(); 65 + return identity; 66 + } 67 + }), 68 + ]; 69 + for (let i = 0; i < follows.length; i += batchSize) { 70 + const batch = follows.slice(i, i + batchSize); 71 + checkBatches.push( 72 + step.run(`check-existing-follows-batch-${batchNumber}`, async () => { 73 + const { data: existingIdentities } = await supabaseServerClient 74 + .from("identities") 75 + .select("atp_did") 76 + .in("atp_did", batch); 77 + 78 + return existingIdentities?.map((identity) => identity.atp_did!) || []; 79 + }), 80 + ); 81 + batchNumber++; 82 + } 83 + 84 + // Wait for all check batches to complete 85 + const batchResults = await Promise.all(checkBatches); 86 + existingFollows = batchResults.flat().filter(Boolean); 87 + 88 + // Filter follows to only include those that exist in identities table 89 + const insertBatchSize = 100; 90 + let insertBatchNumber = 0; 91 + 92 + // Create all insert batches in parallel 93 + const insertBatches = []; 94 + for (let i = 0; i < existingFollows.length; i += insertBatchSize) { 95 + const batch = existingFollows.slice(i, i + insertBatchSize); 96 + insertBatches.push( 97 + step.run(`insert-follows-batch-${insertBatchNumber}`, async () => { 98 + const insertData = batch.map((f) => ({ 99 + identity: event.data.did, 100 + follows: f, 101 + })); 102 + 103 + return await supabaseServerClient 104 + .from("bsky_follows") 105 + .upsert(insertData); 106 + }), 107 + ); 108 + insertBatchNumber++; 109 + } 110 + 111 + // Wait for all insert batches to complete 112 + await Promise.all(insertBatches); 113 + 114 + // Delete follows that are no longer in the fetched list 115 + // For large follow lists, we need to batch this operation 116 + await step.run("delete-unfollowed", async () => { 117 + // Get all current follows from the database 118 + const { data: currentFollows } = await supabaseServerClient 119 + .from("bsky_follows") 120 + .select("follows") 121 + .eq("identity", event.data.did); 122 + 123 + if (!currentFollows || currentFollows.length === 0) { 124 + return { deleted: 0 }; 125 + } 126 + 127 + // Find follows that are in the database but not in the newly fetched list 128 + const currentFollowDids = currentFollows.map((f) => f.follows); 129 + const toDelete = currentFollowDids.filter( 130 + (did) => !existingFollows.includes(did) 131 + ); 132 + 133 + if (toDelete.length === 0) { 134 + return { deleted: 0 }; 135 + } 136 + 137 + // Delete in batches to avoid query size limits 138 + const deleteBatchSize = 100; 139 + const deletePromises = []; 140 + for (let i = 0; i < toDelete.length; i += deleteBatchSize) { 141 + const batch = toDelete.slice(i, i + deleteBatchSize); 142 + deletePromises.push( 143 + supabaseServerClient 144 + .from("bsky_follows") 145 + .delete() 146 + .eq("identity", event.data.did) 147 + .in("follows", batch) 148 + ); 149 + } 150 + 151 + await Promise.all(deletePromises); 152 + return { deleted: toDelete.length }; 153 + }); 154 + return { 155 + done: true, 156 + }; 157 + }, 158 + );
+7 -2
app/api/inngest/route.tsx
··· 3 3 import { index_post_mention } from "./functions/index_post_mention"; 4 4 import { come_online } from "./functions/come_online"; 5 5 import { batched_update_profiles } from "./functions/batched_update_profiles"; 6 + import { index_follows } from "./functions/index_follows"; 6 7 7 - // Create an API that serves zero functions 8 8 export const { GET, POST, PUT } = serve({ 9 9 client: inngest, 10 - functions: [index_post_mention, come_online, batched_update_profiles], 10 + functions: [ 11 + index_post_mention, 12 + come_online, 13 + batched_update_profiles, 14 + index_follows, 15 + ], 11 16 });
+1 -3
app/api/oauth/[route]/route.ts
··· 54 54 case "callback": { 55 55 const params = new URLSearchParams(req.url.split("?")[1]); 56 56 57 - //TODO remember to reset this to a better default! 58 - let redirectPath = "/lish"; 57 + let redirectPath = "/"; 59 58 try { 60 59 const { session, state } = await client.callback(params); 61 60 let s: OauthRequestClientState = JSON.parse(state || "{}"); ··· 122 121 else url = new URL(decodeURIComponent(redirectPath), "https://example.com"); 123 122 if (action?.action === "subscribe") { 124 123 let result = await subscribeToPublication(action.publication); 125 - console.log(result); 126 124 if (result.hasFeed === false) 127 125 url.searchParams.set("showSubscribeSuccess", "true"); 128 126 }
+15 -7
app/api/rpc/[command]/domain_routes.ts
··· 25 25 let errorResponse = e as NextApiResponse; 26 26 if (errorResponse.statusCode === 403) { 27 27 try { 28 - let verification = await vercel.projects.getProjectDomain({ 29 - idOrName: "prj_9jX4tmYCISnm176frFxk07fF74kG", 30 - teamId: "team_42xaJiZMTw9Sr7i0DcLTae9d", 31 - domain, 32 - }); 33 - if (!verification.verification) return {}; 28 + let [verification, config] = await Promise.all([ 29 + vercel.projects.getProjectDomain({ 30 + idOrName: "prj_9jX4tmYCISnm176frFxk07fF74kG", 31 + teamId: "team_42xaJiZMTw9Sr7i0DcLTae9d", 32 + domain, 33 + }), 34 + vercel.domains.getDomainConfig({ 35 + domain, 36 + teamId: "team_42xaJiZMTw9Sr7i0DcLTae9d", 37 + }), 38 + ]); 39 + if (!verification.verification) { 40 + return { config }; 41 + } 34 42 return { 35 - error: "Verification_needed", 36 43 verification: verification.verification, 44 + config, 37 45 } as const; 38 46 } catch (e) { 39 47 return { error: true };
+28 -26
app/discover/PubListing.tsx
··· 1 1 "use client"; 2 2 import { AtUri } from "@atproto/syntax"; 3 + import { PublicationSubscription } from "app/reader/getSubscriptions"; 4 + import { PubIcon } from "components/ActionBar/Publications"; 5 + import { Separator } from "components/Layout"; 3 6 import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; 4 7 import { BaseThemeProvider } from "components/ThemeManager/ThemeProvider"; 5 8 import { PubLeafletPublication, PubLeafletThemeColor } from "lexicons/api"; ··· 7 10 import { timeAgo } from "src/utils/timeAgo"; 8 11 import { Json } from "supabase/database.types"; 9 12 10 - export const PubListing = (props: { 11 - record: Json; 12 - uri: string; 13 - documents_in_publications: { 14 - indexed_at: string; 15 - documents: { data: Json } | null; 16 - }[]; 17 - }) => { 13 + export const PubListing = ( 14 + props: PublicationSubscription & { 15 + resizeHeight?: boolean; 16 + }, 17 + ) => { 18 18 let record = props.record as PubLeafletPublication.Record; 19 19 let theme = usePubTheme(record); 20 20 let backgroundImage = record?.theme?.backgroundImage?.image?.ref ··· 43 43 hover:outline-accent-contrast hover:border-accent-contrast`} 44 44 > 45 45 <div 46 - style={{ 47 - backgroundRepeat: "no-repeat", 48 - backgroundPosition: "center", 49 - backgroundSize: "cover", 50 - backgroundImage: record?.icon 51 - ? `url(${blobRefToSrc(record.icon?.ref, new AtUri(props.uri).host)})` 52 - : undefined, 53 - }} 54 - className={`w-6 h-6 rounded-full bg-accent-1 text-accent-2 flex place-content-center leading-snug font-bold text-center shrink-0 ${record.theme?.showPageBackground ? "mt-[6px]" : "mt-0.5"}`} 55 - > 56 - {!record?.icon ? record.name.slice(0, 1).toLocaleUpperCase() : null} 57 - </div> 58 - <div 59 - className={`flex w-full flex-col ${record.theme?.showPageBackground ? "bg-[rgba(var(--bg-page),var(--bg-page-alpha))] px-2 py-1 rounded-lg" : ""}`} 46 + className={`flex w-full flex-col justify-center text-center max-h-48 pt-4 pb-3 px-3 rounded-lg ${props.resizeHeight ? "" : "sm:h-48 h-full"} ${record.theme?.showPageBackground ? "bg-[rgba(var(--bg-page),var(--bg-page-alpha))] " : ""}`} 60 47 > 61 - <h3>{record.name}</h3> 62 - <p className="text-secondary">{record.description}</p> 63 - <div className="flex gap-1 items-center text-sm text-tertiary pt-2 "> 48 + <div className="mx-auto pb-1"> 49 + <PubIcon record={record} uri={props.uri} large /> 50 + </div> 51 + 52 + <h4 className="truncate shrink-0 ">{record.name}</h4> 53 + {record.description && ( 54 + <p className="text-secondary text-sm max-h-full overflow-hidden pb-1"> 55 + {record.description} 56 + </p> 57 + )} 58 + <div className="flex flex-col items-center justify-center text-xs text-tertiary pt-2"> 59 + <div className="flex flex-row gap-2 items-center"> 60 + {props.authorProfile?.handle} 61 + </div> 64 62 <p> 65 - Updated {timeAgo(props.documents_in_publications[0].indexed_at)} 63 + Updated{" "} 64 + {timeAgo( 65 + props.documents_in_publications?.[0]?.documents?.indexed_at || 66 + "", 67 + )} 66 68 </p> 67 69 </div> 68 70 </div>
+1 -1
app/discover/SortedPublicationList.tsx
··· 39 39 ); 40 40 return bDate.getTime() - aDate.getTime(); 41 41 }) 42 - .map((pub) => <PubListing key={pub.uri} {...pub} />)} 42 + .map((pub) => <PubListing resizeHeight key={pub.uri} {...pub} />)} 43 43 </div> 44 44 </div> 45 45 );
+1 -1
app/discover/page.tsx
··· 40 40 <div className="w-full h-full mx-auto bg-[#FDFCFA]"> 41 41 <DashboardLayout 42 42 id="discover" 43 - hasBackgroundImage={false} 43 + cardBorderHidden={false} 44 44 currentPage="discover" 45 45 defaultTab="default" 46 46 actions={null}
+1
app/home/Actions/Actions.tsx
··· 16 16 {identity ? <AccountSettings /> : <LoginActionButton />} 17 17 {/*<HelpPopover noShortcuts />*/} 18 18 <ThemePopover entityID={rootEntity} home /> 19 + <HelpPopover /> 19 20 </> 20 21 ); 21 22 };
+6 -6
app/home/HomeEmpty/HomeEmpty.tsx
··· 66 66 export const PublicationBanner = (props: { small?: boolean }) => { 67 67 return ( 68 68 <div 69 - className={`accent-container flex sm:py-2 gap-4 items-center ${props.small ? "items-start p-2 text-sm font-normal" : "items-center p-4"}`} 69 + className={`accent-container flex sm:py-2 items-center ${props.small ? "items-start gap-2 p-2 text-sm font-normal" : "items-center p-4 gap-4"}`} 70 70 > 71 71 {props.small ? ( 72 - <PublishSmall className="shrink-0" /> 72 + <PublishSmall className="shrink-0 text-accent-contrast" /> 73 73 ) : ( 74 74 <div className="w-[64px] mx-auto"> 75 75 <PubListEmptyIllo /> 76 76 </div> 77 77 )} 78 - <div className="grow"> 78 + <div className={`${props.small ? "pt-[2px]" : ""} grow`}> 79 79 <Link href={"/lish/createPub"} className="font-bold"> 80 80 Start a Publication 81 81 </Link>{" "} ··· 88 88 export const DiscoverBanner = (props: { small?: boolean }) => { 89 89 return ( 90 90 <div 91 - className={`accent-container flex sm:py-2 gap-4 items-center ${props.small ? "items-start p-2 text-sm font-normal" : "items-center p-4"}`} 91 + className={`accent-container flex sm:py-2 items-center ${props.small ? "items-start gap-2 p-2 text-sm font-normal" : "items-center p-4 gap-4"}`} 92 92 > 93 93 {props.small ? ( 94 - <DiscoverSmall className="shrink-0" /> 94 + <DiscoverSmall className="shrink-0 text-accent-contrast" /> 95 95 ) : ( 96 96 <div className="w-[64px] mx-auto"> 97 97 <DiscoverIllo /> 98 98 </div> 99 99 )} 100 - <div className="grow"> 100 + <div className={`${props.small ? "pt-[2px]" : ""} grow`}> 101 101 <Link href={"/discover"} className="font-bold"> 102 102 Explore Publications 103 103 </Link>{" "}
+1 -2
app/home/HomeLayout.tsx
··· 95 95 return ( 96 96 <DashboardLayout 97 97 id="home" 98 - hasBackgroundImage={hasBackgroundImage} 98 + cardBorderHidden={cardBorderHidden} 99 99 currentPage="home" 100 100 defaultTab="home" 101 101 actions={<Actions />} ··· 192 192 {leaflets.filter((l) => !!l.token.leaflets_in_publications).length === 193 193 0 && <PublicationBanner small />} 194 194 <DiscoverBanner small /> 195 - <div className="spacer h-8 w-full bg-transparent shrink-0 " /> 196 195 </> 197 196 ); 198 197 }
+4 -1
app/home/LeafletList/LeafletPreview.tsx
··· 140 140 <div className="border border-border-light rounded-md w-full h-full overflow-hidden relative"> 141 141 <div className="relative w-full h-full"> 142 142 <ThemeBackgroundProvider entityID={root}> 143 - <div className="leafletPreview relative grow shrink-0 h-full w-full px-2 pt-2 sm:px-3 sm:pt-3 flex items-end pointer-events-none"> 143 + <div 144 + inert 145 + className="leafletPreview relative grow shrink-0 h-full w-full px-2 pt-2 sm:px-3 sm:pt-3 flex items-end pointer-events-none" 146 + > 144 147 <div 145 148 className={`leafletContentWrapper h-full sm:w-48 w-40 mx-auto overflow-clip ${!cardBorderHidden && "border border-border-light border-b-0 rounded-t-md"}`} 146 149 style={
+23 -14
app/lish/Subscribe.tsx
··· 231 231 pubName: string; 232 232 pub_uri: string; 233 233 subscribers: { identity: string }[]; 234 + base_url: string; 234 235 }) => { 235 236 let toaster = useToaster(); 236 237 let [hasFeed] = useState(false); ··· 251 252 <Popover 252 253 trigger={<div className="text-accent-contrast text-sm">Manage</div>} 253 254 > 254 - <div className="max-w-sm flex flex-col gap-3 justify-center text-center"> 255 + <div className="max-w-sm flex flex-col gap-1"> 256 + <h4>Update Options</h4> 257 + 255 258 {!hasFeed && ( 256 - <> 257 - <div className="flex flex-col gap-2 font-bold text-secondary w-full"> 258 - Updates via Bluesky custom feed! 259 - <a 260 - href="https://bsky.app/profile/leaflet.pub/feed/subscribedPublications" 261 - target="_blank" 262 - className=" place-self-center" 263 - > 264 - <ButtonPrimary>View Feed</ButtonPrimary> 265 - </a> 266 - </div> 267 - <hr className="border-border-light" /> 268 - </> 259 + <a 260 + href="https://bsky.app/profile/leaflet.pub/feed/subscribedPublications" 261 + target="_blank" 262 + className=" place-self-center" 263 + > 264 + <ButtonPrimary fullWidth compact className="!px-4"> 265 + View Bluesky Custom Feed 266 + </ButtonPrimary> 267 + </a> 269 268 )} 269 + 270 + <a href={`${props.base_url}/rss`} className="flex" target="_blank"> 271 + <span className="sr-only">Subscribe to RSS</span> 272 + <ButtonPrimary fullWidth compact> 273 + Get RSS 274 + </ButtonPrimary> 275 + </a> 276 + 277 + <hr className="border-border-light my-1" /> 278 + 270 279 <form action={unsubscribe}> 271 280 <button className="font-bold text-accent-contrast w-max place-self-center"> 272 281 {unsubscribePending ? <DotLoader /> : "Unsubscribe"}
+18 -14
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx
··· 44 44 autoFocus?: boolean; 45 45 }) { 46 46 let mountRef = useRef<HTMLPreElement | null>(null); 47 - let { commentBox: { quote } } = useInteractionState(props.doc_uri); 47 + let { 48 + commentBox: { quote }, 49 + } = useInteractionState(props.doc_uri); 48 50 let [loading, setLoading] = useState(false); 49 - 51 + 50 52 const handleSubmit = async () => { 51 53 if (loading || !view.current) return; 52 - 54 + 53 55 setLoading(true); 54 56 let currentState = view.current.state; 55 57 let [plaintext, facets] = docToFacetedText(currentState.doc); ··· 108 110 "Mod-z": undo, 109 111 "Mod-y": redo, 110 112 "Shift-Mod-z": redo, 111 - "Ctrl-Enter": () => { handleSubmit(); return true; }, 112 - "Meta-Enter": () => { handleSubmit(); return true; }, 113 + "Ctrl-Enter": () => { 114 + handleSubmit(); 115 + return true; 116 + }, 117 + "Meta-Enter": () => { 118 + handleSubmit(); 119 + return true; 120 + }, 113 121 }), 114 122 keymap(baseKeymap), 115 123 autolink({ ··· 158 166 let xml = new DOMParser().parseFromString(html, "text/html"); 159 167 text = xml.textContent || ""; 160 168 } 161 - console.log("URL: " + window.location.toString()); 162 - console.log("TEXT: " + text, text?.includes(QUOTE_PARAM)); 163 169 if ( 164 170 text?.includes(QUOTE_PARAM) && 165 171 text.includes(window.location.toString()) ··· 190 196 } 191 197 }, 192 198 dispatchTransaction(tr) { 193 - console.log("dispatching?"); 194 199 let newState = this.state.apply(tr); 195 200 setEditorState(newState); 196 201 view.current?.updateState(newState); ··· 207 212 view.current = null; 208 213 }; 209 214 }, []); 210 - 215 + 211 216 return ( 212 217 <div className=" flex flex-col"> 213 218 {quote && ( ··· 216 221 <button 217 222 className="text-border absolute -top-3 right-1 bg-bg-page p-1 rounded-full" 218 223 onClick={() => 219 - setInteractionState(props.doc_uri, { commentBox: { quote: null } }) 224 + setInteractionState(props.doc_uri, { 225 + commentBox: { quote: null }, 226 + }) 220 227 } 221 228 > 222 229 <CloseFillTiny /> ··· 251 258 view={view} 252 259 /> 253 260 </div> 254 - <ButtonPrimary 255 - compact 256 - onClick={handleSubmit} 257 - > 261 + <ButtonPrimary compact onClick={handleSubmit}> 258 262 {loading ? <DotLoader /> : <ShareSmall />} 259 263 </ButtonPrimary> 260 264 </div>
+3 -3
app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
··· 10 10 11 11 export function PostHeader(props: { 12 12 data: PostPageData; 13 - name: string; 14 13 profile: ProfileViewDetailed; 15 14 preferences: { showComments?: boolean }; 16 15 }) { ··· 40 39 ) 41 40 } 42 41 > 43 - {props.name} 42 + {pub?.name} 44 43 </SpeedyLink> 45 44 {identity && 46 45 identity.atp_did === 47 46 document.documents_in_publications[0]?.publications 48 - .identity_did && ( 47 + .identity_did && 48 + document.leaflets_in_publications[0] && ( 49 49 <a 50 50 className=" rounded-full flex place-items-center" 51 51 href={`https://leaflet.pub/${document.leaflets_in_publications[0].leaflet}`}
+20 -11
app/lish/[did]/[publication]/[rkey]/PublishBskyPostBlock.tsx
··· 43 43 let postId = post.uri.split("/")[4]; 44 44 let url = `https://bsky.app/profile/${post.author.handle}/post/${postId}`; 45 45 46 - let datetimeFormatted = new Date( 47 - timestamp ? timestamp : "", 48 - ).toLocaleString("en-US", { 49 - month: "short", 50 - day: "numeric", 51 - year: "numeric", 52 - hour: "numeric", 53 - minute: "numeric", 54 - hour12: true, 55 - }); 56 46 return ( 57 47 <div 58 48 className={` ··· 98 88 </> 99 89 )} 100 90 <div className="w-full flex gap-2 items-center justify-between"> 101 - <div className="text-xs text-tertiary">{datetimeFormatted}</div> 91 + <ClientDate date={timestamp} /> 102 92 <div className="flex gap-2 items-center"> 103 93 {post.replyCount && post.replyCount > 0 && ( 104 94 <> ··· 123 113 ); 124 114 } 125 115 }; 116 + 117 + const ClientDate = (props: { date?: string }) => { 118 + let pageLoaded = useInitialPageLoad(); 119 + if (!pageLoaded) return null; 120 + 121 + let datetimeFormatted = new Date(props.date ? props.date : "").toLocaleString( 122 + "en-US", 123 + { 124 + month: "short", 125 + day: "numeric", 126 + year: "numeric", 127 + hour: "numeric", 128 + minute: "numeric", 129 + hour12: true, 130 + }, 131 + ); 132 + 133 + return <div className="text-xs text-tertiary">{datetimeFormatted}</div>; 134 + };
+24 -18
app/lish/[did]/[publication]/[rkey]/QuoteHandler.tsx
··· 13 13 import { CommentTiny } from "components/Icons/CommentTiny"; 14 14 import { setInteractionState } from "./Interactions/Interactions"; 15 15 import { PostPageContext } from "./PostPageContext"; 16 + import { PubLeafletPublication } from "lexicons/api"; 16 17 17 18 export function QuoteHandler() { 18 19 let [position, setPosition] = useState<{ ··· 131 132 let { identity } = useIdentityData(); 132 133 const data = useContext(PostPageContext); 133 134 const document_uri = data?.uri; 134 - if (!document_uri) throw new Error('document_uri not available in PostPageContext'); 135 + if (!document_uri) 136 + throw new Error("document_uri not available in PostPageContext"); 135 137 let [url, position] = useMemo(() => { 136 138 let currentUrl = new URL(window.location.href); 137 139 let pos = decodeQuotePosition(props.position); ··· 139 141 currentUrl.pathname = currentUrl.pathname.split("/l-quote/")[0]; 140 142 } 141 143 currentUrl.pathname = currentUrl.pathname + `/l-quote/${props.position}`; 142 - 144 + 143 145 // Clear existing query parameters 144 146 currentUrl.search = ""; 145 147 146 148 currentUrl.hash = `#${pos?.start.block.join(".")}_${pos?.start.offset}`; 147 149 return [currentUrl.toString(), pos]; 148 150 }, [props.position]); 151 + let pubRecord = data.documents_in_publications[0]?.publications?.record as 152 + | PubLeafletPublication.Record 153 + | undefined; 149 154 150 155 return ( 151 156 <> ··· 183 188 <CopyTiny className="shrink-0" /> 184 189 Link 185 190 </button> 186 - <Separator classname="h-4" /> 187 - 188 - {identity?.atp_did && ( 189 - <button 190 - className="flex gap-1 items-center hover:font-bold px-1" 191 - onClick={() => { 192 - if (!position) return; 193 - setInteractionState(document_uri, { 194 - drawer: "comments", 195 - drawerOpen: true, 196 - commentBox: { quote: position }, 197 - }); 198 - }} 199 - > 200 - <CommentTiny /> Comment 201 - </button> 191 + {pubRecord?.preferences?.showComments !== false && identity?.atp_did && ( 192 + <> 193 + <Separator classname="h-4" /> 194 + <button 195 + className="flex gap-1 items-center hover:font-bold px-1" 196 + onClick={() => { 197 + if (!position) return; 198 + setInteractionState(document_uri, { 199 + drawer: "comments", 200 + drawerOpen: true, 201 + commentBox: { quote: position }, 202 + }); 203 + }} 204 + > 205 + <CommentTiny /> Comment 206 + </button> 207 + </> 202 208 )} 203 209 </> 204 210 );
+5 -4
app/lish/[did]/[publication]/[rkey]/page.tsx
··· 26 26 }): Promise<Metadata> { 27 27 let params = await props.params; 28 28 let did = decodeURIComponent(params.did); 29 - let publication = decodeURIComponent(params.publication); 30 29 if (!did) return { title: "Publication 404" }; 31 30 32 31 let [{ data: document }] = await Promise.all([ 33 32 supabaseServerClient 34 33 .from("documents") 35 - .select("*") 34 + .select("*, documents_in_publications(publications(*))") 36 35 .eq("uri", AtUri.make(did, ids.PubLeafletDocument, params.rkey)) 37 36 .single(), 38 37 ]); ··· 41 40 let docRecord = document.data as PubLeafletDocument.Record; 42 41 43 42 return { 44 - title: docRecord.title + " - " + publication, 43 + title: 44 + docRecord.title + 45 + " - " + 46 + document.documents_in_publications[0]?.publications?.name, 45 47 description: docRecord?.description || "", 46 48 }; 47 49 } ··· 162 164 bskyPostData={bskyPostData.data.posts} 163 165 did={did} 164 166 blocks={blocks} 165 - name={decodeURIComponent((await props.params).publication)} 166 167 prerenderedCodeBlocks={prerenderedCodeBlocks} 167 168 /> 168 169 </LeafletLayout>
+3 -2
app/lish/[did]/[publication]/dashboard/DraftList.tsx
··· 46 46 titles={{ 47 47 ...leaflets_in_publications.reduce( 48 48 (acc, leaflet) => { 49 - if (leaflet.title && leaflet.permission_tokens) 50 - acc[leaflet.permission_tokens.root_entity] = leaflet.title; 49 + if (leaflet.permission_tokens) 50 + acc[leaflet.permission_tokens.root_entity] = 51 + leaflet.title || "Untitled"; 51 52 return acc; 52 53 }, 53 54 {} as { [l: string]: string },
+1 -1
app/lish/[did]/[publication]/dashboard/PublicationDashboard.tsx
··· 39 39 return ( 40 40 <DashboardLayout 41 41 id={publication.uri} 42 - hasBackgroundImage={!!record?.theme?.backgroundImage} 42 + cardBorderHidden={!!record.theme?.showPageBackground} 43 43 defaultTab="Drafts" 44 44 tabs={{ 45 45 Drafts: {
-1
app/lish/[did]/[publication]/dashboard/page.tsx
··· 75 75 </PublicationSWRDataProvider> 76 76 ); 77 77 } catch (e) { 78 - console.log(e); 79 78 return <pre>{JSON.stringify(e, undefined, 2)}</pre>; 80 79 } 81 80 }
-2
app/lish/[did]/[publication]/icon.ts
··· 46 46 47 47 let identity = await idResolver.did.resolve(did); 48 48 let service = identity?.service?.find((f) => f.id === "#atproto_pds"); 49 - console.log(identity); 50 49 if (!service) return null; 51 50 let cid = (record.icon.ref as unknown as { $link: string })["$link"]; 52 51 const response = await fetch( ··· 56 55 let resizedImage = await sharp(await blob.arrayBuffer()) 57 56 .resize({ width: 32, height: 32 }) 58 57 .toBuffer(); 59 - console.log("fetched favicon!"); 60 58 return new Response(new Uint8Array(resizedImage), { 61 59 headers: { 62 60 "Content-Type": "image/png",
+1 -1
app/lish/createPub/CreatePubForm.tsx
··· 134 134 </a> 135 135 </p> 136 136 <p className="text-sm text-tertiary font-normal"> 137 - This publication will appear on our public Discover page 137 + You'll be able to change this later! 138 138 </p> 139 139 </div> 140 140 </Checkbox>
+121 -85
app/lish/createPub/UpdatePubForm.tsx
··· 19 19 import { Verification } from "@vercel/sdk/esm/models/getprojectdomainop"; 20 20 import Link from "next/link"; 21 21 import { Checkbox } from "components/Checkbox"; 22 + import type { GetDomainConfigResponseBody } from "@vercel/sdk/esm/models/getdomainconfigop"; 22 23 23 24 export const EditPubForm = () => { 24 25 let { data } = usePublicationData(); ··· 188 189 let [state, setState] = useState< 189 190 | { type: "default" } 190 191 | { type: "addDomain" } 191 - | { type: "domainSettings"; domain: string; verification?: Verification[] } 192 + | { 193 + type: "domainSettings"; 194 + domain: string; 195 + verification?: Verification[]; 196 + config?: GetDomainConfigResponseBody; 197 + } 192 198 >({ type: "default" }); 193 199 let domains = pubData?.publication_domains || []; 194 200 ··· 208 214 ) : state.type === "domainSettings" ? ( 209 215 <DomainSettings 210 216 verification={state.verification} 217 + config={state.config} 211 218 domain={state.domain} 212 219 goBack={() => setState({ type: "default" })} 213 220 /> ··· 223 230 setState({ 224 231 type: "domainSettings", 225 232 domain: d.domain, 226 - verification: v, 233 + verification: v?.verification, 234 + config: v?.config, 227 235 }); 228 236 }} 229 237 /> ··· 312 320 domain: string; 313 321 base_path: string; 314 322 publication_uri: string; 315 - setDomain: (v?: Verification[]) => void; 323 + setDomain: (domain?: { 324 + verification?: Verification[]; 325 + config?: GetDomainConfigResponseBody; 326 + }) => void; 316 327 }) { 317 328 let { data } = useSWR(props.domain, async (domain) => { 318 329 return await callRPC("get_domain_status", { domain }); 319 330 }); 320 331 321 - let pending = data?.config?.misconfigured || data?.error; 322 - console.log(props.domain, data); 332 + let pending = data?.config?.misconfigured || data?.verification; 323 333 324 334 return ( 325 335 <div className="text-sm text-secondary relative w-full "> ··· 329 339 <button 330 340 className="group/pending px-1 py-0.5 flex gap-1 items-center rounded-full hover:bg-accent-1 hover:text-accent-2 hover:outline-accent-1 border-transparent outline-solid outline-transparent selected-outline" 331 341 onClick={() => { 332 - if (data?.error === "Verification_needed") { 333 - props.setDomain(data.verification); 334 - } else { 335 - props.setDomain(); 336 - } 342 + props.setDomain(data); 337 343 }} 338 344 > 339 345 <p className="group-hover/pending:block hidden w-max pl-1 font-bold"> ··· 373 379 374 380 const DomainSettings = (props: { 375 381 domain: string; 382 + config?: GetDomainConfigResponseBody; 376 383 goBack: () => void; 377 384 verification?: Verification[]; 378 385 }) => { 386 + let { data, mutate } = useSWR(props.domain, async (domain) => { 387 + return await callRPC("get_domain_status", { domain }); 388 + }); 379 389 let isSubdomain = props.domain.split(".").length > 2; 380 - if (props.verification) 381 - return ( 382 - <div className="flex flex-col gap-[6px] text-sm"> 383 - <div>{props.domain} is in use on a Vercel account.</div> 384 - <div className="flex gap-3 p-1 border border-border-light rounded-md py-1"> 385 - <div className="flex flex-col "> 386 - <div className="text-tertiary">Type</div> 387 - <div>{props.verification[0].type}</div> 388 - </div> 389 - <div className="flex flex-col"> 390 - <div className="text-tertiary">Name</div> 391 - <div style={{ wordBreak: "break-word" }}> 392 - {props.verification[0].domain} 393 - </div> 394 - </div> 395 - <div className="flex flex-col"> 396 - <div className="text-tertiary">Value</div> 397 - <div style={{ wordBreak: "break-word" }}> 398 - {props.verification?.[0].value} 399 - </div> 400 - </div> 401 - </div> 402 - <div> 403 - <button 404 - className="text-accent-contrast w-fit" 405 - onClick={() => props.goBack()} 406 - > 407 - Back 408 - </button> 409 - </div> 410 - <button className="text-accent-contrast w-fit">verify</button> 411 - </div> 412 - ); 413 - 390 + if (!data) return; 391 + let { config, verification } = data; 392 + if (!config?.misconfigured && !verification) 393 + return <div>This domain is verified!</div>; 414 394 return ( 415 - <div className="flex flex-col gap-[6px] text-sm"> 395 + <div className="flex flex-col gap-[6px] text-sm text-primary"> 416 396 <div> 417 397 To verify this domain, add the following record to your DNS provider for{" "} 418 398 <strong>{props.domain}</strong>. 419 399 </div> 420 - 421 - {isSubdomain ? ( 422 - <div className="flex gap-3 p-1 border border-border-light rounded-md py-1"> 423 - <div className="flex flex-col "> 424 - <div className="text-tertiary">Type</div> 425 - <div>CNAME</div> 426 - </div> 427 - <div className="flex flex-col"> 428 - <div className="text-tertiary">Name</div> 429 - <div style={{ wordBreak: "break-word" }}> 430 - {props.domain.split(".").slice(0, -2).join(".")} 431 - </div> 432 - </div> 433 - <div className="flex flex-col"> 434 - <div className="text-tertiary">Value</div> 435 - <div style={{ wordBreak: "break-word" }}>cname.vercel-dns.com</div> 436 - </div> 437 - </div> 438 - ) : ( 439 - <div className="flex gap-3 px-2 py-1 border border-border-light rounded-md "> 440 - <div className="flex flex-col "> 441 - <div className="text-tertiary">Type</div> 442 - <div>A</div> 443 - </div> 444 - <div className="flex flex-col"> 445 - <div className="text-tertiary">Name</div> 446 - <div>@</div> 447 - </div> 448 - <div className="flex flex-col"> 449 - <div className="text-tertiary">Value</div> 450 - <div>76.76.21.21</div> 451 - </div> 452 - </div> 453 - )} 454 - <button 455 - className="text-accent-contrast w-fit" 456 - onClick={() => props.goBack()} 457 - > 458 - Back 459 - </button> 400 + <table className="border border-border-light rounded-md"> 401 + <thead> 402 + <tr> 403 + <th className="p-1 py-1 text-tertiary">Type</th> 404 + <th className="p-1 py-1 text-tertiary">Name</th> 405 + <th className="p-1 py-1 text-tertiary">Value</th> 406 + </tr> 407 + </thead> 408 + <tbody> 409 + {verification && ( 410 + <tr> 411 + <td className="p-1 py-1"> 412 + <div>{verification[0].type}</div> 413 + </td> 414 + <td className="p-1 py-1"> 415 + <div style={{ wordBreak: "break-word" }}> 416 + {verification[0].domain} 417 + </div> 418 + </td> 419 + <td className="p-1 py-1"> 420 + <div style={{ wordBreak: "break-word" }}> 421 + {verification?.[0].value} 422 + </div> 423 + </td> 424 + </tr> 425 + )} 426 + {config && 427 + (isSubdomain ? ( 428 + <tr> 429 + <td className="p-1 py-1"> 430 + <div>CNAME</div> 431 + </td> 432 + <td className="p-1 py-1"> 433 + <div style={{ wordBreak: "break-word" }}> 434 + {props.domain.split(".").slice(0, -2).join(".")} 435 + </div> 436 + </td> 437 + <td className="p-1 py-1"> 438 + <div style={{ wordBreak: "break-word" }}> 439 + { 440 + config?.recommendedCNAME.sort( 441 + (a, b) => a.rank - b.rank, 442 + )[0].value 443 + } 444 + </div> 445 + </td> 446 + </tr> 447 + ) : ( 448 + <tr> 449 + <td className="p-1 py-1"> 450 + <div>A</div> 451 + </td> 452 + <td className="p-1 py-1"> 453 + <div style={{ wordBreak: "break-word" }}>@</div> 454 + </td> 455 + <td className="p-1 py-1"> 456 + <div style={{ wordBreak: "break-word" }}> 457 + { 458 + config?.recommendedIPv4.sort((a, b) => a.rank - b.rank)[0] 459 + .value[0] 460 + } 461 + </div> 462 + </td> 463 + </tr> 464 + ))} 465 + {config?.configuredBy === "CNAME" && config.recommendedCNAME[0] && ( 466 + <tr></tr> 467 + )} 468 + </tbody> 469 + </table> 470 + <div className="flex flex-row justify-between"> 471 + <button 472 + className="text-accent-contrast w-fit" 473 + onClick={() => props.goBack()} 474 + > 475 + Back 476 + </button> 477 + <VerifyButton verify={() => mutate()} /> 478 + </div> 460 479 </div> 461 480 ); 462 481 }; 482 + 483 + const VerifyButton = (props: { verify: () => Promise<any> }) => { 484 + let [loading, setLoading] = useState(false); 485 + return ( 486 + <button 487 + className="text-accent-contrast w-fit" 488 + onClick={async (e) => { 489 + e.preventDefault(); 490 + setLoading(true); 491 + await props.verify(); 492 + setLoading(false); 493 + }} 494 + > 495 + {loading ? <DotLoader /> : "verify"} 496 + </button> 497 + ); 498 + };
+6 -5
app/lish/subscribeToPublication.ts
··· 49 49 let bsky = new BskyAgent(credentialSession); 50 50 let [prefs, profile, resolveDid] = await Promise.all([ 51 51 bsky.app.bsky.actor.getPreferences(), 52 - bsky.app.bsky.actor.profile.get({ 53 - repo: credentialSession.did!, 54 - rkey: "self", 55 - }), 52 + bsky.app.bsky.actor.profile 53 + .get({ 54 + repo: credentialSession.did!, 55 + rkey: "self", 56 + }) 57 + .catch(), 56 58 idResolver.did.resolve(credentialSession.did!), 57 59 ]); 58 60 if (!identity.bsky_profiles && profile.value) { ··· 72 74 } 73 75 74 76 export async function unsubscribeToPublication(publication: string) { 75 - console.log("calling unsubscribe!"); 76 77 const oauthClient = await createOauthClient(); 77 78 let identity = await getIdentityData(); 78 79 if (!identity || !identity.atp_did) return;
+282
app/reader/ReaderContent.tsx
··· 1 + "use client"; 2 + import { AtUri } from "@atproto/api"; 3 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 4 + import { PubIcon } from "components/ActionBar/Publications"; 5 + import { ButtonPrimary } from "components/Buttons"; 6 + import { CommentTiny } from "components/Icons/CommentTiny"; 7 + import { DiscoverSmall } from "components/Icons/DiscoverSmall"; 8 + import { QuoteTiny } from "components/Icons/QuoteTiny"; 9 + import { Separator } from "components/Layout"; 10 + import { SpeedyLink } from "components/SpeedyLink"; 11 + import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; 12 + import { BaseThemeProvider } from "components/ThemeManager/ThemeProvider"; 13 + import { useSmoker } from "components/Toast"; 14 + import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 15 + import { blobRefToSrc } from "src/utils/blobRefToSrc"; 16 + import { Json } from "supabase/database.types"; 17 + import type { Cursor, Post } from "./getReaderFeed"; 18 + import useSWRInfinite from "swr/infinite"; 19 + import { getReaderFeed } from "./getReaderFeed"; 20 + import { useEffect, useRef } from "react"; 21 + import { useRouter } from "next/navigation"; 22 + import Link from "next/link"; 23 + 24 + export const ReaderContent = (props: { 25 + root_entity: string; 26 + posts: Post[]; 27 + nextCursor: Cursor | null; 28 + }) => { 29 + const getKey = ( 30 + pageIndex: number, 31 + previousPageData: { posts: Post[]; nextCursor: Cursor | null } | null, 32 + ) => { 33 + // Reached the end 34 + if (previousPageData && !previousPageData.nextCursor) return null; 35 + 36 + // First page, we don't have previousPageData 37 + if (pageIndex === 0) return ["reader-feed", null] as const; 38 + 39 + // Add the cursor to the key 40 + return ["reader-feed", previousPageData?.nextCursor] as const; 41 + }; 42 + 43 + const { data, error, size, setSize, isValidating } = useSWRInfinite( 44 + getKey, 45 + ([_, cursor]) => getReaderFeed(cursor), 46 + { 47 + fallbackData: [{ posts: props.posts, nextCursor: props.nextCursor }], 48 + revalidateFirstPage: false, 49 + }, 50 + ); 51 + 52 + const loadMoreRef = useRef<HTMLDivElement>(null); 53 + 54 + // Set up intersection observer to load more when trigger element is visible 55 + useEffect(() => { 56 + const observer = new IntersectionObserver( 57 + (entries) => { 58 + if (entries[0].isIntersecting && !isValidating) { 59 + const hasMore = data && data[data.length - 1]?.nextCursor; 60 + if (hasMore) { 61 + setSize(size + 1); 62 + } 63 + } 64 + }, 65 + { threshold: 0.1 }, 66 + ); 67 + 68 + if (loadMoreRef.current) { 69 + observer.observe(loadMoreRef.current); 70 + } 71 + 72 + return () => observer.disconnect(); 73 + }, [data, size, setSize, isValidating]); 74 + 75 + const allPosts = data ? data.flatMap((page) => page.posts) : []; 76 + 77 + if (allPosts.length === 0 && !isValidating) return <ReaderEmpty />; 78 + 79 + return ( 80 + <div className="flex flex-col gap-3 relative"> 81 + {allPosts.map((p) => ( 82 + <Post {...p} key={p.documents.uri} /> 83 + ))} 84 + {/* Trigger element for loading more posts */} 85 + <div 86 + ref={loadMoreRef} 87 + className="absolute bottom-96 left-0 w-full h-px pointer-events-none" 88 + aria-hidden="true" 89 + /> 90 + {isValidating && ( 91 + <div className="text-center text-tertiary py-4"> 92 + Loading more posts... 93 + </div> 94 + )} 95 + </div> 96 + ); 97 + }; 98 + 99 + const Post = (props: Post) => { 100 + let pubRecord = props.publication.pubRecord as PubLeafletPublication.Record; 101 + 102 + let postRecord = props.documents.data as PubLeafletDocument.Record; 103 + let postUri = new AtUri(props.documents.uri); 104 + 105 + let theme = usePubTheme(pubRecord); 106 + let backgroundImage = pubRecord?.theme?.backgroundImage?.image?.ref 107 + ? blobRefToSrc( 108 + pubRecord?.theme?.backgroundImage?.image?.ref, 109 + new AtUri(props.publication.uri).host, 110 + ) 111 + : null; 112 + 113 + let backgroundImageRepeat = pubRecord?.theme?.backgroundImage?.repeat; 114 + let backgroundImageSize = pubRecord?.theme?.backgroundImage?.width || 500; 115 + 116 + let showPageBackground = pubRecord.theme?.showPageBackground; 117 + 118 + let quotes = props.documents.document_mentions_in_bsky?.[0]?.count || 0; 119 + let comments = 120 + pubRecord.preferences?.showComments === false 121 + ? 0 122 + : props.documents.comments_on_documents?.[0]?.count || 0; 123 + 124 + return ( 125 + <BaseThemeProvider {...theme} local> 126 + <div 127 + style={{ 128 + backgroundImage: `url(${backgroundImage})`, 129 + backgroundRepeat: backgroundImageRepeat ? "repeat" : "no-repeat", 130 + backgroundSize: `${backgroundImageRepeat ? `${backgroundImageSize}px` : "cover"}`, 131 + }} 132 + className={`no-underline! flex flex-row gap-2 w-full relative 133 + bg-bg-leaflet 134 + border border-border-light rounded-lg 135 + sm:p-2 p-2 selected-outline 136 + hover:outline-accent-contrast hover:border-accent-contrast 137 + `} 138 + > 139 + <a 140 + className="h-full w-full absolute top-0 left-0" 141 + href={`${props.publication.href}/${postUri.rkey}`} 142 + /> 143 + <div 144 + className={`${showPageBackground ? "bg-bg-page " : "bg-transparent"} rounded-md w-full px-[10px] pt-2 pb-2`} 145 + style={{ 146 + backgroundColor: showPageBackground 147 + ? "rgba(var(--bg-page), var(--bg-page-alpha))" 148 + : "transparent", 149 + }} 150 + > 151 + <h3 className="text-primary truncate">{postRecord.title}</h3> 152 + 153 + <p className="text-secondary">{postRecord.description}</p> 154 + <div className="flex gap-2 justify-between items-end"> 155 + <div className="flex flex-col-reverse md:flex-row md gap-3 md:gap-2 text-sm text-tertiary items-start justify-start pt-1 md:pt-3"> 156 + <PubInfo 157 + href={props.publication.href} 158 + pubRecord={pubRecord} 159 + uri={props.publication.uri} 160 + /> 161 + <Separator classname="h-4 !min-h-0 md:block hidden" /> 162 + <PostInfo 163 + author={props.author || ""} 164 + publishedAt={postRecord.publishedAt} 165 + /> 166 + </div> 167 + 168 + <PostInterations 169 + postUrl={`${props.publication.href}/${postUri.rkey}`} 170 + quotesCount={quotes} 171 + commentsCount={comments} 172 + showComments={pubRecord.preferences?.showComments} 173 + /> 174 + </div> 175 + </div> 176 + </div> 177 + </BaseThemeProvider> 178 + ); 179 + }; 180 + 181 + const PubInfo = (props: { 182 + href: string; 183 + pubRecord: PubLeafletPublication.Record; 184 + uri: string; 185 + }) => { 186 + return ( 187 + <a 188 + href={props.href} 189 + className="text-accent-contrast font-bold no-underline text-sm flex gap-1 items-center md:w-fit w-full relative shrink-0" 190 + > 191 + <PubIcon small record={props.pubRecord} uri={props.uri} /> 192 + {props.pubRecord.name} 193 + </a> 194 + ); 195 + }; 196 + 197 + const PostInfo = (props: { 198 + author: string; 199 + publishedAt: string | undefined; 200 + }) => { 201 + return ( 202 + <div className="flex flex-wrap gap-2 grow items-center shrink-0"> 203 + {props.author} 204 + {props.publishedAt && ( 205 + <> 206 + <Separator classname="h-4 !min-h-0" /> 207 + {new Date(props.publishedAt).toLocaleDateString("en-US", { 208 + year: "numeric", 209 + month: "short", 210 + day: "numeric", 211 + })}{" "} 212 + </> 213 + )} 214 + </div> 215 + ); 216 + }; 217 + 218 + const PostInterations = (props: { 219 + quotesCount: number; 220 + commentsCount: number; 221 + postUrl: string; 222 + showComments: boolean | undefined; 223 + }) => { 224 + let smoker = useSmoker(); 225 + let interactionsAvailable = 226 + props.quotesCount > 0 || 227 + (props.showComments !== false && props.commentsCount > 0); 228 + 229 + return ( 230 + <div className={`flex gap-2 text-tertiary text-sm items-center`}> 231 + {props.quotesCount === 0 ? null : ( 232 + <div className={`flex gap-1 items-center `}> 233 + <span className="sr-only">Post quotes</span> 234 + <QuoteTiny aria-hidden /> {props.quotesCount} 235 + </div> 236 + )} 237 + {props.showComments === false || props.commentsCount === 0 ? null : ( 238 + <div className={`flex gap-1 items-center`}> 239 + <span className="sr-only">Post comments</span> 240 + <CommentTiny aria-hidden /> {props.commentsCount} 241 + </div> 242 + )} 243 + {interactionsAvailable && <Separator classname="h-4 !min-h-0" />} 244 + <button 245 + id={`copy-post-link-${props.postUrl}`} 246 + className="flex gap-1 items-center hover:font-bold relative" 247 + onClick={(e) => { 248 + e.stopPropagation(); 249 + e.preventDefault(); 250 + let mouseX = e.clientX; 251 + let mouseY = e.clientY; 252 + 253 + if (!props.postUrl) return; 254 + navigator.clipboard.writeText(`leaflet.pub${props.postUrl}`); 255 + 256 + smoker({ 257 + text: <strong>Copied Link!</strong>, 258 + position: { 259 + y: mouseY, 260 + x: mouseX, 261 + }, 262 + }); 263 + }} 264 + > 265 + Share 266 + </button> 267 + </div> 268 + ); 269 + }; 270 + export const ReaderEmpty = () => { 271 + return ( 272 + <div className="flex flex-col gap-2 container bg-[rgba(var(--bg-page),.7)] sm:p-4 p-3 justify-between text-center text-tertiary"> 273 + Nothing to read yet… <br /> 274 + Subscribe to publications and find their posts here! 275 + <Link href={"/discover"}> 276 + <ButtonPrimary className="mx-auto place-self-center"> 277 + <DiscoverSmall /> Discover Publications 278 + </ButtonPrimary> 279 + </Link> 280 + </div> 281 + ); 282 + };
+105
app/reader/SubscriptionsContent.tsx
··· 1 + "use client"; 2 + import { PubListing } from "app/discover/PubListing"; 3 + import { ButtonPrimary } from "components/Buttons"; 4 + import { DiscoverSmall } from "components/Icons/DiscoverSmall"; 5 + import { Json } from "supabase/database.types"; 6 + import { PublicationSubscription, getSubscriptions } from "./getSubscriptions"; 7 + import useSWRInfinite from "swr/infinite"; 8 + import { useEffect, useRef } from "react"; 9 + import { Cursor } from "./getReaderFeed"; 10 + import Link from "next/link"; 11 + 12 + export const SubscriptionsContent = (props: { 13 + publications: PublicationSubscription[]; 14 + nextCursor: Cursor | null; 15 + }) => { 16 + const getKey = ( 17 + pageIndex: number, 18 + previousPageData: { 19 + subscriptions: PublicationSubscription[]; 20 + nextCursor: Cursor | null; 21 + } | null, 22 + ) => { 23 + // Reached the end 24 + if (previousPageData && !previousPageData.nextCursor) return null; 25 + 26 + // First page, we don't have previousPageData 27 + if (pageIndex === 0) return ["subscriptions", null] as const; 28 + 29 + // Add the cursor to the key 30 + return ["subscriptions", previousPageData?.nextCursor] as const; 31 + }; 32 + 33 + const { data, error, size, setSize, isValidating } = useSWRInfinite( 34 + getKey, 35 + ([_, cursor]) => getSubscriptions(cursor), 36 + { 37 + fallbackData: [ 38 + { subscriptions: props.publications, nextCursor: props.nextCursor }, 39 + ], 40 + revalidateFirstPage: false, 41 + }, 42 + ); 43 + 44 + const loadMoreRef = useRef<HTMLDivElement>(null); 45 + 46 + // Set up intersection observer to load more when trigger element is visible 47 + useEffect(() => { 48 + const observer = new IntersectionObserver( 49 + (entries) => { 50 + if (entries[0].isIntersecting && !isValidating) { 51 + const hasMore = data && data[data.length - 1]?.nextCursor; 52 + if (hasMore) { 53 + setSize(size + 1); 54 + } 55 + } 56 + }, 57 + { threshold: 0.1 }, 58 + ); 59 + 60 + if (loadMoreRef.current) { 61 + observer.observe(loadMoreRef.current); 62 + } 63 + 64 + return () => observer.disconnect(); 65 + }, [data, size, setSize, isValidating]); 66 + 67 + const allPublications = data 68 + ? data.flatMap((page) => page.subscriptions) 69 + : []; 70 + 71 + if (allPublications.length === 0 && !isValidating) 72 + return <SubscriptionsEmpty />; 73 + 74 + return ( 75 + <div className="relative"> 76 + <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 gap-3"> 77 + {allPublications?.map((p, index) => <PubListing key={p.uri} {...p} />)} 78 + </div> 79 + {/* Trigger element for loading more subscriptions */} 80 + <div 81 + ref={loadMoreRef} 82 + className="absolute bottom-96 left-0 w-full h-px pointer-events-none" 83 + aria-hidden="true" 84 + /> 85 + {isValidating && ( 86 + <div className="text-center text-tertiary py-4"> 87 + Loading more subscriptions... 88 + </div> 89 + )} 90 + </div> 91 + ); 92 + }; 93 + 94 + export const SubscriptionsEmpty = () => { 95 + return ( 96 + <div className="flex flex-col gap-2 container bg-[rgba(var(--bg-page),.7)] sm:p-4 p-3 justify-between text-center text-tertiary"> 97 + You haven't subscribed to any publications yet! 98 + <Link href={"/discover"}> 99 + <ButtonPrimary className="mx-auto place-self-center"> 100 + <DiscoverSmall /> Discover Publications 101 + </ButtonPrimary> 102 + </Link> 103 + </div> 104 + ); 105 + };
+106
app/reader/getReaderFeed.ts
··· 1 + "use server"; 2 + 3 + import { getIdentityData } from "actions/getIdentityData"; 4 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 5 + import { supabaseServerClient } from "supabase/serverClient"; 6 + import { IdResolver } from "@atproto/identity"; 7 + import type { DidCache, CacheResult, DidDocument } from "@atproto/identity"; 8 + import Client from "ioredis"; 9 + import { AtUri } from "@atproto/api"; 10 + import { Json } from "supabase/database.types"; 11 + import { idResolver } from "./idResolver"; 12 + 13 + export type Cursor = { 14 + timestamp: string; 15 + uri: string; 16 + }; 17 + 18 + export async function getReaderFeed( 19 + cursor?: Cursor | null, 20 + ): Promise<{ posts: Post[]; nextCursor: Cursor | null }> { 21 + let auth_res = await getIdentityData(); 22 + if (!auth_res?.atp_did) return { posts: [], nextCursor: null }; 23 + let query = supabaseServerClient 24 + .from("documents") 25 + .select( 26 + `*, 27 + comments_on_documents(count), 28 + document_mentions_in_bsky(count), 29 + documents_in_publications!inner(publications!inner(*, publication_subscriptions!inner(*)))`, 30 + ) 31 + .eq( 32 + "documents_in_publications.publications.publication_subscriptions.identity", 33 + auth_res.atp_did, 34 + ) 35 + .order("indexed_at", { ascending: false }) 36 + .order("uri", { ascending: false }) 37 + .limit(25); 38 + if (cursor) { 39 + query = query.or( 40 + `indexed_at.lt.${cursor.timestamp},and(indexed_at.eq.${cursor.timestamp},uri.lt.${cursor.uri})`, 41 + ); 42 + } 43 + let { data: feed, error } = await query; 44 + 45 + let posts = await Promise.all( 46 + feed?.map(async (post) => { 47 + let pub = post.documents_in_publications[0].publications!; 48 + let uri = new AtUri(post.uri); 49 + let handle = await idResolver.did.resolve(uri.host); 50 + let p: Post = { 51 + publication: { 52 + href: getPublicationURL(pub), 53 + pubRecord: pub?.record || null, 54 + uri: pub?.uri || "", 55 + }, 56 + author: handle?.alsoKnownAs?.[0] 57 + ? `@${handle.alsoKnownAs[0].slice(5)}` 58 + : null, 59 + documents: { 60 + comments_on_documents: post.comments_on_documents, 61 + document_mentions_in_bsky: post.document_mentions_in_bsky, 62 + data: post.data, 63 + uri: post.uri, 64 + indexed_at: post.indexed_at, 65 + }, 66 + }; 67 + return p; 68 + }) || [], 69 + ); 70 + const nextCursor = 71 + posts.length > 0 72 + ? { 73 + timestamp: posts[posts.length - 1].documents.indexed_at, 74 + uri: posts[posts.length - 1].documents.uri, 75 + } 76 + : null; 77 + 78 + return { 79 + posts, 80 + nextCursor, 81 + }; 82 + } 83 + 84 + export type Post = { 85 + author: string | null; 86 + publication: { 87 + href: string; 88 + pubRecord: Json; 89 + uri: string; 90 + }; 91 + documents: { 92 + data: Json; 93 + uri: string; 94 + indexed_at: string; 95 + comments_on_documents: 96 + | { 97 + count: number; 98 + }[] 99 + | undefined; 100 + document_mentions_in_bsky: 101 + | { 102 + count: number; 103 + }[] 104 + | undefined; 105 + }; 106 + };
+70
app/reader/getSubscriptions.ts
··· 1 + "use server"; 2 + 3 + import { AtpAgent } from "@atproto/api"; 4 + import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 5 + import { getIdentityData } from "actions/getIdentityData"; 6 + import { Json } from "supabase/database.types"; 7 + import { supabaseServerClient } from "supabase/serverClient"; 8 + import { idResolver } from "./idResolver"; 9 + import { Cursor } from "./getReaderFeed"; 10 + 11 + export async function getSubscriptions(cursor?: Cursor | null): Promise<{ 12 + nextCursor: null | Cursor; 13 + subscriptions: PublicationSubscription[]; 14 + }> { 15 + let auth_res = await getIdentityData(); 16 + if (!auth_res?.atp_did) return { subscriptions: [], nextCursor: null }; 17 + let query = supabaseServerClient 18 + .from("publication_subscriptions") 19 + .select(`*, publications(*, documents_in_publications(*, documents(*)))`) 20 + .order(`created_at`, { ascending: false }) 21 + .order(`uri`, { ascending: false }) 22 + .order("indexed_at", { 23 + ascending: false, 24 + referencedTable: "publications.documents_in_publications", 25 + }) 26 + .limit(1, { referencedTable: "publications.documents_in_publications" }) 27 + .limit(25) 28 + .eq("identity", auth_res.atp_did); 29 + 30 + if (cursor) { 31 + query = query.or( 32 + `created_at.lt.${cursor.timestamp},and(created_at.eq.${cursor.timestamp},uri.lt.${cursor.uri})`, 33 + ); 34 + } 35 + let { data: pubs, error } = await query; 36 + 37 + const hydratedSubscriptions: PublicationSubscription[] = await Promise.all( 38 + pubs?.map(async (pub) => { 39 + let id = await idResolver.did.resolve(pub.publications?.identity_did!); 40 + return { 41 + ...pub.publications!, 42 + authorProfile: id?.alsoKnownAs?.[0] 43 + ? { handle: `@${id.alsoKnownAs[0].slice(5)}` } 44 + : undefined, 45 + }; 46 + }) || [], 47 + ); 48 + 49 + const nextCursor = 50 + pubs && pubs.length > 0 51 + ? { 52 + timestamp: pubs[pubs.length - 1].created_at, 53 + uri: pubs[pubs.length - 1].uri, 54 + } 55 + : null; 56 + 57 + return { 58 + subscriptions: hydratedSubscriptions, 59 + nextCursor, 60 + }; 61 + } 62 + 63 + export type PublicationSubscription = { 64 + authorProfile?: { handle: string }; 65 + record: Json; 66 + uri: string; 67 + documents_in_publications: { 68 + documents: { data?: Json; indexed_at: string } | null; 69 + }[]; 70 + };
+78
app/reader/idResolver.ts
··· 1 + import { IdResolver } from "@atproto/identity"; 2 + import type { DidCache, CacheResult, DidDocument } from "@atproto/identity"; 3 + import Client from "ioredis"; 4 + // Create Redis client for DID caching 5 + let redisClient: Client | null = null; 6 + if (process.env.REDIS_URL) { 7 + redisClient = new Client(process.env.REDIS_URL); 8 + } 9 + 10 + // Redis-based DID cache implementation 11 + class RedisDidCache implements DidCache { 12 + private staleTTL: number; 13 + private maxTTL: number; 14 + 15 + constructor( 16 + private client: Client, 17 + staleTTL = 60 * 60, // 1 hour 18 + maxTTL = 60 * 60 * 24, // 24 hours 19 + ) { 20 + this.staleTTL = staleTTL; 21 + this.maxTTL = maxTTL; 22 + } 23 + 24 + async cacheDid(did: string, doc: DidDocument): Promise<void> { 25 + const cacheVal = { 26 + doc, 27 + updatedAt: Date.now(), 28 + }; 29 + await this.client.setex( 30 + `did:${did}`, 31 + this.maxTTL, 32 + JSON.stringify(cacheVal), 33 + ); 34 + } 35 + 36 + async checkCache(did: string): Promise<CacheResult | null> { 37 + const cached = await this.client.get(`did:${did}`); 38 + if (!cached) return null; 39 + 40 + const { doc, updatedAt } = JSON.parse(cached); 41 + const now = Date.now(); 42 + const age = now - updatedAt; 43 + 44 + return { 45 + did, 46 + doc, 47 + updatedAt, 48 + stale: age > this.staleTTL * 1000, 49 + expired: age > this.maxTTL * 1000, 50 + }; 51 + } 52 + 53 + async refreshCache( 54 + did: string, 55 + getDoc: () => Promise<DidDocument | null>, 56 + ): Promise<void> { 57 + const doc = await getDoc(); 58 + if (doc) { 59 + await this.cacheDid(did, doc); 60 + } 61 + } 62 + 63 + async clearEntry(did: string): Promise<void> { 64 + await this.client.del(`did:${did}`); 65 + } 66 + 67 + async clear(): Promise<void> { 68 + const keys = await this.client.keys("did:*"); 69 + if (keys.length > 0) { 70 + await this.client.del(...keys); 71 + } 72 + } 73 + } 74 + 75 + // Create IdResolver with Redis-based DID cache 76 + export const idResolver = new IdResolver({ 77 + didCache: redisClient ? new RedisDidCache(redisClient) : undefined, 78 + });
+104
app/reader/page.tsx
··· 1 + import { cookies } from "next/headers"; 2 + import { Fact, ReplicacheProvider } from "src/replicache"; 3 + import type { Attribute } from "src/replicache/attributes"; 4 + import { 5 + ThemeBackgroundProvider, 6 + ThemeProvider, 7 + } from "components/ThemeManager/ThemeProvider"; 8 + import { EntitySetProvider } from "components/EntitySetProvider"; 9 + import { getIdentityData } from "actions/getIdentityData"; 10 + import { supabaseServerClient } from "supabase/serverClient"; 11 + 12 + import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 13 + import { DashboardLayout } from "components/PageLayouts/DashboardLayout"; 14 + import { ReaderContent, ReaderEmpty } from "./ReaderContent"; 15 + import { 16 + SubscriptionsContent, 17 + SubscriptionsEmpty, 18 + } from "./SubscriptionsContent"; 19 + import { getReaderFeed } from "./getReaderFeed"; 20 + import { getSubscriptions } from "./getSubscriptions"; 21 + 22 + export default async function Reader(props: {}) { 23 + let cookieStore = await cookies(); 24 + let auth_res = await getIdentityData(); 25 + let identity: string | undefined; 26 + let permission_token = auth_res?.home_leaflet; 27 + if (!permission_token) 28 + return ( 29 + <DashboardLayout 30 + id="reader" 31 + cardBorderHidden={false} 32 + currentPage="reader" 33 + defaultTab="Read" 34 + actions={null} 35 + tabs={{ 36 + Read: { 37 + controls: null, 38 + content: <ReaderEmpty />, 39 + }, 40 + Subscriptions: { 41 + controls: null, 42 + content: <SubscriptionsEmpty />, 43 + }, 44 + }} 45 + /> 46 + ); 47 + let [homeLeafletFacts] = await Promise.all([ 48 + supabaseServerClient.rpc("get_facts", { 49 + root: permission_token.root_entity, 50 + }), 51 + ]); 52 + let initialFacts = 53 + (homeLeafletFacts.data as unknown as Fact<Attribute>[]) || []; 54 + let root_entity = permission_token.root_entity; 55 + 56 + if (!auth_res?.atp_did) return; 57 + let posts = await getReaderFeed(); 58 + let publications = await getSubscriptions(); 59 + return ( 60 + <ReplicacheProvider 61 + rootEntity={root_entity} 62 + token={permission_token} 63 + name={root_entity} 64 + initialFacts={initialFacts} 65 + > 66 + <EntitySetProvider 67 + set={permission_token.permission_token_rights[0].entity_set} 68 + > 69 + <ThemeProvider entityID={root_entity}> 70 + <ThemeBackgroundProvider entityID={root_entity}> 71 + <DashboardLayout 72 + id="reader" 73 + cardBorderHidden={false} 74 + currentPage="reader" 75 + defaultTab="Read" 76 + actions={null} 77 + tabs={{ 78 + Read: { 79 + controls: null, 80 + content: ( 81 + <ReaderContent 82 + root_entity={root_entity} 83 + nextCursor={posts.nextCursor} 84 + posts={posts.posts} 85 + /> 86 + ), 87 + }, 88 + Subscriptions: { 89 + controls: null, 90 + content: ( 91 + <SubscriptionsContent 92 + publications={publications.subscriptions} 93 + nextCursor={publications.nextCursor} 94 + /> 95 + ), 96 + }, 97 + }} 98 + /> 99 + </ThemeBackgroundProvider> 100 + </ThemeProvider> 101 + </EntitySetProvider> 102 + </ReplicacheProvider> 103 + ); 104 + }
-4
appview/bumpCursor.js
··· 7 7 } else { 8 8 let newCursor = (cursor + 300 * 60 * 60 * 12).toString(); 9 9 writeFileSync(cursorFile, (cursor + 300 * 60 * 60 * 12).toString()); 10 - console.log(` 11 - old cursor: ${cursor} 12 - new cursor: ${newCursor} 13 - `); 14 10 }
+30 -18
components/ActionBar/Navigation.tsx
··· 7 7 import { PublicationButtons } from "./Publications"; 8 8 import { Popover } from "components/Popover"; 9 9 import { MenuSmall } from "components/Icons/MenuSmall"; 10 + import { 11 + ReaderReadSmall, 12 + ReaderUnreadSmall, 13 + } from "components/Icons/ReaderSmall"; 10 14 11 15 export type navPages = "home" | "reader" | "pub" | "discover"; 12 16 ··· 14 18 currentPage: navPages; 15 19 publication?: string; 16 20 }) => { 17 - let unreadNotifications = true; 18 - 19 21 return ( 20 22 <div className="flex flex-col gap-4"> 21 23 <Sidebar alwaysOpen> ··· 89 91 return ( 90 92 <> 91 93 <HomeButton current={props.currentPage === "home"} /> 92 - <ReaderButton current={props.currentPage === "discover"} /> 94 + <ReaderButton 95 + current={props.currentPage === "reader"} 96 + subs={identity?.publication_subscriptions?.length !== 0} 97 + /> 98 + <DiscoverButton current={props.currentPage === "discover"} /> 99 + 93 100 <hr className="border-border-light my-1" /> 94 101 <PublicationButtons currentPubUri={thisPublication?.uri} /> 95 102 </> ··· 109 116 ); 110 117 }; 111 118 112 - const ReaderButton = (props: { current?: boolean }) => { 113 - // let readerUnreads = true; 114 - // let subs = false; 119 + const ReaderButton = (props: { current?: boolean; subs: boolean }) => { 120 + let readerUnreads = false; 115 121 116 - // if (subs) 117 - // return ( 118 - // <ActionButton 119 - // nav 120 - // icon={readerUnreads ? <ReaderUnreadSmall /> : <ReaderReadSmall />} 121 - // label="Reader" 122 - // className={` 123 - // ${readerUnreads ? "text-accent-contrast! border-accent-contrast" : props.current ? "bg-border-light! border-border" : ""} 124 - // `} 125 - // /> 126 - // ); 122 + if (!props.subs) return; 123 + return ( 124 + <Link href={"/reader"} className="hover:no-underline!"> 125 + <ActionButton 126 + nav 127 + icon={readerUnreads ? <ReaderUnreadSmall /> : <ReaderReadSmall />} 128 + label="Reader" 129 + className={` 130 + ${readerUnreads && "text-accent-contrast!"} 131 + ${props.current && "border-accent-contrast!"} 132 + `} 133 + /> 134 + </Link> 135 + ); 136 + }; 137 + 138 + const DiscoverButton = (props: { current?: boolean }) => { 127 139 return ( 128 140 <Link href={"/discover"} className="hover:no-underline!"> 129 141 <ActionButton 130 142 nav 131 143 icon={<DiscoverSmall />} 132 144 label="Discover" 133 - subtext={"Explore publications!"} 145 + subtext="" 134 146 className={props.current ? "bg-bg-page! border-border-light!" : ""} 135 147 /> 136 148 </Link>
+13 -7
components/ActionBar/Publications.tsx
··· 24 24 {identity.publications?.map((d) => { 25 25 // console.log("thisURI : " + d.uri); 26 26 // console.log("currentURI : " + props.currentPubUri); 27 - console.log(d.uri === props.currentPubUri); 28 27 29 28 return ( 30 29 <PublicationOption ··· 37 36 ); 38 37 })} 39 38 <Link 40 - href={"./lish/createPub"} 39 + href={"/lish/createPub"} 41 40 className="pubListCreateNew text-accent-contrast text-sm place-self-end hover:text-accent-contrast" 42 41 > 43 42 New ··· 82 81 return ( 83 82 <SpeedyLink href={`lish/createPub`} className=" hover:no-underline!"> 84 83 <ActionButton 85 - label="Publish on ATP" 84 + label="Publish" 86 85 icon={<PublishSmall />} 87 86 nav 88 - subtext="Start a blog in the Atmosphere" 87 + subtext="Blog on ATProto!" 89 88 /> 90 89 </SpeedyLink> 91 90 ); ··· 94 93 export const PubIcon = (props: { 95 94 record: PubLeafletPublication.Record; 96 95 uri: string; 96 + small?: boolean; 97 + large?: boolean; 98 + className?: string; 97 99 }) => { 98 100 if (!props.record) return; 99 101 102 + let iconSizeClassName = `${props.small ? "w-4 h-4" : props.large ? "w-12 h-12" : "w-6 h-6"} rounded-full`; 103 + 100 104 return props.record.icon ? ( 101 105 <div 102 106 style={{ ··· 105 109 backgroundSize: "cover", 106 110 backgroundImage: `url(/api/atproto_images?did=${new AtUri(props.uri).host}&cid=${(props.record.icon?.ref as unknown as { $link: string })["$link"]})`, 107 111 }} 108 - className="w-6 h-6 rounded-full" 112 + className={`${iconSizeClassName} ${props.className}`} 109 113 /> 110 114 ) : ( 111 - <div className="w-6 h-6 rounded-full bg-accent-1 relative"> 112 - <div className="font-bold text-sm absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-accent-2"> 115 + <div className={`${iconSizeClassName} bg-accent-1 relative`}> 116 + <div 117 + className={`${props.small ? "text-xs" : props.large ? "text-2xl" : "text-sm"} font-bold absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-accent-2`} 118 + > 113 119 {props.record?.name.slice(0, 1)} 114 120 </div> 115 121 </div>
+2 -1
components/Blocks/Block.tsx
··· 30 30 import { CodeBlock } from "./CodeBlock"; 31 31 import { HorizontalRule } from "./HorizontalRule"; 32 32 import { deepEquals } from "src/utils/deepEquals"; 33 + import { isTextBlock } from "src/utils/isTextBlock"; 33 34 34 35 export type Block = { 35 36 factID: string; ··· 63 64 64 65 let mouseHandlers = useBlockMouseHandlers(props); 65 66 66 - // focus block on longpress, shouldnt the type be based on the block type (?) 67 67 let { isLongPress, handlers } = useLongPress(() => { 68 + if (isTextBlock[props.type]) return; 68 69 if (isLongPress.current) { 69 70 focusBlock( 70 71 { type: props.type, value: props.entityID, parent: props.parent },
+1
components/Blocks/PageLinkBlock.tsx
··· 182 182 return ( 183 183 <div 184 184 ref={previewRef} 185 + inert 185 186 className={`pageLinkBlockPreview w-[120px] overflow-clip mx-3 mt-3 -mb-2 border rounded-md shrink-0 border-border-light flex flex-col gap-0.5 rotate-[4deg] origin-center ${cardBorderHidden ? "" : "bg-bg-page"}`} 186 187 > 187 188 <div
+1 -1
components/Blocks/TextBlock/RenderYJSFragment.tsx
··· 32 32 node.toArray().map((node, index) => { 33 33 if (node.constructor === XmlText) { 34 34 let deltas = node.toDelta() as Delta[]; 35 - if (deltas.length === 0) return <br />; 35 + if (deltas.length === 0) return <br key={index} />; 36 36 return ( 37 37 <Fragment key={index}> 38 38 {deltas.map((d, index) => {
+9 -7
components/Blocks/TextBlock/index.tsx
··· 257 257 let oldEditorState = this.state; 258 258 let newState = this.state.apply(tr); 259 259 let addToHistory = tr.getMeta("addToHistory"); 260 + let isBulkOp = tr.getMeta("bulkOp"); 260 261 let docHasChanges = tr.steps.length !== 0 || tr.docChanged; 261 262 if (addToHistory !== false && docHasChanges) { 262 263 if (actionTimeout.current) { 263 264 window.clearTimeout(actionTimeout.current); 264 265 } else { 265 - rep.undoManager.startGroup(); 266 + if (!isBulkOp) rep.undoManager.startGroup(); 266 267 } 267 268 268 - actionTimeout.current = window.setTimeout(() => { 269 - rep.undoManager.endGroup(); 270 - actionTimeout.current = null; 271 - }, 200); 269 + if (!isBulkOp) 270 + actionTimeout.current = window.setTimeout(() => { 271 + rep.undoManager.endGroup(); 272 + actionTimeout.current = null; 273 + }, 200); 272 274 rep.undoManager.add({ 273 275 redo: () => { 274 276 useEditorStates.setState((oldState) => { 275 277 let view = oldState.editorStates[props.entityID]?.view; 276 - if (!view?.hasFocus()) view?.focus(); 278 + if (!view?.hasFocus() && !isBulkOp) view?.focus(); 277 279 return { 278 280 editorStates: { 279 281 ...oldState.editorStates, ··· 288 290 undo: () => { 289 291 useEditorStates.setState((oldState) => { 290 292 let view = oldState.editorStates[props.entityID]?.view; 291 - if (!view?.hasFocus()) view?.focus(); 293 + if (!view?.hasFocus() && !isBulkOp) view?.focus(); 292 294 return { 293 295 editorStates: { 294 296 ...oldState.editorStates,
+1 -1
components/Blocks/TextBlock/useHandlePaste.ts
··· 389 389 let oldEntityID = child.getAttribute("data-entityid") as string; 390 390 let factsData = child.getAttribute("data-facts"); 391 391 if (factsData) { 392 - let facts = JSON.parse(atob(factsData)) as Fact<any>[]; 392 + let facts = JSON.parse(factsData) as Fact<any>[]; 393 393 394 394 let oldEntityIDToNewID = {} as { [k: string]: string }; 395 395 let oldEntities = facts.reduce((acc, f) => {
+19
components/Icons/ExternalLinkTiny.tsx
··· 1 + import { Props } from "./Props"; 2 + 3 + export const ExternalLinkTiny = (props: Props) => { 4 + return ( 5 + <svg 6 + width="16" 7 + height="16" 8 + viewBox="0 0 16 16" 9 + fill="none" 10 + xmlns="http://www.w3.org/2000/svg" 11 + {...props} 12 + > 13 + <path 14 + d="M4.94336 2.26074C5.22793 2.3192 5.44238 2.57118 5.44238 2.87305C5.44227 3.17482 5.22788 3.42693 4.94336 3.48535L4.81738 3.49805H4.37305C3.61366 3.49805 2.99805 4.11366 2.99805 4.87305V11.627C2.99818 12.3862 3.61374 13.002 4.37305 13.002H11.127C11.8863 13.002 12.5018 12.3862 12.502 11.627V11.1055C12.5021 10.7605 12.7819 10.4805 13.127 10.4805C13.472 10.4805 13.7518 10.7605 13.752 11.1055V11.627C13.7518 13.0766 12.5766 14.252 11.127 14.252H4.37305C2.92338 14.252 1.74818 13.0766 1.74805 11.627V4.87305C1.74805 3.4233 2.9233 2.24805 4.37305 2.24805H4.81738L4.94336 2.26074ZM13.127 1.74805C13.7482 1.74809 14.252 2.25176 14.252 2.87305V8.04199C14.2518 8.66317 13.7482 9.16694 13.127 9.16699C12.5058 9.16693 12.0021 8.66316 12.002 8.04199V5.58887L7.25488 10.3359L7.16895 10.4141C6.7271 10.774 6.07483 10.7476 5.66309 10.3359C5.22426 9.89664 5.22413 9.18433 5.66309 8.74512L10.4102 3.99805H7.95703C7.33606 3.99774 6.83216 3.49406 6.83203 2.87305C6.83203 2.25192 7.33597 1.74836 7.95703 1.74805H13.127Z" 15 + fill="currentColor" 16 + /> 17 + </svg> 18 + ); 19 + };
+12 -10
components/PageHeader.tsx
··· 3 3 4 4 export const Header = (props: { 5 5 children: React.ReactNode; 6 - hasBackgroundImage: boolean; 6 + cardBorderHidden: boolean; 7 7 }) => { 8 8 let [scrollPos, setScrollPos] = useState(0); 9 9 ··· 22 22 } 23 23 }, []); 24 24 25 - let headerBGColor = props.hasBackgroundImage 26 - ? "var(--bg-page)" 27 - : "var(--bg-leaflet)"; 25 + let headerBGColor = props.cardBorderHidden 26 + ? "var(--bg-leaflet)" 27 + : "var(--bg-page)"; 28 28 29 29 return ( 30 30 <div ··· 54 54 style={ 55 55 scrollPos < 20 56 56 ? { 57 - backgroundColor: `rgba(${headerBGColor}, ${scrollPos / 60 + 0.75})`, 58 - paddingLeft: props.hasBackgroundImage 59 - ? "8px" 60 - : `calc(${scrollPos / 20}*8px)`, 61 - paddingRight: props.hasBackgroundImage 57 + backgroundColor: props.cardBorderHidden 58 + ? `rgba(${headerBGColor}, ${scrollPos / 60 + 0.75})` 59 + : `rgba(${headerBGColor}, ${scrollPos / 20})`, 60 + paddingLeft: props.cardBorderHidden 61 + ? "4px" 62 + : `calc(${scrollPos / 20}*4px)`, 63 + paddingRight: props.cardBorderHidden 62 64 ? "8px" 63 65 : `calc(${scrollPos / 20}*8px)`, 64 66 } 65 67 : { 66 68 backgroundColor: `rgb(${headerBGColor})`, 67 - paddingLeft: "8px", 69 + paddingLeft: "4px", 68 70 paddingRight: "8px", 69 71 } 70 72 }
+49 -15
components/PageLayouts/DashboardLayout.tsx
··· 1 1 "use client"; 2 - import { useState, createContext, useContext } from "react"; 2 + import { useState, createContext, useContext, useEffect } from "react"; 3 + import { useSearchParams } from "next/navigation"; 3 4 import { Header } from "../PageHeader"; 4 5 import { Footer } from "components/ActionBar/Footer"; 5 6 import { Sidebar } from "components/ActionBar/Sidebar"; ··· 20 21 import { SearchTiny } from "components/Icons/SearchTiny"; 21 22 import { InterfaceState, useIdentityData } from "components/IdentityProvider"; 22 23 import { updateIdentityInterfaceState } from "actions/updateIdentityInterfaceState"; 24 + import Link from "next/link"; 25 + import { ExternalLinkTiny } from "components/Icons/ExternalLinkTiny"; 26 + import { usePreserveScroll } from "src/hooks/usePreserveScroll"; 23 27 24 28 export type DashboardState = { 25 29 display?: "grid" | "list"; ··· 117 121 118 122 export function DashboardLayout< 119 123 T extends { 120 - [name: string]: { content: React.ReactNode; controls: React.ReactNode }; 124 + [name: string]: { 125 + content: React.ReactNode; 126 + controls: React.ReactNode; 127 + }; 121 128 }, 122 129 >(props: { 123 130 id: string; 124 - hasBackgroundImage: boolean; 131 + cardBorderHidden: boolean; 125 132 tabs: T; 126 133 defaultTab: keyof T; 127 134 currentPage: navPages; 128 135 publication?: string; 129 136 actions: React.ReactNode; 130 137 }) { 131 - let [tab, setTab] = useState(props.defaultTab); 138 + const searchParams = useSearchParams(); 139 + const tabParam = searchParams.get("tab"); 140 + 141 + // Initialize tab from search param if valid, otherwise use default 142 + const initialTab = tabParam && props.tabs[tabParam] ? tabParam : props.defaultTab; 143 + let [tab, setTab] = useState<keyof T>(initialTab); 144 + 145 + // Custom setter that updates both state and URL 146 + const setTabWithUrl = (newTab: keyof T) => { 147 + setTab(newTab); 148 + const params = new URLSearchParams(searchParams.toString()); 149 + params.set("tab", newTab as string); 150 + const newUrl = `${window.location.pathname}?${params.toString()}`; 151 + window.history.replaceState(null, "", newUrl); 152 + }; 153 + 132 154 let { content, controls } = props.tabs[tab]; 155 + let { ref } = usePreserveScroll<HTMLDivElement>( 156 + `dashboard-${props.id}-${tab as string}`, 157 + ); 133 158 134 159 let [headerState, setHeaderState] = useState<"default" | "controls">( 135 160 "default", ··· 150 175 </MediaContents> 151 176 <div 152 177 className={`w-full h-full flex flex-col gap-2 relative overflow-y-scroll pt-3 pb-12 px-3 sm:pt-8 sm:pb-12 sm:pl-6 sm:pr-4 `} 178 + ref={ref} 153 179 id="home-content" 154 180 > 155 181 {Object.keys(props.tabs).length <= 1 && !controls ? null : ( 156 182 <> 157 - <Header hasBackgroundImage={props.hasBackgroundImage}> 183 + <Header cardBorderHidden={props.cardBorderHidden}> 158 184 {headerState === "default" ? ( 159 185 <> 160 186 {Object.keys(props.tabs).length > 1 && ( 161 187 <div className="pubDashTabs flex flex-row gap-1"> 162 - {Object.keys(props.tabs).map((t) => ( 163 - <Tab 164 - key={t} 165 - name={t} 166 - selected={t === tab} 167 - onSelect={() => setTab(t)} 168 - /> 169 - ))} 188 + {Object.keys(props.tabs).map((t) => { 189 + return ( 190 + <Tab 191 + key={t} 192 + name={t} 193 + selected={t === tab} 194 + onSelect={() => setTabWithUrl(t)} 195 + /> 196 + ); 197 + })} 170 198 </div> 171 199 )} 172 200 {props.publication && ( ··· 326 354 ); 327 355 }; 328 356 329 - function Tab(props: { name: string; selected: boolean; onSelect: () => void }) { 357 + function Tab(props: { 358 + name: string; 359 + selected: boolean; 360 + onSelect: () => void; 361 + href?: string; 362 + }) { 330 363 return ( 331 364 <div 332 - className={`pubTabs px-1 py-0 rounded-md hover:cursor-pointer ${props.selected ? "text-accent-2 bg-accent-1 font-bold -mb-px" : "text-tertiary"}`} 365 + className={`pubTabs px-1 py-0 flex gap-1 items-center rounded-md hover:cursor-pointer ${props.selected ? "text-accent-2 bg-accent-1 font-bold -mb-px" : "text-tertiary"}`} 333 366 onClick={() => props.onSelect()} 334 367 > 335 368 {props.name} 369 + {props.href && <ExternalLinkTiny />} 336 370 </div> 337 371 ); 338 372 }
+79 -18
components/Pages/PublicationMetadata.tsx
··· 1 1 import Link from "next/link"; 2 2 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 3 - import { AsyncValueInput, Input } from "components/Input"; 4 - import { useEffect, useState } from "react"; 5 - import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; 6 - import { updateLeafletDraftMetadata } from "actions/publications/updateLeafletDraftMetadata"; 3 + import { useRef } from "react"; 7 4 import { useReplicache } from "src/replicache"; 8 - import { 9 - AsyncValueAutosizeTextarea, 10 - AutosizeTextarea, 11 - } from "components/utils/AutosizeTextarea"; 5 + import { AsyncValueAutosizeTextarea } from "components/utils/AutosizeTextarea"; 12 6 import { Separator } from "components/Layout"; 13 7 import { AtUri } from "@atproto/syntax"; 14 8 import { PubLeafletDocument } from "lexicons/api"; ··· 30 24 let description = useSubscribe(rep, (tx) => 31 25 tx.get<string>("publication_description"), 32 26 ); 33 - let { permissions } = useEntitySetContext(); 34 - 35 27 let record = pub?.documents?.data as PubLeafletDocument.Record | null; 36 28 let publishedAt = record?.publishedAt; 37 29 ··· 56 48 Editor 57 49 </div> 58 50 </div> 59 - <AsyncValueAutosizeTextarea 60 - disabled={!permissions.write} 51 + <TextField 61 52 className="text-xl font-bold outline-hidden bg-transparent" 62 53 value={title} 63 - onChange={async (e) => { 54 + onChange={async (newTitle) => { 64 55 await rep?.mutate.updatePublicationDraft({ 65 - title: e.currentTarget.value, 56 + title: newTitle, 66 57 description, 67 58 }); 68 59 }} 69 60 placeholder="Untitled" 70 61 /> 71 - <AsyncValueAutosizeTextarea 72 - disabled={!permissions.write} 62 + <TextField 73 63 placeholder="add an optional description..." 74 64 className="italic text-secondary outline-hidden bg-transparent" 75 65 value={description} 76 - onChange={async (e) => { 66 + onChange={async (newDescription) => { 77 67 await rep?.mutate.updatePublicationDraft({ 78 - description: e.currentTarget.value, 79 68 title, 69 + description: newDescription, 80 70 }); 81 71 }} 82 72 /> ··· 98 88 <p className="text-sm text-tertiary pt-2">Draft</p> 99 89 )} 100 90 </div> 91 + ); 92 + }; 93 + 94 + export const TextField = ({ 95 + value, 96 + onChange, 97 + className, 98 + placeholder, 99 + }: { 100 + value: string; 101 + onChange: (v: string) => Promise<void>; 102 + className: string; 103 + placeholder: string; 104 + }) => { 105 + let { undoManager } = useReplicache(); 106 + let actionTimeout = useRef<number | null>(null); 107 + let { permissions } = useEntitySetContext(); 108 + let previousSelection = useRef<null | { start: number; end: number }>(null); 109 + let ref = useRef<HTMLTextAreaElement | null>(null); 110 + return ( 111 + <AsyncValueAutosizeTextarea 112 + ref={ref} 113 + disabled={!permissions.write} 114 + onSelect={(e) => { 115 + let start = e.currentTarget.selectionStart, 116 + end = e.currentTarget.selectionEnd; 117 + previousSelection.current = { start, end }; 118 + }} 119 + className={className} 120 + value={value} 121 + onBlur={async () => { 122 + if (actionTimeout.current) { 123 + undoManager.endGroup(); 124 + window.clearTimeout(actionTimeout.current); 125 + actionTimeout.current = null; 126 + } 127 + }} 128 + onChange={async (e) => { 129 + let newValue = e.currentTarget.value; 130 + let oldValue = value; 131 + let start = e.currentTarget.selectionStart, 132 + end = e.currentTarget.selectionEnd; 133 + await onChange(e.currentTarget.value); 134 + 135 + if (actionTimeout.current) { 136 + window.clearTimeout(actionTimeout.current); 137 + } else { 138 + undoManager.startGroup(); 139 + } 140 + 141 + actionTimeout.current = window.setTimeout(() => { 142 + undoManager.endGroup(); 143 + actionTimeout.current = null; 144 + }, 200); 145 + let previousStart = previousSelection.current?.start || null, 146 + previousEnd = previousSelection.current?.end || null; 147 + undoManager.add({ 148 + redo: async () => { 149 + await onChange(newValue); 150 + ref.current?.setSelectionRange(start, end); 151 + ref.current?.focus(); 152 + }, 153 + undo: async () => { 154 + await onChange(oldValue); 155 + ref.current?.setSelectionRange(previousStart, previousEnd); 156 + ref.current?.focus(); 157 + }, 158 + }); 159 + }} 160 + placeholder={placeholder} 161 + /> 101 162 ); 102 163 }; 103 164
+225 -131
components/SelectionManager.tsx
··· 10 10 import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 11 11 import { v7 } from "uuid"; 12 12 import { indent, outdent, outdentFull } from "src/utils/list-operations"; 13 - import { addShortcut } from "src/shortcuts"; 13 + import { addShortcut, Shortcut } from "src/shortcuts"; 14 14 import { htmlToMarkdown } from "src/htmlMarkdownParsers"; 15 15 import { elementId } from "src/utils/elementId"; 16 16 import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded"; ··· 19 19 import { useIsMobile } from "src/hooks/isMobile"; 20 20 import { deleteBlock } from "./Blocks/DeleteBlock"; 21 21 import { Replicache } from "replicache"; 22 + import { schema } from "./Blocks/TextBlock/schema"; 23 + import { TextSelection } from "prosemirror-state"; 24 + import { MarkType } from "prosemirror-model"; 22 25 export const useSelectingMouse = create(() => ({ 23 26 start: null as null | string, 24 27 })); ··· 33 36 let isMobile = useIsMobile(); 34 37 useEffect(() => { 35 38 if (!entity_set.permissions.write || !rep) return; 36 - if (isMobile) return; 37 39 const getSortedSelectionBound = getSortedSelection.bind(null, rep); 38 - let removeListener = addShortcut( 39 - [ 40 - { 41 - metaKey: true, 42 - key: "ArrowUp", 43 - handler: async () => { 44 - let [firstBlock] = 45 - (await rep?.query((tx) => 46 - getBlocksWithType( 47 - tx, 48 - useUIState.getState().selectedBlocks[0].parent, 49 - ), 50 - )) || []; 51 - if (firstBlock) focusBlock(firstBlock, { type: "start" }); 52 - }, 40 + let shortcuts: Shortcut[] = [ 41 + { 42 + metaKey: true, 43 + key: "ArrowUp", 44 + handler: async () => { 45 + let [firstBlock] = 46 + (await rep?.query((tx) => 47 + getBlocksWithType( 48 + tx, 49 + useUIState.getState().selectedBlocks[0].parent, 50 + ), 51 + )) || []; 52 + if (firstBlock) focusBlock(firstBlock, { type: "start" }); 53 + }, 54 + }, 55 + { 56 + metaKey: true, 57 + key: "ArrowDown", 58 + handler: async () => { 59 + let blocks = 60 + (await rep?.query((tx) => 61 + getBlocksWithType( 62 + tx, 63 + useUIState.getState().selectedBlocks[0].parent, 64 + ), 65 + )) || []; 66 + let folded = useUIState.getState().foldedBlocks; 67 + blocks = blocks.filter( 68 + (f) => 69 + !f.listData || 70 + !f.listData.path.find( 71 + (path) => 72 + folded.includes(path.entity) && f.value !== path.entity, 73 + ), 74 + ); 75 + let lastBlock = blocks[blocks.length - 1]; 76 + if (lastBlock) focusBlock(lastBlock, { type: "end" }); 77 + }, 78 + }, 79 + { 80 + metaKey: true, 81 + altKey: true, 82 + key: ["l", "¬"], 83 + handler: async () => { 84 + let [sortedBlocks, siblings] = await getSortedSelectionBound(); 85 + for (let block of sortedBlocks) { 86 + if (!block.listData) { 87 + await rep?.mutate.assertFact({ 88 + entity: block.value, 89 + attribute: "block/is-list", 90 + data: { type: "boolean", value: true }, 91 + }); 92 + } else { 93 + outdentFull(block, rep); 94 + } 95 + } 53 96 }, 97 + }, 98 + { 99 + metaKey: true, 100 + shift: true, 101 + key: ["ArrowDown", "J"], 102 + handler: async () => { 103 + let [sortedBlocks, siblings] = await getSortedSelectionBound(); 104 + let block = sortedBlocks[0]; 105 + let nextBlock = siblings 106 + .slice(siblings.findIndex((s) => s.value === block.value) + 1) 107 + .find( 108 + (f) => 109 + f.listData && 110 + block.listData && 111 + !f.listData.path.find((f) => f.entity === block.value), 112 + ); 113 + if ( 114 + nextBlock?.listData && 115 + block.listData && 116 + nextBlock.listData.depth === block.listData.depth - 1 117 + ) { 118 + if (useUIState.getState().foldedBlocks.includes(nextBlock.value)) 119 + useUIState.getState().toggleFold(nextBlock.value); 120 + await rep?.mutate.moveBlock({ 121 + block: block.value, 122 + oldParent: block.listData?.parent, 123 + newParent: nextBlock.value, 124 + position: { type: "first" }, 125 + }); 126 + } else { 127 + await rep?.mutate.moveBlockDown({ 128 + entityID: block.value, 129 + parent: block.listData?.parent || block.parent, 130 + }); 131 + } 132 + }, 133 + }, 134 + { 135 + metaKey: true, 136 + shift: true, 137 + key: ["ArrowUp", "K"], 138 + handler: async () => { 139 + let [sortedBlocks, siblings] = await getSortedSelectionBound(); 140 + let block = sortedBlocks[0]; 141 + let previousBlock = 142 + siblings?.[siblings.findIndex((s) => s.value === block.value) - 1]; 143 + if (previousBlock.value === block.listData?.parent) { 144 + previousBlock = 145 + siblings?.[ 146 + siblings.findIndex((s) => s.value === block.value) - 2 147 + ]; 148 + } 149 + 150 + if ( 151 + previousBlock?.listData && 152 + block.listData && 153 + block.listData.depth > 1 && 154 + !previousBlock.listData.path.find( 155 + (f) => f.entity === block.listData?.parent, 156 + ) 157 + ) { 158 + let depth = block.listData.depth; 159 + let newParent = previousBlock.listData.path.find( 160 + (f) => f.depth === depth - 1, 161 + ); 162 + if (!newParent) return; 163 + if (useUIState.getState().foldedBlocks.includes(newParent.entity)) 164 + useUIState.getState().toggleFold(newParent.entity); 165 + rep?.mutate.moveBlock({ 166 + block: block.value, 167 + oldParent: block.listData?.parent, 168 + newParent: newParent.entity, 169 + position: { type: "end" }, 170 + }); 171 + } else { 172 + rep?.mutate.moveBlockUp({ 173 + entityID: block.value, 174 + parent: block.listData?.parent || block.parent, 175 + }); 176 + } 177 + }, 178 + }, 179 + 180 + { 181 + metaKey: true, 182 + shift: true, 183 + key: "Enter", 184 + handler: async () => { 185 + let [sortedBlocks, siblings] = await getSortedSelectionBound(); 186 + if (!sortedBlocks[0].listData) return; 187 + useUIState.getState().toggleFold(sortedBlocks[0].value); 188 + }, 189 + }, 190 + ]; 191 + if (moreThanOneSelected) 192 + shortcuts = shortcuts.concat([ 54 193 { 55 194 metaKey: true, 56 - key: "ArrowDown", 195 + key: "u", 57 196 handler: async () => { 58 - let blocks = 59 - (await rep?.query((tx) => 60 - getBlocksWithType( 61 - tx, 62 - useUIState.getState().selectedBlocks[0].parent, 63 - ), 64 - )) || []; 65 - let folded = useUIState.getState().foldedBlocks; 66 - blocks = blocks.filter( 67 - (f) => 68 - !f.listData || 69 - !f.listData.path.find( 70 - (path) => 71 - folded.includes(path.entity) && f.value !== path.entity, 72 - ), 197 + let [sortedBlocks] = await getSortedSelectionBound(); 198 + toggleMarkInBlocks( 199 + sortedBlocks.filter((b) => b.type === "text").map((b) => b.value), 200 + schema.marks.underline, 73 201 ); 74 - let lastBlock = blocks[blocks.length - 1]; 75 - if (lastBlock) focusBlock(lastBlock, { type: "end" }); 76 202 }, 77 203 }, 78 204 { 79 205 metaKey: true, 80 - altKey: true, 81 - key: ["l", "¬"], 206 + key: "i", 82 207 handler: async () => { 83 - let [sortedBlocks, siblings] = await getSortedSelectionBound(); 84 - for (let block of sortedBlocks) { 85 - if (!block.listData) { 86 - await rep?.mutate.assertFact({ 87 - entity: block.value, 88 - attribute: "block/is-list", 89 - data: { type: "boolean", value: true }, 90 - }); 91 - } else { 92 - outdentFull(block, rep); 93 - } 94 - } 208 + let [sortedBlocks] = await getSortedSelectionBound(); 209 + toggleMarkInBlocks( 210 + sortedBlocks.filter((b) => b.type === "text").map((b) => b.value), 211 + schema.marks.em, 212 + ); 95 213 }, 96 214 }, 97 215 { 98 216 metaKey: true, 99 - shift: true, 100 - key: ["ArrowDown", "J"], 217 + key: "b", 101 218 handler: async () => { 102 - let [sortedBlocks, siblings] = await getSortedSelectionBound(); 103 - let block = sortedBlocks[0]; 104 - let nextBlock = siblings 105 - .slice(siblings.findIndex((s) => s.value === block.value) + 1) 106 - .find( 107 - (f) => 108 - f.listData && 109 - block.listData && 110 - !f.listData.path.find((f) => f.entity === block.value), 111 - ); 112 - if ( 113 - nextBlock?.listData && 114 - block.listData && 115 - nextBlock.listData.depth === block.listData.depth - 1 116 - ) { 117 - if (useUIState.getState().foldedBlocks.includes(nextBlock.value)) 118 - useUIState.getState().toggleFold(nextBlock.value); 119 - rep?.mutate.moveBlock({ 120 - block: block.value, 121 - oldParent: block.listData?.parent, 122 - newParent: nextBlock.value, 123 - position: { type: "first" }, 124 - }); 125 - } else { 126 - rep?.mutate.moveBlockDown({ 127 - entityID: block.value, 128 - parent: block.listData?.parent || block.parent, 129 - }); 130 - } 219 + let [sortedBlocks] = await getSortedSelectionBound(); 220 + toggleMarkInBlocks( 221 + sortedBlocks.filter((b) => b.type === "text").map((b) => b.value), 222 + schema.marks.strong, 223 + ); 131 224 }, 132 225 }, 133 226 { 134 - metaKey: true, 135 - shift: true, 136 - key: ["ArrowUp", "K"], 227 + metaAndCtrl: true, 228 + key: "h", 137 229 handler: async () => { 138 - let [sortedBlocks, siblings] = await getSortedSelectionBound(); 139 - let block = sortedBlocks[0]; 140 - let previousBlock = 141 - siblings?.[ 142 - siblings.findIndex((s) => s.value === block.value) - 1 143 - ]; 144 - if (previousBlock.value === block.listData?.parent) { 145 - previousBlock = 146 - siblings?.[ 147 - siblings.findIndex((s) => s.value === block.value) - 2 148 - ]; 149 - } 150 - 151 - if ( 152 - previousBlock?.listData && 153 - block.listData && 154 - block.listData.depth > 1 && 155 - !previousBlock.listData.path.find( 156 - (f) => f.entity === block.listData?.parent, 157 - ) 158 - ) { 159 - let depth = block.listData.depth; 160 - let newParent = previousBlock.listData.path.find( 161 - (f) => f.depth === depth - 1, 162 - ); 163 - if (!newParent) return; 164 - if (useUIState.getState().foldedBlocks.includes(newParent.entity)) 165 - useUIState.getState().toggleFold(newParent.entity); 166 - rep?.mutate.moveBlock({ 167 - block: block.value, 168 - oldParent: block.listData?.parent, 169 - newParent: newParent.entity, 170 - position: { type: "end" }, 171 - }); 172 - } else { 173 - rep?.mutate.moveBlockUp({ 174 - entityID: block.value, 175 - parent: block.listData?.parent || block.parent, 176 - }); 177 - } 230 + let [sortedBlocks] = await getSortedSelectionBound(); 231 + toggleMarkInBlocks( 232 + sortedBlocks.filter((b) => b.type === "text").map((b) => b.value), 233 + schema.marks.highlight, 234 + { 235 + color: useUIState.getState().lastUsedHighlight, 236 + }, 237 + ); 178 238 }, 179 239 }, 180 240 { 181 - metaKey: true, 182 - shift: true, 183 - key: "Enter", 241 + metaAndCtrl: true, 242 + key: "x", 184 243 handler: async () => { 185 - let [sortedBlocks, siblings] = await getSortedSelectionBound(); 186 - if (!sortedBlocks[0].listData) return; 187 - useUIState.getState().toggleFold(sortedBlocks[0].value); 244 + let [sortedBlocks] = await getSortedSelectionBound(); 245 + toggleMarkInBlocks( 246 + sortedBlocks.filter((b) => b.type === "text").map((b) => b.value), 247 + schema.marks.strikethrough, 248 + ); 188 249 }, 189 250 }, 190 - ].map((shortcut) => ({ 251 + ]); 252 + let removeListener = addShortcut( 253 + shortcuts.map((shortcut) => ({ 191 254 ...shortcut, 192 255 handler: () => undoManager.withUndoGroup(() => shortcut.handler()), 193 256 })), ··· 463 526 } 464 527 if ((e.key === "c" || e.key === "x") && (e.metaKey || e.ctrlKey)) { 465 528 if (!rep) return; 529 + if (e.shiftKey || (e.metaKey && e.ctrlKey)) return; 466 530 let [, , selectionWithFoldedChildren] = 467 531 await getSortedSelectionBound(); 468 532 if (!selectionWithFoldedChildren) return; ··· 490 554 removeListener(); 491 555 window.removeEventListener("keydown", listener); 492 556 }; 493 - }, [moreThanOneSelected, rep, entity_set.permissions.write, isMobile]); 557 + }, [moreThanOneSelected, rep, entity_set.permissions.write]); 494 558 495 559 let [mouseDown, setMouseDown] = useState(false); 496 560 let initialContentEditableParent = useRef<null | Node>(null); ··· 667 731 sortedBlocksWithChildren, 668 732 ]; 669 733 }; 734 + 735 + function toggleMarkInBlocks(blocks: string[], mark: MarkType, attrs?: any) { 736 + let everyBlockHasMark = blocks.reduce((acc, block) => { 737 + let editor = useEditorStates.getState().editorStates[block]; 738 + if (!editor) return acc; 739 + let { view } = editor; 740 + let from = 0; 741 + let to = view.state.doc.content.size; 742 + let hasMarkInRange = view.state.doc.rangeHasMark(from, to, mark); 743 + return acc && hasMarkInRange; 744 + }, true); 745 + for (let block of blocks) { 746 + let editor = useEditorStates.getState().editorStates[block]; 747 + if (!editor) return; 748 + let { view } = editor; 749 + let tr = view.state.tr; 750 + 751 + let from = 0; 752 + let to = view.state.doc.content.size; 753 + 754 + tr.setMeta("bulkOp", true); 755 + if (everyBlockHasMark) { 756 + tr.removeMark(from, to, mark); 757 + } else { 758 + tr.addMark(from, to, mark.create(attrs)); 759 + } 760 + 761 + view.dispatch(tr); 762 + } 763 + }
-1
components/ThemeManager/PubPickers/PubBackgroundPickers.tsx
··· 67 67 if (file) { 68 68 const reader = new FileReader(); 69 69 reader.onload = (e) => { 70 - console.log("loaded!", props.bgImage); 71 70 props.setBgImage({ 72 71 src: e.target?.result as string, 73 72 file,
-1
components/ThemeManager/PubThemeSetter.tsx
··· 62 62 onSubmit={async (e) => { 63 63 e.preventDefault(); 64 64 if (!pub) return; 65 - console.log(image); 66 65 setLoading(true); 67 66 let result = await updatePublicationTheme({ 68 67 uri: pub.uri,
+1
components/ThemeManager/ThemeProvider.tsx
··· 185 185 if (!el) return; 186 186 setCSSVariableToColor(el, "--bg-leaflet", bgLeaflet); 187 187 setCSSVariableToColor(el, "--bg-page", bgPage); 188 + document.body.style.backgroundColor = `rgb(${colorToString(bgLeaflet, "rgb")})`; 188 189 document 189 190 .querySelector('meta[name="theme-color"]') 190 191 ?.setAttribute("content", `rgb(${colorToString(bgLeaflet, "rgb")})`);
-1
components/utils/AutosizeTextarea.tsx
··· 19 19 let { noWrap, ...rest } = props; 20 20 useImperativeHandle(ref, () => textarea.current as HTMLTextAreaElement); 21 21 22 - console.log({ noWrap }); 23 22 return ( 24 23 <div 25 24 className={`${styles["grow-wrap"]} ${props.className} ${noWrap ? styles["no-wrap"] : ""}`}
+111 -92
drizzle/relations.ts
··· 1 1 import { relations } from "drizzle-orm/relations"; 2 - import { identities, bsky_profiles, publications, documents, comments_on_documents, entities, facts, entity_sets, permission_tokens, email_subscriptions_to_entity, email_auth_tokens, custom_domains, phone_rsvps_to_entity, custom_domain_routes, poll_votes_on_entity, subscribers_to_publications, document_mentions_in_bsky, bsky_posts, permission_token_on_homepage, documents_in_publications, publication_domains, publication_subscriptions, leaflets_in_publications, permission_token_rights } from "./schema"; 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, 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 bsky_profilesRelations = relations(bsky_profiles, ({one, many}) => ({ 4 + export const publicationsRelations = relations(publications, ({one, many}) => ({ 5 5 identity: one(identities, { 6 - fields: [bsky_profiles.did], 6 + fields: [publications.identity_did], 7 7 references: [identities.atp_did] 8 8 }), 9 - comments_on_documents: many(comments_on_documents), 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), 10 14 })); 11 15 12 16 export const identitiesRelations = relations(identities, ({one, many}) => ({ 13 - bsky_profiles: many(bsky_profiles), 14 17 publications: many(publications), 18 + email_auth_tokens: many(email_auth_tokens), 19 + bsky_profiles: many(bsky_profiles), 15 20 permission_token: one(permission_tokens, { 16 21 fields: [identities.home_page], 17 22 references: [permission_tokens.id] 18 23 }), 19 - email_auth_tokens: many(email_auth_tokens), 20 24 custom_domains_identity: many(custom_domains, { 21 25 relationName: "custom_domains_identity_identities_email" 22 26 }), 23 27 custom_domains_identity_id: many(custom_domains, { 24 28 relationName: "custom_domains_identity_id_identities_id" 25 29 }), 30 + bsky_follows_follows: many(bsky_follows, { 31 + relationName: "bsky_follows_follows_identities_atp_did" 32 + }), 33 + bsky_follows_identity: many(bsky_follows, { 34 + relationName: "bsky_follows_identity_identities_atp_did" 35 + }), 26 36 subscribers_to_publications: many(subscribers_to_publications), 27 37 permission_token_on_homepages: many(permission_token_on_homepage), 28 38 publication_domains: many(publication_domains), 29 39 publication_subscriptions: many(publication_subscriptions), 30 40 })); 31 41 32 - export const publicationsRelations = relations(publications, ({one, many}) => ({ 33 - identity: one(identities, { 34 - fields: [publications.identity_did], 35 - references: [identities.atp_did] 36 - }), 37 - subscribers_to_publications: many(subscribers_to_publications), 38 - documents_in_publications: many(documents_in_publications), 39 - publication_domains: many(publication_domains), 40 - publication_subscriptions: many(publication_subscriptions), 41 - leaflets_in_publications: many(leaflets_in_publications), 42 - })); 43 - 44 42 export const comments_on_documentsRelations = relations(comments_on_documents, ({one}) => ({ 45 43 document: one(documents, { 46 44 fields: [comments_on_documents.document], ··· 54 52 55 53 export const documentsRelations = relations(documents, ({many}) => ({ 56 54 comments_on_documents: many(comments_on_documents), 57 - document_mentions_in_bskies: many(document_mentions_in_bsky), 58 55 documents_in_publications: many(documents_in_publications), 56 + document_mentions_in_bskies: many(document_mentions_in_bsky), 59 57 leaflets_in_publications: many(leaflets_in_publications), 60 58 })); 61 59 62 - export const factsRelations = relations(facts, ({one}) => ({ 63 - entity: one(entities, { 64 - fields: [facts.entity], 65 - references: [entities.id] 60 + export const bsky_profilesRelations = relations(bsky_profiles, ({one, many}) => ({ 61 + comments_on_documents: many(comments_on_documents), 62 + identity: one(identities, { 63 + fields: [bsky_profiles.did], 64 + references: [identities.atp_did] 66 65 }), 67 66 })); 68 67 69 68 export const entitiesRelations = relations(entities, ({one, many}) => ({ 70 - facts: many(facts), 71 69 entity_set: one(entity_sets, { 72 70 fields: [entities.set], 73 71 references: [entity_sets.id] 74 72 }), 75 - permission_tokens: many(permission_tokens), 76 - email_subscriptions_to_entities: many(email_subscriptions_to_entity), 77 - phone_rsvps_to_entities: many(phone_rsvps_to_entity), 73 + facts: many(facts), 78 74 poll_votes_on_entities_option_entity: many(poll_votes_on_entity, { 79 75 relationName: "poll_votes_on_entity_option_entity_entities_id" 80 76 }), 81 77 poll_votes_on_entities_poll_entity: many(poll_votes_on_entity, { 82 78 relationName: "poll_votes_on_entity_poll_entity_entities_id" 83 79 }), 80 + permission_tokens: many(permission_tokens), 81 + phone_rsvps_to_entities: many(phone_rsvps_to_entity), 82 + email_subscriptions_to_entities: many(email_subscriptions_to_entity), 84 83 })); 85 84 86 85 export const entity_setsRelations = relations(entity_sets, ({many}) => ({ ··· 88 87 permission_token_rights: many(permission_token_rights), 89 88 })); 90 89 90 + export const factsRelations = relations(facts, ({one}) => ({ 91 + entity: one(entities, { 92 + fields: [facts.entity], 93 + references: [entities.id] 94 + }), 95 + })); 96 + 97 + export const email_auth_tokensRelations = relations(email_auth_tokens, ({one}) => ({ 98 + identity: one(identities, { 99 + fields: [email_auth_tokens.identity], 100 + references: [identities.id] 101 + }), 102 + })); 103 + 104 + export const poll_votes_on_entityRelations = relations(poll_votes_on_entity, ({one}) => ({ 105 + entity_option_entity: one(entities, { 106 + fields: [poll_votes_on_entity.option_entity], 107 + references: [entities.id], 108 + relationName: "poll_votes_on_entity_option_entity_entities_id" 109 + }), 110 + entity_poll_entity: one(entities, { 111 + fields: [poll_votes_on_entity.poll_entity], 112 + references: [entities.id], 113 + relationName: "poll_votes_on_entity_poll_entity_entities_id" 114 + }), 115 + })); 116 + 91 117 export const permission_tokensRelations = relations(permission_tokens, ({one, many}) => ({ 92 118 entity: one(entities, { 93 119 fields: [permission_tokens.root_entity], 94 120 references: [entities.id] 95 121 }), 96 122 identities: many(identities), 97 - email_subscriptions_to_entities: many(email_subscriptions_to_entity), 98 123 custom_domain_routes_edit_permission_token: many(custom_domain_routes, { 99 124 relationName: "custom_domain_routes_edit_permission_token_permission_tokens_id" 100 125 }), 101 126 custom_domain_routes_view_permission_token: many(custom_domain_routes, { 102 127 relationName: "custom_domain_routes_view_permission_token_permission_tokens_id" 103 128 }), 129 + email_subscriptions_to_entities: many(email_subscriptions_to_entity), 104 130 permission_token_on_homepages: many(permission_token_on_homepage), 105 131 leaflets_in_publications: many(leaflets_in_publications), 106 132 permission_token_rights: many(permission_token_rights), 107 133 })); 108 134 109 - export const email_subscriptions_to_entityRelations = relations(email_subscriptions_to_entity, ({one}) => ({ 135 + export const phone_rsvps_to_entityRelations = relations(phone_rsvps_to_entity, ({one}) => ({ 110 136 entity: one(entities, { 111 - fields: [email_subscriptions_to_entity.entity], 137 + fields: [phone_rsvps_to_entity.entity], 112 138 references: [entities.id] 113 - }), 114 - permission_token: one(permission_tokens, { 115 - fields: [email_subscriptions_to_entity.token], 116 - references: [permission_tokens.id] 117 139 }), 118 140 })); 119 141 120 - export const email_auth_tokensRelations = relations(email_auth_tokens, ({one}) => ({ 121 - identity: one(identities, { 122 - fields: [email_auth_tokens.identity], 123 - references: [identities.id] 142 + export const custom_domain_routesRelations = relations(custom_domain_routes, ({one}) => ({ 143 + custom_domain: one(custom_domains, { 144 + fields: [custom_domain_routes.domain], 145 + references: [custom_domains.domain] 146 + }), 147 + permission_token_edit_permission_token: one(permission_tokens, { 148 + fields: [custom_domain_routes.edit_permission_token], 149 + references: [permission_tokens.id], 150 + relationName: "custom_domain_routes_edit_permission_token_permission_tokens_id" 151 + }), 152 + permission_token_view_permission_token: one(permission_tokens, { 153 + fields: [custom_domain_routes.view_permission_token], 154 + references: [permission_tokens.id], 155 + relationName: "custom_domain_routes_view_permission_token_permission_tokens_id" 124 156 }), 125 157 })); 126 158 127 159 export const custom_domainsRelations = relations(custom_domains, ({one, many}) => ({ 160 + custom_domain_routes: many(custom_domain_routes), 128 161 identity_identity: one(identities, { 129 162 fields: [custom_domains.identity], 130 163 references: [identities.email], ··· 135 168 references: [identities.id], 136 169 relationName: "custom_domains_identity_id_identities_id" 137 170 }), 138 - custom_domain_routes: many(custom_domain_routes), 139 171 publication_domains: many(publication_domains), 140 172 })); 141 173 142 - export const phone_rsvps_to_entityRelations = relations(phone_rsvps_to_entity, ({one}) => ({ 174 + export const email_subscriptions_to_entityRelations = relations(email_subscriptions_to_entity, ({one}) => ({ 143 175 entity: one(entities, { 144 - fields: [phone_rsvps_to_entity.entity], 176 + fields: [email_subscriptions_to_entity.entity], 145 177 references: [entities.id] 146 178 }), 147 - })); 148 - 149 - export const custom_domain_routesRelations = relations(custom_domain_routes, ({one}) => ({ 150 - custom_domain: one(custom_domains, { 151 - fields: [custom_domain_routes.domain], 152 - references: [custom_domains.domain] 153 - }), 154 - permission_token_edit_permission_token: one(permission_tokens, { 155 - fields: [custom_domain_routes.edit_permission_token], 156 - references: [permission_tokens.id], 157 - relationName: "custom_domain_routes_edit_permission_token_permission_tokens_id" 158 - }), 159 - permission_token_view_permission_token: one(permission_tokens, { 160 - fields: [custom_domain_routes.view_permission_token], 161 - references: [permission_tokens.id], 162 - relationName: "custom_domain_routes_view_permission_token_permission_tokens_id" 179 + permission_token: one(permission_tokens, { 180 + fields: [email_subscriptions_to_entity.token], 181 + references: [permission_tokens.id] 163 182 }), 164 183 })); 165 184 166 - export const poll_votes_on_entityRelations = relations(poll_votes_on_entity, ({one}) => ({ 167 - entity_option_entity: one(entities, { 168 - fields: [poll_votes_on_entity.option_entity], 169 - references: [entities.id], 170 - relationName: "poll_votes_on_entity_option_entity_entities_id" 185 + export const bsky_followsRelations = relations(bsky_follows, ({one}) => ({ 186 + identity_follows: one(identities, { 187 + fields: [bsky_follows.follows], 188 + references: [identities.atp_did], 189 + relationName: "bsky_follows_follows_identities_atp_did" 171 190 }), 172 - entity_poll_entity: one(entities, { 173 - fields: [poll_votes_on_entity.poll_entity], 174 - references: [entities.id], 175 - relationName: "poll_votes_on_entity_poll_entity_entities_id" 191 + identity_identity: one(identities, { 192 + fields: [bsky_follows.identity], 193 + references: [identities.atp_did], 194 + relationName: "bsky_follows_identity_identities_atp_did" 176 195 }), 177 196 })); 178 197 ··· 187 206 }), 188 207 })); 189 208 190 - export const document_mentions_in_bskyRelations = relations(document_mentions_in_bsky, ({one}) => ({ 191 - document: one(documents, { 192 - fields: [document_mentions_in_bsky.document], 193 - references: [documents.uri] 194 - }), 195 - bsky_post: one(bsky_posts, { 196 - fields: [document_mentions_in_bsky.uri], 197 - references: [bsky_posts.uri] 198 - }), 199 - })); 200 - 201 - export const bsky_postsRelations = relations(bsky_posts, ({many}) => ({ 202 - document_mentions_in_bskies: many(document_mentions_in_bsky), 203 - })); 204 - 205 209 export const permission_token_on_homepageRelations = relations(permission_token_on_homepage, ({one}) => ({ 206 210 identity: one(identities, { 207 211 fields: [permission_token_on_homepage.identity], ··· 224 228 }), 225 229 })); 226 230 231 + export const document_mentions_in_bskyRelations = relations(document_mentions_in_bsky, ({one}) => ({ 232 + document: one(documents, { 233 + fields: [document_mentions_in_bsky.document], 234 + references: [documents.uri] 235 + }), 236 + bsky_post: one(bsky_posts, { 237 + fields: [document_mentions_in_bsky.uri], 238 + references: [bsky_posts.uri] 239 + }), 240 + })); 241 + 242 + export const bsky_postsRelations = relations(bsky_posts, ({many}) => ({ 243 + document_mentions_in_bskies: many(document_mentions_in_bsky), 244 + })); 245 + 227 246 export const publication_domainsRelations = relations(publication_domains, ({one}) => ({ 228 247 custom_domain: one(custom_domains, { 229 248 fields: [publication_domains.domain], ··· 239 258 }), 240 259 })); 241 260 242 - export const publication_subscriptionsRelations = relations(publication_subscriptions, ({one}) => ({ 243 - identity: one(identities, { 244 - fields: [publication_subscriptions.identity], 245 - references: [identities.atp_did] 246 - }), 247 - publication: one(publications, { 248 - fields: [publication_subscriptions.publication], 249 - references: [publications.uri] 250 - }), 251 - })); 252 - 253 261 export const leaflets_in_publicationsRelations = relations(leaflets_in_publications, ({one}) => ({ 254 262 document: one(documents, { 255 263 fields: [leaflets_in_publications.doc], ··· 261 269 }), 262 270 publication: one(publications, { 263 271 fields: [leaflets_in_publications.publication], 272 + references: [publications.uri] 273 + }), 274 + })); 275 + 276 + export const publication_subscriptionsRelations = relations(publication_subscriptions, ({one}) => ({ 277 + identity: one(identities, { 278 + fields: [publication_subscriptions.identity], 279 + references: [identities.atp_did] 280 + }), 281 + publication: one(publications, { 282 + fields: [publication_subscriptions.publication], 264 283 references: [publications.uri] 265 284 }), 266 285 }));
+127 -84
drizzle/schema.ts
··· 1 - import { pgTable, pgEnum, text, jsonb, foreignKey, timestamp, uuid, bigint, boolean, unique, uniqueIndex, smallint, primaryKey } from "drizzle-orm/pg-core" 1 + import { pgTable, pgEnum, text, jsonb, index, foreignKey, timestamp, uuid, bigint, boolean, 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']) 5 5 export const code_challenge_method = pgEnum("code_challenge_method", ['s256', 'plain']) 6 6 export const factor_status = pgEnum("factor_status", ['unverified', 'verified']) 7 - export const factor_type = pgEnum("factor_type", ['totp', 'webauthn']) 7 + export const factor_type = pgEnum("factor_type", ['totp', 'webauthn', 'phone']) 8 + export const oauth_authorization_status = pgEnum("oauth_authorization_status", ['pending', 'approved', 'denied', 'expired']) 9 + export const oauth_client_type = pgEnum("oauth_client_type", ['public', 'confidential']) 10 + export const oauth_registration_type = pgEnum("oauth_registration_type", ['dynamic', 'manual']) 11 + export const oauth_response_type = pgEnum("oauth_response_type", ['code']) 8 12 export const one_time_token_type = pgEnum("one_time_token_type", ['confirmation_token', 'reauthentication_token', 'recovery_token', 'email_change_token_new', 'email_change_token_current', 'phone_change_token']) 9 - export const request_status = pgEnum("request_status", ['PENDING', 'SUCCESS', 'ERROR']) 10 13 export const key_status = pgEnum("key_status", ['default', 'valid', 'invalid', 'expired']) 11 14 export const key_type = pgEnum("key_type", ['aead-ietf', 'aead-det', 'hmacsha512', 'hmacsha256', 'auth', 'shorthash', 'generichash', 'kdf', 'secretbox', 'secretstream', 'stream_xchacha20']) 12 15 export const rsvp_status = pgEnum("rsvp_status", ['GOING', 'NOT_GOING', 'MAYBE']) 13 16 export const action = pgEnum("action", ['INSERT', 'UPDATE', 'DELETE', 'TRUNCATE', 'ERROR']) 14 17 export const equality_op = pgEnum("equality_op", ['eq', 'neq', 'lt', 'lte', 'gt', 'gte', 'in']) 18 + export const buckettype = pgEnum("buckettype", ['STANDARD', 'ANALYTICS']) 15 19 16 20 17 21 export const oauth_state_store = pgTable("oauth_state_store", { ··· 19 23 state: jsonb("state").notNull(), 20 24 }); 21 25 22 - export const oauth_session_store = pgTable("oauth_session_store", { 23 - key: text("key").primaryKey().notNull(), 24 - session: jsonb("session").notNull(), 25 - }); 26 - 27 - export const bsky_profiles = pgTable("bsky_profiles", { 28 - did: text("did").primaryKey().notNull().references(() => identities.atp_did, { onDelete: "cascade" } ), 29 - record: jsonb("record").notNull(), 30 - indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 31 - handle: text("handle"), 32 - }); 33 - 34 26 export const publications = pgTable("publications", { 35 27 uri: text("uri").primaryKey().notNull(), 36 28 indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 37 29 name: text("name").notNull(), 38 30 identity_did: text("identity_did").notNull().references(() => identities.atp_did, { onDelete: "cascade" } ), 39 31 record: jsonb("record"), 40 - }); 41 - 42 - export const bsky_posts = pgTable("bsky_posts", { 43 - uri: text("uri").primaryKey().notNull(), 44 - indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 45 - post_view: jsonb("post_view").notNull(), 46 - cid: text("cid").notNull(), 32 + }, 33 + (table) => { 34 + return { 35 + identity_did_idx: index("publications_identity_did_idx").on(table.identity_did), 36 + } 47 37 }); 48 38 49 39 export const comments_on_documents = pgTable("comments_on_documents", { ··· 54 44 profile: text("profile").references(() => bsky_profiles.did, { onDelete: "set null", onUpdate: "cascade" } ), 55 45 }); 56 46 47 + export const entities = pgTable("entities", { 48 + id: uuid("id").primaryKey().notNull(), 49 + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 50 + set: uuid("set").notNull().references(() => entity_sets.id, { onDelete: "cascade", onUpdate: "cascade" } ), 51 + }, 52 + (table) => { 53 + return { 54 + set_idx: index("entities_set_idx").on(table.set), 55 + } 56 + }); 57 + 57 58 export const facts = pgTable("facts", { 58 59 id: uuid("id").primaryKey().notNull(), 59 60 entity: uuid("entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "restrict" } ), ··· 63 64 updated_at: timestamp("updated_at", { mode: 'string' }), 64 65 // You can use { mode: "bigint" } if numbers are exceeding js number limitations 65 66 version: bigint("version", { mode: "number" }).default(0).notNull(), 66 - }); 67 - 68 - export const documents = pgTable("documents", { 69 - uri: text("uri").primaryKey().notNull(), 70 - data: jsonb("data").notNull(), 71 - indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 67 + }, 68 + (table) => { 69 + return { 70 + entity_idx: index("facts_entity_idx").on(table.entity), 71 + } 72 72 }); 73 73 74 74 export const replicache_clients = pgTable("replicache_clients", { ··· 76 76 client_group: text("client_group").notNull(), 77 77 // You can use { mode: "bigint" } if numbers are exceeding js number limitations 78 78 last_mutation: bigint("last_mutation", { mode: "number" }).notNull(), 79 + }, 80 + (table) => { 81 + return { 82 + client_group_idx: index("replicache_clients_client_group_idx").on(table.client_group), 83 + } 79 84 }); 80 85 81 - export const entities = pgTable("entities", { 82 - id: uuid("id").primaryKey().notNull(), 86 + export const email_auth_tokens = pgTable("email_auth_tokens", { 87 + id: uuid("id").defaultRandom().primaryKey().notNull(), 83 88 created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 84 - set: uuid("set").notNull().references(() => entity_sets.id, { onDelete: "cascade", onUpdate: "cascade" } ), 89 + confirmed: boolean("confirmed").default(false).notNull(), 90 + email: text("email"), 91 + confirmation_code: text("confirmation_code").notNull(), 92 + identity: uuid("identity").references(() => identities.id, { onDelete: "cascade", onUpdate: "cascade" } ), 93 + }); 94 + 95 + export const bsky_posts = pgTable("bsky_posts", { 96 + uri: text("uri").primaryKey().notNull(), 97 + indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 98 + post_view: jsonb("post_view").notNull(), 99 + cid: text("cid").notNull(), 100 + }); 101 + 102 + export const bsky_profiles = pgTable("bsky_profiles", { 103 + did: text("did").primaryKey().notNull().references(() => identities.atp_did, { onDelete: "cascade" } ), 104 + record: jsonb("record").notNull(), 105 + indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 106 + handle: text("handle"), 85 107 }); 86 108 87 109 export const entity_sets = pgTable("entity_sets", { 88 110 id: uuid("id").defaultRandom().primaryKey().notNull(), 89 111 created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 112 + }); 113 + 114 + export const poll_votes_on_entity = pgTable("poll_votes_on_entity", { 115 + id: uuid("id").defaultRandom().primaryKey().notNull(), 116 + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 117 + poll_entity: uuid("poll_entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "cascade" } ), 118 + option_entity: uuid("option_entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "cascade" } ), 119 + voter_token: uuid("voter_token").notNull(), 90 120 }); 91 121 92 122 export const permission_tokens = pgTable("permission_tokens", { ··· 110 140 } 111 141 }); 112 142 113 - export const email_subscriptions_to_entity = pgTable("email_subscriptions_to_entity", { 114 - id: uuid("id").defaultRandom().primaryKey().notNull(), 115 - entity: uuid("entity").notNull().references(() => entities.id, { onDelete: "cascade" } ), 116 - email: text("email").notNull(), 117 - created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 118 - token: uuid("token").notNull().references(() => permission_tokens.id, { onDelete: "cascade" } ), 119 - confirmed: boolean("confirmed").default(false).notNull(), 120 - confirmation_code: text("confirmation_code").notNull(), 121 - }); 122 - 123 - export const email_auth_tokens = pgTable("email_auth_tokens", { 124 - id: uuid("id").defaultRandom().primaryKey().notNull(), 125 - created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 126 - confirmed: boolean("confirmed").default(false).notNull(), 127 - email: text("email"), 128 - confirmation_code: text("confirmation_code").notNull(), 129 - identity: uuid("identity").references(() => identities.id, { onDelete: "cascade", onUpdate: "cascade" } ), 130 - }); 131 - 132 143 export const phone_number_auth_tokens = pgTable("phone_number_auth_tokens", { 133 144 id: uuid("id").defaultRandom().primaryKey().notNull(), 134 145 created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), ··· 136 147 confirmation_code: text("confirmation_code").notNull(), 137 148 phone_number: text("phone_number").notNull(), 138 149 country_code: text("country_code").notNull(), 139 - }); 140 - 141 - export const custom_domains = pgTable("custom_domains", { 142 - domain: text("domain").primaryKey().notNull(), 143 - identity: text("identity").default('').references(() => identities.email, { onDelete: "cascade", onUpdate: "cascade" } ), 144 - confirmed: boolean("confirmed").notNull(), 145 - created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 146 - identity_id: uuid("identity_id").references(() => identities.id, { onDelete: "cascade" } ), 147 150 }); 148 151 149 152 export const phone_rsvps_to_entity = pgTable("phone_rsvps_to_entity", { ··· 172 175 }, 173 176 (table) => { 174 177 return { 178 + edit_permission_token_idx: index("custom_domain_routes_edit_permission_token_idx").on(table.edit_permission_token), 175 179 custom_domain_routes_domain_route_key: unique("custom_domain_routes_domain_route_key").on(table.domain, table.route), 176 180 } 177 181 }); 178 182 179 - export const poll_votes_on_entity = pgTable("poll_votes_on_entity", { 183 + export const custom_domains = pgTable("custom_domains", { 184 + domain: text("domain").primaryKey().notNull(), 185 + identity: text("identity").default('').references(() => identities.email, { onDelete: "cascade", onUpdate: "cascade" } ), 186 + confirmed: boolean("confirmed").notNull(), 187 + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 188 + identity_id: uuid("identity_id").references(() => identities.id, { onDelete: "cascade" } ), 189 + }); 190 + 191 + export const email_subscriptions_to_entity = pgTable("email_subscriptions_to_entity", { 180 192 id: uuid("id").defaultRandom().primaryKey().notNull(), 193 + entity: uuid("entity").notNull().references(() => entities.id, { onDelete: "cascade" } ), 194 + email: text("email").notNull(), 181 195 created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 182 - poll_entity: uuid("poll_entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "cascade" } ), 183 - option_entity: uuid("option_entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "cascade" } ), 184 - voter_token: uuid("voter_token").notNull(), 196 + token: uuid("token").notNull().references(() => permission_tokens.id, { onDelete: "cascade" } ), 197 + confirmed: boolean("confirmed").default(false).notNull(), 198 + confirmation_code: text("confirmation_code").notNull(), 185 199 }); 186 200 187 - export const subscribers_to_publications = pgTable("subscribers_to_publications", { 188 - identity: text("identity").notNull().references(() => identities.email, { onUpdate: "cascade" } ), 189 - publication: text("publication").notNull().references(() => publications.uri), 190 - created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 201 + export const documents = pgTable("documents", { 202 + uri: text("uri").primaryKey().notNull(), 203 + data: jsonb("data").notNull(), 204 + indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 205 + }); 206 + 207 + export const oauth_session_store = pgTable("oauth_session_store", { 208 + key: text("key").primaryKey().notNull(), 209 + session: jsonb("session").notNull(), 210 + }); 211 + 212 + export const bsky_follows = pgTable("bsky_follows", { 213 + identity: text("identity").notNull().references(() => identities.atp_did, { onDelete: "cascade" } ), 214 + follows: text("follows").notNull().references(() => identities.atp_did, { onDelete: "cascade" } ), 191 215 }, 192 216 (table) => { 193 217 return { 194 - subscribers_to_publications_pkey: primaryKey({ columns: [table.identity, table.publication], name: "subscribers_to_publications_pkey"}), 218 + bsky_follows_pkey: primaryKey({ columns: [table.identity, table.follows], name: "bsky_follows_pkey"}), 195 219 } 196 220 }); 197 221 198 - export const document_mentions_in_bsky = pgTable("document_mentions_in_bsky", { 199 - uri: text("uri").notNull().references(() => bsky_posts.uri, { onDelete: "cascade" } ), 200 - link: text("link").notNull(), 201 - document: text("document").notNull().references(() => documents.uri, { onDelete: "cascade" } ), 222 + export const subscribers_to_publications = pgTable("subscribers_to_publications", { 223 + identity: text("identity").notNull().references(() => identities.email, { onUpdate: "cascade" } ), 224 + publication: text("publication").notNull().references(() => publications.uri), 225 + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 202 226 }, 203 227 (table) => { 204 228 return { 205 - document_mentions_in_bsky_pkey: primaryKey({ columns: [table.uri, table.document], name: "document_mentions_in_bsky_pkey"}), 229 + subscribers_to_publications_pkey: primaryKey({ columns: [table.identity, table.publication], name: "subscribers_to_publications_pkey"}), 206 230 } 207 231 }); 208 232 ··· 224 248 }, 225 249 (table) => { 226 250 return { 251 + publication_idx: index("documents_in_publications_publication_idx").on(table.publication), 227 252 documents_in_publications_pkey: primaryKey({ columns: [table.publication, table.document], name: "documents_in_publications_pkey"}), 228 253 } 229 254 }); 230 255 231 - export const publication_domains = pgTable("publication_domains", { 232 - publication: text("publication").notNull().references(() => publications.uri, { onDelete: "cascade" } ), 233 - domain: text("domain").notNull().references(() => custom_domains.domain, { onDelete: "cascade" } ), 234 - created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 235 - identity: text("identity").notNull().references(() => identities.atp_did, { onDelete: "cascade", onUpdate: "cascade" } ), 256 + export const document_mentions_in_bsky = pgTable("document_mentions_in_bsky", { 257 + uri: text("uri").notNull().references(() => bsky_posts.uri, { onDelete: "cascade" } ), 258 + link: text("link").notNull(), 259 + document: text("document").notNull().references(() => documents.uri, { onDelete: "cascade" } ), 260 + indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 236 261 }, 237 262 (table) => { 238 263 return { 239 - publication_domains_pkey: primaryKey({ columns: [table.publication, table.domain], name: "publication_domains_pkey"}), 264 + document_mentions_in_bsky_pkey: primaryKey({ columns: [table.uri, table.document], name: "document_mentions_in_bsky_pkey"}), 240 265 } 241 266 }); 242 267 243 - export const publication_subscriptions = pgTable("publication_subscriptions", { 268 + export const publication_domains = pgTable("publication_domains", { 244 269 publication: text("publication").notNull().references(() => publications.uri, { onDelete: "cascade" } ), 245 - identity: text("identity").notNull().references(() => identities.atp_did, { onDelete: "cascade" } ), 270 + domain: text("domain").notNull().references(() => custom_domains.domain, { onDelete: "cascade" } ), 246 271 created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 247 - record: jsonb("record").notNull(), 248 - uri: text("uri").notNull(), 272 + identity: text("identity").notNull().references(() => identities.atp_did, { onDelete: "cascade", onUpdate: "cascade" } ), 249 273 }, 250 274 (table) => { 251 275 return { 252 - publication_subscriptions_pkey: primaryKey({ columns: [table.publication, table.identity], name: "publication_subscriptions_pkey"}), 253 - publication_subscriptions_uri_key: unique("publication_subscriptions_uri_key").on(table.uri), 276 + publication_idx: index("publication_domains_publication_idx").on(table.publication), 277 + publication_domains_pkey: primaryKey({ columns: [table.publication, table.domain], name: "publication_domains_pkey"}), 254 278 } 255 279 }); 256 280 ··· 263 287 }, 264 288 (table) => { 265 289 return { 290 + leaflet_idx: index("leaflets_in_publications_leaflet_idx").on(table.leaflet), 291 + publication_idx: index("leaflets_in_publications_publication_idx").on(table.publication), 266 292 leaflets_in_publications_pkey: primaryKey({ columns: [table.publication, table.leaflet], name: "leaflets_in_publications_pkey"}), 267 293 } 268 294 }); 269 295 296 + export const publication_subscriptions = pgTable("publication_subscriptions", { 297 + publication: text("publication").notNull().references(() => publications.uri, { onDelete: "cascade" } ), 298 + identity: text("identity").notNull().references(() => identities.atp_did, { onDelete: "cascade" } ), 299 + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 300 + record: jsonb("record").notNull(), 301 + uri: text("uri").notNull(), 302 + }, 303 + (table) => { 304 + return { 305 + publication_idx: index("publication_subscriptions_publication_idx").on(table.publication), 306 + publication_subscriptions_pkey: primaryKey({ columns: [table.publication, table.identity], name: "publication_subscriptions_pkey"}), 307 + publication_subscriptions_uri_key: unique("publication_subscriptions_uri_key").on(table.uri), 308 + } 309 + }); 310 + 270 311 export const permission_token_rights = pgTable("permission_token_rights", { 271 312 token: uuid("token").notNull().references(() => permission_tokens.id, { onDelete: "cascade", onUpdate: "cascade" } ), 272 313 entity_set: uuid("entity_set").notNull().references(() => entity_sets.id, { onDelete: "cascade", onUpdate: "cascade" } ), ··· 278 319 }, 279 320 (table) => { 280 321 return { 322 + token_idx: index("permission_token_rights_token_idx").on(table.token), 323 + entity_set_idx: index("permission_token_rights_entity_set_idx").on(table.entity_set), 281 324 permission_token_rights_pkey: primaryKey({ columns: [table.token, table.entity_set], name: "permission_token_rights_pkey"}), 282 325 } 283 326 });
+95 -39
feeds/index.ts
··· 4 4 import { parseReqNsid, verifyJwt } from "@atproto/xrpc-server"; 5 5 import { supabaseServerClient } from "supabase/serverClient"; 6 6 import { PubLeafletDocument } from "lexicons/api"; 7 + import { inngest } from "app/api/inngest/client"; 8 + import { AtUri } from "@atproto/api"; 7 9 8 10 const app = new Hono(); 9 11 ··· 27 29 28 30 app.get("/xrpc/app.bsky.feed.getFeedSkeleton", async (c) => { 29 31 let auth = await validateAuth(c.req, serviceDid); 30 - if (!auth) return c.json({ feed: [] }); 32 + let feed = c.req.query("feed"); 33 + if (!auth || !feed) return c.json({ feed: [] }); 31 34 let cursor = c.req.query("cursor"); 35 + let parsedCursor; 36 + if (cursor) { 37 + let date = cursor.split("::")[0]; 38 + let uri = cursor.split("::")[1]; 39 + parsedCursor = { date, uri }; 40 + } 32 41 let limit = parseInt(c.req.query("limit") || "10"); 42 + let feedAtURI = new AtUri(feed); 43 + let posts; 44 + let query; 45 + if (feedAtURI.rkey == "bsky-leaflet-quotes") { 46 + let query = supabaseServerClient 47 + .from("document_mentions_in_bsky") 48 + .select("*") 49 + .order("indexed_at", { ascending: false }) 50 + .order("uri", { ascending: false }) 51 + .limit(25); 52 + if (parsedCursor) 53 + query = query.or( 54 + `indexed_at.lt.${parsedCursor.date},and(indexed_at.eq.${parsedCursor.date},uri.lt.${parsedCursor.uri})`, 55 + ); 33 56 34 - let { data: publications } = await supabaseServerClient 35 - .from("publication_subscriptions") 36 - .select(`publications(documents_in_publications(documents(*)))`) 37 - .eq("identity", auth); 57 + let { data, error } = await query; 58 + let posts = data || []; 38 59 39 - const allPosts = (publications || []) 40 - .flatMap((pub) => { 41 - let posts = pub.publications?.documents_in_publications || []; 42 - return posts; 43 - }) 44 - .sort((a, b) => { 45 - let aRecord = a.documents?.data! as PubLeafletDocument.Record; 46 - let bRecord = b.documents?.data! as PubLeafletDocument.Record; 47 - const aDate = aRecord.publishedAt 48 - ? new Date(aRecord.publishedAt) 49 - : new Date(0); 50 - const bDate = bRecord.publishedAt 51 - ? new Date(bRecord.publishedAt) 52 - : new Date(0); 53 - return bDate.getTime() - aDate.getTime(); // Sort by most recent first 60 + let lastPost = posts[posts.length - 1]; 61 + let newCursor = lastPost ? `${lastPost.indexed_at}::${lastPost.uri}` : null; 62 + return c.json({ 63 + cursor: newCursor || cursor, 64 + feed: posts.flatMap((p) => { 65 + return { post: p.uri }; 66 + }), 54 67 }); 55 - let posts; 56 - if (!cursor) { 57 - posts = allPosts.slice(0, 25); 68 + } 69 + if (feedAtURI.rkey === "bsky-follows-leaflets") { 70 + if (!cursor) { 71 + console.log("Sending event"); 72 + await inngest.send({ name: "feeds/index-follows", data: { did: auth } }); 73 + } 74 + query = supabaseServerClient 75 + .from("documents") 76 + .select( 77 + `*, 78 + documents_in_publications!inner( 79 + publications!inner(*, 80 + identities!publications_identity_did_fkey!inner( 81 + bsky_follows!bsky_follows_follows_fkey!inner(*) 82 + ) 83 + ) 84 + )`, 85 + ) 86 + .eq( 87 + "documents_in_publications.publications.identities.bsky_follows.identity", 88 + auth, 89 + ); 90 + } else if (feedAtURI.rkey === "all-leaflets") { 91 + query = supabaseServerClient 92 + .from("documents") 93 + .select( 94 + `*, 95 + documents_in_publications!inner(publications!inner(*))`, 96 + ) 97 + .or( 98 + "record->preferences->showInDiscover.is.null,record->preferences->>showInDiscover.eq.true", 99 + { referencedTable: "documents_in_publications.publications" }, 100 + ); 58 101 } else { 59 - let date = cursor.split("::")[0]; 60 - let uri = cursor.split("::")[1]; 61 - posts = allPosts 62 - .filter((p) => { 63 - if (!p.documents?.data) return false; 64 - let record = p.documents.data as PubLeafletDocument.Record; 65 - if (!record.publishedAt) return false; 66 - return record.publishedAt <= date && uri !== p.documents?.uri; 67 - }) 68 - .slice(0, 25); 102 + //the default subscription feed 103 + query = supabaseServerClient 104 + .from("documents") 105 + .select( 106 + `*, 107 + documents_in_publications!inner(publications!inner(*, publication_subscriptions!inner(*)))`, 108 + ) 109 + .eq( 110 + "documents_in_publications.publications.publication_subscriptions.identity", 111 + auth, 112 + ); 69 113 } 114 + query = query 115 + .not("data -> postRef", "is", null) 116 + .order("indexed_at", { ascending: false }) 117 + .order("uri", { ascending: false }) 118 + .limit(25); 119 + if (parsedCursor) 120 + query = query.or( 121 + `indexed_at.lt.${parsedCursor.date},and(indexed_at.eq.${parsedCursor.date},uri.lt.${parsedCursor.uri})`, 122 + ); 123 + 124 + let { data, error } = await query; 125 + console.log(error); 126 + posts = data; 127 + 128 + posts = posts || []; 70 129 71 130 let lastPost = posts[posts.length - 1]; 72 - let lastRecord = lastPost?.documents?.data! as PubLeafletDocument.Record; 73 - let newCursor = lastRecord 74 - ? `${lastRecord.publishedAt}::${lastPost.documents?.uri}` 75 - : null; 131 + let newCursor = lastPost ? `${lastPost.indexed_at}::${lastPost.uri}` : null; 76 132 return c.json({ 77 133 cursor: newCursor || cursor, 78 134 feed: posts.flatMap((p) => { 79 - if (!p.documents?.data) return []; 80 - let record = p.documents.data as PubLeafletDocument.Record; 135 + if (!p.data) return []; 136 + let record = p.data as PubLeafletDocument.Record; 81 137 if (!record.postRef) return []; 82 138 return { post: record.postRef.uri }; 83 139 }),
+30
lexicons/src/blocks.ts
··· 191 191 }, 192 192 }; 193 193 194 + export const PubLeafletBlocksOrderedList: LexiconDoc = { 195 + lexicon: 1, 196 + id: "pub.leaflet.blocks.orderedList", 197 + defs: { 198 + main: { 199 + type: "object", 200 + required: ["children"], 201 + properties: { 202 + startIndex: { type: "integer" }, 203 + children: { type: "array", items: { type: "ref", ref: "#listItem" } }, 204 + }, 205 + }, 206 + listItem: { 207 + type: "object", 208 + required: ["content"], 209 + properties: { 210 + content: { 211 + type: "union", 212 + refs: [ 213 + PubLeafletBlocksText, 214 + PubLeafletBlocksHeader, 215 + PubLeafletBlocksImage, 216 + ].map((l) => l.id), 217 + }, 218 + children: { type: "array", items: { type: "ref", ref: "#listItem" } }, 219 + }, 220 + }, 221 + }, 222 + }; 223 + 194 224 export const PubLeafletBlocksUnorderedList: LexiconDoc = { 195 225 lexicon: 1, 196 226 id: "pub.leaflet.blocks.unorderedList",
+1 -1
middleware.ts
··· 83 83 let aturi = new AtUri(pub?.uri); 84 84 return NextResponse.rewrite( 85 85 new URL( 86 - `/lish/${aturi.host}/${encodeURIComponent(pub.name)}${req.nextUrl.pathname}`, 86 + `/lish/${aturi.host}/${aturi.rkey}${req.nextUrl.pathname}`, 87 87 req.url, 88 88 ), 89 89 );
+16 -5
package-lock.json
··· 36 36 "@types/mdx": "^2.0.13", 37 37 "@vercel/analytics": "^1.5.0", 38 38 "@vercel/functions": "^2.2.12", 39 - "@vercel/sdk": "^1.3.1", 39 + "@vercel/sdk": "^1.11.4", 40 40 "babel-plugin-react-compiler": "^19.1.0-rc.1", 41 41 "base64-js": "^1.5.1", 42 42 "colorjs.io": "^0.5.2", ··· 7061 7061 } 7062 7062 }, 7063 7063 "node_modules/@vercel/sdk": { 7064 - "version": "1.3.1", 7065 - "resolved": "https://registry.npmjs.org/@vercel/sdk/-/sdk-1.3.1.tgz", 7066 - "integrity": "sha512-lTzG17DIDEJlWaGRNVyLZ17p8NRMOE2U98Kg96r1q79LGC8nPthoWhxupHXxGk3eAjjsvp8OA3bncKEyCHcofg==", 7064 + "version": "1.11.4", 7065 + "resolved": "https://registry.npmjs.org/@vercel/sdk/-/sdk-1.11.4.tgz", 7066 + "integrity": "sha512-TlLGQq6ToqnGhICQjvAyjSJkrHsLtqIM+nZuGV/SiamwSzXjiTpeTAe5wMDAao7LPJ9Efir/z76TvXFbLlW/9Q==", 7067 + "dependencies": { 7068 + "zod": "^3.20.0" 7069 + }, 7070 + "bin": { 7071 + "mcp": "bin/mcp-server.js" 7072 + }, 7067 7073 "peerDependencies": { 7068 - "zod": ">= 3" 7074 + "@modelcontextprotocol/sdk": ">=1.5.0 <1.10.0" 7075 + }, 7076 + "peerDependenciesMeta": { 7077 + "@modelcontextprotocol/sdk": { 7078 + "optional": true 7079 + } 7069 7080 } 7070 7081 }, 7071 7082 "node_modules/abort-controller": {
+1 -1
package.json
··· 46 46 "@types/mdx": "^2.0.13", 47 47 "@vercel/analytics": "^1.5.0", 48 48 "@vercel/functions": "^2.2.12", 49 - "@vercel/sdk": "^1.3.1", 49 + "@vercel/sdk": "^1.11.4", 50 50 "babel-plugin-react-compiler": "^19.1.0-rc.1", 51 51 "base64-js": "^1.5.1", 52 52 "colorjs.io": "^0.5.2",
+22
src/hooks/usePreserveScroll.ts
··· 1 + import { useRef, useEffect } from "react"; 2 + 3 + let scrollPositions: { [key: string]: number } = {}; 4 + export function usePreserveScroll<T extends HTMLElement>(key: string | null) { 5 + let ref = useRef<T | null>(null); 6 + useEffect(() => { 7 + if (!ref.current || !key) return; 8 + 9 + window.requestAnimationFrame(() => { 10 + ref.current?.scrollTo({ top: scrollPositions[key] || 0 }); 11 + }); 12 + 13 + const listener = () => { 14 + if (!ref.current?.scrollTop) return; 15 + scrollPositions[key] = ref.current.scrollTop; 16 + }; 17 + 18 + ref.current.addEventListener("scroll", listener); 19 + return () => ref.current?.removeEventListener("scroll", listener); 20 + }, [key, ref.current]); 21 + return { ref }; 22 + }
+6 -2
src/shortcuts.ts
··· 3 3 import { ReplicacheMutators } from "./replicache"; 4 4 import { isMac } from "./utils/isDevice"; 5 5 6 - type Shortcut = { 6 + export type Shortcut = { 7 7 metaKey?: boolean; 8 + metaAndCtrl?: boolean; 8 9 altKey?: boolean; 9 10 shift?: boolean; 10 11 key: string | string[]; ··· 15 16 for (let shortcut of [shortcuts].flat()) { 16 17 if (e.shiftKey !== !!shortcut.shift) continue; 17 18 if (e.altKey !== !!shortcut.altKey) continue; 18 - if (!!shortcut.metaKey !== (isMac() ? e.metaKey : e.ctrlKey)) continue; 19 + if (shortcut.metaAndCtrl) { 20 + if (!(e.metaKey && e.ctrlKey)) continue; 21 + } else if (!!shortcut.metaKey !== (isMac() ? e.metaKey : e.ctrlKey)) 22 + continue; 19 23 if (![shortcut.key].flat().includes(e.key)) continue; 20 24 e.preventDefault(); 21 25 return shortcut.handler();
-1
src/utils/addLinkBlock.ts
··· 59 59 } 60 60 61 61 if (data.data.links?.player?.[0]) { 62 - console.log(data.data.links.player); 63 62 let embed = data.data.links?.player?.[0]; 64 63 await rep.mutate.assertFact([ 65 64 {
-1
src/utils/focusBlock.ts
··· 85 85 top: nextBlockViewClientRect.top + 12, 86 86 left: Math.max(position.left, nextBlockViewClientRect.left), 87 87 }); 88 - console.log(pos); 89 88 break; 90 89 } 91 90 case "bottom": {
+1 -2
src/utils/getBlocksAsHTML.tsx
··· 164 164 return ( 165 165 <div 166 166 data-type="card" 167 - data-facts={btoa(JSON.stringify(facts))} 167 + data-facts={JSON.stringify(facts)} 168 168 data-entityid={card.data.value} 169 169 /> 170 170 ); ··· 187 187 let [alignment] = await scanIndex(tx).eav(b.value, "block/text-alignment"); 188 188 let toHtml = BlockTypeToHTML[b.type]; 189 189 let element = await toHtml(b, tx, alignment?.data.value); 190 - console.log(element); 191 190 return renderToStaticMarkup(element); 192 191 }
+33
supabase/database.types.ts
··· 34 34 } 35 35 public: { 36 36 Tables: { 37 + bsky_follows: { 38 + Row: { 39 + follows: string 40 + identity: string 41 + } 42 + Insert: { 43 + follows: string 44 + identity?: string 45 + } 46 + Update: { 47 + follows?: string 48 + identity?: string 49 + } 50 + Relationships: [ 51 + { 52 + foreignKeyName: "bsky_follows_follows_fkey" 53 + columns: ["follows"] 54 + isOneToOne: false 55 + referencedRelation: "identities" 56 + referencedColumns: ["atp_did"] 57 + }, 58 + { 59 + foreignKeyName: "bsky_follows_identity_fkey" 60 + columns: ["identity"] 61 + isOneToOne: false 62 + referencedRelation: "identities" 63 + referencedColumns: ["atp_did"] 64 + }, 65 + ] 66 + } 37 67 bsky_posts: { 38 68 Row: { 39 69 cid: string ··· 214 244 document_mentions_in_bsky: { 215 245 Row: { 216 246 document: string 247 + indexed_at: string 217 248 link: string 218 249 uri: string 219 250 } 220 251 Insert: { 221 252 document: string 253 + indexed_at?: string 222 254 link: string 223 255 uri: string 224 256 } 225 257 Update: { 226 258 document?: string 259 + indexed_at?: string 227 260 link?: string 228 261 uri?: string 229 262 }
+62
supabase/migrations/20251014215602_add_bsky_follows_table.sql
··· 1 + create table "public"."bsky_follows" ( 2 + "identity" text not null, 3 + "follows" text not null 4 + ); 5 + 6 + alter table "public"."bsky_follows" enable row level security; 7 + 8 + CREATE UNIQUE INDEX bsky_follows_pkey ON public.bsky_follows USING btree (identity, follows); 9 + 10 + CREATE INDEX facts_reference_idx ON public.facts USING btree (((data ->> 'value'::text))) WHERE (((data ->> 'type'::text) = 'reference'::text) OR ((data ->> 'type'::text) = 'ordered-reference'::text)); 11 + 12 + alter table "public"."bsky_follows" add constraint "bsky_follows_pkey" PRIMARY KEY using index "bsky_follows_pkey"; 13 + 14 + alter table "public"."bsky_follows" add constraint "bsky_follows_follows_fkey" FOREIGN KEY (follows) REFERENCES identities(atp_did) ON DELETE CASCADE not valid; 15 + 16 + alter table "public"."bsky_follows" validate constraint "bsky_follows_follows_fkey"; 17 + 18 + alter table "public"."bsky_follows" add constraint "bsky_follows_identity_fkey" FOREIGN KEY (identity) REFERENCES identities(atp_did) ON DELETE CASCADE not valid; 19 + 20 + alter table "public"."bsky_follows" validate constraint "bsky_follows_identity_fkey"; 21 + 22 + grant delete on table "public"."bsky_follows" to "anon"; 23 + 24 + grant insert on table "public"."bsky_follows" to "anon"; 25 + 26 + grant references on table "public"."bsky_follows" to "anon"; 27 + 28 + grant select on table "public"."bsky_follows" to "anon"; 29 + 30 + grant trigger on table "public"."bsky_follows" to "anon"; 31 + 32 + grant truncate on table "public"."bsky_follows" to "anon"; 33 + 34 + grant update on table "public"."bsky_follows" to "anon"; 35 + 36 + grant delete on table "public"."bsky_follows" to "authenticated"; 37 + 38 + grant insert on table "public"."bsky_follows" to "authenticated"; 39 + 40 + grant references on table "public"."bsky_follows" to "authenticated"; 41 + 42 + grant select on table "public"."bsky_follows" to "authenticated"; 43 + 44 + grant trigger on table "public"."bsky_follows" to "authenticated"; 45 + 46 + grant truncate on table "public"."bsky_follows" to "authenticated"; 47 + 48 + grant update on table "public"."bsky_follows" to "authenticated"; 49 + 50 + grant delete on table "public"."bsky_follows" to "service_role"; 51 + 52 + grant insert on table "public"."bsky_follows" to "service_role"; 53 + 54 + grant references on table "public"."bsky_follows" to "service_role"; 55 + 56 + grant select on table "public"."bsky_follows" to "service_role"; 57 + 58 + grant trigger on table "public"."bsky_follows" to "service_role"; 59 + 60 + grant truncate on table "public"."bsky_follows" to "service_role"; 61 + 62 + grant update on table "public"."bsky_follows" to "service_role";
+1
supabase/migrations/20251017160632_add_indexed_at_to_document_mentions_in_bsky.sql
··· 1 + alter table "public"."document_mentions_in_bsky" add column "indexed_at" timestamp with time zone not null default now();