diff options
Diffstat (limited to 'src')
58 files changed, 2156 insertions, 1118 deletions
diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 97c351bc8..7af38105b 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -55,7 +55,6 @@ import {DebugModScreen} from '#/view/screens/DebugMod' import {FeedsScreen} from '#/view/screens/Feeds' import {HomeScreen} from '#/view/screens/Home' import {ListsScreen} from '#/view/screens/Lists' -import {LogScreen} from '#/view/screens/Log' import {ModerationBlockedAccounts} from '#/view/screens/ModerationBlockedAccounts' import {ModerationModlistsScreen} from '#/view/screens/ModerationModlists' import {ModerationMutedAccounts} from '#/view/screens/ModerationMutedAccounts' @@ -74,6 +73,7 @@ import {BottomBar} from '#/view/shell/bottom-bar/BottomBar' import {createNativeStackNavigatorWithAuth} from '#/view/shell/createNativeStackNavigatorWithAuth' import {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTesterScreen' import HashtagScreen from '#/screens/Hashtag' +import {LogScreen} from '#/screens/Log' import {MessagesScreen} from '#/screens/Messages/ChatList' import {MessagesConversationScreen} from '#/screens/Messages/Conversation' import {MessagesInboxScreen} from '#/screens/Messages/Inbox' diff --git a/src/components/BlockedGeoOverlay.tsx b/src/components/BlockedGeoOverlay.tsx new file mode 100644 index 000000000..ae5790da9 --- /dev/null +++ b/src/components/BlockedGeoOverlay.tsx @@ -0,0 +1,109 @@ +import {useEffect} from 'react' +import {ScrollView, View} from 'react-native' +import {useSafeAreaInsets} from 'react-native-safe-area-context' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {logger} from '#/logger' +import {isWeb} from '#/platform/detection' +import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' +import {Full as Logo, Mark} from '#/components/icons/Logo' +import {SimpleInlineLinkText as InlineLinkText} from '#/components/Link' +import {Text} from '#/components/Typography' + +export function BlockedGeoOverlay() { + const t = useTheme() + const {_} = useLingui() + const {gtPhone} = useBreakpoints() + const insets = useSafeAreaInsets() + + useEffect(() => { + // just counting overall hits here + logger.metric(`blockedGeoOverlay:shown`, {}) + }, []) + + const textStyles = [a.text_md, a.leading_normal] + const links = { + blog: { + to: `https://bsky.social/about/blog/08-22-2025-mississippi-hb1126`, + label: _(msg`Read our blog post`), + overridePresentation: false, + disableMismatchWarning: true, + style: textStyles, + }, + } + + const blocks = [ + _(msg`Unfortunately, Bluesky is unavailable in Mississippi right now.`), + _( + msg`A new Mississippi law requires us to implement age verification for all users before they can access Bluesky. We think this law creates challenges that go beyond its child safety goals, and creates significant barriers that limit free speech and disproportionately harm smaller platforms and emerging technologies.`, + ), + _( + msg`As a small team, we cannot justify building the expensive infrastructure this requirement demands while legal challenges to this law are pending.`, + ), + _( + msg`For now, we have made the difficult decision to block access to Bluesky in the state of Mississippi.`, + ), + <> + To learn more, read our{' '} + <InlineLinkText {...links.blog}>blog post</InlineLinkText>. + </>, + ] + + return ( + <ScrollView + contentContainerStyle={[ + a.px_2xl, + { + paddingTop: isWeb ? a.p_5xl.padding : insets.top + a.p_2xl.padding, + paddingBottom: 100, + }, + ]}> + <View + style={[ + a.mx_auto, + web({ + maxWidth: 440, + paddingTop: gtPhone ? '8vh' : undefined, + }), + ]}> + <View style={[a.align_start]}> + <View + style={[ + a.pl_md, + a.pr_lg, + a.py_sm, + a.rounded_full, + a.flex_row, + a.align_center, + a.gap_xs, + { + backgroundColor: t.palette.primary_25, + }, + ]}> + <Mark fill={t.palette.primary_600} width={14} /> + <Text + style={[ + a.font_bold, + { + color: t.palette.primary_600, + }, + ]}> + <Trans>Announcement</Trans> + </Text> + </View> + </View> + + <View style={[a.gap_lg, {paddingTop: 32, paddingBottom: 48}]}> + {blocks.map((block, index) => ( + <Text key={index} style={[textStyles]}> + {block} + </Text> + ))} + </View> + + <Logo width={120} textFill={t.atoms.text.color} /> + </View> + </ScrollView> + ) +} diff --git a/src/components/Dialog/index.web.tsx b/src/components/Dialog/index.web.tsx index 12bd8819b..1417e9e91 100644 --- a/src/components/Dialog/index.web.tsx +++ b/src/components/Dialog/index.web.tsx @@ -193,7 +193,7 @@ export function Inner({ onInteractOutside={preventDefault} onFocusOutside={preventDefault} onDismiss={close} - style={{display: 'flex', flexDirection: 'column'}}> + style={{height: '100%', display: 'flex', flexDirection: 'column'}}> {header} <View style={[gtMobile ? a.p_2xl : a.p_xl, contentContainerStyle]}> {children} @@ -227,10 +227,10 @@ export const InnerFlatList = React.forwardRef< web({maxHeight: '80vh'}), webInnerStyle, ]} - contentContainerStyle={[a.px_0, webInnerContentContainerStyle]}> + contentContainerStyle={[a.h_full, a.px_0, webInnerContentContainerStyle]}> <FlatList ref={ref} - style={[gtMobile ? a.px_2xl : a.px_xl, flatten(style)]} + style={[a.h_full, gtMobile ? a.px_2xl : a.px_xl, flatten(style)]} {...props} /> </Inner> diff --git a/src/components/Dialog/shared.tsx b/src/components/Dialog/shared.tsx index 40d040878..b5513b19c 100644 --- a/src/components/Dialog/shared.tsx +++ b/src/components/Dialog/shared.tsx @@ -5,7 +5,6 @@ import { View, type ViewStyle, } from 'react-native' -import type React from 'react' import {atoms as a, useTheme} from '#/alf' import {Text} from '#/components/Typography' @@ -28,6 +27,8 @@ export function Header({ <View onLayout={onLayout} style={[ + a.sticky, + a.top_0, a.relative, a.w_full, a.py_sm, @@ -61,7 +62,7 @@ export function HeaderText({ style?: StyleProp<TextStyle> }) { return ( - <Text style={[a.text_lg, a.text_center, a.font_bold, style]}> + <Text style={[a.text_lg, a.text_center, a.font_heavy, style]}> {children} </Text> ) diff --git a/src/components/FeedInterstitials.tsx b/src/components/FeedInterstitials.tsx index 18da12b22..7debbf5e1 100644 --- a/src/components/FeedInterstitials.tsx +++ b/src/components/FeedInterstitials.tsx @@ -1,6 +1,5 @@ import React from 'react' -import {View} from 'react-native' -import {ScrollView} from 'react-native-gesture-handler' +import {ScrollView, View} from 'react-native' import {type AppBskyFeedDefs, AtUri} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -9,6 +8,7 @@ import {useNavigation} from '@react-navigation/native' import {type NavigationProp} from '#/lib/routes/types' import {logEvent} from '#/lib/statsig/statsig' import {logger} from '#/logger' +import {isIOS} from '#/platform/detection' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useGetPopularFeedsQuery} from '#/state/queries/feed' import {type FeedDescriptor} from '#/state/queries/post-feed' @@ -25,9 +25,9 @@ import { type ViewStyleProp, web, } from '#/alf' -import {Button, ButtonText} from '#/components/Button' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' import * as FeedCard from '#/components/FeedCard' -import {ArrowRight_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow' +import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRight} from '#/components/icons/Arrow' import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag' import {InlineLinkText} from '#/components/Link' import * as ProfileCard from '#/components/ProfileCard' @@ -36,6 +36,7 @@ import type * as bsky from '#/types/bsky' import {ProgressGuideList} from './ProgressGuide/List' const MOBILE_CARD_WIDTH = 165 +const FINAL_CARD_WIDTH = 120 function CardOuter({ children, @@ -46,11 +47,13 @@ function CardOuter({ return ( <View style={[ + a.flex_1, a.w_full, a.p_md, a.rounded_lg, a.border, t.atoms.bg, + t.atoms.shadow_sm, t.atoms.border_contrast_low, !gtMobile && { width: MOBILE_CARD_WIDTH, @@ -63,11 +66,8 @@ function CardOuter({ } export function SuggestedFollowPlaceholder() { - const t = useTheme() - return ( - <CardOuter - style={[a.gap_md, t.atoms.border_contrast_low, t.atoms.shadow_sm]}> + <CardOuter> <ProfileCard.Outer> <View style={[a.flex_col, a.align_center, a.gap_sm, a.pb_sm, a.mb_auto]}> @@ -78,24 +78,15 @@ export function SuggestedFollowPlaceholder() { </View> </View> - <Button - label="" - size="small" - variant="solid" - color="secondary" - disabled - style={[a.w_full, a.rounded_sm]}> - <ButtonText>Follow</ButtonText> - </Button> + <ProfileCard.FollowButtonPlaceholder /> </ProfileCard.Outer> </CardOuter> ) } export function SuggestedFeedsCardPlaceholder() { - const t = useTheme() return ( - <CardOuter style={[a.gap_sm, t.atoms.border_contrast_low]}> + <CardOuter style={[a.gap_sm]}> <FeedCard.Header> <FeedCard.AvatarPlaceholder /> <FeedCard.TitleAndBylinePlaceholder creator /> @@ -253,129 +244,133 @@ export function ProfileGrid({ profiles: bsky.profile.AnyProfileView[] recId?: number error: Error | null - viewContext: 'profile' | 'feed' + viewContext: 'profile' | 'profileHeader' | 'feed' }) { const t = useTheme() const {_} = useLingui() const moderationOpts = useModerationOpts() const {gtMobile} = useBreakpoints() + const isLoading = isSuggestionsLoading || !moderationOpts - const maxLength = gtMobile ? 3 : 6 + const isProfileHeaderContext = viewContext === 'profileHeader' + const isFeedContext = viewContext === 'feed' - const content = isLoading ? ( - Array(maxLength) - .fill(0) - .map((_, i) => ( - <View - key={i} - style={[ - gtMobile && - web([ - a.flex_0, - a.flex_grow, - {width: `calc(30% - ${a.gap_md.gap / 2}px)`}, - ]), - ]}> - <SuggestedFollowPlaceholder /> - </View> - )) - ) : error || !profiles.length ? null : ( - <> - {profiles.slice(0, maxLength).map((profile, index) => ( - <ProfileCard.Link - key={profile.did} - profile={profile} - onPress={() => { - logEvent('suggestedUser:press', { - logContext: - viewContext === 'feed' + const maxLength = gtMobile ? 3 : isProfileHeaderContext ? 12 : 6 + const minLength = gtMobile ? 3 : 4 + + const content = isLoading + ? Array(maxLength) + .fill(0) + .map((_, i) => ( + <View + key={i} + style={[ + a.flex_1, + gtMobile && + web([ + a.flex_0, + a.flex_grow, + {width: `calc(30% - ${a.gap_md.gap / 2}px)`}, + ]), + ]}> + <SuggestedFollowPlaceholder /> + </View> + )) + : error || !profiles.length + ? null + : profiles.slice(0, maxLength).map((profile, index) => ( + <ProfileCard.Link + key={profile.did} + profile={profile} + onPress={() => { + logEvent('suggestedUser:press', { + logContext: isFeedContext ? 'InterstitialDiscover' : 'InterstitialProfile', - recId, - position: index, - }) - }} - style={[ - a.flex_1, - gtMobile && - web([ - a.flex_0, - a.flex_grow, - {width: `calc(30% - ${a.gap_md.gap / 2}px)`}, - ]), - ]}> - {({hovered, pressed}) => ( - <CardOuter - style={[ - a.flex_1, - t.atoms.shadow_sm, - (hovered || pressed) && t.atoms.border_contrast_high, - ]}> - <ProfileCard.Outer> - <View - style={[ - a.flex_col, - a.align_center, - a.gap_sm, - a.pb_sm, - a.mb_auto, - ]}> - <ProfileCard.Avatar - profile={profile} - moderationOpts={moderationOpts} - size={88} - /> - <View style={[a.flex_col, a.align_center, a.max_w_full]}> - <ProfileCard.Name + recId, + position: index, + }) + }} + style={[ + a.flex_1, + gtMobile && + web([ + a.flex_0, + a.flex_grow, + {width: `calc(30% - ${a.gap_md.gap / 2}px)`}, + ]), + ]}> + {({hovered, pressed}) => ( + <CardOuter + style={[(hovered || pressed) && t.atoms.border_contrast_high]}> + <ProfileCard.Outer> + <View + style={[ + a.flex_col, + a.align_center, + a.gap_sm, + a.pb_sm, + a.mb_auto, + ]}> + <ProfileCard.Avatar profile={profile} moderationOpts={moderationOpts} + disabledPreview + size={88} /> - <ProfileCard.Description - profile={profile} - numberOfLines={2} - style={[ - t.atoms.text_contrast_medium, - a.text_center, - a.text_xs, - ]} - /> + <View style={[a.flex_col, a.align_center, a.max_w_full]}> + <ProfileCard.Name + profile={profile} + moderationOpts={moderationOpts} + /> + <ProfileCard.Description + profile={profile} + numberOfLines={2} + style={[ + t.atoms.text_contrast_medium, + a.text_center, + a.text_xs, + ]} + /> + </View> </View> - </View> - - <ProfileCard.FollowButton - profile={profile} - moderationOpts={moderationOpts} - logContext="FeedInterstitial" - withIcon={false} - style={[a.rounded_sm]} - onFollow={() => { - logEvent('suggestedUser:follow', { - logContext: - viewContext === 'feed' + + <ProfileCard.FollowButton + profile={profile} + moderationOpts={moderationOpts} + logContext="FeedInterstitial" + withIcon={false} + style={[a.rounded_sm]} + onFollow={() => { + logEvent('suggestedUser:follow', { + logContext: isFeedContext ? 'InterstitialDiscover' : 'InterstitialProfile', - location: 'Card', - recId, - position: index, - }) - }} - /> - </ProfileCard.Outer> - </CardOuter> - )} - </ProfileCard.Link> - ))} - </> - ) + location: 'Card', + recId, + position: index, + }) + }} + /> + </ProfileCard.Outer> + </CardOuter> + )} + </ProfileCard.Link> + )) - if (error || (!isLoading && profiles.length < 4)) { + if (error || (!isLoading && profiles.length < minLength)) { logger.debug(`Not enough profiles to show suggested follows`) return null } return ( <View - style={[a.border_t, t.atoms.border_contrast_low, t.atoms.bg_contrast_25]}> + style={[ + !isProfileHeaderContext && a.border_t, + t.atoms.border_contrast_low, + t.atoms.bg_contrast_25, + ]} + pointerEvents={isIOS ? 'auto' : 'box-none'}> <View style={[ a.px_lg, @@ -383,19 +378,22 @@ export function ProfileGrid({ a.flex_row, a.align_center, a.justify_between, - ]}> + ]} + pointerEvents={isIOS ? 'auto' : 'box-none'}> <Text style={[a.text_sm, a.font_bold, t.atoms.text]}> - {viewContext === 'profile' ? ( - <Trans>Similar accounts</Trans> - ) : ( + {isFeedContext ? ( <Trans>Suggested for you</Trans> + ) : ( + <Trans>Similar accounts</Trans> )} </Text> - <InlineLinkText - label={_(msg`See more suggested profiles on the Explore page`)} - to="/search"> - <Trans>See more</Trans> - </InlineLinkText> + {!isProfileHeaderContext && ( + <InlineLinkText + label={_(msg`See more suggested profiles on the Explore page`)} + to="/search"> + <Trans>See more</Trans> + </InlineLinkText> + )} </View> {gtMobile ? ( @@ -406,19 +404,16 @@ export function ProfileGrid({ </View> ) : ( <BlockDrawerGesture> - <View> - <ScrollView - horizontal - showsHorizontalScrollIndicator={false} - snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap} - decelerationRate="fast"> - <View style={[a.p_lg, a.pt_md, a.flex_row, a.gap_md]}> - {content} - - <SeeMoreSuggestedProfilesCard /> - </View> - </ScrollView> - </View> + <ScrollView + horizontal + showsHorizontalScrollIndicator={false} + contentContainerStyle={[a.p_lg, a.pt_md, a.flex_row, a.gap_md]} + snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap} + decelerationRate="fast"> + {content} + + {!isProfileHeaderContext && <SeeMoreSuggestedProfilesCard />} + </ScrollView> </BlockDrawerGesture> )} </View> @@ -426,28 +421,29 @@ export function ProfileGrid({ } function SeeMoreSuggestedProfilesCard() { - const navigation = useNavigation<NavigationProp>() const t = useTheme() const {_} = useLingui() + const navigation = useNavigation<NavigationProp>() return ( <Button + color="primary" label={_(msg`Browse more accounts on the Explore page`)} - style={[a.flex_col]} - onPress={() => { - navigation.navigate('SearchTab') - }}> - <CardOuter style={[a.flex_1, t.atoms.shadow_sm]}> - <View style={[a.flex_1, a.justify_center]}> - <View style={[a.flex_col, a.align_center, a.gap_md]}> - <Text style={[a.leading_snug, a.text_center]}> - <Trans>See more accounts you might like</Trans> - </Text> - - <Arrow size="xl" /> - </View> - </View> - </CardOuter> + style={[ + a.flex_col, + a.align_center, + a.gap_xs, + a.p_md, + a.rounded_lg, + t.atoms.shadow_sm, + {width: FINAL_CARD_WIDTH}, + ]} + onPress={() => navigation.navigate('SearchTab')}> + <ButtonIcon icon={ArrowRight} size="lg" /> + <ButtonText + style={[a.text_md, a.font_medium, a.leading_snug, a.text_center]}> + <Trans>See more</Trans> + </ButtonText> </Button> ) } @@ -491,10 +487,7 @@ export function SuggestedFeeds() { }}> {({hovered, pressed}) => ( <CardOuter - style={[ - a.flex_1, - (hovered || pressed) && t.atoms.border_contrast_high, - ]}> + style={[(hovered || pressed) && t.atoms.border_contrast_high]}> <FeedCard.Outer> <FeedCard.Header> <FeedCard.Avatar src={feed.avatar} /> @@ -549,7 +542,7 @@ export function SuggestedFeeds() { style={[t.atoms.text_contrast_medium]}> <Trans>Browse more suggestions</Trans> </InlineLinkText> - <Arrow size="sm" fill={t.atoms.text_contrast_medium.color} /> + <ArrowRight size="sm" fill={t.atoms.text_contrast_medium.color} /> </View> </View> ) : ( @@ -568,7 +561,7 @@ export function SuggestedFeeds() { navigation.navigate('SearchTab') }} style={[a.flex_col]}> - <CardOuter style={[a.flex_1]}> + <CardOuter> <View style={[a.flex_1, a.justify_center]}> <View style={[a.flex_row, a.px_lg]}> <Text style={[a.pr_xl, a.flex_1, a.leading_snug]}> @@ -577,7 +570,7 @@ export function SuggestedFeeds() { </Trans> </Text> - <Arrow size="xl" /> + <ArrowRight size="xl" /> </View> </View> </CardOuter> diff --git a/src/components/Layout/const.ts b/src/components/Layout/const.ts index 2b5d3a1fc..2721bed21 100644 --- a/src/components/Layout/const.ts +++ b/src/components/Layout/const.ts @@ -13,7 +13,7 @@ export const BUTTON_VISUAL_ALIGNMENT_OFFSET = 3 /** * Corresponds to the width of a small square or round button */ -export const HEADER_SLOT_SIZE = 34 +export const HEADER_SLOT_SIZE = 33 /** * How far to shift the center column when in the tablet breakpoint diff --git a/src/components/Link.tsx b/src/components/Link.tsx index 6954be6a8..421a7fe9d 100644 --- a/src/components/Link.tsx +++ b/src/components/Link.tsx @@ -1,5 +1,5 @@ import React, {useMemo} from 'react' -import {type GestureResponderEvent} from 'react-native' +import {type GestureResponderEvent, Linking} from 'react-native' import {sanitizeUrl} from '@braintree/sanitize-url' import { type LinkProps as RNLinkProps, @@ -13,6 +13,7 @@ import {type AllNavigatorParams, type RouteParams} from '#/lib/routes/types' import {shareUrl} from '#/lib/sharing' import { convertBskyAppUrlIfNeeded, + createProxiedUrl, isBskyDownloadUrl, isExternalUrl, linkRequiresWarning, @@ -407,6 +408,91 @@ export function InlineLinkText({ ) } +/** + * A barebones version of `InlineLinkText`, for use outside a + * `react-navigation` context. + */ +export function SimpleInlineLinkText({ + children, + to, + style, + download, + selectable, + label, + disableUnderline, + shouldProxy, + ...rest +}: Omit< + InlineLinkProps, + | 'to' + | 'action' + | 'disableMismatchWarning' + | 'overridePresentation' + | 'onPress' + | 'onLongPress' + | 'shareOnLongPress' +> & { + to: string +}) { + const t = useTheme() + const { + state: hovered, + onIn: onHoverIn, + onOut: onHoverOut, + } = useInteractionState() + const flattenedStyle = flatten(style) || {} + const isExternal = isExternalUrl(to) + + let href = to + if (shouldProxy) { + href = createProxiedUrl(href) + } + + const onPress = () => { + Linking.openURL(href) + } + + return ( + <Text + selectable={selectable} + accessibilityHint="" + accessibilityLabel={label} + {...rest} + style={[ + {color: t.palette.primary_500}, + hovered && + !disableUnderline && { + ...web({ + outline: 0, + textDecorationLine: 'underline', + textDecorationColor: + flattenedStyle.color ?? t.palette.primary_500, + }), + }, + flattenedStyle, + ]} + role="link" + onPress={onPress} + onMouseEnter={onHoverIn} + onMouseLeave={onHoverOut} + accessibilityRole="link" + href={href} + {...web({ + hrefAttrs: { + target: download ? undefined : isExternal ? 'blank' : undefined, + rel: isExternal ? 'noopener noreferrer' : undefined, + download, + }, + dataSet: { + // default to no underline, apply this ourselves + noUnderline: '1', + }, + })}> + {children} + </Text> + ) +} + export function WebOnlyInlineLinkText({ children, to, diff --git a/src/components/MediaPreview.tsx b/src/components/MediaPreview.tsx index 208973cc9..c2603a4d7 100644 --- a/src/components/MediaPreview.tsx +++ b/src/components/MediaPreview.tsx @@ -1,7 +1,6 @@ -import React from 'react' -import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' +import {type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native' import {Image} from 'expo-image' -import {AppBskyFeedDefs} from '@atproto/api' +import {type AppBskyFeedDefs} from '@atproto/api' import {Trans} from '@lingui/macro' import {isTenorGifUri} from '#/lib/strings/embed-player' @@ -92,12 +91,11 @@ export function ImageItem({ <Image key={thumbnail} source={{uri: thumbnail}} + alt={alt} style={[a.flex_1, a.rounded_xs, t.atoms.bg_contrast_25]} contentFit="cover" accessible={true} accessibilityIgnoresInvertColors - accessibilityHint={alt} - accessibilityLabel="" /> <MediaInsetBorder style={[a.rounded_xs]} /> {children} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx index d84a90fa6..e4814462f 100644 --- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx @@ -146,6 +146,8 @@ export function Scrubber({ const progress = scrubberActive ? seekPosition : currentTime const progressPercent = (progress / duration) * 100 + if (duration < 3) return null + return ( <View testID="scrubber" diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx index 676b52661..7a54ef486 100644 --- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx @@ -373,13 +373,15 @@ export function Controls({ onPress={onPressPlayPause} /> <View style={a.flex_1} /> - <Text - style={[ - a.px_xs, - {color: t.palette.white, fontVariant: ['tabular-nums']}, - ]}> - {formatTime(currentTime)} / {formatTime(duration)} - </Text> + {Math.round(duration) > 0 && ( + <Text + style={[ + a.px_xs, + {color: t.palette.white, fontVariant: ['tabular-nums']}, + ]}> + {formatTime(currentTime)} / {formatTime(duration)} + </Text> + )} {hasSubtitleTrack && ( <ControlButton active={subtitlesEnabled} diff --git a/src/components/Post/Embed/index.tsx b/src/components/Post/Embed/index.tsx index 9c5444b27..8566c2fe6 100644 --- a/src/components/Post/Embed/index.tsx +++ b/src/components/Post/Embed/index.tsx @@ -87,14 +87,18 @@ function MediaEmbed({ switch (embed.type) { case 'images': { return ( - <ContentHider modui={rest.moderation?.ui('contentMedia')}> + <ContentHider + modui={rest.moderation?.ui('contentMedia')} + activeStyle={[a.mt_sm]}> <ImageEmbed embed={embed} {...rest} /> </ContentHider> ) } case 'link': { return ( - <ContentHider modui={rest.moderation?.ui('contentMedia')}> + <ContentHider + modui={rest.moderation?.ui('contentMedia')} + activeStyle={[a.mt_sm]}> <ExternalEmbed link={embed.view.external} onOpen={rest.onOpen} @@ -105,7 +109,9 @@ function MediaEmbed({ } case 'video': { return ( - <ContentHider modui={rest.moderation?.ui('contentMedia')}> + <ContentHider + modui={rest.moderation?.ui('contentMedia')} + activeStyle={[a.mt_sm]}> <VideoEmbed embed={embed.view} /> </ContentHider> ) diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx index f12d922fd..095b62167 100644 --- a/src/components/ProfileCard.tsx +++ b/src/components/ProfileCard.tsx @@ -513,12 +513,19 @@ export function FollowButtonInner({ comment: 'User is following this account, click to unfollow', }), ) - const followLabel = _( - msg({ - message: 'Follow', - comment: 'User is not following this account, click to follow', - }), - ) + const followLabel = profile.viewer?.followedBy + ? _( + msg({ + message: 'Follow back', + comment: 'User is not following this account, click to follow back', + }), + ) + : _( + msg({ + message: 'Follow', + comment: 'User is not following this account, click to follow', + }), + ) if (!profile.viewer) return null if ( @@ -561,6 +568,24 @@ export function FollowButtonInner({ ) } +export function FollowButtonPlaceholder({style}: ViewStyleProp) { + const t = useTheme() + + return ( + <View + style={[ + a.rounded_sm, + t.atoms.bg_contrast_25, + a.w_full, + { + height: 33, + }, + style, + ]} + /> + ) +} + export function Labels({ profile, moderationOpts, diff --git a/src/components/dialogs/GifSelect.tsx b/src/components/dialogs/GifSelect.tsx index e18fdf2db..15c1ba26e 100644 --- a/src/components/dialogs/GifSelect.tsx +++ b/src/components/dialogs/GifSelect.tsx @@ -1,4 +1,4 @@ -import React, { +import { useCallback, useImperativeHandle, useMemo, @@ -119,7 +119,7 @@ function GifList({ [onSelectGif], ) - const onEndReached = React.useCallback(() => { + const onEndReached = useCallback(() => { if (isFetchingNextPage || !hasNextPage || error) return fetchNextPage() }, [isFetchingNextPage, hasNextPage, error, fetchNextPage]) @@ -172,7 +172,7 @@ function GifList({ </Button> )} - <TextField.Root> + <TextField.Root style={[!gtMobile && isWeb && a.flex_1]}> <TextField.Icon icon={Search} /> <TextField.Input label={_(msg`Search GIFs`)} @@ -206,11 +206,9 @@ function GifList({ renderItem={renderItem} numColumns={gtMobile ? 3 : 2} columnWrapperStyle={[a.gap_sm]} - contentContainerStyle={[ - native([a.px_xl, {minHeight: height}]), - web(a.h_full_vh), - ]} - style={[web(a.h_full_vh)]} + contentContainerStyle={[native([a.px_xl, {minHeight: height}])]} + webInnerStyle={[web({minHeight: '80vh'})]} + webInnerContentContainerStyle={[web(a.pb_0)]} ListHeaderComponent={ <> {listHeader} diff --git a/src/components/dms/ConvoMenu.tsx b/src/components/dms/ConvoMenu.tsx index 8aa2335d0..1b1ebbcd5 100644 --- a/src/components/dms/ConvoMenu.tsx +++ b/src/components/dms/ConvoMenu.tsx @@ -1,12 +1,12 @@ import React, {useCallback} from 'react' -import {Keyboard, Pressable, View} from 'react-native' -import {ChatBskyConvoDefs, ModerationCause} from '@atproto/api' +import {Keyboard, View} from 'react-native' +import {type ChatBskyConvoDefs, type ModerationCause} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' -import {NavigationProp} from '#/lib/routes/types' -import {Shadow} from '#/state/cache/types' +import {type NavigationProp} from '#/lib/routes/types' +import {type Shadow} from '#/state/cache/types' import { useConvoQuery, useMarkAsReadMutation, @@ -14,11 +14,15 @@ import { import {useMuteConvo} from '#/state/queries/messages/mute-conversation' import {useProfileBlockMutationQueue} from '#/state/queries/profile' import * as Toast from '#/view/com/util/Toast' -import {atoms as a, useTheme, ViewStyleProp} from '#/alf' +import {type ViewStyleProp} from '#/alf' +import {atoms as a} from '#/alf' +import {Button, ButtonIcon} from '#/components/Button' import {BlockedByListDialog} from '#/components/dms/BlockedByListDialog' import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt' import {ReportConversationPrompt} from '#/components/dms/ReportConversationPrompt' +import {ReportDialog} from '#/components/dms/ReportDialog' import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeft} from '#/components/icons/ArrowBoxLeft' +import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '#/components/icons/Bubble' import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid' import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag' import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' @@ -30,9 +34,7 @@ import { import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker' import * as Menu from '#/components/Menu' import * as Prompt from '#/components/Prompt' -import * as bsky from '#/types/bsky' -import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '../icons/Bubble' -import {ReportDialog} from './ReportDialog' +import type * as bsky from '#/types/bsky' let ConvoMenu = ({ convo, @@ -59,7 +61,6 @@ let ConvoMenu = ({ style?: ViewStyleProp['style'] }): React.ReactNode => { const {_} = useLingui() - const t = useTheme() const leaveConvoControl = Prompt.usePromptControl() const reportControl = Prompt.usePromptControl() @@ -73,22 +74,21 @@ let ConvoMenu = ({ {!hideTrigger && ( <View style={[style]}> <Menu.Trigger label={_(msg`Chat settings`)}> - {({props, state}) => ( - <Pressable + {({props}) => ( + <Button + label={props.accessibilityLabel} {...props} onPress={() => { Keyboard.dismiss() props.onPress() }} - style={[ - a.p_sm, - a.rounded_full, - (state.hovered || state.pressed) && t.atoms.bg_contrast_25, - // make sure pfp is in the middle - {marginLeft: -10}, - ]}> - <DotsHorizontal size="md" style={t.atoms.text} /> - </Pressable> + size="small" + color="secondary" + shape="round" + variant="ghost" + style={[a.bg_transparent]}> + <ButtonIcon icon={DotsHorizontal} size="md" /> + </Button> )} </Menu.Trigger> </View> diff --git a/src/components/dms/MessagesListHeader.tsx b/src/components/dms/MessagesListHeader.tsx index c8ed98f88..d37e4a34a 100644 --- a/src/components/dms/MessagesListHeader.tsx +++ b/src/components/dms/MessagesListHeader.tsx @@ -1,48 +1,42 @@ -import React, {useCallback} from 'react' -import {TouchableOpacity, View} from 'react-native' +import {useMemo} from 'react' +import {View} from 'react-native' import { type AppBskyActorDefs, type ModerationCause, type ModerationDecision, } from '@atproto/api' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useNavigation} from '@react-navigation/native' -import {BACK_HITSLOP} from '#/lib/constants' import {makeProfileLink} from '#/lib/routes/links' -import {type NavigationProp} from '#/lib/routes/types' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {isWeb} from '#/platform/detection' import {type Shadow} from '#/state/cache/profile-shadow' import {isConvoActive, useConvo} from '#/state/messages/convo' import {type ConvoItem} from '#/state/messages/convo/types' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' -import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' +import {atoms as a, useTheme, web} from '#/alf' import {ConvoMenu} from '#/components/dms/ConvoMenu' import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2' +import * as Layout from '#/components/Layout' import {Link} from '#/components/Link' import {PostAlerts} from '#/components/moderation/PostAlerts' import {Text} from '#/components/Typography' import {useSimpleVerificationState} from '#/components/verification' import {VerificationCheck} from '#/components/verification/VerificationCheck' -const PFP_SIZE = isWeb ? 40 : 34 +const PFP_SIZE = isWeb ? 40 : Layout.HEADER_SLOT_SIZE -export let MessagesListHeader = ({ +export function MessagesListHeader({ profile, moderation, }: { profile?: Shadow<AppBskyActorDefs.ProfileViewDetailed> moderation?: ModerationDecision -}): React.ReactNode => { +}) { const t = useTheme() - const {_} = useLingui() - const {gtTablet} = useBreakpoints() - const navigation = useNavigation<NavigationProp>() - const blockInfo = React.useMemo(() => { + const blockInfo = useMemo(() => { if (!moderation) return const modui = moderation.ui('profileView') const blocks = modui.alerts.filter(alert => alert.type === 'blocking') @@ -54,87 +48,54 @@ export let MessagesListHeader = ({ } }, [moderation]) - const onPressBack = useCallback(() => { - if (navigation.canGoBack()) { - navigation.goBack() - } else { - navigation.navigate('Messages', {}) - } - }, [navigation]) - return ( - <View - style={[ - t.atoms.bg, - t.atoms.border_contrast_low, - a.border_b, - a.flex_row, - a.align_start, - a.gap_sm, - gtTablet ? a.pl_lg : a.pl_xl, - a.pr_lg, - a.py_sm, - ]}> - <TouchableOpacity - testID="conversationHeaderBackBtn" - onPress={onPressBack} - hitSlop={BACK_HITSLOP} - style={{width: 30, height: 30, marginTop: isWeb ? 6 : 4}} - accessibilityRole="button" - accessibilityLabel={_(msg`Back`)} - accessibilityHint=""> - <FontAwesomeIcon - size={18} - icon="angle-left" - style={{ - marginTop: 6, - }} - color={t.atoms.text.color} - /> - </TouchableOpacity> - - {profile && moderation && blockInfo ? ( - <HeaderReady - profile={profile} - moderation={moderation} - blockInfo={blockInfo} - /> - ) : ( - <> - <View style={[a.flex_row, a.align_center, a.gap_md, a.flex_1]}> - <View - style={[ - {width: PFP_SIZE, height: PFP_SIZE}, - a.rounded_full, - t.atoms.bg_contrast_25, - ]} - /> - <View style={a.gap_xs}> - <View - style={[ - {width: 120, height: 16}, - a.rounded_xs, - t.atoms.bg_contrast_25, - a.mt_xs, - ]} - /> + <Layout.Header.Outer> + <View style={[a.w_full, a.flex_row, a.gap_xs, a.align_start]}> + <View style={[{minHeight: PFP_SIZE}, a.justify_center]}> + <Layout.Header.BackButton /> + </View> + {profile && moderation && blockInfo ? ( + <HeaderReady + profile={profile} + moderation={moderation} + blockInfo={blockInfo} + /> + ) : ( + <> + <View style={[a.flex_row, a.align_center, a.gap_md, a.flex_1]}> <View style={[ - {width: 175, height: 12}, - a.rounded_xs, + {width: PFP_SIZE, height: PFP_SIZE}, + a.rounded_full, t.atoms.bg_contrast_25, ]} /> + <View style={a.gap_xs}> + <View + style={[ + {width: 120, height: 16}, + a.rounded_xs, + t.atoms.bg_contrast_25, + a.mt_xs, + ]} + /> + <View + style={[ + {width: 175, height: 12}, + a.rounded_xs, + t.atoms.bg_contrast_25, + ]} + /> + </View> </View> - </View> - <View style={{width: 30}} /> - </> - )} - </View> + <Layout.Header.Slot /> + </> + )} + </View> + </Layout.Header.Outer> ) } -MessagesListHeader = React.memo(MessagesListHeader) function HeaderReady({ profile, @@ -181,15 +142,13 @@ function HeaderReady({ label={_(msg`View ${displayName}'s profile`)} style={[a.flex_row, a.align_start, a.gap_md, a.flex_1, a.pr_md]} to={makeProfileLink(profile)}> - <View style={[a.pt_2xs]}> - <PreviewableUserAvatar - size={PFP_SIZE} - profile={profile} - moderation={moderation.ui('avatar')} - disableHoverCard={moderation.blocked} - /> - </View> - <View style={a.flex_1}> + <PreviewableUserAvatar + size={PFP_SIZE} + profile={profile} + moderation={moderation.ui('avatar')} + disableHoverCard={moderation.blocked} + /> + <View style={[a.flex_1]}> <View style={[a.flex_row, a.align_center]}> <Text emoji @@ -215,7 +174,7 @@ function HeaderReady({ <Text style={[ t.atoms.text_contrast_medium, - a.text_sm, + a.text_xs, web([a.leading_normal, {marginTop: -2}]), ]} numberOfLines={1}> @@ -235,15 +194,19 @@ function HeaderReady({ </View> </Link> - {isConvoActive(convoState) && ( - <ConvoMenu - convo={convoState.convo} - profile={profile} - currentScreen="conversation" - blockInfo={blockInfo} - latestReportableMessage={latestReportableMessage} - /> - )} + <View style={[{minHeight: PFP_SIZE}, a.justify_center]}> + <Layout.Header.Slot> + {isConvoActive(convoState) && ( + <ConvoMenu + convo={convoState.convo} + profile={profile} + currentScreen="conversation" + blockInfo={blockInfo} + latestReportableMessage={latestReportableMessage} + /> + )} + </Layout.Header.Slot> + </View> </View> <View diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx index 9993317d6..3d4caa93b 100644 --- a/src/components/forms/TextField.tsx +++ b/src/components/forms/TextField.tsx @@ -48,9 +48,11 @@ const Context = createContext<{ }) Context.displayName = 'TextFieldContext' -export type RootProps = React.PropsWithChildren<{isInvalid?: boolean}> +export type RootProps = React.PropsWithChildren< + {isInvalid?: boolean} & TextStyleProp +> -export function Root({children, isInvalid = false}: RootProps) { +export function Root({children, isInvalid = false, style}: RootProps) { const inputRef = useRef<TextInput>(null) const { state: hovered, @@ -85,7 +87,14 @@ export function Root({children, isInvalid = false}: RootProps) { return ( <Context.Provider value={context}> <View - style={[a.flex_row, a.align_center, a.relative, a.w_full, a.px_md]} + style={[ + a.flex_row, + a.align_center, + a.relative, + a.w_full, + a.px_md, + style, + ]} {...web({ onClick: () => inputRef.current?.focus(), onMouseOver: onHoverIn, diff --git a/src/components/icons/Logo.tsx b/src/components/icons/Logo.tsx index 6f16d8a44..75c5cb420 100644 --- a/src/components/icons/Logo.tsx +++ b/src/components/icons/Logo.tsx @@ -1,5 +1,42 @@ +import Svg, {Path} from 'react-native-svg' + +import {type Props, useCommonSVGProps} from './common' import {createSinglePathSVG} from './TEMPLATE' export const Mark = createSinglePathSVG({ path: 'M6.335 4.212c2.293 1.76 4.76 5.327 5.665 7.241.906-1.914 3.372-5.482 5.665-7.241C19.319 2.942 22 1.96 22 5.086c0 .624-.35 5.244-.556 5.994-.713 2.608-3.315 3.273-5.629 2.87 4.045.704 5.074 3.035 2.852 5.366-4.22 4.426-6.066-1.111-6.54-2.53-.086-.26-.126-.382-.127-.278 0-.104-.041.018-.128.278-.473 1.419-2.318 6.956-6.539 2.53-2.222-2.331-1.193-4.662 2.852-5.366-2.314.403-4.916-.262-5.63-2.87C2.35 10.33 2 5.71 2 5.086c0-3.126 2.68-2.144 4.335-.874Z', }) + +export function Full( + props: Omit<Props, 'fill' | 'size' | 'height'> & { + markFill?: Props['fill'] + textFill?: Props['fill'] + }, +) { + const {fill, size, style, gradient, ...rest} = useCommonSVGProps(props) + const ratio = 123 / 555 + + return ( + <Svg + fill="none" + {...rest} + viewBox="0 0 555 123" + width={size} + height={size * ratio} + style={[style]}> + {gradient} + <Path + fill={props.markFill ?? fill} + fillRule="evenodd" + clipRule="evenodd" + d="M101.821 7.673C112.575-.367 130-6.589 130 13.21c0 3.953-2.276 33.214-3.611 37.965-4.641 16.516-21.549 20.729-36.591 18.179 26.292 4.457 32.979 19.218 18.535 33.98-27.433 28.035-39.428-7.034-42.502-16.02-.563-1.647-.827-2.418-.831-1.763-.004-.655-.268.116-.831 1.763-3.074 8.986-15.07 44.055-42.502 16.02C7.223 88.571 13.91 73.81 40.202 69.353c-15.041 2.55-31.95-1.663-36.59-18.179C2.275 46.424 0 17.162 0 13.21 0-6.59 17.426-.368 28.18 7.673 43.084 18.817 59.114 41.413 65 53.54c5.886-12.125 21.917-34.722 36.821-45.866Z" + /> + <Path + fill={props.textFill ?? fill} + fillRule="evenodd" + clipRule="evenodd" + d="m454.459 63.823 24.128-25.056h32.638l4.825 15.104c3.561 11.357 6.664 22.598 9.422 33.72 2.527-9.6 5.744-20.84 9.536-33.603l4.826-15.221H555l-22.864 65.335c-2.413 6.673-5.4 11.475-9.192 14.168-3.791 2.693-9.192 3.98-16.315 3.98-2.413 0-4.481-.117-6.319-.352v-11.59h5.514c6.549 0 9.767-4.099 9.767-9.719 0-2.81-.92-6.908-2.758-12.177l-17.177-49.478-22.239 22.665L497.2 99.184h-16.545l-17.234-28.101-8.962 9.133v18.968h-14.246V15.817h14.246v48.006Zm-48.373-26.46c16.889 0 25.622 6.79 26.196 20.49h-13.673c-.344-7.377-4.595-9.954-12.523-9.954-6.894 0-10.341 2.342-10.341 7.026 0 4.215 2.987 6.089 9.881 7.377l7.469 1.17c14.361 2.694 20.566 8.08 20.566 18.384 0 12.176-9.652 18.967-26.311 18.967-17.235 0-26.311-6.908-27.116-20.842h14.132c.804 7.494 4.481 10.304 13.213 10.304 7.813 0 11.72-2.459 11.72-7.26 0-4.332-2.758-6.44-11.605-7.962l-6.778-1.17c-12.983-2.224-19.418-8.313-19.418-18.265 0-11.358 8.847-18.266 24.588-18.266ZM270.534 76.351c0 7.61 3.677 11.474 11.145 11.474 7.008 0 13.212-5.268 13.213-15.22v-33.84h14.476v60.418h-14.016v-8.782c-4.481 6.791-10.686 10.187-18.614 10.187-12.523 0-20.68-7.728-20.68-21.778V38.767h14.476v37.585Zm75.432-38.99c8.961 0 16.085 3.045 21.37 9.016s7.928 13.933 7.928 23.651v3.513h-44.35c1.034 10.42 6.664 15.572 15.396 15.572 6.663 0 11.144-2.927 13.557-8.664h13.903c-3.103 12.294-13.443 20.139-27.575 20.139-8.847 0-15.971-2.927-21.371-8.664-5.4-5.737-8.157-13.348-8.157-22.95 0-9.483 2.643-17.094 8.043-22.949 5.4-5.737 12.409-8.664 21.256-8.664ZM195.628 15.817c17.809 0 26.426 9.251 26.426 21.545 0 8.196-3.677 14.168-10.915 17.914 9.306 3.396 14.247 11.24 14.247 20.022 0 14.87-9.767 23.886-28.494 23.886h-38.26V15.817h36.996Zm51.264 83.367h-14.477V15.817h14.477v83.367ZM174.143 86.07h21.944c8.732 0 13.443-4.098 13.443-11.474 0-7.728-4.481-11.592-13.443-11.592h-21.944V86.07Zm171.708-37.233c-7.928 0-13.443 4.683-14.822 14.401h29.758c-1.264-8.781-6.549-14.401-14.936-14.401Zm-171.708 1.756h20.336c7.927 0 12.178-4.215 12.178-11.24 0-6.44-4.366-10.539-12.178-10.539h-20.336v21.779Z" + /> + </Svg> + ) +} diff --git a/src/components/interstitials/TrendingVideos.tsx b/src/components/interstitials/TrendingVideos.tsx index 4d59e2fb5..6be64335a 100644 --- a/src/components/interstitials/TrendingVideos.tsx +++ b/src/components/interstitials/TrendingVideos.tsx @@ -1,4 +1,4 @@ -import React, {useEffect} from 'react' +import {useCallback, useEffect, useMemo} from 'react' import {ScrollView, View} from 'react-native' import {AppBskyEmbedVideo, AtUri} from '@atproto/api' import {msg, Trans} from '@lingui/macro' @@ -55,7 +55,7 @@ export function TrendingVideos() { const {setTrendingVideoDisabled} = useTrendingSettingsApi() const trendingPrompt = Prompt.usePromptControl() - const onConfirmHide = React.useCallback(() => { + const onConfirmHide = useCallback(() => { setTrendingVideoDisabled(true) logEvent('trendingVideos:hide', {context: 'interstitial:discover'}) }, [setTrendingVideoDisabled]) @@ -147,9 +147,7 @@ function VideoCards({ }: { data: Exclude<ReturnType<typeof usePostFeedQuery>['data'], undefined> }) { - const t = useTheme() - const {_} = useLingui() - const items = React.useMemo(() => { + const items = useMemo(() => { return data.pages .flatMap(page => page.slices) .map(slice => slice.items[0]) @@ -157,10 +155,6 @@ function VideoCards({ .filter(item => AppBskyEmbedVideo.isView(item.post.embed)) .slice(0, 8) }, [data]) - const href = React.useMemo(() => { - const urip = new AtUri(VIDEO_FEED_URI) - return makeCustomFeedLink(urip.host, urip.rkey, undefined, 'discover') - }, []) return ( <> @@ -183,50 +177,58 @@ function VideoCards({ </View> ))} - <View style={[{width: CARD_WIDTH * 2}]}> - <Link - to={href} - label={_(msg`View more`)} - style={[ - a.justify_center, - a.align_center, - a.flex_1, - a.rounded_lg, - a.border, - t.atoms.border_contrast_low, - t.atoms.bg, - t.atoms.shadow_sm, - ]}> - {({pressed}) => ( - <View - style={[ - a.flex_row, - a.align_center, - a.gap_md, - { - opacity: pressed ? 0.6 : 1, - }, - ]}> - <Text style={[a.text_md]}> - <Trans>View more</Trans> - </Text> - <View - style={[ - a.align_center, - a.justify_center, - a.rounded_full, - { - width: 34, - height: 34, - backgroundColor: t.palette.primary_500, - }, - ]}> - <ButtonIcon icon={ChevronRight} /> - </View> - </View> - )} - </Link> - </View> + <ViewMoreCard /> </> ) } + +function ViewMoreCard() { + const t = useTheme() + const {_} = useLingui() + + const href = useMemo(() => { + const urip = new AtUri(VIDEO_FEED_URI) + return makeCustomFeedLink(urip.host, urip.rkey, undefined, 'discover') + }, []) + + return ( + <View style={[{width: CARD_WIDTH * 2}]}> + <Link + to={href} + label={_(msg`View more`)} + style={[ + a.justify_center, + a.align_center, + a.flex_1, + a.rounded_lg, + a.border, + t.atoms.border_contrast_low, + t.atoms.bg, + t.atoms.shadow_sm, + ]}> + {({pressed}) => ( + <View + style={[ + a.flex_row, + a.align_center, + a.gap_md, + { + opacity: pressed ? 0.6 : 1, + }, + ]}> + <Text style={[a.text_md]}> + <Trans>View more</Trans> + </Text> + <Button + color="primary" + size="small" + shape="round" + label={_(msg`View more trending videos`)}> + <ButtonIcon icon={ChevronRight} /> + </Button> + </View> + )} + </Link> + </View> + ) +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 21f0ab870..130722b9c 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -181,6 +181,10 @@ export const VIDEO_SERVICE = 'https://video.bsky.app' export const VIDEO_SERVICE_DID = 'did:web:video.bsky.app' export const VIDEO_MAX_DURATION_MS = 3 * 60 * 1000 // 3 minutes in milliseconds +/** + * Maximum size of a video in megabytes, _not_ mebibytes. Backend uses + * ISO megabytes. + */ export const VIDEO_MAX_SIZE = 1000 * 1000 * 100 // 100mb export const SUPPORTED_MIME_TYPES = [ diff --git a/src/lib/custom-animations/AccordionAnimation.tsx b/src/lib/custom-animations/AccordionAnimation.tsx new file mode 100644 index 000000000..146735aa6 --- /dev/null +++ b/src/lib/custom-animations/AccordionAnimation.tsx @@ -0,0 +1,77 @@ +import { + type LayoutChangeEvent, + type StyleProp, + View, + type ViewStyle, +} from 'react-native' +import Animated, { + Easing, + FadeInUp, + FadeOutUp, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated' + +import {isIOS, isWeb} from '#/platform/detection' + +type AccordionAnimationProps = React.PropsWithChildren<{ + isExpanded: boolean + duration?: number + style?: StyleProp<ViewStyle> +}> + +function WebAccordion({ + isExpanded, + duration = 300, + style, + children, +}: AccordionAnimationProps) { + const heightValue = useSharedValue(0) + + const animatedStyle = useAnimatedStyle(() => { + const targetHeight = isExpanded ? heightValue.get() : 0 + return { + height: withTiming(targetHeight, { + duration, + easing: Easing.out(Easing.cubic), + }), + overflow: 'hidden', + } + }) + + const onLayout = (e: LayoutChangeEvent) => { + if (heightValue.get() === 0) { + heightValue.set(e.nativeEvent.layout.height) + } + } + + return ( + <Animated.View style={[animatedStyle, style]}> + <View onLayout={onLayout}>{children}</View> + </Animated.View> + ) +} + +function MobileAccordion({ + isExpanded, + duration = 200, + style, + children, +}: AccordionAnimationProps) { + if (!isExpanded) return null + + return ( + <Animated.View + style={style} + entering={FadeInUp.duration(duration)} + exiting={FadeOutUp.duration(duration / 2)} + pointerEvents={isIOS ? 'auto' : 'box-none'}> + {children} + </Animated.View> + ) +} + +export function AccordionAnimation(props: AccordionAnimationProps) { + return isWeb ? <WebAccordion {...props} /> : <MobileAccordion {...props} /> +} diff --git a/src/lib/haptics.ts b/src/lib/haptics.ts index 234be777d..32e371644 100644 --- a/src/lib/haptics.ts +++ b/src/lib/haptics.ts @@ -4,7 +4,6 @@ import {impactAsync, ImpactFeedbackStyle} from 'expo-haptics' import {isIOS, isWeb} from '#/platform/detection' import {useHapticsDisabled} from '#/state/preferences/disable-haptics' -import * as Toast from '#/view/com/util/Toast' export function useHaptics() { const isHapticsDisabled = useHapticsDisabled() @@ -23,7 +22,8 @@ export function useHaptics() { // DEV ONLY - show a toast when a haptic is meant to fire on simulator if (__DEV__ && !Device.isDevice) { - Toast.show(`Buzzz!`) + // disabled because it's annoying + // Toast.show(`Buzzz!`) } }, [isHapticsDisabled], diff --git a/src/lib/media/picker.shared.ts b/src/lib/media/picker.shared.ts index 8fd76f414..8ec1154c8 100644 --- a/src/lib/media/picker.shared.ts +++ b/src/lib/media/picker.shared.ts @@ -17,16 +17,12 @@ export async function openPicker(opts?: ImagePickerOptions) { exif: false, mediaTypes: ['images'], quality: 1, + selectionLimit: 1, ...opts, legacy: true, }) - if (response.assets && response.assets.length > 4) { - Toast.show(t`You may only select up to 4 images`, 'exclamation-circle') - } - return (response.assets ?? []) - .slice(0, 4) .filter(asset => { if (asset.mimeType?.startsWith('image/')) return true Toast.show(t`Only image files are supported`, 'exclamation-circle') diff --git a/src/lib/media/video/compress.ts b/src/lib/media/video/compress.ts index c2d1470c6..1d00bfcea 100644 --- a/src/lib/media/video/compress.ts +++ b/src/lib/media/video/compress.ts @@ -1,8 +1,8 @@ import {getVideoMetaData, Video} from 'react-native-compressor' -import {ImagePickerAsset} from 'expo-image-picker' +import {type ImagePickerAsset} from 'expo-image-picker' -import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants' -import {CompressedVideo} from './types' +import {SUPPORTED_MIME_TYPES, type SupportedMimeTypes} from '#/lib/constants' +import {type CompressedVideo} from './types' import {extToMime} from './util' const MIN_SIZE_FOR_COMPRESSION = 25 // 25mb @@ -20,6 +20,13 @@ export async function compressVideo( file.mimeType as SupportedMimeTypes, ) + if (file.mimeType === 'image/gif') { + // let's hope they're small enough that they don't need compression! + // this compression library doesn't support gifs + // worst case - server rejects them. I think that's fine -sfn + return {uri: file.uri, size: file.fileSize ?? -1, mimeType: 'image/gif'} + } + const minimumFileSizeForCompress = isAcceptableFormat ? MIN_SIZE_FOR_COMPRESSION : 0 diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts index 66134a462..ef6dc1d4d 100644 --- a/src/lib/statsig/gates.ts +++ b/src/lib/statsig/gates.ts @@ -5,9 +5,9 @@ export type Gate = | 'debug_subscriptions' | 'disable_onboarding_policy_update_notice' | 'explore_show_suggested_feeds' - | 'handle_suggestions' | 'old_postonboarding' | 'onboarding_add_video_feed' + | 'post_follow_profile_suggested_accounts' | 'post_threads_v2_unspecced' | 'remove_show_latest_button' | 'test_gate_1' diff --git a/src/lib/strings/helpers.ts b/src/lib/strings/helpers.ts index ca77c4666..61ad4e85b 100644 --- a/src/lib/strings/helpers.ts +++ b/src/lib/strings/helpers.ts @@ -84,6 +84,10 @@ export function augmentSearchQuery(query: string, {did}: {did?: string}) { return query } + // replace “smart quotes” with normal ones + // iOS keyboard will add fancy unicode quotes, but only normal ones work + query = query.replaceAll(/[“”]/g, '"') + // We don't want to replace substrings that are being "quoted" because those // are exact string matches, so what we'll do here is to split them apart diff --git a/src/locale/locales/en/messages.po b/src/locale/locales/en/messages.po index 9d96abb7a..a6c98211c 100644 --- a/src/locale/locales/en/messages.po +++ b/src/locale/locales/en/messages.po @@ -145,7 +145,7 @@ msgstr "" msgid "{0} is not a valid URL" msgstr "" -#: src/screens/Signup/StepHandle/index.tsx:189 +#: src/screens/Signup/StepHandle/index.tsx:186 msgid "{0} is not available" msgstr "" @@ -153,7 +153,7 @@ msgstr "" msgid "{0} joined this week" msgstr "" -#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx:202 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx:204 msgid "{0} of {1}" msgstr "" @@ -524,6 +524,10 @@ msgstr "" msgid "A new form of verification" msgstr "" +#: src/components/BlockedGeoOverlay.tsx:39 +msgid "A new Mississippi law requires us to implement age verification for all users before they can access Bluesky. We think this law creates challenges that go beyond its child safety goals, and creates significant barriers that limit free speech and disproportionately harm smaller platforms and emerging technologies." +msgstr "" + #: src/components/dialogs/nuxs/ActivitySubscriptions.tsx:113 msgid "A screenshot of a profile page with a bell icon next to the follow button, indicating the new activity notifications feature." msgstr "" @@ -602,7 +606,7 @@ msgstr "" msgid "Account removed from quick access" msgstr "" -#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:128 +#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:132 #: src/view/com/profile/ProfileMenu.tsx:148 msgctxt "toast" msgid "Account unblocked" @@ -687,7 +691,7 @@ msgstr "" msgid "Add another account" msgstr "" -#: src/view/com/composer/Composer.tsx:780 +#: src/view/com/composer/Composer.tsx:788 msgid "Add another post" msgstr "" @@ -705,6 +709,11 @@ msgstr "" msgid "Add emoji reaction" msgstr "" +#. Accessibility label for button in composer to add photos or a video to a post +#: src/view/com/composer/SelectMediaButton.tsx:482 +msgid "Add media to post" +msgstr "" + #: src/components/moderation/ReportDialog/index.tsx:403 #: src/components/moderation/ReportDialog/index.tsx:407 msgid "Add more details (optional)" @@ -718,7 +727,7 @@ msgstr "" msgid "Add muted words and tags" msgstr "" -#: src/view/com/composer/Composer.tsx:1344 +#: src/view/com/composer/Composer.tsx:1421 msgid "Add new post" msgstr "" @@ -788,7 +797,7 @@ msgstr "" msgid "Adult Content" msgstr "" -#: src/screens/Moderation/index.tsx:404 +#: src/screens/Moderation/index.tsx:423 msgid "Adult content can only be enabled via the Web at <0>bsky.app</0>." msgstr "" @@ -801,7 +810,7 @@ msgstr "" msgid "Adult Content labels" msgstr "" -#: src/screens/Moderation/index.tsx:454 +#: src/screens/Moderation/index.tsx:473 msgid "Advanced" msgstr "" @@ -888,7 +897,7 @@ msgstr "" #: src/screens/Settings/AccessibilitySettings.tsx:54 #: src/view/com/composer/GifAltText.tsx:154 -#: src/view/com/composer/photos/ImageAltTextDialog.tsx:118 +#: src/view/com/composer/photos/ImageAltTextDialog.tsx:117 #: src/view/com/composer/videos/SubtitleDialog.tsx:40 #: src/view/com/composer/videos/SubtitleDialog.tsx:55 #: src/view/com/composer/videos/SubtitleDialog.tsx:101 @@ -905,7 +914,7 @@ msgid "Alt text describes images for blind and low-vision users, and helps give msgstr "" #: src/view/com/composer/GifAltText.tsx:179 -#: src/view/com/composer/photos/ImageAltTextDialog.tsx:139 +#: src/view/com/composer/photos/ImageAltTextDialog.tsx:138 msgid "Alt text will be truncated. {MAX_ALT_TEXT, plural, other {Limit: {0} characters.}}" msgstr "" @@ -913,11 +922,11 @@ msgstr "" msgid "An email has been sent to {0}. It includes a confirmation code which you can enter below." msgstr "" -#: src/components/dialogs/GifSelect.tsx:266 +#: src/components/dialogs/GifSelect.tsx:264 msgid "An error has occurred" msgstr "" -#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:420 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:422 msgid "An error occurred" msgstr "" @@ -945,10 +954,6 @@ msgstr "" msgid "An error occurred while saving the QR code!" msgstr "" -#: src/view/com/composer/videos/SelectVideoBtn.tsx:63 -msgid "An error occurred while selecting the video" -msgstr "" - #: src/screens/StarterPack/StarterPackScreen.tsx:352 #: src/screens/StarterPack/StarterPackScreen.tsx:374 msgid "An error occurred while trying to follow all" @@ -1011,6 +1016,7 @@ msgstr "" msgid "Animated GIF" msgstr "" +#: src/components/BlockedGeoOverlay.tsx:92 #: src/components/PolicyUpdateOverlay/Badge.tsx:33 msgid "Announcement" msgstr "" @@ -1108,7 +1114,7 @@ msgid "Appeal this decision" msgstr "" #: src/Navigation.tsx:390 -#: src/screens/Settings/AppearanceSettings.tsx:88 +#: src/screens/Settings/AppearanceSettings.tsx:86 #: src/screens/Settings/Settings.tsx:212 #: src/screens/Settings/Settings.tsx:215 msgid "Appearance" @@ -1164,11 +1170,11 @@ msgstr "" msgid "Are you sure you want to remove this from your feeds?" msgstr "" -#: src/view/com/composer/Composer.tsx:729 +#: src/view/com/composer/Composer.tsx:737 msgid "Are you sure you'd like to discard this draft?" msgstr "" -#: src/view/com/composer/Composer.tsx:914 +#: src/view/com/composer/Composer.tsx:927 msgid "Are you sure you'd like to discard this post?" msgstr "" @@ -1189,12 +1195,16 @@ msgstr "" msgid "Artistic or non-erotic nudity." msgstr "" +#: src/components/BlockedGeoOverlay.tsx:42 +msgid "As a small team, we cannot justify building the expensive infrastructure this requirement demands while legal challenges to this law are pending." +msgstr "" + #: src/components/PostControls/PostMenu/PostMenuItems.tsx:491 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:493 msgid "Assign topic for algo" msgstr "" -#: src/screens/Settings/AppIconSettings/useAppIconSets.ts:48 +#: src/screens/Settings/AppIconSettings/useAppIconSets.ts:58 msgctxt "Name of app icon variant" msgid "Aurora" msgstr "" @@ -1209,7 +1219,6 @@ msgstr "" msgid "Available" msgstr "" -#: src/components/dms/MessagesListHeader.tsx:84 #: src/components/moderation/LabelsOnMeDialog.tsx:315 #: src/components/moderation/LabelsOnMeDialog.tsx:316 #: src/screens/Login/ChooseAccountForm.tsx:90 @@ -1279,7 +1288,7 @@ msgid "Birthday" msgstr "" #: src/components/PostControls/PostMenu/PostMenuItems.tsx:753 -#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:320 +#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:328 #: src/view/com/profile/ProfileMenu.tsx:473 msgid "Block" msgstr "" @@ -1333,11 +1342,11 @@ msgstr "" msgid "Block User" msgstr "" -#: src/components/Post/Embed/index.tsx:180 +#: src/components/Post/Embed/index.tsx:186 msgid "Blocked" msgstr "" -#: src/screens/Moderation/index.tsx:282 +#: src/screens/Moderation/index.tsx:301 msgid "Blocked accounts" msgstr "" @@ -1385,7 +1394,7 @@ msgstr "" msgid "Bluesky cannot confirm the authenticity of the claimed date." msgstr "" -#: src/screens/Settings/AppIconSettings/useAppIconSets.ts:165 +#: src/screens/Settings/AppIconSettings/useAppIconSets.ts:175 msgctxt "Name of app icon variant" msgid "Bluesky Classic™" msgstr "" @@ -1448,20 +1457,20 @@ msgstr "" msgid "Books" msgstr "" -#: src/components/FeedInterstitials.tsx:435 +#: src/components/FeedInterstitials.tsx:428 msgid "Browse more accounts on the Explore page" msgstr "" -#: src/components/FeedInterstitials.tsx:566 +#: src/components/FeedInterstitials.tsx:556 msgid "Browse more feeds on the Explore page" msgstr "" -#: src/components/FeedInterstitials.tsx:547 -#: src/components/FeedInterstitials.tsx:550 +#: src/components/FeedInterstitials.tsx:537 +#: src/components/FeedInterstitials.tsx:540 msgid "Browse more suggestions" msgstr "" -#: src/components/FeedInterstitials.tsx:575 +#: src/components/FeedInterstitials.tsx:565 msgid "Browse more suggestions on the Explore page" msgstr "" @@ -1557,8 +1566,8 @@ msgstr "" #: src/screens/Settings/Settings.tsx:289 #: src/screens/Takendown.tsx:99 #: src/screens/Takendown.tsx:102 -#: src/view/com/composer/Composer.tsx:969 -#: src/view/com/composer/Composer.tsx:980 +#: src/view/com/composer/Composer.tsx:982 +#: src/view/com/composer/Composer.tsx:993 #: src/view/com/composer/photos/EditImageDialog.web.tsx:43 #: src/view/com/composer/photos/EditImageDialog.web.tsx:52 #: src/view/com/modals/ChangePassword.tsx:279 @@ -1702,7 +1711,7 @@ msgstr "" msgid "Chat requests" msgstr "" -#: src/components/dms/ConvoMenu.tsx:75 +#: src/components/dms/ConvoMenu.tsx:76 #: src/Navigation.tsx:553 #: src/screens/Messages/ChatList.tsx:367 msgid "Chat settings" @@ -1838,7 +1847,7 @@ msgstr "" #: src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx:184 #: src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx:237 #: src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx:243 -#: src/components/dialogs/GifSelect.tsx:282 +#: src/components/dialogs/GifSelect.tsx:280 #: src/components/dialogs/nuxs/ActivitySubscriptions.tsx:158 #: src/components/dialogs/nuxs/ActivitySubscriptions.tsx:167 #: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:178 @@ -1881,7 +1890,7 @@ msgstr "" #: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:224 #: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:230 -#: src/components/dialogs/GifSelect.tsx:276 +#: src/components/dialogs/GifSelect.tsx:274 #: src/components/verification/VerificationsDialog.tsx:136 #: src/components/verification/VerifierDialog.tsx:136 msgid "Close dialog" @@ -1904,7 +1913,7 @@ msgstr "" msgid "Close image" msgstr "" -#: src/view/com/lightbox/Lightbox.web.tsx:109 +#: src/view/com/lightbox/Lightbox.web.tsx:110 msgid "Close image viewer" msgstr "" @@ -1922,7 +1931,7 @@ msgstr "" msgid "Closes password update alert" msgstr "" -#: src/view/com/composer/Composer.tsx:977 +#: src/view/com/composer/Composer.tsx:990 msgid "Closes post composer and discards post draft" msgstr "" @@ -1944,7 +1953,7 @@ msgid "Collapses list of users for a given notification" msgstr "" #: src/components/dialogs/Embed.tsx:154 -#: src/screens/Settings/AppearanceSettings.tsx:96 +#: src/screens/Settings/AppearanceSettings.tsx:94 msgid "Color mode" msgstr "" @@ -1980,7 +1989,7 @@ msgstr "" msgid "Compose new post" msgstr "" -#: src/view/com/composer/Composer.tsx:878 +#: src/view/com/composer/Composer.tsx:891 msgid "Compose posts up to {0, plural, other {# characters}} in length" msgstr "" @@ -1988,7 +1997,7 @@ msgstr "" msgid "Compose reply" msgstr "" -#: src/view/com/composer/Composer.tsx:1738 +#: src/view/com/composer/Composer.tsx:1815 msgid "Compressing video..." msgstr "" @@ -2015,11 +2024,11 @@ msgstr "" msgid "Confirm delete account" msgstr "" -#: src/screens/Moderation/index.tsx:330 +#: src/screens/Moderation/index.tsx:349 msgid "Confirm your age:" msgstr "" -#: src/screens/Moderation/index.tsx:321 +#: src/screens/Moderation/index.tsx:340 msgid "Confirm your birthdate" msgstr "" @@ -2072,8 +2081,8 @@ msgstr "" msgid "Content Blocked" msgstr "" -#: src/screens/Moderation/index.tsx:317 -#: src/screens/Moderation/index.tsx:351 +#: src/screens/Moderation/index.tsx:336 +#: src/screens/Moderation/index.tsx:370 msgid "Content filters" msgstr "" @@ -2404,8 +2413,8 @@ msgstr "" #: src/components/dialogs/Embed.tsx:167 #: src/components/dialogs/Embed.tsx:169 -#: src/screens/Settings/AppearanceSettings.tsx:108 -#: src/screens/Settings/AppearanceSettings.tsx:129 +#: src/screens/Settings/AppearanceSettings.tsx:106 +#: src/screens/Settings/AppearanceSettings.tsx:127 msgid "Dark" msgstr "" @@ -2418,7 +2427,7 @@ msgstr "" msgid "Dark mode" msgstr "" -#: src/screens/Settings/AppearanceSettings.tsx:121 +#: src/screens/Settings/AppearanceSettings.tsx:119 msgid "Dark theme" msgstr "" @@ -2440,7 +2449,7 @@ msgstr "" msgid "Debug panel" msgstr "" -#: src/screens/Settings/AppearanceSettings.tsx:171 +#: src/screens/Settings/AppearanceSettings.tsx:169 msgid "Default" msgstr "" @@ -2518,7 +2527,7 @@ msgstr "" #: src/components/PostControls/PostMenu/PostMenuItems.tsx:678 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:680 -#: src/view/com/composer/Composer.tsx:888 +#: src/view/com/composer/Composer.tsx:901 msgid "Delete post" msgstr "" @@ -2539,11 +2548,11 @@ msgstr "" msgid "Delete this post?" msgstr "" -#: src/components/Post/Embed/index.tsx:173 +#: src/components/Post/Embed/index.tsx:179 msgid "Deleted" msgstr "" -#: src/components/dms/MessagesListHeader.tsx:160 +#: src/components/dms/MessagesListHeader.tsx:121 #: src/screens/Messages/components/ChatListItem.tsx:128 msgid "Deleted Account" msgstr "" @@ -2566,7 +2575,7 @@ msgid "Description" msgstr "" #: src/view/com/composer/GifAltText.tsx:150 -#: src/view/com/composer/photos/ImageAltTextDialog.tsx:114 +#: src/view/com/composer/photos/ImageAltTextDialog.tsx:113 msgid "Descriptive alt text" msgstr "" @@ -2598,7 +2607,7 @@ msgstr "" msgid "Dialog: adjust who can interact with this post" msgstr "" -#: src/screens/Settings/AppearanceSettings.tsx:125 +#: src/screens/Settings/AppearanceSettings.tsx:123 msgid "Dim" msgstr "" @@ -2620,7 +2629,7 @@ msgstr "" msgid "Disable haptic feedback" msgstr "" -#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:386 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:388 msgid "Disable subtitles" msgstr "" @@ -2629,13 +2638,13 @@ msgstr "" #: src/lib/moderation/useLabelBehaviorDescription.ts:68 #: src/screens/Messages/Settings.tsx:155 #: src/screens/Messages/Settings.tsx:158 -#: src/screens/Moderation/index.tsx:394 +#: src/screens/Moderation/index.tsx:413 msgid "Disabled" msgstr "" #: src/screens/Profile/Header/EditProfileDialog.tsx:89 -#: src/view/com/composer/Composer.tsx:731 -#: src/view/com/composer/Composer.tsx:921 +#: src/view/com/composer/Composer.tsx:739 +#: src/view/com/composer/Composer.tsx:934 msgid "Discard" msgstr "" @@ -2643,11 +2652,11 @@ msgstr "" msgid "Discard changes?" msgstr "" -#: src/view/com/composer/Composer.tsx:728 +#: src/view/com/composer/Composer.tsx:736 msgid "Discard draft?" msgstr "" -#: src/view/com/composer/Composer.tsx:913 +#: src/view/com/composer/Composer.tsx:926 msgid "Discard post?" msgstr "" @@ -2673,7 +2682,7 @@ msgstr "" msgid "Dismiss" msgstr "" -#: src/view/com/composer/Composer.tsx:1662 +#: src/view/com/composer/Composer.tsx:1739 msgid "Dismiss error" msgstr "" @@ -2905,12 +2914,12 @@ msgstr "" #: src/screens/Profile/Header/EditProfileDialog.tsx:276 #: src/screens/Profile/Header/EditProfileDialog.tsx:282 #: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:183 -#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:185 +#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:190 msgid "Edit profile" msgstr "" #: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:186 -#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:188 +#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:193 msgid "Edit Profile" msgstr "" @@ -3002,7 +3011,7 @@ msgstr "" msgid "Enable {0} only" msgstr "" -#: src/screens/Moderation/index.tsx:381 +#: src/screens/Moderation/index.tsx:400 msgid "Enable adult content" msgstr "" @@ -3028,7 +3037,7 @@ msgstr "" msgid "Enable push notifications" msgstr "" -#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:387 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:389 msgid "Enable subtitles" msgstr "" @@ -3048,7 +3057,7 @@ msgstr "" #: src/screens/Messages/Settings.tsx:146 #: src/screens/Messages/Settings.tsx:149 -#: src/screens/Moderation/index.tsx:392 +#: src/screens/Moderation/index.tsx:411 msgid "Enabled" msgstr "" @@ -3074,7 +3083,7 @@ msgstr "" msgid "Enter code" msgstr "" -#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:405 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:407 msgid "Enter fullscreen" msgstr "" @@ -3119,7 +3128,7 @@ msgstr "" msgid "Entertainment" msgstr "" -#: src/view/com/composer/Composer.tsx:1747 +#: src/view/com/composer/Composer.tsx:1824 #: src/view/com/util/error/ErrorScreen.tsx:42 msgid "Error" msgstr "" @@ -3194,7 +3203,7 @@ msgstr "" msgid "Excludes users you follow" msgstr "" -#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:404 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:406 msgid "Exit fullscreen" msgstr "" @@ -3206,11 +3215,11 @@ msgstr "" msgid "Exits image cropping process" msgstr "" -#: src/view/com/lightbox/Lightbox.web.tsx:110 +#: src/view/com/lightbox/Lightbox.web.tsx:111 msgid "Exits image view" msgstr "" -#: src/view/com/lightbox/Lightbox.web.tsx:184 +#: src/view/com/lightbox/Lightbox.web.tsx:185 msgid "Expand alt text" msgstr "" @@ -3362,7 +3371,7 @@ msgstr "" msgid "Failed to load feeds preferences" msgstr "" -#: src/components/dialogs/GifSelect.tsx:226 +#: src/components/dialogs/GifSelect.tsx:224 msgid "Failed to load GIFs" msgstr "" @@ -3606,17 +3615,17 @@ msgstr "" msgid "Fitness" msgstr "" -#: src/screens/Settings/AppIconSettings/useAppIconSets.ts:149 +#: src/screens/Settings/AppIconSettings/useAppIconSets.ts:159 msgctxt "Name of app icon variant" msgid "Flat Black" msgstr "" -#: src/screens/Settings/AppIconSettings/useAppIconSets.ts:117 +#: src/screens/Settings/AppIconSettings/useAppIconSets.ts:127 msgctxt "Name of app icon variant" msgid "Flat Blue" msgstr "" -#: src/screens/Settings/AppIconSettings/useAppIconSets.ts:133 +#: src/screens/Settings/AppIconSettings/useAppIconSets.ts:143 msgctxt "Name of app icon variant" msgid "Flat White" msgstr "" @@ -3626,10 +3635,10 @@ msgid "Flexible" msgstr "" #. User is not following this account, click to follow -#: src/components/ProfileCard.tsx:517 +#: src/components/ProfileCard.tsx:524 #: src/components/ProfileHoverCard/index.web.tsx:496 #: src/components/ProfileHoverCard/index.web.tsx:507 -#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:245 +#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:252 #: src/screens/VideoFeed/index.tsx:851 #: src/view/com/post-thread/PostThreadFollowBtn.tsx:131 msgid "Follow" @@ -3640,7 +3649,7 @@ msgctxt "action" msgid "Follow" msgstr "" -#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:230 +#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:237 #: src/view/com/post-thread/PostThreadFollowBtn.tsx:113 msgid "Follow {0}" msgstr "" @@ -3668,14 +3677,16 @@ msgstr "" msgid "Follow all" msgstr "" -#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:243 +#. User is not following this account, click to follow back +#: src/components/ProfileCard.tsx:518 +#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:250 #: src/view/com/post-thread/PostThreadFollowBtn.tsx:129 -msgid "Follow Back" +msgid "Follow back" msgstr "" #: src/view/com/profile/FollowButton.tsx:81 msgctxt "action" -msgid "Follow Back" +msgid "Follow back" msgstr "" #: src/components/KnownFollowers.tsx:238 @@ -3707,7 +3718,7 @@ msgstr "" #: src/components/ProfileCard.tsx:511 #: src/components/ProfileHoverCard/index.web.tsx:495 #: src/components/ProfileHoverCard/index.web.tsx:506 -#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:241 +#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:248 #: src/screens/VideoFeed/index.tsx:849 #: src/view/com/post-thread/PostThreadFollowBtn.tsx:134 msgid "Following" @@ -3720,7 +3731,7 @@ msgid "Following" msgstr "" #: src/components/ProfileCard.tsx:474 -#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:89 +#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:92 msgid "Following {0}" msgstr "" @@ -3746,11 +3757,11 @@ msgstr "" msgid "Follows You" msgstr "" -#: src/screens/Settings/AppearanceSettings.tsx:143 +#: src/screens/Settings/AppearanceSettings.tsx:141 msgid "Font" msgstr "" -#: src/screens/Settings/AppearanceSettings.tsx:163 +#: src/screens/Settings/AppearanceSettings.tsx:161 msgid "Font size" msgstr "" @@ -3759,6 +3770,10 @@ msgstr "" msgid "Food" msgstr "" +#: src/components/BlockedGeoOverlay.tsx:45 +msgid "For now, we have made the difficult decision to block access to Bluesky in the state of Mississippi." +msgstr "" + #: src/view/com/modals/DeleteAccount.tsx:125 msgid "For security reasons, we'll need to send a confirmation code to your email address." msgstr "" @@ -3767,7 +3782,7 @@ msgstr "" msgid "For security reasons, you won't be able to view this again. If you lose this app password, you'll need to generate a new one." msgstr "" -#: src/screens/Settings/AppearanceSettings.tsx:145 +#: src/screens/Settings/AppearanceSettings.tsx:143 msgid "For the best experience, we recommend using the theme font." msgstr "" @@ -3810,10 +3825,6 @@ msgctxt "from-feed" msgid "From <0/>" msgstr "" -#: src/view/com/composer/photos/SelectPhotoBtn.tsx:50 -msgid "Gallery" -msgstr "" - #: src/components/StarterPack/ProfileStarterPacks.tsx:307 msgid "Generate a starter pack" msgstr "" @@ -3887,7 +3898,7 @@ msgstr "" msgid "Getting started" msgstr "" -#: src/components/MediaPreview.tsx:116 +#: src/components/MediaPreview.tsx:114 msgid "GIF" msgstr "" @@ -3967,6 +3978,7 @@ msgstr "" #: src/components/ageAssurance/AgeAssuranceAdmonition.tsx:89 #: src/components/ageAssurance/AgeRestrictedScreen.tsx:75 #: src/components/ageAssurance/AgeRestrictedScreen.tsx:84 +#: src/screens/Moderation/index.tsx:214 msgid "Go to account settings" msgstr "" @@ -4176,7 +4188,7 @@ msgstr "" msgid "Hmm, we're having trouble finding this feed. It may have been deleted." msgstr "" -#: src/screens/Moderation/index.tsx:59 +#: src/screens/Moderation/index.tsx:60 msgid "Hmmmm, it seems we're having trouble loading this data. See below for more details. If this issue persists, please contact us." msgstr "" @@ -4236,7 +4248,7 @@ msgstr "" msgid "I understand" msgstr "" -#: src/view/com/lightbox/Lightbox.web.tsx:186 +#: src/view/com/lightbox/Lightbox.web.tsx:187 msgid "If alt text is long, toggles alt text expanded state" msgstr "" @@ -4382,7 +4394,7 @@ msgstr "" msgid "Interaction limited" msgstr "" -#: src/screens/Moderation/index.tsx:222 +#: src/screens/Moderation/index.tsx:241 msgid "Interaction settings" msgstr "" @@ -4451,7 +4463,7 @@ msgstr "" msgid "It's just you right now! Add more people to your starter pack by searching above." msgstr "" -#: src/view/com/composer/Composer.tsx:1681 +#: src/view/com/composer/Composer.tsx:1758 msgid "Job ID: {0}" msgstr "" @@ -4536,7 +4548,7 @@ msgstr "" msgid "Languages" msgstr "" -#: src/screens/Settings/AppearanceSettings.tsx:175 +#: src/screens/Settings/AppearanceSettings.tsx:173 msgid "Larger" msgstr "" @@ -4667,7 +4679,7 @@ msgstr "" #: src/components/dialogs/Embed.tsx:162 #: src/components/dialogs/Embed.tsx:164 -#: src/screens/Settings/AppearanceSettings.tsx:104 +#: src/screens/Settings/AppearanceSettings.tsx:102 msgid "Light" msgstr "" @@ -4927,7 +4939,7 @@ msgstr "" msgid "Manage saved feeds" msgstr "" -#: src/screens/Moderation/index.tsx:292 +#: src/screens/Moderation/index.tsx:311 msgid "Manage verification settings" msgstr "" @@ -5025,7 +5037,7 @@ msgstr "" msgid "Messages" msgstr "" -#: src/screens/Settings/AppIconSettings/useAppIconSets.ts:101 +#: src/screens/Settings/AppIconSettings/useAppIconSets.ts:111 msgctxt "Name of app icon variant" msgid "Midnight" msgstr "" @@ -5045,7 +5057,7 @@ msgid "Misleading Post" msgstr "" #: src/Navigation.tsx:176 -#: src/screens/Moderation/index.tsx:99 +#: src/screens/Moderation/index.tsx:100 #: src/screens/Settings/Settings.tsx:188 #: src/screens/Settings/Settings.tsx:191 msgid "Moderation" @@ -5079,7 +5091,7 @@ msgctxt "toast" msgid "Moderation list updated" msgstr "" -#: src/screens/Moderation/index.tsx:252 +#: src/screens/Moderation/index.tsx:271 msgid "Moderation lists" msgstr "" @@ -5096,7 +5108,7 @@ msgstr "" msgid "Moderation states" msgstr "" -#: src/screens/Moderation/index.tsx:206 +#: src/screens/Moderation/index.tsx:225 msgid "Moderation tools" msgstr "" @@ -5211,7 +5223,7 @@ msgstr "" msgid "Mute words & tags" msgstr "" -#: src/screens/Moderation/index.tsx:267 +#: src/screens/Moderation/index.tsx:286 msgid "Muted accounts" msgstr "" @@ -5228,7 +5240,7 @@ msgstr "" msgid "Muted by \"{0}\"" msgstr "" -#: src/screens/Moderation/index.tsx:237 +#: src/screens/Moderation/index.tsx:256 msgid "Muted words & tags" msgstr "" @@ -5446,7 +5458,12 @@ msgstr "" msgid "Next" msgstr "" -#: src/view/com/lightbox/Lightbox.web.tsx:169 +#: src/screens/Settings/AppIconSettings/useAppIconSets.ts:42 +msgctxt "Name of app icon variant" +msgid "Next" +msgstr "" + +#: src/view/com/lightbox/Lightbox.web.tsx:170 msgid "Next image" msgstr "" @@ -5463,7 +5480,7 @@ msgstr "" msgid "No expiry set" msgstr "" -#: src/components/dialogs/GifSelect.tsx:232 +#: src/components/dialogs/GifSelect.tsx:230 msgid "No featured GIFs found. There may be an issue with Tenor." msgstr "" @@ -5481,7 +5498,7 @@ msgid "No likes yet" msgstr "" #: src/components/ProfileCard.tsx:496 -#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:110 +#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:114 msgid "No longer following {0}" msgstr "" @@ -5553,7 +5570,7 @@ msgstr "" msgid "No results." msgstr "" -#: src/components/dialogs/GifSelect.tsx:230 +#: src/components/dialogs/GifSelect.tsx:228 msgid "No search results found for \"{search}\"." msgstr "" @@ -5680,7 +5697,7 @@ msgstr "" msgid "Off" msgstr "" -#: src/components/dialogs/GifSelect.tsx:269 +#: src/components/dialogs/GifSelect.tsx:267 #: src/view/com/util/ErrorBoundary.tsx:57 msgid "Oh no!" msgstr "" @@ -5722,15 +5739,23 @@ msgstr "" msgid "Onboarding reset" msgstr "" -#: src/view/com/composer/Composer.tsx:347 +#: src/view/com/composer/Composer.tsx:354 msgid "One or more GIFs is missing alt text." msgstr "" -#: src/view/com/composer/Composer.tsx:344 +#: src/view/com/composer/Composer.tsx:351 msgid "One or more images is missing alt text." msgstr "" -#: src/view/com/composer/Composer.tsx:354 +#: src/view/com/composer/SelectMediaButton.tsx:390 +msgid "One or more of your selected files are not supported." +msgstr "" + +#: src/view/com/composer/SelectMediaButton.tsx:413 +msgid "One or more of your selected files is too large. Maximum size is 100 MB." +msgstr "" + +#: src/view/com/composer/Composer.tsx:361 msgid "One or more videos is missing alt text." msgstr "" @@ -5748,7 +5773,7 @@ msgstr "" msgid "Only followers who I follow" msgstr "" -#: src/lib/media/picker.shared.ts:32 +#: src/lib/media/picker.shared.ts:28 msgid "Only image files are supported" msgstr "" @@ -5787,7 +5812,7 @@ msgid "Open drawer menu" msgstr "" #: src/screens/Messages/components/MessageInput.web.tsx:181 -#: src/view/com/composer/Composer.tsx:1329 +#: src/view/com/composer/Composer.tsx:1406 msgid "Open emoji picker" msgstr "" @@ -5816,7 +5841,7 @@ msgstr "" msgid "Open moderation debug page" msgstr "" -#: src/screens/Moderation/index.tsx:233 +#: src/screens/Moderation/index.tsx:252 msgid "Open muted words and tags settings" msgstr "" @@ -5862,7 +5887,7 @@ msgstr "" msgid "Opens a dialog to choose who can reply to this thread" msgstr "" -#: src/view/screens/Log.tsx:59 +#: src/screens/Log.tsx:83 msgid "Opens additional details for a debug entry" msgstr "" @@ -5886,11 +5911,17 @@ msgstr "" msgid "Opens composer" msgstr "" -#: src/view/com/composer/photos/SelectPhotoBtn.tsx:51 -msgid "Opens device photo gallery" +#. Accessibility hint on web for button in composer to add images, a video, or a GIF to a post. Maximum number of images that can be selected is currently 4 but may change. +#: src/view/com/composer/SelectMediaButton.tsx:501 +msgid "Opens device gallery to select up to {MAX_IMAGES, plural, other {# images}}, or a single video or GIF." +msgstr "" + +#. Accessibility hint on native for button in composer to add images or a video to a post. Maximum number of images that can be selected is currently 4 but may change. +#: src/view/com/composer/SelectMediaButton.tsx:490 +msgid "Opens device gallery to select up to {MAX_IMAGES, plural, other {# images}}, or a single video." msgstr "" -#: src/view/com/composer/Composer.tsx:1330 +#: src/view/com/composer/Composer.tsx:1407 msgid "Opens emoji picker" msgstr "" @@ -5933,10 +5964,6 @@ msgstr "" msgid "Opens this profile" msgstr "" -#: src/view/com/composer/videos/SelectVideoBtn.tsx:75 -msgid "Opens video picker" -msgstr "" - #: src/components/dms/ReportDialog.tsx:221 #: src/components/ReportDialog/SubmitView.tsx:168 msgid "Optionally provide additional information below:" @@ -6310,12 +6337,12 @@ msgctxt "description" msgid "Post" msgstr "" -#: src/view/com/composer/Composer.tsx:1040 +#: src/view/com/composer/Composer.tsx:1053 msgctxt "action" msgid "Post" msgstr "" -#: src/view/com/composer/Composer.tsx:1038 +#: src/view/com/composer/Composer.tsx:1051 msgctxt "action" msgid "Post All" msgstr "" @@ -6443,7 +6470,7 @@ msgstr "" msgid "Press to view followers of this account that you also follow" msgstr "" -#: src/view/com/lightbox/Lightbox.web.tsx:150 +#: src/view/com/lightbox/Lightbox.web.tsx:151 msgid "Previous image" msgstr "" @@ -6490,7 +6517,7 @@ msgstr "" msgid "Privacy Policy" msgstr "" -#: src/view/com/composer/Composer.tsx:1744 +#: src/view/com/composer/Composer.tsx:1821 msgid "Processing video..." msgstr "" @@ -6529,22 +6556,22 @@ msgid "Public, sharable lists which can be used to drive feeds." msgstr "" #. Accessibility label for button to publish a single post -#: src/view/com/composer/Composer.tsx:1020 +#: src/view/com/composer/Composer.tsx:1033 msgid "Publish post" msgstr "" #. Accessibility label for button to publish multiple posts in a thread -#: src/view/com/composer/Composer.tsx:1013 +#: src/view/com/composer/Composer.tsx:1026 msgid "Publish posts" msgstr "" #. Accessibility label for button to publish multiple replies in a thread -#: src/view/com/composer/Composer.tsx:998 +#: src/view/com/composer/Composer.tsx:1011 msgid "Publish replies" msgstr "" #. Accessibility label for button to publish a single reply -#: src/view/com/composer/Composer.tsx:1005 +#: src/view/com/composer/Composer.tsx:1018 msgid "Publish reply" msgstr "" @@ -6643,7 +6670,7 @@ msgid "Reactivate your account" msgstr "" #: src/screens/PostThread/components/ThreadItemReadMore.tsx:92 -msgid "Read {0} more {1, plural, one {reply} other {replies}}" +msgid "Read {0, plural, one {# more reply} other {# more replies}}" msgstr "" #: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:158 @@ -6663,6 +6690,7 @@ msgstr "" msgid "Read more replies" msgstr "" +#: src/components/BlockedGeoOverlay.tsx:29 #: src/components/PolicyUpdateOverlay/updates/202508/index.tsx:112 msgid "Read our blog post" msgstr "" @@ -6841,11 +6869,11 @@ msgstr "" msgid "Remove your verification for this account?" msgstr "" -#: src/components/Post/Embed/index.tsx:208 +#: src/components/Post/Embed/index.tsx:214 msgid "Removed by author" msgstr "" -#: src/components/Post/Embed/index.tsx:206 +#: src/components/Post/Embed/index.tsx:212 msgid "Removed by you" msgstr "" @@ -6911,7 +6939,7 @@ msgstr "" msgid "Replies to this post are disabled." msgstr "" -#: src/view/com/composer/Composer.tsx:1036 +#: src/view/com/composer/Composer.tsx:1049 msgctxt "action" msgid "Reply" msgstr "" @@ -7239,8 +7267,8 @@ msgstr "" #: src/view/com/composer/GifAltText.tsx:202 #: src/view/com/composer/photos/EditImageDialog.web.tsx:62 #: src/view/com/composer/photos/EditImageDialog.web.tsx:75 -#: src/view/com/composer/photos/ImageAltTextDialog.tsx:153 -#: src/view/com/composer/photos/ImageAltTextDialog.tsx:163 +#: src/view/com/composer/photos/ImageAltTextDialog.tsx:152 +#: src/view/com/composer/photos/ImageAltTextDialog.tsx:162 #: src/view/com/modals/CreateOrEditList.tsx:315 #: src/view/screens/SavedFeeds.tsx:117 msgid "Save" @@ -7424,15 +7452,15 @@ msgstr "" msgid "See jobs at Bluesky" msgstr "" -#: src/components/FeedInterstitials.tsx:397 +#: src/components/FeedInterstitials.tsx:393 msgid "See more" msgstr "" -#: src/components/FeedInterstitials.tsx:444 +#: src/components/FeedInterstitials.tsx:437 msgid "See more accounts you might like" msgstr "" -#: src/components/FeedInterstitials.tsx:395 +#: src/components/FeedInterstitials.tsx:391 msgid "See more suggested profiles on the Explore page" msgstr "" @@ -7440,7 +7468,7 @@ msgstr "" msgid "See this guide" msgstr "" -#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx:195 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx:197 msgid "Seek slider. Use the arrow keys to seek forwards and backwards, and space to play/pause" msgstr "" @@ -7493,7 +7521,7 @@ msgstr "" msgid "Select GIF" msgstr "" -#: src/components/dialogs/GifSelect.tsx:307 +#: src/components/dialogs/GifSelect.tsx:305 msgid "Select GIF \"{0}\"" msgstr "" @@ -7539,10 +7567,6 @@ msgstr "" msgid "Select the moderation service(s) to report to" msgstr "" -#: src/view/com/composer/videos/SelectVideoBtn.tsx:74 -msgid "Select video" -msgstr "" - #: src/components/dialogs/MutedWords.tsx:242 msgid "Select what content this mute word should apply to." msgstr "" @@ -7572,6 +7596,10 @@ msgstr "" msgid "Select your preferred notification channels" msgstr "" +#: src/view/com/composer/SelectMediaButton.tsx:393 +msgid "Selecting multiple media types is not supported." +msgstr "" + #: src/view/com/util/forms/DropdownButton.tsx:302 msgid "Selects option {0} of {numItems}" msgstr "" @@ -7649,7 +7677,7 @@ msgstr "" msgid "Set app icon to {0}" msgstr "" -#: src/screens/Moderation/index.tsx:333 +#: src/screens/Moderation/index.tsx:352 msgid "Set birthdate" msgstr "" @@ -8015,7 +8043,7 @@ msgstr "" msgid "Signed in as @{0}" msgstr "" -#: src/components/FeedInterstitials.tsx:389 +#: src/components/FeedInterstitials.tsx:386 msgid "Similar accounts" msgstr "" @@ -8028,7 +8056,7 @@ msgstr "" msgid "Skip this flow" msgstr "" -#: src/screens/Settings/AppearanceSettings.tsx:167 +#: src/screens/Settings/AppearanceSettings.tsx:165 msgid "Smaller" msgstr "" @@ -8045,7 +8073,7 @@ msgstr "" msgid "Some of your verifications are invalid." msgstr "" -#: src/components/FeedInterstitials.tsx:529 +#: src/components/FeedInterstitials.tsx:519 msgid "Some other feeds you might like" msgstr "" @@ -8077,7 +8105,7 @@ msgid "Something went wrong, please try again" msgstr "" #: src/components/ReportDialog/index.tsx:54 -#: src/screens/Moderation/index.tsx:111 +#: src/screens/Moderation/index.tsx:112 #: src/screens/Profile/Sections/Labels.tsx:184 msgid "Something went wrong, please try again." msgstr "" @@ -8277,7 +8305,7 @@ msgstr "" msgid "Suggested Accounts" msgstr "" -#: src/components/FeedInterstitials.tsx:391 +#: src/components/FeedInterstitials.tsx:384 msgid "Suggested for you" msgstr "" @@ -8286,12 +8314,12 @@ msgstr "" msgid "Suggestive" msgstr "" -#: src/screens/Settings/AppIconSettings/useAppIconSets.ts:72 +#: src/screens/Settings/AppIconSettings/useAppIconSets.ts:82 msgctxt "Name of app icon variant" msgid "Sunrise" msgstr "" -#: src/screens/Settings/AppIconSettings/useAppIconSets.ts:86 +#: src/screens/Settings/AppIconSettings/useAppIconSets.ts:96 msgctxt "Name of app icon variant" msgid "Sunset" msgstr "" @@ -8324,11 +8352,12 @@ msgstr "" #: src/components/dialogs/Embed.tsx:157 #: src/components/dialogs/Embed.tsx:159 -#: src/screens/Settings/AppearanceSettings.tsx:100 -#: src/screens/Settings/AppearanceSettings.tsx:150 +#: src/screens/Settings/AppearanceSettings.tsx:98 +#: src/screens/Settings/AppearanceSettings.tsx:148 msgid "System" msgstr "" +#: src/screens/Log.tsx:58 #: src/screens/Settings/AboutSettings.tsx:107 #: src/screens/Settings/AboutSettings.tsx:110 #: src/screens/Settings/Settings.tsx:441 @@ -8455,7 +8484,7 @@ msgstr "" msgid "That starter pack could not be found." msgstr "" -#: src/screens/Signup/StepHandle/index.tsx:81 +#: src/screens/Signup/StepHandle/index.tsx:78 msgid "That username is already taken" msgstr "" @@ -8467,7 +8496,7 @@ msgstr "" msgid "That's everything!" msgstr "" -#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:316 +#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:324 #: src/view/com/profile/ProfileMenu.tsx:461 msgid "The account will be able to interact with you after unblocking." msgstr "" @@ -8482,7 +8511,7 @@ msgstr "" msgid "The author of this thread has hidden this reply." msgstr "" -#: src/screens/Moderation/index.tsx:407 +#: src/screens/Moderation/index.tsx:426 msgid "The Bluesky web application" msgstr "" @@ -8569,7 +8598,7 @@ msgstr "" msgid "The verification code you have provided is invalid. Please make sure that you have used the correct verification link or request a new one." msgstr "" -#: src/screens/Settings/AppearanceSettings.tsx:154 +#: src/screens/Settings/AppearanceSettings.tsx:152 msgid "Theme" msgstr "" @@ -8577,7 +8606,7 @@ msgstr "" msgid "There is no time limit for account deactivation, come back any time." msgstr "" -#: src/components/dialogs/GifSelect.tsx:227 +#: src/components/dialogs/GifSelect.tsx:225 msgid "There was an issue connecting to Tenor." msgstr "" @@ -8637,9 +8666,9 @@ msgstr "" #: src/components/PostControls/PostMenu/PostMenuItems.tsx:361 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:374 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:384 -#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:98 -#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:119 -#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:132 +#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:101 +#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:123 +#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:136 #: src/view/com/post-thread/PostThreadFollowBtn.tsx:90 #: src/view/com/post-thread/PostThreadFollowBtn.tsx:101 #: src/view/com/profile/ProfileMenu.tsx:128 @@ -8662,7 +8691,7 @@ msgstr "" msgid "There was an issue. Please check your internet connection and try again." msgstr "" -#: src/components/dialogs/GifSelect.tsx:271 +#: src/components/dialogs/GifSelect.tsx:269 #: src/view/com/util/ErrorBoundary.tsx:59 msgid "There was an unexpected issue in the application. Please let us know if this happened to you!" msgstr "" @@ -8840,7 +8869,7 @@ msgstr "" msgid "This post will be hidden from feeds and threads. This cannot be undone." msgstr "" -#: src/view/com/composer/Composer.tsx:463 +#: src/view/com/composer/Composer.tsx:470 msgid "This post's author has disabled quote posts." msgstr "" @@ -8974,7 +9003,7 @@ msgstr "" msgid "Toggle dropdown" msgstr "" -#: src/screens/Moderation/index.tsx:384 +#: src/screens/Moderation/index.tsx:403 msgid "Toggle to enable or disable adult content" msgstr "" @@ -9087,14 +9116,14 @@ msgstr "" #: src/components/dms/MessagesListBlockedFooter.tsx:104 #: src/components/dms/MessagesListBlockedFooter.tsx:112 #: src/components/dms/MessagesListBlockedFooter.tsx:119 -#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:203 -#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:320 +#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:208 +#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:328 #: src/view/com/profile/ProfileMenu.tsx:473 #: src/view/screens/ProfileList.tsx:723 msgid "Unblock" msgstr "" -#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:208 +#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:213 msgctxt "action" msgid "Unblock" msgstr "" @@ -9106,7 +9135,7 @@ msgstr "" msgid "Unblock account" msgstr "" -#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:314 +#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:322 #: src/view/com/profile/ProfileMenu.tsx:455 msgid "Unblock Account?" msgstr "" @@ -9130,7 +9159,7 @@ msgctxt "action" msgid "Unfollow" msgstr "" -#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:229 +#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:236 msgid "Unfollow {0}" msgstr "" @@ -9143,6 +9172,10 @@ msgstr "" msgid "Unfollows the user" msgstr "" +#: src/components/BlockedGeoOverlay.tsx:37 +msgid "Unfortunately, Bluesky is unavailable in Mississippi right now." +msgstr "" + #: src/components/moderation/ReportDialog/index.tsx:372 msgid "Unfortunately, none of your subscribed labelers supports this report type." msgstr "" @@ -9256,12 +9289,8 @@ msgstr "" msgid "Unsubscribed from list" msgstr "" -#: src/view/com/composer/Composer.tsx:818 -msgid "Unsupported video type" -msgstr "" - -#: src/view/com/composer/videos/SelectVideoBtn.tsx:48 -msgid "Unsupported video type: {0}" +#: src/view/com/composer/Composer.tsx:829 +msgid "Unsupported video type: {mimeType}" msgstr "" #: src/components/moderation/ReportDialog/utils/useReportOptions.ts:77 @@ -9346,7 +9375,7 @@ msgstr "" msgid "Uploading link thumbnail..." msgstr "" -#: src/view/com/composer/Composer.tsx:1741 +#: src/view/com/composer/Composer.tsx:1818 msgid "Uploading video..." msgstr "" @@ -9437,15 +9466,15 @@ msgctxt "toast" msgid "User list updated" msgstr "" -#: src/screens/Signup/StepHandle/index.tsx:235 +#: src/screens/Signup/StepHandle/index.tsx:231 msgid "Username cannot be longer than {MAX_SERVICE_HANDLE_LENGTH, plural, other {# characters}}" msgstr "" -#: src/screens/Signup/StepHandle/index.tsx:219 +#: src/screens/Signup/StepHandle/index.tsx:215 msgid "Username cannot begin or end with a hyphen" msgstr "" -#: src/screens/Signup/StepHandle/index.tsx:223 +#: src/screens/Signup/StepHandle/index.tsx:219 msgid "Username must only contain letters (a-z), numbers, and hyphens" msgstr "" @@ -9482,7 +9511,7 @@ msgstr "" msgid "Verification failed, please try again." msgstr "" -#: src/screens/Moderation/index.tsx:297 +#: src/screens/Moderation/index.tsx:316 msgid "Verification settings" msgstr "" @@ -9608,7 +9637,7 @@ msgstr "" msgid "Video settings" msgstr "" -#: src/view/com/composer/Composer.tsx:1751 +#: src/view/com/composer/Composer.tsx:1828 msgid "Video uploaded" msgstr "" @@ -9620,9 +9649,8 @@ msgstr "" msgid "Videos" msgstr "" -#: src/view/com/composer/videos/SelectVideoBtn.tsx:42 -#: src/view/com/composer/videos/SelectVideoBtn.tsx:55 -msgid "Videos must be less than 3 minutes long" +#: src/view/com/composer/SelectMediaButton.tsx:407 +msgid "Videos must be less than 3 minutes long." msgstr "" #: src/screens/Profile/Header/Shell.tsx:229 @@ -9637,7 +9665,7 @@ msgstr "" msgid "View {0}'s profile" msgstr "" -#: src/components/dms/MessagesListHeader.tsx:181 +#: src/components/dms/MessagesListHeader.tsx:142 msgid "View {displayName}'s profile" msgstr "" @@ -9649,7 +9677,7 @@ msgstr "" msgid "View blogpost for more details" msgstr "" -#: src/view/screens/Log.tsx:57 +#: src/screens/Log.tsx:81 msgid "View debug entry" msgstr "" @@ -9671,13 +9699,17 @@ msgstr "" msgid "View information about these labels" msgstr "" -#: src/components/interstitials/TrendingVideos.tsx:189 -#: src/components/interstitials/TrendingVideos.tsx:211 +#: src/components/interstitials/TrendingVideos.tsx:198 +#: src/components/interstitials/TrendingVideos.tsx:220 #: src/screens/Search/modules/ExploreTrendingVideos.tsx:194 #: src/screens/Search/modules/ExploreTrendingVideos.tsx:213 msgid "View more" msgstr "" +#: src/components/interstitials/TrendingVideos.tsx:226 +msgid "View more trending videos" +msgstr "" + #: src/components/ProfileHoverCard/index.web.tsx:466 #: src/components/ProfileHoverCard/index.web.tsx:486 #: src/components/ProfileHoverCard/index.web.tsx:513 @@ -9707,11 +9739,11 @@ msgstr "" msgid "View video" msgstr "" -#: src/screens/Moderation/index.tsx:277 +#: src/screens/Moderation/index.tsx:296 msgid "View your blocked accounts" msgstr "" -#: src/screens/Moderation/index.tsx:217 +#: src/screens/Moderation/index.tsx:236 msgid "View your default post interaction settings" msgstr "" @@ -9720,11 +9752,11 @@ msgstr "" msgid "View your feeds and explore more" msgstr "" -#: src/screens/Moderation/index.tsx:247 +#: src/screens/Moderation/index.tsx:266 msgid "View your moderation lists" msgstr "" -#: src/screens/Moderation/index.tsx:262 +#: src/screens/Moderation/index.tsx:281 msgid "View your muted accounts" msgstr "" @@ -9829,7 +9861,7 @@ msgstr "" msgid "We were unable to load your birth date preferences. Please try again." msgstr "" -#: src/screens/Moderation/index.tsx:464 +#: src/screens/Moderation/index.tsx:483 msgid "We were unable to load your configured labelers at this time." msgstr "" @@ -9894,7 +9926,7 @@ msgstr "" msgid "We're sorry, but your search could not be completed. Please try again in a few minutes." msgstr "" -#: src/view/com/composer/Composer.tsx:460 +#: src/view/com/composer/Composer.tsx:467 msgid "We're sorry! The post you are replying to has been deleted." msgstr "" @@ -9945,7 +9977,7 @@ msgstr "" #: src/view/com/auth/SplashScreen.tsx:38 #: src/view/com/auth/SplashScreen.web.tsx:99 -#: src/view/com/composer/Composer.tsx:781 +#: src/view/com/composer/Composer.tsx:789 msgid "What's up?" msgstr "" @@ -10027,11 +10059,11 @@ msgstr "" msgid "Write a message" msgstr "" -#: src/view/com/composer/Composer.tsx:876 +#: src/view/com/composer/Composer.tsx:889 msgid "Write post" msgstr "" -#: src/view/com/composer/Composer.tsx:779 +#: src/view/com/composer/Composer.tsx:787 #: src/view/com/post-thread/PostThreadComposePrompt.tsx:90 msgid "Write your reply" msgstr "" @@ -10170,10 +10202,23 @@ msgstr "" msgid "You can now sign in with your new password." msgstr "" +#: src/view/com/composer/SelectMediaButton.tsx:410 +msgid "You can only select one GIF at a time." +msgstr "" + +#: src/view/com/composer/SelectMediaButton.tsx:404 +msgid "You can only select one video at a time." +msgstr "" + #: src/screens/Deactivated.tsx:133 msgid "You can reactivate your account to continue logging in. Your profile and posts will be visible to other users." msgstr "" +#. Error message for maximum number of images that can be selected to add to a post, currently 4 but may change. +#: src/view/com/composer/SelectMediaButton.tsx:396 +msgid "You can select up to {MAX_IMAGES, plural, other {# images}} in total." +msgstr "" + #: src/components/dialogs/PostInteractionSettingsDialog.tsx:85 msgid "You can set default interaction settings in <0>Settings → Moderation → Interaction settings</0>." msgstr "" @@ -10316,10 +10361,6 @@ msgstr "" msgid "You may only add up to 3 feeds" msgstr "" -#: src/lib/media/picker.shared.ts:25 -msgid "You may only select up to 4 images" -msgstr "" - #: src/screens/Signup/StepInfo/Policies.tsx:136 msgid "You must be 13 years of age or older to create an account." msgstr "" @@ -10328,7 +10369,7 @@ msgstr "" msgid "You must be following at least seven other people to generate a starter pack." msgstr "" -#: src/screens/Moderation/index.tsx:355 +#: src/screens/Moderation/index.tsx:374 msgid "You must complete age assurance in order to access the settings below." msgstr "" @@ -10349,6 +10390,10 @@ msgstr "" msgid "You must sign in to view this post." msgstr "" +#: src/view/com/composer/SelectMediaButton.tsx:439 +msgid "You need to allow access to your media library." +msgstr "" + #: src/components/dialogs/EmailDialog/screens/Manage2FA/index.tsx:23 msgid "You need to verify your email address before you can enable email 2FA." msgstr "" @@ -10524,6 +10569,10 @@ msgstr "" msgid "Your current handle <0>{0}</0> will automatically remain reserved for you. You can switch back to it at any time from this account." msgstr "" +#: src/screens/Moderation/index.tsx:208 +msgid "Your declared age is under 18. Some settings below may be disabled. If this was a mistake, you may edit your birthdate in your <0>account settings</0>." +msgstr "" + #: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:253 #: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:257 #: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:258 @@ -10586,11 +10635,11 @@ msgstr "" msgid "Your password must be at least 8 characters long." msgstr "" -#: src/view/com/composer/Composer.tsx:522 +#: src/view/com/composer/Composer.tsx:529 msgid "Your post has been published" msgstr "" -#: src/view/com/composer/Composer.tsx:519 +#: src/view/com/composer/Composer.tsx:526 msgid "Your posts have been published" msgstr "" @@ -10606,7 +10655,7 @@ msgstr "" msgid "Your profile, posts, feeds, and lists will no longer be visible to other Bluesky users. You can reactivate your account at any time by logging in." msgstr "" -#: src/view/com/composer/Composer.tsx:521 +#: src/view/com/composer/Composer.tsx:528 msgid "Your reply has been published" msgstr "" diff --git a/src/logger/metrics.ts b/src/logger/metrics.ts index 0c9ea1ef6..e51905f84 100644 --- a/src/logger/metrics.ts +++ b/src/logger/metrics.ts @@ -475,4 +475,11 @@ export type MetricEvents = { 'ageAssurance:redirectDialogFail': {} 'ageAssurance:appealDialogOpen': {} 'ageAssurance:appealDialogSubmit': {} + + /* + * Specifically for the `BlockedGeoOverlay` + */ + 'blockedGeoOverlay:shown': {} + + 'geo:debug': {} } diff --git a/src/screens/Log.tsx b/src/screens/Log.tsx new file mode 100644 index 000000000..2dd7fe84c --- /dev/null +++ b/src/screens/Log.tsx @@ -0,0 +1,128 @@ +import {useCallback, useState} from 'react' +import {LayoutAnimation, View} from 'react-native' +import {Pressable} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useFocusEffect} from '@react-navigation/native' + +import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo' +import { + type CommonNavigatorParams, + type NativeStackScreenProps, +} from '#/lib/routes/types' +import {getEntries} from '#/logger/logDump' +import {useTickEveryMinute} from '#/state/shell' +import {useSetMinimalShellMode} from '#/state/shell' +import {atoms as a, useTheme} from '#/alf' +import { + ChevronBottom_Stroke2_Corner0_Rounded as ChevronBottomIcon, + ChevronTop_Stroke2_Corner0_Rounded as ChevronTopIcon, +} from '#/components/icons/Chevron' +import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo' +import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' +import * as Layout from '#/components/Layout' +import {Text} from '#/components/Typography' + +export function LogScreen({}: NativeStackScreenProps< + CommonNavigatorParams, + 'Log' +>) { + const t = useTheme() + const {_} = useLingui() + const setMinimalShellMode = useSetMinimalShellMode() + const [expanded, setExpanded] = useState<string[]>([]) + const timeAgo = useGetTimeAgo() + const tick = useTickEveryMinute() + + useFocusEffect( + useCallback(() => { + setMinimalShellMode(false) + }, [setMinimalShellMode]), + ) + + const toggler = (id: string) => () => { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + if (expanded.includes(id)) { + setExpanded(expanded.filter(v => v !== id)) + } else { + setExpanded([...expanded, id]) + } + } + + return ( + <Layout.Screen> + <Layout.Header.Outer> + <Layout.Header.BackButton /> + <Layout.Header.Content> + <Layout.Header.TitleText> + <Trans>System log</Trans> + </Layout.Header.TitleText> + </Layout.Header.Content> + <Layout.Header.Slot /> + </Layout.Header.Outer> + <Layout.Content> + {getEntries() + .slice(0) + .map(entry => { + return ( + <View key={`entry-${entry.id}`}> + <Pressable + style={[ + a.flex_row, + a.align_center, + a.py_md, + a.px_sm, + a.border_b, + t.atoms.border_contrast_low, + t.atoms.bg, + a.gap_sm, + ]} + onPress={toggler(entry.id)} + accessibilityLabel={_(msg`View debug entry`)} + accessibilityHint={_( + msg`Opens additional details for a debug entry`, + )}> + {entry.level === 'warn' || entry.level === 'error' ? ( + <WarningIcon size="sm" fill={t.palette.negative_500} /> + ) : ( + <CircleInfoIcon size="sm" /> + )} + <Text style={[a.flex_1]}>{String(entry.message)}</Text> + {entry.metadata && + Object.keys(entry.metadata).length > 0 && + (expanded.includes(entry.id) ? ( + <ChevronTopIcon + size="sm" + style={[t.atoms.text_contrast_low]} + /> + ) : ( + <ChevronBottomIcon + size="sm" + style={[t.atoms.text_contrast_low]} + /> + ))} + <Text style={[{minWidth: 40}, t.atoms.text_contrast_medium]}> + {timeAgo(entry.timestamp, tick)} + </Text> + </Pressable> + {expanded.includes(entry.id) && ( + <View + style={[ + t.atoms.bg_contrast_25, + a.rounded_xs, + a.p_sm, + a.border_b, + t.atoms.border_contrast_low, + ]}> + <View style={[a.px_sm, a.py_xs]}> + <Text>{JSON.stringify(entry.metadata, null, 2)}</Text> + </View> + </View> + )} + </View> + ) + })} + </Layout.Content> + </Layout.Screen> + ) +} diff --git a/src/screens/Moderation/index.tsx b/src/screens/Moderation/index.tsx index 1517792a1..983919c64 100644 --- a/src/screens/Moderation/index.tsx +++ b/src/screens/Moderation/index.tsx @@ -22,6 +22,7 @@ import { import {isNonConfigurableModerationAuthority} from '#/state/session/additional-moderation-authorities' import {useSetMinimalShellMode} from '#/state/shell' import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf' +import {Admonition} from '#/components/Admonition' import {AgeAssuranceAdmonition} from '#/components/ageAssurance/AgeAssuranceAdmonition' import {Button, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' @@ -201,6 +202,24 @@ export function ModerationScreenInner({ return ( <View style={[a.pt_2xl, a.px_lg, gtMobile && a.px_2xl]}> + {isDeclaredUnderage && ( + <View style={[a.pb_2xl]}> + <Admonition type="tip" style={[a.pb_md]}> + <Trans> + Your declared age is under 18. Some settings below may be + disabled. If this was a mistake, you may edit your birthdate in + your{' '} + <InlineLinkText + to="/settings/account" + label={_(msg`Go to account settings`)}> + account settings + </InlineLinkText> + . + </Trans> + </Admonition> + </View> + )} + <Text style={[a.text_md, a.font_bold, a.pb_md, t.atoms.text_contrast_high]}> <Trans>Moderation tools</Trans> diff --git a/src/screens/PostThread/components/ThreadItemReadMore.tsx b/src/screens/PostThread/components/ThreadItemReadMore.tsx index 22ae63395..66ec11cb7 100644 --- a/src/screens/PostThread/components/ThreadItemReadMore.tsx +++ b/src/screens/PostThread/components/ThreadItemReadMore.tsx @@ -90,10 +90,10 @@ export const ThreadItemReadMore = memo(function ThreadItemReadMore({ interacted && a.underline, ]}> <Trans> - Read {item.moreReplies} more{' '} + Read{' '} <Plural - one="reply" - other="replies" + one="# more reply" + other="# more replies" value={item.moreReplies} /> </Trans> diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx index 2f61ba4df..32111dd3b 100644 --- a/src/screens/Profile/Header/ProfileHeaderStandard.tsx +++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx @@ -1,4 +1,4 @@ -import React, {memo, useMemo} from 'react' +import {memo, useCallback, useMemo, useState} from 'react' import {View} from 'react-native' import { type AppBskyActorDefs, @@ -40,6 +40,7 @@ import {EditProfileDialog} from './EditProfileDialog' import {ProfileHeaderHandle} from './Handle' import {ProfileHeaderMetrics} from './Metrics' import {ProfileHeaderShell} from './Shell' +import {AnimatedProfileHeaderSuggestedFollows} from './SuggestedFollows' interface Props { profile: AppBskyActorDefs.ProfileViewDetailed @@ -73,6 +74,7 @@ let ProfileHeaderStandard = ({ const [_queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) const unblockPromptControl = Prompt.usePromptControl() const requireAuth = useRequireAuth() + const [showSuggestedFollows, setShowSuggestedFollows] = useState(false) const isBlockedUser = profile.viewer?.blocking || profile.viewer?.blockedBy || @@ -81,6 +83,7 @@ let ProfileHeaderStandard = ({ const editProfileControl = useDialogControl() const onPressFollow = () => { + setShowSuggestedFollows(true) requireAuth(async () => { try { await queueFollow() @@ -102,6 +105,7 @@ let ProfileHeaderStandard = ({ } const onPressUnfollow = () => { + setShowSuggestedFollows(false) requireAuth(async () => { try { await queueUnfollow() @@ -122,7 +126,7 @@ let ProfileHeaderStandard = ({ }) } - const unblockAccount = React.useCallback(async () => { + const unblockAccount = useCallback(async () => { try { await queueUnblock() Toast.show(_(msg({message: 'Account unblocked', context: 'toast'}))) @@ -155,174 +159,185 @@ let ProfileHeaderStandard = ({ }, [profile]) return ( - <ProfileHeaderShell - profile={profile} - moderation={moderation} - hideBackButton={hideBackButton} - isPlaceholderProfile={isPlaceholderProfile}> - <View - style={[a.px_lg, a.pt_md, a.pb_sm, a.overflow_hidden]} - pointerEvents={isIOS ? 'auto' : 'box-none'}> + <> + <ProfileHeaderShell + profile={profile} + moderation={moderation} + hideBackButton={hideBackButton} + isPlaceholderProfile={isPlaceholderProfile}> <View - style={[ - {paddingLeft: 90}, - a.flex_row, - a.align_center, - a.justify_end, - a.gap_xs, - a.pb_sm, - a.flex_wrap, - ]} + style={[a.px_lg, a.pt_md, a.pb_sm, a.overflow_hidden]} pointerEvents={isIOS ? 'auto' : 'box-none'}> - {isMe ? ( - <> - <Button - testID="profileHeaderEditProfileButton" - size="small" - color="secondary" - variant="solid" - onPress={editProfileControl.open} - label={_(msg`Edit profile`)} - style={[a.rounded_full]}> - <ButtonText> - <Trans>Edit Profile</Trans> - </ButtonText> - </Button> - <EditProfileDialog - profile={profile} - control={editProfileControl} - /> - </> - ) : profile.viewer?.blocking ? ( - profile.viewer?.blockingByList ? null : ( - <Button - testID="unblockBtn" - size="small" - color="secondary" - variant="solid" - label={_(msg`Unblock`)} - disabled={!hasSession} - onPress={() => unblockPromptControl.open()} - style={[a.rounded_full]}> - <ButtonText> - <Trans context="action">Unblock</Trans> - </ButtonText> - </Button> - ) - ) : !profile.viewer?.blockedBy ? ( - <> - {hasSession && subscriptionsAllowed && ( - <SubscribeProfileButton + <View + style={[ + {paddingLeft: 90}, + a.flex_row, + a.align_center, + a.justify_end, + a.gap_xs, + a.pb_sm, + a.flex_wrap, + ]} + pointerEvents={isIOS ? 'auto' : 'box-none'}> + {isMe ? ( + <> + <Button + testID="profileHeaderEditProfileButton" + size="small" + color="secondary" + variant="solid" + onPress={editProfileControl.open} + label={_(msg`Edit profile`)} + style={[a.rounded_full]}> + <ButtonText> + <Trans>Edit Profile</Trans> + </ButtonText> + </Button> + <EditProfileDialog profile={profile} - moderationOpts={moderationOpts} + control={editProfileControl} /> - )} - {hasSession && <MessageProfileButton profile={profile} />} - - <Button - testID={profile.viewer?.following ? 'unfollowBtn' : 'followBtn'} - size="small" - color={profile.viewer?.following ? 'secondary' : 'primary'} - variant="solid" - label={ - profile.viewer?.following - ? _(msg`Unfollow ${profile.handle}`) - : _(msg`Follow ${profile.handle}`) - } - onPress={ - profile.viewer?.following ? onPressUnfollow : onPressFollow - } - style={[a.rounded_full]}> - {!profile.viewer?.following && ( - <ButtonIcon position="left" icon={Plus} /> + </> + ) : profile.viewer?.blocking ? ( + profile.viewer?.blockingByList ? null : ( + <Button + testID="unblockBtn" + size="small" + color="secondary" + variant="solid" + label={_(msg`Unblock`)} + disabled={!hasSession} + onPress={() => unblockPromptControl.open()} + style={[a.rounded_full]}> + <ButtonText> + <Trans context="action">Unblock</Trans> + </ButtonText> + </Button> + ) + ) : !profile.viewer?.blockedBy ? ( + <> + {hasSession && subscriptionsAllowed && ( + <SubscribeProfileButton + profile={profile} + moderationOpts={moderationOpts} + /> )} - <ButtonText> - {profile.viewer?.following ? ( - <Trans>Following</Trans> - ) : profile.viewer?.followedBy ? ( - <Trans>Follow Back</Trans> - ) : ( - <Trans>Follow</Trans> + {hasSession && <MessageProfileButton profile={profile} />} + + <Button + testID={ + profile.viewer?.following ? 'unfollowBtn' : 'followBtn' + } + size="small" + color={profile.viewer?.following ? 'secondary' : 'primary'} + variant="solid" + label={ + profile.viewer?.following + ? _(msg`Unfollow ${profile.handle}`) + : _(msg`Follow ${profile.handle}`) + } + onPress={ + profile.viewer?.following ? onPressUnfollow : onPressFollow + } + style={[a.rounded_full]}> + {!profile.viewer?.following && ( + <ButtonIcon position="left" icon={Plus} /> )} - </ButtonText> - </Button> - </> - ) : null} - <ProfileMenu profile={profile} /> - </View> - <View - style={[a.flex_col, a.gap_xs, a.pb_sm, live ? a.pt_sm : a.pt_2xs]}> - <View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}> - <Text - emoji - testID="profileHeaderDisplayName" - style={[ - t.atoms.text, - gtMobile ? a.text_4xl : a.text_3xl, - a.self_start, - a.font_heavy, - a.leading_tight, - ]}> - {sanitizeDisplayName( - profile.displayName || sanitizeHandle(profile.handle), - moderation.ui('displayName'), - )} - <View + <ButtonText> + {profile.viewer?.following ? ( + <Trans>Following</Trans> + ) : profile.viewer?.followedBy ? ( + <Trans>Follow back</Trans> + ) : ( + <Trans>Follow</Trans> + )} + </ButtonText> + </Button> + </> + ) : null} + <ProfileMenu profile={profile} /> + </View> + <View + style={[a.flex_col, a.gap_xs, a.pb_sm, live ? a.pt_sm : a.pt_2xs]}> + <View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}> + <Text + emoji + testID="profileHeaderDisplayName" style={[ - a.pl_xs, - { - marginTop: platform({ios: 2}), - }, + t.atoms.text, + gtMobile ? a.text_4xl : a.text_3xl, + a.self_start, + a.font_heavy, + a.leading_tight, ]}> - <VerificationCheckButton profile={profile} size="lg" /> - </View> - </Text> + {sanitizeDisplayName( + profile.displayName || sanitizeHandle(profile.handle), + moderation.ui('displayName'), + )} + <View + style={[ + a.pl_xs, + { + marginTop: platform({ios: 2}), + }, + ]}> + <VerificationCheckButton profile={profile} size="lg" /> + </View> + </Text> + </View> + <ProfileHeaderHandle profile={profile} /> </View> - <ProfileHeaderHandle profile={profile} /> - </View> - {!isPlaceholderProfile && !isBlockedUser && ( - <View style={a.gap_md}> - <ProfileHeaderMetrics profile={profile} /> - {descriptionRT && !moderation.ui('profileView').blur ? ( - <View pointerEvents="auto"> - <RichText - testID="profileHeaderDescription" - style={[a.text_md]} - numberOfLines={15} - value={descriptionRT} - enableTags - authorHandle={profile.handle} - /> - </View> - ) : undefined} - - {!isMe && - !isBlockedUser && - shouldShowKnownFollowers(profile.viewer?.knownFollowers) && ( - <View style={[a.flex_row, a.align_center, a.gap_sm]}> - <KnownFollowers - profile={profile} - moderationOpts={moderationOpts} + {!isPlaceholderProfile && !isBlockedUser && ( + <View style={a.gap_md}> + <ProfileHeaderMetrics profile={profile} /> + {descriptionRT && !moderation.ui('profileView').blur ? ( + <View pointerEvents="auto"> + <RichText + testID="profileHeaderDescription" + style={[a.text_md]} + numberOfLines={15} + value={descriptionRT} + enableTags + authorHandle={profile.handle} /> </View> - )} - </View> - )} - </View> - <Prompt.Basic - control={unblockPromptControl} - title={_(msg`Unblock Account?`)} - description={_( - msg`The account will be able to interact with you after unblocking.`, - )} - onConfirm={unblockAccount} - confirmButtonCta={ - profile.viewer?.blocking ? _(msg`Unblock`) : _(msg`Block`) - } - confirmButtonColor="negative" + ) : undefined} + + {!isMe && + !isBlockedUser && + shouldShowKnownFollowers(profile.viewer?.knownFollowers) && ( + <View style={[a.flex_row, a.align_center, a.gap_sm]}> + <KnownFollowers + profile={profile} + moderationOpts={moderationOpts} + /> + </View> + )} + </View> + )} + </View> + + <Prompt.Basic + control={unblockPromptControl} + title={_(msg`Unblock Account?`)} + description={_( + msg`The account will be able to interact with you after unblocking.`, + )} + onConfirm={unblockAccount} + confirmButtonCta={ + profile.viewer?.blocking ? _(msg`Unblock`) : _(msg`Block`) + } + confirmButtonColor="negative" + /> + </ProfileHeaderShell> + + <AnimatedProfileHeaderSuggestedFollows + isExpanded={showSuggestedFollows} + actorDid={profile.did} /> - </ProfileHeaderShell> + </> ) } + ProfileHeaderStandard = memo(ProfileHeaderStandard) export {ProfileHeaderStandard} diff --git a/src/screens/Profile/Header/Shell.tsx b/src/screens/Profile/Header/Shell.tsx index 167be0aa8..cff0a707c 100644 --- a/src/screens/Profile/Header/Shell.tsx +++ b/src/screens/Profile/Header/Shell.tsx @@ -211,7 +211,7 @@ let ProfileHeaderShell = ({ {!isPlaceholderProfile && ( <View - style={[a.px_lg, a.py_xs]} + style={[a.px_lg, a.pt_xs, a.pb_sm]} pointerEvents={isIOS ? 'auto' : 'box-none'}> {isMe ? ( <LabelsOnMe type="account" labels={profile.labels} /> diff --git a/src/screens/Profile/Header/SuggestedFollows.tsx b/src/screens/Profile/Header/SuggestedFollows.tsx new file mode 100644 index 000000000..d005d888e --- /dev/null +++ b/src/screens/Profile/Header/SuggestedFollows.tsx @@ -0,0 +1,45 @@ +import {AccordionAnimation} from '#/lib/custom-animations/AccordionAnimation' +import {useGate} from '#/lib/statsig/statsig' +import {isAndroid} from '#/platform/detection' +import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows' +import {ProfileGrid} from '#/components/FeedInterstitials' + +export function ProfileHeaderSuggestedFollows({actorDid}: {actorDid: string}) { + const {isLoading, data, error} = useSuggestedFollowsByActorQuery({ + did: actorDid, + }) + + return ( + <ProfileGrid + isSuggestionsLoading={isLoading} + profiles={data?.suggestions ?? []} + recId={data?.recId} + error={error} + viewContext="profileHeader" + /> + ) +} + +export function AnimatedProfileHeaderSuggestedFollows({ + isExpanded, + actorDid, +}: { + isExpanded: boolean + actorDid: string +}) { + const gate = useGate() + if (!gate('post_follow_profile_suggested_accounts')) return null + + /* NOTE (caidanw): + * Android does not work well with this feature yet. + * This issue stems from Android not allowing dragging on clickable elements in the profile header. + * Blocking the ability to scroll on Android is too much of a trade-off for now. + **/ + if (isAndroid) return null + + return ( + <AccordionAnimation isExpanded={isExpanded}> + <ProfileHeaderSuggestedFollows actorDid={actorDid} /> + </AccordionAnimation> + ) +} diff --git a/src/screens/Settings/AppIconSettings/index.tsx b/src/screens/Settings/AppIconSettings/index.tsx index 799873c2d..953ae2e60 100644 --- a/src/screens/Settings/AppIconSettings/index.tsx +++ b/src/screens/Settings/AppIconSettings/index.tsx @@ -28,7 +28,7 @@ export function AppIconSettingsScreen({}: Props) { getAppIconName(DynamicAppIcon.getAppIcon()), ) - const onSetAppIcon = (icon: string) => { + const onSetAppIcon = (icon: DynamicAppIcon.IconName) => { if (isAndroid) { const next = sets.defaults.find(i => i.id === icon) ?? @@ -37,7 +37,7 @@ export function AppIconSettingsScreen({}: Props) { next ? _(msg`Change app icon to "${next.name}"`) : _(msg`Change app icon`), - // to determine - can we stop this happening? -sfn + // unfortunately necessary -sfn _(msg`The app will be restarted`), [ { @@ -119,7 +119,7 @@ export function AppIconSettingsScreen({}: Props) { ) } -function setAppIcon(icon: string) { +function setAppIcon(icon: DynamicAppIcon.IconName) { if (icon === 'default_light') { return getAppIconName(DynamicAppIcon.setAppIcon(null)) } else { @@ -127,11 +127,11 @@ function setAppIcon(icon: string) { } } -function getAppIconName(icon: string | false) { +function getAppIconName(icon: string | false): DynamicAppIcon.IconName { if (!icon || icon === 'DEFAULT') { return 'default_light' } else { - return icon + return icon as DynamicAppIcon.IconName } } @@ -143,8 +143,8 @@ function Group({ }: { children: React.ReactNode label: string - value: string - onChange: (value: string) => void + value: DynamicAppIcon.IconName + onChange: (value: DynamicAppIcon.IconName) => void }) { return ( <Toggle.Group @@ -153,7 +153,7 @@ function Group({ values={[value]} maxSelections={1} onChange={vals => { - if (vals[0]) onChange(vals[0]) + if (vals[0]) onChange(vals[0] as DynamicAppIcon.IconName) }}> <View style={[a.flex_1, a.rounded_md, a.overflow_hidden]}> {children} diff --git a/src/screens/Settings/AppIconSettings/types.ts b/src/screens/Settings/AppIconSettings/types.ts index 5010f6f02..02c2791dc 100644 --- a/src/screens/Settings/AppIconSettings/types.ts +++ b/src/screens/Settings/AppIconSettings/types.ts @@ -1,7 +1,8 @@ -import {ImageSourcePropType} from 'react-native' +import {type ImageSourcePropType} from 'react-native' +import type * as DynamicAppIcon from '@mozzius/expo-dynamic-app-icon' export type AppIconSet = { - id: string + id: DynamicAppIcon.IconName name: string iosImage: () => ImageSourcePropType androidImage: () => ImageSourcePropType diff --git a/src/screens/Settings/AppIconSettings/useAppIconSets.ts b/src/screens/Settings/AppIconSettings/useAppIconSets.ts index fd3caeb30..f7d191f77 100644 --- a/src/screens/Settings/AppIconSettings/useAppIconSets.ts +++ b/src/screens/Settings/AppIconSettings/useAppIconSets.ts @@ -37,6 +37,16 @@ export function useAppIconSets() { ) }, }, + { + id: 'next', + name: _(msg({context: 'Name of app icon variant', message: 'Next'})), + iosImage: () => { + return require(`../../../../assets/app-icons/icon_default_next.png`) + }, + androidImage: () => { + return require(`../../../../assets/app-icons/icon_default_next.png`) + }, + }, ] satisfies AppIconSet[] /** diff --git a/src/screens/Settings/AppearanceSettings.tsx b/src/screens/Settings/AppearanceSettings.tsx index 492d6d172..5d597ff8e 100644 --- a/src/screens/Settings/AppearanceSettings.tsx +++ b/src/screens/Settings/AppearanceSettings.tsx @@ -12,7 +12,6 @@ import { type CommonNavigatorParams, type NativeStackScreenProps, } from '#/lib/routes/types' -import {useGate} from '#/lib/statsig/statsig' import {isNative} from '#/platform/detection' import {useSetThemePrefs, useThemePrefs} from '#/state/shell' import {SettingsListItem as AppIconSettingsListItem} from '#/screens/Settings/AppIconSettings/SettingsListItem' @@ -32,7 +31,6 @@ type Props = NativeStackScreenProps<CommonNavigatorParams, 'AppearanceSettings'> export function AppearanceSettingsScreen({}: Props) { const {_} = useLingui() const {fonts} = useAlf() - const gate = useGate() const {colorMode, darkTheme} = useThemePrefs() const {setColorMode, setDarkTheme} = useSetThemePrefs() @@ -180,7 +178,7 @@ export function AppearanceSettingsScreen({}: Props) { onChange={onChangeFontScale} /> - {isNative && IS_INTERNAL && gate('debug_subscriptions') && ( + {isNative && IS_INTERNAL && ( <> <SettingsList.Divider /> <AppIconSettingsListItem /> diff --git a/src/screens/Signup/StepHandle/index.tsx b/src/screens/Signup/StepHandle/index.tsx index aaab435ae..5bf6b2269 100644 --- a/src/screens/Signup/StepHandle/index.tsx +++ b/src/screens/Signup/StepHandle/index.tsx @@ -9,7 +9,6 @@ import Animated, { import {msg, Plural, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useGate} from '#/lib/statsig/statsig' import { createFullHandle, MAX_SERVICE_HANDLE_LENGTH, @@ -28,14 +27,12 @@ import {useThrottledValue} from '#/components/hooks/useThrottledValue' import {At_Stroke2_Corner0_Rounded as AtIcon} from '#/components/icons/At' import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' import {Text} from '#/components/Typography' -import {IS_INTERNAL} from '#/env' import {BackNextButtons} from '../BackNextButtons' import {HandleSuggestions} from './HandleSuggestions' export function StepHandle() { const {_} = useLingui() const t = useTheme() - const gate = useGate() const {state, dispatch} = useSignupContext() const [draftValue, setDraftValue] = useState(state.handle) const isNextLoading = useThrottledValue(state.isLoading, 500) @@ -193,8 +190,7 @@ export function StepHandle() { </RequirementText> </Requirement> {isHandleAvailable.suggestions && - isHandleAvailable.suggestions.length > 0 && - (gate('handle_suggestions') || IS_INTERNAL) && ( + isHandleAvailable.suggestions.length > 0 && ( <HandleSuggestions suggestions={isHandleAvailable.suggestions} onSelect={suggestion => { diff --git a/src/state/cache/post-shadow.ts b/src/state/cache/post-shadow.ts index d7f1eb8b9..8cc3dca1a 100644 --- a/src/state/cache/post-shadow.ts +++ b/src/state/cache/post-shadow.ts @@ -24,6 +24,7 @@ export interface PostShadow { isDeleted: boolean embed: AppBskyEmbedRecord.View | AppBskyEmbedRecordWithMedia.View | undefined pinned: boolean + optimisticReplyCount: number | undefined } export const POST_TOMBSTONE = Symbol('PostTombstone') @@ -34,6 +35,14 @@ const shadows: WeakMap< Partial<PostShadow> > = new WeakMap() +/** + * Use with caution! This function returns the raw shadow data for a post. + * Prefer using `usePostShadow`. + */ +export function dangerousGetPostShadow(post: AppBskyFeedDefs.PostView) { + return shadows.get(post) +} + export function usePostShadow( post: AppBskyFeedDefs.PostView, ): Shadow<AppBskyFeedDefs.PostView> | typeof POST_TOMBSTONE { @@ -95,6 +104,11 @@ function mergeShadow( repostCount = Math.max(0, repostCount) } + let replyCount = post.replyCount ?? 0 + if ('optimisticReplyCount' in shadow) { + replyCount = shadow.optimisticReplyCount ?? replyCount + } + let embed: typeof post.embed if ('embed' in shadow) { if ( @@ -112,6 +126,7 @@ function mergeShadow( embed: embed || post.embed, likeCount: likeCount, repostCount: repostCount, + replyCount: replyCount, viewer: { ...(post.viewer || {}), like: 'likeUri' in shadow ? shadow.likeUri : post.viewer?.like, diff --git a/src/state/feed-feedback.tsx b/src/state/feed-feedback.tsx index ee381259d..8b235f492 100644 --- a/src/state/feed-feedback.tsx +++ b/src/state/feed-feedback.tsx @@ -11,6 +11,7 @@ import {type AppBskyFeedDefs} from '@atproto/api' import throttle from 'lodash.throttle' import {FEEDBACK_FEEDS, STAGING_FEEDS} from '#/lib/constants' +import {isNetworkError} from '#/lib/hooks/useCleanError' import {logEvent} from '#/lib/statsig/statsig' import {Logger} from '#/logger' import { @@ -83,7 +84,9 @@ export function useFeedFeedback( }, ) .catch((e: any) => { - logger.warn('Failed to send feed interactions', {error: e}) + if (!isNetworkError(e)) { + logger.warn('Failed to send feed interactions', {error: e}) + } }) // Send to Statsig diff --git a/src/state/geolocation.tsx b/src/state/geolocation.tsx index 4581996a0..c4d8cb946 100644 --- a/src/state/geolocation.tsx +++ b/src/state/geolocation.tsx @@ -5,6 +5,9 @@ import {networkRetry} from '#/lib/async/retry' import {logger} from '#/logger' import {type Device, device} from '#/storage' +const IPCC_URL = `https://bsky.app/ipcc` +const BAPP_CONFIG_URL = `https://ip.bsky.app/config` + const events = new EventEmitter() const EVENT = 'geolocation-updated' const emitGeolocationUpdate = (geolocation: Device['geolocation']) => { @@ -25,11 +28,22 @@ const onGeolocationUpdate = ( */ export const DEFAULT_GEOLOCATION: Device['geolocation'] = { countryCode: undefined, + isAgeBlockedGeo: undefined, isAgeRestrictedGeo: false, } -async function getGeolocation(): Promise<Device['geolocation']> { - const res = await fetch(`https://bsky.app/ipcc`) +function sanitizeGeolocation( + geolocation: Device['geolocation'], +): Device['geolocation'] { + return { + countryCode: geolocation?.countryCode ?? undefined, + isAgeBlockedGeo: geolocation?.isAgeBlockedGeo ?? false, + isAgeRestrictedGeo: geolocation?.isAgeRestrictedGeo ?? false, + } +} + +async function getGeolocation(url: string): Promise<Device['geolocation']> { + const res = await fetch(url) if (!res.ok) { throw new Error(`geolocation: lookup failed ${res.status}`) @@ -40,13 +54,41 @@ async function getGeolocation(): Promise<Device['geolocation']> { if (json.countryCode) { return { countryCode: json.countryCode, + isAgeBlockedGeo: json.isAgeBlockedGeo ?? false, isAgeRestrictedGeo: json.isAgeRestrictedGeo ?? false, + // @ts-ignore + regionCode: json.regionCode ?? undefined, } } else { return undefined } } +async function compareWithIPCC(bapp: Device['geolocation']) { + try { + const ipcc = await getGeolocation(IPCC_URL) + + if (!ipcc || !bapp) return + + logger.metric( + 'geo:debug', + { + bappCountryCode: bapp.countryCode, + // @ts-ignore + bappRegionCode: bapp.regionCode, + bappIsAgeBlockedGeo: bapp.isAgeBlockedGeo, + bappIsAgeRestrictedGeo: bapp.isAgeRestrictedGeo, + ipccCountryCode: ipcc.countryCode, + ipccIsAgeBlockedGeo: ipcc.isAgeBlockedGeo, + ipccIsAgeRestrictedGeo: ipcc.isAgeRestrictedGeo, + }, + { + statsig: false, + }, + ) + } catch {} +} + /** * Local promise used within this file only. */ @@ -79,11 +121,12 @@ export function beginResolveGeolocation() { try { // Try once, fail fast - const geolocation = await getGeolocation() + const geolocation = await getGeolocation(BAPP_CONFIG_URL) if (geolocation) { - device.set(['geolocation'], geolocation) + device.set(['geolocation'], sanitizeGeolocation(geolocation)) emitGeolocationUpdate(geolocation) logger.debug(`geolocation: success`, {geolocation}) + compareWithIPCC(geolocation) } else { // endpoint should throw on all failures, this is insurance throw new Error(`geolocation: nothing returned from initial request`) @@ -99,13 +142,14 @@ export function beginResolveGeolocation() { device.set(['geolocation'], DEFAULT_GEOLOCATION) // retry 3 times, but don't await, proceed with default - networkRetry(3, getGeolocation) + networkRetry(3, () => getGeolocation(BAPP_CONFIG_URL)) .then(geolocation => { if (geolocation) { - device.set(['geolocation'], geolocation) + device.set(['geolocation'], sanitizeGeolocation(geolocation)) emitGeolocationUpdate(geolocation) logger.debug(`geolocation: success`, {geolocation}) success = true + compareWithIPCC(geolocation) } else { // endpoint should throw on all failures, this is insurance throw new Error(`geolocation: nothing returned from retries`) diff --git a/src/state/queries/suggested-follows.ts b/src/state/queries/suggested-follows.ts index 0a2343150..c7a6e5f75 100644 --- a/src/state/queries/suggested-follows.ts +++ b/src/state/queries/suggested-follows.ts @@ -1,13 +1,13 @@ import { - AppBskyActorDefs, - AppBskyActorGetSuggestions, - AppBskyGraphGetSuggestedFollowsByActor, + type AppBskyActorDefs, + type AppBskyActorGetSuggestions, + type AppBskyGraphGetSuggestedFollowsByActor, moderateProfile, } from '@atproto/api' import { - InfiniteData, - QueryClient, - QueryKey, + type InfiniteData, + type QueryClient, + type QueryKey, useInfiniteQuery, useQuery, } from '@tanstack/react-query' @@ -106,12 +106,15 @@ export function useSuggestedFollowsQuery(options?: SuggestedFollowsOptions) { export function useSuggestedFollowsByActorQuery({ did, enabled, + staleTime = STALE.MINUTES.FIVE, }: { did: string enabled?: boolean + staleTime?: number }) { const agent = useAgent() return useQuery({ + staleTime, queryKey: suggestedFollowsByActorQueryKey(did), queryFn: async () => { const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({ diff --git a/src/state/queries/usePostThread/queryCache.ts b/src/state/queries/usePostThread/queryCache.ts index 826932349..5e27ebb87 100644 --- a/src/state/queries/usePostThread/queryCache.ts +++ b/src/state/queries/usePostThread/queryCache.ts @@ -9,6 +9,10 @@ import { } from '@atproto/api' import {type QueryClient} from '@tanstack/react-query' +import { + dangerousGetPostShadow, + updatePostShadow, +} from '#/state/cache/post-shadow' import {findAllPostsInQueryData as findAllPostsInExploreFeedPreviewsQueryData} from '#/state/queries/explore-feed-previews' import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from '#/state/queries/notifications/feed' import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from '#/state/queries/post-feed' @@ -85,10 +89,27 @@ export function createCacheMutator({ /* * Update parent data */ - parent.value.post = { - ...parent.value.post, - replyCount: (parent.value.post.replyCount || 0) + 1, - } + const shadow = dangerousGetPostShadow(parent.value.post) + const prevOptimisticCount = shadow?.optimisticReplyCount + const prevReplyCount = parent.value.post.replyCount + // prefer optimistic count, if we already have some + const currentReplyCount = + (prevOptimisticCount ?? prevReplyCount ?? 0) + 1 + + /* + * We must update the value in the query cache in order for thread + * traversal to properly compute required metadata. + */ + parent.value.post.replyCount = currentReplyCount + + /** + * Additionally, we need to update the post shadow to keep track of + * these new values, since mutating the post object above does not + * cause a re-render. + */ + updatePostShadow(queryClient, parent.value.post.uri, { + optimisticReplyCount: currentReplyCount, + }) const opDid = getRootPostAtUri(parent.value.post)?.host const nextPreexistingItem = thread.at(i + 1) diff --git a/src/state/queries/usePostThread/traversal.ts b/src/state/queries/usePostThread/traversal.ts index 2809d32e9..2e7693fab 100644 --- a/src/state/queries/usePostThread/traversal.ts +++ b/src/state/queries/usePostThread/traversal.ts @@ -307,9 +307,16 @@ export function sortAndAnnotateThreadItems( metadata.isPartOfLastBranchFromDepth = metadata.depth /** - * If the parent is part of the last branch of the sub-tree, so is the child. + * If the parent is part of the last branch of the sub-tree, so + * is the child. However, if the child is also a last sibling, + * then we need to start tracking `isPartOfLastBranchFromDepth` + * from this point onwards, always updating it to the depth of + * the last sibling as we go down. */ - if (metadata.parentMetadata.isPartOfLastBranchFromDepth) { + if ( + !metadata.isLastSibling && + metadata.parentMetadata.isPartOfLastBranchFromDepth + ) { metadata.isPartOfLastBranchFromDepth = metadata.parentMetadata.isPartOfLastBranchFromDepth } diff --git a/src/state/queries/usePostThread/types.ts b/src/state/queries/usePostThread/types.ts index 2f370b0ab..5df7c2e42 100644 --- a/src/state/queries/usePostThread/types.ts +++ b/src/state/queries/usePostThread/types.ts @@ -151,8 +151,8 @@ export type TraversalMetadata = { */ isLastChild: boolean /** - * Indicates if the post is the left/lower-most branch of the reply tree. - * Value corresponds to the depth at which this branch started. + * Indicates if the post is the left-most AND lower-most branch of the reply + * tree. Value corresponds to the depth at which this branch started. */ isPartOfLastBranchFromDepth?: number /** diff --git a/src/storage/schema.ts b/src/storage/schema.ts index 421264ac1..a3f2336cf 100644 --- a/src/storage/schema.ts +++ b/src/storage/schema.ts @@ -10,6 +10,7 @@ export type Device = { geolocation?: { countryCode: string | undefined isAgeRestrictedGeo: boolean | undefined + isAgeBlockedGeo: boolean | undefined } trendingBetaEnabled: boolean devMode: boolean diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 296545353..d0dbdfaba 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -77,7 +77,11 @@ import {logger} from '#/logger' import {isAndroid, isIOS, isNative, isWeb} from '#/platform/detection' import {useDialogStateControlContext} from '#/state/dialogs' import {emitPostCreated} from '#/state/events' -import {type ComposerImage, pasteImage} from '#/state/gallery' +import { + type ComposerImage, + createComposerImage, + pasteImage, +} from '#/state/gallery' import {useModalControls} from '#/state/modals' import {useRequireAltTextEnabled} from '#/state/preferences' import { @@ -103,7 +107,6 @@ import {LabelsBtn} from '#/view/com/composer/labels/LabelsBtn' import {Gallery} from '#/view/com/composer/photos/Gallery' import {OpenCameraBtn} from '#/view/com/composer/photos/OpenCameraBtn' import {SelectGifBtn} from '#/view/com/composer/photos/SelectGifBtn' -import {SelectPhotoBtn} from '#/view/com/composer/photos/SelectPhotoBtn' import {SelectLangBtn} from '#/view/com/composer/select-language/SelectLangBtn' import {SuggestedLanguage} from '#/view/com/composer/select-language/SuggestedLanguage' // TODO: Prevent naming components that coincide with RN primitives @@ -113,12 +116,10 @@ import { type TextInputRef, } from '#/view/com/composer/text-input/TextInput' import {ThreadgateBtn} from '#/view/com/composer/threadgate/ThreadgateBtn' -import {SelectVideoBtn} from '#/view/com/composer/videos/SelectVideoBtn' import {SubtitleDialogBtn} from '#/view/com/composer/videos/SubtitleDialog' import {VideoPreview} from '#/view/com/composer/videos/VideoPreview' import {VideoTranscodeProgress} from '#/view/com/composer/videos/VideoTranscodeProgress' import {Text} from '#/view/com/util/text/Text' -import * as Toast from '#/view/com/util/Toast' import {UserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, native, useTheme, web} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' @@ -127,9 +128,15 @@ import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' import {LazyQuoteEmbed} from '#/components/Post/Embed/LazyQuoteEmbed' import * as Prompt from '#/components/Prompt' +import * as toast from '#/components/Toast' import {Text as NewText} from '#/components/Typography' import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet' import { + type AssetType, + SelectMediaButton, + type SelectMediaButtonProps, +} from './SelectMediaButton' +import { type ComposerAction, composerReducer, createComposerState, @@ -514,12 +521,13 @@ export const ComposePost = ({ onPostSuccess?.(postSuccessData) } onClose() - Toast.show( + toast.show( thread.posts.length > 1 ? _(msg`Your posts have been published`) : replyTo ? _(msg`Your reply has been published`) : _(msg`Your post has been published`), + {type: 'success'}, ) }, [ _, @@ -811,11 +819,16 @@ let ComposerPost = React.memo(function ComposerPost({ const onPhotoPasted = useCallback( async (uri: string) => { - if (uri.startsWith('data:video/') || uri.startsWith('data:image/gif')) { + if ( + uri.startsWith('data:video/') || + (isWeb && uri.startsWith('data:image/gif')) + ) { if (isNative) return // web only const [mimeType] = uri.slice('data:'.length).split(';') if (!SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes)) { - Toast.show(_(msg`Unsupported video type`), 'xmark') + toast.show(_(msg`Unsupported video type: ${mimeType}`), { + type: 'error', + }) return } const name = `pasted.${mimeToExt(mimeType)}` @@ -1251,7 +1264,6 @@ function ComposerFooter({ dispatch, showAddButton, onEmojiButtonPress, - onError, onSelectVideo, onAddPost, }: { @@ -1266,11 +1278,32 @@ function ComposerFooter({ const t = useTheme() const {_} = useLingui() const {isMobile} = useWebMediaQueries() + /* + * Once we've allowed a certain type of asset to be selected, we don't allow + * other types of media to be selected. + */ + const [selectedAssetsType, setSelectedAssetsType] = useState< + AssetType | undefined + >(undefined) const media = post.embed.media const images = media?.type === 'images' ? media.images : [] const video = media?.type === 'video' ? media.video : null const isMaxImages = images.length >= MAX_IMAGES + const isMaxVideos = !!video + + let selectedAssetsCount = 0 + let isMediaSelectionDisabled = false + + if (media?.type === 'images') { + isMediaSelectionDisabled = isMaxImages + selectedAssetsCount = images.length + } else if (media?.type === 'video') { + isMediaSelectionDisabled = isMaxVideos + selectedAssetsCount = 1 + } else { + isMediaSelectionDisabled = !!media + } const onImageAdd = useCallback( (next: ComposerImage[]) => { @@ -1289,6 +1322,54 @@ function ComposerFooter({ [dispatch], ) + /* + * Reset if the user clears any selected media + */ + if (selectedAssetsType !== undefined && !media) { + setSelectedAssetsType(undefined) + } + + const onSelectAssets = useCallback<SelectMediaButtonProps['onSelectAssets']>( + async ({type, assets, errors}) => { + setSelectedAssetsType(type) + + if (assets.length) { + if (type === 'image') { + const images: ComposerImage[] = [] + + await Promise.all( + assets.map(async image => { + const composerImage = await createComposerImage({ + path: image.uri, + width: image.width, + height: image.height, + mime: image.mimeType!, + }) + images.push(composerImage) + }), + ).catch(e => { + logger.error(`createComposerImage failed`, { + safeMessage: e.message, + }) + }) + + onImageAdd(images) + } else if (type === 'video') { + onSelectVideo(post.id, assets[0]) + } else if (type === 'gif') { + onSelectVideo(post.id, assets[0]) + } + } + + errors.map(error => { + toast.show(error, { + type: 'warning', + }) + }) + }, + [post.id, onSelectVideo, onImageAdd], + ) + return ( <View style={[ @@ -1307,15 +1388,11 @@ function ComposerFooter({ <VideoUploadToolbar state={video} /> ) : ( <ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}> - <SelectPhotoBtn - size={images.length} - disabled={media?.type === 'images' ? isMaxImages : !!media} - onAdd={onImageAdd} - /> - <SelectVideoBtn - onSelectVideo={asset => onSelectVideo(post.id, asset)} - disabled={!!media} - setError={onError} + <SelectMediaButton + disabled={isMediaSelectionDisabled} + allowedAssetTypes={selectedAssetsType} + selectedAssetsCount={selectedAssetsCount} + onSelectAssets={onSelectAssets} /> <OpenCameraBtn disabled={media?.type === 'images' ? isMaxImages : !!media} diff --git a/src/view/com/composer/SelectMediaButton.tsx b/src/view/com/composer/SelectMediaButton.tsx new file mode 100644 index 000000000..026d0ac19 --- /dev/null +++ b/src/view/com/composer/SelectMediaButton.tsx @@ -0,0 +1,524 @@ +import {useCallback} from 'react' +import {Keyboard} from 'react-native' +import { + type ImagePickerAsset, + launchImageLibraryAsync, + UIImagePickerPreferredAssetRepresentationMode, +} from 'expo-image-picker' +import {msg, plural} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {VIDEO_MAX_DURATION_MS, VIDEO_MAX_SIZE} from '#/lib/constants' +import { + usePhotoLibraryPermission, + useVideoLibraryPermission, +} from '#/lib/hooks/usePermissions' +import {extractDataUriMime} from '#/lib/media/util' +import {isIOS, isNative, isWeb} from '#/platform/detection' +import {MAX_IMAGES} from '#/view/com/composer/state/composer' +import {atoms as a, useTheme} from '#/alf' +import {Button} from '#/components/Button' +import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' +import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Image' +import * as toast from '#/components/Toast' + +export type SelectMediaButtonProps = { + disabled?: boolean + /** + * If set, this limits the types of assets that can be selected. + */ + allowedAssetTypes: AssetType | undefined + selectedAssetsCount: number + onSelectAssets: (props: { + type: AssetType + assets: ImagePickerAsset[] + errors: string[] + }) => void +} + +/** + * Generic asset classes, or buckets, that we support. + */ +export type AssetType = 'video' | 'image' | 'gif' + +/** + * Shadows `ImagePickerAsset` from `expo-image-picker`, but with a guaranteed `mimeType` + */ +type ValidatedImagePickerAsset = Omit<ImagePickerAsset, 'mimeType'> & { + mimeType: string +} + +/** + * Codes for known validation states + */ +enum SelectedAssetError { + Unsupported = 'Unsupported', + MixedTypes = 'MixedTypes', + MaxImages = 'MaxImages', + MaxVideos = 'MaxVideos', + VideoTooLong = 'VideoTooLong', + FileTooBig = 'FileTooBig', + MaxGIFs = 'MaxGIFs', +} + +/** + * Supported video mime types. This differs slightly from + * `SUPPORTED_MIME_TYPES` from `#/lib/constants` because we only care about + * videos here. + */ +const SUPPORTED_VIDEO_MIME_TYPES = [ + 'video/mp4', + 'video/mpeg', + 'video/webm', + 'video/quicktime', +] as const +type SupportedVideoMimeType = (typeof SUPPORTED_VIDEO_MIME_TYPES)[number] +function isSupportedVideoMimeType( + mimeType: string, +): mimeType is SupportedVideoMimeType { + return SUPPORTED_VIDEO_MIME_TYPES.includes(mimeType as SupportedVideoMimeType) +} + +/** + * Supported image mime types. + */ +const SUPPORTED_IMAGE_MIME_TYPES = ( + [ + 'image/gif', + 'image/jpeg', + 'image/png', + 'image/svg+xml', + 'image/webp', + 'image/avif', + isNative && 'image/heic', + ] as const +).filter(Boolean) +type SupportedImageMimeType = Exclude< + (typeof SUPPORTED_IMAGE_MIME_TYPES)[number], + boolean +> +function isSupportedImageMimeType( + mimeType: string, +): mimeType is SupportedImageMimeType { + return SUPPORTED_IMAGE_MIME_TYPES.includes(mimeType as SupportedImageMimeType) +} + +/** + * This is a last-ditch effort type thing here, try not to rely on this. + */ +const extensionToMimeType: Record< + string, + SupportedVideoMimeType | SupportedImageMimeType +> = { + mp4: 'video/mp4', + mov: 'video/quicktime', + webm: 'video/webm', + webp: 'image/webp', + gif: 'image/gif', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + svg: 'image/svg+xml', + heic: 'image/heic', +} + +/** + * Attempts to bucket the given asset into one of our known types based on its + * `mimeType`. If `mimeType` is not available, we try to infer it through + * various means. + */ +function classifyImagePickerAsset(asset: ImagePickerAsset): + | { + success: true + type: AssetType + mimeType: string + } + | { + success: false + type: undefined + mimeType: undefined + } { + /* + * Try to use the `mimeType` reported by `expo-image-picker` first. + */ + let mimeType = asset.mimeType + + if (!mimeType) { + /* + * We can try to infer this from the data-uri. + */ + const maybeMimeType = extractDataUriMime(asset.uri) + + if ( + maybeMimeType.startsWith('image/') || + maybeMimeType.startsWith('video/') + ) { + mimeType = maybeMimeType + } else if (maybeMimeType.startsWith('file/')) { + /* + * On the off-chance we get a `file/*` mime, try to infer from the + * extension. + */ + const extension = asset.uri.split('.').pop()?.toLowerCase() + mimeType = extensionToMimeType[extension || ''] + } + } + + if (!mimeType) { + return { + success: false, + type: undefined, + mimeType: undefined, + } + } + + /* + * Distill this down into a type "class". + */ + let type: AssetType | undefined + if (mimeType === 'image/gif') { + type = 'gif' + } else if (mimeType?.startsWith('video/')) { + type = 'video' + } else if (mimeType?.startsWith('image/')) { + type = 'image' + } + + /* + * If we weren't able to find a valid type, we don't support this asset. + */ + if (!type) { + return { + success: false, + type: undefined, + mimeType: undefined, + } + } + + return { + success: true, + type, + mimeType, + } +} + +/** + * Takes in raw assets from `expo-image-picker` and applies validation. Returns + * the dominant `AssetType`, any valid assets, and any errors encountered along + * the way. + */ +async function processImagePickerAssets( + assets: ImagePickerAsset[], + { + selectionCountRemaining, + allowedAssetTypes, + }: { + selectionCountRemaining: number + allowedAssetTypes: AssetType | undefined + }, +) { + /* + * A deduped set of error codes, which we'll use later + */ + const errors = new Set<SelectedAssetError>() + + /* + * We only support selecting a single type of media at a time, so this gets + * set to whatever the first valid asset type is, OR to whatever + * `allowedAssetTypes` is set to. + */ + let selectableAssetType: AssetType | undefined + + /* + * This will hold the assets that we can actually use, after filtering + */ + let supportedAssets: ValidatedImagePickerAsset[] = [] + + for (const asset of assets) { + const {success, type, mimeType} = classifyImagePickerAsset(asset) + + if (!success) { + errors.add(SelectedAssetError.Unsupported) + continue + } + + /* + * If we have an `allowedAssetTypes` prop, constrain to that. Otherwise, + * set this to the first valid asset type we see, and then use that to + * constrain all remaining selected assets. + */ + selectableAssetType = allowedAssetTypes || selectableAssetType || type + + // ignore mixed types + if (type !== selectableAssetType) { + errors.add(SelectedAssetError.MixedTypes) + continue + } + + if (type === 'video') { + /** + * We don't care too much about mimeType at this point on native, + * since the `processVideo` step later on will convert to `.mp4`. + */ + if (isWeb && !isSupportedVideoMimeType(mimeType)) { + errors.add(SelectedAssetError.Unsupported) + continue + } + + /* + * Filesize appears to be stable across all platforms, so we can use it + * to filter out large files on web. On native, we compress these anyway, + * so we only check on web. + */ + if (isWeb && asset.fileSize && asset.fileSize > VIDEO_MAX_SIZE) { + errors.add(SelectedAssetError.FileTooBig) + continue + } + } + + if (type === 'image') { + if (!isSupportedImageMimeType(mimeType)) { + errors.add(SelectedAssetError.Unsupported) + continue + } + } + + if (type === 'gif') { + /* + * Filesize appears to be stable across all platforms, so we can use it + * to filter out large files on web. On native, we compress GIFs as + * videos anyway, so we only check on web. + */ + if (isWeb && asset.fileSize && asset.fileSize > VIDEO_MAX_SIZE) { + errors.add(SelectedAssetError.FileTooBig) + continue + } + } + + /* + * All validations passed, we have an asset! + */ + supportedAssets.push({ + mimeType, + ...asset, + /* + * In `expo-image-picker` >= v17, `uri` is now a `blob:` URL, not a + * data-uri. Our handling elsewhere in the app (for web) relies on the + * base64 data-uri, so we construct it here for web only. + */ + uri: + isWeb && asset.base64 + ? `data:${mimeType};base64,${asset.base64}` + : asset.uri, + }) + } + + if (supportedAssets.length > 0) { + if (selectableAssetType === 'image') { + if (supportedAssets.length > selectionCountRemaining) { + errors.add(SelectedAssetError.MaxImages) + supportedAssets = supportedAssets.slice(0, selectionCountRemaining) + } + } else if (selectableAssetType === 'video') { + if (supportedAssets.length > 1) { + errors.add(SelectedAssetError.MaxVideos) + supportedAssets = supportedAssets.slice(0, 1) + } + + if (supportedAssets[0].duration) { + if (isWeb) { + /* + * Web reports duration as seconds + */ + supportedAssets[0].duration = supportedAssets[0].duration * 1000 + } + + if (supportedAssets[0].duration > VIDEO_MAX_DURATION_MS) { + errors.add(SelectedAssetError.VideoTooLong) + supportedAssets = [] + } + } else { + errors.add(SelectedAssetError.Unsupported) + supportedAssets = [] + } + } else if (selectableAssetType === 'gif') { + if (supportedAssets.length > 1) { + errors.add(SelectedAssetError.MaxGIFs) + supportedAssets = supportedAssets.slice(0, 1) + } + } + } + + return { + type: selectableAssetType!, // set above + assets: supportedAssets, + errors, + } +} + +export function SelectMediaButton({ + disabled, + allowedAssetTypes, + selectedAssetsCount, + onSelectAssets, +}: SelectMediaButtonProps) { + const {_} = useLingui() + const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() + const {requestVideoAccessIfNeeded} = useVideoLibraryPermission() + const sheetWrapper = useSheetWrapper() + const t = useTheme() + + const selectionCountRemaining = MAX_IMAGES - selectedAssetsCount + + const processSelectedAssets = useCallback( + async (rawAssets: ImagePickerAsset[]) => { + const { + type, + assets, + errors: errorCodes, + } = await processImagePickerAssets(rawAssets, { + selectionCountRemaining, + allowedAssetTypes, + }) + + /* + * Convert error codes to user-friendly messages. + */ + const errors = Array.from(errorCodes).map(error => { + return { + [SelectedAssetError.Unsupported]: _( + msg`One or more of your selected files are not supported.`, + ), + [SelectedAssetError.MixedTypes]: _( + msg`Selecting multiple media types is not supported.`, + ), + [SelectedAssetError.MaxImages]: _( + msg({ + message: `You can select up to ${plural(MAX_IMAGES, { + other: '# images', + })} in total.`, + comment: `Error message for maximum number of images that can be selected to add to a post, currently 4 but may change.`, + }), + ), + [SelectedAssetError.MaxVideos]: _( + msg`You can only select one video at a time.`, + ), + [SelectedAssetError.VideoTooLong]: _( + msg`Videos must be less than 3 minutes long.`, + ), + [SelectedAssetError.MaxGIFs]: _( + msg`You can only select one GIF at a time.`, + ), + [SelectedAssetError.FileTooBig]: _( + msg`One or more of your selected files is too large. Maximum size is 100 MB.`, + ), + }[error] + }) + + /* + * Report the selected assets and any errors back to the + * composer. + */ + onSelectAssets({ + type, + assets, + errors, + }) + }, + [_, onSelectAssets, selectionCountRemaining, allowedAssetTypes], + ) + + const onPressSelectMedia = useCallback(async () => { + if (isNative) { + const [photoAccess, videoAccess] = await Promise.all([ + requestPhotoAccessIfNeeded(), + requestVideoAccessIfNeeded(), + ]) + + if (!photoAccess && !videoAccess) { + toast.show(_(msg`You need to allow access to your media library.`), { + type: 'error', + }) + return + } + } + + if (isNative && Keyboard.isVisible()) { + Keyboard.dismiss() + } + + const {assets, canceled} = await sheetWrapper( + launchImageLibraryAsync({ + exif: false, + mediaTypes: ['images', 'videos'], + quality: 1, + allowsMultipleSelection: true, + legacy: true, + base64: isWeb, + selectionLimit: isIOS ? selectionCountRemaining : undefined, + preferredAssetRepresentationMode: + UIImagePickerPreferredAssetRepresentationMode.Current, + videoMaxDuration: VIDEO_MAX_DURATION_MS / 1000, + }), + ) + + if (canceled) return + + await processSelectedAssets(assets) + }, [ + _, + requestPhotoAccessIfNeeded, + requestVideoAccessIfNeeded, + sheetWrapper, + processSelectedAssets, + selectionCountRemaining, + ]) + + return ( + <Button + testID="openMediaBtn" + onPress={onPressSelectMedia} + label={_( + msg({ + message: `Add media to post`, + comment: `Accessibility label for button in composer to add photos or a video to a post`, + }), + )} + accessibilityHint={ + isNative + ? _( + msg({ + message: `Opens device gallery to select up to ${plural( + MAX_IMAGES, + { + other: '# images', + }, + )}, or a single video.`, + comment: `Accessibility hint on native for button in composer to add images or a video to a post. Maximum number of images that can be selected is currently 4 but may change.`, + }), + ) + : _( + msg({ + message: `Opens device gallery to select up to ${plural( + MAX_IMAGES, + { + other: '# images', + }, + )}, or a single video or GIF.`, + comment: `Accessibility hint on web for button in composer to add images, a video, or a GIF to a post. Maximum number of images that can be selected is currently 4 but may change.`, + }), + ) + } + style={a.p_sm} + variant="ghost" + shape="round" + color="primary" + disabled={disabled}> + <ImageIcon + size="lg" + style={disabled && t.atoms.text_contrast_low} + accessibilityIgnoresInvertColors={true} + /> + </Button> + ) +} diff --git a/src/view/com/composer/photos/ImageAltTextDialog.tsx b/src/view/com/composer/photos/ImageAltTextDialog.tsx index 724149937..b356cde9b 100644 --- a/src/view/com/composer/photos/ImageAltTextDialog.tsx +++ b/src/view/com/composer/photos/ImageAltTextDialog.tsx @@ -96,13 +96,12 @@ const ImageAltTextInner = ({ <View style={[t.atoms.bg_contrast_50, a.rounded_sm, a.overflow_hidden]}> <Image style={imageStyle} - source={{ - uri: (image.transformed ?? image.source).path, - }} + source={{uri: (image.transformed ?? image.source).path}} contentFit="contain" accessible={true} accessibilityIgnoresInvertColors enableLiveTextInteraction + autoplay={false} /> </View> </View> diff --git a/src/view/com/composer/photos/SelectPhotoBtn.tsx b/src/view/com/composer/photos/SelectPhotoBtn.tsx deleted file mode 100644 index f4c6aa328..000000000 --- a/src/view/com/composer/photos/SelectPhotoBtn.tsx +++ /dev/null @@ -1,60 +0,0 @@ -/* eslint-disable react-native-a11y/has-valid-accessibility-ignores-invert-colors */ -import {useCallback} from 'react' -import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {usePhotoLibraryPermission} from '#/lib/hooks/usePermissions' -import {openPicker} from '#/lib/media/picker' -import {isNative} from '#/platform/detection' -import {ComposerImage, createComposerImage} from '#/state/gallery' -import {atoms as a, useTheme} from '#/alf' -import {Button} from '#/components/Button' -import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' -import {Image_Stroke2_Corner0_Rounded as Image} from '#/components/icons/Image' - -type Props = { - size: number - disabled?: boolean - onAdd: (next: ComposerImage[]) => void -} - -export function SelectPhotoBtn({size, disabled, onAdd}: Props) { - const {_} = useLingui() - const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() - const t = useTheme() - const sheetWrapper = useSheetWrapper() - - const onPressSelectPhotos = useCallback(async () => { - if (isNative && !(await requestPhotoAccessIfNeeded())) { - return - } - - const images = await sheetWrapper( - openPicker({ - selectionLimit: 4 - size, - allowsMultipleSelection: true, - }), - ) - - const results = await Promise.all( - images.map(img => createComposerImage(img)), - ) - - onAdd(results) - }, [requestPhotoAccessIfNeeded, size, onAdd, sheetWrapper]) - - return ( - <Button - testID="openGalleryBtn" - onPress={onPressSelectPhotos} - label={_(msg`Gallery`)} - accessibilityHint={_(msg`Opens device photo gallery`)} - style={a.p_sm} - variant="ghost" - shape="round" - color="primary" - disabled={disabled}> - <Image size="lg" style={disabled && t.atoms.text_contrast_low} /> - </Button> - ) -} diff --git a/src/view/com/composer/videos/SelectVideoBtn.tsx b/src/view/com/composer/videos/SelectVideoBtn.tsx deleted file mode 100644 index 96715955f..000000000 --- a/src/view/com/composer/videos/SelectVideoBtn.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import {useCallback} from 'react' -import {type ImagePickerAsset} from 'expo-image-picker' -import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import { - SUPPORTED_MIME_TYPES, - type SupportedMimeTypes, - VIDEO_MAX_DURATION_MS, -} from '#/lib/constants' -import {useVideoLibraryPermission} from '#/lib/hooks/usePermissions' -import {isWeb} from '#/platform/detection' -import {isNative} from '#/platform/detection' -import {atoms as a, useTheme} from '#/alf' -import {Button} from '#/components/Button' -import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip' -import {pickVideo} from './pickVideo' - -type Props = { - onSelectVideo: (video: ImagePickerAsset) => void - disabled?: boolean - setError: (error: string) => void -} - -export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) { - const {_} = useLingui() - const t = useTheme() - const {requestVideoAccessIfNeeded} = useVideoLibraryPermission() - - const onPressSelectVideo = useCallback(async () => { - if (isNative && !(await requestVideoAccessIfNeeded())) { - return - } - - const response = await pickVideo() - if (response.assets && response.assets.length > 0) { - const asset = response.assets[0] - try { - if (isWeb) { - // asset.duration is null for gifs (see the TODO in pickVideo.web.ts) - if (asset.duration && asset.duration > VIDEO_MAX_DURATION_MS) { - throw Error(_(msg`Videos must be less than 3 minutes long`)) - } - // compression step on native converts to mp4, so no need to check there - if ( - !SUPPORTED_MIME_TYPES.includes(asset.mimeType as SupportedMimeTypes) - ) { - throw Error(_(msg`Unsupported video type: ${asset.mimeType}`)) - } - } else { - if (typeof asset.duration !== 'number') { - throw Error('Asset is not a video') - } - if (asset.duration > VIDEO_MAX_DURATION_MS) { - throw Error(_(msg`Videos must be less than 3 minutes long`)) - } - } - onSelectVideo(asset) - } catch (err) { - if (err instanceof Error) { - setError(err.message) - } else { - setError(_(msg`An error occurred while selecting the video`)) - } - } - } - }, [requestVideoAccessIfNeeded, setError, _, onSelectVideo]) - - return ( - <> - <Button - testID="openGifBtn" - onPress={onPressSelectVideo} - label={_(msg`Select video`)} - accessibilityHint={_(msg`Opens video picker`)} - style={a.p_sm} - variant="ghost" - shape="round" - color="primary" - disabled={disabled}> - <VideoClipIcon - size="lg" - style={disabled && t.atoms.text_contrast_low} - /> - </Button> - </> - ) -} diff --git a/src/view/com/composer/videos/VideoPreview.tsx b/src/view/com/composer/videos/VideoPreview.tsx index 255174bea..84cb1dba7 100644 --- a/src/view/com/composer/videos/VideoPreview.tsx +++ b/src/view/com/composer/videos/VideoPreview.tsx @@ -1,9 +1,10 @@ import React from 'react' import {View} from 'react-native' -import {ImagePickerAsset} from 'expo-image-picker' +import {Image} from 'expo-image' +import {type ImagePickerAsset} from 'expo-image-picker' import {BlueskyVideoView} from '@haileyok/bluesky-video' -import {CompressedVideo} from '#/lib/media/video/types' +import {type CompressedVideo} from '#/lib/media/video/types' import {clamp} from '#/lib/numbers' import {useAutoplayDisabled} from '#/state/preferences' import {ExternalEmbedRemoveBtn} from '#/view/com/composer/ExternalEmbedRemoveBtn' @@ -48,13 +49,25 @@ export function VideoPreview({ <VideoTranscodeBackdrop uri={asset.uri} /> </View> {isActivePost && ( - <BlueskyVideoView - url={video.uri} - autoplay={!autoplayDisabled} - beginMuted={true} - forceTakeover={true} - ref={playerRef} - /> + <> + {video.mimeType === 'image/gif' ? ( + <Image + style={[a.flex_1]} + autoplay={!autoplayDisabled} + source={{uri: video.uri}} + accessibilityIgnoresInvertColors + cachePolicy="none" + /> + ) : ( + <BlueskyVideoView + url={video.uri} + autoplay={!autoplayDisabled} + beginMuted={true} + forceTakeover={true} + ref={playerRef} + /> + )} + </> )} <ExternalEmbedRemoveBtn onRemove={clear} /> {autoplayDisabled && ( diff --git a/src/view/com/lightbox/Lightbox.web.tsx b/src/view/com/lightbox/Lightbox.web.tsx index 97811da7f..ab50fbcf0 100644 --- a/src/view/com/lightbox/Lightbox.web.tsx +++ b/src/view/com/lightbox/Lightbox.web.tsx @@ -76,6 +76,7 @@ function LightboxInner({ const onKeyDown = useCallback( (e: KeyboardEvent) => { if (e.key === 'Escape') { + e.preventDefault() onClose() } else if (e.key === 'ArrowLeft') { onPressLeft() diff --git a/src/view/com/post-thread/PostThreadFollowBtn.tsx b/src/view/com/post-thread/PostThreadFollowBtn.tsx index 145e919f9..fc9296cad 100644 --- a/src/view/com/post-thread/PostThreadFollowBtn.tsx +++ b/src/view/com/post-thread/PostThreadFollowBtn.tsx @@ -1,5 +1,5 @@ import React from 'react' -import {AppBskyActorDefs} from '@atproto/api' +import {type AppBskyActorDefs} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' @@ -126,7 +126,7 @@ function PostThreadFollowBtnLoaded({ <ButtonText> {!isFollowing ? ( isFollowedBy ? ( - <Trans>Follow Back</Trans> + <Trans>Follow back</Trans> ) : ( <Trans>Follow</Trans> ) diff --git a/src/view/com/profile/FollowButton.tsx b/src/view/com/profile/FollowButton.tsx index 656ed914a..ff9c1cd7b 100644 --- a/src/view/com/profile/FollowButton.tsx +++ b/src/view/com/profile/FollowButton.tsx @@ -1,11 +1,11 @@ -import {StyleProp, TextStyle, View} from 'react-native' +import {type StyleProp, type TextStyle, View} from 'react-native' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {Shadow} from '#/state/cache/types' +import {type Shadow} from '#/state/cache/types' import {useProfileFollowMutationQueue} from '#/state/queries/profile' -import * as bsky from '#/types/bsky' -import {Button, ButtonType} from '../util/forms/Button' +import type * as bsky from '#/types/bsky' +import {Button, type ButtonType} from '../util/forms/Button' import * as Toast from '../util/Toast' export function FollowButton({ @@ -78,7 +78,7 @@ export function FollowButton({ type={unfollowedType} labelStyle={labelStyle} onPress={onPressFollow} - label={_(msg({message: 'Follow Back', context: 'action'}))} + label={_(msg({message: 'Follow back', context: 'action'}))} /> ) } diff --git a/src/view/screens/Log.tsx b/src/view/screens/Log.tsx deleted file mode 100644 index 026319baf..000000000 --- a/src/view/screens/Log.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import React from 'react' -import {StyleSheet, TouchableOpacity, View} from 'react-native' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useFocusEffect} from '@react-navigation/native' - -import {usePalette} from '#/lib/hooks/usePalette' -import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo' -import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' -import {s} from '#/lib/styles' -import {getEntries} from '#/logger/logDump' -import {useTickEveryMinute} from '#/state/shell' -import {useSetMinimalShellMode} from '#/state/shell' -import {Text} from '#/view/com/util/text/Text' -import {ViewHeader} from '#/view/com/util/ViewHeader' -import {ScrollView} from '#/view/com/util/Views' -import * as Layout from '#/components/Layout' - -export function LogScreen({}: NativeStackScreenProps< - CommonNavigatorParams, - 'Log' ->) { - const pal = usePalette('default') - const {_} = useLingui() - const setMinimalShellMode = useSetMinimalShellMode() - const [expanded, setExpanded] = React.useState<string[]>([]) - const timeAgo = useGetTimeAgo() - const tick = useTickEveryMinute() - - useFocusEffect( - React.useCallback(() => { - setMinimalShellMode(false) - }, [setMinimalShellMode]), - ) - - const toggler = (id: string) => () => { - if (expanded.includes(id)) { - setExpanded(expanded.filter(v => v !== id)) - } else { - setExpanded([...expanded, id]) - } - } - - return ( - <Layout.Screen> - <ViewHeader title="Log" /> - <ScrollView style={s.flex1}> - {getEntries() - .slice(0) - .map(entry => { - return ( - <View key={`entry-${entry.id}`}> - <TouchableOpacity - style={[styles.entry, pal.border, pal.view]} - onPress={toggler(entry.id)} - accessibilityLabel={_(msg`View debug entry`)} - accessibilityHint={_( - msg`Opens additional details for a debug entry`, - )}> - {entry.level === 'debug' ? ( - <FontAwesomeIcon icon="info" /> - ) : ( - <FontAwesomeIcon icon="exclamation" style={s.red3} /> - )} - <Text type="sm" style={[styles.summary, pal.text]}> - {String(entry.message)} - </Text> - {entry.metadata && Object.keys(entry.metadata).length ? ( - <FontAwesomeIcon - icon={ - expanded.includes(entry.id) ? 'angle-up' : 'angle-down' - } - style={s.mr5} - /> - ) : undefined} - <Text type="sm" style={[styles.ts, pal.textLight]}> - {timeAgo(entry.timestamp, tick)} - </Text> - </TouchableOpacity> - {expanded.includes(entry.id) ? ( - <View style={[pal.view, s.pl10, s.pr10, s.pb10]}> - <View style={[pal.btn, styles.details]}> - <Text type="mono" style={pal.text}> - {JSON.stringify(entry.metadata, null, 2)} - </Text> - </View> - </View> - ) : undefined} - </View> - ) - })} - <View style={s.footerSpacer} /> - </ScrollView> - </Layout.Screen> - ) -} - -const styles = StyleSheet.create({ - entry: { - flexDirection: 'row', - borderTopWidth: 1, - paddingVertical: 10, - paddingHorizontal: 6, - }, - summary: { - flex: 1, - }, - ts: { - width: 40, - }, - details: { - paddingVertical: 10, - paddingHorizontal: 6, - }, -}) diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx index 04fccc44c..8b4c65b8f 100644 --- a/src/view/shell/index.tsx +++ b/src/view/shell/index.tsx @@ -13,6 +13,7 @@ import {useNotificationsRegistration} from '#/lib/notifications/notifications' import {isStateAtTabRoot} from '#/lib/routes/helpers' import {isAndroid, isIOS} from '#/platform/detection' import {useDialogFullyExpandedCountContext} from '#/state/dialogs' +import {useGeolocation} from '#/state/geolocation' import {useSession} from '#/state/session' import { useIsDrawerOpen, @@ -26,6 +27,7 @@ import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' import {atoms as a, select, useTheme} from '#/alf' import {setSystemUITheme} from '#/alf/util/systemUI' import {AgeAssuranceRedirectDialog} from '#/components/ageAssurance/AgeAssuranceRedirectDialog' +import {BlockedGeoOverlay} from '#/components/BlockedGeoOverlay' import {EmailDialog} from '#/components/dialogs/EmailDialog' import {InAppBrowserConsentDialog} from '#/components/dialogs/InAppBrowserConsent' import {LinkWarningDialog} from '#/components/dialogs/LinkWarning' @@ -180,9 +182,11 @@ function ShellInner() { ) } -export const Shell: React.FC = function ShellImpl() { - const fullyExpandedCount = useDialogFullyExpandedCountContext() +export function Shell() { const t = useTheme() + const {geolocation} = useGeolocation() + const fullyExpandedCount = useDialogFullyExpandedCountContext() + useIntentHandler() useEffect(() => { @@ -200,9 +204,13 @@ export const Shell: React.FC = function ShellImpl() { navigationBar: t.name !== 'light' ? 'light' : 'dark', }} /> - <RoutesContainer> - <ShellInner /> - </RoutesContainer> + {geolocation?.isAgeBlockedGeo ? ( + <BlockedGeoOverlay /> + ) : ( + <RoutesContainer> + <ShellInner /> + </RoutesContainer> + )} </View> ) } diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx index 3c2bc58ab..f942ab49e 100644 --- a/src/view/shell/index.web.tsx +++ b/src/view/shell/index.web.tsx @@ -5,11 +5,10 @@ import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' import {RemoveScrollBar} from 'react-remove-scroll-bar' -import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle' import {useIntentHandler} from '#/lib/hooks/useIntentHandler' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {type NavigationProp} from '#/lib/routes/types' -import {colors} from '#/lib/styles' +import {useGeolocation} from '#/state/geolocation' import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell' import {useComposerKeyboardShortcut} from '#/state/shell/composer/useComposerKeyboardShortcut' import {useCloseAllActiveElements} from '#/state/util' @@ -18,6 +17,7 @@ import {ModalsContainer} from '#/view/com/modals/Modal' import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' import {atoms as a, select, useTheme} from '#/alf' import {AgeAssuranceRedirectDialog} from '#/components/ageAssurance/AgeAssuranceRedirectDialog' +import {BlockedGeoOverlay} from '#/components/BlockedGeoOverlay' import {EmailDialog} from '#/components/dialogs/EmailDialog' import {LinkWarningDialog} from '#/components/dialogs/LinkWarning' import {MutedWordsDialog} from '#/components/dialogs/MutedWords' @@ -130,24 +130,23 @@ function ShellInner() { ) } -export const Shell: React.FC = function ShellImpl() { - const pageBg = useColorSchemeStyle(styles.bgLight, styles.bgDark) +export function Shell() { + const t = useTheme() + const {geolocation} = useGeolocation() return ( - <View style={[a.util_screen_outer, pageBg]}> - <RoutesContainer> - <ShellInner /> - </RoutesContainer> + <View style={[a.util_screen_outer, t.atoms.bg]}> + {geolocation?.isAgeBlockedGeo ? ( + <BlockedGeoOverlay /> + ) : ( + <RoutesContainer> + <ShellInner /> + </RoutesContainer> + )} </View> ) } const styles = StyleSheet.create({ - bgLight: { - backgroundColor: colors.white, - }, - bgDark: { - backgroundColor: colors.black, // TODO - }, drawerMask: { ...a.fixed, width: '100%', |