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>zio/stable
parent
db62f27241
commit
0dae24e78f
|
@ -394,6 +394,7 @@ describe('parseEmbedPlayerFromUrl', () => {
|
||||||
'https://youtube.com/watch?v=videoId',
|
'https://youtube.com/watch?v=videoId',
|
||||||
'https://youtube.com/watch?v=videoId&feature=share',
|
'https://youtube.com/watch?v=videoId&feature=share',
|
||||||
'https://youtube.com/shorts/videoId',
|
'https://youtube.com/shorts/videoId',
|
||||||
|
'https://m.youtube.com/watch?v=videoId',
|
||||||
|
|
||||||
'https://youtube.com/shorts/',
|
'https://youtube.com/shorts/',
|
||||||
'https://youtube.com/',
|
'https://youtube.com/',
|
||||||
|
@ -401,113 +402,346 @@ describe('parseEmbedPlayerFromUrl', () => {
|
||||||
|
|
||||||
'https://twitch.tv/channelName',
|
'https://twitch.tv/channelName',
|
||||||
'https://www.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',
|
||||||
'https://open.spotify.com/playlist/playlistId?param=value',
|
'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',
|
||||||
'https://open.spotify.com/track/songId?param=value',
|
'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',
|
||||||
'https://open.spotify.com/album/albumId?param=value',
|
'https://open.spotify.com/album/albumId?param=value',
|
||||||
|
'https://open.spotify.com/locale/album/albumId',
|
||||||
|
|
||||||
'https://soundcloud.com/user/track',
|
'https://soundcloud.com/user/track',
|
||||||
'https://soundcloud.com/user/sets/set',
|
'https://soundcloud.com/user/sets/set',
|
||||||
'https://soundcloud.com/user/',
|
'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 = [
|
const outputs = [
|
||||||
{
|
{
|
||||||
type: 'youtube_video',
|
type: 'youtube_video',
|
||||||
videoId: 'videoId',
|
source: 'youtube',
|
||||||
playerUri: 'https://www.youtube.com/embed/videoId?autoplay=1',
|
playerUri:
|
||||||
|
'https://www.youtube.com/embed/videoId?autoplay=1&playsinline=1',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'youtube_video',
|
type: 'youtube_video',
|
||||||
videoId: 'videoId',
|
source: 'youtube',
|
||||||
playerUri: 'https://www.youtube.com/embed/videoId?autoplay=1',
|
playerUri:
|
||||||
|
'https://www.youtube.com/embed/videoId?autoplay=1&playsinline=1',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'youtube_video',
|
type: 'youtube_video',
|
||||||
videoId: 'videoId',
|
source: 'youtube',
|
||||||
playerUri: 'https://www.youtube.com/embed/videoId?autoplay=1',
|
playerUri:
|
||||||
|
'https://www.youtube.com/embed/videoId?autoplay=1&playsinline=1',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'youtube_video',
|
type: 'youtube_video',
|
||||||
videoId: 'videoId',
|
source: 'youtube',
|
||||||
playerUri: 'https://www.youtube.com/embed/videoId?autoplay=1',
|
playerUri:
|
||||||
|
'https://www.youtube.com/embed/videoId?autoplay=1&playsinline=1',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'youtube_video',
|
type: 'youtube_video',
|
||||||
videoId: 'videoId',
|
source: 'youtube',
|
||||||
playerUri: 'https://www.youtube.com/embed/videoId?autoplay=1',
|
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',
|
type: 'youtube_video',
|
||||||
videoId: 'videoId',
|
source: 'youtube',
|
||||||
playerUri: 'https://www.youtube.com/embed/videoId?autoplay=1',
|
playerUri:
|
||||||
|
'https://www.youtube.com/embed/videoId?autoplay=1&playsinline=1',
|
||||||
},
|
},
|
||||||
|
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
|
|
||||||
{
|
{
|
||||||
type: 'twitch_live',
|
type: 'twitch_video',
|
||||||
channelId: 'channelName',
|
source: 'twitch',
|
||||||
playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&channel=channelName&parent=localhost`,
|
playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&channel=channelName&parent=localhost`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'twitch_live',
|
type: 'twitch_video',
|
||||||
channelId: 'channelName',
|
source: 'twitch',
|
||||||
playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&channel=channelName&parent=localhost`,
|
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',
|
type: 'spotify_playlist',
|
||||||
playlistId: 'playlistId',
|
source: 'spotify',
|
||||||
playerUri: `https://open.spotify.com/embed/playlist/playlistId`,
|
playerUri: `https://open.spotify.com/embed/playlist/playlistId`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'spotify_playlist',
|
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`,
|
playerUri: `https://open.spotify.com/embed/playlist/playlistId`,
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
type: 'spotify_song',
|
type: 'spotify_song',
|
||||||
songId: 'songId',
|
source: 'spotify',
|
||||||
playerUri: `https://open.spotify.com/embed/track/songId`,
|
playerUri: `https://open.spotify.com/embed/track/songId`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'spotify_song',
|
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`,
|
playerUri: `https://open.spotify.com/embed/track/songId`,
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
type: 'spotify_album',
|
type: 'spotify_album',
|
||||||
albumId: 'albumId',
|
source: 'spotify',
|
||||||
playerUri: `https://open.spotify.com/embed/album/albumId`,
|
playerUri: `https://open.spotify.com/embed/album/albumId`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'spotify_album',
|
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`,
|
playerUri: `https://open.spotify.com/embed/album/albumId`,
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
type: 'soundcloud_track',
|
type: 'soundcloud_track',
|
||||||
user: 'user',
|
source: 'soundcloud',
|
||||||
track: 'track',
|
|
||||||
playerUri: `https://w.soundcloud.com/player/?url=https://soundcloud.com/user/track&auto_play=true&visual=false&hide_related=true`,
|
playerUri: `https://w.soundcloud.com/player/?url=https://soundcloud.com/user/track&auto_play=true&visual=false&hide_related=true`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'soundcloud_set',
|
type: 'soundcloud_set',
|
||||||
user: 'user',
|
source: 'soundcloud',
|
||||||
set: 'set',
|
|
||||||
playerUri: `https://w.soundcloud.com/player/?url=https://soundcloud.com/user/sets/set&auto_play=true&visual=false&hide_related=true`,
|
playerUri: `https://w.soundcloud.com/player/?url=https://soundcloud.com/user/sets/set&auto_play=true&visual=false&hide_related=true`,
|
||||||
},
|
},
|
||||||
undefined,
|
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', () => {
|
it('correctly grabs the correct id from uri', () => {
|
||||||
|
|
|
@ -193,6 +193,7 @@ func serve(cctx *cli.Context) error {
|
||||||
e.GET("/settings/home-feed", server.WebGeneric)
|
e.GET("/settings/home-feed", server.WebGeneric)
|
||||||
e.GET("/settings/saved-feeds", server.WebGeneric)
|
e.GET("/settings/saved-feeds", server.WebGeneric)
|
||||||
e.GET("/settings/threads", server.WebGeneric)
|
e.GET("/settings/threads", server.WebGeneric)
|
||||||
|
e.GET("/settings/external-embeds", server.WebGeneric)
|
||||||
e.GET("/sys/debug", server.WebGeneric)
|
e.GET("/sys/debug", server.WebGeneric)
|
||||||
e.GET("/sys/log", server.WebGeneric)
|
e.GET("/sys/log", server.WebGeneric)
|
||||||
e.GET("/support", server.WebGeneric)
|
e.GET("/support", server.WebGeneric)
|
||||||
|
|
|
@ -166,7 +166,6 @@
|
||||||
"react-native-web-linear-gradient": "^1.1.2",
|
"react-native-web-linear-gradient": "^1.1.2",
|
||||||
"react-native-web-webview": "^1.0.2",
|
"react-native-web-webview": "^1.0.2",
|
||||||
"react-native-webview": "^13.6.3",
|
"react-native-webview": "^13.6.3",
|
||||||
"react-native-youtube-iframe": "^2.3.0",
|
|
||||||
"react-responsive": "^9.0.2",
|
"react-responsive": "^9.0.2",
|
||||||
"rn-fetch-blob": "^0.12.0",
|
"rn-fetch-blob": "^0.12.0",
|
||||||
"sentry-expo": "~7.0.1",
|
"sentry-expo": "~7.0.1",
|
||||||
|
|
|
@ -74,6 +74,7 @@ import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts'
|
||||||
import {SavedFeeds} from 'view/screens/SavedFeeds'
|
import {SavedFeeds} from 'view/screens/SavedFeeds'
|
||||||
import {PreferencesHomeFeed} from 'view/screens/PreferencesHomeFeed'
|
import {PreferencesHomeFeed} from 'view/screens/PreferencesHomeFeed'
|
||||||
import {PreferencesThreads} from 'view/screens/PreferencesThreads'
|
import {PreferencesThreads} from 'view/screens/PreferencesThreads'
|
||||||
|
import {PreferencesExternalEmbeds} from '#/view/screens/PreferencesExternalEmbeds'
|
||||||
import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStackNavigatorWithAuth'
|
import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStackNavigatorWithAuth'
|
||||||
|
|
||||||
const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
|
const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
|
||||||
|
@ -243,6 +244,14 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
||||||
getComponent={() => PreferencesThreads}
|
getComponent={() => PreferencesThreads}
|
||||||
options={{title: title('Threads Preferences'), requireAuth: true}}
|
options={{title: title('Threads Preferences'), requireAuth: true}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="PreferencesExternalEmbeds"
|
||||||
|
getComponent={() => PreferencesExternalEmbeds}
|
||||||
|
options={{
|
||||||
|
title: title('External Media Preferences'),
|
||||||
|
requireAuth: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -147,6 +147,7 @@ interface ScreenPropertiesMap {
|
||||||
Settings: {}
|
Settings: {}
|
||||||
AppPasswords: {}
|
AppPasswords: {}
|
||||||
Moderation: {}
|
Moderation: {}
|
||||||
|
PreferencesExternalEmbeds: {}
|
||||||
BlockedAccounts: {}
|
BlockedAccounts: {}
|
||||||
MutedAccounts: {}
|
MutedAccounts: {}
|
||||||
SavedFeeds: {}
|
SavedFeeds: {}
|
||||||
|
|
|
@ -2,6 +2,7 @@ import {BskyAgent} from '@atproto/api'
|
||||||
import {isBskyAppUrl} from '../strings/url-helpers'
|
import {isBskyAppUrl} from '../strings/url-helpers'
|
||||||
import {extractBskyMeta} from './bsky'
|
import {extractBskyMeta} from './bsky'
|
||||||
import {LINK_META_PROXY} from 'lib/constants'
|
import {LINK_META_PROXY} from 'lib/constants'
|
||||||
|
import {getGiphyMetaUri} from 'lib/strings/embed-player'
|
||||||
|
|
||||||
export enum LikelyType {
|
export enum LikelyType {
|
||||||
HTML,
|
HTML,
|
||||||
|
@ -34,6 +35,13 @@ export async function getLinkMeta(
|
||||||
let urlp
|
let urlp
|
||||||
try {
|
try {
|
||||||
urlp = new URL(url)
|
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) {
|
} catch (e) {
|
||||||
return {
|
return {
|
||||||
error: 'Invalid URL',
|
error: 'Invalid URL',
|
||||||
|
|
|
@ -32,6 +32,7 @@ export type CommonNavigatorParams = {
|
||||||
SavedFeeds: undefined
|
SavedFeeds: undefined
|
||||||
PreferencesHomeFeed: undefined
|
PreferencesHomeFeed: undefined
|
||||||
PreferencesThreads: undefined
|
PreferencesThreads: undefined
|
||||||
|
PreferencesExternalEmbeds: undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BottomTabNavigatorParams = CommonNavigatorParams & {
|
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 =
|
export const embedPlayerSources = [
|
||||||
| {type: 'youtube_video'; videoId: string; playerUri: string}
|
'youtube',
|
||||||
| {type: 'twitch_live'; channelId: string; playerUri: string}
|
'youtubeShorts',
|
||||||
| {type: 'spotify_album'; albumId: string; playerUri: string}
|
'twitch',
|
||||||
| {
|
'spotify',
|
||||||
type: 'spotify_playlist'
|
'soundcloud',
|
||||||
playlistId: string
|
'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
|
playerUri: string
|
||||||
}
|
isGif?: boolean
|
||||||
| {type: 'spotify_song'; songId: string; playerUri: string}
|
source: EmbedPlayerSource
|
||||||
| {type: 'soundcloud_track'; user: string; track: string; playerUri: string}
|
metaUri?: string
|
||||||
| {type: 'soundcloud_set'; user: string; set: string; playerUri: string}
|
hideDetails?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const giphyRegex = /media(?:[0-4]\.giphy\.com|\.giphy\.com)/i
|
||||||
|
const gifFilenameRegex = /^(\S+)\.(webp|gif|mp4)$/i
|
||||||
|
|
||||||
export function parseEmbedPlayerFromUrl(
|
export function parseEmbedPlayerFromUrl(
|
||||||
url: string,
|
url: string,
|
||||||
|
@ -29,63 +71,88 @@ export function parseEmbedPlayerFromUrl(
|
||||||
if (videoId) {
|
if (videoId) {
|
||||||
return {
|
return {
|
||||||
type: 'youtube_video',
|
type: 'youtube_video',
|
||||||
videoId,
|
source: 'youtube',
|
||||||
playerUri: `https://www.youtube.com/embed/${videoId}?autoplay=1`,
|
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 [_, page, shortVideoId] = urlp.pathname.split('/')
|
||||||
const videoId =
|
const videoId =
|
||||||
page === 'shorts' ? shortVideoId : (urlp.searchParams.get('v') as string)
|
page === 'shorts' ? shortVideoId : (urlp.searchParams.get('v') as string)
|
||||||
|
|
||||||
if (videoId) {
|
if (videoId) {
|
||||||
return {
|
return {
|
||||||
type: 'youtube_video',
|
type: page === 'shorts' ? 'youtube_short' : 'youtube_video',
|
||||||
videoId,
|
source: page === 'shorts' ? 'youtubeShorts' : 'youtube',
|
||||||
playerUri: `https://www.youtube.com/embed/${videoId}?autoplay=1`,
|
hideDetails: page === 'shorts' ? true : undefined,
|
||||||
|
playerUri: `https://www.youtube.com/embed/${videoId}?autoplay=1&playsinline=1`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// twitch
|
// 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 =
|
const parent =
|
||||||
Platform.OS === 'web' ? window.location.hostname : 'localhost'
|
Platform.OS === 'web' ? window.location.hostname : 'localhost'
|
||||||
|
|
||||||
const parts = urlp.pathname.split('/')
|
const [_, channelOrVideo, clipOrId, id] = urlp.pathname.split('/')
|
||||||
if (parts.length === 2 && parts[1]) {
|
|
||||||
|
if (channelOrVideo === 'videos') {
|
||||||
return {
|
return {
|
||||||
type: 'twitch_live',
|
type: 'twitch_video',
|
||||||
channelId: parts[1],
|
source: 'twitch',
|
||||||
playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&channel=${parts[1]}&parent=${parent}`,
|
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
|
// spotify
|
||||||
if (urlp.hostname === 'open.spotify.com') {
|
if (urlp.hostname === 'open.spotify.com') {
|
||||||
const [_, type, id] = urlp.pathname.split('/')
|
const [_, typeOrLocale, idOrType, id] = urlp.pathname.split('/')
|
||||||
if (type && id) {
|
|
||||||
if (type === 'playlist') {
|
if (idOrType) {
|
||||||
|
if (typeOrLocale === 'playlist' || idOrType === 'playlist') {
|
||||||
return {
|
return {
|
||||||
type: 'spotify_playlist',
|
type: 'spotify_playlist',
|
||||||
playlistId: id,
|
source: 'spotify',
|
||||||
playerUri: `https://open.spotify.com/embed/playlist/${id}`,
|
playerUri: `https://open.spotify.com/embed/playlist/${
|
||||||
|
id ?? idOrType
|
||||||
|
}`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (type === 'album') {
|
if (typeOrLocale === 'album' || idOrType === 'album') {
|
||||||
return {
|
return {
|
||||||
type: 'spotify_album',
|
type: 'spotify_album',
|
||||||
albumId: id,
|
source: 'spotify',
|
||||||
playerUri: `https://open.spotify.com/embed/album/${id}`,
|
playerUri: `https://open.spotify.com/embed/album/${id ?? idOrType}`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (type === 'track') {
|
if (typeOrLocale === 'track' || idOrType === 'track') {
|
||||||
return {
|
return {
|
||||||
type: 'spotify_song',
|
type: 'spotify_song',
|
||||||
songId: id,
|
source: 'spotify',
|
||||||
playerUri: `https://open.spotify.com/embed/track/${id}`,
|
playerUri: `https://open.spotify.com/embed/track/${id ?? idOrType}`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -102,20 +169,170 @@ export function parseEmbedPlayerFromUrl(
|
||||||
if (trackOrSets === 'sets' && set) {
|
if (trackOrSets === 'sets' && set) {
|
||||||
return {
|
return {
|
||||||
type: 'soundcloud_set',
|
type: 'soundcloud_set',
|
||||||
user,
|
source: 'soundcloud',
|
||||||
set: set,
|
|
||||||
playerUri: `https://w.soundcloud.com/player/?url=${url}&auto_play=true&visual=false&hide_related=true`,
|
playerUri: `https://w.soundcloud.com/player/?url=${url}&auto_play=true&visual=false&hide_related=true`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'soundcloud_track',
|
type: 'soundcloud_track',
|
||||||
user,
|
source: 'soundcloud',
|
||||||
track: trackOrSets,
|
|
||||||
playerUri: `https://w.soundcloud.com/player/?url=${url}&auto_play=true&visual=false&hide_related=true`,
|
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({
|
export function getPlayerHeight({
|
||||||
|
@ -131,22 +348,53 @@ export function getPlayerHeight({
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'youtube_video':
|
case 'youtube_video':
|
||||||
case 'twitch_live':
|
case 'twitch_video':
|
||||||
|
case 'vimeo_video':
|
||||||
return (width / 16) * 9
|
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':
|
case 'spotify_album':
|
||||||
return 380
|
case 'apple_music_album':
|
||||||
|
case 'apple_music_playlist':
|
||||||
case 'spotify_playlist':
|
case 'spotify_playlist':
|
||||||
return 360
|
case 'soundcloud_set':
|
||||||
|
return 380
|
||||||
case 'spotify_song':
|
case 'spotify_song':
|
||||||
if (width <= 300) {
|
if (width <= 300) {
|
||||||
return 180
|
return 155
|
||||||
}
|
}
|
||||||
return 232
|
return 232
|
||||||
case 'soundcloud_track':
|
case 'soundcloud_track':
|
||||||
return 165
|
return 165
|
||||||
case 'soundcloud_set':
|
case 'apple_music_song':
|
||||||
return 360
|
return 150
|
||||||
default:
|
default:
|
||||||
return width
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -26,6 +26,7 @@ export const router = new Router({
|
||||||
AppPasswords: '/settings/app-passwords',
|
AppPasswords: '/settings/app-passwords',
|
||||||
PreferencesHomeFeed: '/settings/home-feed',
|
PreferencesHomeFeed: '/settings/home-feed',
|
||||||
PreferencesThreads: '/settings/threads',
|
PreferencesThreads: '/settings/threads',
|
||||||
|
PreferencesExternalEmbeds: '/settings/external-embeds',
|
||||||
SavedFeeds: '/settings/saved-feeds',
|
SavedFeeds: '/settings/saved-feeds',
|
||||||
Support: '/support',
|
Support: '/support',
|
||||||
PrivacyPolicy: '/support/privacy',
|
PrivacyPolicy: '/support/privacy',
|
||||||
|
|
|
@ -6,6 +6,7 @@ import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||||
import {ImageModel} from '#/state/models/media/image'
|
import {ImageModel} from '#/state/models/media/image'
|
||||||
import {GalleryModel} from '#/state/models/media/gallery'
|
import {GalleryModel} from '#/state/models/media/gallery'
|
||||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||||
|
import {EmbedPlayerSource} from '#/lib/strings/embed-player.ts'
|
||||||
import {ThreadgateSetting} from '../queries/threadgate'
|
import {ThreadgateSetting} from '../queries/threadgate'
|
||||||
|
|
||||||
export interface ConfirmModal {
|
export interface ConfirmModal {
|
||||||
|
@ -180,6 +181,12 @@ export interface LinkWarningModal {
|
||||||
href: string
|
href: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EmbedConsentModal {
|
||||||
|
name: 'embed-consent'
|
||||||
|
source: EmbedPlayerSource
|
||||||
|
onAccept: () => void
|
||||||
|
}
|
||||||
|
|
||||||
export type Modal =
|
export type Modal =
|
||||||
// Account
|
// Account
|
||||||
| AddAppPasswordModal
|
| AddAppPasswordModal
|
||||||
|
@ -223,6 +230,7 @@ export type Modal =
|
||||||
// Generic
|
// Generic
|
||||||
| ConfirmModal
|
| ConfirmModal
|
||||||
| LinkWarningModal
|
| LinkWarningModal
|
||||||
|
| EmbedConsentModal
|
||||||
|
|
||||||
const ModalContext = React.createContext<{
|
const ModalContext = React.createContext<{
|
||||||
isModalActive: boolean
|
isModalActive: boolean
|
||||||
|
|
|
@ -109,6 +109,7 @@ export function transform(legacy: Partial<LegacySchema>): Schema {
|
||||||
step: legacy.onboarding?.step || defaults.onboarding.step,
|
step: legacy.onboarding?.step || defaults.onboarding.step,
|
||||||
},
|
},
|
||||||
hiddenPosts: defaults.hiddenPosts,
|
hiddenPosts: defaults.hiddenPosts,
|
||||||
|
externalEmbeds: defaults.externalEmbeds,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
import {z} from 'zod'
|
import {z} from 'zod'
|
||||||
import {deviceLocales} from '#/platform/detection'
|
import {deviceLocales} from '#/platform/detection'
|
||||||
|
|
||||||
|
const externalEmbedOptions = ['show', 'hide'] as const
|
||||||
|
|
||||||
// only data needed for rendering account page
|
// only data needed for rendering account page
|
||||||
const accountSchema = z.object({
|
const accountSchema = z.object({
|
||||||
service: z.string(),
|
service: z.string(),
|
||||||
|
@ -30,6 +32,19 @@ export const schema = z.object({
|
||||||
appLanguage: z.string(),
|
appLanguage: z.string(),
|
||||||
}),
|
}),
|
||||||
requireAltTextEnabled: z.boolean(), // should move to server
|
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
|
mutedThreads: z.array(z.string()), // should move to server
|
||||||
invites: z.object({
|
invites: z.object({
|
||||||
copiedInvites: z.array(z.string()),
|
copiedInvites: z.array(z.string()),
|
||||||
|
@ -60,6 +75,7 @@ export const defaults: Schema = {
|
||||||
appLanguage: deviceLocales[0] || 'en',
|
appLanguage: deviceLocales[0] || 'en',
|
||||||
},
|
},
|
||||||
requireAltTextEnabled: false,
|
requireAltTextEnabled: false,
|
||||||
|
externalEmbeds: {},
|
||||||
mutedThreads: [],
|
mutedThreads: [],
|
||||||
invites: {
|
invites: {
|
||||||
copiedInvites: [],
|
copiedInvites: [],
|
||||||
|
|
|
@ -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<StateContext>(
|
||||||
|
persisted.defaults.externalEmbeds,
|
||||||
|
)
|
||||||
|
const setContext = React.createContext<SetContext>({} 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 (
|
||||||
|
<stateContext.Provider value={state}>
|
||||||
|
<setContext.Provider value={setStateWrapped}>
|
||||||
|
{children}
|
||||||
|
</setContext.Provider>
|
||||||
|
</stateContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useExternalEmbedsPrefs() {
|
||||||
|
return React.useContext(stateContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSetExternalEmbedPref() {
|
||||||
|
return React.useContext(setContext)
|
||||||
|
}
|
|
@ -2,19 +2,26 @@ import React from 'react'
|
||||||
import {Provider as LanguagesProvider} from './languages'
|
import {Provider as LanguagesProvider} from './languages'
|
||||||
import {Provider as AltTextRequiredProvider} from '../preferences/alt-text-required'
|
import {Provider as AltTextRequiredProvider} from '../preferences/alt-text-required'
|
||||||
import {Provider as HiddenPostsProvider} from '../preferences/hidden-posts'
|
import {Provider as HiddenPostsProvider} from '../preferences/hidden-posts'
|
||||||
|
import {Provider as ExternalEmbedsProvider} from './external-embeds-prefs'
|
||||||
|
|
||||||
export {useLanguagePrefs, useLanguagePrefsApi} from './languages'
|
export {useLanguagePrefs, useLanguagePrefsApi} from './languages'
|
||||||
export {
|
export {
|
||||||
useRequireAltTextEnabled,
|
useRequireAltTextEnabled,
|
||||||
useSetRequireAltTextEnabled,
|
useSetRequireAltTextEnabled,
|
||||||
} from './alt-text-required'
|
} from './alt-text-required'
|
||||||
|
export {
|
||||||
|
useExternalEmbedsPrefs,
|
||||||
|
useSetExternalEmbedPref,
|
||||||
|
} from './external-embeds-prefs'
|
||||||
export * from './hidden-posts'
|
export * from './hidden-posts'
|
||||||
|
|
||||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||||
return (
|
return (
|
||||||
<LanguagesProvider>
|
<LanguagesProvider>
|
||||||
<AltTextRequiredProvider>
|
<AltTextRequiredProvider>
|
||||||
|
<ExternalEmbedsProvider>
|
||||||
<HiddenPostsProvider>{children}</HiddenPostsProvider>
|
<HiddenPostsProvider>{children}</HiddenPostsProvider>
|
||||||
|
</ExternalEmbedsProvider>
|
||||||
</AltTextRequiredProvider>
|
</AltTextRequiredProvider>
|
||||||
</LanguagesProvider>
|
</LanguagesProvider>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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 (
|
||||||
|
<ScrollView
|
||||||
|
testID="embedConsentModal"
|
||||||
|
style={[
|
||||||
|
s.flex1,
|
||||||
|
pal.view,
|
||||||
|
isMobile
|
||||||
|
? {paddingHorizontal: 20, paddingTop: 10}
|
||||||
|
: {paddingHorizontal: 30},
|
||||||
|
]}>
|
||||||
|
<Text style={[pal.text, styles.title]}>
|
||||||
|
<Trans>External Media</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text style={pal.text}>
|
||||||
|
<Trans>
|
||||||
|
This content is hosted by {externalEmbedLabels[source]}. Do you want
|
||||||
|
to enable external media?
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
<View style={[s.mt10]} />
|
||||||
|
<Text style={pal.textLight}>
|
||||||
|
<Trans>
|
||||||
|
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.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
<View style={[s.mt20]} />
|
||||||
|
<TouchableOpacity
|
||||||
|
testID="enableAllBtn"
|
||||||
|
onPress={onShowAllPress}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={_(
|
||||||
|
msg`Show embeds from ${externalEmbedLabels[source]}`,
|
||||||
|
)}
|
||||||
|
accessibilityHint=""
|
||||||
|
onAccessibilityEscape={closeModal}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={[gradients.blueLight.start, gradients.blueLight.end]}
|
||||||
|
start={{x: 0, y: 0}}
|
||||||
|
end={{x: 1, y: 1}}
|
||||||
|
style={[styles.btn]}>
|
||||||
|
<Text style={[s.white, s.bold, s.f18]}>
|
||||||
|
<Trans>Enable External Media</Trans>
|
||||||
|
</Text>
|
||||||
|
</LinearGradient>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<View style={[s.mt10]} />
|
||||||
|
<TouchableOpacity
|
||||||
|
testID="enableSourceBtn"
|
||||||
|
onPress={onShowPress}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={_(
|
||||||
|
msg`Never load embeds from ${externalEmbedLabels[source]}`,
|
||||||
|
)}
|
||||||
|
accessibilityHint=""
|
||||||
|
onAccessibilityEscape={closeModal}>
|
||||||
|
<View style={[styles.btn, pal.btn]}>
|
||||||
|
<Text style={[pal.text, s.bold, s.f18]}>
|
||||||
|
<Trans>Enable {externalEmbedLabels[source]} only</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<View style={[s.mt10]} />
|
||||||
|
<TouchableOpacity
|
||||||
|
testID="disableSourceBtn"
|
||||||
|
onPress={onHidePress}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={_(
|
||||||
|
msg`Never load embeds from ${externalEmbedLabels[source]}`,
|
||||||
|
)}
|
||||||
|
accessibilityHint=""
|
||||||
|
onAccessibilityEscape={closeModal}>
|
||||||
|
<View style={[styles.btn, pal.btn]}>
|
||||||
|
<Text style={[pal.text, s.bold, s.f18]}>
|
||||||
|
<Trans>No thanks</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</ScrollView>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
})
|
|
@ -38,6 +38,7 @@ import * as VerifyEmailModal from './VerifyEmail'
|
||||||
import * as ChangeEmailModal from './ChangeEmail'
|
import * as ChangeEmailModal from './ChangeEmail'
|
||||||
import * as SwitchAccountModal from './SwitchAccount'
|
import * as SwitchAccountModal from './SwitchAccount'
|
||||||
import * as LinkWarningModal from './LinkWarning'
|
import * as LinkWarningModal from './LinkWarning'
|
||||||
|
import * as EmbedConsentModal from './EmbedConsent'
|
||||||
|
|
||||||
const DEFAULT_SNAPPOINTS = ['90%']
|
const DEFAULT_SNAPPOINTS = ['90%']
|
||||||
const HANDLE_HEIGHT = 24
|
const HANDLE_HEIGHT = 24
|
||||||
|
@ -176,6 +177,9 @@ export function ModalsContainer() {
|
||||||
} else if (activeModal?.name === 'link-warning') {
|
} else if (activeModal?.name === 'link-warning') {
|
||||||
snapPoints = LinkWarningModal.snapPoints
|
snapPoints = LinkWarningModal.snapPoints
|
||||||
element = <LinkWarningModal.Component {...activeModal} />
|
element = <LinkWarningModal.Component {...activeModal} />
|
||||||
|
} else if (activeModal?.name === 'embed-consent') {
|
||||||
|
snapPoints = EmbedConsentModal.snapPoints
|
||||||
|
element = <EmbedConsentModal.Component {...activeModal} />
|
||||||
} else {
|
} else {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,6 +34,7 @@ import * as BirthDateSettingsModal from './BirthDateSettings'
|
||||||
import * as VerifyEmailModal from './VerifyEmail'
|
import * as VerifyEmailModal from './VerifyEmail'
|
||||||
import * as ChangeEmailModal from './ChangeEmail'
|
import * as ChangeEmailModal from './ChangeEmail'
|
||||||
import * as LinkWarningModal from './LinkWarning'
|
import * as LinkWarningModal from './LinkWarning'
|
||||||
|
import * as EmbedConsentModal from './EmbedConsent'
|
||||||
|
|
||||||
export function ModalsContainer() {
|
export function ModalsContainer() {
|
||||||
const {isModalActive, activeModals} = useModals()
|
const {isModalActive, activeModals} = useModals()
|
||||||
|
@ -129,6 +130,8 @@ function Modal({modal}: {modal: ModalIface}) {
|
||||||
element = <ChangeEmailModal.Component />
|
element = <ChangeEmailModal.Component />
|
||||||
} else if (modal.name === 'link-warning') {
|
} else if (modal.name === 'link-warning') {
|
||||||
element = <LinkWarningModal.Component {...modal} />
|
element = <LinkWarningModal.Component {...modal} />
|
||||||
|
} else if (modal.name === 'embed-consent') {
|
||||||
|
element = <EmbedConsentModal.Component {...modal} />
|
||||||
} else {
|
} else {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
|
@ -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<Image>(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 (
|
||||||
|
<Pressable
|
||||||
|
style={[
|
||||||
|
{height: imageDims.height},
|
||||||
|
styles.topRadius,
|
||||||
|
styles.gifContainer,
|
||||||
|
]}
|
||||||
|
onPress={onPlayPress}
|
||||||
|
onLayout={onLayout}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityHint={_(msg`Plays the GIF`)}
|
||||||
|
accessibilityLabel={_(msg`Play ${link.title}`)}>
|
||||||
|
{(!isPrefetched || !isAnimating) && ( // If we have not loaded or are not animating, show the overlay
|
||||||
|
<View style={[styles.layer, styles.overlayLayer]}>
|
||||||
|
<View style={[styles.overlayContainer, styles.topRadius]}>
|
||||||
|
{!isAnimating || !isPlayerActive ? ( // Play button when not animating or not active
|
||||||
|
<FontAwesomeIcon icon="play" size={42} color="white" />
|
||||||
|
) : (
|
||||||
|
// Activity indicator while gif loads
|
||||||
|
<ActivityIndicator size="large" color="white" />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<Image
|
||||||
|
source={{
|
||||||
|
uri:
|
||||||
|
!isPrefetched || (isWeb && !isAnimating)
|
||||||
|
? link.thumb
|
||||||
|
: params.playerUri,
|
||||||
|
}} // Web uses the thumb to control playback
|
||||||
|
style={{flex: 1}}
|
||||||
|
ref={imageRef}
|
||||||
|
onLoad={onLoad}
|
||||||
|
autoplay={isAnimating}
|
||||||
|
contentFit="contain"
|
||||||
|
accessibilityIgnoresInvertColors
|
||||||
|
accessibilityLabel={link.title}
|
||||||
|
accessibilityHint={link.title}
|
||||||
|
cachePolicy={isIOS ? 'disk' : 'memory-disk'} // cant control playback with memory-disk on ios
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
})
|
|
@ -8,6 +8,8 @@ import {AppBskyEmbedExternal} from '@atproto/api'
|
||||||
import {toNiceDomain} from 'lib/strings/url-helpers'
|
import {toNiceDomain} from 'lib/strings/url-helpers'
|
||||||
import {parseEmbedPlayerFromUrl} from 'lib/strings/embed-player'
|
import {parseEmbedPlayerFromUrl} from 'lib/strings/embed-player'
|
||||||
import {ExternalPlayer} from 'view/com/util/post-embeds/ExternalPlayerEmbed'
|
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 = ({
|
export const ExternalLinkEmbed = ({
|
||||||
link,
|
link,
|
||||||
|
@ -16,11 +18,15 @@ export const ExternalLinkEmbed = ({
|
||||||
}) => {
|
}) => {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const {isMobile} = useWebMediaQueries()
|
const {isMobile} = useWebMediaQueries()
|
||||||
|
const externalEmbedPrefs = useExternalEmbedsPrefs()
|
||||||
|
|
||||||
const embedPlayerParams = React.useMemo(
|
const embedPlayerParams = React.useMemo(() => {
|
||||||
() => parseEmbedPlayerFromUrl(link.uri),
|
const params = parseEmbedPlayerFromUrl(link.uri)
|
||||||
[link.uri],
|
|
||||||
)
|
if (params && externalEmbedPrefs?.[params.source] !== 'hide') {
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
}, [link.uri, externalEmbedPrefs])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{flexDirection: 'column'}}>
|
<View style={{flexDirection: 'column'}}>
|
||||||
|
@ -40,9 +46,12 @@ export const ExternalLinkEmbed = ({
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
{embedPlayerParams && (
|
{(embedPlayerParams?.isGif && (
|
||||||
|
<ExternalGifEmbed link={link} params={embedPlayerParams} />
|
||||||
|
)) ||
|
||||||
|
(embedPlayerParams && (
|
||||||
<ExternalPlayer link={link} params={embedPlayerParams} />
|
<ExternalPlayer link={link} params={embedPlayerParams} />
|
||||||
)}
|
))}
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
paddingHorizontal: isMobile ? 10 : 14,
|
paddingHorizontal: isMobile ? 10 : 14,
|
||||||
|
@ -55,10 +64,12 @@ export const ExternalLinkEmbed = ({
|
||||||
style={[pal.textLight, styles.extUri]}>
|
style={[pal.textLight, styles.extUri]}>
|
||||||
{toNiceDomain(link.uri)}
|
{toNiceDomain(link.uri)}
|
||||||
</Text>
|
</Text>
|
||||||
|
{!embedPlayerParams?.isGif && (
|
||||||
<Text type="lg-bold" numberOfLines={4} style={[pal.text]}>
|
<Text type="lg-bold" numberOfLines={4} style={[pal.text]}>
|
||||||
{link.title || link.uri}
|
{link.title || link.uri}
|
||||||
</Text>
|
</Text>
|
||||||
{link.description ? (
|
)}
|
||||||
|
{link.description && !embedPlayerParams?.hideDetails ? (
|
||||||
<Text
|
<Text
|
||||||
type="md"
|
type="md"
|
||||||
numberOfLines={4}
|
numberOfLines={4}
|
||||||
|
|
|
@ -16,14 +16,17 @@ import Animated, {
|
||||||
import {Image} from 'expo-image'
|
import {Image} from 'expo-image'
|
||||||
import {WebView} from 'react-native-webview'
|
import {WebView} from 'react-native-webview'
|
||||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||||
import YoutubePlayer from 'react-native-youtube-iframe'
|
|
||||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||||
|
import {msg} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {useNavigation} from '@react-navigation/native'
|
||||||
|
import {AppBskyEmbedExternal} from '@atproto/api'
|
||||||
import {EmbedPlayerParams, getPlayerHeight} from 'lib/strings/embed-player'
|
import {EmbedPlayerParams, getPlayerHeight} from 'lib/strings/embed-player'
|
||||||
import {EventStopper} from '../EventStopper'
|
import {EventStopper} from '../EventStopper'
|
||||||
import {AppBskyEmbedExternal} from '@atproto/api'
|
|
||||||
import {isNative} from 'platform/detection'
|
import {isNative} from 'platform/detection'
|
||||||
import {useNavigation} from '@react-navigation/native'
|
|
||||||
import {NavigationProp} from 'lib/routes/types'
|
import {NavigationProp} from 'lib/routes/types'
|
||||||
|
import {useExternalEmbedsPrefs} from 'state/preferences'
|
||||||
|
import {useModalControls} from 'state/modals'
|
||||||
|
|
||||||
interface ShouldStartLoadRequest {
|
interface ShouldStartLoadRequest {
|
||||||
url: string
|
url: string
|
||||||
|
@ -39,6 +42,8 @@ function PlaceholderOverlay({
|
||||||
isPlayerActive: boolean
|
isPlayerActive: boolean
|
||||||
onPress: (event: GestureResponderEvent) => void
|
onPress: (event: GestureResponderEvent) => void
|
||||||
}) {
|
}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
|
||||||
// If the player is active and not loading, we don't want to show the overlay.
|
// If the player is active and not loading, we don't want to show the overlay.
|
||||||
if (isPlayerActive && !isLoading) return null
|
if (isPlayerActive && !isLoading) return null
|
||||||
|
|
||||||
|
@ -46,8 +51,8 @@ function PlaceholderOverlay({
|
||||||
<View style={[styles.layer, styles.overlayLayer]}>
|
<View style={[styles.layer, styles.overlayLayer]}>
|
||||||
<Pressable
|
<Pressable
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="Play Video"
|
accessibilityLabel={_(msg`Play Video`)}
|
||||||
accessibilityHint=""
|
accessibilityHint={_(msg`Play Video`)}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
style={[styles.overlayContainer, styles.topRadius]}>
|
style={[styles.overlayContainer, styles.topRadius]}>
|
||||||
{!isPlayerActive ? (
|
{!isPlayerActive ? (
|
||||||
|
@ -84,15 +89,6 @@ function Player({
|
||||||
return (
|
return (
|
||||||
<View style={[styles.layer, styles.playerLayer]}>
|
<View style={[styles.layer, styles.playerLayer]}>
|
||||||
<EventStopper>
|
<EventStopper>
|
||||||
{isNative && params.type === 'youtube_video' ? (
|
|
||||||
<YoutubePlayer
|
|
||||||
videoId={params.videoId}
|
|
||||||
play
|
|
||||||
height={height}
|
|
||||||
onReady={onLoad}
|
|
||||||
webViewStyle={[styles.webview, styles.topRadius]}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<View style={{height, width: '100%'}}>
|
<View style={{height, width: '100%'}}>
|
||||||
<WebView
|
<WebView
|
||||||
javaScriptEnabled={true}
|
javaScriptEnabled={true}
|
||||||
|
@ -108,7 +104,6 @@ function Player({
|
||||||
style={[styles.webview, styles.topRadius]}
|
style={[styles.webview, styles.topRadius]}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
|
||||||
</EventStopper>
|
</EventStopper>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
@ -125,6 +120,8 @@ export function ExternalPlayer({
|
||||||
const navigation = useNavigation<NavigationProp>()
|
const navigation = useNavigation<NavigationProp>()
|
||||||
const insets = useSafeAreaInsets()
|
const insets = useSafeAreaInsets()
|
||||||
const windowDims = useWindowDimensions()
|
const windowDims = useWindowDimensions()
|
||||||
|
const externalEmbedsPrefs = useExternalEmbedsPrefs()
|
||||||
|
const {openModal} = useModalControls()
|
||||||
|
|
||||||
const [isPlayerActive, setPlayerActive] = React.useState(false)
|
const [isPlayerActive, setPlayerActive] = React.useState(false)
|
||||||
const [isLoading, setIsLoading] = React.useState(true)
|
const [isLoading, setIsLoading] = React.useState(true)
|
||||||
|
@ -194,12 +191,26 @@ export function ExternalPlayer({
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const onPlayPress = React.useCallback((event: GestureResponderEvent) => {
|
const onPlayPress = React.useCallback(
|
||||||
|
(event: GestureResponderEvent) => {
|
||||||
// Prevent this from propagating upward on web
|
// Prevent this from propagating upward on web
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
|
if (externalEmbedsPrefs?.[params.source] === undefined) {
|
||||||
|
openModal({
|
||||||
|
name: 'embed-consent',
|
||||||
|
source: params.source,
|
||||||
|
onAccept: () => {
|
||||||
setPlayerActive(true)
|
setPlayerActive(true)
|
||||||
}, [])
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setPlayerActive(true)
|
||||||
|
},
|
||||||
|
[externalEmbedsPrefs, openModal, params.source],
|
||||||
|
)
|
||||||
|
|
||||||
// measure the layout to set sizing
|
// measure the layout to set sizing
|
||||||
const onLayout = React.useCallback(
|
const onLayout = React.useCallback(
|
||||||
|
@ -231,7 +242,6 @@ export function ExternalPlayer({
|
||||||
accessibilityIgnoresInvertColors
|
accessibilityIgnoresInvertColors
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<PlaceholderOverlay
|
<PlaceholderOverlay
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
isPlayerActive={isPlayerActive}
|
isPlayerActive={isPlayerActive}
|
||||||
|
@ -274,4 +284,8 @@ const styles = StyleSheet.create({
|
||||||
webview: {
|
webview: {
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
},
|
},
|
||||||
|
gifContainer: {
|
||||||
|
width: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
|
@ -29,9 +29,10 @@ import {faChevronRight} from '@fortawesome/free-solid-svg-icons/faChevronRight'
|
||||||
import {faCircle} from '@fortawesome/free-regular-svg-icons/faCircle'
|
import {faCircle} from '@fortawesome/free-regular-svg-icons/faCircle'
|
||||||
import {faCircleCheck as farCircleCheck} from '@fortawesome/free-regular-svg-icons/faCircleCheck'
|
import {faCircleCheck as farCircleCheck} from '@fortawesome/free-regular-svg-icons/faCircleCheck'
|
||||||
import {faCircleCheck} from '@fortawesome/free-solid-svg-icons/faCircleCheck'
|
import {faCircleCheck} from '@fortawesome/free-solid-svg-icons/faCircleCheck'
|
||||||
import {faCircleExclamation} from '@fortawesome/free-solid-svg-icons/faCircleExclamation'
|
|
||||||
import {faCircleUser} from '@fortawesome/free-regular-svg-icons/faCircleUser'
|
|
||||||
import {faCircleDot} from '@fortawesome/free-solid-svg-icons/faCircleDot'
|
import {faCircleDot} from '@fortawesome/free-solid-svg-icons/faCircleDot'
|
||||||
|
import {faCircleExclamation} from '@fortawesome/free-solid-svg-icons/faCircleExclamation'
|
||||||
|
import {faCirclePlay} from '@fortawesome/free-regular-svg-icons/faCirclePlay'
|
||||||
|
import {faCircleUser} from '@fortawesome/free-regular-svg-icons/faCircleUser'
|
||||||
import {faClone} from '@fortawesome/free-solid-svg-icons/faClone'
|
import {faClone} from '@fortawesome/free-solid-svg-icons/faClone'
|
||||||
import {faClone as farClone} from '@fortawesome/free-regular-svg-icons/faClone'
|
import {faClone as farClone} from '@fortawesome/free-regular-svg-icons/faClone'
|
||||||
import {faComment} from '@fortawesome/free-regular-svg-icons/faComment'
|
import {faComment} from '@fortawesome/free-regular-svg-icons/faComment'
|
||||||
|
@ -129,9 +130,10 @@ library.add(
|
||||||
faCircle,
|
faCircle,
|
||||||
faCircleCheck,
|
faCircleCheck,
|
||||||
farCircleCheck,
|
farCircleCheck,
|
||||||
faCircleExclamation,
|
|
||||||
faCircleUser,
|
|
||||||
faCircleDot,
|
faCircleDot,
|
||||||
|
faCircleExclamation,
|
||||||
|
faCirclePlay,
|
||||||
|
faCircleUser,
|
||||||
faClone,
|
faClone,
|
||||||
farClone,
|
farClone,
|
||||||
faComment,
|
faComment,
|
||||||
|
|
|
@ -0,0 +1,138 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {StyleSheet, View} from 'react-native'
|
||||||
|
import {useFocusEffect} from '@react-navigation/native'
|
||||||
|
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
|
||||||
|
import {s} from 'lib/styles'
|
||||||
|
import {Text} from '../com/util/text/Text'
|
||||||
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
|
import {useAnalytics} from 'lib/analytics/analytics'
|
||||||
|
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||||
|
import {
|
||||||
|
EmbedPlayerSource,
|
||||||
|
externalEmbedLabels,
|
||||||
|
} from '#/lib/strings/embed-player'
|
||||||
|
import {useSetMinimalShellMode} from '#/state/shell'
|
||||||
|
import {Trans} from '@lingui/macro'
|
||||||
|
import {ScrollView} from '../com/util/Views'
|
||||||
|
import {
|
||||||
|
useExternalEmbedsPrefs,
|
||||||
|
useSetExternalEmbedPref,
|
||||||
|
} from 'state/preferences'
|
||||||
|
import {ToggleButton} from 'view/com/util/forms/ToggleButton'
|
||||||
|
import {SimpleViewHeader} from '../com/util/SimpleViewHeader'
|
||||||
|
|
||||||
|
type Props = NativeStackScreenProps<
|
||||||
|
CommonNavigatorParams,
|
||||||
|
'PreferencesExternalEmbeds'
|
||||||
|
>
|
||||||
|
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 (
|
||||||
|
<View style={s.hContentRegion} testID="preferencesExternalEmbedsScreen">
|
||||||
|
<SimpleViewHeader
|
||||||
|
showBackButton={isMobile}
|
||||||
|
style={[
|
||||||
|
pal.border,
|
||||||
|
{borderBottomWidth: 1},
|
||||||
|
!isMobile && {borderLeftWidth: 1, borderRightWidth: 1},
|
||||||
|
]}>
|
||||||
|
<View style={{flex: 1}}>
|
||||||
|
<Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}>
|
||||||
|
<Trans>External Media Preferences</Trans>
|
||||||
|
</Text>
|
||||||
|
<Text style={pal.textLight}>
|
||||||
|
<Trans>Customize media from external sites.</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</SimpleViewHeader>
|
||||||
|
<ScrollView
|
||||||
|
// @ts-ignore web only -prf
|
||||||
|
dataSet={{'stable-gutters': 1}}
|
||||||
|
contentContainerStyle={[pal.viewLight, {paddingBottom: 200}]}>
|
||||||
|
<View style={[pal.view]}>
|
||||||
|
<View style={styles.infoCard}>
|
||||||
|
<Text style={pal.text}>
|
||||||
|
<Trans>
|
||||||
|
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.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text type="xl-bold" style={[pal.text, styles.heading]}>
|
||||||
|
Enable media players for
|
||||||
|
</Text>
|
||||||
|
{Object.entries(externalEmbedLabels).map(([key, label]) => (
|
||||||
|
<PrefSelector
|
||||||
|
source={key as EmbedPlayerSource}
|
||||||
|
label={label}
|
||||||
|
key={key}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PrefSelector({
|
||||||
|
source,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
source: EmbedPlayerSource
|
||||||
|
label: string
|
||||||
|
}) {
|
||||||
|
const pal = usePalette('default')
|
||||||
|
const setExternalEmbedPref = useSetExternalEmbedPref()
|
||||||
|
const sources = useExternalEmbedsPrefs()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<View style={[pal.view, styles.toggleCard]}>
|
||||||
|
<ToggleButton
|
||||||
|
type="default-light"
|
||||||
|
label={label}
|
||||||
|
labelType="lg"
|
||||||
|
isSelected={sources?.[source] === 'show'}
|
||||||
|
onPress={() =>
|
||||||
|
setExternalEmbedPref(
|
||||||
|
source,
|
||||||
|
sources?.[source] === 'show' ? 'hide' : 'show',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
})
|
|
@ -563,6 +563,39 @@ export function SettingsScreen({}: Props) {
|
||||||
<Trans>Moderation</Trans>
|
<Trans>Moderation</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<View style={styles.spacer20} />
|
||||||
|
|
||||||
|
<Text type="xl-bold" style={[pal.text, styles.heading]}>
|
||||||
|
<Trans>Privacy</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
testID="externalEmbedsBtn"
|
||||||
|
style={[
|
||||||
|
styles.linkCard,
|
||||||
|
pal.view,
|
||||||
|
isSwitchingAccounts && styles.dimmed,
|
||||||
|
]}
|
||||||
|
onPress={
|
||||||
|
isSwitchingAccounts
|
||||||
|
? undefined
|
||||||
|
: () => navigation.navigate('PreferencesExternalEmbeds')
|
||||||
|
}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityHint=""
|
||||||
|
accessibilityLabel={_(msg`Opens external embeds settings`)}>
|
||||||
|
<View style={[styles.iconContainer, pal.btn]}>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={['far', 'circle-play']}
|
||||||
|
style={pal.text as FontAwesomeIconStyle}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text type="lg" style={pal.text}>
|
||||||
|
<Trans>External Media Preferences</Trans>
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
<View style={styles.spacer20} />
|
<View style={styles.spacer20} />
|
||||||
|
|
||||||
<Text type="xl-bold" style={[pal.text, styles.heading]}>
|
<Text type="xl-bold" style={[pal.text, styles.heading]}>
|
||||||
|
|
|
@ -18304,13 +18304,6 @@ react-native-webview@^13.6.3:
|
||||||
escape-string-regexp "2.0.0"
|
escape-string-regexp "2.0.0"
|
||||||
invariant "2.2.4"
|
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:
|
react-native@0.73.1:
|
||||||
version "0.73.1"
|
version "0.73.1"
|
||||||
resolved "https://registry.yarnpkg.com/react-native/-/react-native-0.73.1.tgz#5eafaa7e54feeab8b55e8b8e4efc4d21052a4fff"
|
resolved "https://registry.yarnpkg.com/react-native/-/react-native-0.73.1.tgz#5eafaa7e54feeab8b55e8b8e4efc4d21052a4fff"
|
||||||
|
|
Loading…
Reference in New Issue