a tool for shared writing and social publishing
at update/reader 275 lines 9.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"; 13import { Avatar } from "components/Avatar"; 14import { 15 OpenPage, 16 openPage, 17} from "app/lish/[did]/[publication]/[rkey]/PostPages"; 18 19export const BlueskyEmbed = (props: { 20 embed: Exclude<AppBskyFeedDefs.PostView["embed"], undefined>; 21 postUrl?: string; 22 className?: string; 23 compact?: boolean; 24 parent?: OpenPage; 25}) => { 26 // check this file from bluesky for ref 27 // https://github.com/bluesky-social/social-app/blob/main/bskyembed/src/components/embed.tsx 28 switch (true) { 29 case AppBskyEmbedImages.isView(props.embed): 30 let imageEmbed = props.embed; 31 return ( 32 <div className="imageEmbed flex flex-wrap rounded-md w-full overflow-hidden"> 33 {imageEmbed.images.map( 34 ( 35 image: { 36 fullsize: string; 37 alt?: string; 38 aspectRatio?: { width: number; height: number }; 39 }, 40 i: number, 41 ) => { 42 const isSingle = imageEmbed.images.length === 1; 43 const aspectRatio = image.aspectRatio 44 ? image.aspectRatio.width / image.aspectRatio.height 45 : undefined; 46 47 return ( 48 <img 49 key={i} 50 src={image.fullsize} 51 alt={image.alt || "Post image"} 52 style={ 53 isSingle && aspectRatio 54 ? { aspectRatio: String(aspectRatio) } 55 : undefined 56 } 57 className={` 58 overflow-hidden w-full object-cover 59 ${isSingle && "max-h-[800px]"} 60 ${imageEmbed.images.length === 2 && "basis-1/2 aspect-square"} 61 ${imageEmbed.images.length === 3 && "basis-1/3 aspect-2/3"} 62 ${ 63 imageEmbed.images.length === 4 64 ? "basis-1/2 aspect-3/2" 65 : `basis-1/${imageEmbed.images.length}` 66 } 67 `} 68 /> 69 ); 70 }, 71 )} 72 </div> 73 ); 74 case AppBskyEmbedExternal.isView(props.embed): 75 let externalEmbed = props.embed; 76 let isGif = externalEmbed.external.uri.includes(".gif"); 77 if (isGif) { 78 return ( 79 <div className="flex flex-col border border-border-light rounded-md overflow-hidden aspect-video w-full "> 80 <img 81 src={externalEmbed.external.uri} 82 alt={externalEmbed.external.title} 83 className="w-full h-full object-cover" 84 /> 85 </div> 86 ); 87 } 88 return ( 89 <a 90 href={externalEmbed.external.uri} 91 target="_blank" 92 className={`externalLinkEmbed group border border-border-light rounded-md overflow-hidden hover:no-underline sm:hover:border-accent-contrast selected-border w-full ${props.compact ? "flex items-stretch" : "flex flex-col"} 93 ${props.className}`} 94 > 95 {externalEmbed.external.thumb === undefined ? null : ( 96 <> 97 <div 98 className={` overflow-hidden shrink-0 ${props.compact ? "aspect-square h-[113px] hidden sm:block" : "aspect-[1.91/1] w-full "}`} 99 > 100 <img 101 src={externalEmbed.external.thumb} 102 alt={externalEmbed.external.title} 103 className={`object-cover ${props.compact ? "h-full" : "w-full h-full"}`} 104 /> 105 </div> 106 {!props.compact && <hr className="border-border-light" />} 107 </> 108 )} 109 <div 110 className={`p-2 flex flex-col w-full min-w-0 ${props.compact && "sm:pl-3 py-1"}`} 111 > 112 <h4 className="truncate shrink-0" style={{ fontSize: "inherit" }}> 113 {externalEmbed.external.title}{" "} 114 </h4> 115 <div className="grow"> 116 <p className="text-secondary line-clamp-2"> 117 {externalEmbed.external.description} 118 </p> 119 </div> 120 121 <hr className="border-border-light my-1" /> 122 <div className="text-tertiary text-xs shrink-0 sm:group-hover:text-accent-contrast truncate"> 123 {externalEmbed.external.uri} 124 </div> 125 </div> 126 </a> 127 ); 128 case AppBskyEmbedVideo.isView(props.embed): 129 let videoEmbed = props.embed; 130 const videoAspectRatio = videoEmbed.aspectRatio 131 ? videoEmbed.aspectRatio.width / videoEmbed.aspectRatio.height 132 : 16 / 9; 133 return ( 134 <div 135 className={`videoEmbed rounded-md overflow-hidden relative w-full ${props.className}`} 136 style={{ aspectRatio: String(videoAspectRatio) }} 137 > 138 <img 139 src={videoEmbed.thumbnail} 140 alt={ 141 "Thumbnail from embedded video. Go to Bluesky to see the full post." 142 } 143 className="absolute inset-0 w-full h-full object-cover" 144 /> 145 <div className="overlay absolute inset-0 bg-primary opacity-65" /> 146 <div className="absolute w-max top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-border-light rounded-md"> 147 <SeePostOnBluesky postUrl={props.postUrl} /> 148 </div> 149 </div> 150 ); 151 case AppBskyEmbedRecord.isView(props.embed): 152 let recordEmbed = props.embed; 153 let record = recordEmbed.record; 154 155 if (record === undefined) return; 156 157 // if the record is a feed post 158 if (AppBskyEmbedRecord.isViewRecord(record)) { 159 // we have to do this nonsense to get a the proper type for the record text 160 // we aped it from the bluesky front end (check the link at the top of this file) 161 let text: string | null = null; 162 if (AppBskyFeedPost.isRecord(record.value)) { 163 text = (record.value as AppBskyFeedPost.Record).text; 164 } 165 return ( 166 <button 167 className={`bskyPostEmbed text-left w-full flex gap-2 items-start relative overflow-hidden p-2! text-xs block-border hover:border-accent-contrast! `} 168 onClick={(e) => { 169 e.preventDefault(); 170 e.stopPropagation(); 171 172 openPage(props.parent, { type: "thread", uri: record.uri }); 173 }} 174 > 175 <Avatar 176 src={record.author?.avatar} 177 displayName={record.author?.displayName} 178 size="small" 179 /> 180 <div className="flex flex-col "> 181 <div className="flex gap-1"> 182 <div className=" font-bold text-secondary mr-1"> 183 {record.author?.displayName} 184 </div> 185 <a 186 className="text-xs text-tertiary hover:underline" 187 target="_blank" 188 href={`https://bsky.app/profile/${record.author?.handle}`} 189 > 190 @{record.author?.handle} 191 </a> 192 </div> 193 <div className="flex flex-col gap-2 "> 194 {text && ( 195 <pre 196 className={`whitespace-pre-wrap text-secondary ${props.compact ? "line-clamp-6" : ""}`} 197 > 198 {text} 199 </pre> 200 )} 201 {/*{record.embeds !== undefined 202 ? record.embeds.map((embed, index) => ( 203 <BlueskyEmbed embed={embed} key={index} compact /> 204 )) 205 : null}*/} 206 </div> 207 </div> 208 </button> 209 ); 210 } 211 212 // labeller, starterpack or feed 213 if ( 214 AppBskyFeedDefs.isGeneratorView(record) || 215 AppBskyLabelerDefs.isLabelerView(record) || 216 AppBskyGraphDefs.isStarterPackViewBasic(record) 217 ) 218 return <SeePostOnBluesky postUrl={props.postUrl} />; 219 220 // post is blocked or not found 221 if ( 222 AppBskyFeedDefs.isBlockedPost(record) || 223 AppBskyFeedDefs.isNotFoundPost(record) 224 ) 225 return <PostNotAvailable />; 226 227 if (AppBskyEmbedRecord.isViewDetached(record)) return null; 228 229 return <SeePostOnBluesky postUrl={props.postUrl} />; 230 231 // I am not sure when this case will be used? so I'm commenting it out for now 232 case AppBskyEmbedRecordWithMedia.isView(props.embed) && 233 AppBskyEmbedRecord.isViewRecord(props.embed.record.record): 234 return ( 235 <div className={`bskyEmbed flex flex-col gap-2`}> 236 <BlueskyEmbed embed={props.embed.media} /> 237 <BlueskyEmbed 238 embed={{ 239 $type: "app.bsky.embed.record#view", 240 record: props.embed.record.record, 241 }} 242 /> 243 </div> 244 ); 245 246 default: 247 return <SeePostOnBluesky postUrl={props.postUrl} />; 248 } 249}; 250 251const SeePostOnBluesky = (props: { postUrl: string | undefined }) => { 252 return ( 253 <a 254 href={props.postUrl} 255 target="_blank" 256 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"} `} 257 > 258 <div> This media is not supported... </div>{" "} 259 {props.postUrl === undefined ? null : ( 260 <div> 261 See the <span className=" text-accent-contrast">full post</span> on 262 Bluesky! 263 </div> 264 )} 265 </a> 266 ); 267}; 268 269export const PostNotAvailable = () => { 270 return ( 271 <div className="px-3 py-6 w-full rounded-md bg-border-light text-tertiary italic text-center"> 272 This Bluesky post is not available... 273 </div> 274 ); 275};