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";
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};