Merge remote-tracking branch 'origin/main' into halo/base

* origin/main: (47 commits)
  Add option to remove quoted post in composer (#3670)
  Add border radius to GIF loading state (#3669)
  use mp4 over webm (#3666)
  Release 1.79 (#3661)
  Update Japanese translation (#3656)
  [Statsig] Add test gates and events (#3659)
  Quick fix to ensure disable 2fa dialog adjusts to keyboard (#3658)
  Email auth factor (#3602)
  GIF Viewer (#3605)
  Ungate profile scroll fix (#3655)
  rm country param (#3653)
  [GIFs] Remove consent flow (#3652)
  [GIFs] Replace GIPHY with Tenor (#3651)
  properly close the switch account dialog (#3558)
  [GIFs] Reset scroll on query change (#3642)
  Update French translations (#3644)
  [GIFs] Add error boundary to GIF picker (#3643)
  Revert "Add layout transitions to the composer photo gallery on iOS (#3609)" (#3649)
  Update Finnish translations  (#3630)
  Update Japanese translations (#3632)
  ...
This commit is contained in:
Eric Bailey
2024-04-23 18:59:03 -05:00
97 changed files with 12791 additions and 9823 deletions
+65 -26
View File
@@ -5,7 +5,6 @@ import {
Keyboard,
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
StyleSheet,
TouchableOpacity,
@@ -19,6 +18,7 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {observer} from 'mobx-react-lite'
import {LikelyType} from '#/lib/link-meta/link-meta'
import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {emitPostCreated} from '#/state/events'
@@ -30,6 +30,7 @@ import {
useLanguagePrefsApi,
} from '#/state/preferences/languages'
import {useProfileQuery} from '#/state/queries/profile'
import {Gif} from '#/state/queries/tenor'
import {ThreadgateSetting} from '#/state/queries/threadgate'
import {getAgent, useSession} from '#/state/session'
import {useComposerControls} from '#/state/shell/composer'
@@ -48,8 +49,11 @@ import {useDialogStateControlContext} from 'state/dialogs'
import {GalleryModel} from 'state/models/media/gallery'
import {ComposerOpts} from 'state/shell/composer'
import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo'
import {atoms as a} from '#/alf'
import {Button} from '#/components/Button'
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
import * as Prompt from '#/components/Prompt'
import {QuoteEmbed} from '../util/post-embeds/QuoteEmbed'
import {QuoteEmbed, QuoteX} from '../util/post-embeds/QuoteEmbed'
import {Text} from '../util/text/Text'
import * as Toast from '../util/Toast'
import {UserAvatar} from '../util/UserAvatar'
@@ -58,6 +62,7 @@ import {ExternalEmbed} from './ExternalEmbed'
import {LabelsBtn} from './labels/LabelsBtn'
import {Gallery} from './photos/Gallery'
import {OpenCameraBtn} from './photos/OpenCameraBtn'
import {SelectGifBtn} from './photos/SelectGifBtn'
import {SelectPhotoBtn} from './photos/SelectPhotoBtn'
import {SelectLangBtn} from './select-language/SelectLangBtn'
import {SuggestedLanguage} from './select-language/SuggestedLanguage'
@@ -116,6 +121,7 @@ export const ComposePost = observer(function ComposePost({
initQuote,
)
const {extLink, setExtLink} = useExternalLinkFetch({setQuote})
const [extGif, setExtGif] = useState<Gif>()
const [labels, setLabels] = useState<string[]>([])
const [threadgate, setThreadgate] = useState<ThreadgateSetting[]>([])
const gallery = useMemo(
@@ -299,13 +305,35 @@ export const ComposePost = observer(function ComposePost({
? _(msg`Write your reply`)
: _(msg`What's up?`)
const canSelectImages = useMemo(() => gallery.size < 4, [gallery.size])
const canSelectImages = gallery.size < 4 && !extLink
const hasMedia = gallery.size > 0 || Boolean(extLink)
const onEmojiButtonPress = useCallback(() => {
openPicker?.(textInput.current?.getCursorPosition())
}, [openPicker])
const focusTextInput = useCallback(() => {
textInput.current?.focus()
}, [])
const onSelectGif = useCallback(
(gif: Gif) => {
setExtLink({
uri: `${gif.media_formats.gif.url}?hh=${gif.media_formats.gif.dims[1]}&ww=${gif.media_formats.gif.dims[0]}`,
isLoading: true,
meta: {
url: gif.media_formats.gif.url,
image: gif.media_formats.preview.url,
likelyType: LikelyType.HTML,
title: gif.content_description,
description: `ALT: ${gif.content_description}`,
},
})
setExtGif(gif)
},
[setExtLink],
)
return (
<KeyboardAvoidingView
testID="composePostView"
@@ -447,36 +475,47 @@ export const ComposePost = observer(function ComposePost({
{gallery.isEmpty && extLink && (
<ExternalEmbed
link={extLink}
onRemove={() => setExtLink(undefined)}
gif={extGif}
onRemove={() => {
setExtLink(undefined)
setExtGif(undefined)
}}
/>
)}
{quote ? (
<View style={[s.mt5, isWeb && s.mb10, {pointerEvents: 'none'}]}>
<QuoteEmbed quote={quote} />
<View style={[s.mt5, isWeb && s.mb10]}>
<View style={{pointerEvents: 'none'}}>
<QuoteEmbed quote={quote} />
</View>
{quote.uri !== initQuote?.uri && (
<QuoteX onRemove={() => setQuote(undefined)} />
)}
</View>
) : undefined}
</ScrollView>
<SuggestedLanguage text={richtext.text} />
<View style={[pal.border, styles.bottomBar]}>
{canSelectImages ? (
<>
<SelectPhotoBtn gallery={gallery} />
<OpenCameraBtn gallery={gallery} />
</>
) : null}
{!isMobile ? (
<Pressable
onPress={onEmojiButtonPress}
accessibilityRole="button"
accessibilityLabel={_(msg`Open emoji picker`)}
accessibilityHint={_(msg`Open emoji picker`)}>
<FontAwesomeIcon
icon={['far', 'face-smile']}
color={pal.colors.link}
size={22}
/>
</Pressable>
) : null}
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
<SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} />
<OpenCameraBtn gallery={gallery} disabled={!canSelectImages} />
<SelectGifBtn
onClose={focusTextInput}
onSelectGif={onSelectGif}
disabled={hasMedia}
/>
{!isMobile ? (
<Button
onPress={onEmojiButtonPress}
style={a.p_sm}
label={_(msg`Open emoji picker`)}
accessibilityHint={_(msg`Open emoji picker`)}
variant="ghost"
shape="round"
color="primary">
<EmojiSmile size="lg" />
</Button>
) : null}
</View>
<View style={s.flex1} />
<SelectLangBtn />
<CharProgress count={graphemeLength} />
@@ -571,7 +610,7 @@ const styles = StyleSheet.create({
},
bottomBar: {
flexDirection: 'row',
paddingVertical: 10,
paddingVertical: 4,
paddingLeft: 15,
paddingRight: 20,
alignItems: 'center',
+46 -24
View File
@@ -1,11 +1,12 @@
import React from 'react'
import {TouchableOpacity, View} from 'react-native'
import {StyleProp, TouchableOpacity, View, ViewStyle} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {ExternalEmbedDraft} from 'lib/api/index'
import {s} from 'lib/styles'
import {Gif} from 'state/queries/tenor'
import {ExternalLinkEmbed} from 'view/com/util/post-embeds/ExternalLinkEmbed'
import {atoms as a, useTheme} from '#/alf'
import {Loader} from '#/components/Loader'
@@ -14,9 +15,11 @@ import {Text} from '#/components/Typography'
export const ExternalEmbed = ({
link,
onRemove,
gif,
}: {
link?: ExternalEmbedDraft
onRemove: () => void
gif?: Gif
}) => {
const t = useTheme()
const {_} = useLingui()
@@ -34,45 +37,38 @@ export const ExternalEmbed = ({
if (!link) return null
const loadingStyle: ViewStyle | undefined = gif
? {
aspectRatio:
gif.media_formats.gif.dims[0] / gif.media_formats.gif.dims[1],
width: '100%',
}
: undefined
return (
<View
style={[
a.border,
a.rounded_sm,
a.mt_2xl,
a.mb_xl,
a.overflow_hidden,
t.atoms.border_contrast_medium,
]}>
<View style={[a.mb_xl, a.overflow_hidden, t.atoms.border_contrast_medium]}>
{link.isLoading ? (
<View
style={[
a.align_center,
a.justify_center,
a.py_5xl,
t.atoms.bg_contrast_25,
]}>
<Container style={loadingStyle}>
<Loader size="xl" />
</View>
</Container>
) : link.meta?.error ? (
<View
style={[a.justify_center, a.p_md, a.gap_xs, t.atoms.bg_contrast_25]}>
<Container style={[a.align_start, a.p_md, a.gap_xs]}>
<Text numberOfLines={1} style={t.atoms.text_contrast_high}>
{link.uri}
</Text>
<Text numberOfLines={2} style={[{color: t.palette.negative_400}]}>
{link.meta.error}
{link.meta?.error}
</Text>
</View>
</Container>
) : linkInfo ? (
<View style={{pointerEvents: 'none'}}>
<View style={{pointerEvents: !gif ? 'none' : 'auto'}}>
<ExternalLinkEmbed link={linkInfo} />
</View>
) : null}
<TouchableOpacity
style={{
position: 'absolute',
top: 10,
top: 16,
right: 10,
height: 36,
width: 36,
@@ -91,3 +87,29 @@ export const ExternalEmbed = ({
</View>
)
}
function Container({
style,
children,
}: {
style?: StyleProp<ViewStyle>
children: React.ReactNode
}) {
const t = useTheme()
return (
<View
style={[
a.mt_sm,
a.rounded_sm,
a.border,
a.align_center,
a.justify_center,
a.py_5xl,
t.atoms.bg_contrast_25,
t.atoms.border_contrast_medium,
style,
]}>
{children}
</View>
)
}
+25 -34
View File
@@ -1,32 +1,31 @@
import React, {useCallback} from 'react'
import {TouchableOpacity, StyleSheet} from 'react-native'
import * as MediaLibrary from 'expo-media-library'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {usePalette} from 'lib/hooks/usePalette'
import {useAnalytics} from 'lib/analytics/analytics'
import {openCamera} from 'lib/media/picker'
import {useCameraPermission} from 'lib/hooks/usePermissions'
import {HITSLOP_10, POST_IMG_MAX} from 'lib/constants'
import {GalleryModel} from 'state/models/media/gallery'
import {isMobileWeb, isNative} from 'platform/detection'
import {logger} from '#/logger'
import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useAnalytics} from '#/lib/analytics/analytics'
import {POST_IMG_MAX} from '#/lib/constants'
import {useCameraPermission} from '#/lib/hooks/usePermissions'
import {openCamera} from '#/lib/media/picker'
import {logger} from '#/logger'
import {isMobileWeb, isNative} from '#/platform/detection'
import {GalleryModel} from '#/state/models/media/gallery'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {Camera_Stroke2_Corner0_Rounded as Camera} from '#/components/icons/Camera'
type Props = {
gallery: GalleryModel
disabled?: boolean
}
export function OpenCameraBtn({gallery}: Props) {
const pal = usePalette('default')
export function OpenCameraBtn({gallery, disabled}: Props) {
const {track} = useAnalytics()
const {_} = useLingui()
const {requestCameraAccessIfNeeded} = useCameraPermission()
const [mediaPermissionRes, requestMediaPermission] =
MediaLibrary.usePermissions()
const t = useTheme()
const onPressTakePicture = useCallback(async () => {
track('Composer:CameraOpened')
@@ -68,25 +67,17 @@ export function OpenCameraBtn({gallery}: Props) {
}
return (
<TouchableOpacity
<Button
testID="openCameraButton"
onPress={onPressTakePicture}
style={styles.button}
hitSlop={HITSLOP_10}
accessibilityRole="button"
accessibilityLabel={_(msg`Camera`)}
accessibilityHint={_(msg`Opens camera on device`)}>
<FontAwesomeIcon
icon="camera"
style={pal.link as FontAwesomeIconStyle}
size={24}
/>
</TouchableOpacity>
label={_(msg`Camera`)}
accessibilityHint={_(msg`Opens camera on device`)}
style={a.p_sm}
variant="ghost"
shape="round"
color="primary"
disabled={disabled}>
<Camera size="lg" style={disabled && t.atoms.text_contrast_low} />
</Button>
)
}
const styles = StyleSheet.create({
button: {
paddingHorizontal: 15,
},
})
@@ -0,0 +1,53 @@
import React, {useCallback} from 'react'
import {Keyboard} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logEvent} from '#/lib/statsig/statsig'
import {Gif} from '#/state/queries/tenor'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import {GifSelectDialog} from '#/components/dialogs/GifSelect'
import {GifSquare_Stroke2_Corner0_Rounded as GifIcon} from '#/components/icons/Gif'
type Props = {
onClose: () => void
onSelectGif: (gif: Gif) => void
disabled?: boolean
}
export function SelectGifBtn({onClose, onSelectGif, disabled}: Props) {
const {_} = useLingui()
const control = useDialogControl()
const t = useTheme()
const onPressSelectGif = useCallback(async () => {
logEvent('composer:gif:open', {})
Keyboard.dismiss()
control.open()
}, [control])
return (
<>
<Button
testID="openGifBtn"
onPress={onPressSelectGif}
label={_(msg`Select GIF`)}
accessibilityHint={_(msg`Opens GIF select dialog`)}
style={a.p_sm}
variant="ghost"
shape="round"
color="primary"
disabled={disabled}>
<GifIcon size="lg" style={disabled && t.atoms.text_contrast_low} />
</Button>
<GifSelectDialog
control={control}
onClose={onClose}
onSelectGif={onSelectGif}
/>
</>
)
}
+23 -32
View File
@@ -1,27 +1,26 @@
/* eslint-disable react-native-a11y/has-valid-accessibility-ignores-invert-colors */
import React, {useCallback} from 'react'
import {TouchableOpacity, StyleSheet} from 'react-native'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {usePalette} from 'lib/hooks/usePalette'
import {useAnalytics} from 'lib/analytics/analytics'
import {usePhotoLibraryPermission} from 'lib/hooks/usePermissions'
import {GalleryModel} from 'state/models/media/gallery'
import {HITSLOP_10} from 'lib/constants'
import {isNative} from 'platform/detection'
import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useAnalytics} from '#/lib/analytics/analytics'
import {usePhotoLibraryPermission} from '#/lib/hooks/usePermissions'
import {isNative} from '#/platform/detection'
import {GalleryModel} from '#/state/models/media/gallery'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {Image_Stroke2_Corner0_Rounded as Image} from '#/components/icons/Image'
type Props = {
gallery: GalleryModel
disabled?: boolean
}
export function SelectPhotoBtn({gallery}: Props) {
const pal = usePalette('default')
export function SelectPhotoBtn({gallery, disabled}: Props) {
const {track} = useAnalytics()
const {_} = useLingui()
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
const t = useTheme()
const onPressSelectPhotos = useCallback(async () => {
track('Composer:GalleryOpened')
@@ -34,25 +33,17 @@ export function SelectPhotoBtn({gallery}: Props) {
}, [track, requestPhotoAccessIfNeeded, gallery])
return (
<TouchableOpacity
<Button
testID="openGalleryBtn"
onPress={onPressSelectPhotos}
style={styles.button}
hitSlop={HITSLOP_10}
accessibilityRole="button"
accessibilityLabel={_(msg`Gallery`)}
accessibilityHint={_(msg`Opens device photo gallery`)}>
<FontAwesomeIcon
icon={['far', 'image']}
style={pal.link as FontAwesomeIconStyle}
size={24}
/>
</TouchableOpacity>
label={_(msg`Gallery`)}
accessibilityHint={_(msg`Opens device photo gallery`)}
style={a.p_sm}
variant="ghost"
shape="round"
color="primary"
disabled={disabled}>
<Image size="lg" style={disabled && t.atoms.text_contrast_low} />
</Button>
)
}
const styles = StyleSheet.create({
button: {
paddingHorizontal: 15,
},
})
+8 -14
View File
@@ -107,17 +107,11 @@ export function FeedPage({
})
}, [scrollToTop, feed, queryClient, setHasNew])
let feedPollInterval
if (
feed === // Discover
'feedgen|at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot' &&
// TODO: This gate check is still too early. Move it to where the polling happens.
gate('disable_poll_on_discover')
) {
feedPollInterval = undefined
} else {
feedPollInterval = POLL_FREQ
}
const isDiscoverFeed =
feed ===
'feedgen|at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot'
const adjustedHasNew =
hasNew && !(isDiscoverFeed && gate('disable_poll_on_discover_v2'))
return (
<View testID={testID} style={s.h100pct}>
@@ -127,7 +121,7 @@ export function FeedPage({
enabled={isPageFocused}
feed={feed}
feedParams={feedParams}
pollInterval={feedPollInterval}
pollInterval={POLL_FREQ}
disablePoll={hasNew}
scrollElRef={scrollElRef}
onScrolledDownChange={setIsScrolledDown}
@@ -138,11 +132,11 @@ export function FeedPage({
savedFeedConfig={savedFeedConfig}
/>
</MainScrollProvider>
{(isScrolledDown || hasNew) && (
{(isScrolledDown || adjustedHasNew) && (
<LoadLatestBtn
onPress={onPressLoadLatest}
label={_(msg`Load new posts`)}
showIndicator={hasNew}
showIndicator={adjustedHasNew}
/>
)}
+22 -14
View File
@@ -6,23 +6,24 @@ import {
StyleSheet,
View,
} from 'react-native'
import {Svg, Circle, Path} from 'react-native-svg'
import {ScrollView, TextInput} from './util'
import {Circle, Path, Svg} from 'react-native-svg'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Text} from '../util/text/Text'
import {Button} from '../util/forms/Button'
import {ErrorMessage} from '../util/error/ErrorMessage'
import * as Toast from '../util/Toast'
import {s, colors} from 'lib/styles'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logger} from '#/logger'
import {useModalControls} from '#/state/modals'
import {getAgent, useSession, useSessionApi} from '#/state/session'
import {usePalette} from 'lib/hooks/usePalette'
import {isWeb} from 'platform/detection'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {cleanError} from 'lib/strings/errors'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useModalControls} from '#/state/modals'
import {useSession, useSessionApi, getAgent} from '#/state/session'
import {logger} from '#/logger'
import {colors, s} from 'lib/styles'
import {isWeb} from 'platform/detection'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {Button} from '../util/forms/Button'
import {Text} from '../util/text/Text'
import * as Toast from '../util/Toast'
import {ScrollView, TextInput} from './util'
export const snapPoints = ['90%']
@@ -32,7 +33,13 @@ enum Stages {
ConfirmCode,
}
export function Component({showReminder}: {showReminder?: boolean}) {
export function Component({
showReminder,
onSuccess,
}: {
showReminder?: boolean
onSuccess?: () => void
}) {
const pal = usePalette('default')
const {currentAccount} = useSession()
const {updateCurrentAccount} = useSessionApi()
@@ -77,6 +84,7 @@ export function Component({showReminder}: {showReminder?: boolean}) {
updateCurrentAccount({emailConfirmed: true})
Toast.show(_(msg`Email verified`))
closeModal()
onSuccess?.()
} catch (e) {
setError(cleanError(String(e)))
} finally {
@@ -140,7 +140,7 @@ function PostThreadFollowBtnLoaded({
style={[!isFollowing ? palInverted.text : pal.text, s.bold]}
numberOfLines={1}>
{!isFollowing ? (
isFollowedBy && gate('show_follow_back_label') ? (
isFollowedBy && gate('show_follow_back_label_v2') ? (
<Trans>Follow Back</Trans>
) : (
<Trans>Follow</Trans>
+7 -2
View File
@@ -27,6 +27,7 @@ import {sanitizeHandle} from 'lib/strings/handles'
import {countLines, pluralize} from 'lib/strings/helpers'
import {niceDate} from 'lib/strings/time'
import {s} from 'lib/styles'
import {isWeb} from 'platform/detection'
import {useSession} from 'state/session'
import {PostThreadFollowBtn} from 'view/com/post-thread/PostThreadFollowBtn'
import {atoms as a} from '#/alf'
@@ -478,7 +479,12 @@ let PostThreadItemLoaded = ({
avatarSize={28}
displayNameType="md-bold"
displayNameStyle={isThreadedChild && s.ml2}
style={isThreadedChild && s.mb2}
style={
isThreadedChild && {
alignItems: 'center',
paddingBottom: isWeb ? 5 : 2,
}
}
/>
<LabelsOnMyPost post={post} />
<PostAlerts
@@ -669,7 +675,6 @@ const styles = StyleSheet.create({
},
layoutContentThreaded: {
flex: 1,
paddingRight: 10,
},
meta: {
flexDirection: 'row',
+9 -3
View File
@@ -1,12 +1,14 @@
import React, {Component, ErrorInfo, ReactNode} from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logger} from '#/logger'
import {ErrorScreen} from './error/ErrorScreen'
import {CenteredView} from './Views'
import {msg} from '@lingui/macro'
import {logger} from '#/logger'
import {useLingui} from '@lingui/react'
interface Props {
children?: ReactNode
renderError?: (error: any) => ReactNode
}
interface State {
@@ -30,6 +32,10 @@ export class ErrorBoundary extends Component<Props, State> {
public render() {
if (this.state.hasError) {
if (this.props.renderError) {
return this.props.renderError(this.state.error)
}
return (
<CenteredView style={{height: '100%', flex: 1}}>
<TranslatedErrorScreen details={this.state.error.toString()} />
+4 -2
View File
@@ -54,10 +54,12 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
/>
</View>
)}
<Text numberOfLines={1} style={[styles.maxWidth, pal.textLight]}>
<Text
numberOfLines={1}
style={[styles.maxWidth, pal.textLight, opts.displayNameStyle]}>
<TextLinkOnWebOnly
type={opts.displayNameType || 'lg-bold'}
style={[pal.text, opts.displayNameStyle]}
style={[pal.text]}
lineHeight={1.2}
disableMismatchWarning
text={
+40 -15
View File
@@ -1,19 +1,20 @@
import React from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {useNavigation} from '@react-navigation/native'
import {CenteredView} from './Views'
import {Text} from './text/Text'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {useAnalytics} from 'lib/analytics/analytics'
import {NavigationProp} from 'lib/routes/types'
import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
import Animated from 'react-native-reanimated'
import {useSetDrawerOpen} from '#/state/shell'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {useSetDrawerOpen} from '#/state/shell'
import {useAnalytics} from 'lib/analytics/analytics'
import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {NavigationProp} from 'lib/routes/types'
import {useTheme} from '#/alf'
import {Text} from './text/Text'
import {CenteredView} from './Views'
const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20}
@@ -62,6 +63,7 @@ export function ViewHeader({
return (
<DesktopWebHeader
title={title}
subtitle={subtitle}
renderButton={renderButton}
showBorder={showBorder}
/>
@@ -136,14 +138,17 @@ export function ViewHeader({
function DesktopWebHeader({
title,
subtitle,
renderButton,
showBorder = true,
}: {
title: string
subtitle?: string
renderButton?: () => JSX.Element
showBorder?: boolean
}) {
const pal = usePalette('default')
const t = useTheme()
return (
<CenteredView
style={[
@@ -153,13 +158,30 @@ function DesktopWebHeader({
{
borderBottomWidth: showBorder ? 1 : 0,
},
{display: 'flex', flexDirection: 'column'},
]}>
<View style={styles.titleContainer} pointerEvents="none">
<Text type="title-lg" style={[pal.text, styles.title]}>
{title}
</Text>
<View>
<View style={styles.titleContainer} pointerEvents="none">
<Text type="title-lg" style={[pal.text, styles.title]}>
{title}
</Text>
</View>
{renderButton?.()}
</View>
{renderButton?.()}
{subtitle ? (
<View>
<View style={[styles.titleContainer]} pointerEvents="none">
<Text
style={[
pal.text,
styles.subtitleDesktop,
t.atoms.text_contrast_medium,
]}>
{subtitle}
</Text>
</View>
</View>
) : null}
</CenteredView>
)
}
@@ -236,6 +258,9 @@ const styles = StyleSheet.create({
subtitle: {
fontSize: 13,
},
subtitleDesktop: {
fontSize: 15,
},
backBtn: {
width: 30,
height: 30,
@@ -1,20 +1,28 @@
import React from 'react'
import React, {useCallback} from 'react'
import {StyleProp, View, ViewStyle} from 'react-native'
import {Image} from 'expo-image'
import {Text} from '../text/Text'
import {StyleSheet, View} from 'react-native'
import {AppBskyEmbedExternal} from '@atproto/api'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {AppBskyEmbedExternal} from '@atproto/api'
import {toNiceDomain} from 'lib/strings/url-helpers'
import {shareUrl} from 'lib/sharing'
import {parseEmbedPlayerFromUrl} from 'lib/strings/embed-player'
import {ExternalPlayer} from 'view/com/util/post-embeds/ExternalPlayerEmbed'
import {ExternalGifEmbed} from 'view/com/util/post-embeds/ExternalGifEmbed'
import {toNiceDomain} from 'lib/strings/url-helpers'
import {isNative} from 'platform/detection'
import {useExternalEmbedsPrefs} from 'state/preferences'
import {Link} from 'view/com/util/Link'
import {ExternalGifEmbed} from 'view/com/util/post-embeds/ExternalGifEmbed'
import {ExternalPlayer} from 'view/com/util/post-embeds/ExternalPlayerEmbed'
import {GifEmbed} from 'view/com/util/post-embeds/GifEmbed'
import {atoms as a, useTheme} from '#/alf'
import {Text} from '../text/Text'
export const ExternalLinkEmbed = ({
link,
style,
}: {
link: AppBskyEmbedExternal.ViewExternal
style?: StyleProp<ViewStyle>
}) => {
const pal = usePalette('default')
const {isMobile} = useWebMediaQueries()
@@ -28,62 +36,95 @@ export const ExternalLinkEmbed = ({
}
}, [link.uri, externalEmbedPrefs])
if (embedPlayerParams?.source === 'tenor') {
return <GifEmbed params={embedPlayerParams} link={link} />
}
return (
<View style={styles.container}>
{link.thumb && !embedPlayerParams ? (
<Image
style={{aspectRatio: 1.91}}
source={{uri: link.thumb}}
accessibilityIgnoresInvertColors
/>
) : undefined}
{(embedPlayerParams?.isGif && (
<ExternalGifEmbed link={link} params={embedPlayerParams} />
)) ||
(embedPlayerParams && (
<ExternalPlayer link={link} params={embedPlayerParams} />
))}
<View style={[styles.info, {paddingHorizontal: isMobile ? 10 : 14}]}>
<Text
type="sm"
numberOfLines={1}
style={[pal.textLight, styles.extUri]}>
{toNiceDomain(link.uri)}
</Text>
{!embedPlayerParams?.isGif && (
<Text type="lg-bold" numberOfLines={3} style={[pal.text]}>
{link.title || link.uri}
</Text>
)}
{link.description && !embedPlayerParams?.hideDetails ? (
<Text
type="md"
numberOfLines={link.thumb ? 2 : 4}
style={[pal.text, styles.extDescription]}>
{link.description}
</Text>
<View style={[a.flex_col, a.rounded_sm, a.overflow_hidden, a.mt_sm]}>
<LinkWrapper link={link} style={style}>
{link.thumb && !embedPlayerParams ? (
<Image
style={{
aspectRatio: 1.91,
borderTopRightRadius: 6,
borderTopLeftRadius: 6,
}}
source={{uri: link.thumb}}
accessibilityIgnoresInvertColors
/>
) : undefined}
</View>
{embedPlayerParams?.isGif ? (
<ExternalGifEmbed link={link} params={embedPlayerParams} />
) : embedPlayerParams ? (
<ExternalPlayer link={link} params={embedPlayerParams} />
) : undefined}
<View
style={[
a.flex_1,
a.py_sm,
{
paddingHorizontal: isMobile ? 10 : 14,
},
]}>
<Text
type="sm"
numberOfLines={1}
style={[pal.textLight, {marginVertical: 2}]}>
{toNiceDomain(link.uri)}
</Text>
{!embedPlayerParams?.isGif && !embedPlayerParams?.dimensions && (
<Text type="lg-bold" numberOfLines={3} style={[pal.text]}>
{link.title || link.uri}
</Text>
)}
{link.description ? (
<Text
type="md"
numberOfLines={link.thumb ? 2 : 4}
style={[pal.text, a.mt_xs]}>
{link.description}
</Text>
) : undefined}
</View>
</LinkWrapper>
</View>
)
}
const styles = StyleSheet.create({
container: {
flexDirection: 'column',
borderRadius: 6,
overflow: 'hidden',
},
info: {
width: '100%',
bottom: 0,
paddingTop: 8,
paddingBottom: 10,
},
extUri: {
marginTop: 2,
},
extDescription: {
marginTop: 4,
},
})
function LinkWrapper({
link,
style,
children,
}: {
link: AppBskyEmbedExternal.ViewExternal
style?: StyleProp<ViewStyle>
children: React.ReactNode
}) {
const t = useTheme()
const onShareExternal = useCallback(() => {
if (link.uri && isNative) {
shareUrl(link.uri)
}
}, [link.uri])
return (
<Link
asAnchor
anchorNoUnderline
href={link.uri}
style={[
a.flex_1,
a.border,
a.rounded_sm,
t.atoms.border_contrast_medium,
style,
]}
hoverStyle={t.atoms.border_contrast_high}
onLongPress={onShareExternal}>
{children}
</Link>
)
}
+141
View File
@@ -0,0 +1,141 @@
import React from 'react'
import {Pressable, View} from 'react-native'
import {AppBskyEmbedExternal} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {EmbedPlayerParams} from 'lib/strings/embed-player'
import {useAutoplayDisabled} from 'state/preferences'
import {atoms as a, useTheme} from '#/alf'
import {Loader} from '#/components/Loader'
import {GifView} from '../../../../../modules/expo-bluesky-gif-view'
import {GifViewStateChangeEvent} from '../../../../../modules/expo-bluesky-gif-view/src/GifView.types'
function PlaybackControls({
onPress,
isPlaying,
isLoaded,
}: {
onPress: () => void
isPlaying: boolean
isLoaded: boolean
}) {
const {_} = useLingui()
const t = useTheme()
return (
<Pressable
accessibilityRole="button"
accessibilityHint={_(msg`Play or pause the GIF`)}
accessibilityLabel={isPlaying ? _(msg`Pause`) : _(msg`Play`)}
style={[
a.absolute,
a.align_center,
a.justify_center,
!isLoaded && a.border,
t.atoms.border_contrast_medium,
a.inset_0,
a.w_full,
a.h_full,
a.rounded_sm,
{
zIndex: 2,
backgroundColor: !isLoaded
? t.atoms.bg_contrast_25.backgroundColor
: !isPlaying
? 'rgba(0, 0, 0, 0.3)'
: undefined,
},
]}
onPress={onPress}>
{!isLoaded ? (
<View>
<View style={[a.align_center, a.justify_center]}>
<Loader size="xl" />
</View>
</View>
) : !isPlaying ? (
<View
style={[
a.rounded_full,
a.align_center,
a.justify_center,
{
backgroundColor: t.palette.primary_500,
width: 60,
height: 60,
},
]}>
<FontAwesomeIcon
icon="play"
size={42}
color="white"
style={{marginLeft: 8}}
/>
</View>
) : undefined}
</Pressable>
)
}
export function GifEmbed({
params,
link,
}: {
params: EmbedPlayerParams
link: AppBskyEmbedExternal.ViewExternal
}) {
const {_} = useLingui()
const autoplayDisabled = useAutoplayDisabled()
const playerRef = React.useRef<GifView>(null)
const [playerState, setPlayerState] = React.useState<{
isPlaying: boolean
isLoaded: boolean
}>({
isPlaying: !autoplayDisabled,
isLoaded: false,
})
const onPlayerStateChange = React.useCallback(
(e: GifViewStateChangeEvent) => {
setPlayerState(e.nativeEvent)
},
[],
)
const onPress = React.useCallback(() => {
playerRef.current?.toggleAsync()
}, [])
return (
<View style={[a.rounded_sm, a.overflow_hidden, a.mt_sm]}>
<View
style={[
a.rounded_sm,
a.overflow_hidden,
{
aspectRatio: params.dimensions!.width / params.dimensions!.height,
},
]}>
<PlaybackControls
onPress={onPress}
isPlaying={playerState.isPlaying}
isLoaded={playerState.isLoaded}
/>
<GifView
source={params.playerUri}
placeholderSource={link.thumb}
style={[a.flex_1, a.rounded_sm]}
autoplay={!autoplayDisabled}
onPlayerStateChange={onPlayerStateChange}
ref={playerRef}
accessibilityHint={_(msg`Animated GIF`)}
accessibilityLabel={link.description.replace('ALT: ', '')}
/>
</View>
</View>
)
}
+39 -2
View File
@@ -1,5 +1,11 @@
import React from 'react'
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
import {
StyleProp,
StyleSheet,
TouchableOpacity,
View,
ViewStyle,
} from 'react-native'
import {
AppBskyEmbedExternal,
AppBskyEmbedImages,
@@ -12,9 +18,13 @@ import {
RichText as RichTextAPI,
} from '@atproto/api'
import {AtUri} from '@atproto/api'
import {Trans} from '@lingui/macro'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import {HITSLOP_20} from '#/lib/constants'
import {s} from '#/lib/styles'
import {useModerationOpts} from '#/state/queries/preferences'
import {RQKEY as RQKEY_URI} from '#/state/queries/resolve-uri'
import {usePalette} from 'lib/hooks/usePalette'
@@ -177,6 +187,33 @@ export function QuoteEmbed({
)
}
export function QuoteX({onRemove}: {onRemove: () => void}) {
const {_} = useLingui()
return (
<TouchableOpacity
style={[
a.absolute,
a.p_xs,
a.rounded_full,
a.align_center,
a.justify_center,
{
top: 16,
right: 10,
backgroundColor: 'rgba(0, 0, 0, 0.75)',
},
]}
onPress={onRemove}
accessibilityRole="button"
accessibilityLabel={_(msg`Remove quote`)}
accessibilityHint={_(msg`Removes quoted post`)}
onAccessibilityEscape={onRemove}
hitSlop={HITSLOP_20}>
<FontAwesomeIcon size={12} icon="xmark" style={s.white} />
</TouchableOpacity>
)
}
function viewRecordToPostView(
viewRecord: AppBskyEmbedRecord.ViewRecord,
): AppBskyFeedDefs.PostView {
+13 -39
View File
@@ -1,34 +1,32 @@
import React, {useCallback} from 'react'
import React from 'react'
import {
StyleSheet,
InteractionManager,
StyleProp,
StyleSheet,
Text,
View,
ViewStyle,
Text,
InteractionManager,
} from 'react-native'
import {Image} from 'expo-image'
import {
AppBskyEmbedImages,
AppBskyEmbedExternal,
AppBskyEmbedImages,
AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia,
AppBskyFeedDefs,
AppBskyGraphDefs,
ModerationDecision,
} from '@atproto/api'
import {Link} from '../Link'
import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
import {useLightboxControls, ImagesLightbox} from '#/state/lightbox'
import {ImagesLightbox, useLightboxControls} from '#/state/lightbox'
import {usePalette} from 'lib/hooks/usePalette'
import {ExternalLinkEmbed} from './ExternalLinkEmbed'
import {MaybeQuoteEmbed} from './QuoteEmbed'
import {AutoSizedImage} from '../images/AutoSizedImage'
import {ListEmbed} from './ListEmbed'
import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
import {ContentHider} from '../../../../components/moderation/ContentHider'
import {isNative} from '#/platform/detection'
import {shareUrl} from '#/lib/sharing'
import {AutoSizedImage} from '../images/AutoSizedImage'
import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
import {ExternalLinkEmbed} from './ExternalLinkEmbed'
import {ListEmbed} from './ListEmbed'
import {MaybeQuoteEmbed} from './QuoteEmbed'
type Embed =
| AppBskyEmbedRecord.View
@@ -49,16 +47,6 @@ export function PostEmbeds({
const pal = usePalette('default')
const {openLightbox} = useLightboxControls()
const externalUri = AppBskyEmbedExternal.isView(embed)
? embed.external.uri
: null
const onShareExternal = useCallback(() => {
if (externalUri && isNative) {
shareUrl(externalUri)
}
}, [externalUri])
// quote post with media
// =
if (AppBskyEmbedRecordWithMedia.isView(embed)) {
@@ -161,18 +149,9 @@ export function PostEmbeds({
// =
if (AppBskyEmbedExternal.isView(embed)) {
const link = embed.external
return (
<ContentHider modui={moderation?.ui('contentMedia')}>
<Link
asAnchor
anchorNoUnderline
href={link.uri}
style={[styles.extOuter, pal.view, pal.borderDark, style]}
hoverStyle={{borderColor: pal.colors.borderLinkHover}}
onLongPress={onShareExternal}>
<ExternalLinkEmbed link={link} />
</Link>
<ExternalLinkEmbed link={link} style={style} />
</ContentHider>
)
}
@@ -187,11 +166,6 @@ const styles = StyleSheet.create({
singleImage: {
borderRadius: 8,
},
extOuter: {
borderWidth: 1,
borderRadius: 8,
marginTop: 4,
},
altContainer: {
backgroundColor: 'rgba(0, 0, 0, 0.75)',
borderRadius: 6,