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