Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at cope-settings-sync 761 lines 21 kB view raw
1import {Dimensions} from 'react-native' 2import {isDid} from '@atproto/api' 3 4import {isValidHandle} from '#/lib/strings/handles' 5import {IS_WEB, IS_WEB_SAFARI} from '#/env' 6 7const {height: SCREEN_HEIGHT} = Dimensions.get('window') 8 9const IFRAME_HOST = IS_WEB 10 ? // @ts-ignore only for web 11 window.location.host === 'localhost:8100' 12 ? 'http://localhost:8100' 13 : 'https://witchsky.app' 14 : __DEV__ && !process.env.JEST_WORKER_ID 15 ? 'http://localhost:8100' 16 : 'https://bsky.app' 17 18export const embedPlayerSources = [ 19 'youtube', 20 'youtubeShorts', 21 'twitch', 22 'spotify', 23 'soundcloud', 24 'appleMusic', 25 'vimeo', 26 'giphy', 27 'tenor', 28 'klipy', 29 'flickr', 30 'bandcamp', 31 'streamplace', 32] as const 33 34export type EmbedPlayerSource = (typeof embedPlayerSources)[number] 35 36export type EmbedPlayerType = 37 | 'youtube_video' 38 | 'youtube_short' 39 | 'twitch_video' 40 | 'spotify_album' 41 | 'spotify_playlist' 42 | 'spotify_song' 43 | 'soundcloud_track' 44 | 'soundcloud_set' 45 | 'apple_music_playlist' 46 | 'apple_music_album' 47 | 'apple_music_song' 48 | 'vimeo_video' 49 | 'giphy_gif' 50 | 'tenor_gif' 51 | 'klipy_gif' 52 | 'flickr_album' 53 | 'bandcamp_album' 54 | 'bandcamp_track' 55 | 'streamplace_stream' 56 57export const externalEmbedLabels: Record<EmbedPlayerSource, string> = { 58 youtube: 'YouTube', 59 youtubeShorts: 'YouTube Shorts', 60 vimeo: 'Vimeo', 61 twitch: 'Twitch', 62 giphy: 'GIPHY', 63 tenor: 'Tenor', 64 klipy: 'KLIPY', 65 spotify: 'Spotify', 66 appleMusic: 'Apple Music', 67 soundcloud: 'SoundCloud', 68 flickr: 'Flickr', 69 bandcamp: 'Bandcamp', 70 streamplace: 'Streamplace', 71} 72 73export interface EmbedPlayerParams { 74 type: EmbedPlayerType 75 playerUri: string 76 isGif?: boolean 77 source: EmbedPlayerSource 78 metaUri?: string 79 hideDetails?: boolean 80 dimensions?: { 81 height: number 82 width: number 83 } 84} 85 86const giphyRegex = /media(?:[0-4]\.giphy\.com|\.giphy\.com)/i 87const gifFilenameRegex = /^(\S+)\.(webp|gif|mp4)$/i 88 89export function parseEmbedPlayerFromUrl( 90 url: string, 91): EmbedPlayerParams | undefined { 92 let urlp 93 try { 94 urlp = new URL(url) 95 } catch (e) { 96 return undefined 97 } 98 99 // youtube 100 if (urlp.hostname === 'youtu.be') { 101 const videoId = urlp.pathname.split('/')[1] 102 const t = urlp.searchParams.get('t') ?? '0' 103 const seek = encodeURIComponent(t.replace(/s$/, '')) 104 105 if (videoId) { 106 return { 107 type: 'youtube_video', 108 source: 'youtube', 109 playerUri: `${IFRAME_HOST}/iframe/youtube.html?videoId=${videoId}&start=${seek}`, 110 } 111 } 112 } 113 if ( 114 urlp.hostname === 'www.youtube.com' || 115 urlp.hostname === 'youtube.com' || 116 urlp.hostname === 'm.youtube.com' || 117 urlp.hostname === 'music.youtube.com' 118 ) { 119 const [__, page, shortOrLiveVideoId] = urlp.pathname.split('/') 120 121 const isShorts = page === 'shorts' 122 const isLive = page === 'live' 123 const videoId = 124 isShorts || isLive 125 ? shortOrLiveVideoId 126 : (urlp.searchParams.get('v') as string) 127 const t = urlp.searchParams.get('t') ?? '0' 128 const seek = encodeURIComponent(t.replace(/s$/, '')) 129 130 if (videoId) { 131 return { 132 type: isShorts ? 'youtube_short' : 'youtube_video', 133 source: isShorts ? 'youtubeShorts' : 'youtube', 134 hideDetails: isShorts ? true : undefined, 135 playerUri: `${IFRAME_HOST}/iframe/youtube.html?videoId=${videoId}&start=${seek}`, 136 } 137 } 138 } 139 140 // twitch 141 if ( 142 urlp.hostname === 'twitch.tv' || 143 urlp.hostname === 'www.twitch.tv' || 144 urlp.hostname === 'm.twitch.tv' 145 ) { 146 const parent = IS_WEB 147 ? // @ts-ignore only for web 148 window.location.hostname 149 : 'localhost' 150 151 const [__, channelOrVideo, clipOrId, id] = urlp.pathname.split('/') 152 153 if (channelOrVideo === 'videos') { 154 return { 155 type: 'twitch_video', 156 source: 'twitch', 157 playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&video=${clipOrId}&parent=${parent}`, 158 } 159 } else if (clipOrId === 'clip') { 160 return { 161 type: 'twitch_video', 162 source: 'twitch', 163 playerUri: `https://clips.twitch.tv/embed?volume=0.5&autoplay=true&clip=${id}&parent=${parent}`, 164 } 165 } else if (channelOrVideo) { 166 return { 167 type: 'twitch_video', 168 source: 'twitch', 169 playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&channel=${channelOrVideo}&parent=${parent}`, 170 } 171 } 172 } 173 174 // spotify 175 if (urlp.hostname === 'open.spotify.com') { 176 const [__, typeOrLocale, idOrType, id] = urlp.pathname.split('/') 177 178 if (idOrType) { 179 if (typeOrLocale === 'playlist' || idOrType === 'playlist') { 180 return { 181 type: 'spotify_playlist', 182 source: 'spotify', 183 playerUri: `https://open.spotify.com/embed/playlist/${ 184 id ?? idOrType 185 }`, 186 } 187 } 188 if (typeOrLocale === 'album' || idOrType === 'album') { 189 return { 190 type: 'spotify_album', 191 source: 'spotify', 192 playerUri: `https://open.spotify.com/embed/album/${id ?? idOrType}`, 193 } 194 } 195 if (typeOrLocale === 'track' || idOrType === 'track') { 196 return { 197 type: 'spotify_song', 198 source: 'spotify', 199 playerUri: `https://open.spotify.com/embed/track/${id ?? idOrType}`, 200 } 201 } 202 if (typeOrLocale === 'episode' || idOrType === 'episode') { 203 return { 204 type: 'spotify_song', 205 source: 'spotify', 206 playerUri: `https://open.spotify.com/embed/episode/${id ?? idOrType}`, 207 } 208 } 209 if (typeOrLocale === 'show' || idOrType === 'show') { 210 return { 211 type: 'spotify_song', 212 source: 'spotify', 213 playerUri: `https://open.spotify.com/embed/show/${id ?? idOrType}`, 214 } 215 } 216 } 217 } 218 219 // soundcloud 220 if ( 221 urlp.hostname === 'soundcloud.com' || 222 urlp.hostname === 'www.soundcloud.com' 223 ) { 224 const [__, user, trackOrSets, set] = urlp.pathname.split('/') 225 226 if (user && trackOrSets) { 227 if (trackOrSets === 'sets' && set) { 228 return { 229 type: 'soundcloud_set', 230 source: 'soundcloud', 231 playerUri: `https://w.soundcloud.com/player/?url=${url}&auto_play=true&visual=false&hide_related=true`, 232 } 233 } 234 235 return { 236 type: 'soundcloud_track', 237 source: 'soundcloud', 238 playerUri: `https://w.soundcloud.com/player/?url=${url}&auto_play=true&visual=false&hide_related=true`, 239 } 240 } 241 } 242 243 if ( 244 urlp.hostname === 'music.apple.com' || 245 urlp.hostname === 'music.apple.com' 246 ) { 247 // This should always have: locale, type (playlist or album), name, and id. We won't use spread since we want 248 // to check if the length is correct 249 const pathParams = urlp.pathname.split('/') 250 const type = pathParams[2] 251 const songId = urlp.searchParams.get('i') 252 253 if ( 254 pathParams.length === 5 && 255 (type === 'playlist' || type === 'album' || type === 'song') 256 ) { 257 // We want to append the songId to the end of the url if it exists 258 const embedUri = `https://embed.music.apple.com${urlp.pathname}${ 259 songId ? `?i=${songId}` : '' 260 }` 261 262 if (type === 'playlist') { 263 return { 264 type: 'apple_music_playlist', 265 source: 'appleMusic', 266 playerUri: embedUri, 267 } 268 } else if (type === 'album') { 269 if (songId) { 270 return { 271 type: 'apple_music_song', 272 source: 'appleMusic', 273 playerUri: embedUri, 274 } 275 } else { 276 return { 277 type: 'apple_music_album', 278 source: 'appleMusic', 279 playerUri: embedUri, 280 } 281 } 282 } else if (type === 'song') { 283 return { 284 type: 'apple_music_song', 285 source: 'appleMusic', 286 playerUri: embedUri, 287 } 288 } 289 } 290 } 291 292 if (urlp.hostname === 'vimeo.com' || urlp.hostname === 'www.vimeo.com') { 293 const [__, videoId] = urlp.pathname.split('/') 294 if (videoId) { 295 return { 296 type: 'vimeo_video', 297 source: 'vimeo', 298 playerUri: `https://player.vimeo.com/video/${videoId}?autoplay=1`, 299 } 300 } 301 } 302 303 if (urlp.hostname === 'giphy.com' || urlp.hostname === 'www.giphy.com') { 304 const [__, gifs, nameAndId] = urlp.pathname.split('/') 305 306 /* 307 * nameAndId is a string that consists of the name (dash separated) and the id of the gif (the last part of the name) 308 * We want to get the id of the gif, then direct to media.giphy.com/media/{id}/giphy.webp so we can 309 * use it in an <Image> component 310 */ 311 312 if (gifs === 'gifs' && nameAndId) { 313 const gifId = nameAndId.split('-').pop() 314 315 if (gifId) { 316 return { 317 type: 'giphy_gif', 318 source: 'giphy', 319 isGif: true, 320 hideDetails: true, 321 metaUri: `https://giphy.com/gifs/${gifId}`, 322 playerUri: `https://i.giphy.com/media/${gifId}/200.webp`, 323 } 324 } 325 } 326 } 327 328 // There are five possible hostnames that also can be giphy urls: media.giphy.com and media0-4.giphy.com 329 // These can include (presumably) a tracking id in the path name, so we have to check for that as well 330 if (giphyRegex.test(urlp.hostname)) { 331 // We can link directly to the gif, if its a proper link 332 const [__, media, trackingOrId, idOrFilename, filename] = 333 urlp.pathname.split('/') 334 335 if (media === 'media') { 336 if (idOrFilename && gifFilenameRegex.test(idOrFilename)) { 337 return { 338 type: 'giphy_gif', 339 source: 'giphy', 340 isGif: true, 341 hideDetails: true, 342 metaUri: `https://giphy.com/gifs/${trackingOrId}`, 343 playerUri: `https://i.giphy.com/media/${trackingOrId}/200.webp`, 344 } 345 } else if (filename && gifFilenameRegex.test(filename)) { 346 return { 347 type: 'giphy_gif', 348 source: 'giphy', 349 isGif: true, 350 hideDetails: true, 351 metaUri: `https://giphy.com/gifs/${idOrFilename}`, 352 playerUri: `https://i.giphy.com/media/${idOrFilename}/200.webp`, 353 } 354 } 355 } 356 } 357 358 // Finally, we should see if it is a link to i.giphy.com. These links don't necessarily end in .gif but can also 359 // be .webp 360 if (urlp.hostname === 'i.giphy.com' || urlp.hostname === 'www.i.giphy.com') { 361 const [__, mediaOrFilename, filename] = urlp.pathname.split('/') 362 363 if (mediaOrFilename === 'media' && filename) { 364 const gifId = filename.split('.')[0] 365 return { 366 type: 'giphy_gif', 367 source: 'giphy', 368 isGif: true, 369 hideDetails: true, 370 metaUri: `https://giphy.com/gifs/${gifId}`, 371 playerUri: `https://i.giphy.com/media/${gifId}/200.webp`, 372 } 373 } else if (mediaOrFilename) { 374 const gifId = mediaOrFilename.split('.')[0] 375 return { 376 type: 'giphy_gif', 377 source: 'giphy', 378 isGif: true, 379 hideDetails: true, 380 metaUri: `https://giphy.com/gifs/${gifId}`, 381 playerUri: `https://i.giphy.com/media/${ 382 mediaOrFilename.split('.')[0] 383 }/200.webp`, 384 } 385 } 386 } 387 388 const tenorGif = parseTenorGif(urlp) 389 if (tenorGif.success) { 390 const {playerUri, dimensions} = tenorGif 391 392 return { 393 type: 'tenor_gif', 394 source: 'tenor', 395 isGif: true, 396 hideDetails: true, 397 playerUri, 398 dimensions, 399 } 400 } 401 402 const klipyGif = parseKlipyGif(urlp) 403 if (klipyGif.success) { 404 const {playerUri, dimensions} = klipyGif 405 406 return { 407 type: 'klipy_gif', 408 source: 'klipy', 409 isGif: true, 410 hideDetails: true, 411 playerUri, 412 dimensions, 413 } 414 } 415 416 // this is a standard flickr path! we can use the embedder for albums and groups, so validate the path 417 if (urlp.hostname === 'www.flickr.com' || urlp.hostname === 'flickr.com') { 418 let i = urlp.pathname.length - 1 419 while (i > 0 && urlp.pathname.charAt(i) === '/') { 420 --i 421 } 422 423 const path_components = urlp.pathname.slice(1, i + 1).split('/') 424 if (path_components.length === 4) { 425 // discard username - it's not relevant 426 const [photos, __, albums, id] = path_components 427 if (photos === 'photos' && albums === 'albums') { 428 // this at least has the shape of a valid photo-album URL! 429 return { 430 type: 'flickr_album', 431 source: 'flickr', 432 playerUri: `https://embedr.flickr.com/photosets/${id}`, 433 } 434 } 435 } 436 437 if (path_components.length === 3) { 438 const [groups, id, pool] = path_components 439 if (groups === 'groups' && pool === 'pool') { 440 return { 441 type: 'flickr_album', 442 source: 'flickr', 443 playerUri: `https://embedr.flickr.com/groups/${id}`, 444 } 445 } 446 } 447 // not an album or a group pool, don't know what to do with this! 448 return undefined 449 } 450 451 // link shortened flickr path 452 if (urlp.hostname === 'flic.kr') { 453 const b58alph = '123456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ' 454 let [__, type, idBase58Enc] = urlp.pathname.split('/') 455 let id = 0n 456 for (const char of idBase58Enc) { 457 const nextIdx = b58alph.indexOf(char) 458 if (nextIdx >= 0) { 459 id = id * 58n + BigInt(nextIdx) 460 } else { 461 // not b58 encoded, ergo not a valid link to embed 462 return undefined 463 } 464 } 465 466 switch (type) { 467 case 'go': 468 const formattedGroupId = `${id}` 469 return { 470 type: 'flickr_album', 471 source: 'flickr', 472 playerUri: `https://embedr.flickr.com/groups/${formattedGroupId.slice( 473 0, 474 -2, 475 )}@N${formattedGroupId.slice(-2)}`, 476 } 477 case 's': 478 return { 479 type: 'flickr_album', 480 source: 'flickr', 481 playerUri: `https://embedr.flickr.com/photosets/${id}`, 482 } 483 default: 484 // we don't know what this is so we can't embed it 485 return undefined 486 } 487 } 488 489 const bandcampRegex = /^[a-z\d][a-z\d-]{2,}[a-z\d]\.bandcamp\.com$/i 490 491 if (bandcampRegex.test(urlp.hostname)) { 492 const pathComponents = urlp.pathname.split('/') 493 switch (pathComponents[1]) { 494 case 'album': 495 return { 496 type: 'bandcamp_album', 497 source: 'bandcamp', 498 playerUri: `https://bandcamp.com/EmbeddedPlayer/url=${encodeURIComponent( 499 urlp.href, 500 )}/size=large/bgcol=ffffff/linkcol=0687f5/minimal=true/transparent=true/`, 501 } 502 case 'track': 503 return { 504 type: 'bandcamp_track', 505 source: 'bandcamp', 506 playerUri: `https://bandcamp.com/EmbeddedPlayer/url=${encodeURIComponent( 507 urlp.href, 508 )}/size=large/bgcol=ffffff/linkcol=0687f5/minimal=true/transparent=true/`, 509 } 510 default: 511 return undefined 512 } 513 } 514 515 if (urlp.hostname === 'stream.place') { 516 if (isValidStreamPlaceUrl(urlp)) { 517 return { 518 type: 'streamplace_stream', 519 source: 'streamplace', 520 playerUri: `https://stream.place/embed${urlp.pathname}`, 521 } 522 } 523 } 524} 525 526function isValidStreamPlaceUrl(urlp: URL): boolean { 527 // stream.place URLs should have a path like /did:plc:xxx/... or /handle.bsky.social/... 528 const pathParts = urlp.pathname.split('/').filter(Boolean) 529 if (pathParts.length === 0) { 530 return false 531 } 532 533 // The first part of the path should be either a valid DID or a valid handle 534 const identifier = pathParts[0] 535 return isDid(identifier) || isValidHandle(identifier) 536} 537 538export function getPlayerAspect({ 539 type, 540 hasThumb, 541 width, 542}: { 543 type: EmbedPlayerParams['type'] 544 hasThumb: boolean 545 width: number 546}): {aspectRatio?: number; height?: number} { 547 if (!hasThumb) return {aspectRatio: 16 / 9} 548 549 switch (type) { 550 case 'youtube_video': 551 case 'twitch_video': 552 case 'vimeo_video': 553 return {aspectRatio: 16 / 9} 554 case 'youtube_short': 555 if (SCREEN_HEIGHT < 600) { 556 return {aspectRatio: (9 / 16) * 1.75} 557 } else { 558 return {aspectRatio: (9 / 16) * 1.5} 559 } 560 case 'spotify_album': 561 case 'apple_music_album': 562 case 'apple_music_playlist': 563 case 'spotify_playlist': 564 case 'soundcloud_set': 565 return {height: 380} 566 case 'spotify_song': 567 if (width <= 300) { 568 return {height: 155} 569 } 570 return {height: 232} 571 case 'soundcloud_track': 572 return {height: 165} 573 case 'apple_music_song': 574 return {height: 150} 575 case 'bandcamp_album': 576 case 'bandcamp_track': 577 return {aspectRatio: 1} 578 default: 579 return {aspectRatio: 16 / 9} 580 } 581} 582 583export function getGifDims( 584 originalHeight: number, 585 originalWidth: number, 586 viewWidth: number, 587) { 588 const scaledHeight = (originalHeight / originalWidth) * viewWidth 589 590 return { 591 height: scaledHeight > 250 ? 250 : scaledHeight, 592 width: (250 / scaledHeight) * viewWidth, 593 } 594} 595 596export function getGiphyMetaUri(url: URL) { 597 if (giphyRegex.test(url.hostname) || url.hostname === 'i.giphy.com') { 598 const params = parseEmbedPlayerFromUrl(url.toString()) 599 if (params && params.type === 'giphy_gif') { 600 return params.metaUri 601 } 602 } 603} 604 605export function parseTenorGif(urlp: URL): 606 | {success: false} 607 | { 608 success: true 609 playerUri: string 610 dimensions: {height: number; width: number} 611 } { 612 if (urlp.hostname !== 'media.tenor.com') { 613 return {success: false} 614 } 615 616 let [__, id, filename] = urlp.pathname.split('/') 617 618 if (!id || !filename) { 619 return {success: false} 620 } 621 622 if (!id.includes('AAAAC')) { 623 return {success: false} 624 } 625 626 const h = urlp.searchParams.get('hh') 627 const w = urlp.searchParams.get('ww') 628 629 if (!h || !w) { 630 return {success: false} 631 } 632 633 const dimensions = { 634 height: Number(h), 635 width: Number(w), 636 } 637 638 // Validate dimensions are valid positive numbers 639 if ( 640 isNaN(dimensions.height) || 641 isNaN(dimensions.width) || 642 dimensions.height <= 0 || 643 dimensions.width <= 0 644 ) { 645 return {success: false} 646 } 647 648 if (IS_WEB) { 649 if (IS_WEB_SAFARI) { 650 id = id.replace('AAAAC', 'AAAP1') 651 filename = filename.replace('.gif', '.mp4') 652 } else { 653 id = id.replace('AAAAC', 'AAAP3') 654 filename = filename.replace('.gif', '.webm') 655 } 656 } else { 657 id = id.replace('AAAAC', 'AAAAM') 658 } 659 660 return { 661 success: true, 662 playerUri: `https://t.gifs.bsky.app/${id}/${filename}`, 663 dimensions, 664 } 665} 666 667export function isTenorGifUri(url: URL | string) { 668 try { 669 return parseTenorGif(typeof url === 'string' ? new URL(url) : url).success 670 } catch { 671 // Invalid URL 672 return false 673 } 674} 675 676export function parseKlipyGif(urlp: URL): 677 | {success: false} 678 | { 679 success: true 680 playerUri: string 681 dimensions: {height: number; width: number} 682 } { 683 if (urlp.hostname !== 'static.klipy.com') { 684 return {success: false} 685 } 686 687 if (!urlp.pathname.startsWith('/ii/')) { 688 return {success: false} 689 } 690 691 const h = urlp.searchParams.get('hh') 692 const w = urlp.searchParams.get('ww') 693 694 if (!h || !w) { 695 return {success: false} 696 } 697 698 const dimensions = { 699 height: Number(h), 700 width: Number(w), 701 } 702 703 // Validate dimensions are valid positive numbers 704 if ( 705 isNaN(dimensions.height) || 706 isNaN(dimensions.width) || 707 dimensions.height <= 0 || 708 dimensions.width <= 0 709 ) { 710 return {success: false} 711 } 712 713 const playerUrl = new URL(urlp.href) 714 playerUrl.hostname = 'k.gifs.bsky.app' 715 716 // On web, swap the gif filename for a video format so the <video> 717 // element can play it. Klipy uses different filename slugs per 718 // format (unlike Tenor's ID-based scheme), so the slugs are 719 // embedded as query params at composition time by resolveGif(). 720 if (IS_WEB) { 721 const webmSlug = playerUrl.searchParams.get('webm') 722 const mp4Slug = playerUrl.searchParams.get('mp4') 723 const slug = IS_WEB_SAFARI ? mp4Slug : webmSlug 724 const ext = IS_WEB_SAFARI ? 'mp4' : 'webm' 725 726 // Without a slug we can't produce a playable video URL on web, 727 // so fall back to the link card instead of returning a broken player. 728 if (!slug) { 729 return {success: false} 730 } 731 732 const parts = playerUrl.pathname.split('/') 733 parts[parts.length - 1] = `${slug}.${ext}` 734 playerUrl.pathname = parts.join('/') 735 } 736 737 // Strip all metadata params — only the path matters for the CDN 738 playerUrl.searchParams.delete('hh') 739 playerUrl.searchParams.delete('ww') 740 playerUrl.searchParams.delete('mp4') 741 playerUrl.searchParams.delete('webm') 742 743 return { 744 success: true, 745 playerUri: playerUrl.href, 746 dimensions, 747 } 748} 749 750export function isKlipyGifUri(url: URL | string) { 751 try { 752 return parseKlipyGif(typeof url === 'string' ? new URL(url) : url).success 753 } catch { 754 // Invalid URL 755 return false 756 } 757} 758 759export function isGifEmbed(url: URL | string) { 760 return isTenorGifUri(url) || isKlipyGifUri(url) 761}