Additional embed sources and external-media consent controls (#2424)
* add apple music embed * add vimeo embed * add logic for tenor and giphy embeds * keep it simple, use playerUri for images too * add gif embed player * lint, fix tests * remove links that can't produce a thumb * Revert "remove links that can't produce a thumb" This reverts commit 985b92b4e622db936bb0c79fdf324099b9c8fcd8. * Revert "Revert "remove links that can't produce a thumb"" This reverts commit 4895ded8b5120c4fc52b43ae85c9a01ea0b1a733. * Revert "Revert "Revert "remove links that can't produce a thumb""" This reverts commit 36d04b517ba5139e1639f2eda28d7f9aaa2dbfb6. * properly obtain giphy metadata regardless of used url * test fixes * adjust gif player * add all twitch embed types * support m.youtube links * few logic adjustments * adjust spotify player height * prefetch gif before showing * use memory-disk cache policy on gifs * use `disk` cachePolicy on ios - can't start/stop animation * support pause/play on web * onLoad fix * remove extra pressable, add accessibility, fix scale issues * improve size of embed * add settings * fix(?) settings * add source to embed player params * update tests * better naming and settings options * consent modal * fix test id * why is webstorm adding .tsx * web modal * simplify types * adjust snap points * remove unnecessary yt embed library. just use the webview always * remove now useless WebGifStill 😭 * more type cleanup * more type cleanup * combine parse and prefs check in one memo * improve dimensions of youtube shorts * oops didn't commit the test 🫥 * add shorts as separate embed type * fix up schema * shorts modal * hide gif details * support localized spotify embeds * more cleanup * improve look and accessibility of gif embeds * Update routing for the external embeds settings page * Update and simplify the external embed preferences screen * Update copy in embedconsent modal and add 'allow all' button --------- Co-authored-by: Hailey <me@haileyok.com>
This commit is contained in:
parent
db62f27241
commit
0dae24e78f
24 changed files with 1240 additions and 131 deletions
|
@ -147,6 +147,7 @@ interface ScreenPropertiesMap {
|
|||
Settings: {}
|
||||
AppPasswords: {}
|
||||
Moderation: {}
|
||||
PreferencesExternalEmbeds: {}
|
||||
BlockedAccounts: {}
|
||||
MutedAccounts: {}
|
||||
SavedFeeds: {}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -32,6 +32,7 @@ export type CommonNavigatorParams = {
|
|||
SavedFeeds: undefined
|
||||
PreferencesHomeFeed: undefined
|
||||
PreferencesThreads: undefined
|
||||
PreferencesExternalEmbeds: undefined
|
||||
}
|
||||
|
||||
export type BottomTabNavigatorParams = CommonNavigatorParams & {
|
||||
|
|
|
@ -1,17 +1,59 @@
|
|||
import {Platform} from 'react-native'
|
||||
import {Dimensions, Platform} from 'react-native'
|
||||
const {height: SCREEN_HEIGHT} = Dimensions.get('window')
|
||||
|
||||
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}
|
||||
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_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://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 [_, 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,170 @@ 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 [_, path, filename] = urlp.pathname.split('/')
|
||||
|
||||
if (path === '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 +348,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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue