diff options
Diffstat (limited to 'src/lib')
-rw-r--r-- | src/lib/analytics/types.ts | 1 | ||||
-rw-r--r-- | src/lib/api/feed/merge.ts | 2 | ||||
-rw-r--r-- | src/lib/constants.ts | 9 | ||||
-rw-r--r-- | src/lib/link-meta/link-meta.ts | 8 | ||||
-rw-r--r-- | src/lib/media/manip.web.ts | 10 | ||||
-rw-r--r-- | src/lib/media/picker.shared.ts | 7 | ||||
-rw-r--r-- | src/lib/routes/types.ts | 1 | ||||
-rw-r--r-- | src/lib/strings/embed-player.ts | 345 | ||||
-rw-r--r-- | src/lib/styles.ts | 1 | ||||
-rw-r--r-- | src/lib/themes.ts | 2 |
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, |