Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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>

authored by

Paul Frazee
Hailey
and committed by
GitHub
0dae24e7 db62f272

+1238 -129
+260 -26
__tests__/lib/string.test.ts
··· 394 394 'https://youtube.com/watch?v=videoId', 395 395 'https://youtube.com/watch?v=videoId&feature=share', 396 396 'https://youtube.com/shorts/videoId', 397 + 'https://m.youtube.com/watch?v=videoId', 397 398 398 399 'https://youtube.com/shorts/', 399 400 'https://youtube.com/', ··· 401 402 402 403 'https://twitch.tv/channelName', 403 404 'https://www.twitch.tv/channelName', 405 + 'https://m.twitch.tv/channelName', 406 + 407 + 'https://twitch.tv/channelName/clip/clipId', 408 + 'https://twitch.tv/videos/videoId', 404 409 405 410 'https://open.spotify.com/playlist/playlistId', 406 411 'https://open.spotify.com/playlist/playlistId?param=value', 412 + 'https://open.spotify.com/locale/playlist/playlistId', 407 413 408 414 'https://open.spotify.com/track/songId', 409 415 'https://open.spotify.com/track/songId?param=value', 416 + 'https://open.spotify.com/locale/track/songId', 410 417 411 418 'https://open.spotify.com/album/albumId', 412 419 'https://open.spotify.com/album/albumId?param=value', 420 + 'https://open.spotify.com/locale/album/albumId', 413 421 414 422 'https://soundcloud.com/user/track', 415 423 'https://soundcloud.com/user/sets/set', 416 424 'https://soundcloud.com/user/', 425 + 426 + 'https://music.apple.com/us/playlist/playlistName/playlistId', 427 + 'https://music.apple.com/us/album/albumName/albumId', 428 + 'https://music.apple.com/us/album/albumName/albumId?i=songId', 429 + 430 + 'https://vimeo.com/videoId', 431 + 'https://vimeo.com/videoId?autoplay=0', 432 + 433 + 'https://giphy.com/gifs/some-random-gif-name-gifId', 434 + 'https://giphy.com/gif/some-random-gif-name-gifId', 435 + 'https://giphy.com/gifs/', 436 + 437 + 'https://media.giphy.com/media/gifId/giphy.webp', 438 + 'https://media0.giphy.com/media/gifId/giphy.webp', 439 + 'https://media1.giphy.com/media/gifId/giphy.gif', 440 + 'https://media2.giphy.com/media/gifId/giphy.webp', 441 + 'https://media3.giphy.com/media/gifId/giphy.mp4', 442 + 'https://media4.giphy.com/media/gifId/giphy.webp', 443 + 'https://media5.giphy.com/media/gifId/giphy.mp4', 444 + 'https://media0.giphy.com/media/gifId/giphy.mp3', 445 + 'https://media1.google.com/media/gifId/giphy.webp', 446 + 447 + 'https://media.giphy.com/media/trackingId/gifId/giphy.webp', 448 + 449 + 'https://i.giphy.com/media/gifId/giphy.webp', 450 + 'https://i.giphy.com/media/gifId/giphy.webp', 451 + 'https://i.giphy.com/gifId.gif', 452 + 'https://i.giphy.com/gifId.gif', 453 + 454 + 'https://tenor.com/view/gifId', 455 + 'https://tenor.com/notView/gifId', 456 + 'https://tenor.com/view', 457 + 'https://tenor.com/view/gifId.gif', 417 458 ] 418 459 419 460 const outputs = [ 420 461 { 421 462 type: 'youtube_video', 422 - videoId: 'videoId', 423 - playerUri: 'https://www.youtube.com/embed/videoId?autoplay=1', 463 + source: 'youtube', 464 + playerUri: 465 + 'https://www.youtube.com/embed/videoId?autoplay=1&playsinline=1', 424 466 }, 425 467 { 426 468 type: 'youtube_video', 427 - videoId: 'videoId', 428 - playerUri: 'https://www.youtube.com/embed/videoId?autoplay=1', 469 + source: 'youtube', 470 + playerUri: 471 + 'https://www.youtube.com/embed/videoId?autoplay=1&playsinline=1', 429 472 }, 430 473 { 431 474 type: 'youtube_video', 432 - videoId: 'videoId', 433 - playerUri: 'https://www.youtube.com/embed/videoId?autoplay=1', 475 + source: 'youtube', 476 + playerUri: 477 + 'https://www.youtube.com/embed/videoId?autoplay=1&playsinline=1', 434 478 }, 435 479 { 436 480 type: 'youtube_video', 437 - videoId: 'videoId', 438 - playerUri: 'https://www.youtube.com/embed/videoId?autoplay=1', 481 + source: 'youtube', 482 + playerUri: 483 + 'https://www.youtube.com/embed/videoId?autoplay=1&playsinline=1', 439 484 }, 440 485 { 441 486 type: 'youtube_video', 442 - videoId: 'videoId', 443 - playerUri: 'https://www.youtube.com/embed/videoId?autoplay=1', 487 + source: 'youtube', 488 + playerUri: 489 + 'https://www.youtube.com/embed/videoId?autoplay=1&playsinline=1', 490 + }, 491 + { 492 + type: 'youtube_short', 493 + source: 'youtubeShorts', 494 + hideDetails: true, 495 + playerUri: 496 + 'https://www.youtube.com/embed/videoId?autoplay=1&playsinline=1', 444 497 }, 445 498 { 446 499 type: 'youtube_video', 447 - videoId: 'videoId', 448 - playerUri: 'https://www.youtube.com/embed/videoId?autoplay=1', 500 + source: 'youtube', 501 + playerUri: 502 + 'https://www.youtube.com/embed/videoId?autoplay=1&playsinline=1', 449 503 }, 504 + 450 505 undefined, 451 506 undefined, 452 507 undefined, 453 508 454 509 { 455 - type: 'twitch_live', 456 - channelId: 'channelName', 510 + type: 'twitch_video', 511 + source: 'twitch', 457 512 playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&channel=channelName&parent=localhost`, 458 513 }, 459 514 { 460 - type: 'twitch_live', 461 - channelId: 'channelName', 515 + type: 'twitch_video', 516 + source: 'twitch', 462 517 playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&channel=channelName&parent=localhost`, 463 518 }, 519 + { 520 + type: 'twitch_video', 521 + source: 'twitch', 522 + playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&channel=channelName&parent=localhost`, 523 + }, 524 + { 525 + type: 'twitch_video', 526 + source: 'twitch', 527 + playerUri: `https://clips.twitch.tv/embed?volume=0.5&autoplay=true&clip=clipId&parent=localhost`, 528 + }, 529 + { 530 + type: 'twitch_video', 531 + source: 'twitch', 532 + playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&video=videoId&parent=localhost`, 533 + }, 464 534 465 535 { 466 536 type: 'spotify_playlist', 467 - playlistId: 'playlistId', 537 + source: 'spotify', 538 + playerUri: `https://open.spotify.com/embed/playlist/playlistId`, 539 + }, 540 + { 541 + type: 'spotify_playlist', 542 + source: 'spotify', 468 543 playerUri: `https://open.spotify.com/embed/playlist/playlistId`, 469 544 }, 470 545 { 471 546 type: 'spotify_playlist', 472 - playlistId: 'playlistId', 547 + source: 'spotify', 473 548 playerUri: `https://open.spotify.com/embed/playlist/playlistId`, 474 549 }, 475 550 476 551 { 477 552 type: 'spotify_song', 478 - songId: 'songId', 553 + source: 'spotify', 479 554 playerUri: `https://open.spotify.com/embed/track/songId`, 480 555 }, 481 556 { 482 557 type: 'spotify_song', 483 - songId: 'songId', 558 + source: 'spotify', 559 + playerUri: `https://open.spotify.com/embed/track/songId`, 560 + }, 561 + { 562 + type: 'spotify_song', 563 + source: 'spotify', 484 564 playerUri: `https://open.spotify.com/embed/track/songId`, 485 565 }, 486 566 487 567 { 488 568 type: 'spotify_album', 489 - albumId: 'albumId', 569 + source: 'spotify', 570 + playerUri: `https://open.spotify.com/embed/album/albumId`, 571 + }, 572 + { 573 + type: 'spotify_album', 574 + source: 'spotify', 490 575 playerUri: `https://open.spotify.com/embed/album/albumId`, 491 576 }, 492 577 { 493 578 type: 'spotify_album', 494 - albumId: 'albumId', 579 + source: 'spotify', 495 580 playerUri: `https://open.spotify.com/embed/album/albumId`, 496 581 }, 497 582 498 583 { 499 584 type: 'soundcloud_track', 500 - user: 'user', 501 - track: 'track', 585 + source: 'soundcloud', 502 586 playerUri: `https://w.soundcloud.com/player/?url=https://soundcloud.com/user/track&auto_play=true&visual=false&hide_related=true`, 503 587 }, 504 588 { 505 589 type: 'soundcloud_set', 506 - user: 'user', 507 - set: 'set', 590 + source: 'soundcloud', 508 591 playerUri: `https://w.soundcloud.com/player/?url=https://soundcloud.com/user/sets/set&auto_play=true&visual=false&hide_related=true`, 509 592 }, 510 593 undefined, 594 + 595 + { 596 + type: 'apple_music_playlist', 597 + source: 'appleMusic', 598 + playerUri: 599 + 'https://embed.music.apple.com/us/playlist/playlistName/playlistId', 600 + }, 601 + { 602 + type: 'apple_music_album', 603 + source: 'appleMusic', 604 + playerUri: 'https://embed.music.apple.com/us/album/albumName/albumId', 605 + }, 606 + { 607 + type: 'apple_music_song', 608 + source: 'appleMusic', 609 + playerUri: 610 + 'https://embed.music.apple.com/us/album/albumName/albumId?i=songId', 611 + }, 612 + 613 + { 614 + type: 'vimeo_video', 615 + source: 'vimeo', 616 + playerUri: 'https://player.vimeo.com/video/videoId?autoplay=1', 617 + }, 618 + { 619 + type: 'vimeo_video', 620 + source: 'vimeo', 621 + playerUri: 'https://player.vimeo.com/video/videoId?autoplay=1', 622 + }, 623 + 624 + { 625 + type: 'giphy_gif', 626 + source: 'giphy', 627 + isGif: true, 628 + hideDetails: true, 629 + metaUri: 'https://giphy.com/gifs/gifId', 630 + playerUri: 'https://i.giphy.com/media/gifId/giphy.webp', 631 + }, 632 + undefined, 633 + undefined, 634 + 635 + { 636 + type: 'giphy_gif', 637 + source: 'giphy', 638 + isGif: true, 639 + hideDetails: true, 640 + metaUri: 'https://giphy.com/gifs/gifId', 641 + playerUri: 'https://i.giphy.com/media/gifId/giphy.webp', 642 + }, 643 + { 644 + type: 'giphy_gif', 645 + source: 'giphy', 646 + isGif: true, 647 + hideDetails: true, 648 + metaUri: 'https://giphy.com/gifs/gifId', 649 + playerUri: 'https://i.giphy.com/media/gifId/giphy.webp', 650 + }, 651 + { 652 + type: 'giphy_gif', 653 + source: 'giphy', 654 + isGif: true, 655 + hideDetails: true, 656 + metaUri: 'https://giphy.com/gifs/gifId', 657 + playerUri: 'https://i.giphy.com/media/gifId/giphy.webp', 658 + }, 659 + { 660 + type: 'giphy_gif', 661 + source: 'giphy', 662 + isGif: true, 663 + hideDetails: true, 664 + metaUri: 'https://giphy.com/gifs/gifId', 665 + playerUri: 'https://i.giphy.com/media/gifId/giphy.webp', 666 + }, 667 + { 668 + type: 'giphy_gif', 669 + source: 'giphy', 670 + isGif: true, 671 + hideDetails: true, 672 + metaUri: 'https://giphy.com/gifs/gifId', 673 + playerUri: 'https://i.giphy.com/media/gifId/giphy.webp', 674 + }, 675 + { 676 + type: 'giphy_gif', 677 + source: 'giphy', 678 + isGif: true, 679 + hideDetails: true, 680 + metaUri: 'https://giphy.com/gifs/gifId', 681 + playerUri: 'https://i.giphy.com/media/gifId/giphy.webp', 682 + }, 683 + undefined, 684 + undefined, 685 + undefined, 686 + 687 + { 688 + type: 'giphy_gif', 689 + source: 'giphy', 690 + isGif: true, 691 + hideDetails: true, 692 + metaUri: 'https://giphy.com/gifs/gifId', 693 + playerUri: 'https://i.giphy.com/media/gifId/giphy.webp', 694 + }, 695 + 696 + { 697 + type: 'giphy_gif', 698 + source: 'giphy', 699 + isGif: true, 700 + hideDetails: true, 701 + metaUri: 'https://giphy.com/gifs/gifId', 702 + playerUri: 'https://i.giphy.com/media/gifId/giphy.webp', 703 + }, 704 + { 705 + type: 'giphy_gif', 706 + source: 'giphy', 707 + isGif: true, 708 + hideDetails: true, 709 + metaUri: 'https://giphy.com/gifs/gifId', 710 + playerUri: 'https://i.giphy.com/media/gifId/giphy.webp', 711 + }, 712 + { 713 + type: 'giphy_gif', 714 + source: 'giphy', 715 + isGif: true, 716 + hideDetails: true, 717 + metaUri: 'https://giphy.com/gifs/gifId', 718 + playerUri: 'https://i.giphy.com/media/gifId/giphy.webp', 719 + }, 720 + { 721 + type: 'giphy_gif', 722 + source: 'giphy', 723 + isGif: true, 724 + hideDetails: true, 725 + metaUri: 'https://giphy.com/gifs/gifId', 726 + playerUri: 'https://i.giphy.com/media/gifId/giphy.webp', 727 + }, 728 + 729 + { 730 + type: 'tenor_gif', 731 + source: 'tenor', 732 + isGif: true, 733 + hideDetails: true, 734 + playerUri: 'https://tenor.com/view/gifId.gif', 735 + }, 736 + undefined, 737 + undefined, 738 + { 739 + type: 'tenor_gif', 740 + source: 'tenor', 741 + isGif: true, 742 + hideDetails: true, 743 + playerUri: 'https://tenor.com/view/gifId.gif', 744 + }, 511 745 ] 512 746 513 747 it('correctly grabs the correct id from uri', () => {
+1
bskyweb/cmd/bskyweb/server.go
··· 193 193 e.GET("/settings/home-feed", server.WebGeneric) 194 194 e.GET("/settings/saved-feeds", server.WebGeneric) 195 195 e.GET("/settings/threads", server.WebGeneric) 196 + e.GET("/settings/external-embeds", server.WebGeneric) 196 197 e.GET("/sys/debug", server.WebGeneric) 197 198 e.GET("/sys/log", server.WebGeneric) 198 199 e.GET("/support", server.WebGeneric)
-1
package.json
··· 166 166 "react-native-web-linear-gradient": "^1.1.2", 167 167 "react-native-web-webview": "^1.0.2", 168 168 "react-native-webview": "^13.6.3", 169 - "react-native-youtube-iframe": "^2.3.0", 170 169 "react-responsive": "^9.0.2", 171 170 "rn-fetch-blob": "^0.12.0", 172 171 "sentry-expo": "~7.0.1",
+9
src/Navigation.tsx
··· 74 74 import {SavedFeeds} from 'view/screens/SavedFeeds' 75 75 import {PreferencesHomeFeed} from 'view/screens/PreferencesHomeFeed' 76 76 import {PreferencesThreads} from 'view/screens/PreferencesThreads' 77 + import {PreferencesExternalEmbeds} from '#/view/screens/PreferencesExternalEmbeds' 77 78 import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStackNavigatorWithAuth' 78 79 79 80 const navigationRef = createNavigationContainerRef<AllNavigatorParams>() ··· 242 243 name="PreferencesThreads" 243 244 getComponent={() => PreferencesThreads} 244 245 options={{title: title('Threads Preferences'), requireAuth: true}} 246 + /> 247 + <Stack.Screen 248 + name="PreferencesExternalEmbeds" 249 + getComponent={() => PreferencesExternalEmbeds} 250 + options={{ 251 + title: title('External Media Preferences'), 252 + requireAuth: true, 253 + }} 245 254 /> 246 255 </> 247 256 )
+1
src/lib/analytics/types.ts
··· 147 147 Settings: {} 148 148 AppPasswords: {} 149 149 Moderation: {} 150 + PreferencesExternalEmbeds: {} 150 151 BlockedAccounts: {} 151 152 MutedAccounts: {} 152 153 SavedFeeds: {}
+1
src/lib/routes/types.ts
··· 32 32 SavedFeeds: undefined 33 33 PreferencesHomeFeed: undefined 34 34 PreferencesThreads: undefined 35 + PreferencesExternalEmbeds: undefined 35 36 } 36 37 37 38 export type BottomTabNavigatorParams = CommonNavigatorParams & {
+294 -46
src/lib/strings/embed-player.ts
··· 1 - import {Platform} from 'react-native' 1 + import {Dimensions, Platform} from 'react-native' 2 + const {height: SCREEN_HEIGHT} = Dimensions.get('window') 3 + 4 + export const embedPlayerSources = [ 5 + 'youtube', 6 + 'youtubeShorts', 7 + 'twitch', 8 + 'spotify', 9 + 'soundcloud', 10 + 'appleMusic', 11 + 'vimeo', 12 + 'giphy', 13 + 'tenor', 14 + ] as const 15 + 16 + export type EmbedPlayerSource = (typeof embedPlayerSources)[number] 17 + 18 + export type EmbedPlayerType = 19 + | 'youtube_video' 20 + | 'youtube_short' 21 + | 'twitch_video' 22 + | 'spotify_album' 23 + | 'spotify_playlist' 24 + | 'spotify_song' 25 + | 'soundcloud_track' 26 + | 'soundcloud_set' 27 + | 'apple_music_playlist' 28 + | 'apple_music_album' 29 + | 'apple_music_song' 30 + | 'vimeo_video' 31 + | 'giphy_gif' 32 + | 'tenor_gif' 33 + 34 + export const externalEmbedLabels: Record<EmbedPlayerSource, string> = { 35 + youtube: 'YouTube', 36 + youtubeShorts: 'YouTube Shorts', 37 + vimeo: 'Vimeo', 38 + twitch: 'Twitch', 39 + giphy: 'GIPHY', 40 + tenor: 'Tenor', 41 + spotify: 'Spotify', 42 + appleMusic: 'Apple Music', 43 + soundcloud: 'SoundCloud', 44 + } 2 45 3 - export type EmbedPlayerParams = 4 - | {type: 'youtube_video'; videoId: string; playerUri: string} 5 - | {type: 'twitch_live'; channelId: string; playerUri: string} 6 - | {type: 'spotify_album'; albumId: string; playerUri: string} 7 - | { 8 - type: 'spotify_playlist' 9 - playlistId: string 10 - playerUri: string 11 - } 12 - | {type: 'spotify_song'; songId: string; playerUri: string} 13 - | {type: 'soundcloud_track'; user: string; track: string; playerUri: string} 14 - | {type: 'soundcloud_set'; user: string; set: string; playerUri: string} 46 + export interface EmbedPlayerParams { 47 + type: EmbedPlayerType 48 + playerUri: string 49 + isGif?: boolean 50 + source: EmbedPlayerSource 51 + metaUri?: string 52 + hideDetails?: boolean 53 + } 54 + 55 + const giphyRegex = /media(?:[0-4]\.giphy\.com|\.giphy\.com)/i 56 + const gifFilenameRegex = /^(\S+)\.(webp|gif|mp4)$/i 15 57 16 58 export function parseEmbedPlayerFromUrl( 17 59 url: string, ··· 29 71 if (videoId) { 30 72 return { 31 73 type: 'youtube_video', 32 - videoId, 33 - playerUri: `https://www.youtube.com/embed/${videoId}?autoplay=1`, 74 + source: 'youtube', 75 + playerUri: `https://www.youtube.com/embed/${videoId}?autoplay=1&playsinline=1`, 34 76 } 35 77 } 36 78 } 37 - if (urlp.hostname === 'www.youtube.com' || urlp.hostname === 'youtube.com') { 79 + if ( 80 + urlp.hostname === 'www.youtube.com' || 81 + urlp.hostname === 'youtube.com' || 82 + urlp.hostname === 'm.youtube.com' 83 + ) { 38 84 const [_, page, shortVideoId] = urlp.pathname.split('/') 39 85 const videoId = 40 86 page === 'shorts' ? shortVideoId : (urlp.searchParams.get('v') as string) 41 87 42 88 if (videoId) { 43 89 return { 44 - type: 'youtube_video', 45 - videoId, 46 - playerUri: `https://www.youtube.com/embed/${videoId}?autoplay=1`, 90 + type: page === 'shorts' ? 'youtube_short' : 'youtube_video', 91 + source: page === 'shorts' ? 'youtubeShorts' : 'youtube', 92 + hideDetails: page === 'shorts' ? true : undefined, 93 + playerUri: `https://www.youtube.com/embed/${videoId}?autoplay=1&playsinline=1`, 47 94 } 48 95 } 49 96 } 50 97 51 98 // twitch 52 - if (urlp.hostname === 'twitch.tv' || urlp.hostname === 'www.twitch.tv') { 99 + if ( 100 + urlp.hostname === 'twitch.tv' || 101 + urlp.hostname === 'www.twitch.tv' || 102 + urlp.hostname === 'm.twitch.tv' 103 + ) { 53 104 const parent = 54 105 Platform.OS === 'web' ? window.location.hostname : 'localhost' 55 106 56 - const parts = urlp.pathname.split('/') 57 - if (parts.length === 2 && parts[1]) { 107 + const [_, channelOrVideo, clipOrId, id] = urlp.pathname.split('/') 108 + 109 + if (channelOrVideo === 'videos') { 110 + return { 111 + type: 'twitch_video', 112 + source: 'twitch', 113 + playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&video=${clipOrId}&parent=${parent}`, 114 + } 115 + } else if (clipOrId === 'clip') { 116 + return { 117 + type: 'twitch_video', 118 + source: 'twitch', 119 + playerUri: `https://clips.twitch.tv/embed?volume=0.5&autoplay=true&clip=${id}&parent=${parent}`, 120 + } 121 + } else if (channelOrVideo) { 58 122 return { 59 - type: 'twitch_live', 60 - channelId: parts[1], 61 - playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&channel=${parts[1]}&parent=${parent}`, 123 + type: 'twitch_video', 124 + source: 'twitch', 125 + playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&channel=${channelOrVideo}&parent=${parent}`, 62 126 } 63 127 } 64 128 } 65 129 66 130 // spotify 67 131 if (urlp.hostname === 'open.spotify.com') { 68 - const [_, type, id] = urlp.pathname.split('/') 69 - if (type && id) { 70 - if (type === 'playlist') { 132 + const [_, typeOrLocale, idOrType, id] = urlp.pathname.split('/') 133 + 134 + if (idOrType) { 135 + if (typeOrLocale === 'playlist' || idOrType === 'playlist') { 71 136 return { 72 137 type: 'spotify_playlist', 73 - playlistId: id, 74 - playerUri: `https://open.spotify.com/embed/playlist/${id}`, 138 + source: 'spotify', 139 + playerUri: `https://open.spotify.com/embed/playlist/${ 140 + id ?? idOrType 141 + }`, 75 142 } 76 143 } 77 - if (type === 'album') { 144 + if (typeOrLocale === 'album' || idOrType === 'album') { 78 145 return { 79 146 type: 'spotify_album', 80 - albumId: id, 81 - playerUri: `https://open.spotify.com/embed/album/${id}`, 147 + source: 'spotify', 148 + playerUri: `https://open.spotify.com/embed/album/${id ?? idOrType}`, 82 149 } 83 150 } 84 - if (type === 'track') { 151 + if (typeOrLocale === 'track' || idOrType === 'track') { 85 152 return { 86 153 type: 'spotify_song', 87 - songId: id, 88 - playerUri: `https://open.spotify.com/embed/track/${id}`, 154 + source: 'spotify', 155 + playerUri: `https://open.spotify.com/embed/track/${id ?? idOrType}`, 89 156 } 90 157 } 91 158 } ··· 102 169 if (trackOrSets === 'sets' && set) { 103 170 return { 104 171 type: 'soundcloud_set', 105 - user, 106 - set: set, 172 + source: 'soundcloud', 107 173 playerUri: `https://w.soundcloud.com/player/?url=${url}&auto_play=true&visual=false&hide_related=true`, 108 174 } 109 175 } 110 176 111 177 return { 112 178 type: 'soundcloud_track', 113 - user, 114 - track: trackOrSets, 179 + source: 'soundcloud', 115 180 playerUri: `https://w.soundcloud.com/player/?url=${url}&auto_play=true&visual=false&hide_related=true`, 116 181 } 117 182 } 118 183 } 184 + 185 + if ( 186 + urlp.hostname === 'music.apple.com' || 187 + urlp.hostname === 'music.apple.com' 188 + ) { 189 + // This should always have: locale, type (playlist or album), name, and id. We won't use spread since we want 190 + // to check if the length is correct 191 + const pathParams = urlp.pathname.split('/') 192 + const type = pathParams[2] 193 + const songId = urlp.searchParams.get('i') 194 + 195 + if (pathParams.length === 5 && (type === 'playlist' || type === 'album')) { 196 + // We want to append the songId to the end of the url if it exists 197 + const embedUri = `https://embed.music.apple.com${urlp.pathname}${ 198 + urlp.search ? '?i=' + songId : '' 199 + }` 200 + 201 + if (type === 'playlist') { 202 + return { 203 + type: 'apple_music_playlist', 204 + source: 'appleMusic', 205 + playerUri: embedUri, 206 + } 207 + } else if (type === 'album') { 208 + if (songId) { 209 + return { 210 + type: 'apple_music_song', 211 + source: 'appleMusic', 212 + playerUri: embedUri, 213 + } 214 + } else { 215 + return { 216 + type: 'apple_music_album', 217 + source: 'appleMusic', 218 + playerUri: embedUri, 219 + } 220 + } 221 + } 222 + } 223 + } 224 + 225 + if (urlp.hostname === 'vimeo.com' || urlp.hostname === 'www.vimeo.com') { 226 + const [_, videoId] = urlp.pathname.split('/') 227 + if (videoId) { 228 + return { 229 + type: 'vimeo_video', 230 + source: 'vimeo', 231 + playerUri: `https://player.vimeo.com/video/${videoId}?autoplay=1`, 232 + } 233 + } 234 + } 235 + 236 + if (urlp.hostname === 'giphy.com' || urlp.hostname === 'www.giphy.com') { 237 + const [_, gifs, nameAndId] = urlp.pathname.split('/') 238 + 239 + /* 240 + * nameAndId is a string that consists of the name (dash separated) and the id of the gif (the last part of the name) 241 + * We want to get the id of the gif, then direct to media.giphy.com/media/{id}/giphy.webp so we can 242 + * use it in an <Image> component 243 + */ 244 + 245 + if (gifs === 'gifs' && nameAndId) { 246 + const gifId = nameAndId.split('-').pop() 247 + 248 + if (gifId) { 249 + return { 250 + type: 'giphy_gif', 251 + source: 'giphy', 252 + isGif: true, 253 + hideDetails: true, 254 + metaUri: `https://giphy.com/gifs/${gifId}`, 255 + playerUri: `https://i.giphy.com/media/${gifId}/giphy.webp`, 256 + } 257 + } 258 + } 259 + } 260 + 261 + // There are five possible hostnames that also can be giphy urls: media.giphy.com and media0-4.giphy.com 262 + // These can include (presumably) a tracking id in the path name, so we have to check for that as well 263 + if (giphyRegex.test(urlp.hostname)) { 264 + // We can link directly to the gif, if its a proper link 265 + const [_, media, trackingOrId, idOrFilename, filename] = 266 + urlp.pathname.split('/') 267 + 268 + if (media === 'media') { 269 + if (idOrFilename && gifFilenameRegex.test(idOrFilename)) { 270 + return { 271 + type: 'giphy_gif', 272 + source: 'giphy', 273 + isGif: true, 274 + hideDetails: true, 275 + metaUri: `https://giphy.com/gifs/${trackingOrId}`, 276 + playerUri: `https://i.giphy.com/media/${trackingOrId}/giphy.webp`, 277 + } 278 + } else if (filename && gifFilenameRegex.test(filename)) { 279 + return { 280 + type: 'giphy_gif', 281 + source: 'giphy', 282 + isGif: true, 283 + hideDetails: true, 284 + metaUri: `https://giphy.com/gifs/${idOrFilename}`, 285 + playerUri: `https://i.giphy.com/media/${idOrFilename}/giphy.webp`, 286 + } 287 + } 288 + } 289 + } 290 + 291 + // Finally, we should see if it is a link to i.giphy.com. These links don't necessarily end in .gif but can also 292 + // be .webp 293 + if (urlp.hostname === 'i.giphy.com' || urlp.hostname === 'www.i.giphy.com') { 294 + const [_, mediaOrFilename, filename] = urlp.pathname.split('/') 295 + 296 + if (mediaOrFilename === 'media' && filename) { 297 + const gifId = filename.split('.')[0] 298 + return { 299 + type: 'giphy_gif', 300 + source: 'giphy', 301 + isGif: true, 302 + hideDetails: true, 303 + metaUri: `https://giphy.com/gifs/${gifId}`, 304 + playerUri: `https://i.giphy.com/media/${gifId}/giphy.webp`, 305 + } 306 + } else if (mediaOrFilename) { 307 + const gifId = mediaOrFilename.split('.')[0] 308 + return { 309 + type: 'giphy_gif', 310 + source: 'giphy', 311 + isGif: true, 312 + hideDetails: true, 313 + metaUri: `https://giphy.com/gifs/${gifId}`, 314 + playerUri: `https://i.giphy.com/media/${ 315 + mediaOrFilename.split('.')[0] 316 + }/giphy.webp`, 317 + } 318 + } 319 + } 320 + 321 + if (urlp.hostname === 'tenor.com' || urlp.hostname === 'www.tenor.com') { 322 + const [_, path, filename] = urlp.pathname.split('/') 323 + 324 + if (path === 'view' && filename) { 325 + const includesExt = filename.split('.').pop() === 'gif' 326 + 327 + return { 328 + type: 'tenor_gif', 329 + source: 'tenor', 330 + isGif: true, 331 + hideDetails: true, 332 + playerUri: `${url}${!includesExt ? '.gif' : ''}`, 333 + } 334 + } 335 + } 119 336 } 120 337 121 338 export function getPlayerHeight({ ··· 131 348 132 349 switch (type) { 133 350 case 'youtube_video': 134 - case 'twitch_live': 351 + case 'twitch_video': 352 + case 'vimeo_video': 135 353 return (width / 16) * 9 354 + case 'youtube_short': 355 + if (SCREEN_HEIGHT < 600) { 356 + return ((width / 9) * 16) / 1.75 357 + } else { 358 + return ((width / 9) * 16) / 1.5 359 + } 136 360 case 'spotify_album': 361 + case 'apple_music_album': 362 + case 'apple_music_playlist': 363 + case 'spotify_playlist': 364 + case 'soundcloud_set': 137 365 return 380 138 - case 'spotify_playlist': 139 - return 360 140 366 case 'spotify_song': 141 367 if (width <= 300) { 142 - return 180 368 + return 155 143 369 } 144 370 return 232 145 371 case 'soundcloud_track': 146 372 return 165 147 - case 'soundcloud_set': 148 - return 360 373 + case 'apple_music_song': 374 + return 150 149 375 default: 150 376 return width 151 377 } 152 378 } 379 + 380 + export function getGifDims( 381 + originalHeight: number, 382 + originalWidth: number, 383 + viewWidth: number, 384 + ) { 385 + const scaledHeight = (originalHeight / originalWidth) * viewWidth 386 + 387 + return { 388 + height: scaledHeight > 250 ? 250 : scaledHeight, 389 + width: (250 / scaledHeight) * viewWidth, 390 + } 391 + } 392 + 393 + export function getGiphyMetaUri(url: URL) { 394 + if (giphyRegex.test(url.hostname) || url.hostname === 'i.giphy.com') { 395 + const params = parseEmbedPlayerFromUrl(url.toString()) 396 + if (params && params.type === 'giphy_gif') { 397 + return params.metaUri 398 + } 399 + } 400 + }
+1
src/routes.ts
··· 26 26 AppPasswords: '/settings/app-passwords', 27 27 PreferencesHomeFeed: '/settings/home-feed', 28 28 PreferencesThreads: '/settings/threads', 29 + PreferencesExternalEmbeds: '/settings/external-embeds', 29 30 SavedFeeds: '/settings/saved-feeds', 30 31 Support: '/support', 31 32 PrivacyPolicy: '/support/privacy',
+8
src/state/modals/index.tsx
··· 6 6 import {ImageModel} from '#/state/models/media/image' 7 7 import {GalleryModel} from '#/state/models/media/gallery' 8 8 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 9 + import {EmbedPlayerSource} from '#/lib/strings/embed-player.ts' 9 10 import {ThreadgateSetting} from '../queries/threadgate' 10 11 11 12 export interface ConfirmModal { ··· 180 181 href: string 181 182 } 182 183 184 + export interface EmbedConsentModal { 185 + name: 'embed-consent' 186 + source: EmbedPlayerSource 187 + onAccept: () => void 188 + } 189 + 183 190 export type Modal = 184 191 // Account 185 192 | AddAppPasswordModal ··· 223 230 // Generic 224 231 | ConfirmModal 225 232 | LinkWarningModal 233 + | EmbedConsentModal 226 234 227 235 const ModalContext = React.createContext<{ 228 236 isModalActive: boolean
+1
src/state/persisted/legacy.ts
··· 109 109 step: legacy.onboarding?.step || defaults.onboarding.step, 110 110 }, 111 111 hiddenPosts: defaults.hiddenPosts, 112 + externalEmbeds: defaults.externalEmbeds, 112 113 } 113 114 } 114 115
+16
src/state/persisted/schema.ts
··· 1 1 import {z} from 'zod' 2 2 import {deviceLocales} from '#/platform/detection' 3 3 4 + const externalEmbedOptions = ['show', 'hide'] as const 5 + 4 6 // only data needed for rendering account page 5 7 const accountSchema = z.object({ 6 8 service: z.string(), ··· 30 32 appLanguage: z.string(), 31 33 }), 32 34 requireAltTextEnabled: z.boolean(), // should move to server 35 + externalEmbeds: z 36 + .object({ 37 + giphy: z.enum(externalEmbedOptions).optional(), 38 + tenor: z.enum(externalEmbedOptions).optional(), 39 + youtube: z.enum(externalEmbedOptions).optional(), 40 + youtubeShorts: z.enum(externalEmbedOptions).optional(), 41 + twitch: z.enum(externalEmbedOptions).optional(), 42 + vimeo: z.enum(externalEmbedOptions).optional(), 43 + spotify: z.enum(externalEmbedOptions).optional(), 44 + appleMusic: z.enum(externalEmbedOptions).optional(), 45 + soundcloud: z.enum(externalEmbedOptions).optional(), 46 + }) 47 + .optional(), 33 48 mutedThreads: z.array(z.string()), // should move to server 34 49 invites: z.object({ 35 50 copiedInvites: z.array(z.string()), ··· 60 75 appLanguage: deviceLocales[0] || 'en', 61 76 }, 62 77 requireAltTextEnabled: false, 78 + externalEmbeds: {}, 63 79 mutedThreads: [], 64 80 invites: { 65 81 copiedInvites: [],
+54
src/state/preferences/external-embeds-prefs.tsx
··· 1 + import React from 'react' 2 + import * as persisted from '#/state/persisted' 3 + import {EmbedPlayerSource} from 'lib/strings/embed-player' 4 + 5 + type StateContext = persisted.Schema['externalEmbeds'] 6 + type SetContext = (source: EmbedPlayerSource, value: 'show' | 'hide') => void 7 + 8 + const stateContext = React.createContext<StateContext>( 9 + persisted.defaults.externalEmbeds, 10 + ) 11 + const setContext = React.createContext<SetContext>({} as SetContext) 12 + 13 + export function Provider({children}: React.PropsWithChildren<{}>) { 14 + const [state, setState] = React.useState(persisted.get('externalEmbeds')) 15 + 16 + const setStateWrapped = React.useCallback( 17 + (source: EmbedPlayerSource, value: 'show' | 'hide') => { 18 + setState(prev => { 19 + persisted.write('externalEmbeds', { 20 + ...prev, 21 + [source]: value, 22 + }) 23 + 24 + return { 25 + ...prev, 26 + [source]: value, 27 + } 28 + }) 29 + }, 30 + [setState], 31 + ) 32 + 33 + React.useEffect(() => { 34 + return persisted.onUpdate(() => { 35 + setState(persisted.get('externalEmbeds')) 36 + }) 37 + }, [setStateWrapped]) 38 + 39 + return ( 40 + <stateContext.Provider value={state}> 41 + <setContext.Provider value={setStateWrapped}> 42 + {children} 43 + </setContext.Provider> 44 + </stateContext.Provider> 45 + ) 46 + } 47 + 48 + export function useExternalEmbedsPrefs() { 49 + return React.useContext(stateContext) 50 + } 51 + 52 + export function useSetExternalEmbedPref() { 53 + return React.useContext(setContext) 54 + }
+8 -1
src/state/preferences/index.tsx
··· 2 2 import {Provider as LanguagesProvider} from './languages' 3 3 import {Provider as AltTextRequiredProvider} from '../preferences/alt-text-required' 4 4 import {Provider as HiddenPostsProvider} from '../preferences/hidden-posts' 5 + import {Provider as ExternalEmbedsProvider} from './external-embeds-prefs' 5 6 6 7 export {useLanguagePrefs, useLanguagePrefsApi} from './languages' 7 8 export { 8 9 useRequireAltTextEnabled, 9 10 useSetRequireAltTextEnabled, 10 11 } from './alt-text-required' 12 + export { 13 + useExternalEmbedsPrefs, 14 + useSetExternalEmbedPref, 15 + } from './external-embeds-prefs' 11 16 export * from './hidden-posts' 12 17 13 18 export function Provider({children}: React.PropsWithChildren<{}>) { 14 19 return ( 15 20 <LanguagesProvider> 16 21 <AltTextRequiredProvider> 17 - <HiddenPostsProvider>{children}</HiddenPostsProvider> 22 + <ExternalEmbedsProvider> 23 + <HiddenPostsProvider>{children}</HiddenPostsProvider> 24 + </ExternalEmbedsProvider> 18 25 </AltTextRequiredProvider> 19 26 </LanguagesProvider> 20 27 )
+153
src/view/com/modals/EmbedConsent.tsx
··· 1 + import React from 'react' 2 + import {StyleSheet, TouchableOpacity, View} from 'react-native' 3 + import LinearGradient from 'react-native-linear-gradient' 4 + import {s, colors, gradients} from 'lib/styles' 5 + import {Text} from '../util/text/Text' 6 + import {ScrollView} from './util' 7 + import {usePalette} from 'lib/hooks/usePalette' 8 + import { 9 + EmbedPlayerSource, 10 + embedPlayerSources, 11 + externalEmbedLabels, 12 + } from '#/lib/strings/embed-player' 13 + import {msg, Trans} from '@lingui/macro' 14 + import {useLingui} from '@lingui/react' 15 + import {useModalControls} from '#/state/modals' 16 + import {useSetExternalEmbedPref} from '#/state/preferences/external-embeds-prefs' 17 + import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 18 + 19 + export const snapPoints = [450] 20 + 21 + export function Component({ 22 + onAccept, 23 + source, 24 + }: { 25 + onAccept: () => void 26 + source: EmbedPlayerSource 27 + }) { 28 + const pal = usePalette('default') 29 + const {closeModal} = useModalControls() 30 + const {_} = useLingui() 31 + const setExternalEmbedPref = useSetExternalEmbedPref() 32 + const {isMobile} = useWebMediaQueries() 33 + 34 + const onShowAllPress = React.useCallback(() => { 35 + for (const key of embedPlayerSources) { 36 + setExternalEmbedPref(key, 'show') 37 + } 38 + onAccept() 39 + closeModal() 40 + }, [closeModal, onAccept, setExternalEmbedPref]) 41 + 42 + const onShowPress = React.useCallback(() => { 43 + setExternalEmbedPref(source, 'show') 44 + onAccept() 45 + closeModal() 46 + }, [closeModal, onAccept, setExternalEmbedPref, source]) 47 + 48 + const onHidePress = React.useCallback(() => { 49 + setExternalEmbedPref(source, 'hide') 50 + closeModal() 51 + }, [closeModal, setExternalEmbedPref, source]) 52 + 53 + return ( 54 + <ScrollView 55 + testID="embedConsentModal" 56 + style={[ 57 + s.flex1, 58 + pal.view, 59 + isMobile 60 + ? {paddingHorizontal: 20, paddingTop: 10} 61 + : {paddingHorizontal: 30}, 62 + ]}> 63 + <Text style={[pal.text, styles.title]}> 64 + <Trans>External Media</Trans> 65 + </Text> 66 + 67 + <Text style={pal.text}> 68 + <Trans> 69 + This content is hosted by {externalEmbedLabels[source]}. Do you want 70 + to enable external media? 71 + </Trans> 72 + </Text> 73 + <View style={[s.mt10]} /> 74 + <Text style={pal.textLight}> 75 + <Trans> 76 + External media may allow websites to collect information about you and 77 + your device. No information is sent or requested until you press the 78 + "play" button. 79 + </Trans> 80 + </Text> 81 + <View style={[s.mt20]} /> 82 + <TouchableOpacity 83 + testID="enableAllBtn" 84 + onPress={onShowAllPress} 85 + accessibilityRole="button" 86 + accessibilityLabel={_( 87 + msg`Show embeds from ${externalEmbedLabels[source]}`, 88 + )} 89 + accessibilityHint="" 90 + onAccessibilityEscape={closeModal}> 91 + <LinearGradient 92 + colors={[gradients.blueLight.start, gradients.blueLight.end]} 93 + start={{x: 0, y: 0}} 94 + end={{x: 1, y: 1}} 95 + style={[styles.btn]}> 96 + <Text style={[s.white, s.bold, s.f18]}> 97 + <Trans>Enable External Media</Trans> 98 + </Text> 99 + </LinearGradient> 100 + </TouchableOpacity> 101 + <View style={[s.mt10]} /> 102 + <TouchableOpacity 103 + testID="enableSourceBtn" 104 + onPress={onShowPress} 105 + accessibilityRole="button" 106 + accessibilityLabel={_( 107 + msg`Never load embeds from ${externalEmbedLabels[source]}`, 108 + )} 109 + accessibilityHint="" 110 + onAccessibilityEscape={closeModal}> 111 + <View style={[styles.btn, pal.btn]}> 112 + <Text style={[pal.text, s.bold, s.f18]}> 113 + <Trans>Enable {externalEmbedLabels[source]} only</Trans> 114 + </Text> 115 + </View> 116 + </TouchableOpacity> 117 + <View style={[s.mt10]} /> 118 + <TouchableOpacity 119 + testID="disableSourceBtn" 120 + onPress={onHidePress} 121 + accessibilityRole="button" 122 + accessibilityLabel={_( 123 + msg`Never load embeds from ${externalEmbedLabels[source]}`, 124 + )} 125 + accessibilityHint="" 126 + onAccessibilityEscape={closeModal}> 127 + <View style={[styles.btn, pal.btn]}> 128 + <Text style={[pal.text, s.bold, s.f18]}> 129 + <Trans>No thanks</Trans> 130 + </Text> 131 + </View> 132 + </TouchableOpacity> 133 + </ScrollView> 134 + ) 135 + } 136 + 137 + const styles = StyleSheet.create({ 138 + title: { 139 + textAlign: 'center', 140 + fontWeight: 'bold', 141 + fontSize: 24, 142 + marginBottom: 12, 143 + }, 144 + btn: { 145 + flexDirection: 'row', 146 + alignItems: 'center', 147 + justifyContent: 'center', 148 + width: '100%', 149 + borderRadius: 32, 150 + padding: 14, 151 + backgroundColor: colors.gray1, 152 + }, 153 + })
+4
src/view/com/modals/Modal.tsx
··· 38 38 import * as ChangeEmailModal from './ChangeEmail' 39 39 import * as SwitchAccountModal from './SwitchAccount' 40 40 import * as LinkWarningModal from './LinkWarning' 41 + import * as EmbedConsentModal from './EmbedConsent' 41 42 42 43 const DEFAULT_SNAPPOINTS = ['90%'] 43 44 const HANDLE_HEIGHT = 24 ··· 176 177 } else if (activeModal?.name === 'link-warning') { 177 178 snapPoints = LinkWarningModal.snapPoints 178 179 element = <LinkWarningModal.Component {...activeModal} /> 180 + } else if (activeModal?.name === 'embed-consent') { 181 + snapPoints = EmbedConsentModal.snapPoints 182 + element = <EmbedConsentModal.Component {...activeModal} /> 179 183 } else { 180 184 return null 181 185 }
+3
src/view/com/modals/Modal.web.tsx
··· 34 34 import * as VerifyEmailModal from './VerifyEmail' 35 35 import * as ChangeEmailModal from './ChangeEmail' 36 36 import * as LinkWarningModal from './LinkWarning' 37 + import * as EmbedConsentModal from './EmbedConsent' 37 38 38 39 export function ModalsContainer() { 39 40 const {isModalActive, activeModals} = useModals() ··· 129 130 element = <ChangeEmailModal.Component /> 130 131 } else if (modal.name === 'link-warning') { 131 132 element = <LinkWarningModal.Component {...modal} /> 133 + } else if (modal.name === 'embed-consent') { 134 + element = <EmbedConsentModal.Component {...modal} /> 132 135 } else { 133 136 return null 134 137 }
+170
src/view/com/util/post-embeds/ExternalGifEmbed.tsx
··· 1 + import {EmbedPlayerParams, getGifDims} from 'lib/strings/embed-player' 2 + import React from 'react' 3 + import {Image, ImageLoadEventData} from 'expo-image' 4 + import { 5 + ActivityIndicator, 6 + GestureResponderEvent, 7 + LayoutChangeEvent, 8 + Pressable, 9 + StyleSheet, 10 + View, 11 + } from 'react-native' 12 + import {isIOS, isNative, isWeb} from '#/platform/detection' 13 + import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 14 + import {useExternalEmbedsPrefs} from 'state/preferences' 15 + import {useModalControls} from 'state/modals' 16 + import {useLingui} from '@lingui/react' 17 + import {msg} from '@lingui/macro' 18 + import {AppBskyEmbedExternal} from '@atproto/api' 19 + 20 + export function ExternalGifEmbed({ 21 + link, 22 + params, 23 + }: { 24 + link: AppBskyEmbedExternal.ViewExternal 25 + params: EmbedPlayerParams 26 + }) { 27 + const externalEmbedsPrefs = useExternalEmbedsPrefs() 28 + const {openModal} = useModalControls() 29 + const {_} = useLingui() 30 + 31 + const thumbHasLoaded = React.useRef(false) 32 + const viewWidth = React.useRef(0) 33 + 34 + // Tracking if the placer has been activated 35 + const [isPlayerActive, setIsPlayerActive] = React.useState(false) 36 + // Tracking whether the gif has been loaded yet 37 + const [isPrefetched, setIsPrefetched] = React.useState(false) 38 + // Tracking whether the image is animating 39 + const [isAnimating, setIsAnimating] = React.useState(true) 40 + const [imageDims, setImageDims] = React.useState({height: 100, width: 1}) 41 + 42 + // Used for controlling animation 43 + const imageRef = React.useRef<Image>(null) 44 + 45 + const load = React.useCallback(() => { 46 + setIsPlayerActive(true) 47 + Image.prefetch(params.playerUri).then(() => { 48 + // Replace the image once it's fetched 49 + setIsPrefetched(true) 50 + }) 51 + }, [params.playerUri]) 52 + 53 + const onPlayPress = React.useCallback( 54 + (event: GestureResponderEvent) => { 55 + // Don't propagate on web 56 + event.preventDefault() 57 + 58 + // Show consent if this is the first load 59 + if (externalEmbedsPrefs?.[params.source] === undefined) { 60 + openModal({ 61 + name: 'embed-consent', 62 + source: params.source, 63 + onAccept: load, 64 + }) 65 + return 66 + } 67 + // If the player isn't active, we want to activate it and prefetch the gif 68 + if (!isPlayerActive) { 69 + load() 70 + return 71 + } 72 + // Control animation on native 73 + setIsAnimating(prev => { 74 + if (prev) { 75 + if (isNative) { 76 + imageRef.current?.stopAnimating() 77 + } 78 + return false 79 + } else { 80 + if (isNative) { 81 + imageRef.current?.startAnimating() 82 + } 83 + return true 84 + } 85 + }) 86 + }, 87 + [externalEmbedsPrefs, isPlayerActive, load, openModal, params.source], 88 + ) 89 + 90 + const onLoad = React.useCallback((e: ImageLoadEventData) => { 91 + if (thumbHasLoaded.current) return 92 + setImageDims(getGifDims(e.source.height, e.source.width, viewWidth.current)) 93 + thumbHasLoaded.current = true 94 + }, []) 95 + 96 + const onLayout = React.useCallback((e: LayoutChangeEvent) => { 97 + viewWidth.current = e.nativeEvent.layout.width 98 + }, []) 99 + 100 + return ( 101 + <Pressable 102 + style={[ 103 + {height: imageDims.height}, 104 + styles.topRadius, 105 + styles.gifContainer, 106 + ]} 107 + onPress={onPlayPress} 108 + onLayout={onLayout} 109 + accessibilityRole="button" 110 + accessibilityHint={_(msg`Plays the GIF`)} 111 + accessibilityLabel={_(msg`Play ${link.title}`)}> 112 + {(!isPrefetched || !isAnimating) && ( // If we have not loaded or are not animating, show the overlay 113 + <View style={[styles.layer, styles.overlayLayer]}> 114 + <View style={[styles.overlayContainer, styles.topRadius]}> 115 + {!isAnimating || !isPlayerActive ? ( // Play button when not animating or not active 116 + <FontAwesomeIcon icon="play" size={42} color="white" /> 117 + ) : ( 118 + // Activity indicator while gif loads 119 + <ActivityIndicator size="large" color="white" /> 120 + )} 121 + </View> 122 + </View> 123 + )} 124 + <Image 125 + source={{ 126 + uri: 127 + !isPrefetched || (isWeb && !isAnimating) 128 + ? link.thumb 129 + : params.playerUri, 130 + }} // Web uses the thumb to control playback 131 + style={{flex: 1}} 132 + ref={imageRef} 133 + onLoad={onLoad} 134 + autoplay={isAnimating} 135 + contentFit="contain" 136 + accessibilityIgnoresInvertColors 137 + accessibilityLabel={link.title} 138 + accessibilityHint={link.title} 139 + cachePolicy={isIOS ? 'disk' : 'memory-disk'} // cant control playback with memory-disk on ios 140 + /> 141 + </Pressable> 142 + ) 143 + } 144 + 145 + const styles = StyleSheet.create({ 146 + topRadius: { 147 + borderTopLeftRadius: 6, 148 + borderTopRightRadius: 6, 149 + }, 150 + layer: { 151 + position: 'absolute', 152 + top: 0, 153 + left: 0, 154 + right: 0, 155 + bottom: 0, 156 + }, 157 + overlayContainer: { 158 + flex: 1, 159 + justifyContent: 'center', 160 + alignItems: 'center', 161 + backgroundColor: 'rgba(0,0,0,0.5)', 162 + }, 163 + overlayLayer: { 164 + zIndex: 2, 165 + }, 166 + gifContainer: { 167 + width: '100%', 168 + overflow: 'hidden', 169 + }, 170 + })
+22 -11
src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
··· 8 8 import {toNiceDomain} from 'lib/strings/url-helpers' 9 9 import {parseEmbedPlayerFromUrl} from 'lib/strings/embed-player' 10 10 import {ExternalPlayer} from 'view/com/util/post-embeds/ExternalPlayerEmbed' 11 + import {ExternalGifEmbed} from 'view/com/util/post-embeds/ExternalGifEmbed' 12 + import {useExternalEmbedsPrefs} from 'state/preferences' 11 13 12 14 export const ExternalLinkEmbed = ({ 13 15 link, ··· 16 18 }) => { 17 19 const pal = usePalette('default') 18 20 const {isMobile} = useWebMediaQueries() 21 + const externalEmbedPrefs = useExternalEmbedsPrefs() 19 22 20 - const embedPlayerParams = React.useMemo( 21 - () => parseEmbedPlayerFromUrl(link.uri), 22 - [link.uri], 23 - ) 23 + const embedPlayerParams = React.useMemo(() => { 24 + const params = parseEmbedPlayerFromUrl(link.uri) 25 + 26 + if (params && externalEmbedPrefs?.[params.source] !== 'hide') { 27 + return params 28 + } 29 + }, [link.uri, externalEmbedPrefs]) 24 30 25 31 return ( 26 32 <View style={{flexDirection: 'column'}}> ··· 40 46 /> 41 47 </View> 42 48 ) : undefined} 43 - {embedPlayerParams && ( 44 - <ExternalPlayer link={link} params={embedPlayerParams} /> 45 - )} 49 + {(embedPlayerParams?.isGif && ( 50 + <ExternalGifEmbed link={link} params={embedPlayerParams} /> 51 + )) || 52 + (embedPlayerParams && ( 53 + <ExternalPlayer link={link} params={embedPlayerParams} /> 54 + ))} 46 55 <View 47 56 style={{ 48 57 paddingHorizontal: isMobile ? 10 : 14, ··· 55 64 style={[pal.textLight, styles.extUri]}> 56 65 {toNiceDomain(link.uri)} 57 66 </Text> 58 - <Text type="lg-bold" numberOfLines={4} style={[pal.text]}> 59 - {link.title || link.uri} 60 - </Text> 61 - {link.description ? ( 67 + {!embedPlayerParams?.isGif && ( 68 + <Text type="lg-bold" numberOfLines={4} style={[pal.text]}> 69 + {link.title || link.uri} 70 + </Text> 71 + )} 72 + {link.description && !embedPlayerParams?.hideDetails ? ( 62 73 <Text 63 74 type="md" 64 75 numberOfLines={4}
+49 -35
src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx
··· 16 16 import {Image} from 'expo-image' 17 17 import {WebView} from 'react-native-webview' 18 18 import {useSafeAreaInsets} from 'react-native-safe-area-context' 19 - import YoutubePlayer from 'react-native-youtube-iframe' 20 19 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 20 + import {msg} from '@lingui/macro' 21 + import {useLingui} from '@lingui/react' 22 + import {useNavigation} from '@react-navigation/native' 23 + import {AppBskyEmbedExternal} from '@atproto/api' 21 24 import {EmbedPlayerParams, getPlayerHeight} from 'lib/strings/embed-player' 22 25 import {EventStopper} from '../EventStopper' 23 - import {AppBskyEmbedExternal} from '@atproto/api' 24 26 import {isNative} from 'platform/detection' 25 - import {useNavigation} from '@react-navigation/native' 26 27 import {NavigationProp} from 'lib/routes/types' 28 + import {useExternalEmbedsPrefs} from 'state/preferences' 29 + import {useModalControls} from 'state/modals' 27 30 28 31 interface ShouldStartLoadRequest { 29 32 url: string ··· 39 42 isPlayerActive: boolean 40 43 onPress: (event: GestureResponderEvent) => void 41 44 }) { 45 + const {_} = useLingui() 46 + 42 47 // If the player is active and not loading, we don't want to show the overlay. 43 48 if (isPlayerActive && !isLoading) return null 44 49 ··· 46 51 <View style={[styles.layer, styles.overlayLayer]}> 47 52 <Pressable 48 53 accessibilityRole="button" 49 - accessibilityLabel="Play Video" 50 - accessibilityHint="" 54 + accessibilityLabel={_(msg`Play Video`)} 55 + accessibilityHint={_(msg`Play Video`)} 51 56 onPress={onPress} 52 57 style={[styles.overlayContainer, styles.topRadius]}> 53 58 {!isPlayerActive ? ( ··· 84 89 return ( 85 90 <View style={[styles.layer, styles.playerLayer]}> 86 91 <EventStopper> 87 - {isNative && params.type === 'youtube_video' ? ( 88 - <YoutubePlayer 89 - videoId={params.videoId} 90 - play 91 - height={height} 92 - onReady={onLoad} 93 - webViewStyle={[styles.webview, styles.topRadius]} 92 + <View style={{height, width: '100%'}}> 93 + <WebView 94 + javaScriptEnabled={true} 95 + onShouldStartLoadWithRequest={onShouldStartLoadWithRequest} 96 + mediaPlaybackRequiresUserAction={false} 97 + allowsInlineMediaPlayback 98 + bounces={false} 99 + allowsFullscreenVideo 100 + nestedScrollEnabled 101 + source={{uri: params.playerUri}} 102 + onLoad={onLoad} 103 + setSupportMultipleWindows={false} // Prevent any redirects from opening a new window (ads) 104 + style={[styles.webview, styles.topRadius]} 94 105 /> 95 - ) : ( 96 - <View style={{height, width: '100%'}}> 97 - <WebView 98 - javaScriptEnabled={true} 99 - onShouldStartLoadWithRequest={onShouldStartLoadWithRequest} 100 - mediaPlaybackRequiresUserAction={false} 101 - allowsInlineMediaPlayback 102 - bounces={false} 103 - allowsFullscreenVideo 104 - nestedScrollEnabled 105 - source={{uri: params.playerUri}} 106 - onLoad={onLoad} 107 - setSupportMultipleWindows={false} // Prevent any redirects from opening a new window (ads) 108 - style={[styles.webview, styles.topRadius]} 109 - /> 110 - </View> 111 - )} 106 + </View> 112 107 </EventStopper> 113 108 </View> 114 109 ) ··· 125 120 const navigation = useNavigation<NavigationProp>() 126 121 const insets = useSafeAreaInsets() 127 122 const windowDims = useWindowDimensions() 123 + const externalEmbedsPrefs = useExternalEmbedsPrefs() 124 + const {openModal} = useModalControls() 128 125 129 126 const [isPlayerActive, setPlayerActive] = React.useState(false) 130 127 const [isLoading, setIsLoading] = React.useState(true) ··· 194 191 setIsLoading(false) 195 192 }, []) 196 193 197 - const onPlayPress = React.useCallback((event: GestureResponderEvent) => { 198 - // Prevent this from propagating upward on web 199 - event.preventDefault() 194 + const onPlayPress = React.useCallback( 195 + (event: GestureResponderEvent) => { 196 + // Prevent this from propagating upward on web 197 + event.preventDefault() 200 198 201 - setPlayerActive(true) 202 - }, []) 199 + if (externalEmbedsPrefs?.[params.source] === undefined) { 200 + openModal({ 201 + name: 'embed-consent', 202 + source: params.source, 203 + onAccept: () => { 204 + setPlayerActive(true) 205 + }, 206 + }) 207 + return 208 + } 209 + 210 + setPlayerActive(true) 211 + }, 212 + [externalEmbedsPrefs, openModal, params.source], 213 + ) 203 214 204 215 // measure the layout to set sizing 205 216 const onLayout = React.useCallback( ··· 231 242 accessibilityIgnoresInvertColors 232 243 /> 233 244 )} 234 - 235 245 <PlaceholderOverlay 236 246 isLoading={isLoading} 237 247 isPlayerActive={isPlayerActive} ··· 273 283 }, 274 284 webview: { 275 285 backgroundColor: 'transparent', 286 + }, 287 + gifContainer: { 288 + width: '100%', 289 + overflow: 'hidden', 276 290 }, 277 291 })
+4 -2
src/view/icons/index.tsx
··· 29 29 import {faCircle} from '@fortawesome/free-regular-svg-icons/faCircle' 30 30 import {faCircleCheck as farCircleCheck} from '@fortawesome/free-regular-svg-icons/faCircleCheck' 31 31 import {faCircleCheck} from '@fortawesome/free-solid-svg-icons/faCircleCheck' 32 + import {faCircleDot} from '@fortawesome/free-solid-svg-icons/faCircleDot' 32 33 import {faCircleExclamation} from '@fortawesome/free-solid-svg-icons/faCircleExclamation' 34 + import {faCirclePlay} from '@fortawesome/free-regular-svg-icons/faCirclePlay' 33 35 import {faCircleUser} from '@fortawesome/free-regular-svg-icons/faCircleUser' 34 - import {faCircleDot} from '@fortawesome/free-solid-svg-icons/faCircleDot' 35 36 import {faClone} from '@fortawesome/free-solid-svg-icons/faClone' 36 37 import {faClone as farClone} from '@fortawesome/free-regular-svg-icons/faClone' 37 38 import {faComment} from '@fortawesome/free-regular-svg-icons/faComment' ··· 129 130 faCircle, 130 131 faCircleCheck, 131 132 farCircleCheck, 133 + faCircleDot, 132 134 faCircleExclamation, 135 + faCirclePlay, 133 136 faCircleUser, 134 - faCircleDot, 135 137 faClone, 136 138 farClone, 137 139 faComment,
+138
src/view/screens/PreferencesExternalEmbeds.tsx
··· 1 + import React from 'react' 2 + import {StyleSheet, View} from 'react-native' 3 + import {useFocusEffect} from '@react-navigation/native' 4 + import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' 5 + import {s} from 'lib/styles' 6 + import {Text} from '../com/util/text/Text' 7 + import {usePalette} from 'lib/hooks/usePalette' 8 + import {useAnalytics} from 'lib/analytics/analytics' 9 + import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 10 + import { 11 + EmbedPlayerSource, 12 + externalEmbedLabels, 13 + } from '#/lib/strings/embed-player' 14 + import {useSetMinimalShellMode} from '#/state/shell' 15 + import {Trans} from '@lingui/macro' 16 + import {ScrollView} from '../com/util/Views' 17 + import { 18 + useExternalEmbedsPrefs, 19 + useSetExternalEmbedPref, 20 + } from 'state/preferences' 21 + import {ToggleButton} from 'view/com/util/forms/ToggleButton' 22 + import {SimpleViewHeader} from '../com/util/SimpleViewHeader' 23 + 24 + type Props = NativeStackScreenProps< 25 + CommonNavigatorParams, 26 + 'PreferencesExternalEmbeds' 27 + > 28 + export function PreferencesExternalEmbeds({}: Props) { 29 + const pal = usePalette('default') 30 + const setMinimalShellMode = useSetMinimalShellMode() 31 + const {screen} = useAnalytics() 32 + const {isMobile} = useWebMediaQueries() 33 + 34 + useFocusEffect( 35 + React.useCallback(() => { 36 + screen('PreferencesExternalEmbeds') 37 + setMinimalShellMode(false) 38 + }, [screen, setMinimalShellMode]), 39 + ) 40 + 41 + return ( 42 + <View style={s.hContentRegion} testID="preferencesExternalEmbedsScreen"> 43 + <SimpleViewHeader 44 + showBackButton={isMobile} 45 + style={[ 46 + pal.border, 47 + {borderBottomWidth: 1}, 48 + !isMobile && {borderLeftWidth: 1, borderRightWidth: 1}, 49 + ]}> 50 + <View style={{flex: 1}}> 51 + <Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}> 52 + <Trans>External Media Preferences</Trans> 53 + </Text> 54 + <Text style={pal.textLight}> 55 + <Trans>Customize media from external sites.</Trans> 56 + </Text> 57 + </View> 58 + </SimpleViewHeader> 59 + <ScrollView 60 + // @ts-ignore web only -prf 61 + dataSet={{'stable-gutters': 1}} 62 + contentContainerStyle={[pal.viewLight, {paddingBottom: 200}]}> 63 + <View style={[pal.view]}> 64 + <View style={styles.infoCard}> 65 + <Text style={pal.text}> 66 + <Trans> 67 + External media may allow websites to collect information about 68 + you and your device. No information is sent or requested until 69 + you press the "play" button. 70 + </Trans> 71 + </Text> 72 + </View> 73 + </View> 74 + <Text type="xl-bold" style={[pal.text, styles.heading]}> 75 + Enable media players for 76 + </Text> 77 + {Object.entries(externalEmbedLabels).map(([key, label]) => ( 78 + <PrefSelector 79 + source={key as EmbedPlayerSource} 80 + label={label} 81 + key={key} 82 + /> 83 + ))} 84 + </ScrollView> 85 + </View> 86 + ) 87 + } 88 + 89 + function PrefSelector({ 90 + source, 91 + label, 92 + }: { 93 + source: EmbedPlayerSource 94 + label: string 95 + }) { 96 + const pal = usePalette('default') 97 + const setExternalEmbedPref = useSetExternalEmbedPref() 98 + const sources = useExternalEmbedsPrefs() 99 + 100 + return ( 101 + <View> 102 + <View style={[pal.view, styles.toggleCard]}> 103 + <ToggleButton 104 + type="default-light" 105 + label={label} 106 + labelType="lg" 107 + isSelected={sources?.[source] === 'show'} 108 + onPress={() => 109 + setExternalEmbedPref( 110 + source, 111 + sources?.[source] === 'show' ? 'hide' : 'show', 112 + ) 113 + } 114 + /> 115 + </View> 116 + </View> 117 + ) 118 + } 119 + 120 + const styles = StyleSheet.create({ 121 + heading: { 122 + paddingHorizontal: 18, 123 + paddingTop: 14, 124 + paddingBottom: 14, 125 + }, 126 + spacer: { 127 + height: 8, 128 + }, 129 + infoCard: { 130 + paddingHorizontal: 20, 131 + paddingVertical: 14, 132 + }, 133 + toggleCard: { 134 + paddingVertical: 8, 135 + paddingHorizontal: 6, 136 + marginBottom: 1, 137 + }, 138 + })
+33
src/view/screens/Settings.tsx
··· 563 563 <Trans>Moderation</Trans> 564 564 </Text> 565 565 </TouchableOpacity> 566 + 567 + <View style={styles.spacer20} /> 568 + 569 + <Text type="xl-bold" style={[pal.text, styles.heading]}> 570 + <Trans>Privacy</Trans> 571 + </Text> 572 + 573 + <TouchableOpacity 574 + testID="externalEmbedsBtn" 575 + style={[ 576 + styles.linkCard, 577 + pal.view, 578 + isSwitchingAccounts && styles.dimmed, 579 + ]} 580 + onPress={ 581 + isSwitchingAccounts 582 + ? undefined 583 + : () => navigation.navigate('PreferencesExternalEmbeds') 584 + } 585 + accessibilityRole="button" 586 + accessibilityHint="" 587 + accessibilityLabel={_(msg`Opens external embeds settings`)}> 588 + <View style={[styles.iconContainer, pal.btn]}> 589 + <FontAwesomeIcon 590 + icon={['far', 'circle-play']} 591 + style={pal.text as FontAwesomeIconStyle} 592 + /> 593 + </View> 594 + <Text type="lg" style={pal.text}> 595 + <Trans>External Media Preferences</Trans> 596 + </Text> 597 + </TouchableOpacity> 598 + 566 599 <View style={styles.spacer20} /> 567 600 568 601 <Text type="xl-bold" style={[pal.text, styles.heading]}>
-7
yarn.lock
··· 18304 18304 escape-string-regexp "2.0.0" 18305 18305 invariant "2.2.4" 18306 18306 18307 - react-native-youtube-iframe@^2.3.0: 18308 - version "2.3.0" 18309 - resolved "https://registry.yarnpkg.com/react-native-youtube-iframe/-/react-native-youtube-iframe-2.3.0.tgz#40ca8e55db929b91bfa8e8d30e411658cbc304c5" 18310 - integrity sha512-M+z63xwXVtS4dX3k8PbtHUUcWN+gRZt6J1EtPE7Y60BMOB979KjpkdrHqeR96or9pNR2W8K5tQhIkMXW2jwo7Q== 18311 - dependencies: 18312 - events "^3.2.0" 18313 - 18314 18307 react-native@0.73.1: 18315 18308 version "0.73.1" 18316 18309 resolved "https://registry.yarnpkg.com/react-native/-/react-native-0.73.1.tgz#5eafaa7e54feeab8b55e8b8e4efc4d21052a4fff"