From 4d8432bc0a2a6ad6390f25716a9f82192d6bd7e9 Mon Sep 17 00:00:00 2001 From: celine Date: Mon, 3 Mar 2025 16:51:29 -0500 Subject: [PATCH] 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 Co-authored-by: Jared Pereira --- components/Blocks/Block.tsx | 2 + components/Blocks/BlockCommands.tsx | 55 +++-- .../Blocks/BlueskyPostBlock/BlueskyEmbed.tsx | 208 ++++++++++++++++++ .../Blocks/BlueskyPostBlock/BlueskyEmpty.tsx | 125 +++++++++++ .../BlueskyPostBlock/BlueskyRichText.tsx | 71 ++++++ components/Blocks/BlueskyPostBlock/index.tsx | 160 ++++++++++++++ components/Blocks/TextBlock/index.tsx | 36 ++- components/Icons.tsx | 57 ++++- package-lock.json | 98 ++++++++- package.json | 1 + src/replicache/attributes.ts | 28 ++- src/replicache/index.tsx | 14 +- src/replicache/utils.ts | 27 ++- src/utils/addLinkBlock.ts | 52 +++++ supabase/supabase-image-loader.js | 4 +- 15 files changed, 882 insertions(+), 56 deletions(-) create mode 100644 components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx create mode 100644 components/Blocks/BlueskyPostBlock/BlueskyEmpty.tsx create mode 100644 components/Blocks/BlueskyPostBlock/BlueskyRichText.tsx create mode 100644 components/Blocks/BlueskyPostBlock/index.tsx diff --git a/components/Blocks/Block.tsx b/components/Blocks/Block.tsx index 9f112f9f..ff06db52 100644 --- a/components/Blocks/Block.tsx +++ b/components/Blocks/Block.tsx @@ -24,6 +24,7 @@ import { RSVPBlock } from "./RSVPBlock"; import { elementId } from "src/utils/elementId"; import { ButtonBlock } from "./ButtonBlock"; import { PollBlock } from "./PollBlock"; +import { BlueskyPostBlock } from "./BlueskyPostBlock"; export type Block = { factID: string; @@ -177,6 +178,7 @@ const BlockTypeComponents: { rsvp: RSVPBlock, button: ButtonBlock, poll: PollBlock, + "bluesky-post": BlueskyPostBlock, }; export const BlockMultiselectIndicator = (props: BlockProps) => { diff --git a/components/Blocks/BlockCommands.tsx b/components/Blocks/BlockCommands.tsx index 67a67815..5b05dec4 100644 --- a/components/Blocks/BlockCommands.tsx +++ b/components/Blocks/BlockCommands.tsx @@ -15,6 +15,7 @@ import { BlockCalendarSmall, RSVPSmall, BlockPollSmall, + BlockBlueskySmall, } from "components/Icons"; import { generateKeyBetween } from "fractional-indexing"; import { focusPage } from "components/Pages"; @@ -196,36 +197,6 @@ export const blockCommands: Command[] = [ }, }, - { - name: "External Link", - icon: , - type: "block", - onSelect: async (rep, props, um) => { - props.entityID && clearCommandSearchText(props.entityID); - await createBlockWithType(rep, props, "link"); - um.add({ - undo: () => { - props.entityID && keepFocus(props.entityID); - }, - redo: () => {}, - }); - }, - }, - { - name: "Embed Website", - icon: , - type: "block", - onSelect: async (rep, props, um) => { - props.entityID && clearCommandSearchText(props.entityID); - await createBlockWithType(rep, props, "embed"); - um.add({ - undo: () => { - props.entityID && keepFocus(props.entityID); - }, - redo: () => {}, - }); - }, - }, { name: "Image", icon: , @@ -248,6 +219,14 @@ export const blockCommands: Command[] = [ }); }, }, + { + name: "External Link", + icon: , + type: "block", + onSelect: async (rep, props) => { + createBlockWithType(rep, props, "link"); + }, + }, { name: "Button", icon: , @@ -323,6 +302,22 @@ export const blockCommands: Command[] = [ }); }, }, + { + name: "Embed Website", + icon: , + type: "block", + onSelect: async (rep, props) => { + createBlockWithType(rep, props, "embed"); + }, + }, + { + name: "Bluesky Post", + icon: , + type: "block", + onSelect: async (rep, props) => { + createBlockWithType(rep, props, "bluesky-post"); + }, + }, // EVENT STUFF diff --git a/components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx b/components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx new file mode 100644 index 00000000..112cbcdf --- /dev/null +++ b/components/Blocks/BlueskyPostBlock/BlueskyEmbed.tsx @@ -0,0 +1,208 @@ +import { $Typed, is$typed } from "@atproto/api/dist/client/util"; +import { + AppBskyEmbedImages, + AppBskyEmbedVideo, + AppBskyEmbedExternal, + AppBskyEmbedRecord, + AppBskyEmbedRecordWithMedia, + AppBskyFeedPost, + AppBskyFeedDefs, + AppBskyGraphDefs, + AppBskyLabelerDefs, +} from "@atproto/api"; + +export const BlueskyEmbed = (props: { + embed: Exclude; + postUrl?: string; +}) => { + // check this file from bluesky for ref + // https://github.com/bluesky-social/social-app/blob/main/bskyembed/src/components/embed.tsx + switch (true) { + case AppBskyEmbedImages.isView(props.embed): + let imageEmbed = props.embed; + return ( +
+ {imageEmbed.images.map( + (image: { fullsize: string; alt?: string }, i: number) => ( + {image.alt + ), + )} +
+ ); + case AppBskyEmbedExternal.isView(props.embed): + let externalEmbed = props.embed; + let isGif = externalEmbed.external.uri.includes(".gif"); + if (isGif) { + return ( +
+ {externalEmbed.external.title} +
+ ); + } + return ( + + {externalEmbed.external.thumb === undefined ? null : ( + <> + {externalEmbed.external.title} + +
+ + )} +
+
+

{externalEmbed.external.title}

+

+ {externalEmbed.external.description} +

+
+
+
+ {externalEmbed.external.uri} +
+
+
+ ); + case AppBskyEmbedVideo.isView(props.embed): + let videoEmbed = props.embed; + return ( +
+ { +
+
+ +
+
+ ); + case AppBskyEmbedRecord.isView(props.embed): + let recordEmbed = props.embed; + let record = recordEmbed.record; + + if (record === undefined) return; + + // if the record is a feed post + if (AppBskyEmbedRecord.isViewRecord(record)) { + // we have to do this nonsense to get a the proper type for the record text + // we aped it from the bluesky front end (check the link at the top of this file) + let text: string | null = null; + if (AppBskyFeedPost.isRecord(record.value)) { + text = (record.value as AppBskyFeedPost.Record).text; + } + return ( +
+
+ {`${record.author?.displayName}'s +
+ {record.author?.displayName} +
+ + @{record.author?.handle} + +
+ +
+ {text &&
{text}
} + {record.embeds !== undefined + ? record.embeds.map((embed) => ) + : null} +
+
+ ); + } + + // labeller, starterpack or feed + if ( + AppBskyFeedDefs.isGeneratorView(record) || + AppBskyLabelerDefs.isLabelerView(record) || + AppBskyGraphDefs.isStarterPackViewBasic(record) + ) + return ; + + // post is blocked or not found + if ( + AppBskyFeedDefs.isBlockedPost(record) || + AppBskyFeedDefs.isNotFoundPost(record) + ) + return ; + + if (AppBskyEmbedRecord.isViewDetached(record)) return null; + + return ; + + // I am not sure when this case will be used? so I'm commenting it out for now + // case AppBskyEmbedRecordWithMedia.isView(props.embed): + // const recordWithMediaEmbed = props.embed; + // return
This is a record with Media
; + + default: + return ; + } +}; + +const SeePostOnBluesky = (props: { postUrl: string | undefined }) => { + return ( + +
This media is not supported...
{" "} + {props.postUrl === undefined ? null : ( +
+ See the full post on + Bluesky! +
+ )} +
+ ); +}; + +export const PostNotAvailable = () => { + return ( +
+ This Bluesky post is not available... +
+ ); +}; diff --git a/components/Blocks/BlueskyPostBlock/BlueskyEmpty.tsx b/components/Blocks/BlueskyPostBlock/BlueskyEmpty.tsx new file mode 100644 index 00000000..1ee84f95 --- /dev/null +++ b/components/Blocks/BlueskyPostBlock/BlueskyEmpty.tsx @@ -0,0 +1,125 @@ +import { useEntitySetContext } from "components/EntitySetProvider"; +import { generateKeyBetween } from "fractional-indexing"; +import { useState } from "react"; +import { useEntity, useReplicache } from "src/replicache"; +import { useUIState } from "src/useUIState"; +import { BlockProps } from "../Block"; +import { v7 } from "uuid"; +import { useSmoker } from "components/Toast"; +import { BlockBlueskySmall, CheckTiny } from "components/Icons"; +import { Separator } from "components/Layout"; +import { Input } from "components/Input"; +import { isUrl } from "src/utils/isURL"; +import { AppBskyFeedDefs, AtpAgent } from "@atproto/api"; +import { addBlueskyPostBlock } from "src/utils/addLinkBlock"; + +export const BlueskyPostEmpty = (props: BlockProps) => { + let { rep } = useReplicache(); + let isSelected = useUIState((s) => + s.selectedBlocks.find((b) => b.value === props.entityID), + ); + let isLocked = useEntity(props.entityID, "block/is-locked")?.data.value; + + let entity_set = useEntitySetContext(); + let [urlValue, setUrlValue] = useState(""); + + let submit = async () => { + if (!rep) return; + let entity = props.entityID; + + let blueskyPostBlock = await addBlueskyPostBlock(urlValue, entity, rep); + if (blueskyPostBlock === false) { + let rect = document + .getElementById("bluesky-post-block-submit") + ?.getBoundingClientRect(); + smoker({ + error: true, + text: "post not found!", + position: { + x: (rect && rect.left + 12) || 0, + y: (rect && rect.top) || 0, + }, + }); + } + }; + let smoker = useSmoker(); + function errorSmokers(x: number, y: number) { + if (!urlValue || urlValue === "") { + smoker({ + error: true, + text: "no url!", + position: { + x: x, + y: y, + }, + }); + return; + } + if (!isUrl(urlValue) || !urlValue.includes("bsky.app")) { + smoker({ + error: true, + text: "invalid bluesky url!", + position: { + x: x, + y: y, + }, + }); + return; + } + } + + return ( +
{ + e.preventDefault(); + let rect = document + .getElementById("bluesky-post-block-submit") + ?.getBoundingClientRect(); + + rect && errorSmokers(rect.left + 12, rect.top); + submit(); + }} + > +
+ {/* TODO: bsky icon? */} + + + setUrlValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + submit(); + } + if ( + e.key === "Backspace" && + !e.currentTarget.value && + urlValue !== "" + ) { + e.preventDefault(); + console.log("hello!"); + } + }} + /> + +
+
+ ); +}; diff --git a/components/Blocks/BlueskyPostBlock/BlueskyRichText.tsx b/components/Blocks/BlueskyPostBlock/BlueskyRichText.tsx new file mode 100644 index 00000000..fb6e648e --- /dev/null +++ b/components/Blocks/BlueskyPostBlock/BlueskyRichText.tsx @@ -0,0 +1,71 @@ +import { RichText, AppBskyFeedPost, AppBskyRichtextFacet } from "@atproto/api"; + +// this function is ripped straight from the bluesky-social repo +// https://github.com/bluesky-social/social-app/blob/main/bskyembed/src/components/post.tsx#L119 +export function BlueskyRichText({ + record, +}: { + record: AppBskyFeedPost.Record | null; +}) { + if (!record) return null; + + const rt = new RichText({ + text: record.text, + facets: record.facets, + }); + + const richText = []; + + let counter = 0; + for (const segment of rt.segments()) { + if ( + segment.link && + AppBskyRichtextFacet.validateLink(segment.link).success + ) { + richText.push( + + {segment.text} + , + ); + } else if ( + segment.mention && + AppBskyRichtextFacet.validateMention(segment.mention).success + ) { + richText.push( + + {segment.text} + , + ); + } else if ( + segment.tag && + AppBskyRichtextFacet.validateTag(segment.tag).success + ) { + richText.push( + + {segment.text} + , + ); + } else { + richText.push(segment.text); + } + + counter++; + } + + return

{richText}

; +} diff --git a/components/Blocks/BlueskyPostBlock/index.tsx b/components/Blocks/BlueskyPostBlock/index.tsx new file mode 100644 index 00000000..83f504cb --- /dev/null +++ b/components/Blocks/BlueskyPostBlock/index.tsx @@ -0,0 +1,160 @@ +import { useEntitySetContext } from "components/EntitySetProvider"; +import { useEffect, useState } from "react"; +import { useEntity } from "src/replicache"; +import { useUIState } from "src/useUIState"; +import { BlockProps } from "../Block"; +import { elementId } from "src/utils/elementId"; +import { focusBlock } from "src/utils/focusBlock"; +import { AppBskyFeedDefs, AppBskyFeedPost, RichText } from "@atproto/api"; +import { BlueskyEmbed, PostNotAvailable } from "./BlueskyEmbed"; +import { BlueskyPostEmpty } from "./BlueskyEmpty"; +import { BlueskyTiny, CommentTiny } from "components/Icons"; +import { BlueskyRichText } from "./BlueskyRichText"; +import { Separator } from "components/Layout"; +import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; + +export const BlueskyPostBlock = (props: BlockProps & { preview?: boolean }) => { + let { permissions } = useEntitySetContext(); + let isSelected = useUIState((s) => + s.selectedBlocks.find((b) => b.value === props.entityID), + ); + let post = useEntity(props.entityID, "block/bluesky-post")?.data.value; + + useEffect(() => { + if (props.preview) return; + let input = document.getElementById(elementId.block(props.entityID).input); + if (isSelected) { + input?.focus(); + } else input?.blur(); + }, [isSelected, props.entityID, props.preview]); + + switch (true) { + case !post: + if (!permissions.write) return null; + return ( + + ); + + case AppBskyFeedDefs.isBlockedPost(post) || + AppBskyFeedDefs.isBlockedAuthor(post) || + AppBskyFeedDefs.isNotFoundPost(post): + return ( +
+ +
+ ); + + case AppBskyFeedDefs.isThreadViewPost(post): + let record = post.post + .record as AppBskyFeedDefs.FeedViewPost["post"]["record"]; + let facets = record.facets; + + // silliness to get the text and timestamp from the record with proper types + let text: string | null = null; + let timestamp: string | undefined = undefined; + if (AppBskyFeedPost.isRecord(record)) { + text = (record as AppBskyFeedPost.Record).text; + timestamp = (record as AppBskyFeedPost.Record).createdAt; + } + + //getting the url to the post + let postId = post.post.uri.split("/")[4]; + let url = `https://bsky.app/profile/${post.post.author.handle}/post/${postId}`; + + let datetime = new Date(timestamp ? timestamp : ""); + let datetimeFormatted = datetime.toLocaleString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "numeric", + hour12: true, + }); + + return ( +
+ {post.post.author && record && ( + <> +
+ {`${post.post.author?.displayName}'s +
+
+ {post.post.author?.displayName} +
+ + @{post.post.author?.handle} + +
+
+ +
+
+
+                    {BlueskyRichText({
+                      record: record as AppBskyFeedPost.Record | null,
+                    })}
+                  
+
+ {post.post.embed && ( + + )} +
+ + )} +
+
{datetimeFormatted}
+
+ {post.post.replyCount && post.post.replyCount > 0 && ( + <> + + {post.post.replyCount} + + + + + )} + + + + +
+
+
+ ); + } +}; diff --git a/components/Blocks/TextBlock/index.tsx b/components/Blocks/TextBlock/index.tsx index f1cce237..2e18bc11 100644 --- a/components/Blocks/TextBlock/index.tsx +++ b/components/Blocks/TextBlock/index.tsx @@ -30,7 +30,7 @@ import { schema } from "./schema"; import { useUIState } from "src/useUIState"; import { MarkType, DOMParser as ProsemirrorDOMParser } from "prosemirror-model"; import { useAppEventListener } from "src/eventBus"; -import { addLinkBlock } from "src/utils/addLinkBlock"; +import { addBlueskyPostBlock, addLinkBlock } from "src/utils/addLinkBlock"; import { BlockCommandBar } from "components/Blocks/BlockCommandBar"; import { setEditorState, useEditorStates } from "src/state/useEditorState"; import { isIOS } from "@react-aria/utils"; @@ -56,6 +56,7 @@ import { focusPage } from "components/Pages"; import { blockCommands } from "../BlockCommands"; import { CommandPage } from "twilio/lib/rest/wireless/v1/command"; import { betterIsUrl, isUrl } from "src/utils/isURL"; +import { useSmoker } from "components/Toast"; export function TextBlock( props: BlockProps & { className?: string; preview?: boolean }, @@ -428,6 +429,7 @@ export function BaseTextBlock(props: BlockProps & { className?: string }) { const BlockifyLink = (props: { entityID: string }) => { let rep = useReplicache(); + let smoker = useSmoker(); let isLocked = useEntity(props.entityID, "block/is-locked"); let focused = useUIState((s) => s.focusedEntity?.entityID === props.entityID); let editorState = useEditorStates( @@ -448,16 +450,30 @@ const BlockifyLink = (props: { entityID: string }) => { ) { return (