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:
parent
19f3a2fa92
commit
a3334a01a2
133 changed files with 3103 additions and 2839 deletions
|
@ -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}
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
@ -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}}
|
||||
|
|
|
@ -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]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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)}>
|
||||
|
|
|
@ -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 ? (
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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: {
|
55
src/view/com/util/post-embeds/YoutubeEmbed.tsx
Normal file
55
src/view/com/util/post-embeds/YoutubeEmbed.tsx
Normal 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',
|
||||
},
|
||||
})
|
|
@ -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,
|
||||
},
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue