Merge remote-tracking branch 'origin/main' into moar-labels
* origin/main: (30 commits) Only show "followed you back" when appropriate (#4849) [Web] Retrigger onEndReached if needed when content height changes (#4859) Remove unused NoopFeedTuner (#4856) useDedupe callback (#4855) [Video] Uploads (#4754) Make label required in link components (#4844) Boolean filter improvement alternative: TS upgrade (#4840) Add label to profile card (#4843) Improve a11y on noty feed (#4842) Add labels in feed card (#4836) Add labels to mod details dialog (#4839) Add labels to a few missing places (#4838) Add labels in list card (#4837) Refactor feed slices (#4834) `true` (#4833) Replace `import hairlineWidth =` with const (#4831) [Videos] Video player - PR #1 - basic player (#4731) Bump 1.90 (#4832) Fix sloppy filter(Boolean) types (#4830) Fuggedaboudit (#4829) ...
This commit is contained in:
@@ -132,6 +132,7 @@ export const SplashScreen = ({
|
||||
|
||||
function Footer() {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -147,13 +148,19 @@ function Footer() {
|
||||
a.flex_1,
|
||||
t.atoms.border_contrast_medium,
|
||||
]}>
|
||||
<InlineLinkText to="https://bsky.social">
|
||||
<InlineLinkText
|
||||
label={_(msg`Learn more about Bluesky`)}
|
||||
to="https://bsky.social">
|
||||
<Trans>Business</Trans>
|
||||
</InlineLinkText>
|
||||
<InlineLinkText to="https://bsky.social/about/blog">
|
||||
<InlineLinkText
|
||||
label={_(msg`Read the Bluesky blog`)}
|
||||
to="https://bsky.social/about/blog">
|
||||
<Trans>Blog</Trans>
|
||||
</InlineLinkText>
|
||||
<InlineLinkText to="https://bsky.social/about/join">
|
||||
<InlineLinkText
|
||||
label={_(msg`See jobs at Bluesky`)}
|
||||
to="https://bsky.social/about/join">
|
||||
<Trans>Jobs</Trans>
|
||||
</InlineLinkText>
|
||||
|
||||
|
||||
@@ -13,10 +13,16 @@ import {
|
||||
Keyboard,
|
||||
KeyboardAvoidingView,
|
||||
LayoutChangeEvent,
|
||||
StyleProp,
|
||||
StyleSheet,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from 'react-native'
|
||||
// @ts-expect-error no type definition
|
||||
import ProgressCircle from 'react-native-progress/Circle'
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
interpolateColor,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
@@ -55,6 +61,7 @@ import {
|
||||
import {useProfileQuery} from '#/state/queries/profile'
|
||||
import {Gif} from '#/state/queries/tenor'
|
||||
import {ThreadgateSetting} from '#/state/queries/threadgate'
|
||||
import {useUploadVideo} from '#/state/queries/video/video'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {useComposerControls} from '#/state/shell/composer'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
@@ -70,6 +77,7 @@ import {colors, s} from 'lib/styles'
|
||||
import {isAndroid, isIOS, isNative, isWeb} from 'platform/detection'
|
||||
import {useDialogStateControlContext} from 'state/dialogs'
|
||||
import {GalleryModel} from 'state/models/media/gallery'
|
||||
import {State as VideoUploadState} from 'state/queries/video/video'
|
||||
import {ComposerOpts} from 'state/shell/composer'
|
||||
import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
@@ -96,10 +104,8 @@ import {TextInput, TextInputRef} from './text-input/TextInput'
|
||||
import {ThreadgateBtn} from './threadgate/ThreadgateBtn'
|
||||
import {useExternalLinkFetch} from './useExternalLinkFetch'
|
||||
import {SelectVideoBtn} from './videos/SelectVideoBtn'
|
||||
import {useVideoState} from './videos/state'
|
||||
import {VideoPreview} from './videos/VideoPreview'
|
||||
import {VideoTranscodeProgress} from './videos/VideoTranscodeProgress'
|
||||
import hairlineWidth = StyleSheet.hairlineWidth
|
||||
|
||||
type CancelRef = {
|
||||
onPressCancel: () => void
|
||||
@@ -160,14 +166,21 @@ export const ComposePost = observer(function ComposePost({
|
||||
const [quote, setQuote] = useState<ComposerOpts['quote'] | undefined>(
|
||||
initQuote,
|
||||
)
|
||||
|
||||
const {
|
||||
video,
|
||||
onSelectVideo,
|
||||
videoPending,
|
||||
videoProcessingData,
|
||||
selectVideo,
|
||||
clearVideo,
|
||||
videoProcessingProgress,
|
||||
} = useVideoState({setError})
|
||||
state: videoUploadState,
|
||||
} = useUploadVideo({
|
||||
setStatus: (status: string) => setProcessingState(status),
|
||||
onSuccess: () => {
|
||||
if (publishOnUpload) {
|
||||
onPressPublish(true)
|
||||
}
|
||||
},
|
||||
})
|
||||
const [publishOnUpload, setPublishOnUpload] = useState(false)
|
||||
|
||||
const {extLink, setExtLink} = useExternalLinkFetch({setQuote})
|
||||
const [extGif, setExtGif] = useState<Gif>()
|
||||
const [labels, setLabels] = useState<string[]>([])
|
||||
@@ -275,7 +288,7 @@ export const ComposePost = observer(function ComposePost({
|
||||
return false
|
||||
}, [gallery.needsAltText, extLink, extGif, requireAltTextEnabled])
|
||||
|
||||
const onPressPublish = async () => {
|
||||
const onPressPublish = async (finishedUploading?: boolean) => {
|
||||
if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) {
|
||||
return
|
||||
}
|
||||
@@ -284,6 +297,15 @@ export const ComposePost = observer(function ComposePost({
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
!finishedUploading &&
|
||||
videoUploadState.status !== 'idle' &&
|
||||
videoUploadState.asset
|
||||
) {
|
||||
setPublishOnUpload(true)
|
||||
return
|
||||
}
|
||||
|
||||
setError('')
|
||||
|
||||
if (
|
||||
@@ -388,8 +410,12 @@ export const ComposePost = observer(function ComposePost({
|
||||
: _(msg`What's up?`)
|
||||
|
||||
const canSelectImages =
|
||||
gallery.size < 4 && !extLink && !video && !videoPending
|
||||
const hasMedia = gallery.size > 0 || Boolean(extLink) || Boolean(video)
|
||||
gallery.size < 4 &&
|
||||
!extLink &&
|
||||
videoUploadState.status === 'idle' &&
|
||||
!videoUploadState.video
|
||||
const hasMedia =
|
||||
gallery.size > 0 || Boolean(extLink) || Boolean(videoUploadState.video)
|
||||
|
||||
const onEmojiButtonPress = useCallback(() => {
|
||||
openPicker?.(textInput.current?.getCursorPosition())
|
||||
@@ -501,7 +527,10 @@ export const ComposePost = observer(function ComposePost({
|
||||
shape="default"
|
||||
size="small"
|
||||
style={[a.rounded_full, a.py_sm]}
|
||||
onPress={onPressPublish}>
|
||||
onPress={() => onPressPublish()}
|
||||
disabled={
|
||||
videoUploadState.status !== 'idle' && publishOnUpload
|
||||
}>
|
||||
<ButtonText style={[a.text_md]}>
|
||||
{replyTo ? (
|
||||
<Trans context="action">Reply</Trans>
|
||||
@@ -573,7 +602,7 @@ export const ComposePost = observer(function ComposePost({
|
||||
autoFocus
|
||||
setRichText={setRichText}
|
||||
onPhotoPasted={onPhotoPasted}
|
||||
onPressPublish={onPressPublish}
|
||||
onPressPublish={() => onPressPublish()}
|
||||
onNewLink={onNewLink}
|
||||
onError={setError}
|
||||
accessible={true}
|
||||
@@ -603,29 +632,33 @@ export const ComposePost = observer(function ComposePost({
|
||||
</View>
|
||||
)}
|
||||
|
||||
{quote ? (
|
||||
<View style={[s.mt5, s.mb2, isWeb && s.mb10]}>
|
||||
<View style={{pointerEvents: 'none'}}>
|
||||
<QuoteEmbed quote={quote} />
|
||||
<View style={[a.mt_md]}>
|
||||
{quote ? (
|
||||
<View style={[s.mt5, s.mb2, isWeb && s.mb10]}>
|
||||
<View style={{pointerEvents: 'none'}}>
|
||||
<QuoteEmbed quote={quote} />
|
||||
</View>
|
||||
{quote.uri !== initQuote?.uri && (
|
||||
<QuoteX onRemove={() => setQuote(undefined)} />
|
||||
)}
|
||||
</View>
|
||||
{quote.uri !== initQuote?.uri && (
|
||||
<QuoteX onRemove={() => setQuote(undefined)} />
|
||||
)}
|
||||
</View>
|
||||
) : null}
|
||||
{videoPending && videoProcessingData ? (
|
||||
<VideoTranscodeProgress
|
||||
input={videoProcessingData}
|
||||
progress={videoProcessingProgress}
|
||||
/>
|
||||
) : (
|
||||
video && (
|
||||
) : null}
|
||||
{videoUploadState.status === 'compressing' &&
|
||||
videoUploadState.asset ? (
|
||||
<VideoTranscodeProgress
|
||||
asset={videoUploadState.asset}
|
||||
progress={videoUploadState.progress}
|
||||
/>
|
||||
) : videoUploadState.video ? (
|
||||
// remove suspense when we get rid of lazy
|
||||
<Suspense fallback={null}>
|
||||
<VideoPreview video={video} clear={clearVideo} />
|
||||
<VideoPreview
|
||||
video={videoUploadState.video}
|
||||
clear={clearVideo}
|
||||
/>
|
||||
</Suspense>
|
||||
)
|
||||
)}
|
||||
) : null}
|
||||
</View>
|
||||
</Animated.ScrollView>
|
||||
<SuggestedLanguage text={richtext.text} />
|
||||
|
||||
@@ -642,33 +675,37 @@ export const ComposePost = observer(function ComposePost({
|
||||
t.atoms.border_contrast_medium,
|
||||
styles.bottomBar,
|
||||
]}>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||
<SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} />
|
||||
{gate('videos') && (
|
||||
<SelectVideoBtn
|
||||
onSelectVideo={onSelectVideo}
|
||||
disabled={!canSelectImages}
|
||||
{videoUploadState.status !== 'idle' ? (
|
||||
<VideoUploadToolbar state={videoUploadState} />
|
||||
) : (
|
||||
<ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||
<SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} />
|
||||
{gate('videos') && (
|
||||
<SelectVideoBtn
|
||||
onSelectVideo={selectVideo}
|
||||
disabled={!canSelectImages}
|
||||
/>
|
||||
)}
|
||||
<OpenCameraBtn gallery={gallery} disabled={!canSelectImages} />
|
||||
<SelectGifBtn
|
||||
onClose={focusTextInput}
|
||||
onSelectGif={onSelectGif}
|
||||
disabled={hasMedia}
|
||||
/>
|
||||
)}
|
||||
<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>
|
||||
{!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}
|
||||
</ToolbarWrapper>
|
||||
)}
|
||||
<View style={a.flex_1} />
|
||||
<SelectLangBtn />
|
||||
<CharProgress count={graphemeLength} />
|
||||
@@ -763,7 +800,7 @@ function useAnimatedBorders() {
|
||||
|
||||
const topBarAnimatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
borderBottomWidth: hairlineWidth,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: interpolateColor(
|
||||
hasScrolledTop.value,
|
||||
[0, 1],
|
||||
@@ -773,7 +810,7 @@ function useAnimatedBorders() {
|
||||
})
|
||||
const bottomBarAnimatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
borderTopWidth: hairlineWidth,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: interpolateColor(
|
||||
hasScrolledBottom.value,
|
||||
[0, 1],
|
||||
@@ -855,7 +892,7 @@ const styles = StyleSheet.create({
|
||||
marginBottom: 8,
|
||||
},
|
||||
errorIcon: {
|
||||
borderWidth: hairlineWidth,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.red4,
|
||||
color: colors.red4,
|
||||
borderRadius: 30,
|
||||
@@ -891,6 +928,47 @@ const styles = StyleSheet.create({
|
||||
paddingLeft: 7,
|
||||
paddingRight: 16,
|
||||
alignItems: 'center',
|
||||
borderTopWidth: hairlineWidth,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
})
|
||||
|
||||
function ToolbarWrapper({
|
||||
style,
|
||||
children,
|
||||
}: {
|
||||
style: StyleProp<ViewStyle>
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
if (isWeb) return children
|
||||
return (
|
||||
<Animated.View
|
||||
style={style}
|
||||
entering={FadeIn.duration(400)}
|
||||
exiting={FadeOut.duration(400)}>
|
||||
{children}
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
function VideoUploadToolbar({state}: {state: VideoUploadState}) {
|
||||
const t = useTheme()
|
||||
|
||||
const progress =
|
||||
state.status === 'compressing' || state.status === 'uploading'
|
||||
? state.progress
|
||||
: state.jobStatus?.progress ?? 100
|
||||
|
||||
return (
|
||||
<ToolbarWrapper
|
||||
style={[a.gap_sm, a.flex_row, a.align_center, {paddingVertical: 5}]}>
|
||||
<ProgressCircle
|
||||
size={30}
|
||||
borderWidth={1}
|
||||
borderColor={t.atoms.border_contrast_low.borderColor}
|
||||
color={t.palette.primary_500}
|
||||
progress={progress}
|
||||
/>
|
||||
<Text>{state.status}</Text>
|
||||
</ToolbarWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
import hairlineWidth = StyleSheet.hairlineWidth
|
||||
|
||||
export function ComposePrompt({onPressCompose}: {onPressCompose: () => void}) {
|
||||
const {currentAccount} = useSession()
|
||||
@@ -49,7 +48,7 @@ const styles = StyleSheet.create({
|
||||
paddingBottom: 10,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderTopWidth: hairlineWidth,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
labelMobile: {
|
||||
paddingLeft: 12,
|
||||
|
||||
@@ -17,6 +17,7 @@ export function VideoPreview({
|
||||
const player = useVideoPlayer(video.uri, player => {
|
||||
player.loop = true
|
||||
player.play()
|
||||
player.volume = 0
|
||||
})
|
||||
|
||||
return (
|
||||
|
||||
@@ -9,15 +9,15 @@ import {Text} from '#/components/Typography'
|
||||
import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop'
|
||||
|
||||
export function VideoTranscodeProgress({
|
||||
input,
|
||||
asset,
|
||||
progress,
|
||||
}: {
|
||||
input: ImagePickerAsset
|
||||
asset: ImagePickerAsset
|
||||
progress: number
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
const aspectRatio = input.width / input.height
|
||||
const aspectRatio = asset.width / asset.height
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -29,7 +29,7 @@ export function VideoTranscodeProgress({
|
||||
a.overflow_hidden,
|
||||
{aspectRatio: isNaN(aspectRatio) ? 16 / 9 : aspectRatio},
|
||||
]}>
|
||||
<VideoTranscodeBackdrop uri={input.uri} />
|
||||
<VideoTranscodeBackdrop uri={asset.uri} />
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import {useState} from 'react'
|
||||
import {ImagePickerAsset} from 'expo-image-picker'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
|
||||
import {compressVideo} from '#/lib/media/video/compress'
|
||||
import {logger} from '#/logger'
|
||||
import {VideoTooLargeError} from 'lib/media/video/errors'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
|
||||
export function useVideoState({setError}: {setError: (error: string) => void}) {
|
||||
const {_} = useLingui()
|
||||
const [progress, setProgress] = useState(0)
|
||||
|
||||
const {mutate, data, isPending, isError, reset, variables} = useMutation({
|
||||
mutationFn: async (asset: ImagePickerAsset) => {
|
||||
const compressed = await compressVideo(asset.uri, {
|
||||
onProgress: num => setProgress(trunc2dp(num)),
|
||||
})
|
||||
|
||||
return compressed
|
||||
},
|
||||
onError: (e: any) => {
|
||||
// Don't log these errors in sentry, just let the user know
|
||||
if (e instanceof VideoTooLargeError) {
|
||||
Toast.show(_(msg`Videos cannot be larger than 100MB`), 'xmark')
|
||||
return
|
||||
}
|
||||
logger.error('Failed to compress video', {safeError: e})
|
||||
setError(_(msg`Could not compress video`))
|
||||
},
|
||||
onMutate: () => {
|
||||
setProgress(0)
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
video: data,
|
||||
onSelectVideo: mutate,
|
||||
videoPending: isPending,
|
||||
videoProcessingData: variables,
|
||||
videoError: isError,
|
||||
clearVideo: reset,
|
||||
videoProcessingProgress: progress,
|
||||
}
|
||||
}
|
||||
|
||||
function trunc2dp(num: number) {
|
||||
return Math.trunc(num * 100) / 100
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {msg, Plural, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {shouldClickOpenNewTab} from '#/platform/urls'
|
||||
import {FeedSourceInfo, useFeedSourceInfoQuery} from '#/state/queries/feed'
|
||||
import {
|
||||
useAddSavedFeedsMutation,
|
||||
@@ -32,8 +33,6 @@ import * as Prompt from '#/components/Prompt'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
import hairlineWidth = StyleSheet.hairlineWidth
|
||||
import {shouldClickOpenNewTab} from '#/platform/urls'
|
||||
|
||||
export function FeedSourceCard({
|
||||
feedUri,
|
||||
@@ -209,7 +208,7 @@ export function FeedSourceCardLoaded({
|
||||
styles.container,
|
||||
pal.border,
|
||||
style,
|
||||
{borderTopWidth: hideTopBorder ? 0 : hairlineWidth},
|
||||
{borderTopWidth: hideTopBorder ? 0 : StyleSheet.hairlineWidth},
|
||||
]}
|
||||
onPress={e => {
|
||||
const shouldOpenInNewTab = shouldClickOpenNewTab(e)
|
||||
@@ -330,7 +329,7 @@ const styles = StyleSheet.create({
|
||||
gap: 14,
|
||||
},
|
||||
border: {
|
||||
borderTopWidth: hairlineWidth,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
headerContainer: {
|
||||
flexDirection: 'row',
|
||||
|
||||
@@ -73,7 +73,7 @@ export function HomeHeaderLayoutMobile({
|
||||
]}>
|
||||
{IS_DEV && (
|
||||
<>
|
||||
<Link to="/sys/debug">
|
||||
<Link label="View storybook" to="/sys/debug">
|
||||
<ColorPalette size="md" />
|
||||
</Link>
|
||||
</>
|
||||
|
||||
@@ -14,7 +14,6 @@ import {RichText as RichTextCom} from '#/components/RichText'
|
||||
import {Link} from '../util/Link'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
import hairlineWidth = StyleSheet.hairlineWidth
|
||||
|
||||
export const ListCard = ({
|
||||
testID,
|
||||
@@ -134,7 +133,7 @@ export const ListCard = ({
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
outer: {
|
||||
borderTopWidth: hairlineWidth,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
paddingHorizontal: 6,
|
||||
},
|
||||
outerNoBorder: {
|
||||
|
||||
@@ -29,7 +29,6 @@ import {Button} from '../util/forms/Button'
|
||||
import {Text} from '../util/text/Text'
|
||||
import * as Toast from '../util/Toast'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
import hairlineWidth = StyleSheet.hairlineWidth
|
||||
|
||||
export const snapPoints = ['fullscreen']
|
||||
|
||||
@@ -63,7 +62,7 @@ export function Component({
|
||||
return [pal.border, {height: screenHeight / 1.5}]
|
||||
}
|
||||
|
||||
return [pal.border, {flex: 1, borderTopWidth: hairlineWidth}]
|
||||
return [pal.border, {flex: 1, borderTopWidth: StyleSheet.hairlineWidth}]
|
||||
}, [pal.border, screenHeight])
|
||||
|
||||
return (
|
||||
@@ -188,7 +187,7 @@ function ListItem({
|
||||
style={[
|
||||
styles.listItem,
|
||||
pal.border,
|
||||
index !== 0 && {borderTopWidth: hairlineWidth},
|
||||
index !== 0 && {borderTopWidth: StyleSheet.hairlineWidth},
|
||||
]}>
|
||||
<View style={styles.listItemAvi}>
|
||||
<UserAvatar size={40} avatar={list.avatar} type="list" />
|
||||
@@ -248,7 +247,7 @@ const styles = StyleSheet.create({
|
||||
gap: 10,
|
||||
paddingTop: 10,
|
||||
paddingBottom: isAndroid ? 10 : 0,
|
||||
borderTopWidth: hairlineWidth,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
footerBtn: {
|
||||
paddingHorizontal: 24,
|
||||
|
||||
@@ -24,7 +24,6 @@ import {NotificationFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceho
|
||||
import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn'
|
||||
import {CenteredView} from '#/view/com/util/Views'
|
||||
import {FeedItem} from './FeedItem'
|
||||
import hairlineWidth = StyleSheet.hairlineWidth
|
||||
|
||||
const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
|
||||
const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
|
||||
@@ -35,11 +34,13 @@ export function Feed({
|
||||
onPressTryAgain,
|
||||
onScrolledDownChange,
|
||||
ListHeaderComponent,
|
||||
overridePriorityNotifications,
|
||||
}: {
|
||||
scrollElRef?: ListRef
|
||||
onPressTryAgain?: () => void
|
||||
onScrolledDownChange: (isScrolledDown: boolean) => void
|
||||
ListHeaderComponent?: () => JSX.Element
|
||||
overridePriorityNotifications?: boolean
|
||||
}) {
|
||||
const initialNumToRender = useInitialNumToRender()
|
||||
|
||||
@@ -59,7 +60,10 @@ export function Feed({
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
} = useNotificationFeedQuery({enabled: !!moderationOpts})
|
||||
} = useNotificationFeedQuery({
|
||||
enabled: !!moderationOpts,
|
||||
overridePriorityNotifications,
|
||||
})
|
||||
const isEmpty = !isFetching && !data?.pages[0]?.items.length
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
@@ -132,7 +136,7 @@ export function Feed({
|
||||
<View
|
||||
style={[
|
||||
pal.border,
|
||||
!isTabletOrMobile && {borderTopWidth: hairlineWidth},
|
||||
!isTabletOrMobile && {borderTopWidth: StyleSheet.hairlineWidth},
|
||||
]}>
|
||||
<NotificationFeedLoadingPlaceholder />
|
||||
</View>
|
||||
|
||||
@@ -13,27 +13,37 @@ import {
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedPost,
|
||||
AppBskyGraphFollow,
|
||||
moderateProfile,
|
||||
ModerationDecision,
|
||||
ModerationOpts,
|
||||
} from '@atproto/api'
|
||||
import {AtUri} from '@atproto/api'
|
||||
import {TID} from '@atproto/common-web'
|
||||
import {msg, plural, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {parseTenorGif} from '#/lib/strings/embed-player'
|
||||
import {logger} from '#/logger'
|
||||
import {FeedNotification} from '#/state/queries/notifications/feed'
|
||||
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {makeProfileLink} from 'lib/routes/links'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {forceLTR} from 'lib/strings/bidi'
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {niceDate} from 'lib/strings/time'
|
||||
import {colors, s} from 'lib/styles'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {DM_SERVICE_HEADERS} from 'state/queries/messages/const'
|
||||
import {precacheProfile} from 'state/queries/profile'
|
||||
import {useAgent} from 'state/session'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {
|
||||
ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon,
|
||||
ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon,
|
||||
@@ -41,8 +51,10 @@ import {
|
||||
import {Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled} from '#/components/icons/Heart2'
|
||||
import {PersonPlus_Filled_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person'
|
||||
import {Repost_Stroke2_Corner2_Rounded as RepostIcon} from '#/components/icons/Repost'
|
||||
import {StarterPack} from '#/components/icons/StarterPack'
|
||||
import {Link as NewLink} from '#/components/Link'
|
||||
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
|
||||
import {Notification as StarterPackCard} from '#/components/StarterPack/StarterPackCard'
|
||||
import {FeedSourceCard} from '../feeds/FeedSourceCard'
|
||||
import {Post} from '../post/Post'
|
||||
import {ImageHorzList} from '../util/images/ImageHorzList'
|
||||
@@ -52,19 +64,6 @@ import {Text} from '../util/text/Text'
|
||||
import {TimeElapsed} from '../util/TimeElapsed'
|
||||
import {PreviewableUserAvatar, UserAvatar} from '../util/UserAvatar'
|
||||
|
||||
import hairlineWidth = StyleSheet.hairlineWidth
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {parseTenorGif} from '#/lib/strings/embed-player'
|
||||
import {logger} from '#/logger'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {forceLTR} from 'lib/strings/bidi'
|
||||
import {DM_SERVICE_HEADERS} from 'state/queries/messages/const'
|
||||
import {useAgent} from 'state/session'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {StarterPack} from '#/components/icons/StarterPack'
|
||||
import {Notification as StarterPackCard} from '#/components/StarterPack/StarterPackCard'
|
||||
|
||||
const MAX_AUTHORS = 5
|
||||
|
||||
const EXPANDED_AUTHOR_EL_HEIGHT = 35
|
||||
@@ -171,7 +170,6 @@ let FeedItem = ({
|
||||
)
|
||||
}
|
||||
|
||||
let isFollowBack = false
|
||||
let action = ''
|
||||
let icon = (
|
||||
<HeartIconFilled
|
||||
@@ -188,11 +186,28 @@ let FeedItem = ({
|
||||
action = _(msg`reposted your post`)
|
||||
icon = <RepostIcon size="xl" style={{color: t.palette.positive_600}} />
|
||||
} else if (item.type === 'follow') {
|
||||
let isFollowBack = false
|
||||
|
||||
if (
|
||||
item.notification.author.viewer?.following &&
|
||||
gate('ungroup_follow_backs')
|
||||
AppBskyGraphFollow.isRecord(item.notification.record)
|
||||
) {
|
||||
isFollowBack = true
|
||||
let followingTimestamp
|
||||
try {
|
||||
const rkey = new AtUri(item.notification.author.viewer.following).rkey
|
||||
followingTimestamp = TID.fromStr(rkey).timestamp()
|
||||
} catch (e) {
|
||||
// For some reason the following URI was invalid. Default to it not being a follow back.
|
||||
console.error('Invalid following URI')
|
||||
}
|
||||
if (followingTimestamp) {
|
||||
const followedTimestamp =
|
||||
new Date(item.notification.record.createdAt).getTime() * 1000
|
||||
isFollowBack = followedTimestamp > followingTimestamp
|
||||
}
|
||||
}
|
||||
|
||||
if (isFollowBack && gate('ungroup_follow_backs')) {
|
||||
action = _(msg`followed you back`)
|
||||
} else {
|
||||
action = _(msg`followed you`)
|
||||
@@ -211,7 +226,22 @@ let FeedItem = ({
|
||||
return null
|
||||
}
|
||||
|
||||
let formattedCount = authors.length > 1 ? formatCount(authors.length - 1) : ''
|
||||
const formattedCount =
|
||||
authors.length > 1 ? formatCount(authors.length - 1) : ''
|
||||
const firstAuthorName = sanitizeDisplayName(
|
||||
authors[0].profile.displayName || authors[0].profile.handle,
|
||||
)
|
||||
const niceTimestamp = niceDate(item.notification.indexedAt)
|
||||
const a11yLabelUsers =
|
||||
authors.length > 1
|
||||
? _(msg` and `) +
|
||||
plural(authors.length - 1, {
|
||||
one: `${formattedCount} other`,
|
||||
other: `${formattedCount} others`,
|
||||
})
|
||||
: ''
|
||||
const a11yLabel = `${firstAuthorName}${a11yLabelUsers} ${action} ${niceTimestamp}`
|
||||
|
||||
return (
|
||||
<Link
|
||||
testID={`feedItem-by-${item.notification.author.handle}`}
|
||||
@@ -224,10 +254,12 @@ let FeedItem = ({
|
||||
backgroundColor: pal.colors.unreadNotifBg,
|
||||
borderColor: pal.colors.unreadNotifBorder,
|
||||
},
|
||||
{borderTopWidth: hideTopBorder ? 0 : hairlineWidth},
|
||||
{borderTopWidth: hideTopBorder ? 0 : StyleSheet.hairlineWidth},
|
||||
]}
|
||||
href={itemHref}
|
||||
noFeedback
|
||||
accessibilityHint=""
|
||||
accessibilityLabel={a11yLabel}
|
||||
accessible={!isAuthorsExpanded}
|
||||
accessibilityActions={
|
||||
authors.length > 1
|
||||
@@ -272,19 +304,18 @@ let FeedItem = ({
|
||||
visible={!isAuthorsExpanded}
|
||||
authors={authors}
|
||||
onToggleAuthorsExpanded={onToggleAuthorsExpanded}
|
||||
showDmButton={item.type === 'starterpack-joined' || isFollowBack}
|
||||
showDmButton={item.type === 'starterpack-joined'}
|
||||
/>
|
||||
<ExpandedAuthorsList visible={isAuthorsExpanded} authors={authors} />
|
||||
<Text style={[styles.meta, a.self_start]}>
|
||||
<Text
|
||||
style={[styles.meta, a.self_start]}
|
||||
accessibilityHint=""
|
||||
accessibilityLabel={a11yLabel}>
|
||||
<TextLink
|
||||
key={authors[0].href}
|
||||
style={[pal.text, s.bold]}
|
||||
href={authors[0].href}
|
||||
text={forceLTR(
|
||||
sanitizeDisplayName(
|
||||
authors[0].profile.displayName || authors[0].profile.handle,
|
||||
),
|
||||
)}
|
||||
text={forceLTR(firstAuthorName)}
|
||||
disableMismatchWarning
|
||||
/>
|
||||
{authors.length > 1 ? (
|
||||
@@ -306,7 +337,7 @@ let FeedItem = ({
|
||||
{({timeElapsed}) => (
|
||||
<Text
|
||||
style={[pal.textLight, styles.pointer]}
|
||||
title={niceDate(item.notification.indexedAt)}>
|
||||
title={niceTimestamp}>
|
||||
{' ' + timeElapsed}
|
||||
</Text>
|
||||
)}
|
||||
@@ -458,7 +489,6 @@ function CondensedAuthorsList({
|
||||
profile={authors[0].profile}
|
||||
moderation={authors[0].moderation.ui('avatar')}
|
||||
type={authors[0].profile.associated?.labeler ? 'labeler' : 'user'}
|
||||
accessible={false}
|
||||
/>
|
||||
{showDmButton ? <SayHelloBtn profile={authors[0].profile} /> : null}
|
||||
</View>
|
||||
@@ -476,7 +506,6 @@ function CondensedAuthorsList({
|
||||
profile={author.profile}
|
||||
moderation={author.moderation.ui('avatar')}
|
||||
type={author.profile.associated?.labeler ? 'labeler' : 'user'}
|
||||
accessible={false}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {PressableWithHover} from '../util/PressableWithHover'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {DraggableScrollView} from './DraggableScrollView'
|
||||
import hairlineWidth = StyleSheet.hairlineWidth
|
||||
|
||||
export interface TabBarProps {
|
||||
testID?: string
|
||||
@@ -208,6 +207,6 @@ const mobileStyles = StyleSheet.create({
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: '100%',
|
||||
borderBottomWidth: hairlineWidth,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -44,7 +44,6 @@ import {PostEmbeds} from '../util/post-embeds'
|
||||
import {PostMeta} from '../util/PostMeta'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {PreviewableUserAvatar} from '../util/UserAvatar'
|
||||
import hairlineWidth = StyleSheet.hairlineWidth
|
||||
|
||||
export function PostThreadItem({
|
||||
post,
|
||||
@@ -604,7 +603,7 @@ function PostOuterWrapper({
|
||||
{
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: isMobile ? 10 : 6,
|
||||
borderTopWidth: depth === 1 ? hairlineWidth : 0,
|
||||
borderTopWidth: depth === 1 ? StyleSheet.hairlineWidth : 0,
|
||||
},
|
||||
]}>
|
||||
{Array.from(Array(depth - 1)).map((_, n: number) => (
|
||||
@@ -699,7 +698,7 @@ function getThreadAuthor(
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
outer: {
|
||||
borderTopWidth: hairlineWidth,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
paddingLeft: 8,
|
||||
},
|
||||
outerHighlighted: {
|
||||
@@ -709,7 +708,7 @@ const styles = StyleSheet.create({
|
||||
paddingRight: 8,
|
||||
},
|
||||
outerHighlightedRoot: {
|
||||
borderTopWidth: hairlineWidth,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
paddingTop: 16,
|
||||
},
|
||||
noTopBorder: {
|
||||
@@ -761,8 +760,8 @@ const styles = StyleSheet.create({
|
||||
expandedInfo: {
|
||||
flexDirection: 'row',
|
||||
padding: 10,
|
||||
borderTopWidth: hairlineWidth,
|
||||
borderBottomWidth: hairlineWidth,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
marginTop: 5,
|
||||
marginBottom: 10,
|
||||
},
|
||||
|
||||
@@ -37,7 +37,6 @@ import {PostMeta} from '../util/PostMeta'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {PreviewableUserAvatar} from '../util/UserAvatar'
|
||||
import {UserInfoText} from '../util/UserInfoText'
|
||||
import hairlineWidth = StyleSheet.hairlineWidth
|
||||
|
||||
export function Post({
|
||||
post,
|
||||
@@ -155,7 +154,7 @@ function PostInner({
|
||||
style={[
|
||||
styles.outer,
|
||||
pal.border,
|
||||
!hideTopBorder && {borderTopWidth: hairlineWidth},
|
||||
!hideTopBorder && {borderTopWidth: StyleSheet.hairlineWidth},
|
||||
style,
|
||||
]}
|
||||
onBeforePress={onBeforePress}>
|
||||
|
||||
@@ -16,8 +16,10 @@ import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow'
|
||||
import {useFeedFeedbackContext} from '#/state/feed-feedback'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useComposerControls} from '#/state/shell/composer'
|
||||
import {isReasonFeedSource, ReasonFeedSource} from 'lib/api/feed/types'
|
||||
import {MAX_POST_LINES} from 'lib/constants'
|
||||
@@ -29,6 +31,7 @@ import {countLines} from 'lib/strings/helpers'
|
||||
import {s} from 'lib/styles'
|
||||
import {precacheProfile} from 'state/queries/profile'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost'
|
||||
import {ContentHider} from '#/components/moderation/ContentHider'
|
||||
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
|
||||
import {RichText} from '#/components/RichText'
|
||||
@@ -38,17 +41,19 @@ import {FeedNameText} from '../util/FeedInfoText'
|
||||
import {Link, TextLink, TextLinkOnWebOnly} from '../util/Link'
|
||||
import {PostCtrls} from '../util/post-ctrls/PostCtrls'
|
||||
import {PostEmbeds} from '../util/post-embeds'
|
||||
import {VideoEmbed} from '../util/post-embeds/VideoEmbed'
|
||||
import {PostMeta} from '../util/PostMeta'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {PreviewableUserAvatar} from '../util/UserAvatar'
|
||||
import {AviFollowButton} from './AviFollowButton'
|
||||
import hairlineWidth = StyleSheet.hairlineWidth
|
||||
import {useSession} from '#/state/session'
|
||||
import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost'
|
||||
|
||||
interface FeedItemProps {
|
||||
record: AppBskyFeedPost.Record
|
||||
reason: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource | undefined
|
||||
reason:
|
||||
| AppBskyFeedDefs.ReasonRepost
|
||||
| ReasonFeedSource
|
||||
| {[k: string]: unknown; $type: string}
|
||||
| undefined
|
||||
moderation: ModerationDecision
|
||||
parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||
showReplyTo: boolean
|
||||
@@ -132,6 +137,8 @@ let FeedItemInner = ({
|
||||
const {openComposer} = useComposerControls()
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const gate = useGate()
|
||||
|
||||
const href = useMemo(() => {
|
||||
const urip = new AtUri(post.uri)
|
||||
return makeProfileLink(post.author, 'post', urip.rkey)
|
||||
@@ -197,7 +204,8 @@ let FeedItemInner = ({
|
||||
isThreadLastChild || (!isThreadChild && !isThreadParent)
|
||||
? 8
|
||||
: undefined,
|
||||
borderTopWidth: hideTopBorder || isThreadChild ? 0 : hairlineWidth,
|
||||
borderTopWidth:
|
||||
hideTopBorder || isThreadChild ? 0 : StyleSheet.hairlineWidth,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -337,9 +345,11 @@ let FeedItemInner = ({
|
||||
postHref={href}
|
||||
onOpenAuthor={onOpenAuthor}
|
||||
/>
|
||||
{!isThreadChild && showReplyTo && parentAuthor && (
|
||||
<ReplyToLabel blocked={isParentBlocked} profile={parentAuthor} />
|
||||
)}
|
||||
{!isThreadChild &&
|
||||
showReplyTo &&
|
||||
(parentAuthor || isParentBlocked) && (
|
||||
<ReplyToLabel blocked={isParentBlocked} profile={parentAuthor} />
|
||||
)}
|
||||
<LabelsOnMyPost post={post} />
|
||||
<PostContent
|
||||
moderation={moderation}
|
||||
@@ -348,6 +358,9 @@ let FeedItemInner = ({
|
||||
postAuthor={post.author}
|
||||
onOpenEmbed={onOpenEmbed}
|
||||
/>
|
||||
{__DEV__ && gate('videos') && (
|
||||
<VideoEmbed source="https://lumi.jazco.dev/watch/did:plc:q6gjnaw2blty4crticxkmujt/Qmc8w93UpTa2adJHg4ZhnDPrBs1EsbzrekzPcqF5SwusuZ/playlist.m3u8" />
|
||||
)}
|
||||
<PostCtrls
|
||||
post={post}
|
||||
record={record}
|
||||
@@ -431,12 +444,46 @@ function ReplyToLabel({
|
||||
profile,
|
||||
blocked,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewBasic
|
||||
profile: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||
blocked?: boolean
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const {currentAccount} = useSession()
|
||||
const isMe = profile.did === currentAccount?.did
|
||||
|
||||
let label
|
||||
if (blocked) {
|
||||
label = <Trans context="description">Reply to a blocked post</Trans>
|
||||
} else if (profile != null) {
|
||||
const isMe = profile.did === currentAccount?.did
|
||||
if (isMe) {
|
||||
label = <Trans context="description">Reply to you</Trans>
|
||||
} else {
|
||||
label = (
|
||||
<Trans context="description">
|
||||
Reply to{' '}
|
||||
<ProfileHoverCard inline did={profile.did}>
|
||||
<TextLinkOnWebOnly
|
||||
type="md"
|
||||
style={pal.textLight}
|
||||
lineHeight={1.2}
|
||||
numberOfLines={1}
|
||||
href={makeProfileLink(profile)}
|
||||
text={
|
||||
profile.displayName
|
||||
? sanitizeDisplayName(profile.displayName)
|
||||
: sanitizeHandle(profile.handle)
|
||||
}
|
||||
/>
|
||||
</ProfileHoverCard>
|
||||
</Trans>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!label) {
|
||||
// Should not happen.
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[s.flexRow, s.mb2, s.alignCenter]}>
|
||||
@@ -450,29 +497,7 @@ function ReplyToLabel({
|
||||
style={[pal.textLight, s.mr2]}
|
||||
lineHeight={1.2}
|
||||
numberOfLines={1}>
|
||||
{isMe ? (
|
||||
<Trans context="description">Reply to you</Trans>
|
||||
) : blocked ? (
|
||||
<Trans context="description">Reply to a blocked post</Trans>
|
||||
) : (
|
||||
<Trans context="description">
|
||||
Reply to{' '}
|
||||
<ProfileHoverCard inline did={profile.did}>
|
||||
<TextLinkOnWebOnly
|
||||
type="md"
|
||||
style={pal.textLight}
|
||||
lineHeight={1.2}
|
||||
numberOfLines={1}
|
||||
href={makeProfileLink(profile)}
|
||||
text={
|
||||
profile.displayName
|
||||
? sanitizeDisplayName(profile.displayName)
|
||||
: sanitizeHandle(profile.handle)
|
||||
}
|
||||
/>
|
||||
</ProfileHoverCard>
|
||||
</Trans>
|
||||
)}
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -99,6 +99,7 @@ export function FeedShutdownMsg({feedUri}: {feedUri: string}) {
|
||||
<Trans>
|
||||
This feed is no longer online. We are showing{' '}
|
||||
<InlineLinkText
|
||||
label={_(msg`The Discover feed`)}
|
||||
to="/profile/bsky.app/feed/whats-hot"
|
||||
style={[a.text_md]}>
|
||||
Discover
|
||||
|
||||
@@ -23,12 +23,11 @@ import {
|
||||
KnownFollowers,
|
||||
shouldShowKnownFollowers,
|
||||
} from '#/components/KnownFollowers'
|
||||
import * as Pills from '#/components/Pills'
|
||||
import {Link} from '../util/Link'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {PreviewableUserAvatar} from '../util/UserAvatar'
|
||||
import {FollowButton} from './FollowButton'
|
||||
import hairlineWidth = StyleSheet.hairlineWidth
|
||||
import * as Pills from '#/components/Pills'
|
||||
|
||||
export function ProfileCard({
|
||||
testID,
|
||||
@@ -217,7 +216,7 @@ export function ProfileCardWithFollowBtn({
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
outer: {
|
||||
borderTopWidth: hairlineWidth,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 4,
|
||||
},
|
||||
|
||||
@@ -15,15 +15,13 @@ import {makeProfileLink} from 'lib/routes/links'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {isNative} from 'platform/detection'
|
||||
import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu'
|
||||
import {StarterPack} from '#/components/icons/StarterPack'
|
||||
import {TextLink} from '../util/Link'
|
||||
import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {UserAvatar, UserAvatarType} from '../util/UserAvatar'
|
||||
import {CenteredView} from '../util/Views'
|
||||
import hairlineWidth = StyleSheet.hairlineWidth
|
||||
|
||||
import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu'
|
||||
import {StarterPack} from '#/components/icons/StarterPack'
|
||||
|
||||
export function ProfileSubpageHeader({
|
||||
isLoading,
|
||||
@@ -84,7 +82,7 @@ export function ProfileSubpageHeader({
|
||||
{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderBottomWidth: hairlineWidth,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
paddingTop: isNative ? 0 : 8,
|
||||
paddingBottom: 8,
|
||||
paddingHorizontal: isMobile ? 12 : 14,
|
||||
|
||||
@@ -344,10 +344,11 @@ function ListImpl<ItemT>(
|
||||
style={[styles.aboveTheFoldDetector, {height: headerOffset}]}
|
||||
/>
|
||||
{onStartReached && !isEmpty && (
|
||||
<Visibility
|
||||
<EdgeVisibility
|
||||
root={disableFullWindowScroll ? nativeRef : null}
|
||||
onVisibleChange={onHeadVisibilityChange}
|
||||
topMargin={(onStartReachedThreshold ?? 0) * 100 + '%'}
|
||||
containerRef={containerRef}
|
||||
/>
|
||||
)}
|
||||
{headerComponent}
|
||||
@@ -368,11 +369,11 @@ function ListImpl<ItemT>(
|
||||
)
|
||||
})}
|
||||
{onEndReached && !isEmpty && (
|
||||
<Visibility
|
||||
<EdgeVisibility
|
||||
root={disableFullWindowScroll ? nativeRef : null}
|
||||
onVisibleChange={onTailVisibilityChange}
|
||||
bottomMargin={(onEndReachedThreshold ?? 0) * 100 + '%'}
|
||||
key={data?.length}
|
||||
containerRef={containerRef}
|
||||
/>
|
||||
)}
|
||||
{footerComponent}
|
||||
@@ -381,6 +382,34 @@ function ListImpl<ItemT>(
|
||||
)
|
||||
}
|
||||
|
||||
function EdgeVisibility({
|
||||
root,
|
||||
topMargin,
|
||||
bottomMargin,
|
||||
containerRef,
|
||||
onVisibleChange,
|
||||
}: {
|
||||
root?: React.RefObject<HTMLDivElement> | null
|
||||
topMargin?: string
|
||||
bottomMargin?: string
|
||||
containerRef: React.RefObject<Element>
|
||||
onVisibleChange: (isVisible: boolean) => void
|
||||
}) {
|
||||
const [containerHeight, setContainerHeight] = React.useState(0)
|
||||
useResizeObserver(containerRef, (w, h) => {
|
||||
setContainerHeight(h)
|
||||
})
|
||||
return (
|
||||
<Visibility
|
||||
key={containerHeight}
|
||||
root={root}
|
||||
topMargin={topMargin}
|
||||
bottomMargin={bottomMargin}
|
||||
onVisibleChange={onVisibleChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function useResizeObserver(
|
||||
ref: React.RefObject<Element>,
|
||||
onResize: undefined | ((w: number, h: number) => void),
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
Heart2_Stroke2_Corner0_Rounded as HeartIconOutline,
|
||||
} from '#/components/icons/Heart2'
|
||||
import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost'
|
||||
import hairlineWidth = StyleSheet.hairlineWidth
|
||||
|
||||
export function LoadingPlaceholder({
|
||||
width,
|
||||
@@ -236,7 +235,7 @@ export function FeedLoadingPlaceholder({
|
||||
{
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 18,
|
||||
borderTopWidth: showTopBorder ? hairlineWidth : 0,
|
||||
borderTopWidth: showTopBorder ? StyleSheet.hairlineWidth : 0,
|
||||
},
|
||||
pal.border,
|
||||
style,
|
||||
|
||||
@@ -12,7 +12,7 @@ import {atoms as a, useTheme} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {IS_TEST} from '#/env'
|
||||
|
||||
const TIMEOUT = 3.7e3
|
||||
const TIMEOUT = 2e3
|
||||
|
||||
export function show(
|
||||
message: string,
|
||||
|
||||
@@ -55,7 +55,6 @@ interface PreviewableUserAvatarProps extends BaseUserAvatarProps {
|
||||
profile: AppBskyActorDefs.ProfileViewBasic
|
||||
disableHoverCard?: boolean
|
||||
onBeforePress?: () => void
|
||||
accessible?: boolean
|
||||
}
|
||||
|
||||
const BLUR_AMOUNT = isWeb ? 5 : 100
|
||||
@@ -412,7 +411,6 @@ let PreviewableUserAvatar = ({
|
||||
profile,
|
||||
disableHoverCard,
|
||||
onBeforePress,
|
||||
accessible = true,
|
||||
...rest
|
||||
}: PreviewableUserAvatarProps): React.ReactNode => {
|
||||
const {_} = useLingui()
|
||||
@@ -426,12 +424,8 @@ let PreviewableUserAvatar = ({
|
||||
return (
|
||||
<ProfileHoverCard did={profile.did} disable={disableHoverCard}>
|
||||
<Link
|
||||
label={
|
||||
accessible
|
||||
? _(msg`${profile.displayName || profile.handle}'s avatar`)
|
||||
: undefined
|
||||
}
|
||||
accessibilityHint={accessible ? _(msg`Opens this profile`) : undefined}
|
||||
label={_(msg`${profile.displayName || profile.handle}'s avatar`)}
|
||||
accessibilityHint={_(msg`Opens this profile`)}
|
||||
to={makeProfileLink({
|
||||
did: profile.did,
|
||||
handle: profile.handle,
|
||||
|
||||
@@ -13,10 +13,9 @@ import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {useTheme} from '#/alf'
|
||||
import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu'
|
||||
import {Text} from './text/Text'
|
||||
import {CenteredView} from './Views'
|
||||
import hairlineWidth = StyleSheet.hairlineWidth
|
||||
import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu'
|
||||
|
||||
const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20}
|
||||
|
||||
@@ -154,7 +153,7 @@ function DesktopWebHeader({
|
||||
styles.desktopHeader,
|
||||
pal.border,
|
||||
{
|
||||
borderBottomWidth: showBorder ? hairlineWidth : 0,
|
||||
borderBottomWidth: showBorder ? StyleSheet.hairlineWidth : 0,
|
||||
},
|
||||
{display: 'flex', flexDirection: 'column'},
|
||||
]}>
|
||||
@@ -243,7 +242,7 @@ const styles = StyleSheet.create({
|
||||
marginRight: 'auto',
|
||||
},
|
||||
border: {
|
||||
borderBottomWidth: hairlineWidth,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
titleContainer: {
|
||||
marginLeft: 'auto',
|
||||
|
||||
@@ -26,7 +26,6 @@ import Animated from 'react-native-reanimated'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {addStyle} from 'lib/styles'
|
||||
import hairlineWidth = StyleSheet.hairlineWidth
|
||||
|
||||
interface AddedProps {
|
||||
desktopFixedHeight?: boolean | number
|
||||
@@ -50,8 +49,8 @@ export const CenteredView = React.forwardRef(function CenteredView(
|
||||
}
|
||||
if (sideBorders) {
|
||||
style = addStyle(style, {
|
||||
borderLeftWidth: hairlineWidth,
|
||||
borderRightWidth: hairlineWidth,
|
||||
borderLeftWidth: StyleSheet.hairlineWidth,
|
||||
borderRightWidth: StyleSheet.hairlineWidth,
|
||||
})
|
||||
style = addStyle(style, pal.border)
|
||||
}
|
||||
@@ -163,8 +162,8 @@ export const ScrollView = React.forwardRef(function ScrollViewImpl(
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
contentContainer: {
|
||||
borderLeftWidth: hairlineWidth,
|
||||
borderRightWidth: hairlineWidth,
|
||||
borderLeftWidth: StyleSheet.hairlineWidth,
|
||||
borderRightWidth: StyleSheet.hairlineWidth,
|
||||
// @ts-ignore web only
|
||||
minHeight: '100vh',
|
||||
},
|
||||
|
||||
@@ -13,7 +13,6 @@ import {clamp} from '#/lib/numbers'
|
||||
import {colors} from '#/lib/styles'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {useSession} from '#/state/session'
|
||||
import hairlineWidth = StyleSheet.hairlineWidth
|
||||
|
||||
const AnimatedTouchableOpacity =
|
||||
Animated.createAnimatedComponent(TouchableOpacity)
|
||||
@@ -74,7 +73,7 @@ const styles = StyleSheet.create({
|
||||
// @ts-ignore 'fixed' is web only -prf
|
||||
position: isWeb ? 'fixed' : 'absolute',
|
||||
left: 18,
|
||||
borderWidth: hairlineWidth,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: 26,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import React, {useCallback, useId, useMemo, useState} from 'react'
|
||||
|
||||
import {VideoPlayerProvider} from './VideoPlayerContext'
|
||||
|
||||
const ActiveVideoContext = React.createContext<{
|
||||
activeViewId: string | null
|
||||
setActiveView: (viewId: string, src: string) => void
|
||||
} | null>(null)
|
||||
|
||||
export function ActiveVideoProvider({children}: {children: React.ReactNode}) {
|
||||
const [activeViewId, setActiveViewId] = useState<string | null>(null)
|
||||
const [source, setSource] = useState<string | null>(null)
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
activeViewId,
|
||||
setActiveView: (viewId: string, src: string) => {
|
||||
setActiveViewId(viewId)
|
||||
setSource(src)
|
||||
},
|
||||
}),
|
||||
[activeViewId],
|
||||
)
|
||||
|
||||
return (
|
||||
<ActiveVideoContext.Provider value={value}>
|
||||
<VideoPlayerProvider source={source ?? ''} viewId={activeViewId}>
|
||||
{children}
|
||||
</VideoPlayerProvider>
|
||||
</ActiveVideoContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useActiveVideoView() {
|
||||
const context = React.useContext(ActiveVideoContext)
|
||||
if (!context) {
|
||||
throw new Error('useActiveVideo must be used within a ActiveVideoProvider')
|
||||
}
|
||||
const id = useId()
|
||||
|
||||
return {
|
||||
active: context.activeViewId === id,
|
||||
setActive: useCallback(
|
||||
(source: string) => context.setActiveView(id, source),
|
||||
[context, id],
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,6 @@ import {Link} from '../Link'
|
||||
import {PostMeta} from '../PostMeta'
|
||||
import {Text} from '../text/Text'
|
||||
import {PostEmbeds} from '.'
|
||||
import hairlineWidth = StyleSheet.hairlineWidth
|
||||
|
||||
export function MaybeQuoteEmbed({
|
||||
embed,
|
||||
@@ -262,7 +261,7 @@ const styles = StyleSheet.create({
|
||||
marginTop: 8,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 12,
|
||||
borderWidth: hairlineWidth,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
errorContainer: {
|
||||
flexDirection: 'row',
|
||||
@@ -272,7 +271,7 @@ const styles = StyleSheet.create({
|
||||
marginTop: 8,
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 14,
|
||||
borderWidth: hairlineWidth,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
alert: {
|
||||
marginBottom: 6,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import React, {useCallback} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import {Play_Filled_Corner2_Rounded as PlayIcon} from '#/components/icons/Play'
|
||||
import {useActiveVideoView} from './ActiveVideoContext'
|
||||
import {VideoEmbedInner} from './VideoEmbedInner'
|
||||
|
||||
export function VideoEmbed({source}: {source: string}) {
|
||||
const t = useTheme()
|
||||
const {active, setActive} = useActiveVideoView()
|
||||
const {_} = useLingui()
|
||||
|
||||
const onPress = useCallback(() => setActive(source), [setActive, source])
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.rounded_sm,
|
||||
{aspectRatio: 16 / 9},
|
||||
a.overflow_hidden,
|
||||
t.atoms.bg_contrast_25,
|
||||
a.my_xs,
|
||||
]}>
|
||||
{active ? (
|
||||
<VideoEmbedInner source={source} />
|
||||
) : (
|
||||
<Button
|
||||
style={[a.flex_1, t.atoms.bg_contrast_25]}
|
||||
onPress={onPress}
|
||||
label={_(msg`Play video`)}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="large">
|
||||
<ButtonIcon icon={PlayIcon} />
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import React, {useCallback, useEffect, useRef, useState} from 'react'
|
||||
import {Pressable, StyleSheet, useWindowDimensions, View} from 'react-native'
|
||||
import Animated, {
|
||||
measure,
|
||||
runOnJS,
|
||||
useAnimatedRef,
|
||||
useFrameCallback,
|
||||
useSharedValue,
|
||||
} from 'react-native-reanimated'
|
||||
import {VideoPlayer, VideoView} from 'expo-video'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useVideoPlayer} from './VideoPlayerContext'
|
||||
|
||||
export const VideoEmbedInner = ({}: {source: string}) => {
|
||||
const player = useVideoPlayer()
|
||||
const aref = useAnimatedRef<Animated.View>()
|
||||
const {height: windowHeight} = useWindowDimensions()
|
||||
const hasLeftView = useSharedValue(false)
|
||||
const ref = useRef<VideoView>(null)
|
||||
|
||||
const onEnterView = useCallback(() => {
|
||||
if (player.status === 'readyToPlay') {
|
||||
player.play()
|
||||
}
|
||||
}, [player])
|
||||
|
||||
const onLeaveView = useCallback(() => {
|
||||
player.pause()
|
||||
}, [player])
|
||||
|
||||
const enterFullscreen = useCallback(() => {
|
||||
if (ref.current) {
|
||||
ref.current.enterFullscreen()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useFrameCallback(() => {
|
||||
const measurement = measure(aref)
|
||||
|
||||
if (measurement) {
|
||||
if (hasLeftView.value) {
|
||||
// Check if the video is in view
|
||||
if (
|
||||
measurement.pageY >= 0 &&
|
||||
measurement.pageY + measurement.height <= windowHeight
|
||||
) {
|
||||
runOnJS(onEnterView)()
|
||||
hasLeftView.value = false
|
||||
}
|
||||
} else {
|
||||
// Check if the video is out of view
|
||||
if (
|
||||
measurement.pageY + measurement.height < 0 ||
|
||||
measurement.pageY > windowHeight
|
||||
) {
|
||||
runOnJS(onLeaveView)()
|
||||
hasLeftView.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[a.flex_1, a.relative]}
|
||||
ref={aref}
|
||||
collapsable={false}>
|
||||
<VideoView
|
||||
ref={ref}
|
||||
player={player}
|
||||
style={a.flex_1}
|
||||
nativeControls={true}
|
||||
/>
|
||||
<VideoControls player={player} enterFullscreen={enterFullscreen} />
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
function VideoControls({
|
||||
player,
|
||||
enterFullscreen,
|
||||
}: {
|
||||
player: VideoPlayer
|
||||
enterFullscreen: () => void
|
||||
}) {
|
||||
const [currentTime, setCurrentTime] = useState(Math.floor(player.currentTime))
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setCurrentTime(Math.floor(player.duration - player.currentTime))
|
||||
// how often should we update the time?
|
||||
// 1000 gets out of sync with the video time
|
||||
}, 250)
|
||||
|
||||
return () => {
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, [player])
|
||||
|
||||
const minutes = Math.floor(currentTime / 60)
|
||||
const seconds = String(currentTime % 60).padStart(2, '0')
|
||||
|
||||
return (
|
||||
<View style={[a.absolute, a.inset_0]}>
|
||||
<View style={styles.timeContainer} pointerEvents="none">
|
||||
<Text style={styles.timeElapsed}>
|
||||
{minutes}:{seconds}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={enterFullscreen}
|
||||
style={a.flex_1}
|
||||
accessibilityLabel="Video"
|
||||
accessibilityHint="Tap to enter full screen"
|
||||
accessibilityRole="button"
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
timeContainer: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 3,
|
||||
position: 'absolute',
|
||||
left: 5,
|
||||
bottom: 5,
|
||||
},
|
||||
timeElapsed: {
|
||||
color: 'white',
|
||||
fontSize: 12,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import React, {useEffect, useRef} from 'react'
|
||||
import Hls from 'hls.js'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
|
||||
export const VideoEmbedInner = ({source}: {source: string}) => {
|
||||
const ref = useRef<HTMLVideoElement>(null)
|
||||
|
||||
// Use HLS.js to play HLS video
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
if (ref.current.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
ref.current.src = source
|
||||
} else if (Hls.isSupported()) {
|
||||
var hls = new Hls()
|
||||
hls.loadSource(source)
|
||||
hls.attachMedia(ref.current)
|
||||
} else {
|
||||
// TODO: fallback
|
||||
}
|
||||
}
|
||||
}, [source])
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (ref.current) {
|
||||
if (entry.isIntersecting) {
|
||||
if (ref.current.paused) {
|
||||
ref.current.play()
|
||||
}
|
||||
} else {
|
||||
if (!ref.current.paused) {
|
||||
ref.current.pause()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{threshold: 0},
|
||||
)
|
||||
|
||||
observer.observe(ref.current)
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return <video ref={ref} style={a.flex_1} controls playsInline autoPlay loop />
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import React, {useContext, useEffect} from 'react'
|
||||
import type {VideoPlayer} from 'expo-video'
|
||||
import {useVideoPlayer as useExpoVideoPlayer} from 'expo-video'
|
||||
|
||||
const VideoPlayerContext = React.createContext<VideoPlayer | null>(null)
|
||||
|
||||
export function VideoPlayerProvider({
|
||||
viewId,
|
||||
source,
|
||||
children,
|
||||
}: {
|
||||
viewId: string | null
|
||||
source: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||
const player = useExpoVideoPlayer(source, player => {
|
||||
player.loop = true
|
||||
player.play()
|
||||
})
|
||||
|
||||
// make sure we're playing every time the viewId changes
|
||||
// this means the video is different
|
||||
useEffect(() => {
|
||||
player.play()
|
||||
}, [viewId, player])
|
||||
|
||||
return (
|
||||
<VideoPlayerContext.Provider value={player}>
|
||||
{children}
|
||||
</VideoPlayerContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useVideoPlayer() {
|
||||
const context = useContext(VideoPlayerContext)
|
||||
if (!context) {
|
||||
throw new Error('useVideoPlayer must be used within a VideoPlayerProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from 'react'
|
||||
|
||||
export function VideoPlayerProvider({children}: {children: React.ReactNode}) {
|
||||
return children
|
||||
}
|
||||
|
||||
export function useVideoPlayer() {
|
||||
throw new Error('useVideoPlayer must not be used on web')
|
||||
}
|
||||
@@ -21,19 +21,18 @@ import {
|
||||
} from '@atproto/api'
|
||||
|
||||
import {ImagesLightbox, useLightboxControls} from '#/state/lightbox'
|
||||
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Embed as StarterPackCard} from '#/components/StarterPack/StarterPackCard'
|
||||
import {ContentHider} from '../../../../components/moderation/ContentHider'
|
||||
import {AutoSizedImage} from '../images/AutoSizedImage'
|
||||
import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
|
||||
import {ExternalLinkEmbed} from './ExternalLinkEmbed'
|
||||
import {ListEmbed} from './ListEmbed'
|
||||
import {MaybeQuoteEmbed} from './QuoteEmbed'
|
||||
import hairlineWidth = StyleSheet.hairlineWidth
|
||||
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {Embed as StarterPackCard} from '#/components/StarterPack/StarterPackCard'
|
||||
|
||||
type Embed =
|
||||
| AppBskyEmbedRecord.View
|
||||
@@ -231,7 +230,7 @@ const styles = StyleSheet.create({
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
customFeedOuter: {
|
||||
borderWidth: hairlineWidth,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderRadius: 8,
|
||||
marginTop: 4,
|
||||
paddingHorizontal: 12,
|
||||
|
||||
Reference in New Issue
Block a user