an independent Bluesky client using Constellation, PDS Queries, and other services reddwarf.app
frontend spa bluesky reddwarf microcosm client app
at main 898 lines 26 kB view raw
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}