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 {record.author.avatar && (
129 <img
130 src={record.author?.avatar}
131 alt={`${record.author?.displayName}'s avatar`}
132 className="shink-0 w-6 h-6 rounded-full border border-border-light"
133 />
134 )}
135 <div className=" font-bold text-secondary">
136 {record.author?.displayName}
137 </div>
138 <a
139 className="text-xs text-tertiary hover:underline"
140 target="_blank"
141 href={`https://bsky.app/profile/${record.author?.handle}`}
142 >
143 @{record.author?.handle}
144 </a>
145 </div>
146
147 <div className="flex flex-col gap-2 ">
148 {text && <pre className="whitespace-pre-wrap">{text}</pre>}
149 {record.embeds !== undefined
150 ? record.embeds.map((embed, index) => (
151 <BlueskyEmbed embed={embed} key={index} />
152 ))
153 : null}
154 </div>
155 </div>
156 );
157 }
158
159 // labeller, starterpack or feed
160 if (
161 AppBskyFeedDefs.isGeneratorView(record) ||
162 AppBskyLabelerDefs.isLabelerView(record) ||
163 AppBskyGraphDefs.isStarterPackViewBasic(record)
164 )
165 return <SeePostOnBluesky postUrl={props.postUrl} />;
166
167 // post is blocked or not found
168 if (
169 AppBskyFeedDefs.isBlockedPost(record) ||
170 AppBskyFeedDefs.isNotFoundPost(record)
171 )
172 return <PostNotAvailable />;
173
174 if (AppBskyEmbedRecord.isViewDetached(record)) return null;
175
176 return <SeePostOnBluesky postUrl={props.postUrl} />;
177
178 // I am not sure when this case will be used? so I'm commenting it out for now
179 case AppBskyEmbedRecordWithMedia.isView(props.embed) &&
180 AppBskyEmbedRecord.isViewRecord(props.embed.record.record):
181 return (
182 <div className={`flex flex-col gap-2`}>
183 <BlueskyEmbed embed={props.embed.media} />
184 <BlueskyEmbed
185 embed={{
186 $type: "app.bsky.embed.record#view",
187 record: props.embed.record.record,
188 }}
189 />
190 </div>
191 );
192
193 default:
194 return <SeePostOnBluesky postUrl={props.postUrl} />;
195 }
196};
197
198const SeePostOnBluesky = (props: { postUrl: string | undefined }) => {
199 return (
200 <a
201 href={props.postUrl}
202 target="_blank"
203 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"} `}
204 >
205 <div> This media is not supported... </div>{" "}
206 {props.postUrl === undefined ? null : (
207 <div>
208 See the <span className=" text-accent-contrast">full post</span> on
209 Bluesky!
210 </div>
211 )}
212 </a>
213 );
214};
215
216export const PostNotAvailable = () => {
217 return (
218 <div className="px-3 py-6 w-full rounded-md bg-border-light text-tertiary italic text-center">
219 This Bluesky post is not available...
220 </div>
221 );
222};