a tool for shared writing and social publishing
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};