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&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',
|
||||
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_album',
|
||||
albumId: 'albumId',
|
||||
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: 'spotify_album',
|
||||
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', () => {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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<AllNavigatorParams>()
|
||||
|
@ -243,6 +244,14 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
|||
getComponent={() => PreferencesThreads}
|
||||
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: {}
|
||||
AppPasswords: {}
|
||||
Moderation: {}
|
||||
PreferencesExternalEmbeds: {}
|
||||
BlockedAccounts: {}
|
||||
MutedAccounts: {}
|
||||
SavedFeeds: {}
|
||||
|
|
|
@ -2,6 +2,7 @@ import {BskyAgent} from '@atproto/api'
|
|||
import {isBskyAppUrl} from '../strings/url-helpers'
|
||||
import {extractBskyMeta} from './bsky'
|
||||
import {LINK_META_PROXY} from 'lib/constants'
|
||||
import {getGiphyMetaUri} from 'lib/strings/embed-player'
|
||||
|
||||
export enum LikelyType {
|
||||
HTML,
|
||||
|
@ -34,6 +35,13 @@ export async function getLinkMeta(
|
|||
let urlp
|
||||
try {
|
||||
urlp = new URL(url)
|
||||
|
||||
// Get Giphy meta uri if this is any form of giphy link
|
||||
const giphyMetaUri = getGiphyMetaUri(urlp)
|
||||
if (giphyMetaUri) {
|
||||
url = giphyMetaUri
|
||||
urlp = new URL(url)
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
error: 'Invalid URL',
|
||||
|
|
|
@ -32,6 +32,7 @@ export type CommonNavigatorParams = {
|
|||
SavedFeeds: undefined
|
||||
PreferencesHomeFeed: undefined
|
||||
PreferencesThreads: undefined
|
||||
PreferencesExternalEmbeds: undefined
|
||||
}
|
||||
|
||||
export type BottomTabNavigatorParams = CommonNavigatorParams & {
|
||||
|
|
|
@ -1,17 +1,59 @@
|
|||
import {Platform} from 'react-native'
|
||||
import {Dimensions, Platform} from 'react-native'
|
||||
const {height: SCREEN_HEIGHT} = Dimensions.get('window')
|
||||
|
||||
export type EmbedPlayerParams =
|
||||
| {type: 'youtube_video'; videoId: string; playerUri: string}
|
||||
| {type: 'twitch_live'; channelId: string; playerUri: string}
|
||||
| {type: 'spotify_album'; albumId: string; playerUri: string}
|
||||
| {
|
||||
type: 'spotify_playlist'
|
||||
playlistId: string
|
||||
playerUri: string
|
||||
export const embedPlayerSources = [
|
||||
'youtube',
|
||||
'youtubeShorts',
|
||||
'twitch',
|
||||
'spotify',
|
||||
'soundcloud',
|
||||
'appleMusic',
|
||||
'vimeo',
|
||||
'giphy',
|
||||
'tenor',
|
||||
] as const
|
||||
|
||||
export type EmbedPlayerSource = (typeof embedPlayerSources)[number]
|
||||
|
||||
export type EmbedPlayerType =
|
||||
| 'youtube_video'
|
||||
| 'youtube_short'
|
||||
| 'twitch_video'
|
||||
| 'spotify_album'
|
||||
| 'spotify_playlist'
|
||||
| 'spotify_song'
|
||||
| 'soundcloud_track'
|
||||
| 'soundcloud_set'
|
||||
| 'apple_music_playlist'
|
||||
| 'apple_music_album'
|
||||
| 'apple_music_song'
|
||||
| 'vimeo_video'
|
||||
| 'giphy_gif'
|
||||
| 'tenor_gif'
|
||||
|
||||
export const externalEmbedLabels: Record<EmbedPlayerSource, string> = {
|
||||
youtube: 'YouTube',
|
||||
youtubeShorts: 'YouTube Shorts',
|
||||
vimeo: 'Vimeo',
|
||||
twitch: 'Twitch',
|
||||
giphy: 'GIPHY',
|
||||
tenor: 'Tenor',
|
||||
spotify: 'Spotify',
|
||||
appleMusic: 'Apple Music',
|
||||
soundcloud: 'SoundCloud',
|
||||
}
|
||||
| {type: 'spotify_song'; songId: string; playerUri: string}
|
||||
| {type: 'soundcloud_track'; user: string; track: string; playerUri: string}
|
||||
| {type: 'soundcloud_set'; user: string; set: string; playerUri: string}
|
||||
|
||||
export interface EmbedPlayerParams {
|
||||
type: EmbedPlayerType
|
||||
playerUri: string
|
||||
isGif?: boolean
|
||||
source: EmbedPlayerSource
|
||||
metaUri?: string
|
||||
hideDetails?: boolean
|
||||
}
|
||||
|
||||
const giphyRegex = /media(?:[0-4]\.giphy\.com|\.giphy\.com)/i
|
||||
const gifFilenameRegex = /^(\S+)\.(webp|gif|mp4)$/i
|
||||
|
||||
export function parseEmbedPlayerFromUrl(
|
||||
url: string,
|
||||
|
@ -29,63 +71,88 @@ export function parseEmbedPlayerFromUrl(
|
|||
if (videoId) {
|
||||
return {
|
||||
type: 'youtube_video',
|
||||
videoId,
|
||||
playerUri: `https://www.youtube.com/embed/${videoId}?autoplay=1`,
|
||||
source: 'youtube',
|
||||
playerUri: `https://www.youtube.com/embed/${videoId}?autoplay=1&playsinline=1`,
|
||||
}
|
||||
}
|
||||
}
|
||||
if (urlp.hostname === 'www.youtube.com' || urlp.hostname === 'youtube.com') {
|
||||
if (
|
||||
urlp.hostname === 'www.youtube.com' ||
|
||||
urlp.hostname === 'youtube.com' ||
|
||||
urlp.hostname === 'm.youtube.com'
|
||||
) {
|
||||
const [_, page, shortVideoId] = urlp.pathname.split('/')
|
||||
const videoId =
|
||||
page === 'shorts' ? shortVideoId : (urlp.searchParams.get('v') as string)
|
||||
|
||||
if (videoId) {
|
||||
return {
|
||||
type: 'youtube_video',
|
||||
videoId,
|
||||
playerUri: `https://www.youtube.com/embed/${videoId}?autoplay=1`,
|
||||
type: page === 'shorts' ? 'youtube_short' : 'youtube_video',
|
||||
source: page === 'shorts' ? 'youtubeShorts' : 'youtube',
|
||||
hideDetails: page === 'shorts' ? true : undefined,
|
||||
playerUri: `https://www.youtube.com/embed/${videoId}?autoplay=1&playsinline=1`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// twitch
|
||||
if (urlp.hostname === 'twitch.tv' || urlp.hostname === 'www.twitch.tv') {
|
||||
if (
|
||||
urlp.hostname === 'twitch.tv' ||
|
||||
urlp.hostname === 'www.twitch.tv' ||
|
||||
urlp.hostname === 'm.twitch.tv'
|
||||
) {
|
||||
const parent =
|
||||
Platform.OS === 'web' ? window.location.hostname : 'localhost'
|
||||
|
||||
const parts = urlp.pathname.split('/')
|
||||
if (parts.length === 2 && parts[1]) {
|
||||
const [_, channelOrVideo, clipOrId, id] = urlp.pathname.split('/')
|
||||
|
||||
if (channelOrVideo === 'videos') {
|
||||
return {
|
||||
type: 'twitch_live',
|
||||
channelId: parts[1],
|
||||
playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&channel=${parts[1]}&parent=${parent}`,
|
||||
type: 'twitch_video',
|
||||
source: 'twitch',
|
||||
playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&video=${clipOrId}&parent=${parent}`,
|
||||
}
|
||||
} else if (clipOrId === 'clip') {
|
||||
return {
|
||||
type: 'twitch_video',
|
||||
source: 'twitch',
|
||||
playerUri: `https://clips.twitch.tv/embed?volume=0.5&autoplay=true&clip=${id}&parent=${parent}`,
|
||||
}
|
||||
} else if (channelOrVideo) {
|
||||
return {
|
||||
type: 'twitch_video',
|
||||
source: 'twitch',
|
||||
playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&channel=${channelOrVideo}&parent=${parent}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// spotify
|
||||
if (urlp.hostname === 'open.spotify.com') {
|
||||
const [_, type, id] = urlp.pathname.split('/')
|
||||
if (type && id) {
|
||||
if (type === 'playlist') {
|
||||
const [_, typeOrLocale, idOrType, id] = urlp.pathname.split('/')
|
||||
|
||||
if (idOrType) {
|
||||
if (typeOrLocale === 'playlist' || idOrType === 'playlist') {
|
||||
return {
|
||||
type: 'spotify_playlist',
|
||||
playlistId: id,
|
||||
playerUri: `https://open.spotify.com/embed/playlist/${id}`,
|
||||
source: 'spotify',
|
||||
playerUri: `https://open.spotify.com/embed/playlist/${
|
||||
id ?? idOrType
|
||||
}`,
|
||||
}
|
||||
}
|
||||
if (type === 'album') {
|
||||
if (typeOrLocale === 'album' || idOrType === 'album') {
|
||||
return {
|
||||
type: 'spotify_album',
|
||||
albumId: id,
|
||||
playerUri: `https://open.spotify.com/embed/album/${id}`,
|
||||
source: 'spotify',
|
||||
playerUri: `https://open.spotify.com/embed/album/${id ?? idOrType}`,
|
||||
}
|
||||
}
|
||||
if (type === 'track') {
|
||||
if (typeOrLocale === 'track' || idOrType === 'track') {
|
||||
return {
|
||||
type: 'spotify_song',
|
||||
songId: id,
|
||||
playerUri: `https://open.spotify.com/embed/track/${id}`,
|
||||
source: 'spotify',
|
||||
playerUri: `https://open.spotify.com/embed/track/${id ?? idOrType}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -102,20 +169,170 @@ export function parseEmbedPlayerFromUrl(
|
|||
if (trackOrSets === 'sets' && set) {
|
||||
return {
|
||||
type: 'soundcloud_set',
|
||||
user,
|
||||
set: set,
|
||||
source: 'soundcloud',
|
||||
playerUri: `https://w.soundcloud.com/player/?url=${url}&auto_play=true&visual=false&hide_related=true`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'soundcloud_track',
|
||||
user,
|
||||
track: trackOrSets,
|
||||
source: 'soundcloud',
|
||||
playerUri: `https://w.soundcloud.com/player/?url=${url}&auto_play=true&visual=false&hide_related=true`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
urlp.hostname === 'music.apple.com' ||
|
||||
urlp.hostname === 'music.apple.com'
|
||||
) {
|
||||
// This should always have: locale, type (playlist or album), name, and id. We won't use spread since we want
|
||||
// to check if the length is correct
|
||||
const pathParams = urlp.pathname.split('/')
|
||||
const type = pathParams[2]
|
||||
const songId = urlp.searchParams.get('i')
|
||||
|
||||
if (pathParams.length === 5 && (type === 'playlist' || type === 'album')) {
|
||||
// We want to append the songId to the end of the url if it exists
|
||||
const embedUri = `https://embed.music.apple.com${urlp.pathname}${
|
||||
urlp.search ? '?i=' + songId : ''
|
||||
}`
|
||||
|
||||
if (type === 'playlist') {
|
||||
return {
|
||||
type: 'apple_music_playlist',
|
||||
source: 'appleMusic',
|
||||
playerUri: embedUri,
|
||||
}
|
||||
} else if (type === 'album') {
|
||||
if (songId) {
|
||||
return {
|
||||
type: 'apple_music_song',
|
||||
source: 'appleMusic',
|
||||
playerUri: embedUri,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
type: 'apple_music_album',
|
||||
source: 'appleMusic',
|
||||
playerUri: embedUri,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (urlp.hostname === 'vimeo.com' || urlp.hostname === 'www.vimeo.com') {
|
||||
const [_, videoId] = urlp.pathname.split('/')
|
||||
if (videoId) {
|
||||
return {
|
||||
type: 'vimeo_video',
|
||||
source: 'vimeo',
|
||||
playerUri: `https://player.vimeo.com/video/${videoId}?autoplay=1`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (urlp.hostname === 'giphy.com' || urlp.hostname === 'www.giphy.com') {
|
||||
const [_, gifs, nameAndId] = urlp.pathname.split('/')
|
||||
|
||||
/*
|
||||
* nameAndId is a string that consists of the name (dash separated) and the id of the gif (the last part of the name)
|
||||
* We want to get the id of the gif, then direct to media.giphy.com/media/{id}/giphy.webp so we can
|
||||
* use it in an <Image> component
|
||||
*/
|
||||
|
||||
if (gifs === 'gifs' && nameAndId) {
|
||||
const gifId = nameAndId.split('-').pop()
|
||||
|
||||
if (gifId) {
|
||||
return {
|
||||
type: 'giphy_gif',
|
||||
source: 'giphy',
|
||||
isGif: true,
|
||||
hideDetails: true,
|
||||
metaUri: `https://giphy.com/gifs/${gifId}`,
|
||||
playerUri: `https://i.giphy.com/media/${gifId}/giphy.webp`,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// There are five possible hostnames that also can be giphy urls: media.giphy.com and media0-4.giphy.com
|
||||
// These can include (presumably) a tracking id in the path name, so we have to check for that as well
|
||||
if (giphyRegex.test(urlp.hostname)) {
|
||||
// We can link directly to the gif, if its a proper link
|
||||
const [_, media, trackingOrId, idOrFilename, filename] =
|
||||
urlp.pathname.split('/')
|
||||
|
||||
if (media === 'media') {
|
||||
if (idOrFilename && gifFilenameRegex.test(idOrFilename)) {
|
||||
return {
|
||||
type: 'giphy_gif',
|
||||
source: 'giphy',
|
||||
isGif: true,
|
||||
hideDetails: true,
|
||||
metaUri: `https://giphy.com/gifs/${trackingOrId}`,
|
||||
playerUri: `https://i.giphy.com/media/${trackingOrId}/giphy.webp`,
|
||||
}
|
||||
} else if (filename && gifFilenameRegex.test(filename)) {
|
||||
return {
|
||||
type: 'giphy_gif',
|
||||
source: 'giphy',
|
||||
isGif: true,
|
||||
hideDetails: true,
|
||||
metaUri: `https://giphy.com/gifs/${idOrFilename}`,
|
||||
playerUri: `https://i.giphy.com/media/${idOrFilename}/giphy.webp`,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Finally, we should see if it is a link to i.giphy.com. These links don't necessarily end in .gif but can also
|
||||
// be .webp
|
||||
if (urlp.hostname === 'i.giphy.com' || urlp.hostname === 'www.i.giphy.com') {
|
||||
const [_, mediaOrFilename, filename] = urlp.pathname.split('/')
|
||||
|
||||
if (mediaOrFilename === 'media' && filename) {
|
||||
const gifId = filename.split('.')[0]
|
||||
return {
|
||||
type: 'giphy_gif',
|
||||
source: 'giphy',
|
||||
isGif: true,
|
||||
hideDetails: true,
|
||||
metaUri: `https://giphy.com/gifs/${gifId}`,
|
||||
playerUri: `https://i.giphy.com/media/${gifId}/giphy.webp`,
|
||||
}
|
||||
} else if (mediaOrFilename) {
|
||||
const gifId = mediaOrFilename.split('.')[0]
|
||||
return {
|
||||
type: 'giphy_gif',
|
||||
source: 'giphy',
|
||||
isGif: true,
|
||||
hideDetails: true,
|
||||
metaUri: `https://giphy.com/gifs/${gifId}`,
|
||||
playerUri: `https://i.giphy.com/media/${
|
||||
mediaOrFilename.split('.')[0]
|
||||
}/giphy.webp`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (urlp.hostname === 'tenor.com' || urlp.hostname === 'www.tenor.com') {
|
||||
const [_, path, filename] = urlp.pathname.split('/')
|
||||
|
||||
if (path === 'view' && filename) {
|
||||
const includesExt = filename.split('.').pop() === 'gif'
|
||||
|
||||
return {
|
||||
type: 'tenor_gif',
|
||||
source: 'tenor',
|
||||
isGif: true,
|
||||
hideDetails: true,
|
||||
playerUri: `${url}${!includesExt ? '.gif' : ''}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getPlayerHeight({
|
||||
|
@ -131,22 +348,53 @@ export function getPlayerHeight({
|
|||
|
||||
switch (type) {
|
||||
case 'youtube_video':
|
||||
case 'twitch_live':
|
||||
case 'twitch_video':
|
||||
case 'vimeo_video':
|
||||
return (width / 16) * 9
|
||||
case 'youtube_short':
|
||||
if (SCREEN_HEIGHT < 600) {
|
||||
return ((width / 9) * 16) / 1.75
|
||||
} else {
|
||||
return ((width / 9) * 16) / 1.5
|
||||
}
|
||||
case 'spotify_album':
|
||||
return 380
|
||||
case 'apple_music_album':
|
||||
case 'apple_music_playlist':
|
||||
case 'spotify_playlist':
|
||||
return 360
|
||||
case 'soundcloud_set':
|
||||
return 380
|
||||
case 'spotify_song':
|
||||
if (width <= 300) {
|
||||
return 180
|
||||
return 155
|
||||
}
|
||||
return 232
|
||||
case 'soundcloud_track':
|
||||
return 165
|
||||
case 'soundcloud_set':
|
||||
return 360
|
||||
case 'apple_music_song':
|
||||
return 150
|
||||
default:
|
||||
return width
|
||||
}
|
||||
}
|
||||
|
||||
export function getGifDims(
|
||||
originalHeight: number,
|
||||
originalWidth: number,
|
||||
viewWidth: number,
|
||||
) {
|
||||
const scaledHeight = (originalHeight / originalWidth) * viewWidth
|
||||
|
||||
return {
|
||||
height: scaledHeight > 250 ? 250 : scaledHeight,
|
||||
width: (250 / scaledHeight) * viewWidth,
|
||||
}
|
||||
}
|
||||
|
||||
export function getGiphyMetaUri(url: URL) {
|
||||
if (giphyRegex.test(url.hostname) || url.hostname === 'i.giphy.com') {
|
||||
const params = parseEmbedPlayerFromUrl(url.toString())
|
||||
if (params && params.type === 'giphy_gif') {
|
||||
return params.metaUri
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -109,6 +109,7 @@ export function transform(legacy: Partial<LegacySchema>): Schema {
|
|||
step: legacy.onboarding?.step || defaults.onboarding.step,
|
||||
},
|
||||
hiddenPosts: defaults.hiddenPosts,
|
||||
externalEmbeds: defaults.externalEmbeds,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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: [],
|
||||
|
|
|
@ -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 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 (
|
||||
<LanguagesProvider>
|
||||
<AltTextRequiredProvider>
|
||||
<ExternalEmbedsProvider>
|
||||
<HiddenPostsProvider>{children}</HiddenPostsProvider>
|
||||
</ExternalEmbedsProvider>
|
||||
</AltTextRequiredProvider>
|
||||
</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 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 = <LinkWarningModal.Component {...activeModal} />
|
||||
} else if (activeModal?.name === 'embed-consent') {
|
||||
snapPoints = EmbedConsentModal.snapPoints
|
||||
element = <EmbedConsentModal.Component {...activeModal} />
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
|
|
|
@ -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 = <ChangeEmailModal.Component />
|
||||
} else if (modal.name === 'link-warning') {
|
||||
element = <LinkWarningModal.Component {...modal} />
|
||||
} else if (modal.name === 'embed-consent') {
|
||||
element = <EmbedConsentModal.Component {...modal} />
|
||||
} else {
|
||||
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 {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 (
|
||||
<View style={{flexDirection: 'column'}}>
|
||||
|
@ -40,9 +46,12 @@ export const ExternalLinkEmbed = ({
|
|||
/>
|
||||
</View>
|
||||
) : undefined}
|
||||
{embedPlayerParams && (
|
||||
{(embedPlayerParams?.isGif && (
|
||||
<ExternalGifEmbed link={link} params={embedPlayerParams} />
|
||||
)) ||
|
||||
(embedPlayerParams && (
|
||||
<ExternalPlayer link={link} params={embedPlayerParams} />
|
||||
)}
|
||||
))}
|
||||
<View
|
||||
style={{
|
||||
paddingHorizontal: isMobile ? 10 : 14,
|
||||
|
@ -55,10 +64,12 @@ export const ExternalLinkEmbed = ({
|
|||
style={[pal.textLight, styles.extUri]}>
|
||||
{toNiceDomain(link.uri)}
|
||||
</Text>
|
||||
{!embedPlayerParams?.isGif && (
|
||||
<Text type="lg-bold" numberOfLines={4} style={[pal.text]}>
|
||||
{link.title || link.uri}
|
||||
</Text>
|
||||
{link.description ? (
|
||||
)}
|
||||
{link.description && !embedPlayerParams?.hideDetails ? (
|
||||
<Text
|
||||
type="md"
|
||||
numberOfLines={4}
|
||||
|
|
|
@ -16,14 +16,17 @@ import Animated, {
|
|||
import {Image} from 'expo-image'
|
||||
import {WebView} from 'react-native-webview'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import YoutubePlayer from 'react-native-youtube-iframe'
|
||||
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 {EventStopper} from '../EventStopper'
|
||||
import {AppBskyEmbedExternal} from '@atproto/api'
|
||||
import {isNative} from 'platform/detection'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {useExternalEmbedsPrefs} from 'state/preferences'
|
||||
import {useModalControls} from 'state/modals'
|
||||
|
||||
interface ShouldStartLoadRequest {
|
||||
url: string
|
||||
|
@ -39,6 +42,8 @@ function PlaceholderOverlay({
|
|||
isPlayerActive: boolean
|
||||
onPress: (event: GestureResponderEvent) => 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({
|
|||
<View style={[styles.layer, styles.overlayLayer]}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Play Video"
|
||||
accessibilityHint=""
|
||||
accessibilityLabel={_(msg`Play Video`)}
|
||||
accessibilityHint={_(msg`Play Video`)}
|
||||
onPress={onPress}
|
||||
style={[styles.overlayContainer, styles.topRadius]}>
|
||||
{!isPlayerActive ? (
|
||||
|
@ -84,15 +89,6 @@ function Player({
|
|||
return (
|
||||
<View style={[styles.layer, styles.playerLayer]}>
|
||||
<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%'}}>
|
||||
<WebView
|
||||
javaScriptEnabled={true}
|
||||
|
@ -108,7 +104,6 @@ function Player({
|
|||
style={[styles.webview, styles.topRadius]}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</EventStopper>
|
||||
</View>
|
||||
)
|
||||
|
@ -125,6 +120,8 @@ export function ExternalPlayer({
|
|||
const navigation = useNavigation<NavigationProp>()
|
||||
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) => {
|
||||
const onPlayPress = React.useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
// Prevent this from propagating upward on web
|
||||
event.preventDefault()
|
||||
|
||||
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
|
||||
/>
|
||||
)}
|
||||
|
||||
<PlaceholderOverlay
|
||||
isLoading={isLoading}
|
||||
isPlayerActive={isPlayerActive}
|
||||
|
@ -274,4 +284,8 @@ const styles = StyleSheet.create({
|
|||
webview: {
|
||||
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 {faCircleCheck as farCircleCheck} from '@fortawesome/free-regular-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 {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 as farClone} from '@fortawesome/free-regular-svg-icons/faClone'
|
||||
import {faComment} from '@fortawesome/free-regular-svg-icons/faComment'
|
||||
|
@ -129,9 +130,10 @@ library.add(
|
|||
faCircle,
|
||||
faCircleCheck,
|
||||
farCircleCheck,
|
||||
faCircleExclamation,
|
||||
faCircleUser,
|
||||
faCircleDot,
|
||||
faCircleExclamation,
|
||||
faCirclePlay,
|
||||
faCircleUser,
|
||||
faClone,
|
||||
farClone,
|
||||
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>
|
||||
</Text>
|
||||
</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} />
|
||||
|
||||
<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"
|
||||
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"
|
||||
|
|
Loading…
Reference in New Issue