• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

oetherington / bluesky-embed-react / 12323894398

13 Dec 2024 10:20PM UTC coverage: 65.369% (-1.6%) from 67.008%
12323894398

Pull #19

github

web-flow
Merge e4e374ad6 into afedac89b
Pull Request #19: Bump react, react-dom and @types/react

53 of 123 branches covered (43.09%)

Branch coverage included in aggregate %.

266 of 365 relevant lines covered (72.88%)

65.63 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

68.57
/src/components/BlueskyEmbed.tsx
1
import React, {
2
        CSSProperties,
3
        FC,
4
        MouseEvent,
5
        useCallback,
6
        useRef,
7
        useState,
8
} from "react";
9
import type {
10
        AppBskyEmbedDefs,
11
        AppBskyEmbedExternal,
12
        AppBskyEmbedImages,
13
        AppBskyEmbedRecord,
14
        AppBskyEmbedRecordWithMedia,
15
        AppBskyEmbedVideo,
16
} from "@atproto/api";
17
import { useBlueskyConfig } from "../hooks/useBlueskyConfig";
18
import { getBlueskyLinkProps } from "../helpers";
19
import { BlueskyPlayIcon } from "./BlueskyPlayIcon";
20
import { BlueskyWorldIcon } from "./BlueskyWorldIcon";
21
import { useBlueskyHLS } from "../hooks/useBlueskyHLS";
22

23
export type BlueskyEmbedData =
24
        | AppBskyEmbedImages.View
25
        | AppBskyEmbedVideo.View
26
        | AppBskyEmbedExternal.View
27
        | AppBskyEmbedRecord.View
28
        | AppBskyEmbedRecordWithMedia.View
29
        | { $type: string; [k: string]: unknown };
30

31
export type BlueskyEmbedProps = {
32
        embed: BlueskyEmbedData;
33
};
34

35
const commonStyles = (
27✔
36
        borderColor: string,
37
        aspect?: AppBskyEmbedDefs.AspectRatio,
38
): CSSProperties => ({
56✔
39
        width: "100%",
40
        borderRadius: 10,
41
        border: `1px solid ${borderColor}`,
42
        aspectRatio: aspect ? `${aspect.width} / ${aspect.height}` : undefined,
56✔
43
});
44

45
const youtubeRegex = new RegExp(
27✔
46
        "(?:youtu\\.be\\/|youtube\\.com(?:\\/embed\\/|\\/v\\/|\\/watch\\?v=|\\/user\\/\\S+|\\/ytscreeningroom\\?v=))([\\w-]{10,12})\\b",
47
);
48
const vimeoRegex = new RegExp(
27✔
49
        "vimeo\\.com\\/(?:channels\\/(?:\\w+\\/)?|groups\\/(?:[^\\/]*)\\/videos\\/|video\\/|)(\\d+)(?:|\\/\\?)",
50
);
51
const twitchRegex = new RegExp("twitch.tv/(.+)");
27✔
52

53
const getIframeEmbedUrl = (url: string): string | null => {
27✔
54
        let match = url.match(youtubeRegex);
12✔
55
        if (match) {
12✔
56
                return `https://www.youtube.com/embed/${match[1]}?autoplay=1`;
6✔
57
        }
58
        match = url.match(vimeoRegex);
6✔
59
        if (match) {
6!
60
                return `https://player.vimeo.com/video/${match[1]}?autoplay=1`;
×
61
        }
62
        match = url.match(twitchRegex);
6✔
63
        if (match) {
6!
64
                const parent =
65
                        typeof window === "undefined" ? "localhost" : window.location.hostname;
×
66
                const [channelOrVideo, clipOrId, id] = match[1].split("/");
×
67
                if (channelOrVideo === "videos") {
×
68
                        return `https://player.twitch.tv/?volume=0.5&!muted&autoplay&video=${clipOrId}&parent=${parent}`;
×
69
                } else if (clipOrId === "clip") {
×
70
                        return `https://clips.twitch.tv/embed?volume=0.5&autoplay=true&clip=${id}&parent=${parent}`;
×
71
                } else if (channelOrVideo) {
×
72
                        return `https://player.twitch.tv/?volume=0.5&!muted&autoplay&channel=${channelOrVideo}&parent=${parent}`;
×
73
                }
74
        }
75
        return null;
6✔
76
};
77

78
const getUrlHost = (url: string): string | null => {
27✔
79
        try {
12✔
80
                const host = new URL(url).hostname;
12✔
81
                return host.replace("www.", "");
12✔
82
        } catch (_e) {
83
                return null;
×
84
        }
85
};
86

87
const BlueskyImages: FC<{ image: AppBskyEmbedImages.View }> = ({
27✔
88
        image: { images },
89
}) => {
90
        const { openLinksInNewTab, borderColor, grid } = useBlueskyConfig();
21✔
91
        const linkProps = getBlueskyLinkProps(openLinksInNewTab);
21✔
92

93
        switch (images.length) {
21!
94
                case 0:
95
                        return null;
×
96

97
                case 1: {
98
                        const image = images[0];
6✔
99
                        return (
6✔
100
                                <a href={image.fullsize} {...linkProps}>
101
                                        <img
102
                                                src={image.fullsize}
103
                                                title={image.alt}
104
                                                alt={image.alt}
105
                                                style={commonStyles(borderColor, image.aspectRatio)}
106
                                        />
107
                                </a>
108
                        );
109
                }
110

111
                default: {
112
                        return (
15✔
113
                                <div
114
                                        style={{
115
                                                display: "flex",
116
                                                flexWrap: "wrap",
117
                                                gap: grid,
118
                                                width: "100%",
119
                                        }}
120
                                >
121
                                        {images.map((image) => (
122
                                                <div
30✔
123
                                                        key={image.thumb}
124
                                                        style={{
125
                                                                flexGrow: 1,
126
                                                                flexBasis: "40%",
127
                                                        }}
128
                                                >
129
                                                        <a href={image.fullsize} {...linkProps}>
130
                                                                <img
131
                                                                        src={image.thumb}
132
                                                                        title={image.alt}
133
                                                                        alt={image.alt}
134
                                                                        style={{
135
                                                                                ...commonStyles(borderColor),
136
                                                                                aspectRatio: 1,
137
                                                                                objectFit: "cover",
138
                                                                        }}
139
                                                                />
140
                                                        </a>
141
                                                </div>
142
                                        ))}
143
                                </div>
144
                        );
145
                }
146
        }
147
};
148

149
const BlueskyVideo: FC<{ video: AppBskyEmbedVideo.View }> = ({ video }) => {
27✔
150
        const { borderColor } = useBlueskyConfig();
8✔
151
        const videoRef = useRef<HTMLVideoElement>(null);
8✔
152
        const [_hasSubtitleTrack, setHasSubtitleTrack] = useState(false);
8✔
153
        const [_hlsLoading, setHlsLoading] = useState(false);
8✔
154
        const [error, setError] = useState<Error | null>(null);
8✔
155

156
        useBlueskyHLS({
8✔
157
                playlist: video.playlist,
158
                setHasSubtitleTrack,
159
                setError,
160
                videoRef,
161
                setHlsLoading,
162
        });
163

164
        if (error) {
8!
165
                console.error("Video embed error", error);
×
166
                return null;
×
167
        }
168

169
        return (
8✔
170
                <video
171
                        ref={videoRef}
172
                        title={video.alt}
173
                        poster={video.thumbnail}
174
                        style={commonStyles(borderColor, video.aspectRatio)}
175
                        preload="none"
176
                        playsInline
177
                        controls
178
                />
179
        );
180
};
181

