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