From 5634e5af382c1e0aadf0340389b801522e1307d6 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Thu, 4 Jan 2024 17:37:36 -0800 Subject: [PATCH] Additional embed sources and external-media consent controls (#2424) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- __tests__/lib/string.test.ts | 286 +++++++++++++-- bskyweb/cmd/bskyweb/server.go | 1 + package.json | 1 - src/Navigation.tsx | 9 + src/lib/analytics/types.ts | 1 + src/lib/link-meta/link-meta.ts | 8 + src/lib/routes/types.ts | 1 + src/lib/strings/embed-player.ts | 342 +++++++++++++++--- src/routes.ts | 1 + src/state/modals/index.tsx | 8 + src/state/persisted/legacy.ts | 1 + src/state/persisted/schema.ts | 16 + .../preferences/external-embeds-prefs.tsx | 54 +++ src/state/preferences/index.tsx | 9 +- src/view/com/modals/EmbedConsent.tsx | 153 ++++++++ src/view/com/modals/Modal.tsx | 4 + src/view/com/modals/Modal.web.tsx | 3 + .../com/util/post-embeds/ExternalGifEmbed.tsx | 170 +++++++++ .../util/post-embeds/ExternalLinkEmbed.tsx | 33 +- .../util/post-embeds/ExternalPlayerEmbed.tsx | 84 +++-- src/view/icons/index.tsx | 6 +- .../screens/PreferencesExternalEmbeds.tsx | 138 +++++++ src/view/screens/Settings.tsx | 33 ++ yarn.lock | 7 - 24 files changed, 1239 insertions(+), 130 deletions(-) create mode 100644 src/state/preferences/external-embeds-prefs.tsx create mode 100644 src/view/com/modals/EmbedConsent.tsx create mode 100644 src/view/com/util/post-embeds/ExternalGifEmbed.tsx create mode 100644 src/view/screens/PreferencesExternalEmbeds.tsx diff --git a/__tests__/lib/string.test.ts b/__tests__/lib/string.test.ts index 99b0d253736..89df7a6bd1c 100644 --- a/__tests__/lib/string.test.ts +++ b/__tests__/lib/string.test.ts @@ -394,6 +394,7 @@ describe('parseEmbedPlayerFromUrl', () => { 'https://youtube.com/watch?v=videoId', 'https://youtube.com/watch?v=videoId&feature=share', 'https://youtube.com/shorts/videoId', + 'https://m.youtube.com/watch?v=videoId', 'https://youtube.com/shorts/', 'https://youtube.com/', @@ -401,113 +402,346 @@ describe('parseEmbedPlayerFromUrl', () => { 'https://twitch.tv/channelName', 'https://www.twitch.tv/channelName', + 'https://m.twitch.tv/channelName', + + 'https://twitch.tv/channelName/clip/clipId', + 'https://twitch.tv/videos/videoId', 'https://open.spotify.com/playlist/playlistId', 'https://open.spotify.com/playlist/playlistId?param=value', + 'https://open.spotify.com/locale/playlist/playlistId', 'https://open.spotify.com/track/songId', 'https://open.spotify.com/track/songId?param=value', + 'https://open.spotify.com/locale/track/songId', 'https://open.spotify.com/album/albumId', 'https://open.spotify.com/album/albumId?param=value', + 'https://open.spotify.com/locale/album/albumId', 'https://soundcloud.com/user/track', 'https://soundcloud.com/user/sets/set', 'https://soundcloud.com/user/', + + 'https://music.apple.com/us/playlist/playlistName/playlistId', + 'https://music.apple.com/us/album/albumName/albumId', + 'https://music.apple.com/us/album/albumName/albumId?i=songId', + + 'https://vimeo.com/videoId', + 'https://vimeo.com/videoId?autoplay=0', + + 'https://giphy.com/gifs/some-random-gif-name-gifId', + 'https://giphy.com/gif/some-random-gif-name-gifId', + 'https://giphy.com/gifs/', + + 'https://media.giphy.com/media/gifId/giphy.webp', + 'https://media0.giphy.com/media/gifId/giphy.webp', + 'https://media1.giphy.com/media/gifId/giphy.gif', + 'https://media2.giphy.com/media/gifId/giphy.webp', + 'https://media3.giphy.com/media/gifId/giphy.mp4', + 'https://media4.giphy.com/media/gifId/giphy.webp', + 'https://media5.giphy.com/media/gifId/giphy.mp4', + 'https://media0.giphy.com/media/gifId/giphy.mp3', + 'https://media1.google.com/media/gifId/giphy.webp', + + 'https://media.giphy.com/media/trackingId/gifId/giphy.webp', + + 'https://i.giphy.com/media/gifId/giphy.webp', + 'https://i.giphy.com/media/gifId/giphy.webp', + 'https://i.giphy.com/gifId.gif', + 'https://i.giphy.com/gifId.gif', + + 'https://tenor.com/view/gifId', + 'https://tenor.com/notView/gifId', + 'https://tenor.com/view', + 'https://tenor.com/view/gifId.gif', ] const outputs = [ { type: 'youtube_video', - videoId: 'videoId', - playerUri: 'https://www.youtube.com/embed/videoId?autoplay=1', + source: 'youtube', + playerUri: + 'https://www.youtube.com/embed/videoId?autoplay=1&playsinline=1', }, { type: 'youtube_video', - videoId: 'videoId', - playerUri: 'https://www.youtube.com/embed/videoId?autoplay=1', + source: 'youtube', + playerUri: + 'https://www.youtube.com/embed/videoId?autoplay=1&playsinline=1', }, { type: 'youtube_video', - videoId: 'videoId', - playerUri: 'https://www.youtube.com/embed/videoId?autoplay=1', + source: 'youtube', + playerUri: + 'https://www.youtube.com/embed/videoId?autoplay=1&playsinline=1', }, { type: 'youtube_video', - videoId: 'videoId', - playerUri: 'https://www.youtube.com/embed/videoId?autoplay=1', + source: 'youtube', + playerUri: + 'https://www.youtube.com/embed/videoId?autoplay=1&playsinline=1', }, { type: 'youtube_video', - videoId: 'videoId', - playerUri: 'https://www.youtube.com/embed/videoId?autoplay=1', + source: 'youtube', + playerUri: + 'https://www.youtube.com/embed/videoId?autoplay=1&playsinline=1', + }, + { + type: 'youtube_short', + source: 'youtubeShorts', + hideDetails: true, + playerUri: + 'https://www.youtube.com/embed/videoId?autoplay=1&playsinline=1', }, { type: 'youtube_video', - videoId: 'videoId', - playerUri: 'https://www.youtube.com/embed/videoId?autoplay=1', + source: 'youtube', + playerUri: + 'https://www.youtube.com/embed/videoId?autoplay=1&playsinline=1', }, + undefined, undefined, undefined, { - type: 'twitch_live', - channelId: 'channelName', + type: 'twitch_video', + source: 'twitch', playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&channel=channelName&parent=localhost`, }, { - type: 'twitch_live', - channelId: 'channelName', + type: 'twitch_video', + source: 'twitch', playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&channel=channelName&parent=localhost`, }, + { + type: 'twitch_video', + source: 'twitch', + playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&channel=channelName&parent=localhost`, + }, + { + type: 'twitch_video', + source: 'twitch', + playerUri: `https://clips.twitch.tv/embed?volume=0.5&autoplay=true&clip=clipId&parent=localhost`, + }, + { + type: 'twitch_video', + source: 'twitch', + playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&video=videoId&parent=localhost`, + }, { type: 'spotify_playlist', - playlistId: 'playlistId', + source: 'spotify', playerUri: `https://open.spotify.com/embed/playlist/playlistId`, }, { type: 'spotify_playlist', - playlistId: 'playlistId', + source: 'spotify', + playerUri: `https://open.spotify.com/embed/playlist/playlistId`, + }, + { + type: 'spotify_playlist', + source: 'spotify', playerUri: `https://open.spotify.com/embed/playlist/playlistId`, }, { type: 'spotify_song', - songId: 'songId', + source: 'spotify', + playerUri: `https://open.spotify.com/embed/track/songId`, + }, + { + type: 'spotify_song', + source: 'spotify', playerUri: `https://open.spotify.com/embed/track/songId`, }, { type: 'spotify_song', - songId: 'songId', + source: 'spotify', playerUri: `https://open.spotify.com/embed/track/songId`, }, { type: 'spotify_album', - albumId: 'albumId', + source: 'spotify', + playerUri: `https://open.spotify.com/embed/album/albumId`, + }, + { + type: 'spotify_album', + source: 'spotify', playerUri: `https://open.spotify.com/embed/album/albumId`, }, { type: 'spotify_album', - albumId: 'albumId', + source: 'spotify', playerUri: `https://open.spotify.com/embed/album/albumId`, }, { type: 'soundcloud_track', - user: 'user', - track: 'track', + source: 'soundcloud', playerUri: `https://w.soundcloud.com/player/?url=https://soundcloud.com/user/track&auto_play=true&visual=false&hide_related=true`, }, { type: 'soundcloud_set', - user: 'user', - set: 'set', + source: 'soundcloud', playerUri: `https://w.soundcloud.com/player/?url=https://soundcloud.com/user/sets/set&auto_play=true&visual=false&hide_related=true`, }, undefined, + + { + type: 'apple_music_playlist', + source: 'appleMusic', + playerUri: + 'https://embed.music.apple.com/us/playlist/playlistName/playlistId', + }, + { + type: 'apple_music_album', + source: 'appleMusic', + playerUri: 'https://embed.music.apple.com/us/album/albumName/albumId', + }, + { + type: 'apple_music_song', + source: 'appleMusic', + playerUri: + 'https://embed.music.apple.com/us/album/albumName/albumId?i=songId', + }, + + { + type: 'vimeo_video', + source: 'vimeo', + playerUri: 'https://player.vimeo.com/video/videoId?autoplay=1', + }, + { + type: 'vimeo_video', + source: 'vimeo', + playerUri: 'https://player.vimeo.com/video/videoId?autoplay=1', + }, + + { + type: 'giphy_gif', + source: 'giphy', + isGif: true, + hideDetails: true, + metaUri: 'https://giphy.com/gifs/gifId', + playerUri: 'https://i.giphy.com/media/gifId/giphy.webp', + }, + undefined, + undefined, + + { + type: 'giphy_gif', + source: 'giphy', + isGif: true, + hideDetails: true, + metaUri: 'https://giphy.com/gifs/gifId', + playerUri: 'https://i.giphy.com/media/gifId/giphy.webp', + }, + { + type: 'giphy_gif', + source: 'giphy', + isGif: true, + hideDetails: true, + metaUri: 'https://giphy.com/gifs/gifId', + playerUri: 'https://i.giphy.com/media/gifId/giphy.webp', + }, + { + type: 'giphy_gif', + source: 'giphy', + isGif: true, + hideDetails: true, + metaUri: 'https://giphy.com/gifs/gifId', + playerUri: 'https://i.giphy.com/media/gifId/giphy.webp', + }, + { + type: 'giphy_gif', + source: 'giphy', + isGif: true, + hideDetails: true, + metaUri: 'https://giphy.com/gifs/gifId', + playerUri: 'https://i.giphy.com/media/gifId/giphy.webp', + }, + { + type: 'giphy_gif', + source: 'giphy', + isGif: true, + hideDetails: true, + metaUri: 'https://giphy.com/gifs/gifId', + playerUri: 'https://i.giphy.com/media/gifId/giphy.webp', + }, + { + type: 'giphy_gif', + source: 'giphy', + isGif: true, + hideDetails: true, + metaUri: 'https://giphy.com/gifs/gifId', + playerUri: 'https://i.giphy.com/media/gifId/giphy.webp', + }, + undefined, + undefined, + undefined, + + { + type: 'giphy_gif', + source: 'giphy', + isGif: true, + hideDetails: true, + metaUri: 'https://giphy.com/gifs/gifId', + playerUri: 'https://i.giphy.com/media/gifId/giphy.webp', + }, + + { + type: 'giphy_gif', + source: 'giphy', + isGif: true, + hideDetails: true, + metaUri: 'https://giphy.com/gifs/gifId', + playerUri: 'https://i.giphy.com/media/gifId/giphy.webp', + }, + { + type: 'giphy_gif', + source: 'giphy', + isGif: true, + hideDetails: true, + metaUri: 'https://giphy.com/gifs/gifId', + playerUri: 'https://i.giphy.com/media/gifId/giphy.webp', + }, + { + type: 'giphy_gif', + source: 'giphy', + isGif: true, + hideDetails: true, + metaUri: 'https://giphy.com/gifs/gifId', + playerUri: 'https://i.giphy.com/media/gifId/giphy.webp', + }, + { + type: 'giphy_gif', + source: 'giphy', + isGif: true, + hideDetails: true, + metaUri: 'https://giphy.com/gifs/gifId', + playerUri: 'https://i.giphy.com/media/gifId/giphy.webp', + }, + + { + type: 'tenor_gif', + source: 'tenor', + isGif: true, + hideDetails: true, + playerUri: 'https://tenor.com/view/gifId.gif', + }, + undefined, + undefined, + { + type: 'tenor_gif', + source: 'tenor', + isGif: true, + hideDetails: true, + playerUri: 'https://tenor.com/view/gifId.gif', + }, ] it('correctly grabs the correct id from uri', () => { diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go index 7a3b8bf16ab..94bba231a63 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -193,6 +193,7 @@ func serve(cctx *cli.Context) error { e.GET("/settings/home-feed", server.WebGeneric) e.GET("/settings/saved-feeds", server.WebGeneric) e.GET("/settings/threads", server.WebGeneric) + e.GET("/settings/external-embeds", server.WebGeneric) e.GET("/sys/debug", server.WebGeneric) e.GET("/sys/log", server.WebGeneric) e.GET("/support", server.WebGeneric) diff --git a/package.json b/package.json index e29b4ff1ff0..68dc2e0dcb5 100644 --- a/package.json +++ b/package.json @@ -166,7 +166,6 @@ "react-native-web-linear-gradient": "^1.1.2", "react-native-web-webview": "^1.0.2", "react-native-webview": "^13.6.3", - "react-native-youtube-iframe": "^2.3.0", "react-responsive": "^9.0.2", "rn-fetch-blob": "^0.12.0", "sentry-expo": "~7.0.1", diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 27174a31f0a..7bb1aa0ade4 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -74,6 +74,7 @@ import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts' import {SavedFeeds} from 'view/screens/SavedFeeds' import {PreferencesHomeFeed} from 'view/screens/PreferencesHomeFeed' import {PreferencesThreads} from 'view/screens/PreferencesThreads' +import {PreferencesExternalEmbeds} from '#/view/screens/PreferencesExternalEmbeds' import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStackNavigatorWithAuth' const navigationRef = createNavigationContainerRef() @@ -243,6 +244,14 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { getComponent={() => PreferencesThreads} options={{title: title('Threads Preferences'), requireAuth: true}} /> + PreferencesExternalEmbeds} + options={{ + title: title('External Media Preferences'), + requireAuth: true, + }} + /> ) } diff --git a/src/lib/analytics/types.ts b/src/lib/analytics/types.ts index 5a24c360afa..c84f7979a41 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/link-meta/link-meta.ts b/src/lib/link-meta/link-meta.ts index c17dee51fa5..c7c8d4130a9 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/routes/types.ts b/src/lib/routes/types.ts index c157c0ab346..90ae758304e 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 ec996dfa53d..b27fd9e7895 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 = { + 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,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 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 + } + } +} diff --git a/src/routes.ts b/src/routes.ts index bb2421987a6..e58fddd429e 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -26,6 +26,7 @@ export const router = new Router({ AppPasswords: '/settings/app-passwords', PreferencesHomeFeed: '/settings/home-feed', PreferencesThreads: '/settings/threads', + PreferencesExternalEmbeds: '/settings/external-embeds', SavedFeeds: '/settings/saved-feeds', Support: '/support', PrivacyPolicy: '/support/privacy', diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx index 81a220d1b3f..8c32c472a6b 100644 --- a/src/state/modals/index.tsx +++ b/src/state/modals/index.tsx @@ -6,6 +6,7 @@ import {Image as RNImage} from 'react-native-image-crop-picker' import {ImageModel} from '#/state/models/media/image' import {GalleryModel} from '#/state/models/media/gallery' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' +import {EmbedPlayerSource} from '#/lib/strings/embed-player.ts' import {ThreadgateSetting} from '../queries/threadgate' export interface ConfirmModal { @@ -180,6 +181,12 @@ export interface LinkWarningModal { href: string } +export interface EmbedConsentModal { + name: 'embed-consent' + source: EmbedPlayerSource + onAccept: () => void +} + export type Modal = // Account | AddAppPasswordModal @@ -223,6 +230,7 @@ export type Modal = // Generic | ConfirmModal | LinkWarningModal + | EmbedConsentModal const ModalContext = React.createContext<{ isModalActive: boolean diff --git a/src/state/persisted/legacy.ts b/src/state/persisted/legacy.ts index cdb542f5ac7..334ef1d92ab 100644 --- a/src/state/persisted/legacy.ts +++ b/src/state/persisted/legacy.ts @@ -109,6 +109,7 @@ export function transform(legacy: Partial): Schema { step: legacy.onboarding?.step || defaults.onboarding.step, }, hiddenPosts: defaults.hiddenPosts, + externalEmbeds: defaults.externalEmbeds, } } diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index 27b1f26bdd2..6a26cedae5e 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -1,6 +1,8 @@ import {z} from 'zod' import {deviceLocales} from '#/platform/detection' +const externalEmbedOptions = ['show', 'hide'] as const + // only data needed for rendering account page const accountSchema = z.object({ service: z.string(), @@ -30,6 +32,19 @@ export const schema = z.object({ appLanguage: z.string(), }), requireAltTextEnabled: z.boolean(), // should move to server + externalEmbeds: z + .object({ + giphy: z.enum(externalEmbedOptions).optional(), + tenor: z.enum(externalEmbedOptions).optional(), + youtube: z.enum(externalEmbedOptions).optional(), + youtubeShorts: z.enum(externalEmbedOptions).optional(), + twitch: z.enum(externalEmbedOptions).optional(), + vimeo: z.enum(externalEmbedOptions).optional(), + spotify: z.enum(externalEmbedOptions).optional(), + appleMusic: z.enum(externalEmbedOptions).optional(), + soundcloud: z.enum(externalEmbedOptions).optional(), + }) + .optional(), mutedThreads: z.array(z.string()), // should move to server invites: z.object({ copiedInvites: z.array(z.string()), @@ -60,6 +75,7 @@ export const defaults: Schema = { appLanguage: deviceLocales[0] || 'en', }, requireAltTextEnabled: false, + externalEmbeds: {}, mutedThreads: [], invites: { copiedInvites: [], diff --git a/src/state/preferences/external-embeds-prefs.tsx b/src/state/preferences/external-embeds-prefs.tsx new file mode 100644 index 00000000000..0f6385fe8ea --- /dev/null +++ b/src/state/preferences/external-embeds-prefs.tsx @@ -0,0 +1,54 @@ +import React from 'react' +import * as persisted from '#/state/persisted' +import {EmbedPlayerSource} from 'lib/strings/embed-player' + +type StateContext = persisted.Schema['externalEmbeds'] +type SetContext = (source: EmbedPlayerSource, value: 'show' | 'hide') => void + +const stateContext = React.createContext( + persisted.defaults.externalEmbeds, +) +const setContext = React.createContext({} as SetContext) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [state, setState] = React.useState(persisted.get('externalEmbeds')) + + const setStateWrapped = React.useCallback( + (source: EmbedPlayerSource, value: 'show' | 'hide') => { + setState(prev => { + persisted.write('externalEmbeds', { + ...prev, + [source]: value, + }) + + return { + ...prev, + [source]: value, + } + }) + }, + [setState], + ) + + React.useEffect(() => { + return persisted.onUpdate(() => { + setState(persisted.get('externalEmbeds')) + }) + }, [setStateWrapped]) + + return ( + + + {children} + + + ) +} + +export function useExternalEmbedsPrefs() { + return React.useContext(stateContext) +} + +export function useSetExternalEmbedPref() { + return React.useContext(setContext) +} diff --git a/src/state/preferences/index.tsx b/src/state/preferences/index.tsx index 5ec659031ef..cc2d9244c33 100644 --- a/src/state/preferences/index.tsx +++ b/src/state/preferences/index.tsx @@ -2,19 +2,26 @@ import React from 'react' import {Provider as LanguagesProvider} from './languages' import {Provider as AltTextRequiredProvider} from '../preferences/alt-text-required' import {Provider as HiddenPostsProvider} from '../preferences/hidden-posts' +import {Provider as ExternalEmbedsProvider} from './external-embeds-prefs' export {useLanguagePrefs, useLanguagePrefsApi} from './languages' export { useRequireAltTextEnabled, useSetRequireAltTextEnabled, } from './alt-text-required' +export { + useExternalEmbedsPrefs, + useSetExternalEmbedPref, +} from './external-embeds-prefs' export * from './hidden-posts' export function Provider({children}: React.PropsWithChildren<{}>) { return ( - {children} + + {children} + ) diff --git a/src/view/com/modals/EmbedConsent.tsx b/src/view/com/modals/EmbedConsent.tsx new file mode 100644 index 00000000000..04104c52e15 --- /dev/null +++ b/src/view/com/modals/EmbedConsent.tsx @@ -0,0 +1,153 @@ +import React from 'react' +import {StyleSheet, TouchableOpacity, View} from 'react-native' +import LinearGradient from 'react-native-linear-gradient' +import {s, colors, gradients} from 'lib/styles' +import {Text} from '../util/text/Text' +import {ScrollView} from './util' +import {usePalette} from 'lib/hooks/usePalette' +import { + EmbedPlayerSource, + embedPlayerSources, + externalEmbedLabels, +} from '#/lib/strings/embed-player' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' +import {useSetExternalEmbedPref} from '#/state/preferences/external-embeds-prefs' +import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' + +export const snapPoints = [450] + +export function Component({ + onAccept, + source, +}: { + onAccept: () => void + source: EmbedPlayerSource +}) { + const pal = usePalette('default') + const {closeModal} = useModalControls() + const {_} = useLingui() + const setExternalEmbedPref = useSetExternalEmbedPref() + const {isMobile} = useWebMediaQueries() + + const onShowAllPress = React.useCallback(() => { + for (const key of embedPlayerSources) { + setExternalEmbedPref(key, 'show') + } + onAccept() + closeModal() + }, [closeModal, onAccept, setExternalEmbedPref]) + + const onShowPress = React.useCallback(() => { + setExternalEmbedPref(source, 'show') + onAccept() + closeModal() + }, [closeModal, onAccept, setExternalEmbedPref, source]) + + const onHidePress = React.useCallback(() => { + setExternalEmbedPref(source, 'hide') + closeModal() + }, [closeModal, setExternalEmbedPref, source]) + + return ( + + + External Media + + + + + This content is hosted by {externalEmbedLabels[source]}. Do you want + to enable external media? + + + + + + External media may allow websites to collect information about you and + your device. No information is sent or requested until you press the + "play" button. + + + + + + + Enable External Media + + + + + + + + Enable {externalEmbedLabels[source]} only + + + + + + + + No thanks + + + + + ) +} + +const styles = StyleSheet.create({ + title: { + textAlign: 'center', + fontWeight: 'bold', + fontSize: 24, + marginBottom: 12, + }, + btn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + width: '100%', + borderRadius: 32, + padding: 14, + backgroundColor: colors.gray1, + }, +}) diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index 2aac20dacc9..f9d211d07cb 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -38,6 +38,7 @@ import * as VerifyEmailModal from './VerifyEmail' import * as ChangeEmailModal from './ChangeEmail' import * as SwitchAccountModal from './SwitchAccount' import * as LinkWarningModal from './LinkWarning' +import * as EmbedConsentModal from './EmbedConsent' const DEFAULT_SNAPPOINTS = ['90%'] const HANDLE_HEIGHT = 24 @@ -176,6 +177,9 @@ export function ModalsContainer() { } else if (activeModal?.name === 'link-warning') { snapPoints = LinkWarningModal.snapPoints element = + } else if (activeModal?.name === 'embed-consent') { + snapPoints = EmbedConsentModal.snapPoints + element = } else { return null } diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index 12138f54d4b..c43a8a6ce96 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -34,6 +34,7 @@ import * as BirthDateSettingsModal from './BirthDateSettings' import * as VerifyEmailModal from './VerifyEmail' import * as ChangeEmailModal from './ChangeEmail' import * as LinkWarningModal from './LinkWarning' +import * as EmbedConsentModal from './EmbedConsent' export function ModalsContainer() { const {isModalActive, activeModals} = useModals() @@ -129,6 +130,8 @@ function Modal({modal}: {modal: ModalIface}) { element = } else if (modal.name === 'link-warning') { element = + } else if (modal.name === 'embed-consent') { + element = } else { return null } diff --git a/src/view/com/util/post-embeds/ExternalGifEmbed.tsx b/src/view/com/util/post-embeds/ExternalGifEmbed.tsx new file mode 100644 index 00000000000..f06c8b794d6 --- /dev/null +++ b/src/view/com/util/post-embeds/ExternalGifEmbed.tsx @@ -0,0 +1,170 @@ +import {EmbedPlayerParams, getGifDims} from 'lib/strings/embed-player' +import React from 'react' +import {Image, ImageLoadEventData} from 'expo-image' +import { + ActivityIndicator, + GestureResponderEvent, + LayoutChangeEvent, + Pressable, + StyleSheet, + View, +} from 'react-native' +import {isIOS, isNative, isWeb} from '#/platform/detection' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {useExternalEmbedsPrefs} from 'state/preferences' +import {useModalControls} from 'state/modals' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' +import {AppBskyEmbedExternal} from '@atproto/api' + +export function ExternalGifEmbed({ + link, + params, +}: { + link: AppBskyEmbedExternal.ViewExternal + params: EmbedPlayerParams +}) { + const externalEmbedsPrefs = useExternalEmbedsPrefs() + const {openModal} = useModalControls() + const {_} = useLingui() + + const thumbHasLoaded = React.useRef(false) + const viewWidth = React.useRef(0) + + // Tracking if the placer has been activated + const [isPlayerActive, setIsPlayerActive] = React.useState(false) + // Tracking whether the gif has been loaded yet + const [isPrefetched, setIsPrefetched] = React.useState(false) + // Tracking whether the image is animating + const [isAnimating, setIsAnimating] = React.useState(true) + const [imageDims, setImageDims] = React.useState({height: 100, width: 1}) + + // Used for controlling animation + const imageRef = React.useRef(null) + + const load = React.useCallback(() => { + setIsPlayerActive(true) + Image.prefetch(params.playerUri).then(() => { + // Replace the image once it's fetched + setIsPrefetched(true) + }) + }, [params.playerUri]) + + const onPlayPress = React.useCallback( + (event: GestureResponderEvent) => { + // Don't propagate on web + event.preventDefault() + + // Show consent if this is the first load + if (externalEmbedsPrefs?.[params.source] === undefined) { + openModal({ + name: 'embed-consent', + source: params.source, + onAccept: load, + }) + return + } + // If the player isn't active, we want to activate it and prefetch the gif + if (!isPlayerActive) { + load() + return + } + // Control animation on native + setIsAnimating(prev => { + if (prev) { + if (isNative) { + imageRef.current?.stopAnimating() + } + return false + } else { + if (isNative) { + imageRef.current?.startAnimating() + } + return true + } + }) + }, + [externalEmbedsPrefs, isPlayerActive, load, openModal, params.source], + ) + + const onLoad = React.useCallback((e: ImageLoadEventData) => { + if (thumbHasLoaded.current) return + setImageDims(getGifDims(e.source.height, e.source.width, viewWidth.current)) + thumbHasLoaded.current = true + }, []) + + const onLayout = React.useCallback((e: LayoutChangeEvent) => { + viewWidth.current = e.nativeEvent.layout.width + }, []) + + return ( + + {(!isPrefetched || !isAnimating) && ( // If we have not loaded or are not animating, show the overlay + + + {!isAnimating || !isPlayerActive ? ( // Play button when not animating or not active + + ) : ( + // Activity indicator while gif loads + + )} + + + )} + + + ) +} + +const styles = StyleSheet.create({ + topRadius: { + borderTopLeftRadius: 6, + borderTopRightRadius: 6, + }, + layer: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + }, + overlayContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'rgba(0,0,0,0.5)', + }, + overlayLayer: { + zIndex: 2, + }, + gifContainer: { + width: '100%', + overflow: 'hidden', + }, +}) diff --git a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx index 1523dcf5300..af62aa2b335 100644 --- a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx +++ b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx @@ -8,6 +8,8 @@ import {AppBskyEmbedExternal} from '@atproto/api' import {toNiceDomain} from 'lib/strings/url-helpers' import {parseEmbedPlayerFromUrl} from 'lib/strings/embed-player' import {ExternalPlayer} from 'view/com/util/post-embeds/ExternalPlayerEmbed' +import {ExternalGifEmbed} from 'view/com/util/post-embeds/ExternalGifEmbed' +import {useExternalEmbedsPrefs} from 'state/preferences' export const ExternalLinkEmbed = ({ link, @@ -16,11 +18,15 @@ export const ExternalLinkEmbed = ({ }) => { const pal = usePalette('default') const {isMobile} = useWebMediaQueries() + const externalEmbedPrefs = useExternalEmbedsPrefs() - const embedPlayerParams = React.useMemo( - () => parseEmbedPlayerFromUrl(link.uri), - [link.uri], - ) + const embedPlayerParams = React.useMemo(() => { + const params = parseEmbedPlayerFromUrl(link.uri) + + if (params && externalEmbedPrefs?.[params.source] !== 'hide') { + return params + } + }, [link.uri, externalEmbedPrefs]) return ( @@ -40,9 +46,12 @@ export const ExternalLinkEmbed = ({ /> ) : undefined} - {embedPlayerParams && ( - - )} + {(embedPlayerParams?.isGif && ( + + )) || + (embedPlayerParams && ( + + ))} {toNiceDomain(link.uri)} - - {link.title || link.uri} - - {link.description ? ( + {!embedPlayerParams?.isGif && ( + + {link.title || link.uri} + + )} + {link.description && !embedPlayerParams?.hideDetails ? ( void }) { + const {_} = useLingui() + // If the player is active and not loading, we don't want to show the overlay. if (isPlayerActive && !isLoading) return null @@ -46,8 +51,8 @@ function PlaceholderOverlay({ {!isPlayerActive ? ( @@ -84,31 +89,21 @@ function Player({ return ( - {isNative && params.type === 'youtube_video' ? ( - + - ) : ( - - - - )} + ) @@ -125,6 +120,8 @@ export function ExternalPlayer({ const navigation = useNavigation() const insets = useSafeAreaInsets() const windowDims = useWindowDimensions() + const externalEmbedsPrefs = useExternalEmbedsPrefs() + const {openModal} = useModalControls() const [isPlayerActive, setPlayerActive] = React.useState(false) const [isLoading, setIsLoading] = React.useState(true) @@ -194,12 +191,26 @@ export function ExternalPlayer({ setIsLoading(false) }, []) - const onPlayPress = React.useCallback((event: GestureResponderEvent) => { - // Prevent this from propagating upward on web - event.preventDefault() + const onPlayPress = React.useCallback( + (event: GestureResponderEvent) => { + // Prevent this from propagating upward on web + event.preventDefault() - setPlayerActive(true) - }, []) + if (externalEmbedsPrefs?.[params.source] === undefined) { + openModal({ + name: 'embed-consent', + source: params.source, + onAccept: () => { + setPlayerActive(true) + }, + }) + return + } + + setPlayerActive(true) + }, + [externalEmbedsPrefs, openModal, params.source], + ) // measure the layout to set sizing const onLayout = React.useCallback( @@ -231,7 +242,6 @@ export function ExternalPlayer({ accessibilityIgnoresInvertColors /> )} - +export function PreferencesExternalEmbeds({}: Props) { + const pal = usePalette('default') + const setMinimalShellMode = useSetMinimalShellMode() + const {screen} = useAnalytics() + const {isMobile} = useWebMediaQueries() + + useFocusEffect( + React.useCallback(() => { + screen('PreferencesExternalEmbeds') + setMinimalShellMode(false) + }, [screen, setMinimalShellMode]), + ) + + return ( + + + + + External Media Preferences + + + Customize media from external sites. + + + + + + + + + External media may allow websites to collect information about + you and your device. No information is sent or requested until + you press the "play" button. + + + + + + Enable media players for + + {Object.entries(externalEmbedLabels).map(([key, label]) => ( + + ))} + + + ) +} + +function PrefSelector({ + source, + label, +}: { + source: EmbedPlayerSource + label: string +}) { + const pal = usePalette('default') + const setExternalEmbedPref = useSetExternalEmbedPref() + const sources = useExternalEmbedsPrefs() + + return ( + + + + setExternalEmbedPref( + source, + sources?.[source] === 'show' ? 'hide' : 'show', + ) + } + /> + + + ) +} + +const styles = StyleSheet.create({ + heading: { + paddingHorizontal: 18, + paddingTop: 14, + paddingBottom: 14, + }, + spacer: { + height: 8, + }, + infoCard: { + paddingHorizontal: 20, + paddingVertical: 14, + }, + toggleCard: { + paddingVertical: 8, + paddingHorizontal: 6, + marginBottom: 1, + }, +}) diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index 5b381a13881..fedd348e2a0 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -563,6 +563,39 @@ export function SettingsScreen({}: Props) { Moderation + + + + + Privacy + + + navigation.navigate('PreferencesExternalEmbeds') + } + accessibilityRole="button" + accessibilityHint="" + accessibilityLabel={_(msg`Opens external embeds settings`)}> + + + + + External Media Preferences + + + diff --git a/yarn.lock b/yarn.lock index d724c7821c4..0546ca5d1a0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -18304,13 +18304,6 @@ react-native-webview@^13.6.3: escape-string-regexp "2.0.0" invariant "2.2.4" -react-native-youtube-iframe@^2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/react-native-youtube-iframe/-/react-native-youtube-iframe-2.3.0.tgz#40ca8e55db929b91bfa8e8d30e411658cbc304c5" - integrity sha512-M+z63xwXVtS4dX3k8PbtHUUcWN+gRZt6J1EtPE7Y60BMOB979KjpkdrHqeR96or9pNR2W8K5tQhIkMXW2jwo7Q== - dependencies: - events "^3.2.0" - react-native@0.73.1: version "0.73.1" resolved "https://registry.yarnpkg.com/react-native/-/react-native-0.73.1.tgz#5eafaa7e54feeab8b55e8b8e4efc4d21052a4fff"