182
export const BlueskyEmbed: FC<BlueskyEmbedProps> = ({ embed }) => {
27✔
183
        const {
184
                openLinksInNewTab,
185
                embedFontSize,
186
                titleFontWeight,
187
                fontWeight,
188
                lineHeight,
189
                borderColor,
190
                textPrimaryColor,
191
                grid,
192
        } = useBlueskyConfig();
41✔
193
        const marginTop = 2 * grid;
41✔
194

195
        const [revealed, setRevealed] = useState(false);
41✔
196
        const onReveal = useCallback(
41✔
197
                (ev?: MouseEvent) => {
198
                        if (!revealed) {
×
199
                                ev?.preventDefault();
×
200
                                ev?.stopPropagation();
×
201
                                setRevealed(true);
×
202
                        }
203
                },
204
                [revealed],
205
        );
206

207
        switch (embed.$type) {
41!
208
                case "app.bsky.embed.images#view": {
209
                        return (
21✔
210
                                <div style={{ width: "100%", marginTop }}>
211
                                        <BlueskyImages image={embed as AppBskyEmbedImages.View} />
212
                                </div>
213
                        );
214
                }
215

216
                case "app.bsky.embed.video#view": {
217
                        return (
8✔
218
                                <div style={{ width: "100%", marginTop }}>
219
                                        <BlueskyVideo video={embed as AppBskyEmbedVideo.View} />
220
                                </div>
221
                        );
222
                }
223

224
                case "app.bsky.embed.external#view": {
225
                        const external = embed as AppBskyEmbedExternal.View;
12✔
226
                        const { title, description, thumb, uri } = external.external;
12✔
227
                        const iframeEmbedUrl = getIframeEmbedUrl(uri);
12✔
228
                        const host = getUrlHost(uri);
12✔
229
                        return (
12✔
230
                                <a
231
                                        href={uri}
232
                                        {...getBlueskyLinkProps(openLinksInNewTab)}
233
                                        style={{
234
                                                display: "block",
235
                                                overflow: "hidden",
236
                                                textDecoration: "none",
237
                                                color: textPrimaryColor,
238
                                                fontWeight,
239
                                                lineHeight,
240
                                                marginTop,
241
                                                ...commonStyles(borderColor),
242
                                        }}
243
                                >
244
                                        {thumb && (
24✔
245
                                                <div
246
                                                        onClick={iframeEmbedUrl ? onReveal : undefined}
12✔
247
                                                        style={{
248
                                                                height: 300,
249
                                                                overflow: "hidden",
250
                                                                borderBottom: `1px solid ${borderColor}`,
251
                                                                backgroundImage: revealed
12!
252
                                                                        ? undefined
253
                                                                        : `url(${thumb})`,
254
                                                                backgroundPosition: "center",
255
                                                                backgroundRepeat: "no-repeat",
256
                                                                backgroundSize: "cover",
257
                                                                backgroundColor: "white",
258
                                                        }}
259
                                                >
260
                                                        {iframeEmbedUrl && revealed && (
18!
261
                                                                <iframe
262
                                                                        src={iframeEmbedUrl}
263
                                                                        title={title}
264
                                                                        allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
265
                                                                        allowFullScreen
266
                                                                        style={{
267
                                                                                width: "100%",
268
                                                                                height: "100%",
269
                                                                                border: 0,
270
                                                                        }}
271
                                                                />
272
                                                        )}
273
                                                        {iframeEmbedUrl && !revealed && (
24✔
274
                                                                <div
275
                                                                        style={{
276
                                                                                width: "100%",
277
                                                                                height: "100%",
278
                                                                                display: "flex",
279
                                                                                alignItems: "center",
280
                                                                                justifyContent: "center",
281
                                                                                background: "rgba(100, 100, 100, 0.4)",
282
                                                                        }}
283
                                                                >
284
                                                                        <BlueskyPlayIcon />
285
                                                                </div>
286
                                                        )}
287
                                                </div>
288
                                        )}
289
                                        <div
290
                                                style={{
291
                                                        display: "flex",
292
                                                        flexDirection: "column",
293
                                                        gap: grid / 2,
294
                                                        padding: grid,
295
                                                }}
296
                                        >
297
                                                {title && (
24✔
298
                                                        <div
299
                                                                style={{
300
                                                                        fontWeight: titleFontWeight,
301
                                                                }}
302
                                                        >
303
                                                                {title}
304
                                                        </div>
305
                                                )}
306
                                                {description && (
24✔
307
                                                        <div style={{ fontSize: embedFontSize }}>
308
                                                                {description}
309
                                                        </div>
310
                                                )}
311
                                                {host && (
24✔
312
                                                        <div
313
                                                                style={{
314
                                                                        display: "flex",
315
                                                                        alignItems: "center",
316
                                                                        gap: grid / 2,
317
                                                                        borderTop: `1px solid ${borderColor}`,
318
                                                                        fontSize: "0.7rem",
319
                                                                        marginTop: grid / 2,
320
                                                                        paddingTop: grid / 2,
321
                                                                        opacity: 0.6,
322
                                                                }}
323
                                                        >
324
                                                                <BlueskyWorldIcon />
325
                                                                {host}
326
                                                        </div>
327
                                                )}
328
                                        </div>
329
                                </a>
330
                        );
331
                }
332

333
                default: {
334
                        return null;
×
335
                }
336
        }
337
};
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc