Compare commits
31 Commits
thread-bug
..
ip
| Author | SHA1 | Date | |
|---|---|---|---|
| e5dab3f0c0 | |||
| 3b7b93a411 | |||
| 99d7570818 | |||
| 7b9bc52720 | |||
| 78e1e26a2c | |||
| d8c904d1d9 | |||
| 560c503aa6 | |||
| 5120c037d1 | |||
| 184df9cbfe | |||
| 912ab1bd9b | |||
| f038ac70da | |||
| e404ddfbcd | |||
| 0baa5198e0 | |||
| 7a1adfe67c | |||
| af55e578b2 | |||
| 994003e603 | |||
| 8ab49da6da | |||
| c097f68a7d | |||
| b1ad229771 | |||
| eabcd9150d | |||
| d900d0b7a7 | |||
| 16701392b9 | |||
| ef7c222e1b | |||
| 9835d0fb7b | |||
| bf6e01551e | |||
| ea7f984a40 | |||
| 4923786927 | |||
| 48baf7277d | |||
| 92ad887e38 | |||
| 122a46891a | |||
| cced762a7f |
@@ -58,6 +58,7 @@ buck-out/
|
||||
# Ruby / CocoaPods
|
||||
/ios/Pods/
|
||||
/vendor/bundle/
|
||||
Gemfile.lock
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
|
||||
@@ -606,10 +606,10 @@ type IPCCRequest struct {
|
||||
type IPCCResponse struct {
|
||||
CC string `json:"countryCode"`
|
||||
AgeRestrictedGeo bool `json:"isAgeRestrictedGeo,omitempty"`
|
||||
AgeBlockedGeo bool `json:"isAgeBlockedGeo,omitempty"`
|
||||
}
|
||||
|
||||
// IP address data is powered by IPinfo
|
||||
// https://ipinfo.io
|
||||
// This product includes GeoLite2 Data created by MaxMind, available from https://www.maxmind.com.
|
||||
func (srv *Server) WebIpCC(c echo.Context) error {
|
||||
realIP := c.RealIP()
|
||||
addr, err := netip.ParseAddr(realIP)
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
<meta property="og:url" content="https://bsky.app" />
|
||||
<meta name="twitter:url" content="https://bsky.app" />
|
||||
<link rel="canonical" href="https://bsky.app" />
|
||||
|
||||
<meta property="og:image" content="https://bsky.app/static/social-card-default-gradient.png" />
|
||||
<meta property="twitter:image" content="https://bsky.app/static/social-card-default-gradient.png" />
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<meta property="profile:username" content="{{ profileView.Handle }}">
|
||||
{%- if requestURI %}
|
||||
<meta property="og:url" content="{{ requestURI }}">
|
||||
<link rel="canonical" href="{{ requestURI }}" />
|
||||
{% endif -%}
|
||||
{%- if postView.Author.DisplayName %}
|
||||
<meta property="og:title" content="{{ postView.Author.DisplayName }} (@{{ postView.Author.Handle }})">
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<meta property="profile:username" content="{{ profileView.Handle }}">
|
||||
{%- if requestURI %}
|
||||
<meta property="og:url" content="{{ requestURI }}">
|
||||
<link rel="canonical" href="{{ requestURI }}" />
|
||||
{% endif -%}
|
||||
{%- if profileView.DisplayName %}
|
||||
<meta property="og:title" content="{{ profileView.DisplayName }} (@{{ profileView.Handle }})">
|
||||
|
||||
+1
-1
@@ -146,7 +146,7 @@
|
||||
"expo-image": "^2.4.0",
|
||||
"expo-image-crop-tool": "^0.1.8",
|
||||
"expo-image-manipulator": "~13.1.7",
|
||||
"expo-image-picker": "~16.1.4",
|
||||
"expo-image-picker": "^17.0.2",
|
||||
"expo-intent-launcher": "^12.1.5",
|
||||
"expo-linear-gradient": "~14.1.5",
|
||||
"expo-linking": "~7.1.5",
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
diff --git a/node_modules/expo-image-picker/android/src/main/java/expo/modules/imagepicker/MediaHandler.kt b/node_modules/expo-image-picker/android/src/main/java/expo/modules/imagepicker/MediaHandler.kt
|
||||
index c863fb8..cde8859 100644
|
||||
--- a/node_modules/expo-image-picker/android/src/main/java/expo/modules/imagepicker/MediaHandler.kt
|
||||
+++ b/node_modules/expo-image-picker/android/src/main/java/expo/modules/imagepicker/MediaHandler.kt
|
||||
@@ -101,16 +101,30 @@ internal class MediaHandler(
|
||||
val fileData = getAdditionalFileData(sourceUri)
|
||||
val mimeType = getType(context.contentResolver, sourceUri)
|
||||
|
||||
+ // Extract basic metadata
|
||||
+ var width = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)
|
||||
+ var height = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)
|
||||
+ val rotation = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)
|
||||
+
|
||||
+ // Android returns the encoded width/height which do not take the display rotation into
|
||||
+ // account. For videos recorded in portrait mode the encoded dimensions are often landscape
|
||||
+ // (e.g. 1920x1080) paired with a 90°/270° rotation flag. iOS adjusts these values before
|
||||
+ // reporting them, so to keep the behaviour consistent across platforms we swap the width
|
||||
+ // and height when the rotation indicates the video should be displayed in portrait.
|
||||
+ if (rotation % 180 != 0) {
|
||||
+ width = height.also { height = width }
|
||||
+ }
|
||||
+
|
||||
return ImagePickerAsset(
|
||||
type = MediaType.VIDEO,
|
||||
uri = outputUri.toString(),
|
||||
- width = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH),
|
||||
- height = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT),
|
||||
+ width = width,
|
||||
+ height = height,
|
||||
fileName = fileData?.fileName,
|
||||
fileSize = fileData?.fileSize,
|
||||
mimeType = mimeType,
|
||||
duration = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_DURATION),
|
||||
- rotation = metadataRetriever.extractInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION),
|
||||
+ rotation = rotation,
|
||||
assetId = sourceUri.getMediaStoreAssetId()
|
||||
)
|
||||
} catch (cause: FailedToExtractVideoMetadataException) {
|
||||
@@ -1,5 +0,0 @@
|
||||
# Expo Image Picker patch
|
||||
|
||||
Cherry-picked https://github.com/expo/expo/pull/37849
|
||||
|
||||
Remove when we update to a version that includes this commit.
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,7 +25,7 @@ import {
|
||||
type ViewStyleProp,
|
||||
web,
|
||||
} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {Button} from '#/components/Button'
|
||||
import * as FeedCard from '#/components/FeedCard'
|
||||
import {ArrowRight_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow'
|
||||
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
|
||||
@@ -46,11 +46,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 +65,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 +77,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 +243,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 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 isLoading = isSuggestionsLoading || !moderationOpts
|
||||
const isProfileHeaderContext = viewContext === 'profileHeader'
|
||||
const isFeedContext = 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 +377,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 +403,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}
|
||||
<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}
|
||||
|
||||
<SeeMoreSuggestedProfilesCard />
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
{!isProfileHeaderContext && <SeeMoreSuggestedProfilesCard />}
|
||||
</ScrollView>
|
||||
</BlockDrawerGesture>
|
||||
)}
|
||||
</View>
|
||||
@@ -427,7 +421,6 @@ export function ProfileGrid({
|
||||
|
||||
function SeeMoreSuggestedProfilesCard() {
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
return (
|
||||
@@ -437,7 +430,7 @@ function SeeMoreSuggestedProfilesCard() {
|
||||
onPress={() => {
|
||||
navigation.navigate('SearchTab')
|
||||
}}>
|
||||
<CardOuter style={[a.flex_1, t.atoms.shadow_sm]}>
|
||||
<CardOuter>
|
||||
<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]}>
|
||||
@@ -491,10 +484,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} />
|
||||
@@ -568,7 +558,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]}>
|
||||
|
||||
+87
-1
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
+2
-2
@@ -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],
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,6 +8,7 @@ export type Gate =
|
||||
| 'handle_suggestions'
|
||||
| 'old_postonboarding'
|
||||
| 'onboarding_add_video_feed'
|
||||
| 'post_follow_profile_suggested_accounts'
|
||||
| 'post_threads_v2_unspecced'
|
||||
| 'remove_show_latest_button'
|
||||
| 'test_gate_1'
|
||||
|
||||
+287
-243
File diff suppressed because it is too large
Load Diff
@@ -475,4 +475,11 @@ export type MetricEvents = {
|
||||
'ageAssurance:redirectDialogFail': {}
|
||||
'ageAssurance:appealDialogOpen': {}
|
||||
'ageAssurance:appealDialogSubmit': {}
|
||||
|
||||
/*
|
||||
* Specifically for the `BlockedGeoOverlay`
|
||||
*/
|
||||
'blockedGeoOverlay:shown': {}
|
||||
|
||||
'geo:debug': {}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
<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={[
|
||||
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
|
||||
style={[
|
||||
a.pl_xs,
|
||||
{
|
||||
marginTop: platform({ios: 2}),
|
||||
},
|
||||
]}>
|
||||
<VerificationCheckButton profile={profile} size="lg" />
|
||||
</View>
|
||||
</Text>
|
||||
</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.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}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
)}
|
||||
{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>
|
||||
{profile.viewer?.following ? (
|
||||
<Trans>Following</Trans>
|
||||
) : profile.viewer?.followedBy ? (
|
||||
<Trans>Follow back</Trans>
|
||||
) : (
|
||||
<Trans>Follow</Trans>
|
||||
)}
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<ProfileMenu profile={profile} />
|
||||
</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"
|
||||
<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
|
||||
style={[
|
||||
a.pl_xs,
|
||||
{
|
||||
marginTop: platform({ios: 2}),
|
||||
},
|
||||
]}>
|
||||
<VerificationCheckButton profile={profile} size="lg" />
|
||||
</View>
|
||||
</Text>
|
||||
</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}
|
||||
/>
|
||||
</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}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Vendored
+15
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -10,6 +10,7 @@ export type Device = {
|
||||
geolocation?: {
|
||||
countryCode: string | undefined
|
||||
isAgeRestrictedGeo: boolean | undefined
|
||||
isAgeBlockedGeo: boolean | undefined
|
||||
}
|
||||
trendingBetaEnabled: boolean
|
||||
devMode: boolean
|
||||
|
||||
@@ -40,6 +40,7 @@ import Animated, {
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
} from 'react-native-reanimated'
|
||||
import {RootSiblingParent} from 'react-native-root-siblings'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {type ImagePickerAsset} from 'expo-image-picker'
|
||||
import {
|
||||
@@ -77,7 +78,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 +108,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 +117,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,8 +129,14 @@ 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,
|
||||
@@ -514,12 +522,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'},
|
||||
)
|
||||
}, [
|
||||
_,
|
||||
@@ -654,84 +663,88 @@ export const ComposePost = ({
|
||||
const isWebFooterSticky = !isNative && thread.posts.length > 1
|
||||
return (
|
||||
<BottomSheetPortalProvider>
|
||||
<KeyboardAvoidingView
|
||||
testID="composePostView"
|
||||
behavior={isIOS ? 'padding' : 'height'}
|
||||
keyboardVerticalOffset={keyboardVerticalOffset}
|
||||
style={a.flex_1}>
|
||||
<View
|
||||
style={[a.flex_1, viewStyles]}
|
||||
aria-modal
|
||||
accessibilityViewIsModal>
|
||||
<ComposerTopBar
|
||||
canPost={canPost}
|
||||
isReply={!!replyTo}
|
||||
isPublishQueued={publishOnUpload}
|
||||
isPublishing={isPublishing}
|
||||
isThread={thread.posts.length > 1}
|
||||
publishingStage={publishingStage}
|
||||
topBarAnimatedStyle={topBarAnimatedStyle}
|
||||
onCancel={onPressCancel}
|
||||
onPublish={onPressPublish}>
|
||||
{missingAltError && <AltTextReminder error={missingAltError} />}
|
||||
<ErrorBanner
|
||||
error={error}
|
||||
videoState={erroredVideo}
|
||||
clearError={() => setError('')}
|
||||
clearVideo={
|
||||
erroredVideoPostId
|
||||
? () => clearVideo(erroredVideoPostId)
|
||||
: () => {}
|
||||
}
|
||||
/>
|
||||
</ComposerTopBar>
|
||||
|
||||
<Animated.ScrollView
|
||||
ref={scrollViewRef}
|
||||
layout={native(LinearTransition)}
|
||||
onScroll={scrollHandler}
|
||||
contentContainerStyle={a.flex_grow}
|
||||
style={a.flex_1}
|
||||
keyboardShouldPersistTaps="always"
|
||||
onContentSizeChange={onScrollViewContentSizeChange}
|
||||
onLayout={onScrollViewLayout}>
|
||||
{replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined}
|
||||
{thread.posts.map((post, index) => (
|
||||
<React.Fragment key={post.id}>
|
||||
<ComposerPost
|
||||
post={post}
|
||||
dispatch={composerDispatch}
|
||||
textInput={post.id === activePost.id ? textInput : null}
|
||||
isFirstPost={index === 0}
|
||||
isLastPost={index === thread.posts.length - 1}
|
||||
isPartOfThread={thread.posts.length > 1}
|
||||
isReply={index > 0 || !!replyTo}
|
||||
isActive={post.id === activePost.id}
|
||||
canRemovePost={thread.posts.length > 1}
|
||||
canRemoveQuote={index > 0 || !initQuote}
|
||||
onSelectVideo={selectVideo}
|
||||
onClearVideo={clearVideo}
|
||||
onPublish={onComposerPostPublish}
|
||||
onError={setError}
|
||||
<RootSiblingParent>
|
||||
<KeyboardAvoidingView
|
||||
testID="composePostView"
|
||||
behavior={isIOS ? 'padding' : 'height'}
|
||||
keyboardVerticalOffset={keyboardVerticalOffset}
|
||||
style={a.flex_1}>
|
||||
<View
|
||||
style={[a.flex_1, viewStyles]}
|
||||
aria-modal
|
||||
accessibilityViewIsModal>
|
||||
<RootSiblingParent>
|
||||
<ComposerTopBar
|
||||
canPost={canPost}
|
||||
isReply={!!replyTo}
|
||||
isPublishQueued={publishOnUpload}
|
||||
isPublishing={isPublishing}
|
||||
isThread={thread.posts.length > 1}
|
||||
publishingStage={publishingStage}
|
||||
topBarAnimatedStyle={topBarAnimatedStyle}
|
||||
onCancel={onPressCancel}
|
||||
onPublish={onPressPublish}>
|
||||
{missingAltError && <AltTextReminder error={missingAltError} />}
|
||||
<ErrorBanner
|
||||
error={error}
|
||||
videoState={erroredVideo}
|
||||
clearError={() => setError('')}
|
||||
clearVideo={
|
||||
erroredVideoPostId
|
||||
? () => clearVideo(erroredVideoPostId)
|
||||
: () => {}
|
||||
}
|
||||
/>
|
||||
{isWebFooterSticky && post.id === activePost.id && (
|
||||
<View style={styles.stickyFooterWeb}>{footer}</View>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Animated.ScrollView>
|
||||
{!isWebFooterSticky && footer}
|
||||
</View>
|
||||
</ComposerTopBar>
|
||||
|
||||
<Prompt.Basic
|
||||
control={discardPromptControl}
|
||||
title={_(msg`Discard draft?`)}
|
||||
description={_(msg`Are you sure you'd like to discard this draft?`)}
|
||||
onConfirm={onClose}
|
||||
confirmButtonCta={_(msg`Discard`)}
|
||||
confirmButtonColor="negative"
|
||||
/>
|
||||
</KeyboardAvoidingView>
|
||||
<Animated.ScrollView
|
||||
ref={scrollViewRef}
|
||||
layout={native(LinearTransition)}
|
||||
onScroll={scrollHandler}
|
||||
contentContainerStyle={a.flex_grow}
|
||||
style={a.flex_1}
|
||||
keyboardShouldPersistTaps="always"
|
||||
onContentSizeChange={onScrollViewContentSizeChange}
|
||||
onLayout={onScrollViewLayout}>
|
||||
{replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined}
|
||||
{thread.posts.map((post, index) => (
|
||||
<React.Fragment key={post.id}>
|
||||
<ComposerPost
|
||||
post={post}
|
||||
dispatch={composerDispatch}
|
||||
textInput={post.id === activePost.id ? textInput : null}
|
||||
isFirstPost={index === 0}
|
||||
isLastPost={index === thread.posts.length - 1}
|
||||
isPartOfThread={thread.posts.length > 1}
|
||||
isReply={index > 0 || !!replyTo}
|
||||
isActive={post.id === activePost.id}
|
||||
canRemovePost={thread.posts.length > 1}
|
||||
canRemoveQuote={index > 0 || !initQuote}
|
||||
onSelectVideo={selectVideo}
|
||||
onClearVideo={clearVideo}
|
||||
onPublish={onComposerPostPublish}
|
||||
onError={setError}
|
||||
/>
|
||||
{isWebFooterSticky && post.id === activePost.id && (
|
||||
<View style={styles.stickyFooterWeb}>{footer}</View>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Animated.ScrollView>
|
||||
{!isWebFooterSticky && footer}
|
||||
</RootSiblingParent>
|
||||
</View>
|
||||
|
||||
<Prompt.Basic
|
||||
control={discardPromptControl}
|
||||
title={_(msg`Discard draft?`)}
|
||||
description={_(msg`Are you sure you'd like to discard this draft?`)}
|
||||
onConfirm={onClose}
|
||||
confirmButtonCta={_(msg`Discard`)}
|
||||
confirmButtonColor="negative"
|
||||
/>
|
||||
</KeyboardAvoidingView>
|
||||
</RootSiblingParent>
|
||||
</BottomSheetPortalProvider>
|
||||
)
|
||||
}
|
||||
@@ -811,11 +824,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 +1269,6 @@ function ComposerFooter({
|
||||
dispatch,
|
||||
showAddButton,
|
||||
onEmojiButtonPress,
|
||||
onError,
|
||||
onSelectVideo,
|
||||
onAddPost,
|
||||
}: {
|
||||
@@ -1266,11 +1283,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 +1327,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 +1393,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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -76,6 +76,7 @@ function LightboxInner({
|
||||
const onKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onClose()
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
onPressLeft()
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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'}))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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%',
|
||||
|
||||
@@ -11288,6 +11288,11 @@ expo-image-loader@~5.1.0:
|
||||
resolved "https://registry.yarnpkg.com/expo-image-loader/-/expo-image-loader-5.1.0.tgz#f7d65f9b9a9714eaaf5d50a406cb34cb25262153"
|
||||
integrity sha512-sEBx3zDQIODWbB5JwzE7ZL5FJD+DK3LVLWBVJy6VzsqIA6nDEnSFnsnWyCfCTSvbGigMATs1lgkC2nz3Jpve1Q==
|
||||
|
||||
expo-image-loader@~6.0.0:
|
||||
version "6.0.0"
|
||||
resolved "https://registry.yarnpkg.com/expo-image-loader/-/expo-image-loader-6.0.0.tgz#15230442cbb90e101c080a4c81e37d974e43e072"
|
||||
integrity sha512-nKs/xnOGw6ACb4g26xceBD57FKLFkSwEUTDXEDF3Gtcu3MqF3ZIYd3YM+sSb1/z9AKV1dYT7rMSGVNgsveXLIQ==
|
||||
|
||||
expo-image-manipulator@~13.1.7:
|
||||
version "13.1.7"
|
||||
resolved "https://registry.yarnpkg.com/expo-image-manipulator/-/expo-image-manipulator-13.1.7.tgz#e891ce9b49d75962eafdf5b7d670116583379e76"
|
||||
@@ -11295,12 +11300,12 @@ expo-image-manipulator@~13.1.7:
|
||||
dependencies:
|
||||
expo-image-loader "~5.1.0"
|
||||
|
||||
expo-image-picker@~16.1.4:
|
||||
version "16.1.4"
|
||||
resolved "https://registry.yarnpkg.com/expo-image-picker/-/expo-image-picker-16.1.4.tgz#d4ac2d1f64f6ec9347c3f64f8435b40e6e4dcc40"
|
||||
integrity sha512-bTmmxtw1AohUT+HxEBn2vYwdeOrj1CLpMXKjvi9FKSoSbpcarT4xxI0z7YyGwDGHbrJqyyic3I9TTdP2J2b4YA==
|
||||
expo-image-picker@^17.0.2:
|
||||
version "17.0.2"
|
||||
resolved "https://registry.yarnpkg.com/expo-image-picker/-/expo-image-picker-17.0.2.tgz#79af7192b2947e54686d0ece6ccbb5f6a178a809"
|
||||
integrity sha512-O74FIrc37KB4ZxC/BMUL3fEZwdmIB60As0q5XczRlzPvWismBl7GG3pPy+o5SGUI2jcepTvQAa2PcNcMbUZNYg==
|
||||
dependencies:
|
||||
expo-image-loader "~5.1.0"
|
||||
expo-image-loader "~6.0.0"
|
||||
|
||||
expo-image@^2.4.0:
|
||||
version "2.4.0"
|
||||
|
||||
Reference in New Issue
Block a user