about summary refs log tree commit diff
path: root/src/lib
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib')
-rw-r--r--src/lib/analytics/types.ts1
-rw-r--r--src/lib/api/feed/merge.ts2
-rw-r--r--src/lib/constants.ts9
-rw-r--r--src/lib/link-meta/link-meta.ts8
-rw-r--r--src/lib/media/manip.web.ts10
-rw-r--r--src/lib/media/picker.shared.ts7
-rw-r--r--src/lib/routes/types.ts1
-rw-r--r--src/lib/strings/embed-player.ts345
-rw-r--r--src/lib/styles.ts1
-rw-r--r--src/lib/themes.ts2
10 files changed, 327 insertions, 59 deletions
diff --git a/src/lib/analytics/types.ts b/src/lib/analytics/types.ts
index 5a24c360a..c84f7979a 100644
--- a/src/lib/analytics/types.ts
+++ b/src/lib/analytics/types.ts
@@ -147,6 +147,7 @@ interface ScreenPropertiesMap {
   Settings: {}
   AppPasswords: {}
   Moderation: {}
+  PreferencesExternalEmbeds: {}
   BlockedAccounts: {}
   MutedAccounts: {}
   SavedFeeds: {}
diff --git a/src/lib/api/feed/merge.ts b/src/lib/api/feed/merge.ts
index a4391afb2..2314e2b95 100644
--- a/src/lib/api/feed/merge.ts
+++ b/src/lib/api/feed/merge.ts
@@ -98,7 +98,7 @@ export class MergeFeedAPI implements FeedAPI {
     }
 
     return {
-      cursor: posts.length ? String(this.itemCursor) : undefined,
+      cursor: String(this.itemCursor),
       feed: posts,
     }
   }
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index aa5983be7..aec8338d0 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -41,7 +41,7 @@ export function IS_LOCAL_DEV(url: string) {
 }
 
 export function IS_STAGING(url: string) {
-  return !IS_LOCAL_DEV(url) && !IS_PROD(url)
+  return url.startsWith('https://staging.bsky.dev')
 }
 
 export function IS_PROD(url: string) {
@@ -51,7 +51,8 @@ export function IS_PROD(url: string) {
   // -prf
   return (
     url.startsWith('https://bsky.social') ||
-    url.startsWith('https://api.bsky.app')
+    url.startsWith('https://api.bsky.app') ||
+    /bsky\.network\/?$/.test(url)
   )
 }
 
@@ -116,8 +117,8 @@ export async function DEFAULT_FEEDS(
   } else {
     // production
     return {
-      pinned: [],
-      saved: [],
+      pinned: [PROD_DEFAULT_FEED('whats-hot')],
+      saved: [PROD_DEFAULT_FEED('whats-hot')],
     }
   }
 }
diff --git a/src/lib/link-meta/link-meta.ts b/src/lib/link-meta/link-meta.ts
index c17dee51f..c7c8d4130 100644
--- a/src/lib/link-meta/link-meta.ts
+++ b/src/lib/link-meta/link-meta.ts
@@ -2,6 +2,7 @@ import {BskyAgent} from '@atproto/api'
 import {isBskyAppUrl} from '../strings/url-helpers'
 import {extractBskyMeta} from './bsky'
 import {LINK_META_PROXY} from 'lib/constants'
