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
Paul Frazee 2024-01-04 17:37:36 -08:00 committed by GitHub
parent db62f27241
commit 0dae24e78f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 1240 additions and 131 deletions

View File

@ -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', () => {

View File

@ -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)

View File

@ -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",

View File

@ -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,
}}
/>
</> </>
) )
} }

View File

@ -147,6 +147,7 @@ interface ScreenPropertiesMap {
Settings: {} Settings: {}
AppPasswords: {} AppPasswords: {}
Moderation: {} Moderation: {}
PreferencesExternalEmbeds: {}
BlockedAccounts: {} BlockedAccounts: {}
MutedAccounts: {} MutedAccounts: {}
SavedFeeds: {} SavedFeeds: {}

View File

@ -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',

View File

@ -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 & {

View File

@ -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',
playerUri: string 'vimeo',
} 'giphy',
| {type: 'spotify_song'; songId: string; playerUri: string} 'tenor',
| {type: 'soundcloud_track'; user: string; track: string; playerUri: string} ] as const
| {type: 'soundcloud_set'; user: string; set: string; playerUri: string}
export type EmbedPlayerSource = (typeof embedPlayerSources)[number]
export type EmbedPlayerType =
| 'youtube_video'
| 'youtube_short'
| 'twitch_video'
| 'spotify_album'
| 'spotify_playlist'
| 'spotify_song'
| 'soundcloud_track'
| 'soundcloud_set'
| 'apple_music_playlist'
| 'apple_music_album'
| 'apple_music_song'
| 'vimeo_video'
| 'giphy_gif'
| 'tenor_gif'
export const externalEmbedLabels: Record<EmbedPlayerSource, string> = {
youtube: 'YouTube',
youtubeShorts: 'YouTube Shorts',
vimeo: 'Vimeo',
twitch: 'Twitch',
giphy: 'GIPHY',
tenor: 'Tenor',
spotify: 'Spotify',
appleMusic: 'Apple Music',
soundcloud: 'SoundCloud',
}
export interface EmbedPlayerParams {
type: EmbedPlayerType
playerUri: string
isGif?: boolean
source: EmbedPlayerSource
metaUri?: string
hideDetails?: boolean
}
const giphyRegex = /media(?:[0-4]\.giphy\.com|\.giphy\.com)/i
const gifFilenameRegex = /^(\S+)\.(webp|gif|mp4)$/i
export function parseEmbedPlayerFromUrl( 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
}
}
}

View File

@ -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',

View File

@ -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

View File

@ -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,
} }
} }

View File

@ -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: [],

View File

@ -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)
}

View File

@ -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>
<HiddenPostsProvider>{children}</HiddenPostsProvider> <ExternalEmbedsProvider>
<HiddenPostsProvider>{children}</HiddenPostsProvider>
</ExternalEmbedsProvider>
</AltTextRequiredProvider> </AltTextRequiredProvider>
</LanguagesProvider> </LanguagesProvider>
) )

View File

@ -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,
},
})

View File

@ -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
} }

View File

@ -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
} }

View File

@ -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',
},
})

View File

@ -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 && (
<ExternalPlayer link={link} params={embedPlayerParams} /> <ExternalGifEmbed link={link} params={embedPlayerParams} />
)} )) ||
(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>
<Text type="lg-bold" numberOfLines={4} style={[pal.text]}> {!embedPlayerParams?.isGif && (
{link.title || link.uri} <Text type="lg-bold" numberOfLines={4} style={[pal.text]}>
</Text> {link.title || link.uri}
{link.description ? ( </Text>
)}
{link.description && !embedPlayerParams?.hideDetails ? (
<Text <Text
type="md" type="md"
numberOfLines={4} numberOfLines={4}

View File

@ -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,31 +89,21 @@ function Player({
return ( return (
<View style={[styles.layer, styles.playerLayer]}> <View style={[styles.layer, styles.playerLayer]}>
<EventStopper> <EventStopper>
{isNative && params.type === 'youtube_video' ? ( <View style={{height, width: '100%'}}>
<YoutubePlayer <WebView
videoId={params.videoId} javaScriptEnabled={true}
play onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
height={height} mediaPlaybackRequiresUserAction={false}
onReady={onLoad} allowsInlineMediaPlayback
webViewStyle={[styles.webview, styles.topRadius]} bounces={false}
allowsFullscreenVideo
nestedScrollEnabled
source={{uri: params.playerUri}}
onLoad={onLoad}
setSupportMultipleWindows={false} // Prevent any redirects from opening a new window (ads)
style={[styles.webview, styles.topRadius]}
/> />
) : ( </View>
<View style={{height, width: '100%'}}>
<WebView
javaScriptEnabled={true}
onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
mediaPlaybackRequiresUserAction={false}
allowsInlineMediaPlayback
bounces={false}
allowsFullscreenVideo
nestedScrollEnabled
source={{uri: params.playerUri}}
onLoad={onLoad}
setSupportMultipleWindows={false} // Prevent any redirects from opening a new window (ads)
style={[styles.webview, styles.topRadius]}
/>
</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(
// Prevent this from propagating upward on web (event: GestureResponderEvent) => {
event.preventDefault() // Prevent this from propagating upward on web
event.preventDefault()
setPlayerActive(true) if (externalEmbedsPrefs?.[params.source] === undefined) {
}, []) openModal({
name: 'embed-consent',
source: params.source,
onAccept: () => {
setPlayerActive(true)
},
})
return
}
setPlayerActive(true)
},
[externalEmbedsPrefs, openModal, params.source],
)
// measure the layout to set sizing // 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',
},
}) })

View File

@ -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,

View File

@ -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,
},
})

View File

@ -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]}>

View File

@ -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"