Bluesky app fork with some witchin' additions 馃挮
witchsky.app
bluesky
fork
client
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}