diff --git a/__tests__/lib/string.test.ts b/__tests__/lib/string.test.ts index 0032ebf313..e573ca1533 100644 --- a/__tests__/lib/string.test.ts +++ b/__tests__/lib/string.test.ts @@ -1,3 +1,4 @@ +import {getYoutubeVideoId} from './../../src/lib/strings' import { extractEntities, detectLinkables, @@ -487,3 +488,29 @@ describe('toShareUrl', () => { } }) }) + +describe('getYoutubeVideoId', () => { + it(' should return undefined for invalid youtube links', () => { + expect(getYoutubeVideoId('')).toBeUndefined() + expect(getYoutubeVideoId('https://www.google.com')).toBeUndefined() + expect(getYoutubeVideoId('https://www.youtube.com')).toBeUndefined() + expect( + getYoutubeVideoId('https://www.youtube.com/channelName'), + ).toBeUndefined() + expect( + getYoutubeVideoId('https://www.youtube.com/channel/channelName'), + ).toBeUndefined() + }) + + it('getYoutubeVideoId should return video id for valid youtube links', () => { + expect(getYoutubeVideoId('https://www.youtube.com/watch?v=videoId')).toBe( + 'videoId', + ) + expect( + getYoutubeVideoId( + 'https://www.youtube.com/watch?v=videoId&feature=share', + ), + ).toBe('videoId') + expect(getYoutubeVideoId('https://youtu.be/videoId')).toBe('videoId') + }) +}) diff --git a/__tests__/state/models/navigation.test.ts b/__tests__/state/models/navigation.test.ts index f1ff4339ff..b5517e85c1 100644 --- a/__tests__/state/models/navigation.test.ts +++ b/__tests__/state/models/navigation.test.ts @@ -1,11 +1,16 @@ +import {RootStoreModel} from './../../../src/state/models/root-store' import {NavigationModel} from './../../../src/state/models/navigation' import * as flags from '../../../src/build-flags' +import AtpAgent from '@atproto/api' +import {DEFAULT_SERVICE} from '../../../src/state' describe('NavigationModel', () => { let model: NavigationModel + let rootStore: RootStoreModel beforeEach(() => { - model = new NavigationModel() + rootStore = new RootStoreModel(new AtpAgent({service: DEFAULT_SERVICE})) + model = new NavigationModel(rootStore) model.setTitle('0-0', 'title') }) diff --git a/__tests__/state/models/shell-ui.test.ts b/__tests__/state/models/shell-ui.test.ts index b6ccd064a4..adf59f17ea 100644 --- a/__tests__/state/models/shell-ui.test.ts +++ b/__tests__/state/models/shell-ui.test.ts @@ -3,12 +3,17 @@ import { ImagesLightbox, ShellUiModel, } from './../../../src/state/models/shell-ui' +import {RootStoreModel} from '../../../src/state' +import AtpAgent from '@atproto/api' +import {DEFAULT_SERVICE} from '../../../src/state' describe('ShellUiModel', () => { let model: ShellUiModel + let rootStore: RootStoreModel beforeEach(() => { - model = new ShellUiModel() + rootStore = new RootStoreModel(new AtpAgent({service: DEFAULT_SERVICE})) + model = new ShellUiModel(rootStore) }) afterAll(() => { diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 58b26df908..a27224f6d6 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -270,6 +270,8 @@ PODS: - React-Core - react-native-version-number (0.3.6): - React + - react-native-webview (11.26.1): + - React-Core - React-perflogger (0.71.1) - React-RCTActionSheet (0.71.1): - React-Core/RCTActionSheetHeaders (= 0.71.1) @@ -468,6 +470,7 @@ DEPENDENCIES: - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`) - react-native-splash-screen (from `../node_modules/react-native-splash-screen`) - react-native-version-number (from `../node_modules/react-native-version-number`) + - react-native-webview (from `../node_modules/react-native-webview`) - React-perflogger (from `../node_modules/react-native/ReactCommon/reactperflogger`) - React-RCTActionSheet (from `../node_modules/react-native/Libraries/ActionSheetIOS`) - React-RCTAnimation (from `../node_modules/react-native/Libraries/NativeAnimation`) @@ -573,6 +576,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-splash-screen" react-native-version-number: :path: "../node_modules/react-native-version-number" + react-native-webview: + :path: "../node_modules/react-native-webview" React-perflogger: :path: "../node_modules/react-native/ReactCommon/reactperflogger" React-RCTActionSheet: @@ -671,6 +676,7 @@ SPEC CHECKSUMS: react-native-safe-area-context: 99b24a0c5acd0d5dcac2b1a7f18c49ea317be99a react-native-splash-screen: 4312f786b13a81b5169ef346d76d33bc0c6dc457 react-native-version-number: b415bbec6a13f2df62bf978e85bc0d699462f37f + react-native-webview: 9f111dfbcfc826084d6c507f569e5e03342ee1c1 React-perflogger: ec8eef2a8f03ecfa6361c2c5fb9197ef4a29cc85 React-RCTActionSheet: a0c023b86cf4c862fa9c4eb0f6f91fbe878fb2de React-RCTAnimation: 168d53718c74153947c0109f55900faa64d79439 diff --git a/package.json b/package.json index ed0c23bd46..41b21df7d0 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,8 @@ "react-native-uuid": "^2.0.1", "react-native-version-number": "^0.3.6", "react-native-web": "^0.17.7", + "react-native-webview": "^11.26.1", + "react-native-youtube-iframe": "^2.2.2", "rn-fetch-blob": "^0.12.0", "tlds": "^1.234.0", "zod": "^3.20.2" diff --git a/src/lib/strings.ts b/src/lib/strings.ts index a2d3f502a7..f64b0a60fc 100644 --- a/src/lib/strings.ts +++ b/src/lib/strings.ts @@ -268,3 +268,32 @@ export function convertBskyAppUrlIfNeeded(url: string): string { } return url } + +export const getYoutubeVideoId = (link: string): string | undefined => { + let url + try { + url = new URL(link) + } catch (e) { + return undefined + } + + if ( + url.hostname !== 'www.youtube.com' && + url.hostname !== 'youtube.com' && + url.hostname !== 'youtu.be' + ) { + return undefined + } + if (url.hostname === 'youtu.be') { + const videoId = url.pathname.split('/')[1] + if (!videoId) { + return undefined + } + return videoId + } + const videoId = url.searchParams.get('v') as string + if (!videoId) { + return undefined + } + return videoId +} diff --git a/src/state/models/navigation.ts b/src/state/models/navigation.ts index 13bae697be..e2610a2f5c 100644 --- a/src/state/models/navigation.ts +++ b/src/state/models/navigation.ts @@ -1,3 +1,4 @@ +import {RootStoreModel} from './root-store' import {makeAutoObservable} from 'mobx' import {TABS_ENABLED} from '../../build-flags' import {segmentClient} from '../../lib/segmentClient' @@ -228,8 +229,9 @@ export class NavigationModel { ] tabIndex = 0 - constructor() { + constructor(public rootStore: RootStoreModel) { makeAutoObservable(this, { + rootStore: false, serialize: false, hydrate: false, }) @@ -263,6 +265,7 @@ export class NavigationModel { // = navigate(url: string, title?: string) { + this.rootStore.emitNavigation() this.tab.navigate(url, title) } @@ -300,6 +303,7 @@ export class NavigationModel { // fixed tab helper function // -prf switchTo(purpose: TabPurpose, reset: boolean) { + this.rootStore.emitNavigation() switch (purpose) { case TabPurpose.Notifs: this.tabIndex = 2 diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index 0e2e6f39c0..ffd5139a7d 100644 --- a/src/state/models/root-store.ts +++ b/src/state/models/root-store.ts @@ -21,8 +21,8 @@ export class RootStoreModel { agent: AtpAgent log = new LogModel() session = new SessionModel(this) - nav = new NavigationModel() - shell = new ShellUiModel() + nav = new NavigationModel(this) + shell = new ShellUiModel(this) me = new MeModel(this) onboard = new OnboardModel() profiles = new ProfilesViewModel(this) @@ -158,6 +158,14 @@ export class RootStoreModel { DeviceEventEmitter.emit('session-dropped') } + onNavigation(handler: () => void): EmitterSubscription { + return DeviceEventEmitter.addListener('navigation', handler) + } + + emitNavigation() { + DeviceEventEmitter.emit('navigation') + } + // background fetch // = // - we use this to poll for unread notifications, which is not "ideal" behavior but diff --git a/src/state/models/shell-ui.ts b/src/state/models/shell-ui.ts index d05afde5aa..fb8a807fa2 100644 --- a/src/state/models/shell-ui.ts +++ b/src/state/models/shell-ui.ts @@ -1,3 +1,4 @@ +import {RootStoreModel} from './root-store' import {makeAutoObservable} from 'mobx' import {ProfileViewModel} from './profile-view' import {isObj, hasProp} from '../lib/type-guards' @@ -113,8 +114,12 @@ export class ShellUiModel { isComposerActive = false composerOpts: ComposerOpts | undefined - constructor() { - makeAutoObservable(this, {serialize: false, hydrate: false}) + constructor(public rootStore: RootStoreModel) { + makeAutoObservable(this, { + serialize: false, + rootStore: false, + hydrate: false, + }) } serialize(): unknown { @@ -152,6 +157,7 @@ export class ShellUiModel { | ReportAccountModal | DeleteAccountModal, ) { + this.rootStore.emitNavigation() this.isModalActive = true this.activeModal = modal } @@ -162,6 +168,7 @@ export class ShellUiModel { } openLightbox(lightbox: ProfileImageLightbox | ImagesLightbox) { + this.rootStore.emitNavigation() this.isLightboxActive = true this.activeLightbox = lightbox } @@ -172,6 +179,7 @@ export class ShellUiModel { } openComposer(opts: ComposerOpts) { + this.rootStore.emitNavigation() this.isComposerActive = true this.composerOpts = opts } diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx index 1cbb1af836..105d9a6706 100644 --- a/src/view/com/util/Link.tsx +++ b/src/view/com/util/Link.tsx @@ -22,17 +22,21 @@ export const Link = observer(function Link({ noFeedback, }: { style?: StyleProp - href: string + href?: string title?: string children?: React.ReactNode noFeedback?: boolean }) { const store = useStores() const onPress = () => { - handleLink(store, href, false) + if (href) { + handleLink(store, href, false) + } } const onLongPress = () => { - handleLink(store, href, true) + if (href) { + handleLink(store, href, true) + } } if (noFeedback) { return ( diff --git a/src/view/com/util/PostEmbeds/ExternalLinkEmbed.tsx b/src/view/com/util/PostEmbeds/ExternalLinkEmbed.tsx new file mode 100644 index 0000000000..65eac12cd9 --- /dev/null +++ b/src/view/com/util/PostEmbeds/ExternalLinkEmbed.tsx @@ -0,0 +1,66 @@ +import React from 'react' +import {Text} from '../text/Text' +import {Image} from '../images/Image' +import {StyleSheet, View} from 'react-native' +import {usePalette} from '../../../lib/hooks/usePalette' +import {PresentedExternal} from '@atproto/api/dist/client/types/app/bsky/embed/external' + +const ExternalLinkEmbed = ({ + link, + onImagePress, +}: { + link: PresentedExternal + onImagePress: () => void +}) => { + const pal = usePalette('default') + return ( + <> + {link.thumb ? ( + + ) : undefined} + + + {link.title || link.uri} + + + {link.uri} + + {link.description ? ( + + {link.description} + + ) : undefined} + + + ) +} + +const styles = StyleSheet.create({ + extInner: { + padding: 10, + }, + extImage: { + borderTopLeftRadius: 6, + borderTopRightRadius: 6, + width: '100%', + maxHeight: 200, + }, + extUri: { + marginTop: 2, + }, + extDescription: { + marginTop: 4, + }, +}) + +export default ExternalLinkEmbed diff --git a/src/view/com/util/PostEmbeds/YoutubeEmbed.tsx b/src/view/com/util/PostEmbeds/YoutubeEmbed.tsx new file mode 100644 index 0000000000..cd00086bcd --- /dev/null +++ b/src/view/com/util/PostEmbeds/YoutubeEmbed.tsx @@ -0,0 +1,112 @@ +import React, {useEffect} from 'react' +import {useState} from 'react' +import { + View, + StyleSheet, + Pressable, + TouchableWithoutFeedback, + EmitterSubscription, +} from 'react-native' +import YoutubePlayer from 'react-native-youtube-iframe' +import {usePalette} from '../../../lib/hooks/usePalette' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import ExternalLinkEmbed from './ExternalLinkEmbed' +import {PresentedExternal} from '@atproto/api/dist/client/types/app/bsky/embed/external' +import {useStores} from '../../../../state' + +const YoutubeEmbed = ({ + link, + videoId, +}: { + videoId: string + link: PresentedExternal +}) => { + const store = useStores() + const [displayVideoPlayer, setDisplayVideoPlayer] = useState(false) + const [playerDimensions, setPlayerDimensions] = useState({ + width: 0, + height: 0, + }) + const pal = usePalette('default') + const handlePlayButtonPressed = () => { + setDisplayVideoPlayer(true) + } + const handleOnLayout = (event: { + nativeEvent: {layout: {width: any; height: any}} + }) => { + setPlayerDimensions({ + width: event.nativeEvent.layout.width, + height: event.nativeEvent.layout.height, + }) + } + useEffect(() => { + let sub: EmitterSubscription + if (displayVideoPlayer) { + sub = store.onNavigation(() => { + setDisplayVideoPlayer(false) + }) + } + return () => sub && sub.remove() + }, [displayVideoPlayer, store]) + + if (!displayVideoPlayer) { + return ( + + + + + + + ) + } + + const height = (playerDimensions.width / 16) * 9 + const noop = () => {} + + return ( + + + {/* Removing the outter View will make tap events propagate to parents */} + + + + ) +} + +const styles = StyleSheet.create({ + extOuter: { + borderWidth: 1, + borderRadius: 8, + marginTop: 4, + }, + playButton: { + position: 'absolute', + top: '20%', + alignSelf: 'center', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'black', + padding: 10, + borderRadius: 50, + opacity: 0.8, + }, + webView: { + alignItems: 'center', + alignContent: 'center', + justifyContent: 'center', + }, +}) + +export default YoutubeEmbed diff --git a/src/view/com/util/PostEmbeds.tsx b/src/view/com/util/PostEmbeds/index.tsx similarity index 71% rename from src/view/com/util/PostEmbeds.tsx rename to src/view/com/util/PostEmbeds/index.tsx index ab71933821..1ee788d4c9 100644 --- a/src/view/com/util/PostEmbeds.tsx +++ b/src/view/com/util/PostEmbeds/index.tsx @@ -7,14 +7,16 @@ import { Image as RNImage, } from 'react-native' import {AppBskyEmbedImages, AppBskyEmbedExternal} from '@atproto/api' -import {Link} from '../util/Link' -import {Text} from './text/Text' -import {Image} from './images/Image' -import {ImageLayoutGrid} from './images/ImageLayoutGrid' -import {ImagesLightbox} from '../../../state/models/shell-ui' -import {useStores} from '../../../state' -import {usePalette} from '../../lib/hooks/usePalette' -import {saveImageModal} from '../../../lib/images' +import {Link} from '../Link' +import {Image} from '../images/Image' +import {ImageLayoutGrid} from '../images/ImageLayoutGrid' +import {ImagesLightbox} from '../../../../state/models/shell-ui' +import {useStores} from '../../../../state' +import {usePalette} from '../../../lib/hooks/usePalette' +import {saveImageModal} from '../../../../lib/images' +import YoutubeEmbed from './YoutubeEmbed' +import ExternalLinkEmbed from './ExternalLinkEmbed' +import {getYoutubeVideoId} from '../../../../lib/strings' type Embed = | AppBskyEmbedImages.Presented @@ -103,33 +105,18 @@ export function PostEmbeds({ } if (AppBskyEmbedExternal.isPresented(embed)) { const link = embed.external + const youtubeVideoId = getYoutubeVideoId(link.uri) + + if (youtubeVideoId) { + return + } + return ( - {link.thumb ? ( - - ) : undefined} - - - {link.title || link.uri} - - - {link.uri} - - {link.description ? ( - - {link.description} - - ) : undefined} - + ) } @@ -149,19 +136,4 @@ const styles = StyleSheet.create({ borderRadius: 8, marginTop: 4, }, - extInner: { - padding: 10, - }, - extImage: { - borderTopLeftRadius: 6, - borderTopRightRadius: 6, - width: '100%', - maxHeight: 200, - }, - extUri: { - marginTop: 2, - }, - extDescription: { - marginTop: 4, - }, }) diff --git a/src/view/index.ts b/src/view/index.ts index 8ffd5957ae..7cd2c1dfd1 100644 --- a/src/view/index.ts +++ b/src/view/index.ts @@ -65,6 +65,8 @@ import {faTicket} from '@fortawesome/free-solid-svg-icons/faTicket' import {faTrashCan} from '@fortawesome/free-regular-svg-icons/faTrashCan' import {faX} from '@fortawesome/free-solid-svg-icons/faX' import {faXmark} from '@fortawesome/free-solid-svg-icons/faXmark' +import {faPlay} from '@fortawesome/free-solid-svg-icons/faPlay' +import {faPause} from '@fortawesome/free-solid-svg-icons/faPause' export function setup() { library.add( @@ -133,5 +135,7 @@ export function setup() { faTrashCan, faX, faXmark, + faPlay, + faPause, ) } diff --git a/yarn.lock b/yarn.lock index edbe0fb9ac..5580df1cc9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5658,16 +5658,16 @@ escape-html@~1.0.3: resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== +escape-string-regexp@2.0.0, escape-string-regexp@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz#a30304e99daa32e23b2fd20f51babd07cffca344" + integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w== + escape-string-regexp@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg== -escape-string-regexp@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz#a30304e99daa32e23b2fd20f51babd07cffca344" - integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w== - escape-string-regexp@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" @@ -7254,7 +7254,7 @@ internal-slot@^1.0.3, internal-slot@^1.0.4: has "^1.0.3" side-channel "^1.0.4" -invariant@*, invariant@^2.2.4: +invariant@*, invariant@2.2.4, invariant@^2.2.4: version "2.2.4" resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA== @@ -11503,6 +11503,21 @@ react-native-web@^0.17.7: normalize-css-color "^1.0.2" prop-types "^15.6.0" +react-native-webview@^11.26.1: + version "11.26.1" + resolved "https://registry.yarnpkg.com/react-native-webview/-/react-native-webview-11.26.1.tgz#658c09ed5162dc170b361e48c2dd26c9712879da" + integrity sha512-hC7BkxOpf+z0UKhxFSFTPAM4shQzYmZHoELa6/8a/MspcjEP7ukYKpuSUTLDywQditT8yI9idfcKvfZDKQExGw== + dependencies: + escape-string-regexp "2.0.0" + invariant "2.2.4" + +react-native-youtube-iframe@^2.2.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/react-native-youtube-iframe/-/react-native-youtube-iframe-2.2.2.tgz#ade1a2e4ead3d539fbb80463f45b59ff1b510b55" + integrity sha512-og2KW21kCwAHKcnWoyWWBYC6J2Xtqjjwpghhoy9G6zfwZkr8Ej27BbQIAKM/TheJJUZ5/YUrqsgqAdnFYDx5TQ== + dependencies: + events "^3.2.0" + react-native@0.71.1: version "0.71.1" resolved "https://registry.yarnpkg.com/react-native/-/react-native-0.71.1.tgz#72b45af2b29e3d5a660c63425ab5003bf2112f99"