Something went wrong. Try again.
Bluesky app fork with some witchin' additions 💫 forked from jollywhoppers.com/witchsky.app
Something went wrong. Try again.
17 kB · 624 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625import {Dimensions} from 'react-native'import {isDid} from '@atproto/api'
import {isValidHandle} from '#/lib/strings/handles'import {IS_WEB, IS_WEB_SAFARI} from '#/env'
const {height: SCREEN_HEIGHT} = Dimensions.get('window')
const IFRAME_HOST = IS_WEB ? // @ts-ignore only for web window.location.host === 'localhost:8100' ? 'http://localhost:8100' : 'https://witchsky.app' : __DEV__ && !process.env.JEST_WORKER_ID ? 'http://localhost:8100' : 'https://bsky.app'
export const embedPlayerSources = [ 'youtube', 'youtubeShorts', 'twitch', 'spotify', 'soundcloud', 'appleMusic', 'vimeo', 'giphy', 'tenor', 'flickr', 'streamplace',] as const
export type EmbedPlayerSource = (typeof embedPlayerSources)[number]
export type EmbedPlayerType = | 'youtube_video' | 'youtube_short' | 'twitch_video' | 'spotify_album' | 'spotify_playlist' | 'spotify_song' | 'soundcloud_track' | 'soundcloud_set' | 'apple_music_playlist' | 'apple_music_album' | 'apple_music_song' | 'vimeo_video' | 'giphy_gif' | 'tenor_gif' | 'flickr_album' | 'streamplace_stream'
export const externalEmbedLabels: Record<EmbedPlayerSource, string> = { youtube: 'YouTube', youtubeShorts: 'YouTube Shorts', vimeo: 'Vimeo', twitch: 'Twitch', giphy: 'GIPHY', tenor: 'Tenor', spotify: 'Spotify', appleMusic: 'Apple Music', soundcloud: 'SoundCloud', flickr: 'Flickr', streamplace: 'Streamplace',}
export interface EmbedPlayerParams { type: EmbedPlayerType playerUri: string isGif?: boolean source: EmbedPlayerSource metaUri?: string hideDetails?: boolean dimensions?: { height: number width: number }}
const giphyRegex = /media(?:[0-4]\.giphy\.com|\.giphy\.com)/iconst gifFilenameRegex = /^(\S+)\.(webp|gif|mp4)$/i
export function parseEmbedPlayerFromUrl( url: string,): EmbedPlayerParams | undefined { let urlp try { urlp = new URL(url) } catch (e) { return undefined }
// youtube if (urlp.hostname === 'youtu.be') { const videoId = urlp.pathname.split('/')[1] const t = urlp.searchParams.get('t') ?? '0' const seek = encodeURIComponent(t.replace(/s$/, ''))
if (videoId) { return { type: 'youtube_video', source: 'youtube', playerUri: `${IFRAME_HOST}/iframe/youtube.html?videoId=${videoId}&start=${seek}`, } } } if ( urlp.hostname === 'www.youtube.com' || urlp.hostname === 'youtube.com' || urlp.hostname === 'm.youtube.com' || urlp.hostname === 'music.youtube.com' ) { const [__, page, shortOrLiveVideoId] = urlp.pathname.split('/')
const isShorts = page === 'shorts' const isLive = page === 'live' const videoId = isShorts || isLive ? shortOrLiveVideoId : (urlp.searchParams.get('v') as string) const t = urlp.searchParams.get('t') ?? '0' const seek = encodeURIComponent(t.replace(/s$/, ''))
if (videoId) { return { type: isShorts ? 'youtube_short' : 'youtube_video', source: isShorts ? 'youtubeShorts' : 'youtube', hideDetails: isShorts ? true : undefined, playerUri: `${IFRAME_HOST}/iframe/youtube.html?videoId=${videoId}&start=${seek}`, } } }
// twitch if ( urlp.hostname === 'twitch.tv' || urlp.hostname === 'www.twitch.tv' || urlp.hostname === 'm.twitch.tv' ) { const parent = IS_WEB ? // @ts-ignore only for web window.location.hostname : 'localhost'
const [__, channelOrVideo, clipOrId, id] = urlp.pathname.split('/')
if (channelOrVideo === 'videos') { return { type: 'twitch_video', source: 'twitch', playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&video=${clipOrId}&parent=${parent}`, } } else if (clipOrId === 'clip') { return { type: 'twitch_video', source: 'twitch', playerUri: `https://clips.twitch.tv/embed?volume=0.5&autoplay=true&clip=${id}&parent=${parent}`, } } else if (channelOrVideo) { return { type: 'twitch_video', source: 'twitch', playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&channel=${channelOrVideo}&parent=${parent}`, } } }
// spotify if (urlp.hostname === 'open.spotify.com') { const [__, typeOrLocale, idOrType, id] = urlp.pathname.split('/')
if (idOrType) { if (typeOrLocale === 'playlist' || idOrType === 'playlist') { return { type: 'spotify_playlist', source: 'spotify', playerUri: `https://open.spotify.com/embed/playlist/${ id ?? idOrType }`, } } if (typeOrLocale === 'album' || idOrType === 'album') { return { type: 'spotify_album', source: 'spotify', playerUri: `https://open.spotify.com/embed/album/${id ?? idOrType}`, } } if (typeOrLocale === 'track' || idOrType === 'track') { return { type: 'spotify_song', source: 'spotify', playerUri: `https://open.spotify.com/embed/track/${id ?? idOrType}`, } } if (typeOrLocale === 'episode' || idOrType === 'episode') { return { type: 'spotify_song', source: 'spotify', playerUri: `https://open.spotify.com/embed/episode/${id ?? idOrType}`, } } if (typeOrLocale === 'show' || idOrType === 'show') { return { type: 'spotify_song', source: 'spotify', playerUri: `https://open.spotify.com/embed/show/${id ?? idOrType}`, } } } }
// soundcloud if ( urlp.hostname === 'soundcloud.com' || urlp.hostname === 'www.soundcloud.com' ) { const [__, user, trackOrSets, set] = urlp.pathname.split('/')
if (user && trackOrSets) { if (trackOrSets === 'sets' && set) { return { type: 'soundcloud_set', source: 'soundcloud', playerUri: `https://w.soundcloud.com/player/?url=${url}&auto_play=true&visual=false&hide_related=true`, } }
return { type: 'soundcloud_track', source: 'soundcloud', playerUri: `https://w.soundcloud.com/player/?url=${url}&auto_play=true&visual=false&hide_related=true`, } } }
if ( urlp.hostname === 'music.apple.com' || urlp.hostname === 'music.apple.com' ) { // This should always have: locale, type (playlist or album), name, and id. We won't use spread since we want // to check if the length is correct const pathParams = urlp.pathname.split('/') const type = pathParams[2] const songId = urlp.searchParams.get('i')
if ( pathParams.length === 5 && (type === 'playlist' || type === 'album' || type === 'song') ) { // We want to append the songId to the end of the url if it exists const embedUri = `https://embed.music.apple.com${urlp.pathname}${ songId ? `?i=${songId}` : '' }`
if (type === 'playlist') { return { type: 'apple_music_playlist', source: 'appleMusic', playerUri: embedUri, } } else if (type === 'album') { if (songId) { return { type: 'apple_music_song', source: 'appleMusic', playerUri: embedUri, } } else { return { type: 'apple_music_album', source: 'appleMusic', playerUri: embedUri, } } } else if (type === 'song') { return { type: 'apple_music_song', source: 'appleMusic', playerUri: embedUri, } } } }
if (urlp.hostname === 'vimeo.com' || urlp.hostname === 'www.vimeo.com') { const [__, videoId] = urlp.pathname.split('/') if (videoId) { return { type: 'vimeo_video', source: 'vimeo', playerUri: `https://player.vimeo.com/video/${videoId}?autoplay=1`, } } }
if (urlp.hostname === 'giphy.com' || urlp.hostname === 'www.giphy.com') { const [__, gifs, nameAndId] = urlp.pathname.split('/')
/* * nameAndId is a string that consists of the name (dash separated) and the id of the gif (the last part of the name) * We want to get the id of the gif, then direct to media.giphy.com/media/{id}/giphy.webp so we can * use it in an <Image> component */
if (gifs === 'gifs' && nameAndId) { const gifId = nameAndId.split('-').pop()
if (gifId) { return { type: 'giphy_gif', source: 'giphy', isGif: true, hideDetails: true, metaUri: `https://giphy.com/gifs/${gifId}`, playerUri: `https://i.giphy.com/media/${gifId}/200.webp`, } } } }
// There are five possible hostnames that also can be giphy urls: media.giphy.com and media0-4.giphy.com // These can include (presumably) a tracking id in the path name, so we have to check for that as well if (giphyRegex.test(urlp.hostname)) { // We can link directly to the gif, if its a proper link const [__, media, trackingOrId, idOrFilename, filename] = urlp.pathname.split('/')
if (media === 'media') { if (idOrFilename && gifFilenameRegex.test(idOrFilename)) { return { type: 'giphy_gif', source: 'giphy', isGif: true, hideDetails: true, metaUri: `https://giphy.com/gifs/${trackingOrId}`, playerUri: `https://i.giphy.com/media/${trackingOrId}/200.webp`, } } else if (filename && gifFilenameRegex.test(filename)) { return { type: 'giphy_gif', source: 'giphy', isGif: true, hideDetails: true, metaUri: `https://giphy.com/gifs/${idOrFilename}`, playerUri: `https://i.giphy.com/media/${idOrFilename}/200.webp`, } } } }
// Finally, we should see if it is a link to i.giphy.com. These links don't necessarily end in .gif but can also // be .webp if (urlp.hostname === 'i.giphy.com' || urlp.hostname === 'www.i.giphy.com') { const [__, mediaOrFilename, filename] = urlp.pathname.split('/')
if (mediaOrFilename === 'media' && filename) { const gifId = filename.split('.')[0] return { type: 'giphy_gif', source: 'giphy', isGif: true, hideDetails: true, metaUri: `https://giphy.com/gifs/${gifId}`, playerUri: `https://i.giphy.com/media/${gifId}/200.webp`, } } else if (mediaOrFilename) { const gifId = mediaOrFilename.split('.')[0] return { type: 'giphy_gif', source: 'giphy', isGif: true, hideDetails: true, metaUri: `https://giphy.com/gifs/${gifId}`, playerUri: `https://i.giphy.com/media/${ mediaOrFilename.split('.')[0] }/200.webp`, } } }
const tenorGif = parseTenorGif(urlp) if (tenorGif.success) { const {playerUri, dimensions} = tenorGif
return { type: 'tenor_gif', source: 'tenor', isGif: true, hideDetails: true, playerUri, dimensions, } }
// this is a standard flickr path! we can use the embedder for albums and groups, so validate the path if (urlp.hostname === 'www.flickr.com' || urlp.hostname === 'flickr.com') { let i = urlp.pathname.length - 1 while (i > 0 && urlp.pathname.charAt(i) === '/') { --i }
const path_components = urlp.pathname.slice(1, i + 1).split('/') if (path_components.length === 4) { // discard username - it's not relevant const [photos, __, albums, id] = path_components if (photos === 'photos' && albums === 'albums') { // this at least has the shape of a valid photo-album URL! return { type: 'flickr_album', source: 'flickr', playerUri: `https://embedr.flickr.com/photosets/${id}`, } } }
if (path_components.length === 3) { const [groups, id, pool] = path_components if (groups === 'groups' && pool === 'pool') { return { type: 'flickr_album', source: 'flickr', playerUri: `https://embedr.flickr.com/groups/${id}`, } } } // not an album or a group pool, don't know what to do with this! return undefined }
// link shortened flickr path if (urlp.hostname === 'flic.kr') { const b58alph = '123456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ' let [__, type, idBase58Enc] = urlp.pathname.split('/') let id = 0n for (const char of idBase58Enc) { const nextIdx = b58alph.indexOf(char) if (nextIdx >= 0) { id = id * 58n + BigInt(nextIdx) } else { // not b58 encoded, ergo not a valid link to embed return undefined } }
switch (type) { case 'go': const formattedGroupId = `${id}` return { type: 'flickr_album', source: 'flickr', playerUri: `https://embedr.flickr.com/groups/${formattedGroupId.slice( 0, -2, )}@N${formattedGroupId.slice(-2)}`, } case 's': return { type: 'flickr_album', source: 'flickr', playerUri: `https://embedr.flickr.com/photosets/${id}`, } default: // we don't know what this is so we can't embed it return undefined } }
if (urlp.hostname === 'stream.place') { if (isValidStreamPlaceUrl(urlp)) { return { type: 'streamplace_stream', source: 'streamplace', playerUri: `https://stream.place/embed${urlp.pathname}`, } } }}
function isValidStreamPlaceUrl(urlp: URL): boolean { // stream.place URLs should have a path like /did:plc:xxx/... or /handle.bsky.social/... const pathParts = urlp.pathname.split('/').filter(Boolean) if (pathParts.length === 0) { return false }
// The first part of the path should be either a valid DID or a valid handle const identifier = pathParts[0] return isDid(identifier) || isValidHandle(identifier)}
export function getPlayerAspect({ type, hasThumb, width,}: { type: EmbedPlayerParams['type'] hasThumb: boolean width: number}): {aspectRatio?: number; height?: number} { if (!hasThumb) return {aspectRatio: 16 / 9}
switch (type) { case 'youtube_video': case 'twitch_video': case 'vimeo_video': return {aspectRatio: 16 / 9} case 'youtube_short': if (SCREEN_HEIGHT < 600) { return {aspectRatio: (9 / 16) * 1.75} } else { return {aspectRatio: (9 / 16) * 1.5} } case 'spotify_album': case 'apple_music_album': case 'apple_music_playlist': case 'spotify_playlist': case 'soundcloud_set': return {height: 380} case 'spotify_song': if (width <= 300) { return {height: 155} } return {height: 232} case 'soundcloud_track': return {height: 165} case 'apple_music_song': return {height: 150} default: return {aspectRatio: 16 / 9} }}
export function getGifDims( originalHeight: number, originalWidth: number, viewWidth: number,) { const scaledHeight = (originalHeight / originalWidth) * viewWidth
return { height: scaledHeight > 250 ? 250 : scaledHeight, width: (250 / scaledHeight) * viewWidth, }}
export function getGiphyMetaUri(url: URL) { if (giphyRegex.test(url.hostname) || url.hostname === 'i.giphy.com') { const params = parseEmbedPlayerFromUrl(url.toString()) if (params && params.type === 'giphy_gif') { return params.metaUri } }}
export function parseTenorGif(urlp: URL): | {success: false} | { success: true playerUri: string dimensions: {height: number; width: number} } { if (urlp.hostname !== 'media.tenor.com') { return {success: false} }
let [__, id, filename] = urlp.pathname.split('/')
if (!id || !filename) { return {success: false} }
if (!id.includes('AAAAC')) { return {success: false} }
const h = urlp.searchParams.get('hh') const w = urlp.searchParams.get('ww')
if (!h || !w) { return {success: false} }
const dimensions = { height: Number(h), width: Number(w), }
// Validate dimensions are valid positive numbers if ( isNaN(dimensions.height) || isNaN(dimensions.width) || dimensions.height <= 0 || dimensions.width <= 0 ) { return {success: false} }
if (IS_WEB) { if (IS_WEB_SAFARI) { id = id.replace('AAAAC', 'AAAP1') filename = filename.replace('.gif', '.mp4') } else { id = id.replace('AAAAC', 'AAAP3') filename = filename.replace('.gif', '.webm') } } else { id = id.replace('AAAAC', 'AAAAM') }
return { success: true, playerUri: `https://t.gifs.bsky.app/${id}/${filename}`, dimensions, }}
export function isTenorGifUri(url: URL | string) { try { return parseTenorGif(typeof url === 'string' ? new URL(url) : url).success } catch { // Invalid URL return false }}