an independent Bluesky client using Constellation, PDS Queries, and other services
reddwarf.app
frontend
spa
bluesky
reddwarf
microcosm
client
app
1import * as ATPAPI from "@atproto/api"
2import {
3 AppBskyEmbedDefs,
4 AppBskyEmbedExternal,
5 AppBskyEmbedImages,
6 AppBskyEmbedRecord,
7 AppBskyEmbedRecordWithMedia,
8 AppBskyEmbedVideo,
9 AppBskyFeedDefs,
10 AppBskyFeedPost,
11 AppBskyGraphDefs,
12 AtUri,
13 ModerationDecision,
14} from "@atproto/api";
15import * as React from "react";
16import { useEffect, useRef, useState } from "react";
17import ReactPlayer from "react-player";
18
19import { FeedItemRenderAturiLoader } from "~/routes/profile.$did";
20import type { LightboxProps } from "~/routes/profile.$did/post.$rkey.image.$i";
21
22import { PollEmbed } from "./PollComponents";
23import { UniversalPostRenderer, UniversalPostRendererATURILoader } from "./UniversalPostRenderer";
24
25type Embed =
26 | AppBskyEmbedRecord.View
27 | AppBskyEmbedImages.View
28 | AppBskyEmbedVideo.View
29 | AppBskyEmbedExternal.View
30 | AppBskyEmbedRecordWithMedia.View
31 | { $type: string;[k: string]: unknown };
32
33enum PostEmbedViewContext {
34 ThreadHighlighted = "ThreadHighlighted",
35 Feed = "Feed",
36 FeedEmbedRecordWithMedia = "FeedEmbedRecordWithMedia",
37}
38
39const stopgap = {
40 display: "flex",
41 justifyContent: "center",
42 padding: "32px 12px",
43 borderRadius: 12,
44 border: "1px solid rgba(161, 170, 174, 0.38)",
45};
46
47export function PostEmbeds({
48 embed,
49 moderation,
50 onOpen,
51 allowNestedQuotes,
52 viewContext,
53 salt,
54 navigate,
55 postid,
56 nopics,
57 lightboxCallback,
58 constellationLinks,
59 redactedLoading,
60 referral
61}: {
62 embed?: Embed;
63 moderation?: ModerationDecision;
64 onOpen?: () => void;
65 allowNestedQuotes?: boolean;
66 viewContext?: PostEmbedViewContext;
67 salt: string;
68 navigate: (_: any) => void;
69 postid?: { did: string; rkey: string };
70 nopics?: boolean;
71 lightboxCallback?: (d: LightboxProps) => void;
72 constellationLinks?: any;
73 redactedLoading?: boolean;
74 referral?: string[];
75}) {
76 function setLightboxIndex(number: number) {
77 navigate({
78 to: "/profile/$did/post/$rkey/image/$i",
79 params: {
80 did: postid?.did,
81 rkey: postid?.rkey,
82 i: number.toString(),
83 },
84 });
85 }
86
87 if (
88 AppBskyEmbedRecordWithMedia.isView(embed) &&
89 AppBskyEmbedRecord.isViewRecord(embed.record.record) &&
90 AppBskyFeedPost.isRecord(embed.record.record.value)
91 ) {
92 const post: AppBskyFeedDefs.PostView = {
93 $type: "app.bsky.feed.defs#postView",
94 uri: embed.record.record.uri,
95 cid: embed.record.record.cid,
96 author: embed.record.record.author,
97 record: embed.record.record.value as { [key: string]: unknown },
98 embed: embed.record.record.embeds
99 ? embed.record.record.embeds?.[0]
100 : undefined,
101 replyCount: embed.record.record.replyCount,
102 repostCount: embed.record.record.repostCount,
103 likeCount: embed.record.record.likeCount,
104 quoteCount: embed.record.record.quoteCount,
105 indexedAt: embed.record.record.indexedAt,
106 labels: embed.record.record.labels,
107 };
108
109 return (
110 <div>
111 <PostEmbeds
112 embed={embed.media}
113 moderation={moderation}
114 onOpen={onOpen}
115 viewContext={viewContext}
116 salt={salt}
117 navigate={navigate}
118 postid={postid}
119 nopics={nopics}
120 lightboxCallback={lightboxCallback}
121 constellationLinks={constellationLinks}
122 redactedLoading={redactedLoading}
123 />
124 <div style={{ height: 12 }} />
125 <div
126 style={{
127 display: "flex",
128 flexDirection: "column",
129 borderRadius: 12,
130 overflow: "hidden",
131 }}
132 className="shadow border border-gray-200 dark:border-gray-800 was7"
133 >
134 <UniversalPostRenderer
135 post={post}
136 isQuote
137 salt={salt}
138 onPostClick={(e) => {
139 e.stopPropagation();
140 const parsed = new AtUri(post.uri);
141 if (parsed) {
142 navigate({
143 to: "/profile/$did/post/$rkey",
144 params: { did: parsed.host, rkey: parsed.rkey },
145 });
146 }
147 }}
148 depth={1}
149 />
150 </div>
151 </div>
152 );
153 }
154
155 if (AppBskyEmbedRecord.isView(embed)) {
156 const reallybaduri = (embed?.record as any)?.uri as string | undefined;
157 const reallybadaturi = reallybaduri ? new AtUri(reallybaduri) : undefined;
158
159 if (AppBskyFeedDefs.isGeneratorView(embed.record)) {
160 return <div style={stopgap} className={(redactedLoading ? " blur animate-pulse" : undefined)}>feedgen placeholder</div>;
161 } else if (
162 !!reallybaduri &&
163 !!reallybadaturi &&
164 reallybadaturi.collection === "app.bsky.feed.generator"
165 ) {
166 return (
167 <div className={`rounded-xl border` + (redactedLoading ? " blur animate-pulse" : undefined)}>
168 <FeedItemRenderAturiLoader aturi={reallybaduri} disableBottomBorder />
169 </div>
170 );
171 }
172
173 if (AppBskyGraphDefs.isListView(embed.record)) {
174 return <div style={stopgap} className={(redactedLoading ? " blur animate-pulse" : undefined)}>list placeholder</div>;
175 } else if (
176 !!reallybaduri &&
177 !!reallybadaturi &&
178 reallybadaturi.collection === "app.bsky.graph.list"
179 ) {
180 return (
181 <div className={"rounded-xl border" + (redactedLoading ? " blur animate-pulse" : undefined)}>
182 <FeedItemRenderAturiLoader
183 aturi={reallybaduri}
184 disableBottomBorder
185 listmode
186 disablePropagation
187 />
188 </div>
189 );
190 }
191
192 if (AppBskyGraphDefs.isStarterPackViewBasic(embed.record)) {
193 return <div style={stopgap} className={(redactedLoading ? " blur animate-pulse" : undefined)}>starter pack card placeholder</div>;
194 } else if (
195 !!reallybaduri &&
196 !!reallybadaturi &&
197 reallybadaturi.collection === "app.bsky.graph.starterpack"
198 ) {
199 return (
200 <div className={"rounded-xl border" + (redactedLoading ? " blur animate-pulse" : undefined)}>
201 <FeedItemRenderAturiLoader
202 aturi={reallybaduri}
203 disableBottomBorder
204 listmode
205 disablePropagation
206 />
207 </div>
208 );
209 }
210
211 if (
212 AppBskyEmbedRecord.isViewRecord(embed.record) &&
213 AppBskyFeedPost.isRecord(embed.record.value)
214 ) {
215 const post: AppBskyFeedDefs.PostView = {
216 $type: "app.bsky.feed.defs#postView",
217 uri: embed.record.uri,
218 cid: embed.record.cid,
219 author: embed.record.author,
220 record: embed.record.value as { [key: string]: unknown },
221 embed: embed.record.embeds ? embed.record.embeds?.[0] : undefined,
222 replyCount: embed.record.replyCount,
223 repostCount: embed.record.repostCount,
224 likeCount: embed.record.likeCount,
225 quoteCount: embed.record.quoteCount,
226 indexedAt: embed.record.indexedAt,
227 labels: embed.record.labels,
228 };
229
230 return (
231 <div
232 style={{
233 display: "flex",
234 flexDirection: "column",
235 borderRadius: 12,
236 overflow: "hidden",
237 }}
238 className={"shadow border border-gray-200 dark:border-gray-800 was7" + (redactedLoading ? " blur animate-pulse" : undefined)}
239 >
240 <UniversalPostRenderer
241 post={post}
242 isQuote
243 salt={salt}
244 onPostClick={(e) => {
245 e.stopPropagation();
246 const parsed = new AtUri(post.uri);
247 if (parsed) {
248 navigate({
249 to: "/profile/$did/post/$rkey",
250 params: { did: parsed.host, rkey: parsed.rkey },
251 });
252 }
253 }}
254 depth={1}
255 />
256 </div>
257 );
258
259 } if (AppBskyEmbedRecord.isViewNotFound(embed.record)) {
260 return (
261 <UniversalPostRendererATURILoader atUri={embed.record.uri} isQuote />
262 )
263 } else {
264 console.log("what the hell is a ", embed);
265 return <>sorry</>;
266 }
267 }
268
269 if (AppBskyEmbedImages.isView(embed)) {
270 const { images } = embed;
271
272 const lightboxImages = images.map((img) => ({
273 src: img.fullsize,
274 alt: img.alt,
275 }));
276
277 if (lightboxCallback) {
278 lightboxCallback({ images: lightboxImages });
279 }
280
281 if (nopics) return;
282
283 if (images.length > 0) {
284 if (images.length === 1) {
285 const image = images[0];
286 return (
287 <div style={{ marginTop: 0 }}>
288 <div
289 style={{
290 position: "relative",
291 width: "100%",
292 aspectRatio: image.aspectRatio
293 ? (() => {
294 const { width, height } = image.aspectRatio;
295 const ratio = width / height;
296 return ratio < 0.5 ? "1 / 2" : `${width} / ${height}`;
297 })()
298 : "1 / 1",
299 borderRadius: 12,
300 overflow: "hidden",
301 }}
302 className="border border-gray-200 dark:border-gray-800 was7 bg-gray-200 dark:bg-gray-900"
303 >
304 {redactedLoading ? (
305 <div
306 style={{
307 width: "100%",
308 height: "100%",
309 objectFit: "contain",
310 }}
311 className="bg-gray-300 dark:bg-gray-600 blur animate-pulse "
312 />
313 ) : (
314 <img
315 src={image.fullsize}
316 alt={image.alt}
317 style={{
318 width: "100%",
319 height: "100%",
320 objectFit: "contain",
321 }}
322 onClick={(e) => {
323 e.stopPropagation();
324 setLightboxIndex(0);
325 }}
326 />
327 )}
328 </div>
329 </div>
330 );
331 }
332
333 if (images.length === 2) {
334 return (
335 <div
336 style={{
337 display: "flex",
338 gap: 4,
339 marginTop: 0,
340 width: "100%",
341 borderRadius: 12,
342 overflow: "hidden",
343 }}
344 className="border border-gray-200 dark:border-gray-800 was7"
345 >
346 {images.map((img, i) => (
347 <div
348 key={i}
349 style={{ flex: 1, aspectRatio: "1 / 1", position: "relative" }}
350 >
351 {redactedLoading ? (
352 <div
353 style={{
354 width: "100%",
355 height: "100%",
356 objectFit: "cover",
357 borderRadius: i === 0 ? "12px 0 0 12px" : "0 12px 12px 0",
358 }}
359 className="bg-gray-300 dark:bg-gray-600 blur animate-pulse "
360 />
361 ) : (
362 <img
363 src={img.fullsize}
364 alt={img.alt}
365 style={{
366 width: "100%",
367 height: "100%",
368 objectFit: "cover",
369 borderRadius: i === 0 ? "12px 0 0 12px" : "0 12px 12px 0",
370 }}
371 onClick={(e) => {
372 e.stopPropagation();
373 setLightboxIndex(i);
374 }}
375 />
376 )}
377 </div>
378 ))}
379 </div>
380 );
381 }
382
383 if (images.length === 3) {
384 return (
385 <div
386 style={{
387 display: "flex",
388 gap: 4,
389 marginTop: 0,
390 width: "100%",
391 borderRadius: 12,
392 overflow: "hidden",
393 }}
394 className="border border-gray-200 dark:border-gray-800 was7"
395 >
396 <div
397 style={{ flex: 1, aspectRatio: "1 / 1", position: "relative" }}
398 >
399 {redactedLoading ? (
400 <div
401 style={{
402 width: "100%",
403 height: "100%",
404 objectFit: "cover",
405 borderRadius: "12px 0 0 12px",
406 }}
407 className="bg-gray-300 dark:bg-gray-600 blur animate-pulse "
408 />
409 ) : (
410 <img
411 src={images[0].fullsize}
412 alt={images[0].alt}
413 style={{
414 width: "100%",
415 height: "100%",
416 objectFit: "cover",
417 borderRadius: "12px 0 0 12px",
418 }}
419 onClick={(e) => {
420 e.stopPropagation();
421 setLightboxIndex(0);
422 }}
423 />
424 )}
425 </div>
426 <div
427 style={{
428 flex: 1,
429 display: "flex",
430 flexDirection: "column",
431 gap: 4,
432 }}
433 >
434 {[1, 2].map((i) => (
435 <div
436 key={i}
437 style={{
438 flex: 1,
439 aspectRatio: "2 / 1",
440 position: "relative",
441 }}
442 >
443 {redactedLoading ? (
444 <div
445 style={{
446 width: "100%",
447 height: "100%",
448 objectFit: "cover",
449 borderRadius: i === 1 ? "0 12px 0 0" : "0 0 12px 0",
450 }}
451 className="bg-gray-300 dark:bg-gray-600 blur animate-pulse "
452 />
453 ) : (
454 <img
455 src={images[i].fullsize}
456 alt={images[i].alt}
457 style={{
458 width: "100%",
459 height: "100%",
460 objectFit: "cover",
461 borderRadius: i === 1 ? "0 12px 0 0" : "0 0 12px 0",
462 }}
463 onClick={(e) => {
464 e.stopPropagation();
465 setLightboxIndex(i + 1);
466 }}
467 />
468 )}
469 </div>
470 ))}
471 </div>
472 </div>
473 );
474 }
475
476 if (images.length === 4) {
477 return (
478 <div
479 style={{
480 display: "grid",
481 gridTemplateColumns: "1fr 1fr",
482 gridTemplateRows: "1fr 1fr",
483 gap: 4,
484 marginTop: 0,
485 width: "100%",
486 borderRadius: 12,
487 overflow: "hidden",
488 }}
489 className="border border-gray-200 dark:border-gray-800 was7"
490 >
491 {images.map((img, i) => (
492 <div
493 key={i}
494 style={{
495 width: "100%",
496 height: "100%",
497 aspectRatio: "3 / 2",
498 position: "relative",
499 }}
500 >
501 {redactedLoading ? (
502 <div
503 style={{
504 width: "100%",
505 height: "100%",
506 objectFit: "cover",
507 borderRadius:
508 i === 0
509 ? "12px 0 0 0"
510 : i === 1
511 ? "0 12px 0 0"
512 : i === 2
513 ? "0 0 0 12px"
514 : "0 0 12px 0",
515 }}
516 className="bg-gray-300 dark:bg-gray-600 blur animate-pulse "
517 />
518 ) : (
519 <img
520 src={img.fullsize}
521 alt={img.alt}
522 style={{
523 width: "100%",
524 height: "100%",
525 objectFit: "cover",
526 borderRadius:
527 i === 0
528 ? "12px 0 0 0"
529 : i === 1
530 ? "0 12px 0 0"
531 : i === 2
532 ? "0 0 0 12px"
533 : "0 0 12px 0",
534 }}
535 onClick={(e) => {
536 e.stopPropagation();
537 setLightboxIndex(i);
538 }}
539 />
540 )}
541 </div>
542 ))}
543 </div>
544 );
545 }
546
547 return <div style={stopgap}>image count more than one placeholder</div>;
548 }
549 }
550
551 if (AppBskyEmbedExternal.isView(embed)) {
552 const pollLinks = constellationLinks?.links?.["app.reddwarf.embed.poll"];
553 const hasPollLink = pollLinks && Object.keys(pollLinks).length > 0;
554 const isfromappview = referral?.includes("appview")
555
556 if ((hasPollLink || isfromappview) && postid) {
557 // warning: i gave up and warpped it in a div lmao
558 return (
559 <div className={(redactedLoading ? " blur animate-pulse " : undefined)}>
560 <PollEmbed did={postid.did} rkey={postid.rkey} embedtryfall={isfromappview ? {embed, onOpen} : undefined} redactedLoading={redactedLoading}/>
561 </div>
562 );
563 }
564
565 const link = embed.external;
566 return (
567 <ExternalLinkEmbed link={link} onOpen={onOpen} style={{ marginTop: 0 }} redactedLoading={redactedLoading}/>
568 );
569 }
570
571 if (AppBskyEmbedVideo.isView(embed)) {
572 if (nopics) return;
573 const playlist = embed.playlist;
574 return (
575 <SmartHLSPlayer
576 url={playlist}
577 thumbnail={embed.thumbnail}
578 aspect={embed.aspectRatio}
579 redactedLoading={redactedLoading}
580 />
581 );
582 }
583
584 return <div />;
585}
586export type embedtryfall = {
587 embed: ATPAPI.AppBskyEmbedExternal.View,
588 onOpen?: () => void;
589}
590
591export function ExternalLinkEmbed({
592 link,
593 onOpen,
594 style,
595 redactedLoading,
596 referral
597}: {
598 link: AppBskyEmbedExternal.ViewExternal;
599 onOpen?: () => void;
600 style?: React.CSSProperties;
601 redactedLoading?: boolean;
602 referral?: string[];
603}) {
604 //const fromappview = referral?.includes("appview")
605 //const []
606 const { uri, title, description, thumb } = link;
607 const thumbAspectRatio = 1.91;
608
609 const titleStyle = {
610 fontSize: 16,
611 fontWeight: 700,
612 marginBottom: 4,
613 wordBreak: "break-word",
614 textAlign: "left",
615 maxHeight: "4em",
616 display: "-webkit-box",
617 WebkitBoxOrient: "vertical",
618 overflow: "hidden",
619 WebkitLineClamp: 2,
620 };
621
622 const descriptionStyle = {
623 fontSize: 14,
624 marginBottom: 8,
625 wordBreak: "break-word",
626 textAlign: "left",
627 maxHeight: "5em",
628 display: "-webkit-box",
629 WebkitBoxOrient: "vertical",
630 overflow: "hidden",
631 WebkitLineClamp: 3,
632 };
633
634 const linkStyle = {
635 textDecoration: "none",
636 wordBreak: "break-all",
637 textAlign: "left",
638 };
639
640 const containerStyle = {
641 display: "flex",
642 flexDirection: "column",
643 borderRadius: 12,
644 maxWidth: "100%",
645 overflow: "hidden",
646 ...style,
647 };
648
649 return (
650 <a
651 href={redactedLoading ? undefined : uri}
652 target="_blank"
653 rel="noopener noreferrer"
654 onClick={(e) => {
655 e.stopPropagation();
656 if (onOpen) onOpen();
657 }}
658 style={linkStyle as React.CSSProperties}
659 className="text-gray-500 dark:text-gray-400"
660 >
661 <div
662 style={containerStyle as React.CSSProperties}
663 className="border border-gray-200 dark:border-gray-800 was7"
664 >
665 {thumb && (
666 <div
667 style={{
668 position: "relative",
669 width: "100%",
670 aspectRatio: thumbAspectRatio,
671 overflow: "hidden",
672 borderTopLeftRadius: 12,
673 borderTopRightRadius: 12,
674 marginBottom: 8,
675 }}
676 className="border-b border-gray-200 dark:border-gray-800 was7"
677 >
678 {redactedLoading ? (
679 <div
680 style={{
681 position: "absolute",
682 top: 0,
683 left: 0,
684 width: "100%",
685 height: "100%",
686 objectFit: "cover",
687 }}
688 className="bg-gray-300 dark:bg-gray-600 blur animate-pulse "
689 />
690 ) : (
691 <img
692 src={thumb}
693 alt={description}
694 style={{
695 position: "absolute",
696 top: 0,
697 left: 0,
698 width: "100%",
699 height: "100%",
700 objectFit: "cover",
701 }}
702 />
703 )}
704 </div>
705 )}
706 <div
707 style={{
708 paddingBottom: 12,
709 paddingLeft: 12,
710 paddingRight: 12,
711 paddingTop: thumb ? 0 : 12,
712 }}
713 >
714 <div
715 style={titleStyle as React.CSSProperties}
716 className={"text-gray-900 dark:text-gray-100 " + (redactedLoading ? " blur animate-pulse " : undefined)}
717 >
718 {title}
719 </div>
720 <div
721 style={descriptionStyle as React.CSSProperties}
722 className={"text-gray-500 dark:text-gray-400 " + (redactedLoading ? " blur animate-pulse " : undefined)}
723 >
724 {description}
725 </div>
726 <div
727 style={{
728 height: 1,
729 marginBottom: 8,
730 }}
731 className="bg-gray-200 dark:bg-gray-700"
732 />
733 <div
734 style={{
735 display: "flex",
736 alignItems: "center",
737 gap: 4,
738 }}
739 >
740 <div className={redactedLoading ? "blur animate-pulse" : undefined}>
741 <IconMdiGlobe />
742 </div>
743 <span
744 style={{
745 fontSize: 12,
746 }}
747 className={"text-gray-500 dark:text-gray-400 " + (redactedLoading ? " blur animate-pulse " : undefined)}
748 >
749 {getDomain(uri)}
750 </span>
751 </div>
752 </div>
753 </div>
754 </a>
755 );
756}
757
758export const SmartHLSPlayer = ({
759 url,
760 thumbnail,
761 aspect,
762 redactedLoading,
763}: {
764 url: string;
765 thumbnail?: string;
766 aspect?: AppBskyEmbedDefs.AspectRatio;
767 redactedLoading?: boolean;
768}) => {
769 const [playing, setPlaying] = useState(false);
770 const containerRef = useRef(null);
771
772 useEffect(() => {
773 const observer = new IntersectionObserver(
774 ([entry]) => {
775 if (!entry.isIntersecting && playing) {
776 setPlaying(false);
777 }
778 },
779 {
780 root: null,
781 threshold: 0.25,
782 },
783 );
784
785 if (containerRef.current) {
786 observer.observe(containerRef.current);
787 }
788
789 return () => {
790 if (containerRef.current) {
791 observer.unobserve(containerRef.current);
792 }
793 };
794 }, [playing]);
795
796 return (
797 <div
798 ref={containerRef}
799 style={{
800 position: "relative",
801 width: "100%",
802 maxWidth: 640,
803 cursor: "pointer",
804 }}
805 >
806 {!playing && (
807 <>
808 {redactedLoading ? (
809 <div
810 style={{
811 width: "100%",
812 display: "block",
813 aspectRatio: aspect ? aspect?.width / aspect?.height : 16 / 9,
814 borderRadius: 12,
815 }}
816 className="border border-gray-200 dark:border-gray-800 was7 bg-gray-300 dark:bg-gray-600 blur animate-pulse "
817 />
818 ) : (
819 <img
820 src={thumbnail}
821 alt="Video thumbnail"
822 style={{
823 width: "100%",
824 display: "block",
825 aspectRatio: aspect ? aspect?.width / aspect?.height : 16 / 9,
826 borderRadius: 12,
827 }}
828 className="border border-gray-200 dark:border-gray-800 was7"
829 onClick={async (e) => {
830 e.stopPropagation();
831 if (redactedLoading) return;
832 setPlaying(true);
833 }}
834 />
835 )}
836 <div
837 onClick={async (e) => {
838 e.stopPropagation();
839 if (redactedLoading) return;
840 setPlaying(true);
841 }}
842 style={{
843 position: "absolute",
844 top: "50%",
845 left: "50%",
846 transform: "translate(-50%, -50%)",
847 color: "white",
848 pointerEvents: "none",
849 userSelect: "none",
850 }}
851 //className="text-shadow-md"
852 >
853 <IconMdiPlayCircle className="h-14 w-14 drop-shadow-xl drop-shadow-gray-950/10 text-gray-50" />
854 </div>
855 </>
856 )}
857 {playing && (
858 <div
859 style={{
860 position: "relative",
861 width: "100%",
862 borderRadius: 12,
863 overflow: "hidden",
864 paddingTop: `${100 / (aspect ? aspect.width / aspect.height : 16 / 9)
865 }%`,
866 }}
867 className="border border-gray-200 dark:border-gray-800 was7"
868 >
869 <ReactPlayer
870 src={url}
871 playing={true}
872 controls={true}
873 width="100%"
874 height="100%"
875 style={{ position: "absolute", top: 0, left: 0 }}
876 />
877 </div>
878 )}
879 </div>
880 );
881};
882
883function getDomain(url: string) {
884 try {
885 const { hostname } = new URL(url);
886 return hostname;
887 } catch (e) {
888 if (!url.startsWith("http")) {
889 try {
890 const { hostname } = new URL("http://" + url);
891 return hostname;
892 } catch {
893 return null;
894 }
895 }
896 return null;
897 }
898}