+import {getGiphyMetaUri} from 'lib/strings/embed-player'
 
 export enum LikelyType {
   HTML,
@@ -34,6 +35,13 @@ export async function getLinkMeta(
   let urlp
   try {
     urlp = new URL(url)
+
+    // Get Giphy meta uri if this is any form of giphy link
+    const giphyMetaUri = getGiphyMetaUri(urlp)
+    if (giphyMetaUri) {
+      url = giphyMetaUri
+      urlp = new URL(url)
+    }
   } catch (e) {
     return {
       error: 'Invalid URL',
diff --git a/src/lib/media/manip.web.ts b/src/lib/media/manip.web.ts
index 914b05d2e..bdf6836a1 100644
--- a/src/lib/media/manip.web.ts
+++ b/src/lib/media/manip.web.ts
@@ -117,9 +117,6 @@ function createResizedImage(
         return reject(new Error('Failed to resize image'))
       }
 
-      canvas.width = width
-      canvas.height = height
-
       let scale = 1
       if (mode === 'cover') {
         scale = img.width < img.height ? width / img.width : height / img.height
@@ -128,10 +125,11 @@ function createResizedImage(
       }
       let w = img.width * scale
       let h = img.height * scale
-      let x = (width - w) / 2
-      let y = (height - h) / 2
 
-      ctx.drawImage(img, x, y, w, h)
+      canvas.width = w
+      canvas.height = h
+
+      ctx.drawImage(img, 0, 0, w, h)
       resolve(canvas.toDataURL('image/jpeg', quality))
     })
     img.src = dataUri
diff --git a/src/lib/media/picker.shared.ts b/src/lib/media/picker.shared.ts
index 00b09c6b8..8bade34e2 100644
--- a/src/lib/media/picker.shared.ts
+++ b/src/lib/media/picker.shared.ts
@@ -4,6 +4,7 @@ import {
   MediaTypeOptions,
 } from 'expo-image-picker'
 import {getDataUriSize} from './util'
+import * as Toast from 'view/com/util/Toast'
 
 export async function openPicker(opts?: ImagePickerOptions) {
   const response = await launchImageLibraryAsync({
@@ -13,7 +14,11 @@ export async function openPicker(opts?: ImagePickerOptions) {
     ...opts,
   })
 
-  return (response.assets ?? []).map(image => ({
+  if (response.assets && response.assets.length > 4) {
+    Toast.show('You may only select up to 4 images')
+  }
+
+  return (response.assets ?? []).slice(0, 4).map(image => ({
     mime: 'image/jpeg',
     height: image.height,
     width: image.width,
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index c157c0ab3..90ae75830 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -32,6 +32,7 @@ export type CommonNavigatorParams = {
   SavedFeeds: undefined
   PreferencesHomeFeed: undefined
   PreferencesThreads: undefined
+  PreferencesExternalEmbeds: undefined
 }
 
 export type BottomTabNavigatorParams = CommonNavigatorParams & {
diff --git a/src/lib/strings/embed-player.ts b/src/lib/strings/embed-player.ts
index ec996dfa5..0f97eb080 100644
--- a/src/lib/strings/embed-player.ts
+++ b/src/lib/strings/embed-player.ts
@@ -1,17 +1,59 @@
-import {Platform} from 'react-native'
-
-export type EmbedPlayerParams =
-  | {type: 'youtube_video'; videoId: string; playerUri: string}
-  | {type: 'twitch_live'; channelId: string; playerUri: string}
-  | {type: 'spotify_album'; albumId: string; playerUri: string}
-  | {
-      type: 'spotify_playlist'
-      playlistId: string
-      playerUri: string
-    }
-  | {type: 'spotify_song'; songId: string; playerUri: string}
-  | {type: 'soundcloud_track'; user: string; track: string; playerUri: string}
-  | {type: 'soundcloud_set'; user: string; set: string; playerUri: string}
+import {Dimensions, Platform} from 'react-native'
+const {height: SCREEN_HEIGHT} = Dimensions.get('window')
+
+export const embedPlayerSources = [
+  'youtube',
+  'youtubeShorts',
+  'twitch',
+  'spotify',
+  'soundcloud',
+  'appleMusic',
+  'vimeo',
+  'giphy',
+  'tenor',
+] 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'
+
+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',
+}
+
+export interface EmbedPlayerParams {
+  type: EmbedPlayerType
+  playerUri: string
+  isGif?: boolean
+  source: EmbedPlayerSource
+  metaUri?: string
+  hideDetails?: boolean
+}
+
+const giphyRegex = /media(?:[0-4]\.giphy\.com|\.giphy\.com)/i
+const gifFilenameRegex = /^(\S+)\.(webp|gif|mp4)$/i
 
 export function parseEmbedPlayerFromUrl(
   url: string,
@@ -29,63 +71,88 @@ export function parseEmbedPlayerFromUrl(
     if (videoId) {
       return {
         type: 'youtube_video',
-        videoId,
-        playerUri: `https://www.youtube.com/embed/${videoId}?autoplay=1`,
+        source: 'youtube',
+        playerUri: `https://www.youtube.com/embed/${videoId}?autoplay=1&playsinline=1`,
       }
     }
   }
-  if (urlp.hostname === 'www.youtube.com' || urlp.hostname === 'youtube.com') {
+  if (
+    urlp.hostname === 'www.youtube.com' ||
+    urlp.hostname === 'youtube.com' ||
+    urlp.hostname === 'm.youtube.com'
+  ) {
     const [_, page, shortVideoId] = urlp.pathname.split('/')
     const videoId =
       page === 'shorts' ? shortVideoId : (urlp.searchParams.get('v') as string)
 
     if (videoId) {
       return {
-        type: 'youtube_video',
-        videoId,
-        playerUri: `https://www.youtube.com/embed/${videoId}?autoplay=1`,
+        type: page === 'shorts' ? 'youtube_short' : 'youtube_video',
+        source: page === 'shorts' ? 'youtubeShorts' : 'youtube',
+        hideDetails: page === 'shorts' ? true : undefined,
+        playerUri: `https://www.youtube.com/embed/${videoId}?autoplay=1&playsinline=1`,
       }
     }
   }
 
   // twitch
-  if (urlp.hostname === 'twitch.tv' || urlp.hostname === 'www.twitch.tv') {
+  if (
+    urlp.hostname === 'twitch.tv' ||
+    urlp.hostname === 'www.twitch.tv' ||
+    urlp.hostname === 'm.twitch.tv'
+  ) {
     const parent =
       Platform.OS === 'web' ? window.location.hostname : 'localhost'
 
-    const parts = urlp.pathname.split('/')
-    if (parts.length === 2 && parts[1]) {
+    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_live',
-        channelId: parts[1],
-        playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&channel=${parts[1]}&parent=${parent}`,
+        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 [_, type, id] = urlp.pathname.split('/')
-    if (type && id) {
-      if (type === 'playlist') {
+    const [_, typeOrLocale, idOrType, id] = urlp.pathname.split('/')
+
+    if (idOrType) {
+      if (typeOrLocale === 'playlist' || idOrType === 'playlist') {
         return {
           type: 'spotify_playlist',
-          playlistId: id,
-          playerUri: `https://open.spotify.com/embed/playlist/${id}`,
+          source: 'spotify',
+          playerUri: `https://open.spotify.com/embed/playlist/${
+            id ?? idOrType
+          }`,
         }
       }
-      if (type === 'album') {
+      if (typeOrLocale === 'album' || idOrType === 'album') {
         return {
           type: 'spotify_album',
-          albumId: id,
-          playerUri: `https://open.spotify.com/embed/album/${id}`,
+          source: 'spotify',
+          playerUri: `https://open.spotify.com/embed/album/${id ?? idOrType}`,
         }
       }
-      if (type === 'track') {
+      if (typeOrLocale === 'track' || idOrType === 'track') {
         return {
           type: 'spotify_song',
-          songId: id,
-          playerUri: `https://open.spotify.com/embed/track/${id}`,
+          source: 'spotify',
+          playerUri: `https://open.spotify.com/embed/track/${id ?? idOrType}`,
         }
       }
     }
@@ -102,20 +169,173 @@ export function parseEmbedPlayerFromUrl(
       if (trackOrSets === 'sets' && set) {
         return {
           type: 'soundcloud_set',
-          user,
-          set: set,
+          source: 'soundcloud',
           playerUri: `https://w.soundcloud.com/player/?url=${url}&auto_play=true&visual=false&hide_related=true`,
         }
       }
 
       return {
         type: 'soundcloud_track',
-        user,
-        track: trackOrSets,
+        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')) {
+      // We want to append the songId to the end of the url if it exists
+      const embedUri = `https://embed.music.apple.com${urlp.pathname}${
+        urlp.search ? '?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,
+          }
+        }
+      }
+    }
+  }
+
+  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}/giphy.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}/giphy.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}/giphy.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}/giphy.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]
+        }/giphy.webp`,
+      }
+    }
+  }
+
+  if (urlp.hostname === 'tenor.com' || urlp.hostname === 'www.tenor.com') {
+    const [_, pathOrIntl, pathOrFilename, intlFilename] =
+      urlp.pathname.split('/')
+    const isIntl = pathOrFilename === 'view'
+    const filename = isIntl ? intlFilename : pathOrFilename
+
+    if ((pathOrIntl === 'view' || pathOrFilename === 'view') && filename) {
+      const includesExt = filename.split('.').pop() === 'gif'
+
+      return {
+        type: 'tenor_gif',
+        source: 'tenor',
+        isGif: true,
+        hideDetails: true,
+        playerUri: `${url}${!includesExt ? '.gif' : ''}`,
+      }
+    }
+  }
 }
 
 export function getPlayerHeight({
@@ -131,22 +351,53 @@ export function getPlayerHeight({
 
   switch (type) {
     case 'youtube_video':
-    case 'twitch_live':
+    case 'twitch_video':
+    case 'vimeo_video':
       return (width / 16) * 9
+    case 'youtube_short':
+      if (SCREEN_HEIGHT < 600) {
+        return ((width / 9) * 16) / 1.75
+      } else {
+        return ((width / 9) * 16) / 1.5
+      }
     case 'spotify_album':
-      return 380
+    case 'apple_music_album':
+    case 'apple_music_playlist':
     case 'spotify_playlist':
-      return 360
+    case 'soundcloud_set':
+      return 380
     case 'spotify_song':
       if (width <= 300) {
-        return 180
+        return 155
       }
       return 232
     case 'soundcloud_track':
       return 165
-    case 'soundcloud_set':
-      return 360
+    case 'apple_music_song':
+      return 150
     default:
       return width
   }
 }
+
+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
+    }
+  }
+}
diff --git a/src/lib/styles.ts b/src/lib/styles.ts
index 152e60eb0..5a10fea86 100644
--- a/src/lib/styles.ts
+++ b/src/lib/styles.ts
@@ -167,6 +167,7 @@ export const s = StyleSheet.create({
   flexGrow1: {flexGrow: 1},
   alignCenter: {alignItems: 'center'},
   alignBaseline: {alignItems: 'baseline'},
+  justifyCenter: {justifyContent: 'center'},
 
   // position
   absolute: {position: 'absolute'},
diff --git a/src/lib/themes.ts b/src/lib/themes.ts
index b778d5b30..ad7574db6 100644
--- a/src/lib/themes.ts
+++ b/src/lib/themes.ts
@@ -25,6 +25,7 @@ export const defaultTheme: Theme = {
       postCtrl: '#71768A',
       brandText: '#0066FF',
       emptyStateIcon: '#B6B6C9',
+      borderLinkHover: '#cac1c1',
     },
     primary: {
       background: colors.blue3,
@@ -310,6 +311,7 @@ export const darkTheme: Theme = {
       postCtrl: '#707489',
       brandText: '#0085ff',
       emptyStateIcon: colors.gray4,
+      borderLinkHover: colors.gray5,
     },
     primary: {
       ...defaultTheme.palette.primary,