diff options
Diffstat (limited to 'src/view/com')
32 files changed, 730 insertions, 250 deletions
diff --git a/src/view/com/auth/create/state.ts b/src/view/com/auth/create/state.ts index 4df82f8fc..a77d2a44f 100644 --- a/src/view/com/auth/create/state.ts +++ b/src/view/com/auth/create/state.ts @@ -144,7 +144,7 @@ export async function submit({ } export function is13(state: CreateAccountState) { - return getAge(state.birthDate) >= 18 + return getAge(state.birthDate) >= 13 } export function is18(state: CreateAccountState) { diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index c4453e0c3..9f60923d6 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -207,7 +207,11 @@ export const ComposePost = observer(function ComposePost({ setError('') if (richtext.text.trim().length === 0 && gallery.isEmpty && !extLink) { - setError('Did you want to say anything?') + setError(_(msg`Did you want to say anything?`)) + return + } + if (extLink?.isLoading) { + setError(_(msg`Please wait for your link card to finish loading`)) return } @@ -438,7 +442,7 @@ export const ComposePost = observer(function ComposePost({ accessibilityLabel={_(msg`Add link card`)} accessibilityHint={`Creates a card with a thumbnail. The card links to ${url}`}> <Text style={pal.text}> - <Trans>Add link card:</Trans> + <Trans>Add link card:</Trans>{' '} <Text style={[pal.link, s.ml5]}>{toShortUrl(url)}</Text> </Text> </TouchableOpacity> @@ -452,7 +456,7 @@ export const ComposePost = observer(function ComposePost({ <OpenCameraBtn gallery={gallery} /> </> ) : null} - {isDesktop ? <EmojiPickerButton /> : null} + {!isMobile ? <EmojiPickerButton /> : null} <View style={s.flex1} /> <SelectLangBtn /> <CharProgress count={graphemeLength} /> diff --git a/src/view/com/composer/text-input/web/Autocomplete.tsx b/src/view/com/composer/text-input/web/Autocomplete.tsx index 1f7412561..51197b8e4 100644 --- a/src/view/com/composer/text-input/web/Autocomplete.tsx +++ b/src/view/com/composer/text-input/web/Autocomplete.tsx @@ -134,7 +134,7 @@ const MentionList = forwardRef<MentionListRef, SuggestionProps>( return true } - if (event.key === 'Enter') { + if (event.key === 'Enter' || event.key === 'Tab') { enterHandler() return true } diff --git a/src/view/com/composer/text-input/web/EmojiPicker.web.tsx b/src/view/com/composer/text-input/web/EmojiPicker.web.tsx index 09a2dcf41..f4b2d99b0 100644 --- a/src/view/com/composer/text-input/web/EmojiPicker.web.tsx +++ b/src/view/com/composer/text-input/web/EmojiPicker.web.tsx @@ -76,7 +76,7 @@ export function EmojiPicker({close}: {close: () => void}) { return (await import('./EmojiPickerData.json')).default }} onEmojiSelect={onInsert} - autoFocus={false} + autoFocus={true} /> </View> </TouchableWithoutFeedback> @@ -96,6 +96,7 @@ const styles = StyleSheet.create({ }, trigger: { backgroundColor: 'transparent', + // @ts-ignore web only -prf border: 'none', paddingTop: 4, paddingLeft: 12, diff --git a/src/view/com/feeds/ProfileFeedgens.tsx b/src/view/com/feeds/ProfileFeedgens.tsx index ff6505501..8665fbfac 100644 --- a/src/view/com/feeds/ProfileFeedgens.tsx +++ b/src/view/com/feeds/ProfileFeedgens.tsx @@ -1,12 +1,5 @@ import React from 'react' -import { - Dimensions, - RefreshControl, - StyleProp, - StyleSheet, - View, - ViewStyle, -} from 'react-native' +import {Dimensions, StyleProp, StyleSheet, View, ViewStyle} from 'react-native' import {useQueryClient} from '@tanstack/react-query' import {List, ListRef} from '../util/List' import {FeedSourceCardLoaded} from './FeedSourceCard' @@ -180,22 +173,14 @@ export const ProfileFeedgens = React.forwardRef< data={items} keyExtractor={(item: any) => item._reactKey || item.uri} renderItem={renderItemInner} - refreshControl={ - <RefreshControl - refreshing={isPTRing} - onRefresh={onRefresh} - tintColor={pal.colors.text} - titleColor={pal.colors.text} - progressViewOffset={headerOffset} - /> - } + refreshing={isPTRing} + onRefresh={onRefresh} + headerOffset={headerOffset} contentContainerStyle={{ minHeight: Dimensions.get('window').height * 1.5, }} - style={{paddingTop: headerOffset}} indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'} removeClippedSubviews={true} - contentOffset={{x: 0, y: headerOffset * -1}} // @ts-ignore our .web version only -prf desktopFixedHeight onEndReached={onEndReached} diff --git a/src/view/com/lists/ListMembers.tsx b/src/view/com/lists/ListMembers.tsx index a31ca4793..932f4b512 100644 --- a/src/view/com/lists/ListMembers.tsx +++ b/src/view/com/lists/ListMembers.tsx @@ -2,7 +2,6 @@ import React from 'react' import { ActivityIndicator, Dimensions, - RefreshControl, StyleProp, View, ViewStyle, @@ -15,7 +14,6 @@ import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' import {ProfileCard} from '../profile/ProfileCard' import {Button} from '../util/forms/Button' import {useAnalytics} from 'lib/analytics/analytics' -import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useListMembersQuery} from '#/state/queries/list-members' import {logger} from '#/logger' @@ -51,7 +49,6 @@ export function ListMembers({ headerOffset?: number desktopFixedHeightOffset?: number }) { - const pal = usePalette('default') const {track} = useAnalytics() const [isRefreshing, setIsRefreshing] = React.useState(false) const {isMobile} = useWebMediaQueries() @@ -183,6 +180,7 @@ export function ListMembers({ profile={(item as AppBskyGraphDefs.ListItemView).subject} renderButton={renderMemberButton} style={{paddingHorizontal: isMobile ? 8 : 14, paddingVertical: 4}} + noModFilter /> ) }, @@ -215,24 +213,16 @@ export function ListMembers({ renderItem={renderItem} ListHeaderComponent={renderHeader} ListFooterComponent={Footer} - refreshControl={ - <RefreshControl - refreshing={isRefreshing} - onRefresh={onRefresh} - tintColor={pal.colors.text} - titleColor={pal.colors.text} - progressViewOffset={headerOffset} - /> - } + refreshing={isRefreshing} + onRefresh={onRefresh} + headerOffset={headerOffset} contentContainerStyle={{ minHeight: Dimensions.get('window').height * 1.5, }} - style={{paddingTop: headerOffset}} onScrolledDownChange={onScrolledDownChange} onEndReached={onEndReached} onEndReachedThreshold={0.6} removeClippedSubviews={true} - contentOffset={{x: 0, y: headerOffset * -1}} // @ts-ignore our .web version only -prf desktopFixedHeight={desktopFixedHeightOffset || true} /> diff --git a/src/view/com/lists/MyLists.tsx b/src/view/com/lists/MyLists.tsx index 586ad234e..a2a6b0651 100644 --- a/src/view/com/lists/MyLists.tsx +++ b/src/view/com/lists/MyLists.tsx @@ -119,31 +119,51 @@ export function MyLists({ [error, onRefresh, renderItem, pal], ) - const FlatListCom = inline ? RNFlatList : List - return ( - <View testID={testID} style={style}> - {items.length > 0 && ( - <FlatListCom - testID={testID ? `${testID}-flatlist` : undefined} - data={items} - keyExtractor={item => (item.uri ? item.uri : item._reactKey)} - renderItem={renderItemInner} - refreshControl={ - <RefreshControl - refreshing={isPTRing} - onRefresh={onRefresh} - tintColor={pal.colors.text} - titleColor={pal.colors.text} - /> - } - contentContainerStyle={[s.contentContainer]} - removeClippedSubviews={true} - // @ts-ignore our .web version only -prf - desktopFixedHeight - /> - )} - </View> - ) + if (inline) { + return ( + <View testID={testID} style={style}> + {items.length > 0 && ( + <RNFlatList + testID={testID ? `${testID}-flatlist` : undefined} + data={items} + keyExtractor={item => (item.uri ? item.uri : item._reactKey)} + renderItem={renderItemInner} + refreshControl={ + <RefreshControl + refreshing={isPTRing} + onRefresh={onRefresh} + tintColor={pal.colors.text} + titleColor={pal.colors.text} + /> + } + contentContainerStyle={[s.contentContainer]} + removeClippedSubviews={true} + // @ts-ignore our .web version only -prf + desktopFixedHeight + /> + )} + </View> + ) + } else { + return ( + <View testID={testID} style={style}> + {items.length > 0 && ( + <List + testID={testID ? `${testID}-flatlist` : undefined} + data={items} + keyExtractor={item => (item.uri ? item.uri : item._reactKey)} + renderItem={renderItemInner} + refreshing={isPTRing} + onRefresh={onRefresh} + contentContainerStyle={[s.contentContainer]} + removeClippedSubviews={true} + // @ts-ignore our .web version only -prf + desktopFixedHeight + /> + )} + </View> + ) + } } const styles = StyleSheet.create({ diff --git a/src/view/com/lists/ProfileLists.tsx b/src/view/com/lists/ProfileLists.tsx index e3d9bd0b4..db981717f 100644 --- a/src/view/com/lists/ProfileLists.tsx +++ b/src/view/com/lists/ProfileLists.tsx @@ -1,12 +1,5 @@ import React from 'react' -import { - Dimensions, - RefreshControl, - StyleProp, - StyleSheet, - View, - ViewStyle, -} from 'react-native' +import {Dimensions, StyleProp, StyleSheet, View, ViewStyle} from 'react-native' import {useQueryClient} from '@tanstack/react-query' import {List, ListRef} from '../util/List' import {ListCard} from './ListCard' @@ -182,22 +175,14 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>( data={items} keyExtractor={(item: any) => item._reactKey} renderItem={renderItemInner} - refreshControl={ - <RefreshControl - refreshing={isPTRing} - onRefresh={onRefresh} - tintColor={pal.colors.text} - titleColor={pal.colors.text} - progressViewOffset={headerOffset} - /> - } + refreshing={isPTRing} + onRefresh={onRefresh} + headerOffset={headerOffset} contentContainerStyle={{ minHeight: Dimensions.get('window').height * 1.5, }} - style={{paddingTop: headerOffset}} indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'} removeClippedSubviews={true} - contentOffset={{x: 0, y: headerOffset * -1}} // @ts-ignore our .web version only -prf desktopFixedHeight onEndReached={onEndReached} diff --git a/src/view/com/modals/AltImage.tsx b/src/view/com/modals/AltImage.tsx index 80130f43a..a2e918317 100644 --- a/src/view/com/modals/AltImage.tsx +++ b/src/view/com/modals/AltImage.tsx @@ -80,6 +80,7 @@ export function Component({image}: Props) { source={{ uri: image.cropped?.path ?? image.path, }} + contentFit="contain" accessible={true} accessibilityIgnoresInvertColors /> diff --git a/src/view/com/modals/Threadgate.tsx b/src/view/com/modals/Threadgate.tsx index 9d78a2e6d..0deef185b 100644 --- a/src/view/com/modals/Threadgate.tsx +++ b/src/view/com/modals/Threadgate.tsx @@ -69,7 +69,7 @@ export function Component({ <ScrollView> <Text style={[pal.text, styles.description]}> - Choose "Everybody" or "Nobody" + <Trans>Choose "Everybody" or "Nobody"</Trans> </Text> <View style={{flexDirection: 'row', gap: 6, paddingHorizontal: 6}}> <Selectable @@ -86,7 +86,7 @@ export function Component({ /> </View> <Text style={[pal.text, styles.description]}> - Or combine these options: + <Trans>Or combine these options:</Trans> </Text> <View style={{flexDirection: 'column', gap: 4, paddingHorizontal: 6}}> <Selectable diff --git a/src/view/com/notifications/Feed.tsx b/src/view/com/notifications/Feed.tsx index 52d534c4f..a99fe2c1d 100644 --- a/src/view/com/notifications/Feed.tsx +++ b/src/view/com/notifications/Feed.tsx @@ -1,13 +1,12 @@ import React from 'react' import {CenteredView} from '../util/Views' -import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native' +import {ActivityIndicator, StyleSheet, View} from 'react-native' import {FeedItem} from './FeedItem' import {NotificationFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' import {ErrorMessage} from '../util/error/ErrorMessage' import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' import {EmptyState} from '../util/EmptyState' import {s} from 'lib/styles' -import {usePalette} from 'lib/hooks/usePalette' import {useNotificationFeedQuery} from '#/state/queries/notifications/feed' import {useUnreadNotificationsApi} from '#/state/queries/notifications/unread' import {logger} from '#/logger' @@ -30,7 +29,6 @@ export function Feed({ onScrolledDownChange: (isScrolledDown: boolean) => void ListHeaderComponent?: () => JSX.Element }) { - const pal = usePalette('default') const [isPTRing, setIsPTRing] = React.useState(false) const moderationOpts = useModerationOpts() @@ -152,14 +150,8 @@ export function Feed({ renderItem={renderItem} ListHeaderComponent={ListHeaderComponent} ListFooterComponent={FeedFooter} - refreshControl={ - <RefreshControl - refreshing={isPTRing} - onRefresh={onRefresh} - tintColor={pal.colors.text} - titleColor={pal.colors.text} - /> - } + refreshing={isPTRing} + onRefresh={onRefresh} onEndReached={onEndReached} onEndReachedThreshold={0.6} onScrolledDownChange={onScrolledDownChange} diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index aaa2ea2c6..24b7e4fb6 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -42,6 +42,7 @@ import {TimeElapsed} from '../util/TimeElapsed' import {isWeb} from 'platform/detection' import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {FeedSourceCard} from '../feeds/FeedSourceCard' const MAX_AUTHORS = 5 @@ -112,7 +113,7 @@ let FeedItem = ({ ] }, [item, moderationOpts]) - if (item.subjectUri && !item.subject) { + if (item.subjectUri && !item.subject && item.type !== 'feedgen-like') { // don't render anything if the target post was deleted or unfindable return <View /> } @@ -166,7 +167,7 @@ let FeedItem = ({ iconStyle = [s.blue3 as FontAwesomeIconStyle] } else if (item.type === 'feedgen-like') { action = `liked your custom feed${ - item.subjectUri ? ` '${new AtUri(item.subjectUri).rkey}}'` : '' + item.subjectUri ? ` '${new AtUri(item.subjectUri).rkey}'` : '' }` icon = 'HeartIconSolid' iconStyle = [ @@ -256,6 +257,13 @@ let FeedItem = ({ {item.type === 'post-like' || item.type === 'repost' ? ( <AdditionalPostText post={item.subject} /> ) : null} + {item.type === 'feedgen-like' && item.subjectUri ? ( + <FeedSourceCard + feedUri={item.subjectUri} + style={[pal.view, pal.border, styles.feedcard]} + showLikes + /> + ) : null} </View> </Link> ) @@ -496,6 +504,12 @@ const styles = StyleSheet.create({ marginLeft: 2, opacity: 0.8, }, + feedcard: { + borderWidth: 1, + borderRadius: 8, + paddingVertical: 12, + marginTop: 6, + }, addedContainer: { paddingTop: 4, diff --git a/src/view/com/post-thread/PostLikedBy.tsx b/src/view/com/post-thread/PostLikedBy.tsx index 245ba59e8..6e013f611 100644 --- a/src/view/com/post-thread/PostLikedBy.tsx +++ b/src/view/com/post-thread/PostLikedBy.tsx @@ -1,18 +1,16 @@ import React, {useCallback, useMemo, useState} from 'react' -import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native' +import {ActivityIndicator, StyleSheet, View} from 'react-native' import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api' import {CenteredView} from '../util/Views' import {List} from '../util/List' import {ErrorMessage} from '../util/error/ErrorMessage' import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' -import {usePalette} from 'lib/hooks/usePalette' import {logger} from '#/logger' import {useResolveUriQuery} from '#/state/queries/resolve-uri' import {usePostLikedByQuery} from '#/state/queries/post-liked-by' import {cleanError} from '#/lib/strings/errors' export function PostLikedBy({uri}: {uri: string}) { - const pal = usePalette('default') const [isPTRing, setIsPTRing] = useState(false) const { data: resolvedUri, @@ -88,14 +86,8 @@ export function PostLikedBy({uri}: {uri: string}) { <List data={likes} keyExtractor={item => item.actor.did} - refreshControl={ - <RefreshControl - refreshing={isPTRing} - onRefresh={onRefresh} - tintColor={pal.colors.text} - titleColor={pal.colors.text} - /> - } + refreshing={isPTRing} + onRefresh={onRefresh} onEndReached={onEndReached} renderItem={renderItem} initialNumToRender={15} diff --git a/src/view/com/post-thread/PostRepostedBy.tsx b/src/view/com/post-thread/PostRepostedBy.tsx index 5cc006388..a2d3be558 100644 --- a/src/view/com/post-thread/PostRepostedBy.tsx +++ b/src/view/com/post-thread/PostRepostedBy.tsx @@ -1,18 +1,16 @@ import React, {useMemo, useCallback, useState} from 'react' -import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native' +import {ActivityIndicator, StyleSheet, View} from 'react-native' import {AppBskyActorDefs as ActorDefs} from '@atproto/api' import {CenteredView} from '../util/Views' import {List} from '../util/List' import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' import {ErrorMessage} from '../util/error/ErrorMessage' -import {usePalette} from 'lib/hooks/usePalette' import {logger} from '#/logger' import {useResolveUriQuery} from '#/state/queries/resolve-uri' import {usePostRepostedByQuery} from '#/state/queries/post-reposted-by' import {cleanError} from '#/lib/strings/errors' export function PostRepostedBy({uri}: {uri: string}) { - const pal = usePalette('default') const [isPTRing, setIsPTRing] = useState(false) const { data: resolvedUri, @@ -89,14 +87,8 @@ export function PostRepostedBy({uri}: {uri: string}) { <List data={repostedBy} keyExtractor={item => item.did} - refreshControl={ - <RefreshControl - refreshing={isPTRing} - onRefresh={onRefresh} - tintColor={pal.colors.text} - titleColor={pal.colors.text} - /> - } + refreshing={isPTRing} + onRefresh={onRefresh} onEndReached={onEndReached} renderItem={renderItem} initialNumToRender={15} diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index 917550884..6cd1f3551 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -2,7 +2,6 @@ import React, {useEffect, useRef} from 'react' import { ActivityIndicator, Pressable, - RefreshControl, StyleSheet, TouchableOpacity, View, @@ -349,14 +348,8 @@ function PostThreadLoaded({ } keyExtractor={item => item._reactKey} renderItem={renderItem} - refreshControl={ - <RefreshControl - refreshing={isPTRing} - onRefresh={onPTR} - tintColor={pal.colors.text} - titleColor={pal.colors.text} - /> - } + refreshing={isPTRing} + onRefresh={onPTR} onContentSizeChange={onContentSizeChange} style={s.hContentRegion} // @ts-ignore our .web version only -prf diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 2ff803071..986fd70b2 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -5,9 +5,9 @@ import { AppBskyFeedDefs, AppBskyFeedPost, RichText as RichTextAPI, - moderatePost, PostModeration, } from '@atproto/api' +import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {Link, TextLink} from '../util/Link' import {RichText} from '../util/text/RichText' @@ -186,9 +186,9 @@ let PostThreadItemLoaded = ({ return makeProfileLink(post.author, 'post', urip.rkey, 'reposted-by') }, [post.uri, post.author]) const repostsTitle = 'Reposts of this post' - const isSelfLabeledPost = + const isModeratedPost = moderation.decisions.post.cause?.type === 'label' && - moderation.decisions.post.cause.label.src === currentAccount?.did + moderation.decisions.post.cause.label.src !== currentAccount?.did const translatorUrl = getTranslatorLink( record?.text || '', @@ -335,7 +335,7 @@ let PostThreadItemLoaded = ({ postUri={post.uri} record={record} showAppealLabelItem={ - post.author.did === currentAccount?.did && !isSelfLabeledPost + post.author.did === currentAccount?.did && isModeratedPost } style={{ paddingVertical: 6, @@ -539,6 +539,7 @@ let PostThreadItemLoaded = ({ timestamp={post.indexedAt} postHref={postHref} showAvatar={isThreadedChild} + avatarModeration={moderation.avatar} avatarSize={28} displayNameType="md-bold" displayNameStyle={isThreadedChild && s.ml2} diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index 9b1bf7a49..fca4171c3 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -4,10 +4,10 @@ import { AppBskyFeedDefs, AppBskyFeedPost, AtUri, - moderatePost, PostModeration, RichText as RichTextAPI, } from '@atproto/api' +import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {Link, TextLink} from '../util/Link' import {UserInfoText} from '../util/UserInfoText' @@ -221,6 +221,7 @@ const styles = StyleSheet.create({ paddingBottom: 5, paddingLeft: 10, borderTopWidth: 1, + // @ts-ignore web only -prf cursor: 'pointer', }, layout: { diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index 8d5c11bda..02a3537eb 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -3,7 +3,6 @@ import { ActivityIndicator, AppState, Dimensions, - RefreshControl, StyleProp, StyleSheet, View, @@ -16,7 +15,6 @@ import {FeedErrorMessage} from './FeedErrorMessage' import {FeedSlice} from './FeedSlice' import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' import {useAnalytics} from 'lib/analytics/analytics' -import {usePalette} from 'lib/hooks/usePalette' import {useTheme} from 'lib/ThemeContext' import {logger} from '#/logger' import { @@ -74,7 +72,6 @@ let Feed = ({ ListHeaderComponent?: () => JSX.Element extraData?: any }): React.ReactNode => { - const pal = usePalette('default') const theme = useTheme() const {track} = useAnalytics() const queryClient = useQueryClient() @@ -98,10 +95,13 @@ let Feed = ({ isFetchingNextPage, fetchNextPage, } = usePostFeedQuery(feed, feedParams, opts) - const isEmpty = !isFetching && !data?.pages[0]?.slices.length if (data?.pages[0]) { lastFetchRef.current = data?.pages[0].fetchedAt } + const isEmpty = React.useMemo( + () => !isFetching && !data?.pages?.some(page => page.slices.length), + [isFetching, data], + ) const checkForNew = React.useCallback(async () => { if (!data?.pages[0] || isFetching || !onHasNew || !enabled) { @@ -294,25 +294,17 @@ let Feed = ({ renderItem={renderItem} ListFooterComponent={FeedFooter} ListHeaderComponent={ListHeaderComponent} - refreshControl={ - <RefreshControl - refreshing={isPTRing} - onRefresh={onRefresh} - tintColor={pal.colors.text} - titleColor={pal.colors.text} - progressViewOffset={headerOffset} - /> - } + refreshing={isPTRing} + onRefresh={onRefresh} + headerOffset={headerOffset} contentContainerStyle={{ minHeight: Dimensions.get('window').height * 1.5, }} - style={{paddingTop: headerOffset}} onScrolledDownChange={onScrolledDownChange} indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'} onEndReached={onEndReached} onEndReachedThreshold={2} // number of posts left to trigger load more removeClippedSubviews={true} - contentOffset={{x: 0, y: headerOffset * -1}} extraData={extraData} // @ts-ignore our .web version only -prf desktopFixedHeight={ diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index 20d199745..942d7bf71 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -34,6 +34,7 @@ import {countLines} from 'lib/strings/helpers' import {useComposerControls} from '#/state/shell/composer' import {Shadow, usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow' import {FeedNameText} from '../util/FeedInfoText' +import {useSession} from '#/state/session' export function FeedItem({ post, @@ -102,10 +103,14 @@ let FeedItemInner = ({ }): React.ReactNode => { const {openComposer} = useComposerControls() const pal = usePalette('default') + const {currentAccount} = useSession() const href = useMemo(() => { const urip = new AtUri(post.uri) return makeProfileLink(post.author, 'post', urip.rkey) }, [post.uri, post.author]) + const isModeratedPost = + moderation.decisions.post.cause?.type === 'label' && + moderation.decisions.post.cause.label.src !== currentAccount?.did const replyAuthorDid = useMemo(() => { if (!record?.reply) { @@ -284,7 +289,14 @@ let FeedItemInner = ({ postEmbed={post.embed} postAuthor={post.author} /> - <PostCtrls post={post} record={record} onPressReply={onPressReply} /> + <PostCtrls + post={post} + record={record} + onPressReply={onPressReply} + showAppealLabelItem={ + post.author.did === currentAccount?.did && isModeratedPost + } + /> </View> </View> </Link> @@ -364,6 +376,7 @@ const styles = StyleSheet.create({ borderTopWidth: 1, paddingLeft: 10, paddingRight: 15, + // @ts-ignore web only -prf cursor: 'pointer', overflow: 'hidden', }, diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx index c5b2dc528..ef95f5924 100644 --- a/src/view/com/profile/ProfileCard.tsx +++ b/src/view/com/profile/ProfileCard.tsx @@ -27,6 +27,7 @@ import {useSession} from '#/state/session' export function ProfileCard({ testID, profile: profileUnshadowed, + noModFilter, noBg, noBorder, followers, @@ -35,6 +36,7 @@ export function ProfileCard({ }: { testID?: string profile: AppBskyActorDefs.ProfileViewBasic + noModFilter?: boolean noBg?: boolean noBorder?: boolean followers?: AppBskyActorDefs.ProfileView[] | undefined @@ -50,7 +52,11 @@ export function ProfileCard({ return null } const moderation = moderateProfile(profile, moderationOpts) - if (moderation.account.filter) { + if ( + !noModFilter && + moderation.account.filter && + moderation.account.cause?.type !== 'muted' + ) { return null } diff --git a/src/view/com/profile/ProfileFollowers.tsx b/src/view/com/profile/ProfileFollowers.tsx index 077dabe53..fd8dee173 100644 --- a/src/view/com/profile/ProfileFollowers.tsx +++ b/src/view/com/profile/ProfileFollowers.tsx @@ -1,18 +1,16 @@ import React from 'react' -import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native' +import {ActivityIndicator, StyleSheet, View} from 'react-native' import {AppBskyActorDefs as ActorDefs} from '@atproto/api' import {CenteredView} from '../util/Views' import {List} from '../util/List' import {ErrorMessage} from '../util/error/ErrorMessage' import {ProfileCardWithFollowBtn} from './ProfileCard' -import {usePalette} from 'lib/hooks/usePalette' import {useProfileFollowersQuery} from '#/state/queries/profile-followers' import {useResolveDidQuery} from '#/state/queries/resolve-uri' import {logger} from '#/logger' import {cleanError} from '#/lib/strings/errors' export function ProfileFollowers({name}: {name: string}) { - const pal = usePalette('default') const [isPTRing, setIsPTRing] = React.useState(false) const { data: resolvedDid, @@ -90,14 +88,8 @@ export function ProfileFollowers({name}: {name: string}) { <List data={followers} keyExtractor={item => item.did} - refreshControl={ - <RefreshControl - refreshing={isPTRing} - onRefresh={onRefresh} - tintColor={pal.colors.text} - titleColor={pal.colors.text} - /> - } + refreshing={isPTRing} + onRefresh={onRefresh} onEndReached={onEndReached} renderItem={renderItem} initialNumToRender={15} diff --git a/src/view/com/profile/ProfileFollows.tsx b/src/view/com/profile/ProfileFollows.tsx index 5265ee07e..091922dd9 100644 --- a/src/view/com/profile/ProfileFollows.tsx +++ b/src/view/com/profile/ProfileFollows.tsx @@ -1,18 +1,16 @@ import React from 'react' -import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native' +import {ActivityIndicator, StyleSheet, View} from 'react-native' import {AppBskyActorDefs as ActorDefs} from '@atproto/api' import {CenteredView} from '../util/Views' import {List} from '../util/List' import {ErrorMessage} from '../util/error/ErrorMessage' import {ProfileCardWithFollowBtn} from './ProfileCard' -import {usePalette} from 'lib/hooks/usePalette' import {useProfileFollowsQuery} from '#/state/queries/profile-follows' import {useResolveDidQuery} from '#/state/queries/resolve-uri' import {logger} from '#/logger' import {cleanError} from '#/lib/strings/errors' export function ProfileFollows({name}: {name: string}) { - const pal = usePalette('default') const [isPTRing, setIsPTRing] = React.useState(false) const { data: resolvedDid, @@ -90,14 +88,8 @@ export function ProfileFollows({name}: {name: string}) { <List data={follows} keyExtractor={item => item.did} - refreshControl={ - <RefreshControl - refreshing={isPTRing} - onRefresh={onRefresh} - tintColor={pal.colors.text} - titleColor={pal.colors.text} - /> - } + refreshing={isPTRing} + onRefresh={onRefresh} onEndReached={onEndReached} renderItem={renderItem} initialNumToRender={15} diff --git a/src/view/com/util/Html.tsx b/src/view/com/util/Html.tsx index 1590955a2..2e4719481 100644 --- a/src/view/com/util/Html.tsx +++ b/src/view/com/util/Html.tsx @@ -30,6 +30,7 @@ export function H1({children}: React.PropsWithChildren<{}>) { const styles = useStyles() const pal = usePalette('default') const typography = useTheme().typography['title-xl'] + // @ts-ignore Expo's TextStyle definition seems to have gotten away from RN's -prf return <ExpoH1 style={[typography, pal.text, styles.h1]}>{children}</ExpoH1> } @@ -37,6 +38,7 @@ export function H2({children}: React.PropsWithChildren<{}>) { const styles = useStyles() const pal = usePalette('default') const typography = useTheme().typography['title-lg'] + // @ts-ignore Expo's TextStyle definition seems to have gotten away from RN's -prf return <ExpoH2 style={[typography, pal.text, styles.h2]}>{children}</ExpoH2> } @@ -44,6 +46,7 @@ export function H3({children}: React.PropsWithChildren<{}>) { const styles = useStyles() const pal = usePalette('default') const typography = useTheme().typography.title + // @ts-ignore Expo's TextStyle definition seems to have gotten away from RN's -prf return <ExpoH3 style={[typography, pal.text, styles.h3]}>{children}</ExpoH3> } @@ -51,6 +54,7 @@ export function H4({children}: React.PropsWithChildren<{}>) { const styles = useStyles() const pal = usePalette('default') const typography = useTheme().typography['title-sm'] + // @ts-ignore Expo's TextStyle definition seems to have gotten away from RN's -prf return <ExpoH4 style={[typography, pal.text, styles.h4]}>{children}</ExpoH4> } diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx index 2acc3f4b3..9abd7d35a 100644 --- a/src/view/com/util/List.tsx +++ b/src/view/com/util/List.tsx @@ -1,27 +1,42 @@ import React, {memo, startTransition} from 'react' -import {FlatListProps} from 'react-native' +import {FlatListProps, RefreshControl} from 'react-native' import {FlatList_INTERNAL} from './Views' +import {addStyle} from 'lib/styles' import {useScrollHandlers} from '#/lib/ScrollContext' import {runOnJS, useSharedValue} from 'react-native-reanimated' import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' +import {usePalette} from '#/lib/hooks/usePalette' export type ListMethods = FlatList_INTERNAL export type ListProps<ItemT> = Omit< FlatListProps<ItemT>, - 'onScroll' // Use ScrollContext instead. + | 'onScroll' // Use ScrollContext instead. + | 'refreshControl' // Pass refreshing and/or onRefresh instead. + | 'contentOffset' // Pass headerOffset instead. > & { onScrolledDownChange?: (isScrolledDown: boolean) => void + headerOffset?: number + refreshing?: boolean + onRefresh?: () => void } export type ListRef = React.MutableRefObject<FlatList_INTERNAL | null> const SCROLLED_DOWN_LIMIT = 200 function ListImpl<ItemT>( - {onScrolledDownChange, ...props}: ListProps<ItemT>, + { + onScrolledDownChange, + refreshing, + onRefresh, + headerOffset, + style, + ...props + }: ListProps<ItemT>, ref: React.Ref<ListMethods>, ) { const isScrolledDown = useSharedValue(false) const contextScrollHandlers = useScrollHandlers() + const pal = usePalette('default') function handleScrolledDownChange(didScrollDown: boolean) { startTransition(() => { @@ -49,12 +64,36 @@ function ListImpl<ItemT>( }, }) + let refreshControl + if (refreshing !== undefined || onRefresh !== undefined) { + refreshControl = ( + <RefreshControl + refreshing={refreshing ?? false} + onRefresh={onRefresh} + tintColor={pal.colors.text} + titleColor={pal.colors.text} + progressViewOffset={headerOffset} + /> + ) + } + + let contentOffset + if (headerOffset != null) { + style = addStyle(style, { + paddingTop: headerOffset, + }) + contentOffset = {x: 0, y: headerOffset * -1} + } + return ( <FlatList_INTERNAL {...props} scrollIndicatorInsets={{right: 1}} + contentOffset={contentOffset} + refreshControl={refreshControl} onScroll={scrollHandler} scrollEventThrottle={1} + style={style} ref={ref} /> ) diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx index eef7094cd..b9c3842b3 100644 --- a/src/view/com/util/PostMeta.tsx +++ b/src/view/com/util/PostMeta.tsx @@ -11,6 +11,7 @@ import {sanitizeHandle} from 'lib/strings/handles' import {isAndroid} from 'platform/detection' import {TimeElapsed} from './TimeElapsed' import {makeProfileLink} from 'lib/routes/links' +import {ModerationUI} from '@atproto/api' interface PostMetaOpts { author: { @@ -23,6 +24,7 @@ interface PostMetaOpts { postHref: string timestamp: string showAvatar?: boolean + avatarModeration?: ModerationUI avatarSize?: number displayNameType?: TypographyVariant displayNameStyle?: StyleProp<TextStyle> @@ -41,7 +43,7 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => { <UserAvatar avatar={opts.author.avatar} size={opts.avatarSize || 16} - // TODO moderation + moderation={opts.avatarModeration} /> </View> )} diff --git a/src/view/com/util/forms/NativeDropdown.web.tsx b/src/view/com/util/forms/NativeDropdown.web.tsx new file mode 100644 index 000000000..9e9888ad8 --- /dev/null +++ b/src/view/com/util/forms/NativeDropdown.web.tsx @@ -0,0 +1,241 @@ +import React from 'react' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import * as DropdownMenu from '@radix-ui/react-dropdown-menu' +import {Pressable, StyleSheet, View, Text} from 'react-native' +import {IconProp} from '@fortawesome/fontawesome-svg-core' +import {MenuItemCommonProps} from 'zeego/lib/typescript/menu' +import {usePalette} from 'lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' +import {HITSLOP_10} from 'lib/constants' + +// Custom Dropdown Menu Components +// == +export const DropdownMenuRoot = DropdownMenu.Root +export const DropdownMenuContent = DropdownMenu.Content + +type ItemProps = React.ComponentProps<(typeof DropdownMenu)['Item']> +export const DropdownMenuItem = (props: ItemProps & {testID?: string}) => { + const theme = useTheme() + const [focused, setFocused] = React.useState(false) + const backgroundColor = theme.colorScheme === 'dark' ? '#fff1' : '#0001' + + return ( + <DropdownMenu.Item + {...props} + style={StyleSheet.flatten([ + styles.item, + focused && {backgroundColor: backgroundColor}, + ])} + onFocus={() => { + setFocused(true) + }} + onBlur={() => { + setFocused(false) + }} + /> + ) +} + +// Types for Dropdown Menu and Items +export type DropdownItem = { + label: string | 'separator' + onPress?: () => void + testID?: string + icon?: { + ios: MenuItemCommonProps['ios'] + android: string + web: IconProp + } +} +type Props = { + items: DropdownItem[] + testID?: string + accessibilityLabel?: string + accessibilityHint?: string +} + +export function NativeDropdown({ + items, + children, + testID, + accessibilityLabel, + accessibilityHint, +}: React.PropsWithChildren<Props>) { + const pal = usePalette('default') + const theme = useTheme() + const dropDownBackgroundColor = + theme.colorScheme === 'dark' ? pal.btn : pal.view + const [open, setOpen] = React.useState(false) + const buttonRef = React.useRef<HTMLButtonElement>(null) + const menuRef = React.useRef<HTMLDivElement>(null) + const {borderColor: separatorColor} = + theme.colorScheme === 'dark' ? pal.borderDark : pal.border + + React.useEffect(() => { + function clickHandler(e: MouseEvent) { + const t = e.target + + if (!open) return + if (!t) return + if (!buttonRef.current || !menuRef.current) return + + if ( + t !== buttonRef.current && + !buttonRef.current.contains(t as Node) && + t !== menuRef.current && + !menuRef.current.contains(t as Node) + ) { + // prevent clicking through to links beneath dropdown + // only applies to mobile web + e.preventDefault() + e.stopPropagation() + + // close menu + setOpen(false) + } + } + + function keydownHandler(e: KeyboardEvent) { + if (e.key === 'Escape' && open) { + setOpen(false) + } + } + + document.addEventListener('click', clickHandler, true) + window.addEventListener('keydown', keydownHandler, true) + return () => { + document.removeEventListener('click', clickHandler, true) + window.removeEventListener('keydown', keydownHandler, true) + } + }, [open, setOpen]) + + return ( + <DropdownMenuRoot open={open} onOpenChange={o => setOpen(o)}> + <DropdownMenu.Trigger asChild onPointerDown={e => e.preventDefault()}> + <Pressable + ref={buttonRef as unknown as React.Ref<View>} + testID={testID} + accessibilityRole="button" + accessibilityLabel={accessibilityLabel} + accessibilityHint={accessibilityHint} + onPress={() => setOpen(o => !o)} + hitSlop={HITSLOP_10}> + {children} + </Pressable> + </DropdownMenu.Trigger> + + <DropdownMenu.Portal> + <DropdownMenu.Content + ref={menuRef} + style={ + StyleSheet.flatten([ + styles.content, + dropDownBackgroundColor, + ]) as React.CSSProperties + } + loop> + {items.map((item, index) => { + if (item.label === 'separator') { + return ( + <DropdownMenu.Separator + key={getKey(item.label, index, item.testID)} + style={ + StyleSheet.flatten([ + styles.separator, + {backgroundColor: separatorColor}, + ]) as React.CSSProperties + } + /> + ) + } + if (index > 1 && items[index - 1].label === 'separator') { + return ( + <DropdownMenu.Group + key={getKey(item.label, index, item.testID)}> + <DropdownMenuItem + key={getKey(item.label, index, item.testID)} + onSelect={item.onPress}> + <Text + selectable={false} + style={[pal.text, styles.itemTitle]}> + {item.label} + </Text> + {item.icon && ( + <FontAwesomeIcon + icon={item.icon.web} + size={20} + color={pal.colors.textLight} + /> + )} + </DropdownMenuItem> + </DropdownMenu.Group> + ) + } + return ( + <DropdownMenuItem + key={getKey(item.label, index, item.testID)} + onSelect={item.onPress}> + <Text selectable={false} style={[pal.text, styles.itemTitle]}> + {item.label} + </Text> + {item.icon && ( + <FontAwesomeIcon + icon={item.icon.web} + size={20} + color={pal.colors.textLight} + /> + )} + </DropdownMenuItem> + ) + })} + </DropdownMenu.Content> + </DropdownMenu.Portal> + </DropdownMenuRoot> + ) +} + +const getKey = (label: string, index: number, id?: string) => { + if (id) { + return id + } + return `${label}_${index}` +} + +const styles = StyleSheet.create({ + separator: { + height: 1, + marginTop: 4, + marginBottom: 4, + }, + content: { + backgroundColor: '#f0f0f0', + borderRadius: 8, + paddingTop: 4, + paddingBottom: 4, + paddingLeft: 4, + paddingRight: 4, + marginTop: 6, + + // @ts-ignore web only -prf + boxShadow: 'rgba(0, 0, 0, 0.3) 0px 5px 20px', + }, + item: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + columnGap: 20, + // @ts-ignore -web + cursor: 'pointer', + paddingTop: 8, + paddingBottom: 8, + paddingLeft: 12, + paddingRight: 12, + borderRadius: 8, + }, + itemTitle: { + fontSize: 16, + fontWeight: '500', + paddingRight: 10, + }, +}) diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx index 193bb9bd7..1f2e067c2 100644 --- a/src/view/com/util/forms/PostDropdownBtn.tsx +++ b/src/view/com/util/forms/PostDropdownBtn.tsx @@ -18,6 +18,7 @@ import {getTranslatorLink} from '#/locale/helpers' import {usePostDeleteMutation} from '#/state/queries/post' import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads' import {useLanguagePrefs} from '#/state/preferences' +import {useHiddenPosts, useHiddenPostsApi} from '#/state/preferences' import {logger} from '#/logger' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -50,9 +51,12 @@ let PostDropdownBtn = ({ const mutedThreads = useMutedThreads() const toggleThreadMute = useToggleThreadMute() const postDeleteMutation = usePostDeleteMutation() + const hiddenPosts = useHiddenPosts() + const {hidePost} = useHiddenPostsApi() const rootUri = record.reply?.root?.uri || postUri const isThreadMuted = mutedThreads.includes(rootUri) + const isPostHidden = hiddenPosts && hiddenPosts.includes(postUri) const isAuthor = postAuthor.did === currentAccount?.did const href = React.useMemo(() => { const urip = new AtUri(postUri) @@ -98,6 +102,10 @@ let PostDropdownBtn = ({ Linking.openURL(translatorUrl) }, [translatorUrl]) + const onHidePost = React.useCallback(() => { + hidePost({uri: postUri}) + }, [postUri, hidePost]) + const dropdownItems: NativeDropdownItem[] = [ { label: _(msg`Translate`), @@ -159,6 +167,27 @@ let PostDropdownBtn = ({ web: 'comment-slash', }, }, + hasSession && + !isAuthor && + !isPostHidden && { + label: _(msg`Hide post`), + onPress() { + openModal({ + name: 'confirm', + title: _(msg`Hide this post?`), + message: _(msg`This will hide this post from your feeds.`), + onPressConfirm: onHidePost, + }) + }, + testID: 'postDropdownHideBtn', + icon: { + ios: { + name: 'eye.slash', + }, + android: 'ic_menu_delete', + web: ['far', 'eye-slash'], + }, + }, { label: 'separator', }, diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx index 414fb1e09..a50b52175 100644 --- a/src/view/com/util/post-ctrls/PostCtrls.tsx +++ b/src/view/com/util/post-ctrls/PostCtrls.tsx @@ -31,12 +31,14 @@ let PostCtrls = ({ big, post, record, + showAppealLabelItem, style, onPressReply, }: { big?: boolean post: Shadow<AppBskyFeedDefs.PostView> record: AppBskyFeedPost.Record + showAppealLabelItem?: boolean style?: StyleProp<ViewStyle> onPressReply: () => void }): React.ReactNode => { @@ -207,6 +209,7 @@ let PostCtrls = ({ postCid={post.cid} postUri={post.uri} record={record} + showAppealLabelItem={showAppealLabelItem} style={styles.ctrlPad} /> )} diff --git a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx index d5bb38fb2..27aa804d3 100644 --- a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx +++ b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx @@ -6,22 +6,28 @@ 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 {parseEmbedPlayerFromUrl} from 'lib/strings/embed-player' +import {ExternalPlayer} from 'view/com/util/post-embeds/ExternalPlayerEmbed' export const ExternalLinkEmbed = ({ link, - imageChild, }: { link: AppBskyEmbedExternal.ViewExternal - imageChild?: React.ReactNode }) => { const pal = usePalette('default') const {isMobile} = useWebMediaQueries() + + const embedPlayerParams = React.useMemo( + () => parseEmbedPlayerFromUrl(link.uri), + [link.uri], + ) + return ( <View style={{ - flexDirection: isMobile ? 'column' : 'row', + flexDirection: !isMobile && !embedPlayerParams ? 'row' : 'column', }}> - {link.thumb ? ( + {link.thumb && !embedPlayerParams ? ( <View style={ !isMobile @@ -45,9 +51,11 @@ export const ExternalLinkEmbed = ({ source={{uri: link.thumb}} accessibilityIgnoresInvertColors /> - {imageChild} </View> ) : undefined} + {embedPlayerParams && ( + <ExternalPlayer link={link} params={embedPlayerParams} /> + )} <View style={{ paddingHorizontal: isMobile ? 10 : 14, diff --git a/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx b/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx new file mode 100644 index 000000000..580cf363a --- /dev/null +++ b/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx @@ -0,0 +1,251 @@ +import React from 'react' +import { + ActivityIndicator, + Dimensions, + GestureResponderEvent, + Pressable, + StyleSheet, + View, +} from 'react-native' +import {Image} from 'expo-image' +import {WebView} from 'react-native-webview' +import YoutubePlayer from 'react-native-youtube-iframe' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {EmbedPlayerParams, getPlayerHeight} from 'lib/strings/embed-player' +import {EventStopper} from '../EventStopper' +import {AppBskyEmbedExternal} from '@atproto/api' +import {isNative} from 'platform/detection' +import {useNavigation} from '@react-navigation/native' +import {NavigationProp} from 'lib/routes/types' + +interface ShouldStartLoadRequest { + url: string +} + +// This renders the overlay when the player is either inactive or loading as a separate layer +function PlaceholderOverlay({ + isLoading, + isPlayerActive, + onPress, +}: { + isLoading: boolean + isPlayerActive: boolean + onPress: (event: GestureResponderEvent) => void +}) { + // If the player is active and not loading, we don't want to show the overlay. + if (isPlayerActive && !isLoading) return null + + return ( + <View style={[styles.layer, styles.overlayLayer]}> + <Pressable + accessibilityRole="button" + accessibilityLabel="Play Video" + accessibilityHint="" + onPress={onPress} + style={[styles.overlayContainer, styles.topRadius]}> + {!isPlayerActive ? ( + <FontAwesomeIcon icon="play" size={42} color="white" /> + ) : ( + <ActivityIndicator size="large" color="white" /> + )} + </Pressable> + </View> + ) +} + +// This renders the webview/youtube player as a separate layer +function Player({ + height, + params, + onLoad, + isPlayerActive, +}: { + isPlayerActive: boolean + params: EmbedPlayerParams + height: number + onLoad: () => void +}) { + // ensures we only load what's requested + const onShouldStartLoadWithRequest = React.useCallback( + (event: ShouldStartLoadRequest) => event.url === params.playerUri, + [params.playerUri], + ) + + // Don't show the player until it is active + if (!isPlayerActive) return null + + return ( + <View style={[styles.layer, styles.playerLayer]}> + <EventStopper> + {isNative && params.type === 'youtube_video' ? ( + <YoutubePlayer + videoId={params.videoId} + play + height={height} + onReady={onLoad} + webViewStyle={[styles.webview, styles.topRadius]} + /> + ) : ( + <View style={{height, width: '100%'}}> + <WebView + javaScriptEnabled={true} + onShouldStartLoadWithRequest={onShouldStartLoadWithRequest} + mediaPlaybackRequiresUserAction={false} + allowsInlineMediaPlayback + bounces={false} + allowsFullscreenVideo + nestedScrollEnabled + source={{uri: params.playerUri}} + onLoad={onLoad} + setSupportMultipleWindows={false} // Prevent any redirects from opening a new window (ads) + style={[styles.webview, styles.topRadius]} + /> + </View> + )} + </EventStopper> + </View> + ) +} + +// This renders the player area and handles the logic for when to show the player and when to show the overlay +export function ExternalPlayer({ + link, + params, +}: { + link: AppBskyEmbedExternal.ViewExternal + params: EmbedPlayerParams +}) { + const navigation = useNavigation<NavigationProp>() + + const [isPlayerActive, setPlayerActive] = React.useState(false) + const [isLoading, setIsLoading] = React.useState(true) + const [dim, setDim] = React.useState({ + width: 0, + height: 0, + }) + + const viewRef = React.useRef<View>(null) + + // watch for leaving the viewport due to scrolling + React.useEffect(() => { + // Interval for scrolling works in most cases, However, for twitch embeds, if we navigate away from the screen the webview will + // continue playing. We need to watch for the blur event + const unsubscribe = navigation.addListener('blur', () => { + setPlayerActive(false) + }) + + const interval = setInterval(() => { + viewRef.current?.measure((x, y, w, h, pageX, pageY) => { + const window = Dimensions.get('window') + const top = pageY + const bot = pageY + h + const isVisible = isNative + ? top >= 0 && bot <= window.height + : !(top >= window.height || bot <= 0) + if (!isVisible) { + setPlayerActive(false) + } + }) + }, 1e3) + return () => { + unsubscribe() + clearInterval(interval) + } + }, [viewRef, navigation]) + + // calculate height for the player and the screen size + const height = React.useMemo( + () => + getPlayerHeight({ + type: params.type, + width: dim.width, + hasThumb: !!link.thumb, + }), + [params.type, dim.width, link.thumb], + ) + + const onLoad = React.useCallback(() => { + setIsLoading(false) + }, []) + + const onPlayPress = React.useCallback((event: GestureResponderEvent) => { + // Prevent this from propagating upward on web + event.preventDefault() + + setPlayerActive(true) + }, []) + + // measure the layout to set sizing + const onLayout = React.useCallback( + (event: {nativeEvent: {layout: {width: any; height: any}}}) => { + setDim({ + width: event.nativeEvent.layout.width, + height: event.nativeEvent.layout.height, + }) + }, + [], + ) + + return ( + <View + ref={viewRef} + style={{height}} + collapsable={false} + onLayout={onLayout}> + {link.thumb && (!isPlayerActive || isLoading) && ( + <Image + style={[ + { + width: dim.width, + height, + }, + styles.topRadius, + ]} + source={{uri: link.thumb}} + accessibilityIgnoresInvertColors + /> + )} + + <PlaceholderOverlay + isLoading={isLoading} + isPlayerActive={isPlayerActive} + onPress={onPlayPress} + /> + <Player + isPlayerActive={isPlayerActive} + params={params} + height={height} + onLoad={onLoad} + /> + </View> + ) +} + +const styles = StyleSheet.create({ + topRadius: { + borderTopLeftRadius: 6, + borderTopRightRadius: 6, + }, + layer: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + }, + overlayContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'rgba(0,0,0,0.5)', + }, + overlayLayer: { + zIndex: 2, + }, + playerLayer: { + zIndex: 3, + }, + webview: { + backgroundColor: 'transparent', + }, +}) diff --git a/src/view/com/util/post-embeds/YoutubeEmbed.tsx b/src/view/com/util/post-embeds/YoutubeEmbed.tsx deleted file mode 100644 index 2f2da5662..000000000 --- a/src/view/com/util/post-embeds/YoutubeEmbed.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import React from 'react' -import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' -import {usePalette} from 'lib/hooks/usePalette' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {ExternalLinkEmbed} from './ExternalLinkEmbed' -import {AppBskyEmbedExternal} from '@atproto/api' -import {Link} from '../Link' - -export const YoutubeEmbed = ({ - link, - style, -}: { - link: AppBskyEmbedExternal.ViewExternal - style?: StyleProp<ViewStyle> -}) => { - const pal = usePalette('default') - - const imageChild = ( - <View style={styles.playButton}> - <FontAwesomeIcon icon="play" size={24} color="white" /> - </View> - ) - - return ( - <Link - asAnchor - style={[styles.extOuter, pal.view, pal.border, style]} - href={link.uri}> - <ExternalLinkEmbed link={link} imageChild={imageChild} /> - </Link> - ) -} - -const styles = StyleSheet.create({ - extOuter: { - borderWidth: 1, - borderRadius: 8, - }, - playButton: { - position: 'absolute', - alignSelf: 'center', - alignItems: 'center', - top: '44%', - justifyContent: 'center', - backgroundColor: 'black', - padding: 10, - borderRadius: 50, - opacity: 0.8, - }, - webView: { - alignItems: 'center', - alignContent: 'center', - justifyContent: 'center', - }, -}) diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx index 2814cad87..c94ce9684 100644 --- a/src/view/com/util/post-embeds/index.tsx +++ b/src/view/com/util/post-embeds/index.tsx @@ -23,9 +23,7 @@ import {ImageLayoutGrid} from '../images/ImageLayoutGrid' import {useLightboxControls, ImagesLightbox} from '#/state/lightbox' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {YoutubeEmbed} from './YoutubeEmbed' import {ExternalLinkEmbed} from './ExternalLinkEmbed' -import {getYoutubeVideoId} from 'lib/strings/url-helpers' import {MaybeQuoteEmbed} from './QuoteEmbed' import {AutoSizedImage} from '../images/AutoSizedImage' import {ListEmbed} from './ListEmbed' @@ -168,19 +166,13 @@ export function PostEmbeds({ // = if (AppBskyEmbedExternal.isView(embed)) { const link = embed.external - const youtubeVideoId = getYoutubeVideoId(link.uri) - - if (youtubeVideoId) { - return <YoutubeEmbed link={link} style={style} /> - } return ( - <Link - asAnchor - style={[styles.extOuter, pal.view, pal.border, style]} - href={link.uri}> - <ExternalLinkEmbed link={link} /> - </Link> + <View style={[styles.extOuter, pal.view, pal.border, style]}> + <Link asAnchor href={link.uri}> + <ExternalLinkEmbed link={link} /> + </Link> + </View> ) } |