Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

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

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