diff options
Diffstat (limited to 'src/view/com/util')
18 files changed, 315 insertions, 288 deletions
diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx index f356f0b09..703869be1 100644 --- a/src/view/com/util/Link.tsx +++ b/src/view/com/util/Link.tsx @@ -29,6 +29,7 @@ type Event = | GestureResponderEvent export const Link = observer(function Link({ + testID, style, href, title, @@ -36,6 +37,7 @@ export const Link = observer(function Link({ noFeedback, asAnchor, }: { + testID?: string style?: StyleProp<ViewStyle> href?: string title?: string @@ -58,6 +60,7 @@ export const Link = observer(function Link({ if (noFeedback) { return ( <TouchableWithoutFeedback + testID={testID} onPress={onPress} // @ts-ignore web only -prf href={asAnchor ? href : undefined}> @@ -69,6 +72,7 @@ export const Link = observer(function Link({ } return ( <TouchableOpacity + testID={testID} style={style} onPress={onPress} // @ts-ignore web only -prf @@ -79,6 +83,7 @@ export const Link = observer(function Link({ }) export const TextLink = observer(function TextLink({ + testID, type = 'md', style, href, @@ -86,6 +91,7 @@ export const TextLink = observer(function TextLink({ numberOfLines, lineHeight, }: { + testID?: string type?: TypographyVariant style?: StyleProp<TextStyle> href: string @@ -106,6 +112,7 @@ export const TextLink = observer(function TextLink({ return ( <Text + testID={testID} type={type} style={style} numberOfLines={numberOfLines} @@ -120,6 +127,7 @@ export const TextLink = observer(function TextLink({ * Only acts as a link on desktop web */ export const DesktopWebTextLink = observer(function DesktopWebTextLink({ + testID, type = 'md', style, href, @@ -127,6 +135,7 @@ export const DesktopWebTextLink = observer(function DesktopWebTextLink({ numberOfLines, lineHeight, }: { + testID?: string type?: TypographyVariant style?: StyleProp<TextStyle> href: string @@ -137,6 +146,7 @@ export const DesktopWebTextLink = observer(function DesktopWebTextLink({ if (isDesktopWeb) { return ( <TextLink + testID={testID} type={type} style={style} href={href} @@ -148,6 +158,7 @@ export const DesktopWebTextLink = observer(function DesktopWebTextLink({ } return ( <Text + testID={testID} type={type} style={style} numberOfLines={numberOfLines} diff --git a/src/view/com/util/PostCtrls.tsx b/src/view/com/util/PostCtrls.tsx index 00e35eef7..6904928f4 100644 --- a/src/view/com/util/PostCtrls.tsx +++ b/src/view/com/util/PostCtrls.tsx @@ -45,12 +45,12 @@ interface PostCtrlsOpts { style?: StyleProp<ViewStyle> replyCount?: number repostCount?: number - upvoteCount?: number + likeCount?: number isReposted: boolean - isUpvoted: boolean + isLiked: boolean onPressReply: () => void onPressToggleRepost: () => Promise<void> - onPressToggleUpvote: () => Promise<void> + onPressToggleLike: () => Promise<void> onCopyPostText: () => void onOpenTranslate: () => void onDeletePost: () => void @@ -157,26 +157,26 @@ export function PostCtrls(opts: PostCtrlsOpts) { }) } - const onPressToggleUpvoteWrapper = () => { - if (!opts.isUpvoted) { + const onPressToggleLikeWrapper = () => { + if (!opts.isLiked) { ReactNativeHapticFeedback.trigger('impactMedium') setLikeMod(1) opts - .onPressToggleUpvote() + .onPressToggleLike() .catch(_e => undefined) .then(() => setLikeMod(0)) // DISABLED see #135 // likeRef.current?.trigger( // {start: ctrlAnimStart, style: ctrlAnimStyle}, // async () => { - // await opts.onPressToggleUpvote().catch(_e => undefined) + // await opts.onPressToggleLike().catch(_e => undefined) // setLikeMod(0) // }, // ) } else { setLikeMod(-1) opts - .onPressToggleUpvote() + .onPressToggleLike() .catch(_e => undefined) .then(() => setLikeMod(0)) } @@ -186,6 +186,7 @@ export function PostCtrls(opts: PostCtrlsOpts) { <View style={[styles.ctrls, opts.style]}> <View style={s.flex1}> <TouchableOpacity + testID="replyBtn" style={styles.ctrl} hitSlop={HITSLOP} onPress={opts.onPressReply}> @@ -203,6 +204,7 @@ export function PostCtrls(opts: PostCtrlsOpts) { </View> <View style={s.flex1}> <TouchableOpacity + testID="repostBtn" hitSlop={HITSLOP} onPress={onPressToggleRepostWrapper} style={styles.ctrl}> @@ -230,6 +232,7 @@ export function PostCtrls(opts: PostCtrlsOpts) { } {typeof opts.repostCount !== 'undefined' ? ( <Text + testID="repostCount" style={ opts.isReposted || repostMod > 0 ? [s.bold, s.green3, s.f15, s.ml5] @@ -242,12 +245,13 @@ export function PostCtrls(opts: PostCtrlsOpts) { </View> <View style={s.flex1}> <TouchableOpacity + testID="likeBtn" style={styles.ctrl} hitSlop={HITSLOP} - onPress={onPressToggleUpvoteWrapper}> - {opts.isUpvoted || likeMod > 0 ? ( + onPress={onPressToggleLikeWrapper}> + {opts.isLiked || likeMod > 0 ? ( <HeartIconSolid - style={styles.ctrlIconUpvoted as StyleProp<ViewStyle>} + style={styles.ctrlIconLiked as StyleProp<ViewStyle>} size={opts.big ? 22 : 16} /> ) : ( @@ -259,9 +263,9 @@ export function PostCtrls(opts: PostCtrlsOpts) { )} { undefined /*DISABLED see #135 <TriggerableAnimated ref={likeRef}> - {opts.isUpvoted || likeMod > 0 ? ( + {opts.isLiked || likeMod > 0 ? ( <HeartIconSolid - style={styles.ctrlIconUpvoted as ViewStyle} + style={styles.ctrlIconLiked as ViewStyle} size={opts.big ? 22 : 16} /> ) : ( @@ -276,14 +280,15 @@ export function PostCtrls(opts: PostCtrlsOpts) { )} </TriggerableAnimated>*/ } - {typeof opts.upvoteCount !== 'undefined' ? ( + {typeof opts.likeCount !== 'undefined' ? ( <Text + testID="likeCount" style={ - opts.isUpvoted || likeMod > 0 + opts.isLiked || likeMod > 0 ? [s.bold, s.red3, s.f15, s.ml5] : [defaultCtrlColor, s.f15, s.ml5] }> - {opts.upvoteCount + likeMod} + {opts.likeCount + likeMod} </Text> ) : undefined} </TouchableOpacity> @@ -291,6 +296,7 @@ export function PostCtrls(opts: PostCtrlsOpts) { <View style={s.flex1}> {opts.big ? undefined : ( <PostDropdownBtn + testID="postDropdownBtn" style={styles.ctrl} itemUri={opts.itemUri} itemCid={opts.itemCid} @@ -330,7 +336,7 @@ const styles = StyleSheet.create({ ctrlIconReposted: { color: colors.green3, }, - ctrlIconUpvoted: { + ctrlIconLiked: { color: colors.red3, }, mt1: { diff --git a/src/view/com/util/PostEmbeds/YoutubeEmbed.tsx b/src/view/com/util/PostEmbeds/YoutubeEmbed.tsx deleted file mode 100644 index d9425fe4e..000000000 --- a/src/view/com/util/PostEmbeds/YoutubeEmbed.tsx +++ /dev/null @@ -1,119 +0,0 @@ -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/index' - -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]) - - const imageChild = ( - <Pressable onPress={handlePlayButtonPressed} style={styles.playButton}> - <FontAwesomeIcon icon="play" size={24} color="white" /> - </Pressable> - ) - - if (!displayVideoPlayer) { - return ( - <View - style={[styles.extOuter, pal.view, pal.border]} - onLayout={handleOnLayout}> - <ExternalLinkEmbed - link={link} - onImagePress={handlePlayButtonPressed} - imageChild={imageChild} - /> - </View> - ) - } - - const height = (playerDimensions.width / 16) * 9 - const noop = () => {} - - return ( - <TouchableWithoutFeedback onPress={noop}> - <View> - {/* Removing the outter View will make tap events propagate to parents */} - <YoutubePlayer - initialPlayerParams={{ - modestbranding: true, - }} - webViewProps={{ - startInLoadingState: true, - }} - height={height} - videoId={videoId} - webViewStyle={styles.webView} - /> - </View> - </TouchableWithoutFeedback> - ) -} - -const styles = StyleSheet.create({ - extOuter: { - borderWidth: 1, - borderRadius: 8, - marginTop: 4, - }, - playButton: { - position: 'absolute', - alignSelf: 'center', - alignItems: 'center', - top: '44%', - 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/PostMeta.tsx b/src/view/com/util/PostMeta.tsx index c53de5c1f..a675283b8 100644 --- a/src/view/com/util/PostMeta.tsx +++ b/src/view/com/util/PostMeta.tsx @@ -16,7 +16,6 @@ interface PostMetaOpts { postHref: string timestamp: string did?: string - declarationCid?: string showFollowBtn?: boolean } @@ -34,13 +33,7 @@ export const PostMeta = observer(function (opts: PostMetaOpts) { setDidFollow(true) }, [setDidFollow]) - if ( - opts.showFollowBtn && - !isMe && - (!isFollowing || didFollow) && - opts.did && - opts.declarationCid - ) { + if (opts.showFollowBtn && !isMe && (!isFollowing || didFollow) && opts.did) { // two-liner with follow button return ( <View style={styles.metaTwoLine}> @@ -79,7 +72,6 @@ export const PostMeta = observer(function (opts: PostMetaOpts) { <FollowButton type="default" did={opts.did} - declarationCid={opts.declarationCid} onToggleFollow={onToggleFollow} /> </View> diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index 2e0632521..ff741cd34 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -23,6 +23,7 @@ import {isWeb} from 'platform/detection' function DefaultAvatar({size}: {size: number}) { return ( <Svg + testID="userAvatarFallback" width={size} height={size} viewBox="0 0 24 24" @@ -56,6 +57,7 @@ export function UserAvatar({ const dropdownItems = [ !isWeb && { + testID: 'changeAvatarCameraBtn', label: 'Camera', icon: 'camera' as IconProp, onPress: async () => { @@ -73,6 +75,7 @@ export function UserAvatar({ }, }, { + testID: 'changeAvatarLibraryBtn', label: 'Library', icon: 'image' as IconProp, onPress: async () => { @@ -94,6 +97,7 @@ export function UserAvatar({ }, }, { + testID: 'changeAvatarRemoveBtn', label: 'Remove', icon: ['far', 'trash-can'] as IconProp, onPress: async () => { @@ -104,6 +108,7 @@ export function UserAvatar({ // onSelectNewAvatar is only passed as prop on the EditProfile component return onSelectNewAvatar ? ( <DropdownButton + testID="changeAvatarBtn" type="bare" items={dropdownItems} openToRight @@ -112,6 +117,7 @@ export function UserAvatar({ menuWidth={170}> {avatar ? ( <HighPriorityImage + testID="userAvatarImage" style={{ width: size, height: size, @@ -132,6 +138,7 @@ export function UserAvatar({ </DropdownButton> ) : avatar ? ( <HighPriorityImage + testID="userAvatarImage" style={{width: size, height: size, borderRadius: Math.floor(size / 2)}} resizeMode="stretch" source={{uri: avatar}} diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index 8317f93ac..56d7e370a 100644 --- a/src/view/com/util/UserBanner.tsx +++ b/src/view/com/util/UserBanner.tsx @@ -33,6 +33,7 @@ export function UserBanner({ const dropdownItems = [ !isWeb && { + testID: 'changeBannerCameraBtn', label: 'Camera', icon: 'camera' as IconProp, onPress: async () => { @@ -51,6 +52,7 @@ export function UserBanner({ }, }, { + testID: 'changeBannerLibraryBtn', label: 'Library', icon: 'image' as IconProp, onPress: async () => { @@ -73,6 +75,7 @@ export function UserBanner({ }, }, { + testID: 'changeBannerRemoveBtn', label: 'Remove', icon: ['far', 'trash-can'] as IconProp, onPress: () => { @@ -84,6 +87,7 @@ export function UserBanner({ // setUserBanner is only passed as prop on the EditProfile component return onSelectNewBanner ? ( <DropdownButton + testID="changeBannerBtn" type="bare" items={dropdownItems} openToRight @@ -91,9 +95,16 @@ export function UserBanner({ bottomOffset={-10} menuWidth={170}> {banner ? ( - <Image style={styles.bannerImage} source={{uri: banner}} /> + <Image + testID="userBannerImage" + style={styles.bannerImage} + source={{uri: banner}} + /> ) : ( - <View style={[styles.bannerImage, styles.defaultBanner]} /> + <View + testID="userBannerFallback" + style={[styles.bannerImage, styles.defaultBanner]} + /> )} <View style={[styles.editButtonContainer, pal.btn]}> <FontAwesomeIcon @@ -106,12 +117,16 @@ export function UserBanner({ </DropdownButton> ) : banner ? ( <Image + testID="userBannerImage" style={styles.bannerImage} resizeMode="cover" source={{uri: banner}} /> ) : ( - <View style={[styles.bannerImage, styles.defaultBanner]} /> + <View + testID="userBannerFallback" + style={[styles.bannerImage, styles.defaultBanner]} + /> ) } diff --git a/src/view/com/util/ViewHeader.tsx b/src/view/com/util/ViewHeader.tsx index a99282512..ad0a5a1d2 100644 --- a/src/view/com/util/ViewHeader.tsx +++ b/src/view/com/util/ViewHeader.tsx @@ -51,7 +51,7 @@ export const ViewHeader = observer(function ({ return ( <Container hideOnScroll={hideOnScroll || false}> <TouchableOpacity - testID="viewHeaderBackOrMenuBtn" + testID="viewHeaderDrawerBtn" onPress={canGoBack ? onPressBack : onPressMenu} hitSlop={BACK_HITSLOP} style={canGoBack ? styles.backBtn : styles.backBtnWide}> diff --git a/src/view/com/util/ViewSelector.tsx b/src/view/com/util/ViewSelector.tsx index e1280fd82..82351cf08 100644 --- a/src/view/com/util/ViewSelector.tsx +++ b/src/view/com/util/ViewSelector.tsx @@ -47,13 +47,18 @@ export function ViewSelector({ // events // = - const onSwipeEnd = (dx: number) => { - if (dx !== 0) { - setSelectedIndex(clamp(selectedIndex + dx, 0, sections.length)) - } - } - const onPressSelection = (index: number) => - setSelectedIndex(clamp(index, 0, sections.length)) + const onSwipeEnd = React.useCallback( + (dx: number) => { + if (dx !== 0) { + setSelectedIndex(clamp(selectedIndex + dx, 0, sections.length)) + } + }, + [setSelectedIndex, selectedIndex, sections], + ) + const onPressSelection = React.useCallback( + (index: number) => setSelectedIndex(clamp(index, 0, sections.length)), + [setSelectedIndex, sections], + ) useEffect(() => { onSelectView?.(selectedIndex) }, [selectedIndex, onSelectView]) @@ -61,27 +66,33 @@ export function ViewSelector({ // rendering // = - const renderItemInternal = ({item}: {item: any}) => { - if (item === HEADER_ITEM) { - if (renderHeader) { - return renderHeader() + const renderItemInternal = React.useCallback( + ({item}: {item: any}) => { + if (item === HEADER_ITEM) { + if (renderHeader) { + return renderHeader() + } + return <View /> + } else if (item === SELECTOR_ITEM) { + return ( + <Selector + items={sections} + panX={panX} + selectedIndex={selectedIndex} + onSelect={onPressSelection} + /> + ) + } else { + return renderItem(item) } - return <View /> - } else if (item === SELECTOR_ITEM) { - return ( - <Selector - items={sections} - panX={panX} - selectedIndex={selectedIndex} - onSelect={onPressSelection} - /> - ) - } else { - return renderItem(item) - } - } + }, + [sections, panX, selectedIndex, onPressSelection, renderHeader, renderItem], + ) - const data = [HEADER_ITEM, SELECTOR_ITEM, ...items] + const data = React.useMemo( + () => [HEADER_ITEM, SELECTOR_ITEM, ...items], + [items], + ) return ( <HorzSwipe hasPriority diff --git a/src/view/com/util/forms/Button.tsx b/src/view/com/util/forms/Button.tsx index f3f4d1c79..b7c058d2d 100644 --- a/src/view/com/util/forms/Button.tsx +++ b/src/view/com/util/forms/Button.tsx @@ -27,11 +27,13 @@ export function Button({ style, onPress, children, + testID, }: React.PropsWithChildren<{ type?: ButtonType label?: string style?: StyleProp<ViewStyle> onPress?: () => void + testID?: string }>) { const theme = useTheme() const outerStyle = choose<ViewStyle, Record<ButtonType, ViewStyle>>(type, { @@ -107,7 +109,8 @@ export function Button({ return ( <TouchableOpacity style={[outerStyle, styles.outer, style]} - onPress={onPress}> + onPress={onPress} + testID={testID}> {label ? ( <Text type="button" style={[labelStyle]}> {label} diff --git a/src/view/com/util/forms/DropdownButton.tsx b/src/view/com/util/forms/DropdownButton.tsx index d6ae800c6..938c346cd 100644 --- a/src/view/com/util/forms/DropdownButton.tsx +++ b/src/view/com/util/forms/DropdownButton.tsx @@ -24,6 +24,7 @@ const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10} const ESTIMATED_MENU_ITEM_HEIGHT = 52 export interface DropdownItem { + testID?: string icon?: IconProp label: string onPress: () => void @@ -33,6 +34,7 @@ type MaybeDropdownItem = DropdownItem | false | undefined export type DropdownButtonType = ButtonType | 'bare' export function DropdownButton({ + testID, type = 'bare', style, items, @@ -43,6 +45,7 @@ export function DropdownButton({ rightOffset = 0, bottomOffset = 0, }: { + testID?: string type?: DropdownButtonType style?: StyleProp<ViewStyle> items: MaybeDropdownItem[] @@ -90,22 +93,18 @@ export function DropdownButton({ if (type === 'bare') { return ( <TouchableOpacity + testID={testID} style={style} onPress={onPress} hitSlop={HITSLOP} - // Fix an issue where specific references cause runtime error in jest environment - ref={ - typeof process !== 'undefined' && process.env.JEST_WORKER_ID != null - ? null - : ref - }> + ref={ref}> {children} </TouchableOpacity> ) } return ( <View ref={ref}> - <Button onPress={onPress} style={style} label={label}> + <Button testID={testID} onPress={onPress} style={style} label={label}> {children} </Button> </View> @@ -113,6 +112,7 @@ export function DropdownButton({ } export function PostDropdownBtn({ + testID, style, children, itemUri, @@ -123,6 +123,7 @@ export function PostDropdownBtn({ onOpenTranslate, onDeletePost, }: { + testID?: string style?: StyleProp<ViewStyle> children?: React.ReactNode itemUri: string @@ -138,6 +139,7 @@ export function PostDropdownBtn({ const dropdownItems: DropdownItem[] = [ { + testID: 'postDropdownTranslateBtn', icon: 'language', label: 'Translate...', onPress() { @@ -145,6 +147,7 @@ export function PostDropdownBtn({ }, }, { + testID: 'postDropdownCopyTextBtn', icon: ['far', 'paste'], label: 'Copy post text', onPress() { @@ -152,6 +155,7 @@ export function PostDropdownBtn({ }, }, { + testID: 'postDropdownShareBtn', icon: 'share', label: 'Share...', onPress() { @@ -159,6 +163,7 @@ export function PostDropdownBtn({ }, }, { + testID: 'postDropdownReportBtn', icon: 'circle-exclamation', label: 'Report post', onPress() { @@ -171,6 +176,7 @@ export function PostDropdownBtn({ }, isAuthor ? { + testID: 'postDropdownDeleteBtn', icon: ['far', 'trash-can'], label: 'Delete post', onPress() { @@ -186,7 +192,11 @@ export function PostDropdownBtn({ ].filter(Boolean) as DropdownItem[] return ( - <DropdownButton style={style} items={dropdownItems} menuWidth={200}> + <DropdownButton + testID={testID} + style={style} + items={dropdownItems} + menuWidth={200}> {children} </DropdownButton> ) @@ -291,6 +301,7 @@ const DropdownItems = ({ ]}> {items.map((item, index) => ( <TouchableOpacity + testID={item.testID} key={index} style={[styles.menuItem]} onPress={() => onPressItem(index)}> diff --git a/src/view/com/util/forms/RadioButton.tsx b/src/view/com/util/forms/RadioButton.tsx index d6b2bb119..f5696a76d 100644 --- a/src/view/com/util/forms/RadioButton.tsx +++ b/src/view/com/util/forms/RadioButton.tsx @@ -6,12 +6,14 @@ import {useTheme} from 'lib/ThemeContext' import {choose} from 'lib/functions' export function RadioButton({ + testID, type = 'default-light', label, isSelected, style, onPress, }: { + testID?: string type?: ButtonType label: string isSelected: boolean @@ -119,7 +121,7 @@ export function RadioButton({ }, }) return ( - <Button type={type} onPress={onPress} style={style}> + <Button testID={testID} type={type} onPress={onPress} style={style}> <View style={styles.outer}> <View style={[circleStyle, styles.circle]}> {isSelected ? ( diff --git a/src/view/com/util/forms/RadioGroup.tsx b/src/view/com/util/forms/RadioGroup.tsx index 901b0cdd8..071540b73 100644 --- a/src/view/com/util/forms/RadioGroup.tsx +++ b/src/view/com/util/forms/RadioGroup.tsx @@ -10,11 +10,13 @@ export interface RadioGroupItem { } export function RadioGroup({ + testID, type, items, initialSelection = '', onSelect, }: { + testID?: string type?: ButtonType items: RadioGroupItem[] initialSelection?: string @@ -30,6 +32,7 @@ export function RadioGroup({ {items.map((item, i) => ( <RadioButton key={item.key} + testID={testID ? `${testID}-${item.key}` : undefined} style={i !== 0 ? s.mt2 : undefined} type={type} label={item.label} diff --git a/src/view/com/util/images/AutoSizedImage.tsx b/src/view/com/util/images/AutoSizedImage.tsx index 24dbe6a52..ddb09ce39 100644 --- a/src/view/com/util/images/AutoSizedImage.tsx +++ b/src/view/com/util/images/AutoSizedImage.tsx @@ -4,9 +4,9 @@ import { StyleProp, StyleSheet, TouchableOpacity, + View, ViewStyle, } from 'react-native' -// import Image from 'view/com/util/images/Image' import {clamp} from 'lib/numbers' import {useStores} from 'state/index' import {Dim} from 'lib/media/manip' @@ -51,16 +51,24 @@ export function AutoSizedImage({ }) }, [dim, setDim, setAspectRatio, store, uri]) + if (onPress || onLongPress || onPressIn) { + return ( + <TouchableOpacity + onPress={onPress} + onLongPress={onLongPress} + onPressIn={onPressIn} + delayPressIn={DELAY_PRESS_IN} + style={[styles.container, style]}> + <Image style={[styles.image, {aspectRatio}]} source={{uri}} /> + {children} + </TouchableOpacity> + ) + } return ( - <TouchableOpacity - onPress={onPress} - onLongPress={onLongPress} - onPressIn={onPressIn} - delayPressIn={DELAY_PRESS_IN} - style={[styles.container, style]}> + <View style={[styles.container, style]}> <Image style={[styles.image, {aspectRatio}]} source={{uri}} /> {children} - </TouchableOpacity> + </View> ) } diff --git a/src/view/com/util/PostEmbeds/ExternalLinkEmbed.tsx b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx index e8c63bdb7..a4cbb3e29 100644 --- a/src/view/com/util/PostEmbeds/ExternalLinkEmbed.tsx +++ b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx @@ -3,25 +3,20 @@ import {Text} from '../text/Text' import {AutoSizedImage} from '../images/AutoSizedImage' import {StyleSheet, View} from 'react-native' import {usePalette} from 'lib/hooks/usePalette' -import {PresentedExternal} from '@atproto/api/dist/client/types/app/bsky/embed/external' +import {AppBskyEmbedExternal} from '@atproto/api' -const ExternalLinkEmbed = ({ +export const ExternalLinkEmbed = ({ link, - onImagePress, imageChild, }: { - link: PresentedExternal - onImagePress?: () => void + link: AppBskyEmbedExternal.ViewExternal imageChild?: React.ReactNode }) => { const pal = usePalette('default') return ( <> {link.thumb ? ( - <AutoSizedImage - uri={link.thumb} - style={styles.extImage} - onPress={onImagePress}> + <AutoSizedImage uri={link.thumb} style={styles.extImage}> {imageChild} </AutoSizedImage> ) : undefined} @@ -65,5 +60,3 @@ const styles = StyleSheet.create({ marginTop: 4, }, }) - -export default ExternalLinkEmbed diff --git a/src/view/com/util/PostEmbeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx index fee67c9bc..9dc5739a0 100644 --- a/src/view/com/util/PostEmbeds/QuoteEmbed.tsx +++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx @@ -1,13 +1,21 @@ -import {StyleSheet} from 'react-native' import React from 'react' +import {StyleProp, StyleSheet, ViewStyle} from 'react-native' +import {AppBskyEmbedImages, AppBskyEmbedRecordWithMedia} from '@atproto/api' import {AtUri} from '../../../../third-party/uri' import {PostMeta} from '../PostMeta' import {Link} from '../Link' import {Text} from '../text/Text' import {usePalette} from 'lib/hooks/usePalette' import {ComposerOptsQuote} from 'state/models/ui/shell' +import {PostEmbeds} from '.' -const QuoteEmbed = ({quote}: {quote: ComposerOptsQuote}) => { +export function QuoteEmbed({ + quote, + style, +}: { + quote: ComposerOptsQuote + style?: StyleProp<ViewStyle> +}) { const pal = usePalette('default') const itemUrip = new AtUri(quote.uri) const itemHref = `/profile/${quote.author.handle}/post/${itemUrip.rkey}` @@ -16,9 +24,18 @@ const QuoteEmbed = ({quote}: {quote: ComposerOptsQuote}) => { () => quote.text.trim().length === 0, [quote.text], ) + const imagesEmbed = React.useMemo( + () => + quote.embeds?.find( + embed => + AppBskyEmbedImages.isView(embed) || + AppBskyEmbedRecordWithMedia.isView(embed), + ), + [quote.embeds], + ) return ( <Link - style={[styles.container, pal.border]} + style={[styles.container, pal.border, style]} href={itemHref} title={itemTitle}> <PostMeta @@ -37,6 +54,12 @@ const QuoteEmbed = ({quote}: {quote: ComposerOptsQuote}) => { quote.text )} </Text> + {AppBskyEmbedImages.isView(imagesEmbed) && ( + <PostEmbeds embed={imagesEmbed} /> + )} + {AppBskyEmbedRecordWithMedia.isView(imagesEmbed) && ( + <PostEmbeds embed={imagesEmbed.media} /> + )} </Link> ) } @@ -48,7 +71,6 @@ const styles = StyleSheet.create({ borderRadius: 8, paddingVertical: 8, paddingHorizontal: 12, - marginVertical: 8, borderWidth: 1, }, quotePost: { diff --git a/src/view/com/util/post-embeds/YoutubeEmbed.tsx b/src/view/com/util/post-embeds/YoutubeEmbed.tsx new file mode 100644 index 000000000..2ca0750a3 --- /dev/null +++ b/src/view/com/util/post-embeds/YoutubeEmbed.tsx @@ -0,0 +1,55 @@ +import React from 'react' +import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' +import {usePalette} from 'lib/hooks/usePalette' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {ExternalLinkEmbed} from './ExternalLinkEmbed' +import {AppBskyEmbedExternal} from '@atproto/api' +import {Link} from '../Link' + +export const YoutubeEmbed = ({ + link, + style, +}: { + link: AppBskyEmbedExternal.ViewExternal + style?: StyleProp<ViewStyle> +}) => { + const pal = usePalette('default') + + const imageChild = ( + <View style={styles.playButton}> + <FontAwesomeIcon icon="play" size={24} color="white" /> + </View> + ) + + return ( + <Link + style={[styles.extOuter, pal.view, pal.border, style]} + href={link.uri} + noFeedback> + <ExternalLinkEmbed link={link} imageChild={imageChild} /> + </Link> + ) +} + +const styles = StyleSheet.create({ + extOuter: { + borderWidth: 1, + borderRadius: 8, + }, + playButton: { + position: 'absolute', + alignSelf: 'center', + alignItems: 'center', + top: '44%', + justifyContent: 'center', + backgroundColor: 'black', + padding: 10, + borderRadius: 50, + opacity: 0.8, + }, + webView: { + alignItems: 'center', + alignContent: 'center', + justifyContent: 'center', + }, +}) diff --git a/src/view/com/util/PostEmbeds/index.tsx b/src/view/com/util/post-embeds/index.tsx index 02a8aa90e..726bea6e7 100644 --- a/src/view/com/util/PostEmbeds/index.tsx +++ b/src/view/com/util/post-embeds/index.tsx @@ -10,6 +10,7 @@ import { AppBskyEmbedImages, AppBskyEmbedExternal, AppBskyEmbedRecord, + AppBskyEmbedRecordWithMedia, AppBskyFeedPost, } from '@atproto/api' import {Link} from '../Link' @@ -19,15 +20,16 @@ import {ImagesLightbox} from 'state/models/ui/shell' import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {saveImageModal} from 'lib/media/manip' -import YoutubeEmbed from './YoutubeEmbed' -import ExternalLinkEmbed from './ExternalLinkEmbed' +import {YoutubeEmbed} from './YoutubeEmbed' +import {ExternalLinkEmbed} from './ExternalLinkEmbed' import {getYoutubeVideoId} from 'lib/strings/url-helpers' import QuoteEmbed from './QuoteEmbed' type Embed = - | AppBskyEmbedRecord.Presented - | AppBskyEmbedImages.Presented - | AppBskyEmbedExternal.Presented + | AppBskyEmbedRecord.View + | AppBskyEmbedImages.View + | AppBskyEmbedExternal.View + | AppBskyEmbedRecordWithMedia.View | {$type: string; [k: string]: unknown} export function PostEmbeds({ @@ -39,11 +41,35 @@ export function PostEmbeds({ }) { const pal = usePalette('default') const store = useStores() - if (AppBskyEmbedRecord.isPresented(embed)) { + + if ( + AppBskyEmbedRecordWithMedia.isView(embed) && + AppBskyEmbedRecord.isViewRecord(embed.record.record) && + AppBskyFeedPost.isRecord(embed.record.record.value) && + AppBskyFeedPost.validateRecord(embed.record.record.value).success + ) { + return ( + <View style={[styles.stackContainer, style]}> + <PostEmbeds embed={embed.media} /> + <QuoteEmbed + quote={{ + author: embed.record.record.author, + cid: embed.record.record.cid, + uri: embed.record.record.uri, + indexedAt: embed.record.record.indexedAt, + text: embed.record.record.value.text, + embeds: embed.record.record.embeds, + }} + /> + </View> + ) + } + + if (AppBskyEmbedRecord.isView(embed)) { if ( - AppBskyEmbedRecord.isPresentedRecord(embed.record) && - AppBskyFeedPost.isRecord(embed.record.record) && - AppBskyFeedPost.validateRecord(embed.record.record).success + AppBskyEmbedRecord.isViewRecord(embed.record) && + AppBskyFeedPost.isRecord(embed.record.value) && + AppBskyFeedPost.validateRecord(embed.record.value).success ) { return ( <QuoteEmbed @@ -51,14 +77,17 @@ export function PostEmbeds({ author: embed.record.author, cid: embed.record.cid, uri: embed.record.uri, - indexedAt: embed.record.record.createdAt, // TODO - text: embed.record.record.text, + indexedAt: embed.record.indexedAt, + text: embed.record.value.text, + embeds: embed.record.embeds, }} + style={style} /> ) } } - if (AppBskyEmbedImages.isPresented(embed)) { + + if (AppBskyEmbedImages.isView(embed)) { if (embed.images.length > 0) { const uris = embed.images.map(img => img.fullsize) const openLightbox = (index: number) => { @@ -129,12 +158,13 @@ export function PostEmbeds({ } } } - if (AppBskyEmbedExternal.isPresented(embed)) { + + if (AppBskyEmbedExternal.isView(embed)) { const link = embed.external const youtubeVideoId = getYoutubeVideoId(link.uri) if (youtubeVideoId) { - return <YoutubeEmbed videoId={youtubeVideoId} link={link} /> + return <YoutubeEmbed link={link} style={style} /> } return ( @@ -150,6 +180,9 @@ export function PostEmbeds({ } const styles = StyleSheet.create({ + stackContainer: { + gap: 6, + }, imagesContainer: { marginTop: 4, }, diff --git a/src/view/com/util/text/RichText.tsx b/src/view/com/util/text/RichText.tsx index d4cf19172..804db002a 100644 --- a/src/view/com/util/text/RichText.tsx +++ b/src/view/com/util/text/RichText.tsx @@ -1,20 +1,22 @@ import React from 'react' import {TextStyle, StyleProp} from 'react-native' +import {RichText as RichTextObj, AppBskyRichtextFacet} from '@atproto/api' import {TextLink} from '../Link' import {Text} from './Text' import {lh} from 'lib/styles' import {toShortUrl} from 'lib/strings/url-helpers' -import {RichText as RichTextObj, Entity} from 'lib/strings/rich-text' import {useTheme, TypographyVariant} from 'lib/ThemeContext' import {usePalette} from 'lib/hooks/usePalette' export function RichText({ + testID, type = 'md', richText, lineHeight = 1.2, style, numberOfLines, }: { + testID?: string type?: TypographyVariant richText?: RichTextObj lineHeight?: number @@ -29,17 +31,24 @@ export function RichText({ return null } - const {text, entities} = richText - if (!entities?.length) { + const {text, facets} = richText + if (!facets?.length) { if (/^\p{Extended_Pictographic}+$/u.test(text) && text.length <= 5) { style = { fontSize: 26, lineHeight: 30, } - return <Text style={[style, pal.text]}>{text}</Text> + return ( + <Text testID={testID} style={[style, pal.text]}> + {text} + </Text> + ) } return ( - <Text type={type} style={[style, pal.text, lineHeightStyle]}> + <Text + testID={testID} + type={type} + style={[style, pal.text, lineHeightStyle]}> {text} </Text> ) @@ -49,40 +58,40 @@ export function RichText({ } else if (!Array.isArray(style)) { style = [style] } - entities.sort(sortByIndex) - const segments = Array.from(toSegments(text, entities)) + const els = [] let key = 0 - for (const segment of segments) { - if (typeof segment === 'string') { - els.push(segment) + for (const segment of richText.segments()) { + const link = segment.link + const mention = segment.mention + if (mention && AppBskyRichtextFacet.validateMention(mention).success) { + els.push( + <TextLink + key={key} + type={type} + text={segment.text} + href={`/profile/${mention.did}`} + style={[style, lineHeightStyle, pal.link]} + />, + ) + } else if (link && AppBskyRichtextFacet.validateLink(link).success) { + els.push( + <TextLink + key={key} + type={type} + text={toShortUrl(segment.text)} + href={link.uri} + style={[style, lineHeightStyle, pal.link]} + />, + ) } else { - if (segment.entity.type === 'mention') { - els.push( - <TextLink - key={key} - type={type} - text={segment.text} - href={`/profile/${segment.entity.value}`} - style={[style, lineHeightStyle, pal.link]} - />, - ) - } else if (segment.entity.type === 'link') { - els.push( - <TextLink - key={key} - type={type} - text={toShortUrl(segment.text)} - href={segment.entity.value} - style={[style, lineHeightStyle, pal.link]} - />, - ) - } + els.push(segment.text) } key++ } return ( <Text + testID={testID} type={type} style={[style, pal.text, lineHeightStyle]} numberOfLines={numberOfLines}> @@ -90,38 +99,3 @@ export function RichText({ </Text> ) } - -function sortByIndex(a: Entity, b: Entity) { - return a.index.start - b.index.start -} - -function* toSegments(text: string, entities: Entity[]) { - let cursor = 0 - let i = 0 - do { - let currEnt = entities[i] - if (cursor < currEnt.index.start) { - yield text.slice(cursor, currEnt.index.start) - } else if (cursor > currEnt.index.start) { - i++ - continue - } - if (currEnt.index.start < currEnt.index.end) { - let subtext = text.slice(currEnt.index.start, currEnt.index.end) - if (!subtext.trim()) { - // dont yield links to empty strings - yield subtext - } else { - yield { - entity: currEnt, - text: subtext, - } - } - } - cursor = currEnt.index.end - i++ - } while (i < entities.length) - if (cursor < text.length) { - yield text.slice(cursor, text.length) - } -} |