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

Configure Feed

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

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