Compare commits

..

31 Commits

Author SHA1 Message Date
Eric Bailey e5dab3f0c0 Update origin 2025-08-23 15:27:18 -05:00
Eric Bailey 3b7b93a411 Debug (#8888)
* Swap URL

* Add debug
2025-08-23 14:45:57 -05:00
jim 99d7570818 Merge pull request #8883 from internet-development/caidanw/app-1408-add-canonical-url
feat: add canonical URLs to improve SEO
2025-08-22 19:40:11 -07:00
pfrazee 7b9bc52720 Nightly source-language update 2025-08-23 02:36:29 +00:00
jim 78e1e26a2c Merge pull request #8882 from internet-development/binaryfiddler/isnetwork
wrapp failed to send feed interactions inside isNetworkError
2025-08-22 16:10:53 -07:00
Caidan Williams d8c904d1d9 feat: add canonical URL to profile template for better SEO 2025-08-22 14:42:05 -07:00
Caidan Williams 560c503aa6 feat: add canonical URL to post template for better SEO 2025-08-22 14:40:57 -07:00
Caidan Williams 5120c037d1 feat: add canonical URL to home page for SEO optimization 2025-08-22 14:39:56 -07:00
Chenyu Huang 184df9cbfe wrapp failed to send feed interactions inside isNetworkError 2025-08-22 13:28:32 -07:00
Eric Bailey 912ab1bd9b [LEG-246] Geo overlay (#8881)
* Add AgeBlockedGeo

* Add MaxMind usage text

* Add geo overlay

---------

Co-authored-by: rafael <rafael@blueskyweb.xyz>
2025-08-22 14:55:16 -05:00
surfdude29 f038ac70da rearrange translation logic (#8880) 2025-08-22 12:00:00 -07:00
jim e404ddfbcd Merge pull request #8875 from internet-development/caidan/app-1406-follow-suggestions-card-to-show-follow-back
[APP-1406] follow suggestions card to show follow back
2025-08-21 22:15:07 -07:00
pfrazee 0baa5198e0 Nightly source-language update 2025-08-22 02:38:34 +00:00
Caidan Williams 7a1adfe67c refactor: all uses of "Follow Back" to "Follow back" 2025-08-21 15:55:01 -07:00
jim af55e578b2 Merge pull request #8876 from internet-development/caidanw/app-1399-trending-videos-icon-missing
[APP-1399] trending videos icon missing
2025-08-21 15:44:14 -07:00
Caidan Williams 994003e603 fix: capitalization of "Follow back" to "Follow Back" 2025-08-21 13:53:44 -07:00
Caidan Williams 8ab49da6da refactor: replace View with Button in ViewMoreCard for consistent colors across themes
Updated ViewMoreCard to use a Button component instead of a styled View
for the 'View more' action. This improves accessibility and consistency
with other interactive elements in TrendingVideos.
2025-08-21 13:23:53 -07:00
Caidan Williams c097f68a7d refactor: extract ViewMoreCard from VideoCards component
Moved the 'View more' card logic into a separate ViewMoreCard component
for better separation of concerns and readability.

Updated imports to use named React hooks instead of React namespace. To
modernise this old component per the new guidelines.
2025-08-21 12:53:35 -07:00
Caidan Williams b1ad229771 fix: show 'Follow back' label if user is followed by profile
Updated ProfileCard.FollowButton label to display 'Follow back' when the
current user is followed by the profile being viewed.
2025-08-21 12:29:08 -07:00
Caidan eabcd9150d [APP-1357] profile header follow recommendations (#8784) 2025-08-21 21:56:17 +03:00
pfrazee d900d0b7a7 Nightly source-language update 2025-08-21 02:38:38 +00:00
Eric Bailey 16701392b9 [APP-1398] Fix post shadow/like shadow state when replying (#8866)
* Add optimisticReplyCount to post shadow

* Add special util

* Fix subtle bug in query cache
2025-08-20 15:21:52 -05:00
cirx ef7c222e1b fix typo (#8869) 2025-08-20 11:11:02 -07:00
surfdude29 9835d0fb7b tweak plural formatting (#8871) 2025-08-20 11:10:34 -07:00
pfrazee bf6e01551e Nightly source-language update 2025-08-20 02:38:26 +00:00
Eric Bailey ea7f984a40 Fix isPartOfLastBranchFromDepth assignment (#8856) 2025-08-19 11:33:46 -05:00
Ridley Combs 4923786927 [Lightbox] prevent browser-default escape key behavior (#8859)
Some browsers exit fullscreen when the escape key is pressed without `preventDefault()` being called; we don't want that when we're handling the press ourselves.
2025-08-19 06:25:42 -07:00
pfrazee 48baf7277d Nightly source-language update 2025-08-19 02:39:11 +00:00
Eric Bailey 92ad887e38 Add underage notice to Moderation screen (#8868)
* Add underage notice to Moderation screen

* Incorporate suggestion

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
2025-08-18 19:24:54 -05:00
Eric Bailey 122a46891a [APP-1318] SelectMediaButton (#8828)
* Integrate Sonner for toasts

* Fix animation on iOS

* Refactor API

* Update e2e file

* [APP-1318] Post composer: combine image & video buttons  (#8710)

* add: select media btn

* udpate: compose post with combined image and video support

* add: video combine button with edge cases

* add select media btn

* test: select media btn

* add: media button update

* remove unused files and update toast on android

* update: make strings shorter

* add: ValidatedVideoAsset type

* update link comments and add toast support for native and web

* rebase latest toast and update toast structure

* remove unused prop

* fix types

* undo changes to yarn.lock

* remove: support for mkv files

* update: eslint and prettier

(cherry picked from commit f69779ee130f07e1c49219b53117e3bdd1a9f81b)

* Add missing props to launchImageLibraryAsync

(cherry picked from commit 2e80ae561fd66850f787cac0aae0fa5a6980f8f5)

* Rough out new approach

(cherry picked from commit 9add225160e7e407befc73e9cdd9743a30cdf1cd)

* Comments and cleanup

(cherry picked from commit e69bd186e7335372f440c446ae6643ed0fb15db9)

* Handle native case

(cherry picked from commit 74e38acdfd9181d0557426691fcbcbf0800481ca)

* Refactor

(cherry picked from commit 68aea496db8df54dba5f58da267ad962c28ef995)

* Rename

(cherry picked from commit 8609e59ad14219e7378ee6cb9514d633ce7efc27)

* Cleanup, comments

(cherry picked from commit 6c9c98648e37257285a9c8caeb1eadcc56c81402)

* Rename

(cherry picked from commit 66e3db539d5baa41436c9e49af06e87a78e9e7e1)

* Handle selectionLimit on Android

(cherry picked from commit 251f06dd5e65a7083b810bad3d81114b2fe9ab39)

* create composer images in parallel

(cherry picked from commit 70ea79d9d76d99e9c99a7d2296caed84c718650e)

* Update toast API usage

(cherry picked from commit e370018b8ed8cdfd7675c9634058c72cb59d39de)

* Ensure once one type of media is selected, you can only select more of that type

(cherry picked from commit 1a9e6e0cdb5234667f08e3dd9107ae598941fc23)

* Remove TODO and debug code

* Add more descriptive a11y label to button

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Add back post success toast

* Include mimeType in toast error

* Remove unneeded toast

* Clarify hint

* Typo

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* allow gifs on native, just treat as images

* disable haptic toast

* allow gifs on native, treat as videos

* only do keyboard dismiss on native

* tweak pasting logic

* hide web scrubber in certain situations

* Update MaxImages translation

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Add plural formatting to a11y hint translation

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* fix suggestion

* Protect against no valid assets selected

* Handle conversion of too-big assets on web

* Reorder

* Bump expo-image-picker to include bug/perf improvements

See https://github.com/expo/expo/blob/main/packages/expo-image-picker/CHANGELOG.md#1700--2025-08-13

* Handle edge case validations

* Ok actually bump expo-image-picker

* Comment

* HEIC support Android

* Fix handling for new picker version, improve size validation

* Remove getVideoMetadata handling, no longer needed

* Handle web video duration

* Update src/view/com/composer/SelectMediaButton.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

---------

Co-authored-by: Anastasiya Uraleva <anastasiyauraleva@gmail.com>
Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2025-08-18 18:28:01 -05:00
pfrazee cced762a7f Nightly source-language update 2025-08-16 02:39:35 +00:00
47 changed files with 1977 additions and 978 deletions
+1
View File
@@ -58,6 +58,7 @@ buck-out/
# Ruby / CocoaPods
/ios/Pods/
/vendor/bundle/
Gemfile.lock
# Testing
coverage/
+2 -2
View File
@@ -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)
+1
View File
@@ -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" />
+1
View File
@@ -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 }})">
+1
View File
@@ -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
View File
@@ -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",
-38
View File
@@ -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.
+109
View File
@@ -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>
)
}
+137 -147
View File
@@ -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
View File
@@ -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}
+31 -6
View File
@@ -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,
+37
View File
@@ -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>
)
}
+55 -53
View File
@@ -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>
)
}
+4
View File
@@ -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
View File
@@ -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],
+1 -5
View File
@@ -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')
+10 -3
View File
@@ -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
+1
View File
@@ -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'
File diff suppressed because it is too large Load Diff
+7
View File
@@ -475,4 +475,11 @@ export type MetricEvents = {
'ageAssurance:redirectDialogFail': {}
'ageAssurance:appealDialogOpen': {}
'ageAssurance:appealDialogSubmit': {}
/*
* Specifically for the `BlockedGeoOverlay`
*/
'blockedGeoOverlay:shown': {}
'geo:debug': {}
}
+19
View File
@@ -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}
+1 -1
View File
@@ -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>
)
}
+15
View File
@@ -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,
+4 -1
View File
@@ -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
+50 -6
View File
@@ -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`)
+9 -6
View File
@@ -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({
+25 -4
View File
@@ -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)
+1
View File
@@ -10,6 +10,7 @@ export type Device = {
geolocation?: {
countryCode: string | undefined
isAgeRestrictedGeo: boolean | undefined
isAgeBlockedGeo: boolean | undefined
}
trendingBetaEnabled: boolean
devMode: boolean
+175 -93
View File
@@ -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}
+524
View File
@@ -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>
</>
)
}
+22 -9
View File
@@ -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 && (
+1
View File
@@ -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>
)
+5 -5
View File
@@ -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 -5
View File
@@ -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>
)
}
+13 -14
View File
@@ -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%',
+10 -5
View File
@@ -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"