"use client"; import { useEffect, useRef, useState } from "react"; import dynamic from "next/dynamic"; import { AtUri } from "@atproto/syntax"; import type { EditorState } from "prosemirror-state"; import { useSmoker, useToaster } from "../Toast"; import { Menu, MenuItem } from "../Menu"; import { ButtonPrimary } from "../Buttons"; import { Modal } from "../Modal"; import { MobileSheet } from "../MobileSheet"; import { useIsMobile } from "src/hooks/isMobile"; import { ShareTiny } from "../Icons/ShareTiny"; import { DotLoader } from "../utils/DotLoader"; import { useIdentityData } from "../IdentityProvider"; import { publishPostToBsky } from "app/(app)/[leaflet_id]/publish/publishBskyPost"; import { blobRefToSrc } from "src/utils/blobRefToSrc"; import { bskyPostEmbed } from "src/utils/bskyPostEmbed"; import { BlueskyTiny } from "components/Icons/BlueskyTiny"; import { LoginContent } from "components/LoginButton"; import { NormalizedDocument, NormalizedPublication, } from "lexicons/src/normalize"; // Loaded on share-modal open rather than bundled: the composer drags in // prosemirror, which every anonymous reader of a public post would otherwise // download for a button most never click. const BlueskyPostComposer = dynamic( () => import("../BlueskyPostComposer/BlueskyPostComposer").then( (m) => m.BlueskyPostComposer, ), { ssr: false, loading: () => }, ); export const InteractionShareButton = (props: { postRecord: NormalizedDocument; postUrl?: string; documentUri?: string; publication?: NormalizedPublication; pubUri: string | undefined; type: "none" | "weak" | "strong"; trigger?: React.ReactNode; }) => { let { identity } = useIdentityData(); let smoker = useSmoker(); let [shareModalOpen, setShareModalOpen] = useState(false); if (props.type === "none") return; function postOwnerDid(uri: string): string | null { try { return new AtUri(uri).host; } catch { return null; } } return ( <> {props.trigger ? ( props.trigger ) : ( <> Share )} } > setShareModalOpen(true)}> Share on Bluesky { e.stopPropagation(); e.preventDefault(); if (!props.postUrl) return; navigator.clipboard.writeText(props.postUrl); let rect = (e.target as HTMLElement).getBoundingClientRect(); smoker({ text: Copied Link!, position: { y: rect.top, x: rect.left, }, }); }} > Copy Link setShareModalOpen(false)} shareModalOpen={shareModalOpen} setShareModalOpen={setShareModalOpen} /> ); }; export const BskyShareModal = (props: { postUrl?: string; docRecord: NormalizedDocument; documentUri?: string; pubOwnerDid: string | undefined; publication?: NormalizedPublication; pubUri: string | undefined; preferUrlScreenshot?: boolean; onPosted: () => void; shareModalOpen: boolean; setShareModalOpen: (s: boolean) => void; }) => { let { identity } = useIdentityData(); let toaster = useToaster(); let editorStateRef = useRef(null); let [charCount, setCharCount] = useState(0); let [posting, setPosting] = useState(false); let isMobile = useIsMobile(); let profile = { avatar: identity?.bsky_profiles?.record.avatar, displayName: identity?.bsky_profiles?.record.displayName, handle: identity?.bsky_profiles?.record.handle, }; let { title, description, coverImage, publishedAt } = props.docRecord; // The cover blob lives in the repo hosting the document, i.e. the document // uri's host. let authorDid = props.documentUri ? new AtUri(props.documentUri).host : undefined; // The card image is a page screenshot when this is a quote share (the quote // highlight matters more than the cover) or when the document has no cover // image to fall back on. let useScreenshot = !!props.preferUrlScreenshot || !coverImage; let screenshot = usePrefetchedScreenshot( props.postUrl, useScreenshot && props.shareModalOpen, ); let post = async () => { if (!editorStateRef.current || !props.postUrl || posting) return; setPosting(true); // Block on the prefetched screenshot (kicked off when the modal opened) so // the post's card ships with it. If the prefetch failed, // preferUrlScreenshot has the server take its own screenshot instead. let prefetchedThumb = screenshot.promiseRef.current ? ((await screenshot.promiseRef.current) ?? undefined) : undefined; // Runtime import keeps the prosemirror module graph out of the static // bundle; by submit time the composer chunk (same module) is loaded. let { editorStateToFacetedText } = await import( "../BlueskyPostComposer/ProsemirrorEditor" ); let [text, facets] = editorStateToFacetedText(editorStateRef.current); let res = await publishPostToBsky({ text, facets, url: props.postUrl, document_record: props.docRecord, documentUri: props.documentUri, publicationUri: props.pubUri, preferUrlScreenshot: useScreenshot, prefetchedThumb, }); setPosting(false); if (!res.success) { toaster({ content: "Hmm… Something went wrong. Try again!", type: "error", }); return; } let postAtUri = new AtUri(res.uri); let postUrl = `https://bsky.app/profile/${postAtUri.host}/post/${postAtUri.rkey}`; toaster({ content: ( Shared to Bluesky!{" "} View it here. ), type: "success", }); props.onPosted(); }; // The #view embed wants a resolvable thumbnail URL, not the raw blob ref, so // point at the atproto image proxy for the cover blob in the author's PDS. let coverThumb = coverImage && authorDid ? blobRefToSrc(coverImage.ref, authorDid, undefined, { width: 1200, }) : undefined; let embed = bskyPostEmbed({ url: props.postUrl ?? "", title, description, thumb: useScreenshot ? (screenshot.previewSrc ?? undefined) : coverThumb, thumbPending: useScreenshot && !screenshot.previewSrc, publishedAt, publication: props.publication, pubOwnerDid: props.pubOwnerDid, }); let submitButton = ( 300} > {posting ? ( ) : ( <> Post )} ); let loggedIn = identity && identity.atp_did; let shareContent = !loggedIn ? ( ) : ( <> ); return ( <> {isMobile ? ( {shareContent} ) : ( {loggedIn &&
} {shareContent} )} ); }; // Kicks off the card screenshot (a slow browser render) the moment the share // modal opens (`enabled`), so it's ready by the time the user finishes // composing. `promiseRef` resolves to the base64 webp handed to // publishPostToBsky (null on failure); `previewSrc` is an object URL for the // compose card preview once the image lands. function usePrefetchedScreenshot(url: string | undefined, enabled: boolean) { let promiseRef = useRef | null>(null); let [previewSrc, setPreviewSrc] = useState(null); useEffect(() => { if (!enabled || !url) return; let cancelled = false; let objectUrl: string | null = null; promiseRef.current = fetch( `/api/quote_screenshot?url=${encodeURIComponent(url)}`, ) .then(async (res) => { if (!res.ok) return null; let blob = await res.blob(); if (!cancelled) { objectUrl = URL.createObjectURL(blob); setPreviewSrc(objectUrl); } return blobToBase64(blob); }) .catch(() => null); return () => { cancelled = true; promiseRef.current = null; setPreviewSrc(null); if (objectUrl) URL.revokeObjectURL(objectUrl); }; }, [url, enabled]); return { promiseRef, previewSrc }; } function blobToBase64(blob: Blob): Promise { return new Promise((resolve, reject) => { let reader = new FileReader(); // reader.result is a data: URL; the base64 payload follows the comma. reader.onload = () => resolve((reader.result as string).split(",")[1]); reader.onerror = () => reject(reader.error); reader.readAsDataURL(blob); }); }