Lex refactor (#362)

* Remove the hackcheck for upgrades

* Rename the PostEmbeds folder to match the codebase style

* Updates to latest lex refactor

* Update to use new bsky agent

* Update to use api package's richtext library

* Switch to upsertProfile

* Add TextEncoder/TextDecoder polyfill

* Add Intl.Segmenter polyfill

* Update composer to calculate lengths by grapheme

* Fix detox

* Fix login in e2e

* Create account e2e passing

* Implement an e2e mocking framework

* Don't use private methods on mobx models as mobx can't track them

* Add tooling for e2e-specific builds and add e2e media-picker mock

* Add some tests and fix some bugs around profile editing

* Add shell tests

* Add home screen tests

* Add thread screen tests

* Add tests for other user profile screens

* Add search screen tests

* Implement profile imagery change tools and tests

* Update to new embed behaviors

* Add post tests

* Fix to profile-screen test

* Fix session resumption

* Update web composer to new api

* 1.11.0

* Fix pagination cursor parameters

* Add quote posts to notifications

* Fix embed layouts

* Remove youtube inline player and improve tap handling on link cards

* Reset minimal shell mode on all screen loads and feed swipes (close #299)

* Update podfile.lock

* Improve post notfound UI (close #366)

* Bump atproto packages
This commit is contained in:
Paul Frazee 2023-03-31 13:17:26 -05:00 committed by GitHub
parent 19f3a2fa92
commit a3334a01a2
133 changed files with 3103 additions and 2839 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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