a tool for shared writing and social publishing

Feature/bsky embeds (#126)

* install @atproto/api for bluesky

* create new block scaffolding BlueskyPostBlock

* get basic post rendering and static display working

* add bluesky icons for link to orig post in BlueskyPostBlock

* link handle to user profile too

* wire up basic saving bsky url attr, WIP, it saves nothing else, still queries for the post each time

* added embeds to bluesky post, but haven't styled them yet

* forgot to commit a file i renamed to index

* added embeds, styled, and wired up to fact store

* renamed input file to empty, added an error if user pastes an invalid bsky link

* simplifed icons, made them a little smaller

* rename BlueSky to Bluesky

* added facets

* bunch of small fixes and added a reply counter

* removed stray console log

* moved addBlueskyPost to utils, added embed post from textblock

* hack to make types work alas

* a better fix for the types!

* more type casting

---------

Co-authored-by: Brendan Schlagel <brendan.schlagel@gmail.com>
Co-authored-by: Jared Pereira <jared@awarm.space>

+882 -56
+2
components/Blocks/Block.tsx
··· 24 24 import { elementId } from "src/utils/elementId"; 25 25 import { ButtonBlock } from "./ButtonBlock"; 26 26 import { PollBlock } from "./PollBlock"; 27 + import { BlueskyPostBlock } from "./BlueskyPostBlock"; 27 28 28 29 export type Block = { 29 30 factID: string; ··· 177 178 rsvp: RSVPBlock, 178 179 button: ButtonBlock, 179 180 poll: PollBlock, 181 + "bluesky-post": BlueskyPostBlock, 180 182 }; 181 183 182 184 export const BlockMultiselectIndicator = (props: BlockProps) => {
+25 -30
components/Blocks/BlockCommands.tsx
··· 15 15 BlockCalendarSmall, 16 16 RSVPSmall, 17 17 BlockPollSmall, 18 + BlockBlueskySmall, 18 19 } from "components/Icons"; 19 20 import { generateKeyBetween } from "fractional-indexing"; 20 21 import { focusPage } from "components/Pages"; ··· 197 198 }, 198 199 199 200 { 200 - name: "External Link", 201 - icon: <LinkSmall />, 202 - type: "block", 203 - onSelect: async (rep, props, um) => { 204 - props.entityID && clearCommandSearchText(props.entityID); 205 - await createBlockWithType(rep, props, "link"); 206 - um.add({ 207 - undo: () => { 208 - props.entityID && keepFocus(props.entityID); 209 - }, 210 - redo: () => {}, 211 - }); 212 - }, 213 - }, 214 - { 215 - name: "Embed Website", 216 - icon: <BlockEmbedSmall />, 217 - type: "block", 218 - onSelect: async (rep, props, um) => { 219 - props.entityID && clearCommandSearchText(props.entityID); 220 - await createBlockWithType(rep, props, "embed"); 221 - um.add({ 222 - undo: () => { 223 - props.entityID && keepFocus(props.entityID); 224 - }, 225 - redo: () => {}, 226 - }); 227 - }, 228 - }, 229 - { 230 201 name: "Image", 231 202 icon: <BlockImageSmall />, 232 203 type: "block", ··· 246 217 el?.focus(); 247 218 }, 248 219 }); 220 + }, 221 + }, 222 + { 223 + name: "External Link", 224 + icon: <LinkSmall />, 225 + type: "block", 226 + onSelect: async (rep, props) => { 227 + createBlockWithType(rep, props, "link"); 249 228 }, 250 229 }, 251 230 { ··· 321 300 }, 20); 322 301 }, 323 302 }); 303 + }, 304 + }, 305 + { 306 + name: "Embed Website", 307 + icon: <BlockEmbedSmall />, 308 + type: "block", 309 + onSelect: async (rep, props) => { 310 + createBlockWithType(rep, props, "embed"); 311 + }, 312 + }, 313 + { 314 + name: "Bluesky Post", 315 + icon: <BlockBlueskySmall />, 316 + type: "block", 317 + onSelect: async (rep, props) => { 318 + createBlockWithType(rep, props, "bluesky-post"); 324 319 }, 325 320 }, 326 321
+208
components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx
··· 1 + import { $Typed, is$typed } from "@atproto/api/dist/client/util"; 2 + import { 3 + AppBskyEmbedImages, 4 + AppBskyEmbedVideo, 5 + AppBskyEmbedExternal, 6 + AppBskyEmbedRecord, 7 + AppBskyEmbedRecordWithMedia, 8 + AppBskyFeedPost, 9 + AppBskyFeedDefs, 10 + AppBskyGraphDefs, 11 + AppBskyLabelerDefs, 12 + } from "@atproto/api"; 13 + 14 + export const BlueskyEmbed = (props: { 15 + embed: Exclude<AppBskyFeedDefs.PostView["embed"], undefined>; 16 + postUrl?: string; 17 + }) => { 18 + // check this file from bluesky for ref 19 + // https://github.com/bluesky-social/social-app/blob/main/bskyembed/src/components/embed.tsx 20 + switch (true) { 21 + case AppBskyEmbedImages.isView(props.embed): 22 + let imageEmbed = props.embed; 23 + return ( 24 + <div className="flex flex-wrap rounded-md w-full overflow-hidden"> 25 + {imageEmbed.images.map( 26 + (image: { fullsize: string; alt?: string }, i: number) => ( 27 + <img 28 + key={i} 29 + src={image.fullsize} 30 + alt={image.alt || "Post image"} 31 + className={` 32 + overflow-hidden w-full object-cover 33 + ${imageEmbed.images.length === 1 && "h-auto max-h-[800px]"} 34 + ${imageEmbed.images.length === 2 && "basis-1/2 aspect-1/1"} 35 + ${imageEmbed.images.length === 3 && "basis-1/3 aspect-2/3"} 36 + ${ 37 + imageEmbed.images.length === 4 38 + ? "basis-1/2 aspect-[3/2]" 39 + : `basis-1/${imageEmbed.images.length} ` 40 + } 41 + `} 42 + /> 43 + ), 44 + )} 45 + </div> 46 + ); 47 + case AppBskyEmbedExternal.isView(props.embed): 48 + let externalEmbed = props.embed; 49 + let isGif = externalEmbed.external.uri.includes(".gif"); 50 + if (isGif) { 51 + return ( 52 + <div className="flex flex-col border border-border-light rounded-md overflow-hidden"> 53 + <img 54 + src={externalEmbed.external.uri} 55 + alt={externalEmbed.external.title} 56 + className="object-cover" 57 + /> 58 + </div> 59 + ); 60 + } 61 + return ( 62 + <a 63 + href={externalEmbed.external.uri} 64 + target="_blank" 65 + className="group flex flex-col border border-border-light rounded-md overflow-hidden hover:no-underline sm:hover:border-accent-contrast selected-border" 66 + > 67 + {externalEmbed.external.thumb === undefined ? null : ( 68 + <> 69 + <img 70 + src={externalEmbed.external.thumb} 71 + alt={externalEmbed.external.title} 72 + className="object-cover" 73 + /> 74 + 75 + <hr className="border-border-light " /> 76 + </> 77 + )} 78 + <div className="p-2 flex flex-col gap-1"> 79 + <div className="flex flex-col"> 80 + <h4>{externalEmbed.external.title}</h4> 81 + <p className="text-secondary"> 82 + {externalEmbed.external.description} 83 + </p> 84 + </div> 85 + <hr className="border-border-light mt-1" /> 86 + <div className="text-tertiary text-xs sm:group-hover:text-accent-contrast"> 87 + {externalEmbed.external.uri} 88 + </div> 89 + </div> 90 + </a> 91 + ); 92 + case AppBskyEmbedVideo.isView(props.embed): 93 + let videoEmbed = props.embed; 94 + return ( 95 + <div className="rounded-md overflow-hidden relative"> 96 + <img 97 + src={videoEmbed.thumbnail} 98 + alt={ 99 + "Thumbnail from embedded video. Go to Bluesky to see the full post." 100 + } 101 + className={`overflow-hidden w-full object-cover`} 102 + /> 103 + <div className="overlay absolute top-0 right-0 left-0 bottom-0 bg-primary opacity-65" /> 104 + <div className="absolute w-max top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-border-light rounded-md"> 105 + <SeePostOnBluesky postUrl={props.postUrl} /> 106 + </div> 107 + </div> 108 + ); 109 + case AppBskyEmbedRecord.isView(props.embed): 110 + let recordEmbed = props.embed; 111 + let record = recordEmbed.record; 112 + 113 + if (record === undefined) return; 114 + 115 + // if the record is a feed post 116 + if (AppBskyEmbedRecord.isViewRecord(record)) { 117 + // we have to do this nonsense to get a the proper type for the record text 118 + // we aped it from the bluesky front end (check the link at the top of this file) 119 + let text: string | null = null; 120 + if (AppBskyFeedPost.isRecord(record.value)) { 121 + text = (record.value as AppBskyFeedPost.Record).text; 122 + } 123 + return ( 124 + <div 125 + className={`flex flex-col gap-1 relative w-full overflow-hidden sm:p-3 p-2 text-xs block-border`} 126 + > 127 + <div className="bskyAuthor w-full flex items-center gap-1"> 128 + <img 129 + src={record.author?.avatar} 130 + alt={`${record.author?.displayName}'s avatar`} 131 + className="shink-0 w-6 h-6 rounded-full border border-border-light" 132 + /> 133 + <div className=" font-bold text-secondary"> 134 + {record.author?.displayName} 135 + </div> 136 + <a 137 + className="text-xs text-tertiary hover:underline" 138 + target="_blank" 139 + href={`https://bsky.app/profile/${record.author?.handle}`} 140 + > 141 + @{record.author?.handle} 142 + </a> 143 + </div> 144 + 145 + <div className="flex flex-col gap-2 "> 146 + {text && <pre className="whitespace-pre-wrap">{text}</pre>} 147 + {record.embeds !== undefined 148 + ? record.embeds.map((embed) => <BlueskyEmbed embed={embed} />) 149 + : null} 150 + </div> 151 + </div> 152 + ); 153 + } 154 + 155 + // labeller, starterpack or feed 156 + if ( 157 + AppBskyFeedDefs.isGeneratorView(record) || 158 + AppBskyLabelerDefs.isLabelerView(record) || 159 + AppBskyGraphDefs.isStarterPackViewBasic(record) 160 + ) 161 + return <SeePostOnBluesky postUrl={props.postUrl} />; 162 + 163 + // post is blocked or not found 164 + if ( 165 + AppBskyFeedDefs.isBlockedPost(record) || 166 + AppBskyFeedDefs.isNotFoundPost(record) 167 + ) 168 + return <PostNotAvailable />; 169 + 170 + if (AppBskyEmbedRecord.isViewDetached(record)) return null; 171 + 172 + return <SeePostOnBluesky postUrl={props.postUrl} />; 173 + 174 + // I am not sure when this case will be used? so I'm commenting it out for now 175 + // case AppBskyEmbedRecordWithMedia.isView(props.embed): 176 + // const recordWithMediaEmbed = props.embed; 177 + // return <div>This is a record with Media </div>; 178 + 179 + default: 180 + return <SeePostOnBluesky postUrl={props.postUrl} />; 181 + } 182 + }; 183 + 184 + const SeePostOnBluesky = (props: { postUrl: string | undefined }) => { 185 + return ( 186 + <a 187 + href={props.postUrl} 188 + target="_blank" 189 + className={`block-border flex flex-col p-3 font-normal !rounded-md border text-tertiary italic text-center hover:no-underline hover:border-accent-contrast ${props.postUrl === undefined && "pointer-events-none"} `} 190 + > 191 + <div> This media is not supported... </div>{" "} 192 + {props.postUrl === undefined ? null : ( 193 + <div> 194 + See the <span className=" text-accent-contrast">full post</span> on 195 + Bluesky! 196 + </div> 197 + )} 198 + </a> 199 + ); 200 + }; 201 + 202 + export const PostNotAvailable = () => { 203 + return ( 204 + <div className="px-3 py-6 w-full rounded-md bg-border-light text-tertiary italic text-center"> 205 + This Bluesky post is not available... 206 + </div> 207 + ); 208 + };
+125
components/Blocks/BlueskyPostBlock/BlueskyEmpty.tsx
··· 1 + import { useEntitySetContext } from "components/EntitySetProvider"; 2 + import { generateKeyBetween } from "fractional-indexing"; 3 + import { useState } from "react"; 4 + import { useEntity, useReplicache } from "src/replicache"; 5 + import { useUIState } from "src/useUIState"; 6 + import { BlockProps } from "../Block"; 7 + import { v7 } from "uuid"; 8 + import { useSmoker } from "components/Toast"; 9 + import { BlockBlueskySmall, CheckTiny } from "components/Icons"; 10 + import { Separator } from "components/Layout"; 11 + import { Input } from "components/Input"; 12 + import { isUrl } from "src/utils/isURL"; 13 + import { AppBskyFeedDefs, AtpAgent } from "@atproto/api"; 14 + import { addBlueskyPostBlock } from "src/utils/addLinkBlock"; 15 + 16 + export const BlueskyPostEmpty = (props: BlockProps) => { 17 + let { rep } = useReplicache(); 18 + let isSelected = useUIState((s) => 19 + s.selectedBlocks.find((b) => b.value === props.entityID), 20 + ); 21 + let isLocked = useEntity(props.entityID, "block/is-locked")?.data.value; 22 + 23 + let entity_set = useEntitySetContext(); 24 + let [urlValue, setUrlValue] = useState(""); 25 + 26 + let submit = async () => { 27 + if (!rep) return; 28 + let entity = props.entityID; 29 + 30 + let blueskyPostBlock = await addBlueskyPostBlock(urlValue, entity, rep); 31 + if (blueskyPostBlock === false) { 32 + let rect = document 33 + .getElementById("bluesky-post-block-submit") 34 + ?.getBoundingClientRect(); 35 + smoker({ 36 + error: true, 37 + text: "post not found!", 38 + position: { 39 + x: (rect && rect.left + 12) || 0, 40 + y: (rect && rect.top) || 0, 41 + }, 42 + }); 43 + } 44 + }; 45 + let smoker = useSmoker(); 46 + function errorSmokers(x: number, y: number) { 47 + if (!urlValue || urlValue === "") { 48 + smoker({ 49 + error: true, 50 + text: "no url!", 51 + position: { 52 + x: x, 53 + y: y, 54 + }, 55 + }); 56 + return; 57 + } 58 + if (!isUrl(urlValue) || !urlValue.includes("bsky.app")) { 59 + smoker({ 60 + error: true, 61 + text: "invalid bluesky url!", 62 + position: { 63 + x: x, 64 + y: y, 65 + }, 66 + }); 67 + return; 68 + } 69 + } 70 + 71 + return ( 72 + <form 73 + onSubmit={(e) => { 74 + e.preventDefault(); 75 + let rect = document 76 + .getElementById("bluesky-post-block-submit") 77 + ?.getBoundingClientRect(); 78 + 79 + rect && errorSmokers(rect.left + 12, rect.top); 80 + submit(); 81 + }} 82 + > 83 + <div className={`max-w-sm flex gap-2 rounded-md text-secondary`}> 84 + {/* TODO: bsky icon? */} 85 + <BlockBlueskySmall 86 + className={`shrink-0 ${isSelected ? "text-tertiary" : "text-border"} `} 87 + /> 88 + <Separator /> 89 + <Input 90 + type="text" 91 + className="w-full grow border-none outline-none bg-transparent " 92 + placeholder="bsky.app/post-url" 93 + value={urlValue} 94 + disabled={isLocked} 95 + onChange={(e) => setUrlValue(e.target.value)} 96 + onKeyDown={(e) => { 97 + if (e.key === "Enter") { 98 + submit(); 99 + } 100 + if ( 101 + e.key === "Backspace" && 102 + !e.currentTarget.value && 103 + urlValue !== "" 104 + ) { 105 + e.preventDefault(); 106 + console.log("hello!"); 107 + } 108 + }} 109 + /> 110 + <button 111 + type="submit" 112 + id="bluesky-post-block-submit" 113 + className={`p-1 ${isSelected && !isLocked ? "text-accent-contrast" : "text-border"}`} 114 + onMouseDown={(e) => { 115 + e.preventDefault(); 116 + errorSmokers(e.clientX + 12, e.clientY); 117 + submit(); 118 + }} 119 + > 120 + <CheckTiny /> 121 + </button> 122 + </div> 123 + </form> 124 + ); 125 + };
+71
components/Blocks/BlueskyPostBlock/BlueskyRichText.tsx
··· 1 + import { RichText, AppBskyFeedPost, AppBskyRichtextFacet } from "@atproto/api"; 2 + 3 + // this function is ripped straight from the bluesky-social repo 4 + // https://github.com/bluesky-social/social-app/blob/main/bskyembed/src/components/post.tsx#L119 5 + export function BlueskyRichText({ 6 + record, 7 + }: { 8 + record: AppBskyFeedPost.Record | null; 9 + }) { 10 + if (!record) return null; 11 + 12 + const rt = new RichText({ 13 + text: record.text, 14 + facets: record.facets, 15 + }); 16 + 17 + const richText = []; 18 + 19 + let counter = 0; 20 + for (const segment of rt.segments()) { 21 + if ( 22 + segment.link && 23 + AppBskyRichtextFacet.validateLink(segment.link).success 24 + ) { 25 + richText.push( 26 + <a 27 + key={counter} 28 + href={segment.link.uri} 29 + className="text-accent-contrast hover:underline" 30 + target="_blank" 31 + > 32 + {segment.text} 33 + </a>, 34 + ); 35 + } else if ( 36 + segment.mention && 37 + AppBskyRichtextFacet.validateMention(segment.mention).success 38 + ) { 39 + richText.push( 40 + <a 41 + key={counter} 42 + href={`https://bsky.app/profile/${segment.mention.did}`} 43 + className="text-accent-contrast hover:underline" 44 + target="_blank" 45 + > 46 + {segment.text} 47 + </a>, 48 + ); 49 + } else if ( 50 + segment.tag && 51 + AppBskyRichtextFacet.validateTag(segment.tag).success 52 + ) { 53 + richText.push( 54 + <a 55 + key={counter} 56 + href={`https://bsky.app/tag/${segment.tag.tag}`} 57 + className="text-accent-contrast hover:underline" 58 + target="_blank" 59 + > 60 + {segment.text} 61 + </a>, 62 + ); 63 + } else { 64 + richText.push(segment.text); 65 + } 66 + 67 + counter++; 68 + } 69 + 70 + return <p className="whitespace-pre-wrap">{richText}</p>; 71 + }
+160
components/Blocks/BlueskyPostBlock/index.tsx
··· 1 + import { useEntitySetContext } from "components/EntitySetProvider"; 2 + import { useEffect, useState } from "react"; 3 + import { useEntity } from "src/replicache"; 4 + import { useUIState } from "src/useUIState"; 5 + import { BlockProps } from "../Block"; 6 + import { elementId } from "src/utils/elementId"; 7 + import { focusBlock } from "src/utils/focusBlock"; 8 + import { AppBskyFeedDefs, AppBskyFeedPost, RichText } from "@atproto/api"; 9 + import { BlueskyEmbed, PostNotAvailable } from "./BlueskyEmbed"; 10 + import { BlueskyPostEmpty } from "./BlueskyEmpty"; 11 + import { BlueskyTiny, CommentTiny } from "components/Icons"; 12 + import { BlueskyRichText } from "./BlueskyRichText"; 13 + import { Separator } from "components/Layout"; 14 + import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 15 + 16 + export const BlueskyPostBlock = (props: BlockProps & { preview?: boolean }) => { 17 + let { permissions } = useEntitySetContext(); 18 + let isSelected = useUIState((s) => 19 + s.selectedBlocks.find((b) => b.value === props.entityID), 20 + ); 21 + let post = useEntity(props.entityID, "block/bluesky-post")?.data.value; 22 + 23 + useEffect(() => { 24 + if (props.preview) return; 25 + let input = document.getElementById(elementId.block(props.entityID).input); 26 + if (isSelected) { 27 + input?.focus(); 28 + } else input?.blur(); 29 + }, [isSelected, props.entityID, props.preview]); 30 + 31 + switch (true) { 32 + case !post: 33 + if (!permissions.write) return null; 34 + return ( 35 + <label 36 + id={props.preview ? undefined : elementId.block(props.entityID).input} 37 + className={` 38 + w-full h-[104px] p-2 39 + text-tertiary hover:text-accent-contrast hover:cursor-pointer 40 + flex flex-auto gap-2 items-center justify-center hover:border-2 border-dashed rounded-lg 41 + ${isSelected ? "border-2 border-tertiary" : "border border-border"} 42 + ${props.pageType === "canvas" && "bg-bg-page"}`} 43 + onMouseDown={() => { 44 + focusBlock( 45 + { type: props.type, value: props.entityID, parent: props.parent }, 46 + { type: "start" }, 47 + ); 48 + }} 49 + > 50 + <BlueskyPostEmpty {...props} /> 51 + </label> 52 + ); 53 + 54 + case AppBskyFeedDefs.isBlockedPost(post) || 55 + AppBskyFeedDefs.isBlockedAuthor(post) || 56 + AppBskyFeedDefs.isNotFoundPost(post): 57 + return ( 58 + <div 59 + className={`w-full ${isSelected ? "block-border-selected" : "block-border"}`} 60 + > 61 + <PostNotAvailable /> 62 + </div> 63 + ); 64 + 65 + case AppBskyFeedDefs.isThreadViewPost(post): 66 + let record = post.post 67 + .record as AppBskyFeedDefs.FeedViewPost["post"]["record"]; 68 + let facets = record.facets; 69 + 70 + // silliness to get the text and timestamp from the record with proper types 71 + let text: string | null = null; 72 + let timestamp: string | undefined = undefined; 73 + if (AppBskyFeedPost.isRecord(record)) { 74 + text = (record as AppBskyFeedPost.Record).text; 75 + timestamp = (record as AppBskyFeedPost.Record).createdAt; 76 + } 77 + 78 + //getting the url to the post 79 + let postId = post.post.uri.split("/")[4]; 80 + let url = `https://bsky.app/profile/${post.post.author.handle}/post/${postId}`; 81 + 82 + let datetime = new Date(timestamp ? timestamp : ""); 83 + let datetimeFormatted = datetime.toLocaleString("en-US", { 84 + month: "short", 85 + day: "numeric", 86 + year: "numeric", 87 + hour: "numeric", 88 + minute: "numeric", 89 + hour12: true, 90 + }); 91 + 92 + return ( 93 + <div 94 + className={` 95 + flex flex-col gap-2 relative w-full overflow-hidden group/blueskyPostBlock sm:p-3 p-2 text-sm text-secondary bg-bg-page 96 + ${isSelected ? "block-border-selected " : "block-border"} 97 + `} 98 + > 99 + {post.post.author && record && ( 100 + <> 101 + <div className="bskyAuthor w-full flex items-center gap-2"> 102 + <img 103 + src={post.post.author?.avatar} 104 + alt={`${post.post.author?.displayName}'s avatar`} 105 + className="shink-0 w-8 h-8 rounded-full border border-border-light" 106 + /> 107 + <div className="grow flex flex-col gap-0.5 leading-tight"> 108 + <div className=" font-bold text-secondary"> 109 + {post.post.author?.displayName} 110 + </div> 111 + <a 112 + className="text-xs text-tertiary hover:underline" 113 + target="_blank" 114 + href={`https://bsky.app/profile/${post.post.author?.handle}`} 115 + > 116 + @{post.post.author?.handle} 117 + </a> 118 + </div> 119 + </div> 120 + 121 + <div className="flex flex-col gap-2 "> 122 + <div> 123 + <pre className="whitespace-pre-wrap"> 124 + {BlueskyRichText({ 125 + record: record as AppBskyFeedPost.Record | null, 126 + })} 127 + </pre> 128 + </div> 129 + {post.post.embed && ( 130 + <BlueskyEmbed embed={post.post.embed} postUrl={url} /> 131 + )} 132 + </div> 133 + </> 134 + )} 135 + <div className="w-full flex gap-2 items-center justify-between"> 136 + <div className="text-xs text-tertiary">{datetimeFormatted}</div> 137 + <div className="flex gap-2 items-center"> 138 + {post.post.replyCount && post.post.replyCount > 0 && ( 139 + <> 140 + <a 141 + className="flex items-center gap-1 hover:no-underline" 142 + target="_blank" 143 + href={url} 144 + > 145 + {post.post.replyCount} 146 + <CommentTiny /> 147 + </a> 148 + <Separator classname="h-4" /> 149 + </> 150 + )} 151 + 152 + <a className="" target="_blank" href={url}> 153 + <BlueskyTiny /> 154 + </a> 155 + </div> 156 + </div> 157 + </div> 158 + ); 159 + } 160 + };
+26 -10
components/Blocks/TextBlock/index.tsx
··· 30 30 import { useUIState } from "src/useUIState"; 31 31 import { MarkType, DOMParser as ProsemirrorDOMParser } from "prosemirror-model"; 32 32 import { useAppEventListener } from "src/eventBus"; 33 - import { addLinkBlock } from "src/utils/addLinkBlock"; 33 + import { addBlueskyPostBlock, addLinkBlock } from "src/utils/addLinkBlock"; 34 34 import { BlockCommandBar } from "components/Blocks/BlockCommandBar"; 35 35 import { setEditorState, useEditorStates } from "src/state/useEditorState"; 36 36 import { isIOS } from "@react-aria/utils"; ··· 56 56 import { blockCommands } from "../BlockCommands"; 57 57 import { CommandPage } from "twilio/lib/rest/wireless/v1/command"; 58 58 import { betterIsUrl, isUrl } from "src/utils/isURL"; 59 + import { useSmoker } from "components/Toast"; 59 60 60 61 export function TextBlock( 61 62 props: BlockProps & { className?: string; preview?: boolean }, ··· 428 429 429 430 const BlockifyLink = (props: { entityID: string }) => { 430 431 let rep = useReplicache(); 432 + let smoker = useSmoker(); 431 433 let isLocked = useEntity(props.entityID, "block/is-locked"); 432 434 let focused = useUIState((s) => s.focusedEntity?.entityID === props.entityID); 433 435 let editorState = useEditorStates( ··· 448 450 ) { 449 451 return ( 450 452 <button 451 - onClick={async () => { 452 - if (isBlueskyPost) { 453 - console.log("bluesky embed coming soon!"); 453 + onClick={async (e) => { 454 + rep.undoManager.startGroup(); 455 + 456 + if (isBlueskyPost && rep.rep) { 457 + await addBlueskyPostBlock( 458 + editorState.doc.textContent, 459 + props.entityID, 460 + rep.rep, 461 + ); 462 + smoker({ 463 + error: true, 464 + text: "post not found!", 465 + position: { 466 + x: e.clientX + 12, 467 + y: e.clientY, 468 + }, 469 + }); 470 + } else { 471 + await addLinkBlock( 472 + editorState.doc.textContent, 473 + props.entityID, 474 + rep.rep, 475 + ); 454 476 } 455 - rep.undoManager.startGroup(); 456 - await addLinkBlock( 457 - editorState.doc.textContent, 458 - props.entityID, 459 - rep.rep, 460 - ); 461 477 rep.undoManager.endGroup(); 462 478 }} 463 479 className="absolute right-0 top-0 px-1 py-0.5 text-xs text-tertiary sm:hover:text-accent-contrast border border-border-light sm:hover:border-accent-contrast sm:outline-accent-tertiary rounded-md bg-bg-page selected-outline "
+56 -1
components/Icons.tsx
··· 123 123 </svg> 124 124 ); 125 125 }; 126 - 126 + export const BlockBlueskySmall = (props: Props) => { 127 + return ( 128 + <svg 129 + width="24" 130 + height="24" 131 + viewBox="0 0 24 24" 132 + fill="none" 133 + xmlns="http://www.w3.org/2000/svg" 134 + {...props} 135 + > 136 + <path 137 + d="M6.33526 4.21162C8.62822 5.97119 11.0945 9.53887 12.0001 11.4535C12.9056 9.53901 15.3718 5.97116 17.6649 4.21162C19.3193 2.94199 22 1.95962 22 5.08557C22 5.70987 21.6498 10.33 21.4445 11.08C20.7306 13.6878 18.1292 14.3529 15.8152 13.9503C19.86 14.654 20.8889 16.9848 18.6668 19.3156C14.4465 23.7423 12.601 18.205 12.1279 16.7861C11.9998 16.4018 12.0002 16.4018 11.8721 16.7861C11.3993 18.205 9.55378 23.7424 5.33322 19.3156C3.11103 16.9848 4.13995 14.6538 8.18483 13.9503C5.87077 14.3529 3.26934 13.6878 2.55555 11.08C2.35016 10.3299 2 5.7098 2 5.08557C2 1.95962 4.68086 2.94199 6.33526 4.21162Z" 138 + fill="currentColor" 139 + /> 140 + </svg> 141 + ); 142 + }; 127 143 export const BlockButtonSmall = (props: Props) => { 128 144 return ( 129 145 <svg ··· 725 741 ); 726 742 }; 727 743 744 + export const BlueskyTiny = (props: Props) => { 745 + return ( 746 + <svg 747 + width="16" 748 + height="16" 749 + viewBox="0 0 512 512" 750 + fill="none" 751 + xmlns="http://www.w3.org/2000/svg" 752 + {...props} 753 + > 754 + <path 755 + fillRule="evenodd" 756 + clipRule="evenodd" 757 + d="M111.8 62.2C170.2 105.9 233 194.7 256 242.4c23-47.6 85.8-136.4 144.2-180.2c42.1-31.6 110.3-56 110.3 21.8c0 15.5-8.9 130.5-14.1 149.2C478.2 298 412 314.6 353.1 304.5c102.9 17.5 129.1 75.5 72.5 133.5c-107.4 110.2-154.3-27.6-166.3-62.9l0 0c-1.7-4.9-2.6-7.8-3.3-7.8s-1.6 3-3.3 7.8l0 0c-12 35.3-59 173.1-166.3 62.9c-56.5-58-30.4-116 72.5-133.5C100 314.6 33.8 298 15.7 233.1C10.4 214.4 1.5 99.4 1.5 83.9c0-77.8 68.2-53.4 110.3-21.8z" 758 + fill="currentColor" 759 + /> 760 + </svg> 761 + ); 762 + }; 763 + 728 764 export const CheckTiny = (props: Props) => { 729 765 return ( 730 766 <svg ··· 784 820 ); 785 821 }; 786 822 823 + export const CommentTiny = (props: Props) => { 824 + return ( 825 + <svg 826 + width="16" 827 + height="16" 828 + viewBox="0 0 16 16" 829 + fill="none" 830 + xmlns="http://www.w3.org/2000/svg" 831 + {...props} 832 + > 833 + <path 834 + fillRule="evenodd" 835 + clipRule="evenodd" 836 + d="M13.2729 2.64811C12.2701 1.70568 10.6421 1.18237 8.1478 1.18237C6.39426 1.18237 4.77702 1.50266 3.59413 2.35686C2.40039 3.2189 1.67651 4.60444 1.67651 6.66696C1.67651 7.88721 2.1903 9.10636 3.31693 10.0167C4.44114 10.9251 6.16141 11.5153 8.56098 11.5153C9.08508 11.5153 9.60084 11.4629 10.0969 11.3613C10.5398 11.6928 11.083 12.0262 11.6403 12.2074C12.7152 12.5567 13.7185 12.3275 14.1019 12.1609C14.1019 12.1609 14.2711 12.1295 14.2741 11.8717C14.2766 11.6522 14.1019 11.5896 14.1019 11.5896C13.926 11.5083 13.6469 11.1801 13.5349 10.8816L13.5248 10.8546C13.3259 10.3243 13.2725 10.182 13.2725 9.65473C14.1019 9.1196 14.6191 8.12643 14.6191 6.45488C14.6191 4.92613 14.2789 3.59362 13.2729 2.64811ZM8.93574 3.76256L8.70323 5.91493L10.9354 5.30957L11.0981 6.34091L8.95899 6.50907L10.3425 8.28028L9.34264 8.80716L8.35446 6.84537L7.45928 8.80716L6.42459 8.28028L7.7848 6.50907L5.66892 6.34091L5.83168 5.30957L8.04056 5.91493L7.79642 3.76256H8.93574ZM1.97489 11.1316C1.78179 10.9342 1.78529 10.6176 1.98269 10.4246C2.18009 10.2315 2.49666 10.2349 2.68975 10.4324C3.92379 11.6939 5.73209 12.3694 8.36317 12.3694C8.83898 12.3694 9.30531 12.3214 9.7503 12.2291L9.96768 12.1841L10.1469 12.3151C10.5457 12.6067 11.0144 12.883 11.4665 13.0234C12.3294 13.2916 13.2154 13.2513 13.9249 13.0246C14.1879 12.9406 14.4693 13.0857 14.5533 13.3488C14.6374 13.6118 14.4923 13.8932 14.2292 13.9772C13.3347 14.263 12.2387 14.3105 11.1698 13.9784C10.6483 13.8164 10.1492 13.5339 9.73648 13.25C9.29041 13.3289 8.82988 13.3694 8.36317 13.3694C5.56006 13.3694 3.45551 12.6453 1.97489 11.1316Z" 837 + fill="currentColor" 838 + /> 839 + </svg> 840 + ); 841 + }; 787 842 export const RadioEmpty = (props: Props) => { 788 843 return ( 789 844 <svg
+95 -3
package-lock.json
··· 9 9 "version": "1.0.0", 10 10 "license": "ISC", 11 11 "dependencies": { 12 + "@atproto/api": "^0.14.2", 12 13 "@mdx-js/loader": "^3.1.0", 13 14 "@mdx-js/react": "^3.1.0", 14 15 "@next/mdx": "^15.0.3", ··· 93 94 "url": "https://github.com/sponsors/sindresorhus" 94 95 } 95 96 }, 97 + "node_modules/@atproto/api": { 98 + "version": "0.14.2", 99 + "resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.14.2.tgz", 100 + "integrity": "sha512-TRhgRWOftDOTNWcqP0kE1upDn0++o37imW91NaBVkeapqK7QToVsiJbCQC5l1+EPJ7/BJ5o4IgjZx5ZdENh07A==", 101 + "license": "MIT", 102 + "dependencies": { 103 + "@atproto/common-web": "^0.4.0", 104 + "@atproto/lexicon": "^0.4.7", 105 + "@atproto/syntax": "^0.3.3", 106 + "@atproto/xrpc": "^0.6.9", 107 + "await-lock": "^2.2.2", 108 + "multiformats": "^9.9.0", 109 + "tlds": "^1.234.0", 110 + "zod": "^3.23.8" 111 + } 112 + }, 113 + "node_modules/@atproto/common-web": { 114 + "version": "0.4.0", 115 + "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.0.tgz", 116 + "integrity": "sha512-ZYL0P9myHybNgwh/hBY0HaBzqiLR1B5/ie5bJpLQAg0whRzNA28t8/nU2vh99tbsWcAF0LOD29M8++LyENJLNQ==", 117 + "license": "MIT", 118 + "dependencies": { 119 + "graphemer": "^1.4.0", 120 + "multiformats": "^9.9.0", 121 + "uint8arrays": "3.0.0", 122 + "zod": "^3.23.8" 123 + } 124 + }, 125 + "node_modules/@atproto/lexicon": { 126 + "version": "0.4.7", 127 + "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.4.7.tgz", 128 + "integrity": "sha512-/x6h3tAiDNzSi4eXtC8ke65B7UzsagtlGRHmUD95698x5lBRpDnpizj0fZWTZVYed5qnOmz/ZEue+v3wDmO61g==", 129 + "license": "MIT", 130 + "dependencies": { 131 + "@atproto/common-web": "^0.4.0", 132 + "@atproto/syntax": "^0.3.3", 133 + "iso-datestring-validator": "^2.2.2", 134 + "multiformats": "^9.9.0", 135 + "zod": "^3.23.8" 136 + } 137 + }, 138 + "node_modules/@atproto/syntax": { 139 + "version": "0.3.3", 140 + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.3.3.tgz", 141 + "integrity": "sha512-F1LZweesNYdBbZBXVa72N/cSvchG8Q1tG4/209ZXbIuM3FwQtkgn+zgmmV4P4ORmhOeXPBNXvMBpcqiwx/gEQQ==", 142 + "license": "MIT" 143 + }, 144 + "node_modules/@atproto/xrpc": { 145 + "version": "0.6.9", 146 + "resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.6.9.tgz", 147 + "integrity": "sha512-vQGA7++DYMNaHx3C7vEjT+2X6hYYLG7JNbBnDLWu0km1/1KYXgRkAz4h+FfYqg1mvzvIorHU7DAs5wevkJDDlw==", 148 + "license": "MIT", 149 + "dependencies": { 150 + "@atproto/lexicon": "^0.4.7", 151 + "zod": "^3.23.8" 152 + } 153 + }, 96 154 "node_modules/@babel/runtime": { 97 155 "version": "7.24.6", 98 156 "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.6.tgz", ··· 6094 6152 "url": "https://github.com/sponsors/ljharb" 6095 6153 } 6096 6154 }, 6155 + "node_modules/await-lock": { 6156 + "version": "2.2.2", 6157 + "resolved": "https://registry.npmjs.org/await-lock/-/await-lock-2.2.2.tgz", 6158 + "integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==", 6159 + "license": "MIT" 6160 + }, 6097 6161 "node_modules/axe-core": { 6098 6162 "version": "4.7.0", 6099 6163 "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.7.0.tgz", ··· 8408 8472 "node_modules/graphemer": { 8409 8473 "version": "1.4.0", 8410 8474 "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", 8411 - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", 8412 - "dev": true 8475 + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==" 8413 8476 }, 8414 8477 "node_modules/hanji": { 8415 8478 "version": "0.0.5", ··· 9373 9436 "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", 9374 9437 "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", 9375 9438 "dev": true 9439 + }, 9440 + "node_modules/iso-datestring-validator": { 9441 + "version": "2.2.2", 9442 + "resolved": "https://registry.npmjs.org/iso-datestring-validator/-/iso-datestring-validator-2.2.2.tgz", 9443 + "integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==", 9444 + "license": "MIT" 9376 9445 }, 9377 9446 "node_modules/isomorphic.js": { 9378 9447 "version": "0.2.5", ··· 11013 11082 "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", 11014 11083 "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" 11015 11084 }, 11085 + "node_modules/multiformats": { 11086 + "version": "9.9.0", 11087 + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", 11088 + "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", 11089 + "license": "(Apache-2.0 AND MIT)" 11090 + }, 11016 11091 "node_modules/mustache": { 11017 11092 "version": "4.2.0", 11018 11093 "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", ··· 13499 13574 "next-tick": "1" 13500 13575 } 13501 13576 }, 13577 + "node_modules/tlds": { 13578 + "version": "1.255.0", 13579 + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.255.0.tgz", 13580 + "integrity": "sha512-tcwMRIioTcF/FcxLev8MJWxCp+GUALRhFEqbDoZrnowmKSGqPrl5pqS+Sut2m8BgJ6S4FExCSSpGffZ0Tks6Aw==", 13581 + "license": "MIT", 13582 + "bin": { 13583 + "tlds": "bin.js" 13584 + } 13585 + }, 13502 13586 "node_modules/to-regex-range": { 13503 13587 "version": "5.0.1", 13504 13588 "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", ··· 13748 13832 "version": "2.1.0", 13749 13833 "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", 13750 13834 "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", 13751 - "license": "MIT", 13752 13835 "peer": true 13836 + }, 13837 + "node_modules/uint8arrays": { 13838 + "version": "3.0.0", 13839 + "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.0.0.tgz", 13840 + "integrity": "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==", 13841 + "license": "MIT", 13842 + "dependencies": { 13843 + "multiformats": "^9.4.2" 13844 + } 13753 13845 }, 13754 13846 "node_modules/unbox-primitive": { 13755 13847 "version": "1.0.2",
+1
package.json
··· 11 11 "author": "", 12 12 "license": "ISC", 13 13 "dependencies": { 14 + "@atproto/api": "^0.14.2", 14 15 "@mdx-js/loader": "^3.1.0", 15 16 "@mdx-js/react": "^3.1.0", 16 17 "@next/mdx": "^15.0.3",
+27 -1
src/replicache/attributes.ts
··· 1 + import { AppBskyFeedGetPostThread } from "@atproto/api"; 2 + import { 3 + PostView, 4 + ThreadViewPost, 5 + } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 6 + import { DeepAsReadonlyJSONValue } from "./utils"; 7 + 1 8 const RootAttributes = { 2 9 "root/page": { 3 10 type: "ordered-reference", ··· 76 83 type: "reference", 77 84 cardinality: "one", 78 85 }, 86 + "block/bluesky-post": { 87 + type: "bluesky-post", 88 + cardinality: "one", 89 + }, 79 90 } as const; 80 91 81 92 const MailboxAttributes = { ··· 123 134 }, 124 135 } as const; 125 136 137 + const BlueskyPostBlockAttributes = { 138 + "bluesky-post/url": { 139 + type: "string", 140 + cardinality: "one", 141 + }, 142 + } as const; 143 + 126 144 const ButtonBlockAttributes = { 127 145 "button/text": { 128 146 type: "string", ··· 219 237 ...ThemeAttributes, 220 238 ...MailboxAttributes, 221 239 ...EmbedBlockAttributes, 240 + ...BlueskyPostBlockAttributes, 222 241 ...ButtonBlockAttributes, 223 242 ...ImageBlockAttributes, 224 243 ...PollBlockAttributes, ··· 242 261 type: "ordered-reference"; 243 262 position: string; 244 263 value: string; 264 + }; 265 + "bluesky-post": { 266 + type: "bluesky-post"; 267 + value: DeepAsReadonlyJSONValue< 268 + AppBskyFeedGetPostThread.OutputSchema["thread"] 269 + >; 245 270 }; 246 271 image: { 247 272 type: "image"; ··· 282 307 | "mailbox" 283 308 | "embed" 284 309 | "button" 285 - | "poll"; 310 + | "poll" 311 + | "bluesky-post"; 286 312 }; 287 313 "canvas-pattern-union": { 288 314 type: "canvas-pattern-union";
+10 -4
src/replicache/index.tsx
··· 208 208 let initialized = await tx.get("initialized"); 209 209 if (!initialized) return null; 210 210 return ( 211 - await tx 212 - .scan<Fact<A>>({ indexName: "eav", prefix: `${entity}-${attribute}` }) 213 - .toArray() 214 - ).filter((f) => f.attribute === attribute); 211 + ( 212 + await tx 213 + .scan<Fact<A>>({ 214 + indexName: "eav", 215 + prefix: `${entity}-${attribute}`, 216 + }) 217 + // hack to handle rich bluesky-post type 218 + .toArray() 219 + ).filter((f) => f.attribute === attribute) 220 + ); 215 221 }, 216 222 { 217 223 default: null,
+23 -4
src/replicache/utils.ts
··· 47 47 attribute: A | "", 48 48 ) { 49 49 return ( 50 - await tx 51 - .scan<Fact<A>>({ indexName: "eav", prefix: `${entity}-${attribute}` }) 52 - .toArray() 53 - ).filter((f) => attribute === "" || f.attribute === attribute); 50 + ( 51 + await tx 52 + .scan<Fact<A>>({ indexName: "eav", prefix: `${entity}-${attribute}` }) 53 + // Hack rn because of the rich bluesky-post type 54 + .toArray() 55 + ).filter((f) => attribute === "" || f.attribute === attribute) 56 + ); 54 57 }, 55 58 async vae< 56 59 A extends keyof FilterAttributes<{ ··· 72 75 ) as Fact<A>[]; 73 76 }, 74 77 }); 78 + 79 + // Base utility type for making types compatible with ReadonlyJSONObject 80 + export type AsReadonlyJSONObject<T> = T & { 81 + [key: string]: undefined; 82 + }; 83 + 84 + // Recursive utility type for nested objects 85 + export type DeepAsReadonlyJSONValue<T> = T extends object 86 + ? T extends Array<infer U> 87 + ? ReadonlyArray<DeepAsReadonlyJSONValue<U>> // Handle arrays 88 + : AsReadonlyJSONObject<{ 89 + [K in keyof T]: DeepAsReadonlyJSONValue<T[K]>; 90 + }> 91 + : T extends string | number | boolean | null 92 + ? T // Primitive types that already match ReadonlyJSONValue 93 + : never; // For other types that can't be converted
+52
src/utils/addLinkBlock.ts
··· 5 5 } from "app/api/link_previews/route"; 6 6 import { Replicache } from "replicache"; 7 7 import { ReplicacheMutators } from "src/replicache"; 8 + import { AtpAgent } from "@atproto/api"; 9 + import { v7 } from "uuid"; 8 10 9 11 export async function addLinkBlock( 10 12 url: string, ··· 74 76 }); 75 77 }); 76 78 } 79 + 80 + export async function addBlueskyPostBlock( 81 + url: string, 82 + entityID: string, 83 + rep: Replicache<ReplicacheMutators>, 84 + ) { 85 + //construct bsky post uri from url 86 + let urlParts = url?.split("/"); 87 + let userDidOrHandle = urlParts ? urlParts[4] : ""; // "schlage.town" or "did:plc:jjsc5rflv3cpv6hgtqhn2dcm" 88 + let collection = "app.bsky.feed.post"; 89 + let postId = urlParts ? urlParts[6] : ""; 90 + let uri = `at://${userDidOrHandle}/${collection}/${postId}`; 91 + 92 + let post = await getBlueskyPost(uri); 93 + if (!post || post === undefined) return false; 94 + 95 + await rep.mutate.assertFact({ 96 + entity: entityID, 97 + attribute: "block/type", 98 + data: { type: "block-type-union", value: "bluesky-post" }, 99 + }); 100 + await rep?.mutate.assertFact({ 101 + entity: entityID, 102 + attribute: "block/bluesky-post", 103 + data: { 104 + type: "bluesky-post", 105 + //TODO: this is a hack to get rid of a nested Array buffer which cannot be frozen, which replicache does on write. 106 + value: JSON.parse(JSON.stringify(post.data.thread)), 107 + }, 108 + }); 109 + return true; 110 + } 111 + async function getBlueskyPost(uri: string) { 112 + const agent = new AtpAgent({ service: "https://public.api.bsky.app" }); 113 + try { 114 + let blueskyPost = await agent 115 + .getPostThread({ 116 + uri: uri, 117 + depth: 0, 118 + parentHeight: 0, 119 + }) 120 + .then((res) => { 121 + return res; 122 + }); 123 + return blueskyPost; 124 + } catch (error) { 125 + let rect = document; 126 + return; 127 + } 128 + }
+1 -3
supabase/supabase-image-loader.js
··· 1 - const projectId = "bdefzwcumgzjwllsnaej"; // your supabase project id 2 - 3 1 export default function supabaseLoader({ src, width, quality }) { 4 - return `https://${projectId}.supabase.co/storage/v1/render/image/public/${src}?width=${width}&quality=${quality || 75}`; 2 + return `${process.env.NEXT_PUBLIC_SUPABASE_API_URL}/storage/v1/render/image/public/${src}?width=${width}&quality=${quality || 75}`; 5 3 }