a tool for shared writing and social publishing
at feature/reader 220 lines 7.9 kB view raw
1import { $Typed, is$typed } from "@atproto/api/dist/client/util"; 2import { 3 AppBskyEmbedImages, 4 AppBskyEmbedVideo, 5 AppBskyEmbedExternal, 6 AppBskyEmbedRecord, 7 AppBskyEmbedRecordWithMedia, 8 AppBskyFeedPost, 9 AppBskyFeedDefs, 10 AppBskyGraphDefs, 11 AppBskyLabelerDefs, 12} from "@atproto/api"; 13 14export 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-square"} 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, index) => ( 149 <BlueskyEmbed embed={embed} key={index} /> 150 )) 151 : null} 152 </div> 153 </div> 154 ); 155 } 156 157 // labeller, starterpack or feed 158 if ( 159 AppBskyFeedDefs.isGeneratorView(record) || 160 AppBskyLabelerDefs.isLabelerView(record) || 161 AppBskyGraphDefs.isStarterPackViewBasic(record) 162 ) 163 return <SeePostOnBluesky postUrl={props.postUrl} />; 164 165 // post is blocked or not found 166 if ( 167 AppBskyFeedDefs.isBlockedPost(record) || 168 AppBskyFeedDefs.isNotFoundPost(record) 169 ) 170 return <PostNotAvailable />; 171 172 if (AppBskyEmbedRecord.isViewDetached(record)) return null; 173 174 return <SeePostOnBluesky postUrl={props.postUrl} />; 175 176 // I am not sure when this case will be used? so I'm commenting it out for now 177 case AppBskyEmbedRecordWithMedia.isView(props.embed) && 178 AppBskyEmbedRecord.isViewRecord(props.embed.record.record): 179 return ( 180 <div className={`flex flex-col gap-2`}> 181 <BlueskyEmbed embed={props.embed.media} /> 182 <BlueskyEmbed 183 embed={{ 184 $type: "app.bsky.embed.record#view", 185 record: props.embed.record.record, 186 }} 187 /> 188 </div> 189 ); 190 191 default: 192 return <SeePostOnBluesky postUrl={props.postUrl} />; 193 } 194}; 195 196const SeePostOnBluesky = (props: { postUrl: string | undefined }) => { 197 return ( 198 <a 199 href={props.postUrl} 200 target="_blank" 201 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"} `} 202 > 203 <div> This media is not supported... </div>{" "} 204 {props.postUrl === undefined ? null : ( 205 <div> 206 See the <span className=" text-accent-contrast">full post</span> on 207 Bluesky! 208 </div> 209 )} 210 </a> 211 ); 212}; 213 214export const PostNotAvailable = () => { 215 return ( 216 <div className="px-3 py-6 w-full rounded-md bg-border-light text-tertiary italic text-center"> 217 This Bluesky post is not available... 218 </div> 219 ); 220};